yadflow 4.0.0-next.1 → 4.0.0-next.4

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 (129) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/README.md +168 -20
  3. package/bin/commands.mjs +8 -1
  4. package/bin/yad.mjs +262 -32
  5. package/cli/capture.mjs +419 -0
  6. package/cli/checkpoint.mjs +10 -10
  7. package/cli/claims.mjs +165 -0
  8. package/cli/commit.mjs +48 -12
  9. package/cli/docs.mjs +15 -13
  10. package/cli/doctor.mjs +764 -131
  11. package/cli/epic-state.mjs +50 -24
  12. package/cli/errors.mjs +3 -2
  13. package/cli/find-product.mjs +197 -0
  14. package/cli/fold.mjs +247 -0
  15. package/cli/gate-local.mjs +296 -0
  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 +213 -98
  22. package/cli/migrate.mjs +208 -17
  23. package/cli/mode.mjs +22 -22
  24. package/cli/next.mjs +30 -10
  25. package/cli/openpr.mjs +39 -24
  26. package/cli/owners.mjs +266 -0
  27. package/cli/people.mjs +26 -11
  28. package/cli/plan.mjs +798 -50
  29. package/cli/platform.mjs +13 -7
  30. package/cli/product-index.mjs +4 -4
  31. package/cli/{hubcommit.mjs → productcommit.mjs} +14 -9
  32. package/cli/protection.mjs +19 -9
  33. package/cli/reconcile.mjs +100 -23
  34. package/cli/repo-publish.mjs +22 -16
  35. package/cli/repo.mjs +128 -33
  36. package/cli/report.mjs +8 -2
  37. package/cli/review.mjs +26 -19
  38. package/cli/setup.mjs +112 -35
  39. package/cli/ship.mjs +1 -1
  40. package/cli/skip.mjs +4 -4
  41. package/cli/tidy.mjs +7 -7
  42. package/cli/update-commit.mjs +22 -19
  43. package/cli/workspace.mjs +495 -0
  44. package/package.json +7 -4
  45. package/skills/sdlc/config.yaml +14 -13
  46. package/skills/sdlc/module-help.csv +8 -8
  47. package/skills/yad-analysis/SKILL.md +10 -6
  48. package/skills/yad-architecture/SKILL.md +7 -5
  49. package/skills/yad-backfill/SKILL.md +11 -5
  50. package/skills/yad-backfill/references/backfill.md +37 -0
  51. package/skills/yad-backfill/templates/checks/backfill-check.sh +208 -17
  52. package/skills/yad-change/SKILL.md +4 -2
  53. package/skills/yad-checks/SKILL.md +54 -26
  54. package/skills/yad-checks/references/check-gates.md +260 -54
  55. package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
  56. package/skills/yad-checks/templates/checks/contract-check.sh +534 -20
  57. package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
  58. package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
  59. package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
  60. package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
  61. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
  62. package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
  63. package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
  64. package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
  65. package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
  66. package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +16 -12
  67. package/skills/yad-checks/templates/github/yad-update-guard.yml +5 -2
  68. package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
  69. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
  70. package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +18 -18
  71. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
  72. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
  73. package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
  74. package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
  75. package/skills/yad-commit/SKILL.md +18 -7
  76. package/skills/yad-connect-design/SKILL.md +1 -1
  77. package/skills/yad-connect-design/references/design-registry.md +1 -1
  78. package/skills/yad-connect-docs/SKILL.md +16 -11
  79. package/skills/yad-connect-docs/references/docs-registry.md +8 -8
  80. package/skills/yad-connect-learning/SKILL.md +1 -1
  81. package/skills/yad-connect-learning/references/learning-registry.md +1 -1
  82. package/skills/yad-connect-repos/SKILL.md +31 -8
  83. package/skills/yad-connect-repos/references/code-context.md +1 -1
  84. package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
  85. package/skills/yad-connect-repos/references/repos-registry.md +1 -1
  86. package/skills/yad-connect-testing/SKILL.md +1 -1
  87. package/skills/yad-connect-testing/references/testing-registry.md +1 -1
  88. package/skills/yad-discovery/SKILL.md +4 -3
  89. package/skills/yad-discovery/references/discovery-schema.md +1 -1
  90. package/skills/yad-docs/SKILL.md +1 -1
  91. package/skills/yad-docs/references/data-mapping.md +1 -1
  92. package/skills/yad-docs/templates/app/package-lock.json +8 -8
  93. package/skills/yad-docs/templates/app/package.json +1 -1
  94. package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
  95. package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
  96. package/skills/yad-engineer-review/SKILL.md +2 -2
  97. package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
  98. package/skills/yad-epic/SKILL.md +11 -6
  99. package/skills/yad-epic/references/state-schema.md +25 -17
  100. package/skills/yad-open-pr/SKILL.md +1 -1
  101. package/skills/yad-pair-review/SKILL.md +5 -5
  102. package/skills/yad-pair-review/references/session-state.md +2 -2
  103. package/skills/yad-pr-template/SKILL.md +14 -12
  104. package/skills/yad-pr-template/templates/checks/pr-template.sh +28 -17
  105. package/skills/yad-pr-template/templates/checks/pr-title.sh +25 -14
  106. package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
  107. package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
  108. package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +17 -16
  109. package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
  110. package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
  111. package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
  112. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
  113. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
  114. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
  115. package/skills/yad-report/SKILL.md +1 -1
  116. package/skills/yad-review-companion/SKILL.md +3 -3
  117. package/skills/yad-review-gate/SKILL.md +97 -107
  118. package/skills/yad-review-gate/references/gating.md +20 -18
  119. package/skills/yad-run/SKILL.md +2 -2
  120. package/skills/yad-run/references/run-loop.md +2 -2
  121. package/skills/yad-spec/SKILL.md +2 -1
  122. package/skills/yad-spec/references/spec-handoff.md +19 -10
  123. package/skills/yad-status/SKILL.md +7 -0
  124. package/skills/yad-stories/SKILL.md +5 -4
  125. package/skills/yad-sync-repos/SKILL.md +19 -3
  126. package/skills/yad-test-cases/SKILL.md +5 -4
  127. package/skills/yad-ui/SKILL.md +6 -4
  128. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
  129. package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
