yadflow 3.18.1 → 4.0.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +79 -26
  3. package/bin/commands.mjs +41 -0
  4. package/bin/yad.mjs +437 -124
  5. package/cli/artifact-status.mjs +34 -15
  6. package/cli/checkpoint.mjs +69 -49
  7. package/cli/codeowners-command.mjs +170 -0
  8. package/cli/codeowners.mjs +397 -0
  9. package/cli/commit.mjs +13 -9
  10. package/cli/companion.mjs +2 -2
  11. package/cli/dial.mjs +183 -0
  12. package/cli/docs.mjs +88 -32
  13. package/cli/doctor.mjs +1472 -97
  14. package/cli/epic-state.mjs +3478 -232
  15. package/cli/epic.mjs +506 -0
  16. package/cli/errors.mjs +4 -1
  17. package/cli/gate.mjs +1002 -209
  18. package/cli/history.mjs +556 -0
  19. package/cli/hook.mjs +266 -55
  20. package/cli/hubcommit.mjs +6 -17
  21. package/cli/index-command.mjs +87 -0
  22. package/cli/ledger.mjs +57 -7
  23. package/cli/lib.mjs +184 -18
  24. package/cli/manifest.mjs +367 -56
  25. package/cli/migrate.mjs +726 -53
  26. package/cli/mode.mjs +170 -0
  27. package/cli/next.mjs +349 -90
  28. package/cli/openpr.mjs +191 -39
  29. package/cli/people.mjs +654 -0
  30. package/cli/plan.mjs +417 -132
  31. package/cli/platform.mjs +110 -129
  32. package/cli/product-index.mjs +287 -0
  33. package/cli/protection.mjs +706 -0
  34. package/cli/reconcile.mjs +38 -12
  35. package/cli/repo-publish.mjs +24 -26
  36. package/cli/repo.mjs +23 -14
  37. package/cli/report.mjs +21 -15
  38. package/cli/review.mjs +24 -27
  39. package/cli/riskmap-command.mjs +289 -0
  40. package/cli/riskmap.mjs +373 -0
  41. package/cli/setup.mjs +139 -287
  42. package/cli/ship.mjs +7 -6
  43. package/cli/skill.mjs +180 -0
  44. package/cli/skip.mjs +211 -30
  45. package/cli/thread.mjs +42 -17
  46. package/cli/tidy.mjs +20 -20
  47. package/cli/update-commit.mjs +22 -22
  48. package/cli/usage.mjs +115 -109
  49. package/package.json +3 -3
  50. package/skills/sdlc/config.yaml +166 -87
  51. package/skills/sdlc/module-help.csv +35 -35
  52. package/skills/yad-analysis/SKILL.md +125 -65
  53. package/skills/yad-architecture/SKILL.md +34 -23
  54. package/skills/yad-architecture/references/contract-format.md +10 -8
  55. package/skills/yad-backfill/SKILL.md +14 -8
  56. package/skills/yad-backfill/references/backfill.md +1 -1
  57. package/skills/yad-change/SKILL.md +127 -52
  58. package/skills/yad-change/references/triage.md +42 -28
  59. package/skills/yad-checks/SKILL.md +89 -45
  60. package/skills/yad-checks/references/check-gates.md +315 -92
  61. package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
  62. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  63. package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
  64. package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
  65. package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
  66. package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
  67. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  68. package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
  69. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
  70. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  71. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  72. package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
  73. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  74. package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
  75. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  76. package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
  77. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
  78. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  79. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
  80. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  81. package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
  82. package/skills/yad-commit/SKILL.md +6 -6
  83. package/skills/yad-connect-design/SKILL.md +6 -6
  84. package/skills/yad-connect-design/references/design-context.md +1 -1
  85. package/skills/yad-connect-design/references/design-registry.md +2 -2
  86. package/skills/yad-connect-docs/SKILL.md +12 -12
  87. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  88. package/skills/yad-connect-learning/SKILL.md +5 -5
  89. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  90. package/skills/yad-connect-repos/SKILL.md +92 -54
  91. package/skills/yad-connect-repos/references/code-context.md +6 -6
  92. package/skills/yad-connect-repos/references/hub-config.md +68 -58
  93. package/skills/yad-connect-repos/references/repos-registry.md +10 -9
  94. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  95. package/skills/yad-connect-testing/SKILL.md +6 -6
  96. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  97. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  98. package/skills/yad-defects/SKILL.md +8 -8
  99. package/skills/yad-discovery/SKILL.md +130 -94
  100. package/skills/yad-discovery/references/discovery-schema.md +23 -7
  101. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  102. package/skills/yad-docs/SKILL.md +16 -11
  103. package/skills/yad-docs/references/data-mapping.md +9 -7
  104. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  105. package/skills/yad-docs-overview/SKILL.md +32 -17
  106. package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
  107. package/skills/yad-docs-sync/SKILL.md +10 -5
  108. package/skills/yad-docs-sync/references/staleness.md +8 -7
  109. package/skills/yad-engineer-review/SKILL.md +88 -24
  110. package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
  111. package/skills/yad-epic/SKILL.md +178 -100
  112. package/skills/yad-epic/references/state-schema.md +626 -117
  113. package/skills/yad-hub-bridge/SKILL.md +66 -48
  114. package/skills/yad-hub-bridge/references/bridge.md +110 -83
  115. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  116. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  117. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  118. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  119. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
  120. package/skills/yad-implement/SKILL.md +29 -15
  121. package/skills/yad-implement/references/implement-conventions.md +2 -2
  122. package/skills/yad-learn/SKILL.md +9 -9
  123. package/skills/yad-learn/references/learning-state.md +2 -2
  124. package/skills/yad-open-pr/SKILL.md +64 -29
  125. package/skills/yad-pair-review/SKILL.md +18 -16
  126. package/skills/yad-pair-review/references/session-state.md +4 -4
  127. package/skills/yad-pr-template/SKILL.md +48 -27
  128. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  129. package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
  130. package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
  131. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  132. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  133. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  134. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
  135. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
  136. package/skills/yad-reconcile/SKILL.md +3 -3
  137. package/skills/yad-report/SKILL.md +5 -5
  138. package/skills/yad-review-companion/SKILL.md +12 -9
  139. package/skills/yad-review-gate/SKILL.md +198 -79
  140. package/skills/yad-review-gate/references/gating.md +230 -54
  141. package/skills/yad-run/SKILL.md +86 -56
  142. package/skills/yad-run/references/run-loop.md +67 -45
  143. package/skills/yad-ship/SKILL.md +18 -14
  144. package/skills/yad-spec/SKILL.md +31 -17
  145. package/skills/yad-spec/references/spec-handoff.md +17 -5
  146. package/skills/yad-status/SKILL.md +114 -56
  147. package/skills/yad-stories/SKILL.md +42 -27
  148. package/skills/yad-stories/references/story-schema.md +10 -9
  149. package/skills/yad-stub/SKILL.md +59 -48
  150. package/skills/yad-sync-repos/SKILL.md +3 -3
  151. package/skills/yad-test-cases/SKILL.md +37 -30
  152. package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
  153. package/skills/yad-timeline/SKILL.md +8 -7
  154. package/skills/yad-ui/SKILL.md +46 -25
  155. package/cli/roster.mjs +0 -164
  156. package/skills/sdlc/install.sh +0 -68
