@juzser/blacksmith 0.2.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 (71) 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 +25 -9
  8. package/.claude/skills/bs/dispatch.md +60 -7
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +18 -13
  11. package/.claude/skills/bs/wave.md +68 -10
  12. package/README.md +4 -4
  13. package/factory/orchestrator/dist/agentsSync.js +178 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +25 -0
  16. package/factory/orchestrator/dist/budgets.js +91 -2
  17. package/factory/orchestrator/dist/claims.js +8 -4
  18. package/factory/orchestrator/dist/cli.js +394 -26
  19. package/factory/orchestrator/dist/crossFinding.js +20 -0
  20. package/factory/orchestrator/dist/daemon.js +230 -41
  21. package/factory/orchestrator/dist/db/projector.js +93 -36
  22. package/factory/orchestrator/dist/db/queries.js +58 -35
  23. package/factory/orchestrator/dist/db/schema.js +24 -0
  24. package/factory/orchestrator/dist/dispatchAudit.js +10 -2
  25. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  26. package/factory/orchestrator/dist/epic.js +192 -39
  27. package/factory/orchestrator/dist/epicWidth.js +25 -20
  28. package/factory/orchestrator/dist/errorIssues.js +209 -24
  29. package/factory/orchestrator/dist/eventOrder.js +102 -0
  30. package/factory/orchestrator/dist/events.js +73 -91
  31. package/factory/orchestrator/dist/findingContext.js +1 -13
  32. package/factory/orchestrator/dist/findings.js +132 -11
  33. package/factory/orchestrator/dist/gh.js +29 -6
  34. package/factory/orchestrator/dist/git.js +20 -2
  35. package/factory/orchestrator/dist/harness.js +92 -10
  36. package/factory/orchestrator/dist/hookDecision.js +320 -37
  37. package/factory/orchestrator/dist/impact.js +24 -2
  38. package/factory/orchestrator/dist/integration.js +11 -1
  39. package/factory/orchestrator/dist/issueReporter.js +415 -0
  40. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  41. package/factory/orchestrator/dist/judges.js +1 -1
  42. package/factory/orchestrator/dist/lessons.js +46 -18
  43. package/factory/orchestrator/dist/logCache.js +291 -0
  44. package/factory/orchestrator/dist/paths.js +10 -0
  45. package/factory/orchestrator/dist/plan.js +138 -4
  46. package/factory/orchestrator/dist/planQuorum.js +1 -1
  47. package/factory/orchestrator/dist/policy.js +57 -11
  48. package/factory/orchestrator/dist/projects.js +12 -1
  49. package/factory/orchestrator/dist/queue.js +477 -29
  50. package/factory/orchestrator/dist/scheduler.js +51 -0
  51. package/factory/orchestrator/dist/severity.js +40 -8
  52. package/factory/orchestrator/dist/spec.js +70 -0
  53. package/factory/orchestrator/dist/taskEvents.js +13 -4
  54. package/factory/orchestrator/dist/taskStatus.js +74 -0
  55. package/factory/orchestrator/dist/taxonomy.js +11 -0
  56. package/factory/orchestrator/dist/testgate.js +32 -3
  57. package/factory/orchestrator/dist/usage.js +42 -9
  58. package/factory/orchestrator/dist/waivers.js +60 -9
  59. package/factory/orchestrator/dist/waveConcurrency.js +29 -0
  60. package/factory/orchestrator/dist/waveNext.js +12 -10
  61. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  62. package/factory/orchestrator/dist/worktree.js +19 -6
  63. package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
  64. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  65. package/factory/policies/budgets.yml +9 -2
  66. package/factory/policies/crosscheck.yml +2 -2
  67. package/factory/policies/lessons.md +298 -0
  68. package/factory/policies/scheduler.yml +27 -2
  69. package/factory/policies/taxonomy.yml +10 -3
  70. package/factory/specs/roadmap.md +70 -23
  71. package/package.json +8 -8
@@ -34,7 +34,7 @@ import { foldLessons } from './db/projector.js';
34
34
  import { SmithError } from './errors.js';
35
35
  import { appendEvent, readLineageEvents } from './events.js';
36
36
  import { AGENTS_DIR, lessonsReadPath } from './paths.js';
37
- import { LESSON_SCOPES, parseLessons } from './severity.js';
37
+ import { FILE_SCOPED_SCOPES, isFileScopedScope, LESSON_SCOPES, parseLessons, } from './severity.js';
38
38
  import { loadTaxonomy, validateTag } from './taxonomy.js';