package/bin/yad.mjs CHANGED
@@ -1,14 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  // `yad` — setup/maintenance + the PR-driven review gate + build helpers for the SDLC module.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
3
5
  import { VERSION } from '../cli/manifest.mjs';
4
6
  import { c, log, warn, closePrompts, askYesNo, refuse, beginJSON, inJSON, emitJSON, jsonEmitted, jsonFailure, stripAnsi, isPlainObject, ENVELOPE_KEYS } from '../cli/lib.mjs';
5
- import { runLedgerGuardHook } from '../cli/hook.mjs';
7
+ import { runLedgerGuardHook, runStagedLedgerGuard } from '../cli/hook.mjs';
6
8
 
7
9
  const helpText = (profiles) => `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
8
10
 
9
11
  ${c.dim('Every command but `yad hook` takes --json: one JSON object on stdout, every other line on stderr')}
10
12
  ${c.dim('(docs/CLI.md, "--json on every command").')}
11
13
 
14
+ ${c.bold('Start a workspace')} ${c.dim('(the folder holding product/ and the code repos side by side)')}
15
+ ${c.dim('--dir: for init, the workspace; for new and join, the folder the workspace is made in')}
16
+ yad new <name> Greenfield: make <name>/product/, git init it, run setup inside it. Creates
17
+ no remote — prints the gh/glab line to run yourself. Takes the setup flags
18
+ yad init Brownfield, in the folder that already holds your repos: make product/ (or
19
+ use the one there; --path <folder> names it), run setup, offer each repo found
20
+ yad join <url> [folder]
21
+ Clone the Product into [folder]/product/ (default: the URL's last name) and
22
+ every repo it registers; a failed repo clone is reported, never fatal. Then
23
+ only per-machine steps: git-ignored skill copies + the git pre-commit hook.
24
+ Never commits, pushes, or changes a shared file
25
+
12
26
  ${c.bold('Setup & maintenance')}
13
27
  yad setup Guided first-run setup (profile interview, install, connect & wire repos)
14
28
  profile flags: --solo | --team <n>, --greenfield | --brownfield,
@@ -29,11 +43,13 @@ ${c.bold('Setup & maintenance')}
29
43
  'yad check --fix --push'; --allow-branch permits a non-default branch
30
44
  yad doctor [--json] Environment + state health: tools/auth, config files,
31
45
  repo paths, epic ledgers (exit 1 on any failure)
32
- yad migrate [--apply] [--json] Move this project's state files onto the shape
46
+ yad migrate [--apply] [--keep product|hub] [--json] Move this project's state files onto the shape
33
47
  this yadflow expects. Prints what WOULD change and writes
34
48
  nothing until --apply, which copies each file it rewrites to
35
49
  <file>.yad-orig first. Safe to run twice — the second run
36
- reports there is nothing to do
50
+ reports there is nothing to do. When .sdlc/product.json and
51
+ .sdlc/hub.json (or an epic's product-prs.json and hub-prs.json)
52
+ disagree, --apply asks which copy to keep; --keep answers it
37
53
  yad sync-status [epic] Update artifact frontmatter status (draft/in-review/approved)
38
54
  from .sdlc/state.json — all epics if omitted (--dry-run to preview)
39
55
  yad report [-m <text>] File a bug in the yadflow repo with auto-scrubbed diagnostics
@@ -46,7 +62,10 @@ ${c.bold('Setup & maintenance')}
46
62
  --format cursor the verdict is JSON on stdout instead, for a harness
47
63
  whose pre-edit hook asks for permission rather than reading an exit
48
64
  code. Wired into .claude/settings.json and .cursor/hooks.json by
49
- setup / check --fix. YAD_HOOK_DISABLE=1 skips.
65
+ setup / check --fix. With --staged it judges the files a commit is
66
+ about to record instead — the git pre-commit hook check --fix
67
+ installs in this clone refuses a person's hand commit to the ledger.
68
+ YAD_HOOK_DISABLE=1 skips.
50
69
 
51
70
  ${c.bold('Team usage (EM adoption & behavior report)')}
52
71
  yad usage Build a per-member report (HTML) from git + the SDLC ledgers
@@ -151,6 +170,12 @@ ${c.bold('Review gate (Shape)')}
151
170
  in verified mode — there, recover with 'yad gate ci' below
152
171
  yad gate comments <epic> [artifact] Fetch unresolved review comments to address
153
172
  yad gate status <epic> Show each review step + approvals
173
+ yad gate approve <epic> <artifact> --by <name> [--engagement verified|none]
174
+ No platform only: record an approval, bound to the artifact's
175
+ content (an edit revokes it). Reports the verdict; never advances
176
+ yad gate comment <epic> <artifact> --by <name> [--count <n>] [--new-round]
177
+ No platform only: record who commented, and how often, this round
178
+ yad gate advance <epic> <artifact> No platform only: pass the gate when its approvals hold
154
179
  yad gate repair <epic> [--push] Close an author step stranded behind a passed review gate
155
180
  (YAD-STATE-005); --push commits state.json to the default branch
156
181
  yad gate review <epic> [artifact] Print the grounding bundle for the review companion
@@ -164,26 +189,47 @@ ${c.bold('Review gate (Shape)')}
164
189
  --merged advances the step + flips artifact status on the default branch
165
190
 
166
191
  ${c.bold('Build helpers')}
167
- yad commit --type <t> -m <subject> Commit by convention (trailers, atomic guard)
168
- yad open-pr [--repo <name>] Open a task PR/MR against the repo's DEFAULT branch (never a
192
+ yad commit --type <t> -m <subject> [--manual --reason <why>]
193
+ Commit by convention (trailers, atomic guard); --manual is
194
+ the door past the Product's ledger hook, reason recorded
195
+ yad open-pr [--repo <name>] [--title <text>]
196
+ Open a task PR/MR against the repo's DEFAULT branch (never a
169
197
  hardcoded main; --base overrides) — stage-aware on the Product: a
170
198
  review/EP-* branch opens the Shape artifact-review PR
171
199
  (delegates to gate open), any other Product branch uses the
172
- code-task template
173
- yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step (stage-aware)
200
+ code-task template. --title sets the PR/MR title (default: -m,
201
+ else the last commit's subject)
202
+ yad ship --type <t> -m <subject> [--title <text>]
203
+ Commit AND open the task PR/MR in one step (stage-aware).
204
+ --title sets the PR/MR title (default: the commit subject)
174
205
  yad checkpoint [--push] Commit the machine-written Build state on the Product
175
206
  (trust-log/build-log/build-state) — plus any story
176
207
  status: flip (→ in-build/shipped) backed by a build-log
177
- ship — as one audit-trail chore(hub) commit; default
208
+ ship — as one audit-trail chore(product) commit; default
178
209
  branch only (--allow-branch to override); no-op when clean
179
210
  yad checkpoint --retro-ship <epic>/<story> --repo <r>
180
211
  Record a retroactive build-log ship for a PRE-TRACKING story
181
212
  (merged before ledger tracking), then carry its status: shipped
182
213
  flip in the same commit (--merge-commit <sha>, --task <t> opt.);
183
214
  one repo per run — re-run per --repo for a multi-repo story
215
+ yad capture [--no-push] Snapshot every changed Shape artifact onto your private
216
+ yad/wip/<you>/<epic> branches — never your checkout; pushes them
217
+ (a harness hook runs it after each agent edit, with --hook)
218
+ yad claims [<epic>] [--no-fetch] Who else is editing which artifact: read from everyone's
219
+ capture branches (fetched first) — advice, not a lock; a claim
220
+ ends 4 hours after the last save, or once the file is merged
221
+ yad assign <epic> <step> [--to <name>] [--force]
222
+ Assign an authoring step to one person (you, or their git
223
+ name) — advice, not a lock: the capture hook warns anyone
224
+ else who edits its files; --force replaces another owner
225
+ yad unassign <epic> <step> [--force] Remove an assignment (someone else's needs --force)
226
+ yad owners [<epic>] List every step assignment, and whether it is live
227
+ yad fold <epic> <step> End an authoring step: commit ITS artifacts as one commit,
228
+ docs(<epic>): author <step> (signed if git signs) — the drafts
229
+ stay on yad/wip/…; with ledger: local the epic's ledger rides along
184
230
  yad tidy up [<epic>] [--push] Fold FINISHED Build shards (a shipped story's
185
231
  trust-log/build-log entries) back into the single folded
186
- ledger, as one chore(hub) commit — the manual "pack it up"
232
+ ledger, as one chore(product) commit — the manual "pack it up"
187
233
  for the shard files; a no-op when nothing is foldable
188
234
  yad index [--json] Rebuild .sdlc/index.json — one summary line per work item,
189
235
  derived from their own files; the default branch only, with no
@@ -203,9 +249,14 @@ ${c.bold('Build helpers')}
203
249
  pair-review walkthrough (yad-pair-review)
204
250
  yad review nudge --repo <r> --pr <n> Friendly @-mention on a bare code-PR approve
205
251
  yad review reconcile --epic <id> --repo <r> --pr <n> Bridge: stamp engagement onto the build-log ship
206
- yad repo list Show connected repos (fresh / stale)
252
+ yad repo list Show connected repos (fresh / stale / not cloned here)
253
+ yad repo clone [name] Clone every registered repo missing on this machine, at its
254
+ recorded path (the clone step of \`yad join\`). Never touches a
255
+ repo already there; exits 1 when one could not be cloned
256
+ yad repo sync [name] Switch each connected repo to its default branch and
257
+ fast-forward it from origin; dirty repos are skipped
207
258
  yad repo refresh [name] [--push] Re-pack a stale repo (a human decision). --push commits the
208
- refreshed code-maps + registry as a chore(hub): sync code-context … [skip ci]
259
+ refreshed code-maps + registry as a chore(product): sync code-context … [skip ci]
209
260
  audit commit and pushes it to the Product default branch (--allow-branch to override)
210
261
  yad risk-map check [repo] [--json] Warn where a code repo's .sdlc/risk-map (a risk level per
211
262
  directory, no names) has gone stale — advisory, never blocks
@@ -231,16 +282,23 @@ ${c.bold('Interactive docs (generated sites)')}
231
282
  yad docs sync [--check|--refresh|--wire] Staleness sweep; --wire installs the Pages CI
232
283
 
233
284
  ${c.bold('Options')}
234
- --dir <path> Target project root (default: cwd)
285
+ --dir <path> Target project root. Without it, a Product command finds the Product from
286
+ where you are — any folder inside it, or inside a repo it registers
287
+ (via .yad-workspace.json) — and says so on stderr; a code-repo command
288
+ (commit, open-pr, ship, review) works on the repo you are in
235
289
  --type <t> commit: feat|fix|docs|refactor|test|perf|build|ci|chore|revert
236
290
  -m, --message <s> commit: subject / PR title
237
- --task <id> commit: Task trailer, a <story>-T<NN> id (else derived from the branch)
291
+ --task <id> commit: Task trailer, EP-<slug>-S<n>-T<NN> (else derived from the branch)
238
292
  --ai <id> commit: co-author — claude|copilot|cursor|coderabbit|none (default none)
239
293
  --contract-change commit/open-pr: mark the contract surface touched
294
+ --manual commit: on a verified Product, commit past the local ledger hook; needs
295
+ --reason, recorded as a Ledger-Override trailer. The ledger-guard check
296
+ on the pull request still judges the commit, and quotes the reason
240
297
  --risk <level> open-pr: low|medium|high (default low)
241
298
  --repo <name> open-pr: target a registered repo by name
299
+ --title <text> open-pr/ship: the PR/MR title (ignored on the Product's review/EP-* branch)
242
300
  --base <branch> open-pr: override the PR/MR base — default is the repo's own default
243
- branch (repos.json default_branch, else hub.json default_branch for a PR
301
+ branch (repos.json default_branch, else product.json default_branch for a PR
244
302
  on the Product itself, else the platform, else origin/HEAD, else main); a
245
303
  non-default base loses the AI first pass (warns, never blocks)
246
304
  --epic <id> docs: target one epic's site (EP-<slug>)
@@ -264,7 +322,7 @@ ${c.bold('Environment')}
264
322
  YAD_NO_REPORT=1 Never offer to file a bug report after a failure
265
323
  YAD_PLATFORM_LOGIN=0 Name a record's author by git user.name; never ask gh/glab who is logged in`;
