yadflow 3.18.1 → 4.0.0-next.1

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 (156) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +79 -26
  3. package/bin/commands.mjs +41 -0
  4. package/bin/yad.mjs +437 -124
  5. package/cli/artifact-status.mjs +34 -15
  6. package/cli/checkpoint.mjs +69 -49
  7. package/cli/codeowners-command.mjs +170 -0
  8. package/cli/codeowners.mjs +397 -0
  9. package/cli/commit.mjs +13 -9
  10. package/cli/companion.mjs +2 -2
  11. package/cli/dial.mjs +183 -0
  12. package/cli/docs.mjs +88 -32
  13. package/cli/doctor.mjs +1472 -97
  14. package/cli/epic-state.mjs +3478 -232
  15. package/cli/epic.mjs +506 -0
  16. package/cli/errors.mjs +4 -1
  17. package/cli/gate.mjs +1002 -209
  18. package/cli/history.mjs +556 -0
  19. package/cli/hook.mjs +266 -55
  20. package/cli/hubcommit.mjs +6 -17
  21. package/cli/index-command.mjs +87 -0
  22. package/cli/ledger.mjs +57 -7
  23. package/cli/lib.mjs +184 -18
  24. package/cli/manifest.mjs +367 -56
  25. package/cli/migrate.mjs +726 -53
  26. package/cli/mode.mjs +170 -0
  27. package/cli/next.mjs +349 -90
  28. package/cli/openpr.mjs +191 -39
  29. package/cli/people.mjs +654 -0
  30. package/cli/plan.mjs +417 -132
  31. package/cli/platform.mjs +110 -129
  32. package/cli/product-index.mjs +287 -0
  33. package/cli/protection.mjs +706 -0
  34. package/cli/reconcile.mjs +38 -12
  35. package/cli/repo-publish.mjs +24 -26
  36. package/cli/repo.mjs +23 -14
  37. package/cli/report.mjs +21 -15
  38. package/cli/review.mjs +24 -27
  39. package/cli/riskmap-command.mjs +289 -0
  40. package/cli/riskmap.mjs +373 -0
  41. package/cli/setup.mjs +139 -287
  42. package/cli/ship.mjs +7 -6
  43. package/cli/skill.mjs +180 -0
  44. package/cli/skip.mjs +211 -30
  45. package/cli/thread.mjs +42 -17
  46. package/cli/tidy.mjs +20 -20
  47. package/cli/update-commit.mjs +22 -22
  48. package/cli/usage.mjs +115 -109
  49. package/package.json +3 -3
  50. package/skills/sdlc/config.yaml +166 -87
  51. package/skills/sdlc/module-help.csv +35 -35
  52. package/skills/yad-analysis/SKILL.md +125 -65
  53. package/skills/yad-architecture/SKILL.md +34 -23
  54. package/skills/yad-architecture/references/contract-format.md +10 -8
  55. package/skills/yad-backfill/SKILL.md +14 -8
  56. package/skills/yad-backfill/references/backfill.md +1 -1
  57. package/skills/yad-change/SKILL.md +127 -52
  58. package/skills/yad-change/references/triage.md +42 -28
  59. package/skills/yad-checks/SKILL.md +89 -45
  60. package/skills/yad-checks/references/check-gates.md +315 -92
  61. package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
  62. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  63. package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
  64. package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
  65. package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
  66. package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
  67. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  68. package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
  69. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
  70. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  71. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  72. package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
  73. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  74. package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
  75. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  76. package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
  77. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
  78. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  79. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
  80. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  81. package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
  82. package/skills/yad-commit/SKILL.md +6 -6
  83. package/skills/yad-connect-design/SKILL.md +6 -6
  84. package/skills/yad-connect-design/references/design-context.md +1 -1
  85. package/skills/yad-connect-design/references/design-registry.md +2 -2
  86. package/skills/yad-connect-docs/SKILL.md +12 -12
  87. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  88. package/skills/yad-connect-learning/SKILL.md +5 -5
  89. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  90. package/skills/yad-connect-repos/SKILL.md +92 -54
  91. package/skills/yad-connect-repos/references/code-context.md +6 -6
  92. package/skills/yad-connect-repos/references/hub-config.md +68 -58
  93. package/skills/yad-connect-repos/references/repos-registry.md +10 -9
  94. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  95. package/skills/yad-connect-testing/SKILL.md +6 -6
  96. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  97. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  98. package/skills/yad-defects/SKILL.md +8 -8
  99. package/skills/yad-discovery/SKILL.md +130 -94
  100. package/skills/yad-discovery/references/discovery-schema.md +23 -7
  101. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  102. package/skills/yad-docs/SKILL.md +16 -11
  103. package/skills/yad-docs/references/data-mapping.md +9 -7
  104. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  105. package/skills/yad-docs-overview/SKILL.md +32 -17
  106. package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
  107. package/skills/yad-docs-sync/SKILL.md +10 -5
  108. package/skills/yad-docs-sync/references/staleness.md +8 -7
  109. package/skills/yad-engineer-review/SKILL.md +88 -24
  110. package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
  111. package/skills/yad-epic/SKILL.md +178 -100
  112. package/skills/yad-epic/references/state-schema.md +626 -117
  113. package/skills/yad-hub-bridge/SKILL.md +66 -48
  114. package/skills/yad-hub-bridge/references/bridge.md +110 -83
  115. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  116. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  117. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  118. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  119. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
  120. package/skills/yad-implement/SKILL.md +29 -15
  121. package/skills/yad-implement/references/implement-conventions.md +2 -2
  122. package/skills/yad-learn/SKILL.md +9 -9
  123. package/skills/yad-learn/references/learning-state.md +2 -2
  124. package/skills/yad-open-pr/SKILL.md +64 -29
  125. package/skills/yad-pair-review/SKILL.md +18 -16
  126. package/skills/yad-pair-review/references/session-state.md +4 -4
  127. package/skills/yad-pr-template/SKILL.md +48 -27
  128. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  129. package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
  130. package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
  131. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  132. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  133. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  134. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
  135. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
  136. package/skills/yad-reconcile/SKILL.md +3 -3
  137. package/skills/yad-report/SKILL.md +5 -5
  138. package/skills/yad-review-companion/SKILL.md +12 -9
  139. package/skills/yad-review-gate/SKILL.md +198 -79
  140. package/skills/yad-review-gate/references/gating.md +230 -54
  141. package/skills/yad-run/SKILL.md +86 -56
  142. package/skills/yad-run/references/run-loop.md +67 -45
  143. package/skills/yad-ship/SKILL.md +18 -14
  144. package/skills/yad-spec/SKILL.md +31 -17
  145. package/skills/yad-spec/references/spec-handoff.md +17 -5
  146. package/skills/yad-status/SKILL.md +114 -56
  147. package/skills/yad-stories/SKILL.md +42 -27
  148. package/skills/yad-stories/references/story-schema.md +10 -9
  149. package/skills/yad-stub/SKILL.md +59 -48
  150. package/skills/yad-sync-repos/SKILL.md +3 -3
  151. package/skills/yad-test-cases/SKILL.md +37 -30
  152. package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
  153. package/skills/yad-timeline/SKILL.md +8 -7
  154. package/skills/yad-ui/SKILL.md +46 -25
  155. package/cli/roster.mjs +0 -164
  156. package/skills/sdlc/install.sh +0 -68
package/bin/yad.mjs CHANGED
@@ -1,32 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // `yad` — setup/maintenance + the PR-driven review gate + build helpers for the SDLC module.
3
3
  import { VERSION } from '../cli/manifest.mjs';
