yadflow 3.19.0-next.2 → 4.0.0-next.3

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 (158) hide show
  1. package/CHANGELOG.md +371 -0
  2. package/README.md +160 -22
  3. package/bin/commands.mjs +46 -0
  4. package/bin/yad.mjs +506 -117
  5. package/cli/artifact-status.mjs +30 -11
  6. package/cli/capture.mjs +417 -0
  7. package/cli/checkpoint.mjs +47 -27
  8. package/cli/claims.mjs +165 -0
  9. package/cli/codeowners-command.mjs +170 -0
  10. package/cli/codeowners.mjs +397 -0
  11. package/cli/commit.mjs +13 -9
  12. package/cli/dial.mjs +183 -0
  13. package/cli/docs.mjs +88 -32
  14. package/cli/doctor.mjs +1682 -90
  15. package/cli/epic-state.mjs +3460 -211
  16. package/cli/epic.mjs +506 -0
  17. package/cli/errors.mjs +3 -0
  18. package/cli/fold.mjs +247 -0
  19. package/cli/gate-local.mjs +296 -0
  20. package/cli/gate.mjs +977 -183
  21. package/cli/history.mjs +556 -0
  22. package/cli/hook.mjs +264 -53
  23. package/cli/hubcommit.mjs +9 -19
  24. package/cli/index-command.mjs +87 -0
  25. package/cli/ledger.mjs +54 -4
  26. package/cli/lib.mjs +161 -9
  27. package/cli/manifest.mjs +376 -38
  28. package/cli/migrate.mjs +683 -52
  29. package/cli/mode.mjs +170 -0
  30. package/cli/next.mjs +365 -86
  31. package/cli/openpr.mjs +193 -34
  32. package/cli/owners.mjs +266 -0
  33. package/cli/people.mjs +657 -0
  34. package/cli/plan.mjs +466 -125
  35. package/cli/platform.mjs +109 -128
  36. package/cli/product-index.mjs +287 -0
  37. package/cli/protection.mjs +706 -0
  38. package/cli/reconcile.mjs +40 -12
  39. package/cli/repo-publish.mjs +24 -26
  40. package/cli/repo.mjs +23 -14
  41. package/cli/report.mjs +24 -14
  42. package/cli/review.mjs +19 -22
  43. package/cli/riskmap-command.mjs +289 -0
  44. package/cli/riskmap.mjs +373 -0
  45. package/cli/setup.mjs +127 -279
  46. package/cli/ship.mjs +6 -5
  47. package/cli/skill.mjs +180 -0
  48. package/cli/skip.mjs +211 -30
  49. package/cli/thread.mjs +41 -16
  50. package/cli/tidy.mjs +18 -18
  51. package/cli/update-commit.mjs +21 -21
  52. package/cli/usage.mjs +115 -109
  53. package/package.json +3 -3
  54. package/skills/sdlc/config.yaml +149 -70
  55. package/skills/sdlc/module-help.csv +24 -24
  56. package/skills/yad-analysis/SKILL.md +121 -59
  57. package/skills/yad-architecture/SKILL.md +30 -18
  58. package/skills/yad-architecture/references/contract-format.md +8 -5
  59. package/skills/yad-backfill/SKILL.md +10 -4
  60. package/skills/yad-backfill/references/backfill.md +8 -1
  61. package/skills/yad-backfill/templates/checks/backfill-check.sh +10 -2
  62. package/skills/yad-change/SKILL.md +119 -42
  63. package/skills/yad-change/references/triage.md +42 -27
  64. package/skills/yad-checks/SKILL.md +68 -35
  65. package/skills/yad-checks/references/check-gates.md +310 -85
  66. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  67. package/skills/yad-checks/templates/checks/contract-check.sh +130 -9
  68. package/skills/yad-checks/templates/checks/epic-open.sh +2 -2
  69. package/skills/yad-checks/templates/checks/ledger-guard.sh +55 -8
  70. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  71. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +1 -1
  72. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  73. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  74. package/skills/yad-checks/templates/github/yad-checks.yml +13 -2
  75. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  76. package/skills/yad-checks/templates/github/yad-update-guard.yml +7 -5
  77. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  78. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +10 -1
  79. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  80. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +3 -3
  81. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  82. package/skills/yad-checks/templates/hooks/ledger-guard.sh +37 -6
  83. package/skills/yad-checks/templates/hooks/yad-capture.sh +53 -0
  84. package/skills/yad-commit/SKILL.md +4 -4
  85. package/skills/yad-connect-design/SKILL.md +5 -5
  86. package/skills/yad-connect-design/references/design-context.md +1 -1
  87. package/skills/yad-connect-design/references/design-registry.md +2 -2
  88. package/skills/yad-connect-docs/SKILL.md +11 -11
  89. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  90. package/skills/yad-connect-learning/SKILL.md +5 -5
  91. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  92. package/skills/yad-connect-repos/SKILL.md +66 -45
  93. package/skills/yad-connect-repos/references/code-context.md +4 -4
  94. package/skills/yad-connect-repos/references/hub-config.md +47 -51
  95. package/skills/yad-connect-repos/references/repos-registry.md +8 -7
  96. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  97. package/skills/yad-connect-testing/SKILL.md +5 -5
  98. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  99. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  100. package/skills/yad-defects/SKILL.md +7 -7
  101. package/skills/yad-discovery/SKILL.md +131 -94
  102. package/skills/yad-discovery/references/discovery-schema.md +22 -6
  103. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  104. package/skills/yad-docs/SKILL.md +13 -8
  105. package/skills/yad-docs/references/data-mapping.md +9 -7
  106. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  107. package/skills/yad-docs-overview/SKILL.md +30 -15
  108. package/skills/yad-docs-overview/references/pipeline-model.md +33 -20
  109. package/skills/yad-docs-sync/SKILL.md +10 -5
  110. package/skills/yad-docs-sync/references/staleness.md +8 -7
  111. package/skills/yad-engineer-review/SKILL.md +81 -17
  112. package/skills/yad-engineer-review/references/ship-and-record.md +17 -8
  113. package/skills/yad-epic/SKILL.md +173 -92
  114. package/skills/yad-epic/references/state-schema.md +610 -94
  115. package/skills/yad-hub-bridge/SKILL.md +57 -38
  116. package/skills/yad-hub-bridge/references/bridge.md +94 -67
  117. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  118. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  119. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  120. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  121. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +21 -11
  122. package/skills/yad-implement/SKILL.md +27 -13
  123. package/skills/yad-implement/references/implement-conventions.md +2 -2
  124. package/skills/yad-learn/SKILL.md +9 -9
  125. package/skills/yad-learn/references/learning-state.md +2 -2
  126. package/skills/yad-open-pr/SKILL.md +61 -26
  127. package/skills/yad-pair-review/SKILL.md +7 -5
  128. package/skills/yad-pair-review/references/session-state.md +1 -1
  129. package/skills/yad-pr-template/SKILL.md +47 -26
  130. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  131. package/skills/yad-pr-template/templates/checks/pr-template.sh +34 -11
  132. package/skills/yad-pr-template/templates/checks/pr-title.sh +32 -9
  133. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  134. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  135. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  136. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +14 -13
  137. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +14 -12
  138. package/skills/yad-reconcile/SKILL.md +3 -3
  139. package/skills/yad-report/SKILL.md +5 -5
  140. package/skills/yad-review-companion/SKILL.md +6 -3
  141. package/skills/yad-review-gate/SKILL.md +203 -93
  142. package/skills/yad-review-gate/references/gating.md +232 -54
  143. package/skills/yad-run/SKILL.md +81 -51
  144. package/skills/yad-run/references/run-loop.md +61 -39
  145. package/skills/yad-ship/SKILL.md +15 -11
  146. package/skills/yad-spec/SKILL.md +21 -6
  147. package/skills/yad-spec/references/spec-handoff.md +17 -5
  148. package/skills/yad-status/SKILL.md +115 -50
  149. package/skills/yad-stories/SKILL.md +37 -21
  150. package/skills/yad-stories/references/story-schema.md +7 -6
  151. package/skills/yad-stub/SKILL.md +58 -47
  152. package/skills/yad-sync-repos/SKILL.md +2 -2
  153. package/skills/yad-test-cases/SKILL.md +32 -23
  154. package/skills/yad-test-cases/references/test-cases-schema.md +7 -4
  155. package/skills/yad-timeline/SKILL.md +8 -7
  156. package/skills/yad-ui/SKILL.md +41 -18
  157. package/cli/roster.mjs +0 -164
  158. 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
