yadflow 4.0.0-next.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +699 -0
- package/README.md +85 -22
- package/bin/commands.mjs +3 -1
- package/bin/yad.mjs +183 -29
- package/cli/capture.mjs +4 -2
- package/cli/checkpoint.mjs +10 -10
- package/cli/claims.mjs +4 -4
- package/cli/commit.mjs +48 -12
- package/cli/docs.mjs +15 -13
- package/cli/doctor.mjs +557 -141
- package/cli/epic-state.mjs +41 -18
- package/cli/errors.mjs +3 -2
- package/cli/find-product.mjs +197 -0
- package/cli/fold.mjs +4 -4
- package/cli/gate-local.mjs +13 -13
- package/cli/gate.mjs +111 -103
- package/cli/history.mjs +13 -13
- package/cli/hook.mjs +201 -30
- package/cli/index-command.mjs +6 -6
- package/cli/lib.mjs +91 -29
- package/cli/manifest.mjs +171 -104
- package/cli/migrate.mjs +208 -17
- package/cli/mode.mjs +22 -22
- package/cli/next.mjs +6 -6
- package/cli/openpr.mjs +32 -24
- package/cli/people.mjs +22 -10
- package/cli/plan.mjs +718 -45
- package/cli/platform.mjs +13 -7
- package/cli/product-index.mjs +4 -4
- package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
- package/cli/protection.mjs +19 -9
- package/cli/reconcile.mjs +98 -23
- package/cli/repo-publish.mjs +22 -16
- package/cli/repo.mjs +128 -33
- package/cli/report.mjs +4 -2
- package/cli/review.mjs +26 -19
- package/cli/setup.mjs +105 -36
- package/cli/ship.mjs +1 -1
- package/cli/skip.mjs +4 -4
- package/cli/tidy.mjs +7 -7
- package/cli/update-commit.mjs +22 -19
- package/cli/workspace.mjs +495 -0
- package/package.json +7 -4
- package/skills/sdlc/config.yaml +14 -13
- package/skills/sdlc/module-help.csv +8 -8
- package/skills/yad-analysis/SKILL.md +6 -4
- package/skills/yad-architecture/SKILL.md +4 -3
- package/skills/yad-backfill/SKILL.md +11 -5
- package/skills/yad-backfill/references/backfill.md +33 -3
- package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
- package/skills/yad-change/SKILL.md +1 -1
- package/skills/yad-checks/SKILL.md +48 -27
- package/skills/yad-checks/references/check-gates.md +213 -53
- package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
- package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
- package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
- package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
- package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
- package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
- package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
- package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
- package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
- package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
- package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
- package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
- package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
- package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
- package/skills/yad-commit/SKILL.md +18 -7
- package/skills/yad-connect-design/SKILL.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +1 -1
- package/skills/yad-connect-docs/SKILL.md +16 -11
- package/skills/yad-connect-docs/references/docs-registry.md +8 -8
- package/skills/yad-connect-learning/SKILL.md +1 -1
- package/skills/yad-connect-learning/references/learning-registry.md +1 -1
- package/skills/yad-connect-repos/SKILL.md +31 -8
- package/skills/yad-connect-repos/references/code-context.md +1 -1
- package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
- package/skills/yad-connect-repos/references/repos-registry.md +1 -1
- package/skills/yad-connect-testing/SKILL.md +1 -1
- package/skills/yad-connect-testing/references/testing-registry.md +1 -1
- package/skills/yad-discovery/SKILL.md +2 -2
- package/skills/yad-discovery/references/discovery-schema.md +1 -1
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-docs/references/data-mapping.md +1 -1
- package/skills/yad-docs/templates/app/package-lock.json +8 -8
- package/skills/yad-docs/templates/app/package.json +1 -1
- package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
- package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
- package/skills/yad-engineer-review/SKILL.md +2 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
- package/skills/yad-epic/SKILL.md +6 -4
- package/skills/yad-epic/references/state-schema.md +13 -12
- package/skills/yad-open-pr/SKILL.md +1 -1
- package/skills/yad-pair-review/SKILL.md +5 -5
- package/skills/yad-pair-review/references/session-state.md +2 -2
- package/skills/yad-pr-template/SKILL.md +14 -12
- package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
- package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
- package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
- package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
- package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
- package/skills/yad-report/SKILL.md +1 -1
- package/skills/yad-review-companion/SKILL.md +3 -3
- package/skills/yad-review-gate/SKILL.md +22 -23
- package/skills/yad-review-gate/references/gating.md +13 -13
- package/skills/yad-run/SKILL.md +2 -2
- package/skills/yad-run/references/run-loop.md +2 -2
- package/skills/yad-spec/SKILL.md +2 -1
- package/skills/yad-spec/references/spec-handoff.md +19 -10
- package/skills/yad-stories/SKILL.md +2 -2
- package/skills/yad-sync-repos/SKILL.md +19 -3
- package/skills/yad-test-cases/SKILL.md +2 -2
- package/skills/yad-ui/SKILL.md +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
- package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
package/bin/yad.mjs
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `yad` — setup/maintenance + the PR-driven review gate + build helpers for the SDLC module.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
3
5
|
import { VERSION } from '../cli/manifest.mjs';
|
|
4
6
|
import { c, log, warn, closePrompts, askYesNo, refuse, beginJSON, inJSON, emitJSON, jsonEmitted, jsonFailure, stripAnsi, isPlainObject, ENVELOPE_KEYS } from '../cli/lib.mjs';
|
|
5
|
-
import { runLedgerGuardHook } from '../cli/hook.mjs';
|
|
7
|
+
import { runLedgerGuardHook, runStagedLedgerGuard } from '../cli/hook.mjs';
|
|
6
8
|
|
|
7
9
|
const helpText = (profiles) => `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
|
|
8
10
|
|
|
9
11
|
${c.dim('Every command but `yad hook` takes --json: one JSON object on stdout, every other line on stderr')}
|
|
10
12
|
${c.dim('(docs/CLI.md, "--json on every command").')}
|
|
11
13
|
|
|
14
|
+
${c.bold('Start a workspace')} ${c.dim('(the folder holding product/ and the code repos side by side)')}
|
|
15
|
+
${c.dim('--dir: for init, the workspace; for new and join, the folder the workspace is made in')}
|
|
16
|
+
yad new <name> Greenfield: make <name>/product/, git init it, run setup inside it. Creates
|
|
17
|
+
no remote — prints the gh/glab line to run yourself. Takes the setup flags
|
|
18
|
+
yad init Brownfield, in the folder that already holds your repos: make product/ (or
|
|
19
|
+
use the one there; --path <folder> names it), run setup, offer each repo found
|
|
20
|
+
yad join <url> [folder]
|
|
21
|
+
Clone the Product into [folder]/product/ (default: the URL's last name) and
|
|
22
|
+
every repo it registers; a failed repo clone is reported, never fatal. Then
|
|
23
|
+
only per-machine steps: git-ignored skill copies + the git pre-commit hook.
|
|
24
|
+
Never commits, pushes, or changes a shared file
|
|
25
|
+
|
|
12
26
|
${c.bold('Setup & maintenance')}
|
|
13
27
|
yad setup Guided first-run setup (profile interview, install, connect & wire repos)
|
|
14
28
|
profile flags: --solo | --team <n>, --greenfield | --brownfield,
|
|
@@ -29,11 +43,13 @@ ${c.bold('Setup & maintenance')}
|
|
|
29
43
|
'yad check --fix --push'; --allow-branch permits a non-default branch
|
|
30
44
|
yad doctor [--json] Environment + state health: tools/auth, config files,
|
|
31
45
|
repo paths, epic ledgers (exit 1 on any failure)
|
|
32
|
-
yad migrate [--apply] [--json] Move this project's state files onto the shape
|
|
46
|
+
yad migrate [--apply] [--keep product|hub] [--json] Move this project's state files onto the shape
|
|
33
47
|
this yadflow expects. Prints what WOULD change and writes
|
|
34
48
|
nothing until --apply, which copies each file it rewrites to
|
|
35
49
|
<file>.yad-orig first. Safe to run twice — the second run
|
|
36
|
-
reports there is nothing to do
|
|
50
|
+
reports there is nothing to do. When .sdlc/product.json and
|
|
51
|
+
.sdlc/hub.json (or an epic's product-prs.json and hub-prs.json)
|
|
52
|
+
disagree, --apply asks which copy to keep; --keep answers it
|
|
37
53
|
yad sync-status [epic] Update artifact frontmatter status (draft/in-review/approved)
|
|
38
54
|
from .sdlc/state.json — all epics if omitted (--dry-run to preview)
|
|
39
55
|
yad report [-m <text>] File a bug in the yadflow repo with auto-scrubbed diagnostics
|
|
@@ -46,7 +62,10 @@ ${c.bold('Setup & maintenance')}
|
|
|
46
62
|
--format cursor the verdict is JSON on stdout instead, for a harness
|
|
47
63
|
whose pre-edit hook asks for permission rather than reading an exit
|
|
48
64
|
code. Wired into .claude/settings.json and .cursor/hooks.json by
|
|
49
|
-
setup / check --fix.
|
|
65
|
+
setup / check --fix. With --staged it judges the files a commit is
|
|
66
|
+
about to record instead — the git pre-commit hook check --fix
|
|
67
|
+
installs in this clone refuses a person's hand commit to the ledger.
|
|
68
|
+
YAD_HOOK_DISABLE=1 skips.
|
|
50
69
|
|
|
51
70
|
${c.bold('Team usage (EM adoption & behavior report)')}
|
|
52
71
|
yad usage Build a per-member report (HTML) from git + the SDLC ledgers
|
|
@@ -170,17 +189,23 @@ ${c.bold('Review gate (Shape)')}
|
|
|
170
189
|
--merged advances the step + flips artifact status on the default branch
|
|
171
190
|
|
|
172
191
|
${c.bold('Build helpers')}
|
|
173
|
-
yad commit --type <t> -m <subject>
|
|
174
|
-
|
|
192
|
+
yad commit --type <t> -m <subject> [--manual --reason <why>]
|
|
193
|
+
Commit by convention (trailers, atomic guard); --manual is
|
|
194
|
+
the door past the Product's ledger hook, reason recorded
|
|
195
|
+
yad open-pr [--repo <name>] [--title <text>]
|
|
196
|
+
Open a task PR/MR against the repo's DEFAULT branch (never a
|
|
175
197
|
hardcoded main; --base overrides) — stage-aware on the Product: a
|
|
176
198
|
review/EP-* branch opens the Shape artifact-review PR
|
|
177
199
|
(delegates to gate open), any other Product branch uses the
|
|
178
|
-
code-task template
|
|
179
|
-
|
|
200
|
+
code-task template. --title sets the PR/MR title (default: -m,
|
|
201
|
+
else the last commit's subject)
|
|
202
|
+
yad ship --type <t> -m <subject> [--title <text>]
|
|
203
|
+
Commit AND open the task PR/MR in one step (stage-aware).
|
|
204
|
+
--title sets the PR/MR title (default: the commit subject)
|
|
180
205
|
yad checkpoint [--push] Commit the machine-written Build state on the Product
|
|
181
206
|
(trust-log/build-log/build-state) — plus any story
|
|
182
207
|
status: flip (→ in-build/shipped) backed by a build-log
|
|
183
|
-
ship — as one audit-trail chore(
|
|
208
|
+
ship — as one audit-trail chore(product) commit; default
|
|
184
209
|
branch only (--allow-branch to override); no-op when clean
|
|
185
210
|
yad checkpoint --retro-ship <epic>/<story> --repo <r>
|
|
186
211
|
Record a retroactive build-log ship for a PRE-TRACKING story
|
|
@@ -204,7 +229,7 @@ ${c.bold('Build helpers')}
|
|
|
204
229
|
stay on yad/wip/…; with ledger: local the epic's ledger rides along
|
|
205
230
|
yad tidy up [<epic>] [--push] Fold FINISHED Build shards (a shipped story's
|
|
206
231
|
trust-log/build-log entries) back into the single folded
|
|
207
|
-
ledger, as one chore(
|
|
232
|
+
ledger, as one chore(product) commit — the manual "pack it up"
|
|
208
233
|
for the shard files; a no-op when nothing is foldable
|
|
209
234
|
yad index [--json] Rebuild .sdlc/index.json — one summary line per work item,
|
|
210
235
|
derived from their own files; the default branch only, with no
|
|
@@ -224,9 +249,14 @@ ${c.bold('Build helpers')}
|
|
|
224
249
|
pair-review walkthrough (yad-pair-review)
|
|
225
250
|
yad review nudge --repo <r> --pr <n> Friendly @-mention on a bare code-PR approve
|
|
226
251
|
yad review reconcile --epic <id> --repo <r> --pr <n> Bridge: stamp engagement onto the build-log ship
|
|
227
|
-
yad repo list Show connected repos (fresh / stale)
|
|
252
|
+
yad repo list Show connected repos (fresh / stale / not cloned here)
|
|
253
|
+
yad repo clone [name] Clone every registered repo missing on this machine, at its
|
|
254
|
+
recorded path (the clone step of \`yad join\`). Never touches a
|
|
255
|
+
repo already there; exits 1 when one could not be cloned
|
|
256
|
+
yad repo sync [name] Switch each connected repo to its default branch and
|
|
257
|
+
fast-forward it from origin; dirty repos are skipped
|
|
228
258
|
yad repo refresh [name] [--push] Re-pack a stale repo (a human decision). --push commits the
|
|
229
|
-
refreshed code-maps + registry as a chore(
|
|
259
|
+
refreshed code-maps + registry as a chore(product): sync code-context … [skip ci]
|
|
230
260
|
audit commit and pushes it to the Product default branch (--allow-branch to override)
|
|
231
261
|
yad risk-map check [repo] [--json] Warn where a code repo's .sdlc/risk-map (a risk level per
|
|
232
262
|
directory, no names) has gone stale — advisory, never blocks
|
|
@@ -252,16 +282,23 @@ ${c.bold('Interactive docs (generated sites)')}
|
|
|
252
282
|
yad docs sync [--check|--refresh|--wire] Staleness sweep; --wire installs the Pages CI
|
|
253
283
|
|
|
254
284
|
${c.bold('Options')}
|
|
255
|
-
--dir <path> Target project root
|
|
285
|
+
--dir <path> Target project root. Without it, a Product command finds the Product from
|
|
286
|
+
where you are — any folder inside it, or inside a repo it registers
|
|
287
|
+
(via .yad-workspace.json) — and says so on stderr; a code-repo command
|
|
288
|
+
(commit, open-pr, ship, review) works on the repo you are in
|
|
256
289
|
--type <t> commit: feat|fix|docs|refactor|test|perf|build|ci|chore|revert
|
|
257
290
|
-m, --message <s> commit: subject / PR title
|
|
258
|
-
--task <id> commit: Task trailer,
|
|
291
|
+
--task <id> commit: Task trailer, EP-<slug>-S<n>-T<NN> (else derived from the branch)
|
|
259
292
|
--ai <id> commit: co-author — claude|copilot|cursor|coderabbit|none (default none)
|
|
260
293
|
--contract-change commit/open-pr: mark the contract surface touched
|
|
294
|
+
--manual commit: on a verified Product, commit past the local ledger hook; needs
|
|
295
|
+
--reason, recorded as a Ledger-Override trailer. The ledger-guard check
|
|
296
|
+
on the pull request still judges the commit, and quotes the reason
|
|
261
297
|
--risk <level> open-pr: low|medium|high (default low)
|
|
262
298
|
--repo <name> open-pr: target a registered repo by name
|
|
299
|
+
--title <text> open-pr/ship: the PR/MR title (ignored on the Product's review/EP-* branch)
|
|
263
300
|
--base <branch> open-pr: override the PR/MR base — default is the repo's own default
|
|
264
|
-
branch (repos.json default_branch, else
|
|
301
|
+
branch (repos.json default_branch, else product.json default_branch for a PR
|
|
265
302
|
on the Product itself, else the platform, else origin/HEAD, else main); a
|
|
266
303
|
non-default base loses the AI first pass (warns, never blocks)
|
|
267
304
|
--epic <id> docs: target one epic's site (EP-<slug>)
|
|
@@ -285,7 +322,7 @@ ${c.bold('Environment')}
|
|
|
285
322
|
YAD_NO_REPORT=1 Never offer to file a bug report after a failure
|
|
286
323
|
YAD_PLATFORM_LOGIN=0 Name a record's author by git user.name; never ask gh/glab who is logged in`;
|
|
287
324
|
|
|
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']);
|
|
325
|
+
const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--profile', '--parent', '--inherits', '--to', '--retro-ship', '--merge-commit', '--path', '--ide-targets', '--theme', '--thread', '--by', '--count', '--engagement', '--keep']);
|
|
289
326
|
|
|
290
327
|
function parseArgs(argv) {
|
|
291
328
|
const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
|
|
@@ -295,6 +332,7 @@ function parseArgs(argv) {
|
|
|
295
332
|
if (a === '--fix') o.fix = true;
|
|
296
333
|
else if (a === '--force') o.force = true;
|
|
297
334
|
else if (a === '--contract-change') o.contractChange = true;
|
|
335
|
+
else if (a === '--manual') o.manual = true;
|
|
298
336
|
else if (a === '--no-push') o.noPush = true;
|
|
299
337
|
else if (a === '--no-fetch') o.noFetch = true;
|
|
300
338
|
else if (a === '--push') o.push = true;
|
|
@@ -314,6 +352,7 @@ function parseArgs(argv) {
|
|
|
314
352
|
else if (a === '--debt') o.debt = true;
|
|
315
353
|
else if (a === '--new-round') o.newRound = true;
|
|
316
354
|
else if (a === '--hook') o.hook = true;
|
|
355
|
+
else if (a === '--staged') o.staged = true;
|
|
317
356
|
else if (a === '--stub') o.stub = true;
|
|
318
357
|
// setup profile flags (pre-answer the Step 0 interview, for CI/scripts)
|
|
319
358
|
else if (a === '--solo') o.solo = true;
|
|
@@ -373,13 +412,46 @@ const ACTIONS = {
|
|
|
373
412
|
review: { known: ['trailer', 'context', 'chat', 'cards', 'walkthrough', 'nudge', 'reconcile'] },
|
|
374
413
|
tidy: { known: ['up'] },
|
|
375
414
|
history: { known: ['list', 'show', 'search'], default: 'list' },
|
|
376
|
-
repo: { known: ['list', 'refresh', 'sync'], default: 'list' },
|
|
415
|
+
repo: { known: ['list', 'refresh', 'sync', 'clone'], default: 'list' },
|
|
377
416
|
'risk-map': { known: ['check', 'draft'], default: 'check' },
|
|
378
417
|
codeowners: { known: ['check'], default: 'check' },
|
|
379
418
|
docs: { known: ['list', 'build', 'deploy', 'sync'], default: 'list' },
|
|
380
419
|
reconcile: { known: ['check', 'refresh', 'wire'] },
|
|
381
420
|
hook: { known: ['ledger-guard'] },
|
|
382
421
|
};
|
|
422
|
+
// E80 — how each command finds its folder. A PRODUCT command works on the Product; a REPO command works
|
|
423
|
+
// on the code repo it runs in and reads the Product beside it. The rest take their folder as given:
|
|
424
|
+
// `setup` and `new`/`init`/`join` make one, `report` and `hook` read what is there.
|
|
425
|
+
const PRODUCT_CMDS = new Set([
|
|
426
|
+
'check', 'update', 'doctor', 'migrate', 'usage', 'sync-status', 'epic', 'foundation', 'skill', 'next',
|
|
427
|
+
'skip', 'unskip', 'defer', 'undefer', 'dial', 'mode', 'kill', 'unkill', 'unblock', 'gate', 'checkpoint',
|
|
428
|
+
'capture', 'claims', 'assign', 'unassign', 'owners', 'fold', 'tidy', 'index', 'history', 'repo',
|
|
429
|
+
'risk-map', 'codeowners', 'docs', 'thread', 'reconcile',
|
|
430
|
+
]);
|
|
431
|
+
const REPO_CMDS = new Set(['commit', 'open-pr', 'ship', 'review']);
|
|
432
|
+
// The Product commands that WRITE its files without first checking it is one. Run from a code repo they
|
|
433
|
+
// wrote an epic, a dial or the module into it; they refuse instead (E80). The rest either only read, or
|
|
434
|
+
// already refuse a folder that is not a Product.
|
|
435
|
+
function writesProduct(cmd, o) {
|
|
436
|
+
const action = o._[1];
|
|
437
|
+
if (cmd === 'update' || cmd === 'kill' || cmd === 'unkill') return true;
|
|
438
|
+
if (cmd === 'check') return !!o.fix;
|
|
439
|
+
if (cmd === 'epic' || cmd === 'foundation') return action === 'new';
|
|
440
|
+
if (cmd === 'skill') return action === 'bind' || action === 'unbind';
|
|
441
|
+
if (cmd === 'dial') return o.to !== undefined;
|
|
442
|
+
// `--wire` writes a CI workflow; `--refresh` only rebuilds sites the folder already holds.
|
|
443
|
+
if (cmd === 'docs') return action === 'sync' && !!o.wire;
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Where the person IS, as their shell says it: a repo reached through a link (`ws/backend -> /src/backend`)
|
|
448
|
+
// is inside the workspace by the path they typed, and not by the one the disk resolves to.
|
|
449
|
+
function shellCwd() {
|
|
450
|
+
const pwd = process.env.PWD;
|
|
451
|
+
try { if (pwd && path.isAbsolute(pwd) && fs.realpathSync(pwd) === fs.realpathSync(process.cwd())) return pwd; } catch { /* the disk's path, then */ }
|
|
452
|
+
return process.cwd();
|
|
453
|
+
}
|
|
454
|
+
|
|
383
455
|
const ALWAYS_JSON = new Set(['gate review', 'gate walkthrough', 'review context', 'review chat', 'review cards', 'review walkthrough']);
|
|
384
456
|
export function commandName(words) {
|
|
385
457
|
const [cmd, action] = words;
|
|
@@ -423,6 +495,8 @@ async function main() {
|
|
|
423
495
|
// so it takes no --json (E1).
|
|
424
496
|
if (inJSON()) return refuse('yad hook takes no --json — its output is the protocol the agent reads', 'run it without --json');
|
|
425
497
|
if (action !== 'ledger-guard') return refuse(`unknown hook: ${action ?? '(none)'} (ledger-guard)`);
|
|
498
|
+
// `--staged` is the git pre-commit half (E48): the files come from the index, never from stdin.
|
|
499
|
+
if (o.staged) { runStagedLedgerGuard(); return; }
|
|
426
500
|
runLedgerGuardHook({ paths: o.path ? [o.path] : [], format: o.format });
|
|
427
501
|
return;
|
|
428
502
|
}
|
|
@@ -442,16 +516,64 @@ async function main() {
|
|
|
442
516
|
const text = helpText(commands.seedableProfiles());
|
|
443
517
|
return inJSON() ? emitJSON({ help: stripAnsi(text) }) : log(text);
|
|
444
518
|
}
|
|
519
|
+
// E80: find the Product from wherever yad runs. `--dir` always wins. Otherwise a Product command runs
|
|
520
|
+
// against the Product found by walking up — from any subfolder of the Product, or from inside a repo it
|
|
521
|
+
// registers (through `.yad-workspace.json`) — and says where, on stderr. A code-repo command keeps acting
|
|
522
|
+
// on the repo it runs in, and is handed the Product to read (its registry: name, platform, branch).
|
|
523
|
+
const dirGiven = process.argv.slice(2).some((a) => a === '--dir' || a.startsWith('--dir='));
|
|
524
|
+
if (PRODUCT_CMDS.has(cmd) || REPO_CMDS.has(cmd)) {
|
|
525
|
+
const found = commands.findProduct(dirGiven ? o.dir : shellCwd());
|
|
526
|
+
const shown = (p) => path.relative(process.cwd(), p) || '.';
|
|
527
|
+
if (found?.problem) warn(`${found.problem} — using ${o.dir}`);
|
|
528
|
+
else if (found?.elsewhere) {
|
|
529
|
+
// The workspace folder itself, or a repo the Product does not register: no Product here, but the
|
|
530
|
+
// person is one `cd` away from it — say so, rather than let "run yad setup" make a second one.
|
|
531
|
+
if (PRODUCT_CMDS.has(cmd)) warn(`this folder is not in a repo the Product registers — the Product is ${shown(found.elsewhere)}: cd there, or pass --dir ${shown(found.elsewhere)}`);
|
|
532
|
+
} else if (found?.root && REPO_CMDS.has(cmd)) o.product = found.root;
|
|
533
|
+
else if (found?.root && !dirGiven && found.via !== 'here') {
|
|
534
|
+
o.dir = found.root;
|
|
535
|
+
process.stderr.write(c.dim(`Product: ${shown(found.root)} (${found.via === 'workspace' ? `from ${commands.WORKSPACE_FILE}` : 'above this folder'})\n`));
|
|
536
|
+
// Run from inside one code repo, the per-repo commands work on THAT repo, not on every one the
|
|
537
|
+
// Product registers (they take the repo's name as their second word).
|
|
538
|
+
// The CHECKOUT must be the registered repo (a worktree or nested repo inside it is another one).
|
|
539
|
+
if ((cmd === 'risk-map' || cmd === 'codeowners') && o._[2] === undefined) {
|
|
540
|
+
const hit = commands.registryEntryFor(found.root, dirGiven ? o.dir : shellCwd());
|
|
541
|
+
if (typeof hit?.meta?.name === 'string') o._[2] = hit.meta.name;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
// A command that WRITES the Product never writes into a folder that is not one (a code repo, say).
|
|
546
|
+
// Asked by each such command AFTER its own usage checks, so a mistyped command still hears what is
|
|
547
|
+
// wrong with it first.
|
|
548
|
+
const noProduct = () => {
|
|
549
|
+
if (!writesProduct(cmd, o) || commands.hasProduct(o.dir)) return false;
|
|
550
|
+
refuse(`no Product here (${o.dir}) — yad ${cmd} writes the Product's files`, 'run it from the Product, from inside a repo it registers, or pass --dir <the Product>; `yad new` / `yad init` start one');
|
|
551
|
+
return true;
|
|
552
|
+
};
|
|
445
553
|
// A project written by a newer yadflow is warned about before any command reads it (docs/migrations/
|
|
446
554
|
// shape-8.md). Not on `hook` — its stderr is the channel a block reason reaches a model on — and not
|
|
447
555
|
// 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());
|
|
556
|
+
if (!['hook', 'doctor', 'migrate', 'setup', 'report', 'new', 'init', 'join'].includes(cmd)) commands.warnIfProjectAhead(o.dir || process.cwd());
|
|
557
|
+
// E122: the Product's settings (or an epic's PR ledger) under two names that say different things.
|
|
558
|
+
// Every command refuses rather than pick one — whichever it picked, somebody's edit would be silently
|
|
559
|
+
// ignored. Not `doctor` (it reports the drift), `migrate` (it ends it) or `report` (it files a bug
|
|
560
|
+
// about a broken flow, and reads nothing it could get wrong). `hook` returned above: it guards.
|
|
561
|
+
if (!['doctor', 'migrate', 'report'].includes(cmd)) {
|
|
562
|
+
// Both the folder the command runs on and, for a code-repo command, the Product it reads.
|
|
563
|
+
for (const root of new Set([o.dir || process.cwd(), o.product].filter(Boolean))) {
|
|
564
|
+
const drift = commands.productDriftError(root);
|
|
565
|
+
if (drift) throw drift;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
449
568
|
|
|
450
569
|
const today = new Date().toISOString().slice(0, 10);
|
|
451
570
|
// What the command returned: under --json, a command that did not answer itself answers with it.
|
|
452
571
|
let result;
|
|
453
572
|
switch (cmd) {
|
|
454
573
|
case 'setup':
|
|
574
|
+
// A workspace folder holds a Product; it never becomes a second one (E80 review 2 — following
|
|
575
|
+
// "run yad setup" from the workspace folder did exactly that, and hid the real one from its repos).
|
|
576
|
+
if (commands.workspaceProduct(o.dir)) { refuse(`this folder is a workspace — its Product is ${path.relative(process.cwd(), commands.workspaceProduct(o.dir)) || '.'}`, 'run `yad setup` there, or `yad next` from inside it'); break; }
|
|
455
577
|
result = await commands.runSetup(o.dir, {
|
|
456
578
|
today, force: o.force,
|
|
457
579
|
solo: o.solo, team: o.team, greenfield: o.greenfield, brownfield: o.brownfield,
|
|
@@ -466,17 +588,36 @@ async function main() {
|
|
|
466
588
|
ideTargets: o['ide-targets'] === undefined ? undefined : String(o['ide-targets']).split(',').map((t) => t.trim()).filter(Boolean),
|
|
467
589
|
});
|
|
468
590
|
break;
|
|
591
|
+
// E79: the workspace verbs. `--dir` is never the Product here: for `init` it is the WORKSPACE (the
|
|
592
|
+
// folder that holds product/ and the code repos); for `new` and `join` it is the folder the workspace
|
|
593
|
+
// is made IN. The setup flags pass through.
|
|
594
|
+
case 'new':
|
|
595
|
+
case 'init':
|
|
596
|
+
case 'join': {
|
|
597
|
+
const setupFlags = {
|
|
598
|
+
today, force: o.force,
|
|
599
|
+
solo: o.solo, team: o.team, greenfield: o.greenfield, brownfield: o.brownfield,
|
|
600
|
+
monorepo: o.monorepo, separate: o.separate, tools: o.tools,
|
|
601
|
+
ideTargets: o['ide-targets'] === undefined ? undefined : String(o['ide-targets']).split(',').map((t) => t.trim()).filter(Boolean),
|
|
602
|
+
};
|
|
603
|
+
if (cmd === 'new') result = await commands.runNew(o.dir, o._[1], setupFlags);
|
|
604
|
+
else if (cmd === 'init') result = await commands.runInit(o.dir, { ...setupFlags, path: o.path });
|
|
605
|
+
else result = await commands.runJoin(o.dir, o._[1], o._[2], { ideTargets: setupFlags.ideTargets });
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
469
608
|
case 'check':
|
|
609
|
+
if (noProduct()) break;
|
|
470
610
|
result = await commands.reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
|
|
471
611
|
break;
|
|
472
612
|
case 'update':
|
|
613
|
+
if (noProduct()) break;
|
|
473
614
|
result = await commands.reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
|
|
474
615
|
break;
|
|
475
616
|
case 'doctor':
|
|
476
617
|
result = await commands.runDoctor(o.dir, { json: o.json });
|
|
477
618
|
break;
|
|
478
619
|
case 'migrate':
|
|
479
|
-
result = await commands.runMigrate(o.dir, { apply: o.apply, json: o.json });
|
|
620
|
+
result = await commands.runMigrate(o.dir, { apply: o.apply, json: o.json, keep: o.keep ?? null });
|
|
480
621
|
break;
|
|
481
622
|
case 'report':
|
|
482
623
|
result = await commands.runReport(o.dir, { message: o.message });
|
|
@@ -504,6 +645,7 @@ async function main() {
|
|
|
504
645
|
refuse(`unknown epic action: ${action ?? '(none)'} (new)`, `usage: yad epic new <slug> [--type feature|chore|change|defect|hotfix] [--profile ${commands.seedableProfiles().join('|')}] [--parent EP-<slug> --inherits <bases>]`);
|
|
505
646
|
break;
|
|
506
647
|
}
|
|
648
|
+
if (noProduct()) break;
|
|
507
649
|
result = await commands.runEpicNew(o.dir, { slug, type: o.type, profile: o.profile, stub: o.stub, parent: o.parent, inherits: o.inherits, today, json: o.json });
|
|
508
650
|
break;
|
|
509
651
|
}
|
|
@@ -514,12 +656,14 @@ async function main() {
|
|
|
514
656
|
refuse(`unknown foundation action: ${action ?? '(none)'} (new, status)`, 'usage: yad foundation new [--json] | yad foundation status [--json]');
|
|
515
657
|
break;
|
|
516
658
|
}
|
|
659
|
+
if (noProduct()) break;
|
|
517
660
|
result = await commands.runFoundationNew(o.dir, { today, json: o.json });
|
|
518
661
|
break;
|
|
519
662
|
}
|
|
520
663
|
case 'skill': {
|
|
521
664
|
const [, action, step, ...rest] = o._;
|
|
522
665
|
if (action === 'list' || action === undefined) { result = commands.runSkillList(o.dir, { json: o.json }); break; }
|
|
666
|
+
if ((action === 'bind' || action === 'unbind') && noProduct()) break;
|
|
523
667
|
if (action === 'bind') { result = commands.runSkillBind(o.dir, { step, skills: rest }); break; }
|
|
524
668
|
if (action === 'unbind') { result = commands.runSkillUnbind(o.dir, { step }); break; }
|
|
525
669
|
refuse(`unknown skill action: ${action} (list, bind, unbind)`, 'usage: yad skill list [--json] | yad skill bind <step> <skill> [<skill> ...] | yad skill unbind <step>');
|
|
@@ -558,9 +702,11 @@ async function main() {
|
|
|
558
702
|
// One word names a Shape author step (project-wide). Three name a Build lane step: epic, story, step.
|
|
559
703
|
const args = o._.slice(1);
|
|
560
704
|
if (args.length === 1) {
|
|
705
|
+
if (noProduct()) break;
|
|
561
706
|
result = await commands.runDial(o.dir, { step: args[0], to: o.to, json: o.json });
|
|
562
707
|
} else if (args.length === 3) {
|
|
563
708
|
if (!commands.isValidEpicId(args[0])) { refuse(`invalid epic id: ${args[0]} (expected EP-<slug>, [a-z0-9-] only)`); break; }
|
|
709
|
+
if (noProduct()) break;
|
|
564
710
|
result = await commands.runDial(o.dir, { epic: args[0], story: args[1], step: args[2], repo: o.repo, to: o.to, json: o.json });
|
|
565
711
|
} else {
|
|
566
712
|
refuse('usage: yad dial <step> [--to auto|human] | yad dial <epic> <story> --repo <name> <step> [--to auto|human]');
|
|
@@ -575,6 +721,7 @@ async function main() {
|
|
|
575
721
|
case 'kill':
|
|
576
722
|
case 'unkill': {
|
|
577
723
|
if (o._.length > 1) { refuse(`unexpected argument(s): ${o._.slice(1).join(' ')}`); break; }
|
|
724
|
+
if (noProduct()) break;
|
|
578
725
|
result = await commands.runKill(o.dir, { on: o._[0] === 'kill', reason: o.reason, json: o.json, today });
|
|
579
726
|
break;
|
|
580
727
|
}
|
|
@@ -611,26 +758,29 @@ async function main() {
|
|
|
611
758
|
}
|
|
612
759
|
case 'review': {
|
|
613
760
|
const [, action] = o._;
|
|
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 });
|
|
761
|
+
if (action === 'trailer') result = await commands.reviewTrailer(o.dir, { repo: o.repo, product: o.product, pr: o.pr, body: o.body || o.message });
|
|
762
|
+
else if (action === 'context' || action === 'chat' || action === 'cards') result = await commands.reviewContext(o.dir, { repo: o.repo, product: o.product, pr: o.pr });
|
|
763
|
+
else if (action === 'walkthrough') result = await commands.reviewWalkthrough(o.dir, { repo: o.repo, product: o.product, pr: o.pr });
|
|
764
|
+
else if (action === 'nudge') result = await commands.reviewNudge(o.dir, { repo: o.repo, product: o.product, pr: o.pr });
|
|
618
765
|
else if (action === 'reconcile') {
|
|
619
766
|
// The epic becomes a path segment under epics/ — reject anything but EP-<slug> (no `../` escape).
|
|
620
767
|
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 });
|
|
768
|
+
result = await commands.reviewReconcile(o.dir, { epic: o.epic, repo: o.repo, product: o.product, pr: o.pr });
|
|
622
769
|
}
|
|
623
770
|
else { refuse('usage: yad review <trailer|context|walkthrough|nudge|reconcile> --repo <name> --pr <n> [--epic <id>] [--body <text>]'); }
|
|
624
771
|
break;
|
|
625
772
|
}
|
|
626
773
|
case 'commit':
|
|
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 });
|
|
774
|
+
result = await commands.runCommit(o.dir, { type: o.type, message: o.message, task: o.task, ai: o.ai, contractChange: o.contractChange, dryRun: o.dryRun, force: o.force, manual: o.manual, reason: o.reason });
|
|
628
775
|
break;
|
|
629
776
|
case 'open-pr':
|
|
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 });
|
|
777
|
+
result = await commands.runOpenPr(o.dir, { repo: o.repo, product: o.product, platform: o.platform, base: o.base, title: o.title || o.message, task: o.task, risk: o.risk, contractChange: o.contractChange });
|
|
631
778
|
break;
|
|
632
779
|
case 'ship':
|
|
633
|
-
|
|
780
|
+
// The ledger override is its own act (E49): recorded in its own commit, never folded into a
|
|
781
|
+
// commit-and-open-PR run. Refused, never ignored.
|
|
782
|
+
if (o.manual || o.reason !== undefined) { refuse('yad ship takes no --manual/--reason: commit with `yad commit --manual --reason "<why>" …`, then `yad open-pr`'); break; }
|
|
783
|
+
result = await commands.runShip(o.dir, { type: o.type, message: o.message, task: o.task, ai: o.ai, contractChange: o.contractChange, dryRun: o.dryRun, force: o.force, repo: o.repo, product: o.product, platform: o.platform, base: o.base, title: o.title, risk: o.risk });
|
|
634
784
|
break;
|
|
635
785
|
case 'checkpoint': {
|
|
636
786
|
let retroShip;
|
|
@@ -739,13 +889,14 @@ async function main() {
|
|
|
739
889
|
// instead of "unknown command".
|
|
740
890
|
refuse('yad roster was removed — yadflow keeps no list of people',
|
|
741
891
|
'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
|
|
892
|
+
+ 'Request reviewers on the PR itself. Keep an existing `roster` in the Product settings until every review with older approvals is closed: '
|
|
743
893
|
+ 'it decides nothing, but the first sync uses its name → login pairs to recognise those approvals.');
|
|
744
894
|
break;
|
|
745
895
|
case 'docs': {
|
|
746
896
|
const [, action] = o._;
|
|
747
897
|
if (o.epic && !commands.isValidEpicId(o.epic)) { refuse(`invalid epic id: ${o.epic} (expected EP-<slug>, [a-z0-9-] only)`); break; }
|
|
748
898
|
const sync = o.wire ? 'wire' : o.refresh ? 'refresh' : 'check';
|
|
899
|
+
if (noProduct()) break;
|
|
749
900
|
result = await commands.runDocs(o.dir, { action: action || 'list', epic: o.epic, overview: o.overview, sync, today });
|
|
750
901
|
break;
|
|
751
902
|
}
|
|
@@ -815,7 +966,10 @@ main()
|
|
|
815
966
|
// Require a TTY on BOTH ends: stdout for the message, stdin so the y/N prompt can be answered
|
|
816
967
|
// (a TTY stdout with piped/closed stdin would otherwise hang on readline).
|
|
817
968
|
const offerReport = !inJSON() && process.stdin.isTTY && process.stdout.isTTY && !process.env.SDLC_NONINTERACTIVE
|
|
818
|
-
&& !process.env.YAD_NO_REPORT && process.argv[2] !== 'report'
|
|
969
|
+
&& !process.env.YAD_NO_REPORT && process.argv[2] !== 'report'
|
|
970
|
+
// Two copies of the project's own settings that disagree (E122) are the project's state, not a
|
|
971
|
+
// yadflow bug: the refusal already names the one command that ends it.
|
|
972
|
+
&& err?.code !== 'YAD-STATE-008';
|
|
819
973
|
if (offerReport) {
|
|
820
974
|
try {
|
|
821
975
|
if (await askYesNo('\nReport this failure to the yadflow team?', false)) {
|
package/cli/capture.mjs
CHANGED
|
@@ -255,12 +255,14 @@ function push(root, git, name, { hook, now, spawner, env }) {
|
|
|
255
255
|
const last = Number(state.lastPushAt) || 0;
|
|
256
256
|
if (now - last < PUSH_EVERY_MS) return { pushed: 'later', why: `the last push started under ${PUSH_EVERY_MS / 60000} minutes ago` };
|
|
257
257
|
// Recorded BEFORE the push starts, so two hooks a moment apart cannot both start one.
|
|
258
|
+
// `windowsHide` below (E113): on Windows a detached console program gets a console window of its own,
|
|
259
|
+
// so without it every background push would flash one up in front of the person.
|
|
258
260
|
if (stateFile) writeJSON(stateFile, { ...state, lastPushAt: now });
|
|
259
261
|
try {
|
|
260
|
-
spawner('git', pushArgs(name), { cwd: root, env: { ...env, ...pushEnv(env) }, detached: true, stdio: 'ignore' }).unref();
|
|
262
|
+
spawner('git', pushArgs(name), { cwd: root, env: { ...env, ...pushEnv(env) }, detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
261
263
|
// E46: fetch everyone's capture branches in the same window, so the claim check at the next edit reads
|
|
262
264
|
// minutes-old claims, not hours-old ones. Detached too: an edit never waits on it, and a failure is silent.
|
|
263
|
-
try { spawner('git', fetchAllArgs(), { cwd: root, env: { ...env, ...pushEnv(env) }, detached: true, stdio: 'ignore' }).unref(); } catch { /* claims read what was last fetched */ }
|
|
265
|
+
try { spawner('git', fetchAllArgs(), { cwd: root, env: { ...env, ...pushEnv(env) }, detached: true, stdio: 'ignore', windowsHide: true }).unref(); } catch { /* claims read what was last fetched */ }
|
|
264
266
|
return { pushed: 'started' };
|
|
265
267
|
} catch (e) {
|
|
266
268
|
return { pushed: 'failed', why: e.message };
|
package/cli/checkpoint.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// build-state) as one audit-trail commit. This is the Build analogue of the Shape gate sync
|
|
3
3
|
// (cli/gate.mjs): the SDLC's Build part (yad-run, yad-engineer-review) WRITES these ledgers into the
|
|
4
4
|
// working tree but never commits them, so teammates/CI/`yad status` on other machines see stale trust
|
|
5
|
-
// evidence. checkpoint lands them with a `chore(
|
|
5
|
+
// evidence. checkpoint lands them with a `chore(product): ...` message.
|
|
6
6
|
//
|
|
7
7
|
// It also carries the Build story `status:` flip (approved → in-build/shipped) that
|
|
8
8
|
// yad-engineer-review writes into stories/<id>.md but no command committed — the #112 drift where
|
|
@@ -26,7 +26,7 @@ import { c, log, ok, info, fail, hand, exists, readJSON, readJSONStrict, pushWit
|
|
|
26
26
|
import { PROJECT_FILES , productConfigPath } from './manifest.mjs';
|
|
27
27
|
import { loadProduct } from './gate.mjs';
|
|
28
28
|
import { platformLogin } from './platform.mjs';
|
|
29
|
-
import { productGit, resolveDefaultBranch, guardDefaultBranch } from './
|
|
29
|
+
import { productGit, resolveDefaultBranch, guardDefaultBranch } from './productcommit.mjs';
|
|
30
30
|
import { readShips, writeRetroShip } from './ledger.mjs';
|
|
31
31
|
import { readFrontmatter, declaredRepos } from './epic-state.mjs';
|
|
32
32
|
|
|
@@ -68,7 +68,7 @@ const BACK_HALF_STATUSES = new Set(['in-build', 'shipped']);
|
|
|
68
68
|
// 2. its current frontmatter `status:` is a Build value (in-build | shipped).
|
|
69
69
|
// A candidate is only actually carried when its staged diff is the `status:` line ALONE — runCheckpoint
|
|
70
70
|
// drops any candidate whose working tree also changed prose/other frontmatter (stagedStoryIsStatusOnly),
|
|
71
|
-
// so an unrelated edit can never ride into a `chore(
|
|
71
|
+
// so an unrelated edit can never ride into a `chore(product) … [skip ci]` commit that bypasses review.
|
|
72
72
|
// The shared `git add`/`diff --cached` machinery then commits ONLY the ones that actually differ from
|
|
73
73
|
// HEAD (so a story already committed at shipped is a no-op).
|
|
74
74
|
//
|
|
@@ -139,11 +139,11 @@ export function checkpointAuthor(login, name) {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// PURE — the audit-trail commit message. The subject passes the Product commit-message gate (valid type
|
|
142
|
-
// `chore`, optional scope `
|
|
142
|
+
// `chore`, optional scope `product`, non-empty description, no trailing period). No Task trailer and no
|
|
143
143
|
// Co-Authored-By footer: this is human-owned machine state, not an authored code change. `label` and
|
|
144
144
|
// `author` are collapsed to one line so nothing can split the subject or forge a trailer.
|
|
145
145
|
export function buildCheckpointMessage({ label, author, basenames = [] }) {
|
|
146
|
-
const subject = `chore(
|
|
146
|
+
const subject = `chore(product): sync Build state — ${oneLine(label)} by ${oneLine(author)} [skip ci]`;
|
|
147
147
|
const body = basenames.length ? `Updated: ${basenames.join(', ')}` : '';
|
|
148
148
|
return body ? `${subject}\n\n${body}` : subject;
|
|
149
149
|
}
|
|
@@ -307,18 +307,18 @@ export async function runCheckpoint(root, opts = {}) {
|
|
|
307
307
|
log(c.bold('\nyad checkpoint'));
|
|
308
308
|
if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
|
|
309
309
|
if (!exists(productConfigPath(root))) {
|
|
310
|
-
fail('no .sdlc/
|
|
310
|
+
fail('no .sdlc/product.json — checkpoint commits the Product Build ledger; run it from the Product');
|
|
311
311
|
process.exitCode = 1;
|
|
312
312
|
return;
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
const {
|
|
315
|
+
const { productConfig } = loadProduct(root);
|
|
316
316
|
const git = productGit(root);
|
|
317
317
|
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
318
|
-
const defaultBranch = resolveDefaultBranch(git,
|
|
318
|
+
const defaultBranch = resolveDefaultBranch(git, productConfig);
|
|
319
319
|
|
|
320
320
|
// Default-branch guard (invariant 2) — shared with `yad tidy up`.
|
|
321
|
-
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch: opts.allowBranch, cmd: 'yad checkpoint' })) return;
|
|
321
|
+
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch: opts.allowBranch, cmd: 'yad checkpoint', root })) return;
|
|
322
322
|
|
|
323
323
|
// --retro-ship (#142): record a retroactive build-log ship for a PRE-TRACKING story (merged before
|
|
324
324
|
// the Build ledger existed, so it has no ship and its `status:` flip can't be carried). Done
|
|
@@ -377,7 +377,7 @@ export async function runCheckpoint(root, opts = {}) {
|
|
|
377
377
|
const staged = git('diff', '--cached', '--name-only', '--', ...pathspecs).stdout.split('\n').filter(Boolean);
|
|
378
378
|
|
|
379
379
|
const { label, basenames } = summarizeStaged(staged);
|
|
380
|
-
const author = checkpointAuthor(platformLogin(root,
|
|
380
|
+
const author = checkpointAuthor(platformLogin(root, productConfig?.platform), git('config', 'user.name').stdout);
|
|
381
381
|
const message = buildCheckpointMessage({ label, author, basenames });
|
|
382
382
|
|
|
383
383
|
if (opts.dryRun) {
|
package/cli/claims.mjs
CHANGED
|
@@ -29,15 +29,15 @@ import fs from 'node:fs';
|
|
|
29
29
|
import { ok, info, warn, fail, hand, readJSON, writeJSON } from './lib.mjs';
|
|
30
30
|
import { productConfigPath } from './manifest.mjs';
|
|
31
31
|
import { capturedEpic, gitIn, isOwnerPath, pushEnv, wipName, WIP_PREFIX, fetchAllArgs } from './capture.mjs';
|
|
32
|
-
import { resolveDefaultBranch } from './
|
|
32
|
+
import { resolveDefaultBranch } from './productcommit.mjs';
|
|
33
33
|
|
|
34
34
|
export const CLAIM_HOURS = 4;
|
|
35
35
|
const CLAIM_MS = CLAIM_HOURS * 60 * 60 * 1000;
|
|
36
36
|
|
|
37
37
|
// The default branch as a ref this clone holds — origin's copy first, then a local branch — or null.
|
|
38
38
|
function defaultRef(root, git) {
|
|
39
|
-
const
|
|
40
|
-
const def = resolveDefaultBranch((...a) => { const r = git(a); return { ok: r.ok, stdout: r.out.trim() }; },
|
|
39
|
+
const productConfig = readJSON(productConfigPath(root), {}) || {};
|
|
40
|
+
const def = resolveDefaultBranch((...a) => { const r = git(a); return { ok: r.ok, stdout: r.out.trim() }; }, productConfig);
|
|
41
41
|
for (const ref of [`refs/remotes/origin/${def}`, `refs/heads/${def}`]) if (git(['rev-parse', '--verify', '-q', `${ref}^{commit}`]).ok) return { ref, name: def };
|
|
42
42
|
return { ref: null, name: def };
|
|
43
43
|
}
|
|
@@ -149,7 +149,7 @@ export async function runClaims(root, { epic = null, noFetch = false, env = proc
|
|
|
149
149
|
const opts = { cwd: root, encoding: 'utf8', timeout: 30_000, env: { ...env, ...pushEnv(env) } };
|
|
150
150
|
const r = spawnSync('git', fetchAllArgs({ prune: true }), opts);
|
|
151
151
|
fetched = r.status === 0 ? 'done' : 'failed';
|
|
152
|
-
// The default branch, for the "landed" rule — on its own: a name guessed wrong (no
|
|
152
|
+
// The default branch, for the "landed" rule — on its own: a name guessed wrong (no Product default, no
|
|
153
153
|
// origin/HEAD) must not stop the capture branches from arriving. Its failure changes nothing.
|
|
154
154
|
const def = defaultRef(root, git).name;
|
|
155
155
|
if (fetched === 'done') spawnSync('git', ['-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=20', 'fetch', '--quiet', '--no-tags', 'origin', `+refs/heads/${def}:refs/remotes/origin/${def}`], opts);
|