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
@@ -1,4 +1,4 @@
1
- // `yad checkpoint` — commit the machine-written Build hub state (trust-log / build-log /
1
+ // `yad checkpoint` — commit the machine-written Build state on the Product (trust-log / build-log /
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
@@ -22,13 +22,13 @@
22
22
  // marker would strand the PR's required checks.
23
23
  import fs from 'node:fs';
24
24
  import path from 'node:path';
25
- import { c, log, ok, info, fail, hand, exists, readJSON, pushWithRebase } from './lib.mjs';
26
- import { PROJECT_FILES } from './manifest.mjs';
27
- import { loadHub } from './gate.mjs';
28
- import { resolveCommitterLogin } from './platform.mjs';
29
- import { hubGit, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
25
+ import { c, log, ok, info, fail, hand, exists, readJSON, readJSONStrict, pushWithRebase } from './lib.mjs';
26
+ import { PROJECT_FILES , productConfigPath } from './manifest.mjs';
27
+ import { loadProduct } from './gate.mjs';
28
+ import { platformLogin } from './platform.mjs';
29
+ import { productGit, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
30
30
  import { readShips, writeRetroShip } from './ledger.mjs';
31
- import { readFrontmatter } from './epic-state.mjs';
31
+ import { readFrontmatter, declaredRepos } from './epic-state.mjs';
32
32
 
33
33
  // The machine-written Build ledgers, relative to an epic's dir. The two append-only logs are
34
34
  // shard-then-fold (cli/ledger.mjs): each is a folded file PLUS a shard dir of loose per-entry files —
@@ -130,7 +130,7 @@ export function summarizeStaged(files = []) {
130
130
  // path from breaking the one-line subject or injecting a fake trailer line.
131
131
  const oneLine = (s = '') => String(s).replace(/\s+/g, ' ').trim();
132
132
 
133
- // `@login` from the roster (the auditable handle the user asked for), else the raw git user.name,
133
+ // `@login` from the platform CLI (the auditable handle the user asked for), else the raw git user.name,
134
134
  // else a stable placeholder so the subject is never empty.
135
135
  export function checkpointAuthor(login, name) {
136
136
  if (login) return `@${oneLine(login)}`;
@@ -138,7 +138,7 @@ export function checkpointAuthor(login, name) {
138
138
  return n || 'unknown';
139
139
  }
140
140
 
141
- // PURE — the audit-trail commit message. The subject passes the hub commit-message gate (valid type
141
+ // PURE — the audit-trail commit message. The subject passes the Product commit-message gate (valid type
142
142
  // `chore`, optional scope `hub`, 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.
@@ -151,7 +151,7 @@ export function buildCheckpointMessage({ label, author, basenames = [] }) {
151
151
  // True iff a story file's STAGED diff changes ONLY the frontmatter `status:` line — every added or
152
152
  // removed content line is a `status:` line. A newly-added file (all lines added) or any prose/other
153
153
  // edit fails, so only a clean flip is carried into the `[skip ci]` chore commit; anything broader is
154
- // left for a reviewed change (#112 review-bypass guard). `git` is a hubGit-style accessor.
154
+ // left for a reviewed change (#112 review-bypass guard). `git` is a productGit-style accessor.
155
155
  export function stagedStoryIsStatusOnly(git, file) {
156
156
  const d = git('diff', '--cached', '-U0', '--', file);
157
157
  if (!d.ok) return false;
@@ -176,15 +176,14 @@ export function stagedStoryIsStatusOnly(git, file) {
176
176
  // `--repo`; the second run finds the flip already committed, so it lands only the new ship shard.
177
177
  //
178
178
  // The repo names a retro ship MAY carry — the story's own `repos:` frontmatter (its statement of where
179
- // it was implemented), else the hub's connected-repo registry as the project-wide fallback. Used to
179
+ // it was implemented), else the Product's connected-repo registry as the project-wide fallback. Used to
180
180
  // reject a typo'd/mis-cased/invented `--repo` (#166 review): once the duplicate guard is per repo, a
181
181
  // wrong name no longer collides with anything, so nothing else would stop it from committing a
182
182
  // `retroactive: true` ship for a repo that never existed. Returns `{ names: [], source: 'none' }` when
183
183
  // neither declares anything — a legacy story with no metadata is still backfillable, never blocked on
184
184
  // a missing list.
185
185
  export function retroShipRepos(root, storyFile) {
186
- const declared = readFrontmatter(storyFile).repos;
187
- const story = (Array.isArray(declared) ? declared : declared ? [declared] : []).map(String).filter(Boolean);
186
+ const story = declaredRepos(readFrontmatter(storyFile));
188
187
  if (story.length) return { names: story, source: 'story' };
189
188
  const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
190
189
  const names = (Array.isArray(reg?.repos) ? reg.repos : []).map((r) => r?.name).filter(Boolean);
@@ -215,20 +214,38 @@ export function recordRetroShip(root, { epic, story, repo, task, mergeCommit, to
215
214
  // repo (`source: 'none'`), so a legacy story with no metadata is never blocked.
216
215
  const { names, source } = retroShipRepos(root, storyFile);
217
216
  if (names.length && !names.includes(repo)) {
218
- fail(`${repo} is not a repo ${source === 'story' ? `${story} declares` : 'connected to this hub'} — a retroactive ship must name a real repo, never invent one`);
217
+ fail(`${repo} is not a repo ${source === 'story' ? `${story} declares` : 'connected to this Product'} — a retroactive ship must name a real repo, never invent one`);
219
218
  hand(`known: ${names.join(', ')} (names are case-sensitive)`);
220
219
  return { ok: false };
221
220
  }
221
+ // A lane SKIPPED whole (E39) did not ship: recording a ship for it would make the two records contradict.
222
+ // And a skipped repo is not "still unrecorded" — it is owed nothing — so it is left out of `remaining`.
223
+ // Read STRICTLY: a corrupt file would otherwise read as "nothing skipped" and let a permanent ship be
224
+ // recorded over a skip — the same refusal to guess this function makes for a corrupt build-log (E39 review).
225
+ let laneRepos;
226
+ try { laneRepos = readJSONStrict(path.join(epicDir, '.sdlc', 'build-state', `${story}.json`), null)?.repos; }
227
+ catch (e) {
228
+ fail(`could not read ${story}'s build-state — ${e.message}`);
229
+ hand('a ship is permanent audit evidence, so it is not recorded while the lane state cannot be read — fix the file, then re-run');
230
+ return { ok: false };
231
+ }
232
+ const skipped = new Set(laneRepos && typeof laneRepos === 'object'
233
+ ? Object.entries(laneRepos).filter(([, lane]) => lane?.status === 'skipped').map(([name]) => name) : []);
234
+ if (skipped.has(repo)) {
235
+ fail(`${story} / ${repo} is skipped — a lane that was skipped did not ship`);
236
+ hand(`if it did ship after all, put the lane back first: yad unskip ${epic} ${story} --repo ${repo}`);
237
+ return { ok: false };
238
+ }
222
239
  // Which of the story's OWN declared repos still lack evidence — read BEFORE the write so both the
223
240
  // refusal and the success path can report honestly how much of a multi-repo backfill is left. Only
224
- // the story's own list is used: the hub registry lists every connected repo, which says nothing
241
+ // the story's own list is used: the Product registry lists every connected repo, which says nothing
225
242
  // about where THIS story shipped.
226
243
  const remaining = () => {
227
244
  if (source !== 'story') return [];
228
245
  let recorded;
229
246
  try { recorded = new Set(readShips(epicDir).filter((s) => s.story === story).map((s) => s.repo)); }
230
247
  catch { return []; } // corrupt build-log — the write below reports it; don't guess at progress
231
- return names.filter((n) => !recorded.has(n) && n !== repo);
248
+ return names.filter((n) => !recorded.has(n) && n !== repo && !skipped.has(n));
232
249
  };
233
250
  const left = remaining();
234
251
 
@@ -289,14 +306,14 @@ function cleanupRetroShard(file) {
289
306
  export async function runCheckpoint(root, opts = {}) {
290
307
  log(c.bold('\nyad checkpoint'));
291
308
  if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
292
- if (!exists(path.join(root, PROJECT_FILES.hubConfig))) {
293
- fail('no .sdlc/hub.json — checkpoint commits the hub Build ledger; run it from the product hub');
309
+ if (!exists(productConfigPath(root))) {
310
+ fail('no .sdlc/hub.json — checkpoint commits the Product Build ledger; run it from the Product');
294
311
  process.exitCode = 1;
295
312
  return;
296
313
  }
297
314
 
298
- const { hub } = loadHub(root);
299
- const git = hubGit(root);
315
+ const { hub } = loadProduct(root);
316
+ const git = productGit(root);
300
317
  const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
301
318
  const defaultBranch = resolveDefaultBranch(git, hub);
302
319
 
@@ -321,11 +338,13 @@ export async function runCheckpoint(root, opts = {}) {
321
338
  // plain `yad checkpoint` as permanent `retroactive: true` audit evidence nobody chose to record.
322
339
  // A real run deliberately keeps its shard on a failure (see the commit-failed path below).
323
340
  const rollbackRetro = () => { if (opts.dryRun && retroFile) cleanupRetroShard(retroFile); };
341
+ // The --json answer (E1) of a run that had nothing to commit.
342
+ const nothing = () => ({ message: null, files: [], committed: false, pushed: false, dryRun: !!opts.dryRun, retroShip: null });
324
343
 
325
344
  // The machine ledgers PLUS any build-log-backed story `status:` flip (#112) — one commit records
326
345
  // both, so the story artifact never drifts from build-log and no raw git-to-main push is needed.
327
346
  const pathspecs = [...buildLedgerPathspecs(root), ...storyStatusPathspecs(root)];
328
- if (!pathspecs.length) { rollbackRetro(); info('no Build ledgers found — nothing to checkpoint'); return; }
347
+ if (!pathspecs.length) { rollbackRetro(); info('no Build ledgers found — nothing to checkpoint'); return nothing(); }
329
348
 
330
349
  // Stage the allowlist. `git add -- <spec>` picks up new + modified files, and deletions of tracked
331
350
  // files WITHIN a still-present spec (e.g. a removed build-state/<story>.json). A wholesale-deleted
@@ -349,7 +368,7 @@ export async function runCheckpoint(root, opts = {}) {
349
368
  if (git('diff', '--cached', '--quiet', '--', ...pathspecs).ok) {
350
369
  rollbackRetro();
351
370
  info('Build state unchanged — nothing to commit');
352
- return;
371
+ return nothing();
353
372
  }
354
373
  // The exact files staged from the allowlist — all known to git by construction, so they are the
355
374
  // pathspec for the commit (a directory spec like build-state/ would make `git commit -- <dir>` fail
@@ -358,7 +377,7 @@ export async function runCheckpoint(root, opts = {}) {
358
377
  const staged = git('diff', '--cached', '--name-only', '--', ...pathspecs).stdout.split('\n').filter(Boolean);
359
378
 
360
379
  const { label, basenames } = summarizeStaged(staged);
361
- const author = checkpointAuthor(resolveCommitterLogin(root, hub?.roster || []), git('config', 'user.name').stdout);
380
+ const author = checkpointAuthor(platformLogin(root, hub?.platform), git('config', 'user.name').stdout);
362
381
  const message = buildCheckpointMessage({ label, author, basenames });
363
382
 
364
383
  if (opts.dryRun) {
@@ -368,7 +387,7 @@ export async function runCheckpoint(root, opts = {}) {
368
387
  // run leaves no side effect on disk (git reset only unstaged it, back to untracked).
369
388
  rollbackRetro();
370
389
  info('dry run — not committed');
371
- return { message };
390
+ return { message, files: staged, committed: false, pushed: false, dryRun: true, retroShip: null };
372
391
  }
373
392
 
374
393
  const cm = git('commit', '-m', message, '--', ...staged);
@@ -381,14 +400,15 @@ export async function runCheckpoint(root, opts = {}) {
381
400
  return { message };
382
401
  }
383
402
  ok(`checkpointed ${staged.length} file(s): ${c.dim(label)}`);
403
+ const done = { message, files: staged, committed: true, dryRun: false, retroShip: retroFile ? path.relative(root, retroFile) : null };
384
404
 
385
- if (!opts.push) return { message };
405
+ if (!opts.push) return { ...done, pushed: false };
386
406
  // Push HEAD to its OWN branch — never to `defaultBranch` blindly. On the default branch these are the
387
407
  // same; with --allow-branch on a WIP branch, pushing HEAD:defaultBranch would publish the whole WIP
388
408
  // branch to the default branch (bypassing review), so the target is always the branch we are on.
389
- if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return { message }; }
409
+ if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return { ...done, pushed: true }; }
390
410
  fail(`could not push to origin/${branch} — a protected branch, or the append-only ledgers hit an unresolvable rebase conflict`);
391
411
  hand(`run \`git pull --rebase\` and re-run \`yad checkpoint --push\``);
392
412
  process.exitCode = 1;
393
- return { message };
413
+ return { ...done, pushed: false };
394
414
  }
package/cli/claims.mjs ADDED
@@ -0,0 +1,165 @@
1
+ // `yad claims` (E46) — who else is editing which artifact, read from the E43 capture branches.
2
+ //
3
+ // "architecture.md — claimed by alice, 10:04." ADVICE, NOT A LOCK: with no server a real lock is impossible,
4
+ // and a claim never stops anyone. The decisions were the user's (2026-09-25):
5
+ // 1. SOURCE: the capture branches, and nothing else. There is no claim file and no `yad claim` command —
6
+ // "record" is E43's capture, which already commits every edit to `yad/wip/<name>/<epic>` and pushes it.
7
+ // 2. SHOW: this command (it fetches everyone's capture branches first), and a warning at edit time from the
8
+ // post-edit capture hook, which reads only what is already fetched and never blocks. On Claude Code the
9
+ // warning reaches the agent (`additionalContext`); elsewhere it is a line on stderr.
10
+ // 3. EXPIRY: 4 hours after the branch's last capture — and at once for a file whose change is on the default
11
+ // branch (its content there equals the capture's), so a merged fold releases its files.
12
+ // 4. FRESHNESS: the hook's throttled background push also starts a background fetch of everyone's capture
13
+ // branches (cli/capture.mjs), so what the hook reads is minutes old, not hours.
14
+ //
15
+ // WHAT A CLAIM IS: a file of the epic that differs between the capture branch's tip and the tip's `Yad-Base`
16
+ // — the HEAD that capture was built on. That is exactly the person's own in-progress edits. It is NOT the
17
+ // diff against the previous capture: after a pull, a capture's tree is the NEW HEAD plus the edits, and the
18
+ // pull's changes would read as that person's work. When `Yad-Base` is not in this clone (a later HEAD is not
19
+ // an ancestor of the branch, so it was never pushed with it), the commit the branch's FIRST capture was built
20
+ // on is the fallback, marked `basis: 'first-capture'` — over-reporting is the safe direction for advice.
21
+ // The hook's background fetch and push can both move this person's own remote-tracking copies at once; one
22
+ // may lose the race in silence. Nothing reads those copies while the local branch exists, and a capture by
23
+ // hand fetches (with prune) before it relies on them.
24
+ //
25
+ // LIMITS, said in the README: capture off, or offline, is invisible; the push runs at most every 5 minutes;
26
+ // two people with one git name share branches, so they never see each other; clocks are the committer's.
27
+ import { spawnSync } from 'node:child_process';
28
+ import fs from 'node:fs';
29
+ import { ok, info, warn, fail, hand, readJSON, writeJSON } from './lib.mjs';
30
+ import { productConfigPath } from './manifest.mjs';
31
+ import { capturedEpic, gitIn, isOwnerPath, pushEnv, wipName, WIP_PREFIX, fetchAllArgs } from './capture.mjs';
32
+ import { resolveDefaultBranch } from './hubcommit.mjs';
33
+
34
+ export const CLAIM_HOURS = 4;
35
+ const CLAIM_MS = CLAIM_HOURS * 60 * 60 * 1000;
36
+
37
+ // The default branch as a ref this clone holds — origin's copy first, then a local branch — or null.
38
+ function defaultRef(root, git) {
39
+ const hub = readJSON(productConfigPath(root), {}) || {};
40
+ const def = resolveDefaultBranch((...a) => { const r = git(a); return { ok: r.ok, stdout: r.out.trim() }; }, hub);
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
+ return { ref: null, name: def };
43
+ }
44
+
45
+ // Every open claim this clone can see. `epics` narrows to those epics; `now` is a number of milliseconds.
46
+ // Returns { me, claims: [{ path, epic, name, person, lastSavedAt, mine, basis }], defaultBranch }.
47
+ export function readClaims(root, { env = process.env, now = Date.now(), epics = null } = {}) {
48
+ const git = gitIn(root, env);
49
+ const prefix = git(['rev-parse', '--show-prefix']).out.trim();
50
+ const me = wipName(git(['config', 'user.name']).out.trim(), git(['config', 'user.email']).out.trim());
51
+ const def = defaultRef(root, git);
52
+ const empty = git(['hash-object', '-t', 'tree', '--stdin'], '').out.trim();
53
+ const fmt = '%(refname)%00%(objectname)%00%(committerdate:unix)%00%(authorname)%00%(trailers:key=Yad-Base,valueonly)';
54
+ // Others' branches from the remote-tracking copies; my own from the local branches, which are the freshest.
55
+ const roots = [`refs/remotes/origin/${WIP_PREFIX}/`, ...(me ? [`refs/heads/${WIP_PREFIX}/${me}/`] : [])];
56
+ // `trailer.separators=:` — capture writes `Yad-Base: <sha>`; a person's own separators setting would hide it.
57
+ const listed = git(['-c', 'trailer.separators=:', 'for-each-ref', `--format=${fmt}`, ...roots]);
58
+ const claims = [];
59
+ for (const line of listed.ok ? listed.out.split('\n').filter(Boolean) : []) {
60
+ const [ref, tip, date, person, baseRaw = ''] = line.split('\0');
61
+ const rest = ref.replace(/^refs\/(remotes\/origin|heads)\//, '').slice(WIP_PREFIX.length + 1);
62
+ const slash = rest.indexOf('/');
63
+ const name = rest.slice(0, slash);
64
+ const epic = rest.slice(slash + 1);
65
+ if (slash < 1 || !/^EP-[^/]+$/.test(epic)) continue;
66
+ if (epics && !epics.has(epic)) continue;
67
+ const remote = ref.startsWith('refs/remotes/');
68
+ if (remote && name === me) continue; // my own: the local branch speaks for it
69
+ const savedMs = Number(date) * 1000;
70
+ if (!Number.isFinite(savedMs) || now - savedMs > CLAIM_MS) continue;
71
+ const base = baseRaw.trim();
72
+ let against;
73
+ let basis = 'yad-base';
74
+ if (base === 'none') against = empty;
75
+ else if (base && git(['cat-file', '-e', `${base}^{tree}`]).ok) against = base;
76
+ else {
77
+ // Not in this clone. The commit the branch's FIRST capture was built on always is (it is an ancestor, so
78
+ // it was pushed with the branch): diffing against it takes in every capture since, and maybe a pull's
79
+ // files too — over-reporting, the safe side for advice, where the previous capture would drop earlier edits.
80
+ basis = 'first-capture';
81
+ // One git call, however long the branch: the newest first-parent ancestor that is not a capture.
82
+ // `--basic-regexp` and `--no-show-signature` pin it against a person's own git config: another
83
+ // `grep.patternType` matches nothing (the tip comes back, and the claims vanish), and
84
+ // `log.showSignature` prints a signature check before the hash.
85
+ const first = git(['log', '--no-show-signature', '--basic-regexp', '--first-parent', '--invert-grep', '--grep=^wip(EP-[^)]*): capture$', '-n', '1', '--format=%H', tip]);
86
+ against = first.ok && first.out.trim() ? first.out.trim() : empty;
87
+ }
88
+ const diff = git(['diff-tree', '-r', '-z', '--name-only', '--no-renames', against, tip]);
89
+ if (!diff.ok) continue;
90
+ let files = diff.out.split('\0').filter((p) => p && p.startsWith(prefix)).map((p) => p.slice(prefix.length))
91
+ // A step owner file (E47) rides the assigner's capture, but assigning is not editing an artifact.
92
+ .filter((p) => capturedEpic(p) === epic && !isOwnerPath(p));
93
+ // Landed: the file's content on the default branch is the capture's — the work is merged, the claim ends.
94
+ if (files.length && def.ref) {
95
+ const differs = git(['diff-tree', '-r', '-z', '--name-only', '--no-renames', def.ref, tip]);
96
+ if (differs.ok) {
97
+ const set = new Set(differs.out.split('\0').filter((p) => p && p.startsWith(prefix)).map((p) => p.slice(prefix.length)));
98
+ files = files.filter((p) => set.has(p));
99
+ }
100
+ }
101
+ for (const p of files) {
102
+ claims.push({ path: p, epic, name, person: person || name, lastSavedAt: new Date(savedMs).toISOString(), mine: name === me, basis });
103
+ }
104
+ }
105
+ claims.sort((a, b) => a.path.localeCompare(b.path) || (a.mine - b.mine) || a.name.localeCompare(b.name));
106
+ return { me, claims, defaultBranch: def.name };
107
+ }
108
+
109
+ const clock = (iso) => iso.slice(11, 16);
110
+ const ago = (iso, now) => {
111
+ const m = Math.max(0, Math.round((now - Date.parse(iso)) / 60000));
112
+ return m < 60 ? `${m} min ago` : `${Math.floor(m / 60)} h ${m % 60} min ago`;
113
+ };
114
+ export const claimLine = (c, now) => `${c.path} — ${c.person}${c.mine ? ' (you)' : ''}, last saved ${clock(c.lastSavedAt)} UTC (${ago(c.lastSavedAt, now)})`;
115
+
116
+ // The capture hook's check (cli/capture.mjs): of the files this capture changed, which does someone ELSE
117
+ // also have open? Reads only what is already fetched. Repeats are held back per person and file for an hour
118
+ // (kept in the capture's per-clone state file), or an agent would hear the same line on every edit.
119
+ export const WARN_AGAIN_MS = 60 * 60 * 1000;
120
+ export function claimWarnings(root, changed, { env = process.env, now = Date.now(), statePath = null } = {}) {
121
+ if (!changed.length) return [];
122
+ const epics = new Set(changed.map((p) => capturedEpic(p)).filter(Boolean));
123
+ const mine = new Set(changed);
124
+ const hits = readClaims(root, { env, now, epics }).claims.filter((c) => !c.mine && mine.has(c.path));
125
+ if (!hits.length || !statePath) return hits;
126
+ let state;
127
+ try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') return hits; state = {}; }
128
+ if (!state || typeof state !== 'object' || Array.isArray(state)) return hits; // not ours to overwrite
129
+ const warned = state.claimsWarned && typeof state.claimsWarned === 'object' ? state.claimsWarned : {};
130
+ const fresh = hits.filter((c) => !(now - (Number(warned[`${c.name}\0${c.path}`]) || 0) < WARN_AGAIN_MS));
131
+ const kept = Object.fromEntries(Object.entries(warned).filter(([, t]) => now - Number(t) < CLAIM_MS));
132
+ for (const c of fresh) kept[`${c.name}\0${c.path}`] = now;
133
+ try { writeJSON(statePath, { ...state, claimsWarned: kept }); } catch { /* a cache */ }
134
+ return fresh;
135
+ }
136
+ export const warningText = (hits, now) => `yad claims: ${hits.length === 1 ? 'this file is' : 'these files are'} also being edited by someone else — advice, not a lock; talk to them before you go further:\n${hits.map((c) => ` • ${claimLine(c, now)}`).join('\n')}`;
137
+
138
+ // `yad claims [<epic>] [--no-fetch]`. Returns a plain object (E1 makes it the `--json` answer).
139
+ export async function runClaims(root, { epic = null, noFetch = false, env = process.env, now = Date.now() } = {}) {
140
+ const refuse = (msg, hint) => { fail(msg); if (hint) hand(hint); process.exitCode = 1; };
141
+ const git = gitIn(root, env);
142
+ if (!git(['rev-parse', '--show-toplevel']).ok) return refuse('not a git repository — there are no capture branches to read');
143
+ if (!fs.existsSync(productConfigPath(root))) return refuse('not a Product (no .sdlc/hub.json or product.json here)', 'run it from the Product root, or pass --dir');
144
+ if (epic && !/^EP-[a-z0-9-]+$/.test(epic)) return refuse(`not an epic id: ${epic}`);
145
+ let fetched = 'skipped';
146
+ if (!noFetch) {
147
+ if (!git(['remote', 'get-url', 'origin']).ok) fetched = 'local';
148
+ else {
149
+ const opts = { cwd: root, encoding: 'utf8', timeout: 30_000, env: { ...env, ...pushEnv(env) } };
150
+ const r = spawnSync('git', fetchAllArgs({ prune: true }), opts);
151
+ fetched = r.status === 0 ? 'done' : 'failed';
152
+ // The default branch, for the "landed" rule — on its own: a name guessed wrong (no hub default, no
153
+ // origin/HEAD) must not stop the capture branches from arriving. Its failure changes nothing.
154
+ const def = defaultRef(root, git).name;
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);
156
+ }
157
+ }
158
+ if (fetched === 'local') info('no remote named origin — only your own captures can be read here');
159
+ if (fetched === 'failed') warn('could not fetch the capture branches — showing what was last fetched, which may be old');
160
+ const { claims, defaultBranch } = readClaims(root, { env, now, epics: epic ? new Set([epic]) : null });
161
+ if (!claims.length) ok(`no open claims${epic ? ` on ${epic}` : ''} — nobody's capture branch has an unmerged change saved in the last ${CLAIM_HOURS} hours`);
162
+ for (const c of claims) info(claimLine(c, now));
163
+ if (claims.length) info(`advice, not a lock — a claim ends ${CLAIM_HOURS} hours after the last save, or once the file on ${defaultBranch} matches it`);
164
+ return { fetched, expireHours: CLAIM_HOURS, defaultBranch, claims };
165
+ }
@@ -0,0 +1,170 @@
1
+ // `yad codeowners check [<repo>]` — warn where a code repo's CODEOWNERS has gone stale (E69). The rules for
2
+ // reading the file are E68's (cli/codeowners.mjs); this finds the repos, reads each file from disk, and
3
+ // prints. Part 3: "CODEOWNERS is a hint, never an authority — in practice these files rot." So this only
4
+ // ever WARNS: it never sets a failing exit code for a finding, never writes the file (there is no
5
+ // `--write`: any name yad wrote would become an owner the platform can enforce), and never says who owns
6
+ // code.
7
+ //
8
+ // Two kinds of answer, kept apart:
9
+ // FACTS — a line that matches no file, a GitHub file of 3 MB or more, a second file the platform never
10
+ // reads, a line this reader cannot read. `yad doctor` prints these too.
11
+ // A HINT — the @logins with no commit in the last 90 days that carries their noreply address. Only a
12
+ // hint: a person may commit under a work address. Printed by this command only, never by the doctor,
13
+ // and only where a noreply address is evidence (a github.com or gitlab.com remote).
14
+ import { spawnSync } from 'node:child_process';
15
+ import fs from 'node:fs';
16
+
17
+ import { c, fail, hand, info, log, ok, run, warn, emitJSON } from './lib.mjs';
18
+ import { deadLines, diskCodeowners, parseCodeowners } from './codeowners.mjs';
19
+ import { OWNER_WINDOW, recentLoginsFor, repoFiles, targets } from './riskmap-command.mjs';
20
+ import { PUBLIC_HOST, remoteHost } from './openpr.mjs';
21
+ import { detectPlatform } from './platform.mjs';
22
+
23
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
24
+ const PLATFORM_NAME = { github: 'GitHub', gitlab: 'GitLab' };
25
+ // "90 days ago" → "90 days", for the sentences.
26
+ const WINDOW_WORDS = OWNER_WINDOW.replace(/ ago$/, '');
27
+
28
+ // The paths of a repo's submodules: their contents are not in its file list.
29
+ function submodulesOf(repoRoot) {
30
+ const r = spawnSync('git', ['ls-files', '-z', '--stage'], { cwd: repoRoot, encoding: 'utf8', maxBuffer: 1 << 30 });
31
+ if (r.status !== 0) return [];
32
+ return r.stdout.split('\0').filter((l) => l.startsWith('160000 ')).map((l) => l.slice(l.indexOf('\t') + 1));
33
+ }
34
+
35
+ // A pattern is printed as written unless it holds an `@`: E67's rule that no e-mail address is ever printed.
36
+ const shownPattern = (d) => (d.pattern.includes('@') ? '' : ` (\`${d.negate ? '!' : ''}${d.pattern}\`)`);
37
+
38
+ // One repo → { git, platform, path?, none?, unknown?, tooBig?, ignored, notRead, dead, inactive? }. With
39
+ // `hint`, `inactive` is { unknown } or { quiet, checked, notChecked } (see the header).
40
+ export function checkCodeowners(repoRoot, { platform = null, remote, hint = false } = {}) {
41
+ // A work tree, not only a repo: a bare repo has no files on disk to check.
42
+ if (run('git', ['rev-parse', '--is-inside-work-tree'], { cwd: repoRoot }).stdout !== 'true') return { git: false };
43
+ // The platform reads CODEOWNERS from the repo's top folder, and the file list must be the whole repo's.
44
+ // `.native`: git names the folder as the disk stores it, and on a disk that ignores case the plain
45
+ // `realpathSync` keeps the case it was given (`../MyRepo` for `myrepo`) — never the same folder twice.
46
+ const top = run('git', ['rev-parse', '--show-toplevel'], { cwd: repoRoot }).stdout;
47
+ if (!top || fs.realpathSync.native(top) !== fs.realpathSync.native(repoRoot)) {
48
+ return { git: true, platform: null, ignored: [], notRead: [], dead: [], unknown: 'this folder is inside a git repo but is not its top folder, where the platform reads CODEOWNERS — check the repo\'s top folder instead' };
49
+ }
50
+ const url = remote ?? run('git', ['remote', 'get-url', 'origin'], { cwd: repoRoot }).stdout;
51
+ const plat = platform || detectPlatform(url || '');
52
+ const base = { git: true, platform: plat, ignored: [], notRead: [], dead: [] };
53
+ if (!plat) return { ...base, unknown: 'yad cannot tell whether this repo is on GitHub or GitLab (no `platform` in repos.json, and the origin remote names neither) — pass --platform' };
54
+ const co = diskCodeowners(repoRoot, plat);
55
+ if (co.unknown) return { ...base, unknown: co.unknown };
56
+ if (co.none) return { ...base, none: co.none };
57
+ const out = { ...base, path: co.path, ignored: co.ignored };
58
+ if (co.tooBig) return { ...out, tooBig: true };
59
+ // Only now the file list: it is slow on a large repo, and a repo with no CODEOWNERS never needs it.
60
+ const files = repoFiles(repoRoot);
61
+ if (!files) return { ...base, unknown: 'git could not list the files in this repo' };
62
+ const parsed = parseCodeowners(co.text, plat);
63
+ out.notRead = parsed.skipped;
64
+ out.dead = deadLines(parsed, files, { submodules: submodulesOf(repoRoot) });
65
+ if (hint) out.inactive = inactiveOwners(repoRoot, parsed, plat, url);
66
+ return out;
67
+ }
68
+
69
+ // The hint: which @logins no recent commit names. Only a person's login can be checked — a team, group,
70
+ // role or address has no noreply commits of its own — and only on the public host a noreply login
71
+ // belongs to. On GitLab an `@name` may be a group; the sentence printed is true either way.
72
+ function inactiveOwners(repoRoot, parsed, platform, url) {
73
+ const persons = new Map();
74
+ const others = new Map();
75
+ for (const r of parsed.rules) {
76
+ if (r.negate) continue;
77
+ for (const o of r.owners) {
78
+ if (o.kind === 'user' || o.kind === 'name') {
79
+ const k = o.text.slice(1).toLowerCase();
80
+ if (!persons.has(k)) persons.set(k, o.text);
81
+ } else others.set(`${o.kind}\0${o.key || o.text}`, o.kind);
82
+ }
83
+ }
84
+ const notChecked = {};
85
+ for (const kind of others.values()) notChecked[kind] = (notChecked[kind] || 0) + 1;
86
+ if (!persons.size) return { quiet: [], checked: 0, notChecked };
87
+ const host = PUBLIC_HOST[platform];
88
+ if (remoteHost(url) !== host) {
89
+ return { unknown: `the origin remote is not on ${host}, and a noreply address is evidence only for a ${host} account (a self-managed server keeps its own accounts)` };
90
+ }
91
+ const h = recentLoginsFor(repoRoot, 'HEAD');
92
+ if (h.unknown) return { unknown: h.unknown };
93
+ const seen = new Set(h.logins.filter((l) => l.host === platform).map((l) => l.login.toLowerCase()));
94
+ return { quiet: [...persons].filter(([k]) => !seen.has(k)).map(([, text]) => text), checked: persons.size, notChecked };
95
+ }
96
+
97
+ // The facts as a list: [{ code, line?, target?, message }] — what `yad doctor` summarises and `--json` prints.
98
+ export function codeownersFindings(r) {
99
+ const out = [];
100
+ const name = PLATFORM_NAME[r.platform] || r.platform;
101
+ if (r.tooBig) out.push({ code: 'too-big', target: r.path, message: `${r.path} is 3 MB or more — GitHub does not load a file that large, so it lists no owner for any file` });
102
+ for (const p of r.ignored) out.push({ code: 'never-read', target: p, message: `${p} is never read — ${name} reads ${r.path} first` });
103
+ for (const s of r.notRead) out.push({ code: 'not-read', line: s.line, message: `line ${s.line} not read — ${s.why}` });
104
+ for (const d of r.dead) {
105
+ out.push({ code: 'matches-nothing', line: d.line, message: `line ${d.line}${shownPattern(d)} ${d.negate ? 'excludes' : 'matches'} no file in this repo` });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ const KIND_WORD = { team: ['team'], group: ['group'], role: ['role'], email: ['e-mail address', 'e-mail addresses'] };
111
+
112
+ function printRepo(name, r) {
113
+ log(c.bold(`\ncodeowners — ${name}`));
114
+ if (!r.git) { warn('no files on disk to check (not a git repo, or a bare one) — skipped'); return; }
115
+ if (r.unknown) { warn(`CODEOWNERS: not known — ${r.unknown}`); return; }
116
+ if (r.none) { info(`none — ${r.none}; nothing to check`); return; }
117
+ const findings = codeownersFindings(r);
118
+ for (const f of findings) warn(f.message);
119
+ if (!findings.length) ok(`${r.path}: every line yad can read matches a file`);
120
+ const i = r.inactive;
121
+ if (i?.unknown) info(`owners with recent commits: not known — ${i.unknown}`);
122
+ else if (i) {
123
+ const host = PUBLIC_HOST[r.platform];
124
+ const partial = r.notRead.length ? ' (from the lines yad could read)' : '';
125
+ if (i.quiet.length) {
126
+ const gl = r.platform === 'gitlab' ? '; on GitLab an @name can also be a group, which never commits' : '';
127
+ hand(`no commit on the checked-out branch in the last ${WINDOW_WORDS} carries a ${host} noreply address for ${i.quiet.join(', ')}${partial} — a hint only: they may commit under another address or work in other repos, so this does not mean they have left${gl}`);
128
+ } else if (i.checked) {
129
+ info(`every @login ${r.path} lists${partial} has a commit in the last ${WINDOW_WORDS} with its ${host} noreply address`);
130
+ }
131
+ const nc = Object.entries(i.notChecked).map(([k, n]) => plural(n, ...(KIND_WORD[k] || [k])));
132
+ if (nc.length) info(`not checked for recent commits: ${nc.join(', ')} — the hint reads only @logins`);
133
+ }
134
+ if (findings.length) hand(`fix ${r.path} in ${name} through a PR — the warnings are advisory: CODEOWNERS is a hint, and yad never enforces it`);
135
+ }
136
+
137
+ export async function runCodeowners(root, { action = 'check', name, json = false, platform = null, write = false } = {}) {
138
+ if (action !== 'check' || write) {
139
+ // `--write` was part of E69's title and was dropped by decision: a name yad wrote into the file would
140
+ // become an owner the platform can enforce (GitHub's "Require review from Code Owners", GitLab's code
141
+ // owner approval), turning a hint into an authority. Said plainly, so a habit or a script learns why.
142
+ const refused = write || action === 'write' || action === '--write';
143
+ const error = refused ? 'yad never writes CODEOWNERS — any name it wrote would become an owner the platform can enforce'
144
+ : `unknown action: ${action} (use: yad codeowners check [repo])`;
145
+ const hint = refused ? 'edit CODEOWNERS by hand and commit it through a PR; `yad codeowners check` shows which lines look stale' : '';
146
+ if (json) emitJSON({ ok: false, error, ...(hint ? { hint } : {}) });
147
+ else { fail(error); if (hint) hand(hint); }
148
+ process.exitCode = 1;
149
+ return { ok: false };
150
+ }
151
+ const t = targets(root, name);
152
+ if (t.error) {
153
+ if (json) emitJSON({ ok: false, error: t.error, hint: t.hint });
154
+ else { fail(t.error); hand(t.hint); }
155
+ process.exitCode = 1;
156
+ return { ok: false };
157
+ }
158
+ const repos = t.list.map((x) => ({ name: x.name, root: x.root, ...checkCodeowners(x.root, { platform: platform || x.platform, hint: true }) }));
159
+ if (json) {
160
+ emitJSON({ ok: true, repos: repos.map((r) => ({
161
+ name: r.name, git: r.git, platform: r.platform ?? null, path: r.path ?? null,
162
+ ...(r.none ? { none: r.none } : {}), ...(r.unknown ? { unknown: r.unknown } : {}),
163
+ findings: r.git && !r.unknown && !r.none ? codeownersFindings(r) : [],
164
+ ...(r.inactive ? { inactive: r.inactive } : {}),
165
+ })) });
166
+ return { ok: true, repos };
167
+ }
168
+ for (const r of repos) printRepo(r.name, r);
169
+ return { ok: true, repos };
170
+ }