@@ -61,16 +42,11 @@ ${c.bold('Setup & maintenance')}
61
42
  yad hook ledger-guard ${c.dim('harness-invoked, not typed')} — refuse an agent's edit to the
62
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,16 +56,90 @@ ${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
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
@@ -101,6 +151,12 @@ ${c.bold('Review gate (Shape)')}
101
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
154
+ yad gate approve <epic> <artifact> --by <name> [--engagement verified|none]
155
+ No platform only: record an approval, bound to the artifact's
156
+ content (an edit revokes it). Reports the verdict; never advances
157
+ yad gate comment <epic> <artifact> --by <name> [--count <n>] [--new-round]
158
+ No platform only: record who commented, and how often, this round
159
+ yad gate advance <epic> <artifact> No platform only: pass the gate when its approvals hold
104
160
  yad gate repair <epic> [--push] Close an author step stranded behind a passed review gate
105
161
  (YAD-STATE-005); --push commits state.json to the default branch
106
162
  yad gate review <epic> [artifact] Print the grounding bundle for the review companion
@@ -110,18 +166,18 @@ ${c.bold('Review gate (Shape)')}
110
166
  yad gate trailer <epic> [artifact] --body <text> [--pr <n>]
111
167
  Upsert the companion's 60-sec briefing into the PR/MR description
112
168
  yad gate ci [--branch <head>] [--pr <n>] [--merged]
113
- CI entry (hub workflow): pre-merge is read-only (nothing pushed);
169
+ CI entry (Product workflow): pre-merge is read-only (nothing pushed);
114
170
  --merged advances the step + flips artifact status on the default branch
115
171
 
116
172
  ${c.bold('Build helpers')}
117
173
  yad commit --type <t> -m <subject> Commit by convention (trailers, atomic guard)
118
174
  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
175
+ hardcoded main; --base overrides) — stage-aware on the Product: a
120
176
  review/EP-* branch opens the Shape artifact-review PR
121
- (delegates to gate open), any other hub branch uses the
177
+ (delegates to gate open), any other Product branch uses the
122
178
  code-task template
123
179
  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 Build hub state
180
+ yad checkpoint [--push] Commit the machine-written Build state on the Product
125
181
  (trust-log/build-log/build-state) — plus any story
126
182
  status: flip (→ in-build/shipped) backed by a build-log
127
183
  ship — as one audit-trail chore(hub) commit; default
@@ -131,10 +187,37 @@ ${c.bold('Build helpers')}
131
187
  (merged before ledger tracking), then carry its status: shipped
132
188
  flip in the same commit (--merge-commit <sha>, --task <t> opt.);
133
189
  one repo per run — re-run per --repo for a multi-repo story
190
+ yad capture [--no-push] Snapshot every changed Shape artifact onto your private
191
+ yad/wip/<you>/<epic> branches — never your checkout; pushes them
192
+ (a harness hook runs it after each agent edit, with --hook)
193
+ yad claims [<epic>] [--no-fetch] Who else is editing which artifact: read from everyone's
194
+ capture branches (fetched first) — advice, not a lock; a claim
195
+ ends 4 hours after the last save, or once the file is merged
196
+ yad assign <epic> <step> [--to <name>] [--force]
197
+ Assign an authoring step to one person (you, or their git
198
+ name) — advice, not a lock: the capture hook warns anyone
199
+ else who edits its files; --force replaces another owner
200
+ yad unassign <epic> <step> [--force] Remove an assignment (someone else's needs --force)
201
+ yad owners [<epic>] List every step assignment, and whether it is live
202
+ yad fold <epic> <step> End an authoring step: commit ITS artifacts as one commit,
203
+ docs(<epic>): author <step> (signed if git signs) — the drafts
204
+ stay on yad/wip/…; with ledger: local the epic's ledger rides along
134
205
  yad tidy up [<epic>] [--push] Fold FINISHED Build shards (a shipped story's
135
206
  trust-log/build-log entries) back into the single folded
136
207
  ledger, as one chore(hub) commit — the manual "pack it up"
137
208
  for the shard files; a no-op when nothing is foldable
209
+ yad index [--json] Rebuild .sdlc/index.json — one summary line per work item,
210
+ derived from their own files; the default branch only, with no
211
+ override; on a verified Product CI writes it.
212
+ --json prints it, built live, and writes nothing
213
+ yad history [list] [--type t] [--theme x] [--thread EP-…] [--open|--done] [--json]
214
+ Every work item, open and shape-done (Build is not counted),
215
+ newest first — built live from their own files; writes nothing
216
+ yad history show <id> [--json] One work item: every step with its state and closing record,
217
+ and the approvals recorded for each review step
218
+ yad history search <text> [filters] [--json]
219
+ Match text in ids, titles, themes, types, repos, and in who
220
+ closed or merged a step, its PR and its commit
138
221
  yad review trailer --repo <r> --pr <n> --body <text> Post the companion's 60-sec briefing to a code PR/MR
139
222
  yad review context --repo <r> --pr <n> Print the grounding bundle for cards/chat
140
223
  yad review walkthrough --repo <r> --pr <n> Bundle + ordered risk-tagged stops for the
@@ -144,7 +227,17 @@ ${c.bold('Build helpers')}
144
227
  yad repo list Show connected repos (fresh / stale)
145
228
  yad repo refresh [name] [--push] Re-pack a stale repo (a human decision). --push commits the
146
229
  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)
