@juzser/blacksmith 0.4.0 → 0.5.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 → bs-auditor.md} +5 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +4 -4
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +13 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +4 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +34 -27
- package/.claude/skills/bs/dispatch.md +100 -50
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +27 -18
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +181 -49
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +109 -53
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +48 -14
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +125 -17
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +790 -100
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +154 -36
- package/factory/orchestrator/dist/db/projector.js +278 -29
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +13 -37
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +323 -38
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +10 -4
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +105 -3
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +83 -12
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +92 -3
- package/factory/orchestrator/dist/integration.js +9 -7
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +90 -32
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +10 -7
- package/factory/orchestrator/dist/plan.js +269 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +4 -4
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +177 -13
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +179 -27
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +16 -13
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +97 -40
- package/factory/orchestrator/dist/waivers.js +39 -5
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +61 -19
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +7 -7
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -35,19 +35,21 @@
|
|
|
35
35
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
36
36
|
import path from 'node:path';
|
|
37
37
|
import Database from 'better-sqlite3';
|
|
38
|
-
import { eq } from 'drizzle-orm';
|
|
38
|
+
import { and, eq, isNull } from 'drizzle-orm';
|
|
39
39
|
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 { foldOperatorFeedback } from '../feedback.js';
|
|
43
44
|
import { findingScope, foldFindingsDetailed, missingProjectionFields, } from '../findings.js';
|
|
44
45
|
import { DB_MIGRATIONS_DIR, roadmapReadPath, STATE_DB_PATH, STATE_EVENTS_DIR } from '../paths.js';
|
|
45
46
|
import { isPlanRefTaskId, latestPlanVersion, loadPlan } from '../plan.js';
|
|
47
|
+
import { normalizeProjectName } from '../projectName.js';
|
|
46
48
|
import { loadRoadmap } from '../roadmap.js';
|
|
47
49
|
import { assertRuntimeSupported } from '../runtime.js';
|
|
48
50
|
import { bareTaskId, epicOfTaskId, isQualifiedTaskId, qualifyTaskId, taskIdsMatch, } from '../taskId.js';
|
|
49
51
|
import { TERMINAL_TASK_STATUSES } from '../taskStatus.js';
|
|
50
|
-
import { RESERVED_TASK_ID, taskBranchName } from '../worktree.js';
|
|
52
|
+
import { LEGACY_BRANCH_PREFIX, RESERVED_TASK_ID, taskBranchName } from '../worktree.js';
|
|
51
53
|
import * as schema from './schema.js';
|
|
52
54
|
/** Open (creating if needed) the projection DB and ensure its schema is current. */
|
|
53
55
|
export function openDb(dbPath = STATE_DB_PATH, opts = {}) {
|
|
@@ -79,6 +81,7 @@ const ALL_TABLES = [
|
|
|
79
81
|
schema.artifacts,
|
|
80
82
|
schema.milestones,
|
|
81
83
|
schema.issue_reports,
|
|
84
|
+
schema.operatorFeedback,
|
|
82
85
|
];
|
|
83
86
|
/** Delete every row in every projection table (full "drop" for rebuild()). */
|
|
84
87
|
function clearAll(handle) {
|
|
@@ -105,6 +108,9 @@ function clearSession(db, sessionId) {
|
|
|
105
108
|
// those tables whole (D-199, D-200, and the continuation case projectTasks()
|
|
106
109
|
// describes). Deleting by session_id here would delete a row this session
|
|
107
110
|
// raised and another session has since approved, closed or worked on.
|
|
111
|
+
// `operator_feedback` is absent for the same reason: a comment recorded here
|
|
112
|
+
// can be resolved from a later, continuing session, so
|
|
113
|
+
// projectOperatorFeedback() owns that table whole too.
|
|
108
114
|
}
|
|
109
115
|
/**
|
|
110
116
|
* Fully replace the milestones table from factory/specs/roadmap.md (not
|
|
@@ -154,6 +160,19 @@ function projectMilestones(handle, opts) {
|
|
|
154
160
|
}
|
|
155
161
|
});
|
|
156
162
|
}
|
|
163
|
+
/** Task 3 (dispatch reason fallback): writers put the reason under other
|
|
164
|
+
* keys than `reason` — the dashboard's "Recent dispatch decisions" showed
|
|
165
|
+
* "no reason given" on almost every row because of it. Same chain as the
|
|
166
|
+
* UI's own fallback (ui/src/lib/timelineDisplay.ts's dispatchReasonText):
|
|
167
|
+
* no shared lib between the two runtimes, so it's kept in step by hand. */
|
|
168
|
+
function dispatchReasonText(p) {
|
|
169
|
+
for (const key of ['reason', 'rationale', 'note', 'why']) {
|
|
170
|
+
const v = p[key];
|
|
171
|
+
if (typeof v === 'string' && v.trim() !== '')
|
|
172
|
+
return v.trim();
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
157
176
|
function describeShape(value) {
|
|
158
177
|
if (value === null)
|
|
159
178
|
return 'null';
|
|
@@ -161,6 +180,26 @@ function describeShape(value) {
|
|
|
161
180
|
return 'an array';
|
|
162
181
|
return typeof value === 'object' ? 'an object' : `a ${typeof value}`;
|
|
163
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* A hand-written `task-result-recorded` from before `validateResultArtifactsShape`
|
|
185
|
+
* (events.ts) started refusing non-array artifacts at append time keyed them
|
|
186
|
+
* by name instead of listing them, e.g. `{"claude_half":
|
|
187
|
+
* "scratchpad/r23/claude-half.json"}`. The event log is append-only history,
|
|
188
|
+
* so that line can never be rewritten into the array shape the guard now
|
|
189
|
+
* requires — read it as the legacy shape it plainly is, rather than holding
|
|
190
|
+
* it back forever behind a banner an operator can never clear.
|
|
191
|
+
*
|
|
192
|
+
* Only a non-empty plain object whose every value is a non-empty string
|
|
193
|
+
* counts: an empty object, one bad value, or any other non-array shape goes
|
|
194
|
+
* through the loud skip below (D-141 — a loud undercount beats a crash).
|
|
195
|
+
*/
|
|
196
|
+
function readLegacyArtifactsMap(value) {
|
|
197
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
198
|
+
return null;
|
|
199
|
+
const entries = Object.entries(value);
|
|
200
|
+
const allPaths = entries.length > 0 && entries.every(([, v]) => typeof v === 'string' && v.length > 0);
|
|
201
|
+
return allPaths ? entries : null;
|
|
202
|
+
}
|
|
164
203
|
/** Splits an `error.class` string into its group/class parts; tolerant of malformed values. */
|
|
165
204
|
function splitErrorClass(value) {
|
|
166
205
|
const [group, cls] = (value ?? '').split('.');
|
|
@@ -218,10 +257,16 @@ function readClaims(raw) {
|
|
|
218
257
|
return undefined;
|
|
219
258
|
return raw.every((c) => typeof c === 'string') ? raw : undefined;
|
|
220
259
|
}
|
|
260
|
+
// Fills in `branch` for a `task-added` written before taskEvents.ts's
|
|
261
|
+
// `addedPayload` carried that field at all — every such event predates the
|
|
262
|
+
// field, so it was written by a build that only ever knew `smith/`, never
|
|
263
|
+
// `bs/`. Defaulting to `BRANCH_PREFIX` here would assert a branch
|
|
264
|
+
// `worktree create` never cut for that task; `LEGACY_BRANCH_PREFIX` is the
|
|
265
|
+
// one prefix that build could have used.
|
|
221
266
|
function branchFor(epicId, taskId) {
|
|
222
267
|
if (!epicId)
|
|
223
268
|
return null;
|
|
224
|
-
return taskBranchName(epicId, taskId);
|
|
269
|
+
return taskBranchName(epicId, taskId, LEGACY_BRANCH_PREFIX);
|
|
225
270
|
}
|
|
226
271
|
/**
|
|
227
272
|
* Every (task id, epic) pair an event asserts: from the id itself when it is
|
|
@@ -421,6 +466,15 @@ function errorTaskRefIds(record) {
|
|
|
421
466
|
.map((id) => id.trim())
|
|
422
467
|
.filter(Boolean);
|
|
423
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* `record.project`, normalized through the legacy alias -- the one place an
|
|
471
|
+
* event's raw project value becomes the current one before it reaches a
|
|
472
|
+
* row, so an old event stamped `project: 'black-smith'` never lands in the
|
|
473
|
+
* projection under that name.
|
|
474
|
+
*/
|
|
475
|
+
function recordProject(record) {
|
|
476
|
+
return record.project === undefined ? undefined : normalizeProjectName(record.project);
|
|
477
|
+
}
|
|
424
478
|
/**
|
|
425
479
|
* The task ids one event asserts something about, spelled the way foldTasks()'s
|
|
426
480
|
* switch reads them: the envelope or payload `task_id` (D-245) for the
|
|
@@ -446,6 +500,27 @@ function assertedTaskIds(record) {
|
|
|
446
500
|
return [];
|
|
447
501
|
}
|
|
448
502
|
}
|
|
503
|
+
/**
|
|
504
|
+
* The agent role an event names, read the one place both spellings live.
|
|
505
|
+
* `dispatch_decision` and `judge-reported` carry it as `agent_role`
|
|
506
|
+
* (DispatchPayload; events.ts's PAYLOAD_TAG_MAP); `task-result-recorded`
|
|
507
|
+
* carries the same fact as `agent` (results.ts's `ResultEnvelope.agent` —
|
|
508
|
+
* the role the dispatcher sent the work to). One reader for both spellings,
|
|
509
|
+
* so the auditor-close/reopen checks below never grow a second string
|
|
510
|
+
* compare that could disagree with this one.
|
|
511
|
+
*/
|
|
512
|
+
function closingTurnRole(record) {
|
|
513
|
+
const p = record.payload;
|
|
514
|
+
if (record.event_type === 'task-result-recorded')
|
|
515
|
+
return p?.agent ?? null;
|
|
516
|
+
return p?.agent_role ?? null;
|
|
517
|
+
}
|
|
518
|
+
/** Whether an event names an auditor turn — the only role that closes or
|
|
519
|
+
* reopens a task row outside wave-merged/task-superseded/error-logged (an
|
|
520
|
+
* audit axis never joins a wave, so these are the only signals it gets). */
|
|
521
|
+
function isAuditorTurn(record) {
|
|
522
|
+
return closingTurnRole(record) === 'auditor';
|
|
523
|
+
}
|
|
449
524
|
export function foldTasks(events, opts = {}) {
|
|
450
525
|
const byId = new Map();
|
|
451
526
|
// Normalisation happens at this boundary, once, before any row exists —
|
|
@@ -484,12 +559,15 @@ export function foldTasks(events, opts = {}) {
|
|
|
484
559
|
taskStatus: 'todo',
|
|
485
560
|
planVersion: null,
|
|
486
561
|
objective: null,
|
|
562
|
+
title: null,
|
|
563
|
+
summary: null,
|
|
487
564
|
claims: null,
|
|
488
565
|
budgetTokens: null,
|
|
489
566
|
branch: null,
|
|
490
567
|
createdAt: ts,
|
|
491
568
|
updatedAt: ts,
|
|
492
569
|
project: null,
|
|
570
|
+
terminalAt: null,
|
|
493
571
|
};
|
|
494
572
|
// Not every id an event carries is a task, and a ref that is not a
|
|
495
573
|
// task must never surface as a kanban card. Three shapes are refused by
|
|
@@ -568,10 +646,12 @@ export function foldTasks(events, opts = {}) {
|
|
|
568
646
|
row.taskStatus = p.task_status ?? row.taskStatus;
|
|
569
647
|
row.planVersion = p.plan_version ?? row.planVersion;
|
|
570
648
|
row.objective = p.objective ?? row.objective;
|
|
649
|
+
row.title = p.title ?? row.title;
|
|
650
|
+
row.summary = p.summary ?? row.summary;
|
|
571
651
|
row.claims = readClaims(p.claims) ?? row.claims;
|
|
572
652
|
row.budgetTokens = p.budget_tokens ?? row.budgetTokens;
|
|
573
653
|
row.branch = p.branch ?? branchFor(row.epicId, row.taskId);
|
|
574
|
-
row.project = record
|
|
654
|
+
row.project = recordProject(record) ?? row.project;
|
|
575
655
|
break;
|
|
576
656
|
}
|
|
577
657
|
case 'wave-admitted': {
|
|
@@ -593,9 +673,44 @@ export function foldTasks(events, opts = {}) {
|
|
|
593
673
|
if (!eventTask)
|
|
594
674
|
break;
|
|
595
675
|
const row = touch(eventTask, record.ts, record.session_id);
|
|
596
|
-
|
|
676
|
+
const terminal = TERMINAL_TASK_STATUSES.has(row.taskStatus);
|
|
677
|
+
if (!terminal) {
|
|
678
|
+
row.taskStatus = 'in-progress';
|
|
679
|
+
}
|
|
680
|
+
else if (isAuditorTurn(record) && row.taskStatus === 'completed') {
|
|
681
|
+
// An audit axis never joins a wave, so a `completed` row only ever
|
|
682
|
+
// got there via the judge-reported/task-result-recorded close
|
|
683
|
+
// below — a later auditor dispatch for the same id is a re-run of
|
|
684
|
+
// that axis, not a stray dispatch against a shipped plan task
|
|
685
|
+
// (those always move through wave-merged/task-superseded/
|
|
686
|
+
// error-logged instead, never this role). terminalAt resets to
|
|
687
|
+
// null — schema.ts's comment on tasks.terminalAt ("null while the
|
|
688
|
+
// task is still open") describes this row again, exactly. Any
|
|
689
|
+
// other terminal status (escalated, failed, waived, superseded…)
|
|
690
|
+
// is held open for the operator and must not be reopened here.
|
|
597
691
|
row.taskStatus = 'in-progress';
|
|
598
|
-
|
|
692
|
+
row.terminalAt = null;
|
|
693
|
+
}
|
|
694
|
+
row.project = recordProject(record) ?? row.project;
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
case 'judge-reported':
|
|
698
|
+
case 'task-result-recorded': {
|
|
699
|
+
if (!eventTask)
|
|
700
|
+
break;
|
|
701
|
+
if (!isAuditorTurn(record))
|
|
702
|
+
break;
|
|
703
|
+
if (record.event_type === 'task-result-recorded') {
|
|
704
|
+
const p = record.payload;
|
|
705
|
+
if (p.run_status !== 'done')
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
const row = touch(eventTask, record.ts, record.session_id);
|
|
709
|
+
// First terminal transition wins — see schema.ts's terminalAt comment.
|
|
710
|
+
if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
|
|
711
|
+
break;
|
|
712
|
+
row.terminalAt = record.ts;
|
|
713
|
+
row.taskStatus = 'completed';
|
|
599
714
|
break;
|
|
600
715
|
}
|
|
601
716
|
case 'gate-outcome': {
|
|
@@ -617,14 +732,21 @@ export function foldTasks(events, opts = {}) {
|
|
|
617
732
|
}
|
|
618
733
|
case 'wave-merged': {
|
|
619
734
|
for (const taskId of waveTaskIds(record)) {
|
|
620
|
-
touch(taskId, record.ts, record.session_id)
|
|
735
|
+
const row = touch(taskId, record.ts, record.session_id);
|
|
736
|
+
// First terminal transition wins — see schema.ts's terminalAt comment.
|
|
737
|
+
if (row.terminalAt === null)
|
|
738
|
+
row.terminalAt = record.ts;
|
|
739
|
+
row.taskStatus = 'completed';
|
|
621
740
|
}
|
|
622
741
|
break;
|
|
623
742
|
}
|
|
624
743
|
case 'task-superseded': {
|
|
625
744
|
if (!eventTask)
|
|
626
745
|
break;
|
|
627
|
-
touch(eventTask, record.ts, record.session_id)
|
|
746
|
+
const row = touch(eventTask, record.ts, record.session_id);
|
|
747
|
+
if (row.terminalAt === null)
|
|
748
|
+
row.terminalAt = record.ts;
|
|
749
|
+
row.taskStatus = 'superseded';
|
|
628
750
|
break;
|
|
629
751
|
}
|
|
630
752
|
case 'error-logged': {
|
|
@@ -635,7 +757,7 @@ export function foldTasks(events, opts = {}) {
|
|
|
635
757
|
const taskIds = errorTaskRefIds(record);
|
|
636
758
|
for (const taskId of taskIds) {
|
|
637
759
|
const row = touch(taskId, record.ts, record.session_id);
|
|
638
|
-
row.project = record
|
|
760
|
+
row.project = recordProject(record) ?? row.project;
|
|
639
761
|
if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
|
|
640
762
|
continue;
|
|
641
763
|
// Severity decides whether the task moves; the error class decides
|
|
@@ -648,7 +770,12 @@ export function foldTasks(events, opts = {}) {
|
|
|
648
770
|
// log this reader does not own.
|
|
649
771
|
if (NOTE_ONLY_SEVERITIES.has(p.severity ?? ''))
|
|
650
772
|
continue;
|
|
651
|
-
|
|
773
|
+
const nextStatus = p.error?.startsWith('coordination.') ? 'escalated' : 'blocked';
|
|
774
|
+
// Only `escalated` is terminal (TERMINAL_TASK_STATUSES); `blocked` is
|
|
775
|
+
// not, so it never sets terminalAt.
|
|
776
|
+
if (nextStatus === 'escalated' && row.terminalAt === null)
|
|
777
|
+
row.terminalAt = record.ts;
|
|
778
|
+
row.taskStatus = nextStatus;
|
|
652
779
|
}
|
|
653
780
|
break;
|
|
654
781
|
}
|
|
@@ -779,7 +906,7 @@ export function foldEpics(events) {
|
|
|
779
906
|
blockers: Array.isArray(p.blockers) ? p.blockers : [],
|
|
780
907
|
closedAt: record.ts,
|
|
781
908
|
eventId: event_id,
|
|
782
|
-
project: record
|
|
909
|
+
project: recordProject(record) ?? null,
|
|
783
910
|
});
|
|
784
911
|
}
|
|
785
912
|
return [...byId.values()];
|
|
@@ -808,7 +935,7 @@ export function foldEpics(events) {
|
|
|
808
935
|
* deliberate that an absent project stays absent on the wire -- but it IS a
|
|
809
936
|
* gap in these tables, because db/queries.ts's projectOf() resolves a NULL
|
|
810
937
|
* column to the DEFAULT project. Left alone, every gate outcome and every
|
|
811
|
-
* failure of a demo-rpg run reads back as
|
|
938
|
+
* failure of a demo-rpg run reads back as blacksmith's.
|
|
812
939
|
*
|
|
813
940
|
* A child row's project is its task's, which is exactly the rule the Scope
|
|
814
941
|
* docblock in db/queries.ts already states (D-170). Derived from the same
|
|
@@ -845,7 +972,7 @@ function projectResolver(taskRows) {
|
|
|
845
972
|
* rows fold to project NULL, projectResolver() hands the NULL down to every
|
|
846
973
|
* dispatch and error under them, and db/queries.ts's projectOf() resolves the
|
|
847
974
|
* lot to the DEFAULT project: the whole demo-rpg epic filed under
|
|
848
|
-
*
|
|
975
|
+
* blacksmith, its own board empty. That history cannot be repaired from the
|
|
849
976
|
* log, because the log never held the answer. The plan file did, all along.
|
|
850
977
|
*
|
|
851
978
|
* Same source and same precedence as the write side -- what the envelope
|
|
@@ -872,8 +999,12 @@ function planProjectResolver(specsDir) {
|
|
|
872
999
|
let project = null;
|
|
873
1000
|
try {
|
|
874
1001
|
const version = latestPlanVersion(epicId, { specsDir });
|
|
875
|
-
if (version !== null)
|
|
876
|
-
|
|
1002
|
+
if (version !== null) {
|
|
1003
|
+
// The plan file is immutable and may still literally say the legacy
|
|
1004
|
+
// name; normalize it the same way an event's project value is.
|
|
1005
|
+
const planProject = loadPlan(epicId, version, { specsDir }).project;
|
|
1006
|
+
project = planProject === undefined ? null : normalizeProjectName(planProject);
|
|
1007
|
+
}
|
|
877
1008
|
}
|
|
878
1009
|
catch (err) {
|
|
879
1010
|
console.error(`db/projector.ts planProjectResolver(): plan file for "${epicId}" ` +
|
|
@@ -893,11 +1024,50 @@ function planProjectResolver(specsDir) {
|
|
|
893
1024
|
* projectTasks() writes the tasks table from another over every session's
|
|
894
1025
|
* log, and projectFindings() folds the same events again for
|
|
895
1026
|
* findings.project. Split the backfill across only one of them and a single
|
|
896
|
-
* task reads as demo-rpg's on the board and
|
|
1027
|
+
* task reads as demo-rpg's on the board and blacksmith's on the errors page.
|
|
897
1028
|
*/
|
|
898
1029
|
function foldTasksWithPlanProject(events, opts) {
|
|
899
1030
|
const projectFromPlan = planProjectResolver(opts.specsDir);
|
|
900
|
-
|
|
1031
|
+
const folded = foldTasks([...events], { specsDir: opts.specsDir });
|
|
1032
|
+
// The log before the plan file: a task added with no project -- a follow-up
|
|
1033
|
+
// minted inside a wave, whose runner stamps nothing -- belongs where the
|
|
1034
|
+
// rest of its epic's tasks were stamped. The plan file is only read when
|
|
1035
|
+
// the log has nothing to say, and it may not be the project's own (ui serve
|
|
1036
|
+
// without --specs-dir reads the factory's specs).
|
|
1037
|
+
const fromSiblings = unanimousEpicProjects(folded);
|
|
1038
|
+
return folded.map((t) => t.project === null
|
|
1039
|
+
? {
|
|
1040
|
+
...t,
|
|
1041
|
+
project: (t.epicId === null ? undefined : fromSiblings.get(t.epicId)) ??
|
|
1042
|
+
projectFromPlan(t.epicId),
|
|
1043
|
+
}
|
|
1044
|
+
: t);
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* The one project each epic's tasks agree on, keyed by epic id.
|
|
1048
|
+
*
|
|
1049
|
+
* An epic belongs to one project, so a task of it that was never stamped
|
|
1050
|
+
* belongs to the project its siblings were. An epic whose tasks name two
|
|
1051
|
+
* projects breaks that assumption, and the map leaves it out rather than
|
|
1052
|
+
* pick a winner by log order: an unanswered epic stays NULL, which is
|
|
1053
|
+
* visible; a guessed one is filed under the wrong project silently.
|
|
1054
|
+
*/
|
|
1055
|
+
export function unanimousEpicProjects(rows) {
|
|
1056
|
+
const seen = new Map();
|
|
1057
|
+
for (const { epicId, project } of rows) {
|
|
1058
|
+
if (!epicId || project === null)
|
|
1059
|
+
continue;
|
|
1060
|
+
const prior = seen.get(epicId);
|
|
1061
|
+
if (prior === undefined)
|
|
1062
|
+
seen.set(epicId, project);
|
|
1063
|
+
else if (prior !== project)
|
|
1064
|
+
seen.set(epicId, null);
|
|
1065
|
+
}
|
|
1066
|
+
const agreed = new Map();
|
|
1067
|
+
for (const [epicId, project] of seen)
|
|
1068
|
+
if (project !== null)
|
|
1069
|
+
agreed.set(epicId, project);
|
|
1070
|
+
return agreed;
|
|
901
1071
|
}
|
|
902
1072
|
export function projectSession(handle, sessionId, events, opts = {}) {
|
|
903
1073
|
const skippedArtifacts = [];
|
|
@@ -913,7 +1083,7 @@ export function projectSession(handle, sessionId, events, opts = {}) {
|
|
|
913
1083
|
// plan-file backfill lands here so they agree with the global one.
|
|
914
1084
|
const taskRows = foldTasksWithPlanProject(events, opts);
|
|
915
1085
|
const projectForTask = projectResolver(taskRows);
|
|
916
|
-
const projectForEpic =
|
|
1086
|
+
const projectForEpic = unanimousEpicProjects(taskRows);
|
|
917
1087
|
/**
|
|
918
1088
|
* The project a row inherits from whatever its ref names, task or epic.
|
|
919
1089
|
*
|
|
@@ -923,7 +1093,7 @@ export function projectSession(handle, sessionId, events, opts = {}) {
|
|
|
923
1093
|
* them (D-250), so the task leg alone can never answer. Resolving through
|
|
924
1094
|
* it alone left those rows NULL, and queries.ts's projectOf() reads a NULL
|
|
925
1095
|
* back as the DEFAULT project, not as "unscoped": every one of them was
|
|
926
|
-
* filed under
|
|
1096
|
+
* filed under blacksmith, including the one stop-the-line error in the
|
|
927
1097
|
* shipped logs, which demo-rpg had raised (D-252).
|
|
928
1098
|
*
|
|
929
1099
|
* epicOfTaskId() answers for a qualified ref; `?? ref` covers the bare
|
|
@@ -963,8 +1133,11 @@ export function projectSession(handle, sessionId, events, opts = {}) {
|
|
|
963
1133
|
planVersion: record.plan_version,
|
|
964
1134
|
causalParent: record.causal_parent,
|
|
965
1135
|
payload: JSON.stringify(record.payload),
|
|
966
|
-
project: record
|
|
1136
|
+
project: recordProject(record) ?? projectForRef(eventTask),
|
|
967
1137
|
actor: record.actor,
|
|
1138
|
+
// Copied, never derived: absent on a log written outside a CLI
|
|
1139
|
+
// session or before the stamp existed, and NULL here for both.
|
|
1140
|
+
cliSessionId: record.cli_session_id ?? null,
|
|
968
1141
|
})
|
|
969
1142
|
.run();
|
|
970
1143
|
if (record.event_type === 'user_prompt') {
|
|
@@ -997,10 +1170,10 @@ export function projectSession(handle, sessionId, events, opts = {}) {
|
|
|
997
1170
|
provider: p.provider,
|
|
998
1171
|
modelTier: p.model_tier,
|
|
999
1172
|
specRef: p.spec_ref ?? null,
|
|
1000
|
-
reason: p
|
|
1173
|
+
reason: dispatchReasonText(p),
|
|
1001
1174
|
parentPromptId: p.parent_prompt_id ?? null,
|
|
1002
1175
|
causalParent: record.causal_parent,
|
|
1003
|
-
project: record
|
|
1176
|
+
project: recordProject(record) ?? projectForRef(eventTask),
|
|
1004
1177
|
})
|
|
1005
1178
|
.run();
|
|
1006
1179
|
continue;
|
|
@@ -1040,7 +1213,7 @@ export function projectSession(handle, sessionId, events, opts = {}) {
|
|
|
1040
1213
|
errorClass: cls,
|
|
1041
1214
|
severity: p.severity,
|
|
1042
1215
|
detail: p.detail ?? null,
|
|
1043
|
-
project: record
|
|
1216
|
+
project: recordProject(record) ?? projectForRef(p.task_ref ?? eventTask),
|
|
1044
1217
|
})
|
|
1045
1218
|
.run();
|
|
1046
1219
|
continue;
|
|
@@ -1064,7 +1237,7 @@ export function projectSession(handle, sessionId, events, opts = {}) {
|
|
|
1064
1237
|
reason: p.reason ?? null,
|
|
1065
1238
|
repoSlug: p.repo_slug ?? null,
|
|
1066
1239
|
source: p.source ?? '',
|
|
1067
|
-
project: record
|
|
1240
|
+
project: recordProject(record) ?? projectForRef(p.task_ref ?? eventTask),
|
|
1068
1241
|
})
|
|
1069
1242
|
.run();
|
|
1070
1243
|
continue;
|
|
@@ -1082,6 +1255,28 @@ export function projectSession(handle, sessionId, events, opts = {}) {
|
|
|
1082
1255
|
// nothing. Hold back the one list that cannot be read, name it, and
|
|
1083
1256
|
// keep going (D-141: a loud undercount beats a crash).
|
|
1084
1257
|
if (p.artifacts !== undefined && !Array.isArray(p.artifacts)) {
|
|
1258
|
+
const legacyMap = readLegacyArtifactsMap(p.artifacts);
|
|
1259
|
+
if (legacyMap) {
|
|
1260
|
+
legacyMap.forEach(([description, artifactPath], index) => {
|
|
1261
|
+
txDb
|
|
1262
|
+
.insert(schema.artifacts)
|
|
1263
|
+
.values({
|
|
1264
|
+
id: `${event_id}#${index}`,
|
|
1265
|
+
sessionId: record.session_id,
|
|
1266
|
+
taskId,
|
|
1267
|
+
eventId: event_id,
|
|
1268
|
+
ts: record.ts,
|
|
1269
|
+
// Never 'screenshot': this is history from before artifacts
|
|
1270
|
+
// carried a type at all. An image path can still reach the
|
|
1271
|
+
// gallery by its extension, which is what it is.
|
|
1272
|
+
type: 'file',
|
|
1273
|
+
path: artifactPath,
|
|
1274
|
+
description,
|
|
1275
|
+
})
|
|
1276
|
+
.run();
|
|
1277
|
+
});
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1085
1280
|
skippedArtifacts.push({
|
|
1086
1281
|
event_id,
|
|
1087
1282
|
session_id: record.session_id,
|
|
@@ -1204,7 +1399,8 @@ export function projectSession(handle, sessionId, events, opts = {}) {
|
|
|
1204
1399
|
export function projectTasks(handle, events, opts = {}) {
|
|
1205
1400
|
handle.db.transaction((txDb) => {
|
|
1206
1401
|
txDb.delete(schema.tasks).run();
|
|
1207
|
-
|
|
1402
|
+
const rows = foldTasksWithPlanProject(events, opts);
|
|
1403
|
+
for (const task of rows) {
|
|
1208
1404
|
txDb
|
|
1209
1405
|
.insert(schema.tasks)
|
|
1210
1406
|
.values({
|
|
@@ -1216,15 +1412,30 @@ export function projectTasks(handle, events, opts = {}) {
|
|
|
1216
1412
|
taskStatus: task.taskStatus,
|
|
1217
1413
|
planVersion: task.planVersion,
|
|
1218
1414
|
objective: task.objective,
|
|
1415
|
+
title: task.title,
|
|
1416
|
+
summary: task.summary,
|
|
1219
1417
|
claims: task.claims ? JSON.stringify(task.claims) : null,
|
|
1220
1418
|
budgetTokens: task.budgetTokens,
|
|
1221
1419
|
branch: task.branch,
|
|
1222
1420
|
createdAt: task.createdAt,
|
|
1223
1421
|
updatedAt: task.updatedAt,
|
|
1224
1422
|
project: task.project,
|
|
1423
|
+
terminalAt: task.terminalAt,
|
|
1225
1424
|
})
|
|
1226
1425
|
.run();
|
|
1227
1426
|
}
|
|
1427
|
+
// The epics row is written per session, and a session that closes an
|
|
1428
|
+
// epic it never planned -- a continuation -- folds none of its tasks, so
|
|
1429
|
+
// the row's own fallback (projectForEpic in projectSession) has nothing
|
|
1430
|
+
// to read and stays NULL. Every session's tasks are in hand here: fill
|
|
1431
|
+
// what is still unanswered, and never overwrite a stamped project.
|
|
1432
|
+
for (const [epicId, project] of unanimousEpicProjects(rows)) {
|
|
1433
|
+
txDb
|
|
1434
|
+
.update(schema.epics)
|
|
1435
|
+
.set({ project })
|
|
1436
|
+
.where(and(eq(schema.epics.epicId, epicId), isNull(schema.epics.project)))
|
|
1437
|
+
.run();
|
|
1438
|
+
}
|
|
1228
1439
|
});
|
|
1229
1440
|
}
|
|
1230
1441
|
/**
|
|
@@ -1289,6 +1500,41 @@ function projectLessons(handle, events) {
|
|
|
1289
1500
|
}
|
|
1290
1501
|
});
|
|
1291
1502
|
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Fully replace the operator_feedback table from EVERY session's log at
|
|
1505
|
+
* once, in one causal order (mergeSessionLogs) — the same shape as
|
|
1506
|
+
* projectLessons() above, and for the same reason: a comment recorded in one
|
|
1507
|
+
* session's log is routinely resolved (bounced, or turned into a follow-up
|
|
1508
|
+
* task) from a later, continuing session, so the table's primary key is the
|
|
1509
|
+
* feedback id, not (session, feedback) — one global key, one global fold.
|
|
1510
|
+
* feedback.ts's foldOperatorFeedback() already resolves a `resolved` event
|
|
1511
|
+
* against the `recorded` row it belongs to; this only re-inserts the result.
|
|
1512
|
+
*/
|
|
1513
|
+
function projectOperatorFeedback(handle, events) {
|
|
1514
|
+
handle.db.transaction((txDb) => {
|
|
1515
|
+
txDb.delete(schema.operatorFeedback).run();
|
|
1516
|
+
for (const row of foldOperatorFeedback(events)) {
|
|
1517
|
+
txDb
|
|
1518
|
+
.insert(schema.operatorFeedback)
|
|
1519
|
+
.values({
|
|
1520
|
+
id: row.feedbackId,
|
|
1521
|
+
taskId: row.taskId,
|
|
1522
|
+
sessionId: row.sessionId,
|
|
1523
|
+
body: row.body,
|
|
1524
|
+
kind: row.kind,
|
|
1525
|
+
source: row.source,
|
|
1526
|
+
externalId: row.externalId,
|
|
1527
|
+
author: row.author,
|
|
1528
|
+
recordedAt: row.recordedAt,
|
|
1529
|
+
recordedEventId: row.recordedEventId,
|
|
1530
|
+
resolvedAt: row.resolvedAt,
|
|
1531
|
+
resolution: row.resolution,
|
|
1532
|
+
followUpTaskId: row.followUpTaskId,
|
|
1533
|
+
})
|
|
1534
|
+
.run();
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1292
1538
|
function findingTimestamps(events) {
|
|
1293
1539
|
const stamps = new Map();
|
|
1294
1540
|
for (const { event_id, record } of events) {
|
|
@@ -1373,7 +1619,7 @@ function projectFindings(handle, events, opts) {
|
|
|
1373
1619
|
// against `<epic>/integration`, a pseudo-task that never gets a task row:
|
|
1374
1620
|
// no task lookup can ever answer for it, its epic always can, and the row
|
|
1375
1621
|
// already stores that epic. On the shipped logs this is 37 of 56 findings.
|
|
1376
|
-
const projectForEpic =
|
|
1622
|
+
const projectForEpic = unanimousEpicProjects(taskRows);
|
|
1377
1623
|
const projectFromPlan = planProjectResolver(opts.specsDir);
|
|
1378
1624
|
const rows = [];
|
|
1379
1625
|
for (const finding of findings) {
|
|
@@ -1411,7 +1657,7 @@ function projectFindings(handle, events, opts) {
|
|
|
1411
1657
|
taskId: finding.task_id,
|
|
1412
1658
|
epicId: findingEpicId,
|
|
1413
1659
|
project: projectForTask(finding.task_id) ??
|
|
1414
|
-
projectForEpic.get(findingEpicId) ??
|
|
1660
|
+
(findingEpicId === null ? undefined : projectForEpic.get(findingEpicId)) ??
|
|
1415
1661
|
projectFromPlan(findingEpicId),
|
|
1416
1662
|
fingerprint: finding.fingerprint,
|
|
1417
1663
|
findingCategory: finding.finding_category,
|
|
@@ -1496,6 +1742,7 @@ export async function rebuild(dbPath = STATE_DB_PATH, sessions = 'all', opts = {
|
|
|
1496
1742
|
projectTasks(handle, merged, opts);
|
|
1497
1743
|
const skippedFindings = projectFindings(handle, merged, opts);
|
|
1498
1744
|
projectLessons(handle, merged);
|
|
1745
|
+
projectOperatorFeedback(handle, merged);
|
|
1499
1746
|
projectMilestones(handle, opts);
|
|
1500
1747
|
return {
|
|
1501
1748
|
sessionsProcessed: sessionIds.length,
|
|
@@ -1514,10 +1761,11 @@ export async function rebuild(dbPath = STATE_DB_PATH, sessions = 'all', opts = {
|
|
|
1514
1761
|
* replaces only its rows, leaving every other session's projection intact.
|
|
1515
1762
|
* Safe to call repeatedly while a session is still running (tailing).
|
|
1516
1763
|
*
|
|
1517
|
-
* The
|
|
1764
|
+
* The five tables that are not session-scoped are rewritten whole on every
|
|
1518
1765
|
* call: `milestones` from roadmap.md, and — from every session's log, which is
|
|
1519
1766
|
* why this reads more than the one session it re-folds — `tasks`
|
|
1520
|
-
* (projectTasks()), `lessons` (D-199)
|
|
1767
|
+
* (projectTasks()), `lessons` (D-199), `findings` (D-200) and
|
|
1768
|
+
* `operator_feedback` (projectOperatorFeedback(), same shape).
|
|
1521
1769
|
*/
|
|
1522
1770
|
export async function apply(dbPath = STATE_DB_PATH, sessionId, opts = {}) {
|
|
1523
1771
|
const handle = openDb(dbPath, opts);
|
|
@@ -1540,6 +1788,7 @@ export async function apply(dbPath = STATE_DB_PATH, sessionId, opts = {}) {
|
|
|
1540
1788
|
projectTasks(handle, merged, opts);
|
|
1541
1789
|
const skippedFindings = projectFindings(handle, merged, opts);
|
|
1542
1790
|
projectLessons(handle, merged);
|
|
1791
|
+
projectOperatorFeedback(handle, merged);
|
|
1543
1792
|
projectMilestones(handle, opts);
|
|
1544
1793
|
return {
|
|
1545
1794
|
sessionsProcessed: 1,
|