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
@@ -0,0 +1,495 @@
1
+ // `yad new` · `yad init` · `yad join` (E79) — the three ways into a workspace.
2
+ //
3
+ // A WORKSPACE is the folder that holds the Product and the code repos side by side:
4
+ //
5
+ // my-product/
6
+ // product/ <- the Product (goals, epics, decisions) — its own git repo
7
+ // backend/ <- a code repo, registered in product/.sdlc/repos.json as `../backend`
8
+ // frontend/
9
+ //
10
+ // The workspace is the Product folder's parent (`insideWorkspace` in setup.mjs, issue #129). All three
11
+ // commands write `.yad-workspace.json` there (E80, cli/find-product.mjs), so yad finds the Product from
12
+ // inside any repo it registers.
13
+ //
14
+ // yad new <name> greenfield: make <name>/product/, `git init` it, run setup inside it
15
+ // yad init brownfield: in a folder that already holds code repos, make (or pick) the
16
+ // Product folder, run setup, and offer the repos found beside it
17
+ // yad join <url> anyone else: clone the Product and every repo it registers, then do the
18
+ // per-machine steps only
19
+ //
20
+ // `yad setup` is unchanged: new and init are front doors over it, not a replacement.
21
+ //
22
+ // NOTHING HERE WRITES OUTSIDE THIS MACHINE. `new` never creates a remote — it prints the exact
23
+ // `gh repo create` / `glab repo create` line. `join` never commits, never pushes, and never changes a
24
+ // file the team shares: it installs only what git ignores in the Product (the per-machine skill copies)
25
+ // and the git pre-commit hook, which git never shares (E48).
26
+ import path from 'node:path';
27
+ import fs from 'node:fs';
28
+ import { spawnSync } from 'node:child_process';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { c, log, ok, info, warn, hand, fail, run, exists, readJSON, readJSONStrict, forTerminal } from './lib.mjs';
31
+ import { productConfigPath, PROJECT_FILES } from './manifest.mjs';
32
+ import { runSetup, insideWorkspace, throughGitDir, selectIdeTargets } from './setup.mjs';
33
+ import { moduleActions, gitHookActions } from './plan.mjs';
34
+ import { writeWorkspaceFile, WORKSPACE_FILE } from './find-product.mjs';
35
+
36
+ // E80: the file that lets yad find the Product from inside the repos it registers. Written by all three.
37
+ function noteWorkspaceFile(product) {
38
+ const r = writeWorkspaceFile(product);
39
+ if (r === 'written') ok(`wrote ${WORKSPACE_FILE} — yad finds the Product from any repo it registers`);
40
+ else if (r.startsWith('skipped')) warn(`${WORKSPACE_FILE} not written (${r.replace(/^skipped: /, '')})`);
41
+ return r;
42
+ }
43
+
44
+ // The Product's folder inside a workspace. `join` clones to it and `new` creates it, so a workspace
45
+ // made by `new` and one made by `join` from its remote look the same.
46
+ export const PRODUCT_DIR = 'product';
47
+
48
+ // A folder name we create: letters, digits, `.`, `_`, `-`, not starting with `.` or `-`. No separator,
49
+ // so it is always one folder under the one it is made in.
50
+ const FOLDER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
51
+ export const validFolderName = (name) => typeof name === 'string' && FOLDER_RE.test(name);
52
+
53
+ // Text read from a shared file is shown in the terminal through `shown` — `forTerminal` in lib.mjs, one
54
+ // cleaner for every module that prints registry text (E81 review 12).
55
+ export const shown = forTerminal;
56
+
57
+ // A folder as the person's shell reaches it: relative to where they ran yad (which `--dir` may not
58
+ // be), quoted when it holds a space.
59
+ const fromShell = (dir) => { const r = path.relative(process.cwd(), dir) || '.'; return /\s/.test(r) ? `"${r}"` : r; };
60
+
61
+ const isEmptyDir = (dir) => { try { return fs.readdirSync(dir).length === 0; } catch { return false; } };
62
+ // Empty, or holding only `.git`: a fresh clone of an empty remote.
63
+ const isBareStart = (dir) => { try { return fs.readdirSync(dir).every((n) => n === '.git'); } catch { return false; } };
64
+
65
+ // `insideWorkspace` and `throughGitDir` judge the path TEXT. A link the Product commits makes the text
66
+ // lie: `evil -> ../../..` carries `evil/x` outside the workspace, and `g -> .git` carries `g/hooks` into
67
+ // git's own storage, where a cloned hook or config runs commands (E79 reviews 1 and 3). So a clone
68
+ // target is refused when any folder on the way to it, below the workspace, is a link — a dangling one
69
+ // too. What is not there yet is made by this clone, so it cannot be a link.
70
+ function throughLink(productRoot, target) {
71
+ const root = path.resolve(productRoot);
72
+ const workspace = path.dirname(root) === root ? root : path.dirname(root);
73
+ let cur = workspace;
74
+ for (const part of path.relative(workspace, target).split(path.sep).filter(Boolean)) {
75
+ cur = path.join(cur, part);
76
+ let st;
77
+ try { st = fs.lstatSync(cur); } catch { return false; }
78
+ if (st.isSymbolicLink()) return true;
79
+ }
80
+ return false;
81
+ }
82
+ const insideGitRepo = (dir) => run('git', ['rev-parse', '--show-toplevel'], { cwd: dir });
83
+
84
+ // The folder `join` makes from a remote URL: its last path segment without `.git`. So the remote
85
+ // `new acme` tells you to create (`acme`) joins back into `acme/`.
86
+ export function workspaceNameFromUrl(url) {
87
+ const last = String(url || '').replace(/[\\/]+$/, '').split(/[\\/:]/).pop() || '';
88
+ return last.replace(/\.git$/i, '');
89
+ }
90
+
91
+ // `git init` on a named branch. `-b` needs git 2.28; an older git gets the same result from pointing
92
+ // the unborn HEAD at the branch.
93
+ export function gitInit(dir, branch = 'main') {
94
+ if (run('git', ['init', '-q', '-b', branch], { cwd: dir }).ok) return true;
95
+ if (!run('git', ['init', '-q'], { cwd: dir }).ok) return false;
96
+ return run('git', ['symbolic-ref', 'HEAD', `refs/heads/${branch}`], { cwd: dir }).ok;
97
+ }
98
+
99
+ // How a clone may reach its source. `join`'s Product URL is the person's own, typed by them: git's own
100
+ // rules. A REGISTERED repo's URL was chosen by the shared registry, so git is told which transports it
101
+ // may use, by `GIT_ALLOW_PROTOCOL` (git 2.6.1+) — which git obeys over every `protocol.*.allow` setting,
102
+ // so a value the person's environment carries cannot widen it (E81 review 5) — and by
103
+ // `protocol.file.allow` as a second word. A network URL gets the network transports only, never the
104
+ // file one: git reads `evil.com:x` as a LOCAL folder when one by that name exists (E81 reviews 3–4). A
105
+ // local source (the YAD_ALLOW_LOCAL_REMOTES door) gets the file transport only.
106
+ const TRANSPORTS = { network: 'https:http:ssh:git', local: 'file' };
107
+ function gitClone(url, target, env, { kind = null } = {}) {
108
+ const allow = TRANSPORTS[kind];
109
+ const args = allow ? ['-c', `protocol.file.allow=${kind === 'local' ? 'always' : 'never'}`] : [];
110
+ const r = spawnSync('git', [...args, 'clone', '-q', '--', url, target], { encoding: 'utf8', env: allow ? { ...env, GIT_ALLOW_PROTOCOL: allow } : env, windowsHide: true });
111
+ let error = (r.stderr || r.error?.message || '').trim().split('\n').pop() || `git clone exited ${r.status}`;
112
+ if (kind === 'network' && /transport '.*' not allowed/.test(error)) error += ' — a registered repo is cloned over the network only (an `insteadOf` rule of yours may point it at a local folder)';
113
+ return { ok: r.status === 0, error };
114
+ }
115
+
116
+ // A registered repo's `git_url` is shared text, handed to `git clone`. Only a network address is cloned:
117
+ // `https://`, `http://`, `ssh://`, `git://`, or scp-style `user@host:path` / `host:path` (an ssh alias
118
+ // too). A plain path, `file://` or any `<helper>::` URL is refused — a local path can name a folder the
119
+ // Product itself commits (E81 review 3). The pattern is the first gate; the transports `gitClone` allows
120
+ // are the second.
121
+ //
122
+ // A team whose remotes really are on a disk sets YAD_ALLOW_LOCAL_REMOTES=1: then a local path or
123
+ // `file://` URL is cloned when it names a FOLDER THAT EXISTS outside the workspace. It is resolved on
124
+ // disk (links followed, `file:///C:/x` read as Windows does, `%` refused because git decodes it), and git
125
+ // is handed that resolved path — never the typed text, which git could read another way (it tries
126
+ // `<path>.git` when `<path>` is not there; E81 review 5). Compared without case where the disk ignores it.
127
+ // The host keeps to letters, digits, `.`, `_` and `-` (an IPv6 host in brackets), and the user and
128
+ // password to those plus `~` and `+`, in every form git hands to ssh: an ssh config whose `ProxyCommand`
129
+ // or `Match exec` uses `%h` or `%r` on an OpenSSH before 9.6 puts them in a shell command
130
+ // (CVE-2023-51385; E81 reviews 6–7). No `%` there: git decodes `%24%28` to `$(` before ssh sees it.
131
+ // Only `http(s)://`, which goes to curl and never to ssh, may carry `%` in its user and password (a
132
+ // login that is an email address, `me%40corp.com@`).
133
+ // Each starts with a letter or digit — never `-`, which ssh would read as an option (git refuses it too).
134
+ const HOST = String.raw`(?:\w[\w-]*(?:\.[\w-]+)*|\[[0-9a-f:.]+\])`;
135
+ const USER = String.raw`\w[\w.~+-]*`;
136
+ const HTTP_USER = String.raw`[\w%][\w.~%+-]*`;
137
+ const NETWORK_URL_RE = new RegExp(String.raw`^(?![^/]*::)(?:https?:\/\/(?:${HTTP_USER}(?::${HTTP_USER}?)?@)?${HOST}(?::\d*)?(?:\/|$)|(?:ssh|git|git\+ssh|ssh\+git):\/\/(?:${USER}(?::${USER}?)?@)?${HOST}(?::\d*)?(?:\/|$)|${USER}@${HOST}:(?!\/\/)|\w[\w-]+(?:\.[\w-]+)*:(?!\/\/))`, 'i');
138
+ // A control character anywhere in the URL, typed or %-encoded (`%0d`, `%1b`), is refused. git decodes
139
+ // the user, password and path of a `scheme://` URL and writes them to the person's credential helper one
140
+ // `key=value` per line: a carriage return there makes a helper that ends lines on it (Git Credential
141
+ // Manager does) read `host=github.com` and hand that token to the URL's own host (CVE-2024-52006); an
142
+ // escape in the user repaints the terminal in git's password prompt (CVE-2024-50349). git before 2.48.1
143
+ // guards against neither (E81 review 8). The C1 controls (U+0080–U+009F — U+009B starts a terminal
144
+ // escape), the line and paragraph separators and the bidi controls are refused raw or as their UTF-8
145
+ // %-escapes too (`%c2%9b`, `%e2%80%ae`; E81 review 9). `%40` and every other printable escape pass.
146
+ // eslint-disable-next-line no-control-regex -- matching control characters is the point
147
+ const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u202a-\u202e\u2066-\u2069\u061c]|%(?:[01][0-9a-f]|7f|c2%[89][0-9a-f]|d8%9c|e2%80%(?:a[89a-e]|8[ef])|e2%81%a[6-9])/i;
148
+ export const localRemotesAllowed = (env = process.env) => env.YAD_ALLOW_LOCAL_REMOTES === '1';
149
+ const folded = (p) => (process.platform === 'darwin' || process.platform === 'win32' ? p.toLowerCase() : p);
150
+ const under = (p, dir) => p === dir || p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep);
151
+ // { kind: 'network', url } — the URL as recorded; { kind: 'local', url } — the resolved folder; or null.
152
+ export function cloneSource(productRoot, url, env = process.env) {
153
+ if (CONTROL_RE.test(url)) return null;
154
+ if (NETWORK_URL_RE.test(url)) return { kind: 'network', url };
155
+ if (!localRemotesAllowed(env) || url.includes('%') || url.includes('::')) return null;
156
+ let p = url;
157
+ if (/^file:/i.test(url)) { try { p = fileURLToPath(url); } catch { return null; } }
158
+ if (!path.isAbsolute(p)) return null;
159
+ let real;
160
+ try { real = fs.realpathSync.native(p); if (!fs.statSync(real).isDirectory()) return null; } catch { return null; }
161
+ const root = path.resolve(productRoot);
162
+ let workspace = path.dirname(root) === root ? root : path.dirname(root);
163
+ try { workspace = fs.realpathSync.native(workspace); } catch { /* as resolved */ }
164
+ return under(folded(real), folded(workspace)) ? null : { kind: 'local', url: real };
165
+ }
166
+
167
+ // A judged entry git may run in: a checkout that is there (or the Product itself), and not reached through
168
+ // a link inside a repo's tree (E81). Doctor's checks and `sync`'s stale count use it; the `list`,
169
+ // `refresh` and `sync` loops branch on the same states themselves, to say which one applies.
170
+ export const runnable = (j) => (j.state === 'present' || j.state === 'product') && !j.linked;
171
+
172
+ // A clone that needs a password must not sit waiting at a prompt nobody will answer.
173
+ const cloneEnv = () => (process.env.SDLC_NONINTERACTIVE ? { ...process.env, GIT_TERMINAL_PROMPT: '0' } : process.env);
174
+
175
+ // WHERE A REGISTRY ENTRY STANDS ON THIS MACHINE, judged before any git runs (E81). Shared by every
176
+ // command that acts on the registered repos — the clone step (`yad join`, `yad repo clone`), `yad repo
177
+ // list`, `yad repo sync` and `yad doctor` — so one never says "run X" for an entry X then refuses.
178
+ //
179
+ // The registry is SHARED content — anyone who can push to the Product wrote it — so a path outside the
180
+ // workspace is refused (the same bound setup keeps), a path through a `.git` folder or a link is refused,
181
+ // and a folder that exists but holds no `.git` is refused too: a Product can commit a folder shaped like
182
+ // a BARE git repo (`HEAD`, `config`, `objects/`), and git run there reads that `config`, whose
183
+ // `core.fsmonitor` runs a command. The Product itself (`.`, a monorepo) is its own state.
184
+ //
185
+ // Returns { name, path, target, state, reason?, inside?, linked?, url?, urlKind? } — `url` and `urlKind`
186
+ // ('network' | 'local') only for 'missing': what the clone step hands git. State is one of:
187
+ // 'product' the Product itself; never cloned
188
+ // 'present' a git checkout is there (`.git` at the path), or the path is a folder inside one (`inside`
189
+ // names the checkout). `linked` is set when a folder on the way, deeper than the workspace
190
+ // folder itself, is a link — see `inRepoLink`
191
+ // 'missing' nothing there yet, and the clone step can make it
192
+ // 'refused' not there, or not a checkout, and the clone step will not make it; `reason` says why
193
+ export function judgeRepo(productRoot, repo) {
194
+ if (!repo || typeof repo !== 'object' || Array.isArray(repo)) return { name: '(not an entry)', path: '', state: 'refused', reason: 'this entry in repos.json is not an object' };
195
+ const name = shown(repo?.name || '(unnamed)');
196
+ const rpath = typeof repo?.path === 'string' ? repo.path : '';
197
+ const entry = { name, path: shown(rpath) };
198
+ if (!rpath) return { ...entry, state: 'refused', reason: 'no path recorded' };
199
+ const target = path.resolve(productRoot, rpath);
200
+ const at = { ...entry, target };
201
+ if (target === path.resolve(productRoot)) return { ...at, state: 'product', reason: 'the Product itself' };
202
+ if (throughGitDir(productRoot, rpath)) return { ...at, state: 'refused', reason: 'it runs through a .git folder — git\'s own storage, never a code repo' };
203
+ if (!insideWorkspace(productRoot, rpath)) return { ...at, state: 'refused', reason: 'outside the workspace (the Product folder\'s parent)' };
204
+ if (exists(path.join(target, '.git'))) return { ...at, state: 'present', ...(inRepoLink(productRoot, target) ? { linked: true } : {}) };
205
+ // A folder inside a checkout (the monorepo layout: `apps/web` in the Product's own repo) has no `.git`
206
+ // of its own, and git finds the checkout by walking up. It is present when no link deeper than the
207
+ // workspace folder is on the way (so the walk git makes is the one read here — a link of the person's
208
+ // own directly in the workspace folder is followed, as for a checkout) and no folder on the way could
209
+ // be taken for a bare repo, where git would stop and read that folder's `config`.
210
+ const top = exists(target) && !inRepoLink(productRoot, target) ? enclosingCheckout(productRoot, target) : null;
211
+ if (top) {
212
+ if (headOnWay(target, top)) return { ...at, state: 'refused', reason: 'a folder on its way holds an entry named HEAD, so git could take it for a bare repo and read its config' };
213
+ return { ...at, state: 'present', inside: top };
214
+ }
215
+ if (throughLink(productRoot, target)) return { ...at, state: 'refused', reason: 'a folder on its path is a link, so where the clone lands is not what the path says' };
216
+ if (exists(target) && !isEmptyDir(target)) return { ...at, state: 'refused', reason: 'the folder exists and is not a git repo — move it aside and re-run' };
217
+ const url = typeof repo.git_url === 'string' ? repo.git_url.trim() : '';
218
+ if (!url) return { ...at, state: 'refused', reason: 'no git_url recorded — clone it by hand' };
219
+ if (url.startsWith('-')) return { ...at, state: 'refused', reason: 'the recorded git_url starts with "-"' };
220
+ const src = cloneSource(productRoot, url);
221
+ if (!src) return { ...at, state: 'refused', reason: 'the recorded git_url is not a network address (https://, http://, ssh://, git://, user@host:path or host:path) — clone it by hand, or set YAD_ALLOW_LOCAL_REMOTES=1 for a local folder outside the workspace' };
222
+ return { ...at, state: 'missing', url: src.url, urlKind: src.kind };
223
+ }
224
+
225
+ // The nearest folder above `target`, below the workspace folder, that holds `.git`: the checkout git
226
+ // finds from `target`. Null when there is none.
227
+ function enclosingCheckout(productRoot, target) {
228
+ const root = path.resolve(productRoot);
229
+ const workspace = path.dirname(root) === root ? root : path.dirname(root);
230
+ for (let cur = path.dirname(target); cur.startsWith(workspace + path.sep); cur = path.dirname(cur)) {
231
+ if (exists(path.join(cur, '.git'))) return cur;
232
+ }
233
+ return null;
234
+ }
235
+
236
+ // Git takes a folder for a bare repo only when it holds `HEAD` — then `objects/` and `refs/` may come
237
+ // from anywhere (a `commondir` file points elsewhere), and `HEAD` may be a link that points nowhere
238
+ // (E81 review 2 made git run a command both ways). So the one sound test is the part git always needs:
239
+ // ANY entry named `HEAD`, of any kind, read with `lstat` so a dangling link counts. The file system
240
+ // matches the name as git's own lookup does (without case on macOS and Windows). From `target` up to,
241
+ // not including, the checkout `top`.
242
+ const hasHead = (dir) => { try { fs.lstatSync(path.join(dir, 'HEAD')); return true; } catch { return false; } };
243
+ function headOnWay(target, top) {
244
+ for (let cur = target; cur !== top && cur.startsWith(top + path.sep); cur = path.dirname(cur)) {
245
+ if (hasHead(cur)) return true;
246
+ }
247
+ return false;
248
+ }
249
+
250
+ // Where `yad repo refresh` writes a repo's pack and where `--push` stages its code-map: from the SHARED
251
+ // registry (`contextPack`, `codeMap`, or a default built from the repo's `name`). Allowed only under
252
+ // `.sdlc/code-context/` in the Product, with no `..`, no `.git` part and no link on the way — else a
253
+ // registry entry chooses a file outside the Product to write, or one inside it to commit and push.
254
+ export const CODE_CONTEXT_DIR = '.sdlc/code-context';
255
+ export function codeContextPathOk(productRoot, rel) {
256
+ if (typeof rel !== 'string' || !rel || path.isAbsolute(rel) || /^[A-Za-z]:/.test(rel)) return false;
257
+ const norm = path.posix.normalize(rel.replace(/\\/g, '/'));
258
+ if (!norm.startsWith(`${CODE_CONTEXT_DIR}/`)) return false;
259
+ const parts = norm.split('/');
260
+ if (parts.some((p) => p === '..' || /^\.git$/i.test(p.split(':')[0].replace(/[. ]+$/, '')) || /^git~\d+$/i.test(p))) return false;
261
+ let cur = path.resolve(productRoot);
262
+ for (const part of parts) {
263
+ cur = path.join(cur, part);
264
+ let st;
265
+ try { st = fs.lstatSync(cur); } catch { return true; }
266
+ if (st.isSymbolicLink()) return false;
267
+ }
268
+ return true;
269
+ }
270
+
271
+ // A link on the way to a PRESENT repo that lies inside some repo's tree — deeper than the workspace
272
+ // folder's own children. The workspace folder is the person's: `ws/backend -> /src/backend` is their own
273
+ // layout, and a clone there is theirs. A link any deeper was written by whoever writes that repo — the
274
+ // Product's `evil -> ../../..` — so `yad repo sync` will not run git through it.
275
+ function inRepoLink(productRoot, target) {
276
+ const root = path.resolve(productRoot);
277
+ const workspace = path.dirname(root) === root ? root : path.dirname(root);
278
+ const parts = path.relative(workspace, target).split(path.sep).filter(Boolean);
279
+ let cur = workspace;
280
+ for (const [i, part] of parts.entries()) {
281
+ cur = path.join(cur, part);
282
+ let st;
283
+ try { st = fs.lstatSync(cur); } catch { return false; }
284
+ if (i > 0 && st.isSymbolicLink()) return true;
285
+ }
286
+ return false;
287
+ }
288
+
289
+ // THE CLONE STEP — `yad join` and `yad repo clone` (E81). For each repo the registry lists that
290
+ // `judgeRepo` finds missing, put a clone at its `path` (relative to the Product). The URL is passed after
291
+ // `--` so it can never be read as a git option. A failure is recorded and the next repo is tried: one bad
292
+ // entry never stops the rest (the roadmap: "partial clone failures never fail the whole join"). A present
293
+ // repo is only ever skipped — nothing here runs git in it.
294
+ //
295
+ // Returns { cloned, present, failed }, each a list of { name, path, reason? }.
296
+ export function cloneMissingRepos(productRoot, registry, { clone = gitClone, env = cloneEnv() } = {}) {
297
+ const out = { cloned: [], present: [], failed: [] };
298
+ // A `repos` that is not a list (`{}`, `5`) is a broken shared file: nothing to clone, and the caller
299
+ // says so — the per-machine steps still run.
300
+ for (const repo of Array.isArray(registry?.repos) ? registry.repos : []) {
301
+ const j = judgeRepo(productRoot, repo);
302
+ const entry = { name: j.name, path: j.path };
303
+ if (j.state === 'product') { out.present.push({ ...entry, reason: j.reason }); continue; }
304
+ if (j.state === 'present') { out.present.push(entry); continue; }
305
+ if (j.state === 'refused') { out.failed.push({ ...entry, reason: j.reason }); continue; }
306
+ // A path through a file (`.sdlc/hub.json/x`) or a dangling link fails here — for this entry only.
307
+ let r;
308
+ try {
309
+ fs.mkdirSync(path.dirname(j.target), { recursive: true });
310
+ // The transports by what the URL passed as (E81 reviews 4–5): see `gitClone`.
311
+ r = clone(j.url, j.target, env, { kind: j.urlKind });
312
+ } catch (e) { r = { ok: false, error: `cannot make its folder (${e.code || e.message})` }; }
313
+ if (r.ok) out.cloned.push(entry);
314
+ else out.failed.push({ ...entry, reason: shown(r.error) });
315
+ }
316
+ return out;
317
+ }
318
+
319
+ // What the clone step did, said the same way by `join` and `repo clone`.
320
+ export function reportClones(repos) {
321
+ for (const r of repos.cloned) ok(`cloned ${r.name} → ${r.path}`);
322
+ for (const r of repos.present) info(`${r.name}: ${r.reason || 'already there'}`);
323
+ for (const r of repos.failed) warn(`${r.name} (${r.path}): ${r.reason}`);
324
+ }
325
+
326
+ // The Product's registry, read strictly (E81, from join): a file that does not parse (a merge-conflict
327
+ // marker left in it) must be SAID, not read as "no repos". Returns { registry, problem } — `problem` is
328
+ // the sentence to show, with the file's absolute path (a home folder name, spaces and all) taken out;
329
+ // `registry` is then `{ repos: [] }`. A file that parses and holds no list (`null`, `[]`, `5`) is a
330
+ // problem too.
331
+ export function readRegistry(productRoot) {
332
+ const regFile = path.join(productRoot, PROJECT_FILES.reposRegistry);
333
+ let registry;
334
+ try { registry = readJSONStrict(regFile, { repos: [] }); }
335
+ catch (e) {
336
+ const why = (e.message || '').replace(`corrupt JSON in ${regFile}: `, '').split(regFile).join(PROJECT_FILES.reposRegistry);
337
+ return { registry: { repos: [] }, problem: `${PROJECT_FILES.reposRegistry} in the Product cannot be read (${shown(why)})` };
338
+ }
339
+ if (!Array.isArray(registry?.repos)) return { registry: { repos: [] }, problem: `${PROJECT_FILES.reposRegistry} has no list of repos` };
340
+ return { registry, problem: null };
341
+ }
342
+
343
+ // The lines that put the Product on a platform — printed, never run.
344
+ export function remoteSteps({ name, platform, branch = 'main' }) {
345
+ const first = ['git add -A', `git commit -m "chore: start the ${name} Product"`];
346
+ if (platform === 'github') return [...first, `gh repo create ${name} --private --source=. --remote=origin --push`];
347
+ if (platform === 'gitlab') return [...first, `glab repo create ${name} --private`, 'git remote add origin <the URL glab printed>', `git push -u origin ${branch}`];
348
+ return [...first, 'git remote add origin <your remote URL>', `git push -u origin ${branch}`];
349
+ }
350
+
351
+ const refuse = (message) => { fail(message); process.exitCode = 1; return null; };
352
+
353
+ // ---- yad new <name> ------------------------------------------------------------------------------
354
+ export async function runNew(cwd, name, opts = {}) {
355
+ log(c.bold('\nyad new'));
356
+ if (!validFolderName(name)) return refuse(`usage: yad new <name> — a folder name of letters, digits, ".", "_" or "-" (got ${name === undefined ? 'nothing' : JSON.stringify(shown(name))})`);
357
+ const inside = insideGitRepo(cwd);
358
+ if (inside.ok) return refuse(`${cwd} is inside the git repo ${inside.stdout} — a workspace holds its repos side by side, so run \`yad new\` from a folder outside any repo`);
359
+ const workspace = path.join(cwd, name);
360
+ if (exists(workspace) && !isEmptyDir(workspace)) return refuse(`${workspace} already exists and is not empty — pick another name, or run \`yad init\` inside it to connect what is there`);
361
+ const product = path.join(workspace, PRODUCT_DIR);
362
+ fs.mkdirSync(product, { recursive: true });
363
+ if (!gitInit(product)) return refuse(`git init failed in ${product}`);
364
+ ok(`created ${path.join(name, PRODUCT_DIR)}/ (git, branch main)`);
365
+
366
+ const setup = await runSetup(product, { ...opts, greenfield: !opts.brownfield });
367
+ if (process.exitCode) return null;
368
+ noteWorkspaceFile(product);
369
+
370
+ // Setup asked for the default branch; the repo has no commit yet, so HEAD can still follow the answer.
371
+ const productConfig = readJSON(productConfigPath(product), {}) || {};
372
+ const branch = productConfig.default_branch || 'main';
373
+ if (branch !== 'main' && !run('git', ['rev-parse', '-q', '--verify', 'HEAD'], { cwd: product }).ok) {
374
+ run('git', ['symbolic-ref', 'HEAD', `refs/heads/${branch}`], { cwd: product });
375
+ }
376
+ const steps = remoteSteps({ name, platform: productConfig.platform, branch });
377
+ log('');
378
+ log(c.bold('Put the Product on your platform') + c.dim(' (yad creates nothing outside this machine — run these yourself):'));
379
+ log(` cd ${fromShell(product)}`);
380
+ for (const s of steps) log(` ${s}`);
381
+ hand('teammates then join with: `yad join <the Product\'s clone URL>`');
382
+ hand(`then, from here: cd ${fromShell(product)} && yad next`);
383
+ return { workspace, product, platform: productConfig.platform ?? null, branch, remoteSteps: steps, setup };
384
+ }
385
+
386
+ // ---- yad init ------------------------------------------------------------------------------------
387
+ export async function runInit(cwd, opts = {}) {
388
+ log(c.bold('\nyad init'));
389
+ const inside = insideGitRepo(cwd);
390
+ if (inside.ok) {
391
+ return refuse(`${cwd} is inside the git repo ${inside.stdout}. \`yad init\` runs in the folder that HOLDS your repos. For one repo that holds all the code, run \`yad setup --brownfield --monorepo\` inside it instead`);
392
+ }
393
+ const children = fs.readdirSync(cwd, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith('.')).map((d) => d.name).sort();
394
+ let productName = opts.path;
395
+ if (productName !== undefined && !validFolderName(productName)) return refuse(`--path must be one folder name (got ${JSON.stringify(shown(productName))})`);
396
+ if (productName === undefined) {
397
+ const products = children.filter((n) => exists(productConfigPath(path.join(cwd, n))));
398
+ if (products.length > 1) return refuse(`more than one Product here (${products.join(', ')}) — name the one to use with --path <folder>`);
399
+ productName = products[0] || PRODUCT_DIR;
400
+ }
401
+ const product = path.join(cwd, productName);
402
+ // An existing folder becomes the Product only when it already is one, or has nothing in it yet (a
403
+ // fresh clone of an empty remote holds just `.git`). Anything else is someone's code: setup would
404
+ // write the Product's files into it.
405
+ if (exists(product) && !exists(productConfigPath(product)) && !isBareStart(product)) {
406
+ return refuse(`${productName}/ already holds files and is not a Product — name a new folder for the Product with --path <folder>`);
407
+ }
408
+ if (!exists(product)) {
409
+ fs.mkdirSync(product);
410
+ if (!gitInit(product)) return refuse(`git init failed in ${product}`);
411
+ ok(`created ${productName}/ (git, branch main)`);
412
+ } else info(`Product folder: ${productName}/`);
413
+
414
+ const repos = children.filter((n) => n !== productName && exists(path.join(cwd, n, '.git')));
415
+ const found = repos.filter(validFolderName);
416
+ const odd = repos.filter((n) => !validFolderName(n));
417
+ if (odd.length) warn(`not offered (a repo name must be letters, digits, ".", "_" or "-"): ${odd.map(shown).join(', ')} — connect them with \`yad setup\` under another name`);
418
+ if (found.length) info(`found ${found.length} repo(s) beside it: ${found.join(', ')}`);
419
+ else info('found no repos beside it — connect them later with `yad setup`');
420
+ const discovered = found.map((n) => ({ name: n, rpath: `../${n}` }));
421
+ const layout = opts.monorepo ? {} : { separate: true };
422
+ const setup = await runSetup(product, { ...opts, brownfield: !opts.greenfield, ...layout, discovered });
423
+ if (process.exitCode) return null;
424
+ noteWorkspaceFile(product);
425
+ // Setup's own "yad next" is said from the Product; the person is still in the workspace folder.
426
+ hand(`start with: cd ${fromShell(product)} && yad next`);
427
+ return { workspace: cwd, product, found, setup };
428
+ }
429
+
430
+ // ---- yad join <url> [folder] ---------------------------------------------------------------------
431
+ // The per-machine skill copies: only what git IGNORES in the Product. A copy the team commits is a
432
+ // shared file — installing a missing one, or refreshing an old one, is `yad update`'s job, pushed as a
433
+ // change everyone sees. So those are counted and named, never written.
434
+ function installLocalSkills(product, ideTargets) {
435
+ const ignored = (rel) => spawnSync('git', ['check-ignore', '-q', '--', rel], { cwd: product, windowsHide: true }).status === 0;
436
+ // `check-ignore` is asked about a path that may not exist yet, so a folder-only pattern
437
+ // (`/.claude/skills/yad-*/`) is not matched and the copy is counted as shared — the safe side. Ignore
438
+ // the folder itself (`.claude/`, `.claude/skills/`) for join to install into it.
439
+ const res = { installed: [], stale: [], shared: [] };
440
+ for (const a of moduleActions(product, ideTargets)) {
441
+ if (a.status === 'ok') continue;
442
+ const rel = a.paths[0];
443
+ const local = !!rel && ignored(rel);
444
+ if (local && (a.status === 'new' || a.status === 'missing')) { a.apply(); res.installed.push(rel); }
445
+ else (local ? res.stale : res.shared).push(rel || `${a.scope}/${a.item}`);
446
+ }
447
+ return res;
448
+ }
449
+
450
+ export async function runJoin(cwd, url, folder, opts = {}) {
451
+ log(c.bold('\nyad join'));
452
+ if (typeof url !== 'string' || !url.trim() || url.startsWith('-')) return refuse('usage: yad join <the Product\'s clone URL> [folder]');
453
+ const wsName = folder ?? workspaceNameFromUrl(url);
454
+ if (!validFolderName(wsName)) return refuse(`cannot make a folder name from ${JSON.stringify(shown(url))} — pass one: yad join <url> <folder>`);
455
+ const inside = insideGitRepo(cwd);
456
+ if (inside.ok) return refuse(`${cwd} is inside the git repo ${inside.stdout} — run \`yad join\` from a folder outside any repo`);
457
+ const workspace = path.join(cwd, wsName);
458
+ const product = path.join(workspace, PRODUCT_DIR);
459
+
460
+ // A re-run after a partial join finds the Product already there and carries on with what is missing.
461
+ if (exists(path.join(product, '.git'))) info(`${path.join(wsName, PRODUCT_DIR)}/ already cloned`);
462
+ else {
463
+ if (exists(product) && !isEmptyDir(product)) return refuse(`${product} exists and is not a git repo — move it aside and re-run`);
464
+ fs.mkdirSync(workspace, { recursive: true });
465
+ const r = gitClone(url, product, cloneEnv());
466
+ if (!r.ok) return refuse(`could not clone the Product: ${shown(r.error)}`);
467
+ ok(`cloned the Product into ${path.join(wsName, PRODUCT_DIR)}/`);
468
+ }
469
+ if (!exists(productConfigPath(product))) return refuse(`${path.join(wsName, PRODUCT_DIR)}/ has no .sdlc/product.json — that repo is not a yad Product`);
470
+
471
+ const { registry, problem } = readRegistry(product);
472
+ if (problem) warn(`${problem} — nothing to clone; fix it in the Product and re-run`);
473
+ const repos = cloneMissingRepos(product, registry);
474
+ reportClones(repos);
475
+
476
+ // The per-machine steps.
477
+ const ideTargets = await selectIdeTargets(product, opts.ideTargets);
478
+ const skills = installLocalSkills(product, ideTargets);
479
+ if (skills.installed.length) ok(`installed ${skills.installed.length} skill folder(s) the Product's git ignores (per machine)`);
480
+ if (skills.stale.length) info(`${skills.stale.length} per-machine skill copy(ies) differ from this yadflow's — left as they are; \`yad update\` refreshes them`);
481
+ if (skills.shared.length) info(`${skills.shared.length} managed file(s) are missing or out of date here in folders the Product's git does not ignore — writing them is a change the team shares (\`yad update\`), so join leaves them`);
482
+ noteWorkspaceFile(product);
483
+ const hook = gitHookActions(product);
484
+ for (const a of hook) a.apply();
485
+ if (hook.length) ok('installed the git pre-commit hook in this clone');
486
+
487
+ log('');
488
+ if (repos.failed.length) hand(`${repos.failed.length} repo(s) not cloned — fix what is named above and re-run \`yad join ${shown(url)}\`; it keeps what is already there`);
489
+ hand(`start with: cd ${fromShell(product)} && yad next`);
490
+ return {
491
+ workspace, product,
492
+ repos, skills: { installed: skills.installed, stale: skills.stale.length, shared: skills.shared.length },
493
+ hook: hook.length ? 'installed' : 'unchanged',
494
+ };
495
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "4.0.0-next.3",
4
- "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). Plus 38 yad-* skills.",
3
+ "version": "4.0.0",
4
+ "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (new/init/join, next, gate, capture/fold, commit/ship, repo, thread, history, doctor/migrate/update). Plus 38 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
7
7
  "license": "MIT",
@@ -24,6 +24,8 @@
24
24
  "!cli/test-threads.mjs",
25
25
  "!cli/test-golden.mjs",
26
26
  "!cli/test-migrate.mjs",
27
+ "!cli/test-hooks.mjs",
28
+ "!cli/test-fuzz.js",
27
29
  "!cli/fixtures",
28
30
  "skills/",
29
31
  "README.md",
@@ -40,10 +42,10 @@
40
42
  "scripts": {
41
43
  "yad": "node bin/yad.mjs",
42
44
  "lint": "eslint cli bin",
43
- "test": "node --test cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs",
45
+ "test": "node --test cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs cli/test-hooks.mjs cli/test-fuzz.js",
44
46
  "test:e2e": "bash test/e2e/run.sh",
45
47
  "release-check": "bash scripts/release-check.sh",
46
- "coverage": "node --test --experimental-test-coverage --test-coverage-exclude='cli/test*.mjs' --test-coverage-lines=70 --test-coverage-branches=70 cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs",
48
+ "coverage": "node --test --experimental-test-coverage --test-coverage-exclude='cli/test*.mjs' --test-coverage-exclude='cli/test*.js' --test-coverage-lines=70 --test-coverage-branches=70 cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs cli/test-hooks.mjs cli/test-fuzz.js",
47
49
  "diagrams": "npx -y @mermaid-js/mermaid-cli -i docs/diagrams/sdlc-overview.mmd -o docs/diagrams/sdlc-overview.svg -b transparent && npx -y @mermaid-js/mermaid-cli -i docs/diagrams/review-loop.mmd -o docs/diagrams/review-loop.svg -b transparent",
48
50
  "prepublishOnly": "npm test"
49
51
  },
@@ -69,6 +71,7 @@
69
71
  "@semantic-release/changelog": "^7.0.0",
70
72
  "@semantic-release/git": "^11.0.1",
71
73
  "eslint": "^10.5.0",
74
+ "fast-check": "^4.10.2",
72
75
  "semantic-release": "^25.0.3"
73
76
  }
