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/platform.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // Platform adapter — the ONLY place that shells out to gh/glab. Read recipes mirror
2
- // skills/yad-hub-bridge/references/bridge.md. Everything runs as the local user (gh/glab own auth);
2
+ // skills/yad-product-bridge/references/bridge.md. Everything runs as the local user (gh/glab own auth);
3
3
  // no tokens are stored. Pure mapping fns (resolveLogin/mapApprovers) are exported for unit tests;
4
4
  // readPr is injectable so the gate can be tested with a fake.
5
5
  import { URLSearchParams } from 'node:url';
@@ -22,6 +22,12 @@ export function cliFor(platform) {
22
22
  // Bare host from a git remote URL, for hostname-scoped CLI auth checks. Handles both the
23
23
  // `https://[user@]host[:port]/...` and the scp-like `git@host:group/repo.git` forms. Returns
24
24
  // null when nothing parses (caller falls back to an unscoped check).
25
+ // A host yad may use in a CLI call or print — inside `gh auth login --hostname <host>`, say — or null. The
26
+ // host comes from shared files (hub.json, repos.json) or a clone's remote, and `hostFromGitUrl` passes
27
+ // `$(curl …|sh)` or an escape through, so a host must be a plain host name or a bracketed IPv6 address.
28
+ // ONE test for every caller (E81 reviews 13–14: the second copy was missed once).
29
+ export const plainHost = (h) => (typeof h === 'string' && /^(?:\w[\w-]*(?:\.[\w-]+)*|\[[0-9a-f:.]+\])$/i.test(h) ? h : null);
30
+
25
31
  export function hostFromGitUrl(url = '') {
26
32
  if (typeof url !== 'string' || !url.trim()) return null;
27
33
  const u = url.trim();
@@ -97,9 +103,9 @@ export function actorName(cwd, platform, opts = {}) {
97
103
  // logins that share it. An older record under such a
98
104
  // name could be either person, so it is never matched by name — only as `upsertBridge` matches a record
99
105
  // no name can place (an exact submission time, or an open step's one-to-one).
100
- export function ambiguousLegacyNames(hub) {
106
+ export function ambiguousLegacyNames(productConfig) {
101
107
  const logins = new Map();
102
- for (const e of Array.isArray(hub?.roster) ? hub.roster : []) {
108
+ for (const e of Array.isArray(productConfig?.roster) ? productConfig.roster : []) {
103
109
  if (!e || typeof e.name !== 'string' || !e.name || typeof e.login !== 'string' || !e.login) continue;
104
110
  if (!logins.has(e.name)) logins.set(e.name, new Set());
105
111
  logins.get(e.name).add(e.login);
@@ -397,9 +403,9 @@ export function platformDefaultBranch(platform, { cwd, runner = run } = {}) {
397
403
  // though only this chain has a platform rung — they stop at the local `origin/HEAD`:
398
404
  // 1 flag — an explicit --base; the human said so
399
405
  // 2 registry — the repo's `default_branch` in .sdlc/repos.json
400
- // 3 hub — hub.json's `default_branch`, for a PR against the Product itself
406
+ // 3 product — the Product settings' `default_branch`, for a PR against the Product itself
401
407
  // 4 platform — what the remote says (see platformDefaultBranch)
402
- // 5 origin-head — local `refs/remotes/origin/HEAD`, the same read repo.mjs/hubcommit.mjs use.
408
+ // 5 origin-head — local `refs/remotes/origin/HEAD`, the same read repo.mjs/productcommit.mjs use.
403
409
  // Deliberately NOT `ls-remote`: see branchExists above for why a network probe on
404
410
  // this path is a hang hazard.
405
411
  // 6 fallback — 'main'
@@ -415,7 +421,7 @@ export function platformDefaultBranch(platform, { cwd, runner = run } = {}) {
415
421
  // and an early rung winning, `platformDefault` is null because it was never asked, not because the
416
422
  // platform had no answer.
417
423
  export function resolveBaseBranch(platform, {
418
- cwd, explicit = null, meta = null, hub = null, runner = run, probe = true,
424
+ cwd, explicit = null, meta = null, productConfig = null, runner = run, probe = true,
419
425
  } = {}) {
420
426
  let platformDefault = null;
421
427
  let asked = false;
@@ -434,7 +440,7 @@ export function resolveBaseBranch(platform, {
434
440
  const chain = [
435
441
  ['flag', explicit],
436
442
  ['registry', meta?.default_branch],
437
- ['hub', hub?.default_branch],
443
+ ['product', productConfig?.default_branch],
438
444
  ['platform', askPlatform],
439
445
  ['origin-head', originHead],
440
446
  ];
@@ -24,7 +24,7 @@ import { createHash } from 'node:crypto';
24
24
  import { spawnSync } from 'node:child_process';
25
25
  import { isPlainObject, writeJSON, info, warn } from './lib.mjs';
26
26
  import { isVerifiedLedger } from './manifest.mjs';
27
- import { productGit, resolveDefaultBranch } from './hubcommit.mjs';
27
+ import { productGit, resolveDefaultBranch } from './productcommit.mjs';
28
28
  import {
29
29
  epicIds, epicRel, epicRoot, unlistedLedgerDirs, parseFrontmatter, lineageFrom, stepStatus, STEP_STATES,
30
30
  FOUNDATION_EPIC, FOUNDATION_TITLE, titleOf,
@@ -270,11 +270,11 @@ export function uncommittedIndexInputs(root, commits = null) {
270
270
  // The index is derived: a failure to rebuild it is said, and never stops the gate write it follows.
271
271
  // Returns whether the file changed, so a caller that commits can carry it in the same commit. `quiet`
272
272
  // for a caller whose stdout is JSON: the warning still goes to stderr.
273
- export function refreshIndexAfterWrite(root, hub, { quiet = false } = {}) {
274
- if (isVerifiedLedger(hub) || !fs.existsSync(path.join(root, '.git'))) return false;
273
+ export function refreshIndexAfterWrite(root, productConfig, { quiet = false } = {}) {
274
+ if (isVerifiedLedger(productConfig) || !fs.existsSync(path.join(root, '.git'))) return false;
275
275
  const git = productGit(root);
276
276
  const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
277
- if (!branch || branch !== resolveDefaultBranch(git, hub)) return false;
277
+ if (!branch || branch !== resolveDefaultBranch(git, productConfig)) return false;
278
278
  try {
279
279
  const changed = writeIndex(root);
280
280
  if (changed && !quiet) info(`rebuilt ${INDEX_FILE}`);
@@ -2,7 +2,8 @@
2
2
  // `yad checkpoint` (sync new state) and `yad tidy up` (fold finished shards). Both must commit ONLY on
3
3
  // the default branch, so their `[skip ci]` commit never enters a PR's base..HEAD range (where it would
4
4
  // strand required checks and fail verified-commits). This module is the single home of that guard.
5
- import { warn, fail, hand, run } from './lib.mjs';
5
+ import { warn, fail, hand, run, forTerminal } from './lib.mjs';
6
+ import { SETTINGS_EDIT_HINT, settingsEditHint } from './manifest.mjs';
6
7
 
7
8
  export const productGit = (root) => (...args) => run('git', args, { cwd: root });
8
9
 
@@ -22,9 +23,9 @@ export function preflightGuardReadiness(root, why = 'the yad-update-guard requir
22
23
  // NEVER the current branch — falling back to it (as gate.mjs does, safe there because CI checks out the
23
24
  // default branch) would make the guard below a no-op on a WIP branch and let an unsigned commit land in
24
25
  // a future PR's range.
25
- export function resolveDefaultBranch(git, hub) {
26
+ export function resolveDefaultBranch(git, productConfig) {
26
27
  const originHead = git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD'); // e.g. "origin/main"
27
- return hub?.default_branch
28
+ return productConfig?.default_branch
28
29
  || (originHead.ok && originHead.stdout ? originHead.stdout.replace(/^origin\//, '') : '')
29
30
  || 'main';
30
31
  }
@@ -32,14 +33,17 @@ export function resolveDefaultBranch(git, hub) {
32
33
  // Guard: only commit on the default branch. Returns true when OK; on a non-default branch it prints the
33
34
  // refusal + sets a non-zero exit code, unless `allowBranch` overrides (with a warning). `--allow-branch`
34
35
  // is the SINGLE documented override — never `--force` (which elsewhere only waives the atomic guard).
35
- export function guardDefaultBranch(branch, defaultBranch, { allowBranch = false, cmd = 'yad checkpoint' } = {}) {
36
+ // `root` (the Product) lets the hint name the exact settings file to edit; without it the hint says it in words.
37
+ export function guardDefaultBranch(branch, defaultBranch, { allowBranch = false, cmd = 'yad checkpoint', root = null } = {}) {
36
38
  if (branch === defaultBranch) return true;
39
+ // Printed cleaned (E81 review 13): `default_branch` comes from the Product's shared hub.json.
40
+ const [b, d] = [forTerminal(branch), forTerminal(defaultBranch)];
37
41
  if (allowBranch) {
38
- warn(`--allow-branch: committing on '${branch}' — pushes go to origin/${branch}, and this commit needs a verified signature to pass the gate in a PR`);
42
+ warn(`--allow-branch: committing on '${b}' — pushes go to origin/${b}, and this commit needs a verified signature to pass the gate in a PR`);
39
43
  return true;
40
44
  }
41
- fail(`on '${branch}', not the default branch '${defaultBranch}' — ${cmd} commits go to the default branch to stay out of PR-checked ranges`);
42
- hand(`switch to '${defaultBranch}' and re-run, or pass --allow-branch to override (set default_branch in .sdlc/hub.json if '${defaultBranch}' is wrong)`);
45
+ fail(`on '${b}', not the default branch '${d}' — ${cmd} commits go to the default branch to stay out of PR-checked ranges`);
46
+ hand(`switch to '${d}' and re-run, or pass --allow-branch to override (if '${d}' is wrong, set default_branch in ${root ? settingsEditHint(root) : SETTINGS_EDIT_HINT})`);
43
47
  process.exitCode = 1;
44
48
  return false;
45
49
  }
@@ -23,15 +23,18 @@
23
23
  // leaves the machine for anywhere else). `YAD_PLATFORM_READ=0` turns the reads off — for offline work, and
24
24
  // for the test suite, which must never ask the developer's real account (the `YAD_PLATFORM_LOGIN=0`
25
25
  // precedent). `runner` is injectable so every answer is testable without a network.
26
- import { run } from './lib.mjs';
27
- import { cliFor, hostFromGitUrl, detectPlatform } from './platform.mjs';
26
+ import { run, forTerminal } from './lib.mjs';
27
+ import { SETTINGS_EDIT_HINT } from './manifest.mjs';
28
+ import { cliFor, hostFromGitUrl, detectPlatform, plainHost } from './platform.mjs';
28
29
 
29
30
  export const PLATFORM_NAME = { github: 'GitHub', gitlab: 'GitLab' };
30
31
  const TIMEOUT = 10_000;
31
32
 
32
33
  // A word with an `@` after its first character may be an e-mail address, and no address is ever printed
33
34
  // (E67). A branch or a repo path is printed as written otherwise.
34
- export const shown = (w) => (typeof w === 'string' && w.indexOf('@', 1) >= 0 ? 'a name with an @ in it' : `${w}`);
35
+ // Also cleaned for the terminal (E81 review 12): a branch or repo from the shared registry could hold a
36
+ // control character, and every line here prints through this.
37
+ export const shown = (w) => (typeof w === 'string' && w.indexOf('@', 1) >= 0 ? 'a name with an @ in it' : forTerminal(`${w}`));
35
38
 
36
39
  // The repo's path on its host (`owner/repo`, or `group/sub/project` on GitLab), from a git remote URL in
37
40
  // either form. Not the `repoSlug` the doctor used to carry, which kept only the last two parts and so named
@@ -165,7 +168,11 @@ function loggedIn(runner, cli, host, authCache) {
165
168
  // a GitLab answer carries no such key)
166
169
  // `kind` names the reason for a hint, so the hint never depends on the words of `why`.
167
170
  export function readProtection({ platform, gitUrl, branch = null, branchFrom = 'registry' } = {}, { runner = run, env = process.env, authCache = new Map() } = {}) {
168
- const host = hostFromGitUrl(gitUrl || '');
171
+ // A host that is not a plain host name is never used or repeated (E81 review 13): it comes from the
172
+ // shared registry's `git_url` (or a registered clone's own remote), and it is printed inside
173
+ // `gh auth login --hostname …` for the person to run — `$(curl …|sh)` parses as a host.
174
+ const rawHost = hostFromGitUrl(gitUrl || '');
175
+ const host = plainHost(rawHost);
169
176
  const repo = repoPathFromGitUrl(gitUrl || '');
170
177
  const plat = platform || detectPlatform(gitUrl || '') || null;
171
178
  const base = { platform: plat, host, repo, branch, branchFrom, platformDefault: null };
@@ -173,10 +180,11 @@ export function readProtection({ platform, gitUrl, branch = null, branchFrom = '
173
180
  const cli = cliFor(plat);
174
181
  if (!cli) {
175
182
  return platform
176
- ? unknown('no-platform', `yad does not know the platform ${JSON.stringify(platform)} (it reads GitHub and GitLab)`)
183
+ ? unknown('no-platform', `yad does not know the platform ${JSON.stringify(forTerminal(platform))} (it reads GitHub and GitLab)`)
177
184
  : unknown('no-platform', 'no platform (GitHub or GitLab) is set, so there is no platform to ask');
178
185
  }
179
186
  if (env.YAD_PLATFORM_READ === '0') return unknown('off', 'platform reads are turned off (YAD_PLATFORM_READ=0)');
187
+ if (rawHost && !host) return unknown('no-url', 'the git remote URL names a host that is not a plain host name, so yad does not ask about it', { badHost: true });
180
188
  if (!host || !repo) return unknown('no-url', 'no git remote URL yad can read, so it cannot tell which repo to ask about');
181
189
  if (!runner(process.platform === 'win32' ? 'where' : 'which', [cli], {}).ok) return unknown('no-cli', `${cli} is not installed, so yad cannot ask ${PLATFORM_NAME[plat]}`);
182
190
  if (!loggedIn(runner, cli, host, authCache)) return unknown('no-login', `${cli} is not logged in for ${host} (or ${host} did not answer)`);
@@ -440,7 +448,7 @@ function readGitLab(base, runner, unknown) {
440
448
  const named = typeof r.name === 'string' && r.name.trim() ? r.name.trim() : null;
441
449
  // A rule NAMED "42" and a rule with ID 42 must not read the same, so an id is printed as an id —
442
450
  // the shape the GitHub side already uses ("a repo ruleset (id 7)").
443
- if (named !== null) out.from.push(`approval rule ${JSON.stringify(named)}`);
451
+ if (named !== null) out.from.push(`approval rule ${JSON.stringify(forTerminal(named))}`);
444
452
  else out.from.push(count(r.id) !== null ? `an approval rule (id ${r.id})` : 'an approval rule');
445
453
  }
446
454
  // A rule the platform DID return that does not reach this branch: say which way it misses, so the
@@ -488,7 +496,7 @@ function readGitLab(base, runner, unknown) {
488
496
  }
489
497
 
490
498
  // The doctor line for one answer: { status: 'ok'|'warn', message, hint }. `name` is the repo's name in
491
- // yad (or "Product hub"). In solo mode (the user's decision) the facts print as `ok` with no banner — one
499
+ // yad (or "Product"). In solo mode (the user's decision) the facts print as `ok` with no banner — one
492
500
  // person cannot approve their own pull request, so "no approval rules" may be the right setup — except
493
501
  // the old solo warning: a required approval there blocks the solo developer's own merge.
494
502
  //
@@ -682,7 +690,9 @@ function unknownHint(r) {
682
690
  case 'no-cli': return `install ${cli} and log in, then run \`yad doctor\` again`;
683
691
  case 'no-login': return `run \`${cli} auth login --hostname ${r.host}\`, then \`yad doctor\` again`;
684
692
  case 'no-platform': return 'set `platform` (github or gitlab) in yad\'s files — `yad setup` for the Product, `yad repo connect` for a code repo';
685
- case 'no-url': return 'add `git_url` to the repo\'s entry in .sdlc/repos.json (.sdlc/product.json, or hub.json, for the Product), or give the repo an origin remote';
693
+ case 'no-url': return r.badHost
694
+ ? `fix \`git_url\` in the repo's entry in .sdlc/repos.json (for the Product, ${SETTINGS_EDIT_HINT}) — or its origin remote — to name a plain host`
695
+ : `add \`git_url\` to the repo's entry in .sdlc/repos.json (for the Product, ${SETTINGS_EDIT_HINT}), or give the repo an origin remote`;
686
696
  // The access tail turns on what the read PROVED (`cause`), never on the platform: a 404 whose body was
687
697
  // not recognised leaves a permission open, and one that named the branch closes it (E109).
688
698
  // The tail's noun is GitLab's: GitHub never reaches it, because its reader sets `cause: 'branch'` on
@@ -697,7 +707,7 @@ function unknownHint(r) {
697
707
  case 'no-flag': return `ask someone who can see ${PLATFORM_NAME[r.platform] || 'the platform'}'s settings for ${shown(r.branch) === r.branch ? `\`${r.branch}\`` : shown(r.branch)}`;
698
708
  // One action per open cause (E110): yad's files name no branch, and on GitLab the repository may be
699
709
  // unreadable — turned off (an Owner turns it on) or too little access (a Maintainer or Owner grants it).
700
- case 'no-default': return `set \`default_branch\` in yad's files (.sdlc/repos.json, or .sdlc/product.json — hub.json on an older Product)${r.defaultMayBeHidden
710
+ case 'no-default': return `set \`default_branch\` in yad's files (.sdlc/repos.json, or for the Product ${SETTINGS_EDIT_HINT})${r.defaultMayBeHidden
701
711
  ? `. GitLab also hides the default from a login that cannot read the repository: ${REPO_ACTIONS} — then run \`yad doctor\` again`
702
712
  : ''}`;
703
713
  case 'no-repo': return `check \`git_url\` in yad's files, or ask for access to the ${r.platform === 'gitlab' ? 'project' : 'repo'}`;
package/cli/reconcile.mjs CHANGED
@@ -4,25 +4,31 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import {
7
- c, log, ok, info, warn, hand, readJSON, writeJSON, exists,
7
+ c, log, ok, info, warn, hand, readJSON, writeJSON, exists, forTerminal,
8
8
  } from './lib.mjs';
9
9
 
10
10
  const readFileSafe = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } };
11
11
 
12
- import { preflightGuardReadiness } from './hubcommit.mjs';
13
- import { VERSION, PROJECT_FILES, MANAGED_LEDGER, BACKUP_SUFFIX , productConfigPath } from './manifest.mjs';
12
+ import { preflightGuardReadiness } from './productcommit.mjs';
13
+ import { VERSION, PROJECT_FILES, MANAGED_LEDGER, BACKUP_SUFFIX , productConfigPath, PRODUCT_LINK } from './manifest.mjs';
14
14
  import {
15
15
  moduleActions, repoActions, productActions, hookActions,
16
- legacyModuleActions, removedModuleActions, orphanHookActions, captureHookActions, orphanCaptureHookActions, legacyRepoActions, legacyHubActions,
17
- ideTargetStateFor, recordManagedWrites,
16
+ legacyModuleActions, removedModuleActions, orphanHookActions, captureHookActions, orphanCaptureHookActions, legacyHookScriptActions, legacyRepoActions, legacyProductActions,
17
+ ideTargetStateFor, recordManagedWrites, gitHookActions, orphanGitHookActions, gitHookState, gitHookAdvice, renamedNameHits, withoutKeptRenames, PRODUCT_PROFILE_GATES, productProfileGap, productProfileEffect, PRODUCT_PROFILE_FIX, workflowsPassingProduct, productGateBlockers, oldProfileAdvice,
18
18
  } from './plan.mjs';
19
19
  import { gitHead, packRepo } from './setup.mjs';
20
20
  import { groupByRoot, commitUpdates, repoLabel } from './update-commit.mjs';
21
+ import { hasSiblingRepo, workspaceFileState, writeWorkspaceFile, WORKSPACE_FILE } from './find-product.mjs';
21
22
 
22
23
  const MARK = { missing: c.red('missing'), new: c.cyan('new'), outdated: c.yellow('outdated'), modified: c.cyan('modified'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), removed: c.yellow('removed'), ok: c.green('ok') };
23
24
 
24
25
  // The --json answer's list (E1): every managed item, as the report groups it — never the apply step.
25
- const itemsOf = (actions) => actions.map((a) => ({ scope: a.scope, item: a.item, status: a.status }));
26
+ // `product` says whose item it is: false for a connected repo's, true for the Product's own (E124 review 1).
27
+ // The scope alone cannot say it — a repo may be named `product`, the Product's own scope.
28
+ // A repo named `product` shares the Product's scope; in printed text it is shown with its path (E124 review 2).
29
+ const repoShown = (repo) => `product (the code repo at ${forTerminal(repo.path)})`;
30
+ const shown = (a) => a.shownScope ?? a.scope;
31
+ const itemsOf = (actions) => actions.map((a) => ({ scope: a.scope, item: a.item, status: a.status, product: !a.fromRepo }));
26
32
 
27
33
  export async function reconcile(root, { fix = false, scope = 'all', force = false, push = false, allowBranch = false, overwriteLocal = false } = {}) {
28
34
  log(c.bold(`\nSDLC reconcile ${c.dim('v' + VERSION)}`));
@@ -31,7 +37,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
31
37
  // --- missing one-time setup (needs the interactive wizard) ---
32
38
  const gaps = [];
33
39
  if (!exists(path.join(root, PROJECT_FILES.version))) gaps.push('module not installed (.sdlc/cli-version.json absent)');
34
- if (!exists(productConfigPath(root))) gaps.push('hub not configured (.sdlc/hub.json absent)');
40
+ if (!exists(productConfigPath(root))) gaps.push('Product not configured (.sdlc/product.json absent)');
35
41
  const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
36
42
  if (!exists(path.join(root, PROJECT_FILES.reposRegistry))) gaps.push('no repos registered (.sdlc/repos.json absent)');
37
43
 
@@ -51,14 +57,19 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
51
57
  // and purge of skills removed in a later release ('removed': delete the lingering install) ---
52
58
  const actions = [
53
59
  ...moduleActions(root, ideTargets), ...legacyModuleActions(root, ideTargets), ...removedModuleActions(root, ideTargets),
54
- ...productActions(root), ...legacyHubActions(root), ...hookActions(root, ideTargets),
60
+ // E123: a renamed CI file the team edited is kept, and its new name is not installed beside it.
61
+ ...withoutKeptRenames([...productActions(root), ...legacyProductActions(root)]), ...hookActions(root, ideTargets),
55
62
  ...orphanHookActions(root, ideTargets),
63
+ // E48: this clone's git pre-commit hook — the person's half of the ledger guard.
64
+ ...gitHookActions(root), ...orphanGitHookActions(root),
56
65
  // E43: the post-edit capture hook, in both ledger modes.
57
66
  ...captureHookActions(root, ideTargets), ...orphanCaptureHookActions(root, ideTargets),
67
+ // E113: the old shell-script hooks. LAST, so the entries above that named them are rewritten first.
68
+ ...legacyHookScriptActions(root, ideTargets),
58
69
  ];
59
70
  if (ideState.needsRepair) {
60
71
  actions.push({
61
- scope: 'hub',
72
+ scope: 'product',
62
73
  item: `${PROJECT_FILES.version} ideTargets`,
63
74
  status: 'outdated',
64
75
  root,
@@ -67,7 +78,12 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
67
78
  apply: () => undefined,
68
79
  });
69
80
  }
70
- for (const repo of registry.repos) actions.push(...repoActions(root, repo), ...legacyRepoActions(root, repo));
81
+ for (const repo of registry.repos) {
82
+ const own = withoutKeptRenames([...repoActions(root, repo), ...legacyRepoActions(root, repo)]);
83
+ // Set on the action itself: later steps compare actions by identity. `true`, not the name, which may be empty.
84
+ for (const a of own) { a.fromRepo = true; if (repo.name === 'product') a.shownScope = repoShown(repo); }
85
+ actions.push(...own);
86
+ }
71
87
 
72
88
  // --- stale code-context (HEAD moved since last pack) ---
73
89
  const staleRepos = [];
@@ -75,12 +91,14 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
75
91
  const head = gitHead(path.resolve(root, repo.path));
76
92
  if (head && repo.syncedHead && head !== repo.syncedHead) {
77
93
  staleRepos.push(repo);
78
- // packRepo writes the repomix cache under the HUB root (root/repo.contextPack, e.g.
94
+ // packRepo writes the repomix cache under the Product root (root/repo.contextPack, e.g.
79
95
  // .sdlc/code-context/<name>/pack.md), so the touched path belongs to the Product — and is commonly
80
96
  // gitignored, in which case the push stage's check-ignore drops it. codeMap is AI-generated
81
97
  // later, not here, so only the pack is claimed.
82
98
  actions.push({
83
99
  scope: repo.name,
100
+ fromRepo: true, // about the repo, though its file is under the Product
101
+ ...(repo.name === 'product' ? { shownScope: repoShown(repo) } : {}),
84
102
  item: 'code-context',
85
103
  status: 'stale',
86
104
  root,
@@ -93,8 +111,8 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
93
111
  // --- report, grouped by scope ---
94
112
  const byScope = new Map();
95
113
  for (const a of actions) {
96
- if (!byScope.has(a.scope)) byScope.set(a.scope, []);
97
- byScope.get(a.scope).push(a);
114
+ if (!byScope.has(shown(a))) byScope.set(shown(a), []);
115
+ byScope.get(shown(a)).push(a);
98
116
  }
99
117
  const counts = { missing: 0, new: 0, outdated: 0, modified: 0, stale: 0, legacy: 0, removed: 0, ok: 0 };
100
118
  for (const [scopeName, items] of byScope) {
@@ -105,6 +123,23 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
105
123
  for (const i of notOk) log(` ${MARK[i.status]} ${i.item}`);
106
124
  }
107
125
  for (const g of gaps) warn(g);
126
+ // E123: the team's own files that name one of our renamed CI names — each with its file and line, the same
127
+ // list `yad doctor` shows. yad never edits them; the include line it rewrites is said as what it is.
128
+ // After this run: a gate it replaces handles `product`; one it keeps (edited) may not (E124 review 3).
129
+ // No `drift` here on purpose: under drift the dispatcher refuses `yad check`/`update` before this runs.
130
+ const profileFirst = oldProfileAdvice(productGateBlockers(root, { fix, overwriteLocal }));
131
+ for (const h of renamedNameHits(root)) {
132
+ if (h.rewrittenBy === 'update' || (h.rewrittenBy === 'overwrite-local' && overwriteLocal)) {
133
+ info(`${h.file}:${h.line} includes the old fragment — ${fix ? 'rewritten' : `rewritten by ${h.rewrittenBy === 'update' ? '`yad check --fix`/`yad update`' : '`yad update --overwrite-local`'}`} to name ${h.new}`);
134
+ } else if (h.rewrittenBy === 'overwrite-local') {
135
+ info(`${h.file}:${h.line} includes the edited old fragment — \`yad update --overwrite-local\` rewrites it when it replaces that fragment; leave it until then`);
136
+ } else if (h.profile && profileFirst) {
137
+ warn(`${h.file}:${h.line} passes \`${h.old}\`, renamed \`${h.new}\` in 4.0 — change it by hand, ${profileFirst}`);
138
+ } else warn(`${h.file}:${h.line} names \`${h.old}\`, renamed \`${h.new}\` in 4.0 — yad does not edit this file; change it by hand`);
139
+ }
140
+ // A hook yad may not write (someone else's, or a hooks folder a tool manages) has no action; say so.
141
+ const gitHookNote = gitHookAdvice(gitHookState(root));
142
+ if (gitHookNote) warn(gitHookNote);
108
143
  const shownInvalid = ideState.invalid.map((v) => {
109
144
  try { return JSON.stringify(v) ?? String(v); } catch { return String(v); }
110
145
  });
@@ -135,7 +170,10 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
135
170
  // `--overwrite-local` replaces it, which still saves the previous content beside it.
136
171
  const modified = actions.filter((a) => a.status === 'modified');
137
172
  for (const m of modified) {
138
- warn(`${m.scope}/${m.item} is locally modified — it matches neither the shipped template nor the copy yad wrote`);
173
+ // The product-link record has no template: it is `modified` only when it is not a JSON object (E120).
174
+ warn(m.item === PRODUCT_LINK
175
+ ? `${shown(m)}/${m.item} is not a JSON object — left alone (--overwrite-local saves it and writes a new one)`
176
+ : `${shown(m)}/${m.item} is locally modified — it matches neither the shipped template nor the copy yad wrote`);
139
177
  }
140
178
  // A gate-sync fragment is kept like any other edited file — but it also decides which yadflow CI runs.
141
179
  // It trusts a committed version only from its own major (`YAD_MAJOR`), and the version stamp is
@@ -150,13 +188,37 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
150
188
  return found ? Number(found[1]) : null;
151
189
  } catch { return null; }
152
190
  };
153
- for (const m of modified.filter((a) => a.managed && /yad-gate-sync\.ya?ml$/.test(a.item))) {
191
+ for (const m of modified.filter((a) => a.managed && !a.rename && /yad-gate-sync\.ya?ml$/.test(a.item))) {
154
192
  const kept = pinMajorOf(m.managed.dest);
155
193
  const ships = pinMajorOf(m.managed.src);
156
194
  if (kept === null || ships === null || kept === ships) continue;
157
- warn(`${m.scope}/${m.item} trusts only yadflow ${kept}.x pins, but this release's fragment trusts ${ships}.x — kept as it is, CI will skip the new version stamp and run yadflow@${kept}`);
195
+ warn(`${shown(m)}/${m.item} trusts only yadflow ${kept}.x pins, but this release's fragment trusts ${ships}.x — kept as it is, CI will skip the new version stamp and run yadflow@${kept}`);
158
196
  hand(`re-apply your edit on top of the new fragment, or replace it with \`yad update --overwrite-local\` (your copy is saved beside it as ${path.basename(m.managed.dest)}${BACKUP_SUFFIX})`);
159
197
  }
198
+ // A renamed file the team edited (E123): kept under its old name, so say that the rename is half done and
199
+ // how to finish it. True whatever else this run does: the old file goes on running (GitHub) or being the
200
+ // one the root .gitlab-ci.yml includes (GitLab) until --overwrite-local replaces it.
201
+ // An edited Product gate that does not handle the profile the Product's checks workflow passes (E123): kept,
202
+ // and then it fails every Product PR, or passes what it should stop. Said only when a workflow passing
203
+ // `product` is on disk or this run installs one — the same test `yad doctor` makes (review 2).
204
+ const productRel = (p) => path.relative(root, p).split(path.sep).join('/');
205
+ const installsProduct = actions.filter((a) => a.managed && a.managed.root === root
206
+ && /(^|\/)yad-product-checks\.yml$/.test(productRel(a.managed.dest))
207
+ && a.status !== 'ok' && a.status !== 'modified' && (scope === 'all' || a.status !== 'missing'))
208
+ .map((a) => productRel(a.managed.dest));
209
+ const passing = [...new Set([...workflowsPassingProduct(root), ...installsProduct])];
210
+ for (const m of overwriteLocal || !passing.length ? [] : modified.filter((a) => a.managed && a.managed.root === root && !a.rename
211
+ && PRODUCT_PROFILE_GATES.includes(productRel(a.managed.dest)))) {
212
+ const gap = productProfileGap(m.managed.dest);
213
+ if (!gap) continue;
214
+ warn(`${m.scope}/${productProfileEffect(gap, m.item, `${passing.join(' and ')} ${passing.length > 1 ? 'pass' : 'passes'}`)} — it was edited, so it is kept`);
215
+ hand(`${PRODUCT_PROFILE_FIX} (your copy is saved as ${path.basename(m.managed.dest)}${BACKUP_SUFFIX})`);
216
+ }
217
+ for (const m of overwriteLocal ? [] : modified.filter((a) => a.rename)) {
218
+ const { from, to } = m.rename;
219
+ warn(`${shown(m)}/${from} was renamed ${to} in this release, but it was edited, so it is kept — ${from.startsWith('.gitlab/') ? 'the root .gitlab-ci.yml goes on including it' : 'it goes on running'} under its old name`);
220
+ hand(`\`yad update --overwrite-local\` replaces it with ${to} (your copy is saved as ${path.basename(from)}${BACKUP_SUFFIX}); then copy your edits into ${to}`);
221
+ }
160
222
  if (modified.length && !overwriteLocal) {
161
223
  hand(`keep the edits (reported as \`modified\` on every check), or replace them with \`yad update --overwrite-local\` — each previous version is saved beside the file as <file>${BACKUP_SUFFIX}`);
162
224
  }
@@ -169,10 +231,15 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
169
231
  log('');
170
232
  log(c.dim(`summary: ${counts.missing} missing, ${counts.new} new, ${counts.outdated} outdated, ${counts.modified} modified, ${counts.stale} stale, ${counts.legacy} legacy, ${counts.removed} removed, ${counts.ok} ok`));
171
233
 
234
+ // E80: the workspace file beside the Product, for one whose repos live beside it. Per machine, outside
235
+ // the Product repo — so never staged by --push. Reported here; written by --fix below.
236
+ const wsNeeded = exists(productConfigPath(root)) && hasSiblingRepo(root);
237
+ let workspaceFile = wsNeeded ? workspaceFileState(root) : null;
172
238
  if (!fix) {
239
+ if (workspaceFile === 'missing') info(`no ${WORKSPACE_FILE} beside the Product — \`yad check --fix\` writes it, so yad finds the Product from its code repos`);
173
240
  if (push) warn('--push has no effect without --fix (there is nothing applied to commit).');
174
241
  if (fixable.length || gaps.length) hand('run `yad check --fix` to reconcile (or `yad setup` for missing one-time setup).');
175
- return { fix: false, counts, gaps, items: itemsOf(actions), applied: 0, modified: modified.length, commits: [] };
242
+ return { fix: false, counts, gaps, items: itemsOf(actions), applied: 0, modified: modified.length, commits: [], workspaceFile };
176
243
  }
177
244
 
178
245
  // --- apply --- (collect the applied actions so --push can stage each repo's exact allowlist) ---
@@ -185,17 +252,20 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
185
252
  appliedActions.push(a);
186
253
  // A backup means the replaced content was not provably ours (a pre-ledger install, or an edit
187
254
  // --overwrite-local was told to discard). Never report that as an ordinary template adoption.
188
- info(`${a.status} → fixed: ${a.scope}/${a.item}${a.backup ? ` ${c.yellow(`(previous content saved to ${path.basename(a.backup)})`)}` : ''}`);
255
+ info(`${a.status} → fixed: ${shown(a)}/${a.item}${a.backup ? ` ${c.yellow(`(previous content saved to ${path.basename(a.backup)})`)}` : ''}`);
189
256
  }
190
257
  if (force) {
191
258
  // --force re-copies what is already correct; it deliberately does NOT reach a `modified` file —
192
259
  // only --overwrite-local discards a local edit, and only after backing it up.
193
260
  for (const a of actions.filter((a) => a.status === 'ok')) { a.apply(); appliedActions.push(a); }
194
261
  }
262
+ // A file that is right on disk but not committed as it stands, which --push must still commit: the
263
+ // product-link record (E120) — the gates read it from the default branch, not from anyone's disk.
264
+ if (push) for (const a of actions) if (a.pendingCommit && !appliedActions.includes(a)) appliedActions.push(a);
195
265
  // Refresh the version stamp and persist only the canonical targets used to build actions. This also
196
266
  // completes legacy/corrupt target migration even when no skill content itself needed an update.
197
267
  writeCanonicalStamp();
198
- appliedActions.push({ scope: 'hub', item: PROJECT_FILES.version, status: 'stamp', root, paths: [PROJECT_FILES.version] });
268
+ appliedActions.push({ scope: 'product', item: PROJECT_FILES.version, status: 'stamp', root, paths: [PROJECT_FILES.version] });
199
269
  // Record what we wrote (and what was already correct) so the NEXT update can tell a stale managed
200
270
  // file from an edited one. Seeding the already-correct files is what migrates a pre-ledger install.
201
271
  // A file left as `modified` records nothing — it differs from the template by definition.
@@ -205,6 +275,11 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
205
275
  root: ledgerRoot, paths: [MANAGED_LEDGER],
206
276
  });
207
277
  }
278
+ if (workspaceFile === 'missing') {
279
+ workspaceFile = writeWorkspaceFile(root) === 'written' ? 'written' : workspaceFileState(root);
280
+ // Said with its full path: it is the one file this command writes OUTSIDE the Product.
281
+ if (workspaceFile === 'written') warn(`wrote ${path.join(path.dirname(path.resolve(root)), WORKSPACE_FILE)} (this machine only) — run from inside a repo this Product registers, yad finds the Product; delete the file to stop that`);
282
+ }
208
283
  applied ? ok(`reconciled ${applied} item(s)`) : info('nothing to fix');
209
284
  if (modified.length && !overwriteLocal) {
210
285
  warn(`${modified.length} locally modified file(s) left untouched — this update did not reach them`);
@@ -217,9 +292,9 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
217
292
  let commits = [];
218
293
  if (push) {
219
294
  preflightGuardReadiness(root);
220
- const hub = readJSON(productConfigPath(root), {});
221
- const defByRoot = new Map([[root, hub?.default_branch]]);
222
- const platformByRoot = new Map([[root, hub?.platform]]);
295
+ const productConfig = readJSON(productConfigPath(root), {});
296
+ const defByRoot = new Map([[root, productConfig?.default_branch]]);
297
+ const platformByRoot = new Map([[root, productConfig?.platform]]);
223
298
  for (const repo of registry.repos) {
224
299
  const repoRoot = path.resolve(root, repo.path);
225
300
  defByRoot.set(repoRoot, repo.default_branch);
@@ -239,5 +314,5 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
239
314
  },
240
315
  });
241
316
  }
242
- return { fix: true, counts, gaps, items: itemsOf(actions), applied, modified: modified.length, commits };
317
+ return { fix: true, counts, gaps, items: itemsOf(actions), applied, modified: modified.length, commits, workspaceFile };
243
318
  }
@@ -16,23 +16,27 @@
16
16
  // commit never enters a PR's base..HEAD range where it would strand required checks.
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
- import { c, ok, info, fail, hand, exists, pushWithRebase } from './lib.mjs';
19
+ import { c, ok, info, fail, hand, exists, pushWithRebase, forTerminal } from './lib.mjs';
20
20
  import { PROJECT_FILES , productConfigPath } from './manifest.mjs';
21
21
  import { loadProduct } from './gate.mjs';
22
22
  import { platformLogin } from './platform.mjs';
23
- import { productGit, resolveDefaultBranch, guardDefaultBranch, preflightGuardReadiness } from './hubcommit.mjs';
23
+ import { productGit, resolveDefaultBranch, guardDefaultBranch, preflightGuardReadiness } from './productcommit.mjs';
24
24
  import { ensurePackIgnored, PACK_IGNORE_BLOCK } from './setup.mjs';
25
25
  import { checkpointAuthor } from './checkpoint.mjs';
26
+ import { codeContextPathOk } from './workspace.mjs';
26
27
 
27
28
  // Collapse any whitespace/newline runs to a single space — keeps a hostile `git user.name` or a stray
28
29
  // path from breaking the one-line subject or injecting a fake trailer line.
29
30
  const oneLine = (s = '') => String(s).replace(/\s+/g, ' ').trim();
30
31
 
31
32
  // The tracked code-map for a repo: the registered path, else the conventional location.
32
- const codeMapOf = (repo) => repo.codeMap || path.posix.join('.sdlc/code-context', repo.name, 'code-map.md');
33
+ // A registry entry with no `name` (or a name that is not text) and no path of its own has no place
34
+ // here: null, which `codeContextPathOk` refuses — never a throw halfway through a publish (E81 review 2).
35
+ const inContext = (repo, file) => (typeof repo?.name === 'string' && repo.name ? path.posix.join('.sdlc/code-context', repo.name, file) : null);
36
+ export const codeMapOf = (repo) => repo.codeMap || inContext(repo, 'code-map.md');
33
37
 
34
38
  // The repomix pack for a repo: the registered path, else the conventional location.
35
- const packOf = (repo) => repo.contextPack || path.posix.join('.sdlc/code-context', repo.name, 'pack.md');
39
+ export const packOf = (repo) => repo.contextPack || inContext(repo, 'pack.md');
36
40
 
37
41
  // PURE — the repo-relative pathspecs to stage: the registry plus each registered repo's code-map that
38
42
  // exists on disk. When `name` is given (a scoped `yad repo refresh <name> --push`), only that repo's
@@ -41,9 +45,11 @@ const packOf = (repo) => repo.contextPack || path.posix.join('.sdlc/code-context
41
45
  export function codeMapPathspecs(root, registry = { repos: [] }, name = null) {
42
46
  const out = [];
43
47
  for (const repo of registry.repos || []) {
44
- if (name && repo.name !== name) continue;
48
+ if (!repo || typeof repo !== 'object' || (name && repo.name !== name)) continue;
45
49
  const rel = codeMapOf(repo);
46
- if (fs.existsSync(path.join(root, rel))) out.push(rel);
50
+ // Shared registry text chooses this path, and it is committed and pushed: only under
51
+ // .sdlc/code-context/ in the Product, never through `..`, `.git` or a link (E81).
52
+ if (codeContextPathOk(root, rel) && fs.existsSync(path.join(root, rel))) out.push(rel);
47
53
  }
48
54
  // The registry always rides along — `yad repo refresh` stamps syncedHead/lastSyncedAt into it.
49
55
  if (fs.existsSync(path.join(root, PROJECT_FILES.reposRegistry))) out.push(PROJECT_FILES.reposRegistry);
@@ -57,9 +63,9 @@ export function codeMapPathspecs(root, registry = { repos: [] }, name = null) {
57
63
  export function packPathspecs(root, registry = { repos: [] }, name = null) {
58
64
  const out = [];
59
65
  for (const repo of registry.repos || []) {
60
- if (name && repo.name !== name) continue;
66
+ if (!repo || typeof repo !== 'object' || (name && repo.name !== name)) continue;
61
67
  const rel = packOf(repo);
62
- if (fs.existsSync(path.join(root, rel))) out.push(rel);
68
+ if (codeContextPathOk(root, rel) && fs.existsSync(path.join(root, rel))) out.push(rel);
63
69
  }
64
70
  return out;
65
71
  }
@@ -122,12 +128,12 @@ export function summarizeCodeContext(files = []) {
122
128
  }
123
129
 
124
130
  // PURE — the audit-trail commit message. Subject passes the Product commit-message gate (valid type
125
- // `chore`, scope `hub`, non-empty description, no trailing period). No Task trailer and no
131
+ // `chore`, scope `product`, non-empty description, no trailing period). No Task trailer and no
126
132
  // Co-Authored-By: this is human-owned machine state, not an authored code change. `[skip ci]` mirrors
127
133
  // `yad checkpoint` — it lands on the default branch and needs no PR gate suite. `label`/`author` are
128
134
  // collapsed to one line so nothing can split the subject or forge a trailer.
129
135
  export function buildCodeMapMessage({ label, author, basenames = [] }) {
130
- const subject = `chore(hub): sync code-context — ${oneLine(label)} by ${oneLine(author)} [skip ci]`;
136
+ const subject = `chore(product): sync code-context — ${oneLine(label)} by ${oneLine(author)} [skip ci]`;
131
137
  const body = basenames.length ? `Updated: ${basenames.join(', ')}` : '';
132
138
  return body ? `${subject}\n\n${body}` : subject;
133
139
  }
@@ -137,18 +143,18 @@ export function buildCodeMapMessage({ label, author, basenames = [] }) {
137
143
  export async function publishCodeContext(root, { push = false, allowBranch = false, name = null } = {}) {
138
144
  if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
139
145
  if (!exists(productConfigPath(root))) {
140
- fail('no .sdlc/hub.json — --push publishes the Product code-context; run it from the Product');
146
+ fail('no .sdlc/product.json — --push publishes the Product code-context; run it from the Product');
141
147
  process.exitCode = 1;
142
148
  return;
143
149
  }
144
150
 
145
- const { hub, repos } = loadProduct(root);
151
+ const { productConfig, repos } = loadProduct(root);
146
152
  const registry = { repos: repos || [] };
147
153
  const git = productGit(root);
148
154
 
149
155
  const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
150
- const defaultBranch = resolveDefaultBranch(git, hub);
151
- if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad repo refresh --push' })) return;
156
+ const defaultBranch = resolveDefaultBranch(git, productConfig);
157
+ if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad repo refresh --push', root })) return;
152
158
 
153
159
  // Stage the EXPLICIT allowlist (code-maps + registry), scoped so an unrelated pre-staged file is never
154
160
  // swept in and the user's index is left untouched — mirrors `runCheckpoint`. NEVER `git add -A` and
@@ -213,7 +219,7 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
213
219
  if (push) preflightGuardReadiness(root);
214
220
 
215
221
  const { label, basenames } = summarizeCodeContext(fileset);
216
- const author = checkpointAuthor(platformLogin(root, hub?.platform), git('config', 'user.name').stdout);
222
+ const author = checkpointAuthor(platformLogin(root, productConfig?.platform), git('config', 'user.name').stdout);
217
223
  const message = buildCodeMapMessage({ label, author, basenames });
218
224
 
219
225
  // Untrack the packs by holding their bytes and removing the files across the --only commit, then
@@ -236,7 +242,7 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
236
242
  } finally {
237
243
  for (const h of held) if (!fs.existsSync(h.abs)) fs.writeFileSync(h.abs, h.buf);
238
244
  }
239
- ok(`published ${fileset.length} file(s): ${c.dim(label)}`);
245
+ ok(`published ${fileset.length} file(s): ${c.dim(forTerminal(label))}`); // a registry name can be in it (E81 review 15)
240
246
 
241
247
  if (!push) return { message, committed: true, pushed: false };
242
248
  return { message, committed: true, pushed: pushHead() };