@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isLaterEvent } from './eventOrder.js';
|
|
2
3
|
/** A `Record` over every `GateBlockedReason`, not a switch with a default: a reason missing from `GateBlockedReason` fails to compile here. */
|
|
3
4
|
const GATE_BLOCKED_ERROR_CLASSES = {
|
|
4
5
|
'schema-invalid': 'gate.blocked.schema-invalid',
|
|
@@ -19,6 +20,10 @@ const BLOCKING_SEVERITY = 'S2-major';
|
|
|
19
20
|
const GATE_OUTCOME_EVENT_TYPE = 'gate-outcome';
|
|
20
21
|
const ERROR_LOGGED_EVENT_TYPE = 'error-logged';
|
|
21
22
|
const TASK_ADDED_EVENT_TYPE = 'task-added';
|
|
23
|
+
/** `agents-registry.ts`'s `DISPATCH_EVENT_TYPE`, hand-restated rather than
|
|
24
|
+
* imported -- this module's only imports are `node:crypto` and two local,
|
|
25
|
+
* type-only siblings, on purpose (see the file banner). */
|
|
26
|
+
const DISPATCH_DECISION_EVENT_TYPE = 'dispatch_decision';
|
|
22
27
|
/**
|
|
23
28
|
* Source 2: `task_status: failed` is set in exactly one place in the whole
|
|
24
29
|
* projector, `db/projector.ts`'s `case 'task-added'` arm copying
|
|
@@ -28,8 +33,6 @@ const TASK_ADDED_EVENT_TYPE = 'task-added';
|
|
|
28
33
|
* carries no failure discriminator beyond that.
|
|
29
34
|
*/
|
|
30
35
|
const TASK_FAILED_ERROR_CLASS = 'task.failed';
|
|
31
|
-
/** Default project for an event stamped with none (events.ts's documented convention; the writer never sets it, only read helpers default it). */
|
|
32
|
-
const DEFAULT_PROJECT = 'black-smith';
|
|
33
36
|
function asString(value) {
|
|
34
37
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
35
38
|
}
|
|
@@ -37,18 +40,14 @@ function asNumber(value) {
|
|
|
37
40
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
38
41
|
}
|
|
39
42
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* a
|
|
43
|
+
* The sources, keyed by the event type each one reads. A table rather than a
|
|
44
|
+
* chain of `if`s so that ISSUE_CANDIDATE_EVENT_TYPES below can be read off
|
|
45
|
+
* it: a fourth source cannot be folded without appearing here, and appearing
|
|
46
|
+
* here is what puts it in the roster.
|
|
43
47
|
*/
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const project = asString(record.project) ?? DEFAULT_PROJECT;
|
|
48
|
-
const sessionId = asString(record.session_id);
|
|
49
|
-
const planVersion = asNumber(record.plan_version) ?? null;
|
|
50
|
-
const ts = asString(record.ts);
|
|
51
|
-
if (record.event_type === GATE_OUTCOME_EVENT_TYPE) {
|
|
48
|
+
const CANDIDATE_READERS = Object.freeze({
|
|
49
|
+
[GATE_OUTCOME_EVENT_TYPE]: (event, { payload, project, sessionId, planVersion, ts }) => {
|
|
50
|
+
const { record } = event;
|
|
52
51
|
if (payload.outcome !== 'blocked')
|
|
53
52
|
return 'ignore';
|
|
54
53
|
const reasonRaw = asString(payload.reason);
|
|
@@ -70,8 +69,9 @@ function toCandidate(event) {
|
|
|
70
69
|
epicId: null,
|
|
71
70
|
planVersion,
|
|
72
71
|
};
|
|
73
|
-
}
|
|
74
|
-
|
|
72
|
+
},
|
|
73
|
+
[ERROR_LOGGED_EVENT_TYPE]: (event, { payload, project, sessionId, planVersion, ts }) => {
|
|
74
|
+
const { record } = event;
|
|
75
75
|
const errorClass = asString(payload.error);
|
|
76
76
|
const severity = asString(payload.severity);
|
|
77
77
|
const taskRef = asString(record.task_id) ?? asString(payload.task_ref);
|
|
@@ -89,8 +89,9 @@ function toCandidate(event) {
|
|
|
89
89
|
epicId: null,
|
|
90
90
|
planVersion,
|
|
91
91
|
};
|
|
92
|
-
}
|
|
93
|
-
|
|
92
|
+
},
|
|
93
|
+
[TASK_ADDED_EVENT_TYPE]: (event, { payload, project, sessionId, planVersion, ts }) => {
|
|
94
|
+
const { record } = event;
|
|
94
95
|
if (payload.task_status !== 'failed')
|
|
95
96
|
return 'ignore';
|
|
96
97
|
const taskRef = asString(record.task_id);
|
|
@@ -109,9 +110,168 @@ function toCandidate(event) {
|
|
|
109
110
|
epicId,
|
|
110
111
|
planVersion,
|
|
111
112
|
};
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
/**
|
|
116
|
+
* The event types this fold reads candidates from — read off the table above
|
|
117
|
+
* rather than hand-copied a second time, the way GATE_BLOCKED_REASONS is.
|
|
118
|
+
* cli.ts scopes `--epic`/`--since` with it and passes everything else
|
|
119
|
+
* through as history; typed there instead, a source added here and not there
|
|
120
|
+
* would be reported outside the window the operator asked about.
|
|
121
|
+
*/
|
|
122
|
+
export const ISSUE_CANDIDATE_EVENT_TYPES = new Set(Object.keys(CANDIDATE_READERS));
|
|
123
|
+
/** The row's task ref, off the envelope first and the payload second -- the same two places every `CandidateReader` above already looks. */
|
|
124
|
+
function candidateTaskRef(record) {
|
|
125
|
+
const payload = (record.payload ?? {});
|
|
126
|
+
return asString(record.task_id) ?? asString(payload.task_ref);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Event types allowed to stamp a session's project for
|
|
130
|
+
* `sessionProjectStamps` below: the issue candidates themselves, plus
|
|
131
|
+
* `task-added` and `dispatch_decision` rows recording where a task was
|
|
132
|
+
* planned or dispatched. Deliberately excludes `issue-reported` and every
|
|
133
|
+
* other reporter-authored row -- those carry this module's own *guess* at a
|
|
134
|
+
* row's project (see `appendIssueReported` in `issueReporter.ts`), not an
|
|
135
|
+
* independent origin stamp, so folding them back in here would let one run's
|
|
136
|
+
* guess leak into the next run's resolution of an unrelated, unstamped row
|
|
137
|
+
* in the same session.
|
|
138
|
+
*/
|
|
139
|
+
const ORIGIN_STAMP_EVENT_TYPES = new Set([
|
|
140
|
+
...ISSUE_CANDIDATE_EVENT_TYPES,
|
|
141
|
+
DISPATCH_DECISION_EVENT_TYPE,
|
|
142
|
+
]);
|
|
143
|
+
/**
|
|
144
|
+
* The session's project, from its origin-stamped rows only -- and only when
|
|
145
|
+
* every one of them agrees. Real sessions mix dozens of stamped rows with
|
|
146
|
+
* unstamped ones (evidence: a foreign project's session stamps some events
|
|
147
|
+
* and not others); a unanimous stamped sibling in the same session is strong
|
|
148
|
+
* evidence for an unstamped one. A session whose origin stamps disagree
|
|
149
|
+
* tells us nothing safe to guess, so it answers unresolved rather than
|
|
150
|
+
* picking whichever stamp happened to come first.
|
|
151
|
+
*/
|
|
152
|
+
function sessionProjectStamps(events) {
|
|
153
|
+
const bySession = new Map();
|
|
154
|
+
for (const { record } of events) {
|
|
155
|
+
if (!ORIGIN_STAMP_EVENT_TYPES.has(record.event_type))
|
|
156
|
+
continue;
|
|
157
|
+
const project = asString(record.project);
|
|
158
|
+
if (project === undefined)
|
|
159
|
+
continue;
|
|
160
|
+
const seen = bySession.get(record.session_id);
|
|
161
|
+
if (seen)
|
|
162
|
+
seen.add(project);
|
|
163
|
+
else
|
|
164
|
+
bySession.set(record.session_id, new Set([project]));
|
|
112
165
|
}
|
|
113
|
-
|
|
166
|
+
const stamps = new Map();
|
|
167
|
+
for (const [sessionId, projects] of bySession) {
|
|
168
|
+
if (projects.size !== 1)
|
|
169
|
+
continue;
|
|
170
|
+
const [project] = projects;
|
|
171
|
+
if (project !== undefined)
|
|
172
|
+
stamps.set(sessionId, project);
|
|
173
|
+
}
|
|
174
|
+
return stamps;
|
|
114
175
|
}
|
|
176
|
+
function taskAddedKey(sessionId, taskId) {
|
|
177
|
+
return `${sessionId}\u0000${taskId}`;
|
|
178
|
+
}
|
|
179
|
+
/** `task-added`'s own `payload.epic_id`, keyed by session and task id, so a bare ref (no epic segment) can still be widened to `<epic>/<ref>` before asking the resolver. */
|
|
180
|
+
function taskAddedEpicIds(events) {
|
|
181
|
+
const out = new Map();
|
|
182
|
+
for (const { record } of events) {
|
|
183
|
+
if (record.event_type !== TASK_ADDED_EVENT_TYPE)
|
|
184
|
+
continue;
|
|
185
|
+
const taskId = asString(record.task_id);
|
|
186
|
+
const epicId = asString(record.payload?.epic_id);
|
|
187
|
+
if (taskId !== undefined && epicId !== undefined) {
|
|
188
|
+
out.set(taskAddedKey(record.session_id, taskId), epicId);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Widens a per-epic `ResolveProjectForTaskRef` into a `RowProjectResolver`
|
|
195
|
+
* with three session-scoped fallbacks a single task ref cannot answer alone,
|
|
196
|
+
* tried strictly in this order (S2-c):
|
|
197
|
+
*
|
|
198
|
+
* 1. `resolveProject`, the strict/direct resolver -- a bare ref (no epic
|
|
199
|
+
* segment) tried again as `<epic>/<ref>` using the same session's own
|
|
200
|
+
* `task-added` row when the bare ref alone cannot answer.
|
|
201
|
+
* 2. The session stamp: the ONE project every origin-stamped row in the same
|
|
202
|
+
* session agrees on (`sessionProjectStamps`), when unanimous.
|
|
203
|
+
* 3. `selfFallback`, tried last and only when neither of the above could
|
|
204
|
+
* answer -- same widening as step 1.
|
|
205
|
+
*
|
|
206
|
+
* Self-fallback sits last on purpose: it is "no plan says otherwise, and a
|
|
207
|
+
* plan exists locally for this epic, so guess this factory's own name" --
|
|
208
|
+
* the weakest of the three signals. A session's OWN unanimous stamp naming a
|
|
209
|
+
* DIFFERENT project is stronger evidence than that guess, and must not be
|
|
210
|
+
* overridden by it just because the same session also has an unrelated,
|
|
211
|
+
* unstamped row whose epic happens to have a local, unlabeled plan. Before
|
|
212
|
+
* this order existed, the self-fallback was folded into `resolveProject`
|
|
213
|
+
* itself (step 1) and so always outran the session stamp.
|
|
214
|
+
*
|
|
215
|
+
* Exported so `issueReporter.ts`'s `priorOpenReports` can apply the identical
|
|
216
|
+
* fallback to the same event snapshot.
|
|
217
|
+
*/
|
|
218
|
+
export function withSessionFallback(events, resolveProject, selfFallback = () => null) {
|
|
219
|
+
const stamps = sessionProjectStamps(events);
|
|
220
|
+
const epicIds = taskAddedEpicIds(events);
|
|
221
|
+
const tryResolve = (resolve, taskRef, sessionId) => {
|
|
222
|
+
if (taskRef === undefined)
|
|
223
|
+
return null;
|
|
224
|
+
const direct = resolve(taskRef);
|
|
225
|
+
if (direct !== null)
|
|
226
|
+
return direct;
|
|
227
|
+
if (sessionId === undefined)
|
|
228
|
+
return null;
|
|
229
|
+
const epicId = epicIds.get(taskAddedKey(sessionId, taskRef));
|
|
230
|
+
if (epicId === undefined)
|
|
231
|
+
return null;
|
|
232
|
+
return resolve(`${epicId}/${taskRef}`);
|
|
233
|
+
};
|
|
234
|
+
return (taskRef, sessionId) => {
|
|
235
|
+
const direct = tryResolve(resolveProject, taskRef, sessionId);
|
|
236
|
+
if (direct !== null)
|
|
237
|
+
return direct;
|
|
238
|
+
if (sessionId !== undefined) {
|
|
239
|
+
const stamp = stamps.get(sessionId);
|
|
240
|
+
if (stamp !== undefined)
|
|
241
|
+
return stamp;
|
|
242
|
+
}
|
|
243
|
+
return tryResolve(selfFallback, taskRef, sessionId);
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/** One event to a `Candidate`, or `'ignore'` when no source claims its event type at all. */
|
|
247
|
+
function toCandidate(event, resolveRow) {
|
|
248
|
+
const { record } = event;
|
|
249
|
+
const read = CANDIDATE_READERS[record.event_type];
|
|
250
|
+
if (!read)
|
|
251
|
+
return 'ignore';
|
|
252
|
+
const stamped = asString(record.project);
|
|
253
|
+
const taskRef = candidateTaskRef(record);
|
|
254
|
+
const sessionId = asString(record.session_id);
|
|
255
|
+
const project = stamped ?? resolveRow(taskRef, sessionId);
|
|
256
|
+
return read(event, {
|
|
257
|
+
payload: record.payload ?? {},
|
|
258
|
+
project,
|
|
259
|
+
sessionId,
|
|
260
|
+
planVersion: asNumber(record.plan_version) ?? null,
|
|
261
|
+
ts: asString(record.ts),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* A row whose project cannot be resolved at all still needs a stable
|
|
266
|
+
* fingerprint-grouping key -- otherwise two unrelated foreign projects'
|
|
267
|
+
* rows sharing `(source, error_class, task_ref)` would incorrectly group
|
|
268
|
+
* together and leak one row's session/epic/severity metadata onto the
|
|
269
|
+
* other's report, inside this factory's own private log (both rows are
|
|
270
|
+
* always skipped before anything is filed publicly, but the log itself
|
|
271
|
+
* should stay correct). `task_ref` alone already disambiguates in practice
|
|
272
|
+
* (refs are namespaced by epic id), so a single fixed sentinel is enough.
|
|
273
|
+
*/
|
|
274
|
+
const UNRESOLVED_PROJECT_FINGERPRINT_KEY = '\u0000unresolved-project\u0000';
|
|
115
275
|
/**
|
|
116
276
|
* The first 16 hex characters of a SHA-256 over the NUL-joined tuple
|
|
117
277
|
* `project`, `source`, `error_class`, `task_ref`. Deliberately excludes
|
|
@@ -120,27 +280,52 @@ function toCandidate(event) {
|
|
|
120
280
|
* and including any of them would open one issue per round instead of one.
|
|
121
281
|
*/
|
|
122
282
|
function computeFingerprint(project, source, errorClass, taskRef) {
|
|
123
|
-
const material = [
|
|
283
|
+
const material = [
|
|
284
|
+
project ?? UNRESOLVED_PROJECT_FINGERPRINT_KEY,
|
|
285
|
+
source,
|
|
286
|
+
errorClass,
|
|
287
|
+
taskRef,
|
|
288
|
+
].join('\0');
|
|
124
289
|
return createHash('sha256').update(material).digest('hex').slice(0, 16);
|
|
125
290
|
}
|
|
126
291
|
/**
|
|
127
292
|
* Fold a stored event sequence into reportable errors. Pure: two calls with
|
|
128
293
|
* identical input return byte-identical output. `now` is threaded in rather
|
|
129
294
|
* than read off the clock; nothing in this contract branches on it yet.
|
|
295
|
+
*
|
|
296
|
+
* `resolveProject` and `selfFallback` both default to "no answer" so the
|
|
297
|
+
* ~50 existing call sites across this module's own tests and
|
|
298
|
+
* `issueReporter.ts`'s need no change. An unstamped row still unresolved
|
|
299
|
+
* after `withSessionFallback`'s three fallbacks reports `project: null`
|
|
300
|
+
* rather than this factory's own name -- `isProjectEnabled` is never asked
|
|
301
|
+
* about a row it cannot identify, and the decision to skip filing it
|
|
302
|
+
* belongs to `issueReporter.ts`, the one caller that goes on to call `gh`.
|
|
303
|
+
*
|
|
304
|
+
* `scopeEvents` defaults to `events` itself -- the session-stamp/`task-added`
|
|
305
|
+
* maps `withSessionFallback` builds read the same rows this fold iterates,
|
|
306
|
+
* same as before this parameter existed. A caller that narrows `events` to a
|
|
307
|
+
* candidate WINDOW (`cli.ts`'s `--epic`/`--since`) passes the full, unscoped
|
|
308
|
+
* lineage here instead (S3), so a session-mate's stamp sitting outside that
|
|
309
|
+
* window still counts as evidence for a row inside it -- scoping which rows
|
|
310
|
+
* get REPORTED must not also scope which rows get CONSULTED, or the same
|
|
311
|
+
* unstamped row resolves to a different project (and hashes a different
|
|
312
|
+
* fingerprint, S1-b) depending on whether the caller happened to pass
|
|
313
|
+
* `--epic`.
|
|
130
314
|
*/
|
|
131
|
-
export function foldErrorEvents(events, now, isProjectEnabled) {
|
|
315
|
+
export function foldErrorEvents(events, now, isProjectEnabled, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
|
|
132
316
|
void now;
|
|
317
|
+
const resolveRow = withSessionFallback(scopeEvents, resolveProject, selfFallback);
|
|
133
318
|
const candidates = [];
|
|
134
319
|
let skipped = 0;
|
|
135
320
|
for (const event of events) {
|
|
136
|
-
const outcome = toCandidate(event);
|
|
321
|
+
const outcome = toCandidate(event, resolveRow);
|
|
137
322
|
if (outcome === 'ignore')
|
|
138
323
|
continue;
|
|
139
324
|
if (outcome === null) {
|
|
140
325
|
skipped += 1;
|
|
141
326
|
continue;
|
|
142
327
|
}
|
|
143
|
-
if (!isProjectEnabled(outcome.project))
|
|
328
|
+
if (outcome.project !== null && !isProjectEnabled(outcome.project))
|
|
144
329
|
continue;
|
|
145
330
|
candidates.push(outcome);
|
|
146
331
|
}
|
|
@@ -155,7 +340,7 @@ export function foldErrorEvents(events, now, isProjectEnabled) {
|
|
|
155
340
|
}
|
|
156
341
|
const reports = [];
|
|
157
342
|
for (const [fingerprint, group] of groups) {
|
|
158
|
-
const latest = group.reduce((a, b) =>
|
|
343
|
+
const latest = group.reduce((a, b) => (isLaterEvent(b, a) ? b : a));
|
|
159
344
|
for (const candidate of group) {
|
|
160
345
|
reports.push({
|
|
161
346
|
fingerprint,
|
|
@@ -225,7 +410,7 @@ export function renderBody(fields) {
|
|
|
225
410
|
`Session: ${fields.session_id}`,
|
|
226
411
|
`Epic: ${fields.epic_id ?? '(none)'}`,
|
|
227
412
|
`Plan version: ${fields.plan_version ?? '(unknown)'}`,
|
|
228
|
-
`Project: ${fields.project}`,
|
|
413
|
+
`Project: ${fields.project ?? '(unknown)'}`,
|
|
229
414
|
`Source: ${fields.source}`,
|
|
230
415
|
fingerprintLine(fields.fingerprint),
|
|
231
416
|
'',
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The log-order vocabulary, split out of events.ts (the log's I/O hub) so a
|
|
2
|
+
// pure reader can order events without pulling in filesystem access.
|
|
3
|
+
// errorIssues.ts is that reader: task-2's purity clauses keep it free of any
|
|
4
|
+
// runtime import from `./events.js`, so this module has NO import statement
|
|
5
|
+
// at all — not even `./errors.js` — and its transitive import graph is
|
|
6
|
+
// empty.
|
|
7
|
+
/**
|
|
8
|
+
* Split an event id into the session that owns it and its line index.
|
|
9
|
+
*
|
|
10
|
+
* Event ids have always been `<session-id>#<index>` — that shape is what makes
|
|
11
|
+
* a cross-session reference resolvable at all (P9-7): given only the id, a
|
|
12
|
+
* reader knows which log file to open. Splitting on the LAST `#` keeps a
|
|
13
|
+
* session id that happens to contain one from silently resolving to the wrong
|
|
14
|
+
* session.
|
|
15
|
+
*
|
|
16
|
+
* This is the ONE parser of that shape. Unlike `parseEventId` in
|
|
17
|
+
* `events.ts` (the throwing wrapper callers reach for at the log's edges),
|
|
18
|
+
* this never throws — it returns `null` for anything malformed, including a
|
|
19
|
+
* non-string reached through a cast, so a pure comparator can stay total.
|
|
20
|
+
*/
|
|
21
|
+
export function tryParseEventId(eventId) {
|
|
22
|
+
if (typeof eventId !== 'string')
|
|
23
|
+
return null;
|
|
24
|
+
const cut = eventId.lastIndexOf('#');
|
|
25
|
+
const sessionId = cut === -1 ? '' : eventId.slice(0, cut);
|
|
26
|
+
const rawIndex = eventId.slice(cut + 1);
|
|
27
|
+
// Number.parseInt would accept "3abc"; an event id is exact or it is a typo.
|
|
28
|
+
const index = /^\d+$/.test(rawIndex) ? Number(rawIndex) : Number.NaN;
|
|
29
|
+
if (cut === -1 || sessionId.length === 0 || Number.isNaN(index))
|
|
30
|
+
return null;
|
|
31
|
+
return { sessionId, index };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Where an event sits in the order the log actually wrote it.
|
|
35
|
+
*
|
|
36
|
+
* An event id is `<session-id>#<index>` and the index is the event's line in
|
|
37
|
+
* its session's log, so within a session this *is* the order rather than a
|
|
38
|
+
* proxy for it. Across sessions the logs are separate files that nothing
|
|
39
|
+
* interleaves, so the session id is here to make the answer the same on every
|
|
40
|
+
* call, not because one session precedes another.
|
|
41
|
+
*
|
|
42
|
+
* Total where tryParseEventId returns null, and the fallback is unreachable
|
|
43
|
+
* by construction — every id this sees came out of readEvents() or a
|
|
44
|
+
* projection of it, and readEvents builds each one as `<session>#<index>`. It
|
|
45
|
+
* is here because the readers on top of this are a dashboard `/api/pulse`
|
|
46
|
+
* polls every 5s and two audits the operator runs on a whole log, and an id
|
|
47
|
+
* that somehow would not parse should sort somewhere rather than take the
|
|
48
|
+
* caller down with it. Where it lands is deliberately modest: an event whose
|
|
49
|
+
* place in the log is unreadable does not get to win a tie on it.
|
|
50
|
+
*/
|
|
51
|
+
function logOrderOf(eventId) {
|
|
52
|
+
return tryParseEventId(eventId) ?? { sessionId: eventId, index: -1 };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The order the log wrote two events in: negative when `a` came first,
|
|
56
|
+
* positive when `b` did, zero only for the same event.
|
|
57
|
+
*
|
|
58
|
+
* `ts` is stamped at millisecond resolution (`appendEvent` in `events.ts`
|
|
59
|
+
* stamps it), so a burst of appends routinely shares one: the test fixture's
|
|
60
|
+
* own last two events tie in roughly one build in three, and a gate outcome
|
|
61
|
+
* and the retry dispatched in answer to it are written back to back. Nothing
|
|
62
|
+
* else in the record carries the sequence — `events_raw` has no such column
|
|
63
|
+
* and the JSONL line has no such field — so on a tie `ts` has nothing left to
|
|
64
|
+
* say, and nothing downstream of it does either. A `>` between two tied rows
|
|
65
|
+
* is not a decision, it is whichever the scan reached first; `ORDER BY ts` is
|
|
66
|
+
* the same non-answer spelled in SQL, since SQLite promises nothing about
|
|
67
|
+
* tied rows and hands them back in physical order, which changes the moment a
|
|
68
|
+
* row is rewritten; and a JS `.sort()` whose comparator returns 0 throughout
|
|
69
|
+
* is stable, so it keeps that same scan order and passes it off as
|
|
70
|
+
* chronology.
|
|
71
|
+
*
|
|
72
|
+
* The log index behind the event id is what actually decides, and it has to
|
|
73
|
+
* be read as a number: ordered as text — which is what `ORDER BY ts,
|
|
74
|
+
* event_id` does — `#9` sorts after `#10`, so the tiebreaker inverts as soon
|
|
75
|
+
* as a session's log passes ten events.
|
|
76
|
+
*
|
|
77
|
+
* It lives here, below `events.ts`, rather than in any one reader because
|
|
78
|
+
* more than one of them asks this question: the dashboard queries fold and
|
|
79
|
+
* sort rows, escalation.ts walks a task's rounds looking for the dispatch on
|
|
80
|
+
* either side of one, and errorIssues.ts's pure fold needs the same answer
|
|
81
|
+
* without importing the fs hub that owns the log's I/O. Every ordering a
|
|
82
|
+
* reader would call chronological routes through here, so that no two of them
|
|
83
|
+
* can answer the same question about the same two events differently — which
|
|
84
|
+
* is exactly what happened when the callers spelled the comparison
|
|
85
|
+
* themselves, with opposite operators.
|
|
86
|
+
*/
|
|
87
|
+
export function compareLogOrder(a, b) {
|
|
88
|
+
if (a.ts !== b.ts)
|
|
89
|
+
return a.ts < b.ts ? -1 : 1;
|
|
90
|
+
const left = logOrderOf(a.eventId);
|
|
91
|
+
const right = logOrderOf(b.eventId);
|
|
92
|
+
if (left.sessionId !== right.sessionId)
|
|
93
|
+
return left.sessionId < right.sessionId ? -1 : 1;
|
|
94
|
+
return left.index - right.index;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Whether `a` is the later of two events — the one a reader means by "what
|
|
98
|
+
* just happened".
|
|
99
|
+
*/
|
|
100
|
+
export function isLaterEvent(a, b) {
|
|
101
|
+
return compareLogOrder(a, b) > 0;
|
|
102
|
+
}
|
|
@@ -2,6 +2,7 @@ import { existsSync, readdirSync } from 'node:fs';
|
|
|
2
2
|
import { appendFile, mkdir, open, readFile, rm, stat } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { SmithError } from './errors.js';
|
|
5
|
+
import { tryParseEventId } from './eventOrder.js';
|
|
5
6
|
import { STATE_EVENTS_DIR } from './paths.js';
|
|
6
7
|
import { compileSchemas, validateRecord } from './schemas.js';
|
|
7
8
|
import { taskIdsMatch } from './taskId.js';
|
|
@@ -17,6 +18,11 @@ export class EventError extends SmithError {
|
|
|
17
18
|
* the literal is a second thing to keep in step.
|
|
18
19
|
*/
|
|
19
20
|
export const ROOT_EVENT_TYPE = 'session-start';
|
|
21
|
+
// `compareLogOrder`, `isLaterEvent` and `ParsedEventId` now live in
|
|
22
|
+
// `eventOrder.ts`, a leaf with no import statement, so a pure reader (the
|
|
23
|
+
// error fold in errorIssues.ts) can order events without importing this
|
|
24
|
+
// fs hub.
|
|
25
|
+
export { compareLogOrder, isLaterEvent } from './eventOrder.js';
|
|
20
26
|
/**
|
|
21
27
|
* Split an event id into the session that owns it and its line index.
|
|
22
28
|
*
|
|
@@ -25,6 +31,10 @@ export const ROOT_EVENT_TYPE = 'session-start';
|
|
|
25
31
|
* reader knows which log file to open. Splitting on the LAST `#` keeps a
|
|
26
32
|
* session id that happens to contain one from silently resolving to the wrong
|
|
27
33
|
* session.
|
|
34
|
+
*
|
|
35
|
+
* The parse itself lives in `eventOrder.ts` (`tryParseEventId`, which never
|
|
36
|
+
* throws); this wrapper is the one place that turns a malformed id into the
|
|
37
|
+
* `events.malformed-event-id` error every caller of `parseEventId` expects.
|
|
28
38
|
*/
|
|
29
39
|
export function parseEventId(eventId) {
|
|
30
40
|
// Typed as a string and reached with whatever the writer sent. `smith event
|
|
@@ -37,87 +47,11 @@ export function parseEventId(eventId) {
|
|
|
37
47
|
if (typeof eventId !== 'string') {
|
|
38
48
|
throw new EventError('events.malformed-event-id', `${JSON.stringify(eventId) ?? String(eventId)} (${typeof eventId}) is not an event id. The form is <session-id>#<index>, e.g. dogfood-envkit-1#42.`, { event_id: eventId });
|
|
39
49
|
}
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
const rawIndex = eventId.slice(cut + 1);
|
|
43
|
-
// Number.parseInt would accept "3abc"; an event id is exact or it is a typo.
|
|
44
|
-
const index = /^\d+$/.test(rawIndex) ? Number(rawIndex) : Number.NaN;
|
|
45
|
-
if (cut === -1 || sessionId.length === 0 || Number.isNaN(index)) {
|
|
50
|
+
const parsed = tryParseEventId(eventId);
|
|
51
|
+
if (parsed === null) {
|
|
46
52
|
throw new EventError('events.malformed-event-id', `"${eventId}" is not an event id. The form is <session-id>#<index>, e.g. dogfood-envkit-1#42.`, { event_id: eventId });
|
|
47
53
|
}
|
|
48
|
-
return
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Where an event sits in the order the log actually wrote it.
|
|
52
|
-
*
|
|
53
|
-
* An event id is `<session-id>#<index>` and the index is the event's line in
|
|
54
|
-
* its session's log, so within a session this *is* the order rather than a
|
|
55
|
-
* proxy for it. Across sessions the logs are separate files that nothing
|
|
56
|
-
* interleaves, so the session id is here to make the answer the same on every
|
|
57
|
-
* call, not because one session precedes another.
|
|
58
|
-
*
|
|
59
|
-
* Total where parseEventId throws, and the fallback is unreachable by
|
|
60
|
-
* construction — every id this sees came out of readEvents() or a projection
|
|
61
|
-
* of it, and readEvents builds each one as `<session>#<index>`. It is here
|
|
62
|
-
* because the readers on top of this are a dashboard `/api/pulse` polls every
|
|
63
|
-
* 5s and two audits the operator runs on a whole log, and an id that somehow
|
|
64
|
-
* would not parse should sort somewhere rather than take the caller down with
|
|
65
|
-
* it. Where it lands is deliberately modest: an event whose place in the log
|
|
66
|
-
* is unreadable does not get to win a tie on it.
|
|
67
|
-
*/
|
|
68
|
-
function logOrderOf(eventId) {
|
|
69
|
-
try {
|
|
70
|
-
return parseEventId(eventId);
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
return { sessionId: eventId, index: -1 };
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* The order the log wrote two events in: negative when `a` came first,
|
|
78
|
-
* positive when `b` did, zero only for the same event.
|
|
79
|
-
*
|
|
80
|
-
* `ts` is stamped at millisecond resolution (appendEvent, just below), so a
|
|
81
|
-
* burst of appends routinely shares one: the test fixture's own last two
|
|
82
|
-
* events tie in roughly one build in three, and a gate outcome and the retry
|
|
83
|
-
* dispatched in answer to it are written back to back. Nothing else in the
|
|
84
|
-
* record carries the sequence — `events_raw` has no such column and the JSONL
|
|
85
|
-
* line has no such field — so on a tie `ts` has nothing left to say, and
|
|
86
|
-
* nothing downstream of it does either. A `>` between two tied rows is not a
|
|
87
|
-
* decision, it is whichever the scan reached first; `ORDER BY ts` is the same
|
|
88
|
-
* non-answer spelled in SQL, since SQLite promises nothing about tied rows and
|
|
89
|
-
* hands them back in physical order, which changes the moment a row is
|
|
90
|
-
* rewritten; and a JS `.sort()` whose comparator returns 0 throughout is
|
|
91
|
-
* stable, so it keeps that same scan order and passes it off as chronology.
|
|
92
|
-
*
|
|
93
|
-
* The log index behind the event id is what actually decides, and it has to be
|
|
94
|
-
* read as a number: ordered as text — which is what `ORDER BY ts, event_id`
|
|
95
|
-
* does — `#9` sorts after `#10`, so the tiebreaker inverts as soon as a
|
|
96
|
-
* session's log passes ten events.
|
|
97
|
-
*
|
|
98
|
-
* It lives beside parseEventId rather than in any one reader because more than
|
|
99
|
-
* one of them asks this question: the dashboard queries fold and sort rows,
|
|
100
|
-
* escalation.ts walks a task's rounds looking for the dispatch on either side
|
|
101
|
-
* of one. Every ordering a reader would call chronological routes through
|
|
102
|
-
* here, so that no two of them can answer the same question about the same two
|
|
103
|
-
* events differently — which is exactly what happened when the callers spelled
|
|
104
|
-
* the comparison themselves, with opposite operators.
|
|
105
|
-
*/
|
|
106
|
-
export function compareLogOrder(a, b) {
|
|
107
|
-
if (a.ts !== b.ts)
|
|
108
|
-
return a.ts < b.ts ? -1 : 1;
|
|
109
|
-
const left = logOrderOf(a.eventId);
|
|
110
|
-
const right = logOrderOf(b.eventId);
|
|
111
|
-
if (left.sessionId !== right.sessionId)
|
|
112
|
-
return left.sessionId < right.sessionId ? -1 : 1;
|
|
113
|
-
return left.index - right.index;
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Whether `a` is the later of two events — the one a reader means by "what
|
|
117
|
-
* just happened".
|
|
118
|
-
*/
|
|
119
|
-
export function isLaterEvent(a, b) {
|
|
120
|
-
return compareLogOrder(a, b) > 0;
|
|
54
|
+
return parsed;
|
|
121
55
|
}
|
|
122
56
|
/**
|
|
123
57
|
* The task an event names, read from both places one can be written.
|
|
@@ -517,7 +451,7 @@ const TYPED_PAYLOAD_SCHEMAS = {
|
|
|
517
451
|
/**
|
|
518
452
|
* The one field of a `task-result-recorded` payload a reader iterates. The
|
|
519
453
|
* projector writes `artifacts[]` into rows inside the session's transaction,
|
|
520
|
-
* so an object here (
|
|
454
|
+
* so an object here (a dogfood run's event #100 carried `{ claude_half, external_half,
|
|
521
455
|
* repair_brief }`) rolled back the whole session and the dashboard drew
|
|
522
456
|
* nothing for it. Pinning only this field is deliberate: the full result
|
|
523
457
|
* schema is not enforced on the event, because real logs carry many
|
|
@@ -537,8 +471,28 @@ function validateResultArtifactsShape(record) {
|
|
|
537
471
|
errors: [{ path: '/artifacts', message: `must be an array, got ${typeof artifacts}` }],
|
|
538
472
|
});
|
|
539
473
|
}
|
|
474
|
+
/**
|
|
475
|
+
* `error-logged`'s task_id (D-245: envelope or payload, envelope wins) names
|
|
476
|
+
* exactly one task, the same rule taxonomy.ts enforces for `task_ref` — but
|
|
477
|
+
* `task_id` is schema-typed as a plain string, so no taxonomy rule ever sees
|
|
478
|
+
* it, and a comma-joined "epic-1/task-1,epic-1/task-2" passed straight
|
|
479
|
+
* through. db/projector.ts's fold read that string as a single task id and
|
|
480
|
+
* minted a phantom row for it that stayed `todo` forever. Checked here,
|
|
481
|
+
* beside validateResultArtifactsShape, for the same reason: the shape a
|
|
482
|
+
* later reader assumes is not one the event schema or taxonomy already rule
|
|
483
|
+
* out.
|
|
484
|
+
*/
|
|
485
|
+
function validateErrorLoggedTaskIdShape(record) {
|
|
486
|
+
if (record.event_type !== 'error-logged')
|
|
487
|
+
return;
|
|
488
|
+
const taskId = eventTaskId(record);
|
|
489
|
+
if (taskId === null || !taskId.includes(','))
|
|
490
|
+
return;
|
|
491
|
+
throw new EventError('events.multi-value-task-id', `Event "error-logged" names more than one task in its task_id ("${taskId}"). Log one error-logged event per task instead.`, { event_type: record.event_type, task_id: taskId });
|
|
492
|
+
}
|
|
540
493
|
function validateTypedPayload(schemas, taxonomy, record) {
|
|
541
494
|
validateResultArtifactsShape(record);
|
|
495
|
+
validateErrorLoggedTaskIdShape(record);
|
|
542
496
|
const schemaName = TYPED_PAYLOAD_SCHEMAS[record.event_type];
|
|
543
497
|
if (schemaName === undefined)
|
|
544
498
|
return;
|
|
@@ -590,19 +544,47 @@ async function validateCausalParent(input, opts, existing) {
|
|
|
590
544
|
throw new EventError('events.unknown-causal-parent', `causal_parent "${input.causal_parent}" does not reference an existing event in session "${parent.sessionId}" (it holds ${parentEvents.length}).`, { causal_parent: input.causal_parent, session_id: parent.sessionId });
|
|
591
545
|
}
|
|
592
546
|
}
|
|
547
|
+
// EventInput requires causal_parent, so inside the orchestrator undefined is
|
|
548
|
+
// unreachable. It is reachable from outside: every event appended through the
|
|
549
|
+
// CLI crosses a JSON boundary where the type guarantees nothing, and omitting
|
|
550
|
+
// a field is the likeliest way a hand-written event is malformed. This used
|
|
551
|
+
// to compare against `null` alone, so an omitted parent fell through into
|
|
552
|
+
// parseEventId(undefined) and reached the caller as a raw TypeError with no
|
|
553
|
+
// `code` — the one shape produced by accident was the one shape with nothing
|
|
554
|
+
// to branch on. Normalise once so every caller reads the same guard.
|
|
555
|
+
function requireCausalParentPresence(eventType, causalParent) {
|
|
556
|
+
if (causalParent === null && eventType !== ROOT_EVENT_TYPE) {
|
|
557
|
+
throw new EventError('events.missing-causal-parent', `causal_parent may only be null for "${ROOT_EVENT_TYPE}" events (got event_type "${eventType}").`, { event_type: eventType });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Validate an event's envelope — the same causal_parent checks `appendEvent`
|
|
562
|
+
* runs immediately before it writes — without appending anything.
|
|
563
|
+
*
|
|
564
|
+
* `queue.ts`'s `step`/`batchStep` and `integration.ts`'s check suite each do
|
|
565
|
+
* real work of their own (a rebase and a no-ff merge; a full check run) ahead
|
|
566
|
+
* of their own first append, so a bad `--causal-parent` used to surface only
|
|
567
|
+
* there — after the git work had already landed for real, with no event to
|
|
568
|
+
* show for it. A retry then found the branch already advanced and reported it
|
|
569
|
+
* as stuck. Calling this first, before any of that work starts, means a bad
|
|
570
|
+
* envelope is refused with nothing done yet to undo.
|
|
571
|
+
*
|
|
572
|
+
* Only the causal_parent rules and the session-id shape are checked here. The
|
|
573
|
+
* event schema, the taxonomy and typed payloads are not: a caller whose payload
|
|
574
|
+
* could fail those must not treat a pass as "the append will succeed".
|
|
575
|
+
*/
|
|
576
|
+
export async function validateEventEnvelope(input, opts = {}) {
|
|
577
|
+
const causalParent = input.causal_parent ?? null;
|
|
578
|
+
requireCausalParentPresence(input.event_type, causalParent);
|
|
579
|
+
if (causalParent === null)
|
|
580
|
+
return;
|
|
581
|
+
const filePath = logPath(input.session_id, opts);
|
|
582
|
+
const existing = await readEventsAtPath(filePath, input.session_id);
|
|
583
|
+
await validateCausalParent(input, opts, existing);
|
|
584
|
+
}
|
|
593
585
|
async function appendEventLocked(input, opts, filePath) {
|
|
594
|
-
// EventInput requires causal_parent, so inside the orchestrator undefined is
|
|
595
|
-
// unreachable. It is reachable from outside: every event appended through the
|
|
596
|
-
// CLI crosses a JSON boundary where the type guarantees nothing, and omitting
|
|
597
|
-
// a field is the likeliest way a hand-written event is malformed. Both guards
|
|
598
|
-
// below used to compare against `null` alone, so an omitted parent fell
|
|
599
|
-
// through them into parseEventId(undefined) and reached the caller as a raw
|
|
600
|
-
// TypeError with no `code` — the one shape produced by accident was the one
|
|
601
|
-
// shape with nothing to branch on. Normalise once so both guards read it.
|
|
602
586
|
const causalParent = input.causal_parent ?? null;
|
|
603
|
-
|
|
604
|
-
throw new EventError('events.missing-causal-parent', `causal_parent may only be null for "${ROOT_EVENT_TYPE}" events (got event_type "${input.event_type}").`, { event_type: input.event_type });
|
|
605
|
-
}
|
|
587
|
+
requireCausalParentPresence(input.event_type, causalParent);
|
|
606
588
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
607
589
|
const existing = await readEventsAtPath(filePath, input.session_id);
|
|
608
590
|
if (causalParent !== null) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { claimCoversPath } from './claims.js';
|
|
2
|
-
import { listFindings } from './findings.js';
|
|
2
|
+
import { listFindings, OPEN_FOR_DISPATCH } from './findings.js';
|
|
3
3
|
/**
|
|
4
4
|
* The spliced block's delimiters, exported for the same two reasons
|
|
5
5
|
* lessons.ts exports its own: a caller composing a dispatch prompt needs to
|
|
@@ -8,18 +8,6 @@ import { listFindings } from './findings.js';
|
|
|
8
8
|
*/
|
|
9
9
|
export const FINDING_BLOCK_BEGIN = '<!-- BEGIN OPEN FINDINGS -->';
|
|
10
10
|
export const FINDING_BLOCK_END = '<!-- END OPEN FINDINGS -->';
|
|
11
|
-
/**
|
|
12
|
-
* Which statuses are "open" for the purpose of dispatch context.
|
|
13
|
-
*
|
|
14
|
-
* `raised` and `confirmed` only. `refuted`, `waived`, `fix-verified`,
|
|
15
|
-
* `amended` and `expired` are closed. `fix-pending`/`fix-landed` are *already
|
|
16
|
-
* someone's assignment* — surfacing one to a second coder is how two diffs end
|
|
17
|
-
* up fixing one finding, and the second one lands as an unrequested change.
|
|
18
|
-
* `amend-pending` (D-127) is open elsewhere but not here for that same reason:
|
|
19
|
-
* it already names the tasks that discharge it, and the fix belongs in those
|
|
20
|
-
* tasks' diffs, not in whatever task is being dispatched now.
|
|
21
|
-
*/
|
|
22
|
-
export const OPEN_FOR_DISPATCH = ['raised', 'confirmed'];
|
|
23
11
|
/**
|
|
24
12
|
* Prompt-facing text for one field. Same two guarantees as lessons.ts's
|
|
25
13
|
* `flatten`, and for the same reason — this text is going INTO a prompt:
|