4
- import { c, log, closePrompts, askYesNo } from '../cli/lib.mjs';
5
- import { runSetup } from '../cli/setup.mjs';
6
- import { reconcile } from '../cli/reconcile.mjs';
7
- import { gateOpen, gateSync, gateComments, gateStatus, gateCi, gateReview, gateTrailer, gateWalkthrough, gateRepair } from '../cli/gate.mjs';
8
- import { isValidEpicId } from '../cli/epic-state.mjs';
9
- import { runCommit } from '../cli/commit.mjs';
10
- import { runOpenPr } from '../cli/openpr.mjs';
11
- import { reviewTrailer, reviewContext, reviewNudge, reviewReconcile, reviewWalkthrough } from '../cli/review.mjs';
12
- import { runShip } from '../cli/ship.mjs';
13
- import { runCheckpoint } from '../cli/checkpoint.mjs';
14
- import { runTidy } from '../cli/tidy.mjs';
15
- import { runRepo } from '../cli/repo.mjs';
16
- import { runRoster } from '../cli/roster.mjs';
17
- import { runDocs } from '../cli/docs.mjs';
18
- import { runDoctor } from '../cli/doctor.mjs';
19
- import { runMigrate } from '../cli/migrate.mjs';
20
- import { runNext } from '../cli/next.mjs';
21
- import { runSkip } from '../cli/skip.mjs';
22
- import { syncStatuses } from '../cli/artifact-status.mjs';
23
- import { runThread, runReconcile } from '../cli/thread.mjs';
24
- import { runReport } from '../cli/report.mjs';
25
- import { runUsage } from '../cli/usage.mjs';
4
+ import { c, log, warn, closePrompts, askYesNo, refuse, beginJSON, inJSON, emitJSON, jsonEmitted, jsonFailure, stripAnsi, isPlainObject, ENVELOPE_KEYS } from '../cli/lib.mjs';
26
5
  import { runLedgerGuardHook } from '../cli/hook.mjs';
27
- import { maybeNotifyUpdate } from '../cli/update-notice.mjs';
28
6
 