230
+ audit commit and pushes it to the Product default branch (--allow-branch to override)
231
+ yad risk-map check [repo] [--json] Warn where a code repo's .sdlc/risk-map (a risk level per
232
+ directory, no names) has gone stale — advisory, never blocks
233
+ yad risk-map draft [repo] Add an 'unset' line for every directory the map does not cover
234
+ (--dry-run prints it);
235
+ never changes a line (the yad-connect-repos skill classifies them)
236
+ yad codeowners check [repo] [--json] [--platform github|gitlab]
237
+ Warn where a code repo's CODEOWNERS looks stale: a line that
238
+ matches no file, a file the platform never reads or will not load,
239
+ a line yad cannot read; plus a hint about @logins with no recent
240
+ commit — advisory, never blocks, never writes the file
148
241
 
149
242
  ${c.bold('Feature threads (post-lock change management)')}
150
243
  yad thread List every feature thread (genesis → changes → defects)
@@ -169,7 +262,7 @@ ${c.bold('Options')}
169
262
  --repo <name> open-pr: target a registered repo by name
170
263
  --base <branch> open-pr: override the PR/MR base — default is the repo's own default
171
264
  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
265
+ on the Product itself, else the platform, else origin/HEAD, else main); a
173
266
  non-default base loses the AI first pass (warns, never blocks)
174
267
  --epic <id> docs: target one epic's site (EP-<slug>)
175
268
  --overview docs: target the project SDLC-overview site
@@ -189,18 +282,21 @@ ${c.bold('Options')}
189
282
 
190
283
  ${c.bold('Environment')}
191
284
  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`;
285
+ YAD_NO_REPORT=1 Never offer to file a bug report after a failure
286
+ YAD_PLATFORM_LOGIN=0 Name a record's author by git user.name; never ask gh/glab who is logged in`;
193
287
 
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']);
288
+ 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']);
195
289
 
