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/repo.mjs CHANGED
@@ -1,17 +1,31 @@
1
- // `yad repo list|refresh|sync` — connected-repo maintenance as explicit HUMAN decisions.
1
+ // `yad repo list|refresh|sync|clone` — connected-repo maintenance as explicit HUMAN decisions.
2
2
  // list/refresh: staleness of the cached code-context pack (HEAD != syncedHead). Skill steps no longer
3
3
  // silently repack a stale repo; they flag it and point here. (`yad check --fix` refreshes too — also
4
4
  // human-invoked.) sync: switch every connected repo to its registry default_branch and fast-forward
5
5
  // it to origin — a working-tree-only op (it never writes the registry; a dirty tree is skipped).
6
+ // clone (E81): clone every registered repo that is missing on this machine — `yad join`'s clone step,
7
+ // for the repo a teammate registered after you joined. It never runs git in a repo already there.
8
+ import fs from 'node:fs';
6
9
  import path from 'node:path';
7
- import { c, log, ok, info, warn, hand, fail, readJSON, writeJSON, run } from './lib.mjs';
10
+ import { c, log, ok, info, warn, hand, fail, writeJSON, run } from './lib.mjs';
8
11
  import { PROJECT_FILES } from './manifest.mjs';
12
+ import { judgeRepo, cloneMissingRepos, reportClones, readRegistry, codeContextPathOk, CODE_CONTEXT_DIR, runnable, shown } from './workspace.mjs';
9
13
  import { gitHead, packRepo } from './setup.mjs';
10
- import { publishCodeContext } from './repo-publish.mjs';
14
+ import { publishCodeContext, packOf, codeMapOf } from './repo-publish.mjs';
11
15
 
16
+ // Why `refresh` will not pack this entry, or null when it will (E81): see the loop in `runRepo`.
17
+ function notPackable(root, repo, j) {
18
+ if (j.state === 'missing') return 'not cloned on this machine (`yad repo clone`)';
19
+ if (j.state === 'refused') return j.reason;
20
+ if (j.linked) return 'a folder on its path, inside a repo, is a link, so the checkout is not where the path says';
21
+ if (!codeContextPathOk(root, packOf(repo)) || !codeContextPathOk(root, codeMapOf(repo))) return `its contextPack or codeMap is not a path under ${CODE_CONTEXT_DIR}/ in the Product`;
22
+ return null;
23
+ }
24
+
25
+ // Strict (E81): a registry that does not parse is said, not read as "no repos" — and `refresh` writes it
26
+ // back, so a lenient read would replace a broken file (and every entry in it) with an empty list.
12
27
  function load(root) {
13
- const regPath = path.join(root, PROJECT_FILES.reposRegistry);
14
- return { regPath, registry: readJSON(regPath, { repos: [] }) };
28
+ return { regPath: path.join(root, PROJECT_FILES.reposRegistry), ...readRegistry(root) };
15
29
  }
16
30
 
17
31
  // HEAD != syncedHead => stale (config.yaml code_context.staleness: head-sha). A repo that has a HEAD but
@@ -29,45 +43,83 @@ const git = (cwd, ...args) => run('git', args, { cwd });
29
43
  const hasRemote = (cwd, remote = 'origin') => git(cwd, 'remote', 'get-url', remote).ok;
30
44
  const isDirty = (cwd) => { const r = git(cwd, 'status', '--porcelain'); return r.ok && r.stdout.length > 0; };
31
45
  const currentBranch = (cwd) => { const r = git(cwd, 'rev-parse', '--abbrev-ref', 'HEAD'); return r.ok ? r.stdout : null; };
