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,556 @@
1
+ // E20 — `yad history`: what the Product has done, read from the work items' own files.
2
+ //
3
+ // yad history [list] [--type t] [--theme x] [--thread EP-…] [--open|--done] [--json]
4
+ // yad history show <id> [--json]
5
+ // yad history search <text> [the same filters] [--json]
6
+ //
7
+ // THE RULES, each one the user's decision (2026-09-24):
8
+ // 1. BUILT LIVE, every time, by `buildIndex` — the same builder `.sdlc/index.json` is made from, so the
9
+ // two can never describe an item differently. The saved file is not read: it is behind on every
10
+ // branch and after a skill hand-writes `state.json` (E17b), and `yad doctor` already says so. The
11
+ // saved file stays the front door for apps and CI.
12
+ // 2. `list` shows EVERY work item, open and shape-done, NEWEST FIRST by its created date. A value that
13
+ // is not a real calendar date sorts last — never guessed — and ties sort by id.
14
+ // 3. `show` prints the item's summary and lineage, then every step in chain order with its state and
15
+ // its closing record (E18) — the author steps too, which nothing printed before — and, under each
16
+ // review step, the approvals recorded for it (E64), judged by the rules the gate counts by.
17
+ // 4. `search` matches text, ignoring case, in each item's id, title, theme, type and repos, and in its
18
+ // steps' closing records (who closed, who merged, the PR, the commit). Approvals and the text of an
19
+ // artifact are not searched.
20
+ // 5. "SHAPE DONE", never "finished" (the PR review): an item's `state.json` holds its Shape steps only —
21
+ // Build (the code, the checks, the code review) is tracked elsewhere — so an item whose Shape steps
22
+ // are all closed may have shipped nothing. `--open` / `--done` and `shapeDone` say Shape only.
23
+ // 6. `--json` answers are E1's one format: `{ jsonVersion, version, command, ok, … }`, a refusal adding
24
+ // `error`, `code` and `hint` — and every key of an answer is ALWAYS present (null, false or [] when
25
+ // there is nothing), so a script never has to ask whether a key exists.
26
+ //
27
+ // What it reads: each work item's `state.json`, `epic.md` and `change.json` (the summary), and for `show`
28
+ // and `search` its `state.json` again and its `approvals.json`; `--thread` also reads the `epic.md` of
29
+ // the work items it walks through; `show` reads the Product's settings (`hub.json`/`product.json`) for
30
+ // solo mode and the engagement rule. Nothing is written.
31
+ //
32
+ // A work item whose files cannot be read is never dropped (E19): `list` and `search` name it under every
33
+ // answer, whatever the filters, because a filter cannot know what an unreadable item holds.
34
+ //
35
+ // Everything printed from a file goes through `printable` (control and bidi characters dropped, one
36
+ // line): these values reach other people's terminals, as titles do (E111). `--json` carries each value
37
+ // as the file holds it, except where a field is kept only when it is what it should be (an approver,
38
+ // status or date only when it is text, a PR only when it is a whole number) — never a value re-typed.
39
+ import fs from 'node:fs';
40
+ import path from 'node:path';
41
+ import { c, log, info, warn, fail, hand, exists, isPlainObject, emitJSON } from './lib.mjs';
42
+ import { productConfigPath } from './manifest.mjs';
43
+ import { buildIndex } from './product-index.mjs';
44
+ import {
45
+ epicRoot, isValidEpicId, FOUNDATION_EPIC, stepStatus, acceptedHashes, isStaleHash,
46
+ WORK_ITEM_TYPES, themeKey, threadEpics, resolveThread, printable,
47
+ claimsInherited, claimsSkipped, isSkippableStep, optionalStepsFor,
48
+ } from './epic-state.mjs';
49
+ import { closedLine, prText, requireEngagement, isSolo } from './gate.mjs';
50
+
51
+ // ---- the pure parts ---------------------------------------------------------------------------------
52
+
53
+ // A created date as a sortable number, or null when it is not a real calendar date. `createdAt` is
54
+ // copied as written (E19 never parses it), and E71 met `2026-9-4`, so one-digit months and days are
55
+ // read; a time after the date is ignored. Anything else — a word, `2026-13-40`, `2026-02-31` — is null
56
+ // and sorts last.
57
+ export function createdKey(v) {
58
+ const m = typeof v === 'string' ? v.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:$|[T ])/) : null;
59
+ if (!m) return null;
60
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
61
+ const date = new Date(Date.UTC(y, mo - 1, d));
62
+ if (date.getUTCFullYear() !== y || date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d) return null;
63
+ return y * 10000 + mo * 100 + d;
64
+ }
65
+
66
+ // Newest first; no date last; then by id, so the order is the same on every run.
67
+ export function newestFirst(items) {
68
+ return [...items].sort((a, b) => {
69
+ const ka = createdKey(a.createdAt) ?? -Infinity;
70
+ const kb = createdKey(b.createdAt) ?? -Infinity;
71
+ if (ka !== kb) return kb - ka;
72
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
73
+ });
74
+ }
75
+
76
+ // Are the item's SHAPE steps done (rule 5)? Every step in its `state.json` closed for good: `done`,
77
+ // `skipped` or `satisfied`. A `deferred` step is still owed (E37), so it keeps the item open, as does a
78
+ // step in a state this release does not know. An item with no steps has not started, so it is open.
79
+ // Build is not counted: it is not in this file.
80
+ const SHAPE_DONE_STATES = ['done', 'skipped', 'satisfied'];
81
+ export function isShapeDone(item) {
82
+ const steps = item.steps || {};
83
+ const total = Object.values(steps).reduce((n, v) => n + (Number.isInteger(v) ? v : 0), 0);
84
+ return total > 0 && SHAPE_DONE_STATES.reduce((n, s) => n + (steps[s] || 0), 0) === total;
85
+ }
86
+
87
+ // What is wrong with the filters, or null. Checked before anything is read: a value that can match
88
+ // nothing is a typo, not an answer. A type is compared without case (`Chore` is `chore`).
89
+ export function filterError({ type = null, theme = null, thread = null, open = false, done = false } = {}) {
90
+ if (open && done) return { error: '--open and --done cannot be used together', hint: 'use one, or neither to see every work item' };
91
+ if (type !== null && !WORK_ITEM_TYPES.includes(String(type).toLowerCase())) {
92
+ return { error: `unknown work-item type: ${type}`, hint: `a work-item type is one of ${WORK_ITEM_TYPES.join(' · ')}` };
93
+ }
94
+ if (thread !== null && !isValidEpicId(thread)) return { error: `invalid thread id: ${thread}`, hint: 'a thread is named by a work-item id, EP-<slug>' };
95
+ if (thread === FOUNDATION_EPIC) {
96
+ return { error: `${FOUNDATION_EPIC} is the product level and is in no feature thread`, hint: `\`yad history show ${FOUNDATION_EPIC}\` shows it` };
97
+ }
98
+ if (theme !== null && themeKey(theme) === '') return { error: `--theme ${JSON.stringify(theme)} has no letters or digits to match`, hint: 'a theme is matched by its letters and digits' };
99
+ return null;
100
+ }
101
+
102
+ // The filters as one test: { error, hint } or { keep(item) }. `threadMembers`: the ids in the thread
103
+ // `--thread` names, as `yad thread` finds them — by walking `parent:`, never by the `thread:` key, which
104
+ // is only a cache (a genesis written before the key existed never matched its own thread).
105
+ export function historyFilter(flags = {}, { threadMembers = null } = {}) {
106
+ const bad = filterError(flags);
107
+ if (bad) return bad;
108
+ const { type = null, theme = null, thread = null, open = false, done = false } = flags;
109
+ const wantType = type === null ? null : String(type).toLowerCase();
110
+ // A theme groups by its folded key, as `yad doctor` groups it (E31): `Checkout Revamp` and
111
+ // `checkout-revamp` are one theme typed twice.
112
+ const wantTheme = theme === null ? null : themeKey(theme);
113
+ const members = thread === null ? null : new Set(threadMembers || []);
114
+ return {
115
+ keep: (item) => (wantType === null || item.type === wantType)
116
+ && (wantTheme === null || themeKey(item.theme) === wantTheme)
117
+ && (members === null || members.has(item.id))
118
+ && (!open || !isShapeDone(item))
119
+ && (!done || isShapeDone(item)),
120
+ };
121
+ }
122
+
123
+ // Text compared without case. `NFKC` first, so an accent typed as one character or as two matches
124
+ // either way. Not every language's case rules are followed: `ß` does not match `SS`.
125
+ const fold = (s) => s.normalize('NFKC').toLowerCase();
126
+
127
+ // What `search` matches in one item: [{ field, value, step }], empty when nothing does. `steps` is the
128
+ // item's `state.json` steps, or null when they could not be read (then only the summary is searched).
129
+ // - words (id, title, theme, type, repos, who closed, who merged) match anywhere in the value;
130
+ // - a PR matches as the WHOLE number — `12`, `#12`, `PR 12` or `PR #12` finds PR 12, never 112 —
131
+ // since a part of a number is not a PR anyone means;
132
+ // - a commit matches from its START, and only from 4 characters on: a short commit finds the full one,
133
+ // and a digit or two does not match half the hashes in the Product.
134
+ // Only text is matched, and a PR only as a whole number: `String()` of any other value would match text
135
+ // that is not in the file (`[object Object]`).
136
+ export function searchMatches(item, steps, text) {
137
+ const needle = fold(String(text).trim());
138
+ // `12`, `#12`, `PR 12`, `pr12` and `PR #12` all name PR 12.
139
+ const prWanted = needle.replace(/^(pr\s*#?|#)\s*/, '');
140
+ const prQuery = /^\d+$/.test(prWanted) ? Number(prWanted) : null;
141
+ const hits = [];
142
+ const hit = (field, value, step) => hits.push({ field, value, step: step ?? null });
143
+ const words = (field, value, step) => { if (typeof value === 'string' && fold(value).includes(needle)) hit(field, value, step); };
144
+ words('id', item.id);
145
+ words('title', item.title);
146
+ words('theme', item.theme);
147
+ words('type', item.type);
148
+ for (const r of item.repos || []) words('repo', r);
149
+ for (const s of Array.isArray(steps) ? steps : []) {
150
+ const k = isPlainObject(s) && isPlainObject(s.closed) ? s.closed : null;
151
+ if (!k) continue;
152
+ const id = typeof s.id === 'string' ? s.id : null;
153
+ words('closed.by', k.by, id);
154
+ words('closed.mergedBy', k.mergedBy, id);
155
+ const pr = prText(k.pr);
156
+ if (pr !== null && prQuery !== null && Number(pr) === prQuery) hit('closed.pr', pr, id);
157
+ if (typeof k.commit === 'string' && needle.length >= 4 && fold(k.commit).startsWith(needle)) hit('closed.commit', k.commit, id);
158
+ }
159
+ return hits;
160
+ }
161
+
162
+ // ---- reading ----------------------------------------------------------------------------------------
163
+
164
+ // The live summary, or { error } when the epics folder itself cannot be listed.
165
+ function readItems(root) {
166
+ try {
167
+ const { index } = buildIndex(root);
168
+ return {
169
+ items: index.items.filter((i) => !i.unreadable),
170
+ unreadable: index.items.filter((i) => i.unreadable).map(({ id, dir, why }) => ({ id, dir, why })),
171
+ unlisted: index.unlisted || [],
172
+ };
173
+ } catch (e) {
174
+ return { error: `the epics folder could not be listed (${e.code || e.message})` };
175
+ }
176
+ }
177
+
178
+ // One JSON file: { value }, { missing: true } or { why }. Read here rather than through
179
+ // `readJSONStrict`, which names every failure `YAD-STATE-001`: a view that says why a file could not be
180
+ // read must tell "does not parse" from "is a folder" or "permission denied". Nothing is written, so no
181
+ // shape stamp is needed.
182
+ function readJsonFile(file) {
183
+ let text;
184
+ try {
185
+ text = fs.readFileSync(file, 'utf8');
186
+ } catch (e) {
187
+ if (e.code === 'ENOENT') return { missing: true };
188
+ return { why: e.code === 'EISDIR' ? 'it is a folder' : (e.code || e.message) };
189
+ }
190
+ try {
191
+ return { value: JSON.parse(text) };
192
+ } catch {
193
+ return { why: 'it does not parse' };
194
+ }
195
+ }
196
+
197
+ // One item's `state.json`: { state, steps } or { why }. The summary was built a moment earlier from the
198
+ // same file; if it changed in between (a checkout, a pull), this says so rather than guessing. `state`
199
+ // is the whole file, for the route (`optionalStepsFor`).
200
+ export function readState(root, id) {
201
+ const r = readJsonFile(path.join(epicRoot(root, id), '.sdlc', 'state.json'));
202
+ if (r.missing) return { why: 'it is missing now' };
203
+ if (r.why) return { why: r.why };
204
+ return Array.isArray(r.value?.steps) ? { state: r.value, steps: r.value.steps } : { why: 'it has no list of steps' };
205
+ }
206
+
207
+ // The Product's settings, strictly: { hub } or { why }. A file that does not parse is said, never read
208
+ // as "team mode, no engagement rule" — that would print a confident count the gate itself refuses.
209
+ function readHub(root) {
210
+ const r = readJsonFile(productConfigPath(root));
211
+ if (r.missing) return { hub: null };
212
+ if (r.why) return { why: r.why };
213
+ return isPlainObject(r.value) ? { hub: r.value } : { why: 'it is not an object' };
214
+ }
215
+
216
+ // Why a recorded approval does not count, in the order the gate asks — or null when it counts:
217
+ // 'not-an-approval' (another status, such as `inherited` or `changes-requested`), 'stale', 'unnamed'
218
+ // (it names nobody; the gate counts people), 'unengaged' (engagement is required and not verified).
219
+ function notCountedReason(a, { approved, stale, named, reqEng }) {
220
+ if (!approved) return 'not-an-approval';
221
+ if (stale) return 'stale';
222
+ if (!named) return 'unnamed';
223
+ if (reqEng && a.engagement !== 'verified') return 'unengaged';
224
+ return null;
225
+ }
226
+
227
+ const NOT_JUDGED = { stale: null, counted: null, notCounted: null };
228
+
229
+ // The whole story of one item, for `show`. Throws nothing: every part that cannot be read says why.
230
+ // `hub` / `hubWhy`: the Product's settings, or why they could not be read.
231
+ export function itemHistory(root, item, { hub = null, hubWhy = null } = {}) {
232
+ const epicDir = epicRoot(root, item.id);
233
+ const stateRead = readState(root, item.id);
234
+ const approvalsRead = readJsonFile(path.join(epicDir, '.sdlc', 'approvals.json'));
235
+ const approvals = approvalsRead.missing ? [] : Array.isArray(approvalsRead.value) ? approvalsRead.value : null;
236
+ const approvalsWhy = approvals ? null : approvalsRead.why || 'it is not a list';
237
+ const reqEng = requireEngagement(hub);
238
+ const solo = isSolo(hub);
239
+ // The steps THIS item's route lets be skipped — what `gatePredicate` asks before it honours a skip.
240
+ const optional = stateRead.state ? optionalStepsFor(stateRead.state) : [];
241
+ const out = [];
242
+ for (const s of stateRead.steps || []) {
243
+ // Every entry in the list is a row, so the count matches the index's: one that is not a step object
244
+ // is said to be one, never skipped.
245
+ if (!isPlainObject(s)) {
246
+ out.push({ id: null, type: null, state: null, known: false, notAStep: true, closed: null, record: null, inheritedFrom: null, debt: false, approvals: null, staleUnknown: null });
247
+ continue;
248
+ }
249
+ const review = s.type === 'review+approve';
250
+ const row = {
251
+ id: typeof s.id === 'string' ? s.id : null,
252
+ type: typeof s.type === 'string' ? s.type : null,
253
+ // The canonical state (`blocked` with no record reads `todo`, E38); one this release cannot name
254
+ // is carried as written and flagged, never renamed.
255
+ state: stepStatus(s) || (typeof s.status === 'string' ? s.status : null),
256
+ known: stepStatus(s) !== null,
257
+ notAStep: false,
258
+ closed: isPlainObject(s.closed) ? s.closed : null,
259
+ record: isPlainObject(s.record) ? s.record : null,
260
+ inheritedFrom: typeof s.inheritedFrom === 'string' ? s.inheritedFrom : null,
261
+ debt: s.debt === true,
262
+ approvals: review && approvals ? [] : null,
263
+ staleUnknown: null,
264
+ };
265
+ if (review && approvals) {
266
+ // The rules `gate status` and `gatePredicate` count by. An APPROVED record whose fingerprint is
267
+ // outside the accepted ones is stale (never "not today's exact hash"); one that names nobody is not
268
+ // counted, since the gate counts people; with `requireEngagement` on, one with no verified
269
+ // engagement is not counted. A record of another status is shown, and counts for nothing.
270
+ //
271
+ // `counted` is per RECORD, not a head count: two approvals from one person are both counted, and
272
+ // the gate counts that person once. It is null — no count applies — where the gate counts nothing:
273
+ // in solo mode (approvals are waived, E10), and on a step the gate waives before it reads any
274
+ // approval. That second case is decided by the gate's own test, on what the step CLAIMS — an
275
+ // inherited claim, or a skip this item's route allows (`isSkippableStep`) — never by its final
276
+ // state, as `gate status` decides it: a skip on a step the route requires is not honoured, and its
277
+ // approvals are judged like any other. On a waived step `stale` is null as well — `gatePredicate`
278
+ // never judges it, and an inherited review's content is the parent's, so this item's own
279
+ // fingerprint would give the wrong answer. In solo mode `stale` is still told. When the settings
280
+ // cannot be read, solo mode and the engagement rule are unknown, so `counted` is null too.
281
+ const notJudged = claimsInherited(s) || (claimsSkipped(s) && isSkippableStep(s.id, optional));
282
+ let accepted = null;
283
+ if (!notJudged) {
284
+ try {
285
+ accepted = typeof s.artifact === 'string' ? acceptedHashes(epicDir, s.artifact) : [];
286
+ } catch (e) {
287
+ row.staleUnknown = e.code || e.message;
288
+ }
289
+ }
290
+ row.approvals = approvals.filter((a) => isPlainObject(a) && a.step === s.id).map((a) => {
291
+ const approved = a.status === 'approved';
292
+ const pr = prText(a.pr);
293
+ const fields = {
294
+ approver: typeof a.approver === 'string' ? a.approver : null,
295
+ status: typeof a.status === 'string' ? a.status : null,
296
+ date: typeof a.date === 'string' ? a.date : null,
297
+ // An inherited record names the epic whose review it took over, not a person (E42).
298
+ from: typeof a.from === 'string' ? a.from : null,
299
+ source: typeof a.source === 'string' ? a.source : null,
300
+ pr: pr === null ? null : Number(pr),
301
+ engagement: typeof a.engagement === 'string' ? a.engagement : null,
302
+ };
303
+ if (notJudged) return { ...fields, ...NOT_JUDGED };
304
+ const stale = !approved ? null : accepted === null ? null : isStaleHash(a.artifactHash, accepted);
305
+ if (approved && (stale === null || solo || hubWhy)) return { ...fields, stale, counted: null, notCounted: null };
306
+ // Named by the gate's own test (`gatePredicate`): any text that is not blank, even text with
307
+ // nothing printable in it — the gate counts that record, so this must too.
308
+ const named = typeof a.approver === 'string' && a.approver.trim() !== '';
309
+ const notCounted = notCountedReason(a, { approved, stale, named, reqEng });
310
+ return { ...fields, stale, counted: notCounted === null, notCounted };
311
+ });
312
+ }
313
+ out.push(row);
314
+ }
315
+ // The thread as `yad thread` walks it, not the `thread:` key in epic.md, which is only a cache.
316
+ let thread = { root: null, broken: null };
317
+ if (item.id !== FOUNDATION_EPIC) {
318
+ try {
319
+ const walk = resolveThread(root, item.id);
320
+ thread = { root: walk.rootId, broken: walk.broken || null };
321
+ } catch (e) {
322
+ thread = { root: null, broken: `the thread could not be walked (${e.code || e.message})` };
323
+ }
324
+ }
325
+ return {
326
+ thread,
327
+ steps: stateRead.steps ? out : null,
328
+ stepsWhy: stateRead.steps ? null : stateRead.why,
329
+ approvalsWhy,
330
+ hubWhy,
331
+ };
332
+ }
333
+
334
+ // ---- printing ---------------------------------------------------------------------------------------
335
+
336
+ const nameOf = (item) => printable(item.title) ?? item.id;
337
+ const typeOf = (item) => printable(item.type) ?? (item.id === FOUNDATION_EPIC ? 'product level (the Foundation)' : null);
338
+ const withShapeDone = (item) => ({ ...item, shapeDone: isShapeDone(item) });
339
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
340
+
341
+ function itemLine(item) {
342
+ const meta = [
343
+ ...(item.title ? [item.id] : []),
344
+ typeOf(item),
345
+ printable(item.theme) ? `#${printable(item.theme)}` : null,
346
+ printable(item.currentStep) ? `at ${printable(item.currentStep)}` : null,
347
+ isShapeDone(item) ? 'shape done' : null,
348
+ printable(item.lastClosed?.date) ? `last closed ${printable(item.lastClosed.date)}` : null,
349
+ ].filter(Boolean).join(' · ');
350
+ log(` ${isShapeDone(item) ? c.green('✓') : c.yellow('•')} ${c.bold(nameOf(item))} ${c.dim(meta)}`);
351
+ }
352
+
353
+ function printUnreadable({ unreadable, unlisted }) {
354
+ for (const u of unreadable) warn(`${u.id} could not be read — ${printable(u.why)} (not filtered: its type, theme and steps are unknown)`);
355
+ // What `unlistedLedgerDirs` returns is more than one kind of folder (a name that is not an id, a
356
+ // symlink, an `epics/EP-foundation` beside `foundation/`), so the line says only what is known.
357
+ for (const d of unlisted) warn(`${printable(d)} holds a .sdlc/ that is not read as a work item, so it is not listed`);
358
+ if (unreadable.length || unlisted.length) hand('fix or restore the files named above — `yad doctor` checks them');
359
+ }
360
+
361
+ // The one JSON answer (E1's envelope: `jsonVersion`, `version`, `command`, `ok`, `warnings`).
362
+ const printJson = (obj) => emitJSON(obj);
363
+
364
+ // A refusal, in the form the caller asked for (rule 6): JSON under `--json`, a line and a hint otherwise.
365
+ function refuse(json, error, hint = null) {
366
+ process.exitCode = 1;
367
+ if (json) return printJson({ ok: false, error, hint });
368
+ fail(error);
369
+ if (hint) hand(hint);
370
+ }
371
+
372
+ function stepLine(s) {
373
+ if (s.notAStep) return ` ${c.yellow('•')} ${c.dim('(not a step object)')}`;
374
+ // A tick for a step closed for good; a deferred one is still owed, so it keeps the open mark (E37).
375
+ const tick = SHAPE_DONE_STATES.includes(s.state) ? c.green('✓') : c.yellow('•');
376
+ const state = s.known ? s.state : `${printable(s.state) ?? 'no state'} (unknown)`;
377
+ // The same words `gate status` uses for an inherited step and for debt (E41).
378
+ const notes = [
379
+ s.type === 'review+approve' ? 'review' : null,
380
+ printable(s.inheritedFrom) ? `inherited from ${printable(s.inheritedFrom)}` : null,
381
+ s.debt && s.state === 'deferred' ? 'still owed, as debt' : s.debt ? 'owed as debt — being paid back' : null,
382
+ ].filter(Boolean);
383
+ return ` ${tick} ${printable(s.id) ?? '(no id)'} ${c.dim(`— ${state}${notes.length ? `, ${notes.join(', ')}` : ''}`)}`;
384
+ }
385
+
386
+ function recordLine(s) {
387
+ const r = s.record;
388
+ const [by, date, reason, link] = [r.by, r.date, r.reason, r.link].map(printable);
389
+ // A link the reason already names is not said twice ("carried by reference from EP-x (EP-x)").
390
+ const linkShown = link && !(reason && reason.includes(link)) ? ` (${link})` : '';
391
+ return `${printable(s.state) ?? 'recorded'}${by ? ` by ${by}` : ''}${date ? ` on ${date}` : ''}${reason ? ` — ${reason}` : ''}${linkShown}`;
392
+ }
393
+
394
+ const NOT_COUNTED_WORDS = {
395
+ stale: 'stale (revoked)',
396
+ unnamed: 'names nobody (not counted)',
397
+ unengaged: 'not engagement-verified (not counted)',
398
+ };
399
+
400
+ function approvalLine(a) {
401
+ // The reason is carried on the record (`notCounted`), so the words can never disagree with the count.
402
+ const tag = NOT_COUNTED_WORDS[a.notCounted] || (a.stale === true ? NOT_COUNTED_WORDS.stale : null);
403
+ const nameless = typeof a.approver === 'string' && a.approver.trim() !== '' ? 'a name with no visible characters' : 'nobody named';
404
+ const who = a.status === 'inherited' ? ` from ${printable(a.from) ?? 'the parent epic'}` : ` by ${printable(a.approver) ?? nameless}`;
405
+ return `${printable(a.status) ?? 'recorded'}${who}${a.date ? ` on ${printable(a.date)}` : ''}${a.pr !== null ? ` (PR #${a.pr})` : ''}${tag ? ` — ${tag}` : ''}`;
406
+ }
407
+
408
+ // ---- the commands -------------------------------------------------------------------------------------
409
+
410
+ function listCmd(read, keep, { json, thread, threadNote }) {
411
+ const items = newestFirst(read.items.filter(keep));
412
+ if (json) {
413
+ return printJson({ ok: true, items: items.map(withShapeDone), unreadable: read.unreadable, unlisted: read.unlisted, threadBroken: thread?.broken ?? null });
414
+ }
415
+ const more = read.unreadable.length ? ` (${read.unreadable.length} more could not be read)` : '';
416
+ log(c.bold(`\nyad history — ${plural(items.length, 'work item')}${items.length === read.items.length ? '' : ` of ${read.items.length}`}${more}, newest first`));
417
+ for (const item of items) itemLine(item);
418
+ if (!items.length) info(read.items.length ? 'no work item matches these filters' : 'no work items yet');
419
+ threadNote();
420
+ printUnreadable(read);
421
+ }
422
+
423
+ function searchCmd(root, read, keep, text, { json, thread, threadNote }) {
424
+ const found = [];
425
+ const summaryOnly = [];
426
+ for (const item of newestFirst(read.items.filter(keep))) {
427
+ const r = readState(root, item.id);
428
+ if (!r.steps) summaryOnly.push({ id: item.id, why: r.why });
429
+ const matches = searchMatches(item, r.steps || null, text);
430
+ if (matches.length) found.push({ ...withShapeDone(item), matches });
431
+ }
432
+ if (json) {
433
+ return printJson({ ok: true, query: text, items: found, summaryOnly, unreadable: read.unreadable, unlisted: read.unlisted, threadBroken: thread?.broken ?? null });
434
+ }
435
+ log(c.bold(`\nyad history search ${JSON.stringify(printable(text))} — ${plural(found.length, 'work item')}`));
436
+ for (const item of found) {
437
+ itemLine(item);
438
+ for (const m of item.matches) log(` ${c.dim(`${m.step ? `${printable(m.step)}: ` : ''}${m.field} = ${printable(m.value)}`)}`);
439
+ }
440
+ if (!found.length) {
441
+ info('nothing matches');
442
+ hand('search covers ids, titles, themes, types, repos, and who closed or merged a step, its PR and its commit — not approvals; `yad history show <id>` lists an item\'s approvals');
443
+ }
444
+ threadNote();
445
+ for (const s of summaryOnly) warn(`${s.id}: its steps could not be read (${s.why}), so only its summary was searched`);
446
+ printUnreadable(read);
447
+ }
448
+
449
+ function showCmd(root, read, id, { json }) {
450
+ const bad = read.unreadable.find((u) => u.id === id);
451
+ if (bad) return refuse(json, `${id} could not be read — ${printable(bad.why)}`, 'fix or restore its files — `yad doctor` checks them');
452
+ const item = read.items.find((i) => i.id === id);
453
+ if (!item) return refuse(json, `no work item ${id} in this Product`, '`yad history` lists the work items');
454
+ const hubRead = readHub(root);
455
+ const story = itemHistory(root, item, { hub: hubRead.hub ?? null, hubWhy: hubRead.why ?? null });
456
+ // Steps that cannot be read are a failure in both forms: a script reading `--json` must see it too.
457
+ if (!story.steps) process.exitCode = 1;
458
+ if (json) {
459
+ const failed = story.steps ? {} : { error: `the steps could not be read — .sdlc/state.json: ${story.stepsWhy}`, hint: 'restore it from git — `yad doctor` checks it' };
460
+ return printJson({ ok: story.steps !== null, ...failed, item: withShapeDone(item), ...story });
461
+ }
462
+
463
+ log(`\n ${c.bold(nameOf(item))}${item.title ? ` ${c.dim(item.id)}` : ''}`);
464
+ const threadText = story.thread.root ? `${story.thread.root}${story.thread.broken ? ` (${printable(story.thread.broken)})` : ''}` : null;
465
+ const facts = [
466
+ ['type', typeOf(item)],
467
+ ['theme', printable(item.theme)],
468
+ ['thread', threadText],
469
+ ['parent', printable(item.parent)],
470
+ ['profile', printable(item.profile)],
471
+ ['created', printable(item.createdAt)],
472
+ ['current step', printable(item.currentStep)],
473
+ ['repos', item.repos.length ? item.repos.map((r) => printable(r)).filter(Boolean).join(', ') : null],
474
+ ['shape', isShapeDone(item) ? 'done (Build is not shown here)' : null],
475
+ ].filter(([, v]) => v);
476
+ for (const [k, v] of facts) log(` ${c.dim(`${k}:`)} ${v}`);
477
+ if (story.hubWhy) warn(`the Product's settings could not be read — ${story.hubWhy}; whether an approval counts is not told`);
478
+ if (story.approvalsWhy) {
479
+ warn(`.sdlc/approvals.json could not be read — ${story.approvalsWhy}; no approvals are shown`);
480
+ hand('restore it from git — `yad doctor` checks it');
481
+ }
482
+ if (!story.steps) {
483
+ warn(`the steps could not be read — .sdlc/state.json: ${story.stepsWhy}`);
484
+ hand('restore it from git — `yad doctor` checks it');
485
+ return;
486
+ }
487
+ if (!story.steps.length) { info('no steps yet'); return; }
488
+ log('');
489
+ for (const s of story.steps) {
490
+ log(stepLine(s));
491
+ if (s.closed) log(` ${c.dim(closedLine(s.closed))}`);
492
+ if (s.record && !s.closed) log(` ${c.dim(recordLine(s))}`);
493
+ for (const a of s.approvals || []) log(` ${c.dim(approvalLine(a))}`);
494
+ if (s.staleUnknown) log(` ${c.dim(`whether these approvals are stale cannot be told — ${printable(s.staleUnknown)}`)}`);
495
+ }
496
+ const closedCount = story.steps.filter((s) => s.closed).length;
497
+ info(`${plural(closedCount, 'step')} of ${story.steps.length} ${closedCount === 1 ? 'has' : 'have'} a closing record`);
498
+ }
499
+
500
+ // The flags `yad history` takes. `unknownFlags`: any other flag the dispatcher saw, which is refused —
501
+ // a flag quietly ignored reads as a filter that was applied (`--since` would print everything).
502
+ export const HISTORY_FLAGS = ['--type', '--theme', '--thread', '--open', '--done', '--json', '--dir'];
503
+
504
+ export async function runHistory(root, { action = 'list', args = [], json = false, unknownFlags = [], ...flags } = {}) {
505
+ // A flag first: `yad history --opne` is a mistyped flag, not a subcommand named `--opne`.
506
+ if (unknownFlags.length) {
507
+ return refuse(json, `yad history does not take ${unknownFlags.join(', ')}`, `its flags are ${HISTORY_FLAGS.join(', ')}`);
508
+ }
509
+ if (!['list', 'show', 'search'].includes(action)) {
510
+ return refuse(json, `unknown history command: ${printable(action)}`, 'use `yad history list`, `yad history show <id>` or `yad history search <text>`');
511
+ }
512
+ if (action === 'list' && args.length) {
513
+ return refuse(json, `list takes no words (${args.map((a) => JSON.stringify(printable(a) ?? '')).join(' ')})`, 'to find text, use `yad history search <text>`');
514
+ }
515
+ const filtersGiven = ['type', 'theme', 'thread'].filter((k) => flags[k] != null).map((k) => `--${k}`)
516
+ .concat(['open', 'done'].filter((k) => flags[k]).map((k) => `--${k}`));
517
+ if (action === 'show' && filtersGiven.length) {
518
+ return refuse(json, `show prints one work item, so ${filtersGiven.join(' and ')} cannot apply`, 'the filters are for list and search');
519
+ }
520
+ if (action === 'show' && (!args[0] || !isValidEpicId(args[0]) || args.length > 1)) {
521
+ return refuse(json, !args[0] ? 'show needs a work-item id' : args.length > 1 ? 'show takes one work-item id' : `invalid work-item id: ${printable(args[0])}`,
522
+ 'a work-item id is EP-<slug>; `yad history` lists them');
523
+ }
524
+ const text = action === 'search' ? args.join(' ').trim() : null;
525
+ if (action === 'search' && !text) return refuse(json, 'search needs some text', 'yad history search <text>');
526
+ if (!exists(productConfigPath(root))) return refuse(json, 'no Product here (.sdlc/product.json)', 'run `yad history` from the Product, or pass --dir <path>');
527
+ // Checked before anything is read: a refused value reads no file.
528
+ if (action !== 'show') {
529
+ const bad = filterError(flags);
530
+ if (bad) return refuse(json, bad.error, bad.hint);
531
+ }
532
+ // The thread's members as `yad thread` finds them — plus its root, which `threadEpics` leaves out when
533
+ // it has no `epic.md` yet (a genesis seeded by `yad-analysis` before its epic step). A thread whose
534
+ // named epic has no folder is refused; a walk broken further up is reported, never read as "empty".
535
+ let thread = null;
536
+ if (action !== 'show' && flags.thread) {
537
+ try {
538
+ const walk = resolveThread(root, flags.thread);
539
+ if (walk.broken === `missing epic ${flags.thread}`) {
540
+ return refuse(json, `no work item ${flags.thread} to start a thread from`, '`yad thread` lists the feature threads');
541
+ }
542
+ thread = { members: [...new Set([...threadEpics(root, flags.thread), walk.rootId])], broken: walk.broken || null };
543
+ } catch (e) {
544
+ // The walk reads each member's epic.md as well as the folder, so the message names the walk.
545
+ return refuse(json, `the thread ${flags.thread} could not be walked (${e.code || e.message})`, 'fix or restore the files it reads — `yad doctor` checks them');
546
+ }
547
+ }
548
+ const read = readItems(root);
549
+ if (read.error) return refuse(json, `${read.error}, so there is no honest history to show`, '`yad doctor` checks the Product');
550
+ if (action === 'show') return showCmd(root, read, args[0], { json });
551
+ const { keep } = historyFilter(flags, { threadMembers: thread?.members ?? null });
552
+ // What the walk could not follow is said, never left to read as "nothing in this thread".
553
+ const threadNote = () => { if (thread?.broken) warn(`thread ${flags.thread}: ${printable(thread.broken)}`); };
554
+ if (action === 'list') return listCmd(read, keep, { json, thread, threadNote });
555
+ return searchCmd(root, read, keep, text, { json, thread, threadNote });
556
+ }