29
- const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
7
+ const helpText = (profiles) => `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
8
+
9
+ ${c.dim('Every command but `yad hook` takes --json: one JSON object on stdout, every other line on stderr')}
10
+ ${c.dim('(docs/CLI.md, "--json on every command").')}
30
11
 
31
12
  ${c.bold('Setup & maintenance')}
32
13
  yad setup Guided first-run setup (profile interview, install, connect & wire repos)
@@ -42,7 +23,7 @@ ${c.bold('Setup & maintenance')}
42
23
  it. Anything else it cannot account for is replaced only after
43
24
  a <file>.yad-orig backup
44
25
  yad update --push Also commit each repo's applied changes and push them straight to the
45
- default branch of the hub + every connected repo (a chore(yad-update)
26
+ default branch of the Product + every connected repo (a chore(yad-update)
46
27
  commit; no PR — the push-on-main yad-update-guard runs verified-commits
47
28
  + commit-message). Announce the team & pause merges first. Also works as
48
29
  'yad check --fix --push'; --allow-branch permits a non-default branch
@@ -59,18 +40,13 @@ ${c.bold('Setup & maintenance')}
59
40
  (no paths/hosts/repo names/logins/flag values). Also offered
60
41
  automatically after an unexpected failure. YAD_NO_REPORT=1 disables.
61
42
  yad hook ledger-guard ${c.dim('harness-invoked, not typed')} — refuse an agent's edit to the
62
- CI-owned gate ledger in bridge mode and name the command that owns
43
+ CI-owned gate ledger in verified mode and name the command that owns
63
44
  the transition. Reads a tool-call payload on stdin (or --path <p>);
64
- exit 0 allows, exit 2 denies with the reason on stderr. Wired into
65
- .claude/settings.json by setup / check --fix. YAD_HOOK_DISABLE=1 skips.
66
-
67
- ${c.bold('Reviewer roster')}
68
- yad roster list Show every member + their roles per scope (hub + each repo)
69
- yad roster add <login> Add/edit a member, then walk the connected repos for their roles
70
- (--name, --email, --roles "hub=owner,reviewer backend=domain-owner")
71
- yad roster grant <name> <repo> <role...> Grant role(s) for a connected repo (domain-owner|reviewer|owner)
72
- yad roster revoke <name> <repo> <role...> Remove role(s) for a repo
73
- yad roster remove <login> Delete a member from the roster
45
+ exit 0 allows, exit 2 denies with the reason on stderr. With
46
+ --format cursor the verdict is JSON on stdout instead, for a harness
47
+ whose pre-edit hook asks for permission rather than reading an exit
48
+ code. Wired into .claude/settings.json and .cursor/hooks.json by
49
+ setup / check --fix. YAD_HOOK_DISABLE=1 skips.
74
50
 
75
51
  ${c.bold('Team usage (EM adoption & behavior report)')}
76
52
  yad usage Build a per-member report (HTML) from git + the SDLC ledgers
@@ -80,25 +56,99 @@ ${c.bold('Team usage (EM adoption & behavior report)')}
80
56
  --member <name>, --format html|json|md, --repos (code commits)
81
57
 
82
58
  ${c.bold('Where am I / what next')}
59
+ yad epic new <slug> [--type <t>] [--profile <p>] [--stub] [--parent <epic> --inherits <bases>] [--json]
60
+ Seed a new epic's lifecycle: writes its step chain from a
61
+ profile, plus empty approval/comment ledgers and reviews/.
62
+ --type feature|chore (default feature). A change/defect/
63
+ hotfix needs --parent: it takes the parent's route, and
64
+ --inherits epic,architecture,contract,ui-design carries
65
+ those steps by reference (satisfied, bound to the owner's
66
+ hash). Carrying the contract writes a pointer-lock instead
67
+ of a second contract (E42). The yad-change skill triages.
68
+ --profile ${profiles.join('|')} (default classic).
69
+ classic and analysis-first are the full chains; chore and
70
+ spike are E40's short lanes — epic + stories, with the
71
+ analyst's brief in front for a spike. Neither carries an
72
+ architecture gate, so neither may move the contract surface.
73
+ --stub seeds a brownfield anchor instead: the same chain
74
+ with every step blocked behind backfill-pending, so a
75
+ defect can thread off a feature that shipped before the
76
+ Product existed (yad-backfill promote wakes it).
77
+ Writes no epic.md, no branch and no commit: run the skill
78
+ the chain names next. Refuses an epic that already has one
79
+ yad foundation new [--json] Seed the Product level (E75): the Foundation's two-step chain
80
+ in foundation/.sdlc/, plus empty ledgers and reviews/. Run once
81
+ per product, before any epic — then the yad-discovery skill
82
+ authors its sections. Refuses a second one, and a product still
83
+ on the old epics/EP-discovery/ (yad migrate converts that)
84
+ yad foundation status [--json] Which roadmap features are started: reads each proposed epic id
85
+ in roadmap.md and reports planned / in-shape / in-build /
86
+ shipped from the epic ledgers. Read-only — the roadmap's Status
87
+ column is no longer kept by hand
83
88
  yad next Project-wide: the one next action to take (or run setup)
84
89
  yad next <epic> The single next action for one epic (skill or yad command)
85
90
  yad next <epic> --check <step> Exit 0 if <step> is runnable now, else 1 (precondition guard)
86
91
  yad next --all Every active epic's next action at once
87
92
  yad next [<epic>] --json The same answer as a machine-readable action object (for
88
93
  agents/CI) — always every epic, so --all is implied
89
- yad skip <epic> ui-design --reason <text> Mark an optional step N/A for this epic (only
90
- ui-design today) — a backend/API/data epic with no UI.
91
- Stays visible & auditable (pre-done, gate short-circuited);
92
- --undo reverses it until the stories review opens
94
+ yad skill list [--json] Which skill runs which step, and whether that is this
95
+ project's choice or the engine's default
96
+ yad skill bind <step> <skill> [<skill> ...]
97
+ Bind a step to a skill of your own. Several skills run in
98
+ the order given, one after another — each costs tokens
99
+ yad skill unbind <step> Drop the binding; the step goes back to the engine's default
100
+ yad skip <epic> <step> --reason <text> Mark an optional step N/A for this epic. Which steps
101
+ those are comes from the epic's lifecycle route — on classic
102
+ and analysis-first, ui-design: a backend/API/data epic with no
103
+ UI. Stays visible & auditable (reason recorded, gate
104
+ short-circuited); refused once any later step has started
105
+ yad unskip <epic> <step> Put a skipped step back in the chain (\`skip --undo\` does the
106
+ same), until the step that follows it is finished or work
107
+ past it starts — on classic, until stories are done
108
+ yad skip <epic> <story> --repo <name> --reason <text>
109
+ Skip a whole Build lane: this story needs no change in this
110
+ repo (E39). Written to build-state/<story>.json; commit it with
111
+ yad checkpoint --push. Refused before the stories review passes
112
+ (edit the story's repos: instead), for a repo the story does not
113
+ declare, once work started or a ship is recorded, and for the
114
+ story's last lane. No single Build step can be skipped
115
+ yad unskip <epic> <story> --repo <name>
116
+ Put a skipped Build lane back; yad-run adds it on its next run
117
+ yad defer <epic> <step> --reason <text> [--debt]
118
+ Set an optional step aside to do LATER: marks it deferred.
119
+ Say who is waiting for it in the reason. Same steps and same
120
+ refusals as skip; the chain goes on, its review is still owed.
121
+ --debt marks it owed back: yad next and yad doctor remind
122
+ you until its review passes
123
+ yad undefer <epic> <step> Put a deferred step back, at any time. After later work has
124
+ finished, the step re-opens beside that work, which stays done
125
+ yad unblock <epic> <step> Clear a recorded blocker once the wait is over: moves the
126
+ step off blocked and removes its record in one write
127
+ yad dial <step> [--to auto|human] [--json]
128
+ A Shape author step's advance dial, for the whole project (E34),
129
+ kept in .sdlc/automation.json. No --to shows it. A review gate is
130
+ always human. Recorded only for now: nothing drives a Shape step
131
+ on its own until the engine runs agents
132
+ yad dial <epic> <story> --repo <name> <step> [--to auto|human] [--json]
133
+ A Build lane step's dial (spec, tasks, implement, checks), in
134
+ build-state; on auto the yad-run skill moves past it after a clean
135
+ run. Shows the step's run record as advice, never as a rule
136
+ yad kill --reason <text> Kill switch: hold every step at advance: human. Recorded in
137
+ .sdlc/automation.json — who, when and why
138
+ yad unkill [--reason <text>] Turn the kill switch off; each step follows its own dial again
139
+ yad mode [solo --reason <text> | team [--reason <text>]] [--json]
140
+ Who must approve. Solo waives approvals on every review gate (the
141
+ merge still decides); team counts them. Records who, when and why.
142
+ No word reads the mode. Open reviews follow it from their next sync
93
143
 
94
- ${c.bold('Review gate (front half)')}
144
+ ${c.bold('Review gate (Shape)')}
95
145
  yad gate open <epic> <artifact> Open the review PR/MR; mark the step in_review. The review
96
146
  branch must already be on origin (it is never created here)
97
147
  yad gate sync <epic> [artifact] [--pr <n>]
98
148
  Pull PR state -> ledger; advance on approved+resolved+merged.
99
149
  With no recorded PR, resolves it from the review branch; --pr
100
150
  names one (and overrides a stale recorded pointer). Advisory
101
- in bridge mode — there, recover with 'yad gate ci' below
151
+ in verified mode — there, recover with 'yad gate ci' below
102
152
  yad gate comments <epic> [artifact] Fetch unresolved review comments to address
103
153
  yad gate status <epic> Show each review step + approvals
104
154
  yad gate repair <epic> [--push] Close an author step stranded behind a passed review gate
@@ -110,18 +160,18 @@ ${c.bold('Review gate (front half)')}
110
160
  yad gate trailer <epic> [artifact] --body <text> [--pr <n>]
111
161
  Upsert the companion's 60-sec briefing into the PR/MR description
112
162
  yad gate ci [--branch <head>] [--pr <n>] [--merged]
113
- CI entry (hub workflow): pre-merge is read-only (nothing pushed);
163
+ CI entry (Product workflow): pre-merge is read-only (nothing pushed);
114
164
  --merged advances the step + flips artifact status on the default branch
115
165
 
116
166
  ${c.bold('Build helpers')}
117
167
  yad commit --type <t> -m <subject> Commit by convention (trailers, atomic guard)
118
168
  yad open-pr [--repo <name>] Open a task PR/MR against the repo's DEFAULT branch (never a
119
- hardcoded main; --base overrides) — stage-aware on the hub: a
120
- review/EP-* branch opens the front-half artifact-review PR
121
- (delegates to gate open), any other hub branch uses the
169
+ hardcoded main; --base overrides) — stage-aware on the Product: a
170
+ review/EP-* branch opens the Shape artifact-review PR
171
+ (delegates to gate open), any other Product branch uses the
122
172
  code-task template
123
173
  yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step (stage-aware)
124
- yad checkpoint [--push] Commit the machine-written back-half hub state
174
+ yad checkpoint [--push] Commit the machine-written Build state on the Product
125
175
  (trust-log/build-log/build-state) — plus any story
126
176
  status: flip (→ in-build/shipped) backed by a build-log
127
177
  ship — as one audit-trail chore(hub) commit; default
@@ -131,10 +181,22 @@ ${c.bold('Build helpers')}
131
181
  (merged before ledger tracking), then carry its status: shipped
132
182
  flip in the same commit (--merge-commit <sha>, --task <t> opt.);
133
183
  one repo per run — re-run per --repo for a multi-repo story
134
- yad tidy up [<epic>] [--push] Fold FINISHED back-half shards (a shipped story's
184
+ yad tidy up [<epic>] [--push] Fold FINISHED Build shards (a shipped story's
135
185
  trust-log/build-log entries) back into the single folded
136
186
  ledger, as one chore(hub) commit — the manual "pack it up"
137
187
  for the shard files; a no-op when nothing is foldable
188
+ yad index [--json] Rebuild .sdlc/index.json — one summary line per work item,
189
+ derived from their own files; the default branch only, with no
190
+ override; on a verified Product CI writes it.
191
+ --json prints it, built live, and writes nothing
192
+ yad history [list] [--type t] [--theme x] [--thread EP-…] [--open|--done] [--json]
193
+ Every work item, open and shape-done (Build is not counted),
194
+ newest first — built live from their own files; writes nothing
195
+ yad history show <id> [--json] One work item: every step with its state and closing record,
196
+ and the approvals recorded for each review step
197
+ yad history search <text> [filters] [--json]
198
+ Match text in ids, titles, themes, types, repos, and in who
199
+ closed or merged a step, its PR and its commit
138
200
  yad review trailer --repo <r> --pr <n> --body <text> Post the companion's 60-sec briefing to a code PR/MR
139
201
  yad review context --repo <r> --pr <n> Print the grounding bundle for cards/chat
140
202
  yad review walkthrough --repo <r> --pr <n> Bundle + ordered risk-tagged stops for the
@@ -144,7 +206,17 @@ ${c.bold('Build helpers')}
144
206
  yad repo list Show connected repos (fresh / stale)
145
207
  yad repo refresh [name] [--push] Re-pack a stale repo (a human decision). --push commits the
146
208
  refreshed code-maps + registry as a chore(hub): sync code-context … [skip ci]
147
- audit commit and pushes it to the hub default branch (--allow-branch to override)
209
+ audit commit and pushes it to the Product default branch (--allow-branch to override)
210
+ yad risk-map check [repo] [--json] Warn where a code repo's .sdlc/risk-map (a risk level per
211
+ directory, no names) has gone stale — advisory, never blocks
212
+ yad risk-map draft [repo] Add an 'unset' line for every directory the map does not cover
213
+ (--dry-run prints it);
214
+ never changes a line (the yad-connect-repos skill classifies them)
215
+ yad codeowners check [repo] [--json] [--platform github|gitlab]
216
+ Warn where a code repo's CODEOWNERS looks stale: a line that
217
+ matches no file, a file the platform never reads or will not load,
218
+ a line yad cannot read; plus a hint about @logins with no recent
219
+ commit — advisory, never blocks, never writes the file
148
220
 
149
221
  ${c.bold('Feature threads (post-lock change management)')}
150
222
  yad thread List every feature thread (genesis → changes → defects)
@@ -169,7 +241,7 @@ ${c.bold('Options')}
169
241
  --repo <name> open-pr: target a registered repo by name
170
242
  --base <branch> open-pr: override the PR/MR base — default is the repo's own default
171
243
  branch (repos.json default_branch, else hub.json default_branch for a PR
172
- on the hub itself, else the platform, else origin/HEAD, else main); a
244
+ on the Product itself, else the platform, else origin/HEAD, else main); a
173
245
  non-default base loses the AI first pass (warns, never blocks)
174
246
  --epic <id> docs: target one epic's site (EP-<slug>)
175
247
  --overview docs: target the project SDLC-overview site
@@ -189,12 +261,14 @@ ${c.bold('Options')}
189
261
 
190
262
  ${c.bold('Environment')}
191
263
  YAD_NO_UPDATE_NOTIFIER=1 Silence the "update available" notice (also off in CI)
192
- YAD_NO_REPORT=1 Never offer to file a bug report after a failure`;
264
+ YAD_NO_REPORT=1 Never offer to file a bug report after a failure
265
+ YAD_PLATFORM_LOGIN=0 Name a record's author by git user.name; never ask gh/glab who is logged in`;
193
266
 