196
290
  function parseArgs(argv) {
197
291
  const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
292
+ try {
198
293
  for (let i = 0; i < argv.length; i++) {
199
294
  const a = argv[i];
200
295
  if (a === '--fix') o.fix = true;
201
296
  else if (a === '--force') o.force = true;
202
297
  else if (a === '--contract-change') o.contractChange = true;
203
298
  else if (a === '--no-push') o.noPush = true;
299
+ else if (a === '--no-fetch') o.noFetch = true;
204
300
  else if (a === '--push') o.push = true;
205
301
  else if (a === '--allow-branch') o.allowBranch = true;
206
302
  else if (a === '--overwrite-local') o.overwriteLocal = true;
@@ -212,7 +308,13 @@ function parseArgs(argv) {
212
308
  // positional. `o._[0]` is the command, already pushed by the time `--check` is seen in normal use.
213
309
  else if (a === '--check') { const v = argv[i + 1]; o.check = (o._[0] === 'next' && v !== undefined && !v.startsWith('-')) ? argv[++i] : true; }
214
310
  else if (a === '--all') o.all = true;
311
+ else if (a === '--open') o.open = true;
312
+ else if (a === '--done') o.done = true;
215
313
  else if (a === '--undo') o.undo = true;
314
+ else if (a === '--debt') o.debt = true;
315
+ else if (a === '--new-round') o.newRound = true;
316
+ else if (a === '--hook') o.hook = true;
317
+ else if (a === '--stub') o.stub = true;
216
318
  // setup profile flags (pre-answer the Step 0 interview, for CI/scripts)
217
319
  else if (a === '--solo') o.solo = true;
218
320
  else if (a === '--greenfield') o.greenfield = true;
@@ -234,11 +336,60 @@ function parseArgs(argv) {
234
336
  else if (a.startsWith('--scope=')) o.scope = a.slice('--scope='.length);
235
337
  else if (a === '-m' || a === '--message') o.message = takeValue(argv, ++i, a);
236
338
  else if (VALUE_FLAGS.has(a)) o[a.replace(/^--/, '')] = takeValue(argv, ++i, a);
237
- else o._.push(a);
339
+ // `--flag=value` for the same flags. Without it the token matched nothing and fell through to the
340
+ // positionals, where it was silently ignored — and for `yad hook ledger-guard --format=cursor`
341
+ // that is not a cosmetic miss: the format goes undefined, the exit protocol is selected, and under
342
+ // Cursor an empty stdout on an allow BLOCKS every file write. A flag spelling that silently
343
+ // inverts a guard is worth accepting rather than quietly dropping.
344
+ else if (a.startsWith('--') && a.includes('=') && VALUE_FLAGS.has(a.slice(0, a.indexOf('=')))) {
345
+ const eq = a.indexOf('=');
346
+ const value = a.slice(eq + 1);
347
+ if (!value) throw new Error(`${a.slice(0, eq)} expects a value`);
348
+ o[a.slice(2, eq)] = value;
349
+ } else o._.push(a);
350
+ }
351
+ } catch (e) {
352
+ // The command read so far, so the error handler knows WHICH command failed — the parse did not finish.
353
+ e.parsedCmd = o._[0] ?? null;
354
+ // The words read so far, so a refusal names `history show`, not the default action (E1 review).
355
+ e.parsedWords = o._.slice(0, 2);
356
+ throw e;
238
357
  }
239
358
  return o;
240
359
  }
241
360
 
361
+ // The command `main` is running, for the error handler (set once the arguments are read).
362
+ let runningCmd = null;
363
+
364
+ // The `command` a --json answer names: the command word, plus its action where it has one, as the
365
+ // user would type it (`gate status`, `history show`). An action left out is the one that ran
366
+ // (`yad skill` is `skill list`); a word that is not one of the command's actions is not added, so a
367
+ // refusal of `yad gate frobnicate` names `gate`, not a command that does not exist.
368
+ const ACTIONS = {
369
+ epic: { known: ['new'] },
370
+ foundation: { known: ['new', 'status'] },
371
+ skill: { known: ['list', 'bind', 'unbind'], default: 'list' },
372
+ gate: { known: ['open', 'sync', 'comments', 'status', 'repair', 'review', 'walkthrough', 'trailer', 'ci', 'approve', 'comment', 'advance'] },
373
+ review: { known: ['trailer', 'context', 'chat', 'cards', 'walkthrough', 'nudge', 'reconcile'] },
374
+ tidy: { known: ['up'] },
375
+ history: { known: ['list', 'show', 'search'], default: 'list' },
376
+ repo: { known: ['list', 'refresh', 'sync'], default: 'list' },
377
+ 'risk-map': { known: ['check', 'draft'], default: 'check' },
378
+ codeowners: { known: ['check'], default: 'check' },
379
+ docs: { known: ['list', 'build', 'deploy', 'sync'], default: 'list' },
380
+ reconcile: { known: ['check', 'refresh', 'wire'] },
381
+ hook: { known: ['ledger-guard'] },
382
+ };
383
+ const ALWAYS_JSON = new Set(['gate review', 'gate walkthrough', 'review context', 'review chat', 'review cards', 'review walkthrough']);
384
+ export function commandName(words) {
385
+ const [cmd, action] = words;
386
+ if (!cmd) return null;
387
+ const a = ACTIONS[cmd];
388
+ if (!a) return cmd;
389
+ if (action === undefined) return a.default ? `${cmd} ${a.default}` : cmd;
390
+ return a.known.includes(action) ? `${cmd} ${action}` : cmd;
391
+ }
392
+
242
393
  // A value flag must be followed by a token; erroring beats silently passing `undefined` downstream.
243
394
  function takeValue(argv, i, flag) {
244
395
  const v = argv[i];
@@ -247,179 +398,413 @@ function takeValue(argv, i, flag) {
247
398
  }
248
399
 
249
400
  async function main() {
401
+ // `--json` is read from the raw arguments too, so a refusal from the parser itself (a flag with no
402
+ // value) still answers as one object.
403
+ if (process.argv.slice(2).includes('--json')) beginJSON(null);
250
404
  const o = parseArgs(process.argv.slice(2));
251
405
  const cmd = o._[0];
252
- if (o.version) return log(VERSION);
253
- if (o.help || !cmd) return log(HELP);
406
+ runningCmd = cmd ?? null;
407
+ // The grounding bundles a skill parses are JSON with or without the flag, so they are always a
408
+ // --json run: one object on stdout, a refusal included, and every other line on stderr.
409
+ if (o.json || ALWAYS_JSON.has(commandName(o._))) beginJSON(commandName(o._));
410
+ if (o.version) return inJSON() ? emitJSON({}) : log(VERSION);
411
+
412
+ // THE HOT PATH, handled before anything heavy is loaded. `yad hook ledger-guard` runs inside the
413
+ // agent's tool loop on every file-editing call, and it needs `cli/hook.mjs` alone. Everything else
414
+ // lives behind the single dynamic import below, so the hook no longer pays for 29 modules it does
415
+ // not use — measured at ~30ms of module loading against ~14ms for what it actually needs.
416
+ //
417
+ // It sits above the help and the newer-shape warning deliberately: `--help` is not a hook call, and
418
+ // the warning is already excluded for `hook` below because its stderr is the channel a block reason
419
+ // reaches the model on.
420
+ if (cmd === 'hook') {
421
+ const [, action] = o._;
422
+ // Its stdout is already a protocol (Claude Code reads the exit code, Cursor a permission object),
423
+ // so it takes no --json (E1).
424
+ if (inJSON()) return refuse('yad hook takes no --json — its output is the protocol the agent reads', 'run it without --json');
425
+ if (action !== 'ledger-guard') return refuse(`unknown hook: ${action ?? '(none)'} (ledger-guard)`);
426
+ runLedgerGuardHook({ paths: o.path ? [o.path] : [], format: o.format });
427
+ return;
428
+ }
429
+
430
+ // The same hot path for the post-edit capture hook (E43): it runs after every agent edit, never fails,
431
+ // and needs `cli/capture.mjs` alone.
432
+ if (cmd === 'capture' && o.hook) {
433
+ const { runCapture } = await import('../cli/capture.mjs');
434
+ try { await runCapture(o.dir, { hook: true, noPush: !!o.noPush, format: o.format || null }); } catch (e) { process.stderr.write(` • yad capture: ${e.message}\n`); }
435
+ process.exitCode = 0;
436
+ return;
437
+ }
438
+
439
+ // Every other command. One await, once, for a process that is about to do real work anyway.
440
+ const commands = await import('./commands.mjs');
441
+ if (o.help || !cmd) {
442
+ const text = helpText(commands.seedableProfiles());
443
+ return inJSON() ? emitJSON({ help: stripAnsi(text) }) : log(text);
444
+ }
445
+ // A project written by a newer yadflow is warned about before any command reads it (docs/migrations/
446
+ // shape-8.md). Not on `hook` — its stderr is the channel a block reason reaches a model on — and not
447
+ // where the command reports the same thing itself (doctor, migrate) or runs before a project exists.
448
+ if (!['hook', 'doctor', 'migrate', 'setup', 'report'].includes(cmd)) commands.warnIfProjectAhead(o.dir || process.cwd());
254
449
 
255
450
  const today = new Date().toISOString().slice(0, 10);
451
+ // What the command returned: under --json, a command that did not answer itself answers with it.
452
+ let result;
256
453
  switch (cmd) {
257
454
  case 'setup':
258
- await runSetup(o.dir, {
455
+ result = await commands.runSetup(o.dir, {
259
456
  today, force: o.force,
260
457
  solo: o.solo, team: o.team, greenfield: o.greenfield, brownfield: o.brownfield,
261
458
  monorepo: o.monorepo, separate: o.separate, tools: o.tools,
459
+ // The agent directories to install into, as a pre-answer for CI/scripts exactly like `--solo`
460
+ // above (E11). Until this flag existed, `ideTargets` was reachable only programmatically: a
461
+ // non-interactive run took `ask`'s default and had NO way to choose. That was survivable while
462
+ // the default was one directory; it stopped being survivable when the default became two,
463
+ // because a scripted setup would write a second full copy of the skills with no way to say no.
464
+ // `o['ide-targets']`, not `o.ideTargets`: VALUE_FLAGS strips the leading `--` and nothing else,
465
+ // so a hyphenated flag keeps its hyphen as the key (`--retro-ship` is read the same way below).
466
+ ideTargets: o['ide-targets'] === undefined ? undefined : String(o['ide-targets']).split(',').map((t) => t.trim()).filter(Boolean),
262
467
  });
263
468
  break;
264
469
  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 });
470
+ 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
471
  break;
267
472
  case 'update':
268
- await reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
473
+ result = await commands.reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
269
474
  break;
270
475
  case 'doctor':
271
- await runDoctor(o.dir, { json: o.json });
476
+ result = await commands.runDoctor(o.dir, { json: o.json });
272
477
  break;
273
478
  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] : [] });
479
+ result = await commands.runMigrate(o.dir, { apply: o.apply, json: o.json });
286
480
  break;
287
- }
288
481
  case 'report':
289
- await runReport(o.dir, { message: o.message });
482
+ result = await commands.runReport(o.dir, { message: o.message });
290
483
  break;
291
484
  case 'usage':
292
- runUsage(o.dir, {
485
+ result = commands.runUsage(o.dir, {
293
486
  out: o.out, since: o.since, until: o.until, all: o.all, member: o.member,
294
487
  format: o.format, repos: o.repos, json: o.json, today,
295
488
  });
296
489
  break;
297
490
  case 'sync-status': {
298
491
  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 });
492
+ if (epic && !commands.isValidEpicId(epic)) { refuse(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`); break; }
493
+ result = await commands.syncStatuses(o.dir, { epic, dryRun: o.dryRun });
494
+ break;
495
+ }
496
+ case 'epic': {
497
+ const [, action, slug, ...extra] = o._;
498
+ // A stray word is refused rather than dropped: `--inherits epic architecture` takes only `epic` as
499
+ // the value, and silently seeding without `architecture` would carry less than the author asked.
500
+ if (action === 'new' && extra.length) {
501
+ refuse(`unexpected argument(s): ${extra.join(' ')} — a list takes commas, e.g. --inherits epic,architecture`); break;
502
+ }
503
+ if (action !== 'new') {
504
+ 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>]`);
505
+ break;
506
+ }
507
+ 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 });
508
+ break;
509
+ }
510
+ case 'foundation': {
511
+ const [, action] = o._;
512
+ if (action === 'status') { result = await commands.runFoundationStatus(o.dir, { json: o.json }); break; }
513
+ if (action !== 'new') {
514
+ refuse(`unknown foundation action: ${action ?? '(none)'} (new, status)`, 'usage: yad foundation new [--json] | yad foundation status [--json]');
515
+ break;
516
+ }
517
+ result = await commands.runFoundationNew(o.dir, { today, json: o.json });
518
+ break;
519
+ }
520
+ case 'skill': {
521
+ const [, action, step, ...rest] = o._;
522
+ if (action === 'list' || action === undefined) { result = commands.runSkillList(o.dir, { json: o.json }); break; }
523
+ if (action === 'bind') { result = commands.runSkillBind(o.dir, { step, skills: rest }); break; }
524
+ if (action === 'unbind') { result = commands.runSkillUnbind(o.dir, { step }); break; }
525
+ refuse(`unknown skill action: ${action} (list, bind, unbind)`, 'usage: yad skill list [--json] | yad skill bind <step> <skill> [<skill> ...] | yad skill unbind <step>');
301
526
  break;
302
527
  }
