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
package/cli/hook.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // `yad hook ledger-guard` — the harness-side half of the ledger rule (#171).
2
2
  //
3
- // In BRIDGE mode the gate ledger is CI-owned: `templates/checks/ledger-guard.sh` rejects any non-bot
4
- // commit that changes `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or `epics/*/reviews/*.md`.
3
+ // In verified mode the gate ledger is CI-owned: `templates/checks/ledger-guard.sh` rejects any non-bot
4
+ // commit that changes `epics/*/.sdlc/{state,approvals,comments,product-prs,hub-prs}.json` or `epics/*/reviews/*.md`.
5
5
  // That gate is the authority, but it only speaks at CI time — an agent that hand-edits `state.json`
6
6
  // learns twenty minutes later, from a failed pipeline with nothing connecting cause to effect. This
7
7
  // hook says the same thing at the moment of the edit, and names the command that owns the transition.
@@ -10,69 +10,162 @@
10
10
  // stdin a harness tool-call payload as JSON (optional; `--path <p>` works instead)
11
11
  // exit 0 allow
12
12
  // exit 2 deny — the reason is on stderr, for the agent to read
13
- // Claude Code's PreToolUse protocol is exactly that (exit 2 blocks the call and feeds stderr back to
14
- // the model), so `hooks/ledger-guard.sh` wires it with no adapter logic; another harness needs only
15
- // the same two exit codes.
13
+ // Claude Code's `PreToolUse` protocol is exactly that — exit 2 blocks the call and feeds stderr back
14
+ // to the model — so `hooks/ledger-guard.sh` wires it with no adapter logic.
16
15
  //
17
- // FAIL-OPEN, deliberately. No hub, unreadable config, an unparseable payload, no git — every one of
16
+ // Cursor's `preToolUse` is NOT that. It is a permission hook: it wants a JSON verdict on stdout and
17
+ // treats an empty or off-schema answer as a refusal, so the contract above would block every write.
18
+ // `--format cursor` below answers in that protocol instead, and `hooks/ledger-guard-cursor.sh` is
19
+ // what Cursor's entry points at. Check which kind a harness is before wiring a new one.
20
+ //
21
+ // FAIL-OPEN, deliberately. No Product, unreadable config, an unparseable payload, no git — every one of
18
22
  // those ALLOWS, with a note on stderr. This is a local guardrail, and one that failed closed would
19
23
  // brick an agent's ability to edit anything the moment a config went sideways. The asymmetry is the
20
24
  // design: `ledger-guard` in CI fails closed and is what actually protects the ledger.
21
25
  import fs from 'node:fs';
22
26
  import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
23
28
  import { note, readJSON, run } from './lib.mjs';
24
- import { PROJECT_FILES, isBridgeHub } from './manifest.mjs';
29
+ import { isVerifiedLedger , productConfigPath, HOOK_PROJECT_DIR_ENVS } from './manifest.mjs';
30
+ import { DISCOVERY_EPIC, FOUNDATION_DIR, FOUNDATION_EPIC } from './epic-state.mjs';
25
31
 
26
32
  // The CI-owned files, exactly as `templates/checks/ledger-guard.sh` lists them. NOT `contract-lock.json`
27
33
  // (artifact-side: the architect commits it with the architecture) and NOT `change.json` — both are a
28
34
  // human's to write, and both are exempt in the gate too.
29
- const LEDGER_FILES = new Set(['state.json', 'approvals.json', 'comments.json', 'hub-prs.json']);
35
+ // Both names of the PR ledger: it was renamed `hub-prs.json` -> `product-prs.json` and the engine
36
+ // writes both for one major, so refusing only one would leave the other hand-editable.
37
+ const LEDGER_FILES = new Set(['state.json', 'approvals.json', 'comments.json', 'product-prs.json', 'hub-prs.json']);
30
38
 
31
39
  // `epics/<epic>/.sdlc/<ledger>.json` or `epics/<epic>/reviews/<name>.md` → { epic, rel }; else null.
32
- // Takes a hub-relative POSIX path.
40
+ // Takes a Product-relative POSIX path.
33
41
  //
34
42
  // Depth is matched the way the CI gate matches it, not more strictly. Its arms are bash `case`
35
43
  // globs — `epics/*/.sdlc/state.json` — and a bash glob's `*` spans `/`, so the gate ALSO rejects
36
44
  // `epics/EP-a/nested/.sdlc/state.json`. Requiring exactly four segments here would have let a path
37
45
  // through locally that CI blocks, in a guard whose whole claim is that its scope is the gate's.
38
46
  // The slug is the second segment either way (the gate's `${f#epics/}` / `${_slug%%/*}`).
