@juzser/blacksmith 0.1.0 → 0.1.1
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/coder.md +9 -3
- package/.claude/agents/grader.md +13 -5
- package/.claude/agents/merger.md +2 -0
- package/.claude/agents/planner.md +1 -1
- package/.claude/agents/researcher.md +3 -1
- package/.claude/agents/reviewer.md +9 -2
- package/.claude/agents/security-reviewer.md +15 -7
- package/.claude/agents/spec-reviewer.md +12 -3
- package/.claude/agents/tester.md +5 -2
- package/.claude/agents/uiux.md +10 -3
- package/.claude/agents/verifier.md +8 -3
- package/.claude/skills/bs/SKILL.md +103 -0
- package/.claude/skills/bs/audit.md +250 -0
- package/.claude/skills/bs/dispatch.md +394 -0
- package/.claude/skills/bs/lessons.md +73 -0
- package/.claude/skills/bs/mcp.md +29 -0
- package/.claude/skills/bs/new.md +33 -0
- package/.claude/skills/bs/plan.md +155 -0
- package/.claude/skills/bs/report.md +129 -0
- package/.claude/skills/bs/run.md +325 -0
- package/.claude/skills/bs/status.md +54 -0
- package/.claude/skills/bs/ui.md +12 -0
- package/.claude/skills/bs/waivers.md +13 -0
- package/.claude/skills/bs/wave.md +261 -0
- package/README.md +119 -65
- package/factory/orchestrator/dist/actors.js +4 -3
- package/factory/orchestrator/dist/agents-registry.js +96 -19
- package/factory/orchestrator/dist/artifacts.js +36 -4
- package/factory/orchestrator/dist/budgets.js +7 -5
- package/factory/orchestrator/dist/cli.js +107 -7
- package/factory/orchestrator/dist/crosscheck.js +11 -0
- package/factory/orchestrator/dist/daemon.js +14 -0
- package/factory/orchestrator/dist/db/projector.js +272 -90
- package/factory/orchestrator/dist/db/queries.js +83 -14
- package/factory/orchestrator/dist/db/schema.js +7 -0
- package/factory/orchestrator/dist/events.js +38 -4
- package/factory/orchestrator/dist/harness.js +367 -0
- package/factory/orchestrator/dist/judges.js +49 -8
- package/factory/orchestrator/dist/lessons.js +4 -4
- package/factory/orchestrator/dist/mcp.js +4 -3
- package/factory/orchestrator/dist/paths.js +167 -15
- package/factory/orchestrator/dist/plan.js +1 -1
- package/factory/orchestrator/dist/planQuorum.js +36 -8
- package/factory/orchestrator/dist/providers/api-transport.js +62 -5
- package/factory/orchestrator/dist/providers/index.js +1 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +58 -7
- package/factory/orchestrator/dist/providers/types.js +5 -0
- package/factory/orchestrator/dist/roadmap.js +3 -3
- package/factory/orchestrator/dist/scaffold.js +3 -2
- package/factory/orchestrator/dist/stack.js +10 -2
- package/factory/orchestrator/dist/templateFragments.js +178 -0
- package/factory/orchestrator/dist/usage.js +24 -5
- package/factory/orchestrator/dist/workroot.js +113 -0
- package/factory/orchestrator/drizzle/0011_findings_spec_ref.sql +3 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +6 -3
- package/factory/policies/crosscheck.yml +10 -8
- package/factory/policies/lessons.md +122 -0
- package/factory/policies/taxonomy.yml +6 -2
- package/factory/specs/roadmap.md +154 -0
- package/package.json +8 -6
|
@@ -40,8 +40,8 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
|
40
40
|
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
|
41
41
|
import { detectStale as detectStaleAgents, foldAgents, } from '../agents-registry.js';
|
|
42
42
|
import { eventTaskId, listSessionIds, mergeSessionLogs, readEvents, } from '../events.js';
|
|
43
|
-
import { foldFindingsDetailed, missingProjectionFields, } from '../findings.js';
|
|
44
|
-
import { DB_MIGRATIONS_DIR,
|
|
43
|
+
import { findingScope, foldFindingsDetailed, missingProjectionFields, } from '../findings.js';
|
|
44
|
+
import { DB_MIGRATIONS_DIR, roadmapReadPath, STATE_DB_PATH, STATE_EVENTS_DIR } from '../paths.js';
|
|
45
45
|
import { isPlanRefTaskId, latestPlanVersion, loadPlan } from '../plan.js';
|
|
46
46
|
import { loadRoadmap } from '../roadmap.js';
|
|
47
47
|
import { assertRuntimeSupported } from '../runtime.js';
|
|
@@ -92,16 +92,16 @@ function clearSession(db, sessionId) {
|
|
|
92
92
|
db.delete(schema.prompts).where(eq(schema.prompts.sessionId, sessionId)).run();
|
|
93
93
|
db.delete(schema.dispatches).where(eq(schema.dispatches.sessionId, sessionId)).run();
|
|
94
94
|
db.delete(schema.agents).where(eq(schema.agents.sessionId, sessionId)).run();
|
|
95
|
-
db.delete(schema.tasks).where(eq(schema.tasks.sessionId, sessionId)).run();
|
|
96
95
|
db.delete(schema.epics).where(eq(schema.epics.sessionId, sessionId)).run();
|
|
97
96
|
db.delete(schema.edges).where(eq(schema.edges.sessionId, sessionId)).run();
|
|
98
97
|
db.delete(schema.errors).where(eq(schema.errors.sessionId, sessionId)).run();
|
|
99
98
|
db.delete(schema.waivers).where(eq(schema.waivers.sessionId, sessionId)).run();
|
|
100
99
|
db.delete(schema.artifacts).where(eq(schema.artifacts.sessionId, sessionId)).run();
|
|
101
|
-
// `lessons` and `
|
|
102
|
-
// projectLessons() and
|
|
103
|
-
//
|
|
104
|
-
//
|
|
100
|
+
// `lessons`, `findings` and `tasks` are deliberately absent, like
|
|
101
|
+
// `milestones` — projectLessons(), projectFindings() and projectTasks() own
|
|
102
|
+
// those tables whole (D-199, D-200, and the continuation case projectTasks()
|
|
103
|
+
// describes). Deleting by session_id here would delete a row this session
|
|
104
|
+
// raised and another session has since approved, closed or worked on.
|
|
105
105
|
}
|
|
106
106
|
/**
|
|
107
107
|
* Fully replace the milestones table from factory/specs/roadmap.md (not
|
|
@@ -118,7 +118,7 @@ function clearSession(db, sessionId) {
|
|
|
118
118
|
* than the whole write erroring out; logged to stderr so it is visible.
|
|
119
119
|
*/
|
|
120
120
|
function projectMilestones(handle, opts) {
|
|
121
|
-
const roadmapPath = opts.roadmapPath ??
|
|
121
|
+
const roadmapPath = opts.roadmapPath ?? roadmapReadPath();
|
|
122
122
|
if (!existsSync(roadmapPath))
|
|
123
123
|
return;
|
|
124
124
|
let defs;
|
|
@@ -150,6 +150,13 @@ function projectMilestones(handle, opts) {
|
|
|
150
150
|
}
|
|
151
151
|
});
|
|
152
152
|
}
|
|
153
|
+
function describeShape(value) {
|
|
154
|
+
if (value === null)
|
|
155
|
+
return 'null';
|
|
156
|
+
if (Array.isArray(value))
|
|
157
|
+
return 'an array';
|
|
158
|
+
return typeof value === 'object' ? 'an object' : `a ${typeof value}`;
|
|
159
|
+
}
|
|
153
160
|
/** Splits an `error.class` string into its group/class parts; tolerant of malformed values. */
|
|
154
161
|
function splitErrorClass(value) {
|
|
155
162
|
const [group, cls] = (value ?? '').split('.');
|
|
@@ -162,6 +169,11 @@ const TERMINAL_TASK_STATUSES = new Set([
|
|
|
162
169
|
'escalated',
|
|
163
170
|
'waived',
|
|
164
171
|
]);
|
|
172
|
+
/**
|
|
173
|
+
* Severities that record an error without stopping the task (taxonomy.yml
|
|
174
|
+
* `severity`). An `error-logged` at one of these leaves `task_status` alone.
|
|
175
|
+
*/
|
|
176
|
+
const NOTE_ONLY_SEVERITIES = new Set(['S3-minor', 'S4-nit']);
|
|
165
177
|
/**
|
|
166
178
|
* Fallback for a `task-added` that declares no `branch` — every event logged
|
|
167
179
|
* before D-23/P9-12 added the field, and any hand-appended one. The
|
|
@@ -262,29 +274,37 @@ function knownEpicIds(events) {
|
|
|
262
274
|
*/
|
|
263
275
|
function planRosterAliases(epics, specsDir) {
|
|
264
276
|
const candidates = new Map();
|
|
277
|
+
for (const id of planRosterTaskIds(epics, specsDir)) {
|
|
278
|
+
if (!isQualifiedTaskId(id))
|
|
279
|
+
continue;
|
|
280
|
+
const bare = bareTaskId(id);
|
|
281
|
+
const set = candidates.get(bare) ?? new Set();
|
|
282
|
+
set.add(id);
|
|
283
|
+
candidates.set(bare, set);
|
|
284
|
+
}
|
|
285
|
+
return candidates;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Every task id the latest plan on disk lists, for every epic the log names.
|
|
289
|
+
* The one read behind both `planRosterAliases` and foldTasks()'s "is this id
|
|
290
|
+
* a task at all" check, so the two can never disagree about what a plan says.
|
|
291
|
+
*/
|
|
292
|
+
function planRosterTaskIds(epics, specsDir) {
|
|
293
|
+
const ids = [];
|
|
265
294
|
for (const epicId of epics) {
|
|
266
|
-
let roster = [];
|
|
267
295
|
try {
|
|
268
296
|
const version = latestPlanVersion(epicId, { specsDir });
|
|
269
297
|
if (version !== null) {
|
|
270
|
-
|
|
298
|
+
ids.push(...loadPlan(epicId, version, { specsDir }).tasks.map((t) => t.task_id));
|
|
271
299
|
}
|
|
272
300
|
}
|
|
273
301
|
catch (err) {
|
|
274
|
-
console.error(`db/projector.ts
|
|
302
|
+
console.error(`db/projector.ts planRosterTaskIds(): plan file for "${epicId}" ` +
|
|
275
303
|
`failed to load, leaving its task ids unresolved: ` +
|
|
276
304
|
`${err instanceof Error ? err.message : String(err)}`);
|
|
277
305
|
}
|
|
278
|
-
for (const id of roster) {
|
|
279
|
-
if (!isQualifiedTaskId(id))
|
|
280
|
-
continue;
|
|
281
|
-
const bare = bareTaskId(id);
|
|
282
|
-
const set = candidates.get(bare) ?? new Set();
|
|
283
|
-
set.add(id);
|
|
284
|
-
candidates.set(bare, set);
|
|
285
|
-
}
|
|
286
306
|
}
|
|
287
|
-
return
|
|
307
|
+
return ids;
|
|
288
308
|
}
|
|
289
309
|
/**
|
|
290
310
|
* Bare id -> the one qualified id it must mean, built from every epic the log
|
|
@@ -367,12 +387,55 @@ function waveTaskIds(record) {
|
|
|
367
387
|
const taskId = eventTaskId(record);
|
|
368
388
|
return taskId ? [taskId] : [];
|
|
369
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* The task ids one event asserts something about, spelled the way foldTasks()'s
|
|
392
|
+
* switch reads them: the envelope or payload `task_id` (D-245) for the
|
|
393
|
+
* single-task events, `payload.task_ids` for a wave's admission or merge, and
|
|
394
|
+
* `error-logged`'s own `payload.task_ref`. Kept beside the switch on purpose —
|
|
395
|
+
* the two must agree on what names a task, or the walk touches an id the
|
|
396
|
+
* pre-pass never saw.
|
|
397
|
+
*/
|
|
398
|
+
function assertedTaskIds(record) {
|
|
399
|
+
switch (record.event_type) {
|
|
400
|
+
case 'wave-admitted':
|
|
401
|
+
case 'wave-merged':
|
|
402
|
+
return waveTaskIds(record);
|
|
403
|
+
case 'error-logged': {
|
|
404
|
+
const id = eventTaskId(record) ?? record.payload.task_ref;
|
|
405
|
+
return id ? [id] : [];
|
|
406
|
+
}
|
|
407
|
+
case 'task-added':
|
|
408
|
+
case 'gate-outcome':
|
|
409
|
+
case 'task-superseded': {
|
|
410
|
+
const id = eventTaskId(record);
|
|
411
|
+
return id ? [id] : [];
|
|
412
|
+
}
|
|
413
|
+
default:
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
}
|
|
370
417
|
export function foldTasks(events, opts = {}) {
|
|
371
418
|
const byId = new Map();
|
|
372
419
|
// Normalisation happens at this boundary, once, before any row exists —
|
|
373
420
|
// so no later code has to ask which of two ids for one task it is holding.
|
|
374
421
|
const canonical = taskIdCanonicalizer(events, opts);
|
|
375
422
|
const epics = knownEpicIds(events);
|
|
423
|
+
// The ids the log asserts a task under: every id a task-level event names
|
|
424
|
+
// (`task-added`, a wave admission or merge, a gate outcome, a supersession,
|
|
425
|
+
// an error) plus what the plan roster on disk lists (D-250's task whose
|
|
426
|
+
// every event was bare and whose `task-added` never reached the log). A
|
|
427
|
+
// `dispatch_decision` is deliberately not on that list — see touch() for
|
|
428
|
+
// the twenty-two cards that taught it. Read over the whole log before the
|
|
429
|
+
// walk so the answer does not depend on which event reached the log first.
|
|
430
|
+
const declared = new Set();
|
|
431
|
+
for (const { record } of events) {
|
|
432
|
+
if (record.event_type === 'dispatch_decision')
|
|
433
|
+
continue;
|
|
434
|
+
for (const id of assertedTaskIds(record))
|
|
435
|
+
declared.add(canonical(id));
|
|
436
|
+
}
|
|
437
|
+
for (const id of planRosterTaskIds(epics, opts.specsDir))
|
|
438
|
+
declared.add(canonical(id));
|
|
376
439
|
function touch(rawTaskId, ts, sessionId) {
|
|
377
440
|
const taskId = canonical(rawTaskId);
|
|
378
441
|
let row = byId.get(taskId);
|
|
@@ -396,8 +459,9 @@ export function foldTasks(events, opts = {}) {
|
|
|
396
459
|
updatedAt: ts,
|
|
397
460
|
project: null,
|
|
398
461
|
};
|
|
399
|
-
//
|
|
400
|
-
//
|
|
462
|
+
// Not every id an event carries is a task, and a ref that is not a
|
|
463
|
+
// task must never surface as a kanban card. Three shapes are refused by
|
|
464
|
+
// name: the epic's own bare id, which `error-logged` writes into
|
|
401
465
|
// `payload.task_ref` when the failure belongs to no single task (D-251)
|
|
402
466
|
// — the phantom foldEpics() keeps itself off this fold to avoid; the
|
|
403
467
|
// errors table takes that row straight from the event, so refusing the
|
|
@@ -409,18 +473,33 @@ export function foldTasks(events, opts = {}) {
|
|
|
409
473
|
// dispatch_decision/judge-verdict/quorum-decision events their quorum
|
|
410
474
|
// case emits, so provider-agreement analytics can group by it like any
|
|
411
475
|
// other dispatch (db/queries.ts's providerAgreement() reads eventsRaw
|
|
412
|
-
// directly, never joins tasks, so it's unaffected either way).
|
|
413
|
-
//
|
|
414
|
-
//
|
|
476
|
+
// directly, never joins tasks, so it's unaffected either way).
|
|
477
|
+
//
|
|
478
|
+
// The fourth refusal is the general one: an id nothing but a dispatch
|
|
479
|
+
// ever named. A session that ran its planning rounds by hand, before
|
|
480
|
+
// planRefTaskId() existed, stamped each round's dispatch with an id it
|
|
481
|
+
// made up on the spot — `<epic>/plan-r12`, `<epic>/spec-review-r15` —
|
|
482
|
+
// and twenty-two of those sat in the board's "In progress" column for
|
|
483
|
+
// a week, one card each, no objective, nothing ever moving them; the
|
|
484
|
+
// column was nothing BUT them. A dispatch is a fact about an agent and
|
|
485
|
+
// its task_id is where the agent was pointed; a task is a fact the log
|
|
486
|
+
// asserts elsewhere (`declared`, above), and a dispatch moves one that
|
|
487
|
+
// exists rather than minting one. An orphan the log DOES assert — a
|
|
488
|
+
// bare id no roster claims (D-250), an error against a ref nobody
|
|
489
|
+
// declared — keeps its row on purpose: a producer's mistake stays
|
|
490
|
+
// visible. Refusing by what the log asserts rather than by spelling
|
|
491
|
+
// means the next hand-minted ref never gets a card either.
|
|
492
|
+
//
|
|
493
|
+
// Every event type that carries a task_id routes through this one
|
|
494
|
+
// touch() choke point (task-added, wave-admitted, dispatch_decision,
|
|
415
495
|
// gate-outcome, wave-merged, task-superseded, error-logged), so one
|
|
416
496
|
// guard here is enough: build the row (callers below still mutate it
|
|
417
|
-
// freely) but never register a
|
|
418
|
-
// reaches foldTasks()'s returned rows or
|
|
419
|
-
// insert loop.
|
|
497
|
+
// freely) but never register a refused id in byId, so it never
|
|
498
|
+
// reaches foldTasks()'s returned rows or projectTasks()'s insert loop.
|
|
420
499
|
const isReservedRef = taskId.split('/').pop() === RESERVED_TASK_ID ||
|
|
421
500
|
isPlanRefTaskId(taskId) ||
|
|
422
501
|
epics.has(taskId);
|
|
423
|
-
if (!isReservedRef)
|
|
502
|
+
if (!isReservedRef && declared.has(taskId))
|
|
424
503
|
byId.set(taskId, row);
|
|
425
504
|
}
|
|
426
505
|
row.updatedAt = ts;
|
|
@@ -451,7 +530,8 @@ export function foldTasks(events, opts = {}) {
|
|
|
451
530
|
// the amended task carries the new plan_version) would otherwise revert
|
|
452
531
|
// a merged task to `todo`. A terminal status is a fact the log earned
|
|
453
532
|
// from `wave-merged`; a static field in a plan file does not overrule
|
|
454
|
-
// it.
|
|
533
|
+
// it. `wave-admitted`, `dispatch_decision`, `gate-outcome` and
|
|
534
|
+
// `error-logged` carry the same guard.
|
|
455
535
|
if (!TERMINAL_TASK_STATUSES.has(row.taskStatus))
|
|
456
536
|
row.taskStatus = p.task_status ?? row.taskStatus;
|
|
457
537
|
row.planVersion = p.plan_version ?? row.planVersion;
|
|
@@ -466,7 +546,13 @@ export function foldTasks(events, opts = {}) {
|
|
|
466
546
|
const p = record.payload;
|
|
467
547
|
for (const taskId of waveTaskIds(record)) {
|
|
468
548
|
const row = touch(taskId, record.ts, record.session_id);
|
|
469
|
-
|
|
549
|
+
// A re-planned wave admits the new plan's task ids, and one of them
|
|
550
|
+
// can be an id that already merged under the previous version. The
|
|
551
|
+
// admission is a fact about the wave, not a reopening of the task:
|
|
552
|
+
// the board showed a shipped task as `ready` for the rest of the
|
|
553
|
+
// epic (D-249). Same guard as `task-added` and `dispatch_decision`.
|
|
554
|
+
if (!TERMINAL_TASK_STATUSES.has(row.taskStatus))
|
|
555
|
+
row.taskStatus = 'ready';
|
|
470
556
|
row.epicId = epicOfTaskId(row.taskId) ?? p.epic_id ?? row.epicId;
|
|
471
557
|
}
|
|
472
558
|
break;
|
|
@@ -485,6 +571,10 @@ export function foldTasks(events, opts = {}) {
|
|
|
485
571
|
break;
|
|
486
572
|
const p = record.payload;
|
|
487
573
|
const row = touch(eventTask, record.ts, record.session_id);
|
|
574
|
+
// A judge whose evidence lands late is gated after `wave-merged`; its
|
|
575
|
+
// verdict is on the record, but the task it grades already shipped.
|
|
576
|
+
if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
|
|
577
|
+
break;
|
|
488
578
|
if (p.outcome === 'blocked')
|
|
489
579
|
row.taskStatus = 'blocked';
|
|
490
580
|
else if (p.outcome === 'pass-with-waivers-pending')
|
|
@@ -516,6 +606,16 @@ export function foldTasks(events, opts = {}) {
|
|
|
516
606
|
row.project = record.project ?? row.project;
|
|
517
607
|
if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
|
|
518
608
|
break;
|
|
609
|
+
// Severity decides whether the task moves; the error class decides
|
|
610
|
+
// where. taxonomy.yml: S3 is "real but waivable; batched to operator
|
|
611
|
+
// at epic end", S4 is "logged, never asked" — a budget note or a
|
|
612
|
+
// tool hiccup at that level is on the record but the task carries on,
|
|
613
|
+
// and showing it as blocked is what the board did for 43 of the 55
|
|
614
|
+
// errors logged so far. A record with no severity is treated as
|
|
615
|
+
// major: the write path requires the field, so its absence means a
|
|
616
|
+
// log this reader does not own.
|
|
617
|
+
if (NOTE_ONLY_SEVERITIES.has(p.severity ?? ''))
|
|
618
|
+
break;
|
|
519
619
|
row.taskStatus = p.error?.startsWith('coordination.') ? 'escalated' : 'blocked';
|
|
520
620
|
break;
|
|
521
621
|
}
|
|
@@ -754,26 +854,30 @@ function planProjectResolver(specsDir) {
|
|
|
754
854
|
/**
|
|
755
855
|
* foldTasks() with D-246's plan-file backfill applied.
|
|
756
856
|
*
|
|
757
|
-
*
|
|
758
|
-
* question and must not answer it differently: projectSession()
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
857
|
+
* Every fold of the task list goes through here, because all of them answer
|
|
858
|
+
* the same question and must not answer it differently: projectSession()
|
|
859
|
+
* derives the epics, dispatches, artifacts and errors rows from one,
|
|
860
|
+
* projectTasks() writes the tasks table from another over every session's
|
|
861
|
+
* log, and projectFindings() folds the same events again for
|
|
862
|
+
* findings.project. Split the backfill across only one of them and a single
|
|
863
|
+
* task reads as demo-rpg's on the board and black-smith's on the errors page.
|
|
763
864
|
*/
|
|
764
865
|
function foldTasksWithPlanProject(events, opts) {
|
|
765
866
|
const projectFromPlan = planProjectResolver(opts.specsDir);
|
|
766
867
|
return foldTasks([...events], { specsDir: opts.specsDir }).map((t) => t.project === null ? { ...t, project: projectFromPlan(t.epicId) } : t);
|
|
767
868
|
}
|
|
768
869
|
export function projectSession(handle, sessionId, events, opts = {}) {
|
|
870
|
+
const skippedArtifacts = [];
|
|
769
871
|
handle.db.transaction((txDb) => {
|
|
770
872
|
clearSession(txDb, sessionId);
|
|
771
873
|
if (events.length === 0)
|
|
772
874
|
return;
|
|
773
|
-
// Folded once, up here: the rows below inherit their project from it
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
//
|
|
875
|
+
// Folded once, up here: the rows below inherit their project from it. The
|
|
876
|
+
// tasks table itself is NOT written from this array -- projectTasks() folds
|
|
877
|
+
// every session's log for that, because a task planned in one session and
|
|
878
|
+
// worked in its continuation is one row -- but the epics table and every
|
|
879
|
+
// child row inherit their project from this per-session fold, and the
|
|
880
|
+
// plan-file backfill lands here so they agree with the global one.
|
|
777
881
|
const taskRows = foldTasksWithPlanProject(events, opts);
|
|
778
882
|
const projectForTask = projectResolver(taskRows);
|
|
779
883
|
const projectForEpic = new Map(taskRows.flatMap((t) => (t.project === null ? [] : [[t.epicId, t.project]])));
|
|
@@ -916,8 +1020,21 @@ export function projectSession(handle, sessionId, events, opts = {}) {
|
|
|
916
1020
|
const taskId = eventTask;
|
|
917
1021
|
if (!taskId)
|
|
918
1022
|
continue;
|
|
1023
|
+
// The whole session runs inside this transaction: a throw here used
|
|
1024
|
+
// to roll back every row above, so an epic three waves deep drew as
|
|
1025
|
+
// nothing. Hold back the one list that cannot be read, name it, and
|
|
1026
|
+
// keep going (D-141: a loud undercount beats a crash).
|
|
1027
|
+
if (p.artifacts !== undefined && !Array.isArray(p.artifacts)) {
|
|
1028
|
+
skippedArtifacts.push({
|
|
1029
|
+
event_id,
|
|
1030
|
+
session_id: record.session_id,
|
|
1031
|
+
task_id: taskId,
|
|
1032
|
+
reason: `payload.artifacts is ${describeShape(p.artifacts)}, not an array`,
|
|
1033
|
+
});
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
919
1036
|
(p.artifacts ?? []).forEach((artifact, index) => {
|
|
920
|
-
if (!artifact.type || !artifact.path)
|
|
1037
|
+
if (!artifact || typeof artifact !== 'object' || !artifact.type || !artifact.path)
|
|
921
1038
|
return;
|
|
922
1039
|
txDb
|
|
923
1040
|
.insert(schema.artifacts)
|
|
@@ -952,27 +1069,6 @@ export function projectSession(handle, sessionId, events, opts = {}) {
|
|
|
952
1069
|
.run();
|
|
953
1070
|
}
|
|
954
1071
|
}
|
|
955
|
-
for (const task of taskRows) {
|
|
956
|
-
txDb
|
|
957
|
-
.insert(schema.tasks)
|
|
958
|
-
.values({
|
|
959
|
-
taskId: task.taskId,
|
|
960
|
-
sessionId: task.sessionId,
|
|
961
|
-
epicId: task.epicId,
|
|
962
|
-
caseTag: task.caseTag,
|
|
963
|
-
origin: task.origin,
|
|
964
|
-
taskStatus: task.taskStatus,
|
|
965
|
-
planVersion: task.planVersion,
|
|
966
|
-
objective: task.objective,
|
|
967
|
-
claims: task.claims ? JSON.stringify(task.claims) : null,
|
|
968
|
-
budgetTokens: task.budgetTokens,
|
|
969
|
-
branch: task.branch,
|
|
970
|
-
createdAt: task.createdAt,
|
|
971
|
-
updatedAt: task.updatedAt,
|
|
972
|
-
project: task.project,
|
|
973
|
-
})
|
|
974
|
-
.run();
|
|
975
|
-
}
|
|
976
1072
|
for (const epic of foldEpics(events)) {
|
|
977
1073
|
txDb
|
|
978
1074
|
.insert(schema.epics)
|
|
@@ -1017,6 +1113,62 @@ export function projectSession(handle, sessionId, events, opts = {}) {
|
|
|
1017
1113
|
}
|
|
1018
1114
|
// `lessons` is not written here — see projectLessons() (D-199).
|
|
1019
1115
|
});
|
|
1116
|
+
return { skippedArtifacts };
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Fully replace the tasks table from EVERY session's log at once, in one
|
|
1120
|
+
* causal order (mergeSessionLogs). Not session-scoped, the same shape as
|
|
1121
|
+
* projectLessons() and projectFindings() below, and for the same reason.
|
|
1122
|
+
*
|
|
1123
|
+
* A task is planned in one session and worked in another BY DESIGN: an epic
|
|
1124
|
+
* outlives an orchestrator's context window, and `smith session start
|
|
1125
|
+
* --continues` (P9-7) is how the next session picks the epic up. Every event
|
|
1126
|
+
* in the continuation names the same task_id the parent's `task-added` did,
|
|
1127
|
+
* and `tasks.task_id` is the table's whole primary key. Folding one session
|
|
1128
|
+
* at a time inserted the row twice -- once from the parent's fold, once from
|
|
1129
|
+
* the continuation's -- and the second insert aborted the whole rebuild on
|
|
1130
|
+
* `UNIQUE constraint failed: tasks.task_id`. The first real continuation in
|
|
1131
|
+
* this factory's own logs could not be projected at all; the dashboard showed
|
|
1132
|
+
* the epic without its working session.
|
|
1133
|
+
*
|
|
1134
|
+
* A per-session upsert would not do either. Whichever session is projected
|
|
1135
|
+
* last would own the row, and listSessionIds() sorts by filename, which is
|
|
1136
|
+
* nothing causal: re-applying the parent after the continuation would put the
|
|
1137
|
+
* task back to `todo`. foldTasks() already knows how to fold a task's whole
|
|
1138
|
+
* history in order -- terminal statuses stick, a late gate-outcome does not
|
|
1139
|
+
* reopen a merged task -- so it is handed the whole history: one global key,
|
|
1140
|
+
* one global fold.
|
|
1141
|
+
*
|
|
1142
|
+
* The row still carries the session that FIRST touched the task -- the one
|
|
1143
|
+
* whose plan added it -- so `kanban({ sessionId })` keeps meaning "tasks born
|
|
1144
|
+
* here", and the lineage width (D-264) is what shows the continuation's
|
|
1145
|
+
* progress on them.
|
|
1146
|
+
*/
|
|
1147
|
+
export function projectTasks(handle, events, opts = {}) {
|
|
1148
|
+
handle.db.transaction((txDb) => {
|
|
1149
|
+
txDb.delete(schema.tasks).run();
|
|
1150
|
+
for (const task of foldTasksWithPlanProject(events, opts)) {
|
|
1151
|
+
txDb
|
|
1152
|
+
.insert(schema.tasks)
|
|
1153
|
+
.values({
|
|
1154
|
+
taskId: task.taskId,
|
|
1155
|
+
sessionId: task.sessionId,
|
|
1156
|
+
epicId: task.epicId,
|
|
1157
|
+
caseTag: task.caseTag,
|
|
1158
|
+
origin: task.origin,
|
|
1159
|
+
taskStatus: task.taskStatus,
|
|
1160
|
+
planVersion: task.planVersion,
|
|
1161
|
+
objective: task.objective,
|
|
1162
|
+
claims: task.claims ? JSON.stringify(task.claims) : null,
|
|
1163
|
+
budgetTokens: task.budgetTokens,
|
|
1164
|
+
branch: task.branch,
|
|
1165
|
+
createdAt: task.createdAt,
|
|
1166
|
+
updatedAt: task.updatedAt,
|
|
1167
|
+
project: task.project,
|
|
1168
|
+
})
|
|
1169
|
+
.run();
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1020
1172
|
}
|
|
1021
1173
|
/**
|
|
1022
1174
|
* Fully replace the lessons table from EVERY session's log at once, in one
|
|
@@ -1149,9 +1301,10 @@ function projectFindings(handle, events, opts) {
|
|
|
1149
1301
|
const stamps = findingTimestamps(events);
|
|
1150
1302
|
// A finding has no `project` of its own on the wire (findings.ts's raiseFinding()
|
|
1151
1303
|
// predates Phase 6b) — derive it from its owning task's project, the same fold
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
// backfill (D-246) has to reach both folds or the
|
|
1304
|
+
// projectTasks() writes the tasks table from, over the same merged events, so
|
|
1305
|
+
// the value always matches tasks.project exactly. Same helper for exactly that
|
|
1306
|
+
// reason: the plan-file backfill (D-246) has to reach both folds or the
|
|
1307
|
+
// invariant is a lie.
|
|
1155
1308
|
const taskRows = foldTasksWithPlanProject(events, opts);
|
|
1156
1309
|
// projectResolver and not a Map lookup, for the reason its own docblock
|
|
1157
1310
|
// gives: the log spells one task both `epic/task-1` and `task-1`, and a
|
|
@@ -1216,6 +1369,10 @@ function projectFindings(handle, events, opts) {
|
|
|
1216
1369
|
waiverId: finding.waiver_id ?? null,
|
|
1217
1370
|
raisedAt: stamp.raisedAt,
|
|
1218
1371
|
updatedAt: stamp.updatedAt,
|
|
1372
|
+
// Through findingScope(), not the raw field: absence means diff.
|
|
1373
|
+
findingScope: findingScope(finding),
|
|
1374
|
+
specPlanVersion: finding.spec_ref?.plan_version ?? null,
|
|
1375
|
+
criterionRef: finding.spec_ref?.criterion_ref ?? null,
|
|
1219
1376
|
});
|
|
1220
1377
|
}
|
|
1221
1378
|
handle.db.transaction((txDb) => {
|
|
@@ -1226,22 +1383,25 @@ function projectFindings(handle, events, opts) {
|
|
|
1226
1383
|
return skipped;
|
|
1227
1384
|
}
|
|
1228
1385
|
/**
|
|
1229
|
-
*
|
|
1230
|
-
*
|
|
1231
|
-
*
|
|
1232
|
-
* The order is part of the contract, not tidiness. mergeSessionLogs breaks a
|
|
1233
|
-
* tie on `ts` by array position — for a lineage that is right, because
|
|
1234
|
-
* walkLineage hands it the chain root-first, but a SET of sessions has no
|
|
1235
|
-
* such order, and two events written in the same millisecond in two sessions
|
|
1236
|
-
* are exactly the case a global fold has to settle the same way every time.
|
|
1237
|
-
* "The session that started earlier" is the one causal reading available;
|
|
1238
|
-
* session id settles two logs that start in the same millisecond, so the
|
|
1239
|
-
* order is total and independent of how the caller listed them.
|
|
1386
|
+
* Reads every named log. `tolerate` names the sessions whose read may fail
|
|
1387
|
+
* without failing the call: those come back in `unreadable` instead of as
|
|
1388
|
+
* rows. Any other session's failure is thrown as it always was.
|
|
1240
1389
|
*/
|
|
1241
|
-
async function readAllSessionLogs(sessionIds, stateDir) {
|
|
1390
|
+
async function readAllSessionLogs(sessionIds, stateDir, tolerate = new Set()) {
|
|
1242
1391
|
const logs = [];
|
|
1392
|
+
const unreadable = [];
|
|
1243
1393
|
for (const sessionId of sessionIds) {
|
|
1244
|
-
|
|
1394
|
+
try {
|
|
1395
|
+
logs.push({ sessionId, events: await readEvents(sessionId, { stateDir }) });
|
|
1396
|
+
}
|
|
1397
|
+
catch (err) {
|
|
1398
|
+
if (!tolerate.has(sessionId))
|
|
1399
|
+
throw err;
|
|
1400
|
+
unreadable.push({
|
|
1401
|
+
session_id: sessionId,
|
|
1402
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1245
1405
|
}
|
|
1246
1406
|
logs.sort((a, b) => {
|
|
1247
1407
|
const aStart = a.events[0]?.record.ts ?? '';
|
|
@@ -1250,7 +1410,7 @@ async function readAllSessionLogs(sessionIds, stateDir) {
|
|
|
1250
1410
|
return aStart < bStart ? -1 : 1;
|
|
1251
1411
|
return a.sessionId.localeCompare(b.sessionId);
|
|
1252
1412
|
});
|
|
1253
|
-
return logs;
|
|
1413
|
+
return { logs, unreadable };
|
|
1254
1414
|
}
|
|
1255
1415
|
/**
|
|
1256
1416
|
* Full rebuild: drop every table's rows, then replay every session (or the
|
|
@@ -1265,18 +1425,28 @@ export async function rebuild(dbPath = STATE_DB_PATH, sessions = 'all', opts = {
|
|
|
1265
1425
|
const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
|
|
1266
1426
|
const sessionIds = sessions === 'all' ? listSessionIds(stateDir) : [...sessions];
|
|
1267
1427
|
// Read each log once: the per-session projection consumes them one at a
|
|
1268
|
-
// time,
|
|
1269
|
-
|
|
1428
|
+
// time; projectTasks(), projectFindings() and projectLessons() need them
|
|
1429
|
+
// all at once.
|
|
1430
|
+
const { logs } = await readAllSessionLogs(sessionIds, stateDir);
|
|
1270
1431
|
let eventsApplied = 0;
|
|
1432
|
+
const skippedArtifacts = [];
|
|
1271
1433
|
for (const { sessionId, events } of logs) {
|
|
1272
|
-
projectSession(handle, sessionId, events, opts);
|
|
1434
|
+
const report = projectSession(handle, sessionId, events, opts);
|
|
1435
|
+
skippedArtifacts.push(...report.skippedArtifacts);
|
|
1273
1436
|
eventsApplied += events.length;
|
|
1274
1437
|
}
|
|
1275
1438
|
const merged = mergeSessionLogs(logs);
|
|
1439
|
+
projectTasks(handle, merged, opts);
|
|
1276
1440
|
const skippedFindings = projectFindings(handle, merged, opts);
|
|
1277
1441
|
projectLessons(handle, merged);
|
|
1278
1442
|
projectMilestones(handle, opts);
|
|
1279
|
-
return {
|
|
1443
|
+
return {
|
|
1444
|
+
sessionsProcessed: sessionIds.length,
|
|
1445
|
+
eventsApplied,
|
|
1446
|
+
skippedFindings,
|
|
1447
|
+
skippedArtifacts,
|
|
1448
|
+
unreadableSessions: [],
|
|
1449
|
+
};
|
|
1280
1450
|
}
|
|
1281
1451
|
finally {
|
|
1282
1452
|
handle.sqlite.close();
|
|
@@ -1287,10 +1457,10 @@ export async function rebuild(dbPath = STATE_DB_PATH, sessions = 'all', opts = {
|
|
|
1287
1457
|
* replaces only its rows, leaving every other session's projection intact.
|
|
1288
1458
|
* Safe to call repeatedly while a session is still running (tailing).
|
|
1289
1459
|
*
|
|
1290
|
-
* The
|
|
1460
|
+
* The four tables that are not session-scoped are rewritten whole on every
|
|
1291
1461
|
* call: `milestones` from roadmap.md, and — from every session's log, which is
|
|
1292
|
-
* why this reads more than the one session it re-folds — `
|
|
1293
|
-
* `findings` (D-200).
|
|
1462
|
+
* why this reads more than the one session it re-folds — `tasks`
|
|
1463
|
+
* (projectTasks()), `lessons` (D-199) and `findings` (D-200).
|
|
1294
1464
|
*/
|
|
1295
1465
|
export async function apply(dbPath = STATE_DB_PATH, sessionId, opts = {}) {
|
|
1296
1466
|
const handle = openDb(dbPath, opts);
|
|
@@ -1301,14 +1471,26 @@ export async function apply(dbPath = STATE_DB_PATH, sessionId, opts = {}) {
|
|
|
1301
1471
|
// naming it anyway costs an empty read and keeps it in the global folds
|
|
1302
1472
|
// below, which would otherwise drop the very session being applied.
|
|
1303
1473
|
const listed = listSessionIds(stateDir);
|
|
1304
|
-
|
|
1474
|
+
// Every other session's log is read for the global folds only, so one of
|
|
1475
|
+
// them being unreadable is reported, not thrown: this session's rows do
|
|
1476
|
+
// not depend on it. The applied session's own log still throws -- that
|
|
1477
|
+
// failure is the caller's to name, and there is nothing to write for it.
|
|
1478
|
+
const others = new Set(listed.filter((id) => id !== sessionId));
|
|
1479
|
+
const { logs, unreadable } = await readAllSessionLogs(listed.includes(sessionId) ? listed : [...listed, sessionId], stateDir, others);
|
|
1305
1480
|
const events = logs.find((l) => l.sessionId === sessionId)?.events ?? [];
|
|
1306
|
-
projectSession(handle, sessionId, events, opts);
|
|
1481
|
+
const { skippedArtifacts } = projectSession(handle, sessionId, events, opts);
|
|
1307
1482
|
const merged = mergeSessionLogs(logs);
|
|
1483
|
+
projectTasks(handle, merged, opts);
|
|
1308
1484
|
const skippedFindings = projectFindings(handle, merged, opts);
|
|
1309
1485
|
projectLessons(handle, merged);
|
|
1310
1486
|
projectMilestones(handle, opts);
|
|
1311
|
-
return {
|
|
1487
|
+
return {
|
|
1488
|
+
sessionsProcessed: 1,
|
|
1489
|
+
eventsApplied: events.length,
|
|
1490
|
+
skippedFindings,
|
|
1491
|
+
skippedArtifacts,
|
|
1492
|
+
unreadableSessions: unreadable,
|
|
1493
|
+
};
|
|
1312
1494
|
}
|
|
1313
1495
|
finally {
|
|
1314
1496
|
handle.sqlite.close();
|