74
77
  }
@@ -70,7 +70,7 @@ lifecycle:
70
70
  # marks any step optional. Each epic RECORDS its route as `profile` in `state.json`
71
71
  # (file shape 6); `yad epic new` writes it when it seeds the chain, and for an epic that predates the
72
72
  # field `yad migrate` fills it in by matching the chain. The chain stays the truth — `yad doctor`
73
- # compares the two. NOT the `hub.json` `profile`, which is the unrelated setup record
73
+ # compares the two. NOT the `product.json` `profile`, which is the unrelated setup record
74
74
  # { codebase, repo_layout, team_size }.
75
75
  profiles: [classic, analysis-first, chore, spike, discovery, foundation]
76
76
  # The PRODUCT level's phase (E75) — a ladder of its own, run once per product before any epic, and
@@ -113,7 +113,7 @@ review_gate:
113
113
  # only the base holds a gate.
114
114
  default_reviewers: 1 # legacy, not read since E62
115
115
  # Solo mode (a lone developer): a relaxed, opt-in safety guarantee, recorded per-project in
116
- # .sdlc/hub.json (`solo: true`). On GitHub you CANNOT approve your own PR, so requiring an approval
116
+ # .sdlc/product.json (`solo: true`). On GitHub you CANNOT approve your own PR, so requiring an approval
117
117
  # would deadlock a solo user. Solo waives the APPROVAL requirement only — the review PR/MR and its