303
528
  case 'next': {
304
529
  const [, epic] = o._;
305
530
  // `--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 });
531
+ if (o.check === true) { refuse('usage: yad next <epic> --check <step>'); break; }
532
+ result = await commands.runNext(o.dir, { epic, check: typeof o.check === 'string' ? o.check : undefined, all: o.all, json: o.json });
533
+ break;
534
+ }
535
+ case 'skip':
536
+ case 'unskip':
537
+ case 'defer':
538
+ case 'undefer': {
539
+ const [verb, epic, step] = o._;
540
+ if (!epic || !commands.isValidEpicId(epic)) { refuse(`invalid or missing epic id: ${epic ?? '(none)'} (expected EP-<slug>, [a-z0-9-] only)`); break; }
541
+ // A STORY id where the step goes names a whole Build lane (E39), with --repo naming the repo. No Shape
542
+ // step id ends in -S<n>, so the two never overlap. A lane is skipped, never deferred.
543
+ if (step && /-S\d+$/i.test(step)) {
544
+ if (verb === 'defer' || verb === 'undefer') {
545
+ refuse(`a Build lane is skipped whole, never deferred: yad skip ${epic} ${step} --repo <name> --reason "<why>"`); break;
546
+ }
547
+ if (o.debt) warn('--debt is not used on a Build lane: a lane is skipped whole, never owed back');
548
+ result = await commands.runLaneSkip(o.dir, { epic, story: step, repo: o.repo, reason: o.reason, undo: verb === 'unskip' || o.undo, today });
549
+ break;
550
+ }
551
+ if (o.repo) warn(`--repo is only for a Build lane (yad ${verb} ${epic} <story> --repo <name>) — ignored for a Shape step`);
552
+ // `unskip` / `undefer` are the verbs E36 / E37 named; `--undo` is the spelling that shipped first and stays.
553
+ const runVerb = verb === 'defer' || verb === 'undefer' ? commands.runDefer : commands.runSkip;
554
+ result = await runVerb(o.dir, { epic, step, reason: o.reason, debt: o.debt, undo: verb.startsWith('un') || o.undo, today });
308
555
  break;
309
556
  }
