@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.
- package/.claude/agents/auditor.md +3 -0
- package/.claude/agents/grader.md +12 -5
- package/.claude/agents/reviewer.md +3 -0
- package/.claude/agents/security-reviewer.md +3 -0
- package/.claude/agents/spec-reviewer.md +3 -0
- package/.claude/agents/verifier.md +3 -0
- package/.claude/skills/bs/audit.md +25 -9
- package/.claude/skills/bs/dispatch.md +60 -7
- package/.claude/skills/bs/plan.md +33 -1
- package/.claude/skills/bs/run.md +18 -13
- package/.claude/skills/bs/wave.md +68 -10
- package/README.md +4 -4
- package/factory/orchestrator/dist/agentsSync.js +178 -0
- package/factory/orchestrator/dist/audit.js +134 -17
- package/factory/orchestrator/dist/autonomy.js +25 -0
- package/factory/orchestrator/dist/budgets.js +91 -2
- package/factory/orchestrator/dist/claims.js +8 -4
- package/factory/orchestrator/dist/cli.js +394 -26
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/daemon.js +230 -41
- package/factory/orchestrator/dist/db/projector.js +93 -36
- package/factory/orchestrator/dist/db/queries.js +58 -35
- package/factory/orchestrator/dist/db/schema.js +24 -0
- package/factory/orchestrator/dist/dispatchAudit.js +10 -2
- package/factory/orchestrator/dist/dispatchLint.js +132 -0
- package/factory/orchestrator/dist/epic.js +192 -39
- package/factory/orchestrator/dist/epicWidth.js +25 -20
- package/factory/orchestrator/dist/errorIssues.js +209 -24
- package/factory/orchestrator/dist/eventOrder.js +102 -0
- package/factory/orchestrator/dist/events.js +73 -91
- package/factory/orchestrator/dist/findingContext.js +1 -13
- package/factory/orchestrator/dist/findings.js +132 -11
- package/factory/orchestrator/dist/gh.js +29 -6
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/harness.js +92 -10
- package/factory/orchestrator/dist/hookDecision.js +320 -37
- package/factory/orchestrator/dist/impact.js +24 -2
- package/factory/orchestrator/dist/integration.js +11 -1
- package/factory/orchestrator/dist/issueReporter.js +415 -0
- package/factory/orchestrator/dist/judgeStopHook.js +181 -0
- package/factory/orchestrator/dist/judges.js +1 -1
- package/factory/orchestrator/dist/lessons.js +46 -18
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/paths.js +10 -0
- package/factory/orchestrator/dist/plan.js +138 -4
- package/factory/orchestrator/dist/planQuorum.js +1 -1
- package/factory/orchestrator/dist/policy.js +57 -11
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/queue.js +477 -29
- package/factory/orchestrator/dist/scheduler.js +51 -0
- package/factory/orchestrator/dist/severity.js +40 -8
- package/factory/orchestrator/dist/spec.js +70 -0
- package/factory/orchestrator/dist/taskEvents.js +13 -4
- package/factory/orchestrator/dist/taskStatus.js +74 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testgate.js +32 -3
- package/factory/orchestrator/dist/usage.js +42 -9
- package/factory/orchestrator/dist/waivers.js +60 -9
- package/factory/orchestrator/dist/waveConcurrency.js +29 -0
- package/factory/orchestrator/dist/waveNext.js +12 -10
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/worktree.js +19 -6
- package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +9 -2
- package/factory/policies/crosscheck.yml +2 -2
- package/factory/policies/lessons.md +298 -0
- package/factory/policies/scheduler.yml +27 -2
- package/factory/policies/taxonomy.yml +10 -3
- package/factory/specs/roadmap.md +70 -23
- 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")
|
|
726
|
-
*
|
|
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
|
|
735
|
-
const checkpoint =
|
|
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
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 && !
|
|
897
|
-
warnings.push(`claim_path is ignored for a ${shape.lessonScope}-scoped lesson — severity.ts only matches
|
|
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
|
|
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?).
|
|
182
|
-
*
|
|
183
|
-
*
|
|
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
|
|
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
|