47
+ //
48
+ // The Foundation (E75) is the same rule in its own folder: `foundation/.sdlc/<ledger>.json` and
49
+ // `foundation/reviews/*.md`, under the fixed id `EP-foundation`. The gate's arms for it are globs of the
50
+ // same depth-blind kind, so the same depth-blind match is used here.
51
+ //
52
+ // The Product index (E19) is the one protected path outside an epic: `.sdlc/index.json`, exactly, and
53
+ // nothing else at the Product root (product.json, hub.json, repos.json are a person's to edit). It belongs
54
+ // to no epic and is never a seed, so it is returned with `epic: null` and no carve-out applies.
55
+ export const INDEX_REL = '.sdlc/index.json';
39
56
  export function protectedLedgerPath(rel) {
57
+ if (rel === INDEX_REL) return { epic: null, rel, kind: 'index' };
58
+ const ledgers = [...LEDGER_FILES].map((f) => f.replace('.', '\\.')).join('|');
59
+ const classify = (epic) => {
60
+ if (new RegExp(`/\\.sdlc/(?:${ledgers})$`).test(rel)) return { epic, rel, kind: 'state' };
61
+ if (/\/reviews\/.*\.md$/.test(rel)) return { epic, rel, kind: 'review' };
62
+ return null;
63
+ };
64
+ if (rel.startsWith(`${FOUNDATION_DIR}/`)) return classify(FOUNDATION_EPIC);
40
65
  if (!rel.startsWith('epics/')) return null;
41
66
  const epic = rel.slice('epics/'.length).split('/')[0];
42
67
  if (!epic || epic === '.' || epic === '..') return null;
43
- const ledgers = [...LEDGER_FILES].map((f) => f.replace('.', '\\.')).join('|');
44
- if (new RegExp(`/\\.sdlc/(?:${ledgers})$`).test(rel)) return { epic, rel, kind: 'state' };
45
- if (/\/reviews\/.*\.md$/.test(rel)) return { epic, rel, kind: 'review' };
46
- return null;
68
+ return classify(epic);
69
+ }
70
+
71
+ // Every path a tool call would write.
72
+ //
73
+ // The named keys are Claude Code's, and they are checked first because they are documented and exact:
74
+ // `file_path` (Edit/Write), `notebook_path` (NotebookEdit), `path`, and a `MultiEdit`-style `edits[]`
75
+ // array of `file_path`.
76
+ //
77
+ // THE SHAPE RULE EXISTS BECAUSE THE SECOND HARNESS DOES NOT DOCUMENT ITS KEYS (E11). Cursor publishes
78
+ // the payload envelope — `tool_name`, `tool_input`, `cwd` — but not what `tool_input` is called
79
+ // inside for Write, Edit or Delete. Inventing a name from memory would produce the worst outcome this
80
+ // hook has: an entry wired, reported healthy by `yad doctor`, reading a key that does not exist,
81
+ // finding no path, and allowing every ledger edit. So instead of guessing a vendor's spelling, any
82
+ // key whose NAME ends in `path`, `file`, `paths` or `files` (in snake_case or camelCase) is read as
83
+ // one — a structural rule, which `target_file`, `filePath` and `file_path` all satisfy without this
84
+ // code having to know which harness sent them.
85
+ //
86
+ // It matches on the KEY, never on the value. A rule that scanned `tool_input`'s values for anything
87
+ // shaped like `epics/…/state.json` would refuse an ordinary edit to a document that merely QUOTES a
88
+ // ledger path — and a false deny is far worse here than a miss, because the whole design fails open
89
+ // on purpose and CI is what fails closed.
90
+ //
91
+ // An unrecognised payload still yields nothing, which allows — see the fail-open note above.
92
+ // A key is path-shaped when one of its WORDS is. Splitting on `_` and on camelCase humps is what
93
+ // makes `target_file`, `filePath`, `notebook_path` and `dest_path` all match while `profile` does not
94
+ // — `profile` is one word, and that word is not in the set. Matching a bare suffix instead would
95
+ // claim it, and a false path is a false DENY, which is the worse error in a guard that fails open by
96
+ // design. `filename`/`filepath` are single words, so they are in the set in their own right.
97
+ const PATH_WORDS = new Set(['file', 'files', 'filename', 'filenames', 'filepath', 'filepaths', 'path', 'paths', 'dest', 'destination']);
98
+
99
+ // `uri` and `url` are deliberately NOT path words — a `url` is usually somewhere to fetch, and
100
+ // claiming one as a file would be a false path, which is a false DENY. But a `file://` URL is
101
+ // unambiguously a local file, and some editors send exactly that. So those keys are read only when
102
+ // the VALUE says it is a file, and the scheme is stripped so the rest of the guard sees a path.
103
+ // `decodeURIComponent` because a URL escapes the spaces and other characters a real path may hold.
104
+ const LOCATOR_WORDS = new Set(['uri', 'url', 'uris', 'urls']);
105
+ const FILE_URL = /^file:\/\//i;
106
+ const fileUrlToPath = (value) => {
107
+ if (!FILE_URL.test(value)) return null;
108
+ try { return fileURLToPath(value); } catch { return null; }
109
+ };
110
+ const keyWords = (key) => String(key).split(/[_\-\s]+|(?=[A-Z])/).map((w) => w.toLowerCase()).filter(Boolean);
111
+ const isPathKey = (key) => keyWords(key).some((w) => PATH_WORDS.has(w));
112
+ const isLocatorKey = (key) => keyWords(key).some((w) => LOCATOR_WORDS.has(w));
113
+
114
+ // Walk the tool input for path-shaped keys, a bounded distance down. Harnesses nest: an `args` or
115
+ // `parameters` wrapper, a `files: [{ path, content }]` batch. A top-level-only scan missed all of it
116
+ // and allowed the edit, which is the failure this rule exists to avoid. The depth cap keeps a
117
+ // pathological payload from costing anything — this runs inside the agent's tool loop, on every call.
118
+ const MAX_PAYLOAD_DEPTH = 4;
119
+
120
+ function pathsFromInput(input, out, depth = 0) {
121
+ if (!input || typeof input !== 'object' || depth > MAX_PAYLOAD_DEPTH) return;
122
+ for (const [key, value] of Object.entries(input)) {
123
+ if (typeof value === 'string') {
124
+ if (!value) continue;
125
+ if (isPathKey(key)) out.push(value);
126
+ else if (isLocatorKey(key)) { const p = fileUrlToPath(value); if (p) out.push(p); }
127
+ continue;
128
+ }
129
+ if (Array.isArray(value)) {
130
+ for (const v of value) {
131
+ // A string in a path-shaped list (`paths`, `files`) is a path; an object in ANY list may hold
132
+ // one under a key of its own, which is the `files: [{ path }]` batch shape.
133
+ if (typeof v === 'string') {
134
+ if (!v) continue;
135
+ if (isPathKey(key)) out.push(v);
136
+ else if (isLocatorKey(key)) { const p = fileUrlToPath(v); if (p) out.push(p); }
137
+ continue;
138
+ }
139
+ pathsFromInput(v, out, depth + 1);
140
+ }
141
+ continue;
142
+ }
143
+ pathsFromInput(value, out, depth + 1);
144
+ }
47
145
  }
