mandrel 1.87.0 → 1.89.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 (140) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +63 -16
  17. package/.agents/docs/configuration.md +5 -3
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +16 -4
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
  28. package/.agents/schemas/signal-event.schema.json +28 -13
  29. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  30. package/.agents/scripts/check-context-budget.js +320 -0
  31. package/.agents/scripts/diagnose-friction.js +4 -4
  32. package/.agents/scripts/epic-audit-prepare.js +30 -2
  33. package/.agents/scripts/epic-audit-recheck.js +46 -13
  34. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  35. package/.agents/scripts/epic-plan-spec.js +4 -8
  36. package/.agents/scripts/generate-lens-checklists.js +180 -0
  37. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  38. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  39. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  40. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  41. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  42. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  43. package/.agents/scripts/lib/cli-args.js +8 -0
  44. package/.agents/scripts/lib/config/explain.js +4 -0
  45. package/.agents/scripts/lib/config/runners.js +21 -2
  46. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  47. package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
  48. package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
  49. package/.agents/scripts/lib/doc-tiers.js +291 -0
  50. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  51. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  52. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  53. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
  54. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  55. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  56. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  57. package/.agents/scripts/lib/gates/friction.js +15 -5
  58. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  59. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  60. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  61. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  62. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  63. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  64. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  65. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  66. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  67. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  68. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  69. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  70. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
  71. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  72. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
  73. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  79. package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
  80. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  81. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  82. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  83. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  84. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  85. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  86. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  91. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  92. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  93. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  94. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  95. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  96. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  97. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  98. package/.agents/scripts/lib/planning-corpus.js +306 -0
  99. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  100. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  101. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  102. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  103. package/.agents/scripts/lib/signals/schema.js +56 -81
  104. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  105. package/.agents/scripts/lib/story-plan.js +3 -0
  106. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  107. package/.agents/scripts/lifecycle-emit.js +39 -8
  108. package/.agents/scripts/providers/github/issues.js +12 -1
  109. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  110. package/.agents/scripts/retro-run.js +51 -0
  111. package/.agents/scripts/signals-view.js +1 -1
  112. package/.agents/scripts/single-story-close.js +20 -1
  113. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  114. package/.agents/scripts/story-close.js +48 -0
  115. package/.agents/scripts/story-plan.js +51 -12
  116. package/.agents/scripts/validate-docs-freshness.js +69 -15
  117. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  118. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  119. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  120. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  121. package/.agents/skills/skills.index.json +3 -3
  122. package/.agents/workflows/audit-documentation.md +82 -2
  123. package/.agents/workflows/helpers/code-review.md +193 -44
  124. package/.agents/workflows/helpers/deliver-epic.md +128 -39
  125. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  126. package/.agents/workflows/helpers/epic-audit.md +116 -283
  127. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  128. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  129. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  130. package/.agents/workflows/helpers/plan-epic.md +141 -105
  131. package/.agents/workflows/helpers/plan-story.md +32 -0
  132. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  133. package/.agents/workflows/loops/nightly-audit.md +9 -7
  134. package/docs/CHANGELOG.md +29 -0
  135. package/lib/cli/doctor.js +44 -0
  136. package/package.json +4 -3
  137. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  138. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
  139. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  140. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -6,14 +6,33 @@
6
6
  * `~/.claude/projects/<repo>/memory/`), parses the YAML frontmatter, extracts
7
7
  * candidate references (file paths, GitHub labels, GitHub issue numbers),
8
8
  * verifies each, and rewrites the frontmatter with `stale: true`,
9
- * `staleReason: "..."`, `staleDetectedAt: "<iso>"` when any reference is dead.
9
+ * `staleReason: "..."`, `staleDetectedAt: "<iso>"` when a reference is
10
+ * **confirmed** dead.
10
11
  *
11
- * The walker is idempotent: entries already marked `stale: true` are skipped
12
- * untouched, so a subsequent run does not re-flag or thrash the frontmatter.
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.
13
31
  *
14
32
  * Best-effort guarantees:
15
33
  * - The memory directory missing yields `{ scanned: 0, staleEntries: [],
16
- * errors: [{ phase: 'discover', reason: '...' }] }` and no throw.
34
+ * unstaledEntries: [], errors: [{ phase: 'discover', reason: '...' }] }` and
35
+ * no throw.
17
36
  * - Per-file parse / probe failures are captured in `errors[]` and the file
18
37
  * is skipped — the walker keeps going.
19
38
  * - The function NEVER throws.
@@ -35,6 +54,60 @@ import * as path from 'node:path';
35
54
 
36
55
  const FRONTMATTER_FENCE = '---';
37
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
+
38
111
  const FILE_PATH_REGEX =
39
112
  /(?<![\w/])((?:\.{1,2}\/|\/)?[\w.\-/]+\.[A-Za-z0-9]{1,8})\b/g;
40
113
  const LABEL_REGEX = /\b([a-z][\w-]*::[a-z][\w-]+)\b/g;
@@ -179,17 +252,53 @@ export function extractReferences(body) {
179
252
  }
180
253
 
181
254
  /**
182
- * Probe `gh` for an issue's open/closed state. Resolves to one of:
183
- * - `{ exists: true, state: 'open' | 'closed' }`
184
- * - `{ exists: false }` gh missing or probe failed (best-effort skip)
185
- * - `{ exists: true, state: 'unknown' }` couldn't parse JSON
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`)
186
290
  *