118
118
  # MERGE stay (CI still runs on the PR, and the merge is what advances the step). Net: the gate passes
119
119
  # on `merged + all comment threads resolved`, no approval needed. NOT a default; team gates are
@@ -137,7 +137,7 @@ review_gate:
137
137
  # default: both count, a bare approve still passes but draws a friendly public @-mention nudge so
138
138
  # review quality is VISIBLE without blocking anyone ("visible, not impossible"). Flip to true to make
139
139
  # only verified approvals count (the signal is gameable by design — it raises the cost of a rubber-
140
- # stamp, it does not prove a human read the artifact). Persisted per-project in hub.json as
140
+ # stamp, it does not prove a human read the artifact). Persisted per-project in product.json as
141
141
  # `review.requireEngagement`; companion comments carry `<!-- yad:noblock -->` so they never block.
142
142
  require_engagement: false
143
143
  # Pair Review (yad-pair-review) — the guided, two-way, TEACHING walkthrough; the AI-driven 5th
@@ -173,7 +173,8 @@ build:
173
173
  # Commit ownership + per-commit AI co-author (yad-implement installs the .gitmessage template).
174
174
  # The human git author OWNS the commit; the assisting AI is recorded per-commit as a Co-Authored-By
175
175
  # trailer, chosen by the owner from `ai_coauthor.allowed`. The AI is never the author. `none` is an