39
39
  export class LessonsError extends SmithError {
40
40
  }
@@ -655,6 +655,26 @@ export function lessonsForDispatch(role, claimPaths, opts = {}) {
655
655
  warnings,
656
656
  };
657
657
  }
658
+ // ---------------------------------------------------------------------------
659
+ // (c) smith dream — decision-checkpoint extraction
660
+ // ---------------------------------------------------------------------------
661
+ /**
662
+ * The checkpoint roster, in scan order — the one place a checkpoint type is
663
+ * declared. `CheckpointType` is derived from it and `CHECKPOINT_EXTRACTORS` is
664
+ * a total `Record` over it, so a type named here without an extractor, or an
665
+ * extractor written without a name here, is a compile error rather than a scan
666
+ * that silently never runs.
667
+ *
668
+ * The order is load-bearing: `extractDecisionCheckpoints` takes the first
669
+ * extractor that claims an event and stops, so placing a new type ahead of an
670
+ * existing one decides which of the two wins.
671
+ */
672
+ export const CHECKPOINT_TYPES = [
673
+ 'plan-sign-off',
674
+ 'waiver-decision',
675
+ 'escalation',
676
+ 'gate-block',
677
+ ];
658
678
  function planSignOffCheckpoint(event) {
659
679
  const { record } = event;
660
680
  // D-164: this used to demand `actor === 'operator'` exactly. Six of the
@@ -714,16 +734,17 @@ function gateBlockCheckpoint(event) {
714
734
  summary: `Gate blocked on ${record.task_id ?? 'unknown task'} (${p.reason ?? 'unspecified reason'})`,
715
735
  };
716
736
  }
717
- const CHECKPOINT_EXTRACTORS = [
718
- planSignOffCheckpoint,
719
- waiverDecisionCheckpoint,
720
- escalationCheckpoint,
721
- gateBlockCheckpoint,
722
- ];
737
+ const CHECKPOINT_EXTRACTORS = {
738
+ 'plan-sign-off': planSignOffCheckpoint,
739
+ 'waiver-decision': waiverDecisionCheckpoint,
740
+ escalation: escalationCheckpoint,
741
+ 'gate-block': gateBlockCheckpoint,
742
+ };
723
743
  /**
724
744
  * Deterministic scan for decision checkpoints (architecture §9.1: "what was
725
- * proposed, approved, modified, rejected — and why"): plan sign-offs, waiver
726
- * grants/denials, escalations (coordination.* errors), and gate blocks.
745
+ * proposed, approved, modified, rejected — and why"). `CHECKPOINT_TYPES` is
746
+ * the roster it walks, in that order, and the first extractor to claim an
747
+ * event owns it.
727
748
  */
