@juzser/blacksmith 0.3.0 → 0.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.
Files changed (46) hide show
  1. package/.claude/agents/auditor.md +3 -0
  2. package/.claude/agents/grader.md +12 -5
  3. package/.claude/agents/reviewer.md +3 -0
  4. package/.claude/agents/security-reviewer.md +3 -0
  5. package/.claude/agents/spec-reviewer.md +3 -0
  6. package/.claude/agents/verifier.md +3 -0
  7. package/.claude/skills/bs/audit.md +12 -5
  8. package/.claude/skills/bs/dispatch.md +40 -4
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +11 -5
  11. package/.claude/skills/bs/wave.md +40 -3
  12. package/README.md +1 -1
  13. package/factory/orchestrator/dist/agentsSync.js +23 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +6 -2
  16. package/factory/orchestrator/dist/cli.js +309 -28
  17. package/factory/orchestrator/dist/crossFinding.js +20 -0
  18. package/factory/orchestrator/dist/daemon.js +198 -31
  19. package/factory/orchestrator/dist/db/projector.js +51 -27
  20. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  21. package/factory/orchestrator/dist/epic.js +179 -12
  22. package/factory/orchestrator/dist/errorIssues.js +175 -10
  23. package/factory/orchestrator/dist/events.js +59 -11
  24. package/factory/orchestrator/dist/findings.js +25 -2
  25. package/factory/orchestrator/dist/gh.js +19 -2
  26. package/factory/orchestrator/dist/git.js +20 -2
  27. package/factory/orchestrator/dist/hookDecision.js +320 -37
  28. package/factory/orchestrator/dist/integration.js +11 -1
  29. package/factory/orchestrator/dist/issueReporter.js +81 -16
  30. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  31. package/factory/orchestrator/dist/logCache.js +291 -0
  32. package/factory/orchestrator/dist/paths.js +10 -0
  33. package/factory/orchestrator/dist/plan.js +130 -0
  34. package/factory/orchestrator/dist/projects.js +12 -1
  35. package/factory/orchestrator/dist/queue.js +469 -28
  36. package/factory/orchestrator/dist/scheduler.js +3 -3
  37. package/factory/orchestrator/dist/spec.js +70 -0
  38. package/factory/orchestrator/dist/taskEvents.js +13 -4
  39. package/factory/orchestrator/dist/taxonomy.js +11 -0
  40. package/factory/orchestrator/dist/testgate.js +4 -3
  41. package/factory/orchestrator/dist/usage.js +18 -10
  42. package/factory/orchestrator/dist/waivers.js +60 -7
  43. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  44. package/factory/orchestrator/dist/worktree.js +19 -6
  45. package/factory/specs/roadmap.md +24 -0
  46. package/package.json +1 -1
@@ -25,12 +25,15 @@
25
25
  * is a required parameter, supplied by the caller (task 5), never read off
26
26
  * the filesystem by this module.
27
27
  */
28
- import { FINGERPRINT_LINE_PREFIX, foldErrorEvents, renderBody, renderComment, renderTitle, toIssueBodyFields, toIssueCommentFields, } from './errorIssues.js';
28
+ import { FINGERPRINT_LINE_PREFIX, foldErrorEvents, renderBody, renderComment, renderTitle, toIssueBodyFields, toIssueCommentFields, withSessionFallback, } from './errorIssues.js';
29
29
  import { appendEvent } from './events.js';
30
30
  import { buildCommentArgv, buildCreateIssueArgv, buildSearchIssuesArgv, classifyGh, resolveProjectRepo, } from './gh.js';
31
31
  /**
32
- * The closed, named outcome vocabulary. Exactly one of these eight words is
32
+ * The closed, named outcome vocabulary. Exactly one of these nine words is
33
33
  * recorded for every candidate error, always — functional clause 2.
34
+ * `skipped-unresolved-project` is settled before any of the others can be:
35
+ * a row this factory cannot identify the project of is never filed against
36
+ * this factory's own repository by default (the privacy-leak guard).
34
37
  */