48
146
 
49
- // Every path a tool call would write. Covers the shapes harnesses actually send: a single
50
- // `file_path` (Edit/Write), `notebook_path` (NotebookEdit), and a `MultiEdit`-style `edits[]` array.
51
- // An unrecognised payload yields nothing, which allows — see the fail-open note above.
52
147
  export function payloadPaths(payload) {
53
148
  const out = [];
54
149
  const input = payload?.tool_input;
55
150
  if (!input || typeof input !== 'object') return out;
56
- for (const key of ['file_path', 'notebook_path', 'path']) {
57
- if (typeof input[key] === 'string' && input[key]) out.push(input[key]);
58
- }
59
- if (Array.isArray(input.edits)) {
60
- for (const edit of input.edits) if (typeof edit?.file_path === 'string' && edit.file_path) out.push(edit.file_path);
61
- }
151
+ // One walk. Claude's documented `file_path` / `notebook_path` / `path`, and its `edits[]` array,
152
+ // are all path-shaped keys under the rule above, so naming them separately would only be a second
153
+ // list to forget to update.
154
+ pathsFromInput(input, out);
62
155
  return [...new Set(out)];
63
156
  }
64
157
 
65
- // The hub a path belongs to: the nearest ancestor holding `.sdlc/hub.json`.
158
+ // The Product a path belongs to: the nearest ancestor holding `.sdlc/hub.json`.
66
159
  //
67
160
  // Resolved from the PATH, never from the session. The documented layout puts code repos BESIDE the
68
- // hub (`project/{product,backend,mobile}` — see `insideWorkspace` in setup.mjs), so a session opened
161
+ // Product (`project/{product,backend,mobile}` — see `insideWorkspace` in setup.mjs), so a session opened
69
162
  // at the workspace has no `hub.json` under its root, and a session-rooted lookup would find nothing
70
163
  // and silently allow a mutation inside `project/product/epics/…` — the multi-repo, parallel-agent
71
164
  // setup this hook was reported from.