266
324
 
267
- const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--profile', '--parent', '--inherits', '--to', '--retro-ship', '--merge-commit', '--path', '--ide-targets', '--theme', '--thread']);
325
+ const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--profile', '--parent', '--inherits', '--to', '--retro-ship', '--merge-commit', '--path', '--ide-targets', '--theme', '--thread', '--by', '--count', '--engagement', '--keep']);
268
326
 
269
327
  function parseArgs(argv) {
270
328
  const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
@@ -274,7 +332,9 @@ function parseArgs(argv) {
274
332
  if (a === '--fix') o.fix = true;
275
333
  else if (a === '--force') o.force = true;
276
334
  else if (a === '--contract-change') o.contractChange = true;
335
+ else if (a === '--manual') o.manual = true;
277
336
  else if (a === '--no-push') o.noPush = true;
337
+ else if (a === '--no-fetch') o.noFetch = true;
278
338
  else if (a === '--push') o.push = true;
279
339
  else if (a === '--allow-branch') o.allowBranch = true;
280
340
  else if (a === '--overwrite-local') o.overwriteLocal = true;
@@ -290,6 +350,9 @@ function parseArgs(argv) {
290
350
  else if (a === '--done') o.done = true;
291
351
  else if (a === '--undo') o.undo = true;
292
352
  else if (a === '--debt') o.debt = true;
353
+ else if (a === '--new-round') o.newRound = true;
354
+ else if (a === '--hook') o.hook = true;
355
+ else if (a === '--staged') o.staged = true;
293
356
  else if (a === '--stub') o.stub = true;
294
357
  // setup profile flags (pre-answer the Step 0 interview, for CI/scripts)
295
358
  else if (a === '--solo') o.solo = true;
@@ -345,17 +408,50 @@ const ACTIONS = {
345
408
  epic: { known: ['new'] },
346
409
  foundation: { known: ['new', 'status'] },
347
410
  skill: { known: ['list', 'bind', 'unbind'], default: 'list' },
348
- gate: { known: ['open', 'sync', 'comments', 'status', 'repair', 'review', 'walkthrough', 'trailer', 'ci'] },
411
+ gate: { known: ['open', 'sync', 'comments', 'status', 'repair', 'review', 'walkthrough', 'trailer', 'ci', 'approve', 'comment', 'advance'] },
349
412
  review: { known: ['trailer', 'context', 'chat', 'cards', 'walkthrough', 'nudge', 'reconcile'] },
350
413
  tidy: { known: ['up'] },
351
414
  history: { known: ['list', 'show', 'search'], default: 'list' },
352
- repo: { known: ['list', 'refresh', 'sync'], default: 'list' },
415
+ repo: { known: ['list', 'refresh', 'sync', 'clone'], default: 'list' },
353
416
  'risk-map': { known: ['check', 'draft'], default: 'check' },
354
417
  codeowners: { known: ['check'], default: 'check' },
355
418
  docs: { known: ['list', 'build', 'deploy', 'sync'], default: 'list' },
356
419
  reconcile: { known: ['check', 'refresh', 'wire'] },
357
420
  hook: { known: ['ledger-guard'] },
358
421
  };
422
+ // E80 — how each command finds its folder. A PRODUCT command works on the Product; a REPO command works
423
+ // on the code repo it runs in and reads the Product beside it. The rest take their folder as given:
424
+ // `setup` and `new`/`init`/`join` make one, `report` and `hook` read what is there.
425
+ const PRODUCT_CMDS = new Set([
426
+ 'check', 'update', 'doctor', 'migrate', 'usage', 'sync-status', 'epic', 'foundation', 'skill', 'next',
427
+ 'skip', 'unskip', 'defer', 'undefer', 'dial', 'mode', 'kill', 'unkill', 'unblock', 'gate', 'checkpoint',
428
+ 'capture', 'claims', 'assign', 'unassign', 'owners', 'fold', 'tidy', 'index', 'history', 'repo',
429
+ 'risk-map', 'codeowners', 'docs', 'thread', 'reconcile',
430
+ ]);
431
+ const REPO_CMDS = new Set(['commit', 'open-pr', 'ship', 'review']);
432
+ // The Product commands that WRITE its files without first checking it is one. Run from a code repo they
433
+ // wrote an epic, a dial or the module into it; they refuse instead (E80). The rest either only read, or
434
+ // already refuse a folder that is not a Product.
435
+ function writesProduct(cmd, o) {
436
+ const action = o._[1];
437
+ if (cmd === 'update' || cmd === 'kill' || cmd === 'unkill') return true;
438
+ if (cmd === 'check') return !!o.fix;
439
+ if (cmd === 'epic' || cmd === 'foundation') return action === 'new';
440
+ if (cmd === 'skill') return action === 'bind' || action === 'unbind';
441
+ if (cmd === 'dial') return o.to !== undefined;
442
+ // `--wire` writes a CI workflow; `--refresh` only rebuilds sites the folder already holds.
443
+ if (cmd === 'docs') return action === 'sync' && !!o.wire;
444
+ return false;
445
+ }
446
+
447
+ // Where the person IS, as their shell says it: a repo reached through a link (`ws/backend -> /src/backend`)
448
+ // is inside the workspace by the path they typed, and not by the one the disk resolves to.
449
+ function shellCwd() {
450
+ const pwd = process.env.PWD;
451
+ try { if (pwd && path.isAbsolute(pwd) && fs.realpathSync(pwd) === fs.realpathSync(process.cwd())) return pwd; } catch { /* the disk's path, then */ }
452
+ return process.cwd();
453
+ }
454
+
359
455
  const ALWAYS_JSON = new Set(['gate review', 'gate walkthrough', 'review context', 'review chat', 'review cards', 'review walkthrough']);
360
456
  export function commandName(words) {
361
457
  const [cmd, action] = words;
@@ -399,26 +495,85 @@ async function main() {
399
495
  // so it takes no --json (E1).
400
496
  if (inJSON()) return refuse('yad hook takes no --json — its output is the protocol the agent reads', 'run it without --json');
401
497
  if (action !== 'ledger-guard') return refuse(`unknown hook: ${action ?? '(none)'} (ledger-guard)`);
498
+ // `--staged` is the git pre-commit half (E48): the files come from the index, never from stdin.
499
+ if (o.staged) { runStagedLedgerGuard(); return; }
402
500
  runLedgerGuardHook({ paths: o.path ? [o.path] : [], format: o.format });
403
501
  return;
404
502
  }
405
503
 
504
+ // The same hot path for the post-edit capture hook (E43): it runs after every agent edit, never fails,
505
+ // and needs `cli/capture.mjs` alone.
506
+ if (cmd === 'capture' && o.hook) {
507
+ const { runCapture } = await import('../cli/capture.mjs');
508
+ try { await runCapture(o.dir, { hook: true, noPush: !!o.noPush, format: o.format || null }); } catch (e) { process.stderr.write(` • yad capture: ${e.message}\n`); }
509
+ process.exitCode = 0;
510
+ return;
511
+ }
512
+
406
513
  // Every other command. One await, once, for a process that is about to do real work anyway.
407
514
  const commands = await import('./commands.mjs');
408
515
  if (o.help || !cmd) {
409
516
  const text = helpText(commands.seedableProfiles());
410
517
  return inJSON() ? emitJSON({ help: stripAnsi(text) }) : log(text);
411
518
  }
519
+ // E80: find the Product from wherever yad runs. `--dir` always wins. Otherwise a Product command runs
520
+ // against the Product found by walking up — from any subfolder of the Product, or from inside a repo it
521
+ // registers (through `.yad-workspace.json`) — and says where, on stderr. A code-repo command keeps acting
522
+ // on the repo it runs in, and is handed the Product to read (its registry: name, platform, branch).
523
+ const dirGiven = process.argv.slice(2).some((a) => a === '--dir' || a.startsWith('--dir='));
524
+ if (PRODUCT_CMDS.has(cmd) || REPO_CMDS.has(cmd)) {
525
+ const found = commands.findProduct(dirGiven ? o.dir : shellCwd());
526
+ const shown = (p) => path.relative(process.cwd(), p) || '.';
527
+ if (found?.problem) warn(`${found.problem} — using ${o.dir}`);
528
+ else if (found?.elsewhere) {
529
+ // The workspace folder itself, or a repo the Product does not register: no Product here, but the
530
+ // person is one `cd` away from it — say so, rather than let "run yad setup" make a second one.
531
+ if (PRODUCT_CMDS.has(cmd)) warn(`this folder is not in a repo the Product registers — the Product is ${shown(found.elsewhere)}: cd there, or pass --dir ${shown(found.elsewhere)}`);
532
+ } else if (found?.root && REPO_CMDS.has(cmd)) o.product = found.root;
533
+ else if (found?.root && !dirGiven && found.via !== 'here') {
534
+ o.dir = found.root;
535
+ process.stderr.write(c.dim(`Product: ${shown(found.root)} (${found.via === 'workspace' ? `from ${commands.WORKSPACE_FILE}` : 'above this folder'})\n`));
536
+ // Run from inside one code repo, the per-repo commands work on THAT repo, not on every one the
537
+ // Product registers (they take the repo's name as their second word).
538
+ // The CHECKOUT must be the registered repo (a worktree or nested repo inside it is another one).
539
+ if ((cmd === 'risk-map' || cmd === 'codeowners') && o._[2] === undefined) {
540
+ const hit = commands.registryEntryFor(found.root, dirGiven ? o.dir : shellCwd());
541
+ if (typeof hit?.meta?.name === 'string') o._[2] = hit.meta.name;
542
+ }
543
+ }
544
+ }
545
+ // A command that WRITES the Product never writes into a folder that is not one (a code repo, say).
546
+ // Asked by each such command AFTER its own usage checks, so a mistyped command still hears what is
547
+ // wrong with it first.
548
+ const noProduct = () => {
549
+ if (!writesProduct(cmd, o) || commands.hasProduct(o.dir)) return false;
550
+ refuse(`no Product here (${o.dir}) — yad ${cmd} writes the Product's files`, 'run it from the Product, from inside a repo it registers, or pass --dir <the Product>; `yad new` / `yad init` start one');
551
+ return true;
552
+ };
412
553
  // A project written by a newer yadflow is warned about before any command reads it (docs/migrations/
413
554
  // shape-8.md). Not on `hook` — its stderr is the channel a block reason reaches a model on — and not
414
555
  // where the command reports the same thing itself (doctor, migrate) or runs before a project exists.
415
- if (!['hook', 'doctor', 'migrate', 'setup', 'report'].includes(cmd)) commands.warnIfProjectAhead(o.dir || process.cwd());
556
+ if (!['hook', 'doctor', 'migrate', 'setup', 'report', 'new', 'init', 'join'].includes(cmd)) commands.warnIfProjectAhead(o.dir || process.cwd());
557
+ // E122: the Product's settings (or an epic's PR ledger) under two names that say different things.
558
+ // Every command refuses rather than pick one — whichever it picked, somebody's edit would be silently
559
+ // ignored. Not `doctor` (it reports the drift), `migrate` (it ends it) or `report` (it files a bug
560
+ // about a broken flow, and reads nothing it could get wrong). `hook` returned above: it guards.
561
+ if (!['doctor', 'migrate', 'report'].includes(cmd)) {
562
+ // Both the folder the command runs on and, for a code-repo command, the Product it reads.
563
+ for (const root of new Set([o.dir || process.cwd(), o.product].filter(Boolean))) {
564
+ const drift = commands.productDriftError(root);
565
+ if (drift) throw drift;
566
+ }
567
+ }
416
568
 
417
569
  const today = new Date().toISOString().slice(0, 10);
418
570
  // What the command returned: under --json, a command that did not answer itself answers with it.
419
571
  let result;
420
572
  switch (cmd) {
421
573
  case 'setup':
574
+ // A workspace folder holds a Product; it never becomes a second one (E80 review 2 — following
575
+ // "run yad setup" from the workspace folder did exactly that, and hid the real one from its repos).
576
+ if (commands.workspaceProduct(o.dir)) { refuse(`this folder is a workspace — its Product is ${path.relative(process.cwd(), commands.workspaceProduct(o.dir)) || '.'}`, 'run `yad setup` there, or `yad next` from inside it'); break; }
422
577
  result = await commands.runSetup(o.dir, {
423
578
  today, force: o.force,
424
579
  solo: o.solo, team: o.team, greenfield: o.greenfield, brownfield: o.brownfield,
@@ -433,17 +588,36 @@ async function main() {
433
588
  ideTargets: o['ide-targets'] === undefined ? undefined : String(o['ide-targets']).split(',').map((t) => t.trim()).filter(Boolean),
434
589
  });
435
590
  break;
591
+ // E79: the workspace verbs. `--dir` is never the Product here: for `init` it is the WORKSPACE (the
592
+ // folder that holds product/ and the code repos); for `new` and `join` it is the folder the workspace
593
+ // is made IN. The setup flags pass through.
594
+ case 'new':
595
+ case 'init':
596
+ case 'join': {
597
+ const setupFlags = {
598
+ today, force: o.force,
599
+ solo: o.solo, team: o.team, greenfield: o.greenfield, brownfield: o.brownfield,
600
+ monorepo: o.monorepo, separate: o.separate, tools: o.tools,
601
+ ideTargets: o['ide-targets'] === undefined ? undefined : String(o['ide-targets']).split(',').map((t) => t.trim()).filter(Boolean),
602
+ };
603
+ if (cmd === 'new') result = await commands.runNew(o.dir, o._[1], setupFlags);
604
+ else if (cmd === 'init') result = await commands.runInit(o.dir, { ...setupFlags, path: o.path });
605
+ else result = await commands.runJoin(o.dir, o._[1], o._[2], { ideTargets: setupFlags.ideTargets });
606
+ break;
607
+ }
436
608
  case 'check':
609
+ if (noProduct()) break;
437
610
  result = await commands.reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
438
611
  break;
439
612
  case 'update':
613
+ if (noProduct()) break;
440
614
  result = await commands.reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
441
615
  break;
442
616
  case 'doctor':
443
617
  result = await commands.runDoctor(o.dir, { json: o.json });
444
618
  break;
445
619
  case 'migrate':
446
- result = await commands.runMigrate(o.dir, { apply: o.apply, json: o.json });
620
+ result = await commands.runMigrate(o.dir, { apply: o.apply, json: o.json, keep: o.keep ?? null });
447
621
  break;
448
622
  case 'report':
449
623
  result = await commands.runReport(o.dir, { message: o.message });
@@ -471,6 +645,7 @@ async function main() {
471
645
  refuse(`unknown epic action: ${action ?? '(none)'} (new)`, `usage: yad epic new <slug> [--type feature|chore|change|defect|hotfix] [--profile ${commands.seedableProfiles().join('|')}] [--parent EP-<slug> --inherits <bases>]`);
472
646
  break;
473
647
  }
648
+ if (noProduct()) break;
474
649
  result = await commands.runEpicNew(o.dir, { slug, type: o.type, profile: o.profile, stub: o.stub, parent: o.parent, inherits: o.inherits, today, json: o.json });
475
650
  break;
476
651
  }
@@ -481,12 +656,14 @@ async function main() {
481
656
  refuse(`unknown foundation action: ${action ?? '(none)'} (new, status)`, 'usage: yad foundation new [--json] | yad foundation status [--json]');
482
657
  break;
483
658
  }
659
+ if (noProduct()) break;
484
660
  result = await commands.runFoundationNew(o.dir, { today, json: o.json });
485
661
  break;
486
662
  }
487
663
  case 'skill': {
488
664
  const [, action, step, ...rest] = o._;
489
665
  if (action === 'list' || action === undefined) { result = commands.runSkillList(o.dir, { json: o.json }); break; }
666
+ if ((action === 'bind' || action === 'unbind') && noProduct()) break;
490
667
  if (action === 'bind') { result = commands.runSkillBind(o.dir, { step, skills: rest }); break; }
491
668
  if (action === 'unbind') { result = commands.runSkillUnbind(o.dir, { step }); break; }
492
669
  refuse(`unknown skill action: ${action} (list, bind, unbind)`, 'usage: yad skill list [--json] | yad skill bind <step> <skill> [<skill> ...] | yad skill unbind <step>');
@@ -525,9 +702,11 @@ async function main() {
525
702
  // One word names a Shape author step (project-wide). Three name a Build lane step: epic, story, step.
526
703
  const args = o._.slice(1);
527
704
  if (args.length === 1) {
705
+ if (noProduct()) break;
528
706
  result = await commands.runDial(o.dir, { step: args[0], to: o.to, json: o.json });
529
707
  } else if (args.length === 3) {
530
708
  if (!commands.isValidEpicId(args[0])) { refuse(`invalid epic id: ${args[0]} (expected EP-<slug>, [a-z0-9-] only)`); break; }
709
+ if (noProduct()) break;
531
710
  result = await commands.runDial(o.dir, { epic: args[0], story: args[1], step: args[2], repo: o.repo, to: o.to, json: o.json });
532
711
  } else {
533
712
  refuse('usage: yad dial <step> [--to auto|human] | yad dial <epic> <story> --repo <name> <step> [--to auto|human]');
@@ -542,6 +721,7 @@ async function main() {
542
721
  case 'kill':
543
722
  case 'unkill': {
544
723
  if (o._.length > 1) { refuse(`unexpected argument(s): ${o._.slice(1).join(' ')}`); break; }
724
+ if (noProduct()) break;
545
725
  result = await commands.runKill(o.dir, { on: o._[0] === 'kill', reason: o.reason, json: o.json, today });
546
726
  break;
547
727
  }
@@ -555,7 +735,7 @@ async function main() {
555
735
  const [, action, epic, artifact] = o._;
556
736
  // `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
557
737
  if (action === 'ci') { result = await commands.gateCi(o.dir, { branch: o.branch, pr: o.pr, merged: o.merged, push: !o.noPush, today }); break; }
558
- if (!epic) { refuse('usage: yad gate <open|sync|comments|status|repair|review|walkthrough|trailer|ci> <epic> [artifact]'); break; }
738
+ if (!epic) { refuse('usage: yad gate <open|sync|comments|status|repair|review|walkthrough|trailer|ci|approve|comment|advance> <epic> [artifact]'); break; }
559
739
  // The epic id becomes a path segment under epics/ — reject anything but EP-<slug> outright.
560
740
  if (!commands.isValidEpicId(epic)) { refuse(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`); break; }
561
741
  // In verified mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
@@ -569,31 +749,38 @@ async function main() {
569
749
  else if (action === 'review') result = await commands.gateReview(o.dir, { epic, artifact });
570
750
  else if (action === 'walkthrough') result = await commands.gateWalkthrough(o.dir, { epic, artifact });
571
751
  else if (action === 'trailer') result = await commands.gateTrailer(o.dir, { epic, artifact, body: o.body || o.message, number: o.pr });
572
- else { refuse(`unknown gate action: ${action} (open|sync|comments|status|repair|review|walkthrough|trailer|ci)`); }
752
+ // E112 — the gate on a Product with no platform: record an approval, record a comment round, advance.
753
+ else if (action === 'approve') result = await commands.gateApprove(o.dir, { epic, artifact, by: o.by, engagement: o.engagement ?? null, today });
754
+ else if (action === 'comment') result = await commands.gateComment(o.dir, { epic, artifact, by: o.by, count: o.count ?? null, newRound: !!o.newRound, today });
755
+ else if (action === 'advance') result = await commands.gateAdvance(o.dir, { epic, artifact, today });
756
+ else { refuse(`unknown gate action: ${action} (open|sync|comments|status|repair|review|walkthrough|trailer|ci|approve|comment|advance)`); }
573
757
  break;
574
758
  }
575
759
  case 'review': {
576
760
  const [, action] = o._;
577
- if (action === 'trailer') result = await commands.reviewTrailer(o.dir, { repo: o.repo, pr: o.pr, body: o.body || o.message });
578
- else if (action === 'context' || action === 'chat' || action === 'cards') result = await commands.reviewContext(o.dir, { repo: o.repo, pr: o.pr });
579
- else if (action === 'walkthrough') result = await commands.reviewWalkthrough(o.dir, { repo: o.repo, pr: o.pr });
580
- else if (action === 'nudge') result = await commands.reviewNudge(o.dir, { repo: o.repo, pr: o.pr });
761
+ if (action === 'trailer') result = await commands.reviewTrailer(o.dir, { repo: o.repo, product: o.product, pr: o.pr, body: o.body || o.message });
762
+ else if (action === 'context' || action === 'chat' || action === 'cards') result = await commands.reviewContext(o.dir, { repo: o.repo, product: o.product, pr: o.pr });
763
+ else if (action === 'walkthrough') result = await commands.reviewWalkthrough(o.dir, { repo: o.repo, product: o.product, pr: o.pr });
764
+ else if (action === 'nudge') result = await commands.reviewNudge(o.dir, { repo: o.repo, product: o.product, pr: o.pr });
581
765
  else if (action === 'reconcile') {
582
766
  // The epic becomes a path segment under epics/ — reject anything but EP-<slug> (no `../` escape).
583
767
  if (!o.epic || !commands.isValidEpicId(o.epic)) { refuse(`invalid or missing --epic: ${o.epic ?? '(none)'} (expected EP-<slug>, [a-z0-9-] only)`); break; }
584
- result = await commands.reviewReconcile(o.dir, { epic: o.epic, repo: o.repo, pr: o.pr });
768
+ result = await commands.reviewReconcile(o.dir, { epic: o.epic, repo: o.repo, product: o.product, pr: o.pr });
585
769
  }
586
770
  else { refuse('usage: yad review <trailer|context|walkthrough|nudge|reconcile> --repo <name> --pr <n> [--epic <id>] [--body <text>]'); }
587
771
  break;
588
772
  }
589
773
  case 'commit':
590
- result = await commands.runCommit(o.dir, { type: o.type, message: o.message, task: o.task, ai: o.ai, contractChange: o.contractChange, dryRun: o.dryRun, force: o.force });
774
+ result = await commands.runCommit(o.dir, { type: o.type, message: o.message, task: o.task, ai: o.ai, contractChange: o.contractChange, dryRun: o.dryRun, force: o.force, manual: o.manual, reason: o.reason });
591
775
  break;
592
776
  case 'open-pr':
593
- result = await commands.runOpenPr(o.dir, { repo: o.repo, platform: o.platform, base: o.base, title: o.title || o.message, task: o.task, risk: o.risk, contractChange: o.contractChange });
777
+ result = await commands.runOpenPr(o.dir, { repo: o.repo, product: o.product, platform: o.platform, base: o.base, title: o.title || o.message, task: o.task, risk: o.risk, contractChange: o.contractChange });
594
778
  break;
595
779
  case 'ship':
596
- result = await commands.runShip(o.dir, { type: o.type, message: o.message, task: o.task, ai: o.ai, contractChange: o.contractChange, dryRun: o.dryRun, force: o.force, repo: o.repo, platform: o.platform, base: o.base, title: o.title, risk: o.risk });
780
+ // The ledger override is its own act (E49): recorded in its own commit, never folded into a
781
+ // commit-and-open-PR run. Refused, never ignored.
782
+ if (o.manual || o.reason !== undefined) { refuse('yad ship takes no --manual/--reason: commit with `yad commit --manual --reason "<why>" …`, then `yad open-pr`'); break; }
783
+ result = await commands.runShip(o.dir, { type: o.type, message: o.message, task: o.task, ai: o.ai, contractChange: o.contractChange, dryRun: o.dryRun, force: o.force, repo: o.repo, product: o.product, platform: o.platform, base: o.base, title: o.title, risk: o.risk });
597
784
  break;
598
785
  case 'checkpoint': {
599
786
  let retroShip;
@@ -609,6 +796,45 @@ async function main() {
609
796
  result = await commands.runCheckpoint(o.dir, { push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun, retroShip });
610
797
  break;
611
798
  }
799
+ case 'capture': {
800
+ // E43. `--hook` is the harness's post-edit command: it never fails and never prints to stdout.
801
+ if (o._[1]) { refuse(`yad capture takes no word (got ${o._[1]})`, 'usage: yad capture [--no-push]'); break; }
802
+ result = await commands.runCapture(o.dir, { hook: !!o.hook, noPush: !!o.noPush });
803
+ if (o.hook) process.exitCode = 0;
804
+ break;
805
+ }
806
+ case 'claims': {
807
+ // E46. Read from the capture branches; nothing is recorded here.
808
+ const [, epic, extra] = o._;
809
+ if (extra) { refuse(`yad claims takes at most an epic (got an extra word: ${extra})`, 'usage: yad claims [<epic>] [--no-fetch]'); break; }
810
+ result = await commands.runClaims(o.dir, { epic, noFetch: !!o.noFetch });
811
+ break;
812
+ }
813
+ case 'assign':
814
+ case 'unassign': {
815
+ // E47. A file per step; nothing is committed and nothing is blocked.
816
+ const [verb, epic, step, extra] = o._;
817
+ const usage = verb === 'assign' ? 'usage: yad assign <epic> <step> [--to <git name>] [--force]' : 'usage: yad unassign <epic> <step> [--force]';
818
+ if (extra) { refuse(`yad ${verb} takes an epic and a step (got an extra word: ${extra})`, usage); break; }
819
+ if (verb === 'unassign' && o.to != null) { refuse('yad unassign takes no --to: it removes whoever is assigned', usage); break; }
820
+ result = verb === 'assign'
821
+ ? await commands.runAssign(o.dir, { epic, step, to: o.to ?? null, force: !!o.force, today })
822
+ : await commands.runUnassign(o.dir, { epic, step, force: !!o.force });
823
+ break;
824
+ }
825
+ case 'owners': {
826
+ const [, epic, extra] = o._;
827
+ if (extra) { refuse(`yad owners takes at most an epic (got an extra word: ${extra})`, 'usage: yad owners [<epic>]'); break; }
828
+ result = await commands.runOwners(o.dir, { epic });
829
+ break;
830
+ }
831
+ case 'fold': {
832
+ // E44. The step-boundary commit; the authoring skills run it where they used to say "commit".
833
+ const [, epic, step, extra] = o._;
834
+ if (extra) { refuse(`yad fold takes an epic and a step (got an extra word: ${extra})`, 'usage: yad fold <epic> <step>'); break; }
835
+ result = await commands.runFold(o.dir, { epic, step });
836
+ break;
837
+ }
612
838
  case 'tidy': {
613
839
  const [, action, epic] = o._;
614
840
  if (action !== 'up') {
@@ -663,13 +889,14 @@ async function main() {
663
889
  // instead of "unknown command".
664
890
  refuse('yad roster was removed — yadflow keeps no list of people',
665
891
  'A gate needs one approval (not the author\'s own) from anyone with access to the repo; the platform records who approved. '
666
- + 'Request reviewers on the PR itself. Keep an existing `roster` in hub.json until every review with older approvals is closed: '
892
+ + 'Request reviewers on the PR itself. Keep an existing `roster` in the Product settings until every review with older approvals is closed: '
667
893
  + 'it decides nothing, but the first sync uses its name → login pairs to recognise those approvals.');
668
894
  break;
669
895
  case 'docs': {
670
896
  const [, action] = o._;
671
897
  if (o.epic && !commands.isValidEpicId(o.epic)) { refuse(`invalid epic id: ${o.epic} (expected EP-<slug>, [a-z0-9-] only)`); break; }
672
898
  const sync = o.wire ? 'wire' : o.refresh ? 'refresh' : 'check';
899
+ if (noProduct()) break;
673
900
  result = await commands.runDocs(o.dir, { action: action || 'list', epic: o.epic, overview: o.overview, sync, today });
674
901
  break;
675
902
  }
@@ -739,7 +966,10 @@ main()
739
966
  // Require a TTY on BOTH ends: stdout for the message, stdin so the y/N prompt can be answered
740
967
  // (a TTY stdout with piped/closed stdin would otherwise hang on readline).
741
968
  const offerReport = !inJSON() && process.stdin.isTTY && process.stdout.isTTY && !process.env.SDLC_NONINTERACTIVE
742
- && !process.env.YAD_NO_REPORT && process.argv[2] !== 'report';
969
+ && !process.env.YAD_NO_REPORT && process.argv[2] !== 'report'
970
+ // Two copies of the project's own settings that disagree (E122) are the project's state, not a
971
+ // yadflow bug: the refusal already names the one command that ends it.
972
+ && err?.code !== 'YAD-STATE-008';
743
973
  if (offerReport) {
744
974
  try {
745
975
  if (await askYesNo('\nReport this failure to the yadflow team?', false)) {