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,287 @@
1
+ // E19 — `.sdlc/index.json`, the one-file front door: a summary of every work item in the Product, so a
2
+ // reader (an app, CI, an agent, E20's `yad history`) opens ONE file instead of walking `epics/*`.
3
+ //
4
+ // THE RULES, each one the user's decision (2026-09-23; the E19 roadmap row has the reasons):
5
+ // 1. DERIVED, never the system of record. It is rebuilt from `epicIds()` plus each item's
6
+ // `.sdlc/state.json`, `epic.md` frontmatter and `.sdlc/change.json`, and never edited by hand (phase 5's founding rule:
7
+ // rebuildable from git). It is committed, but WRITTEN ON THE DEFAULT BRANCH ONLY: an index every
8
+ // branch rewrites would bring back the merge conflict `cli/ledger.mjs` sharded the ledgers to remove.
9
+ // 2. A SUMMARY per item, not a mirror of every step. An item whose files cannot be read is LISTED,
10
+ // marked `unreadable` with the reason — never dropped (a silently missing item is the quiet
11
+ // under-count E71 warned about), and never allowed to stop the whole file.
12
+ // 3. STALENESS is a hash of the exact bytes the index was built from (`inputs`), the `docs-build.json`
13
+ // precedent — not a HEAD sha, which moves on commits that touch no work item. `yad doctor` checks it.
14
+ // 4. The TITLE is `titleOf` (E111): the `title:` key in `epic.md`, else the item's `change.json`
15
+ // title (only change items have one), else null. `change.json` is read only for that fallback,
16
+ // so one that cannot be read or parsed leaves the title null rather than the item unreadable —
17
+ // `yad doctor` already fails such a file. Its bytes are hashed either way.
18
+ //
19
+ // There is NO timestamp in the file: `writeJSON` skips identical bytes, so an unchanged Product never
20
+ // dirties git, and the hash is the version.
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { createHash } from 'node:crypto';
24
+ import { spawnSync } from 'node:child_process';
25
+ import { isPlainObject, writeJSON, info, warn } from './lib.mjs';
26
+ import { isVerifiedLedger } from './manifest.mjs';
27
+ import { productGit, resolveDefaultBranch } from './hubcommit.mjs';
28
+ import {
29
+ epicIds, epicRel, epicRoot, unlistedLedgerDirs, parseFrontmatter, lineageFrom, stepStatus, STEP_STATES,
30
+ FOUNDATION_EPIC, FOUNDATION_TITLE, titleOf,
31
+ } from './epic-state.mjs';
32
+
33
+ export const INDEX_FILE = path.join('.sdlc', 'index.json');
34
+ export const indexPath = (root) => path.join(root, INDEX_FILE);
35
+
36
+ // Mixed into the hash, so an engine that builds a DIFFERENT summary from the same inputs reads the old
37
+ // file as behind. Move it whenever `summarize` changes what it writes.
38
+ export const INDEX_FORMAT = 'e111-1';
39
+
40
+ // One input file's bytes, or why there are none. `absent` is a fact, not an error: an epic folder with no
41
+ // `epic.md` (the Foundation, always) is a normal shape.
42
+ //
43
+ // Line endings are made `\n` before anything else: a checkout with `core.autocrlf` (the Windows default)
44
+ // holds CRLF where git stores LF, and the same Product must hash the same on every machine — and parse
45
+ // the same, since the frontmatter reader expects `\n`. Nothing else in the bytes is touched.
46
+ function readInput(file) {
47
+ try {
48
+ const raw = fs.readFileSync(file);
49
+ return { bytes: raw.includes(13) ? Buffer.from(raw.toString('latin1').replace(/\r\n/g, '\n'), 'latin1') : raw };
50
+ } catch (e) {
51
+ return e.code === 'ENOENT' ? { absent: true } : { error: e.code || e.message };
52
+ }
53
+ }
54
+
55
+ // The hash of everything the index reads, length-prefixed so two inputs can never run together
56
+ // (`a` + `bc` never collides with `ab` + `c`), and naming each input's place so a file moved from one
57
+ // epic to another reads as a change. Every name is JSON-quoted, so no folder name can imitate a separator.
58
+ // `format` is the summary's own version: exported so a test can show it is in the hash.
59
+ export function indexHash(parts, format = INDEX_FORMAT) {
60
+ const h = createHash('sha256');
61
+ h.update(`format:${JSON.stringify(format)}\n`);
62
+ for (const { name, input } of parts) {
63
+ if (input.bytes) {
64
+ h.update(`${JSON.stringify(name)}:${input.bytes.length}\n`);
65
+ h.update(input.bytes);
66
+ } else h.update(`${JSON.stringify(name)}:${input.absent ? 'absent' : `unreadable ${JSON.stringify(input.error)}`}\n`);
67
+ }
68
+ return `sha256:${h.digest('hex')}`;
69
+ }
70
+
71
+ // The state a work item's summary is built from, as { state }, or why there is none, as { why }.
72
+ function readState(input) {
73
+ if (input.absent) return { why: 'it has no .sdlc/state.json' };
74
+ if (input.error) return { why: `.sdlc/state.json could not be read (${input.error})` };
75
+ let state;
76
+ try {
77
+ state = JSON.parse(input.bytes.toString('utf8'));
78
+ } catch {
79
+ return { why: '.sdlc/state.json does not parse' };
80
+ }
81
+ if (!isPlainObject(state)) return { why: '.sdlc/state.json is not an object' };
82
+ if (!Array.isArray(state.steps)) return { why: '.sdlc/state.json has no list of steps' };
83
+ return { state };
84
+ }
85
+
86
+ // A change item's intake record, read for its title only (rule 4). `titleOf` takes any JSON value.
87
+ function readChange(input) {
88
+ if (!input.bytes) return null;
89
+ try {
90
+ return JSON.parse(input.bytes.toString('utf8'));
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ const text = (v) => (typeof v === 'string' && v ? v : null);
97
+ const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x) : typeof v === 'string' && v ? [v] : []);
98
+
99
+ // The summary of one work item. Every value is what the files SAY — nothing is parsed further than the
100
+ // field it is (a date is carried as written: E71 met `2026-9-4`, and nothing here needs a date's value).
101
+ function summarize(id, state, fm, change) {
102
+ const counts = Object.fromEntries(STEP_STATES.map((s) => [s.id, 0]));
103
+ let unknown = 0;
104
+ let lastClosed = null;
105
+ for (const step of state.steps) {
106
+ // `stepStatus`, never the raw field: `blocked` with no record reads `todo` (E38), and a state this
107
+ // release does not know is counted as unknown rather than guessed.
108
+ const st = stepStatus(step);
109
+ if (st) counts[st] += 1; else unknown += 1;
110
+ // The LAST closed step in the chain's own order, not the newest date: the order is a fact, and a
111
+ // date written by hand may not sort.
112
+ if (isPlainObject(step) && isPlainObject(step.closed)) {
113
+ lastClosed = { step: text(step.id), date: text(step.closed.date), by: text(step.closed.by) };
114
+ }
115
+ }
116
+ // The Foundation has no epic.md and no work-item type (E75), so it carries none rather than the
117
+ // `feature` a missing type would otherwise default to.
118
+ const product = id === FOUNDATION_EPIC;
119
+ const lineage = product ? null : lineageFrom(fm);
120
+ return {
121
+ id,
122
+ dir: epicRel(id),
123
+ title: product ? FOUNDATION_TITLE : titleOf(fm, change),
124
+ kind: text(state.kind),
125
+ type: lineage ? lineage.type : null,
126
+ theme: lineage ? lineage.theme : null,
127
+ parent: lineage ? lineage.parent : null,
128
+ thread: lineage ? lineage.thread : null,
129
+ profile: text(state.profile),
130
+ currentStep: text(state.currentStep),
131
+ createdAt: text(state.createdAt),
132
+ repos: product ? [] : list(fm.repos),
133
+ steps: { ...counts, ...(unknown ? { unknown } : {}) },
134
+ lastClosed,
135
+ };
136
+ }
137
+
138
+ // Build the index from the Product on disk: { index, inputs }. Never throws for a work item — each one
139
+ // that cannot be read is listed as unreadable. Throws only when the `epics/` folder itself cannot be
140
+ // listed, because then there is no honest list to write at all. `format`: for a test that builds the
141
+ // index an older engine wrote.
142
+ export function buildIndex(root, { format = INDEX_FORMAT } = {}) {
143
+ const ids = epicIds(root);
144
+ const unlisted = unlistedLedgerDirs(root, ids).map((e) => `epics/${e}`);
145
+ const parts = [{ name: JSON.stringify({ ids, unlisted }), input: { absent: true } }];
146
+ const items = [];
147
+ for (const id of ids) {
148
+ const dir = epicRoot(root, id);
149
+ const stateIn = readInput(path.join(dir, '.sdlc', 'state.json'));
150
+ const epicIn = id === FOUNDATION_EPIC ? { absent: true } : readInput(path.join(dir, 'epic.md'));
151
+ const changeIn = id === FOUNDATION_EPIC ? { absent: true } : readInput(path.join(dir, '.sdlc', 'change.json'));
152
+ parts.push(
153
+ { name: `${id}/state.json`, input: stateIn },
154
+ { name: `${id}/epic.md`, input: epicIn },
155
+ { name: `${id}/change.json`, input: changeIn },
156
+ );
157
+ const read = readState(stateIn);
158
+ if (read.why) { items.push({ id, dir: epicRel(id), unreadable: true, why: read.why }); continue; }
159
+ if (epicIn.error) { items.push({ id, dir: epicRel(id), unreadable: true, why: `epic.md could not be read (${epicIn.error})` }); continue; }
160
+ const fm = epicIn.bytes ? parseFrontmatter(epicIn.bytes.toString('utf8')) : {};
161
+ items.push(summarize(id, read.state, fm, readChange(changeIn)));
162
+ }
163
+ const inputs = indexHash(parts, format);
164
+ return { index: { inputs, items, ...(unlisted.length ? { unlisted } : {}) }, inputs };
165
+ }
166
+
167
+ // Write the index. Returns whether the bytes changed. The caller decides WHETHER to write (the branch
168
+ // and ledger rules live with the commands, not here).
169
+ export function writeIndex(root, built = buildIndex(root)) {
170
+ const file = indexPath(root);
171
+ const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
172
+ writeJSON(file, built.index);
173
+ return fs.readFileSync(file, 'utf8') !== before;
174
+ }
175
+
176
+ // How the committed index compares with the Product on disk:
177
+ // { state: 'current' | 'behind' | 'missing' | 'unreadable' | 'none', why? }
178
+ // `none` — there are no work items and no index: nothing to be behind.
179
+ // `prebuilt`: an index the caller already built, so one command does not walk every work item twice.
180
+ export function indexFreshness(root, prebuilt = null) {
181
+ const file = indexPath(root);
182
+ let built = prebuilt;
183
+ if (!built) {
184
+ try {
185
+ built = buildIndex(root);
186
+ } catch (e) {
187
+ return { state: 'unreadable', why: `the epics folder could not be listed (${e.code || e.message})` };
188
+ }
189
+ }
190
+ if (!fs.existsSync(file)) {
191
+ return built.index.items.length || built.index.unlisted ? { state: 'missing' } : { state: 'none' };
192
+ }
193
+ let onDisk;
194
+ try {
195
+ onDisk = JSON.parse(fs.readFileSync(file, 'utf8'));
196
+ } catch {
197
+ return { state: 'unreadable', why: '.sdlc/index.json does not parse' };
198
+ }
199
+ if (!isPlainObject(onDisk) || typeof onDisk.inputs !== 'string') {
200
+ return { state: 'unreadable', why: '.sdlc/index.json records no input hash' };
201
+ }
202
+ if (onDisk.inputs !== built.inputs) return { state: 'behind' };
203
+ // The same inputs, and yet a summary that is not what they build: a hand edit. Behind, because
204
+ // `yad index` puts it right.
205
+ const content = { ...onDisk };
206
+ delete content.schemaVersion;
207
+ return JSON.stringify(content) === JSON.stringify(built.index) ? { state: 'current' } : { state: 'behind' };
208
+ }
209
+
210
+ // ---- may the index ride this commit? ------------------------------------------------------------
211
+ // The index hashes files ON DISK, so it may be committed only when every one of them is exactly what the
212
+ // commit will hold — otherwise it describes work the commit does not carry, and every clean checkout
213
+ // after it reads "behind". Returns the paths that are not (sorted), [] when all are, or null when git
214
+ // cannot say. Run AFTER the caller has staged what it commits. `commits`: the paths a pathspec-limited
215
+ // commit will hold (`gate repair`), or null for a commit of everything staged (`gate ci`).
216
+ //
217
+ // Two questions, because neither alone is enough:
218
+ // 1. What differs from what is staged. Git is spawned directly with `-z`, never through `run()`, which
219
+ // trims its output — and a trim eats the leading space of a porcelain line, turning the first
220
+ // unstaged edit into a staged one (E19 review). `-z` also gives paths unquoted. EVERY path under
221
+ // `epics/` and `foundation/` counts, not only the files the index reads: erring wide only ever
222
+ // leaves the index out, and says so.
223
+ // 2. What exists on disk but not in git at all. `git status` never lists an ignored file or an empty
224
+ // folder, yet `epicIds` lists every folder with a valid name — so a folder holding only an ignored
225
+ // `.DS_Store`, or a work item `.gitignore` excludes, would be indexed and never committed. Every
226
+ // input the index read must be tracked or staged, and every folder it listed must hold a file that is.
227
+ export function uncommittedIndexInputs(root, commits = null) {
228
+ const git = (args) => spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
229
+ const st = git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', 'epics', 'foundation']);
230
+ const ls = git(['ls-files', '-z', '--cached', '--', 'epics', 'foundation']);
231
+ if (st.status !== 0 || ls.status !== 0) return null;
232
+ const held = commits ? new Set(commits) : null;
233
+ const out = new Set();
234
+ const entries = st.stdout.split('\0');
235
+ for (let i = 0; i < entries.length; i++) {
236
+ const e = entries[i];
237
+ if (e.length < 4) continue;
238
+ const x = e[0];
239
+ const y = e[1];
240
+ const p = e.slice(3);
241
+ if (x === 'R' || x === 'C') i += 1; // `-z` puts a rename's source in the next entry
242
+ if (y !== ' ' || (held && x !== ' ' && !held.has(p))) out.add(p);
243
+ }
244
+ const tracked = new Set(ls.stdout.split('\0').filter(Boolean));
245
+ const holds = (prefix) => [...tracked].some((t) => t.startsWith(prefix));
246
+ let ids;
247
+ try {
248
+ ids = epicIds(root);
249
+ } catch {
250
+ return null;
251
+ }
252
+ for (const id of ids) {
253
+ const rel = epicRel(id);
254
+ // The same files `buildIndex` reads, no more and no fewer.
255
+ const read = [`${rel}/.sdlc/state.json`, ...(id === FOUNDATION_EPIC ? [] : [`${rel}/epic.md`, `${rel}/.sdlc/change.json`])];
256
+ for (const f of read) {
257
+ if (fs.existsSync(path.join(root, f)) && !tracked.has(f)) out.add(f);
258
+ }
259
+ if (!holds(`${rel}/`)) out.add(rel);
260
+ }
261
+ for (const e of unlistedLedgerDirs(root, ids)) if (!holds(`epics/${e}/.sdlc/`)) out.add(`epics/${e}`);
262
+ return [...out].sort();
263
+ }
264
+
265
+ // Rebuild the index after a LOCAL write — a gate write, a `yad migrate --apply` — on the default branch
266
+ // only, and never on a verified Product, where CI is the ledger's one writer (it rebuilds the index in
267
+ // `gateCi`'s merge commit instead). A branch never carries a rewrite of the file every other branch rewrites too. No `.git` means
268
+ // the branch cannot be known, so nothing is written: the golden fixture is copied into a folder with no
269
+ // `.git` of its own, and asking git there would read yadflow's own branch (E71 finding c).
270
+ // The index is derived: a failure to rebuild it is said, and never stops the gate write it follows.
271
+ // Returns whether the file changed, so a caller that commits can carry it in the same commit. `quiet`
272
+ // for a caller whose stdout is JSON: the warning still goes to stderr.
273
+ export function refreshIndexAfterWrite(root, hub, { quiet = false } = {}) {
274
+ if (isVerifiedLedger(hub) || !fs.existsSync(path.join(root, '.git'))) return false;
275
+ const git = productGit(root);
276
+ const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
277
+ if (!branch || branch !== resolveDefaultBranch(git, hub)) return false;
278
+ try {
279
+ const changed = writeIndex(root);
280
+ if (changed && !quiet) info(`rebuilt ${INDEX_FILE}`);
281
+ return changed;
282
+ } catch (e) {
283
+ const why = `${INDEX_FILE} was not rebuilt (${e.code || e.message}) — run \`yad index\``;
284
+ if (quiet) process.stderr.write(`${why}\n`); else warn(why);
285
+ return false;
286
+ }
287
+ }