176
- # explicit human-only choice. Trailer order is Task: -> Contract-Change: (if any) -> Co-Authored-By:.
176
+ # explicit human-only choice. Trailer order is Task: -> Contract-Change: (if any) ->
177
+ # Ledger-Override: (only with `yad commit --manual --reason`) -> Co-Authored-By:.
177
178
  commit_owner: git_author
178
179
  ai_coauthor:
179
180
  trailer: "Co-Authored-By"
@@ -190,7 +191,7 @@ build:
190
191
  # Shape artifacts can never go stale), and reconcile-debt (a hotfix's ship-first debt blocks the next
191
192
  # change on its thread until paid). See the `change:` block below.
192
193
  gates: [spec-link, contract-check, build-test-lint, lineage-check, epic-open, reconcile-debt]
193
- # Pattern gates — commit subject + PR/MR title + PR/MR template-usage. Profile-aware (code|hub|product): code
194
+ # Pattern gates — commit subject + PR/MR title + PR/MR template-usage. Profile-aware (code|product; `hub` is the old name for product): code
194
195
  # repos validate the Conventional-Commits / task-PR conventions; the Product validates its
195
196
  # artifact-review conventions (review/EP-<slug>/<artifact>, the Product PR template). verified-commits is
196
197
  # the separate security gate (a platform-Verified signature only; no author allowlist since E62). All blocking in CI; both repos + Product.
