mandrel 2.24.0 → 2.26.0

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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -1,707 +0,0 @@
1
- /**
2
- * memory-freshness.js — walker + verifier for the `/plan` Phase 0
3
- * memory-freshness pre-flight. Story #2557 / Epic #2547. Tech Spec #2550.
4
- *
5
- * Walks every `.md` file under a memory directory (typically
6
- * `~/.claude/projects/<repo>/memory/`), parses the YAML frontmatter, extracts
7
- * candidate references (file paths, GitHub labels, GitHub issue numbers),
8
- * verifies each, and rewrites the frontmatter with `stale: true`,
9
- * `staleReason: "..."`, `staleDetectedAt: "<iso>"` when a reference is
10
- * **confirmed** dead.
11
- *
12
- * Three-valued probes (Story #4414 / Epic #4406). Every reference resolves to
13
- * one of three states — `exists`, `missing`, or `unknown` — so only a
14
- * *confirmed-missing* (or confirmed-closed) reference marks an entry stale.
15
- * A transient `gh` failure (rate-limit, auth, network) resolves to `unknown`
16
- * and mutates nothing: it can neither newly-stale a fresh entry nor un-stale a
17
- * previously-stale one. This closes the poison-on-outage bug where any `gh`
18
- * exit 1 was read as "reference deleted".
19
- *
20
- * Reversible stale path (Story #4414). A previously-staled entry whose
21
- * references are **all** re-confirmed `exists` on a later scan is un-staled:
22
- * the `stale` / `staleReason` / `staleDetectedAt` keys are stripped via the
23
- * same atomic rewrite path used to stamp them. An entry that is still dead, or
24
- * whose recovery cannot be confirmed (any `unknown` probe), is left
25
- * byte-identical — so a stuck entry is never thrashed and recovery is only
26
- * ever asserted from positive evidence.
27
- *
28
- * The walker is idempotent: a still-stale entry and a still-fresh entry are
29
- * both left untouched, so a subsequent scan over an unchanged memory dir
30
- * produces byte-identical frontmatter.
31
- *
32
- * Best-effort guarantees:
33
- * - The memory directory missing yields `{ scanned: 0, staleEntries: [],
34
- * unstaledEntries: [], errors: [{ phase: 'discover', reason: '...' }] }` and
35
- * no throw.
36
- * - Per-file parse / probe failures are captured in `errors[]` and the file
37
- * is skipped — the walker keeps going.
38
- * - The function NEVER throws.
39
- *
40
- * Test seams:
41
- * - `fsImpl` — node:fs/promises-compatible surface (`readdir`, `readFile`,
42
- * `writeFile`, `rename`, `access`, `stat`).
43
- * - `gitImpl` — unused today; kept in the signature for symmetry with the
44
- * Tech Spec contract and forward compatibility (e.g. branch-existence
45
- * probes).
46
- * - `ghPath` — path to the `gh` binary used for label and issue probes. When
47
- * `gh` is not on PATH, label/issue probes are skipped (best-effort).
48
- * - `spawnImpl` — node:child_process spawn-compatible seam for tests.
49
- */
50
-
51
- import { spawn as defaultSpawn } from 'node:child_process';
52
- import * as defaultFs from 'node:fs/promises';
53
- import * as path from 'node:path';
54
-
55
- const FRONTMATTER_FENCE = '---';
56
-
57
- /**
58
- * Default per-probe watchdog bound for the `gh` spawns. Matches
59
- * `graduator-core`'s `DEFAULT_RUN_CHILD_TIMEOUT_MS` (30000 ms) — the other
60
- * feedback-loop spawn site Epic #4406 bounded so a hung `gh` cannot block a
61
- * finalize/scan forever. Caller-overridable via `scanMemoryFreshness`'s
62
- * `probeTimeoutMs`.
63
- */
64
- const DEFAULT_PROBE_TIMEOUT_MS = 30000;
65
-
66
- /**
67
- * Arm a caller-overridable watchdog over a probe's spawned child and return a
68
- * `settle(value)` function the probe's own event handlers call to resolve.
69
- * The first `settle` wins (subsequent calls are ignored) and always clears the
70
- * timer, so it never outlives its purpose.
71
- *
72
- * On timeout the child is SIGKILL'd and the probe settles to the supplied
73
- * `onTimeout` value — for these three-valued probes always `{ status:
74
- * 'unknown' }`, so a hung `gh` never confirms a `missing` reference.
75
- *
76
- * The timer is intentionally **not** `.unref()`'d. An unref'd watchdog cannot
77
- * keep an otherwise-idle event loop alive to fire, so on a stub child (or a
78
- * real child whose stdio handles close early) it would silently never fire and
79
- * the awaiting promise would hang forever — the exact defect Epic #4406 fixed
80
- * in `graduator-core.runChild`. `settle()` always `clearTimeout()`s it.
81
- *
82
- * @param {object} opts
83
- * @param {{ kill?: Function }} opts.child
84
- * @param {number} opts.timeoutMs — watchdog bound; `0`/`Infinity` disables it
85
- * @param {Function} opts.resolve — the enclosing Promise's resolve
86
- * @param {*} opts.onTimeout — value to settle with on overrun
87
- * @returns {(value: *) => void}
88
- */
89
- function armProbeWatchdog({ child, timeoutMs, resolve, onTimeout }) {
90
- let settled = false;
91
- let timer = null;
92
- const settle = (value) => {
93
- if (settled) return;
94
- settled = true;
95
- if (timer) clearTimeout(timer);
96
- resolve(value);
97
- };
98
- if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
99
- timer = setTimeout(() => {
100
- try {
101
- child.kill?.('SIGKILL');
102
- } catch {
103
- // Killing an already-dead / stub child is a no-op we ignore.
104
- }
105
- settle(onTimeout);
106
- }, timeoutMs);
107
- }
108
- return settle;
109
- }
110
-
111
- const FILE_PATH_REGEX =
112
- /(?<![\w/])((?:\.{1,2}\/|\/)?[\w.\-/]+\.[A-Za-z0-9]{1,8})\b/g;
113
- const LABEL_REGEX = /\b([a-z][\w-]*::[a-z][\w-]+)\b/g;
114
- const ISSUE_REGEX = /(?:^|[^&\w])#(\d+)\b/g;
115
-
116
- /**
117
- * Parse YAML frontmatter from a markdown buffer.
118
- *
119
- * Returns `{ frontmatter: Record<string,string>, body: string, keyOrder:
120
- * string[], hasFrontmatter: boolean }`. The parser is intentionally narrow:
121
- * the memory substrate uses a flat string-valued frontmatter (no nesting,
122
- * no lists), so a minimal `key: value` reader avoids dragging in a YAML
123
- * dependency.
124
- *
125
- * Lines that do not match `^([A-Za-z0-9_-]+):\s*(.*)$` inside the
126
- * frontmatter block are preserved verbatim into the body to keep round-trip
127
- * safety — we never silently drop content.
128
- *
129
- * @param {string} raw
130
- * @returns {{ frontmatter: Record<string,string>, body: string, keyOrder: string[], hasFrontmatter: boolean }}
131
- */
132
- export function parseFrontmatter(raw) {
133
- if (typeof raw !== 'string' || raw.length === 0) {
134
- return { frontmatter: {}, body: '', keyOrder: [], hasFrontmatter: false };
135
- }
136
-
137
- // Tolerate a leading BOM or CR.
138
- const text = raw.replace(/^/, '');
139
- const lines = text.split(/\r?\n/);
140
-
141
- if (lines[0] !== FRONTMATTER_FENCE) {
142
- return { frontmatter: {}, body: raw, keyOrder: [], hasFrontmatter: false };
143
- }
144
-
145
- let endIdx = -1;
146
- for (let i = 1; i < lines.length; i++) {
147
- if (lines[i] === FRONTMATTER_FENCE) {
148
- endIdx = i;
149
- break;
150
- }
151
- }
152
- if (endIdx === -1) {
153
- // Unterminated frontmatter — treat the whole file as body to stay safe.
154
- return { frontmatter: {}, body: raw, keyOrder: [], hasFrontmatter: false };
155
- }
156
-
157
- const frontmatter = {};
158
- const keyOrder = [];
159
- for (let i = 1; i < endIdx; i++) {
160
- const line = lines[i];
161
- const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
162
- if (!match) continue;
163
- const key = match[1];
164
- let value = match[2];
165
- // Strip a single surrounding pair of quotes if present.
166
- if (
167
- (value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
168
- (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
169
- ) {
170
- value = value.slice(1, -1);
171
- }
172
- if (!Object.hasOwn(frontmatter, key)) {
173
- keyOrder.push(key);
174
- }
175
- frontmatter[key] = value;
176
- }
177
-
178
- const body = lines.slice(endIdx + 1).join('\n');
179
- return { frontmatter, body, keyOrder, hasFrontmatter: true };
180
- }
181
-
182
- /**
183
- * Re-render a parsed memory entry back to a markdown string. Preserves the
184
- * original key order and appends any newly-added keys (in stable order) at
185
- * the tail of the frontmatter block.
186
- *
187
- * @param {{ frontmatter: Record<string,string>, body: string, keyOrder: string[] }} parsed
188
- * @returns {string}
189
- */
190
- export function renderFrontmatter({ frontmatter, body, keyOrder }) {
191
- const seen = new Set();
192
- const orderedKeys = [];
193
- for (const key of keyOrder) {
194
- if (Object.hasOwn(frontmatter, key)) {
195
- orderedKeys.push(key);
196
- seen.add(key);
197
- }
198
- }
199
- for (const key of Object.keys(frontmatter)) {
200
- if (!seen.has(key)) orderedKeys.push(key);
201
- }
202
-
203
- const fmLines = orderedKeys.map((key) => {
204
- const value = frontmatter[key];
205
- // Quote any value containing characters that would confuse our reader.
206
- const needsQuote = /[:#]/.test(value) || /^\s|\s$/.test(value);
207
- const rendered = needsQuote ? JSON.stringify(value) : value;
208
- return `${key}: ${rendered}`;
209
- });
210
-
211
- return [FRONTMATTER_FENCE, ...fmLines, FRONTMATTER_FENCE, body].join('\n');
212
- }
213
-
214
- /**
215
- * Extract unique candidate references from a memory entry body.
216
- *
217
- * @param {string} body
218
- * @returns {{ filePaths: string[], labels: string[], issues: number[] }}
219
- */
220
- export function extractReferences(body) {
221
- const filePaths = new Set();
222
- const labels = new Set();
223
- const issues = new Set();
224
-
225
- if (typeof body !== 'string' || body.length === 0) {
226
- return { filePaths: [], labels: [], issues: [] };
227
- }
228
-
229
- for (const m of body.matchAll(FILE_PATH_REGEX)) {
230
- const candidate = m[1];
231
- // Filter pure label-shaped strings, anchor names, and trivially short
232
- // matches that are not real paths.
233
- if (candidate.includes('::')) continue;
234
- if (candidate.length < 4) continue;
235
- filePaths.add(candidate);
236
- }
237
-
238
- for (const m of body.matchAll(LABEL_REGEX)) {
239
- labels.add(m[1]);
240
- }
241
-
242
- for (const m of body.matchAll(ISSUE_REGEX)) {
243
- const n = Number.parseInt(m[1], 10);
244
- if (Number.isInteger(n) && n > 0) issues.add(n);
245
- }
246
-
247
- return {
248
- filePaths: [...filePaths],
249
- labels: [...labels],
250
- issues: [...issues],
251
- };
252
- }
253
-
254
- /**
255
- * Classify a non-zero `gh` exit into a confirmed-missing signal versus an
256
- * inconclusive/transient one. Only a positively-recognized "not found" (HTTP
257
- * 404 / "could not resolve to a …") counts as `missing`; everything else —
258
- * rate-limit, auth failure, network error, or any stderr we cannot positively
259
- * read as a 404 — is `unknown` so a transient outage never poisons an entry.
260
- *
261
- * @param {string} stderr
262
- * @returns {'missing' | 'unknown'}
263
- */
264
- function classifyGhFailure(stderr) {
265
- const s = String(stderr ?? '');
266
- // Transient / non-authoritative failures never confirm a missing reference.
267
- if (
268
- /rate.?limit|\b429\b|\b403\b|\b401\b|authentic|unauthor|bad credentials|gh auth|login|token|network|timeout|timed out|ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|could not resolve host|dial tcp|connection refused|no such host|TLS|handshake/i.test(
269
- s,
270
- )
271
- ) {
272
- return 'unknown';
273
- }
274
- // A genuine not-found is the only confirmed-missing signal.
275
- if (/not found|\b404\b|could not resolve to (?:an?|the)|no such/i.test(s)) {
276
- return 'missing';
277
- }
278
- // Anything else is inconclusive — never poison on an unrecognized failure.
279
- return 'unknown';
280
- }
281
-
282
- /**
283
- * Probe `gh` for an issue's existence and open/closed state. Resolves to one
284
- * of the three-valued shapes:
285
- * - `{ status: 'exists', state: 'open' | 'closed' }`
286
- * - `{ status: 'missing' }` — confirmed 404 (issue does not exist)
287
- * - `{ status: 'unknown' }` — gh missing, spawn/child error, unparseable
288
- * JSON, a transient (rate-limit/auth/network) failure, or a spawn that
289
- * overran `timeoutMs` (the child is SIGKILL'd; never `missing`)
290
- *
291
- * Never throws.
292
- */
293
- function probeIssue({
294
- number,
295
- ghPath,
296
- spawnImpl,
297
- timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
298
- }) {
299
- return new Promise((resolve) => {
300
- if (!ghPath) {
301
- resolve({ status: 'unknown' });
302
- return;
303
- }
304
- let child;
305
- try {
306
- child = spawnImpl(
307
- ghPath,
308
- ['issue', 'view', String(number), '--json', 'state'],
309
- { stdio: ['ignore', 'pipe', 'pipe'] },
310
- );
311
- } catch {
312
- resolve({ status: 'unknown' });
313
- return;
314
- }
315
- const settle = armProbeWatchdog({
316
- child,
317
- timeoutMs,
318
- resolve,
319
- onTimeout: { status: 'unknown' },
320
- });
321
- let stdout = '';
322
- let stderr = '';
323
- child.stdout?.on('data', (c) => {
324
- stdout += c.toString();
325
- });
326
- child.stderr?.on('data', (c) => {
327
- stderr += c.toString();
328
- });
329
- child.on('error', () => settle({ status: 'unknown' }));
330
- child.on('close', (code) => {
331
- if (code !== 0) {
332
- // Distinguish a confirmed 404 from a transient outage.
333
- settle({ status: classifyGhFailure(stderr) });
334
- return;
335
- }
336
- try {
337
- const parsed = JSON.parse(stdout || '{}');
338
- if (typeof parsed.state !== 'string') {
339
- settle({ status: 'unknown' });
340
- return;
341
- }
342
- settle({ status: 'exists', state: parsed.state.toLowerCase() });
343
- } catch {
344
- settle({ status: 'unknown' });
345
- }
346
- });
347
- });
348
- }
349
-
350
- /**
351
- * Probe `gh` for a label's existence. Resolves to one of:
352
- * - `{ status: 'exists' }`
353
- * - `{ status: 'missing' }` — confirmed 404 (label does not exist)
354
- * - `{ status: 'unknown' }` — gh/owner/repo missing, spawn/child error, a
355
- * transient (rate-limit/auth/network) failure, or a spawn that overran
356
- * `timeoutMs` (the child is SIGKILL'd; never `missing`)
357
- *
358
- * Never throws.
359
- */
360
- function probeLabel({
361
- name,
362
- owner,
363
- repo,
364
- ghPath,
365
- spawnImpl,
366
- timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
367
- }) {
368
- return new Promise((resolve) => {
369
- if (!ghPath || !owner || !repo) {
370
- // No way to verify; cannot confirm existence or absence.
371
- resolve({ status: 'unknown' });
372
- return;
373
- }
374
- let child;
375
- try {
376
- child = spawnImpl(
377
- ghPath,
378
- ['api', `repos/${owner}/${repo}/labels/${encodeURIComponent(name)}`],
379
- { stdio: ['ignore', 'pipe', 'pipe'] },
380
- );
381
- } catch {
382
- resolve({ status: 'unknown' });
383
- return;
384
- }
385
- const settle = armProbeWatchdog({
386
- child,
387
- timeoutMs,
388
- resolve,
389
- onTimeout: { status: 'unknown' },
390
- });
391
- let stderr = '';
392
- child.stdout?.on('data', () => {});
393
- child.stderr?.on('data', (c) => {
394
- stderr += c.toString();
395
- });
396
- child.on('error', () => settle({ status: 'unknown' }));
397
- child.on('close', (code) => {
398
- if (code === 0) {
399
- settle({ status: 'exists' });
400
- return;
401
- }
402
- // Only a confirmed 404 marks the label missing; a transient failure
403
- // (rate-limit/auth/network) stays unknown and mutates nothing.
404
- settle({ status: classifyGhFailure(stderr) });
405
- });
406
- });
407
- }
408
-
409
- /**
410
- * Verify every candidate reference inside a single memory entry and collapse
411
- * the outcome into a three-valued freshness verdict:
412
- * - `{ status: 'dead', reason }` — at least one reference is confirmed
413
- * missing (or a referenced issue is confirmed closed).
414
- * - `{ status: 'alive' }` — every reference is confirmed to exist.
415
- * - `{ status: 'unknown' }` — no confirmed-dead reference, but at least
416
- * one probe was inconclusive, so recovery cannot be asserted.
417
- *
418
- * A confirmed-dead reference dominates (marks the entry stale even if other
419
- * probes are unknown); `alive` requires *every* reference positively confirmed
420
- * so an un-stale is only ever driven by positive evidence.
421
- *
422
- * @returns {Promise<{ status: 'dead' | 'alive' | 'unknown', reason?: string }>}
423
- */
424
- async function verifyReferences({
425
- references,
426
- fsImpl,
427
- ghPath,
428
- spawnImpl,
429
- owner,
430
- repo,
431
- projectRoot,
432
- probeTimeoutMs,
433
- }) {
434
- let sawUnknown = false;
435
-
436
- // Files resolve deterministically off the filesystem — never `unknown`.
437
- for (const filePath of references.filePaths) {
438
- const resolved = path.isAbsolute(filePath)
439
- ? filePath
440
- : path.resolve(projectRoot, filePath);
441
- try {
442
- await fsImpl.access(resolved);
443
- } catch {
444
- return {
445
- status: 'dead',
446
- reason: `file reference no longer exists: ${filePath}`,
447
- };
448
- }
449
- }
450
-
451
- for (const number of references.issues) {
452
- const probe = await probeIssue({
453
- number,
454
- ghPath,
455
- spawnImpl,
456
- timeoutMs: probeTimeoutMs,
457
- });
458
- if (probe.status === 'missing') {
459
- return { status: 'dead', reason: `issue #${number} no longer exists` };
460
- }
461
- if (probe.status === 'exists' && probe.state === 'closed') {
462
- return { status: 'dead', reason: `issue #${number} is closed` };
463
- }
464
- if (probe.status === 'unknown') {
465
- sawUnknown = true;
466
- }
467
- }
468
-
469
- for (const labelName of references.labels) {
470
- const probe = await probeLabel({
471
- name: labelName,
472
- owner,
473
- repo,
474
- ghPath,
475
- spawnImpl,
476
- timeoutMs: probeTimeoutMs,
477
- });
478
- if (probe.status === 'missing') {
479
- return {
480
- status: 'dead',
481
- reason: `label "${labelName}" no longer exists`,
482
- };
483
- }
484
- if (probe.status === 'unknown') {
485
- sawUnknown = true;
486
- }
487
- }
488
-
489
- return sawUnknown ? { status: 'unknown' } : { status: 'alive' };
490
- }
491
-
492
- const STALE_KEYS = ['stale', 'staleReason', 'staleDetectedAt'];
493
-
494
- /**
495
- * Return a copy of a parsed entry with the stale-marker keys stripped from
496
- * both the frontmatter map and the key order, preserving every other key and
497
- * the body verbatim.
498
- *
499
- * @param {{ frontmatter: Record<string,string>, body: string, keyOrder: string[] }} parsed
500
- * @returns {{ frontmatter: Record<string,string>, body: string, keyOrder: string[] }}
501
- */
502
- function stripStaleKeys(parsed) {
503
- const frontmatter = { ...parsed.frontmatter };
504
- for (const key of STALE_KEYS) delete frontmatter[key];
505
- const keyOrder = parsed.keyOrder.filter((key) => !STALE_KEYS.includes(key));
506
- return { ...parsed, frontmatter, keyOrder };
507
- }
508
-
509
- /**
510
- * Whether a parsed entry currently carries the stale marker.
511
- *
512
- * @param {{ frontmatter: Record<string,string> }} parsed
513
- * @returns {boolean}
514
- */
515
- function isStale(parsed) {
516
- return (
517
- parsed.frontmatter.stale === 'true' || parsed.frontmatter.stale === true
518
- );
519
- }
520
-
521
- /**
522
- * Scan a memory directory for stale entries.
523
- *
524
- * @param {object} opts
525
- * @param {string} opts.memoryDir — absolute path to the memory directory
526
- * @param {object} [opts.fsImpl] — node:fs/promises-compatible seam
527
- * @param {object} [opts.gitImpl] — reserved; not consumed today
528
- * @param {string} [opts.ghPath="gh"] — path to gh binary; pass empty/null to skip label/issue probes
529
- * @param {Function} [opts.spawnImpl] — child_process.spawn-compatible test seam
530
- * @param {string} [opts.projectRoot] — base for resolving relative file references; defaults to `process.cwd()`
531
- * @param {string} [opts.owner] — GitHub owner used for label probes
532
- * @param {string} [opts.repo] — GitHub repo used for label probes
533
- * @param {string} [opts.now] — ISO timestamp injector (test seam)
534
- * @param {number} [opts.probeTimeoutMs] — per-`gh`-spawn watchdog bound (ms);
535
- * defaults to {@link DEFAULT_PROBE_TIMEOUT_MS}. A spawn that overruns is
536
- * SIGKILL'd and resolves `unknown`, so a hung `gh` never marks an entry stale.
537
- * @returns {Promise<{
538
- * scanned: number,
539
- * staleEntries: Array<{ file: string, reason: string }>,
540
- * unstaledEntries: Array<{ file: string }>,
541
- * errors: Array<{ phase: string, file?: string, reason: string }>,
542
- * }>}
543
- */
544
- export async function scanMemoryFreshness({
545
- memoryDir,
546
- fsImpl = defaultFs,
547
- gitImpl: _gitImpl,
548
- ghPath = 'gh',
549
- spawnImpl = defaultSpawn,
550
- projectRoot = process.cwd(),
551
- owner,
552
- repo,
553
- now,
554
- probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
555
- } = {}) {
556
- const result = {
557
- scanned: 0,
558
- staleEntries: [],
559
- unstaledEntries: [],
560
- errors: [],
561
- };
562
-
563
- if (typeof memoryDir !== 'string' || memoryDir.length === 0) {
564
- result.errors.push({
565
- phase: 'discover',
566
- reason: 'memoryDir argument is missing or empty',
567
- });
568
- return result;
569
- }
570
-
571
- let entries;
572
- try {
573
- entries = await fsImpl.readdir(memoryDir);
574
- } catch (err) {
575
- result.errors.push({
576
- phase: 'discover',
577
- reason: `memory directory unreachable: ${err.message}`,
578
- });
579
- return result;
580
- }
581
-
582
- const markdownFiles = entries.filter((name) => name.endsWith('.md'));
583
-
584
- for (const name of markdownFiles) {
585
- const filePath = path.join(memoryDir, name);
586
- let raw;
587
- try {
588
- raw = await fsImpl.readFile(filePath, 'utf8');
589
- } catch (err) {
590
- result.errors.push({
591
- phase: 'read',
592
- file: name,
593
- reason: `read failed: ${err.message}`,
594
- });
595
- continue;
596
- }
597
-
598
- result.scanned += 1;
599
-
600
- let parsed;
601
- try {
602
- parsed = parseFrontmatter(raw);
603
- } catch (err) {
604
- result.errors.push({
605
- phase: 'parse',
606
- file: name,
607
- reason: `frontmatter parse failed: ${err.message}`,
608
- });
609
- continue;
610
- }
611
-
612
- let references;
613
- try {
614
- references = extractReferences(parsed.body);
615
- } catch (err) {
616
- result.errors.push({
617
- phase: 'extract',
618
- file: name,
619
- reason: `reference extraction failed: ${err.message}`,
620
- });
621
- continue;
622
- }
623
-
624
- let verdict;
625
- try {
626
- verdict = await verifyReferences({
627
- references,
628
- fsImpl,
629
- ghPath,
630
- spawnImpl,
631
- owner,
632
- repo,
633
- projectRoot,
634
- probeTimeoutMs,
635
- });
636
- } catch (err) {
637
- result.errors.push({
638
- phase: 'verify',
639
- file: name,
640
- reason: `verify failed: ${err.message}`,
641
- });
642
- continue;
643
- }
644
-
645
- const alreadyStale = isStale(parsed);
646
-
647
- // Reversible stale path (Story #4414): a previously-stale entry whose
648
- // references are now ALL confirmed alive is un-staled. `unknown` (a
649
- // transient probe) leaves the marker in place — recovery is only ever
650
- // asserted from positive evidence — and `dead` keeps it stale. Both the
651
- // still-dead and still-unknown cases fall through to a no-op, so a scan
652
- // over an unchanged memory dir is byte-identical (idempotent).
653
- if (alreadyStale) {
654
- if (verdict.status !== 'alive') continue;
655
-
656
- const rendered = renderFrontmatter(stripStaleKeys(parsed));
657
- const tmpPath = `${filePath}.unstale.tmp`;
658
- try {
659
- await fsImpl.writeFile(tmpPath, rendered, 'utf8');
660
- await fsImpl.rename(tmpPath, filePath);
661
- } catch (err) {
662
- result.errors.push({
663
- phase: 'write',
664
- file: name,
665
- reason: `atomic un-stale write failed: ${err.message}`,
666
- });
667
- continue;
668
- }
669
- result.unstaledEntries.push({ file: name });
670
- continue;
671
- }
672
-
673
- // A fresh entry is marked stale ONLY on a confirmed-dead reference; an
674
- // `unknown` verdict (transient gh outage) mutates nothing.
675
- if (verdict.status !== 'dead') continue;
676
-
677
- const stamped = {
678
- ...parsed,
679
- frontmatter: {
680
- ...parsed.frontmatter,
681
- stale: 'true',
682
- staleReason: verdict.reason,
683
- staleDetectedAt: now ?? new Date().toISOString(),
684
- },
685
- };
686
-
687
- const rendered = renderFrontmatter(stamped);
688
- const tmpPath = `${filePath}.stale.tmp`;
689
- try {
690
- await fsImpl.writeFile(tmpPath, rendered, 'utf8');
691
- await fsImpl.rename(tmpPath, filePath);
692
- } catch (err) {
693
- result.errors.push({
694
- phase: 'write',
695
- file: name,
696
- reason: `atomic write failed: ${err.message}`,
697
- });
698
- continue;
699
- }
700
-
701
- result.staleEntries.push({ file: name, reason: verdict.reason });
702
- }
703
-
704
- return result;
705
- }
706
-
707
- export default scanMemoryFreshness;