310
- case 'skip': {
557
+ case 'dial': {
558
+ // One word names a Shape author step (project-wide). Three name a Build lane step: epic, story, step.
559
+ const args = o._.slice(1);
560
+ if (args.length === 1) {
561
+ result = await commands.runDial(o.dir, { step: args[0], to: o.to, json: o.json });
562
+ } else if (args.length === 3) {
563
+ if (!commands.isValidEpicId(args[0])) { refuse(`invalid epic id: ${args[0]} (expected EP-<slug>, [a-z0-9-] only)`); break; }
564
+ result = await commands.runDial(o.dir, { epic: args[0], story: args[1], step: args[2], repo: o.repo, to: o.to, json: o.json });
565
+ } else {
566
+ refuse('usage: yad dial <step> [--to auto|human] | yad dial <epic> <story> --repo <name> <step> [--to auto|human]');
567
+ }
568
+ break;
569
+ }
570
+ case 'mode': {
571
+ if (o._.length > 2) { refuse(`unexpected argument(s): ${o._.slice(2).join(' ')}`); break; }
572
+ result = await commands.runMode(o.dir, { to: o._[1] ?? null, reason: o.reason, json: o.json, today });
573
+ break;
574
+ }
575
+ case 'kill':
576
+ case 'unkill': {
577
+ if (o._.length > 1) { refuse(`unexpected argument(s): ${o._.slice(1).join(' ')}`); break; }
578
+ result = await commands.runKill(o.dir, { on: o._[0] === 'kill', reason: o.reason, json: o.json, today });
579
+ break;
580
+ }
581
+ case 'unblock': {
311
582
  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 });
583
+ if (!epic || !commands.isValidEpicId(epic)) { refuse(`invalid or missing epic id: ${epic ?? '(none)'} (expected EP-<slug>, [a-z0-9-] only)`); break; }
584
+ result = await commands.runUnblock(o.dir, { epic, step });
314
585
  break;
315
586
  }