@@ -201,7 +202,7 @@ build:
201
202
  pr_templates:
202
203
  github: ".github/pull_request_template.md"
203
204
  gitlab: ".gitlab/merge_request_templates/Default.md"
204
- hub: # Shape artifact-review PR/MR on the Product (yad-pr-template repo:hub)
205
+ product: # Shape artifact-review PR/MR on the Product (yad-pr-template repo:product; older projects: `hub:`)
205
206
  github: ".github/pull_request_template.md"
206
207
  gitlab: ".gitlab/merge_request_templates/Default.md"
207
208
  risk_levels: [low, medium, high] # high +1 / a contract surface +2 to the approver count (advisory; yad-review-gate count)
@@ -304,13 +305,13 @@ learning:
304
305
  # generated React+Vite+Tailwind doc SITES. yad-docs builds a per-epic site (epics/EP-<slug>/docs-site/)
305
306
  # from the authored artifacts, themed by the connected design system; yad-docs-overview builds the
306
307
  # project SDLC-overview site (docs/sdlc-site/) from the pipeline definition, superseding docs/index.html.
307
- # The deploy TARGET is a Pages host, auto-detected from hub.platform (github->github-pages,
308
+ # The deploy TARGET is a Pages host, auto-detected from the Product's `platform` (github->github-pages,
308
309
  # gitlab->gitlab-pages, null->build-only). The yad CLI does the npm build + Pages wiring + staleness;
