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/doctor.mjs CHANGED
@@ -5,15 +5,17 @@
5
5
  // `--json` emits the checks for CI / bug reports.
6
6
  import path from 'node:path';
7
7
  import fs from 'node:fs';
8
- import { c, log, ok, info, warn, fail, hand, run, has, exists, isPlainObject, readJSON, readJSONStrict, emitJSON } from './lib.mjs';
9
- import { VERSION, BACKUP_SUFFIX, MIRRORED_FILES, PROJECT_FILES, MODULE_CONFIG, epicFiles, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS, HOOK_ADAPTERS, CAPTURE_ADAPTERS, isVerifiedLedger , productConfigPath, ADVANCE_FROM_AUTOMATION, DRIVER_FROM_ASSISTANCE } from './manifest.mjs';
10
- import { mergeHookSettings, hookMatcherFires, ideTargetsFor, safeIdeTargetStateFor, hookScriptReady, miswiredGuardCommand } from './plan.mjs';
8
+ import { c, log, ok, info, warn, fail, hand, run, has, exists, isPlainObject, readJSON, readJSONStrict, emitJSON, asArg } from './lib.mjs';
9
+ import { VERSION, BACKUP_SUFFIX, MIRRORED_FILES, mirrorDrift, PROJECT_FILES, MODULE_CONFIG, epicFiles, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS, HOOK_ADAPTERS, CAPTURE_ADAPTERS, HOOK_WIRING, CAPTURE_WIRING, PROTECTION_GUIDE_URL, isVerifiedLedger , productConfigPath, settingsEditHint, PRODUCT_LINK, ADVANCE_FROM_AUTOMATION, DRIVER_FROM_ASSISTANCE } from './manifest.mjs';
10
+ import { mergeHookSettings, hookMatcherFires, ideTargetsFor, safeIdeTargetStateFor, hookScriptReady, miswiredGuardCommand, gitHookState, legacyModuleActions, legacyProductActions, renamedNameHits, PRODUCT_CHECK_WORKFLOWS, productProfileEffect, workflowsPassingProduct, productGateBlockers, oldProfileAdvice, gateProfileFix } from './plan.mjs';
11
+ import { hasSiblingRepo, workspaceFileState, WORKSPACE_FILE } from './find-product.mjs';
11
12
  import { planMigration } from './migrate.mjs';
12
- import { ADVANCE_VALUES, isGateStep, killSwitchOn, loadAutomation, stepDef as catalogueStep, loadLedger, owedSteps, epicIds, epicRel, epicRoot, FOUNDATION_DIR, FOUNDATION_EPIC, DISCOVERY_EPIC, staleFoundationGuards, unwrittenSections, artifactBase, artifactAgrees, epicStories, laneStarted, isValidEpicId, epicLineage, isGenesisType, readFrontmatter, resolveThread, stateInvariants, contractSurfaceHash, acceptedHashes, isStaleHash, workItemType, WORK_ITEM_TYPES, themeOf, themeKey, stepPhase, stepDef, matchLifecycleProfile, lifecycleProfile, LIFECYCLE_PROFILES, SENTINELS, normalizeBindings, optionalStepsFor, isSkippableStep, recordedRouteDisagrees, isPassed, stepStatus, claimsSkipped, STEP_STATES, isStepRecord, RECORDED_STEP_STATES } from './epic-state.mjs';
13
+ import { ADVANCE_VALUES, isGateStep, killSwitchOn, loadAutomation, stepDef as catalogueStep, loadLedger, owedSteps, epicIds, epicRel, epicRoot, FOUNDATION_DIR, FOUNDATION_EPIC, DISCOVERY_EPIC, staleFoundationGuards, unwrittenSections, artifactBase, artifactAgrees, epicStories, laneStarted, isValidEpicId, epicLineage, isGenesisType, readFrontmatter, resolveThread, stateInvariants, contractSurfaceHash, acceptedHashes, isStaleHash, workItemType, WORK_ITEM_TYPES, themeOf, themeKey, stepPhase, stepDef, matchLifecycleProfile, lifecycleProfile, LIFECYCLE_PROFILES, SENTINELS, normalizeBindings, optionalStepsFor, isSkippableStep, recordedRouteDisagrees, isPassed, stepStatus, claimsSkipped, STEP_STATES, isStepRecord, RECORDED_STEP_STATES, productDriftPairs } from './epic-state.mjs';
13
14
  import { loadDebt } from './thread.mjs';
14
15
  import { readShips } from './ledger.mjs';
15
16
  import { gitHead, insideWorkspace } from './setup.mjs';
16
- import { cliFor, hostFromGitUrl, ambiguousLegacyNames } from './platform.mjs';
17
+ import { judgeRepo, runnable, shown as forTerminal } from './workspace.mjs';
18
+ import { cliFor, hostFromGitUrl, ambiguousLegacyNames, plainHost } from './platform.mjs';
17
19
  import { legacyLogins, stampLegacyLogins } from './gate.mjs';
18
20
  import { checkRepo } from './riskmap-command.mjs';
19
21
  import { checkCodeowners, codeownersFindings } from './codeowners-command.mjs';
@@ -21,14 +23,23 @@ import { RISK_MAP_FILE } from './riskmap.mjs';
21
23
  import { readProtection, protectionLine, protectionJSON, hideAddresses } from './protection.mjs';
22
24
  import { soloTeamHint, TEAM_CMD } from './people.mjs';
23
25
  import { indexFreshness, INDEX_FILE } from './product-index.mjs';
24
- import { productGit, resolveDefaultBranch } from './hubcommit.mjs';
26
+ import { productGit, resolveDefaultBranch } from './productcommit.mjs';
25
27
  import { readOwners } from './owners.mjs';
26
28
 
29
+ // A registered path doctor may run git in (E81): a checkout the judgement accepts — never a refused
30
+ // entry (git run in a folder the Product shaped as a repo reads its `config`, which can run a command),
31
+ // and never one reached through a link inside a repo's tree (`yad repo sync` and `refresh` skip it too).
32
+ // The repos check reports every other entry.
33
+ const isCheckout = (root, repo) => runnable(judgeRepo(root, repo));
34
+ // The same for a registry `default_branch` inside a git command to copy: a valid branch name can hold
35
+ // `;`, `|`, `$(` or start a path part with `-` (E81 review 11), so only a plain one is printed.
36
+ const branchArg = (b) => (typeof b === 'string' && /^\w[\w./-]*$/.test(b) && !b.includes('..') ? b : '<default-branch>');
37
+
27
38
  const MIN_NODE = 18;
28
39
 
29
40
  // Solo mode (a lone developer): approval waived, merge + resolved threads still gate. Persisted in
30
41
  // hub.json. Mirrors gate.mjs / next.mjs.
31
- const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
42
+ const isSolo = (productConfig) => !!(productConfig && (productConfig.solo === true || productConfig.review_gate?.solo === true));
32
43
  // Is an already-resolved path nested under the project root? Repo paths are contained to the WORKSPACE
33
44
  // (the root's parent, see setup.insideWorkspace), so a registered sibling resolves outside the root —
34
45
  // which is what distinguishes "absent because it lives elsewhere" from "absent because it is broken".
@@ -49,6 +60,26 @@ function check(checks, id, section, status, message, hint = '', extra = null) {
49
60
  checks.push({ id, section, status, message, ...(hint ? { hint } : {}), ...(extra || {}) });
50
61
  }
51
62
 