316
587
  case 'gate': {
317
588
  const [, action, epic, artifact] = o._;
318
589
  // `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; }
590
+ if (action === 'ci') { result = await commands.gateCi(o.dir, { branch: o.branch, pr: o.pr, merged: o.merged, push: !o.noPush, today }); break; }
591
+ if (!epic) { refuse('usage: yad gate <open|sync|comments|status|repair|review|walkthrough|trailer|ci|approve|comment|advance> <epic> [artifact]'); break; }
321
592
  // 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; }
593
+ if (!commands.isValidEpicId(epic)) { refuse(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`); break; }
323
594
  // In verified mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
324
595
  // advisory (reads the platform, prints status, writes nothing). The artifact status flip is
325
596
  // CI's job at merge — never wired into the local gate. Local 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; }
597
+ if (action === 'open') result = await commands.gateOpen(o.dir, { epic, artifact, today });
598
+ else if (action === 'sync') result = await commands.gateSync(o.dir, { epic, artifact, today, number: o.pr, local: true });
599
+ else if (action === 'comments') result = await commands.gateComments(o.dir, { epic, artifact, today });
600
+ else if (action === 'status') result = await commands.gateStatus(o.dir, { epic });
601
+ else if (action === 'repair') result = await commands.gateRepair(o.dir, { epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun, today });
602
+ else if (action === 'review') result = await commands.gateReview(o.dir, { epic, artifact });
603
+ else if (action === 'walkthrough') result = await commands.gateWalkthrough(o.dir, { epic, artifact });
604
+ else if (action === 'trailer') result = await commands.gateTrailer(o.dir, { epic, artifact, body: o.body || o.message, number: o.pr });
605
+ // E112 — the gate on a Product with no platform: record an approval, record a comment round, advance.
606
+ else if (action === 'approve') result = await commands.gateApprove(o.dir, { epic, artifact, by: o.by, engagement: o.engagement ?? null, today });
607
+ else if (action === 'comment') result = await commands.gateComment(o.dir, { epic, artifact, by: o.by, count: o.count ?? null, newRound: !!o.newRound, today });
608
+ else if (action === 'advance') result = await commands.gateAdvance(o.dir, { epic, artifact, today });
609
+ else { refuse(`unknown gate action: ${action} (open|sync|comments|status|repair|review|walkthrough|trailer|ci|approve|comment|advance)`); }
335
610
  break;
336
611
  }
337
612
  case 'review': {
338
613
  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 });
614
+ if (action === 'trailer') result = await commands.reviewTrailer(o.dir, { repo: o.repo, pr: o.pr, body: o.body || o.message });
615
+ else if (action === 'context' || action === 'chat' || action === 'cards') result = await commands.reviewContext(o.dir, { repo: o.repo, pr: o.pr });
616
+ else if (action === 'walkthrough') result = await commands.reviewWalkthrough(o.dir, { repo: o.repo, pr: o.pr });
617
+ else if (action === 'nudge') result = await commands.reviewNudge(o.dir, { repo: o.repo, pr: o.pr });
343
618
  else if (action === 'reconcile') {
344
619
  // 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 });
620
+ if (!o.epic || !commands.isValidEpicId(o.epic)) { refuse(`invalid or missing --epic: ${o.epic ?? '(none)'} (expected EP-<slug>, [a-z0-9-] only)`); break; }
621
+ result = await commands.reviewReconcile(o.dir, { epic: o.epic, repo: o.repo, pr: o.pr });
347
622
  }
348
- else { log(c.red('usage: yad review <trailer|context|walkthrough|nudge|reconcile> --repo <name> --pr <n> [--epic <id>] [--body <text>]')); process.exitCode = 1; }
623
+ else { refuse('usage: yad review <trailer|context|walkthrough|nudge|reconcile> --repo <name> --pr <n> [--epic <id>] [--body <text>]'); }
349
624
  break;
350
625
  }
351
626
  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 });
627
+ 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
628
  break;
354
629
  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 });
630
+ 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
631
  break;
357
632
  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 });
633
+ 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
634
  break;
360
635
  case 'checkpoint': {
361
636
  let retroShip;
362
637
  if (o['retro-ship']) {
363
638
  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; }
639
+ if (!epic || !commands.isValidEpicId(epic)) { refuse(`invalid --retro-ship: expected <epic>/<story> with epic EP-<slug> (got ${o['retro-ship']})`); break; }
365
640
  // The story id becomes a path element (stories/<story>.md) — pin it to the id shape (and to its
366
641
  // own epic) so a `..` or a slash can never traverse out of the epic's stories dir, and a typo'd
367
642
  // 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; }
643
+ 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
644
  retroShip = { epic, story, repo: o.repo, task: o.task, mergeCommit: o['merge-commit'], today };
370
645
  }
371
- await runCheckpoint(o.dir, { push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun, retroShip });
646
+ result = await commands.runCheckpoint(o.dir, { push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun, retroShip });
647
+ break;
648
+ }
649
+ case 'capture': {
650
+ // E43. `--hook` is the harness's post-edit command: it never fails and never prints to stdout.
651
+ if (o._[1]) { refuse(`yad capture takes no word (got ${o._[1]})`, 'usage: yad capture [--no-push]'); break; }
652
+ result = await commands.runCapture(o.dir, { hook: !!o.hook, noPush: !!o.noPush });
653
+ if (o.hook) process.exitCode = 0;
654
+ break;
655
+ }
656
+ case 'claims': {
657
+ // E46. Read from the capture branches; nothing is recorded here.
658
+ const [, epic, extra] = o._;
659
+ if (extra) { refuse(`yad claims takes at most an epic (got an extra word: ${extra})`, 'usage: yad claims [<epic>] [--no-fetch]'); break; }
660
+ result = await commands.runClaims(o.dir, { epic, noFetch: !!o.noFetch });
661
+ break;
662
+ }
663
+ case 'assign':
664
+ case 'unassign': {
665
+ // E47. A file per step; nothing is committed and nothing is blocked.
666
+ const [verb, epic, step, extra] = o._;
667
+ const usage = verb === 'assign' ? 'usage: yad assign <epic> <step> [--to <git name>] [--force]' : 'usage: yad unassign <epic> <step> [--force]';
668
+ if (extra) { refuse(`yad ${verb} takes an epic and a step (got an extra word: ${extra})`, usage); break; }
669
+ if (verb === 'unassign' && o.to != null) { refuse('yad unassign takes no --to: it removes whoever is assigned', usage); break; }
670
+ result = verb === 'assign'
671
+ ? await commands.runAssign(o.dir, { epic, step, to: o.to ?? null, force: !!o.force, today })
672
+ : await commands.runUnassign(o.dir, { epic, step, force: !!o.force });
673
+ break;
674
+ }
675
+ case 'owners': {
676
+ const [, epic, extra] = o._;
677
+ if (extra) { refuse(`yad owners takes at most an epic (got an extra word: ${extra})`, 'usage: yad owners [<epic>]'); break; }
678
+ result = await commands.runOwners(o.dir, { epic });
679
+ break;
680
+ }
681
+ case 'fold': {
682
+ // E44. The step-boundary commit; the authoring skills run it where they used to say "commit".
683
+ const [, epic, step, extra] = o._;
684
+ if (extra) { refuse(`yad fold takes an epic and a step (got an extra word: ${extra})`, 'usage: yad fold <epic> <step>'); break; }
685
+ result = await commands.runFold(o.dir, { epic, step });
372
686
  break;
373
687
  }
374
688
  case 'tidy': {
375
689
  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 });
690
+ if (action !== 'up') {
691
+ // A bare `yad tidy` is a request for the usage, not a mistake: exit 0.
692
+ if (action) refuse(`unknown tidy action: ${action} (up)`, 'usage: yad tidy up [<epic>] [--push] [--dry-run]');
693
+ else if (inJSON()) emitJSON({ usage: 'yad tidy up [<epic>] [--push] [--dry-run]' });
694
+ else log(`usage: yad tidy up [<epic>] [--push] [--dry-run]`);
695
+ break;
696
+ }
697
+ result = await commands.runTidy(o.dir, { epic: epic || o.epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
698
+ break;
699
+ }
700
+ case 'index':
701
+ result = await commands.runIndex(o.dir, { json: o.json });
702
+ break;
703
+ case 'history': {
704
+ const [, action, ...args] = o._;
705
+ // Every flag that is not history's own is refused, not ignored: an ignored `--since` would read as
706
+ // a filter that was applied. Read from the RAW arguments, as typed — `parseArgs` turns a flag it
707
+ // does not know (`--limit`, a typo) into a plain word, which `search` would take as text, and it
708
+ // stores `--preview` under another name. A word that starts with `--`, or a one-letter flag such
709
+ // as `-m`, is a flag; a value (`--type chore`) never starts with `-`, because `parseArgs` refuses it.
710
+ const HISTORY_OWN = new Set(commands.HISTORY_FLAGS);
711
+ const unknownFlags = [...new Set(process.argv.slice(2)
712
+ .filter((t) => /^--./.test(t) || /^-[A-Za-z]$/.test(t))
713
+ .map((t) => t.split('=')[0])
714
+ .filter((f) => !HISTORY_OWN.has(f)))];
715
+ result = await commands.runHistory(o.dir, {
716
+ action: action || 'list', args, json: !!o.json, unknownFlags,
717
+ type: o.type ?? null, theme: o.theme ?? null, thread: o.thread ?? null, open: !!o.open, done: !!o.done,
718
+ });
378
719
  break;
379
720
  }
380
721
  case 'repo': {
381
722
  const [, action, name] = o._;
382
- await runRepo(o.dir, { action: action || 'list', name, today, push: o.push, allowBranch: o.allowBranch });
723
+ result = await commands.runRepo(o.dir, { action: action || 'list', name, today, push: o.push, allowBranch: o.allowBranch });
724
+ break;
725
+ }
726
+ case 'risk-map': {
727
+ const [, action, name] = o._;
728
+ result = await commands.runRiskMap(o.dir, { action: action || 'check', name, json: o.json, dryRun: o.dryRun });
383
729
  break;
384
730
  }
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 });
731
+ case 'codeowners': {
732
+ const [, action, name] = o._;
733
+ // `--write` was dropped (E69); wherever it is typed, it is refused with the reason — never ignored.
734
+ 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=')) });
388
735
  break;
389
736
  }
737
+ case 'roster':
738
+ // Removed in E62. Kept as a word for one major so a script or a habit gets told where it went
739
+ // instead of "unknown command".
740
+ refuse('yad roster was removed — yadflow keeps no list of people',
741
+ 'A gate needs one approval (not the author\'s own) from anyone with access to the repo; the platform records who approved. '
742
+ + 'Request reviewers on the PR itself. Keep an existing `roster` in hub.json until every review with older approvals is closed: '
743
+ + 'it decides nothing, but the first sync uses its name → login pairs to recognise those approvals.');
744
+ break;
390
745
  case 'docs': {
391
746
  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; }
747
+ if (o.epic && !commands.isValidEpicId(o.epic)) { refuse(`invalid epic id: ${o.epic} (expected EP-<slug>, [a-z0-9-] only)`); break; }
393
748
  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 });
749
+ result = await commands.runDocs(o.dir, { action: action || 'list', epic: o.epic, overview: o.overview, sync, today });
395
750
  break;
396
751
  }
397
752
  case 'thread': {
398
753
  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 });
754
+ if (epic && !commands.isValidEpicId(epic)) { refuse(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`); break; }
755
+ result = await commands.runThread(o.dir, { epic, json: o.json });
401
756
  break;
402
757
  }
