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/plan.mjs CHANGED
@@ -5,13 +5,13 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { err } from './errors.mjs';
7
7
  import {
8
- asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON, readJSONStrict, writeJSON, fileSha, warn, isPlainObject,
8
+ asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON, readJSONStrict, writeJSON, contentSha, warn, info, isPlainObject, run, samePath,
9
9
  } from './lib.mjs';
10
10
  import {
11
11
  VERSION, SKILLS, IDE_TARGETS, IDE_OPENCODE_DIR, IDE_OPENCODE_TARGET, IDE_RECOVERY_TARGET, MODULE_CONFIG, wiringFor, PRODUCT_WIRING, PROJECT_FILES, isVerifiedLedger,
12
12
  HOOK_WIRING, HOOK_ADAPTERS, CLAUDE_HOOK_ADAPTER, CAPTURE_WIRING, CAPTURE_ADAPTERS,
13
- LEGACY_SKILLS, REMOVED_SKILLS, LEGACY_MARKER, LEGACY_REPO_FILES, LEGACY_PRODUCT_FILES, MANAGED_LEDGER, BACKUP_SUFFIX,
14
- productConfigPath,
13
+ legacySkillPairs, REMOVED_SKILLS, LEGACY_REPO_FILES, LEGACY_PRODUCT_FILES, LEGACY_HOOK_SCRIPTS, MANAGED_LEDGER, BACKUP_SUFFIX,
14
+ productConfigPath, PRODUCT_LINK, PRODUCT_LINK_DEFAULT_PATH,
15
15
  } from './manifest.mjs';
16
16
 
17
17
  // A git pathspec (forward slashes, relative to a repo root) for `dest` under `root`. Actions carry
@@ -29,8 +29,12 @@ const rel = (root, dest) => path.relative(root, dest).split(path.sep).join('/');
29
29
  // at it carries fail-open semantics, so every ledger edit is quietly PERMITTED while `yad check` and
30
30
  // `yad doctor` both report the guard healthy. Reachable without anyone doing anything odd: a zip or
31
31
  // tarball download, `cp` without `-p`, a restrictive umask, `core.fileMode=false`.
32
+ //
33
+ // WINDOWS HAS NO EXECUTE BIT ON DISK (E113): Node reports every file there without one, so the test
34
+ // below would call every gate script outdated for ever, and `--fix` could never clear it. The gates run
35
+ // on the team's CI as `bash checks/<gate>.sh`, which needs no bit, so on Windows a file present counts.
32
36
  const isExecutable = (dest) => {
33
- try { return !!(fs.statSync(dest).mode & 0o111); } catch { return false; }
37
+ try { return process.platform === 'win32' || !!(fs.statSync(dest).mode & 0o111); } catch { return false; }
34
38
  };