194
- const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--retro-ship', '--merge-commit', '--path']);
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']);
195
268
 
196
269
  function parseArgs(argv) {
197
270
  const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
271
+ try {
198
272
  for (let i = 0; i < argv.length; i++) {
199
273
  const a = argv[i];
200
274
  if (a === '--fix') o.fix = true;
@@ -212,7 +286,11 @@ function parseArgs(argv) {
212
286
  // positional. `o._[0]` is the command, already pushed by the time `--check` is seen in normal use.
213
287
  else if (a === '--check') { const v = argv[i + 1]; o.check = (o._[0] === 'next' && v !== undefined && !v.startsWith('-')) ? argv[++i] : true; }
214
288
  else if (a === '--all') o.all = true;
289
+ else if (a === '--open') o.open = true;
290
+ else if (a === '--done') o.done = true;
215
291
  else if (a === '--undo') o.undo = true;
292
+ else if (a === '--debt') o.debt = true;
293
+ else if (a === '--stub') o.stub = true;
216
294
  // setup profile flags (pre-answer the Step 0 interview, for CI/scripts)
217
295
  else if (a === '--solo') o.solo = true;
218
296
  else if (a === '--greenfield') o.greenfield = true;
@@ -234,11 +312,60 @@ function parseArgs(argv) {
234
312
  else if (a.startsWith('--scope=')) o.scope = a.slice('--scope='.length);
235
313
  else if (a === '-m' || a === '--message') o.message = takeValue(argv, ++i, a);
236
314
  else if (VALUE_FLAGS.has(a)) o[a.replace(/^--/, '')] = takeValue(argv, ++i, a);
237
- else o._.push(a);
315
+ // `--flag=value` for the same flags. Without it the token matched nothing and fell through to the
316
+ // positionals, where it was silently ignored — and for `yad hook ledger-guard --format=cursor`
317
+ // that is not a cosmetic miss: the format goes undefined, the exit protocol is selected, and under
318
+ // Cursor an empty stdout on an allow BLOCKS every file write. A flag spelling that silently
319
+ // inverts a guard is worth accepting rather than quietly dropping.
320
+ else if (a.startsWith('--') && a.includes('=') && VALUE_FLAGS.has(a.slice(0, a.indexOf('=')))) {
321
+ const eq = a.indexOf('=');
322
+ const value = a.slice(eq + 1);
323
+ if (!value) throw new Error(`${a.slice(0, eq)} expects a value`);
324
+ o[a.slice(2, eq)] = value;
325
+ } else o._.push(a);
326
+ }
327
+ } catch (e) {
328
+ // The command read so far, so the error handler knows WHICH command failed — the parse did not finish.
329
+ e.parsedCmd = o._[0] ?? null;
330
+ // The words read so far, so a refusal names `history show`, not the default action (E1 review).
331
+ e.parsedWords = o._.slice(0, 2);
332
+ throw e;
238
333
  }
239
334
  return o;
240
335
  }
241
336
 
337
+ // The command `main` is running, for the error handler (set once the arguments are read).
338
+ let runningCmd = null;
339
+
340
+ // The `command` a --json answer names: the command word, plus its action where it has one, as the
341
+ // user would type it (`gate status`, `history show`). An action left out is the one that ran
342
+ // (`yad skill` is `skill list`); a word that is not one of the command's actions is not added, so a
343
+ // refusal of `yad gate frobnicate` names `gate`, not a command that does not exist.
344
+ const ACTIONS = {
345
+ epic: { known: ['new'] },
346
+ foundation: { known: ['new', 'status'] },
347
+ skill: { known: ['list', 'bind', 'unbind'], default: 'list' },
348
+ gate: { known: ['open', 'sync', 'comments', 'status', 'repair', 'review', 'walkthrough', 'trailer', 'ci'] },
349
+ review: { known: ['trailer', 'context', 'chat', 'cards', 'walkthrough', 'nudge', 'reconcile'] },
350
+ tidy: { known: ['up'] },
351
+ history: { known: ['list', 'show', 'search'], default: 'list' },
352
+ repo: { known: ['list', 'refresh', 'sync'], default: 'list' },
353
+ 'risk-map': { known: ['check', 'draft'], default: 'check' },
354
+ codeowners: { known: ['check'], default: 'check' },
355
+ docs: { known: ['list', 'build', 'deploy', 'sync'], default: 'list' },
356
+ reconcile: { known: ['check', 'refresh', 'wire'] },
357
+ hook: { known: ['ledger-guard'] },
358
+ };
359
+ const ALWAYS_JSON = new Set(['gate review', 'gate walkthrough', 'review context', 'review chat', 'review cards', 'review walkthrough']);
360
+ export function commandName(words) {
361
+ const [cmd, action] = words;
362
+ if (!cmd) return null;
363
+ const a = ACTIONS[cmd];
364
+ if (!a) return cmd;
365
+ if (action === undefined) return a.default ? `${cmd} ${a.default}` : cmd;
366
+ return a.known.includes(action) ? `${cmd} ${action}` : cmd;
367
+ }
368
+
242
369
  // A value flag must be followed by a token; erroring beats silently passing `undefined` downstream.
243
370
  function takeValue(argv, i, flag) {
244
371
  const v = argv[i];
@@ -247,179 +374,361 @@ function takeValue(argv, i, flag) {
247
374
  }
248
375
 
249
376
  async function main() {
377
+ // `--json` is read from the raw arguments too, so a refusal from the parser itself (a flag with no
378
+ // value) still answers as one object.
379
+ if (process.argv.slice(2).includes('--json')) beginJSON(null);
250
380
  const o = parseArgs(process.argv.slice(2));
251
381
  const cmd = o._[0];
252
- if (o.version) return log(VERSION);
253
- if (o.help || !cmd) return log(HELP);
382
+ runningCmd = cmd ?? null;
383
+ // The grounding bundles a skill parses are JSON with or without the flag, so they are always a
384
+ // --json run: one object on stdout, a refusal included, and every other line on stderr.
385
+ if (o.json || ALWAYS_JSON.has(commandName(o._))) beginJSON(commandName(o._));
386
+ if (o.version) return inJSON() ? emitJSON({}) : log(VERSION);
387
+
388
+ // THE HOT PATH, handled before anything heavy is loaded. `yad hook ledger-guard` runs inside the
389
+ // agent's tool loop on every file-editing call, and it needs `cli/hook.mjs` alone. Everything else
390
+ // lives behind the single dynamic import below, so the hook no longer pays for 29 modules it does
391
+ // not use — measured at ~30ms of module loading against ~14ms for what it actually needs.
392
+ //
393
+ // It sits above the help and the newer-shape warning deliberately: `--help` is not a hook call, and
394
+ // the warning is already excluded for `hook` below because its stderr is the channel a block reason
395
+ // reaches the model on.
396
+ if (cmd === 'hook') {
397
+ const [, action] = o._;
398
+ // Its stdout is already a protocol (Claude Code reads the exit code, Cursor a permission object),
399
+ // so it takes no --json (E1).
400
+ if (inJSON()) return refuse('yad hook takes no --json — its output is the protocol the agent reads', 'run it without --json');
401
+ if (action !== 'ledger-guard') return refuse(`unknown hook: ${action ?? '(none)'} (ledger-guard)`);
402
+ runLedgerGuardHook({ paths: o.path ? [o.path] : [], format: o.format });
403
+ return;
404
+ }
405
+
406
+ // Every other command. One await, once, for a process that is about to do real work anyway.
407
+ const commands = await import('./commands.mjs');
408
+ if (o.help || !cmd) {
409
+ const text = helpText(commands.seedableProfiles());
410
+ return inJSON() ? emitJSON({ help: stripAnsi(text) }) : log(text);
411
+ }
412
+ // A project written by a newer yadflow is warned about before any command reads it (docs/migrations/
413
+ // shape-8.md). Not on `hook` — its stderr is the channel a block reason reaches a model on — and not
414
+ // 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());
254
416
 
255
417
  const today = new Date().toISOString().slice(0, 10);
418
+ // What the command returned: under --json, a command that did not answer itself answers with it.
419
+ let result;
256
420
  switch (cmd) {
257
421
  case 'setup':
258
- await runSetup(o.dir, {
422
+ result = await commands.runSetup(o.dir, {
259
423
  today, force: o.force,
260
424
  solo: o.solo, team: o.team, greenfield: o.greenfield, brownfield: o.brownfield,
261
425
  monorepo: o.monorepo, separate: o.separate, tools: o.tools,
426
+ // The agent directories to install into, as a pre-answer for CI/scripts exactly like `--solo`
427
+ // above (E11). Until this flag existed, `ideTargets` was reachable only programmatically: a
428
+ // non-interactive run took `ask`'s default and had NO way to choose. That was survivable while
429
+ // the default was one directory; it stopped being survivable when the default became two,
430
+ // because a scripted setup would write a second full copy of the skills with no way to say no.
431
+ // `o['ide-targets']`, not `o.ideTargets`: VALUE_FLAGS strips the leading `--` and nothing else,
432
+ // so a hyphenated flag keeps its hyphen as the key (`--retro-ship` is read the same way below).
433
+ ideTargets: o['ide-targets'] === undefined ? undefined : String(o['ide-targets']).split(',').map((t) => t.trim()).filter(Boolean),
262
434
  });
263
435
  break;
264
436
  case 'check':
265
- await reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
437
+ 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 });
266
438
  break;
267
439
  case 'update':
268
- await reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
440
+ result = await commands.reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
269
441
  break;
270
442
  case 'doctor':
271
- await runDoctor(o.dir, { json: o.json });
443
+ result = await commands.runDoctor(o.dir, { json: o.json });
272
444
  break;
273
445
  case 'migrate':
274
- await runMigrate(o.dir, { apply: o.apply, json: o.json });
275
- break;
276
- // Harness-invoked, not typed by a human: a tool-call payload arrives on stdin and the exit code
277
- // is the verdict (0 allow, 2 deny). See cli/hook.mjs for the contract.
278
- case 'hook': {
279
- const [, action] = o._;
280
- if (action !== 'ledger-guard') {
281
- log(c.red(`unknown hook: ${action ?? '(none)'} (ledger-guard)`));
282
- process.exitCode = 1;
283
- break;
284
- }
285
- runLedgerGuardHook({ paths: o.path ? [o.path] : [] });
446
+ result = await commands.runMigrate(o.dir, { apply: o.apply, json: o.json });
286
447
  break;
287
- }
288
448
  case 'report':
289
- await runReport(o.dir, { message: o.message });
449
+ result = await commands.runReport(o.dir, { message: o.message });
290
450
  break;
291
451
  case 'usage':
292
- runUsage(o.dir, {
452
+ result = commands.runUsage(o.dir, {
293
453
  out: o.out, since: o.since, until: o.until, all: o.all, member: o.member,
294
454
  format: o.format, repos: o.repos, json: o.json, today,
295
455
  });
296
456
  break;
297
457
  case 'sync-status': {
298
458
  const [, epic] = o._;
299
- if (epic && !isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
300
- await syncStatuses(o.dir, { epic, dryRun: o.dryRun });
459
+ if (epic && !commands.isValidEpicId(epic)) { refuse(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`); break; }
460
+ result = await commands.syncStatuses(o.dir, { epic, dryRun: o.dryRun });
461
+ break;
462
+ }
463
+ case 'epic': {
464
+ const [, action, slug, ...extra] = o._;
465
+ // A stray word is refused rather than dropped: `--inherits epic architecture` takes only `epic` as
466
+ // the value, and silently seeding without `architecture` would carry less than the author asked.
467
+ if (action === 'new' && extra.length) {
468
+ refuse(`unexpected argument(s): ${extra.join(' ')} — a list takes commas, e.g. --inherits epic,architecture`); break;
469
+ }
470
+ if (action !== 'new') {
471
+ 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
+ break;
473
+ }
474
+ 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
+ break;
476
+ }
477
+ case 'foundation': {
478
+ const [, action] = o._;
479
+ if (action === 'status') { result = await commands.runFoundationStatus(o.dir, { json: o.json }); break; }
480
+ if (action !== 'new') {
481
+ refuse(`unknown foundation action: ${action ?? '(none)'} (new, status)`, 'usage: yad foundation new [--json] | yad foundation status [--json]');
482
+ break;
483
+ }
484
+ result = await commands.runFoundationNew(o.dir, { today, json: o.json });
485
+ break;
486
+ }
487
+ case 'skill': {
488
+ const [, action, step, ...rest] = o._;
489
+ if (action === 'list' || action === undefined) { result = commands.runSkillList(o.dir, { json: o.json }); break; }
490
+ if (action === 'bind') { result = commands.runSkillBind(o.dir, { step, skills: rest }); break; }
491
+ if (action === 'unbind') { result = commands.runSkillUnbind(o.dir, { step }); break; }
492
+ refuse(`unknown skill action: ${action} (list, bind, unbind)`, 'usage: yad skill list [--json] | yad skill bind <step> <skill> [<skill> ...] | yad skill unbind <step>');
301
493
  break;
302
494
  }
303
495
  case 'next': {
304
496
  const [, epic] = o._;
305
497
  // `--check` with no step is a malformed guard call — fail loudly rather than silently print.
306
- if (o.check === true) { log(c.red('usage: yad next <epic> --check <step>')); process.exitCode = 1; break; }
307
- await runNext(o.dir, { epic, check: typeof o.check === 'string' ? o.check : undefined, all: o.all, json: o.json });
498
+ if (o.check === true) { refuse('usage: yad next <epic> --check <step>'); break; }
499
+ result = await commands.runNext(o.dir, { epic, check: typeof o.check === 'string' ? o.check : undefined, all: o.all, json: o.json });
308
500
  break;
309
501
  }
310
- case 'skip': {
502
+ case 'skip':
503
+ case 'unskip':
504
+ case 'defer':
505
+ case 'undefer': {
506
+ const [verb, epic, step] = o._;
507
+ if (!epic || !commands.isValidEpicId(epic)) { refuse(`invalid or missing epic id: ${epic ?? '(none)'} (expected EP-<slug>, [a-z0-9-] only)`); break; }
508
+ // A STORY id where the step goes names a whole Build lane (E39), with --repo naming the repo. No Shape
509
+ // step id ends in -S<n>, so the two never overlap. A lane is skipped, never deferred.
510
+ if (step && /-S\d+$/i.test(step)) {
511
+ if (verb === 'defer' || verb === 'undefer') {
512
+ refuse(`a Build lane is skipped whole, never deferred: yad skip ${epic} ${step} --repo <name> --reason "<why>"`); break;
513
+ }
514
+ if (o.debt) warn('--debt is not used on a Build lane: a lane is skipped whole, never owed back');
515
+ result = await commands.runLaneSkip(o.dir, { epic, story: step, repo: o.repo, reason: o.reason, undo: verb === 'unskip' || o.undo, today });
516
+ break;
517
+ }
518
+ if (o.repo) warn(`--repo is only for a Build lane (yad ${verb} ${epic} <story> --repo <name>) — ignored for a Shape step`);
519
+ // `unskip` / `undefer` are the verbs E36 / E37 named; `--undo` is the spelling that shipped first and stays.
520
+ const runVerb = verb === 'defer' || verb === 'undefer' ? commands.runDefer : commands.runSkip;
521
+ result = await runVerb(o.dir, { epic, step, reason: o.reason, debt: o.debt, undo: verb.startsWith('un') || o.undo, today });
522
+ break;
523
+ }
524
+ case 'dial': {
525
+ // One word names a Shape author step (project-wide). Three name a Build lane step: epic, story, step.
526
+ const args = o._.slice(1);
527
+ if (args.length === 1) {
528
+ result = await commands.runDial(o.dir, { step: args[0], to: o.to, json: o.json });
529
+ } else if (args.length === 3) {
530
+ if (!commands.isValidEpicId(args[0])) { refuse(`invalid epic id: ${args[0]} (expected EP-<slug>, [a-z0-9-] only)`); break; }
531
+ 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
+ } else {
533
+ refuse('usage: yad dial <step> [--to auto|human] | yad dial <epic> <story> --repo <name> <step> [--to auto|human]');
534
+ }
535
+ break;
536
+ }
537
+ case 'mode': {
538
+ if (o._.length > 2) { refuse(`unexpected argument(s): ${o._.slice(2).join(' ')}`); break; }
539
+ result = await commands.runMode(o.dir, { to: o._[1] ?? null, reason: o.reason, json: o.json, today });
540
+ break;
541
+ }
542
+ case 'kill':
543
+ case 'unkill': {
544
+ if (o._.length > 1) { refuse(`unexpected argument(s): ${o._.slice(1).join(' ')}`); break; }
545
+ result = await commands.runKill(o.dir, { on: o._[0] === 'kill', reason: o.reason, json: o.json, today });
546
+ break;
547
+ }
548
+ case 'unblock': {
311
549
  const [, epic, step] = o._;
312
- if (!epic || !isValidEpicId(epic)) { log(c.red(`invalid or missing epic id: ${epic ?? '(none)'} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
313
- await runSkip(o.dir, { epic, step, reason: o.reason, undo: o.undo, today });
550
+ if (!epic || !commands.isValidEpicId(epic)) { refuse(`invalid or missing epic id: ${epic ?? '(none)'} (expected EP-<slug>, [a-z0-9-] only)`); break; }
551
+ result = await commands.runUnblock(o.dir, { epic, step });
314
552
  break;
315
553
  }
316
554
  case 'gate': {
317
555
  const [, action, epic, artifact] = o._;
318
556
  // `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
319
- if (action === 'ci') { await gateCi(o.dir, { branch: o.branch, pr: o.pr, merged: o.merged, push: !o.noPush, today }); break; }
320
- if (!epic) { log(c.red('usage: yad gate <open|sync|comments|status|repair|review|walkthrough|trailer|ci> <epic> [artifact]')); process.exitCode = 1; break; }
557
+ 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; }
321
559
  // The epic id becomes a path segment under epics/ — reject anything but EP-<slug> outright.
322
- if (!isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
323
- // In bridge mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
560
+ if (!commands.isValidEpicId(epic)) { refuse(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`); break; }
561
+ // In verified mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
324
562
  // advisory (reads the platform, prints status, writes nothing). The artifact status flip is
325
- // CI's job at merge — never wired into the local gate. File-only mode keeps local writes.
326
- if (action === 'open') await gateOpen(o.dir, { epic, artifact });
327
- else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, number: o.pr, local: true });
328
- else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
329
- else if (action === 'status') await gateStatus(o.dir, { epic });
330
- else if (action === 'repair') await gateRepair(o.dir, { epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
331
- else if (action === 'review') await gateReview(o.dir, { epic, artifact });
332
- else if (action === 'walkthrough') await gateWalkthrough(o.dir, { epic, artifact });
333
- else if (action === 'trailer') await gateTrailer(o.dir, { epic, artifact, body: o.body || o.message, number: o.pr });
334
- else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|repair|review|walkthrough|trailer|ci)`)); process.exitCode = 1; }
563
+ // CI's job at merge — never wired into the local gate. Local mode keeps local writes.
564
+ if (action === 'open') result = await commands.gateOpen(o.dir, { epic, artifact, today });
565
+ else if (action === 'sync') result = await commands.gateSync(o.dir, { epic, artifact, today, number: o.pr, local: true });
566
+ else if (action === 'comments') result = await commands.gateComments(o.dir, { epic, artifact, today });
567
+ else if (action === 'status') result = await commands.gateStatus(o.dir, { epic });
568
+ else if (action === 'repair') result = await commands.gateRepair(o.dir, { epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun, today });
569
+ else if (action === 'review') result = await commands.gateReview(o.dir, { epic, artifact });
570
+ else if (action === 'walkthrough') result = await commands.gateWalkthrough(o.dir, { epic, artifact });
571
+ 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)`); }
335
573
  break;
336
574
  }
337
575
  case 'review': {
338
576
  const [, action] = o._;
339
- if (action === 'trailer') await reviewTrailer(o.dir, { repo: o.repo, pr: o.pr, body: o.body || o.message });
340
- else if (action === 'context' || action === 'chat' || action === 'cards') await reviewContext(o.dir, { repo: o.repo, pr: o.pr });
341
- else if (action === 'walkthrough') await reviewWalkthrough(o.dir, { repo: o.repo, pr: o.pr });
342
- else if (action === 'nudge') await reviewNudge(o.dir, { repo: o.repo, pr: o.pr });
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 });
343
581
  else if (action === 'reconcile') {
344
582
  // The epic becomes a path segment under epics/ — reject anything but EP-<slug> (no `../` escape).
345
- if (!o.epic || !isValidEpicId(o.epic)) { log(c.red(`invalid or missing --epic: ${o.epic ?? '(none)'} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
346
- await reviewReconcile(o.dir, { epic: o.epic, repo: o.repo, pr: o.pr });
583
+ 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 });
347
585
  }
348
- else { log(c.red('usage: yad review <trailer|context|walkthrough|nudge|reconcile> --repo <name> --pr <n> [--epic <id>] [--body <text>]')); process.exitCode = 1; }
586
+ else { refuse('usage: yad review <trailer|context|walkthrough|nudge|reconcile> --repo <name> --pr <n> [--epic <id>] [--body <text>]'); }
349
587
  break;
350
588
  }
351
589
  case 'commit':
352
- await runCommit(o.dir, { type: o.type, message: o.message, task: o.task, ai: o.ai, contractChange: o.contractChange, dryRun: o.dryRun, force: o.force });
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 });
353
591
  break;
354
592
  case 'open-pr':
355
- await 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 });
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 });
356
594
  break;
