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/setup.mjs CHANGED
@@ -3,14 +3,14 @@ import path from 'node:path';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import {
6
- c, log, step, guide, ok, info, warn, hand, fail, ask, askYesNo, run, has,
6
+ c, log, step, guide, ok, info, warn, hand, fail, ask, askYesNo, run, runLauncher, has,
7
7
  exists, readJSON, readJSONStrict, writeJSON,
8
- writeProductConfig,
8
+ writeProductConfig, forTerminal,
9
9
  } from './lib.mjs';
10
10
  import { VERSION, IDE_TARGETS, IDE_AGENTS, DEFAULT_IDE_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY , productConfigPath } from './manifest.mjs';
11
11
  import {
12
- moduleActions, repoActions, productActions, hookActions, captureHookActions,
13
- legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
12
+ moduleActions, repoActions, productActions, hookActions, captureHookActions, legacyHookScriptActions, gitHookActions, gitHookState, gitHookAdvice,
13
+ legacyModuleActions, removedModuleActions, legacyRepoActions, legacyProductActions, withoutKeptRenames,
14
14
  safeIdeTargetsFor, detectedIdeTargetStateFor, recordManagedWrites,
15
15
  } from './plan.mjs';
16
16
  import { modeFields, modeOf } from './mode.mjs';
@@ -55,6 +55,15 @@ export function detectPlatform(remoteUrl = '') {
55
55
  }
56
56
  export const gitHead = (cwd) => run('git', ['rev-parse', 'HEAD'], { cwd }).stdout || null;
57
57
 