35
39
  const fileAction = (scope, item, src, dest, { root, exec = false } = {}) => ({
36
40
  scope,
@@ -90,7 +94,7 @@ const wiredFileAction = (scope, item, src, dest, { root, exec = false, ledger =
90
94
  // Compares CONTENT. A file that is outdated only because its execute bit was lost still has the
91
95
  // sha we recorded, so it reads as ours and is re-applied with no backup — which is right: nothing
92
96
  // of the team's is being discarded, the mode is simply restored.
93
- const ours = !!recorded && recorded === fileSha(dest);
97
+ const ours = !!recorded && recorded === contentSha(dest);
94
98
  const backup = ours ? null : backupPathFor(dest);
95
99
  return {
96
100
  ...base,
@@ -119,7 +123,7 @@ export function recordManagedWrites(actions = []) {
119
123
  if (!m || !m.root) continue;
120
124
  if (!sameContent(m.src, m.dest)) continue;
121
125
  if (!byRoot.has(m.root)) byRoot.set(m.root, {});
122
- byRoot.get(m.root)[rel(m.root, m.dest)] = fileSha(m.dest);
126
+ byRoot.get(m.root)[rel(m.root, m.dest)] = contentSha(m.dest);
123
127
  }
124
128
  const roots = [];
125
129
  for (const [root, written] of byRoot) {
@@ -246,12 +250,10 @@ export function safeIdeTargetsFor(root, input) {
246
250
  return targets;
247
251
  }
248
252
 
249
- // Is a wired hook script present AND runnable? Shared by the planner (via `fileAction`'s exec rule)
250
- // and `yad doctor`, so the two cannot disagree about whether the guard is installed.
251
- export const hookScriptReady = (root, relPath) => {
252
- const full = path.join(root, relPath);
253
- return !!lstatIfPresent(full) && isExecutable(full);
254
- };
253
+ // Is a wired hook script present? Shared by the planner and `yad doctor`, so the two cannot disagree about
254
+ // whether the guard is installed. Since E113 every hook is a Node script the entry runs as `node <file>`,
255
+ // so an execute bit is no longer part of "ready" — and Windows could never show one.
256
+ export const hookScriptReady = (root, relPath) => !!lstatIfPresent(path.join(root, relPath))?.isFile();
255
257
 
256
258
  // Which of these targets are safe to write through, without throwing on the ones that are not.
257
259
  // `safeIdeTargetsFor` throws on the first bad target, which is right for an installer and wrong for a
@@ -421,7 +423,8 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
421
423
  return actions;
422
424
  }
423
425
 
424
- // Migration of a pre-2.0 install (the sdlc-* -> yad-* rename). Status is 'legacy' — unlike
426
+ // Migration of an install carrying an old skill name (the 2.0 sdlc-* -> yad-* rename, and E123's
427
+ // yad-hub-bridge -> yad-product-bridge; see LEGACY_SKILLS). Status is 'legacy' — unlike
425
428
  // 'missing' it is applied by `yad update` (--scope=changed) too, because the skill IS
426
429
  // installed, just under its old name. apply() removes the old copy AND installs the renamed
427
430
  // one, so a single update completes the rename even when the new copy would otherwise be
@@ -429,14 +432,14 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
429
432
  export function legacyModuleActions(root, ideTargets = ideTargetsFor(root)) {
430
433
  const targets = safeIdeTargetsFor(root, ideTargets);
431
434
  if (targets.includes('.opencode')) {
432
- const writes = Object.entries(LEGACY_SKILLS)
435
+ const writes = legacySkillPairs()
433
436
  .filter(([, old]) => exists(path.join(root, IDE_OPENCODE_DIR, `${old}.md`)))
434
437
  .map(([skill]) => skill);
435
438
  assertSafeOpenCodeWriteDestinations(root, writes);
436
439
  }
437
440
  const actions = [];
438
441
  for (const ide of targets) {
439
- for (const [skill, old] of Object.entries(LEGACY_SKILLS)) {
442
+ for (const [skill, old] of legacySkillPairs()) {
440
443
  if (ide === '.opencode') {
441
444
  const oldDest = path.join(root, IDE_OPENCODE_DIR, `${old}.md`);
442
445
  const newDest = path.join(root, IDE_OPENCODE_DIR, `${skill}.md`);
@@ -447,6 +450,7 @@ export function legacyModuleActions(root, ideTargets = ideTargetsFor(root)) {
447
450
  status: 'legacy',
448
451
  root,
449
452
  paths: [rel(root, oldDest), rel(root, newDest)],
453
+ rename: { from: rel(root, oldDest), to: rel(root, newDest) },
450
454
  apply: () => {
451
455
  fs.rmSync(oldDest, { force: true });
452
456
  copyFile(asset('skills', skill, 'SKILL.md'), newDest);
@@ -462,6 +466,7 @@ export function legacyModuleActions(root, ideTargets = ideTargetsFor(root)) {
462
466
  status: 'legacy',
463
467
  root,
464
468
  paths: [rel(root, oldDest), rel(root, newDest)],
469
+ rename: { from: rel(root, oldDest), to: rel(root, newDest) },
465
470
  apply: () => {
466
471
  fs.rmSync(oldDest, { recursive: true, force: true });
467
472
  copyDir(asset('skills', skill), newDest);
@@ -511,10 +516,11 @@ export function removedModuleActions(root, ideTargets = ideTargetsFor(root)) {
511
516
  return actions;
512
517
  }
513
518
 
514
- // True only for a file WE installed pre-2.0: its first line carries the old ownership marker
515
- // (`# sdlc-managed:` / `# sdlc-managed-include:`). A same-named user-authored file is never ours.
516
- function ownedByOldInstall(p) {
517
- try { return fs.readFileSync(p, 'utf8').startsWith(LEGACY_MARKER); } catch { return false; }
519
+ // True only for a file WE installed: its first line starts with the ownership marker its rename entry
520
+ // names (`# sdlc-managed` for the pre-2.0 files, `# yad-managed: yad-checks` for E123's). A same-named
521
+ // user-authored file is never ours.
522
+ function ownedByOldInstall(p, marker) {
523
+ try { return fs.readFileSync(p, 'utf8').startsWith(marker); } catch { return false; }
518
524
  }
519
525
 
520
526
  // old-dest -> new-dest migrations for wired CI files: remove the marker-owned old file and
@@ -522,13 +528,29 @@ function ownedByOldInstall(p) {
522
528
  // path from the root `.gitlab-ci.yml` (`include: - local: ...`, written by the wire step), so
523
529
  // the migration must also rewrite that include — otherwise the pipeline hard-fails on a
524
530
  // `local file does not exist` the moment the old fragment is removed.
531
+ //
532
+ // The marker says who installed the file, not that nobody changed it since, so the provenance record
533
+ // decides what happens to its content — the three states every managed file has (`wiredFileAction`):
534
+ // recorded, and the bytes are what yad wrote → removed ('legacy')
535
+ // no record (installed before the record) → removed, after a <file>.yad-orig copy ('legacy')
536
+ // recorded, and the bytes differ (edited) → 'modified': left, with the rename not done; only
537
+ // `--overwrite-local` removes it, after the same copy.
538
+ // Meanwhile the new name is not installed beside it
539
+ // (`withoutKeptRenames`), or both would run
540
+ // The copy is inert where it lands: GitHub runs only `.yml`/`.yaml` files, and nothing includes it.
541
+ // When the old file goes, its line leaves the record, so the record lists only files that are there.
525
542
  function legacyFileActions(scope, baseRoot, fileMap, wiring) {
526
543
  const actions = [];
527
- for (const [oldDest, newDest] of Object.entries(fileMap || {})) {
544
+ let ledger = null;
545
+ for (const [oldDest, { to: newDest, marker }] of Object.entries(fileMap || {})) {
528
546
  const oldPath = path.join(baseRoot, oldDest);
529
- if (!ownedByOldInstall(oldPath)) continue;
547
+ if (!ownedByOldInstall(oldPath, marker)) continue;
530
548
  const w = wiring.find((x) => x.dest === newDest);
531
549
  if (!w) continue; // never delete a working file without a replacement to install
550
+ ledger ??= readManagedLedger(baseRoot);
551
+ const recorded = ledger[oldDest];
552
+ const ours = !!recorded && recorded === contentSha(oldPath);
553
+ const backup = ours ? null : backupPathFor(oldPath);
532
554
  // Only claim the root .gitlab-ci.yml when apply() will actually rewrite it (it references the old
533
555
  // fragment) — else a --push would sweep the user's unrelated edits to that shared-ownership file
534
556
  // into the chore(yad-update) commit. The old (deletion) + new (add) paths are always ours.
@@ -537,32 +559,292 @@ function legacyFileActions(scope, baseRoot, fileMap, wiring) {
537
559
  actions.push({
538
560
  scope,
539
561
  item: `${oldDest} → ${newDest}`,
540
- status: 'legacy',
562
+ status: ours || !recorded ? 'legacy' : 'modified',
541
563
  root: baseRoot,
542
- paths: rewritesRootCi ? [oldDest, newDest, '.gitlab-ci.yml'] : [oldDest, newDest],
564
+ paths: [oldDest, newDest, ...(rewritesRootCi ? ['.gitlab-ci.yml'] : []), ...(recorded ? [MANAGED_LEDGER] : [])],
565
+ backup,
566
+ rename: { from: oldDest, to: newDest },
567
+ // The new file it installs, so `recordManagedWrites` records it when it is the template. Without this a
568
+ // rename done by `--overwrite-local` (the new name's own action is left out then, `withoutKeptRenames`)
569
+ // left the new file unrecorded, and the next update replaced the edits a person copied into it (review 2).
570
+ managed: { src: asset(w.src), dest: path.join(baseRoot, newDest), root: baseRoot },
543
571
  apply: () => {
572
+ if (backup) fs.copyFileSync(oldPath, backup);
544
573
  fs.rmSync(oldPath, { force: true });
545
- copyFile(asset(w.src), path.join(baseRoot, newDest), { exec: !!w.exec });
574
+ // Only into an empty path. A file already at the new name is the new name's own wired-file action's
575
+ // to judge — with its provenance and its backup — and may hold the team's edits (review 1).
576
+ if (!exists(path.join(baseRoot, newDest))) copyFile(asset(w.src), path.join(baseRoot, newDest), { exec: !!w.exec });
546
577
  const rootCi = path.join(baseRoot, '.gitlab-ci.yml');
547
578
  try {
548
579
  const txt = fs.readFileSync(rootCi, 'utf8');
549
580
  if (txt.includes(oldDest)) fs.writeFileSync(rootCi, txt.split(oldDest).join(newDest));
550
581
  } catch { /* no root .gitlab-ci.yml (github repo, or fragment-only gitlab) — nothing to rewrite */ }
582
+ const current = readManagedLedger(baseRoot);
583
+ if (Object.hasOwn(current, oldDest)) {
584
+ delete current[oldDest];
585
+ writeJSON(path.join(baseRoot, MANAGED_LEDGER), { version: VERSION, files: current });
586
+ }
551
587
  },
552
588
  });
553
589
  }
554
590
  return actions;
555
591
  }
556
592
 
593
+ // E123: the team's own files that name something the hub -> Product rename moved. yad owns none of them
594
+ // and never edits them — except the one GitLab include line `legacyFileActions` rewrites — so each hit is
595
+ // named, with its file and line, by `yad doctor` and `yad check`/`update`, and left for a person.
596
+ // Old -> new, the exact literals the old templates carried (`yad-hub-checks.yml`, and the one job in
597
+ // `yad-verified-commits.yml`). `yad-hub-checks` covers the file name (`.github/workflows/…`, `.gitlab/ci/…`,
598
+ // the include path, the badge URL that names the file) and the GitHub workflow `name:` (a `workflow_run:`
599
+ // trigger, the older badge URL). Nothing broader: a bare `yad-hub-` prefix would claim a team's own
600
+ // `yad-hub-deploy` job.
601
+ export const RENAMED_CI_NAMES = Object.freeze([
602
+ ['yad-hub-checks', 'yad-product-checks'],
603
+ ['yad-hub-commit-message', 'yad-product-commit-message'],
604
+ ['yad-hub-pr-title', 'yad-product-pr-title'],
605
+ ['yad-hub-pr-template', 'yad-product-pr-template'],
606
+ ['yad-hub-ledger-guard', 'yad-product-ledger-guard'],
607
+ ['yad-hub-verified-commits', 'yad-product-verified-commits'],
608
+ ['.yad_hub_mr_only', '.yad_product_mr_only'],
609
+ ]);
610
+ // The profile value a team's own CI file may pass (E124 review 1). The gates still accept `--profile hub` until
611
+ // v5, but a gate edited after 4.0 without the line that would turn `hub` into `product` lets `--profile hub`
612
+ // skip the Product's rules (the cell `productProfileGap` does not judge) — so the CI line is named instead.
613
+ // Its own pattern: `=` or any run of spaces, the value bare or quoted, and nothing after it that could be part
614
+ // of a longer word (`--profile hubble`, `--profile hub.x`). Only the literal is seen: a value passed through a
615
+ // variable (`--profile "$P"`) is not. Only in CI files — a README that quotes the command is prose.
616
+ const OLD_PROFILE_RE = /(?<![\w-])--profile(?:=|[ \t]+)(["']?)hub\1(?![\w.-])/g;
617
+ const escapeRe = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
618
+ // Bounded on both sides by anything that cannot be part of a name, so `yad-hub-checks-extra` is not a hit.
619
+ const RENAMED_CI_RE = new RegExp(`(?<![\\w.-])(${RENAMED_CI_NAMES.map(([o]) => escapeRe(o)).join('|')})(?![\\w-])`, 'g');
620
+ // Where a person's own reference to our CI lives. The CI folders are read only for files yad did not install:
621
+ // every file yad writes there starts `# yad-managed` (`# yad-managed:`, `# yad-managed-include:`,
622
+ // `# yad-managed —`), so the FIRST line decides, never a mention further down.
623
+ const CODEOWNERS_DIRS = ['', '.github', '.gitlab', 'docs'];
624
+ function renamedNameFiles(root) {
625
+ const listYml = (dir) => {
626
+ try {
627
+ return fs.readdirSync(path.join(root, dir), { withFileTypes: true })
628
+ .filter((e) => e.isFile() && /\.ya?ml$/.test(e.name)).map((e) => `${dir}/${e.name}`).sort();
629
+ } catch { return []; }
630
+ };
631
+ const notOurs = (rel) => {
632
+ try { return !fs.readFileSync(path.join(root, rel), 'utf8').split('\n', 1)[0].startsWith('# yad-managed'); } catch { return false; }
633
+ };
634
+ return [
635
+ '.gitlab-ci.yml',
636
+ ...[...listYml('.gitlab/ci'), ...listYml('.github/workflows')].filter(notOurs),
637
+ 'README.md',
638
+ ...CODEOWNERS_DIRS.map((d) => (d ? `${d}/CODEOWNERS` : 'CODEOWNERS')),
639
+ ];
640
+ }
641
+ // Every hit: { file, line, old, new, rewrittenBy }. `rewrittenBy` marks a match inside the old fragment's path
642
+ // on a line of the root .gitlab-ci.yml, which yad rewrites itself when it replaces that fragment: 'update'
643
+ // for a fragment `yad update` replaces, 'overwrite-local' for an edited one it keeps until that flag; null
644
+ // for every other hit, which is the team's to change.
645
+ export function renamedNameHits(root) {
646
+ const fragments = new Map();
647
+ try {
648
+ for (const a of legacyProductActions(root)) {
649
+ if (a.paths.includes('.gitlab-ci.yml') || a.status === 'modified') fragments.set(a.rename.from, a.status === 'modified' ? 'overwrite-local' : 'update');
650
+ }
651
+ } catch { /* an unreadable provenance record: nothing is promised, so every hit reads as the team's to change */ }
652
+ const hits = [];
653
+ for (const file of renamedNameFiles(root)) {
654
+ let text;
655
+ try {
656
+ if (!fs.lstatSync(path.join(root, file)).isFile()) continue;
657
+ text = fs.readFileSync(path.join(root, file), 'utf8');
658
+ } catch { continue; }
659
+ const ci = /\.ya?ml$/.test(file);
660
+ text.split(/\r?\n/).forEach((line, i) => {
661
+ if (ci) {
662
+ for (const m of line.matchAll(OLD_PROFILE_RE)) {
663
+ hits.push({ file, line: i + 1, old: m[0], new: m[0].replace(/hub(["']?)$/, 'product$1'), rewrittenBy: null, profile: true });
664
+ }
665
+ }
666
+ for (const m of line.matchAll(RENAMED_CI_RE)) {
667
+ const next = RENAMED_CI_NAMES.find(([o]) => o === m[1])[1];
668
+ // Per match, not per line: only the fragment path itself is rewritten, not a job named beside it.
669
+ let rewrittenBy = null;
670
+ if (file === '.gitlab-ci.yml') {
671
+ for (const [f, by] of fragments) {
672
+ for (let at = line.indexOf(f); at !== -1 && !rewrittenBy; at = line.indexOf(f, at + 1)) {
673
+ if (m.index >= at && m.index < at + f.length) rewrittenBy = by;
674
+ }
675
+ }
676
+ }
677
+ hits.push({ file, line: i + 1, old: m[1], new: next, rewrittenBy });
678
+ }
679
+ });
680
+ }
681
+ return hits;
682
+ }
683
+
684
+ // The Product's pattern gates, whose workflow passes `--profile product` since E123. A copy the team edited
685
+ // is kept by `yad update` — and one from before 4.0 accepts only `code|hub`, so it fails every Product PR
686
+ // with "unknown --profile 'product'" (review 1). 'rejects' when the gate's own `case "$PROFILE" in …)` list lacks
687
+ // `product`; 'unmapped' when it has it, but the gate branches on `= hub` with no line turning `product`
688
+ // into `hub` — then `product` passes the list and silently skips every Product rule (review 2); null otherwise,
689
+ // and for a copy with no such list, which is not judged. The shipped copies branch on `= product` since
690
+ // E124 (and turn `hub` into `product`), so they read null here; the `= hub` shape is an older copy's.
691
+ //
692
+ // Every case, as gate shape × the value a workflow passes (E124):
693
+ // gate passes product passes hub
694
+ // branches on `= hub`, maps product -> hub ok ok
695
+ // branches on `= hub`, no mapping 'unmapped' ok
696
+ // list without `product` (before 4.0) 'rejects' ok
697
+ // branches on `= product`, maps hub -> product ok (shipped) ok (shipped)
698
+ // branches on `= product`, no mapping ok skips the Product rules — see below
699
+ // The last cell is not judged here, from the gate: it is closed from the workflow side instead. A team's own CI
700
+ // line that passes `--profile hub` literally is a `renamed-ref:` hit (OLD_PROFILE_RE), and yad's own old
701
+ // workflow is a `renamed:` one. A value passed through a variable is not seen.
702
+ export const PRODUCT_PROFILE_GATES = Object.freeze(['checks/commit-message.sh', 'checks/pr-title.sh', 'checks/pr-template.sh']);
703
+ export function productProfileGap(file) {
704
+ let text;
705
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
706
+ const list = text.match(/^\s*case\s+"\$PROFILE"\s+in\s+([^)]*)\)/m);
707
+ if (!list) return null;
708
+ if (!list[1].split('|').map((x) => x.trim()).includes('product')) return 'rejects';
709
+ // Any spelling of the mapping on one line — `[ "$PROFILE" = product ] && PROFILE=hub`, `[[ $PROFILE == product ]]
710
+ // && …`, a `product) PROFILE=hub ;;` arm — and any spelling of the branch: quoted or not, `=` or `==`.
711
+ // A comment is prose, not code: the shipped copies explain the mapping in one.
712
+ const code = text.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
713
+ const mapped = /product[^\n]*\bPROFILE=["']?hub\b/.test(code);
714
+ // The line that maps the old name (the shipped shape since E124: turn `hub` into `product`) compares with `= hub` too, but it is
715
+ // the mapping, not a branch — so it is not what the branch test below looks for.
716
+ const branches = code.split('\n').filter((l) => !/\bPROFILE=["']?product\b/.test(l)).join('\n');
717
+ return /\$\{?PROFILE\}?"?\s*==?\s*["']?hub\b/.test(branches) && !mapped ? 'unmapped' : null;
718
+ }
719
+ // What each gap does to a Product PR, and the one fix for both — shared by `yad doctor` and `yad update`.
720
+ export const productProfileEffect = (gap, gate, passing) => (gap === 'rejects'
721
+ ? `${gate} does not accept \`--profile product\`, which ${passing} — every Product PR fails that check`
722
+ : `${gate} accepts \`--profile product\` but never turns it into \`hub\`, which ${passing} — so it skips the Product's rules and passes what it should stop`);
723
+ export const PRODUCT_PROFILE_FIX = 'add `product` to its `case "$PROFILE" in` list and `[ "$PROFILE" = product ] && PROFILE=hub` after it — or replace it with the shipped copy, which branches on `product` itself: `yad update --overwrite-local`';
724
+
725
+ // The Product's checks workflows, under the new name and the old (E123): an edited old one is kept, and runs.
726
+ export const PRODUCT_CHECK_WORKFLOWS = Object.freeze([
727
+ '.github/workflows/yad-product-checks.yml', '.gitlab/ci/yad-product-checks.yml',
728
+ '.github/workflows/yad-hub-checks.yml', '.gitlab/ci/yad-hub-checks.yml',
729
+ ]);
730
+ // Those of them on disk that pass `--profile product` to the gates.
731
+ // yad's own workflows, and the team's own CI files (E124 review 2): a team that follows `renamed-ref:` and changes
732
+ // its `--profile hub` to `product` must then hear from `profile:` if an edited gate still rejects it.
733
+ export const workflowsPassingProduct = (root) => [...new Set([...PRODUCT_CHECK_WORKFLOWS,
734
+ ...renamedNameFiles(root).filter((rel) => /\.ya?ml$/.test(rel))])].filter((rel) => {
735
+ try { return /--profile(?:=|[ \t]+)(["']?)product\1(?![\w.-])/.test(fs.readFileSync(path.join(root, rel), 'utf8')); } catch { return false; }
736
+ });
737
+ // The Product gates that would mishandle `--profile product` (E124 reviews 2–4): refuse it ('rejects'), or take it
738
+ // and skip the Product's rules ('unmapped'). Each gate's state decides what to do first, and is read from the
739
+ // provenance record through `productActions` — never guessed from the file:
740
+ // state what it is before a run on update --overwrite-local
741
+ // 'outdated' yad's own copy, older than this release run yad update replaced replaced
742
+ // 'unrecorded' no record (a pre-3.16 install): update backs it up run yad update replaced replaced
743
+ // 'kept' edited since yad wrote it: update keeps it fix it fix it replaced
744
+ // 'unreadable' the settings do not read (or are not an object), or restore the file, then run yad update
745
+ // the provenance record does not: yad cannot tell (with
746
+ // an unreadable record, update itself refuses until then)
747
+ // 'drift' a file under two names says different things (E122): yad migrate --apply, then doctor
748
+ // every command but doctor/migrate/report refuses until
749
+ // one copy is kept — so nothing else can be the first step
750
+ // 'unmanaged' yad has no action for it (Product not verified) fix it by hand same same
751
+ // 'unreadable' and 'unmanaged' are read from the file on disk; 'drift' reads no provenance record.
752
+ // On a run that fixes, a gate that run replaces is left out: after it, the gate is the shipped copy.
753
+ // `drift`: `productDriftPairs(root)` (cli/epic-state.mjs; plan.mjs lists no epics) — the pairs every command refuses
754
+ // on. Only `yad doctor` passes it: `yad check`/`update` never run under drift, the dispatcher refuses them first.
755
+ export function productGateBlockers(root, { fix = false, overwriteLocal = false, drift = [] } = {}) {
756
+ let actions = [];
757
+ let unreadable = null; // the file that does not read, for 'unreadable' (review 5)
758
+ const settings = productConfigPath(root);
759
+ try {
760
+ // Not an object is as unreadable as not parsing: `yad doctor` fails both (review 6).
761
+ if (exists(settings) && !isPlainObject(readJSONStrict(settings, null))) throw new Error('not an object');
762
+ } catch { unreadable = path.relative(root, settings).split(path.sep).join('/'); }
763
+ const drifted = !unreadable && drift.length ? drift : null;
764
+ if (!unreadable && !drifted) {
765
+ try { actions = productActions(root); } catch { unreadable = MANAGED_LEDGER; }
766
+ }
767
+ const byGate = new Map(actions.filter((a) => a.managed)
768
+ .map((a) => [path.relative(root, a.managed.dest).split(path.sep).join('/'), a]));
769
+ const out = [];
770
+ for (const gate of PRODUCT_PROFILE_GATES) {
771
+ const gap = productProfileGap(path.join(root, gate));
772
+ if (!gap) continue;
773
+ const a = byGate.get(gate);
774
+ let state;
775
+ if (unreadable) state = 'unreadable';
776
+ else if (drifted) state = 'drift';
777
+ else if (!a) state = 'unmanaged';
778
+ else if (a.status === 'modified') state = fix && overwriteLocal ? null : 'kept';
779
+ else state = fix ? null : (a.backup ? 'unrecorded' : 'outdated');
780
+ if (state) out.push({ gate, gap, state, ...(unreadable ? { file: unreadable } : {}), ...(drifted ? { pairs: drifted } : {}) });
781
+ }
782
+ return out;
783
+ }
784
+ const gapWords = (b) => `${b.gate} (it ${b.gap === 'rejects' ? 'refuses `--profile product`, so every Product PR would fail it' : 'takes `--profile product` but then skips the Product\'s rules'})`;
785
+ // The drifted pairs, said once: which files say different things (E122).
786
+ const driftWords = (pairs) => `${pairs.map((p) => `${p.canonical} and ${p.legacy}`).join('; ')} say different things`;
787
+ const saved = (b) => `${b.gate}${BACKUP_SUFFIX}`;
788
+ // What to do about ONE gate — `yad doctor`'s `profile:<gate>` hint.
789
+ export function gateProfileFix(b) {
790
+ if (b.state === 'outdated') return 'run `yad update`: it replaces this copy, which yad wrote, with the shipped one';
791
+ if (b.state === 'unrecorded') return `run \`yad update\`: it replaces it with the shipped one and saves yours as ${saved(b)}`;
792
+ if (b.state === 'kept') return `it was changed by hand, so \`yad update\` keeps it: ${PRODUCT_PROFILE_FIX} (your copy is saved as ${saved(b)})`;
793
+ if (b.state === 'drift') return `${driftWords(b.pairs)}, so \`yad update\` refuses until one copy is kept: run \`yad migrate\` to see the difference, then \`yad migrate --apply\` to keep one, then \`yad doctor\` again — it says what this check needs`;
794
+ if (b.state === 'unreadable') return `restore ${b.file} from git — it does not read, so yad cannot tell whether this copy is its own${b.file === MANAGED_LEDGER ? ' (and `yad update` refuses until then)' : ''} — then run \`yad update\` and \`yad doctor\` again`;
795
+ return `fix it by hand — yad does not manage the checks on this Product, so no \`yad update\` replaces it: ${PRODUCT_PROFILE_FIX.split(' — ')[0]}`;
796
+ }
797
+ // What to do before a team changes its `--profile hub` to `product`, or '' when nothing stands in the way —
798
+ // one sentence for `yad doctor` and `yad check`/`update` alike, naming EVERY gate in the way, each group with
799
+ // its own step (review 4: naming only the edited one let a stale one fail every PR).
800
+ export function oldProfileAdvice(blockers) {
801
+ if (!blockers.length) return '';
802
+ const parts = [];
803
+ const split = blockers.filter((b) => b.state === 'drift');
804
+ if (split.length) {
805
+ const names = split.map((b) => b.gate);
806
+ const listed = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)} need` : `${names[0]} needs`;
807
+ parts.push(`run \`yad migrate\` to see the difference, then \`yad migrate --apply\` to keep one copy — ${driftWords(split[0].pairs)}, so every yad command but \`yad doctor\`, \`yad migrate\` and \`yad report\` refuses until then — and \`yad doctor\` again: it says what ${listed}`);
808
+ }
809
+ const lost = blockers.filter((b) => b.state === 'unreadable');
810
+ if (lost.length) {
811
+ parts.push(`restore ${lost[0].file} from git (it does not read, so yad cannot tell ${lost.length > 1 ? `which of ${lost.map(gapWords).join('; and ')} are` : `whether ${gapWords(lost[0])} is`} its own), then run \`yad update\` and \`yad doctor\` again`);
812
+ }
813
+ const replace = blockers.filter((b) => b.state === 'outdated' || b.state === 'unrecorded');
814
+ if (replace.length) {
815
+ const backed = replace.filter((b) => b.state === 'unrecorded');
816
+ parts.push(`run \`yad update\`, which replaces ${replace.map(gapWords).join('; and ')}${backed.length ? ` (saving your ${backed.length > 1 ? 'copies' : 'copy'} as ${backed.map(saved).join(', ')})` : ''}`);
817
+ }
818
+ const kept = blockers.filter((b) => b.state === 'kept');
819
+ if (kept.length) {
820
+ parts.push(`fix ${kept.map(gapWords).join('; and ')} — ${kept.length > 1 ? 'they were' : 'it was'} edited, so \`yad update\` keeps ${kept.length > 1 ? 'them; for each one' : 'it'}: ${PRODUCT_PROFILE_FIX}`);
821
+ }
822
+ const own = blockers.filter((b) => b.state === 'unmanaged');
823
+ if (own.length) {
824
+ parts.push(`fix ${own.map(gapWords).join('; and ')} by hand — yad does not manage the checks on this Product`);
825
+ }
826
+ // `; then`, never `; and` — the gate lists inside a part already use `; and` (review 5).
827
+ return `but first ${parts.join('; then ')}`;
828
+ }
829
+
830
+ // The new name of a renamed CI file is not installed while an edited old one is kept (`modified`): both
831
+ // would run on GitHub — every gate twice, the stock one undoing whatever the team's edit loosened — and on
832
+ // GitLab the new one would sit there included by nothing. `--overwrite-local` installs it with the rename.
833
+ export function withoutKeptRenames(actions) {
834
+ const kept = new Set(actions.filter((a) => a.rename && a.status === 'modified').map((a) => path.join(a.root, a.rename.to)));
835
+ if (!kept.size) return actions;
836
+ return actions.filter((a) => a.rename || !(a.managed && !exists(a.managed.dest) && kept.has(a.managed.dest)));
837
+ }
838
+
557
839
  export function legacyRepoActions(root, repo) {
558
840
  return legacyFileActions(repo.name, path.resolve(root, repo.path), LEGACY_REPO_FILES[repo.platform], wiringFor(repo.platform));
559
841
  }
560
842
 
561
- export function legacyHubActions(root) {
562
- const hub = readJSON(productConfigPath(root));
563
- if (!isVerifiedLedger(hub)) return [];
564
- const wiring = [...PRODUCT_WIRING.common, ...(PRODUCT_WIRING[hub.platform] || [])];
565
- return legacyFileActions('hub', root, LEGACY_PRODUCT_FILES[hub.platform], wiring);
843
+ export function legacyProductActions(root) {
844
+ const productConfig = readJSON(productConfigPath(root));
845
+ if (!isVerifiedLedger(productConfig)) return [];
846
+ const wiring = [...PRODUCT_WIRING.common, ...(PRODUCT_WIRING[productConfig.platform] || [])];
847
+ return legacyFileActions('product', root, LEGACY_PRODUCT_FILES[productConfig.platform], wiring);
566
848
  }
567
849
 
568
850
  // Per-repo wiring (gate scripts, CI, PR template).
@@ -588,22 +870,103 @@ export function repoActions(root, repo) {
588
870
  // whole one-time setup on a repo that never asked for it.
589
871
  const wired = Object.keys(ledger).length > 0
590
872
  || actions.some((a) => a.status !== 'missing' && a.item.startsWith('checks/'));
873
+ const link = productLinkAction(root, repo, repoRoot);
874
+ if (link) actions.push(link);
591
875
  if (!wired) return actions;
592
- const neverWritten = (a) => !ledger[rel(a.managed.root, a.managed.dest)];
876
+ // The record is not a template: an absent one on a wired repo is always `new` (nothing to respect).
877
+ const neverWritten = (a) => !a.managed || !ledger[rel(a.managed.root, a.managed.dest)];
593
878
  return actions.map((a) => (a.status === 'missing' && neverWritten(a) ? { ...a, status: 'new' } : a));
594
879
  }
595
880
 
881
+ // The git repo a folder belongs to (its top level), or '' when it is not in one.
882
+ const gitTop = (dir) => {
883
+ const r = run('git', ['rev-parse', '--show-toplevel'], { cwd: dir });
884
+ return r.ok ? path.resolve(r.stdout.trim()) : '';
885
+ };
886
+
887
+ // The Product's git_url as a code repo may carry it: no user name or password (review 1). `setup` takes
888
+ // it from `git remote get-url origin`, which can hold a token; the record is committed to every code
889
+ // repo, some of which may be seen by more people than the Product.
890
+ export function publicGitUrl(u) {
891
+ if (typeof u !== 'string' || !u) return null;
892
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) return u; // scp-style ssh (git@host:org/repo) holds no password
893
+ try {
894
+ const x = new URL(u);
895
+ // An ssh user (`git@`) is the login name, not a secret, and the checkout reads the url by it.
896
+ if (!/^(git\+)?ssh:$/i.test(x.protocol)) x.username = '';
897
+ x.password = '';
898
+ // A token can ride in the query or the fragment too (`?access_token=…`); a clone url needs neither.
899
+ x.search = '';
900
+ x.hash = '';
901
+ return x.toString();
902
+ } catch { return null; }
903
+ }
904
+
905
+ // E120. `.sdlc/product-link.json` in a connected code repo: where its Product lives. Generated, not
906
+ // copied from a template, so it is not in the managed ledger. `git_url` and `default_branch` come from
907
+ // the Product's own settings every time; `path` (where CI checks the Product out) is the team's to
908
+ // change and is kept. Any other key in the file is kept too. A code repo that IS the Product's git repo
909
+ // (a monorepo) gets none: the gates already read a Product kept in the repo from the base.
910
+ function productLinkAction(root, repo, repoRoot) {
911
+ if (!exists(repoRoot)) return null;
912
+ const top = gitTop(repoRoot);
913
+ if (!top || top === gitTop(root)) return null;
914
+ const dest = path.join(repoRoot, PRODUCT_LINK);
915
+ const productConfig = readJSON(productConfigPath(root), {}) || {};
916
+ let current;
917
+ try { current = readJSONStrict(dest, null); } catch { current = undefined; }
918
+ const base = {
919
+ scope: repo.name,
920
+ item: PRODUCT_LINK,
921
+ root: repoRoot,
922
+ paths: [PRODUCT_LINK],
923
+ };
924
+ const want = {
925
+ git_url: publicGitUrl(productConfig.git_url),
926
+ path: isPlainObject(current) && typeof current.path === 'string' && current.path ? current.path : PRODUCT_LINK_DEFAULT_PATH,
927
+ // Unknown stays null (review 1): a guessed `main` failed every clone of a Product whose trunk is
928
+ // `master`; with none, checks/product-checkout.sh clones the remote's own default branch.
929
+ default_branch: typeof productConfig.default_branch === 'string' && productConfig.default_branch ? productConfig.default_branch : null,
930
+ };
931
+ // A record that does not parse, or is not an object, is someone's: reported and never overwritten by
932
+ // a plain update; `--overwrite-local` saves it beside itself first, like any managed file.
933
+ if (current === undefined || (current !== null && !isPlainObject(current))) {
934
+ const backup = backupPathFor(dest);
935
+ return {
936
+ ...base, status: 'modified', backup,
937
+ apply: () => { fs.copyFileSync(dest, backup); writeJSON(dest, want); },
938
+ };
939
+ }
940
+ const same = current && ['git_url', 'path', 'default_branch'].every((k) => current[k] === want[k]);
941
+ // Right on disk is `ok` — like every wired file `yad setup` leaves for the team to commit, so a fresh
942
+ // setup reports no drift. But the gates read the record from the default branch, so `--push` must
943
+ // commit one that is not committed AS IT IS on disk (reviews 1–2: a record setup or a plain --fix had
944
+ // written, or rewritten, was never committed by a later --push): `pendingCommit` makes reconcile
945
+ // stage it with the rest. `git diff HEAD` also sees a staged-only change. One the repo's .gitignore
946
+ // covers cannot be committed by --push at all — `yad doctor` says what to do instead.
947
+ const git = (...a) => run('git', a, { cwd: repoRoot }).ok;
948
+ const committed = () => git('ls-files', '--error-unmatch', '--', PRODUCT_LINK) && git('diff', '--quiet', 'HEAD', '--', PRODUCT_LINK);
949
+ const ignored = () => git('check-ignore', '-q', '--', PRODUCT_LINK);
950
+ const status = current === null ? 'missing' : !same ? 'outdated' : 'ok';
951
+ return {
952
+ ...base,
953
+ status,
954
+ pendingCommit: status === 'ok' && !committed() && !ignored(),
955
+ apply: () => writeJSON(dest, { ...(current || {}), ...want }),
956
+ };
957
+ }
958
+
596
959
  // Product wiring (gate-sync + verified-commits CI on the Product itself). Only when the Product has a
597
960
  // platform and the verified ledger is explicitly enabled — a local Product stays local, with no error.
598
961
  export function productActions(root) {
599
- const hub = readJSON(productConfigPath(root));
600
- // `ledger` is the canonical switch and `bridge_enabled` its older spelling (the documented hub-config schema); older setup versions
962
+ const productConfig = readJSON(productConfigPath(root));
963
+ // `ledger` is the canonical switch and `bridge_enabled` its older spelling (the documented Product settings schema); older setup versions
601
964
  // wrote `bridge` — `isVerifiedLedger` accepts an explicit true in either spelling, and is the one
602
965
  // predicate the CLI, the wiring, and the ledger hook all read (#186). Wire nothing otherwise.
603
- if (!isVerifiedLedger(hub)) return [];
966
+ if (!isVerifiedLedger(productConfig)) return [];
604
967
  const ledger = readManagedLedger(root);
605
- return [...PRODUCT_WIRING.common, ...(PRODUCT_WIRING[hub.platform] || [])].map((w) =>
606
- wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
968
+ return [...PRODUCT_WIRING.common, ...(PRODUCT_WIRING[productConfig.platform] || [])].map((w) =>
969
+ wiredFileAction('product', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
607
970
  );
608
971
  }
609
972
 
@@ -867,11 +1230,11 @@ function hookSettingsAction(root, adapter) {
867
1230
  // the entry that invokes it. Verified-only exactly like `productActions` — with a local ledger it is
868
1231
  // locally owned, the hand-edit the authoring skills describe is CORRECT, and a guard would be wrong.
869
1232
  export function hookActions(root, ideTargets = ideTargetsFor(root)) {
870
- const hub = readJSON(productConfigPath(root));
871
- if (!isVerifiedLedger(hub)) return [];
1233
+ const productConfig = readJSON(productConfigPath(root));
1234
+ if (!isVerifiedLedger(productConfig)) return [];
872
1235
  const ledger = readManagedLedger(root);
873
1236
  const actions = HOOK_WIRING.map((w) =>
874
- wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
1237
+ wiredFileAction('product', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
875
1238
  );
876
1239
  for (const ide of safeIdeTargetsFor(root, ideTargets)) {
877
1240
  const adapter = HOOK_ADAPTERS[ide];
@@ -881,7 +1244,7 @@ export function hookActions(root, ideTargets = ideTargetsFor(root)) {
881
1244
  // `.claude`-only tree is a file nobody can explain. It is pushed BEFORE the settings entry that
882
1245
  // points at it, so the two land in the order `asNew` already guarantees they land together.
883
1246
  for (const w of adapter.wiring) {
884
- actions.push(wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }));
1247
+ actions.push(wiredFileAction('product', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }));
885
1248
  }
886
1249
  actions.push(hookSettingsAction(root, adapter));
887
1250
  }
@@ -905,8 +1268,8 @@ export function hookActions(root, ideTargets = ideTargetsFor(root)) {
905
1268
  // Verified-only, like `hookActions`, and for the same reason: with a local ledger none of this was
906
1269
  // installed in the first place.
907
1270
  export function orphanHookActions(root, ideTargets = ideTargetsFor(root)) {
908
- const hub = readJSON(productConfigPath(root));
909
- if (!isVerifiedLedger(hub)) return [];
1271
+ const productConfig = readJSON(productConfigPath(root));
1272
+ if (!isVerifiedLedger(productConfig)) return [];
910
1273
  const kept = new Set(safeIdeTargetsFor(root, ideTargets));
911
1274
  const stillNeeded = new Set(
912
1275
  [...kept].flatMap((ide) => (HOOK_ADAPTERS[ide]?.wiring || []).map((w) => w.dest)),
@@ -947,7 +1310,7 @@ export function orphanHookActions(root, ideTargets = ideTargetsFor(root)) {
947
1310
  const dest = path.join(root, w.dest);
948
1311
  if (!exists(dest)) continue;
949
1312
  actions.push({
950
- scope: 'hub',
1313
+ scope: 'product',
951
1314
  item: `${w.dest} (removed)`,
952
1315
  status: 'removed',
953
1316
  root,
@@ -959,9 +1322,145 @@ export function orphanHookActions(root, ideTargets = ideTargetsFor(root)) {
959
1322
  return actions;
960
1323
  }
961
1324
 
1325
+ // ---- the git pre-commit hook (E48) ----------------------------------------------------------------
1326
+ //
1327
+ // The person's half of the ledger guard. `hooks/ledger-guard.mjs` refuses an AGENT's edit; this refuses a
1328
+ // PERSON's `git commit` of the same files, by running that same script with `--staged`. Verified-only,
1329
+ // like `hookActions`: with a local ledger the person owns the ledger and there is nothing to refuse.
1330
+ //
1331
+ // PER CLONE, NOT COMMITTED. Git never commits `.git/hooks`, so every clone needs its own copy, and
1332
+ // `yad check --fix` (or setup, or update) installs it. That is why nothing here touches the provenance
1333
+ // record: that file is committed, and recording this hook in it would tell every teammate's `yad check`
1334
+ // the hook is there when their clone has none. Ours is recognised by its marker line instead, and
1335
+ // `paths` is empty so `yad update --push` never stages it.
1336
+ //
1337
+ // NEVER SOMEONE ELSE'S HOOK. A `pre-commit` without our marker is the team's (or husky's, or
1338
+ // pre-commit's), and `core.hooksPath` means a tool manages hooks from a folder that is usually committed.
1339
+ // Either way nothing is written: `gitHookState` says so, with the one line to add by hand, and doctor
1340
+ // and `yad check` print it.
1341
+ export const GIT_HOOK_MARKER = '# yadflow ledger-guard (E48)';
1342
+ const shQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
1343
+ export function gitPreCommitScript(prefix = '') {
1344
+ return [
1345
+ '#!/bin/sh',
1346
+ `${GIT_HOOK_MARKER} — installed in this clone by \`yad check --fix\`.`,
1347
+ '# Refuses a commit that changes a file only CI may change on a verified Product (the gate ledger and',
1348
+ '# the Product index), the same files the `ledger-guard` check in CI refuses. It fails open: with no',
1349
+ '# node, or no guard script, it allows. Skip it once with YAD_HOOK_DISABLE=1. Git runs it from the top',
1350
+ '# of the repo. yad rewrites this file; to stop it, delete the file.',
1351
+ `guard=${shQuote(`${prefix}hooks/ledger-guard.mjs`)}`,
1352
+ 'command -v node >/dev/null 2>&1 || exit 0',
1353
+ '[ -f "$guard" ] || exit 0',
1354
+ 'node "$guard" --staged || [ $? -ne 2 ] || exit 1',
1355
+ 'exit 0',
1356
+ '',
1357
+ ].join('\n');
1358
+ }
1359
+ // The line a person adds to a hook yad will not touch, run from the top of the repo.
1360
+ // It fails open as the script does: only the guard's refusal (2) stops the commit, and its own status is
1361
+ // 0 otherwise, so it is safe as the last line of a hook. Every command that can fail sits on the left of
1362
+ // `||`, so it is safe under `sh -e` too (husky runs hooks that way): no guard file, no node, or a crash
1363
+ // never stops the commit.
1364
+ export const gitHookLine = (prefix = '') => {
1365
+ const guard = shQuote(`${prefix}hooks/ledger-guard.mjs`);
1366
+ return `[ ! -f ${guard} ] || node ${guard} --staged || [ $? -ne 2 ] || exit 1`;
1367
+ };
1368
+
1369
+ // Where this clone's pre-commit hook is, and whose it is. `{ applies: false }` when there is nothing to
1370
+ // install (a local ledger, or no git). Otherwise `{ applies: true, file, expected, prefix, state }` with
1371
+ // `state` one of: 'missing' | 'ok' | 'outdated' (ours, different bytes) | 'foreign' (a hook that is not
1372
+ // ours) | 'hooks-path' (core.hooksPath is set). The last two carry `line`, the one to add by hand.
1373
+ export function gitHookState(root) {
1374
+ if (!isVerifiedLedger(readJSON(productConfigPath(root)))) return { applies: false };
1375
+ const where = run('git', ['-C', root, 'rev-parse', '--git-path', 'hooks/pre-commit', '--show-prefix']);
1376
+ if (!where.ok) return { applies: false };
1377
+ const [hookRel, prefix = ''] = where.stdout.split('\n');
1378
+ const file = path.resolve(root, hookRel);
1379
+ const expected = gitPreCommitScript(prefix);
1380
+ const line = gitHookLine(prefix);
1381
+ const hooksPath = run('git', ['-C', root, 'config', '--get', 'core.hooksPath']);
1382
+ // A core.hooksPath that names the default folder anyway (some tools set it so) is not a tool's folder.
1383
+ // `--path-format` needs git 2.31; an older git fails the call, and the setting is then treated as a tool's.
1384
+ const common = run('git', ['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir']);
1385
+ // The folder is compared through its PARENT, which always exists: `.git/hooks` itself may not (a clone
1386
+ // made with an empty template), and a path that does not exist cannot be resolved to compare.
1387
+ // When the folder does exist, it is compared whole: a setting typed `.git/Hooks` on a disk that ignores
1388
+ // case is the same folder, and the OS gives both sides one spelling.
1389
+ const hooksDir = path.dirname(file);
1390
+ const isDefault = common.ok && (samePath(hooksDir, path.join(common.stdout, 'hooks'))
1391
+ || (path.basename(hooksDir) === 'hooks' && samePath(path.dirname(hooksDir), common.stdout)));
1392
+ if (hooksPath.ok && hooksPath.stdout && !isDefault) {
1393
+ // husky points core.hooksPath at `.husky/_`, a folder it generates; the hook a person edits is the one
1394
+ // beside it, `.husky/pre-commit`. Name that one, or the advice sends them to a file husky rewrites.
1395
+ const dir = hooksPath.stdout.replace(/[\\/]+$/, '');
1396
+ const edit = /(^|[\\/])_$/.test(dir) ? `${dir.replace(/[\\/]?_$/, '') || '.'}/pre-commit` : `${dir}/pre-commit`;
1397
+ return { applies: true, file, expected, prefix, line, hooksPath: hooksPath.stdout, editFile: edit, state: 'hooks-path' };
1398
+ }
1399
+ let text = null;
1400
+ try { text = fs.readFileSync(file, 'utf8'); } catch { /* absent */ }
1401
+ if (text === null) return { applies: true, file, expected, prefix, state: 'missing' };
1402
+ if (!text.includes(GIT_HOOK_MARKER)) return { applies: true, file, expected, prefix, line, state: 'foreign' };
1403
+ // Git silently skips a hook that is not executable (everywhere but Windows), so right bytes without the
1404
+ // bit are not an installed hook.
1405
+ let runnable = true;
1406
+ if (process.platform !== 'win32') { try { runnable = (fs.statSync(file).mode & 0o111) !== 0; } catch { runnable = false; } }
1407
+ if (text === expected && runnable) return { applies: true, file, expected, prefix, state: 'ok' };
1408
+ return { applies: true, file, expected, prefix, state: 'outdated', why: text === expected ? 'not-executable' : 'changed' };
1409
+ }
1410
+
1411
+ // The install action. 'foreign' and 'hooks-path' build no action — there is nothing yad may write —
1412
+ // and are reported by `gitHookAdvice` instead.
1413
+ export function gitHookActions(root) {
1414
+ const st = gitHookState(root);
1415
+ if (!st.applies || st.state === 'foreign' || st.state === 'hooks-path') return [];
1416
+ const status = st.state === 'missing' ? 'new' : st.state;
1417
+ // An outdated copy may carry a person's edit below our marker, so it is saved beside itself first —
1418
+ // the same rule `wiredFileAction` keeps for content it cannot prove it wrote. Not when only the execute
1419
+ // bit is missing: nothing of anyone's is being replaced then.
1420
+ const sameBytes = st.state === 'outdated' && (() => { try { return fs.readFileSync(st.file, 'utf8') === st.expected; } catch { return false; } })();
1421
+ const backup = st.state === 'outdated' && !sameBytes ? backupPathFor(st.file) : null;
1422
+ return [{
1423
+ scope: 'product',
1424
+ item: 'pre-commit git hook (this clone)',
1425
+ status,
1426
+ root,
1427
+ paths: [],
1428
+ backup,
1429
+ apply: () => {
1430
+ if (backup) fs.copyFileSync(st.file, backup);
1431
+ fs.mkdirSync(path.dirname(st.file), { recursive: true });
1432
+ fs.writeFileSync(st.file, st.expected, { mode: 0o755 });
1433
+ fs.chmodSync(st.file, 0o755);
1434
+ },
1435
+ }];
1436
+ }
1437
+
1438
+ // A local ledger: the hook yad installed while the Product was verified has nothing to guard. Removed
1439
+ // only when its bytes are exactly what yad writes — an edited copy is left for its owner.
1440
+ export function orphanGitHookActions(root) {
1441
+ if (isVerifiedLedger(readJSON(productConfigPath(root)))) return [];
1442
+ const where = run('git', ['-C', root, 'rev-parse', '--git-path', 'hooks/pre-commit', '--show-prefix']);
1443
+ if (!where.ok) return [];
1444
+ const [hookRel, prefix = ''] = where.stdout.split('\n');
1445
+ const file = path.resolve(root, hookRel);
1446
+ let text;
1447
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
1448
+ if (text !== gitPreCommitScript(prefix)) return [];
1449
+ return [{ scope: 'product', item: 'pre-commit git hook (this clone) (removed)', status: 'removed', root, paths: [], apply: () => fs.rmSync(file, { force: true }) }];
1450
+ }
1451
+
1452
+ // The sentence for a clone where yad may not write the hook, or null.
1453
+ // A tool that owns the hook (the pre-commit framework, husky v4) may rewrite it on reinstall, which drops
1454
+ // an added line, so the advice says to check after one.
1455
+ export function gitHookAdvice(st) {
1456
+ if (st?.state === 'hooks-path') return `git's core.hooksPath is set (${st.hooksPath}), so yad did not install its pre-commit ledger guard; add this line to ${st.editFile}: ${st.line}`;
1457
+ if (st?.state === 'foreign') return `${st.file} is not yad's, so yad left it alone; add this line to it to refuse a hand commit to the CI-owned ledger (and again if a tool rewrites the hook): ${st.line}`;
1458
+ return null;
1459
+ }
1460
+
962
1461
  // ---- background capture wiring (E43) -----------------------------------------------------------
963
1462
  //
964
- // The post-edit capture hook: `hooks/yad-capture.sh`, plus one entry per IDE target that has a post-edit
1463
+ // The post-edit capture hook: `hooks/yad-capture.mjs`, plus one entry per IDE target that has a post-edit
965
1464
  // protocol. Unlike `hookActions` it is wired in BOTH ledger modes — never losing a draft matters whoever
966
1465
  // owns the ledger — on any Product (a `.sdlc/hub.json` or `product.json`) whose config does not say
967
1466
  // `"capture": false`. Only the CONFIG turns the wiring off: `YAD_CAPTURE=0` is one shell's choice, and
@@ -977,7 +1476,7 @@ export function captureHookActions(root, ideTargets = ideTargetsFor(root)) {
977
1476
  if (!captureWanted(root)) return [];
978
1477
  const ledger = readManagedLedger(root);
979
1478
  const actions = CAPTURE_WIRING.map((w) =>
980
- wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
1479
+ wiredFileAction('product', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
981
1480
  );
982
1481
  for (const ide of safeIdeTargetsFor(root, ideTargets)) {
983
1482
  const adapter = CAPTURE_ADAPTERS[ide];
@@ -1022,9 +1521,183 @@ export function orphanCaptureHookActions(root, ideTargets = ideTargetsFor(root))
1022
1521
  for (const w of CAPTURE_WIRING) {
1023
1522
  const dest = path.join(root, w.dest);
1024
1523
  if (!exists(dest)) continue;
1025
- actions.push({ scope: 'hub', item: `${w.dest} (removed)`, status: 'removed', root, paths: [w.dest], apply: () => fs.rmSync(dest, { force: true }) });
1524
+ actions.push({ scope: 'product', item: `${w.dest} (removed)`, status: 'removed', root, paths: [w.dest], apply: () => fs.rmSync(dest, { force: true }) });
1026
1525
  }
1027
1526
  }
1028
1527
  return actions;
1029
1528
  }
1030
1529
 
1530
+ // ---- the shell-script hooks, retired (E113) -----------------------------------------------------------
1531
+ //
1532
+ // Every hook was a bash script until E113; each is now a Node script, and the adapters list the old
1533
+ // commands as `legacyCommands`, so the same `yad check --fix` that rewrites an entry also installs the new
1534
+ // file. This removes the old one — status `removed`, so `yad update` applies it too.
1535
+ //
1536
+ // ONLY A COPY YAD PROVABLY WROTE. The provenance record (`.sdlc/managed.json`) must hold the script's sha,
1537
+ // and the file on disk must still match it. An edited copy, or one from before the record existed, is left
1538
+ // alone: the header of each script invited a team to wire it into another harness by hand, and deleting a
1539
+ // file their own hook runs would switch that hook off without a word.
1540
+ //
1541
+ // And never while anything still RUNS it — in two places, both read at PLAN time so `yad check` shows only
1542
+ // what `--fix` will really do (an action that plans a removal and then keeps the file would be pending on
1543
+ // every run, for ever):
1544
+ // - the settings files AS COMMITTED (HEAD). The rewritten entry is the team's to commit (a settings file is
1545
+ // never staged by `--push`), so until it is, everyone who pulls runs the old command — deleting the
1546
+ // script under it would leave them all a hook that does not exist. The removal waits for a later run,
1547
+ // after the new entry is committed.
1548
+ // - the settings files ON DISK AS THIS RUN WILL LEAVE THEM: each adapter's own entry merged or unmerged
1549
+ // exactly as this run's actions do it (review 2 — excusing our old commands wherever they appeared
1550
+ // also excused copies no action touches, under another event or in `settings.local.json`, and those
1551
+ // were planned, kept, and pending for ever).
1552
+ // Both harness settings files are read, plus Claude Code's personal `settings.local.json`, and a command
1553
+ // is matched with either kind of slash (`hooks\yad-capture.sh` on Windows).
1554
+ const HOOK_SETTINGS_FILES = [...new Set([...Object.values(HOOK_ADAPTERS), ...Object.values(CAPTURE_ADAPTERS)].map((a) => a.settings)), '.claude/settings.local.json'];
1555
+ function commandsIn(value, out = []) {
1556
+ if (Array.isArray(value)) { for (const v of value) commandsIn(v, out); return out; }
1557
+ if (!isPlainObject(value)) return out;
1558
+ for (const [k, v] of Object.entries(value)) {
1559
+ if (k === 'command' && typeof v === 'string') out.push(v);
1560
+ else if (k === 'args' && Array.isArray(v)) out.push(...v.filter((a) => typeof a === 'string'));
1561
+ else commandsIn(v, out);
1562
+ }
1563
+ return out;
1564
+ }
1565
+ const namesScript = (text, scriptRel) => text.replace(/\\/g, '/').includes(scriptRel);
1566
+ // Which settings files name the script, read from `raw(file)` (null = absent). A file that will not parse
1567
+ // names it when its text does: nothing can be proved about it, so it keeps the script.
1568
+ function namedIn(raw, scriptRel) {
1569
+ const where = [];
1570
+ for (const file of HOOK_SETTINGS_FILES) {
1571
+ const text = raw(file);
1572
+ if (text === null) continue;
1573
+ let parsed;
1574
+ try { parsed = JSON.parse(text); } catch { if (namesScript(text, scriptRel)) where.push(file); continue; }
1575
+ if (commandsIn(parsed).some((cmd) => namesScript(cmd, scriptRel))) where.push(file);
1576
+ }
1577
+ return where;
1578
+ }
1579
+ const onDisk = (root) => (file) => { try { return fs.readFileSync(path.join(root, file), 'utf8'); } catch { return null; } };
1580
+ // `HEAD:./<file>` is read relative to the Product (the `./`), so a Product in a subfolder of its repository
1581
+ // reads its own files. Not a repository, no commit yet, or a file never committed: nothing is committed.
1582
+ // The old scripts that belonged to the ledger guard, which only a verified Product has.
1583
+ const GUARD_SCRIPTS = new Set(['hooks/ledger-guard.sh', 'hooks/ledger-guard-cursor.sh']);
1584
+
1585
+ // What to do with an entry that still runs an old script: repoint it at the Node script yad now writes —
1586
+ // except a guard script on a Product whose ledger is not verified (or cannot be read), where no guard is
1587
+ // installed to point at, so the entry is removed. One sentence for the plan's note AND apply's warning, so
1588
+ // the two cannot give different advice (review 5).
1589
+ //
1590
+ // It never promises what `yad check --fix` will do: both callers are reached only for entries that run has
1591
+ // NOT taken out (another event, `settings.local.json`, a file changed since the plan), so the reader acts.
1592
+ export function entryAdvice(root, scriptRel) {
1593
+ const productConfig = readJSON(productConfigPath(root), null);
1594
+ // Capture off: no Node capture script is installed, so there is nothing to point at (review 8).
1595
+ if (!GUARD_SCRIPTS.has(scriptRel) && !captureWanted(root)) {
1596
+ return 'remove each entry that runs it — capture is off here (`"capture": false` in the Product config), so no capture hook should run';
1597
+ }
1598
+ if (!GUARD_SCRIPTS.has(scriptRel) || isVerifiedLedger(productConfig)) {
1599
+ return 'point each entry that runs it at the `node hooks/….mjs` command yad now writes, or remove it if that harness should no longer run the hook';
1600
+ }
1601
+ return productConfig === null
1602
+ ? 'the Product config does not read, so yad cannot tell whether a ledger guard belongs here — fix the config, or remove each entry that runs it'
1603
+ : 'remove each entry that runs it — this Product has no ledger guard (its ledger is not verified), so `yad check --fix` leaves those entries alone';
1604
+ }
1605
+
1606
+ // Read once per file per plan, however many old scripts ask (review 3).
1607
+ const committed = (root) => {
1608
+ const seen = new Map();
1609
+ return (file) => {
1610
+ if (!seen.has(file)) {
1611
+ const r = run('git', ['-C', root, 'show', `HEAD:./${file}`]);
1612
+ seen.set(file, r.ok ? r.stdout : null);
1613
+ }
1614
+ return seen.get(file);
1615
+ };
1616
+ };
1617
+ // The settings files on disk as this run's hook actions will leave them. The same choices the actions make:
1618
+ // the guard's entry merged for a verified Product's current targets and unmerged for its dropped ones
1619
+ // (`hookActions` / `orphanHookActions`), nothing done to it with a local ledger; capture's merged for the
1620
+ // current targets while capture is wanted and unmerged otherwise (`captureHookActions` /
1621
+ // `orphanCaptureHookActions`). A file the merge cannot read is left as it is, as the action leaves it.
1622
+ function afterThisRun(root, ideTargets) {
1623
+ const targets = new Set(safeIdeTargetsFor(root, ideTargets));
1624
+ const verified = isVerifiedLedger(readJSON(productConfigPath(root)));
1625
+ const capture = captureWanted(root);
1626
+ const plans = [
1627
+ ...(verified ? Object.values(HOOK_ADAPTERS) : []).map((a) => [a, targets.has(a.target)]),
1628
+ ...Object.values(CAPTURE_ADAPTERS).map((a) => [a, capture && targets.has(a.target)]),
1629
+ ];
1630
+ const read = onDisk(root);
1631
+ return (file) => {
1632
+ const text = read(file);
1633
+ if (text === null) return null;
1634
+ let settings;
1635
+ try { settings = JSON.parse(text); } catch { return text; }
1636
+ if (!isPlainObject(settings)) return text;
1637
+ for (const [adapter, wired] of plans) {
1638
+ if (adapter.settings !== file) continue;
1639
+ const r = wired ? mergeHookSettings(settings, adapter) : unmergeHookSettings(settings, adapter);
1640
+ if (!r.unreadable) settings = r.settings;
1641
+ }
1642
+ return JSON.stringify(settings);
1643
+ };
1644
+ }
1645
+ export function legacyHookScriptActions(root, ideTargets = ideTargetsFor(root)) {
1646
+ if (!exists(productConfigPath(root))) return [];
1647
+ const ledger = readManagedLedger(root);
1648
+ const actions = [];
1649
+ const head = committed(root);
1650
+ const ours = new Set([...Object.values(HOOK_ADAPTERS), ...Object.values(CAPTURE_ADAPTERS)].flatMap((a) => a.legacyCommands));
1651
+ for (const scriptRel of LEGACY_HOOK_SCRIPTS) {
1652
+ const file = path.join(root, scriptRel);
1653
+ if (!lstatIfPresent(file)?.isFile()) continue;
1654
+ const recorded = ledger[scriptRel];
1655
+ if (!recorded || recorded !== contentSha(file)) continue;
1656
+ const after = afterThisRun(root, ideTargets);
1657
+ const onDiskAfter = namedIn(after, scriptRel);
1658
+ if (onDiskAfter.length) {
1659
+ // Kept because something still runs it. A command of the team's own is their choice and is left in
1660
+ // silence; one of OURS that no action reaches — another event, `settings.local.json` — is stuck, and
1661
+ // is named with the way out (review 3).
1662
+ const stuck = onDiskAfter.filter((f) => {
1663
+ try { return commandsIn(JSON.parse(after(f))).some((cmd) => ours.has(cmd) && namesScript(cmd, scriptRel)); } catch { return false; }
1664
+ });
1665
+ if (stuck.length) {
1666
+ info(`${scriptRel} stays — ${stuck.join(' and ')} still ${stuck.length > 1 ? 'run' : 'runs'} it through an old yad hook command. To clear it: ${entryAdvice(root, scriptRel)}; then \`yad check --fix\` removes the script`);
1667
+ }
1668
+ continue;
1669
+ }
1670
+ const inHead = namedIn(head, scriptRel);
1671
+ if (inHead.length) {
1672
+ // The one wait a person ends: committing the rewritten settings. Said, so the second run is not a mystery.
1673
+ info(`${scriptRel} stays until ${inHead.join(' and ')} ${inHead.length > 1 ? 'are' : 'is'} committed as ${inHead.length > 1 ? 'they now stand' : 'it now stands'} — then \`yad check --fix\` removes it`);
1674
+ continue;
1675
+ }
1676
+ actions.push({
1677
+ scope: 'product',
1678
+ item: `${scriptRel} (removed)`,
1679
+ status: 'removed',
1680
+ root,
1681
+ paths: [scriptRel, MANAGED_LEDGER],
1682
+ apply: () => {
1683
+ // Checked once more, on disk and with nothing excused: the rewrites above have run by now, so any
1684
+ // command still naming the script is one this run did not take out.
1685
+ const where = namedIn(onDisk(root), scriptRel);
1686
+ if (where.length) {
1687
+ // Reached when a plan's assumption did not hold at apply — e.g. `yad setup`, which does not unwire a
1688
+ // dropped target the way `yad check --fix` does (review 3).
1689
+ warn(`${scriptRel} kept — ${where.join(', ')} still ${where.length > 1 ? 'run' : 'runs'} it. To clear it: ${entryAdvice(root, scriptRel)}`);
1690
+ return;
1691
+ }
1692
+ fs.rmSync(file, { force: true });
1693
+ // Drop its line from the provenance record, so the record lists only files that are there.
1694
+ const current = readManagedLedger(root);
1695
+ if (Object.hasOwn(current, scriptRel)) {
1696
+ delete current[scriptRel];
1697
+ writeJSON(path.join(root, MANAGED_LEDGER), { version: VERSION, files: current });
1698
+ }
1699
+ },
1700
+ });
1701
+ }
1702
+ return actions;
1703
+ }