@event4u/agent-config 14.1.0 → 14.4.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +291 -181
- package/agents/templates/.ai-council.yml.example +35 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
- package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
- package/dist/agent-src/scripts/resume_probe.ts +154 -9
- package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
- package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
- package/dist/agent-src/templates/agent-settings.md +70 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/dist/cli/python/workspace_drive.js +8 -3
- package/dist/cli/python/workspace_drive.js.map +1 -1
- package/dist/cli/python/workspace_hosts.js +16 -12
- package/dist/cli/python/workspace_hosts.js.map +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
- package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
- package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
- package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +26 -11
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +17 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +3773 -1731
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1070 -408
- package/dist/scripts/_lib/host_launch.js +66 -0
- package/dist/scripts/_lib/host_launch.js.map +1 -0
- package/dist/server/io/yamlIO.js +175 -3
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/wizard.js +20 -1
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/settingsConsent.js +25 -0
- package/dist/shared/settingsConsent.js.map +1 -1
- package/docs/contracts/settings-classes.md +11 -0
- package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/resume_probe.ts +154 -9
- package/src/agent-src/scripts/roadmap_gates.ts +210 -6
- package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/src/config/estate-count-budget.json +120 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +32 -4
- package/src/config/gate-violation-baselines.json +63 -63
- package/src/config/gitignore-block.txt +5 -0
- package/src/config/hook-latency-budget.json +40 -1
- package/src/config/hook-token-budget.json +22 -0
- package/src/config/rule-stub-ceilings.json +11 -3
- package/src/scripts/_dispatch.bash +42 -1
- package/src/scripts/_lib/council_fallback_posture.ts +81 -0
- package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
- package/src/scripts/_lib/council_settings_block.ts +152 -0
- package/src/scripts/_lib/headless_invocation.ts +285 -0
- package/src/scripts/_lib/host_launch.ts +72 -0
- package/src/scripts/_lib/map_to_object.ts +16 -0
- package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
- package/src/scripts/_lib/run_checkpoint.ts +409 -0
- package/src/scripts/_lib/session_register.ts +32 -1
- package/src/scripts/_lib/skill_catalogue_series.ts +91 -0
- package/src/scripts/_lib/stdin.ts +35 -1
- package/src/scripts/_lib/turn_end_refusals.ts +12 -4
- package/src/scripts/_lib/unattended_guard.ts +371 -0
- package/src/scripts/ai_council/config.ts +27 -1
- package/src/scripts/ai_council/events_log.ts +12 -1
- package/src/scripts/ai_council/fallback_config.ts +114 -0
- package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
- package/src/scripts/ai_council/orchestrator.ts +251 -292
- package/src/scripts/ai_council/py_parity.ts +139 -0
- package/src/scripts/ai_council/response_render.ts +99 -0
- package/src/scripts/ai_council/spend_gate.ts +123 -0
- package/src/scripts/ai_council/transport_resolver.ts +81 -13
- package/src/scripts/bench_hook_injection.ts +100 -6
- package/src/scripts/bench_hook_latency.ts +442 -16
- package/src/scripts/check_estate_count.ts +924 -0
- package/src/scripts/compile_hook_manifest.ts +67 -0
- package/src/scripts/council_cli.ts +100 -125
- package/src/scripts/decision_memo.ts +325 -0
- package/src/scripts/dispatch_r2_reviewer.ts +169 -15
- package/src/scripts/hook_manifest.json +1 -0
- package/src/scripts/hook_manifest.yaml +172 -25
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +183 -10
- package/src/scripts/hooks/dispatch_issues.ts +26 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
- package/src/scripts/hooks/hook_stdin.ts +111 -5
- package/src/scripts/hooks/injection_budget.ts +595 -0
- package/src/scripts/hooks/payload_stub.ts +446 -0
- package/src/scripts/hooks/run_continuation_hook.ts +627 -0
- package/src/scripts/hooks/session_eol_hook.ts +52 -0
- package/src/scripts/hooks/skill_route_hook.ts +172 -11
- package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
- package/src/scripts/hooks_doctor.ts +16 -2
- package/src/scripts/install-hooks.sh +26 -0
- package/src/scripts/interruption_report.ts +330 -13
- package/src/scripts/lint_hook_manifest.ts +167 -0
- package/src/scripts/roadmap_progress_hook.ts +254 -46
- package/src/scripts/run_supervise.ts +623 -0
- package/src/scripts/session_register_hook.ts +108 -8
- package/src/scripts/sessions_cli.ts +18 -6
- package/src/scripts/telemetry_disclosure_hook.ts +244 -0
- package/src/server/io/yamlIO.ts +167 -3
- package/src/shared/settingsConsent.ts +49 -2
- package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checkbox vocabulary and the phase-span rule, in one place.
|
|
3
|
+
*
|
|
4
|
+
* R2 review, findings 11 and 15. Two scanners on the autonomous-run path —
|
|
5
|
+
* `run_checkpoint.countRoadmap` and `run_continuation_hook.scanOpenSteps` —
|
|
6
|
+
* each carried their own narrower regex and each scanned the WHOLE file, while
|
|
7
|
+
* both docblocks claimed the dashboard's vocabulary. Two consequences, and
|
|
8
|
+
* they pull in opposite directions:
|
|
9
|
+
*
|
|
10
|
+
* - NARROWER VOCABULARY under-counts. Only `-` bullets and a lowercase `x`
|
|
11
|
+
* matched, so a roadmap authored with `*` bullets or `[X]` read
|
|
12
|
+
* `open === 0` and `run_supervise.classify` reported disposition
|
|
13
|
+
* `complete` — "the run finished, the session just never said so" — for a
|
|
14
|
+
* run with real open work. That is the exact confusion the watcher exists
|
|
15
|
+
* to remove.
|
|
16
|
+
* - WHOLE-FILE SCANNING over-counts. The dashboard attributes checkboxes to
|
|
17
|
+
* `## Phase` spans; these did not. On the very roadmap under review every
|
|
18
|
+
* phase step is closed and the only `- [ ]` sits under `## Acceptance criteria`, so
|
|
19
|
+
* the continuation would have named "A killed session resumes via the
|
|
20
|
+
* watcher…" as the next step — an observation of a live multi-day run, not
|
|
21
|
+
* an executable step, and by construction with no `verify:` line. Steps
|
|
22
|
+
* under `## Blockers` and `## Risk Register` are the same shape.
|
|
23
|
+
*
|
|
24
|
+
* The vocabulary is deliberately the dashboard's, character for character:
|
|
25
|
+
* where an autonomous run and the dashboard disagree about whether a roadmap
|
|
26
|
+
* is finished, the disagreement is the defect.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Bullet `-` or `*`, mark ` `, `x`, `X`, `~` or `-`; body captured. */
|
|
30
|
+
export const CHECKBOX_LINE = /^[ \t]*[-*][ \t]+\[([ xX~-])\][ \t]+(.*)$/;
|
|
31
|
+
|
|
32
|
+
/** H2/H3 heading, its text captured. */
|
|
33
|
+
const HEADING = /^(#{2,3})[ \t]+(.*?)[ \t]*$/;
|
|
34
|
+
|
|
35
|
+
/** An H2/H3 that opens a phase span, matching the dashboard's PHASE_RE shape. */
|
|
36
|
+
const PHASE_HEADING = /^Phase[ \t]+(?:\d+(?:\.\d+)*[a-z]?|[IVX]+|[A-Z](?:\d+)?)\b/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The lines of `text` that sit inside a `## Phase …` span.
|
|
40
|
+
*
|
|
41
|
+
* A phase span runs from its heading to the next heading AT ITS OWN LEVEL OR
|
|
42
|
+
* SHALLOWER. A DEEPER heading does not close it — an `### Measurement A`
|
|
43
|
+
* inside a `## Phase 1` is part of that phase, not the end of it.
|
|
44
|
+
*
|
|
45
|
+
* R2 round 6, critical finding 2, and it was live in this tree rather than
|
|
46
|
+
* hypothetical. The first version closed the span at ANY H2/H3, so on
|
|
47
|
+
* `road-to-ui-track-integrity-followup.md` — whose `## Phase 1` is immediately
|
|
48
|
+
* followed by three `###` sub-headings — `countRoadmap` returned `open: 0`
|
|
49
|
+
* against ten real open checkboxes. `ladder` then answers `complete` and
|
|
50
|
+
* `run_supervise` reports "the run finished, the session just never said so".
|
|
51
|
+
* That is the exact false-completion this module was written to prevent,
|
|
52
|
+
* produced by the module itself.
|
|
53
|
+
*
|
|
54
|
+
* `## Blockers`, `## Acceptance criteria` and `## Risk Register` still close a
|
|
55
|
+
* `## Phase` because they are H2s — the level rule gives that for free, and it
|
|
56
|
+
* is why the rule is a level comparison rather than a list of section names
|
|
57
|
+
* nobody would keep current.
|
|
58
|
+
*
|
|
59
|
+
* A roadmap with NO phase heading at all yields every line. That is the
|
|
60
|
+
* deliberate choice and it is the safe direction: an unphased roadmap is a
|
|
61
|
+
* legitimate shape, and returning nothing for it would make every such run
|
|
62
|
+
* read as `complete`, which is the more dangerous of the two errors.
|
|
63
|
+
*/
|
|
64
|
+
export function phaseLines(text: string): string[] {
|
|
65
|
+
const lines = text.split('\n');
|
|
66
|
+
const out: string[] = [];
|
|
67
|
+
// The heading level of the phase currently open, or 0 when none is.
|
|
68
|
+
let phaseLevel = 0;
|
|
69
|
+
let sawPhase = false;
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
const h = HEADING.exec(line);
|
|
72
|
+
if (h !== null) {
|
|
73
|
+
const level = (h[1] as string).length;
|
|
74
|
+
const isPhase = PHASE_HEADING.test(h[2] ?? '');
|
|
75
|
+
if (isPhase) {
|
|
76
|
+
phaseLevel = level;
|
|
77
|
+
sawPhase = true;
|
|
78
|
+
} else if (phaseLevel !== 0 && level <= phaseLevel) {
|
|
79
|
+
// A sibling or shallower section ends the phase.
|
|
80
|
+
phaseLevel = 0;
|
|
81
|
+
}
|
|
82
|
+
// A DEEPER non-phase heading leaves the span open.
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (phaseLevel !== 0) out.push(line);
|
|
86
|
+
}
|
|
87
|
+
return sawPhase ? out : lines;
|
|
88
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run_checkpoint — the deterministic checkpoint a dying run leaves behind,
|
|
3
|
+
* and the re-verification a resumed run owes before trusting it.
|
|
4
|
+
*
|
|
5
|
+
* road-to-long-horizon-execution Phase 3.0 / 3.2 (sequencing UOTL Phase 6.1).
|
|
6
|
+
*
|
|
7
|
+
* ## Why "deterministic" is the load-bearing word
|
|
8
|
+
*
|
|
9
|
+
* A handoff summary is authored — a model writes prose about where the work
|
|
10
|
+
* stands, and prose can be wrong in ways nothing catches. A checkpoint is
|
|
11
|
+
* DERIVED: every field here is recomputed from files on disk, so writing one
|
|
12
|
+
* costs no judgement and reading one back can be checked against the tree
|
|
13
|
+
* that produced it. The two are complements, not alternatives: the summary
|
|
14
|
+
* says what the run was thinking, this says what the run had actually done.
|
|
15
|
+
*
|
|
16
|
+
* ## Why a resumed run re-verifies rather than trusts
|
|
17
|
+
*
|
|
18
|
+
* The reference design this borrows from resumes by BOOKKEEPING — the daemon
|
|
19
|
+
* believes its own record of where a session was. That is fine while nothing
|
|
20
|
+
* else touches the tree, and wrong the moment something does: a human
|
|
21
|
+
* committing, a sibling worktree, a partially-applied edit the dying session
|
|
22
|
+
* never finished. So `verifyCheckpoint` recomputes the same fields and reports
|
|
23
|
+
* a per-field agreement rather than a boolean — a resumed run should be able
|
|
24
|
+
* to say WHICH claim went stale, not merely that something did.
|
|
25
|
+
*
|
|
26
|
+
* A disagreement is explicitly NOT an error here. Work landing between the
|
|
27
|
+
* checkpoint and the resume is the normal case, and a verifier that treats
|
|
28
|
+
* progress as corruption would refuse every healthy resume.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import * as fs from 'node:fs';
|
|
32
|
+
import * as path from 'node:path';
|
|
33
|
+
|
|
34
|
+
import { CHECKBOX_LINE, phaseLines } from './roadmap_checkboxes.js';
|
|
35
|
+
|
|
36
|
+
export const CHECKPOINT_DIR_REL = path.join('agents', 'runtime', 'state', 'checkpoints');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A run's derived state at the moment its session ended.
|
|
40
|
+
*
|
|
41
|
+
* Every field is recomputed, never remembered — that is what makes
|
|
42
|
+
* `verifyCheckpoint` possible at all.
|
|
43
|
+
*/
|
|
44
|
+
export interface RunCheckpoint {
|
|
45
|
+
readonly schema_version: 1;
|
|
46
|
+
/** The session-derived run id this checkpoint belongs to. */
|
|
47
|
+
readonly run_id: string;
|
|
48
|
+
/** Roadmap slug from the session's `sessions:claim` file. */
|
|
49
|
+
readonly roadmap: string;
|
|
50
|
+
/** Steps still `[ ]` at checkpoint time. */
|
|
51
|
+
readonly open_steps: number;
|
|
52
|
+
/** Steps already `[x]` at checkpoint time. */
|
|
53
|
+
readonly done_steps: number;
|
|
54
|
+
/** `[~]` deferred plus `[-]` cancelled — neither is open work. */
|
|
55
|
+
readonly parked_steps: number;
|
|
56
|
+
/** The next `[ ]` step's text, trimmed, or `null` when none remains. */
|
|
57
|
+
readonly next_step: string | null;
|
|
58
|
+
/** Commit the tree was on. A resume onto a different commit is normal, and visible. */
|
|
59
|
+
readonly head: string | null;
|
|
60
|
+
readonly written_at: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Counts derived from a roadmap body — the same vocabulary the loop uses. */
|
|
64
|
+
export interface RoadmapCounts {
|
|
65
|
+
open: number;
|
|
66
|
+
done: number;
|
|
67
|
+
parked: number;
|
|
68
|
+
next: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Count checkboxes in a roadmap body.
|
|
73
|
+
*
|
|
74
|
+
* `[~]` (deferred) and `[-]` (cancelled) are PARKED, not open: a resumed run
|
|
75
|
+
* that treated them as work to do would re-engage into the exact items a human
|
|
76
|
+
* decided not to do, which is the anti-stall mechanism manufacturing a stall.
|
|
77
|
+
*/
|
|
78
|
+
export function countRoadmap(text: string): RoadmapCounts {
|
|
79
|
+
const counts: RoadmapCounts = { open: 0, done: 0, parked: 0, next: null };
|
|
80
|
+
// Phase spans only, and the dashboard's own vocabulary — see
|
|
81
|
+
// `_lib/roadmap_checkboxes.ts` for what each half was getting wrong.
|
|
82
|
+
for (const raw of phaseLines(text)) {
|
|
83
|
+
const m = CHECKBOX_LINE.exec(raw);
|
|
84
|
+
if (m === null) continue;
|
|
85
|
+
const mark = m[1];
|
|
86
|
+
if (mark === 'x' || mark === 'X') {
|
|
87
|
+
counts.done += 1;
|
|
88
|
+
} else if (mark === '~' || mark === '-') {
|
|
89
|
+
counts.parked += 1;
|
|
90
|
+
} else {
|
|
91
|
+
counts.open += 1;
|
|
92
|
+
if (counts.next === null) {
|
|
93
|
+
counts.next = (m[2] ?? '').trim() || null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return counts;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function checkpointFile(repoRoot: string, runId: string): string {
|
|
101
|
+
return path.join(repoRoot, CHECKPOINT_DIR_REL, `${runId.replace(/[^A-Za-z0-9_-]/g, '_')}.json`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function roadmapPath(repoRoot: string, slug: string): string {
|
|
105
|
+
return path.join(repoRoot, 'agents', 'roadmaps', `${slug}.md`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface BuildOptions {
|
|
109
|
+
/** Injected so a test does not depend on the ambient git state. */
|
|
110
|
+
readonly head?: string | null;
|
|
111
|
+
readonly now?: () => Date;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The current commit, read straight off `.git` — no subprocess.
|
|
116
|
+
*
|
|
117
|
+
* R2 round 2, finding 5. `head` was populated only from `opts.head`, and the
|
|
118
|
+
* ONE production caller (`session_eol_hook`) never passed it, so the field was
|
|
119
|
+
* always `null` while the checkpoint's own docblock, the loop contract and the
|
|
120
|
+
* roadmap all describe it as carrying the commit the tree was on. Only the
|
|
121
|
+
* test injected a value, which is why nothing noticed.
|
|
122
|
+
*
|
|
123
|
+
* No `git rev-parse`: this runs on the Stop path, where a spawn is a cost paid
|
|
124
|
+
* on every reply. Two files answer it — and a WORKTREE is the case a naive
|
|
125
|
+
* reader gets wrong, because there `.git` is a FILE holding `gitdir: <path>`
|
|
126
|
+
* rather than a directory. This package is developed in worktrees, so the naive
|
|
127
|
+
* version would have returned null exactly where it was being exercised.
|
|
128
|
+
*
|
|
129
|
+
* Returns `null` on anything unexpected. A checkpoint without a commit is
|
|
130
|
+
* still useful; a Stop path that throws is not.
|
|
131
|
+
*/
|
|
132
|
+
export function readHead(repoRoot: string): string | null {
|
|
133
|
+
try {
|
|
134
|
+
let gitDir = path.join(repoRoot, '.git');
|
|
135
|
+
if (fs.statSync(gitDir).isFile()) {
|
|
136
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitDir, 'utf-8').trim());
|
|
137
|
+
if (m === null) return null;
|
|
138
|
+
gitDir = path.resolve(repoRoot, m[1] as string);
|
|
139
|
+
}
|
|
140
|
+
// HEAD is per-worktree; REFS are not. A linked worktree's gitdir holds
|
|
141
|
+
// its own HEAD and a `commondir` pointer, and `refs/` + `packed-refs`
|
|
142
|
+
// live at that commondir — never beside HEAD.
|
|
143
|
+
//
|
|
144
|
+
// R2 round 3, finding 1, and the round-2 version of this function got
|
|
145
|
+
// it exactly half right: it handled `.git`-as-a-file and then resolved
|
|
146
|
+
// refs against the worktree gitdir, so `head` was null in every linked
|
|
147
|
+
// worktree — including the one this package is developed in. Its test
|
|
148
|
+
// passed because the fixture wrote refs beside HEAD, a layout git never
|
|
149
|
+
// produces. A fixture that agrees with the code instead of with reality
|
|
150
|
+
// reads as coverage and is worse than no test at all.
|
|
151
|
+
let refRoot = gitDir;
|
|
152
|
+
try {
|
|
153
|
+
const common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf-8').trim();
|
|
154
|
+
if (common !== '') refRoot = path.resolve(gitDir, common);
|
|
155
|
+
} catch {
|
|
156
|
+
// No commondir — a plain repository, where gitDir already is it.
|
|
157
|
+
}
|
|
158
|
+
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf-8').trim();
|
|
159
|
+
// A detached HEAD is the sha itself.
|
|
160
|
+
if (/^[0-9a-f]{40}$/.test(head)) return head;
|
|
161
|
+
const ref = /^ref:\s*(.+)$/.exec(head);
|
|
162
|
+
if (ref === null) return null;
|
|
163
|
+
const refName = ref[1] as string;
|
|
164
|
+
try {
|
|
165
|
+
return fs.readFileSync(path.join(refRoot, refName), 'utf-8').trim() || null;
|
|
166
|
+
} catch {
|
|
167
|
+
// Packed refs: a loose file does not exist for every branch.
|
|
168
|
+
const packed = fs.readFileSync(path.join(refRoot, 'packed-refs'), 'utf-8');
|
|
169
|
+
for (const line of packed.split('\n')) {
|
|
170
|
+
const pm = /^([0-9a-f]{40})\s+(.+)$/.exec(line.trim());
|
|
171
|
+
if (pm !== null && pm[2] === refName) return pm[1] as string;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Derive a checkpoint from the tree. Returns `null` when the roadmap is
|
|
182
|
+
* unreadable — a checkpoint that guesses is worse than none, because the whole
|
|
183
|
+
* contract of this file is that its fields were computed rather than recalled.
|
|
184
|
+
*/
|
|
185
|
+
export function buildCheckpoint(
|
|
186
|
+
repoRoot: string,
|
|
187
|
+
runId: string,
|
|
188
|
+
slug: string,
|
|
189
|
+
opts: BuildOptions = {},
|
|
190
|
+
): RunCheckpoint | null {
|
|
191
|
+
let text: string;
|
|
192
|
+
try {
|
|
193
|
+
text = fs.readFileSync(roadmapPath(repoRoot, slug), 'utf-8');
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const counts = countRoadmap(text);
|
|
198
|
+
return {
|
|
199
|
+
schema_version: 1,
|
|
200
|
+
run_id: runId,
|
|
201
|
+
roadmap: slug,
|
|
202
|
+
open_steps: counts.open,
|
|
203
|
+
done_steps: counts.done,
|
|
204
|
+
parked_steps: counts.parked,
|
|
205
|
+
next_step: counts.next,
|
|
206
|
+
head: opts.head ?? readHead(repoRoot),
|
|
207
|
+
written_at: (opts.now ?? ((): Date => new Date()))().toISOString(),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Write a checkpoint. Best-effort: a failed write must never break a Stop. */
|
|
212
|
+
export function writeCheckpoint(repoRoot: string, cp: RunCheckpoint): string | null {
|
|
213
|
+
const file = checkpointFile(repoRoot, cp.run_id);
|
|
214
|
+
try {
|
|
215
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
216
|
+
fs.writeFileSync(file, `${JSON.stringify(cp, null, 2)}\n`, 'utf-8');
|
|
217
|
+
return file;
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function readCheckpoint(repoRoot: string, runId: string): RunCheckpoint | null {
|
|
224
|
+
try {
|
|
225
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(checkpointFile(repoRoot, runId), 'utf-8'));
|
|
226
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
227
|
+
const o = parsed as Record<string, unknown>;
|
|
228
|
+
if (typeof o['run_id'] !== 'string' || typeof o['roadmap'] !== 'string') return null;
|
|
229
|
+
if (typeof o['open_steps'] !== 'number' || typeof o['done_steps'] !== 'number') return null;
|
|
230
|
+
return parsed as RunCheckpoint;
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The most recent checkpoint for a ROADMAP, across every run id.
|
|
238
|
+
*
|
|
239
|
+
* R2 review, finding 7. `readCheckpoint` keys on the run id, which is derived
|
|
240
|
+
* from the session id of the session that DIED — so a relaunched session, which
|
|
241
|
+
* by definition has a new id and no index from slug to old id, could not reach
|
|
242
|
+
* its own checkpoint through any path in the tree. `verifyCheckpoint` and
|
|
243
|
+
* `renderVerification` had zero production call sites, and the loop contract
|
|
244
|
+
* nonetheless instructed a resumed run that "the first act is verifyCheckpoint".
|
|
245
|
+
* The mitigation Risk 5 names was unreachable as shipped.
|
|
246
|
+
*
|
|
247
|
+
* The roadmap slug is the one key a relaunched run genuinely holds: it claims
|
|
248
|
+
* the same roadmap, which is what makes it a resume rather than a new run.
|
|
249
|
+
*
|
|
250
|
+
* Ties are broken by `written_at` descending, then by filename, so the result
|
|
251
|
+
* is deterministic when two checkpoints share a timestamp. A malformed or
|
|
252
|
+
* unreadable file is skipped rather than failing the lookup — a resume that
|
|
253
|
+
* refuses to start because one stale JSON file is corrupt is worse than a
|
|
254
|
+
* resume that verifies against the newest readable one.
|
|
255
|
+
*/
|
|
256
|
+
export function latestCheckpointFor(repoRoot: string, roadmap: string): RunCheckpoint | null {
|
|
257
|
+
const dir = path.join(repoRoot, CHECKPOINT_DIR_REL);
|
|
258
|
+
let names: string[];
|
|
259
|
+
try {
|
|
260
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const found: RunCheckpoint[] = [];
|
|
265
|
+
for (const name of names.sort()) {
|
|
266
|
+
let parsed: unknown;
|
|
267
|
+
try {
|
|
268
|
+
parsed = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
|
|
269
|
+
} catch {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
|
|
273
|
+
const o = parsed as Record<string, unknown>;
|
|
274
|
+
if (o['roadmap'] !== roadmap) continue;
|
|
275
|
+
if (typeof o['run_id'] !== 'string') continue;
|
|
276
|
+
if (typeof o['open_steps'] !== 'number' || typeof o['done_steps'] !== 'number') continue;
|
|
277
|
+
found.push(parsed as RunCheckpoint);
|
|
278
|
+
}
|
|
279
|
+
if (found.length === 0) return null;
|
|
280
|
+
found.sort((a, b) => {
|
|
281
|
+
const at = String(a.written_at ?? '');
|
|
282
|
+
const bt = String(b.written_at ?? '');
|
|
283
|
+
if (at !== bt) return at < bt ? 1 : -1;
|
|
284
|
+
return a.run_id < b.run_id ? 1 : -1;
|
|
285
|
+
});
|
|
286
|
+
return found[0] ?? null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** One claim's verdict — the field, what was claimed, what is true now. */
|
|
290
|
+
export interface FieldVerdict {
|
|
291
|
+
readonly field: string;
|
|
292
|
+
readonly claimed: string | number | null;
|
|
293
|
+
readonly actual: string | number | null;
|
|
294
|
+
readonly agrees: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface VerifyResult {
|
|
298
|
+
/** `false` when the roadmap named by the checkpoint no longer reads. */
|
|
299
|
+
readonly readable: boolean;
|
|
300
|
+
readonly fields: readonly FieldVerdict[];
|
|
301
|
+
/** Every field agreed. NOT a precondition for resuming — see the header. */
|
|
302
|
+
readonly agrees: boolean;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Recompute the checkpoint's claims against the tree as it is NOW.
|
|
307
|
+
*
|
|
308
|
+
* Reports per-field rather than a boolean on purpose: "the checkpoint is
|
|
309
|
+
* stale" tells a resumed run nothing it can act on, while "open_steps claimed
|
|
310
|
+
* 4, actual 3" tells it a step landed after the checkpoint was written and
|
|
311
|
+
* which count to trust.
|
|
312
|
+
*/
|
|
313
|
+
export function verifyCheckpoint(repoRoot: string, cp: RunCheckpoint): VerifyResult {
|
|
314
|
+
let text: string;
|
|
315
|
+
try {
|
|
316
|
+
text = fs.readFileSync(roadmapPath(repoRoot, cp.roadmap), 'utf-8');
|
|
317
|
+
} catch {
|
|
318
|
+
return { readable: false, fields: [], agrees: false };
|
|
319
|
+
}
|
|
320
|
+
const counts = countRoadmap(text);
|
|
321
|
+
// Read ONCE, so the recorded `actual` and the verdict about it are one
|
|
322
|
+
// expression rather than two that happen to agree.
|
|
323
|
+
const head = readHead(repoRoot);
|
|
324
|
+
const fields: FieldVerdict[] = [
|
|
325
|
+
{ field: 'open_steps', claimed: cp.open_steps, actual: counts.open, agrees: cp.open_steps === counts.open },
|
|
326
|
+
{ field: 'done_steps', claimed: cp.done_steps, actual: counts.done, agrees: cp.done_steps === counts.done },
|
|
327
|
+
{ field: 'parked_steps', claimed: cp.parked_steps, actual: counts.parked, agrees: cp.parked_steps === counts.parked },
|
|
328
|
+
{ field: 'next_step', claimed: cp.next_step, actual: counts.next, agrees: cp.next_step === counts.next },
|
|
329
|
+
// `head` is recomputed like every other field. R2 round 3, finding 2:
|
|
330
|
+
// round 2 fixed only the WRITE half of finding 5, so the loop
|
|
331
|
+
// contract's "every field is recomputed" was false for exactly the
|
|
332
|
+
// field that says whether the tree moved under the run — which is the
|
|
333
|
+
// one a resume most needs to see. A disagreement here is the NORMAL
|
|
334
|
+
// case (a human committed, a sibling worktree moved) and is reported,
|
|
335
|
+
// never treated as corruption.
|
|
336
|
+
{
|
|
337
|
+
field: 'head',
|
|
338
|
+
claimed: cp.head,
|
|
339
|
+
actual: head,
|
|
340
|
+
// A `null` on EITHER side is "not known", never "different".
|
|
341
|
+
//
|
|
342
|
+
// R2 round 4, finding 2. `readHead` is explicitly designed to
|
|
343
|
+
// return null on any failure — no git, an unreadable ref, a
|
|
344
|
+
// packed-refs miss — so a strict `===` turned "I could not read
|
|
345
|
+
// the commit" into "the tree moved since the checkpoint", on a
|
|
346
|
+
// resume at the SAME commit with every other field agreeing. That
|
|
347
|
+
// is a false alarm on the one field whose whole job is to tell a
|
|
348
|
+
// human whether anything moved, and a false alarm there costs more
|
|
349
|
+
// than a missing one: it trains the reader to skip the line.
|
|
350
|
+
agrees: cp.head === null || head === null || cp.head === head,
|
|
351
|
+
},
|
|
352
|
+
];
|
|
353
|
+
return { readable: true, fields, agrees: fields.every((f) => f.agrees) };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** A one-line-per-field report a resumed run can put in front of a human. */
|
|
357
|
+
export function renderVerification(cp: RunCheckpoint, res: VerifyResult): string {
|
|
358
|
+
if (!res.readable) {
|
|
359
|
+
return (
|
|
360
|
+
`checkpoint ${cp.run_id}: roadmap '${cp.roadmap}' no longer reads — ` +
|
|
361
|
+
`the claimed state cannot be re-verified, so none of it may be assumed.`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
const lines = [`checkpoint ${cp.run_id} · roadmap ${cp.roadmap} · written ${cp.written_at}`];
|
|
365
|
+
// Three states, not two. R2 round 5, finding 3: the null-head tolerance
|
|
366
|
+
// added in round 4 is correct as a VERDICT — an unknown commit is not a
|
|
367
|
+
// moved one — but rendering it as `ok head: null` alongside "the tree
|
|
368
|
+
// still matches the checkpoint" tells the reader something was checked
|
|
369
|
+
// when nothing could be. UNKNOWN is a third word because the reader's next
|
|
370
|
+
// action differs: `ok` needs nothing, `CHANGED` names what moved, and
|
|
371
|
+
// UNKNOWN says this axis carries no information and the other four are
|
|
372
|
+
// what the verdict rests on.
|
|
373
|
+
// ONLY `head` can be unknown. R2 round 6, finding 6: the predicate was
|
|
374
|
+
// written for `head` — where `null` means "could not read" — and applied
|
|
375
|
+
// to all five fields, but for `next_step` a `null` is a documented VALUE
|
|
376
|
+
// ("or `null` when none remains"). A finished roadmap therefore rendered
|
|
377
|
+
// `UNKNOWN next_step: not readable` and claimed a field had not been
|
|
378
|
+
// compared when it had been, on the exact state a resume most wants to
|
|
379
|
+
// see. The field name is the discriminator because the meaning of `null`
|
|
380
|
+
// is a property of the field, not of the value.
|
|
381
|
+
const UNKNOWNABLE: ReadonlySet<string> = new Set(['head']);
|
|
382
|
+
let unknown = 0;
|
|
383
|
+
for (const f of res.fields) {
|
|
384
|
+
if (UNKNOWNABLE.has(f.field) && (f.claimed === null || f.actual === null)) {
|
|
385
|
+
unknown += 1;
|
|
386
|
+
lines.push(
|
|
387
|
+
` UNKNOWN ${f.field}: not readable ` +
|
|
388
|
+
`(claimed ${String(f.claimed)}, actual ${String(f.actual)}) — not compared`,
|
|
389
|
+
);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
lines.push(
|
|
393
|
+
f.agrees
|
|
394
|
+
? ` ok ${f.field}: ${String(f.actual)}`
|
|
395
|
+
: ` CHANGED ${f.field}: claimed ${String(f.claimed)} → actual ${String(f.actual)}`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
if (!res.agrees) {
|
|
399
|
+
lines.push(' the tree moved since the checkpoint — the ACTUAL column is what to resume from.');
|
|
400
|
+
} else if (unknown > 0) {
|
|
401
|
+
lines.push(
|
|
402
|
+
` the ${res.fields.length - unknown} comparable field(s) match; ` +
|
|
403
|
+
`${unknown} could not be read and were not compared.`,
|
|
404
|
+
);
|
|
405
|
+
} else {
|
|
406
|
+
lines.push(' the tree still matches the checkpoint.');
|
|
407
|
+
}
|
|
408
|
+
return lines.join('\n');
|
|
409
|
+
}
|
|
@@ -321,10 +321,37 @@ export function delete_record(dir: string, session_id: string): boolean {
|
|
|
321
321
|
export interface ReadLiveOptions {
|
|
322
322
|
/** Unlink expired records as they are encountered. Default `false`. */
|
|
323
323
|
prune?: boolean;
|
|
324
|
+
/**
|
|
325
|
+
* How long an EXPIRED record survives the prune. Default 24 h.
|
|
326
|
+
*
|
|
327
|
+
* R2 round 4, finding 5. The register has two consumers with opposite
|
|
328
|
+
* needs and only one of them knew: `sessions:list` and the session-start
|
|
329
|
+
* hook both read with `prune: true`, while `run:supervise` exists
|
|
330
|
+
* ENTIRELY to classify expired records — the runs whose session died. So
|
|
331
|
+
* the watcher's whole input was deleted by two routine read paths, the
|
|
332
|
+
* hook one of them, which fires on every session start. The digest could
|
|
333
|
+
* then report nothing, and round 3's `clearCompleted` release could never
|
|
334
|
+
* fire because no completed candidate ever reached it.
|
|
335
|
+
*
|
|
336
|
+
* The 24 h is derived, not picked: `digest` is documented as the MORNING
|
|
337
|
+
* report, so a once-a-day reader must be able to see a full day of deaths.
|
|
338
|
+
* A shorter grace loses runs that died overnight, which is precisely the
|
|
339
|
+
* population the watcher was built for.
|
|
340
|
+
*
|
|
341
|
+
* `0` restores the old delete-on-sight behaviour for a caller that really
|
|
342
|
+
* wants it. Nothing in the tree passes it today.
|
|
343
|
+
*/
|
|
344
|
+
prune_grace_ms?: number;
|
|
324
345
|
/** Clock injection for tests. */
|
|
325
346
|
now?: Date;
|
|
326
347
|
}
|
|
327
348
|
|
|
349
|
+
/**
|
|
350
|
+
* How long an expired record outlives its expiry before a prune may remove it.
|
|
351
|
+
* See {@link ReadLiveOptions.prune_grace_ms} for why this is a day.
|
|
352
|
+
*/
|
|
353
|
+
export const PRUNE_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
354
|
+
|
|
328
355
|
/**
|
|
329
356
|
* Every live record in the register, optionally pruning expired ones as it goes.
|
|
330
357
|
*
|
|
@@ -339,6 +366,7 @@ export interface ReadLiveOptions {
|
|
|
339
366
|
export function read_live_records(dir: string, options: ReadLiveOptions = {}): SessionRecord[] {
|
|
340
367
|
const now = options.now ?? new Date();
|
|
341
368
|
const prune = options.prune === true;
|
|
369
|
+
const grace = options.prune_grace_ms ?? PRUNE_GRACE_MS;
|
|
342
370
|
let names: string[];
|
|
343
371
|
try {
|
|
344
372
|
names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
|
|
@@ -356,7 +384,10 @@ export function read_live_records(dir: string, options: ReadLiveOptions = {}): S
|
|
|
356
384
|
rec = null;
|
|
357
385
|
}
|
|
358
386
|
if (rec === null || is_expired(rec, now)) {
|
|
359
|
-
|
|
387
|
+
// A record is dropped from the LIVE set the moment it expires, and
|
|
388
|
+
// deleted from disk only after the grace window — the watcher
|
|
389
|
+
// reads the register directly and needs the expired ones.
|
|
390
|
+
if (prune && (rec === null || is_expired(rec, new Date(now.getTime() - grace)))) {
|
|
360
391
|
try {
|
|
361
392
|
fs.unlinkSync(full);
|
|
362
393
|
} catch {
|
|
@@ -409,3 +409,94 @@ export function formatPointableBare(
|
|
|
409
409
|
}
|
|
410
410
|
return lines.join('\n');
|
|
411
411
|
}
|
|
412
|
+
|
|
413
|
+
/* ------------------------------------------------------------------ *
|
|
414
|
+
* The delivery filter — what the runtime router must not name.
|
|
415
|
+
* ------------------------------------------------------------------ */
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The bare names one host is KNOWN to have delivered, or `null` when nothing
|
|
419
|
+
* about that host's per-entry delivery was ever enumerated.
|
|
420
|
+
*
|
|
421
|
+
* `joinPointableBare` above answers "how large is the D-4 divergence" for an
|
|
422
|
+
* operator report. This answers the runtime question the same log can settle:
|
|
423
|
+
* given this host, which skill names must a pointer line not name? It is the
|
|
424
|
+
* reducer `skill_route_hook` consumes, and it lives here rather than in that
|
|
425
|
+
* hook for one reason — every guard below is already argued in this module's
|
|
426
|
+
* prose, and a second reducer over the same log is how two readers start
|
|
427
|
+
* breaking the same tie in opposite directions (`_supersedes`, in the parent
|
|
428
|
+
* module, exists because that happened once and printed two drop counts for one
|
|
429
|
+
* host on one day).
|
|
430
|
+
*
|
|
431
|
+
* THE THREE-WAY DISTINCTION IS THE WHOLE POINT, and collapsing any two of them
|
|
432
|
+
* is the failure:
|
|
433
|
+
*
|
|
434
|
+
* - **`null` — nothing was enumerated.** No record for this host, or none of
|
|
435
|
+
* its records is a `per-entry` observation, or the latest one carries no
|
|
436
|
+
* `bare_names` array. The consumer must fail OPEN: filtering on this would
|
|
437
|
+
* be a zero inferred from silence, and a filter that quietly narrows on
|
|
438
|
+
* missing data is worse than the divergence it treats.
|
|
439
|
+
* - **An EMPTY set — enumerated, and nothing was bare.** A legitimate answer,
|
|
440
|
+
* and behaviourally identical to `null` at today's only consumer. It is
|
|
441
|
+
* still returned distinctly, because "measured clean" and "never measured"
|
|
442
|
+
* are different facts and this module refuses to let them look alike.
|
|
443
|
+
* - **A NON-EMPTY set — these names went dark.** The pointer must not name
|
|
444
|
+
* them.
|
|
445
|
+
*
|
|
446
|
+
* A `budget-strip-and-drop` host is therefore always `null` here, never an
|
|
447
|
+
* empty set: it publishes no per-entry list at all, so its empty `bare_names`
|
|
448
|
+
* records that nothing was counted. That is the same skip `joinPointableBare`
|
|
449
|
+
* makes, for the same reason, and it is why a codex install gets no filtering
|
|
450
|
+
* rather than a filter that believes nothing was degraded.
|
|
451
|
+
*
|
|
452
|
+
* LATEST WINS, per host, by `observed_at` — the `latestPointableBarePerHost`
|
|
453
|
+
* tie-break rather than a union across the series. A union would let a
|
|
454
|
+
* superseded observation keep suppressing a skill the host now delivers fine,
|
|
455
|
+
* which is a filter that only ever narrows; the whole series is available to an
|
|
456
|
+
* operator report, and a runtime filter wants the current reading.
|
|
457
|
+
*
|
|
458
|
+
* `bare_names` is guarded with `Array.isArray`, and `observed_at` with a
|
|
459
|
+
* `typeof` check, because `readObservationLog` produces records by an unchecked
|
|
460
|
+
* `JSON.parse … as` over an append-only log with more than one producer — the
|
|
461
|
+
* same guard, for the same recorded reason, that `joinPointableBare` and
|
|
462
|
+
* `knownHostLimits` carry. Both guards resolve to `null`, never to an older
|
|
463
|
+
* reading: a malformed LATEST record means the current state is unknown, and
|
|
464
|
+
* falling back to a superseded one would be the stale-suppression failure this
|
|
465
|
+
* function's own tie-break exists to prevent.
|
|
466
|
+
*/
|
|
467
|
+
export function knownBareNames(
|
|
468
|
+
records: readonly ObservationRecord[],
|
|
469
|
+
host: string,
|
|
470
|
+
): Set<string> | null {
|
|
471
|
+
// The latest record for the host decides, and the mode is read OFF that
|
|
472
|
+
// record rather than used to select it. R2 finding 2: filtering `per-entry`
|
|
473
|
+
// first meant a NEWER `budget-strip-and-drop` record could never supersede
|
|
474
|
+
// an older `per-entry` one, so a host that changed truncation mode kept
|
|
475
|
+
// being filtered against a bare set from before the change — the precise
|
|
476
|
+
// failure the LATEST-WINS paragraph above claims to prevent.
|
|
477
|
+
//
|
|
478
|
+
// `headlineRecordPerHost` rather than a fourth inline tie-break (R2 finding
|
|
479
|
+
// 7): this module already imports it, already defines
|
|
480
|
+
// `latestPointableBarePerHost`, and its own comment cites `_supersedes` as
|
|
481
|
+
// the reason duplicate reducers are dangerous. Restating the rule here would
|
|
482
|
+
// have meant the runtime filter silently not inheriting any precedence the
|
|
483
|
+
// parent adds later.
|
|
484
|
+
//
|
|
485
|
+
// The `observed_at` pre-filter is R2 finding 6, and it is a pre-filter
|
|
486
|
+
// rather than a change to `_supersedes` because that reducer is shared.
|
|
487
|
+
// `_supersedes` compares dates with `!==` then `>`, and BOTH
|
|
488
|
+
// `undefined > "2026-08-12"` and `"2026-08-12" > undefined` are false — so a
|
|
489
|
+
// record with a missing or non-string date, once it becomes the incumbent,
|
|
490
|
+
// can never be displaced by a well-formed later one. One malformed line in
|
|
491
|
+
// an append-only log with two producers would pin the filter to it forever.
|
|
492
|
+
// Feeding the shared reducer only well-dated records avoids that without
|
|
493
|
+
// changing behaviour for its other callers.
|
|
494
|
+
const dated = records.filter(
|
|
495
|
+
(record) => record.host === host && typeof record.observed_at === 'string',
|
|
496
|
+
);
|
|
497
|
+
const latest = headlineRecordPerHost(dated).get(host);
|
|
498
|
+
if (latest === undefined) return null;
|
|
499
|
+
if (truncationModeOf(latest) !== 'per-entry') return null;
|
|
500
|
+
if (!Array.isArray(latest.bare_names)) return null;
|
|
501
|
+
return new Set(latest.bare_names.filter((name): name is string => typeof name === 'string'));
|
|
502
|
+
}
|