72
165
  export function hubRootFor(abs) {
73
166
  let dir = path.dirname(path.resolve(abs));
74
167
  for (;;) {
75
- if (fs.existsSync(path.join(dir, PROJECT_FILES.hubConfig))) return dir;
168
+ if (fs.existsSync(productConfigPath(dir))) return dir;
76
169
  const parent = path.dirname(dir);
77
170
  if (parent === dir) return null;
78
171
  dir = parent;
@@ -80,9 +173,33 @@ export function hubRootFor(abs) {
80
173
  }
81
174
 
82
175
  // Where a RELATIVE path in the payload is anchored. Only used to make such a path absolute, so the
83
- // hub walk-up above has somewhere to start.
84
- export function baseDirFor(env = process.env, runner = run) {
85
- if (env.CLAUDE_PROJECT_DIR) return env.CLAUDE_PROJECT_DIR;
176
+ // Product walk-up above has somewhere to start.
177
+ //
178
+ // Every harness's project-root variable is tried, in the order the adapters declare them, before
179
+ // anything is guessed (E11).
180
+ //
181
+ // A correction worth keeping, because the obvious reasoning is wrong: Cursor's docs say it also
182
+ // exports `CLAUDE_PROJECT_DIR`, as a deliberate compatibility alias for `CURSOR_PROJECT_DIR`. So
183
+ // "reading only Claude Code's variable" did NOT leave Cursor falling through — both names are set
184
+ // there and point at the same directory, which is also why first-wins ordering is safe rather than
185
+ // merely untested. The real gap this order closes is a harness that sets neither, and the case below
186
+ // it: no variable at all, where the git toplevel used to win over the process cwd.
187
+ //
188
+ // `env` is a parameter precisely so a test can pin each branch without setting real environment
189
+ // variables.
190
+ export function baseDirFor(env = process.env, runner = run, payloadCwd = null) {
191
+ for (const name of HOOK_PROJECT_DIR_ENVS) {
192
+ if (env[name]) return env[name];
193
+ }
194
+ // The harness's own working directory for this tool call, when the payload carries one. It is the
195
+ // directory a relative path in that same payload is relative TO, so it beats any guess.
196
+ if (typeof payloadCwd === 'string' && payloadCwd) return payloadCwd;
197
+ // `process.cwd()` BEFORE the git toplevel, which is the opposite of the old order and fixes a real
198
+ // miss: the documented layout puts the Product in a subdirectory of its repo, so the toplevel is the
199
+ // repo, `hubRootFor` walks up from there, finds no `hub.json`, and ALLOWS a ledger edit it must
200
+ // refuse. Anchoring too deep still tends to resolve into the Product and deny; anchoring too
201
+ // shallow misses entirely, so the shallower guess goes last.
202
+ if (process.cwd()) return process.cwd();
86
203
  const top = runner('git', ['rev-parse', '--show-toplevel']);
87
204
  return top.ok && top.stdout ? top.stdout : process.cwd();
88
205
  }
@@ -97,12 +214,12 @@ const fold = (s) => s.toLowerCase();
97
214
  // and `git fetch` never fast-forwards it, so probing `main` would report an epic whose review PR has
98
215
  // already merged as absent from the base and wave a real mutation straight through. That is the
99
216
  // stale-clone case, and it is the common one, not an edge.
100
- export function resolveHookBase(hubRoot, hub, runner = run) {
217
+ export function resolveHookBase(productRoot, hub, runner = run) {
101
218
  const cfg = hub?.default_branch || '';
102
- const head = runner('git', ['-C', hubRoot, 'symbolic-ref', '--short', '--quiet', 'refs/remotes/origin/HEAD']);
219
+ const head = runner('git', ['-C', productRoot, 'symbolic-ref', '--short', '--quiet', 'refs/remotes/origin/HEAD']);
103
220
  for (const base of [cfg ? `origin/${cfg}` : '', head.ok ? head.stdout : '', 'origin/main']) {
104
221
  if (!base || base === 'origin/') continue;
105
- if (runner('git', ['-C', hubRoot, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`]).ok) return base;
222
+ if (runner('git', ['-C', productRoot, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`]).ok) return base;
106
223
  }
107
224
  return null;
108
225
  }
@@ -113,11 +230,11 @@ export function resolveHookBase(hubRoot, hub, runner = run) {
113
230
  // rides the first review PR/MR. Mutating a ledger that is already on the base is what only the bot
114
231
  // may do.
115
232
  //
116
- // Read with `ls-tree` from the hub, never with a `<rev>:<path>` probe: a rev:path spec is always
117
- // resolved from the repository TOP LEVEL and `-C` does not re-anchor it, so a hub sitting in a
233
+ // Read with `ls-tree` from the Product, never with a `<rev>:<path>` probe: a rev:path spec is always
234
+ // resolved from the repository TOP LEVEL and `-C` does not re-anchor it, so a Product sitting in a
118
235
  // subdirectory of its repo (a monorepo, or a workspace that is itself a repo) would miss on every
119
- // probe and the guard would allow everything, silently. `ls-tree` run with `-C hubRoot` takes a
120
- // cwd-relative pathspec and prints cwd-relative paths, so both halves stay hub-relative.
236
+ // probe and the guard would allow everything, silently. `ls-tree` run with `-C productRoot` takes a
237
+ // cwd-relative pathspec and prints cwd-relative paths, so both halves stay Product-relative.
121
238
  //
122
239
  // Slugs are FOLDED because the gate folds them: on a case-insensitive filesystem `epics/ep-x/…` and
123
240
  // `epics/EP-X/…` are the same file, so a byte-exact compare lets a mutation be laundered as a
@@ -126,29 +243,34 @@ export function resolveHookBase(hubRoot, hub, runner = run) {
126
243
  // null means the base could not be read at all — "unknown", which ALLOWS. The working tree cannot
127
244
  // stand in for the base ref: a seed writes `state.json` first, so using that as proof would deny
128
245
  // every remaining file of the same seed.
129
- export function seededSlugs(hubRoot, hub, runner = run) {
130
- const base = resolveHookBase(hubRoot, hub, runner);
246
+ export function seededSlugs(productRoot, hub, runner = run) {
247
+ const base = resolveHookBase(productRoot, hub, runner);
131
248
  if (!base) return null;
132
249
  const tree = runner('git', [
133
- '-C', hubRoot, '-c', 'core.quotePath=false', 'ls-tree', '-r', '--name-only', '-z', base, '--', 'epics',
250
+ '-C', productRoot, '-c', 'core.quotePath=false', 'ls-tree', '-r', '--name-only', '-z', base, '--', 'epics', FOUNDATION_DIR,
134
251
  ]);
135
252
  if (!tree.ok) return null;
136
253
  const slugs = new Set();
137
254
  for (const p of tree.stdout.split('\0')) {
138
255
  const m = /^epics\/([^/]+)\/\.sdlc\/state\.json$/.exec(p);
139
256
  if (m) slugs.add(fold(m[1]));
257
+ if (p === `${FOUNDATION_DIR}/.sdlc/state.json`) slugs.add(fold(FOUNDATION_EPIC)); // E75
140
258
  }
259
+ // One product level: with the old spelling on base, a Foundation ledger replaces a CI-owned one
260
+ // rather than creating something new — the same rule the CI gate applies.
261
+ if (slugs.has(fold(DISCOVERY_EPIC))) slugs.add(fold(FOUNDATION_EPIC));
262
+ else if (slugs.has(fold(FOUNDATION_EPIC))) slugs.add(fold(DISCOVERY_EPIC)); // the mirror
141
263
  return slugs;
142
264
  }
143
265
 
144
266
 
145
267
  // What the agent is told when the edit is refused. Names the command that owns each transition —
146
268
  // the whole point of #171 was that the ledger write had no command behind it.
147
- export function denyMessage({ epic, rel, hubRoot }) {
269
+ export function denyMessage({ epic, rel, productRoot }) {
148
270
  return [
149
271
  `[yad] Blocked: ${rel} is CI-owned gate state.`,
150
272
  '',
151
- 'This hub runs in bridge mode, where CI is the sole writer of the gate ledger. The `ledger-guard`',
273
+ 'This Product runs in verified mode, where CI is the sole writer of the gate ledger. The `ledger-guard`',
152
274
  'check rejects any non-bot commit that changes it, so this edit cannot reach the default branch —',
153
275
  'it would fail the review PR/MR and have to be reverted.',
154
276
  '',
@@ -160,35 +282,51 @@ export function denyMessage({ epic, rel, hubRoot }) {
160
282
  'Commit the ARTIFACT only (the .md you authored) and hand off to `yad-review-gate`; the ledger',
161
283
  'follows on merge.',
162
284
  '',
163
- `hub: ${hubRoot} · override for one command: YAD_HOOK_DISABLE=1`,
285
+ `hub: ${productRoot} · override for one command: YAD_HOOK_DISABLE=1`,
286
+ ].join('\n');
287
+ }
288
+
289
+ // The index is derived — the hint is to read it, or rebuild it the one way that can be committed.
290
+ export function indexDenyMessage({ rel, productRoot }) {
291
+ return [
292
+ `[yad] Blocked: ${rel} is the Product index — derived from every work item's files, and CI-owned here.`,
293
+ '',
294
+ 'This Product runs in verified mode. CI rebuilds the index in the same commit that records a merged',
295
+ 'review, and the `ledger-guard` check rejects any other commit that changes it.',
296
+ '',
297
+ 'To read it as the files say it is now: yad index --json',
298
+ 'To see whether the committed one is behind: yad doctor',
299
+ '',
300
+ `hub: ${productRoot} · override for one command: YAD_HOOK_DISABLE=1`,
164
301
  ].join('\n');
165
302
  }
166
303
 
167
304
  // The decision, with git injectable so the tests can drive every branch. Returns
168
305
  // `{ allow: true }` or `{ allow: false, message, epic, rel }`.
169
- export function ledgerGuardDecision(paths, { env = process.env, runner = run } = {}) {
306
+ export function ledgerGuardDecision(paths, { env = process.env, runner = run, payloadCwd = null } = {}) {
170
307
  if (env.YAD_HOOK_DISABLE) return { allow: true, skipped: 'YAD_HOOK_DISABLE' };
171
308
  if (!paths.length) return { allow: true };
172
- const base = baseDirFor(env, runner);
173
- // One `ls-tree` per hub, not one per candidate path: a MultiEdit carries many paths and this runs
309
+ const base = baseDirFor(env, runner, payloadCwd);
310
+ // One `ls-tree` per Product, not one per candidate path: a MultiEdit carries many paths and this runs
174
311
  // inside the agent's tool loop.
175
312
  const seededByHub = new Map();
176
313
  for (const candidate of paths) {
177
314
  const abs = path.resolve(base, candidate);
178
- const hubRoot = hubRootFor(abs);
179
- if (!hubRoot) continue;
315
+ const productRoot = hubRootFor(abs);
316
+ if (!productRoot) continue;
180
317
  // Non-strict on purpose: a hub.json that does not parse is a real problem, but refusing every
181
318
  // edit in the repo is not this hook's way of reporting it (`yad doctor` says so properly).
182
- const hub = readJSON(path.join(hubRoot, PROJECT_FILES.hubConfig), null);
183
- if (!isBridgeHub(hub)) continue;
184
- const rel = path.relative(hubRoot, abs).split(path.sep).join('/');
319
+ const hub = readJSON(productConfigPath(productRoot), null);
320
+ if (!isVerifiedLedger(hub)) continue;
321
+ const rel = path.relative(productRoot, abs).split(path.sep).join('/');
185
322
  const hit = protectedLedgerPath(rel);
186
323
  if (!hit) continue;
187
- if (!seededByHub.has(hubRoot)) seededByHub.set(hubRoot, seededSlugs(hubRoot, hub, runner));
188
- const seeded = seededByHub.get(hubRoot);
324
+ if (hit.kind === 'index') return { allow: false, epic: null, rel, message: indexDenyMessage({ rel, productRoot }) };
325
+ if (!seededByHub.has(productRoot)) seededByHub.set(productRoot, seededSlugs(productRoot, hub, runner));
326
+ const seeded = seededByHub.get(productRoot);
189
327
  if (seeded === null) continue; // base unreadable — unknown allows
190
328
  if (!seeded.has(fold(hit.epic))) continue; // creation, not mutation (#162)
191
- return { allow: false, epic: hit.epic, rel, message: denyMessage({ epic: hit.epic, rel, hubRoot }) };
329
+ return { allow: false, epic: hit.epic, rel, message: denyMessage({ epic: hit.epic, rel, productRoot }) };
192
330
  }
193
331
  return { allow: true };
194
332
  }
@@ -207,16 +345,89 @@ function readPayload() {
207
345
  }
208
346
  }
209
347
 
348
+ // HOW A HARNESS IS TOLD THE VERDICT. Two protocols, because the second harness does not accept the
349
+ // first (E11).
350
+ //
351
+ // `exit` is the original and the default: exit 0 allows, exit 2 denies, the reason goes to stderr for
352
+ // the model to read. Claude Code reads exactly that.
353
+ //
354
+ // `cursor` is for a harness whose PERMISSION hooks are answered in JSON on stdout. Cursor's docs are
355
+ // explicit that for a permission hook — and `preToolUse` is one — "invalid JSON or a response that
356
+ // doesn't match the hook's schema blocks the action". Printing nothing, which is what `exit` does on
357
+ // an allow, is invalid JSON. So the exit protocol under Cursor would have blocked EVERY file write in
358
+ // a verified project: fail-CLOSED on everything, the one outcome this guard's whole design forbids.
359
+ //
360
+ // WHAT THE REASON IS FOR, since three decisions below turn on it. A block alone is what CI already
361
+ // delivers, twenty minutes later. The only thing this hook adds is the sentence naming `yad gate
362
+ // open` at the moment of the edit — so a choice that keeps the block but loses the sentence has given
363
+ // up everything the hook was built for, while still passing any test that asks whether a deny denies.
364
+ //
365
+ // 1. THE FIELD NAMES are Cursor's documented permission-hook schema: `permission`, `user_message`,
366
+ // `agent_message`, all snake_case. An off-schema response still BLOCKS, so a camelCase field would
367
+ // refuse the write and discard the sentence, silently.
368
+ // 2. A DENY EXITS 0, not 2. The JSON is the authoritative answer and exit 0 is what tells Cursor to
369
+ // read it; exit 2 blocks too, but is documented as the code for "no JSON to read". The reason also
370
+ // goes to stderr, where Cursor logs it, so it is never only in a channel we cannot confirm.
371
+ // 3. THE ALLOW RESPONSE is a fixed literal with nothing interpolated into it, because a malformed
372
+ // allow is a block. The deny response does carry text, and if a field there were ever rejected as
373
+ // off-schema the response is invalid — which blocks, which is what a deny wanted anyway. Both
374
+ // failure directions are safe, in opposite ways, on purpose.
375
+ export const HOOK_FORMATS = ['exit', 'cursor'];
376
+ const CURSOR_ALLOW = '{"permission":"allow"}';
377
+ // `user_message` is what the person sees in the client, `agent_message` what the model reads. The
378
+ // same text serves both: the person needs to know why their agent stopped, and the reason already
379
+ // names the command rather than just refusing.
380
+ const cursorDeny = (message) => JSON.stringify({ permission: 'deny', user_message: message, agent_message: message });
381
+
210
382
  // The `yad hook ledger-guard` entry point. `paths` (from `--path`) is additive to the payload, so
211
383
  // a harness with no JSON contract can call the guard directly.
212
- export function runLedgerGuardHook({ paths = [] } = {}) {
384
+ // Tool names that cannot write a file, in either harness's vocabulary. A call naming one of these is
385
+ // allowed without being judged.
386
+ //
387
+ // AN ALLOWLIST OF READS, never a check that the tool IS a write, and the asymmetry is the point: a
388
+ // harness this code has never heard of sends a tool name nobody here recognises, and that call must
389
+ // still be judged. Recognising only known writers would wave it straight through.
390
+ //
391
+ // It exists because the write/read distinction otherwise lives entirely in the `matcher` inside the
392
+ // team's settings file — which the merge deliberately refuses to correct, and which both harnesses
393
+ // document `""` and `*` as widening to everything. A team that widens it gets a refusal on every READ
394
+ // of `state.json`, which `yad status` and the review-gate skill do routinely, and which an agent has
395
+ // to do to obey the deny message it was just handed. The code's own stance is that a false deny is
396
+ // far worse than a miss.
397
+ const READ_ONLY_TOOLS = new Set(['read', 'grep', 'glob', 'search', 'list', 'ls', 'webfetch', 'websearch']);
398
+ const isReadOnlyCall = (payload) => {
399
+ const name = payload?.tool_name;
400
+ return typeof name === 'string' && READ_ONLY_TOOLS.has(name.toLowerCase());
401
+ };
402
+
403
+ export function runLedgerGuardHook({ paths = [], format = 'exit' } = {}) {
213
404
  // Only read stdin when there is nothing else to go on. `readFileSync(0)` blocks until EOF, and a
214
405
  // caller that passes `--path` (the documented stdin-free alternative) may well have inherited an
215
406
  // open pipe from a long-lived parent — `isTTY` is false there, so the TTY guard does not trip and
216
407
  // the hook would hang forever. A hang is strictly worse for an agent than a block.
217
408
  const payload = paths.length ? null : readPayload();
218
- const all = [...new Set([...payloadPaths(payload), ...paths])];
219
- const verdict = ledgerGuardDecision(all);
409
+ const all = isReadOnlyCall(payload) ? [...paths] : [...new Set([...payloadPaths(payload), ...paths])];
410
+ const verdict = ledgerGuardDecision(all, {
411
+ // `cwd` is what the harness says this tool call ran in, and it is what a relative path in the same
412
+ // payload is relative to. It was already being parsed and thrown away.
413
+ payloadCwd: typeof payload?.cwd === 'string' ? payload.cwd : null,
414
+ });
415
+ // An unknown format is treated as `exit` rather than refused: this runs inside an agent's tool loop,
416
+ // and a usage error there would be a non-zero exit on every single call. It is SAID on stderr
417
+ // though, because the fallback is not harmless everywhere — a harness that needed `cursor` and got
418
+ // `exit` sees an empty stdout on an allow and blocks the write, with nothing else to explain it.
419
+ if (!HOOK_FORMATS.includes(format)) {
420
+ note(`yad hook: unknown --format '${format}' — using '${HOOK_FORMATS[0]}' (known: ${HOOK_FORMATS.join(', ')})`);
421
+ }
422
+ if (format === 'cursor') {
423
+ if (verdict.allow) {
424
+ process.stdout.write(`${CURSOR_ALLOW}\n`);
425
+ return 0;
426
+ }
427
+ process.stdout.write(`${cursorDeny(verdict.message)}\n`);
428
+ console.error(verdict.message);
429
+ return 0;
430
+ }
220
431
  if (verdict.allow) return 0;
221
432
  console.error(verdict.message);
222
433
  process.exitCode = 2;
package/cli/hubcommit.mjs CHANGED
@@ -1,34 +1,23 @@
1
- // Shared machinery for committing the machine-written back-half ledgers to the hub — used by both
1
+ // Shared machinery for committing the machine-written Build ledgers to the Product — used by both
2
2
  // `yad checkpoint` (sync new state) and `yad tidy up` (fold finished shards). Both must commit ONLY on
3
3
  // the default branch, so their `[skip ci]` commit never enters a PR's base..HEAD range (where it would
4
4
  // strand required checks and fail verified-commits). This module is the single home of that guard.
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
5
  import { warn, fail, hand, run } from './lib.mjs';
8
6
 
9
- export const hubGit = (root) => (...args) => run('git', args, { cwd: root });
10
-
11
- const readFileSafe = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } };
7
+ export const productGit = (root) => (...args) => run('git', args, { cwd: root });
12
8
 
13
9
  // A direct-to-default push (`yad update --push`, `yad repo refresh --push`) will be rejected by the
14
- // yad-update-guard unless the commits are signed AND their author email is allowlisted. Warn up front
15
- // (never block) so the operator isn't surprised by a reddened default branch. Best-effort, hub-identity
16
- // based.
10
+ // yad-update-guard unless the commits are signed. Warn up front (never block) so the operator isn't
11
+ // surprised by a reddened default branch. There is no author allowlist to check since E62: write access
12
+ // decides who can author.
17
13
  export function preflightGuardReadiness(root) {
18
- const gitcfg = (k) => run('git', ['config', '--get', k], { cwd: root }).stdout;
19
14
  // Only commit.gpgsign actually enables signing — user.signingkey merely picks WHICH key once
20
15
  // signing is on, so it must not count (it would hide the warning while commits stay unsigned).
21
16
  const signing = run('git', ['config', '--bool', '--get', 'commit.gpgsign'], { cwd: root }).stdout === 'true';
22
17
  if (!signing) warn('commit signing is not enabled (git config commit.gpgsign true) — the yad-update-guard requires a platform-Verified signature; unsigned pushes will fail the gate.');
23
- const email = gitcfg('user.email').toLowerCase();
24
- const allow = readFileSafe(path.join(root, '.sdlc', 'verified-authors'));
25
- const known = allow.split('\n').map((l) => l.trim().toLowerCase()).filter((l) => l && !l.startsWith('#'));
26
- if (known.length && email && !known.includes(email)) {
27
- warn(`your git email <${email}> is not in .sdlc/verified-authors — the yad-update-guard will reject these commits (add it to the hub roster and re-run \`yad check --fix\`).`);
28
- }
29
18
  }
30
19
 
31
- // The default branch: hub config, else the remote's published default (origin/HEAD), else 'main'.
20
+ // The default branch: Product config, else the remote's published default (origin/HEAD), else 'main'.
32
21
  // NEVER the current branch — falling back to it (as gate.mjs does, safe there because CI checks out the
33
22
  // default branch) would make the guard below a no-op on a WIP branch and let an unsigned commit land in
34
23
  // a future PR's range.
@@ -0,0 +1,87 @@
1
+ // `yad index` — rebuild `.sdlc/index.json`, the Product's one-file front door (E19), on demand.
2
+ //
3
+ // yad index rebuild and write it — the default branch only, on a local Product. There is
4
+ // no override: a branch never carries the index (E19 review — `--allow-branch`
5
+ // was the one way round the user's decision, and `gate repair` already keeps
6
+ // the index off a branch even when it allows one)
7
+ // yad index --json print what it holds, built live from the files; writes nothing, any branch
8
+ //
9
+ // WHO WRITES IT (the user's decision): the default branch only, so a branch never carries a rewrite of a
10
+ // file every other branch rewrites too. On a VERIFIED Product the ledger is CI's alone (rule 9): CI
11
+ // rebuilds the index when it records a merge, and a local write could not be committed past the ledger
12
+ // guard — so this command writes nothing there and says so, the way a local `gate open` does.
13
+ // It never commits: the index rides the commit that carries the change, like the ledger it summarizes.
14
+ import { c, log, ok, info, warn, fail, hand, exists, emitJSON } from './lib.mjs';
15
+ import path from 'node:path';
16
+ import { productConfigPath, isVerifiedLedger, SCHEMA_VERSION } from './manifest.mjs';
17
+ import { loadProduct } from './gate.mjs';
18
+ import { productGit, resolveDefaultBranch } from './hubcommit.mjs';
19
+ import { buildIndex, writeIndex, indexFreshness, INDEX_FILE } from './product-index.mjs';
20
+ import { printable } from './epic-state.mjs';
21
+
22
+ export async function runIndex(root, { json = false } = {}) {
23
+ if (!exists(productConfigPath(root))) {
24
+ fail('no Product here (.sdlc/product.json) — run `yad index` from the Product');
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ let built;
29
+ try {
30
+ built = buildIndex(root);
31
+ } catch (e) {
32
+ fail(`the epics folder could not be listed (${e.code || e.message}), so there is no honest index to build`);
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+ // A read: the index as the files say it is right now, on whatever branch this is. Nothing is written,
37
+ // so no branch or ledger rule applies.
38
+ // The same keys as the file, `schemaVersion` first as `writeJSON` stamps it, inside E1's envelope:
39
+ // this answer IS a file, so it keeps the file's shape number beside the contract's own.
40
+ if (json) return emitJSON({ schemaVersion: SCHEMA_VERSION, ...built.index });
41
+
42
+ log(c.bold('\nyad index'));
43
+ const { hub } = loadProduct(root);
44
+ const items = built.index.items;
45
+ const unreadable = items.filter((i) => i.unreadable);
46
+ const summary = `${items.length} work item${items.length === 1 ? '' : 's'}${unreadable.length ? `, ${unreadable.length} unreadable` : ''}`;
47
+ if (isVerifiedLedger(hub)) {
48
+ const fresh = indexFreshness(root, built);
49
+ info(`this Product's ledger is verified: CI rebuilds ${INDEX_FILE} when it records a merge on the default branch, and a local write could not be committed — nothing written`);
50
+ if (fresh.state === 'current') ok(`${INDEX_FILE} is current (${summary})`);
51
+ else warn(`${INDEX_FILE} is ${fresh.state === 'missing' ? 'not built yet' : fresh.state === 'unreadable' ? `unreadable — ${fresh.why}` : 'behind the work items'}; the next review CI records rebuilds it`);
52
+ reportUnreadable(built.index);
53
+ return;
54
+ }
55
+ if (!exists(path.join(root, '.git'))) {
56
+ fail('not a git repo, so yad cannot tell whether this is the default branch — the index is written there only');
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+ const git = productGit(root);
61
+ const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
62
+ const defaultBranch = resolveDefaultBranch(git, hub);
63
+ if (branch !== defaultBranch) {
64
+ fail(`on '${branch}', not the default branch '${defaultBranch}' — ${INDEX_FILE} is written on the default branch only, so a branch never carries it`);
65
+ hand(`switch to '${defaultBranch}' and re-run (set default_branch in .sdlc/product.json if '${defaultBranch}' is wrong); \`yad index --json\` prints it here without writing`);
66
+ process.exitCode = 1;
67
+ return;
68
+ }
69
+ let changed;
70
+ try {
71
+ changed = writeIndex(root, built);
72
+ } catch (e) {
73
+ fail(`${INDEX_FILE} could not be written (${e.code || e.message})`);
74
+ process.exitCode = 1;
75
+ return;
76
+ }
77
+ if (changed) ok(`wrote ${INDEX_FILE} (${summary}) — commit it with the change it describes`);
78
+ else ok(`${INDEX_FILE} is already current (${summary})`);
79
+ reportUnreadable(built.index);
80
+ }
81
+
82
+ // Every item the index could not read is said, with the reason: it is in the file, and it is on screen.
83
+ function reportUnreadable(index) {
84
+ for (const i of index.items.filter((x) => x.unreadable)) warn(`${printable(i.dir)}: listed as unreadable — ${printable(i.why)}`);
85
+ for (const d of index.unlisted || []) warn(`${printable(d)} holds a ledger that is not read as a work item — listed as unlisted`);
86
+ if (index.items.some((x) => x.unreadable) || index.unlisted) hand('fix or restore the files named above, then run `yad index` again');
87
+ }