357
595
  case 'ship':
358
- await 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 });
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 });
359
597
  break;
360
598
  case 'checkpoint': {
361
599
  let retroShip;
362
600
  if (o['retro-ship']) {
363
601
  const [epic, story] = String(o['retro-ship']).split('/');
364
- if (!epic || !isValidEpicId(epic)) { log(c.red(`invalid --retro-ship: expected <epic>/<story> with epic EP-<slug> (got ${o['retro-ship']})`)); process.exitCode = 1; break; }
602
+ if (!epic || !commands.isValidEpicId(epic)) { refuse(`invalid --retro-ship: expected <epic>/<story> with epic EP-<slug> (got ${o['retro-ship']})`); break; }
365
603
  // The story id becomes a path element (stories/<story>.md) — pin it to the id shape (and to its
366
604
  // own epic) so a `..` or a slash can never traverse out of the epic's stories dir, and a typo'd
367
605
  // cross-epic id is caught here rather than failing obscurely later.
368
- if (!story || !/^EP-[a-z0-9-]+-S\d+$/.test(story) || !story.startsWith(`${epic}-S`)) { log(c.red(`invalid --retro-ship: expected <epic>/<story> with story <epic>-S<NN> (got ${o['retro-ship']})`)); process.exitCode = 1; break; }
606
+ if (!story || !/^EP-[a-z0-9-]+-S\d+$/.test(story) || !story.startsWith(`${epic}-S`)) { refuse(`invalid --retro-ship: expected <epic>/<story> with story <epic>-S<NN> (got ${o['retro-ship']})`); break; }
369
607
  retroShip = { epic, story, repo: o.repo, task: o.task, mergeCommit: o['merge-commit'], today };
370
608
  }
371
- await runCheckpoint(o.dir, { push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun, retroShip });
609
+ result = await commands.runCheckpoint(o.dir, { push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun, retroShip });
372
610
  break;
373
611
  }