58
+ // A repo's (one `yad init` found beside the Product) default branch: the remote's published default, else the branch it is on (a repo with
59
+ // no remote has nothing else to say), else main.
60
+ export function detectDefaultBranch(repoDir) {
61
+ const originHead = run('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd: repoDir });
62
+ if (originHead.ok && originHead.stdout) return originHead.stdout.replace(/^origin\//, '');
63
+ const current = run('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: repoDir });
64
+ return (current.ok && current.stdout) || 'main';
65
+ }
66
+
58
67
  // Containment: a repo path must live inside the WORKSPACE — the Product root's parent. The standard
59
68
  // multi-repo layout puts the code repos BESIDE the Product, not under it (project/{product,backend,frontend}),
60
69
  // so `../backend` has to register; containing to the Product root instead forced separate git repos to nest
@@ -76,15 +85,33 @@ export function insideWorkspace(root, rpath) {
76
85
  const parent = path.dirname(projectRoot);
77
86
  const workspace = parent === projectRoot ? projectRoot : parent; // degenerate: root is the fs root
78
87
  const resolved = path.resolve(projectRoot, rpath);
88
+ if (throughGitDir(root, rpath)) return false;
79
89
  return resolved === projectRoot || resolved.startsWith(workspace + path.sep);
80
90
  }
81
91
 
92
+ // A repo path that runs through a `.git` folder is never a code repo (E79 review 2). `yad join` clones
93
+ // every registered path with no person looking, and a clone that lands at `../.git` makes the workspace
94
+ // a repo whose `config` the Product's writer chose (`core.fsmonitor` runs a command on `git status`);
95
+ // one at `.git/hooks` puts their hooks in the Product. Git refuses a `.git` part in a tracked path for
96
+ // the same reason. Matched without case (macOS and Windows file systems ignore it), and Windows' short
97
+ // name for `.git` (`GIT~1`) too.
98
+ export function throughGitDir(root, rpath) {
99
+ const projectRoot = path.resolve(root);
100
+ const parent = path.dirname(projectRoot);
101
+ const workspace = parent === projectRoot ? projectRoot : parent;
102
+ const parts = path.relative(workspace, path.resolve(projectRoot, rpath)).split(/[\\/]+/);
103
+ // Windows also drops trailing dots and spaces from a name and reads `name:stream` as `name`, so
104
+ // `.git.`, `.git ` and `.git::$INDEX_ALLOCATION` are all `.git` there — judged as git itself does.
105
+ const ntfs = (p) => p.split(':')[0].replace(/[. ]+$/, '');
106
+ return parts.some((p) => /^\.git$/i.test(ntfs(p)) || /^git~\d+$/i.test(ntfs(p)));
107
+ }
108
+
82
109
  // Build the hub.json object for a (re)configure write: the fields this run collected, laid over the
83
110
  // existing file so nothing the wizard does not ask about is dropped. Mirrors the safe { ...cur } merge
84
111
  // on the keep path. That includes a `roster` or `verified_authors` an older release wrote (E62): the
85
112
  // wizard no longer collects or reads either, and it never deletes them — `yad doctor` names them as
86
113
  // unused instead.
87
- export function buildReconfiguredHub(cur, fields) {
114
+ export function buildReconfiguredProductConfig(cur, fields) {
88
115
  return { ...(cur || {}), ...fields };
89
116
  }
90
117
 
@@ -92,6 +119,7 @@ export function buildReconfiguredHub(cur, fields) {
92
119
  // A path that is not a git repository is rejected and NOTHING is written — a registry entry with
93
120
  // syncedHead:null would only surface later as an unexplained "unknown status" in the CI gates.
94
121
  export function registerRepo(root, registry, { name, rpath, platform, default_branch = 'main', today = null, pack = true }) {
122
+ if (throughGitDir(root, rpath)) { warn(`${rpath} runs through a .git folder — not a code repo, skipped`); return null; }
95
123
  if (!insideWorkspace(root, rpath)) {
96
124
  warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`);
97
125
  return null;
@@ -245,21 +273,21 @@ function applyActions(actions, { force = false } = {}) {
245
273
  // otherwise we prompt with a default. Pure of side effects — it only reads. Returns
246
274
  // { solo, team_size, codebase, repo_layout, configureTools }.
247
275
  export async function resolveProfile(root, opts = {}) {
248
- const hub = readJSON(productConfigPath(root), null);
249
- const prev = (hub && hub.profile) || {};
276
+ const productConfig = readJSON(productConfigPath(root), null);
277
+ const prev = (productConfig && productConfig.profile) || {};
250
278
 
251
- // 1. Solo or team (+ size). --solo / --team <n> win; else carry hub.solo forward; else ask.
279
+ // 1. Solo or team (+ size). --solo / --team <n> win; else carry the Product's `solo` forward; else ask.
252
280
  let solo, team_size;
253
281
  if (opts.solo) { solo = true; team_size = 1; }
254
282
  else if (opts.team != null) { team_size = Math.max(1, parseInt(opts.team, 10) || 1); solo = team_size <= 1; }
255
283
  // Either spelling carries forward: the older `review_gate.solo: true` also waives the gates, and asking again
256
284
  // would default a configured team Product to solo and switch approvals off with nobody choosing it (E10).
257
- else if (typeof hub?.solo === 'boolean' || hub?.review_gate?.solo === true) { solo = modeOf(hub) === 'solo'; team_size = prev.team_size ?? (solo ? 1 : 2); }
285
+ else if (typeof productConfig?.solo === 'boolean' || productConfig?.review_gate?.solo === true) { solo = modeOf(productConfig) === 'solo'; team_size = prev.team_size ?? (solo ? 1 : 2); }
258
286
  else {
259
287
  // No mode recorded. The default used to come from the roster's size; it now comes from the team size
260
288
  // setup recorded, and a Product that exists with neither defaults to TEAM (E62). Solo waives every
261
289
  // approval, so it must be a choice, never what a scripted re-run of an old Product falls into.
262
- const known = prev.team_size ?? (hub ? 2 : 1);
290
+ const known = prev.team_size ?? (productConfig ? 2 : 1);
263
291
  solo = !(await ask('Solo or team?', known > 1 ? 'team' : 'solo')).toLowerCase().startsWith('t');
264
292
  team_size = solo ? 1 : Math.max(2, parseInt(await ask(' how many team members?', String(Math.max(2, known))), 10) || 2);
265
293
  }
@@ -312,7 +340,7 @@ export async function runSetup(root, opts = {}) {
312
340
  if (await askYesNo('Not a git repo. Run `git init` here?', true)) {
313
341
  run('git', ['init'], { cwd: root });
314
342
  ok('git initialized');
315
- } else warn('continuing without git — hub detection will be skipped');
343
+ } else warn('continuing without git — the Product platform will not be detected');
316
344
  } else ok('git repo detected');
317
345
  for (const tool of ['git', 'node']) has(tool) ? ok(`${tool} present`) : warn(`${tool} not found on PATH`);
318
346
  if (!has('npx')) warn('npx not found — repomix packing will be skipped');
@@ -365,18 +393,18 @@ export async function runSetup(root, opts = {}) {
365
393
  S('Product platform');
366
394
  guide(solo
367
395
  ? [
368
- 'Your hub is this repo on GitHub/GitLab (or none for a local gate).',
396
+ 'Your Product is this repo on GitHub/GitLab (or none for a local gate).',
369
397
  'Solo: you review by merging your own PR (approval waived).',
370
398
  ]
371
399
  : [
372
- 'Your hub is this repo on GitHub/GitLab; reviewers approve artifacts there.',
400
+ 'Your Product is this repo on GitHub/GitLab; reviewers approve artifacts there.',
373
401
  'yad keeps no list of people: anyone with access to the repo can approve, and the platform records who did.',
374
402
  'A gate needs one approval, which should not come from the author (GitHub blocks self-approval; GitLab only if its settings do).',
375
403
  'It also reports how many people it would like: 3 on a contract review, 2 where auth or payments is touched, 1 elsewhere. That extra number is advisory for now.',
376
404
  ]);
377
405
  const productPath = productConfigPath(root);
378
- if (exists(productPath) && !(await askYesNo('hub.json exists — reconfigure?', false))) {
379
- info('keeping existing .sdlc/hub.json');
406
+ if (exists(productPath) && !(await askYesNo(`${path.relative(root, productPath)} exists — reconfigure?`, false))) {
407
+ info(`keeping the existing ${path.relative(root, productPath)}`);
380
408
  } else {
381
409
  const remote = run('git', ['remote', 'get-url', 'origin'], { cwd: root });
382
410
  if (!remote.ok && exists(path.join(root, '.git'))) info('no origin remote — platform detection skipped');
@@ -407,14 +435,14 @@ export async function runSetup(root, opts = {}) {
407
435
  // the reader falls back to when `ledger` is absent, so nothing is lost by waiting: `yad migrate`
408
436
  // adds the key, and the setting it computes is the one these booleans just recorded.
409
437
  const onNewShape = (cur.schemaVersion ?? 1) >= 2 || !Object.keys(cur).length;
410
- const next = buildReconfiguredHub(cur, {
438
+ const next = buildReconfiguredProductConfig(cur, {
411
439
  platform: enabled ? platform : null, git_url,
412
440
  ...(onNewShape ? { ledger: enabled ? 'verified' : 'local' } : {}),
413
441
  bridge_enabled: enabled, bridge: enabled,
414
442
  default_branch, ...setupModeFields(root, cur, solo, opts), profile: { codebase, repo_layout, team_size },
415
443
  });
416
444
  writeProductConfig(root, next);
417
- ok(`wrote ${PROJECT_FILES.productConfig} + ${PROJECT_FILES.hubConfig}${solo ? ' (solo mode)' : ''}`);
445
+ ok(`wrote ${PROJECT_FILES.productConfig} + ${PROJECT_FILES.productConfigLegacy}${solo ? ' (solo mode)' : ''}`);
418
446
  }
419
447
  // Persist the profile + solo flag even on the "keeping existing" path, so re-running setup with new
420
448
  // flags (e.g. `yad setup --solo`) updates the mode without a full reconfigure. Merge, never clobber.
@@ -431,7 +459,7 @@ export async function runSetup(root, opts = {}) {
431
459
  if (modeMoved || JSON.stringify(cur.profile || {}) !== JSON.stringify({ codebase, repo_layout, team_size }) || backfillUrl) {
432
460
  writeProductConfig(root, { ...cur, ...(backfillUrl ? { git_url: backfillUrl } : {}), ...mode, profile: { codebase, repo_layout, team_size } });
433
461
  if (mode.mode_set) info(`mode: ${mode.mode_set.from} → ${mode.mode_set.to} (recorded as mode_set)`);
434
- if (backfillUrl) info(`backfilled hub git_url from origin: ${backfillUrl}`);
462
+ if (backfillUrl) info(`backfilled the Product's git_url from origin: ${backfillUrl}`);
435
463
  else info(`recorded profile: ${solo ? 'solo' : `team(${team_size})`}, ${codebase}, ${repo_layout}`);
436
464
  }
437
465
  }
@@ -533,6 +561,24 @@ export async function runSetup(root, opts = {}) {
533
561
  const known = new Set(registry.repos.map((r) => r.name));
534
562
  const greenfield = codebase === 'greenfield';
535
563
  const mono = repo_layout === 'monorepo';
564
+ // `yad init` (E79) found these beside the Product: one yes/no each, with what git already knows about
565
+ // them — the platform from `origin`, the default branch from origin/HEAD or the branch it is on.
566
+ for (const d of (opts.discovered || []).filter((x) => !known.has(x.name))) {
567
+ const repoRoot = path.resolve(root, d.rpath);
568
+ const remote = run('git', ['remote', 'get-url', 'origin'], { cwd: repoRoot });
569
+ const platform = detectPlatform(remote.ok ? remote.stdout : '') || 'github';
570
+ const default_branch = detectDefaultBranch(repoRoot);
571
+ // "Connect" means what it means in the loop below: register it, and the wiring step then writes the
572
+ // CI gates and the PR template into it. Said here, because init asks for every repo at once.
573
+ if (!(await askYesNo(`Connect and wire ${d.name} (${d.rpath}, ${platform}, default branch ${default_branch}) — writes its CI gates + PR template?`, true))) continue;
574
+ const repo = registerRepo(root, registry, { name: d.name, rpath: d.rpath, platform, default_branch, today: opts.today ?? null, pack: !greenfield });
575
+ if (!repo) continue;
576
+ known.add(d.name);
577
+ ok(`registered ${d.name}`);
578
+ if (greenfield) info(`${d.name}: greenfield — skipped repomix pack (run \`yad repo refresh ${d.name}\` once it has code)`);
579
+ else packRepo(root, repo);
580
+ if (mono) { info('monorepo — one repo connected'); break; }
581
+ }
536
582
  if (await askYesNo(`Connect a code repo? ${c.dim(`(${registry.repos.length} already registered)`)}`, registry.repos.length === 0)) {
537
583
  for (;;) {
538
584
  const name = await ask(' repo name (blank to finish)', '');
@@ -540,6 +586,7 @@ export async function runSetup(root, opts = {}) {
540
586
  if (known.has(name)) { warn(`${name} already registered — skipping`); continue; }
541
587
  // Siblings of the Product are the common layout (project/{product,backend}) — `../backend` is valid.
542
588
  const rpath = await ask(' path (relative to project root, e.g. ../backend)', `demo-repos/${name}`);
589
+ if (throughGitDir(root, rpath)) { warn(`${rpath} runs through a .git folder — not a code repo, skipped`); continue; }
543
590
  if (!insideWorkspace(root, rpath)) { warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`); continue; }
544
591
  const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
545
592
  const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
@@ -563,38 +610,52 @@ export async function runSetup(root, opts = {}) {
563
610
  const wired = [];
564
611
  for (const repo of registry.repos) {
565
612
  log(` ${c.bold(repo.name)} ${c.dim(`(${repo.platform})`)}`);
566
- const repoWiring = repoActions(root, repo);
613
+ // Migrate renamed wired CI (marker-owned sdlc-*.yml -> yad-*.yml); a user-authored same-named file is
614
+ // never touched, and an edited one is kept without its new name beside it (E123, `withoutKeptRenames`).
615
+ const repoLegacy = legacyRepoActions(root, repo);
616
+ const repoWiring = withoutKeptRenames([...repoActions(root, repo), ...repoLegacy]).filter((a) => !repoLegacy.includes(a));
567
617
  applyActions(repoWiring, { force: true });
568
618
  wired.push(...repoWiring);
569
- // Migrate pre-2.0 wired CI (marker-owned sdlc-*.yml -> yad-*.yml); a user-authored
570
- // same-named file is never touched.
571
- applyActions(legacyRepoActions(root, repo), { force: true });
619
+ applyActions(repoLegacy, { force: true });
572
620
  }
573
621
  // the Product: event-driven gate-sync CI, so platform approvals/merges drive `yad gate ci`
574
- const hubWiring = productActions(root);
575
- if (hubWiring.length) {
576
- log(` ${c.bold('hub')} ${c.dim('(gate-sync + verified-commits CI)')}`);
577
- applyActions(hubWiring, { force: true });
578
- wired.push(...hubWiring);
622
+ const productLegacy = legacyProductActions(root);
623
+ const productWiring = withoutKeptRenames([...productActions(root), ...productLegacy]).filter((a) => !productLegacy.includes(a));
624
+ if (productWiring.length) {
625
+ log(` ${c.bold('product')} ${c.dim('(gate-sync + verified-commits CI)')}`);
626
+ applyActions(productWiring, { force: true });
627
+ wired.push(...productWiring);
579
628
  }
580
- applyActions(legacyHubActions(root), { force: true });
629
+ applyActions(productLegacy, { force: true });
581
630
  // the Product, locally: the harness ledger guard, so an agent is refused the CI-owned ledger write at
582
631
  // the moment it tries it rather than by a failed pipeline later (#171). Verified-only like the CI
583
632
  // above — with no bridge the ledger is locally owned and the guard would be wrong.
584
633
  const hookWiring = hookActions(root, ideTargets);
585
634
  if (hookWiring.length) {
586
- log(` ${c.bold('hub')} ${c.dim('(agent ledger guard)')}`);
635
+ log(` ${c.bold('product')} ${c.dim('(agent ledger guard)')}`);
587
636
  applyActions(hookWiring, { force: true });
588
637
  wired.push(...hookWiring);
589
638
  }
639
+ // E48: the same guard for a person's `git commit`, installed in this clone's git hooks. Not committed,
640
+ // so it is not pushed to `wired` (nothing to stage).
641
+ const gitHook = gitHookActions(root);
642
+ if (gitHook.length) {
643
+ log(` ${c.bold('product')} ${c.dim('(git pre-commit ledger guard, this clone)')}`);
644
+ applyActions(gitHook, { force: true });
645
+ }
646
+ const gitHookNote = gitHookAdvice(gitHookState(root));
647
+ if (gitHookNote) warn(gitHookNote);
590
648
  // The post-edit capture hook (E43): every change to an artifact is snapshotted onto the person's private
591
649
  // `yad/wip/<name>/<epic>` branches. Both ledger modes; `"capture": false` in the Product config turns it off.
592
650
  const captureWiring = captureHookActions(root, ideTargets);
593
651
  if (captureWiring.length) {
594
- log(` ${c.bold('hub')} ${c.dim('(wip capture)')}`);
652
+ log(` ${c.bold('product')} ${c.dim('(wip capture)')}`);
595
653
  applyActions(captureWiring, { force: true });
596
654
  wired.push(...captureWiring);
597
655
  }
656
+ // E113: the shell-script hooks a re-run replaces, once the entries above no longer name them.
657
+ const retiredHooks = legacyHookScriptActions(root, ideTargets);
658
+ if (retiredHooks.length) applyActions(retiredHooks, { force: true });
598
659
  // After every write to a managed path has landed (including the legacy renames), so the recorded
599
660
  // sha is the file's final state.
600
661
  recordManagedWrites(wired);
@@ -675,7 +736,12 @@ export const PACK_IGNORE_BLOCK = [
675
736
  // and returns true.
676
737
  export function ensurePackIgnored(root) {
677
738
  const gi = path.join(root, '.gitignore');
678
- const lines = exists(gi) ? fs.readFileSync(gi, 'utf8').split('\n') : [];
739
+ // Only a plain file, or none: the Product can commit `.gitignore` as a link, and reading then writing
740
+ // through it would append this block to whatever file it points at, outside the workspace (E81 review 16).
741
+ let st = null;
742
+ try { st = fs.lstatSync(gi); } catch { /* none yet: written below */ }
743
+ if (st && !st.isFile()) { warn('.gitignore in the Product is not a plain file (a link?) — the code-context pack ignore line was not added'); return false; }
744
+ const lines = st ? fs.readFileSync(gi, 'utf8').split('\n') : [];
679
745
  if (lines.some((l) => l.trim() === PACK_IGNORE_GLOB)) return false;
680
746
  const body = lines.join('\n').replace(/\n*$/, '');
681
747
  const prefix = body ? `${body}\n\n` : '';
@@ -687,12 +753,15 @@ export function ensurePackIgnored(root) {
687
753
  export function packRepo(root, repo) {
688
754
  const repoRoot = path.resolve(root, repo.path);
689
755
  const out = path.join(root, repo.contextPack);
690
- if (!has('npx')) { warn(`${repo.name}: npx missing — skipped repomix pack`); return false; }
756
+ // Printed through `forTerminal` (E81 review 12): the name and pack path come from the shared registry,
757
+ // and repomix's error line from a run in a registered folder.
758
+ const nm = forTerminal(repo.name);
759
+ if (!has('npx')) { warn(`${nm}: npx missing — skipped repomix pack`); return false; }
691
760
  fs.mkdirSync(path.dirname(out), { recursive: true });
692
761
  ensurePackIgnored(root); // keep the pack out of git before it is (re)written — see repo-publish.mjs invariant 1
693
- info(`${repo.name}: packing with repomix …`);
694
- const r = run('npx', ['repomix@latest', '--compress', '--include-logs', '--style', 'markdown', '-o', out], { cwd: repoRoot });
695
- if (r.ok) { ok(`${repo.name}: cached ${repo.contextPack}`); hand(`${repo.name}: generate the code-map in your AI agent (yad-connect-repos)`); return true; }
696
- fail(`${repo.name}: repomix failed — ${r.stderr.split('\n')[0] || 'unknown error'}`);
762
+ info(`${nm}: packing with repomix …`);
763
+ const r = runLauncher('npx', ['repomix@latest', '--compress', '--include-logs', '--style', 'markdown', '-o', out], { cwd: repoRoot });
764
+ if (r.ok) { ok(`${nm}: cached ${forTerminal(repo.contextPack)}`); hand(`${nm}: generate the code-map in your AI agent (yad-connect-repos)`); return true; }
765
+ fail(`${nm}: repomix failed — ${forTerminal(r.stderr.split('\n')[0]) || 'unknown error'}`);
697
766
  return false;
698
767
  }
package/cli/ship.mjs CHANGED
@@ -28,7 +28,7 @@ export async function runShip(root, opts = {}) {
28
28
  // from the committed subject (the full `<type>: …` form), which the pr-title gate expects — passing
29
29
  // the bare --message here would override that with a type-less title and fail the gate.
30
30
  const opened = await runOpenPr(root, {
31
- repo: opts.repo, platform: opts.platform, base: opts.base,
31
+ repo: opts.repo, product: opts.product, platform: opts.platform, base: opts.base,
32
32
  title: opts.title, task: opts.task,
33
33
  risk: opts.risk, contractChange: opts.contractChange,
34
34
  });
package/cli/skip.mjs CHANGED
@@ -32,7 +32,7 @@ import { actorName } from './platform.mjs';
32
32
  // gate, so it must not block the verb.
33
33
  export function recordActor(root) {
34
34
  let platform = null;
35
- try { platform = loadProduct(root)?.hub?.platform || null; } catch { /* no Product / malformed — attribute by raw git name */ }
35
+ try { platform = loadProduct(root)?.productConfig?.platform || null; } catch { /* no Product / malformed — attribute by raw git name */ }
36
36
  return actorName(root, platform);
37
37
  }
38
38
 
@@ -70,9 +70,9 @@ const VERBS = {
70
70
  // these verbs on a local ledger (`yad doctor` reports both), and the hook and the verb must give the same
71
71
  // answer on the same file.
72
72
  function ciOwnsLedger(root, { epic, command, undoCommand = null, runner }) {
73
- const hub = readJSON(productConfigPath(root), null);
74
- if (!isVerifiedLedger(hub)) return false;
75
- const seeded = seededSlugs(root, hub, runner);
73
+ const productConfig = readJSON(productConfigPath(root), null);
74
+ if (!isVerifiedLedger(productConfig)) return false;
75
+ const seeded = seededSlugs(root, productConfig, runner);
76
76
  if (seeded === null) {
77
77
  warn(`this Product's ledger is verified, and origin could not be read — cannot tell whether CI already owns ${epicRel(epic)}/.sdlc/state.json. If the epic's first review PR has merged, ledger-guard will reject this change`);
78
78
  return false;
package/cli/tidy.mjs CHANGED
@@ -13,7 +13,7 @@ import { productConfigPath } from './manifest.mjs';
13
13
  import { loadProduct } from './gate.mjs';
14
14
  import { platformLogin } from './platform.mjs';
15
15
  import { checkpointAuthor } from './checkpoint.mjs';
16
- import { productGit, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
16
+ import { productGit, resolveDefaultBranch, guardDefaultBranch } from './productcommit.mjs';
17
17
  import { foldTrust, foldBuild } from './ledger.mjs';
18
18
  import { epicRoot, isValidEpicId, readFrontmatter } from './epic-state.mjs';
19
19
 
@@ -40,16 +40,16 @@ export async function runTidy(root, opts = {}) {
40
40
  log(c.bold('\nyad tidy up'));
41
41
  if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
42
42
  if (!exists(productConfigPath(root))) {
43
- fail('no .sdlc/hub.json — run `yad tidy up` from the Product');
43
+ fail('no .sdlc/product.json — run `yad tidy up` from the Product');
44
44
  process.exitCode = 1;
45
45
  return;
46
46
  }
47
47
 
48
- const { hub } = loadProduct(root);
48
+ const { productConfig } = loadProduct(root);
49
49
  const git = productGit(root);
50
50
  const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
51
- const defaultBranch = resolveDefaultBranch(git, hub);
52
- if (!guardDefaultBranch(branch, defaultBranch, { allowBranch: opts.allowBranch, cmd: 'yad tidy up' })) return;
51
+ const defaultBranch = resolveDefaultBranch(git, productConfig);
52
+ if (!guardDefaultBranch(branch, defaultBranch, { allowBranch: opts.allowBranch, cmd: 'yad tidy up', root })) return;
53
53
 
54
54
  if (opts.epic && !isValidEpicId(opts.epic)) { fail(`invalid epic id '${opts.epic}'`); process.exitCode = 1; return; }
55
55
  if (opts.epic && !exists(epicRoot(root, opts.epic))) { fail(`no such epic '${opts.epic}'`); process.exitCode = 1; return; }
@@ -70,9 +70,9 @@ export async function runTidy(root, opts = {}) {
70
70
  }
71
71
  if (!folded) { info('nothing to tidy — no finished shards to fold'); return { folded: 0, epics: [], message: null, committed: false, pushed: false, dryRun: !!opts.dryRun }; }
72
72
 
73
- const author = checkpointAuthor(platformLogin(root, hub?.platform), git('config', 'user.name').stdout);
73
+ const author = checkpointAuthor(platformLogin(root, productConfig?.platform), git('config', 'user.name').stdout);
74
74
  const label = touched.length === 1 ? touched[0] : `${touched.length} epics`;
75
- const message = `chore(hub): tidy Build ledgers — ${label} by ${author} [skip ci]`;
75
+ const message = `chore(product): tidy Build ledgers — ${label} by ${author} [skip ci]`;
76
76
 
77
77
  if (opts.dryRun) {
78
78
  log('\n' + c.dim(message) + '\n');
@@ -21,18 +21,18 @@
21
21
  // Task trailer, no Co-Authored-By (human-owned machine state, not an authored code change).
22
22
  import fs from 'node:fs';
23
23
  import path from 'node:path';
24
- import { c, log, ok, info, warn, fail, hand, run, pushWithRebase } from './lib.mjs';
24
+ import { c, log, ok, info, warn, fail, hand, run, pushWithRebase, samePath } from './lib.mjs';
25
25
  import { VERSION } from './manifest.mjs';
26
- import { productGit, resolveDefaultBranch } from './hubcommit.mjs';
26
+ import { productGit, resolveDefaultBranch } from './productcommit.mjs';
27
27
 
28
28
  // Collapse whitespace/newline runs to a single space — keeps a stray path or hostile value from
29
29
  // breaking the one-line subject or injecting a fake trailer line.
30
30
  const oneLine = (s = '') => String(s).replace(/\s+/g, ' ').trim();
31
31
 
32
- // A short, human label for a repo root relative to the Product: 'hub' for the Product itself, else the
32
+ // A short, human label for a repo root relative to the Product: 'product' for the Product itself, else the
33
33
  // registered path (e.g. demo-repos/backend), else the basename.
34
34
  export function repoLabel(productRoot, root) {
35
- if (root === productRoot) return 'hub';
35
+ if (root === productRoot) return 'product';
36
36
  const r = path.relative(productRoot, root);
37
37
  return r && !r.startsWith('..') ? r.split(path.sep).join('/') : path.basename(root);
38
38
  }
@@ -92,25 +92,28 @@ function stageAllowlist(git, root, paths) {
92
92
  }
93
93
 
94
94
  // Commit (and, with push, push) one repo group. `defaultBranch` is the repo's configured default
95
- // (hub.default_branch / repo.default_branch); falls back to origin/HEAD then 'main'. Returns a small
95
+ // (the Product's default_branch / repo.default_branch); falls back to origin/HEAD then 'main'. Returns a small
96
96
  // result object; never throws. On any hard error it sets process.exitCode so the CLI reports failure.
97
97
  export function commitAndPush(group, { push = false, allowBranch = false, productRoot, defaultBranch } = {}) {
98
98
  const { root, paths, items } = group;
99
99
  const label = repoLabel(productRoot ?? root, root);
100
+ // Whether this is the Product itself, from the folder — never from the label, which a repo named `product`
101
+ // shares (E124 review 1).
102
+ const isProduct = root === (productRoot ?? root);
100
103
  const git = productGit(root);
101
104
 
102
105
  // `root` must be the TOP of its OWN git repo — not merely "inside a work tree". A registered repo
103
106
  // whose clone is missing (reconcile's apply() happily recreates the wiring files) but whose path
104
- // sits under the Product would otherwise report inside-work-tree=true against the HUB: git resolves the
105
- // pathspecs relative to cwd, so we would stage the connected repo's files into the HUB's index and
106
- // push them to the HUB's remote, mislabeled. Require the worktree top to BE this root.
107
+ // sits under the Product would otherwise report inside-work-tree=true against the Product: git resolves the
108
+ // pathspecs relative to cwd, so we would stage the connected repo's files into the Product's index and
109
+ // push them to the Product's remote, mislabeled. Require the worktree top to BE this root.
107
110
  const top = git('rev-parse', '--show-toplevel');
108
- const sameRepo = top.ok && (() => {
109
- try { return fs.realpathSync(top.stdout) === fs.realpathSync(root); } catch { return false; }
110
- })();
111
+ // `samePath`, not a plain realpath compare: on Windows git's long name and Node's 8.3 short name for the
112
+ // same folder differed, and the repo was skipped with nothing committed.
113
+ const sameRepo = top.ok && samePath(top.stdout, root);
111
114
  if (!sameRepo) {
112
115
  warn(`${label}: not its own git repo (missing/renamed clone?) — skipped (changes left in the working tree)`);
113
- return { label, committed: false, pushed: false, skipped: true, error: false };
116
+ return { label, product: isProduct, committed: false, pushed: false, skipped: true, error: false };
114
117
  }
115
118
  const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
116
119
  const target = defaultBranch || resolveDefaultBranch(git);
@@ -120,13 +123,13 @@ export function commitAndPush(group, { push = false, allowBranch = false, produc
120
123
  warn(`${label}: on '${branch}', not default '${target}' — --allow-branch: commit/push go to origin/${branch}`);
121
124
  } else {
122
125
  warn(`${label}: on '${branch}', not the default branch '${target}' — skipped (switch to '${target}' or pass --allow-branch)`);
123
- return { label, committed: false, pushed: false, skipped: true, error: false };
126
+ return { label, product: isProduct, committed: false, pushed: false, skipped: true, error: false };
124
127
  }
125
128
  }
126
129
 
127
130
  const { staged, addError } = stageAllowlist(git, root, paths);
128
131
  if (addError) warn(`${label}: git add reported "${addError}" — staging may be incomplete`);
129
- if (!staged.length) { info(`${label}: nothing to commit (unchanged or ignored)`); return { label, committed: false, pushed: false, skipped: false, error: false }; }
132
+ if (!staged.length) { info(`${label}: nothing to commit (unchanged or ignored)`); return { label, product: isProduct, committed: false, pushed: false, skipped: false, error: false }; }
130
133
 
131
134
  // Pushing HEAD lands any local commits ahead of the remote too. Warn before we add ours so the
132
135
  // operator sees unpublished WIP about to ride the update push (never silently publish it).
@@ -142,18 +145,18 @@ export function commitAndPush(group, { push = false, allowBranch = false, produc
142
145
  git('reset', '-q', '--', ...staged); // don't leave our allowlist staged for an unrelated commit to sweep up
143
146
  fail(`${label}: git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
144
147
  process.exitCode = 1;
145
- return { label, committed: false, pushed: false, skipped: false, error: true };
148
+ return { label, product: isProduct, committed: false, pushed: false, skipped: false, error: true };
146
149
  }
147
150
  ok(`${label}: committed ${staged.length} file(s)`);
148
151
 
149
- if (!push) return { label, committed: true, pushed: false, skipped: false, error: false };
150
- if (pushWithRebase(root, branch).ok) { ok(`${label}: pushed to origin/${branch}`); return { label, committed: true, pushed: true, skipped: false, error: false }; }
152
+ if (!push) return { label, product: isProduct, committed: true, pushed: false, skipped: false, error: false };
153
+ if (pushWithRebase(root, branch).ok) { ok(`${label}: pushed to origin/${branch}`); return { label, product: isProduct, committed: true, pushed: true, skipped: false, error: false }; }
151
154
  // The commit already landed locally — a re-run of `yad update --push` would see no drift and skip
152
155
  // this repo, so point the operator at the direct push of the commit that already exists.
153
156
  fail(`${label}: could not push to origin/${branch} — a protected branch, or an unresolvable rebase conflict`);
154
- hand(`resolve it in ${label === 'hub' ? '.' : label}, then push the existing commit with \`git push origin ${branch}\``);
157
+ hand(`resolve it in ${isProduct ? '.' : label}, then push the existing commit with \`git push origin ${branch}\``);
155
158
  process.exitCode = 1;
156
- return { label, committed: true, pushed: false, skipped: false, error: true };
159
+ return { label, product: isProduct, committed: true, pushed: false, skipped: false, error: true };
157
160
  }
158
161
 
159
162
  // Orchestrate the per-repo commit/push over the grouped applied actions, bookended by the announce