32
- // Registry default_branch wins; else origin/HEAD; else 'main'.
46
+ // Registry default_branch wins; else origin/HEAD; else 'main'. The registry is shared content, and the
47
+ // name is handed to `git fetch` and `git checkout`: `--upload-pack=<command>` would run a command. So a
48
+ // recorded name must be a valid `refs/heads/<name>` (which also rules out `@{-1}`) and must not start
49
+ // with `-` or `+` (a forced refspec); anything else is null.
33
50
  function defaultBranch(cwd, repo) {
34
- if (repo.default_branch) return repo.default_branch;
51
+ if (repo.default_branch != null && repo.default_branch !== '') {
52
+ const b = repo.default_branch;
53
+ return typeof b === 'string' && !/^[-+]/.test(b) && git(cwd, 'check-ref-format', `refs/heads/${b}`).ok ? b : null;
54
+ }
55
+ // origin/HEAD is what the remote says — the remote the registry chose — so it passes the same test.
35
56
  const r = git(cwd, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD');
36
- return r.ok && r.stdout ? r.stdout.replace(/^origin\//, '') : 'main';
57
+ if (!r.ok || !r.stdout) return 'main';
58
+ const b = r.stdout.replace(/^origin\//, '');
59
+ return !/^[-+]/.test(b) && git(cwd, 'check-ref-format', `refs/heads/${b}`).ok ? b : null;
37
60
  }
38
61
 
39
62
  export async function runRepo(root, { action = 'list', name, today, push = false, allowBranch = false } = {}) {
40
- const { regPath, registry } = load(root);
63
+ const { regPath, registry, problem } = load(root);
64
+ if (problem) { fail(`${problem} — fix it in the Product and re-run`); process.exitCode = 1; return { action, repos: [] }; }
41
65
  if (!registry.repos.length) { warn('no repos registered (.sdlc/repos.json) — run `yad setup`'); return { action, repos: [] }; }
66
+ // An entry that is not an object (`null`, `5`) is named where it would be acted on, never dereferenced.
67
+ const isEntry = (r) => !!r && typeof r === 'object' && !Array.isArray(r);
68
+ const odd = registry.repos.filter((r) => !isEntry(r)).length;
69
+ if (odd) warn(`${odd} entr${odd > 1 ? 'ies' : 'y'} in ${PROJECT_FILES.reposRegistry} ${odd > 1 ? 'are' : 'is'} not an object — skipped; fix ${odd > 1 ? 'them' : 'it'} in the Product`);
70
+ const entries = registry.repos.filter(isEntry);
42
71
 
43
72
  if (action === 'list') {
44
73
  log(c.bold('\nconnected repos'));
45
74
  let staleCount = 0;
46
75
  const rows = []; // the --json answer (E1): each repo, as the list reads it
47
- for (const repo of registry.repos) {
76
+ let missing = 0;
77
+ for (const repo of entries) {
78
+ // Judged before git runs (E81): only a checkout that is there is read. A missing repo says so and
79
+ // names the command that clones it; a refused entry says why — git is never run in it.
80
+ const j = judgeRepo(root, repo);
81
+ // A checkout behind a link inside a repo's tree is not read either, as `sync` and `refresh` skip it.
82
+ if (j.linked) Object.assign(j, { state: 'refused', reason: 'a folder on its path, inside a repo, is a link, so the checkout is not where the path says' });
83
+ if (j.state === 'missing' || j.state === 'refused') {
84
+ rows.push({ name: repo.name ?? null, path: repo.path ?? null, head: null, syncedHead: repo.syncedHead ?? null, state: j.state === 'missing' ? 'missing' : 'refused', ...(j.reason ? { reason: j.reason } : {}) });
85
+ if (j.state === 'missing') { missing++; warn(`${j.name} ${c.dim(`(${j.path})`)} — ${c.yellow('not cloned on this machine')}`); }
86
+ else warn(`${j.name} ${c.dim(`(${j.path})`)} — ${j.reason}`);
87
+ continue;
88
+ }
48
89
  const { head, stale, unknown, neverPacked } = staleness(root, repo);
49
90
  rows.push({ name: repo.name ?? null, path: repo.path ?? null, head: head || null, syncedHead: repo.syncedHead ?? null,
50
91
  state: unknown ? 'unreadable' : neverPacked ? 'no-pack' : stale ? 'stale' : 'fresh' });
51
- if (unknown) { warn(`${repo.name} ${c.dim(`(${repo.path})`)} — HEAD unreadable`); continue; }
52
- if (neverPacked) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('no code-context pack yet')} (registered without one)`); }
53
- else if (stale) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('stale')} (HEAD moved since last pack)`); }
54
- else ok(`${repo.name} ${c.dim('— fresh')}`);
92
+ if (unknown) { warn(`${j.name} ${c.dim(`(${j.path})`)} — HEAD unreadable`); continue; }
93
+ if (neverPacked) { staleCount++; warn(`${j.name} ${c.dim(`(${j.path})`)} — ${c.yellow('no code-context pack yet')} (registered without one)`); }
94
+ else if (stale) { staleCount++; warn(`${j.name} ${c.dim(`(${j.path})`)} — ${c.yellow('stale')} (HEAD moved since last pack)`); }
95
+ else ok(`${j.name} ${c.dim('— fresh')}`);
55
96
  }
56
- if (staleCount) hand(`refresh with \`yad repo refresh${registry.repos.length > 1 ? ' <name>' : ''}\` (or \`yad repo refresh\` for all)`);
57
- return { action, repos: rows, stale: staleCount };
97
+ if (missing) hand('clone the missing repo(s) with `yad repo clone`');
98
+ if (staleCount) hand(`refresh with \`yad repo refresh${entries.length > 1 ? ' <name>' : ''}\` (or \`yad repo refresh\` for all)`);
99
+ return { action, repos: rows, stale: staleCount, missing };
58
100
  }
59
101
 
60
102
  if (action === 'refresh') {
61
- const targets = name ? registry.repos.filter((r) => r.name === name) : registry.repos;
62
- if (name && !targets.length) { fail(`unknown repo: ${name}`); process.exitCode = 1; return { action, refreshed: 0, repos: [], published: null }; }
103
+ const targets = name ? entries.filter((r) => r.name === name) : entries;
104
+ if (name && !targets.length) { fail(`unknown repo: ${shown(name)}`); process.exitCode = 1; return { action, refreshed: 0, repos: [], published: null }; }
63
105
  let refreshed = 0;
64
106
  let published = null;
65
107
  const rows = [];
66
108
  for (const repo of targets) {
109
+ // Judged before git runs (E81), as `sync` judges it: only a checkout that is there is packed, never
110
+ // one reached through a link inside a repo's tree (repomix would pack wherever the link points,
111
+ // git logs included). And the pack is written only where the Product keeps code context.
112
+ const j = judgeRepo(root, repo);
113
+ const why = notPackable(root, repo, j);
114
+ if (why) {
115
+ warn(`${j.name}: ${why} — skipped`);
116
+ rows.push({ name: repo.name ?? null, refreshed: false, head: null });
117
+ continue;
118
+ }
67
119
  const { head, unknown } = staleness(root, repo);
68
- if (unknown) { warn(`${repo.name}: HEAD unreadable — skipped`); rows.push({ name: repo.name ?? null, refreshed: false, head: null }); continue; }
69
- log(` ${c.bold(repo.name)}`);
70
- const packed = !!packRepo(root, repo);
120
+ if (unknown) { warn(`${j.name}: HEAD unreadable — skipped`); rows.push({ name: repo.name ?? null, refreshed: false, head: null }); continue; }
121
+ log(` ${c.bold(j.name)}`);
122
+ const packed = !!packRepo(root, { ...repo, contextPack: packOf(repo) });
71
123
  if (packed) {
72
124
  repo.syncedHead = head;
73
125
  if (today) repo.lastSyncedAt = today; // always stamp when a date is supplied (the CLI passes today)
@@ -75,6 +127,16 @@ export async function runRepo(root, { action = 'list', name, today, push = false
75
127
  }
76
128
  rows.push({ name: repo.name ?? null, refreshed: packed, head: head || null });
77
129
  }
130
+ // Never through a `.sdlc` the Product commits as a link: the rename would land in the folder it names
131
+ // (E81 review 17).
132
+ let sdlcLinked = false;
133
+ try { sdlcLinked = fs.lstatSync(path.dirname(regPath)).isSymbolicLink(); } catch { /* not there */ }
134
+ if (sdlcLinked) {
135
+ // A refusal stops here, `--push` included (E81 review 18): nothing is published after it.
136
+ fail(`${path.dirname(PROJECT_FILES.reposRegistry)} in the Product is a link — ${PROJECT_FILES.reposRegistry} was not written`);
137
+ process.exitCode = 1;
138
+ return { action, refreshed, repos: rows, published: null };
139
+ }
78
140
  writeJSON(regPath, registry);
79
141
  refreshed ? ok(`refreshed ${refreshed} repo(s)`) : info('nothing refreshed');
80
142
  if (push) {
@@ -94,43 +156,76 @@ export async function runRepo(root, { action = 'list', name, today, push = false
94
156
  }
95
157
 
96
158
  if (action === 'sync') {
97
- const targets = name ? registry.repos.filter((r) => r.name === name) : registry.repos;
98
- if (name && !targets.length) { fail(`unknown repo: ${name}`); process.exitCode = 1; return { action, synced: 0, skipped: 0, repos: [] }; }
159
+ const targets = name ? entries.filter((r) => r.name === name) : entries;
160
+ if (name && !targets.length) { fail(`unknown repo: ${shown(name)}`); process.exitCode = 1; return { action, synced: 0, skipped: 0, repos: [] }; }
99
161
  log(c.bold('\nsync connected repos'));
100
162
  let synced = 0, skipped = 0;
101
163
  for (const repo of targets) {
102
- const repoRoot = path.resolve(root, repo.path);
103
- const tag = `${repo.name} ${c.dim(`(${repo.path})`)}`;
164
+ // The registry is shared content, and sync runs git in each path (E81): the same judgement the
165
+ // clone step makes comes first. A path outside the workspace or through `.git` is refused; so is a
166
+ // folder with no `.git` (a committed folder shaped like a bare repo would have git read its
167
+ // `config`), and a checkout reached through a link inside a repo's tree (the Product's
168
+ // `evil -> ../../..`). A link of the person's own, directly in the workspace folder, is followed.
169
+ const j = judgeRepo(root, repo);
170
+ const tag = `${j.name} ${c.dim(`(${j.path})`)}`;
171
+ if (j.state === 'missing') { warn(`${tag} — not cloned on this machine — skipped (\`yad repo clone\`)`); skipped++; continue; }
172
+ if (j.state === 'refused') { warn(`${tag} — ${j.reason} — skipped`); skipped++; continue; }
173
+ if (j.linked) { warn(`${tag} — a folder on its path, inside a repo, is a link, so the checkout is not where the path says — skipped`); skipped++; continue; }
174
+ // A folder inside another checkout (a monorepo's `apps/web`): switching "its" branch would switch
175
+ // the whole checkout, on this entry's say-so. Sync the checkout it belongs to instead.
176
+ if (j.inside) { info(`${tag} — a folder inside the checkout ${shown(path.relative(root, j.inside) || '.')}, not a repo of its own — skipped (sync that checkout)`); skipped++; continue; }
177
+ const repoRoot = j.target || path.resolve(root, repo.path);
104
178
  if (!gitHead(repoRoot)) { warn(`${tag} — not a git repo / HEAD unreadable — skipped`); skipped++; continue; }
105
- if (isDirty(repoRoot)) { warn(`${repo.name} — ${c.yellow('dirty')} → SKIPPED (commit/stash first)`); skipped++; continue; }
179
+ if (isDirty(repoRoot)) { warn(`${j.name} — ${c.yellow('dirty')} → SKIPPED (commit/stash first)`); skipped++; continue; }
106
180
  const branch = defaultBranch(repoRoot, repo);
181
+ if (!branch) { warn(`${tag} — its default branch (recorded, or origin/HEAD) is not a plain branch name git accepts — skipped (fix default_branch in ${PROJECT_FILES.reposRegistry})`); skipped++; continue; }
107
182
  const remote = hasRemote(repoRoot);
183
+ // Printed through `shown` (E81 review 9): git accepts bytes above 0x7f in a branch name, and the
184
+ // remote a registered clone fetches from was chosen by the shared registry, so both can carry text
185
+ // a terminal obeys.
186
+ const b = shown(branch);
187
+ const firstLine = (s) => shown(String(s || '').split('\n')[0]);
108
188
  if (remote) {
109
189
  const f = git(repoRoot, 'fetch', 'origin', branch, '--prune');
110
- if (!f.ok) { warn(`${repo.name} — fetch failed (${(f.stderr.split('\n')[0]) || 'auth?'}) — skipped`); skipped++; continue; }
190
+ if (!f.ok) { warn(`${j.name} — fetch failed (${firstLine(f.stderr) || 'auth?'}) — skipped`); skipped++; continue; }
111
191
  }
112
192
  if (currentBranch(repoRoot) !== branch) {
113
193
  const co = git(repoRoot, 'checkout', branch);
114
- if (!co.ok) { warn(`${repo.name} — cannot switch to ${branch} (${co.stderr.split('\n')[0] || 'no such branch'}) — skipped`); skipped++; continue; }
194
+ if (!co.ok) { warn(`${j.name} — cannot switch to ${b} (${firstLine(co.stderr) || 'no such branch'}) — skipped`); skipped++; continue; }
115
195
  }
116
196
  if (remote) {
117
197
  const before = gitHead(repoRoot);
118
198
  const m = git(repoRoot, 'merge', '--ff-only', `origin/${branch}`);
119
- if (!m.ok) { warn(`${repo.name} — ${c.yellow('diverged')} on ${branch} → not fast-forwarded (resolve manually)`); skipped++; continue; }
120
- ok(`${repo.name} ${c.dim('—')} ${before === gitHead(repoRoot) ? `already current on ${branch}` : `switched to ${branch}, pulled (ff)`}`);
199
+ if (!m.ok) { warn(`${j.name} — ${c.yellow('diverged')} on ${b} → not fast-forwarded (resolve manually)`); skipped++; continue; }
200
+ ok(`${j.name} ${c.dim('—')} ${before === gitHead(repoRoot) ? `already current on ${b}` : `switched to ${b}, pulled (ff)`}`);
121
201
  } else {
122
- ok(`${repo.name} ${c.dim('—')} on ${branch} (local-only, no remote)`);
202
+ ok(`${j.name} ${c.dim('—')} on ${b} (local-only, no remote)`);
123
203
  }
124
204
  synced++;
125
205
  }
126
206
  // A pulled repo's HEAD moves, so its cached code-context pack goes stale — point the human at refresh.
127
- const staleCount = registry.repos.filter((r) => staleness(root, r).stale).length;
207
+ // Only the checkouts the loop would run git in (E81) — never a refused or linked entry.
208
+ const staleCount = entries.filter((r) => runnable(judgeRepo(root, r)) && staleness(root, r).stale).length;
128
209
  info(`synced ${synced}, skipped ${skipped}`);
129
210
  if (staleCount) hand(`${staleCount} repo(s) now have a stale code-context pack — \`yad repo refresh\` to repack`);
130
211
  return { action, synced, skipped, stale: staleCount };
131
212
  }
132
213
 
133
- fail(`unknown repo action: ${action} (list | refresh | sync)`);
214
+ if (action === 'clone') {
215
+ const targets = name ? entries.filter((r) => r.name === name) : entries;
216
+ if (name && !targets.length) { fail(`unknown repo: ${shown(name)}`); process.exitCode = 1; return { action, cloned: [], present: [], failed: [] }; }
217
+ log(c.bold('\nclone missing repos'));
218
+ const repos = cloneMissingRepos(root, { repos: targets });
219
+ reportClones(repos);
220
+ if (!repos.cloned.length && !repos.failed.length) info(name ? `${shown(name)} is already on this machine` : 'nothing missing — every registered repo is on this machine');
221
+ // Unlike `join` (whose other steps still matter), cloning is this command's only job: a repo it could
222
+ // not clone fails it, so a script sees it.
223
+ if (repos.failed.length) { hand(`${repos.failed.length} repo(s) not cloned — fix what is named above and re-run \`yad repo clone\``); process.exitCode = 1; }
224
+ if (repos.cloned.length) hand('build the cloned repos\' code-context pack with `yad repo refresh <name>` when you need it');
225
+ return { action, ...repos };
226
+ }
227
+
228
+ fail(`unknown repo action: ${action} (list | refresh | sync | clone)`);
134
229
  process.exitCode = 1;
135
230
  return { action };
136
231
  }
package/cli/report.mjs CHANGED
@@ -52,6 +52,8 @@ const SAFE_VERBS = new Set([
52
52
  'approve', 'advance', 'comment',
53
53
  // E43: the background capture of Shape artifacts.
54
54
  'capture',
55
+ // E79: the workspace verbs (`new` is also `epic new`'s action).
56
+ 'new', 'init', 'join',
55
57
  ]);
56
58
 
57
59
  // Reduce an argv to a safe command line: the leading verb chain (at most `command subcommand`, and
@@ -78,8 +80,8 @@ export function sanitizeArgv(argv = []) {
78
80
  // Build the safe, postable context — the ONLY data that can reach the issue. Reads doctor for tool
79
81
  // state but keeps just the booleans, never the raw checks (which carry names + paths).
80
82
  export function sanitizeContext(dir, { error = null, argv = process.argv.slice(2) } = {}) {
81
- const hub = readJSON(productConfigPath(dir), null);
82
- const platform = hub && ['github', 'gitlab'].includes(hub.platform) ? hub.platform : 'local';
83
+ const productConfig = readJSON(productConfigPath(dir), null);
84
+ const platform = productConfig && ['github', 'gitlab'].includes(productConfig.platform) ? productConfig.platform : 'local';
83
85
  // Derive tool auth from doctor's checks without keeping any check text.
84
86
  const toolState = (cli, p) => (has(cli) ? (platformAuthed(p) ? 'present + authenticated' : 'present, not authenticated') : 'not installed');
85
87
  const ctx = {
package/cli/review.mjs CHANGED
@@ -10,6 +10,7 @@ import { log, ok, info, warn, fail, note, run, readJSON, emitJSON, refuse, inJSO
10
10
  import { PROJECT_FILES , productConfigPath } from './manifest.mjs';
11
11
  import { updateShip } from './ledger.mjs';
12
12
  import { epicRoot } from './epic-state.mjs';
13
+ import { registryEntryFor } from './find-product.mjs';
13
14
  import {
14
15
  detectPlatform, readPr, mapApprovers, getPrBody, editPrBody, postComment, prNumberFromUrl,
15
16
  resolveBaseBranch,
@@ -22,14 +23,20 @@ const NUDGE_CMD = 'yad review chat';
22
23
  // Resolve the target code repo: --repo <name> from the registry (platform + path), else cwd.
23
24
  // An explicit --repo that is NOT in the registry is an error — never silently fall through to cwd (that
24
25
  // would operate on the wrong repo). Returns { error } in that case for the caller to surface.
25
- function resolveRepo(root, { repo, dir }) {
26
+ //
27
+ // `product` (E80) is the Product found from a code repo beside it: the registry, the code map and the
28
+ // epics are read there, and a repo run without --repo is looked up in the registry by its path.
29
+ function resolveRepo(root, { repo, dir, product }) {
30
+ const productRoot = product || root;
26
31
  if (repo) {
27
- const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
32
+ const reg = readJSON(path.join(productRoot, PROJECT_FILES.reposRegistry), { repos: [] });
28
33
  const found = (reg.repos || []).find((r) => r.name === repo);
29
34
  if (!found) return { error: `repo '${repo}' is not in .sdlc/repos.json — connect it first (yad-connect-repos)` };
30
- return { repoRoot: path.resolve(root, found.path), meta: found };
35
+ return { repoRoot: path.resolve(productRoot, found.path), meta: found, productRoot };
31
36
  }
32
- return { repoRoot: path.resolve(root, dir || '.'), meta: null };
37
+ const here = path.resolve(root, dir || '.');
38
+ const hit = registryEntryFor(product, here);
39
+ return { repoRoot: hit ? hit.top : here, meta: hit ? hit.meta : null, productRoot };
33
40
  }
34
41
 
35
42
  function platformOf(root, repoRoot, meta) {
@@ -41,8 +48,8 @@ function platformOf(root, repoRoot, meta) {
41
48
  // Assemble (but don't print) the Build grounding bundle. Shared by `context` and `walkthrough` so the
42
49
  // pair walkthrough adds an ordered stop-list on top of the exact same grounding the companion uses.
43
50
  // Returns { error } on a bad --repo, else { bundle, repoRoot, base }.
44
- function contextBundle(root, { repo, dir, pr, runner = run } = {}) {
45
- const rr = resolveRepo(root, { repo, dir });
51
+ function contextBundle(root, { repo, dir, product, pr, runner = run } = {}) {
52
+ const rr = resolveRepo(root, { repo, dir, product });
46
53
  if (rr.error) return { error: rr.error };
47
54
  const { repoRoot, meta } = rr;
48
55
  const platform = platformOf(root, repoRoot, meta);
@@ -58,8 +65,8 @@ function contextBundle(root, { repo, dir, pr, runner = run } = {}) {
58
65
  pr: pr || null,
59
66
  base,
60
67
  diffCmd: `git -C ${repoRoot} diff ${base}...HEAD`,
61
- codeMap: meta?.name ? path.join(root, '.sdlc/code-context', meta.name, 'code-map.md') : null,
62
- pack: meta?.name ? path.join(root, '.sdlc/code-context', meta.name, 'pack.md') : null,
68
+ codeMap: meta?.name ? path.join(rr.productRoot, '.sdlc/code-context', meta.name, 'code-map.md') : null,
69
+ pack: meta?.name ? path.join(rr.productRoot, '.sdlc/code-context', meta.name, 'pack.md') : null,
63
70
  contract: meta?.contract || null,
64
71
  markers: {
65
72
  trailerBegin: '<!-- yad:trailer -->', noblock: '<!-- yad:noblock -->',
@@ -71,8 +78,8 @@ function contextBundle(root, { repo, dir, pr, runner = run } = {}) {
71
78
 
72
79
  // `yad review context --repo <r> --pr <n>` — print the grounding bundle the companion uses to generate
73
80
  // the trailer / cards and run the chat over the CODE diff (grounded in the repo code-map + the PR).
74
- export async function reviewContext(root, { repo, dir, pr, runner = run } = {}) {
75
- const r = contextBundle(root, { repo, dir, pr, runner });
81
+ export async function reviewContext(root, { repo, dir, product, pr, runner = run } = {}) {
82
+ const r = contextBundle(root, { repo, dir, product, pr, runner });
76
83
  if (r.error) { refuse(r.error, null, { json: true }); return; }
77
84
  emitJSON(r.bundle);
78
85
  return r.bundle;
@@ -82,8 +89,8 @@ export async function reviewContext(root, { repo, dir, pr, runner = run } = {})
82
89
  // ordered `stops[]` (the code diff parsed into hunk-anchored, risk-tagged review stops, highest-risk
83
90
  // first). The CLI sequences deterministically; the skill (yad-pair-review) walks the stops, generates
84
91
  // the per-stop briefing + Socratic question, and runs the two-way session. No LLM here, no ledger write.
85
- export async function reviewWalkthrough(root, { repo, dir, pr, runner = run } = {}) {
86
- const r = contextBundle(root, { repo, dir, pr, runner });
92
+ export async function reviewWalkthrough(root, { repo, dir, product, pr, runner = run } = {}) {
93
+ const r = contextBundle(root, { repo, dir, product, pr, runner });
87
94
  if (r.error) { refuse(r.error, null, { json: true }); return; }
88
95
  const { bundle, repoRoot, base } = r;
89
96
  const diff = runner('git', ['-C', repoRoot, 'diff', `${base}...HEAD`]);
@@ -99,10 +106,10 @@ export async function reviewWalkthrough(root, { repo, dir, pr, runner = run } =
99
106
 
100
107
  // `yad review trailer --repo <r> --pr <n> --body <text>` — idempotently upsert the 60-sec briefing into
101
108
  // the code PR/MR description (delimited block; safe to re-run after a push).
102
- export async function reviewTrailer(root, { repo, dir, pr, body, getBody = getPrBody, editBody = editPrBody } = {}) {
109
+ export async function reviewTrailer(root, { repo, dir, product, pr, body, getBody = getPrBody, editBody = editPrBody } = {}) {
103
110
  if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
104
111
  if (!body || !String(body).trim()) { fail('trailer body is required: `yad review trailer --repo <r> --pr <n> --body <text>`'); process.exitCode = 1; return; }
105
- const rr = resolveRepo(root, { repo, dir });
112
+ const rr = resolveRepo(root, { repo, dir, product });
106
113
  if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
107
114
  const { repoRoot, meta } = rr;
108
115
  const platform = platformOf(root, repoRoot, meta);
@@ -117,9 +124,9 @@ export async function reviewTrailer(root, { repo, dir, pr, body, getBody = getPr
117
124
 
118
125
  // `yad review nudge --repo <r> --pr <n>` — friendly public @-mention on a bare approve (engagement none)
119
126
  // of a code PR. A platform comment (carries the noblock marker so it never blocks); call once per PR.
120
- export async function reviewNudge(root, { repo, dir, pr, reader = readPr, poster = postComment } = {}) {
127
+ export async function reviewNudge(root, { repo, dir, product, pr, reader = readPr, poster = postComment } = {}) {
121
128
  if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
122
- const rr = resolveRepo(root, { repo, dir });
129
+ const rr = resolveRepo(root, { repo, dir, product });
123
130
  if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
124
131
  const { repoRoot, meta } = rr;
125
132
  const platform = platformOf(root, repoRoot, meta);
@@ -141,10 +148,10 @@ export async function reviewNudge(root, { repo, dir, pr, reader = readPr, poster
141
148
  // so the build ledger reflects who actually engaged. The first CLI to write build-log.json. Matches the
142
149
  // ship record by its `pr` field (url or number); if none exists yet, prints the engineer_review block
143
150
  // for the engineer/CI to attach at ship time (we never fabricate a story/task).
144
- export async function reviewReconcile(root, { epic, repo, dir, pr, reader = readPr } = {}) {
151
+ export async function reviewReconcile(root, { epic, repo, dir, product, pr, reader = readPr } = {}) {
145
152
  if (!epic) { fail('--epic <id> is required'); process.exitCode = 1; return; }
146
153
  if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
147
- const rr = resolveRepo(root, { repo, dir });
154
+ const rr = resolveRepo(root, { repo, dir, product });
148
155
  if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
149
156
  const { repoRoot, meta } = rr;
150
157
  const platform = platformOf(root, repoRoot, meta);
@@ -167,7 +174,7 @@ export async function reviewReconcile(root, { epic, repo, dir, pr, reader = read
167
174
  // build-log is shard-then-fold now: updateShip mutates the ship's own loose shard (authoritative
168
175
  // until `yad tidy up` folds it), or the folded file if already folded — never both.
169
176
  const res = updateShip(
170
- epicRoot(root, epic),
177
+ epicRoot(rr.productRoot, epic),
171
178
  (s) => s.pr != null && (String(s.pr) === String(pr) || prNumberFromUrl(s.pr) === String(pr)),
172
179
  (s) => { s.engineer_review = engineerReview; },
173
180
  );