728
749
  export function extractDecisionCheckpoints(events, since) {
729
750
  const sinceMs = since ? Date.parse(since) : null;
@@ -731,8 +752,8 @@ export function extractDecisionCheckpoints(events, since) {
731
752
  for (const event of events) {
732
753
  if (sinceMs !== null && Date.parse(event.record.ts) < sinceMs)
733
754
  continue;
734
- for (const extractor of CHECKPOINT_EXTRACTORS) {
735
- const checkpoint = extractor(event);
755
+ for (const checkpointType of CHECKPOINT_TYPES) {
756
+ const checkpoint = CHECKPOINT_EXTRACTORS[checkpointType](event);
736
757
  if (checkpoint) {
737
758
  checkpoints.push(checkpoint);
738
759
  break;
@@ -868,11 +889,18 @@ export async function dream(events, ctx, opts = {}, options = {}) {
868
889
  // (d) The operator's raise verb — P9-34
869
890
  // ---------------------------------------------------------------------------
870
891
  /**
871
- * Scopes whose compiled entry is matched against a file path by the
872
- * same-mistake gate (severity.ts's own FILE_SCOPED set — kept in sync by the
873
- * warnings below, which are the only thing that reads this).
892
+ * "claim-path, stack-wide and security" — severity.ts's ruling, read out for
893
+ * the warning below. The warning tells the operator what the same-mistake gate
894
+ * will do with their entry, so it has to name the scopes that gate actually
895
+ * matches; typing them into the sentence made this file's third-hand copy of a
896
+ * decision it does not own.
874
897
  */
875
- const FILE_SCOPED_SCOPES = new Set(['claim-path', 'stack-wide', 'security']);
898
+ function fileScopedScopeList() {
899
+ const scopes = [...FILE_SCOPED_SCOPES];
900
+ if (scopes.length <= 1)
901
+ return scopes.join('');
902
+ return `${scopes.slice(0, -1).join(', ')} and ${scopes[scopes.length - 1]}`;
903
+ }
876
904
  /**
877
905
  * Everything that is legal but inert about `shape`, in the operator's words.
878
906
  *
@@ -889,12 +917,12 @@ const FILE_SCOPED_SCOPES = new Set(['claim-path', 'stack-wide', 'security']);
889
917
  function scopeMismatchWarnings(shape) {
890
918
  const warnings = [];
891
919
  if (shape.lessonType === 'rule' &&
892
- FILE_SCOPED_SCOPES.has(shape.lessonScope) &&
920
+ isFileScopedScope(shape.lessonScope) &&
893
921
  !shape.findingCategory) {
894
922
  warnings.push('A file-scoped `rule` with no finding_category is injected at dispatch but can never escalate: severity.ts skips a category-less lesson.');
895
923
  }
896
- if (shape.claimPath && !FILE_SCOPED_SCOPES.has(shape.lessonScope)) {
897
- warnings.push(`claim_path is ignored for a ${shape.lessonScope}-scoped lesson — severity.ts only matches claim-path, stack-wide and security entries against a file.`);
924
+ if (shape.claimPath && !isFileScopedScope(shape.lessonScope)) {
925
+ warnings.push(`claim_path is ignored for a ${shape.lessonScope}-scoped lesson — severity.ts only matches ${fileScopedScopeList()} entries against a file.`);
898
926
  }
899
927
  // A selector on the wrong scope is never read back: lessonsForScope consults
900
928
  // exactly one field per scope. Warn rather than refuse — the entry is still
@@ -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
+ }
@@ -11,6 +11,16 @@ const here = path.dirname(fileURLToPath(import.meta.url));
11
11
  * assets ships in the tarball. `packaging.test.ts` holds that claim.
12
12
  */
13
13
  export const REPO_ROOT = path.resolve(here, '..', '..', '..');
14
+ /**
15
+ * Is `dir` this factory's own checkout, resolved rather than compared raw so
16
+ * a trailing slash or a relative path names the same answer. `audit.ts`
17
+ * (`auditedProjectName`) and `projects.ts` (`unwatchedProjects`) both need to
18
+ * tell "this clone, filed under its own directory name" from "a project that
19
+ * is genuinely named that" and used to each spell the comparison themselves.
20
+ */
21
+ export function isFactoryCheckout(dir) {
22
+ return path.resolve(dir) === path.resolve(REPO_ROOT);
23
+ }
14
24
  /**
15
25
  * Is this module running out of the blacksmith clone, or out of an installed
16
26
  * copy of the package?
@@ -6,6 +6,7 @@ import { SmithError } from './errors.js';
6
6
  import { topoSort } from './graph.js';
7
7
  import { SPECS_ACTIVE_DIR } from './paths.js';
8
8
  import { compileSchemas, validateRecord, } from './schemas.js';
9
+ import { epicOfTaskId } from './taskId.js';
9
10
  import { loadTaxonomy, TaxonomyError, validateRequiredDimensions, } from './taxonomy.js';
10
11
  export class PlanError extends SmithError {
11
12
  }
@@ -133,6 +134,122 @@ export function loadPlan(epicId, version, opts = {}) {
133
134
  }
134
135
  return JSON.parse(readFileSync(filePath, 'utf8'));
135
136
  }
137
+ /**
138
+ * A task ref's project, read off its own epic's latest plan version -- the
139
+ * D-246 precedent (`db/projector.ts`'s `planProjectResolver`, for the board)
140
+ * restated here so `errorIssues.ts` can answer the same question without
141
+ * importing anything database-shaped (`test/cliBoot.test.ts` pins `cli.ts`'s
142
+ * built module graph off drizzle/sqlite entirely, and `cli.ts` statically
143
+ * imports the error-issue reporter).
144
+ *
145
+ * Never throws and never guesses: an id with no epic segment, an epic with
146
+ * no plan on disk, and an unreadable or unparseable plan file all answer
147
+ * `null` alike, on purpose -- "cannot tell" and "the plan has no project" are
148
+ * the same non-answer to a caller that must never turn either one into this
149
+ * factory's own name. Answers cache by epic id for the resolver's lifetime,
150
+ * since one `issues report` run reads the same epic's plan for every row
151
+ * that epic contributed.
152
+ */
153
+ export function planProjectResolverForTaskRef(opts = {}) {
154
+ const cache = new Map();
155
+ return (taskRef) => {
156
+ const epicId = epicOfTaskId(taskRef);
157
+ if (epicId === null)
158
+ return null;
159
+ const cached = cache.get(epicId);
160
+ if (cached !== undefined)
161
+ return cached;
162
+ let project = null;
163
+ try {
164
+ const version = latestPlanVersion(epicId, opts);
165
+ if (version !== null)
166
+ project = loadPlan(epicId, version, opts).project ?? null;
167
+ }
168
+ catch {
169
+ // Unreadable or unparseable plan file: no answer, not a crash.
170
+ }
171
+ cache.set(epicId, project);
172
+ return project;
173
+ };
174
+ }
175
+ /**
176
+ * Whether `opts.specsDir` names THIS checkout's own specs/active tree --
177
+ * absent (so `planFilePath`/`latestPlanVersion` fall back to
178
+ * `SPECS_ACTIVE_DIR` themselves) or an explicit path that resolves to the
179
+ * same directory. Only then is a plan found there, naming no project,
180
+ * evidence of "self": `--specs-dir` is also how a caller points this
181
+ * resolver at a DIFFERENT checkout's specs tree entirely -- `issueInputs`'s
182
+ * own doc comment names "the daemon's own event log and a target checkout's
183
+ * specs tree" as an intended use -- and a plan found THERE naming no project
184
+ * is that other project's own silence, not this factory's (S2-b).
185
+ */
186
+ function isSelfSpecsDir(opts) {
187
+ return opts.specsDir === undefined || path.resolve(opts.specsDir) === SPECS_ACTIVE_DIR;
188
+ }
189
+ /**
190
+ * The self-fallback half of `planProjectResolverForTaskRefOrSelf`, on its
191
+ * own: a plan found on disk for the epic that simply declares no `project`
192
+ * field IS this factory's own project, by construction -- only this
193
+ * checkout's own epics have a plan under `specs/active` at all, so finding
194
+ * one there is itself the "self" signal a foreign epic's plan (which lives
195
+ * in the foreign project's own specs tree, never this one) can never
196
+ * produce. An epic with no plan on disk anywhere, or a bare ref with no
197
+ * epic segment, answers `null` -- "cannot tell whose project this is" --
198
+ * because plan.ts must not be the module that hardcodes this factory's
199
+ * name; the caller supplies it.
200
+ *
201
+ * That "only this checkout's own epics" premise holds only while `opts`
202
+ * actually points at this checkout's own specs/active tree
203
+ * (`isSelfSpecsDir`, S2-b): a caller that redirected `--specs-dir` at some
204
+ * OTHER checkout's specs tree gets no self-fallback at all, `null` only,
205
+ * because a plan found there naming no project is that other checkout's own
206
+ * silence, not this factory's.
207
+ *
208
+ * Exported as its own resolver, separate from `planProjectResolverForTaskRef`
209
+ * (S2-c): production callers (`cli.ts`'s `issueInputs`) hand the two to
210
+ * `withSessionFallback`/`foldErrorEvents` as DISTINCT tiers -- the strict
211
+ * resolver first, then a session's own unanimous stamp, and only then this
212
+ * guess -- so a session-mate's real stamp naming a different project
213
+ * outranks this weakest signal rather than losing to it, which is what
214
+ * happened when the two were folded into one resolver and tried together as
215
+ * a single first tier.
216
+ */
217
+ export function planSelfFallbackForTaskRef(selfProject, opts = {}) {
218
+ const selfEligible = isSelfSpecsDir(opts);
219
+ const hasPlanCache = new Map();
220
+ return (taskRef) => {
221
+ if (!selfEligible)
222
+ return null;
223
+ const epicId = epicOfTaskId(taskRef);
224
+ if (epicId === null)
225
+ return null;
226
+ let hasPlan = hasPlanCache.get(epicId);
227
+ if (hasPlan === undefined) {
228
+ hasPlan = latestPlanVersion(epicId, opts) !== null;
229
+ hasPlanCache.set(epicId, hasPlan);
230
+ }
231
+ return hasPlan ? selfProject : null;
232
+ };
233
+ }
234
+ /**
235
+ * `planProjectResolverForTaskRef` composed with `planSelfFallbackForTaskRef`
236
+ * as a single resolver, direct answer first and self-fallback second, for
237
+ * callers that want the OLD combined behavior (both tiers tried back to
238
+ * back, with nothing -- such as a session stamp -- able to sit between
239
+ * them). Kept for callers that do not need the S2-c tier split; `cli.ts`'s
240
+ * `issueInputs` no longer uses this, it hands the two halves to
241
+ * `withSessionFallback` separately instead.
242
+ */
243
+ export function planProjectResolverForTaskRefOrSelf(selfProject, opts = {}) {
244
+ const resolve = planProjectResolverForTaskRef(opts);
245
+ const selfFallback = planSelfFallbackForTaskRef(selfProject, opts);
246
+ return (taskRef) => {
247
+ const direct = resolve(taskRef);
248
+ if (direct !== null)
249
+ return direct;
250
+ return selfFallback(taskRef);
251
+ };
252
+ }
136
253
  /**
137
254
  * Reduce an `output_schema_ref` to the name the compiled schema set knows it
138
255
  * by. `compileSchemas` keys on filename-minus-`.schema.json`, so a path, a
@@ -175,12 +292,16 @@ const GLOB_CHARS = /[*?[{]/;
175
292
  /**
176
293
  * A `keeps_exports` promise is a file the task swears to keep the exports of,
177
294
  * and the post-run verifier reads it back against the task's diff. Three
178
- * things make a promise unverifiable, and each is caught here, at plan time:
295
+ * things make a promise unverifiable at plan time, and each is caught here:
179
296
  * a pattern (the verifier diffs one file, not a glob), a file outside the
180
297
  * task's claims (the task cannot edit it, so its diff never shows it), and a
181
- * file listed twice (which entry did the verifier report?). Shape is the
182
- * schema's business; when the schema already rejected the field, this reads
183
- * nothing and reports nothing, so the operator sees one error, not two.
298
+ * file listed twice (which entry did the verifier report?). A fourth is not
299
+ * knowable here and is answered where it is knowable: a promise on a file the
300
+ * symbol scanner does not speak for reads back `unverified`, not `kept`
301
+ * (impact.ts collectExportDiffs) — because whether this repo's scanner can
302
+ * parse that file is a fact about the checkout, not about the plan. Shape is
303
+ * the schema's business; when the schema already rejected the field, this
304
+ * reads nothing and reports nothing, so the operator sees one error, not two.
184
305
  */
185
306
  function unkeptPromises(t, schemaIssues) {
186
307
  const promises = t.keeps_exports;
@@ -505,6 +626,19 @@ export function livePlanTasks(plan) {
505
626
  }
506
627
  return live;
507
628
  }
629
+ /**
630
+ * The plan's tasks in the shape `resolveFindingOwner` reads: only the live
631
+ * (D-126) ones, and only the `claims` field ownership resolution needs. This
632
+ * is `livePlanTasks` restated as claims rather than full specs, so a caller
633
+ * asking "does some task in this plan still claim that file?" (`audit
634
+ * resolve`) gets the same live/superseded rule `epic verdict` does, not the
635
+ * raw `plan.tasks` array that also holds every amendment left behind.
636
+ */
637
+ export function planClaimedTasks(plan) {
638
+ return livePlanTasks(plan)
639
+ .filter((task) => Array.isArray(task.claims))
640
+ .map((task) => ({ task_id: task.task_id, claims: task.claims }));
641
+ }
508
642
  /**
509
643
  * What makes two records the same *spec*: everything except the version stamp
510
644
  * and the workflow status. `nextVersion` rewrites `plan_version` on every
@@ -192,7 +192,7 @@ const DEFAULT_JUDGE_BUDGET = { timeout_ms: 120_000, max_output_bytes: 262_144 };
192
192
  * case/token budgets, each task's acceptance criteria and nonfunctional
193
193
  * clauses, edge count, fired triggers with their evidence) — never file
194
194
  * contents, never a diff. The criteria are part of the claim, not evidence
195
- * for it: FD-46 (csb-signing-policy-1) had both external critics refute
195
+ * for it: FD-46 had both external critics refute
196
196
  * plan-v1 for missing a case its task already listed as a criterion, because
197
197
  * the prompt showed them the objective and nothing under it. The tasks it
198
198
  * lists are `livePlanTasks`, not `plan.tasks`: this prompt is the whole of