35
38
  export const ISSUE_REPORT_OUTCOMES = [
36
39
  'opened',
@@ -40,6 +43,7 @@ export const ISSUE_REPORT_OUTCOMES = [
40
43
  'skipped-no-remote',
41
44
  'skipped-gh-missing',
42
45
  'skipped-unauthenticated',
46
+ 'skipped-unresolved-project',
43
47
  'failed',
44
48
  ];
45
49
  const ISSUE_REPORTED_EVENT_TYPE = 'issue-reported';
@@ -60,8 +64,6 @@ export const ISSUE_REPORT_PAYLOAD_KEYS = [
60
64
  'source',
61
65
  'task_ref',
62
66
  ];
63
- /** Default project for an event stamped with none — matches events.ts's documented convention. */
64
- const DEFAULT_PROJECT = 'black-smith';
65
67
  function parseSearchResult(stdout) {
66
68
  let parsed;
67
69
  try {
@@ -94,14 +96,49 @@ function findExactMatch(issues, fingerprint) {
94
96
  const line = fingerprintLine(fingerprint);
95
97
  return issues.find((issue) => issue.body.split('\n').includes(line)) ?? null;
96
98
  }
99
+ /**
100
+ * The task ref an unstamped row carries, if any -- the envelope's own
101
+ * `task_id` first, falling back to a hand-appended row's `payload.task_ref`.
102
+ * Mirrors errorIssues.ts's `candidateTaskRef`, which this module never
103
+ * imports so as to keep the two folds' internals independent.
104
+ */
105
+ function candidateTaskRef(record) {
106
+ if (typeof record.task_id === 'string' && record.task_id.length > 0)
107
+ return record.task_id;
108
+ const payload = (record.payload ?? {});
109
+ return typeof payload.task_ref === 'string' && payload.task_ref.length > 0
110
+ ? payload.task_ref
111
+ : undefined;
112
+ }
113
+ /**
114
+ * A row's project, resolved the same way `errorIssues.ts`'s fold resolves
115
+ * one: an explicit stamp always wins; absent that, `resolveRow` (the row's
116
+ * own task ref widened with `withSessionFallback`'s session fallbacks) gets
117
+ * the answer. `null` when none of them can tell -- this factory's own
118
+ * project is never guessed. This is what keeps an unstamped row from a
119
+ * foreign (or unidentifiable) project's session from being folded into this
120
+ * factory's own prior-report history (and, through `decideOutcome`'s dedup
121
+ * key, its own repository) merely because nothing stamped it.
122
+ */
123
+ function resolveRecordProject(record, resolveRow) {
124
+ if (typeof record.project === 'string')
125
+ return record.project;
126
+ return resolveRow(candidateTaskRef(record), record.session_id);
127
+ }
97
128
  /**
98
129
  * Every already-appended `issue-reported` record still open, read off the
99
130
  * SNAPSHOT of events this call was handed — never off events this same call
100
131
  * itself appends. That is what lets the five duplicate candidates a single
101
132
  * broken gate produces in one round resolve through GitHub search (one
102
133
  * create, four comments) rather than short-circuiting on each other.
134
+ *
135
+ * A record whose project cannot be resolved at all is dropped rather than
136
+ * pushed: it can never match `priorReportFor`'s `h.project === report.project`
137
+ * comparison against a null-project report either (Step 0 below settles
138
+ * those before Step 4 runs), so keeping it around would only cost a lookup.
103
139
  */
104
- function priorOpenReports(events) {
140
+ function priorOpenReports(events, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
141
+ const resolveRow = withSessionFallback(scopeEvents, resolveProject, selfFallback);
105
142
  const out = [];
106
143
  for (const event of events) {
107
144
  const { record } = event;
@@ -116,8 +153,11 @@ function priorOpenReports(events) {
116
153
  if (typeof payload.fingerprint !== 'string' || typeof payload.latest_event_id !== 'string') {
117
154
  continue;
118
155
  }
156
+ const project = resolveRecordProject(record, resolveRow);
157
+ if (project === null)
158
+ continue;
119
159
  out.push({
120
- project: record.project ?? DEFAULT_PROJECT,
160
+ project,
121
161
  fingerprint: payload.fingerprint,
122
162
  outcome: payload.outcome,
123
163
  latest_event_id: payload.latest_event_id,
@@ -155,7 +195,10 @@ async function appendIssueReported(report, record, opts) {
155
195
  plan_version: report.plan_version ?? 1,
156
196
  causal_parent: report.latest_event_id,
157
197
  payload: toPayload(record),
158
- project: report.project,
198
+ // `EventInput.project` is `string | undefined` -- omitted, never
199
+ // `null`, when a row's project could not be resolved; the writer
200
+ // never stamps a guess (events.ts's documented convention).
201
+ ...(report.project !== null ? { project: report.project } : {}),
159
202
  }, opts);
160
203
  }
161
204
  /**
@@ -187,12 +230,21 @@ async function decideOutcome(report, isProjectEnabled, register, runner, history
187
230
  task_ref: report.task_ref,
188
231
  latest_event_id: report.latest_event_id,
189
232
  };
233
+ // Step 0: can this row's project be identified at all. A row still
234
+ // unresolved after the fold's own session fallbacks is never filed
235
+ // against this factory's own repository by default (the privacy-leak
236
+ // guard this whole module exists to close) -- settled before repo
237
+ // resolution or any `gh` interaction, ahead of every other step.
238
+ if (report.project === null) {
239
+ return { ...base, outcome: 'skipped-unresolved-project', reason: 'project-unresolved' };
240
+ }
241
+ const project = report.project;
190
242
  // Step 1: the switch.
191
- if (!isProjectEnabled(report.project)) {
243
+ if (!isProjectEnabled(project)) {
192
244
  return { ...base, outcome: 'skipped-disabled', reason: 'switch-off' };
193
245
  }
194
246
  // Step 2: which repository.
195
- const repo = resolveProjectRepo(report.project, register);
247
+ const repo = resolveProjectRepo(project, register);
196
248
  if (!('slug' in repo)) {
197
249
  return { ...base, outcome: 'skipped-no-remote', reason: repo.reason, detail: repo.detail };
198
250
  }
@@ -234,7 +286,11 @@ async function decideOutcome(report, isProjectEnabled, register, runner, history
234
286
  }
235
287
  const issues = parseSearchResult(searchResult.stdout);
236
288
  if (issues === null) {
237
- return { ...base, outcome: 'failed', reason: 'search-failed', repo_slug: repoSlug };
289
+ // gh exited 0 but the output could not be read as an issue list (e.g.
290
+ // --json was dropped somewhere upstream and gh printed its human table,
291
+ // or the response was truncated). Distinct from a genuine nonzero exit
292
+ // above, which is a process failure rather than an unreadable payload.
293
+ return { ...base, outcome: 'failed', reason: 'search-unparseable', repo_slug: repoSlug };
238
294
  }
239
295
  // Step 6: an exact fingerprint-line match gets a comment.
240
296
  const match = findExactMatch(issues, report.fingerprint);
@@ -274,17 +330,26 @@ const DECISION_NOTE = 'create or comment: which of the two would run is decided
274
330
  * `0`, a placeholder known only after that search. `runner` is accepted for
275
331
  * signature parity with `reportErrors()`; nothing here reaches it.
276
332
  */
277
- export async function previewOutcomes(events, isProjectEnabled, register, runner, clock) {
333
+ export async function previewOutcomes(events, isProjectEnabled, register, runner, clock, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
278
334
  requireRegister(register, 'previewOutcomes');
279
335
  void runner;
280
- const { reports } = foldErrorEvents(events, clock(), () => true);
281
- const history = priorOpenReports(events);
336
+ const { reports } = foldErrorEvents(events, clock(), () => true, resolveProject, selfFallback, scopeEvents);
337
+ const history = priorOpenReports(events, resolveProject, selfFallback, scopeEvents);
282
338
  const out = [];
283
339
  for (const report of reports) {
284
340
  const { fingerprint, project, source, error_class, task_ref, latest_event_id } = report;
285
341
  const base = { fingerprint, project, source, error_class, task_ref, latest_event_id };
286
342
  const step3 = { step_3_gh_availability: STEP_3_NOT_PERFORMED };
287
343
  const settle = (rest) => out.push({ ...base, ...step3, ...rest });
344
+ // Step 0: can this row's project be identified at all.
345
+ if (project === null) {
346
+ settle({
347
+ settled_at_step: 0,
348
+ outcome: 'skipped-unresolved-project',
349
+ reason: 'project-unresolved',
350
+ });
351
+ continue;
352
+ }
288
353
  // Step 1: the switch.
289
354
  if (!isProjectEnabled(project)) {
290
355
  settle({ settled_at_step: 1, outcome: 'skipped-disabled', reason: 'switch-off' });
@@ -333,13 +398,13 @@ export async function previewOutcomes(events, isProjectEnabled, register, runner
333
398
  * calls `factoryProjects()` itself), `runner` is task 3's injected `gh`
334
399
  * command runner, and `clock` supplies `now` for the fold below.
335
400
  */
336
- export async function reportErrors(events, isProjectEnabled, register, runner, clock, opts = {}) {
401
+ export async function reportErrors(events, isProjectEnabled, register, runner, clock, opts = {}, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
337
402
  requireRegister(register, 'reportErrors');
338
403
  // Every project's switch is applied by this module's own step 1, not by
339
404
  // the fold: foldErrorEvents dropping a disabled project's candidates
340
405
  // silently would lose the `skipped-disabled` record clause 2 requires.
341
- const { reports } = foldErrorEvents(events, clock(), () => true);
342
- const history = priorOpenReports(events);
406
+ const { reports } = foldErrorEvents(events, clock(), () => true, resolveProject, selfFallback, scopeEvents);
407
+ const history = priorOpenReports(events, resolveProject, selfFallback, scopeEvents);
343
408
  const out = [];
344
409
  for (const report of reports) {
345
410
  const record = await decideOutcome(report, isProjectEnabled, register, runner, history);
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `.claude/hooks/judge-stop.sh` — the SubagentStop decision for a judge-class
4
+ * agent (reviewer, verifier, grader, spec-reviewer, security-reviewer,
5
+ * auditor; `JUDGE_ROLES` in dispatchLint.ts is the authoritative set).
6
+ *
7
+ * A judge can end its turn without ever writing the artifact its dispatch
8
+ * declared, and today that gap is found only downstream, when `smith judge
9
+ * report` answers `judges.artifact-missing` — after the agent is gone. This
10
+ * hook closes the loop at the one moment it can still act: block the stop
11
+ * while the declared path does not exist, allow it once it does. A harness
12
+ * `maxTurns` cap is out of scope; this hook cannot override it.
13
+ *
14
+ * The declared-artifact line and its parser come from `dispatchLint.ts`,
15
+ * never redefined — `dispatch lint` already refuses, before dispatch, any
16
+ * judge prompt whose line is missing, relative, or mismatched, so this hook
17
+ * only asks "does the file exist yet", never "is the line well-formed".
18
+ *
19
+ * Fail open on anything this hook cannot read: a non-judge `agent_type`, a
20
+ * prompt with no declared-artifact line, a relative path, unparseable stdin,
21
+ * an unreadable transcript — blocking on those would trap the agent until
22
+ * `maxTurns` for an obligation the hook could never state. Every fail-open
23
+ * path prints a stderr note and is covered upstream by `dispatch lint` or
24
+ * downstream by `judge report`'s `judges.artifact-missing`.
25
+ *
26
+ * Fail closed ONLY on a readable absolute declared path that does not exist.
27
+ *
28
+ * SubagentStop stdin fields: https://code.claude.com/docs/en/hooks
29
+ */
30
+ import { existsSync, readFileSync } from 'node:fs';
31
+ import path from 'node:path';
32
+ import { pathToFileURL } from 'node:url';
33
+ import { JUDGE_ROLES, parseDeclaredArtifactLine } from './dispatchLint.js';
34
+ const JUDGE_ROLE_SET = new Set(JUDGE_ROLES);
35
+ /**
36
+ * The pure decision: given the hook's stdin and the subagent's prompt text
37
+ * (or `null` when it could not be read at all), decide whether the stop is
38
+ * allowed. Touches the filesystem only to check whether the declared path
39
+ * exists -- the one question this hook exists to ask.
40
+ */
41
+ export function decideJudgeStop(input, promptText) {
42
+ const agentType = input.agent_type;
43
+ if (agentType === undefined || !JUDGE_ROLE_SET.has(agentType)) {
44
+ // Not one of the six judge roles -- a coder's turn carries no declared
45
+ // artifact at all, so there is nothing for this hook to check.
46
+ return { decision: 'allow' };
47
+ }
48
+ if (promptText === null) {
49
+ return {
50
+ decision: 'allow',
51
+ note: "judge-stop: could not read this agent's prompt (transcript missing or " +
52
+ "unreadable); allowing the stop. judge report's judges.artifact-missing " +
53
+ 'remains the backstop.',
54
+ };
55
+ }
56
+ const declared = parseDeclaredArtifactLine(promptText);
57
+ if (declared === null) {
58
+ return {
59
+ decision: 'allow',
60
+ note: 'judge-stop: no "Declared artifact: <path>" line in this prompt; allowing ' +
61
+ "the stop. dispatch lint refuses this before dispatch, and judge report's " +
62
+ 'judges.artifact-missing catches it downstream.',
63
+ };
64
+ }
65
+ if (!path.isAbsolute(declared)) {
66
+ return {
67
+ decision: 'allow',
68
+ note: `judge-stop: declared artifact "${declared}" is not an absolute path; ` +
69
+ 'allowing the stop. dispatch lint refuses this before dispatch, and judge ' +
70
+ "report's judges.artifact-missing catches it downstream.",
71
+ };
72
+ }
73
+ if (!existsSync(declared)) {
74
+ return {
75
+ decision: 'block',
76
+ reason: `Declared artifact ${declared} does not exist yet. Write it before ending this turn.`,
77
+ };
78
+ }
79
+ // Existence is this hook's whole question -- an empty or unparseable file
80
+ // is `judge report`'s to refuse (`judges.artifact-unparseable`, `judges.
81
+ // artifact-not-a-list`), not this hook's to trap the agent over.
82
+ return { decision: 'allow' };
83
+ }
84
+ /**
85
+ * The last user-role message's text in a Claude Code JSONL transcript --
86
+ * where a dispatch prompt and its "Declared artifact:" line live. Walks
87
+ * backward from the end, since a subagent's transcript can carry many turns
88
+ * and the dispatch prompt need not be the only user turn. Returns `null` on
89
+ * any read or parse failure.
90
+ */
91
+ export function extractLastUserPromptText(transcriptPath) {
92
+ let raw;
93
+ try {
94
+ raw = readFileSync(transcriptPath, 'utf8');
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ const lines = raw.split('\n');
100
+ for (let i = lines.length - 1; i >= 0; i--) {
101
+ const line = lines[i];
102
+ if (line === undefined || line.trim() === '')
103
+ continue;
104
+ let entry;
105
+ try {
106
+ entry = JSON.parse(line);
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ const text = userTextOf(entry);
112
+ if (text !== null)
113
+ return text;
114
+ }
115
+ return null;
116
+ }
117
+ function userTextOf(entry) {
118
+ if (typeof entry !== 'object' || entry === null)
119
+ return null;
120
+ const rec = entry;
121
+ if (rec.type !== 'user')
122
+ return null;
123
+ const message = rec.message;
124
+ if (typeof message !== 'object' || message === null)
125
+ return null;
126
+ const content = message.content;
127
+ if (typeof content === 'string')
128
+ return content;
129
+ if (Array.isArray(content)) {
130
+ const parts = content
131
+ .filter((block) => typeof block === 'object' && block !== null)
132
+ .filter((block) => block.type === 'text' && typeof block.text === 'string')
133
+ .map((block) => block.text);
134
+ if (parts.length > 0)
135
+ return parts.join('\n');
136
+ }
137
+ return null;
138
+ }
139
+ /**
140
+ * The whole hook, minus process I/O: parse stdin, read the prompt, decide,
141
+ * and format the two output streams Claude Code and an operator each read.
142
+ * Exercised directly by tests without spawning a process; `main` below is
143
+ * the only piece that touches fd 0 and process.exit.
144
+ */
145
+ export function runJudgeStopHook(rawStdin) {
146
+ let input;
147
+ try {
148
+ input = JSON.parse(rawStdin);
149
+ }
150
+ catch {
151
+ return {
152
+ stdout: '',
153
+ stderr: 'judge-stop: could not parse stdin as JSON; allowing the stop. ' +
154
+ "judge report's judges.artifact-missing remains the backstop.\n",
155
+ };
156
+ }
157
+ const promptText = input.transcript_path
158
+ ? extractLastUserPromptText(input.transcript_path)
159
+ : null;
160
+ const result = decideJudgeStop(input, promptText);
161
+ if (result.decision === 'block') {
162
+ return {
163
+ stdout: `${JSON.stringify({ decision: 'block', reason: result.reason })}\n`,
164
+ stderr: '',
165
+ };
166
+ }
167
+ return { stdout: '', stderr: result.note ? `${result.note}\n` : '' };
168
+ }
169
+ function main() {
170
+ const { stdout, stderr } = runJudgeStopHook(readFileSync(0, 'utf8'));
171
+ if (stdout)
172
+ process.stdout.write(stdout);
173
+ if (stderr)
174
+ process.stderr.write(stderr);
175
+ }
176
+ // Runs only when executed directly (judge-stop.sh execs `node
177
+ // dist/judgeStopHook.js`) -- importing this module for its exports, as the
178
+ // tests do, must never read stdin as a side effect.
179
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
180
+ main();
181
+ }
@@ -0,0 +1,291 @@
1
+ // The daemon's fix for finding 50acc356: `runTick` used to call `readEvents`
2
+ // for every session on every tick, re-reading and re-parsing every byte ever
3
+ // written to every log, forever. This module is the one place that reads a
4
+ // session log more than once in a process's lifetime, and it reads each log
5
+ // exactly once per byte that was ever appended to it.
6
+ //
7
+ // The fingerprint that decides "did this log change" is taken from an OPEN
8
+ // file descriptor's fstat, never a path stat: `stat(path)` can race a
9
+ // concurrent writer (open, then unlink-and-replace, then stat sees the new
10
+ // inode while a reader mid-read still holds the old one) in a way an fstat on
11
+ // a handle already open for THIS read cannot.
12
+ //
13
+ // Per log, the cache keeps an OFFSET (the byte offset just past the last
14
+ // consumed newline) and an ANCHOR (the bytes of the last complete line,
15
+ // terminating newline included, occupying exactly
16
+ // `[offset - anchor.length, offset)`). A grown file is read once, from
17
+ // `offset - anchor.length` to the new end; only when the leading
18
+ // `anchor.length` bytes of that read still equal the cached anchor is the
19
+ // growth treated as a genuine append, and only the bytes after `offset` are
20
+ // parsed. Every other outcome — a different dev/ino, a shrink, an anchor
21
+ // mismatch, or a cached offset of 0 with no anchor at all — is a full
22
+ // re-read from byte 0.
23
+ //
24
+ // RESIDUAL CONTRACT: session logs are append-only by design (`appendEvent`
25
+ // only ever appends). This cache DETECTS truncation, replacement, and any
26
+ // rewrite that changes the anchor's bytes or offset. It does NOT detect an
27
+ // in-place rewrite, on the same inode, that happens to keep the anchor's
28
+ // bytes — terminating newline included — sitting in the same
29
+ // `[offset - anchor.length, offset)` window, nor a same-size rewrite that
30
+ // lands inside one mtime tick. Both are out of scope: nothing in this repo
31
+ // writes a session log that way, so this is stated as a limit rather than
32
+ // implied to be full change detection.
33
+ import { open } from 'node:fs/promises';
34
+ import path from 'node:path';
35
+ import { EventError } from './events.js';
36
+ import { STATE_EVENTS_DIR } from './paths.js';
37
+ function fpEqual(a, b) {
38
+ return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs;
39
+ }
40
+ /** Whatever a thrown value has to say for itself, without assuming it is an Error. */
41
+ function errorText(err) {
42
+ return err instanceof Error ? err.message : String(err);
43
+ }
44
+ /**
45
+ * Same algorithm as `events.ts`'s private `readEventsAtPath` — trim trailing
46
+ * whitespace, split on `\n`, drop empty lines, parse each survivor as JSON —
47
+ * copied rather than imported so this module never calls `readEvents` (the
48
+ * `readEventsCalls` seam stays truthfully zero) while still throwing the
49
+ * identical `events.unreadable-session-log` error other readers throw.
50
+ *
51
+ * `startIndex` offsets the event id / line-number math for a delta parse:
52
+ * parsing only the newly appended tail of a log must still number its events
53
+ * (and report a malformed line's number) as if the whole file had been
54
+ * parsed at once.
55
+ */
56
+ function parseCompleteLines(text, sessionId, filePath, startIndex) {
57
+ const lines = text
58
+ .trimEnd()
59
+ .split('\n')
60
+ .filter((line) => line.length > 0);
61
+ return lines.map((line, offset) => {
62
+ const index = startIndex + offset;
63
+ let record;
64
+ try {
65
+ record = JSON.parse(line);
66
+ }
67
+ catch (err) {
68
+ throw new EventError('events.unreadable-session-log', `Line ${index + 1} of ${filePath} is not JSON, so session "${sessionId}" cannot be read: ${errorText(err)}.`, { session_id: sessionId, path: filePath, line: index + 1 });
69
+ }
70
+ return { event_id: `${sessionId}#${index}`, record };
71
+ });
72
+ }
73
+ /**
74
+ * Parse a JSON pending tail — a well-formed JSON value with no terminating
75
+ * newline yet — into the one event it names. Throws the same
76
+ * `events.unreadable-session-log` error, with the same line number,
77
+ * `readEvents` would report for a malformed final line.
78
+ */
79
+ function parsePendingTail(text, sessionId, filePath, index) {
80
+ let record;
81
+ try {
82
+ record = JSON.parse(text);
83
+ }
84
+ catch (err) {
85
+ throw new EventError('events.unreadable-session-log', `Line ${index + 1} of ${filePath} is not JSON, so session "${sessionId}" cannot be read: ${errorText(err)}.`, { session_id: sessionId, path: filePath, line: index + 1 });
86
+ }
87
+ return { event_id: `${sessionId}#${index}`, record };
88
+ }
89
+ const NEWLINE = 0x0a;
90
+ /**
91
+ * Tail-detection and parse, applied to the RAW, untrimmed bytes of
92
+ * `tailBuf` — everything at and after `baseOffset` in the log.
93
+ *
94
+ * Locates the LAST `\n` in `tailBuf`: everything up to and including it is
95
+ * newline-terminated ("complete") lines, parsed and cached; everything after
96
+ * it is the candidate pending tail. A whitespace-only (or empty) candidate is
97
+ * no pending tail at all. `baseAnchor`/`baseOffset` are reused unchanged when
98
+ * `tailBuf` holds no newline — nothing newline-terminated was added, so
99
+ * nothing the entry remembers may advance.
100
+ */
101
+ function parseTail(tailBuf, sessionId, filePath, baseOffset, baseIndex, baseAnchor, baseCachedEvents) {
102
+ const lastNL = tailBuf.lastIndexOf(NEWLINE);
103
+ let offset = baseOffset;
104
+ let anchor = baseAnchor;
105
+ let newComplete = [];
106
+ let pendingBuf;
107
+ if (lastNL === -1) {
108
+ pendingBuf = tailBuf;
109
+ }
110
+ else {
111
+ const completeBuf = tailBuf.subarray(0, lastNL + 1);
112
+ const prevNL = lastNL <= 0 ? -1 : tailBuf.lastIndexOf(NEWLINE, lastNL - 1);
113
+ const anchorStart = prevNL === -1 ? 0 : prevNL + 1;
114
+ anchor = Buffer.from(tailBuf.subarray(anchorStart, lastNL + 1));
115
+ offset = baseOffset + lastNL + 1;
116
+ newComplete = parseCompleteLines(completeBuf.toString('utf8'), sessionId, filePath, baseIndex);
117
+ pendingBuf = tailBuf.subarray(lastNL + 1);
118
+ }
119
+ const cachedEvents = newComplete.length === 0 ? [...baseCachedEvents] : [...baseCachedEvents, ...newComplete];
120
+ const pendingText = pendingBuf.toString('utf8');
121
+ if (pendingText.trim().length === 0) {
122
+ return { cachedEvents, events: cachedEvents, offset, anchor };
123
+ }
124
+ const pendingIndex = baseIndex + newComplete.length;
125
+ const pendingEvent = parsePendingTail(pendingText, sessionId, filePath, pendingIndex);
126
+ return { cachedEvents, events: [...cachedEvents, pendingEvent], offset, anchor };
127
+ }
128
+ const EMPTY_SEAM = {
129
+ opens: 0,
130
+ fstats: 0,
131
+ bytesRead: 0,
132
+ readEventsCalls: 0,
133
+ fullReparses: 0,
134
+ };
135
+ /**
136
+ * A reusable, package-internal cache of session event logs, keyed by a
137
+ * fingerprint taken from an open handle rather than a path stat.
138
+ *
139
+ * One instance is meant to live across many `runTick` calls (the daemon's
140
+ * long-running loop holds one for its whole run); a fresh instance is always
141
+ * cold, so `runTick` with no shared cache reads exactly what `readEvents`
142
+ * would have — this is an optimization for the repeat-tick case, not a
143
+ * change in what any single read returns.
144
+ */
145
+ export class LogCache {
146
+ entries = new Map();
147
+ appliedSignatures = new Map();
148
+ counters;
149
+ /** `seam` lets a caller inject (and so share, or directly assert on) the counting object. */
150
+ constructor(seam) {
151
+ this.counters = seam ?? { ...EMPTY_SEAM };
152
+ }
153
+ logPath(sessionId, opts) {
154
+ return path.join(opts.stateDir ?? STATE_EVENTS_DIR, `${sessionId}.jsonl`);
155
+ }
156
+ /** This session's log, read through the cache. */
157
+ async read(sessionId, opts = {}) {
158
+ const filePath = this.logPath(sessionId, opts);
159
+ let handle;
160
+ try {
161
+ handle = await open(filePath, 'r');
162
+ this.counters.opens += 1;
163
+ }
164
+ catch (err) {
165
+ if (err.code === 'ENOENT') {
166
+ // The previous entry, if any, is left untouched — same rule as a
167
+ // read that throws below. A log that has vanished (and may come
168
+ // back) is not the same fact as a log this cache has never seen.
169
+ return [];
170
+ }
171
+ throw err;
172
+ }
173
+ try {
174
+ const stats = await handle.stat();
175
+ this.counters.fstats += 1;
176
+ const fp = {
177
+ dev: stats.dev,
178
+ ino: stats.ino,
179
+ size: stats.size,
180
+ mtimeMs: stats.mtimeMs,
181
+ };
182
+ const cached = this.entries.get(sessionId);
183
+ if (cached !== undefined && fpEqual(fp, cached.fp)) {
184
+ return cached.events;
185
+ }
186
+ // Append-only is verified, never inferred from size: the incremental
187
+ // path is only even attempted when the anchor byte-for-byte matches
188
+ // what the file now holds at the same window.
189
+ const canAttemptIncremental = cached !== undefined &&
190
+ fp.dev === cached.fp.dev &&
191
+ fp.ino === cached.fp.ino &&
192
+ cached.anchor.length > 0 &&
193
+ fp.size >= cached.offset;
194
+ if (canAttemptIncremental) {
195
+ // biome-ignore lint/style/noNonNullAssertion: canAttemptIncremental narrows `cached`.
196
+ const entry = cached;
197
+ const start = entry.offset - entry.anchor.length;
198
+ const readLen = fp.size - start;
199
+ const buf = Buffer.alloc(readLen);
200
+ const { bytesRead } = await handle.read(buf, 0, readLen, start);
201
+ this.counters.bytesRead += bytesRead;
202
+ const slice = buf.subarray(0, bytesRead);
203
+ if (slice.subarray(0, entry.anchor.length).equals(entry.anchor)) {
204
+ const tailBuf = slice.subarray(entry.anchor.length);
205
+ const parsed = parseTail(tailBuf, sessionId, filePath, entry.offset, entry.cachedEvents.length, entry.anchor, entry.cachedEvents);
206
+ this.entries.set(sessionId, {
207
+ fp,
208
+ offset: parsed.offset,
209
+ anchor: parsed.anchor,
210
+ cachedEvents: parsed.cachedEvents,
211
+ events: parsed.events,
212
+ });
213
+ return parsed.events;
214
+ }
215
+ // Anchor mismatch: a same-size-or-larger rewrite that is not a
216
+ // genuine append. Fall through to a full re-read.
217
+ }
218
+ this.counters.fullReparses += 1;
219
+ const whole = await handle.readFile();
220
+ this.counters.bytesRead += whole.length;
221
+ const parsed = parseTail(whole, sessionId, filePath, 0, 0, Buffer.alloc(0), []);
222
+ this.entries.set(sessionId, {
223
+ fp,
224
+ offset: parsed.offset,
225
+ anchor: parsed.anchor,
226
+ cachedEvents: parsed.cachedEvents,
227
+ events: parsed.events,
228
+ });
229
+ return parsed.events;
230
+ }
231
+ finally {
232
+ await handle.close();
233
+ }
234
+ }
235
+ /** The `event_id` of this session's last event, or `null` for empty/absent. */
236
+ async lastEventId(sessionId, opts = {}) {
237
+ const events = await this.read(sessionId, opts);
238
+ return events.length === 0 ? null : events[events.length - 1].event_id;
239
+ }
240
+ /**
241
+ * The fingerprint this cache last read for `sessionId`, or `undefined` if
242
+ * it has never been read through this cache. Lets a caller (the daemon's
243
+ * apply-skip below) build a cheap in-memory signature over a set of
244
+ * sessions without re-reading any of them.
245
+ */
246
+ fingerprintOf(sessionId) {
247
+ return this.entries.get(sessionId)?.fp;
248
+ }
249
+ /**
250
+ * The signature this cache last recorded a projector `apply()` against for
251
+ * `leaf` (`setAppliedSignature`'s argument), so the daemon can skip a
252
+ * redundant apply when nothing in the leaf's lineage changed since.
253
+ */
254
+ getAppliedSignature(leaf) {
255
+ return this.appliedSignatures.get(leaf);
256
+ }
257
+ setAppliedSignature(leaf, signature) {
258
+ this.appliedSignatures.set(leaf, signature);
259
+ }
260
+ /**
261
+ * Evicts every key not in `liveSessionIds` from both `entries` and
262
+ * `appliedSignatures`, so the cache's memory is bounded by live logs, not
263
+ * by history. `runTick` calls this once per tick with the session ids
264
+ * `listSessionIds` just returned -- the LISTED set, not the set of
265
+ * sessions whose read succeeded this tick -- so a session that is listed
266
+ * but unreadable keeps its entry (a read that throws leaves the entry
267
+ * untouched; eviction is the tick's decision, driven by the listing, not
268
+ * the read). A session that vanishes and later reappears loses both its
269
+ * fingerprint and its applied signature, so it is read cold and its leaf
270
+ * is re-applied. One pass over each map's own keys; no file I/O.
271
+ */
272
+ retainOnly(liveSessionIds) {
273
+ for (const sessionId of this.entries.keys()) {
274
+ if (!liveSessionIds.has(sessionId))
275
+ this.entries.delete(sessionId);
276
+ }
277
+ for (const leaf of this.appliedSignatures.keys()) {
278
+ if (!liveSessionIds.has(leaf))
279
+ this.appliedSignatures.delete(leaf);
280
+ }
281
+ }
282
+ }
283
+ /**
284
+ * The public factory. `dist/logCache.js` exports this, the `LogCache` type
285
+ * it returns, and the `LogCacheSeam` type its optional argument takes — the
286
+ * three names a consumer (task 3's dashboard wiring) imports to inject a
287
+ * counting cache elsewhere.
288
+ */
289
+ export function createLogCache(seam) {
290
+ return new LogCache(seam);
291
+ }