63
+ // Where bash is missing, what that costs. The check gates are bash, but they run on the team's CI, not
64
+ // here. On Windows it costs more (E113): Claude Code runs hook commands in Git Bash and falls back to
65
+ // PowerShell without it, and the hook entries yad writes (`node "$CLAUDE_PROJECT_DIR/…"`) are bash
66
+ // spelling — so there the agent hooks do not run at all.
67
+ export const bashMissingHint = (platform = process.platform) => (platform === 'win32'
68
+ ? 'install Git for Windows with its Git Bash (or set CLAUDE_CODE_GIT_BASH_PATH) — Claude Code runs hook commands in Git Bash, and without it yad\'s hooks do not run under Claude Code; Cursor is not affected, and the check gates run on your CI'
69
+ : 'the check gates are bash scripts — needed only to run one here by hand; your CI runs them');
70
+
71
+ // Is there a bash this machine's agent hooks can use? Elsewhere, bash on PATH. On Windows, Git Bash —
72
+ // which the Git for Windows installer does NOT put on PATH (only `Git\cmd` is), and a `bash` that IS on
73
+ // PATH may be WSL's launcher in System32. So look where Claude Code looks: `CLAUDE_CODE_GIT_BASH_PATH`,
74
+ // then `bin\bash.exe` beside the `cmd\git.exe` on PATH.
75
+ export function hasBash({ platform = process.platform, env = process.env, runner = run } = {}) {
76
+ if (platform !== 'win32') return has('bash');
77
+ if (env.CLAUDE_CODE_GIT_BASH_PATH && exists(env.CLAUDE_CODE_GIT_BASH_PATH)) return true;
78
+ const where = runner('where', ['git']);
79
+ if (!where.ok) return false;
80
+ return where.stdout.split(/\r?\n/).some((gitExe) => exists(path.join(path.dirname(path.dirname(gitExe.trim())), 'bin', 'bash.exe')));
81
+ }
82
+
52
83
  export function envChecks(checks) {
53
84
  const major = Number(process.versions.node.split('.')[0]);
54
85
  if (major >= MIN_NODE) check(checks, 'node', 'environment', 'ok', `node ${process.versions.node}`);
@@ -58,14 +89,19 @@ export function envChecks(checks) {
58
89
  else check(checks, 'git', 'environment', 'fail', 'git not found on PATH [YAD-ENV-001]', 'install git — every yad command needs it');
59
90
 
60
91
  for (const tool of ['npx', 'bash']) {
61
- if (has(tool)) check(checks, tool, 'environment', 'ok', `${tool} present`);
62
- else check(checks, tool, 'environment', 'warn', `${tool} not found on PATH`, tool === 'npx' ? 'repomix packing will be skipped' : 'the check gates are bash scripts');
92
+ // On Windows the bash that matters is Git Bash, found beside git rather than on PATH — say so by name.
93
+ const name = tool === 'bash' && process.platform === 'win32' ? 'Git Bash' : tool;
94
+ if (tool === 'bash' ? hasBash() : has(tool)) check(checks, tool, 'environment', 'ok', `${name} present`);
95
+ else check(checks, tool, 'environment', 'warn', name === 'Git Bash' ? 'Git Bash not found' : `${tool} not found on PATH`, tool === 'npx' ? 'repomix packing will be skipped' : bashMissingHint());
63
96
  }
64
97
  }
65
98
 
66
99
  // `headCount` is a count of people the caller already read (E74); the CLI passes none, a test passes one.
67
100
  export function projectChecks(checks, root, { headCount = null } = {}) {
68
101
  const productPath = productConfigPath(root);
102
+ // The file actually read (E122: product.json when it exists), named in every message below — naming
103
+ // the old one would send a person to edit the file that is not read, which is a refused drift.
104
+ const settingsRel = path.relative(root, productPath).split(path.sep).join('/');
69
105
  const regPath = path.join(root, PROJECT_FILES.reposRegistry);
70
106
  const verPath = path.join(root, PROJECT_FILES.version);
71
107
  if (!exists(productPath) && !exists(regPath) && !exists(verPath)) {
@@ -84,27 +120,27 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
84
120
  else check(checks, 'cli-version', 'project', 'ok', `version stamp matches (v${VERSION})`);
85
121
 
86
122
  // hub.json: parse + shape
87
- let hub = null;
123
+ let productConfig = null;
88
124
  if (!exists(productPath)) {
89
- check(checks, 'hub', 'project', 'warn', `${PROJECT_FILES.hubConfig} absent — local gate`, 'run `yad setup` to configure a platform');
125
+ check(checks, 'product', 'project', 'warn', `${settingsRel} absent — local gate`, 'run `yad setup` to configure a platform');
90
126
  } else {
91
- let hubBroken = false;
127
+ let productConfigBroken = false;
92
128
  try {
93
- hub = readJSONStrict(productPath, null);
129
+ productConfig = readJSONStrict(productPath, null);
94
130
  } catch (e) {
95
- hubBroken = true;
96
- check(checks, 'hub', 'project', 'fail', `${PROJECT_FILES.hubConfig} does not parse [${e.code || 'YAD-STATE-001'}]`, e.hint || 'fix the JSON or restore it from git');
131
+ productConfigBroken = true;
132
+ check(checks, 'product', 'project', 'fail', `${settingsRel} does not parse [${e.code || 'YAD-STATE-001'}]`, e.hint || 'fix the JSON or restore it from git');
97
133
  }
98
- if (hubBroken) { /* reported above */ }
99
- else if (typeof hub !== 'object' || Array.isArray(hub) || hub === null) check(checks, 'hub', 'project', 'fail', `${PROJECT_FILES.hubConfig} has the wrong shape [YAD-STATE-002]`, 'expected a JSON object');
100
- else if (![null, undefined, 'github', 'gitlab'].includes(hub.platform)) check(checks, 'hub', 'project', 'fail', `${PROJECT_FILES.hubConfig}: unknown platform '${hub.platform}' [YAD-CFG-001]`, 'expected github, gitlab, or null');
134
+ if (productConfigBroken) { /* reported above */ }
135
+ else if (typeof productConfig !== 'object' || Array.isArray(productConfig) || productConfig === null) check(checks, 'product', 'project', 'fail', `${settingsRel} has the wrong shape [YAD-STATE-002]`, 'expected a JSON object');
136
+ else if (![null, undefined, 'github', 'gitlab'].includes(productConfig.platform)) check(checks, 'product', 'project', 'fail', `${settingsRel}: unknown platform '${forTerminal(productConfig.platform)}' [YAD-CFG-001]`, 'expected github, gitlab, or null');
101
137
  else {
102
- check(checks, 'hub', 'project', 'ok', `hub: ${hub.platform || 'local'}`);
138
+ check(checks, 'product', 'project', 'ok', `product: ${productConfig.platform || 'local'}`);
103
139
  // E62 removed the roster. A list an older release wrote is kept on disk and decides nothing — its
104
140
  // name → login pairs only let the first sync recognise older approvals (`legacyLogins`) — so say so
105
141
  // — a team that still edits it would otherwise believe it decides something. An empty list (what a
106
142
  // solo setup used to write) says nothing about people and is left quiet.
107
- const r = hub.roster;
143
+ const r = productConfig.roster;
108
144
  const listed = Array.isArray(r) ? r.length > 0 : (r && typeof r === 'object' ? Object.keys(r).length > 0 : !!r);
109
145
  if (listed) {
110
146
  // When it can go (E64): once every older record it can place names the login. Counted with the same
@@ -112,8 +148,8 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
112
148
  let waiting = 0;
113
149
  let unplaced = 0;
114
150
  const waitingIn = [];
115
- const aliases = legacyLogins(hub);
116
- const clashed = ambiguousLegacyNames(hub);
151
+ const aliases = legacyLogins(productConfig);
152
+ const clashed = ambiguousLegacyNames(productConfig);
117
153
  for (const e of epicIds(root)) {
118
154
  try {
119
155
  const led = loadLedger(epicRoot(root, e));
@@ -122,15 +158,15 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
122
158
  unplaced += st.unplaced;
123
159
  } catch { /* an unreadable ledger is reported by its own check */ }
124
160
  }
125
- const when = !hub.platform
126
- ? 'nothing reads it on a Product with no platform — delete the `roster` key'
161
+ const when = !productConfig.platform
162
+ ? `nothing reads it on a Product with no platform — delete the \`roster\` key from ${settingsEditHint(root)}`
127
163
  : waiting
128
- ? `keep it for now: ${waiting} older approval/comment record(s) in ${waitingIn.join(', ')} still name people by roster name. The next gate write records their logins — ${isVerifiedLedger(hub) ? 'CI\'s run on the next merged review' : '`yad gate sync <epic>`'} — then delete the \`roster\` key`
164
+ ? `keep it for now: ${waiting} older approval/comment record(s) in ${waitingIn.join(', ')} still name people by roster name. The next gate write records their logins — ${isVerifiedLedger(productConfig) ? 'CI\'s run on the next merged review' : '`yad gate sync <epic>`'} — then delete the \`roster\` key from ${settingsEditHint(root)}`
129
165
  : unplaced
130
- ? `every older record it can place names its login now; ${unplaced} it cannot place (a name two logins share, or records that disagree about which review they are) are matched by submission time or given again on a new review — delete the \`roster\` key once those reviews are closed`
131
- : 'no older record needs it any more — delete the `roster` key';
166
+ ? `every older record it can place names its login now; ${unplaced} it cannot place (a name two logins share, or records that disagree about which review they are) are matched by submission time or given again on a new review — delete the \`roster\` key from ${settingsEditHint(root)} once those reviews are closed`
167
+ : `no older record needs it any more — delete the \`roster\` key from ${settingsEditHint(root)}`;
132
168
  check(checks, 'people:roster-unused', 'project', 'warn',
133
- `${PROJECT_FILES.hubConfig} has a \`roster\` that no longer decides who approves — a gate needs one approval (not the author's own) from anyone with access`,
169
+ `${settingsRel} has a \`roster\` that no longer decides who approves — a gate needs one approval (not the author's own) from anyone with access`,
134
170
  when);
135
171
  // Those pairs are exact only when the roster is: a name two logins share cannot say which person an
136
172
  // older record means. Named here, because on an open review that approval may then have to be given
@@ -138,21 +174,21 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
138
174
  // that entry, and an unlisted login was written `unverified` — so it is not warned about.) The hint
139
175
  // does NOT say "rename it": renaming one entry hands every older record under the name to whoever
140
176
  // keeps it, which can put someone's approval of old content on a person who never gave it.
141
- const unclear = [...ambiguousLegacyNames(hub).keys()];
177
+ const unclear = [...ambiguousLegacyNames(productConfig).keys()];
142
178
  if (unclear.length) {
143
179
  check(checks, 'people:roster-ambiguous', 'project', 'warn',
144
- `${PROJECT_FILES.hubConfig} roster name(s) ${unclear.join(', ')} are given to more than one login — an older approval under that name cannot be recognised by name`,
180
+ `${settingsRel} roster name(s) ${unclear.map(forTerminal).join(', ')} are given to more than one login — an older approval under that name cannot be recognised by name`,
145
181
  'leave the roster as it is: an older approval under that name is matched only when its submission time says whose it is, and otherwise may need to be given again on a new PR. Renaming an entry hands those records to whoever keeps the name — only do it if you know whose approval each one was');
146
182
  }
147
183
  }
148
- if (isSolo(hub)) {
184
+ if (isSolo(productConfig)) {
149
185
  check(checks, 'solo', 'project', 'ok', 'mode: solo — approval waived; the PR merge + resolved threads gate the step');
150
186
  // E74: suggest team mode when the count shows more than one person may work here. Counted ONLY
151
187
  // in solo mode, because the count walks the git history of every connected repo; team mode
152
188
  // pays nothing. A suggestion, never a switch — so a warning, never a failure. An unknown count
153
189
  // is a warning too (the user's choice, 2026-09-22): doctor is where a count that cannot be read
154
190
  // is fixed, and a ✓ beside "could not be counted" would read as healthy.
155
- const hint = soloTeamHint(root, hub, { solo: true, headCount });
191
+ const hint = soloTeamHint(root, productConfig, { solo: true, headCount });
156
192
  if (hint.line && hint.known) {
157
193
  check(checks, 'mode:suggest-team', 'project', 'warn', hint.line,
158
194
  `run \`${TEAM_CMD}\` if more than one person works here; if it is only you (for example two accounts, two spellings of your name, a robot committing or auto-approving, or your own approval on a local ledger), leave solo mode on`);
@@ -164,44 +200,48 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
164
200
  // E10 writes `mode: solo|team` beside `solo`, and `solo` is still the one read. A hand edit can leave
165
201
  // the two saying different things; name that, and say which one the gates follow. Silent on a file
166
202
  // with no `mode`, which is every Product set up before E10 (the frozen golden one included).
167
- if (hub.mode !== undefined) {
168
- const acting = isSolo(hub) ? 'solo' : 'team';
169
- if (hub.mode !== acting) {
203
+ if (productConfig.mode !== undefined) {
204
+ const acting = isSolo(productConfig) ? 'solo' : 'team';
205
+ if (productConfig.mode !== acting) {
170
206
  check(checks, 'mode:disagree', 'project', 'warn',
171
- `${PROJECT_FILES.hubConfig} says mode: ${JSON.stringify(hub.mode)}, but solo mode is ${acting === 'solo' ? 'on' : 'off'} — the old \`solo\` flag is the one the gates read`,
172
- ['solo', 'team'].includes(hub.mode)
173
- ? `\`yad mode ${acting}\` keeps what the gates do now; \`yad mode ${hub.mode === 'solo' ? 'solo --reason "<why>"' : 'team'}\` makes the gates follow \`mode\``
207
+ `${settingsRel} says mode: ${JSON.stringify(forTerminal(productConfig.mode))}, but solo mode is ${acting === 'solo' ? 'on' : 'off'} — the old \`solo\` flag is the one the gates read`,
208
+ ['solo', 'team'].includes(productConfig.mode)
209
+ ? `\`yad mode ${acting}\` keeps what the gates do now; \`yad mode ${productConfig.mode === 'solo' ? 'solo --reason "<why>"' : 'team'}\` makes the gates follow \`mode\``
174
210
  : `\`yad mode ${acting}\` writes a mode the gates recognise`);
175
211
  }
176
212
  }
177
213
  // platform CLI + auth (best-effort; auth probing is the user's own session)
178
- const cli = cliFor(hub.platform);
214
+ const cli = cliFor(productConfig.platform);
179
215
  if (cli) {
180
216
  // git_url is required whenever a platform is set — doctor needs it to scope the auth probe
181
217
  // and the verified ledger/PR flow needs it to open PRs. Warn on its absence directly (not on the
182
218
  // resolved host), so it fires even when an origin remote can substitute: the field itself
183
219
  // is required regardless.
184
- if (!hostFromGitUrl(hub.git_url)) {
185
- check(checks, 'hub-git-url', 'project', 'warn',
186
- `${PROJECT_FILES.hubConfig} sets platform '${hub.platform}' but has no git_url [YAD-CFG-005]`,
187
- 'add git_url to hub.json (or re-run `yad setup`) — auth/PR checks need the Product host');
220
+ if (!hostFromGitUrl(productConfig.git_url)) {
221
+ check(checks, 'product-git-url', 'project', 'warn',
222
+ `${settingsRel} sets platform '${productConfig.platform}' but has no git_url [YAD-CFG-005]`,
223
+ `add git_url to ${settingsEditHint(root)} (or re-run \`yad setup\`) — auth/PR checks need the Product host`);
188
224
  }
189
225
  // Scope the auth probe to the Product's own host (derived from git_url, falling back to the
190
226
  // origin remote). `${cli} auth status` without --hostname exits non-zero when ANY configured
191
227
  // instance fails, so an unrelated stale login (e.g. a dead gitlab.com token) would falsely
192
228
  // flag a working self-hosted Product — so we SKIP the probe entirely when no host resolves
193
229
  // rather than run the flaky unscoped form.
194
- const host = hostFromGitUrl(hub.git_url)
230
+ const rawHost = hostFromGitUrl(productConfig.git_url)
195
231
  || hostFromGitUrl(run('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout);
232
+ // Never asked about or repeated unless it is a plain host name (E81 review 14): it is printed
233
+ // inside `gh auth login --hostname …` for the person to run.
234
+ const host = plainHost(rawHost);
196
235
  if (!has(cli)) check(checks, 'platform-cli', 'project', 'warn', `${cli} not found on PATH [YAD-ENV-002]`, `install ${cli} — the gate degrades to local without it`);
197
- else if (!host) check(checks, 'platform-cli', 'project', 'warn', 'auth check skipped — hub host unknown (no git_url / origin)', 'add git_url to hub.json so the auth probe can target the right host');
236
+ else if (rawHost && !host) check(checks, 'platform-cli', 'project', 'warn', 'auth check skipped — the Product\'s git remote URL names a host that is not a plain host name', `fix git_url in ${settingsEditHint(root)} (or fix the origin remote) to name a plain host`);
237
+ else if (!host) check(checks, 'platform-cli', 'project', 'warn', 'auth check skipped — the Product\'s host is unknown (no git_url / origin)', `add git_url to ${settingsEditHint(root)}, so the auth probe can target the right host`);
198
238
  else if (!run(cli, ['auth', 'status', '--hostname', host]).ok) check(checks, 'platform-cli', 'project', 'warn', `${cli} present but not authenticated for ${host} [YAD-ENV-002]`, `run \`${cli} auth login --hostname ${host}\``);
199
239
  else {
200
240
  check(checks, 'platform-cli', 'project', 'ok', `${cli} present and authenticated`);
201
241
  // GitLab API reachability: the gate reads MR state via `glab api …` (approvals, discussions).
202
242
  // A present+authenticated glab whose token lacks api scope would still break readPrGitLab, so
203
243
  // probe a cheap api call (warn-only) to surface it before a sync silently holds the gate.
204
- if (hub.platform === 'gitlab') {
244
+ if (productConfig.platform === 'gitlab') {
205
245
  // Scope the probe to the Product's own host (like the auth check above) so a multi-instance
206
246
  // setup doesn't hit the wrong GitLab. `host` is guaranteed truthy here (we skip the whole
207
247
  // auth branch when it cannot be resolved), so the probe is always host-scoped.
@@ -221,15 +261,12 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
221
261
  // an agent's hand-edit is always rejected later by `ledger-guard`, so the local hook that refuses it
222
262
  // up front should be installed. With a local ledger nothing guards it, and the hand-edit the
223
263
  // authoring skills describe is correct — nothing to report, so the check is silent rather than `ok`.
224
- const hubForHooks = readJSON(productPath, null);
225
- if (isVerifiedLedger(hubForHooks)) {
264
+ const productConfigForHooks = readJSON(productPath, null);
265
+ if (isVerifiedLedger(productConfigForHooks)) {
226
266
  const unwired = [];
227
267
  const broken = [];
228
- // PRESENT AND EXECUTABLE. A script at mode 644 has the right bytes and cannot run: the harness
229
- // entry pointing at it fails, fails OPEN, and every ledger edit is permitted while this check and
230
- // `yad check` both call the guard healthy. Reachable from a zip download, `cp` without `-p`, or a
231
- // restrictive umask, with nobody having done anything unusual.
232
- if (!hookScriptReady(root, 'hooks/ledger-guard.sh')) unwired.push('hooks/ledger-guard.sh');
268
+ // PRESENT. The entry runs it as `node <file>` (E113), so no execute bit is involved.
269
+ for (const w of HOOK_WIRING) if (!hookScriptReady(root, w.dest)) unwired.push(w.dest);
233
270
  // The SAME target list `hookActions` wires — the persisted `ideTargets`, not "does the directory
234
271
  // exist". Keyed on the directory, a project whose targets are `['.agents']` but which also has a
235
272
  // stray `.claude/` would be told to run `yad check --fix` forever, while that command builds no
@@ -255,7 +292,7 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
255
292
  const adapter = HOOK_ADAPTERS[ide];
256
293
  if (!adapter) { noProtocol.push(ide); continue; }
257
294
  // The SCRIPT THE ENTRY POINTS AT, which is not always the shared one. `.cursor`'s entry names
258
- // `hooks/ledger-guard-cursor.sh`, and only the shared `hooks/ledger-guard.sh` was checked above
295
+ // `hooks/ledger-guard-cursor.mjs`, and only the shared `hooks/ledger-guard.mjs` was checked above
259
296
  // — so a project whose wrapper was deleted, gitignored or lost in a partial checkout had
260
297
  // `yad check` calling it `new` while `yad doctor` printed a green "guard wired". The two
261
298
  // commands disagreeing about one project is the worst version of this: whichever the human
@@ -314,7 +351,7 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
314
351
  // `yad check --fix` would add our entry BESIDE theirs, leaving the one that refuses everything
315
352
  // still in place, while the report said the guard was simply missing.
316
353
  check(checks, 'hooks', 'project', 'warn', `agent ledger guard wired with a command that will refuse every write: ${miswired.join('; ')}`,
317
- 'this harness answers with a JSON verdict and reads an empty answer as a deny; replace that command with `hooks/ledger-guard-cursor.sh`, which `yad check --fix` installs, or have your own wrapper run `yad hook ledger-guard --format cursor` and pass its stdout through');
354
+ 'this harness answers with a JSON verdict and reads an empty answer as a deny; replace that command with `node hooks/ledger-guard-cursor.mjs`, which `yad check --fix` installs, or have your own wrapper run `yad hook ledger-guard --format cursor` and pass its stdout through');
318
355
  } else if (unwired.length) {
319
356
  check(checks, 'hooks', 'project', 'warn', `agent ledger guard not wired: ${unwired.join(', ')}${alsoUnguarded}`,
320
357
  'run `yad check --fix` — until then an agent can hand-edit the CI-owned ledger and only find out when the review PR/MR fails');
@@ -328,15 +365,19 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
328
365
  // Name every script that is actually wired, not just the shared one — on a `.cursor` project the
329
366
  // file Cursor invokes is the wrapper, and a health line that never mentions it is a health line
330
367
  // about something else.
331
- const wiredScripts = ['hooks/ledger-guard.sh',
368
+ const wiredScripts = [...HOOK_WIRING.map((w) => w.dest),
332
369
  ...targets.flatMap((ide) => (HOOK_ADAPTERS[ide]?.wiring || []).map((w) => w.dest))];
333
370
  check(checks, 'hooks', 'project', 'ok', `agent ledger guard wired (${[...new Set(wiredScripts)].join(', ')})${alsoUnguarded}`);
334
371
  }
372
+ gitHookCheck(root, checks);
335
373
  }
374
+ // E80: any Product whose repos live beside it.
375
+ workspaceFileCheck(root, checks);
336
376
 
337
377
  // Background capture (E43), on any Product in either ledger mode: is the post-edit hook wired, and will
338
378
  // a push to the `yad/wip/*` branches start the team's own CI?
339
379
  if (exists(productPath)) captureChecks(root, checks, readJSON(productPath, null));
380
+ if (exists(productPath)) lineEndingChecks(root, checks);
340
381
 
341
382
  // design.json: parse + shape + tool + MCP confirmation (absent is the normal markdown-only default —
342
383
  // pre-feature projects have none, so silence rather than warn when the file does not exist).
@@ -418,33 +459,49 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
418
459
  if (regBroken) { /* reported above */ }
419
460
  else if (!Array.isArray(registry?.repos)) check(checks, 'repos', 'project', 'fail', `${PROJECT_FILES.reposRegistry} has the wrong shape [YAD-STATE-002]`, 'expected a `repos` array');
420
461
  else {
421
- for (const repo of registry.repos) {
462
+ // An entry that is not an object (`null`, `5`) is named, never dereferenced (E81).
463
+ const repoEntries = registry.repos.filter((r) => isPlainObject(r));
464
+ const odd = registry.repos.length - repoEntries.length;
465
+ if (odd) check(checks, 'repos:not-an-entry', 'project', 'fail', `${odd} entr${odd > 1 ? 'ies' : 'y'} in ${PROJECT_FILES.reposRegistry} ${odd > 1 ? 'are' : 'is'} not an object [YAD-STATE-002]`, 'fix or remove it in repos.json');
466
+ for (const repo of repoEntries) {
422
467
  // A missing/empty path must NOT fall back to the project root (which is itself a git repo and
423
468
  // would read as "healthy") — an entry with no path is malformed.
424
- if (!repo.path) { check(checks, `repo:${repo.name || '(unnamed)'}`, 'project', 'fail', `${repo.name || '(unnamed)'}: no \`path\` in repos.json [YAD-STATE-003]`, 're-connect the repo (`yad setup`)'); continue; }
469
+ if (typeof repo.path !== 'string' || !repo.path) { check(checks, `repo:${repo.name || '(unnamed)'}`, 'project', 'fail', `${forTerminal(repo.name || '(unnamed)')}: no \`path\` in repos.json [YAD-STATE-003]`, 're-connect the repo (`yad setup`)'); continue; }
425
470
  const repoRoot = path.resolve(root, repo.path);
471
+ // Judged before git runs (E81) — the same judgement `yad repo clone` makes, so the hint names that
472
+ // command only for a repo it would clone, and git never runs in a folder with no `.git` (a
473
+ // committed folder shaped like a bare repo would have git read its `config`).
474
+ const judged = judgeRepo(root, repo);
475
+ const { name: nm, path: pth } = judged; // shown in the terminal: control characters made spaces
476
+ const cloneHint = judged.state === 'missing' ? 'run `yad repo clone` to clone it here' : null;
426
477
  // A registered repo may be a SIBLING of the Product (`../backend`, the standard multi-repo layout).
427
478
  // Such a checkout is legitimately absent wherever only the Product is checked out — Product CI, a fresh
428
479
  // clone — so its absence is a warn, not corruption. A missing path INSIDE the project root is
429
480
  // still a hard fail: nothing but damage explains it.
430
481
  if (!exists(repoRoot)) {
431
- if (underProjectRoot(root, repoRoot) || !isRegistrableSibling(root, repo.path)) check(checks, `repo:${repo.name}`, 'project', 'fail', `${repo.name}: path ${repo.path} does not exist [YAD-STATE-003]`, 'fix the path in repos.json or re-connect the repo');
432
- else check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: ${repo.path} is not present in this checkout (sibling repo, outside the Product)`, 'expected when only the Product is checked out; clone it alongside the Product to work on it here');
482
+ if (underProjectRoot(root, repoRoot) || !isRegistrableSibling(root, repo.path)) check(checks, `repo:${repo.name}`, 'project', 'fail', `${nm}: path ${pth} does not exist [YAD-STATE-003]`, cloneHint || 'fix the path in repos.json or re-connect the repo');
483
+ else check(checks, `repo:${repo.name}`, 'project', 'warn', `${nm}: ${pth} is not present in this checkout (sibling repo, outside the Product)`, cloneHint ? `expected when only the Product is checked out; ${cloneHint}` : `expected when only the Product is checked out; \`yad repo clone\` will not clone it (${judged.reason}) — clone it alongside the Product by hand to work on it here`);
433
484
  continue;
434
485
  }
435
- const head = gitHead(repoRoot);
436
- if (!head) { check(checks, `repo:${repo.name}`, 'project', 'fail', `${repo.name}: ${repo.path} is not a git repository (or has no commits) [YAD-STATE-003]`, 'init/clone the repo, then re-connect it'); continue; }
437
- if (!repo.syncedHead) check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: registered without a code-context pack (greenfield)`, 'run `yad repo refresh ' + repo.name + '` once it has code');
438
- else if (head !== repo.syncedHead) check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: code-context is stale (HEAD moved since last pack)`, 'run `yad repo refresh ' + repo.name + '`');
439
- else check(checks, `repo:${repo.name}`, 'project', 'ok', `${repo.name}: git repo, context fresh`);
486
+ // There, but refused (outside the workspace, through `.git` or a link, or a folder with no `.git`):
487
+ // said with the reason, and git is not run in it.
488
+ if (judged.state === 'refused') { check(checks, `repo:${repo.name}`, 'project', 'fail', `${nm}: ${pth} — ${judged.reason} [YAD-STATE-003]`, 'fix the path in repos.json or re-connect the repo'); continue; }
489
+ // Reached through a link inside a repo's tree (a link the Product commits): `yad repo sync` and
490
+ // `refresh` skip it, and so do doctor's other checks — said, not read.
491
+ if (judged.linked) { check(checks, `repo:${repo.name}`, 'project', 'warn', `${nm}: ${pth} is reached through a link inside a repo's tree, so the checkout is not where the path says — yad skips it`, 'register the checkout at its own path'); continue; }
492
+ const head = judged.state === 'present' || judged.state === 'product' ? gitHead(repoRoot) : null;
493
+ if (!head) { check(checks, `repo:${repo.name}`, 'project', 'fail', `${nm}: ${pth} is not a git repository (or has no commits) [YAD-STATE-003]`, cloneHint || 'init/clone the repo, then re-connect it'); continue; }
494
+ if (!repo.syncedHead) check(checks, `repo:${repo.name}`, 'project', 'warn', `${nm}: registered without a code-context pack (greenfield)`, 'run `yad repo refresh ' + asArg(repo.name) + '` once it has code');
495
+ else if (head !== repo.syncedHead) check(checks, `repo:${repo.name}`, 'project', 'warn', `${nm}: code-context is stale (HEAD moved since last pack)`, 'run `yad repo refresh ' + asArg(repo.name) + '`');
496
+ else check(checks, `repo:${repo.name}`, 'project', 'ok', `${nm}: git repo, context fresh`);
440
497
  }
441
498
  if (!registry.repos.length) check(checks, 'repos', 'project', 'warn', 'no code repos registered', 'run `yad setup` to connect one');
442
499
  // Per-repo owners went with the roster (E62). Named only where a repo actually lists someone: every
443
500
  // repo an older setup connected carries an EMPTY `domain_owner`, which says nothing.
444
- const owned = registry.repos.filter((r) => (Array.isArray(r.domain_owners) && r.domain_owners.length) || (typeof r.domain_owner === 'string' && r.domain_owner));
501
+ const owned = repoEntries.filter((r) => (Array.isArray(r.domain_owners) && r.domain_owners.length) || (typeof r.domain_owner === 'string' && r.domain_owner));
445
502
  if (owned.length) {
446
503
  check(checks, 'people:domain-owners-unused', 'project', 'warn',
447
- `${PROJECT_FILES.reposRegistry} names domain owners that nothing reads any more: ${owned.map((r) => r.name).join(', ')}`,
504
+ `${PROJECT_FILES.reposRegistry} names domain owners that nothing reads any more: ${owned.map((r) => forTerminal(r.name)).join(', ')}`,
448
505
  'delete `domain_owner` / `domain_owners` when convenient; request reviewers on the PR itself');
449
506
  }
450
507
  // The verified-commits author allowlist went with the roster too (E62): the gate checks signatures
@@ -452,21 +509,21 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
452
509
  // list in hub.json and every generated file — so nobody maintains a list that no longer protects.
453
510
  const allowFiles = [
454
511
  { where: 'the Product', file: path.join(root, '.sdlc', 'verified-authors') },
455
- ...registry.repos.filter((r) => r.path).map((r) => ({ where: r.name, file: path.join(path.resolve(root, r.path), '.sdlc', 'verified-authors') })),
512
+ ...repoEntries.filter((r) => typeof r.path === 'string' && r.path).map((r) => ({ where: forTerminal(r.name), file: path.join(path.resolve(root, r.path), '.sdlc', 'verified-authors') })),
456
513
  ].filter((x) => exists(x.file)).map((x) => x.where);
457
- const listedAuthors = hub && typeof hub === 'object' && Array.isArray(hub.verified_authors) && hub.verified_authors.length > 0;
514
+ const listedAuthors = productConfig && typeof productConfig === 'object' && Array.isArray(productConfig.verified_authors) && productConfig.verified_authors.length > 0;
458
515
  if (allowFiles.length || listedAuthors) {
459
- const what = [listedAuthors ? `\`verified_authors\` in ${PROJECT_FILES.hubConfig}` : null, allowFiles.length ? `.sdlc/verified-authors in ${allowFiles.join(', ')}` : null].filter(Boolean).join(' and ');
516
+ const what = [listedAuthors ? `\`verified_authors\` in ${settingsRel}` : null, allowFiles.length ? `.sdlc/verified-authors in ${allowFiles.join(', ')}` : null].filter(Boolean).join(' and ');
460
517
  check(checks, 'people:verified-authors-unused', 'project', 'warn',
461
518
  `${what} — the verified-commits gate no longer reads an author list; it checks signatures only`,
462
- 'delete them when convenient; write access to the repo decides who can author a commit');
519
+ `delete them when convenient${listedAuthors ? ` (\`verified_authors\` from ${settingsEditHint(root)})` : ''}; write access to the repo decides who can author a commit`);
463
520
  }
464
521
  // A wired `verified-commits.sh` from before E62 still ENFORCES the author list. On a verified ledger
465
522
  // `yad check --fix` refreshes it; on a local ledger the Product's CI files are not managed, so an older
466
523
  // copy stays and keeps failing commits from unlisted authors (E62 upgrade simulation). Named, not fixed.
467
524
  const oldGates = [
468
525
  { where: 'the Product', file: path.join(root, 'checks', 'verified-commits.sh') },
469
- ...registry.repos.filter((r) => r.path).map((r) => ({ where: r.name, file: path.join(path.resolve(root, r.path), 'checks', 'verified-commits.sh') })),
526
+ ...repoEntries.filter((r) => typeof r.path === 'string' && r.path).map((r) => ({ where: forTerminal(r.name), file: path.join(path.resolve(root, r.path), 'checks', 'verified-commits.sh') })),
470
527
  ].filter((x) => exists(x.file) && /SDLC_VERIFIED_AUTHORS|ALLOWLIST=/.test(fs.readFileSync(x.file, 'utf8'))).map((x) => x.where);
471
528
  if (oldGates.length) {
472
529
  check(checks, 'people:allowlist-gate-stale', 'project', 'warn',
@@ -477,7 +534,7 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
477
534
  // slice out of specs/<story>/contracts/ (or a file out of src/<feature>/) passes the gate. `yad check
478
535
  // --fix` refreshes a wired contract-check nobody changed; one changed by hand is kept, and
479
536
  // backfill-check is never wired at all — every copy is placed by hand. Named, not fixed.
480
- const gateRoots = [{ where: 'the Product', root }, ...registry.repos.filter((r) => r.path).map((r) => ({ where: r.name, root: path.resolve(root, r.path) }))];
537
+ const gateRoots = [{ where: 'the Product', root }, ...repoEntries.filter((r) => typeof r.path === 'string' && r.path).map((r) => ({ where: forTerminal(r.name), root: path.resolve(root, r.path) }))];
481
538
  const renameBlind = gateRoots
482
539
  .flatMap((x) => RENAME_BLIND_GATES.map((rel) => ({ ...x, rel })))
483
540
  .filter((x) => renameBlindGate(path.join(x.root, x.rel)))
@@ -496,10 +553,189 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
496
553
  `checks/contract-check.sh in ${symlinkBlind.join(', ')} ${symlinkBlind.length > 1 ? 'are older copies that do' : 'is an older copy that does'} not refuse a symlink or submodule under specs/ — a link there hides the contract slice, and every later edit to its target passes`,
497
554
  'run `yad check --fix` (it refreshes a wired contract-check nobody changed by hand); otherwise copy skills/yad-checks/templates/checks/contract-check.sh over it');
498
555
  }
556
+ // E116. An older backfill-check reads the spec (and its `verified: true`) from the PR itself, so a PR
557
+ // can approve itself or delete the spec; and it reads paths only, so a symlink or submodule at `src`,
558
+ // `src/<feature>` or inside the feature hides every later edit to its target. Never wired: named, not fixed.
559
+ const backfillBlind = gateRoots.filter((x) => backfillBlindGate(path.join(x.root, 'checks/backfill-check.sh'))).map((x) => x.where);
560
+ if (backfillBlind.length) {
561
+ check(checks, 'checks:backfill-blind', 'project', 'warn',
562
+ `checks/backfill-check.sh in ${backfillBlind.join(', ')} ${backfillBlind.length > 1 ? 'are older copies that read' : 'is an older copy that reads'} the approval from the PR itself and does not refuse a symlink under src/ — a PR can approve its own change, and an edit behind a link passes`,
563
+ 'copy skills/yad-backfill/templates/checks/backfill-check.sh over it — backfill-check is never wired, so `yad check --fix` does not refresh it');
564
+ }
565
+ // E117. An older Product-reading gate takes link.md's `product-repo` as the PR leaves it, so a PR can
566
+ // point it at nothing (a deferral, a PASS) or at a Product it commits itself. The current four read
567
+ // `product-repo` from the base and a tracked Product from the base commit. Named, not fixed.
568
+ const pathBlind = gateRoots
569
+ .flatMap((x) => PRODUCT_PATH_GATES.map((rel) => ({ ...x, rel })))
570
+ .filter((x) => productPathBlindGate(path.join(x.root, x.rel)))
571
+ .map((x) => `${x.rel} in ${x.where}`);
572
+ if (pathBlind.length) {
573
+ check(checks, 'checks:product-path-blind', 'project', 'warn',
574
+ `${pathBlind.join(', ')} ${pathBlind.length > 1 ? 'are older copies that read' : 'is an older copy that reads'} the Product from wherever the PR's link.md points — a PR can point it at nothing, or at a Product it commits itself, and pass`,
575
+ 'run `yad check --fix` (it refreshes a wired gate nobody changed by hand); otherwise copy the shipped one over it from skills/yad-checks/templates/checks/');
576
+ }
577
+ // E120. An older Product-reading gate never reads `.sdlc/product-link.json`, so it still takes where
578
+ // the Product lives from link.md; and a checks workflow the team changed by hand (which `yad update`
579
+ // keeps) may not run checks/product-checkout.sh, so CI never checks the Product out. Named, not fixed.
580
+ const blindGates = gateRoots.flatMap((x) => PRODUCT_PATH_GATES.map((rel) => ({ ...x, rel })))
581
+ .filter((x) => productRecordBlindGate(path.join(x.root, x.rel)))
582
+ .map((x) => `${x.rel} in ${x.where}`);
583
+ // A `#` line naming the script is prose, not a step that runs it (review 1).
584
+ const blindFlows = gateRoots.filter((x) => x.where !== 'the Product').flatMap((x) => CHECK_WORKFLOWS.map((rel) => ({ ...x, rel })))
585
+ .filter((x) => {
586
+ let src;
587
+ try { src = fs.readFileSync(path.join(x.root, x.rel), 'utf8'); } catch { return false; }
588
+ return !src.split('\n').some((l) => !/^\s*#/.test(l) && /checks\/product-checkout\.sh/.test(l));
589
+ })
590
+ .map((x) => `${x.rel} in ${x.where}`);
591
+ const said = [
592
+ ...(blindGates.length ? [`${blindGates.join(', ')} ${blindGates.length > 1 ? 'never read' : 'never reads'} .sdlc/product-link.json, so where the Product lives is taken from link.md`] : []),
593
+ ...(blindFlows.length ? [`${blindFlows.join(', ')} ${blindFlows.length > 1 ? 'do' : 'does'} not run checks/product-checkout.sh, so CI never checks the Product out`] : []),
594
+ ];
595
+ if (said.length) {
596
+ check(checks, 'checks:product-record-blind', 'project', 'warn', said.join('; '),
597
+ 'run `yad check --fix` (it refreshes wired files nobody changed by hand); otherwise copy the shipped gate or workflow over it from skills/yad-checks/templates/');
598
+ }
599
+ // E120. A wired code repo (not the Product's own git repo) whose record is not COMMITTED: its gates
600
+ // fall back to link.md, and CI has nothing to check the Product out from. Committed, not on disk
601
+ // (review 1): the gates read it from the default branch, and one that setup wrote and nobody
602
+ // committed looked fine here. Wired only: a repo with no gates has nothing to read it, and the
603
+ // hint would install its whole wiring.
604
+ const top = (d) => { const x = run('git', ['rev-parse', '--show-toplevel'], { cwd: d }); return x.ok ? path.resolve(x.stdout.trim()) : ''; };
605
+ const rootTop = top(root);
606
+ const unlinked = repoEntries.filter((r) => typeof r.path === 'string' && r.path).map((r) => ({ r, dir: path.resolve(root, r.path) }))
607
+ .filter(({ r, dir }) => isCheckout(root, r) && (exists(path.join(dir, '.sdlc/managed.json')) || exists(path.join(dir, 'checks/contract-check.sh'))))
608
+ .filter(({ dir }) => { const t = top(dir); return t && t !== rootTop; })
609
+ .map(({ r, dir }) => {
610
+ const branch = r.default_branch || 'main';
611
+ const onRemote = run('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], { cwd: dir }).ok;
612
+ const ref = onRemote ? `origin/${branch}` : 'HEAD';
613
+ const shown = run('git', ['show', `${ref}:${PRODUCT_LINK}`], { cwd: dir });
614
+ // What is on disk (what yad last wrote) against what the default branch holds (what CI reads):
615
+ // a record updated on disk and never committed is as stale as a missing one (review 2).
616
+ let disk = null;
617
+ try { disk = JSON.parse(fs.readFileSync(path.join(dir, PRODUCT_LINK), 'utf8')); } catch { /* none, or not JSON */ }
618
+ let there;
619
+ try { there = shown.ok ? JSON.parse(shown.stdout) : null; } catch { there = null; }
620
+ const ignored = run('git', ['check-ignore', '-q', '--', PRODUCT_LINK], { cwd: dir }).ok;
621
+ const same = (a, b) => ['git_url', 'path', 'default_branch'].every((k) => (a?.[k] ?? null) === (b?.[k] ?? null));
622
+ const behind = !there || (disk && !same(disk, there));
623
+ if (!behind) return null;
624
+ if (ignored) return { name: r.name, branch, why: 'ignored' };
625
+ // Committed here and never pushed (review 3): a --push whose push was refused (a protected
626
+ // branch) leaves the record `ok` for the writer, so `--fix --push` stages nothing — the fix is to
627
+ // push the commit, not to run it again.
628
+ // The disk copy IS this clone's committed one (HEAD), and origin's differs: which side is ahead is
629
+ // git's to say, not the copies' (reviews 4–5). One table, every case:
630
+ // no commit to the record in origin/<b>..HEAD → this clone is only behind: on <b>, pull;
631
+ // elsewhere, nothing — CI's copy is the newer one
632
+ // such a commit, on <b>, origin not ahead → not pushed: push it
633
+ // such a commit, on <b>, origin also ahead → diverged: pull, settle the record, push
634
+ // such a commit, on a detached HEAD → check out <b> first
635
+ // such a commit, on another branch → merge it into <b> (a PR)
636
+ // The branch this clone is on: a hint that ends in `--push` only works on <b> (review 6).
637
+ const cur = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir });
638
+ const onBranch = cur.ok ? cur.stdout.trim() : 'HEAD';
639
+ const offBranch = onBranch !== branch ? onBranch : null;
640
+ if (onRemote && disk) {
641
+ let head;
642
+ try { const h = run('git', ['show', `HEAD:${PRODUCT_LINK}`], { cwd: dir }); head = h.ok ? JSON.parse(h.stdout) : null; } catch { head = null; }
643
+ if (head && same(head, disk)) {
644
+ const count = (range, ...only) => {
645
+ const x = run('git', ['rev-list', '--count', range, ...(only.length ? ['--', ...only] : [])], { cwd: dir });
646
+ return x.ok ? Number(x.stdout.trim()) || 0 : 0;
647
+ };
648
+ const ahead = count(`origin/${branch}..HEAD`, PRODUCT_LINK) > 0;
649
+ // Not ahead and off <b>: nothing to say only while <b> still holds a readable record, which is
650
+ // then the newer one (review 6: one deleted or broken on <b> was passed over in silence).
651
+ if (!ahead && onBranch === branch) return { name: r.name, branch, why: 'behind' };
652
+ if (!ahead && there) return null;
653
+ if (ahead && onBranch === branch) return { name: r.name, branch, why: count(`HEAD..origin/${branch}`) > 0 ? 'diverged' : 'unpushed' };
654
+ if (ahead && onBranch === 'HEAD') return { name: r.name, branch, why: 'detached' };
655
+ if (ahead) return { name: r.name, branch, onBranch, why: 'elsewhere' };
656
+ }
657
+ }
658
+ // A copy ON DISK that is not JSON is what a plain --fix leaves alone (review 8) — not the default
659
+ // branch's copy, which a clone off that branch only picks up once it switches and pulls.
660
+ const diskBad = !disk && exists(path.join(dir, PRODUCT_LINK));
661
+ // Off the default branch, the hint's first step is a switch, and a switch REPLACES a record this
662
+ // branch tracks with the default branch's (review 9) — so judge the copy it will have then.
663
+ const trackedHere = !!offBranch && run('git', ['cat-file', '-e', `HEAD:${PRODUCT_LINK}`], { cwd: dir }).ok;
664
+ if (!there) return { name: r.name, branch, offBranch, diskBad, trackedHere, why: shown.ok ? 'unreadable' : 'missing' };
665
+ return { name: r.name, branch, offBranch, why: 'stale' };
666
+ })
667
+ .filter(Boolean)
668
+ // Everything below prints these (E81 review 11): the registry's name and branch, and the clone's own
669
+ // branch names, made safe once here — names for prose, the branch for the git commands to copy.
670
+ .map((u) => ({ ...u, name: forTerminal(u.name), branch: branchArg(u.branch),
671
+ ...(u.onBranch ? { onBranch: forTerminal(u.onBranch) } : {}), ...(u.offBranch ? { offBranch: forTerminal(u.offBranch) } : {}) }));
672
+ const named = (why) => unlinked.filter((u) => u.why === why).map((u) => u.name);
673
+ const missing = named('missing');
674
+ const unreadable = named('unreadable');
675
+ const stale = named('stale');
676
+ const ignoredRepos = named('ignored');
677
+ const unpushed = unlinked.filter((u) => u.why === 'unpushed');
678
+ if (missing.length || unreadable.length || stale.length) {
679
+ check(checks, 'repos:product-link-missing', 'project', 'warn', [
680
+ ...(missing.length ? [`${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no ${PRODUCT_LINK} on the default branch — the gates there take where the Product lives from link.md, and CI cannot check the Product out`] : []),
681
+ ...(unreadable.length ? [`${unreadable.join(', ')} ${unreadable.length > 1 ? 'have' : 'has'} no readable ${PRODUCT_LINK} on the default branch (it is not JSON) — CI cannot read it`] : []),
682
+ ...(stale.length ? [`${stale.join(', ')} ${stale.length > 1 ? 'hold' : 'holds'} a ${PRODUCT_LINK} on disk that differs from the one on the default branch — CI reads the default branch's`] : []),
683
+ ].join('; '),
684
+ // One step per repo that needs more than the plain command (review 7): which branch it is on, and
685
+ // --overwrite-local where the copy is not JSON (a plain --fix leaves such a copy alone).
686
+ (() => {
687
+ const listed = unlinked.filter((u) => ['missing', 'unreadable', 'stale'].includes(u.why));
688
+ const where = (u) => (u.offBranch === 'HEAD' ? 'a detached HEAD' : u.offBranch);
689
+ const off = listed.filter((u) => u.offBranch);
690
+ // --overwrite-local only where a plain --fix would leave the copy alone: it is not JSON on disk
691
+ // now, or will not be once an off-branch clone pulls the default branch's unreadable one.
692
+ const bad = listed.filter((u) => (!u.offBranch ? u.diskBad
693
+ : u.trackedHere ? u.why === 'unreadable' : u.diskBad || u.why === 'unreadable'));
694
+ return [
695
+ ...(off.length ? [`first bring ${off.length > 1 ? 'each clone' : 'the clone'} to its default branch — ${off.map((u) => `${u.name} (on ${where(u)}): \`git checkout ${u.branch} && git pull origin ${u.branch}\``).join('; ')} — or commit the record on the branch ${off.length > 1 ? 'each is' : 'it is'} on and merge it with a PR`] : []),
696
+ `${off.length ? 'then ' : ''}from the Product run \`yad check --fix --push\`: it writes the record into each connected repo and commits it to the default branch`,
697
+ ...(bad.length ? [`add \`--overwrite-local\` for ${bad.map((u) => u.name).join(', ')}, whose copy is not JSON and is otherwise left alone — the flag applies to the whole run: it also replaces files changed by hand in the Product and in every connected repo, after saving each`] : []),
698
+ ].join('; ');
699
+ })());
700
+ }
701
+ const elsewhere = unlinked.filter((u) => u.why === 'elsewhere');
702
+ if (elsewhere.length) {
703
+ check(checks, 'repos:product-link-missing', 'project', 'warn',
704
+ elsewhere.map((u) => `${u.name} has ${PRODUCT_LINK} committed on branch ${u.onBranch}, not on ${u.branch} — CI reads ${u.branch}`).join('; '),
705
+ `merge it into the default branch (${elsewhere.map((u) => `a PR from ${u.onBranch} into ${u.branch} in ${u.name}`).join(', ')})`);
706
+ }
707
+ const behind = unlinked.filter((u) => u.why === 'behind');
708
+ if (behind.length) {
709
+ check(checks, 'repos:product-link-missing', 'project', 'warn',
710
+ `${behind.map((u) => u.name).join(', ')} ${behind.length > 1 ? 'are' : 'is'} behind the default branch for ${PRODUCT_LINK} — someone changed it there since this clone last pulled`,
711
+ `pull (${behind.map((u) => `\`git pull origin ${u.branch}\` in ${u.name}`).join(', ')}); the default branch's record is the one CI reads`);
712
+ }
713
+ const diverged = unlinked.filter((u) => u.why === 'diverged');
714
+ if (diverged.length) {
715
+ check(checks, 'repos:product-link-missing', 'project', 'warn',
716
+ `${diverged.map((u) => u.name).join(', ')} ${diverged.length > 1 ? 'have' : 'has'} a ${PRODUCT_LINK} commit the default branch lacks, and the default branch has moved on too — a push would be refused`,
717
+ `pull (rebase or merge) in ${diverged.map((u) => u.name).join(', ')}, settle the record, then push — or open a PR`);
718
+ }
719
+ const detached = unlinked.filter((u) => u.why === 'detached');
720
+ if (detached.length) {
721
+ check(checks, 'repos:product-link-missing', 'project', 'warn',
722
+ `${detached.map((u) => u.name).join(', ')} ${detached.length > 1 ? 'have' : 'has'} a ${PRODUCT_LINK} commit the default branch lacks, on a detached HEAD`,
723
+ `check out the default branch (${detached.map((u) => `\`git checkout ${u.branch}\` in ${u.name}`).join(', ')}), bring the commit onto it, then push`);
724
+ }
725
+ if (unpushed.length) {
726
+ check(checks, 'repos:product-link-missing', 'project', 'warn',
727
+ `${unpushed.map((u) => u.name).join(', ')} ${unpushed.length > 1 ? 'have' : 'has'} ${PRODUCT_LINK} committed but not pushed — CI reads the default branch, which does not have it yet`,
728
+ `push that commit (${unpushed.map((u) => `\`git push origin ${u.branch}\` in ${u.name}`).join(', ')}), or open a PR with it if the branch is protected — \`yad check --fix --push\` has nothing left to commit`);
729
+ }
730
+ if (ignoredRepos.length) {
731
+ check(checks, 'repos:product-link-missing', 'project', 'warn',
732
+ `${ignoredRepos.join(', ')} ${ignoredRepos.length > 1 ? 'ignore' : 'ignores'} ${PRODUCT_LINK} (.gitignore), so \`yad check --fix --push\` cannot commit it — CI cannot read it`,
733
+ `un-ignore ${PRODUCT_LINK} in that repo's .gitignore (or \`git add -f\` it), and commit it to the default branch`);
734
+ }
499
735
  }
500
736
 
501
- ciTagsChecks(checks, root, hub, registry);
502
- return { hub, registry };
737
+ ciTagsChecks(checks, root, productConfig, registry);
738
+ return { productConfig, registry };
503
739
  }
504
740
 
505
741
  // GitLab CI runner tags: the wired fragments run docker-image jobs. On instances whose runners are
@@ -507,7 +743,7 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
507
743
  // forever — silently blocking the gates (issue #50). A current fragment carries
508
744
  // `tags: [$YAD_RUNNER_TAGS]`; warn on any wired GitLab fragment that sets an `image:` but has no
509
745
  // `tags:` (an old install, or one hand-reverted by a sync). Pure local read — no API calls.
510
- export function ciTagsChecks(checks, root, hub, registry) {
746
+ export function ciTagsChecks(checks, root, productConfig, registry) {
511
747
  const untagged = (p) => {
512
748
  try {
513
749
  const txt = fs.readFileSync(p, 'utf8');
@@ -515,22 +751,26 @@ export function ciTagsChecks(checks, root, hub, registry) {
515
751
  } catch { return false; } // absent fragment is not this check's concern
516
752
  };
517
753
  const fragments = [];
518
- if (hub?.platform === 'gitlab' && isVerifiedLedger(hub)) {
754
+ if (productConfig?.platform === 'gitlab' && isVerifiedLedger(productConfig)) {
519
755
  fragments.push(
520
- { scope: 'hub', file: '.gitlab/ci/yad-gate-sync.yml', path: path.join(root, '.gitlab/ci/yad-gate-sync.yml') },
521
- { scope: 'hub', file: '.gitlab/ci/yad-verified-commits.yml', path: path.join(root, '.gitlab/ci/yad-verified-commits.yml') },
522
- { scope: 'hub', file: '.gitlab/ci/yad-hub-checks.yml', path: path.join(root, '.gitlab/ci/yad-hub-checks.yml') },
756
+ { scope: 'product', file: '.gitlab/ci/yad-gate-sync.yml', path: path.join(root, '.gitlab/ci/yad-gate-sync.yml') },
757
+ { scope: 'product', file: '.gitlab/ci/yad-verified-commits.yml', path: path.join(root, '.gitlab/ci/yad-verified-commits.yml') },
758
+ { scope: 'product', file: '.gitlab/ci/yad-product-checks.yml', path: path.join(root, '.gitlab/ci/yad-product-checks.yml') },
759
+ // Its old name (E123), while it is still there — `renamed:` says to run `yad update`.
760
+ { scope: 'product', file: '.gitlab/ci/yad-hub-checks.yml', path: path.join(root, '.gitlab/ci/yad-hub-checks.yml') },
523
761
  );
524
762
  }
525
- for (const repo of registry?.repos || []) {
526
- if (repo.platform !== 'gitlab' || !repo.path) continue;
527
- fragments.push({ scope: repo.name, file: '.gitlab/ci/yad-checks.yml', path: path.join(path.resolve(root, repo.path), '.gitlab/ci/yad-checks.yml') });
763
+ for (const repo of Array.isArray(registry?.repos) ? registry.repos : []) {
764
+ if (!isPlainObject(repo) || repo.platform !== 'gitlab' || !repo.path) continue;
765
+ fragments.push({ scope: forTerminal(repo.name), repo: true, file: '.gitlab/ci/yad-checks.yml', path: path.join(path.resolve(root, repo.path), '.gitlab/ci/yad-checks.yml') });
528
766
  }
529
767
  for (const f of fragments) {
530
768
  if (untagged(f.path)) {
531
769
  check(checks, `ci-tags:${f.scope}`, 'project', 'warn',
532
770
  `${f.scope}: ${f.file} runs a docker job with no \`tags:\` [YAD-CI-001]`,
533
- 'tag-locked runners (run_untagged: false) will strand it at `pending` — run `yad update`, then set the `YAD_RUNNER_TAGS` CI/CD variable');
771
+ 'tag-locked runners (run_untagged: false) will strand it at `pending` — run `yad update`, then set the `YAD_RUNNER_TAGS` CI/CD variable',
772
+ // The id cannot tell the Product from a repo named `product`; this key can (E124 review 1).
773
+ { product: f.scope === 'product' && !f.repo });
534
774
  }
535
775
  }
536
776
  }
@@ -706,13 +946,15 @@ export function epicChecks(checks, root) {
706
946
  for (const v of stateInvariants(ledger.state)) {
707
947
  check(checks, `epic:${e}:${v.authorStep}`, 'epics', 'fail',
708
948
  `${e}: ${v.message} [${v.code}]`,
709
- `run \`yad gate repair ${e}\` to close it`);
949
+ // On a verified Product the repair's hand commit is refused by the git pre-commit guard (E48);
950
+ // `--push` makes the commit yad owns, so that is the command named there.
951
+ `run \`yad gate repair ${e}${isVerifiedLedger(readJSON(productConfigPath(root), null)) ? ' --push' : ''}\` to close it`);
710
952
  }
711
953
  // Migration guard (pre-3.0 model): under the current model CI records the ledger on the
712
954
  // default branch only at merge (when the step is already done), and writes nothing during
713
955
  // review — so an OPEN (non-done) review PR recorded here means it was opened under an older
714
956
  // model. Merge/close it under the version that opened it before relying on the CI flow.
715
- const openPr = (ledger.hubPrs || []).find((p) => {
957
+ const openPr = (ledger.productPrs || []).find((p) => {
716
958
  const step = ledger.state.steps.find((s) => s.id === p.step);
717
959
  return !!step && !isPassed(step);
718
960
  });
@@ -754,7 +996,7 @@ export function epicChecks(checks, root) {
754
996
  // ledger — so it is reported as fine, because a warning nobody can clear teaches
755
997
  // people to stop reading warnings.
756
998
  export function foundationChecks(checks, root) {
757
- const hub = readJSON(productConfigPath(root), null);
999
+ const productConfig = readJSON(productConfigPath(root), null);
758
1000
  const hasFoundation = exists(path.join(epicRoot(root, FOUNDATION_EPIC), '.sdlc', 'state.json'));
759
1001
  const hasLegacy = exists(path.join(epicRoot(root, DISCOVERY_EPIC), '.sdlc', 'state.json'));
760
1002
  if (hasFoundation && hasLegacy) {
@@ -762,7 +1004,7 @@ export function foundationChecks(checks, root) {
762
1004
  `two product levels: ${FOUNDATION_DIR}/ and epics/${DISCOVERY_EPIC}/ — a product has one Foundation`,
763
1005
  `decide which one is real. \`yad next\` uses ${FOUNDATION_DIR}/; to keep the old one instead, move ${FOUNDATION_DIR}/ aside and run \`yad migrate --apply\``);
764
1006
  } else if (hasLegacy) {
765
- if (isVerifiedLedger(hub)) {
1007
+ if (isVerifiedLedger(productConfig)) {
766
1008
  // CI moves it (cli/gate.mjs `convertProductLevel`), but only once the committed checks know the
767
1009
  // Foundation — so the stale case is the one with something for a person to do.
768
1010
  const stale = staleFoundationGuards(root);
@@ -785,7 +1027,7 @@ export function foundationChecks(checks, root) {
785
1027
  `epics/${FOUNDATION_EPIC}/ exists, but that id's folder is ${FOUNDATION_DIR}/ — nothing ever reads this one`,
786
1028
  `move anything real into ${FOUNDATION_DIR}/, then delete epics/${FOUNDATION_EPIC}/`);
787
1029
  }
788
- foundationGuardChecks(checks, root, hub);
1030
+ foundationGuardChecks(checks, root, productConfig);
789
1031
  if (hasFoundation) foundationSectionChecks(checks, root);
790
1032
  }
791
1033
 
@@ -817,9 +1059,9 @@ export function foundationSectionChecks(checks, root) {
817
1059
  : 'write the section before the review is approved — the yad-discovery skill says what each section needs');
818
1060
  }
819
1061
 
820
- function foundationGuardChecks(checks, root, hub) {
1062
+ function foundationGuardChecks(checks, root, productConfig) {
821
1063
  if (!exists(path.join(root, FOUNDATION_DIR, '.sdlc'))) return;
822
- if (!isVerifiedLedger(hub)) return;
1064
+ if (!isVerifiedLedger(productConfig)) return;
823
1065
  const stale = staleFoundationGuards(root);
824
1066
  if (!stale.length) return;
825
1067
  check(checks, 'foundation:guard', 'project', 'warn',
@@ -907,20 +1149,21 @@ function shapeCheckFor(checks, id, label, rows, engine) {
907
1149
  // reaches it, which is how the drift report is proven before there is any drift to report.
908
1150
  // A file that lives under two names must say the same thing under both. The engine writes them
909
1151
  // together, so they only drift when something outside the engine touched one — a person editing the
910
- // name they happen to know, a script, a half-finished merge. The older name is the authoritative one
911
- // this major, so a silent drift means the OTHER copy is being ignored, which is the kind of thing
912
- // people lose an afternoon to. Say it out loud instead.
1152
+ // name they happen to know, a script, a half-finished merge. Since E122 no command reads a drifted
1153
+ // pair (every one refuses, YAD-STATE-008), so this is a failure, with the one command that ends it.
1154
+ // When the pair agrees, one plain line says why the old name is still there and when it goes.
913
1155
  export function mirrorChecks(checks, root) {
914
- const pairs = [...MIRRORED_FILES.map(({ canonical, legacy }) => ({ canonical, legacy }))];
1156
+ const pairs = MIRRORED_FILES.map(({ canonical, legacy }) => ({ canonical, legacy, epic: null }));
915
1157
  // The per-epic PR ledger is renamed the same way, so it drifts the same way. It is not in
916
1158
  // MIRRORED_FILES because that list is project-relative and this one exists once per epic.
917
1159
  // `epicIds` reads with `withFileTypes`, so a dangling symlink is simply not a directory — it cannot
918
1160
  // throw here and take the whole health check down, which is what the old `statSync` guard was for.
919
1161
  for (const e of epicIds(root)) {
920
1162
  const f = epicFiles(epicRel(e));
921
- pairs.push({ canonical: f.productPrs, legacy: f.hubPrs });
1163
+ pairs.push({ canonical: f.productPrs, legacy: f.productPrsLegacy, epic: e });
922
1164
  }
923
- for (const { canonical, legacy } of pairs) {
1165
+ let kept = false;
1166
+ for (const { canonical, legacy, epic } of pairs) {
924
1167
  const a = path.join(root, canonical);
925
1168
  const b = path.join(root, legacy);
926
1169
  // Only the settings file reaches this branch in practice: the per-epic PR ledgers are top-level
@@ -946,13 +1189,20 @@ export function mirrorChecks(checks, root) {
946
1189
  continue;
947
1190
  }
948
1191
  if (!exists(a)) continue;
949
- let same;
950
- try { same = fs.readFileSync(a, 'utf8') === fs.readFileSync(b, 'utf8'); } catch { continue; }
951
- if (same) continue;
1192
+ if (!mirrorDrift(a, b)) { if (!epic) kept = true; continue; }
1193
+ // An epic's drifted pair is already a failure of that epic's own check (`loadLedger` refuses it,
1194
+ // with the same code and hint), so it is not said twice.
1195
+ if (epic) continue;
952
1196
  check(
953
- checks, `mirror:${canonical}`, 'shape', 'warn',
954
- `${canonical} and ${legacy} do not match — ${legacy} is the one being read`,
955
- 'they are two names for one file while the rename settles. Copy the one you meant to keep over the other, then re-run the command that writes it',
1197
+ checks, `mirror:${canonical}`, 'shape', 'fail',
1198
+ `${canonical} and ${legacy} say different things — every command refuses until one is chosen [YAD-STATE-008]`,
1199
+ 'run `yad migrate` to see the difference, then `yad migrate --apply` to choose the copy to keep (or `--apply --keep product` / `--keep hub`)',
1200
+ );
1201
+ }
1202
+ if (kept) {
1203
+ check(
1204
+ checks, 'mirror:legacy-names', 'shape', 'ok',
1205
+ `${PROJECT_FILES.productConfigLegacy} (and each epic's hub-prs.json) is kept beside the new name for check gates an older yadflow installed — ${PROJECT_FILES.productConfig} is the one read, and v5 deletes the old name`,
956
1206
  );
957
1207
  }
958
1208
  }
@@ -1643,7 +1893,7 @@ export function ownerChecks(checks, root) {
1643
1893
  // `\` goes on to the next, as a hand-edited copy may split `git … \ diff --name-only`; then each line is
1644
1894
  // cut at `&&`, `||`, `;` and `|`, so `fixed && \ blind` is two commands, not one.
1645
1895
  export const RENAME_BLIND_GATES = ['checks/contract-check.sh', 'checks/backfill-check.sh'];
1646
- const renameBlindCommand = (c) => /\bgit\b.*\bdiff\b.*--name-only/.test(c) && !/--no-renames/.test(c);
1896
+ const renameBlindCommand = (c) => /\bgit\b.*\bdiff\b.*(--name-only|--raw)/.test(c) && !/--no-renames/.test(c);
1647
1897
  export const renameBlindText = (src) => src.split('\n').map((l) => (/^\s*#/.test(l) ? '' : l)).join('\n')
1648
1898
  .replace(/\\\r?\n/g, ' ').split('\n').some((l) => l.split(/&&|\|\||;|\|/).some(renameBlindCommand));
1649
1899
  export function renameBlindGate(file) {
@@ -1652,34 +1902,138 @@ export function renameBlindGate(file) {
1652
1902
  return renameBlindText(src);
1653
1903
  }
1654
1904
  // E115: a contract-check that never reads the tree for links under specs/. Read like the one above — a
1655
- // `#` line is prose, a line ending in `\` goes on — and it is enough that ONE command runs `git ls-tree`:
1656
- // no older copy ever did, and the current one reads the whole tree (the name `specs` is in its awk).
1905
+ // `#` line is prose, a line ending in `\` goes on — and it is enough that ONE command runs `git ls-tree`
1906
+ // on HEAD: no older copy ever did, and the current one reads the whole tree (the name `specs` is in its
1907
+ // awk). HEAD, because E117 added a second `git ls-tree`, of the BASE, that finds sibling link.md files.
1657
1908
  export const symlinkBlindText = (src) => !src.split('\n').map((l) => (/^\s*#/.test(l) ? '' : l)).join('\n')
1658
- .replace(/\\\r?\n/g, ' ').split('\n').some((l) => /\bgit\b.*\bls-tree\b/.test(l));
1909
+ .replace(/\\\r?\n/g, ' ').split('\n').some((l) => /\bgit\b.*\bls-tree\b.*\bHEAD\b/.test(l));
1659
1910
  export function symlinkBlindGate(file) {
1660
1911
  let src;
1661
1912
  try { src = fs.readFileSync(file, 'utf8'); } catch { return false; }
1662
1913
  return symlinkBlindText(src);
1663
1914
  }
1915
+ // E116: a backfill-check that reads the spec from the PR's working tree, or never reads the tree at HEAD
1916
+ // for links under src/, or splits its --raw list with `tr`. Read like the ones above; no older copy ran
1917
+ // `git ls-tree` or `git cat-file`.
1918
+ export const backfillBlindText = (src) => {
1919
+ const lines = src.split('\n').map((l) => (/^\s*#/.test(l) ? '' : l)).join('\n').replace(/\\\r?\n/g, ' ').split('\n');
1920
+ return !lines.some((l) => /\bgit\b.*\bls-tree\b.*\bHEAD\b/.test(l)) || !lines.some((l) => /\bgit\s+cat-file\s/.test(l))
1921
+ // The first E116 copy split its --raw list into lines with `tr`, so one path with a newline hid every
1922
+ // feature after it (review 1). The current one pairs the records NUL by NUL.
1923
+ || lines.some((l) => /\bgit\b.*\bdiff\b.*--raw\b.*\|\s*tr\b/.test(l));
1924
+ };
1925
+ export function backfillBlindGate(file) {
1926
+ let src;
1927
+ try { src = fs.readFileSync(file, 'utf8'); } catch { return false; }
1928
+ return backfillBlindText(src);
1929
+ }
1930
+ // E120: a Product-reading gate that never reads `.sdlc/product-link.json` (no non-comment command naming
1931
+ // it). Read like the ones above.
1932
+ export const productRecordBlindText = (src) => !src.split('\n').map((l) => (/^\s*#/.test(l) ? '' : l)).join('\n')
1933
+ .replace(/\\\r?\n/g, ' ').split('\n').some((l) => /\bgit\b.*product-link\.json/.test(l));
1934
+ export function productRecordBlindGate(file) {
1935
+ let src;
1936
+ try { src = fs.readFileSync(file, 'utf8'); } catch { return false; }
1937
+ return productRecordBlindText(src);
1938
+ }
1939
+ // The checks workflows a connected repo may carry (one per platform).
1940
+ export const CHECK_WORKFLOWS = ['.github/workflows/yad-checks.yml', '.gitlab/ci/yad-checks.yml'];
1941
+ // E117: a Product-reading gate that does not read link.md from the base (`git show`) or a tracked Product
1942
+ // from the base commit (`git checkout-index`, from a throwaway index). Read like the two above; no older
1943
+ // copy ran either command.
1944
+ export const PRODUCT_PATH_GATES = ['checks/contract-check.sh', 'checks/lineage-check.sh', 'checks/epic-open.sh', 'checks/reconcile-debt-check.sh'];
1945
+ export const productPathBlindText = (src) => {
1946
+ const lines = src.split('\n').map((l) => (/^\s*#/.test(l) ? '' : l)).join('\n').replace(/\\\r?\n/g, ' ').split('\n');
1947
+ return !lines.some((l) => /\bgit\s+show\s/.test(l)) || !lines.some((l) => /\bgit\s+checkout-index\s/.test(l));
1948
+ };
1949
+ export function productPathBlindGate(file) {
1950
+ let src;
1951
+ try { src = fs.readFileSync(file, 'utf8'); } catch { return false; }
1952
+ return productPathBlindText(src);
1953
+ }
1664
1954
 
1665
1955
  // `owners:rename-blind` (E47). The wired `pr-title` / `pr-template` checks let a PR of step owner files alone
1666
- // through; that is safe only while the hub-checks workflow lists renames by BOTH paths (`--no-renames`).
1956
+ // through; that is safe only while the product-checks workflow lists renames by BOTH paths (`--no-renames`).
1667
1957
  // `yad update` keeps a workflow the team changed by hand, so a Product can hold the new checks and the old
1668
1958
  // workflow — and then `git mv epic.md .sdlc/owners/epic.json` on a non-review branch passes. Say so.
1669
- export const HUB_CHECK_WORKFLOWS = ['.github/workflows/yad-hub-checks.yml', '.gitlab/ci/yad-hub-checks.yml'];
1959
+ // The old names too (E123): an edited `yad-hub-checks.yml` is kept by `yad update`, and still runs.
1670
1960
  export function ownerGuardChecks(checks, root) {
1671
1961
  const read = (rel) => { try { return fs.readFileSync(path.join(root, rel), 'utf8'); } catch { return null; } };
1672
1962
  // The exemption's own pattern, not any mention of the folder: a comment is not a rule.
1673
1963
  const exempting = ['checks/pr-title.sh', 'checks/pr-template.sh'].filter((rel) => read(rel)?.includes('\\.sdlc/owners/[^/]+\\.json$'));
1674
1964
  if (!exempting.length) return;
1675
1965
  // Line by line: one fixed diff line must not hide its blind twin in the other job.
1676
- const blind = HUB_CHECK_WORKFLOWS.filter((rel) => renameBlindText(read(rel) || ''));
1966
+ const blind = PRODUCT_CHECK_WORKFLOWS.filter((rel) => renameBlindText(read(rel) || ''));
1677
1967
  if (!blind.length) return;
1678
1968
  check(checks, 'owners:rename-blind', 'project', 'warn',
1679
1969
  `${blind.join(', ')} lists a PR's changes without \`--no-renames\`, while ${exempting.join(' and ')} let a PR of step owner files alone through — so moving an artifact into .sdlc/owners/ on a non-review branch passes both`,
1680
1970
  'add `--no-renames` (and `-c core.quotePath=false`) to that workflow\'s `git diff --name-only` lines, as the shipped one has — `yad update` did not replace it because it was changed by hand');
1681
1971
  }
1682
1972
 
1973
+ // Names the `hub` -> Product rename moved (E123), on the Product. Two kinds, both warnings:
1974
+ // renamed:<old path> something yad installed is still under its old name — a skill folder, or a CI
1975
+ // file. `yad update` renames it; an edited CI file it keeps, and says how to finish.
1976
+ // renamed-ref:<file> a file of the team's own names one of our old CI names, by file and line. yad
1977
+ // never edits those files; the one exception is the include line in the root
1978
+ // .gitlab-ci.yml, which `yad update` rewrites when it replaces the old fragment.
1979
+ // Silent when nothing old is left, so a project that never had the old names sees no line at all.
1980
+ export function renamedChecks(checks, root) {
1981
+ if (!exists(productConfigPath(root))) return;
1982
+ let installed = [];
1983
+ // An unreadable provenance record or IDE target is named by its own check; this one then says nothing.
1984
+ try { installed = [...legacyModuleActions(root), ...legacyProductActions(root)]; } catch { /* named elsewhere */ }
1985
+ for (const a of installed) {
1986
+ const { from, to } = a.rename;
1987
+ check(checks, `renamed:${from}`, 'project', 'warn',
1988
+ `${from} is an old name — renamed ${to}`,
1989
+ a.status === 'modified'
1990
+ ? `it was edited, so \`yad update\` keeps it: \`yad update --overwrite-local\` replaces it with ${to} (your copy is saved as ${path.basename(from)}${BACKUP_SUFFIX}); then copy your edits into ${to}`
1991
+ : 'run `yad update` — it installs the new name and removes the old one');
1992
+ }
1993
+ const byFile = new Map();
1994
+ // The same pairs every other command refuses on (`productDriftError`): then only `yad migrate` comes first.
1995
+ const profileFirst = oldProfileAdvice(productGateBlockers(root, { drift: productDriftPairs(root) }));
1996
+ for (const h of renamedNameHits(root)) {
1997
+ if (!byFile.has(h.file)) byFile.set(h.file, []);
1998
+ byFile.get(h.file).push(h);
1999
+ }
2000
+ for (const [file, hits] of byFile) {
2001
+ const listed = hits.map((h) => `line ${h.line} \`${h.old}\` → \`${h.new}\``).join(', ');
2002
+ const theirs = hits.filter((h) => !h.rewrittenBy);
2003
+ // The include line is rewritten by yad — by a plain `yad update`, or, when the old fragment was edited,
2004
+ // only by `--overwrite-local`. Until then the new fragment is not installed, so a hand edit of that line
2005
+ // would include a file that is not there and fail every pipeline (review 1): say to leave it.
2006
+ const late = hits.some((h) => h.rewrittenBy === 'overwrite-local');
2007
+ const by = late ? '`yad update --overwrite-local`' : '`yad update`';
2008
+ const all = theirs.length === hits.length;
2009
+ // One mention per line, though a line may hold two old names (review 3).
2010
+ const what = all ? (hits.length > 1 ? 'each' : 'it') : [...new Set(theirs.map((h) => h.line))].map((l) => `line ${l}`).join(', ');
2011
+ const hint = theirs.length === 0
2012
+ ? `${by} rewrites it when it replaces the old fragment${late ? ' — leave it until then: the new fragment is not installed before that' : ''}`
2013
+ : `yad does not edit this file — change ${what} to ${!all && theirs.length > 1 ? 'their new names' : 'its new name'}${all ? '' : ` (${by} rewrites the include line itself${late ? ' — leave that one until then' : ''})`}${
2014
+ // E124 reviews 2–3: a gate that would mishandle `product` must be fixed, or replaced, before the change.
2015
+ hits.some((h) => h.profile && !h.rewrittenBy) && profileFirst ? `; ${profileFirst}` : ''}`;
2016
+ check(checks, `renamed-ref:${file}`, 'project', 'warn',
2017
+ `${file} names what 4.0 renamed: ${listed}`, hint);
2018
+ }
2019
+ }
2020
+
2021
+ // `profile:<gate>` (E123). A Product workflow passes `--profile product` to the pattern gates, and a gate the
2022
+ // team edited before 4.0 — which `yad update` keeps — accepts only `code|hub`, so every Product PR fails it.
2023
+ // A warning, like E123's other lines. Only when a workflow on disk passes that profile; an old workflow still
2024
+ // passing `hub` breaks nothing.
2025
+ export function productProfileChecks(checks, root) {
2026
+ if (!exists(productConfigPath(root))) return;
2027
+ const passing = workflowsPassingProduct(root);
2028
+ if (!passing.length) return;
2029
+ // What to do depends on the gate's state in the provenance record, as in `renamed-ref:` (E124 review 4).
2030
+ for (const b of productGateBlockers(root, { drift: productDriftPairs(root) })) {
2031
+ check(checks, `profile:${b.gate}`, 'project', 'warn',
2032
+ productProfileEffect(b.gap, b.gate, `${passing.join(' and ')} ${passing.length > 1 ? 'pass' : 'passes'}`),
2033
+ gateProfileFix(b));
2034
+ }
2035
+ }
2036
+
1683
2037
  // `.sdlc/skills.json`: which skill runs which step, when the project does not want the engine's
1684
2038
  // default (E6). Absent is the normal case and says nothing — most projects run the shipped skills.
1685
2039
  //
@@ -1910,27 +2264,30 @@ export function riskMapChecks(checks, root) {
1910
2264
  for (const repo of repos) {
1911
2265
  if (!repo || typeof repo.name !== 'string' || typeof repo.path !== 'string' || !repo.path) continue;
1912
2266
  const repoRoot = path.resolve(root, repo.path);
1913
- if (!exists(repoRoot) || !gitHead(repoRoot)) continue;
2267
+ if (!isCheckout(root, repo) || !gitHead(repoRoot)) continue;
1914
2268
  const r = checkRepo(repoRoot);
1915
2269
  if (!r.git) continue;
1916
2270
  const id = `risk-map:${repo.name}`;
2271
+ // Printed (E81 review 11): the name in prose through `shown`, and in a command to copy only as a plain word.
2272
+ const nm = forTerminal(repo.name);
2273
+ const arg = asArg(repo.name);
1917
2274
  if (!r.map) {
1918
- check(checks, id, 'risk-map', 'ok', `${repo.name}: no ${RISK_MAP_FILE} yet — no directory has a risk level (\`yad risk-map draft ${repo.name}\` starts one)`);
2275
+ check(checks, id, 'risk-map', 'ok', `${nm}: no ${RISK_MAP_FILE} yet — no directory has a risk level (\`yad risk-map draft ${arg}\` starts one)`);
1919
2276
  continue;
1920
2277
  }
1921
- if (!r.findings.length) { check(checks, id, 'risk-map', 'ok', `${repo.name}: every directory has a confirmed level`); continue; }
2278
+ if (!r.findings.length) { check(checks, id, 'risk-map', 'ok', `${nm}: every directory has a confirmed level`); continue; }
1922
2279
  const groups = [];
1923
2280
  for (const f of r.findings) {
1924
2281
  let g = groups.find((x) => x.code === f.code);
1925
2282
  if (!g) { g = { code: f.code, targets: [] }; groups.push(g); }
1926
- if (f.target) g.targets.push(f.target);
2283
+ if (f.target) g.targets.push(forTerminal(f.target)); // a folder name from the repo's own tree (E81 review 13)
1927
2284
  }
1928
2285
  const said = groups.map((g) => {
1929
2286
  const shown = g.targets.slice(0, 3).join(', ') + (g.targets.length > 3 ? ` +${g.targets.length - 3} more` : '');
1930
2287
  return `${g.code}${shown ? ` (${shown})` : ''}`;
1931
2288
  }).join('; ');
1932
- check(checks, id, 'risk-map', 'warn', `${repo.name}: ${RISK_MAP_FILE} is out of date — ${said}`,
1933
- `\`yad risk-map check ${repo.name}\` lists each one; fix the map in ${repo.name} through a PR (advisory — it blocks nothing)`,
2289
+ check(checks, id, 'risk-map', 'warn', `${nm}: ${RISK_MAP_FILE} is out of date — ${said}`,
2290
+ `\`yad risk-map check ${arg}\` lists each one; fix the map in ${nm} through a PR (advisory — it blocks nothing)`,
1934
2291
  { findings: r.findings });
1935
2292
  }
1936
2293
  }
@@ -1946,15 +2303,16 @@ export function codeownersChecks(checks, root) {
1946
2303
  for (const repo of repos) {
1947
2304
  if (!repo || typeof repo.name !== 'string' || typeof repo.path !== 'string' || !repo.path) continue;
1948
2305
  const repoRoot = path.resolve(root, repo.path);
1949
- if (!exists(repoRoot) || !gitHead(repoRoot)) continue;
2306
+ if (!isCheckout(root, repo) || !gitHead(repoRoot)) continue;
1950
2307
  const r = checkCodeowners(repoRoot, { platform: repo.platform || null });
1951
2308
  if (!r.git) continue;
1952
2309
  const id = `codeowners:${repo.name}`;
1953
- const more = `\`yad codeowners check ${repo.name}\``;
1954
- if (r.unknown) { check(checks, id, 'codeowners', 'warn', `${repo.name}: CODEOWNERS could not be checked — ${r.unknown}`, more); continue; }
1955
- if (r.none) { check(checks, id, 'codeowners', 'ok', `${repo.name}: no CODEOWNERS — nothing to check`); continue; }
2310
+ const nm = forTerminal(repo.name); // printed as in riskMapChecks (E81 review 11)
2311
+ const more = `\`yad codeowners check ${asArg(repo.name)}\``;
2312
+ if (r.unknown) { check(checks, id, 'codeowners', 'warn', `${nm}: CODEOWNERS could not be checked — ${forTerminal(r.unknown)}`, more); continue; }
2313
+ if (r.none) { check(checks, id, 'codeowners', 'ok', `${nm}: no CODEOWNERS — nothing to check`); continue; }
1956
2314
  const findings = codeownersFindings(r);
1957
- if (!findings.length) { check(checks, id, 'codeowners', 'ok', `${repo.name}: every ${r.path} line yad can read matches a file`); continue; }
2315
+ if (!findings.length) { check(checks, id, 'codeowners', 'ok', `${nm}: every ${r.path} line yad can read matches a file`); continue; }
1958
2316
  const lines = (code) => findings.filter((f) => f.code === code).map((f) => f.line);
1959
2317
  const listed = (ns) => ns.slice(0, 3).join(', ') + (ns.length > 3 ? ` +${ns.length - 3} more` : '');
1960
2318
  const said = [];
@@ -1964,13 +2322,13 @@ export function codeownersChecks(checks, root) {
1964
2322
  if (dead.length) said.push(`in ${r.path}, ${dead.length === 1 ? '1 line matches' : `${dead.length} lines match`} no file (line${dead.length === 1 ? '' : 's'} ${listed(dead)})`);
1965
2323
  const unread = lines('not-read');
1966
2324
  if (unread.length) said.push(`in ${r.path}, ${unread.length === 1 ? '1 line' : `${unread.length} lines`} yad could not read (line${unread.length === 1 ? '' : 's'} ${listed(unread)})`);
1967
- check(checks, id, 'codeowners', 'warn', `${repo.name}: CODEOWNERS may be out of date — ${said.join('; ')}`,
1968
- `${more} lists each one; fix it in ${repo.name} through a PR (advisory — CODEOWNERS is a hint, and yad never enforces it)`,
2325
+ check(checks, id, 'codeowners', 'warn', `${nm}: CODEOWNERS may be out of date — ${said.join('; ')}`,
2326
+ `${more} lists each one; fix it in ${nm} through a PR (advisory — CODEOWNERS is a hint, and yad never enforces it)`,
1969
2327
  { findings });
1970
2328
  }
1971
2329
  }
1972
2330
 
1973
- // E70 — what the platform holds on the Product hub's branch and on each connected repo's: branch
2331
+ // E70 — what the platform holds on the Product's branch and on each connected repo's: branch
1974
2332
  // protection, and whether a rule requires an approval before a merge (cli/protection.mjs). Never quiet
1975
2333
  // (rule 6): every repo gets a line, and a read that failed says "not known" and why — never "fine", never
1976
2334
  // "unprotected". Advisory, like E69: a warning at most, never a failure — the platform holds a merge, not
@@ -1979,8 +2337,8 @@ export function codeownersChecks(checks, root) {
1979
2337
  // the branch gates merge into; with none, the platform's own default is read and the line says so.
1980
2338
  export function protectionChecks(checks, root, { runner, env } = {}) {
1981
2339
  const productPath = productConfigPath(root);
1982
- const hub = readJSON(productPath, null);
1983
- const solo = isSolo(hub);
2340
+ const productConfig = readJSON(productPath, null);
2341
+ const solo = isSolo(productConfig);
1984
2342
  // One login check per host for this run, and never longer: a login can change between runs.
1985
2343
  const opts = { ...(runner ? { runner } : {}), ...(env ? { env } : {}), authCache: new Map() };
1986
2344
  const origin = (cwd) => run('git', ['remote', 'get-url', 'origin'], { cwd }).stdout || null;
@@ -1989,12 +2347,12 @@ export function protectionChecks(checks, root, { runner, env } = {}) {
1989
2347
  const line = protectionLine(r, { name, solo });
1990
2348
  check(checks, id, 'protection', line.status, line.message, line.hint || '', { protection: protectionJSON(r), alwaysHint: true });
1991
2349
  };
1992
- if (isPlainObject(hub)) {
1993
- // `protection` alone, never `protection:hub`: a connected repo may be named `hub`.
1994
- emit('protection', 'Product hub', {
1995
- platform: hub.platform || null,
1996
- gitUrl: hub.git_url || origin(root),
1997
- branch: typeof hub.default_branch === 'string' && hub.default_branch ? hub.default_branch : null,
2350
+ if (isPlainObject(productConfig)) {
2351
+ // `protection` alone, never `protection:product`: a connected repo may be named `product`.
2352
+ emit('protection', 'Product', {
2353
+ platform: productConfig.platform || null,
2354
+ gitUrl: productConfig.git_url || origin(root),
2355
+ branch: typeof productConfig.default_branch === 'string' && productConfig.default_branch ? productConfig.default_branch : null,
1998
2356
  });
1999
2357
  }
2000
2358
  const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
@@ -2002,10 +2360,10 @@ export function protectionChecks(checks, root, { runner, env } = {}) {
2002
2360
  for (const [i, repo] of repos.entries()) {
2003
2361
  if (!repo || typeof repo.name !== 'string' || !repo.name) continue;
2004
2362
  const repoRoot = typeof repo.path === 'string' && repo.path ? path.resolve(root, repo.path) : null;
2005
- const onDisk = repoRoot && exists(repoRoot) && gitHead(repoRoot);
2363
+ const onDisk = repoRoot && isCheckout(root, repo) && gitHead(repoRoot);
2006
2364
  // A name with an `@` is hidden in the id too; its place in repos.json keeps the id one word. Nothing
2007
2365
  // validates a repo's name, so a repo literally named `#1` could take the same id — it only labels a line.
2008
- emit(hideAddresses(repo.name) === repo.name ? `protection:${repo.name}` : `protection:#${i + 1}`, repo.name, {
2366
+ emit(hideAddresses(repo.name) === repo.name ? `protection:${repo.name}` : `protection:#${i + 1}`, forTerminal(repo.name), {
2009
2367
  platform: repo.platform || null,
2010
2368
  gitUrl: (typeof repo.git_url === 'string' && repo.git_url) || (onDisk ? origin(repoRoot) : null),
2011
2369
  branch: typeof repo.default_branch === 'string' && repo.default_branch ? repo.default_branch : null,
@@ -2052,8 +2410,8 @@ export function indexChecks(checks, root) {
2052
2410
  if (!exists(productConfigPath(root))) return;
2053
2411
  const fresh = indexFreshness(root);
2054
2412
  if (fresh.state === 'none') return; // no work items and no index: nothing to be behind
2055
- const hubNow = readJSON(productConfigPath(root), null);
2056
- const verified = isVerifiedLedger(hubNow);
2413
+ const productConfigNow = readJSON(productConfigPath(root), null);
2414
+ const verified = isVerifiedLedger(productConfigNow);
2057
2415
  const hint = verified
2058
2416
  ? 'CI rebuilds it when it records the next merged review; on a verified Product a local write could not be committed'
2059
2417
  : 'run `yad index` on the default branch, then commit it';
@@ -2067,9 +2425,9 @@ export function indexChecks(checks, root) {
2067
2425
  if (fresh.state !== 'unreadable' && exists(path.join(root, '.git'))) {
2068
2426
  const git = productGit(root);
2069
2427
  const head = git('rev-parse', '--abbrev-ref', 'HEAD');
2070
- const main = resolveDefaultBranch(git, hubNow);
2428
+ const main = resolveDefaultBranch(git, productConfigNow);
2071
2429
  if (head.ok && head.stdout && head.stdout !== main) {
2072
- check(checks, 'index', 'index', 'ok', `${INDEX_FILE} ${fresh.state === 'missing' ? 'is not built yet' : 'is not what this yadflow builds from the work items on this branch'} — expected on '${head.stdout}': it is written on '${main}' only, once this work merges`);
2430
+ check(checks, 'index', 'index', 'ok', `${INDEX_FILE} ${fresh.state === 'missing' ? 'is not built yet' : 'is not what this yadflow builds from the work items on this branch'} — expected on '${head.stdout}': it is written on '${forTerminal(main)}' only, once this work merges`);
2073
2431
  return;
2074
2432
  }
2075
2433
  }
@@ -2110,6 +2468,8 @@ export function collectDoctor(root, { headCount = null } = {}) {
2110
2468
  stepStateChecks(checks, root);
2111
2469
  ownerChecks(checks, root);
2112
2470
  ownerGuardChecks(checks, root);
2471
+ renamedChecks(checks, root);
2472
+ productProfileChecks(checks, root);
2113
2473
  phaseChecks(checks, root);
2114
2474
  laneChecks(checks, root);
2115
2475
  epicChecks(checks, root);
@@ -2130,13 +2490,18 @@ export async function runDoctor(root, { json = false, headCount = null } = {}) {
2130
2490
  } else {
2131
2491
  log(c.bold(`\nyad doctor ${c.dim('v' + VERSION)}`));
2132
2492
  let section = '';
2493
+ // E48: the `protection` section ends with where to read how to set protection up, once, when any of
2494
+ // its lines warns. Text only — `--json` keeps its shape.
2495
+ const protectionWarns = checks.some((x) => x.section === 'protection' && x.status === 'warn');
2496
+ const guide = () => { if (section === 'protection' && protectionWarns) hand(`how to set branch protection up for yad: ${PROTECTION_GUIDE_URL}`); };
2133
2497
  for (const x of checks) {
2134
- if (x.section !== section) { section = x.section; log(`\n ${c.bold(section)}`); }
2498
+ if (x.section !== section) { guide(); section = x.section; log(`\n ${c.bold(section)}`); }
2135
2499
  ({ ok, warn, fail })[x.status](x.message);
2136
2500
  // A check may ask for its hint whatever its level (`alwaysHint`): an E70 `protection` line is `ok` in
2137
2501
  // solo mode even when the platform could not be read, and its hint is the fix ("run `gh auth login` …").
2138
2502
  if (x.hint && (x.status !== 'ok' || x.alwaysHint)) hand(x.hint);
2139
2503
  }
2504
+ guide();
2140
2505
  log('');
2141
2506
  if (failed.length) fail(`${failed.length} problem(s) found`);
2142
2507
  else if (warned.length) info(`healthy with ${warned.length} warning(s)`);
@@ -2146,6 +2511,20 @@ export async function runDoctor(root, { json = false, headCount = null } = {}) {
2146
2511
  return { ok: failed.length === 0, failed: failed.length, warned: warned.length, checks };
2147
2512
  }
2148
2513
 
2514
+ // Line endings on Windows (E113). With `core.autocrlf=true` — the Git for Windows default — git checks
2515
+ // every text file out with CRLF. The managed files cope (they are compared line endings aside), but an
2516
+ // artifact's approval is bound to its exact bytes: a review approved on LF bytes reads as stale in a CRLF
2517
+ // checkout, and an approval recorded there binds bytes CI never sees. So on Windows it is named, with the
2518
+ // one setting that avoids it. Silent everywhere else, and when git cannot be asked.
2519
+ export function lineEndingChecks(root, checks, { platform = process.platform, runner = run } = {}) {
2520
+ if (platform !== 'win32') return;
2521
+ const r = runner('git', ['-C', root, 'config', '--get', 'core.autocrlf']);
2522
+ if (!r.ok || r.stdout.trim().toLowerCase() !== 'true') return;
2523
+ check(checks, 'line-endings', 'project', 'warn', 'git checks text out here with CRLF line endings (core.autocrlf=true) — an approval is bound to an artifact\'s exact bytes, so reviews approved elsewhere read as stale here',
2524
+ // Three separate commands, not joined with `&&`: Windows PowerShell 5.1, the default shell there, has no `&&`.
2525
+ 'commit or stash your work first, then run `git config core.autocrlf input`, then `git rm --cached -r . -q`, then `git reset --hard` — the files come back with LF. `git config --global core.autocrlf input` does the same for every future clone');
2526
+ }
2527
+
2149
2528
  // The capture half of `yad doctor` (E43). Three facts: whether capture is on, whether every IDE target with
2150
2529
  // a post-edit protocol runs it, and which of the team's OWN GitHub workflows start on a push to any branch —
2151
2530
  // those run on every `yad/wip/*` push. yadflow's own templates exclude the branches; a team's file is theirs,
@@ -2156,7 +2535,7 @@ export function captureChecks(root, checks, cfg) {
2156
2535
  return;
2157
2536
  }
2158
2537
  const unwired = [];
2159
- if (!hookScriptReady(root, 'hooks/yad-capture.sh')) unwired.push('hooks/yad-capture.sh');
2538
+ for (const w of CAPTURE_WIRING) if (!hookScriptReady(root, w.dest)) unwired.push(w.dest);
2160
2539
  const targets = ideTargetsFor(root);
2161
2540
  const safe = new Set(safeIdeTargetStateFor(root, targets).targets);
2162
2541
  const noProtocol = [];
@@ -2180,7 +2559,7 @@ export function captureChecks(root, checks, cfg) {
2180
2559
  // could never be cleared by doing what it asks, so it is said as a fact.
2181
2560
  check(checks, 'capture', 'project', 'ok', `wip capture is by hand here — no IDE target (${noProtocol.join(', ')}) has a post-edit hook yad wires; run \`yad capture\` after editing`);
2182
2561
  } else {
2183
- check(checks, 'capture', 'project', 'ok', `wip capture wired (hooks/yad-capture.sh)${byHand}`);
2562
+ check(checks, 'capture', 'project', 'ok', `wip capture wired (${CAPTURE_WIRING.map((w) => w.dest).join(', ')})${byHand}`);
2184
2563
  }
2185
2564
  const noisy = pushOnEveryBranch(root);
2186
2565
  if (noisy.length) {
@@ -2274,3 +2653,40 @@ export function pushOnEveryBranch(root) {
2274
2653
  }
2275
2654
  return out;
2276
2655
  }
2656
+
2657
+ // The workspace file (E80): lets yad find this Product from its code repos. Only for a Product whose
2658
+ // registered repos live beside it — per machine, so a teammate who cloned by hand has none.
2659
+ function workspaceFileCheck(root, checks) {
2660
+ if (!hasSiblingRepo(root)) return;
2661
+ const st = workspaceFileState(root);
2662
+ if (st === 'ok') check(checks, 'workspace-file', 'project', 'ok', `${WORKSPACE_FILE} beside the Product — yad finds it from the code repos`);
2663
+ else if (st === 'missing') check(checks, 'workspace-file', 'project', 'warn', `no ${WORKSPACE_FILE} beside the Product — run from a code repo, yad does not find the Product`, 'run `yad check --fix` (it writes the file on this machine only)');
2664
+ else check(checks, 'workspace-file', 'project', 'warn', `${WORKSPACE_FILE} not usable: ${st.replace(/^other: /, '')}`, 'run yad from the Product, or pass --dir <the Product>');
2665
+ }
2666
+
2667
+ // The person's half of the ledger guard (E48): this clone's git pre-commit hook. Per clone, because git
2668
+ // never commits `.git/hooks` — so a teammate who pulled a verified Product has none until their own
2669
+ // `yad check --fix`, and this line is how they find out.
2670
+ function gitHookCheck(root, checks) {
2671
+ const st = gitHookState(root);
2672
+ if (!st.applies) return;
2673
+ const where = path.relative(root, st.file).split(path.sep).join('/');
2674
+ if (st.state === 'hooks-path' || st.state === 'foreign') {
2675
+ // yad writes neither: a folder core.hooksPath names is usually committed and owned by a tool, and a
2676
+ // hook without yad's marker line is the team's. The person adds one line instead.
2677
+ const why = st.state === 'hooks-path' ? `core.hooksPath is set (${st.hooksPath}), so hooks are run from there` : `${where} is not yad's, so yad left it alone`;
2678
+ const into = st.state === 'hooks-path' ? st.editFile : `${where} (and again if a tool rewrites it)`;
2679
+ check(checks, 'git-hook', 'project', 'warn', `git pre-commit ledger guard not installed: ${why}`, `add this line to ${into}: ${st.line}`);
2680
+ } else if (st.state === 'missing') {
2681
+ check(checks, 'git-hook', 'project', 'warn', 'git pre-commit ledger guard not installed in this clone',
2682
+ 'run `yad check --fix` — until then a hand `git commit` that changes the CI-owned ledger is refused only by CI, after the push');
2683
+ } else if (st.state === 'outdated' && st.why === 'not-executable') {
2684
+ check(checks, 'git-hook', 'project', 'warn', `git pre-commit ledger guard installed but not executable (${where}) — git skips it`,
2685
+ 'run `yad check --fix`, which restores the execute bit');
2686
+ } else if (st.state === 'outdated') {
2687
+ check(checks, 'git-hook', 'project', 'warn', `git pre-commit ledger guard out of date (${where})`,
2688
+ 'run `yad check --fix` — it saves the current file beside it before replacing it');
2689
+ } else {
2690
+ check(checks, 'git-hook', 'project', 'ok', `git pre-commit ledger guard installed in this clone (${where})`);
2691
+ }
2692
+ }