187
291
  * Never throws.
188
292
  */
189
- function probeIssue({ number, ghPath, spawnImpl }) {
293
+ function probeIssue({
294
+ number,
295
+ ghPath,
296
+ spawnImpl,
297
+ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
298
+ }) {
190
299
  return new Promise((resolve) => {
191
300
  if (!ghPath) {
192
- resolve({ exists: false });
301
+ resolve({ status: 'unknown' });
193
302
  return;
194
303
  }
195
304
  let child;
@@ -200,46 +309,66 @@ function probeIssue({ number, ghPath, spawnImpl }) {
200
309
  { stdio: ['ignore', 'pipe', 'pipe'] },
201
310
  );
202
311
  } catch {
203
- resolve({ exists: false });
312
+ resolve({ status: 'unknown' });
204
313
  return;
205
314
  }
315
+ const settle = armProbeWatchdog({
316
+ child,
317
+ timeoutMs,
318
+ resolve,
319
+ onTimeout: { status: 'unknown' },
320
+ });
206
321
  let stdout = '';
322
+ let stderr = '';
207
323
  child.stdout?.on('data', (c) => {
208
324
  stdout += c.toString();
209
325
  });
210
- child.stderr?.on('data', () => {});
211
- child.on('error', () => resolve({ exists: false }));
326
+ child.stderr?.on('data', (c) => {
327
+ stderr += c.toString();
328
+ });
329
+ child.on('error', () => settle({ status: 'unknown' }));
212
330
  child.on('close', (code) => {
213
331
  if (code !== 0) {
214
- resolve({ exists: false });
332
+ // Distinguish a confirmed 404 from a transient outage.
333
+ settle({ status: classifyGhFailure(stderr) });
215
334
  return;
216
335
  }
217
336
  try {
218
337
  const parsed = JSON.parse(stdout || '{}');
219
- const state =
220
- typeof parsed.state === 'string'
221
- ? parsed.state.toLowerCase()
222
- : 'unknown';
223
- resolve({ exists: true, state });
338
+ if (typeof parsed.state !== 'string') {
339
+ settle({ status: 'unknown' });
340
+ return;
341
+ }
342
+ settle({ status: 'exists', state: parsed.state.toLowerCase() });
224
343
  } catch {
225
- resolve({ exists: true, state: 'unknown' });
344
+ settle({ status: 'unknown' });
226
345
  }
227
346
  });
228
347
  });
229
348
  }
230
349
 
231
350
  /**
232
- * Probe `gh` for a label's existence. Resolves to:
233
- * - `{ exists: true }`
234
- * - `{ exists: false }` (label not found OR gh missing — best-effort skip)
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`)
235
357
  *
236
358
  * Never throws.
237
359
  */
238
- function probeLabel({ name, owner, repo, ghPath, spawnImpl }) {
360
+ function probeLabel({
361
+ name,
362
+ owner,
363
+ repo,
364
+ ghPath,
365
+ spawnImpl,
366
+ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
367
+ }) {
239
368
  return new Promise((resolve) => {
240
369
  if (!ghPath || !owner || !repo) {
241
- // No way to verify; treat as best-effort skip (existing).
242
- resolve({ exists: true, probed: false });
370
+ // No way to verify; cannot confirm existence or absence.
371
+ resolve({ status: 'unknown' });
243
372
  return;
244
373
  }
245
374
  let child;
@@ -250,37 +379,49 @@ function probeLabel({ name, owner, repo, ghPath, spawnImpl }) {
250
379
  { stdio: ['ignore', 'pipe', 'pipe'] },
251
380
  );
252
381
  } catch {
253
- resolve({ exists: true, probed: false });
382
+ resolve({ status: 'unknown' });
254
383
  return;
255
384
  }
385
+ const settle = armProbeWatchdog({
386
+ child,
387
+ timeoutMs,
388
+ resolve,
389
+ onTimeout: { status: 'unknown' },
390
+ });
256
391
  let stderr = '';
257
392
  child.stdout?.on('data', () => {});
258
393
  child.stderr?.on('data', (c) => {
259
394
  stderr += c.toString();
260
395
  });
261
- child.on('error', () => resolve({ exists: true, probed: false }));
396
+ child.on('error', () => settle({ status: 'unknown' }));
262
397
  child.on('close', (code) => {
263
398
  if (code === 0) {
264
- resolve({ exists: true, probed: true });
265
- return;
266
- }
267
- if (/not found/i.test(stderr) || code === 1) {
268
- resolve({ exists: false, probed: true });
399
+ settle({ status: 'exists' });
269
400
  return;
270
401
  }
271
- // Any other failure best-effort skip.
272
- resolve({ exists: true, probed: false });
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) });
273
405
  });
274
406
  });
275
407
  }
276
408
 