403
758
  case 'reconcile': {
404
759
  const [, action] = o._;
405
760
  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; }
761
+ if (thread && !commands.isValidEpicId(thread)) { refuse(`invalid epic id: ${thread} (expected EP-<slug>, [a-z0-9-] only)`); break; }
407
762
  const act = action || (o.wire ? 'wire' : o.refresh ? 'refresh' : 'check');
408
763
  if (!['check', 'refresh', 'wire'].includes(act)) {
409
- log(c.red(`unknown reconcile action: ${act} (check|refresh|wire)`)); process.exitCode = 1; break;
764
+ refuse(`unknown reconcile action: ${act} (check|refresh|wire)`); break;
410
765
  }
411
- await runReconcile(o.dir, { action: act, thread });
766
+ result = await commands.runReconcile(o.dir, { action: act, thread });
412
767
  break;
413
768
  }
414
769
  default:
770
+ if (inJSON()) { refuse(`unknown command: ${cmd}`, '`yad --help` lists the commands'); break; }
415
771
  log(c.red(`unknown command: ${cmd}`));
416
- log(HELP);
772
+ log(helpText(commands.seedableProfiles()));
417
773
  process.exitCode = 1;
418
774
  }
775
+ return result;
419
776
  }
420
777
 
421
778
  main()
779
+ // A command that finished a --json run without answering is a yadflow bug. Stdout still gets one
780
+ // object saying so, never an empty stream a parser chokes on — and never a guessed `ok: true`.
781
+ .then((result) => {
782
+ if (!inJSON() || jsonEmitted()) return;
783
+ // A refusal said in prose (`fail` + its `hint`) becomes the JSON refusal.
784
+ // What the command DID rides along: a push that failed after the commit landed must not read as
785
+ // "nothing happened" (E1 review) — `ship` answers `committed: true` beside the refusal.
786
+ const said = process.exitCode ? jsonFailure() : null;
787
+ if (said) {
788
+ // Never a key the envelope or the refusal owns: a clash would throw inside the answer and lose the
789
+ // real refusal, and a `json` key is an option of `refuse`, not data.
790
+ const owned = new Set([...ENVELOPE_KEYS, 'ok', 'error', 'code', 'hint', 'warnings', 'json']);
791
+ const did = Object.fromEntries(Object.entries(isPlainObject(result) ? result : {}).filter(([k]) => !owned.has(k)));
792
+ return refuse(said.error, said.hint, { code: said.code, ...did });
793
+ }
794
+ // Otherwise the command's result object IS the answer — `ok` follows the exit code, as everywhere.
795
+ if (isPlainObject(result)) return emitJSON({ ...result, ok: !process.exitCode });
796
+ 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`');
797
+ })
422
798
  .catch(async (err) => {
799
+ // Under --json every failure is still one object on stdout (E1) — a refusal from the parser, a
800
+ // thrown YadError, a bug. The `code` is the `YAD-` code README "Troubleshooting" is keyed on.
801
+ // If the command had already answered, stdout holds its object, and the failure goes to stderr.
802
+ if (inJSON() && !jsonEmitted()) {
803
+ if (err?.parsedCmd !== undefined && runningCmd === null) beginJSON(commandName(err.parsedWords ?? []));
804
+ const hint = err?.hint || (/expects a value$/.test(String(err?.message)) ? '`yad --help` lists the flags of each command' : null);
805
+ refuse(String(err?.message || err), hint, { code: err?.code && /^YAD-/.test(err.code) ? err.code : null });
806
+ return;
807
+ }
423
808
  const code = err?.code && /^YAD-/.test(err.code) ? ` [${err.code}]` : '';
424
809
  log(c.red(`\nyad failed${code}: ${err?.message || err}`));
425
810
  if (err?.hint) log(c.yellow(` → ${err.hint}`));
@@ -429,11 +814,12 @@ main()
429
814
  // YAD_NO_REPORT. `report` failing on its own would land here, so never re-offer for it.
430
815
  // Require a TTY on BOTH ends: stdout for the message, stdin so the y/N prompt can be answered
431
816
  // (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
817
+ const offerReport = !inJSON() && process.stdin.isTTY && process.stdout.isTTY && !process.env.SDLC_NONINTERACTIVE
433
818
  && !process.env.YAD_NO_REPORT && process.argv[2] !== 'report';
434
819
  if (offerReport) {
435
820
  try {
436
821
  if (await askYesNo('\nReport this failure to the yadflow team?', false)) {
822
+ const { runReport } = await import('./commands.mjs');
437
823
  await runReport(process.cwd(), { error: err });
438
824
  }
439
825
  } catch { /* reporting is best-effort — never mask the original failure */ }
@@ -450,7 +836,10 @@ main()
450
836
  // block reason travels on — an update banner there would land in front of a model.
451
837
  // Resolved the way main() resolves it, NOT from argv[2]: that is the first raw argument, so
452
838
  // `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();
839
+ if (parseArgs(process.argv.slice(2))._[0] !== 'hook') {
840
+ const { maybeNotifyUpdate } = await import('./commands.mjs');
841
+ await maybeNotifyUpdate();
842
+ }
454
843
  } catch { /* the notice is never worth failing or hanging a command over */ } finally {
455
844
  closePrompts();
456
845
  }