@@ -0,0 +1,289 @@
1
+ // `yad risk-map check|draft [<repo>]` — the risk map of a code repo (E65). The rules live in
2
+ // cli/riskmap.mjs; this finds the repos, lists their files, prints and writes.
3
+ //
4
+ // `draft` only ADDS: an `unset` line for every directory nothing covers. It never changes a line that is
5
+ // already there, so a level a person confirmed is never overwritten. Classifying the `unset` lines is the
6
+ // `yad-connect-repos` skill's job — an AI agent reads the code, which this command cannot do.
7
+ //
8
+ // `check` is the local twin of `checks/risk-map-check.sh`, over the whole repo instead of one change.
9
+ // It is advisory like the CI check: it prints warnings and never sets a failing exit code for them.
10
+ import { spawnSync } from 'node:child_process';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ import { c, fail, hand, info, log, ok, readJSONStrict, run, warn, emitJSON } from './lib.mjs';
15
+ import { PROJECT_FILES } from './manifest.mjs';
16
+ import { changeLevel, draftRiskMap, folderPathspec, highTouched, parseRiskMap, pathspecsFor, rankAuthors, recentAuthors, RISK_MAP_FILE, riskMapFindings, touchedFolders } from './riskmap.mjs';
17
+
18
+ // Every file in a code repo, as the map sees it: tracked files plus new files git does not ignore, so a
19
+ // directory someone has just created is asked about before it is committed. null when it is not a git repo.
20
+ export function repoFiles(repoRoot) {
21
+ // Not `run`: it trims stdout, and ` notes/a.md` sorts first, so its leading space would be lost. And a
22
+ // large repo's list is many megabytes; spawnSync's default 1 MiB buffer would fail it as "not a git repo".
23
+ const r = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: repoRoot, encoding: 'utf8', maxBuffer: 1 << 30 });
24
+ if (r.status !== 0) return null;
25
+ return [...new Set(r.stdout.split('\0').filter(Boolean))];
26
+ }
27
+
28
+ export function readRiskMap(repoRoot) {
29
+ const file = path.join(repoRoot, RISK_MAP_FILE);
30
+ try {
31
+ return fs.readFileSync(file, 'utf8');
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ // The repos to act on: { list } or { error, hint }. A name from `.sdlc/repos.json` first; else a path to a
38
+ // code repo; with neither, every connected repo — or the directory itself, but ONLY when there is no
39
+ // registry file at all (run inside a code repo). A registry that is empty or does not parse is a Product
40
+ // with no repos to name, and `draft` must never fall back to writing a map into the Product itself.
41
+ // Each entry carries the registry's `platform` when it has one (`yad codeowners check` reads it).
42
+ export function targets(root, name) {
43
+ const regFile = path.join(root, PROJECT_FILES.reposRegistry);
44
+ const hasRegistry = fs.existsSync(regFile);
45
+ let registry = { repos: [] };
46
+ if (hasRegistry) {
47
+ try {
48
+ registry = readJSONStrict(regFile, { repos: [] });
49
+ } catch {
50
+ return { error: `${PROJECT_FILES.reposRegistry} does not parse`, hint: 'fix the JSON (`yad doctor`), or name the path to a code repo' };
51
+ }
52
+ }
53
+ const repos = Array.isArray(registry?.repos) ? registry.repos.filter((r) => r && typeof r.name === 'string' && typeof r.path === 'string' && r.path) : [];
54
+ if (name) {
55
+ const hit = repos.find((r) => r.name === name);
56
+ if (hit) return { list: [{ name: hit.name, root: path.resolve(root, hit.path), platform: hit.platform || null }] };
57
+ const p = path.resolve(root, name);
58
+ if (fs.existsSync(p) && fs.statSync(p).isDirectory()) return { list: [{ name, root: p }] };
59
+ return { error: `unknown repo: ${name}`, hint: 'name a repo from .sdlc/repos.json (`yad repo list`) or give the path to a code repo' };
60
+ }
61
+ if (repos.length) return { list: repos.map((r) => ({ name: r.name, root: path.resolve(root, r.path), platform: r.platform || null })) };
62
+ if (hasRegistry) return { error: `no repos in ${PROJECT_FILES.reposRegistry}`, hint: 'connect one (`yad setup`), or name the path to a code repo' };
63
+ return { list: [{ name: path.basename(path.resolve(root)), root: path.resolve(root) }] };
64
+ }
65
+
66
+ // E66 — the level the change on HEAD takes from the map on `baseRef` (the twin of
67
+ // `checks/risk-map-check.sh --level`). The map is read from the BASE, never the working tree, so a change
68
+ // cannot lower its own count; the files are every path changed since HEAD left the base (three dots),
69
+ // deletions and moves included. Returns { base, files, level, step, dirs } from `changeLevel`, or
70
+ // { base, noMap: why } when the base holds no map (nothing adds a step), or { base, unknown: why } when
71
+ // the level cannot be read — which a caller must say, never read as zero.
72
+ export function baseChangeLevel(repoRoot, baseRef) {
73
+ const git = (args) => spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 1 << 30 });
74
+ const base = baseRef;
75
+ if (git(['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]).status !== 0 || git(['merge-base', baseRef, 'HEAD']).status !== 0) {
76
+ return { base, unknown: `base ref '${baseRef}' not found, or it shares no history with HEAD (a shallow clone?)` };
77
+ }
78
+ // Only a real file is a map: `git show` would print a symlink's target as if it were the text.
79
+ // --full-tree: ls-tree reads a path from the current folder, so from a subfolder it would find no map.
80
+ const entry = git(['ls-tree', '--full-tree', baseRef, '--', RISK_MAP_FILE]).stdout || '';
81
+ if (!entry) return { base, noMap: `'${baseRef}' has no ${RISK_MAP_FILE}` };
82
+ if (!/^100(644|755) blob /.test(entry)) return { base, noMap: `'${baseRef}' holds ${RISK_MAP_FILE}, but not as a file` };
83
+ const text = git(['show', `${baseRef}:${RISK_MAP_FILE}`]);
84
+ const diff = git(['diff', '--name-only', '-z', '--no-renames', `${baseRef}...HEAD`]);
85
+ if (text.status !== 0 || diff.status !== 0) return { base, unknown: `git could not read ${baseRef}` };
86
+ const files = diff.stdout.split('\0').filter(Boolean);
87
+ const parsed = parseRiskMap(text.stdout);
88
+ const got = changeLevel(parsed, files);
89
+ if (got.unsupported) return { base, unknown: `the map on ${baseRef} is written for a newer risk-map version` };
90
+ // `entries` and `changed` are what E67's history query needs next, and reading the map twice could read
91
+ // two different maps (the base can move between the calls).
92
+ return { base, files: files.length, entries: parsed.entries, changed: files, ...got };
93
+ }
94
+
95
+ // The environment for a git call that carries pathspecs. Four variables change how git reads EVERY
96
+ // pathspec: `GIT_LITERAL_PATHSPECS` makes `:(glob)src/*` match nothing (and git still exits 0, so the
97
+ // answer would read as "nobody"), `GIT_ICASE_PATHSPECS` makes `src/` match `SRC/` (the wrong people),
98
+ // and the glob pair changes what `*` means. The pathspecs here say exactly what they mean, so none of
99
+ // the four may reach git.
100
+ export const PATHSPEC_ENV = ['GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS'];
101
+ export function gitEnv() {
102
+ const env = { ...process.env };
103
+ for (const k of PATHSPEC_ENV) delete env[k];
104
+ return env;
105
+ }
106
+
107
+ // Git's own words for E67's window (Part 3: expertise is a fixed, tight 30 days).
108
+ export const HISTORY_WINDOW = '30 days ago';
109
+
110
+ // E67 — the people with recent commits in the `high` directories this change touches, read from the BASE
111
+ // branch's history so a change cannot add its own. ONE QUERY PER DIRECTORY, each carrying the pathspecs
112
+ // that describe that directory as the map sees it (`pathspecsFor`), so git applies the cover rule and
113
+ // returns author records only — no file names are read back, which is what let an odd path name the
114
+ // wrong person. `window` is handed to git as written (`--since`), and git filters on the COMMITTER date,
115
+ // so a rebased or squashed commit counts from when it landed.
116
+ // Returns { authors: [{ name, login }] } — the people in map order of the directory they worked in,
117
+ // newest first within each, nobody twice — or { unknown: why }, never an empty list for a history it
118
+ // could not read: a shallow clone holds only the newest commits, and reading that as "nobody has worked
119
+ // here" would drop the ask instead of raising it. The twin is `--level` in checks/risk-map-check.sh.
120
+ export function recentAuthorsFor(repoRoot, baseRef, { entries, changed, window = HISTORY_WINDOW } = {}) {
121
+ const h = historyReader(repoRoot, baseRef);
122
+ if (h.unknown) return h;
123
+ const commits = [];
124
+ for (const e of highTouched(entries, changed)) {
125
+ const got = h.authorRecords(pathspecsFor(entries, e.dir), window);
126
+ if (!got) return { unknown: `git could not read the history of '${e.dir}' on '${baseRef}'` };
127
+ commits.push(...got);
128
+ }
129
+ return { authors: recentAuthors(commits, h.own) };
130
+ }
131
+
132
+ // The one git reader behind E67's ask and E68's suggestion. Returns { unknown: why } for a history it
133
+ // cannot read — a shallow clone holds only the newest commits, and reading that as "nobody has worked
134
+ // here" would be a guess — or { own, authorRecords }: the addresses of the change's own authors (left out
135
+ // of every answer: an approval, and a suggestion, have to be someone else), and a function that runs ONE
136
+ // log on the base branch for a set of pathspecs and returns its author records, or null when git fails.
137
+ // `what` names what a shallow clone lacks, for the sentence (E69 reads a whole branch, not directories).
138
+ function historyReader(repoRoot, baseRef, { what = 'those directories' } = {}) {
139
+ const git = (args) => spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 1 << 30, env: gitEnv() });
140
+ if (/true/.test(git(['rev-parse', '--is-shallow-repository']).stdout || '')) {
141
+ return { unknown: `this is a shallow clone — it does not hold the history of ${what}` };
142
+ }
143
+ const own = git(['log', `${baseRef}..HEAD`, '--no-merges', '--format=%ae']);
144
+ if (own.status !== 0) return { unknown: `git could not read the history of '${baseRef}'` };
145
+ const authorRecords = (pathspecs, window) => {
146
+ // --no-merges: a merge commit is nobody's work here. --no-renames: a file moved OUT of the directory
147
+ // is work in it, counted where it was (E66). --full-history: without it git simplifies a
148
+ // path-filtered log and hides a side branch whose merge kept the other side.
149
+ // --no-show-signature: `log.showSignature=true` (common where commits are signed) prints a signature
150
+ // check for each commit into this output, and each such line would read as a person.
151
+ // --no-follow: `log.follow=true` makes git crash on a single `:(glob)dir/*` pathspec (E68's kind; E67's
152
+ // `:(literal)` ones are unaffected), which would read every one-folder suggestion as "not read".
153
+ const r = git(['log', baseRef, '--no-merges', '--no-renames', '--full-history', '--no-show-signature', '--no-follow', `--since=${window}`,
154
+ '--format=%an%x1f%ae', '--', ...pathspecs]);
155
+ if (r.status !== 0) return null;
156
+ return r.stdout.split('\n').filter(Boolean).map((line) => {
157
+ const [name, email] = line.split('\x1f');
158
+ return { name, email };
159
+ });
160
+ };
161
+ return { own: own.stdout.split('\n').filter(Boolean), authorRecords };
162
+ }
163
+
164
+ // E68 — the files this change touches, deletions and moves included (E66's range: three dots, from where
165
+ // the branch left its base, `--no-renames`, NUL-separated so an odd name is never quoted). Read WITHOUT the
166
+ // risk map, because most repos have none and a suggestion does not need one. { files } or { unknown }.
167
+ export function changedSince(repoRoot, baseRef) {
168
+ const git = (args) => spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 1 << 30 });
169
+ if (git(['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]).status !== 0 || git(['merge-base', baseRef, 'HEAD']).status !== 0) {
170
+ return { unknown: `base ref '${baseRef}' not found, or it shares no history with HEAD (a shallow clone?)` };
171
+ }
172
+ const diff = git(['diff', '--name-only', '-z', '--no-renames', `${baseRef}...HEAD`]);
173
+ if (diff.status !== 0) return { unknown: `git could not read the change against ${baseRef}` };
174
+ return { files: diff.stdout.split('\0').filter(Boolean) };
175
+ }
176
+
177
+ // How many folders one suggestion asks git about. Every folder goes into ONE log, so the cost does not
178
+ // grow with the count; the cap only keeps the command line short. A cut is always printed.
179
+ export const SUGGEST_FOLDER_CAP = 200;
180
+
181
+ // E68 — who has committed in the folders this change touches, in the window: a SUGGESTION of who may know
182
+ // the code, never an ask, never a request. The files DIRECTLY in each touched folder (`folderPathspec`),
183
+ // all in one log, so a commit touching two of them is counted once. Returns { authors: [{ name, login,
184
+ // commits }], folders, skipped } or { unknown: why } — never an empty list for a history it could not read.
185
+ export function suggestedAuthorsFor(repoRoot, baseRef, { changed, window = HISTORY_WINDOW, cap = SUGGEST_FOLDER_CAP } = {}) {
186
+ const all = touchedFolders(changed);
187
+ const folders = all.slice(0, cap);
188
+ if (!folders.length) return { authors: [], folders: 0, skipped: 0 };
189
+ const h = historyReader(repoRoot, baseRef);
190
+ if (h.unknown) return h;
191
+ const got = h.authorRecords(folders.map(folderPathspec), window);
192
+ if (!got) return { unknown: `git could not read the history of '${baseRef}'` };
193
+ return { authors: rankAuthors(got, h.own), folders: folders.length, skipped: all.length - folders.length };
194
+ }
195
+
196
+ // E69's window for the "owner seems inactive" hint: longer than E67's 30 days, so a person on leave or
197
+ // busy in another repo for a month is not named.
198
+ export const OWNER_WINDOW = '90 days ago';
199
+
200
+ // E69 — the platform logins that committed on `ref` in the window, read from noreply commit addresses:
201
+ // [{ login, host }], host 'github' or 'gitlab'. The whole history of `ref` (no pathspec — work anywhere in
202
+ // the repo counts), through the one reader E67 and E68 share, so the shallow-clone refusal and the robot
203
+ // rule are the same code. With `ref` HEAD there is no change of one's own to leave out. { unknown: why }
204
+ // for a history it cannot read, never an empty list.
205
+ export function recentLoginsFor(repoRoot, ref = 'HEAD', { window = OWNER_WINDOW } = {}) {
206
+ const h = historyReader(repoRoot, ref, { what: 'this branch' });
207
+ if (h.unknown) return h;
208
+ const got = h.authorRecords([], window);
209
+ if (!got) return { unknown: `git could not read the history of '${ref}'` };
210
+ return { logins: rankAuthors(got).filter((a) => a.login).map((a) => ({ login: a.login, host: a.loginHost })) };
211
+ }
212
+
213
+ export function checkRepo(repoRoot) {
214
+ const text = readRiskMap(repoRoot);
215
+ // No map: nothing to check, so the repo's file list (slow on a large repo) is never read.
216
+ if (text === null) return { git: run('git', ['rev-parse', '--is-inside-work-tree'], { cwd: repoRoot }).ok, map: false, findings: [] };
217
+ const files = repoFiles(repoRoot);
218
+ if (!files) return { git: false, map: true, findings: [] };
219
+ const parsed = parseRiskMap(text);
220
+ return { git: true, map: true, entries: parsed.entries, findings: riskMapFindings(parsed, { files }) };
221
+ }
222
+
223
+ const findingLine = (f) => `${c.bold(f.code)}${f.target ? ` ${f.target}` : ''}: ${f.message}`;
224
+
225
+ export async function runRiskMap(root, { action = 'check', name, json = false, dryRun = false } = {}) {
226
+ const t = targets(root, name);
227
+ if (t.error) {
228
+ if (json) emitJSON({ ok: false, error: t.error, hint: t.hint });
229
+ else { fail(t.error); hand(t.hint); }
230
+ process.exitCode = 1;
231
+ return { ok: false };
232
+ }
233
+ const { list } = t;
234
+
235
+ if (action === 'check') {
236
+ const repos = list.map((t) => ({ name: t.name, root: t.root, ...checkRepo(t.root) }));
237
+ if (json) {
238
+ emitJSON({ ok: true, repos: repos.map((r) => ({ name: r.name, git: r.git, map: r.map, findings: r.findings })) });
239
+ return { ok: true, repos };
240
+ }
241
+ for (const r of repos) {
242
+ log(c.bold(`\nrisk map — ${r.name}`));
243
+ if (!r.git) { warn(`${r.root} is not a git repo — skipped`); continue; }
244
+ if (!r.map) { info(`no ${RISK_MAP_FILE} yet — no directory has a risk level`); hand(`draft one with \`yad risk-map draft ${r.name}\``); continue; }
245
+ if (!r.findings.length) { ok('every directory has a confirmed level'); continue; }
246
+ for (const f of r.findings) warn(findingLine(f));
247
+ hand(`fix ${RISK_MAP_FILE} in ${r.name} and commit it through a PR — the warnings are advisory and block nothing`);
248
+ }
249
+ return { ok: true, repos };
250
+ }
251
+
252
+ if (action === 'draft') {
253
+ const results = [];
254
+ for (const t of list) {
255
+ const files = repoFiles(t.root);
256
+ if (!files) { results.push({ name: t.name, git: false, added: [], written: false }); continue; }
257
+ const before = readRiskMap(t.root);
258
+ const d = draftRiskMap(before, files);
259
+ const changed = !d.refused && d.text !== before;
260
+ if (changed && !dryRun) {
261
+ fs.mkdirSync(path.join(t.root, '.sdlc'), { recursive: true });
262
+ fs.writeFileSync(path.join(t.root, RISK_MAP_FILE), d.text);
263
+ }
264
+ results.push({ name: t.name, git: true, added: d.added, unwritable: d.unwritable, refused: d.refused, written: changed && !dryRun });
265
+ }
266
+ if (json) {
267
+ emitJSON({ ok: !results.some((r) => r.refused), dryRun, repos: results });
268
+ } else {
269
+ for (const r of results) {
270
+ log(c.bold(`\nrisk map — ${r.name}`));
271
+ if (!r.git) { warn('not a git repo — skipped'); continue; }
272
+ if (r.refused) { fail(`${RISK_MAP_FILE} is ${r.refused} — nothing was written over it`); continue; }
273
+ if (r.unwritable.length) warn(`not added — a map line cannot hold these names (a space, \`#\`, \`*\`, \`?\`, \`[\` or \`\\\`): ${r.unwritable.join(' ')}; rename them to give them a level`);
274
+ if (!r.added.length) { ok('every directory already has a line — nothing to add'); continue; }
275
+ (r.written ? ok : info)(`${r.written ? 'added' : 'would add'} ${r.added.length} unset line(s): ${r.added.join(' ')}`);
276
+ }
277
+ if (results.some((r) => r.added.length)) {
278
+ hand('classify the `unset` lines: ask your AI agent to run the risk-map step of yad-connect-repos (it reads the code), or set them by hand');
279
+ hand(`then review every \`guessed\` level, change the right ones to \`confirmed\`, and commit ${RISK_MAP_FILE} in the code repo through a PR`);
280
+ }
281
+ }
282
+ if (results.some((r) => r.refused)) process.exitCode = 1;
283
+ return { ok: !results.some((r) => r.refused), repos: results };
284
+ }
285
+
286
+ fail(`unknown risk-map action: ${action} (check | draft)`);
287
+ process.exitCode = 1;
288
+ return { ok: false };
289
+ }