309
310
  # the content generation is the AI step. Docs are an OUTPUT ENRICHMENT — never a gate, never touch state.
310
311
  docs:
311
312
  registry: "{project-root}/.sdlc/docs.json" # project-wide docs/Pages connection (NOT per-epic)
312
- targets: [github-pages, gitlab-pages] # supported Pages hosts; auto-detected from hub.platform
313
- scope: hub # publish from: hub (default) | <repo-name> | dedicated
313
+ targets: [github-pages, gitlab-pages] # supported Pages hosts; auto-detected from the Product's platform
314
+ scope: product # publish from: product (default; older projects: `hub`) | <repo-name> | dedicated
314
315
  degrade: build-only # no Pages host / no gh|glab => build the dist, publish via CI
315
316
  login_gate: false # public docs by default (the client-side gate is presentational only)
316
317
  manifest: "{project-root}/epics/EP-<slug>/.sdlc/docs-build.json" # per-epic staleness baseline (yad-docs)
@@ -319,7 +320,7 @@ docs:
319
320
  # tokens — publishing runs as the LOCAL user's own gh/glab or the platform CI. Idempotent, refreshable.
320
321
  auth: user
321
322
 
322
- # Product platform + Shape review bridge (yad-connect-repos `detect-hub`; yad-review-gate + yad-hub-bridge).
323
+ # Product platform + Shape review bridge (yad-connect-repos `detect-product`; yad-review-gate + yad-product-bridge).
323
324
  # The Product is itself a git repo on a platform. With a verified ledger, the Shape review/