277
409
  /**
278
- * Verify the candidate references inside a single memory entry. Returns the
279
- * first dead-reference reason discovered, or `null` if everything checks out.
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.
280
417
  *
281
- * @returns {Promise<string|null>}
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 }>}
282
423
  */
283
- async function findFirstDeadReason({
424
+ async function verifyReferences({
284
425
  references,
285
426
  fsImpl,
286
427
  ghPath,
@@ -288,7 +429,11 @@ async function findFirstDeadReason({
288
429
  owner,
289
430
  repo,
290
431
  projectRoot,
432
+ probeTimeoutMs,
291
433
  }) {
434
+ let sawUnknown = false;
435
+
436
+ // Files resolve deterministically off the filesystem — never `unknown`.
292
437
  for (const filePath of references.filePaths) {
293
438
  const resolved = path.isAbsolute(filePath)
294
439
  ? filePath
@@ -296,32 +441,81 @@ async function findFirstDeadReason({
296
441
  try {
297
442
  await fsImpl.access(resolved);
298
443
  } catch {
299
- return `file reference no longer exists: ${filePath}`;
444
+ return {
445
+ status: 'dead',
446
+ reason: `file reference no longer exists: ${filePath}`,
447
+ };
300
448
  }
301
449
  }
302
450
 
303
- if (ghPath) {
304
- for (const number of references.issues) {
305
- const probe = await probeIssue({ number, ghPath, spawnImpl });
306
- if (probe.exists && probe.state === 'closed') {
307
- return `issue #${number} is closed`;
308
- }
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` };
309
460
  }
310
- for (const labelName of references.labels) {
311
- const probe = await probeLabel({
312
- name: labelName,
313
- owner,
314
- repo,
315
- ghPath,
316
- spawnImpl,
317
- });
318
- if (probe.probed && !probe.exists) {
319
- return `label "${labelName}" no longer exists`;
320
- }
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;
321
466
  }
322
467
  }
323
468
 
324
- return null;
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
+ );
325
519
  }
326
520
 
327
521
  /**
@@ -337,9 +531,13 @@ async function findFirstDeadReason({
337
531
  * @param {string} [opts.owner] — GitHub owner used for label probes
338
532
  * @param {string} [opts.repo] — GitHub repo used for label probes
339
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.
340
537
  * @returns {Promise<{
341
538
  * scanned: number,
342
539
  * staleEntries: Array<{ file: string, reason: string }>,
540
+ * unstaledEntries: Array<{ file: string }>,
343
541
  * errors: Array<{ phase: string, file?: string, reason: string }>,
344
542
  * }>}
345
543
  */
@@ -353,8 +551,14 @@ export async function scanMemoryFreshness({
353
551
  owner,
354
552
  repo,
355
553
  now,
554
+ probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
356
555
  } = {}) {
357
- const result = { scanned: 0, staleEntries: [], errors: [] };
556
+ const result = {
557
+ scanned: 0,
558
+ staleEntries: [],
559
+ unstaledEntries: [],
560
+ errors: [],
561
+ };
358
562
 
359
563
  if (typeof memoryDir !== 'string' || memoryDir.length === 0) {
360
564
  result.errors.push({
@@ -405,14 +609,6 @@ export async function scanMemoryFreshness({
405
609
  continue;
406
610
  }
407
611
 
408
- // Idempotent: already-stale entries are left untouched.
409
- if (
410
- parsed.frontmatter.stale === 'true' ||
411
- parsed.frontmatter.stale === true
412
- ) {
413
- continue;
414
- }
415
-
416
612
  let references;
417
613
  try {
418
614
  references = extractReferences(parsed.body);
@@ -425,9 +621,9 @@ export async function scanMemoryFreshness({
425
621
  continue;
426
622
  }
427
623
 
428
- let reason;
624
+ let verdict;
429
625
  try {
430
- reason = await findFirstDeadReason({
626
+ verdict = await verifyReferences({
431
627
  references,
432
628
  fsImpl,
433
629
  ghPath,
@@ -435,6 +631,7 @@ export async function scanMemoryFreshness({
435
631
  owner,
436
632
  repo,
437
633
  projectRoot,
634
+ probeTimeoutMs,
438
635
  });
439
636
  } catch (err) {
440
637
  result.errors.push({
@@ -445,14 +642,44 @@ export async function scanMemoryFreshness({
445
642
  continue;
446
643
  }
447
644
 
448
- if (!reason) continue;
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;
449
676
 
450
677
  const stamped = {
451
678
  ...parsed,
452
679
  frontmatter: {
453
680
  ...parsed.frontmatter,
454
681
  stale: 'true',
455
- staleReason: reason,
682
+ staleReason: verdict.reason,
456
683
  staleDetectedAt: now ?? new Date().toISOString(),
457
684
  },
458
685
  };
@@ -471,7 +698,7 @@ export async function scanMemoryFreshness({
471
698
  continue;
472
699
  }
473
700
 
474
- result.staleEntries.push({ file: name, reason });
701
+ result.staleEntries.push({ file: name, reason: verdict.reason });
475
702
  }
476
703
 
477
704
  return result;