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.
- package/.agents/README.md +18 -13
- package/.agents/audit-checklists/architecture.md +24 -0
- package/.agents/audit-checklists/clean-code.md +24 -0
- package/.agents/audit-checklists/dependencies.md +14 -0
- package/.agents/audit-checklists/devops.md +17 -0
- package/.agents/audit-checklists/documentation.md +22 -0
- package/.agents/audit-checklists/lighthouse.md +15 -0
- package/.agents/audit-checklists/navigability.md +14 -0
- package/.agents/audit-checklists/performance.md +22 -0
- package/.agents/audit-checklists/privacy.md +21 -0
- package/.agents/audit-checklists/quality.md +18 -0
- package/.agents/audit-checklists/security.md +22 -0
- package/.agents/audit-checklists/seo.md +16 -0
- package/.agents/audit-checklists/sre.md +24 -0
- package/.agents/audit-checklists/ux-ui.md +21 -0
- package/.agents/docs/SDLC.md +63 -16
- package/.agents/docs/configuration.md +5 -3
- package/.agents/instructions.md +51 -21
- package/.agents/personas/architect.md +10 -7
- package/.agents/personas/engineer.md +4 -3
- package/.agents/personas/project-manager.md +5 -2
- package/.agents/personas/refactorer.md +5 -3
- package/.agents/rules/git-conventions.md +77 -0
- package/.agents/schemas/agentrc.schema.json +16 -4
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
- package/.agents/schemas/signal-event.schema.json +28 -13
- package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
- package/.agents/scripts/check-context-budget.js +320 -0
- package/.agents/scripts/diagnose-friction.js +4 -4
- package/.agents/scripts/epic-audit-prepare.js +30 -2
- package/.agents/scripts/epic-audit-recheck.js +46 -13
- package/.agents/scripts/epic-deliver-prepare.js +80 -8
- package/.agents/scripts/epic-plan-spec.js +4 -8
- package/.agents/scripts/generate-lens-checklists.js +180 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
- package/.agents/scripts/lib/audit-suite/findings.js +27 -0
- package/.agents/scripts/lib/audit-suite/index.js +9 -0
- package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
- package/.agents/scripts/lib/audit-suite/selector.js +136 -5
- package/.agents/scripts/lib/checks/loop-health.js +340 -0
- package/.agents/scripts/lib/cli-args.js +8 -0
- package/.agents/scripts/lib/config/explain.js +4 -0
- package/.agents/scripts/lib/config/runners.js +21 -2
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
- package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
- package/.agents/scripts/lib/doc-tiers.js +291 -0
- package/.agents/scripts/lib/epic-body-sections.js +5 -2
- package/.agents/scripts/lib/epic-merge-lock.js +83 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
- package/.agents/scripts/lib/gates/friction.js +15 -5
- package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
- package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
- package/.agents/scripts/lib/observability/signal-validator.js +204 -0
- package/.agents/scripts/lib/observability/signals-writer.js +157 -54
- package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
- package/.agents/scripts/lib/orchestration/code-review.js +74 -4
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
- package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
- package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
- package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
- package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
- package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
- package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
- package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
- package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
- package/.agents/scripts/lib/planning-corpus.js +306 -0
- package/.agents/scripts/lib/signals/detectors/common.js +10 -10
- package/.agents/scripts/lib/signals/detectors/index.js +4 -4
- package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
- package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
- package/.agents/scripts/lib/signals/schema.js +56 -81
- package/.agents/scripts/lib/signals/span-tree.js +6 -5
- package/.agents/scripts/lib/story-plan.js +3 -0
- package/.agents/scripts/lib/wave-runner/tick.js +10 -2
- package/.agents/scripts/lifecycle-emit.js +39 -8
- package/.agents/scripts/providers/github/issues.js +12 -1
- package/.agents/scripts/resolve-doc-tiers.js +83 -0
- package/.agents/scripts/retro-run.js +51 -0
- package/.agents/scripts/signals-view.js +1 -1
- package/.agents/scripts/single-story-close.js +20 -1
- package/.agents/scripts/standalone-feedback-rollup.js +188 -0
- package/.agents/scripts/story-close.js +48 -0
- package/.agents/scripts/story-plan.js +51 -12
- package/.agents/scripts/validate-docs-freshness.js +69 -15
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
- package/.agents/skills/core/scope-triage/SKILL.md +61 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/workflows/audit-documentation.md +82 -2
- package/.agents/workflows/helpers/code-review.md +193 -44
- package/.agents/workflows/helpers/deliver-epic.md +128 -39
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -283
- package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
- package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
- package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
- package/.agents/workflows/helpers/plan-epic.md +141 -105
- package/.agents/workflows/helpers/plan-story.md +32 -0
- package/.agents/workflows/helpers/single-story-deliver.md +43 -0
- package/.agents/workflows/loops/nightly-audit.md +9 -7
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/doctor.js +44 -0
- package/package.json +4 -3
- package/.agents/scripts/epic-plan-spec-validate.js +0 -111
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- 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
|
|
9
|
+
* `staleReason: "..."`, `staleDetectedAt: "<iso>"` when a reference is
|
|
10
|
+
* **confirmed** dead.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
* - `{
|
|
234
|
-
* - `{
|
|
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({
|
|
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;
|
|
242
|
-
resolve({
|
|
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({
|
|
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', () =>
|
|
396
|
+
child.on('error', () => settle({ status: 'unknown' }));
|
|
262
397
|
child.on('close', (code) => {
|
|
263
398
|
if (code === 0) {
|
|
264
|
-
|
|
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
|
-
//
|
|
272
|
-
|
|
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
|
|
279
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
444
|
+
return {
|
|
445
|
+
status: 'dead',
|
|
446
|
+
reason: `file reference no longer exists: ${filePath}`,
|
|
447
|
+
};
|
|
300
448
|
}
|
|
301
449
|
}
|
|
302
450
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
624
|
+
let verdict;
|
|
429
625
|
try {
|
|
430
|
-
|
|
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
|
-
|
|
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;
|