374
612
  case 'tidy': {
375
613
  const [, action, epic] = o._;
376
- if (action !== 'up') { log(`usage: yad tidy up [<epic>] [--push] [--dry-run]`); process.exitCode = action ? 1 : 0; break; }
377
- await runTidy(o.dir, { epic: epic || o.epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
614
+ if (action !== 'up') {
615
+ // A bare `yad tidy` is a request for the usage, not a mistake: exit 0.
616
+ if (action) refuse(`unknown tidy action: ${action} (up)`, 'usage: yad tidy up [<epic>] [--push] [--dry-run]');
617
+ else if (inJSON()) emitJSON({ usage: 'yad tidy up [<epic>] [--push] [--dry-run]' });
618
+ else log(`usage: yad tidy up [<epic>] [--push] [--dry-run]`);
619
+ break;
620
+ }
621
+ result = await commands.runTidy(o.dir, { epic: epic || o.epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
622
+ break;
623
+ }
624
+ case 'index':
625
+ result = await commands.runIndex(o.dir, { json: o.json });
626
+ break;
627
+ case 'history': {
628
+ const [, action, ...args] = o._;
629
+ // Every flag that is not history's own is refused, not ignored: an ignored `--since` would read as
630
+ // a filter that was applied. Read from the RAW arguments, as typed — `parseArgs` turns a flag it
631
+ // does not know (`--limit`, a typo) into a plain word, which `search` would take as text, and it
632
+ // stores `--preview` under another name. A word that starts with `--`, or a one-letter flag such
633
+ // as `-m`, is a flag; a value (`--type chore`) never starts with `-`, because `parseArgs` refuses it.
634
+ const HISTORY_OWN = new Set(commands.HISTORY_FLAGS);
635
+ const unknownFlags = [...new Set(process.argv.slice(2)
636
+ .filter((t) => /^--./.test(t) || /^-[A-Za-z]$/.test(t))
637
+ .map((t) => t.split('=')[0])
638
+ .filter((f) => !HISTORY_OWN.has(f)))];
639
+ result = await commands.runHistory(o.dir, {
640
+ action: action || 'list', args, json: !!o.json, unknownFlags,
641
+ type: o.type ?? null, theme: o.theme ?? null, thread: o.thread ?? null, open: !!o.open, done: !!o.done,
642
+ });
378
643
  break;
379
644
  }
380
645
  case 'repo': {
381
646
  const [, action, name] = o._;
382
- await runRepo(o.dir, { action: action || 'list', name, today, push: o.push, allowBranch: o.allowBranch });
647
+ result = await commands.runRepo(o.dir, { action: action || 'list', name, today, push: o.push, allowBranch: o.allowBranch });
383
648
  break;
384
649
  }
385
- case 'roster': {
386
- const [, action, ...rest] = o._;
387
- await runRoster(o.dir, { action: action || 'list', args: rest, name: o.name, email: o.email, roles: o.roles, today });
650
+ case 'risk-map': {
651
+ const [, action, name] = o._;
652
+ result = await commands.runRiskMap(o.dir, { action: action || 'check', name, json: o.json, dryRun: o.dryRun });
388
653
  break;
389
654
  }
655
+ case 'codeowners': {
656
+ const [, action, name] = o._;
657
+ // `--write` was dropped (E69); wherever it is typed, it is refused with the reason — never ignored.
658
+ result = await commands.runCodeowners(o.dir, { action: action || 'check', name, json: o.json, platform: o.platform, write: o._.some((a) => a === '--write' || a.startsWith('--write=')) });
659
+ break;
660
+ }
661
+ case 'roster':
662
+ // Removed in E62. Kept as a word for one major so a script or a habit gets told where it went
663
+ // instead of "unknown command".
664
+ refuse('yad roster was removed — yadflow keeps no list of people',
665
+ '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: '
667
+ + 'it decides nothing, but the first sync uses its name → login pairs to recognise those approvals.');
668
+ break;
390
669
  case 'docs': {
391
670
  const [, action] = o._;
392
- if (o.epic && !isValidEpicId(o.epic)) { log(c.red(`invalid epic id: ${o.epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
671
+ if (o.epic && !commands.isValidEpicId(o.epic)) { refuse(`invalid epic id: ${o.epic} (expected EP-<slug>, [a-z0-9-] only)`); break; }
393
672
  const sync = o.wire ? 'wire' : o.refresh ? 'refresh' : 'check';
394
- await runDocs(o.dir, { action: action || 'list', epic: o.epic, overview: o.overview, sync, today });
673
+ result = await commands.runDocs(o.dir, { action: action || 'list', epic: o.epic, overview: o.overview, sync, today });
395
674
  break;
396
675
  }
397
676
  case 'thread': {
398
677
  const [, epic] = o._;
399
- if (epic && !isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
400
- await runThread(o.dir, { epic, json: o.json });
678
+ if (epic && !commands.isValidEpicId(epic)) { refuse(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`); break; }
679
+ result = await commands.runThread(o.dir, { epic, json: o.json });
401
680
  break;
402
681
  }
403
682
  case 'reconcile': {
404
683
  const [, action] = o._;
405
684
  const thread = o.epic || o.thread || null;
406
- if (thread && !isValidEpicId(thread)) { log(c.red(`invalid epic id: ${thread} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
685
+ if (thread && !commands.isValidEpicId(thread)) { refuse(`invalid epic id: ${thread} (expected EP-<slug>, [a-z0-9-] only)`); break; }
407
686
  const act = action || (o.wire ? 'wire' : o.refresh ? 'refresh' : 'check');
408
687
  if (!['check', 'refresh', 'wire'].includes(act)) {
409
- log(c.red(`unknown reconcile action: ${act} (check|refresh|wire)`)); process.exitCode = 1; break;
688
+ refuse(`unknown reconcile action: ${act} (check|refresh|wire)`); break;
410
689
  }
411
- await runReconcile(o.dir, { action: act, thread });
690
+ result = await commands.runReconcile(o.dir, { action: act, thread });
412
691
  break;
413
692
  }
414
693
  default:
694
+ if (inJSON()) { refuse(`unknown command: ${cmd}`, '`yad --help` lists the commands'); break; }
415
695
  log(c.red(`unknown command: ${cmd}`));
416
- log(HELP);
696
+ log(helpText(commands.seedableProfiles()));
417
697
  process.exitCode = 1;
418
698
  }
699
+ return result;
419
700
  }
420
701
 
421
702
  main()
703
+ // A command that finished a --json run without answering is a yadflow bug. Stdout still gets one
704
+ // object saying so, never an empty stream a parser chokes on — and never a guessed `ok: true`.
705
+ .then((result) => {
706
+ if (!inJSON() || jsonEmitted()) return;
707
+ // A refusal said in prose (`fail` + its `hint`) becomes the JSON refusal.
708
+ // What the command DID rides along: a push that failed after the commit landed must not read as
709
+ // "nothing happened" (E1 review) — `ship` answers `committed: true` beside the refusal.
710
+ const said = process.exitCode ? jsonFailure() : null;
711
+ if (said) {
712
+ // Never a key the envelope or the refusal owns: a clash would throw inside the answer and lose the
713
+ // real refusal, and a `json` key is an option of `refuse`, not data.
714
+ const owned = new Set([...ENVELOPE_KEYS, 'ok', 'error', 'code', 'hint', 'warnings', 'json']);
715
+ const did = Object.fromEntries(Object.entries(isPlainObject(result) ? result : {}).filter(([k]) => !owned.has(k)));
716
+ return refuse(said.error, said.hint, { code: said.code, ...did });
717
+ }
718
+ // Otherwise the command's result object IS the answer — `ok` follows the exit code, as everywhere.
719
+ if (isPlainObject(result)) return emitJSON({ ...result, ok: !process.exitCode });
720
+ refuse(`yad ${runningCmd ?? ''} gave no JSON answer — this is a yadflow bug`.replace(' ', ' '), 'run it without --json to see what it printed, and report it with `yad report`');
721
+ })
422
722
  .catch(async (err) => {
723
+ // Under --json every failure is still one object on stdout (E1) — a refusal from the parser, a
724
+ // thrown YadError, a bug. The `code` is the `YAD-` code README "Troubleshooting" is keyed on.
725
+ // If the command had already answered, stdout holds its object, and the failure goes to stderr.
726
+ if (inJSON() && !jsonEmitted()) {
727
+ if (err?.parsedCmd !== undefined && runningCmd === null) beginJSON(commandName(err.parsedWords ?? []));
728
+ const hint = err?.hint || (/expects a value$/.test(String(err?.message)) ? '`yad --help` lists the flags of each command' : null);
729
+ refuse(String(err?.message || err), hint, { code: err?.code && /^YAD-/.test(err.code) ? err.code : null });
730
+ return;
731
+ }
423
732
  const code = err?.code && /^YAD-/.test(err.code) ? ` [${err.code}]` : '';
424
733
  log(c.red(`\nyad failed${code}: ${err?.message || err}`));
425
734
  if (err?.hint) log(c.yellow(` → ${err.hint}`));
@@ -429,11 +738,12 @@ main()
429
738
  // YAD_NO_REPORT. `report` failing on its own would land here, so never re-offer for it.
430
739
  // Require a TTY on BOTH ends: stdout for the message, stdin so the y/N prompt can be answered
431
740
  // (a TTY stdout with piped/closed stdin would otherwise hang on readline).
432
- const offerReport = process.stdin.isTTY && process.stdout.isTTY && !process.env.SDLC_NONINTERACTIVE
741
+ const offerReport = !inJSON() && process.stdin.isTTY && process.stdout.isTTY && !process.env.SDLC_NONINTERACTIVE
433
742
  && !process.env.YAD_NO_REPORT && process.argv[2] !== 'report';
434
743
  if (offerReport) {
435
744
  try {
436
745
  if (await askYesNo('\nReport this failure to the yadflow team?', false)) {
746
+ const { runReport } = await import('./commands.mjs');
437
747
  await runReport(process.cwd(), { error: err });
438
748
  }
439
749
  } catch { /* reporting is best-effort — never mask the original failure */ }
@@ -450,7 +760,10 @@ main()
450
760
  // block reason travels on — an update banner there would land in front of a model.
451
761
  // Resolved the way main() resolves it, NOT from argv[2]: that is the first raw argument, so
452
762
  // `yad --dir <path> hook ledger-guard` puts `--dir` there and the banner slips through.
453
- if (parseArgs(process.argv.slice(2))._[0] !== 'hook') await maybeNotifyUpdate();
763
+ if (parseArgs(process.argv.slice(2))._[0] !== 'hook') {
764
+ const { maybeNotifyUpdate } = await import('./commands.mjs');
765
+ await maybeNotifyUpdate();
766
+ }
454
767
  } catch { /* the notice is never worth failing or hanging a command over */ } finally {
455
768
  closePrompts();
456
769
  }