324
325
  # comment/approval cycle runs through a real PR/MR on the Product: a review PR is opened per artifact, reviewers
325
326
  # approve/comment on the platform (their own gh/glab auth — NO stored tokens), and `yad-review-gate action: sync`
@@ -330,14 +331,14 @@ docs:
330
331
  # both. The paths below carry both names too, for the same reason the files do.
331
332
  product:
332
333
  config: "{project-root}/.sdlc/product.json" # product platform (committed, like repos.json); no roster since E62
333
- config_legacy: "{project-root}/.sdlc/hub.json" # the older name, still written and still the one READ this major
334
+ config_legacy: "{project-root}/.sdlc/hub.json" # the older name: still written with identical bytes until v5; read only when product.json is absent (E122)
334
335
  pr_ledger: "{project-root}/epics/EP-<slug>/.sdlc/product-prs.json" # per-step review-PR record (sibling of approvals.json)
335
- pr_ledger_legacy: "{project-root}/epics/EP-<slug>/.sdlc/hub-prs.json" # the older name — written alongside it AND still the one read, like config_legacy
336
+ pr_ledger_legacy: "{project-root}/epics/EP-<slug>/.sdlc/hub-prs.json" # the older name — written alongside it; read only when product-prs.json is absent, like config_legacy
336
337
  bridge: true # master enable; false => local Shape gates everywhere
337
338
  platforms: [github, gitlab] # detected from the Product's own `git remote get-url origin`
338
339
  artifact_branch: "review/EP-<slug>/<artifact-base>" # branch a review PR is opened on, per artifact
339
340
  # No roster since E62: yadflow stores no people. Approvals record the platform login as `approver`. A
340
- # `roster` in hub.json or `domain_owner(s)` in repos.json left by an older release decides nothing (the
341
+ # `roster` in the Product settings or `domain_owner(s)` in repos.json left by an older release decides nothing (the
341
342
  # roster's name -> login pairs only let the first sync recognise older approvals).
342
343
 
343
344
  # Phase 4 (automation) — the SECOND dial made real: the orchestrator (yad-run) reads it and acts. Since