@juzser/blacksmith 0.1.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 +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,1317 @@
|
|
|
1
|
+
// Rebuild state/smith.db (SQLite projections) from state/events/*.jsonl.
|
|
2
|
+
//
|
|
3
|
+
// Events are the source of truth (architecture §7); this module is the
|
|
4
|
+
// ONLY writer of the tables in db/schema.ts. Two entry points:
|
|
5
|
+
// - rebuild(): drop every table's rows and replay every session's full
|
|
6
|
+
// event log from scratch. Idempotent — running it twice yields the
|
|
7
|
+
// same rows.
|
|
8
|
+
// - apply(): re-fold ONE session's current event log and refresh only
|
|
9
|
+
// that session's rows, leaving every other session's projection
|
|
10
|
+
// untouched ("incremental" in the sense that it never re-touches
|
|
11
|
+
// unrelated sessions, not in the sense of appending a single event —
|
|
12
|
+
// see the design note above projectSession below).
|
|
13
|
+
//
|
|
14
|
+
// Deviation (noted for the human, per the phase-5 task brief): the
|
|
15
|
+
// architecture doc describes dispatch_decision's terminal counterpart only
|
|
16
|
+
// in prose ("task result recorded"); no event type or payload shape for it
|
|
17
|
+
// exists anywhere in the codebase yet. This module introduces
|
|
18
|
+
// `task-result-recorded` (payload = the full Result object, i.e.
|
|
19
|
+
// result.schema.json's shape) as that terminal event, consumed here by
|
|
20
|
+
// agents-registry.ts's fold and by tasks/analytics below. No taxonomy.yml
|
|
21
|
+
// change was needed — event_type is a free string in event.schema.json
|
|
22
|
+
// (dispatch_decision, error-logged, session-start, edge-recorded, and
|
|
23
|
+
// every gate_event/graph_event value are already free strings by the same
|
|
24
|
+
// precedent) — but a short note was added to docs/specs/
|
|
25
|
+
// black-smith-architecture.md §7 documenting the new event kind, in the
|
|
26
|
+
// same place dispatch_decision/user_prompt are documented.
|
|
27
|
+
//
|
|
28
|
+
// A second, smaller deviation: `lesson-candidate-raised` /
|
|
29
|
+
// `lesson-status-changed` events (consumed by foldLessons below) are
|
|
30
|
+
// likewise new — Phase 7 owns the real lessons loop (novelty gate,
|
|
31
|
+
// operator approval UI) and will define these for real; this module's
|
|
32
|
+
// fold is forward-compatible with whatever Phase 7 emits, since it only
|
|
33
|
+
// depends on the taxonomy's already-existing lesson_type/lesson_level/
|
|
34
|
+
// lesson_status/lesson_scope vocabulary, not on any new taxonomy value.
|
|
35
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import Database from 'better-sqlite3';
|
|
38
|
+
import { eq } from 'drizzle-orm';
|
|
39
|
+
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
40
|
+
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
|
41
|
+
import { detectStale as detectStaleAgents, foldAgents, } from '../agents-registry.js';
|
|
42
|
+
import { eventTaskId, listSessionIds, mergeSessionLogs, readEvents, } from '../events.js';
|
|
43
|
+
import { foldFindingsDetailed, missingProjectionFields, } from '../findings.js';
|
|
44
|
+
import { DB_MIGRATIONS_DIR, ROADMAP_PATH, STATE_DB_PATH, STATE_EVENTS_DIR } from '../paths.js';
|
|
45
|
+
import { isPlanRefTaskId, latestPlanVersion, loadPlan } from '../plan.js';
|
|
46
|
+
import { loadRoadmap } from '../roadmap.js';
|
|
47
|
+
import { assertRuntimeSupported } from '../runtime.js';
|
|
48
|
+
import { bareTaskId, epicOfTaskId, isQualifiedTaskId, qualifyTaskId, taskIdsMatch, } from '../taskId.js';
|
|
49
|
+
import { RESERVED_TASK_ID, taskBranchName } from '../worktree.js';
|
|
50
|
+
import * as schema from './schema.js';
|
|
51
|
+
/** Open (creating if needed) the projection DB and ensure its schema is current. */
|
|
52
|
+
export function openDb(dbPath = STATE_DB_PATH, opts = {}) {
|
|
53
|
+
// The line below is where an unsupported runtime dies: the prebuilt binding
|
|
54
|
+
// imports fine on Node 20 and then segfaults here, with both streams empty
|
|
55
|
+
// (D-47). Assert immediately before it so every in-process consumer — not
|
|
56
|
+
// just the CLI — gets a sentence instead of a signal.
|
|
57
|
+
assertRuntimeSupported();
|
|
58
|
+
mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
59
|
+
const sqlite = new Database(dbPath);
|
|
60
|
+
sqlite.pragma('journal_mode = WAL');
|
|
61
|
+
const db = drizzle(sqlite, { schema });
|
|
62
|
+
migrate(db, { migrationsFolder: opts.migrationsDir ?? DB_MIGRATIONS_DIR });
|
|
63
|
+
return { sqlite, db };
|
|
64
|
+
}
|
|
65
|
+
const ALL_TABLES = [
|
|
66
|
+
schema.eventsRaw,
|
|
67
|
+
schema.sessions,
|
|
68
|
+
schema.prompts,
|
|
69
|
+
schema.dispatches,
|
|
70
|
+
schema.agents,
|
|
71
|
+
schema.tasks,
|
|
72
|
+
schema.epics,
|
|
73
|
+
schema.edges,
|
|
74
|
+
schema.errors,
|
|
75
|
+
schema.findings,
|
|
76
|
+
schema.waivers,
|
|
77
|
+
schema.lessons,
|
|
78
|
+
schema.artifacts,
|
|
79
|
+
schema.milestones,
|
|
80
|
+
];
|
|
81
|
+
/** Delete every row in every projection table (full "drop" for rebuild()). */
|
|
82
|
+
function clearAll(handle) {
|
|
83
|
+
handle.db.transaction((txDb) => {
|
|
84
|
+
for (const table of ALL_TABLES)
|
|
85
|
+
txDb.delete(table).run();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** Delete one session's rows from every table (used before re-projecting it). */
|
|
89
|
+
function clearSession(db, sessionId) {
|
|
90
|
+
db.delete(schema.eventsRaw).where(eq(schema.eventsRaw.sessionId, sessionId)).run();
|
|
91
|
+
db.delete(schema.sessions).where(eq(schema.sessions.sessionId, sessionId)).run();
|
|
92
|
+
db.delete(schema.prompts).where(eq(schema.prompts.sessionId, sessionId)).run();
|
|
93
|
+
db.delete(schema.dispatches).where(eq(schema.dispatches.sessionId, sessionId)).run();
|
|
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
|
+
db.delete(schema.epics).where(eq(schema.epics.sessionId, sessionId)).run();
|
|
97
|
+
db.delete(schema.edges).where(eq(schema.edges.sessionId, sessionId)).run();
|
|
98
|
+
db.delete(schema.errors).where(eq(schema.errors.sessionId, sessionId)).run();
|
|
99
|
+
db.delete(schema.waivers).where(eq(schema.waivers.sessionId, sessionId)).run();
|
|
100
|
+
db.delete(schema.artifacts).where(eq(schema.artifacts.sessionId, sessionId)).run();
|
|
101
|
+
// `lessons` and `findings` are deliberately absent, like `milestones` —
|
|
102
|
+
// projectLessons() and projectFindings() own those tables whole (D-199,
|
|
103
|
+
// D-200). Deleting by session_id here would delete a row this session
|
|
104
|
+
// raised and another session has since approved or closed.
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Fully replace the milestones table from factory/specs/roadmap.md (not
|
|
108
|
+
* session-scoped — see schema.ts's milestones comment). Missing roadmap.md
|
|
109
|
+
* is tolerated (leaves milestones empty) rather than fatal, so `db rebuild`
|
|
110
|
+
* still works in a repo/state-dir that hasn't adopted a roadmap yet.
|
|
111
|
+
*
|
|
112
|
+
* A roadmap.md that fails to PARSE (malformed status, duplicate id, ...) is
|
|
113
|
+
* likewise tolerated, deliberately: this runs after every session write
|
|
114
|
+
* (rebuild()/apply(), the latter called by ui/server after every waiver/
|
|
115
|
+
* lesson event) — a markdown typo must degrade the Roadmap/Overview
|
|
116
|
+
* milestone data only, not the write path it happens to share a call site
|
|
117
|
+
* with. Existing milestones rows are left as-is (stale, not wiped) rather
|
|
118
|
+
* than the whole write erroring out; logged to stderr so it is visible.
|
|
119
|
+
*/
|
|
120
|
+
function projectMilestones(handle, opts) {
|
|
121
|
+
const roadmapPath = opts.roadmapPath ?? ROADMAP_PATH;
|
|
122
|
+
if (!existsSync(roadmapPath))
|
|
123
|
+
return;
|
|
124
|
+
let defs;
|
|
125
|
+
try {
|
|
126
|
+
defs = loadRoadmap(roadmapPath);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
console.error(`db/projector.ts projectMilestones(): roadmap.md failed to parse, ` +
|
|
130
|
+
`skipping the milestones refresh (existing rows left as-is): ` +
|
|
131
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
handle.db.transaction((txDb) => {
|
|
135
|
+
txDb.delete(schema.milestones).run();
|
|
136
|
+
for (const m of defs) {
|
|
137
|
+
txDb
|
|
138
|
+
.insert(schema.milestones)
|
|
139
|
+
.values({
|
|
140
|
+
milestoneId: m.milestoneId,
|
|
141
|
+
name: m.name,
|
|
142
|
+
status: m.status,
|
|
143
|
+
sequence: m.sequence,
|
|
144
|
+
goal: m.goal,
|
|
145
|
+
epicIds: JSON.stringify(m.epicIds),
|
|
146
|
+
project: m.project,
|
|
147
|
+
kind: m.kind,
|
|
148
|
+
})
|
|
149
|
+
.run();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/** Splits an `error.class` string into its group/class parts; tolerant of malformed values. */
|
|
154
|
+
function splitErrorClass(value) {
|
|
155
|
+
const [group, cls] = (value ?? '').split('.');
|
|
156
|
+
return { group: group ?? 'unknown', cls: cls ?? 'unknown' };
|
|
157
|
+
}
|
|
158
|
+
const TERMINAL_TASK_STATUSES = new Set([
|
|
159
|
+
'completed',
|
|
160
|
+
'superseded',
|
|
161
|
+
'failed',
|
|
162
|
+
'escalated',
|
|
163
|
+
'waived',
|
|
164
|
+
]);
|
|
165
|
+
/**
|
|
166
|
+
* Fallback for a `task-added` that declares no `branch` — every event logged
|
|
167
|
+
* before D-23/P9-12 added the field, and any hand-appended one. The
|
|
168
|
+
* convention itself lives in worktree.ts, which is what actually creates the
|
|
169
|
+
* branch; deriving it here from a second copy of the string is how the two
|
|
170
|
+
* would drift.
|
|
171
|
+
*/
|
|
172
|
+
/**
|
|
173
|
+
* The paths a `task-added` says its task owns, or undefined when the payload
|
|
174
|
+
* does not say it in a shape a reader can use.
|
|
175
|
+
*
|
|
176
|
+
* event.schema.json validates `payload` as `type: object` and stops there —
|
|
177
|
+
* the body is free-form on purpose, so that a new event type is never
|
|
178
|
+
* rejected at write time — which is why taskEvents.ts types the same field
|
|
179
|
+
* `unknown`. The cast above says `string[]` and nothing checks it, so
|
|
180
|
+
* whatever was written lands in tasks.claims and comes back out of
|
|
181
|
+
* taskDetail() still cast to `string[]`: a claim set written as one bare path
|
|
182
|
+
* reaches the board as a string and renders one chip per character.
|
|
183
|
+
* waveTaskIds already learned that a hand-appended payload fills in the
|
|
184
|
+
* singular field ("four times during the dogfood run"), and a single claim is
|
|
185
|
+
* the same keystroke.
|
|
186
|
+
*
|
|
187
|
+
* Unreadable leaves the row's claims alone rather than becoming `[]`: the
|
|
188
|
+
* column starts null meaning "no task-added told us what this owns", and that
|
|
189
|
+
* is still true here, whereas an empty list would assert the task declares
|
|
190
|
+
* nothing. A half-readable list is the same hole as no list — filtering it
|
|
191
|
+
* would hand every reader a shorter set than the task declared and call that
|
|
192
|
+
* what it owns — and gate.ts's loggedClaims() draws the line in the same
|
|
193
|
+
* place on the same payload.
|
|
194
|
+
*/
|
|
195
|
+
function readClaims(raw) {
|
|
196
|
+
if (!Array.isArray(raw))
|
|
197
|
+
return undefined;
|
|
198
|
+
return raw.every((c) => typeof c === 'string') ? raw : undefined;
|
|
199
|
+
}
|
|
200
|
+
function branchFor(epicId, taskId) {
|
|
201
|
+
if (!epicId)
|
|
202
|
+
return null;
|
|
203
|
+
return taskBranchName(epicId, taskId);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Every (task id, epic) pair an event asserts: from the id itself when it is
|
|
207
|
+
* qualified, from the payload's `epic_id` when it is not. `wave-admitted`
|
|
208
|
+
* carries the epic beside a list of ids; `task-added`/`task-superseded` carry
|
|
209
|
+
* it beside the one on the record.
|
|
210
|
+
*/
|
|
211
|
+
function epicAssertions(record) {
|
|
212
|
+
const payload = record.payload;
|
|
213
|
+
const epicId = typeof payload?.epic_id === 'string' ? payload.epic_id : undefined;
|
|
214
|
+
const taskId = eventTaskId(record);
|
|
215
|
+
const ids = [
|
|
216
|
+
...(taskId ? [taskId] : []),
|
|
217
|
+
...(Array.isArray(payload?.task_ids)
|
|
218
|
+
? payload.task_ids.filter((v) => typeof v === 'string')
|
|
219
|
+
: []),
|
|
220
|
+
];
|
|
221
|
+
return ids.map((id) => [id, epicId]);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Every epic id the log names: from any event's `epic_id`, and from the epic
|
|
225
|
+
* half of every qualified task id it mentions.
|
|
226
|
+
*
|
|
227
|
+
* An epic id is not a task id — it never contains a `/` — so a set of them
|
|
228
|
+
* can never shadow a qualified task. Two things below need it: the guard that
|
|
229
|
+
* keeps an epic-shaped ref off the board (D-251), and the roster lookup that
|
|
230
|
+
* only visits epics this log actually ran (D-250).
|
|
231
|
+
*/
|
|
232
|
+
function knownEpicIds(events) {
|
|
233
|
+
const epics = new Set();
|
|
234
|
+
for (const { record } of events) {
|
|
235
|
+
const payload = record.payload;
|
|
236
|
+
if (typeof payload?.epic_id === 'string' && payload.epic_id.length > 0) {
|
|
237
|
+
epics.add(payload.epic_id);
|
|
238
|
+
}
|
|
239
|
+
for (const [id] of epicAssertions(record)) {
|
|
240
|
+
const epic = epicOfTaskId(id);
|
|
241
|
+
if (epic !== null)
|
|
242
|
+
epics.add(epic);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return epics;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Bare id -> qualified id, read from the plan roster of every epic the log
|
|
249
|
+
* names (D-250).
|
|
250
|
+
*
|
|
251
|
+
* `buildTaskIdAliases` can only match a bare id against a qualified spelling
|
|
252
|
+
* some OTHER event supplied. A task whose every event is bare and carries no
|
|
253
|
+
* `epic_id` — no `task-added`, just gate results, as `task-4-api` was through
|
|
254
|
+
* the whole envkit dogfood run — has no such spelling, so it folds to its own
|
|
255
|
+
* epic-less row and the project-scoped board never draws it. The plan roster
|
|
256
|
+
* this projector already opens for `planProjectResolver` names it in full.
|
|
257
|
+
*
|
|
258
|
+
* Same tolerance as that resolver: a missing dir, a missing file, or one that
|
|
259
|
+
* will not parse all mean "no answer", never a failed rebuild. The full
|
|
260
|
+
* `plan.tasks` roster is read rather than `livePlanTasks` — a superseded task
|
|
261
|
+
* is still that task, and its id still has to fold onto the right row.
|
|
262
|
+
*/
|
|
263
|
+
function planRosterAliases(epics, specsDir) {
|
|
264
|
+
const candidates = new Map();
|
|
265
|
+
for (const epicId of epics) {
|
|
266
|
+
let roster = [];
|
|
267
|
+
try {
|
|
268
|
+
const version = latestPlanVersion(epicId, { specsDir });
|
|
269
|
+
if (version !== null) {
|
|
270
|
+
roster = loadPlan(epicId, version, { specsDir }).tasks.map((t) => t.task_id);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
console.error(`db/projector.ts planRosterAliases(): plan file for "${epicId}" ` +
|
|
275
|
+
`failed to load, leaving its task ids unresolved: ` +
|
|
276
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
277
|
+
}
|
|
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
|
+
}
|
|
287
|
+
return candidates;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Bare id -> the one qualified id it must mean, built from every epic the log
|
|
291
|
+
* asserts for it (D-49/P9-10), and from the plan rosters on disk for the ids
|
|
292
|
+
* the log never qualifies anywhere (D-250).
|
|
293
|
+
*
|
|
294
|
+
* Producers disagree about the form: `task-added` writes `envkit/task-0`
|
|
295
|
+
* while `wave-admitted`/`wave-merged` write `task-0` and put the epic in the
|
|
296
|
+
* payload. Folded literally that is two rows for one task — one stuck at
|
|
297
|
+
* `todo` with no epic, one `completed` with no objective — and every consumer
|
|
298
|
+
* that counts tasks counts it twice.
|
|
299
|
+
*
|
|
300
|
+
* A bare id claimed by two different epics gets NO entry: merging it into
|
|
301
|
+
* either would silently complete the wrong task, so it stays its own row and
|
|
302
|
+
* stays visible as the ambiguity it is.
|
|
303
|
+
*/
|
|
304
|
+
function buildTaskIdAliases(events, opts) {
|
|
305
|
+
const candidates = new Map();
|
|
306
|
+
const register = (bare, qualified) => {
|
|
307
|
+
const set = candidates.get(bare) ?? new Set();
|
|
308
|
+
set.add(qualified);
|
|
309
|
+
candidates.set(bare, set);
|
|
310
|
+
};
|
|
311
|
+
for (const { record } of events) {
|
|
312
|
+
for (const [id, epicId] of epicAssertions(record)) {
|
|
313
|
+
if (isQualifiedTaskId(id))
|
|
314
|
+
register(bareTaskId(id), id);
|
|
315
|
+
else if (epicId !== undefined && id.length > 0)
|
|
316
|
+
register(id, qualifyTaskId(epicId, id));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// The roster is consulted only for a bare id NO event qualified. It is a
|
|
320
|
+
// fallback and never an override — the same precedence D-246 gives the
|
|
321
|
+
// plan's `project` — so a log that already answered keeps its answer, and a
|
|
322
|
+
// roster entry can never turn a clean event-derived alias into an
|
|
323
|
+
// ambiguity. An id the LOG left ambiguous stays ambiguous too: the plan
|
|
324
|
+
// breaking that tie would quietly overrule two events that disagree.
|
|
325
|
+
const fromPlan = planRosterAliases(knownEpicIds(events), opts.specsDir);
|
|
326
|
+
const aliases = new Map();
|
|
327
|
+
for (const bare of new Set([...candidates.keys(), ...fromPlan.keys()])) {
|
|
328
|
+
const qualified = candidates.get(bare) ?? fromPlan.get(bare) ?? new Set();
|
|
329
|
+
const [only] = [...qualified];
|
|
330
|
+
if (qualified.size === 1 && only !== undefined && only !== bare)
|
|
331
|
+
aliases.set(bare, only);
|
|
332
|
+
}
|
|
333
|
+
return aliases;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The exact normalisation `foldTasks` applies to every id it folds.
|
|
337
|
+
*
|
|
338
|
+
* Exported because a consumer that keys its own map on raw log ids — the ids
|
|
339
|
+
* as producers wrote them — must resolve them the same way, or its lookups
|
|
340
|
+
* miss the rows `foldTasks` returns and silently fall back to a default
|
|
341
|
+
* (D-182). One resolver, one answer.
|
|
342
|
+
*
|
|
343
|
+
* `opts.specsDir` is threaded through for the same reason: a caller that
|
|
344
|
+
* resolves ids without it would miss D-250's roster-derived aliases and go
|
|
345
|
+
* back to disagreeing with the rows in the db.
|
|
346
|
+
*/
|
|
347
|
+
export function taskIdCanonicalizer(events, opts = {}) {
|
|
348
|
+
const aliases = buildTaskIdAliases(events, opts);
|
|
349
|
+
return (taskId) => (isQualifiedTaskId(taskId) ? taskId : (aliases.get(taskId) ?? taskId));
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* The tasks a wave-scoped event (`wave-admitted`, `wave-merged`) speaks for.
|
|
353
|
+
*
|
|
354
|
+
* D-23/P9-12: the payload's `task_ids` is authoritative — that is what the
|
|
355
|
+
* producers in taskEvents.ts write, and a wave is a set. But an event
|
|
356
|
+
* appended by hand naturally fills in the envelope's singular `task_id` and
|
|
357
|
+
* leaves `task_ids` empty; that happened four times during the dogfood run
|
|
358
|
+
* and the fold read nothing at all, so the board silently stayed wrong.
|
|
359
|
+
* Reading the record-level id as a one-task wave makes those keystrokes mean
|
|
360
|
+
* what they look like they mean, and costs the producers nothing: they emit
|
|
361
|
+
* both (one `wave-merged` per merged task, carrying its own id).
|
|
362
|
+
*/
|
|
363
|
+
function waveTaskIds(record) {
|
|
364
|
+
const ids = record.payload.task_ids;
|
|
365
|
+
if (Array.isArray(ids) && ids.length > 0)
|
|
366
|
+
return ids;
|
|
367
|
+
const taskId = eventTaskId(record);
|
|
368
|
+
return taskId ? [taskId] : [];
|
|
369
|
+
}
|
|
370
|
+
export function foldTasks(events, opts = {}) {
|
|
371
|
+
const byId = new Map();
|
|
372
|
+
// Normalisation happens at this boundary, once, before any row exists —
|
|
373
|
+
// so no later code has to ask which of two ids for one task it is holding.
|
|
374
|
+
const canonical = taskIdCanonicalizer(events, opts);
|
|
375
|
+
const epics = knownEpicIds(events);
|
|
376
|
+
function touch(rawTaskId, ts, sessionId) {
|
|
377
|
+
const taskId = canonical(rawTaskId);
|
|
378
|
+
let row = byId.get(taskId);
|
|
379
|
+
if (!row) {
|
|
380
|
+
row = {
|
|
381
|
+
taskId,
|
|
382
|
+
sessionId,
|
|
383
|
+
// A qualified id IS an epic assertion; reading it here means every
|
|
384
|
+
// consumer can filter on the field instead of re-deriving it, and a
|
|
385
|
+
// task whose only event is a dispatch still knows its epic.
|
|
386
|
+
epicId: epicOfTaskId(taskId),
|
|
387
|
+
caseTag: null,
|
|
388
|
+
origin: null,
|
|
389
|
+
taskStatus: 'todo',
|
|
390
|
+
planVersion: null,
|
|
391
|
+
objective: null,
|
|
392
|
+
claims: null,
|
|
393
|
+
budgetTokens: null,
|
|
394
|
+
branch: null,
|
|
395
|
+
createdAt: ts,
|
|
396
|
+
updatedAt: ts,
|
|
397
|
+
project: null,
|
|
398
|
+
};
|
|
399
|
+
// Three ref shapes are NOT tasks and must never surface as kanban
|
|
400
|
+
// cards. The epic's own bare id, which `error-logged` writes into
|
|
401
|
+
// `payload.task_ref` when the failure belongs to no single task (D-251)
|
|
402
|
+
// — the phantom foldEpics() keeps itself off this fold to avoid; the
|
|
403
|
+
// errors table takes that row straight from the event, so refusing the
|
|
404
|
+
// card costs nothing. And two qualified-looking refs:
|
|
405
|
+
// <epic>/integration (worktree.ts's RESERVED_TASK_ID, the epic's
|
|
406
|
+
// integration branch — epic.ts's runEpicVerdict()) and <epic>/plan-v<n>
|
|
407
|
+
// (plan.ts's planRefTaskId(), a plan version — planQuorum.ts's
|
|
408
|
+
// runPlanQuorum()). Both hosts stamp their ref onto the
|
|
409
|
+
// dispatch_decision/judge-verdict/quorum-decision events their quorum
|
|
410
|
+
// case emits, so provider-agreement analytics can group by it like any
|
|
411
|
+
// other dispatch (db/queries.ts's providerAgreement() reads eventsRaw
|
|
412
|
+
// directly, never joins tasks, so it's unaffected either way). Every
|
|
413
|
+
// event type that carries a task_id routes through this one touch()
|
|
414
|
+
// choke point (task-added, wave-admitted, dispatch_decision,
|
|
415
|
+
// gate-outcome, wave-merged, task-superseded, error-logged), so one
|
|
416
|
+
// guard here is enough: build the row (callers below still mutate it
|
|
417
|
+
// freely) but never register a reserved ref in byId, so it never
|
|
418
|
+
// reaches foldTasks()'s returned rows or projectSession()'s tasks-table
|
|
419
|
+
// insert loop.
|
|
420
|
+
const isReservedRef = taskId.split('/').pop() === RESERVED_TASK_ID ||
|
|
421
|
+
isPlanRefTaskId(taskId) ||
|
|
422
|
+
epics.has(taskId);
|
|
423
|
+
if (!isReservedRef)
|
|
424
|
+
byId.set(taskId, row);
|
|
425
|
+
}
|
|
426
|
+
row.updatedAt = ts;
|
|
427
|
+
return row;
|
|
428
|
+
}
|
|
429
|
+
for (const { record } of events) {
|
|
430
|
+
// Resolved once, above the switch, for the same reason `touch()` is the
|
|
431
|
+
// one place a row is born: a hand-written event names its task in the
|
|
432
|
+
// payload and leaves the envelope null (D-245), and reading the envelope
|
|
433
|
+
// alone dropped every such event on the floor — a dispatched task stayed
|
|
434
|
+
// `todo` on the board and its `project` was never stamped.
|
|
435
|
+
const eventTask = eventTaskId(record);
|
|
436
|
+
switch (record.event_type) {
|
|
437
|
+
case 'task-added': {
|
|
438
|
+
if (!eventTask)
|
|
439
|
+
break;
|
|
440
|
+
const p = record.payload;
|
|
441
|
+
const row = touch(eventTask, record.ts, record.session_id);
|
|
442
|
+
// The id wins when it carries an epic: it is what every branch name
|
|
443
|
+
// and finding is keyed on, and a payload that disagrees with it is a
|
|
444
|
+
// producer bug, not a second opinion worth honouring.
|
|
445
|
+
row.epicId = epicOfTaskId(row.taskId) ?? p.epic_id ?? row.epicId;
|
|
446
|
+
row.caseTag = p.case ?? row.caseTag;
|
|
447
|
+
row.origin = p.origin ?? row.origin;
|
|
448
|
+
// D-18b. `task_status` in a plan file is only ever the task's INITIAL
|
|
449
|
+
// status — nothing writes completion back into the document — so a
|
|
450
|
+
// second `task-added` for the same id (which an amendment now emits, so
|
|
451
|
+
// the amended task carries the new plan_version) would otherwise revert
|
|
452
|
+
// a merged task to `todo`. A terminal status is a fact the log earned
|
|
453
|
+
// from `wave-merged`; a static field in a plan file does not overrule
|
|
454
|
+
// it. Same guard `dispatch_decision` and `error-logged` already apply.
|
|
455
|
+
if (!TERMINAL_TASK_STATUSES.has(row.taskStatus))
|
|
456
|
+
row.taskStatus = p.task_status ?? row.taskStatus;
|
|
457
|
+
row.planVersion = p.plan_version ?? row.planVersion;
|
|
458
|
+
row.objective = p.objective ?? row.objective;
|
|
459
|
+
row.claims = readClaims(p.claims) ?? row.claims;
|
|
460
|
+
row.budgetTokens = p.budget_tokens ?? row.budgetTokens;
|
|
461
|
+
row.branch = p.branch ?? branchFor(row.epicId, row.taskId);
|
|
462
|
+
row.project = record.project ?? row.project;
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
case 'wave-admitted': {
|
|
466
|
+
const p = record.payload;
|
|
467
|
+
for (const taskId of waveTaskIds(record)) {
|
|
468
|
+
const row = touch(taskId, record.ts, record.session_id);
|
|
469
|
+
row.taskStatus = 'ready';
|
|
470
|
+
row.epicId = epicOfTaskId(row.taskId) ?? p.epic_id ?? row.epicId;
|
|
471
|
+
}
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
case 'dispatch_decision': {
|
|
475
|
+
if (!eventTask)
|
|
476
|
+
break;
|
|
477
|
+
const row = touch(eventTask, record.ts, record.session_id);
|
|
478
|
+
if (!TERMINAL_TASK_STATUSES.has(row.taskStatus))
|
|
479
|
+
row.taskStatus = 'in-progress';
|
|
480
|
+
row.project = record.project ?? row.project;
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
case 'gate-outcome': {
|
|
484
|
+
if (!eventTask)
|
|
485
|
+
break;
|
|
486
|
+
const p = record.payload;
|
|
487
|
+
const row = touch(eventTask, record.ts, record.session_id);
|
|
488
|
+
if (p.outcome === 'blocked')
|
|
489
|
+
row.taskStatus = 'blocked';
|
|
490
|
+
else if (p.outcome === 'pass-with-waivers-pending')
|
|
491
|
+
row.taskStatus = 'reviewing';
|
|
492
|
+
else if (p.outcome === 'pass')
|
|
493
|
+
row.taskStatus = 'merging';
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
case 'wave-merged': {
|
|
497
|
+
for (const taskId of waveTaskIds(record)) {
|
|
498
|
+
touch(taskId, record.ts, record.session_id).taskStatus = 'completed';
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
case 'task-superseded': {
|
|
503
|
+
if (!eventTask)
|
|
504
|
+
break;
|
|
505
|
+
touch(eventTask, record.ts, record.session_id).taskStatus = 'superseded';
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
case 'error-logged': {
|
|
509
|
+
const p = record.payload;
|
|
510
|
+
// `task_ref` is this event's own spelling; eventTaskId covers the two
|
|
511
|
+
// the rest of the log uses (D-245).
|
|
512
|
+
const taskId = eventTask ?? p.task_ref;
|
|
513
|
+
if (!taskId)
|
|
514
|
+
break;
|
|
515
|
+
const row = touch(taskId, record.ts, record.session_id);
|
|
516
|
+
row.project = record.project ?? row.project;
|
|
517
|
+
if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
|
|
518
|
+
break;
|
|
519
|
+
row.taskStatus = p.error?.startsWith('coordination.') ? 'escalated' : 'blocked';
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
default:
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return [...byId.values()];
|
|
527
|
+
}
|
|
528
|
+
export function foldLessons(events) {
|
|
529
|
+
const byId = new Map();
|
|
530
|
+
const prevented = new Map();
|
|
531
|
+
for (const { record } of events) {
|
|
532
|
+
if (record.event_type === 'lesson-candidate-raised') {
|
|
533
|
+
const p = record.payload;
|
|
534
|
+
if (!p.lesson_id || !p.lesson_type || !p.lesson_level || !p.lesson_scope || !p.statement) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
byId.set(p.lesson_id, {
|
|
538
|
+
lessonId: p.lesson_id,
|
|
539
|
+
sessionId: record.session_id,
|
|
540
|
+
lessonType: p.lesson_type,
|
|
541
|
+
lessonLevel: p.lesson_level,
|
|
542
|
+
lessonStatus: p.lesson_status ?? 'candidate',
|
|
543
|
+
lessonScope: p.lesson_scope,
|
|
544
|
+
statement: p.statement,
|
|
545
|
+
validFrom: p.valid_from ?? record.ts,
|
|
546
|
+
supersededBy: p.superseded_by ?? null,
|
|
547
|
+
invalidatedByEventId: p.invalidated_by_event_id ?? null,
|
|
548
|
+
provenanceEventIds: p.provenance_event_ids ?? [],
|
|
549
|
+
evidence: p.evidence ?? null,
|
|
550
|
+
timesPrevented: 0,
|
|
551
|
+
findingCategory: p.finding_category ?? null,
|
|
552
|
+
claimPath: p.claim_path ?? null,
|
|
553
|
+
agentRole: p.agent_role ?? null,
|
|
554
|
+
caseType: p.case_type ?? null,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
else if (record.event_type === 'lesson-status-changed') {
|
|
558
|
+
const p = record.payload;
|
|
559
|
+
if (!p.lesson_id || !p.to_status)
|
|
560
|
+
continue;
|
|
561
|
+
const row = byId.get(p.lesson_id);
|
|
562
|
+
if (!row)
|
|
563
|
+
continue;
|
|
564
|
+
row.lessonStatus = p.to_status;
|
|
565
|
+
if (p.superseded_by)
|
|
566
|
+
row.supersededBy = p.superseded_by;
|
|
567
|
+
if (p.invalidated_by_event_id)
|
|
568
|
+
row.invalidatedByEventId = p.invalidated_by_event_id;
|
|
569
|
+
}
|
|
570
|
+
else if (record.event_type === 'lesson-edited') {
|
|
571
|
+
const p = record.payload;
|
|
572
|
+
if (!p.lesson_id)
|
|
573
|
+
continue;
|
|
574
|
+
const row = byId.get(p.lesson_id);
|
|
575
|
+
if (!row)
|
|
576
|
+
continue;
|
|
577
|
+
// Trimmed, and a whitespace-only statement ignored rather than
|
|
578
|
+
// applied: transitionLesson trims the edit and then refuses an empty
|
|
579
|
+
// result outright, so this is the writer's own rule read back. A fold
|
|
580
|
+
// cannot refuse -- it is replaying what already happened -- so the
|
|
581
|
+
// nearest true answer is to leave the statement the edit failed to say
|
|
582
|
+
// anything better than, instead of blanking a real one to spaces.
|
|
583
|
+
const statement = p.statement?.trim();
|
|
584
|
+
if (statement)
|
|
585
|
+
row.statement = statement;
|
|
586
|
+
if (p.lesson_type)
|
|
587
|
+
row.lessonType = p.lesson_type;
|
|
588
|
+
if (p.lesson_scope)
|
|
589
|
+
row.lessonScope = p.lesson_scope;
|
|
590
|
+
if (p.agent_role)
|
|
591
|
+
row.agentRole = p.agent_role;
|
|
592
|
+
if (p.case_type)
|
|
593
|
+
row.caseType = p.case_type;
|
|
594
|
+
}
|
|
595
|
+
else if (record.event_type === 'severity-decisions') {
|
|
596
|
+
const p = record.payload;
|
|
597
|
+
for (const d of p.decisions ?? []) {
|
|
598
|
+
if (d.same_mistake && d.matched_lesson_id) {
|
|
599
|
+
prevented.set(d.matched_lesson_id, (prevented.get(d.matched_lesson_id) ?? 0) + 1);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
for (const [lessonId, count] of prevented) {
|
|
605
|
+
const row = byId.get(lessonId);
|
|
606
|
+
if (row)
|
|
607
|
+
row.timesPrevented = count;
|
|
608
|
+
}
|
|
609
|
+
return [...byId.values()];
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* D-44/P9-27. Appending the close event is half the fix. projectSession()
|
|
613
|
+
* writes every event to events_raw, so a hand-written `epic-closed` was
|
|
614
|
+
* queryable there — but foldTasks()'s switch knows seven event types and ends
|
|
615
|
+
* `default: break;`, so nothing a human ever looks at changed. The log and the
|
|
616
|
+
* projection disagreed, and the projection is what every surface reads.
|
|
617
|
+
*
|
|
618
|
+
* Deliberately its own fold, keyed on `payload.epic_id` and never on task_id:
|
|
619
|
+
* the real dogfood close used `<epic>/epic`, an unreserved suffix that slips
|
|
620
|
+
* past foldTasks()'s isReservedRef guard, so anything routed through the task
|
|
621
|
+
* fold would mint a phantom task card named after the epic. Reading the id
|
|
622
|
+
* from the payload makes that structurally impossible.
|
|
623
|
+
*
|
|
624
|
+
* Last close wins — re-closing an epic is a correction, not a second epic.
|
|
625
|
+
*/
|
|
626
|
+
export function foldEpics(events) {
|
|
627
|
+
const byId = new Map();
|
|
628
|
+
for (const { event_id, record } of events) {
|
|
629
|
+
if (record.event_type !== 'epic-closed')
|
|
630
|
+
continue;
|
|
631
|
+
const p = record.payload;
|
|
632
|
+
// No epic_id means there is no epic to attribute this to. Guessing one
|
|
633
|
+
// out of task_id is exactly the inference this fold exists to avoid.
|
|
634
|
+
if (!p.epic_id)
|
|
635
|
+
continue;
|
|
636
|
+
byId.set(p.epic_id, {
|
|
637
|
+
epicId: p.epic_id,
|
|
638
|
+
sessionId: record.session_id,
|
|
639
|
+
epicStatus: 'closed',
|
|
640
|
+
// An unattributed close still closed the epic — dropping the row would
|
|
641
|
+
// leave it reading as in flight forever, which is the D-43 bug again.
|
|
642
|
+
closedBy: p.closed_by ?? 'unspecified',
|
|
643
|
+
machineVerdict: p.machine_verdict ?? null,
|
|
644
|
+
machineReason: p.machine_reason ?? null,
|
|
645
|
+
overrideRationale: p.override_rationale ?? null,
|
|
646
|
+
blockers: Array.isArray(p.blockers) ? p.blockers : [],
|
|
647
|
+
closedAt: record.ts,
|
|
648
|
+
eventId: event_id,
|
|
649
|
+
project: record.project ?? null,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return [...byId.values()];
|
|
653
|
+
}
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
// Per-session projection
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
/**
|
|
658
|
+
* Re-fold one session's CURRENT full event log into every table's rows for
|
|
659
|
+
* that session. Both rebuild() and apply() call this — there is one fold
|
|
660
|
+
* path, not two: "incremental" apply() re-derives the session's rows from
|
|
661
|
+
* its complete history (readEvents() always returns the full log; findings.
|
|
662
|
+
* ts's foldFindings() already works this way too) rather than patching a
|
|
663
|
+
* single new event into partially-mutable state. That keeps this file the
|
|
664
|
+
* single owner of every table's shape and makes rebuild/apply equivalence
|
|
665
|
+
* trivial to prove (same function, same input) instead of two fold
|
|
666
|
+
* implementations that can drift.
|
|
667
|
+
*/
|
|
668
|
+
/**
|
|
669
|
+
* Where a row belongs when its own envelope does not say.
|
|
670
|
+
*
|
|
671
|
+
* D-233. Only the writers holding the plan stamp a project (taskEvents.ts,
|
|
672
|
+
* D-232): a task's `task-added` carries one, and the dispatch, the result and
|
|
673
|
+
* the error that follow it carry nothing, because the code that appends them
|
|
674
|
+
* never saw the plan file. That is not a gap in the log -- events.ts is
|
|
675
|
+
* deliberate that an absent project stays absent on the wire -- but it IS a
|
|
676
|
+
* gap in these tables, because db/queries.ts's projectOf() resolves a NULL
|
|
677
|
+
* column to the DEFAULT project. Left alone, every gate outcome and every
|
|
678
|
+
* failure of a demo-rpg run reads back as black-smith's.
|
|
679
|
+
*
|
|
680
|
+
* A child row's project is its task's, which is exactly the rule the Scope
|
|
681
|
+
* docblock in db/queries.ts already states (D-170). Derived from the same
|
|
682
|
+
* foldTasks() the tasks table is written from, so the two can never disagree.
|
|
683
|
+
*
|
|
684
|
+
* This docblock used to claim projectFindings() below already applied that
|
|
685
|
+
* same rule. It did not -- it looked its tasks up in a plain exact-key Map,
|
|
686
|
+
* and 41 of the 56 findings in the shipped logs fell through the difference
|
|
687
|
+
* (D-247). It calls this resolver now, so the claim is true.
|
|
688
|
+
*
|
|
689
|
+
* Membership asks taskIdsMatch and not Map.has: the log spells the same task
|
|
690
|
+
* both qualified ("epic/task-1") and bare ("task-1"), and a spelling
|
|
691
|
+
* difference is not a different task (D-130/D-143).
|
|
692
|
+
*/
|
|
693
|
+
function projectResolver(taskRows) {
|
|
694
|
+
const exact = new Map(taskRows.map((t) => [t.taskId, t.project]));
|
|
695
|
+
return (taskId) => {
|
|
696
|
+
if (typeof taskId !== 'string')
|
|
697
|
+
return null;
|
|
698
|
+
const hit = exact.get(taskId);
|
|
699
|
+
if (hit !== undefined)
|
|
700
|
+
return hit;
|
|
701
|
+
return taskRows.find((t) => taskIdsMatch(taskId, t.taskId))?.project ?? null;
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* What an epic's plan file says it belongs to, for the tasks whose own events
|
|
706
|
+
* never said.
|
|
707
|
+
*
|
|
708
|
+
* D-246. taskEvents.ts's planScoped() stamps the project onto `task-added` at
|
|
709
|
+
* write time (D-232), which repairs every run started after it and nothing
|
|
710
|
+
* that came before -- and the real logs are mostly "before": not one of
|
|
711
|
+
* dogfood-demo-rpg-1's thirteen task-added events carries a project. Those
|
|
712
|
+
* rows fold to project NULL, projectResolver() hands the NULL down to every
|
|
713
|
+
* dispatch and error under them, and db/queries.ts's projectOf() resolves the
|
|
714
|
+
* lot to the DEFAULT project: the whole demo-rpg epic filed under
|
|
715
|
+
* black-smith, its own board empty. That history cannot be repaired from the
|
|
716
|
+
* log, because the log never held the answer. The plan file did, all along.
|
|
717
|
+
*
|
|
718
|
+
* Same source and same precedence as the write side -- what the envelope
|
|
719
|
+
* declared, else what the plan does -- so a rebuild and a fresh run agree.
|
|
720
|
+
* The plan is a fallback and never an override: an epic re-homed after its
|
|
721
|
+
* plan was written keeps where its events put it.
|
|
722
|
+
*
|
|
723
|
+
* Tolerant in exactly the way projectMilestones() is tolerant of roadmap.md:
|
|
724
|
+
* a missing dir, a missing file, a plan with no project, or one that will not
|
|
725
|
+
* parse all mean "no answer", which leaves the row as it was. An unreadable
|
|
726
|
+
* spec must not cost the operator the rest of the projection.
|
|
727
|
+
*/
|
|
728
|
+
function planProjectResolver(specsDir) {
|
|
729
|
+
const cache = new Map();
|
|
730
|
+
return (epicId) => {
|
|
731
|
+
// A task with no epic has no plan file to ask. Both spellings of that
|
|
732
|
+
// reach here: NULL from a `task-added` that named no epic, and '' from
|
|
733
|
+
// the phantom rows a bare task id folds into.
|
|
734
|
+
if (epicId === null || epicId === '')
|
|
735
|
+
return null;
|
|
736
|
+
const cached = cache.get(epicId);
|
|
737
|
+
if (cached !== undefined)
|
|
738
|
+
return cached;
|
|
739
|
+
let project = null;
|
|
740
|
+
try {
|
|
741
|
+
const version = latestPlanVersion(epicId, { specsDir });
|
|
742
|
+
if (version !== null)
|
|
743
|
+
project = loadPlan(epicId, version, { specsDir }).project ?? null;
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
console.error(`db/projector.ts planProjectResolver(): plan file for "${epicId}" ` +
|
|
747
|
+
`failed to load, leaving its project unresolved: ` +
|
|
748
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
749
|
+
}
|
|
750
|
+
cache.set(epicId, project);
|
|
751
|
+
return project;
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* foldTasks() with D-246's plan-file backfill applied.
|
|
756
|
+
*
|
|
757
|
+
* Both folds of the task list go through here, because both answer the same
|
|
758
|
+
* question and must not answer it differently: projectSession() derives the
|
|
759
|
+
* tasks, epics, dispatches, artifacts and errors rows from one, and
|
|
760
|
+
* projectFindings() folds the same events again for findings.project. Split
|
|
761
|
+
* the backfill across only one of them and a single task reads as demo-rpg's
|
|
762
|
+
* on the board and black-smith's on the errors page.
|
|
763
|
+
*/
|
|
764
|
+
function foldTasksWithPlanProject(events, opts) {
|
|
765
|
+
const projectFromPlan = planProjectResolver(opts.specsDir);
|
|
766
|
+
return foldTasks([...events], { specsDir: opts.specsDir }).map((t) => t.project === null ? { ...t, project: projectFromPlan(t.epicId) } : t);
|
|
767
|
+
}
|
|
768
|
+
export function projectSession(handle, sessionId, events, opts = {}) {
|
|
769
|
+
handle.db.transaction((txDb) => {
|
|
770
|
+
clearSession(txDb, sessionId);
|
|
771
|
+
if (events.length === 0)
|
|
772
|
+
return;
|
|
773
|
+
// Folded once, up here: the rows below inherit their project from it, and
|
|
774
|
+
// the tasks table is written from the very same array further down. The
|
|
775
|
+
// plan-file backfill lands here for that reason -- one insertion point,
|
|
776
|
+
// and the tasks table, the epics table and every child row come with it.
|
|
777
|
+
const taskRows = foldTasksWithPlanProject(events, opts);
|
|
778
|
+
const projectForTask = projectResolver(taskRows);
|
|
779
|
+
const projectForEpic = new Map(taskRows.flatMap((t) => (t.project === null ? [] : [[t.epicId, t.project]])));
|
|
780
|
+
/**
|
|
781
|
+
* The project a row inherits from whatever its ref names, task or epic.
|
|
782
|
+
*
|
|
783
|
+
* Half the refs a real run writes name an epic and no task at all --
|
|
784
|
+
* `<epic>/integration`, `<epic>/plan-v2`, `<epic>/epic`, or the bare epic
|
|
785
|
+
* id -- and foldTasks() deliberately refuses to mint a task row for any of
|
|
786
|
+
* them (D-250), so the task leg alone can never answer. Resolving through
|
|
787
|
+
* it alone left those rows NULL, and queries.ts's projectOf() reads a NULL
|
|
788
|
+
* back as the DEFAULT project, not as "unscoped": every one of them was
|
|
789
|
+
* filed under black-smith, including the one stop-the-line error in the
|
|
790
|
+
* shipped logs, which demo-rpg had raised (D-252).
|
|
791
|
+
*
|
|
792
|
+
* epicOfTaskId() answers for a qualified ref; `?? ref` covers the bare
|
|
793
|
+
* spelling. It cannot mis-file: the map is keyed by epic id, so a bare
|
|
794
|
+
* TASK id that names no epic simply misses and stays null.
|
|
795
|
+
*/
|
|
796
|
+
const projectForRef = (ref) => typeof ref !== 'string'
|
|
797
|
+
? null
|
|
798
|
+
: (projectForTask(ref) ?? projectForEpic.get(epicOfTaskId(ref) ?? ref) ?? null);
|
|
799
|
+
const first = events[0];
|
|
800
|
+
const last = events[events.length - 1];
|
|
801
|
+
txDb
|
|
802
|
+
.insert(schema.sessions)
|
|
803
|
+
.values({
|
|
804
|
+
sessionId,
|
|
805
|
+
startedAt: first.record.ts,
|
|
806
|
+
lastEventAt: last.record.ts,
|
|
807
|
+
eventCount: events.length,
|
|
808
|
+
})
|
|
809
|
+
.run();
|
|
810
|
+
for (const { event_id, record } of events) {
|
|
811
|
+
// The JSONL is the archive; every table below is a projection of it, and
|
|
812
|
+
// `project` here is already derived rather than copied. The task id gets
|
|
813
|
+
// the same treatment: read from both levels (D-245), or a hand-written
|
|
814
|
+
// dispatch lands in events_raw and dispatches with a null task and
|
|
815
|
+
// answers no task-scoped query the operator can type.
|
|
816
|
+
const eventTask = eventTaskId(record);
|
|
817
|
+
txDb
|
|
818
|
+
.insert(schema.eventsRaw)
|
|
819
|
+
.values({
|
|
820
|
+
eventId: event_id,
|
|
821
|
+
sessionId: record.session_id,
|
|
822
|
+
ts: record.ts,
|
|
823
|
+
eventType: record.event_type,
|
|
824
|
+
taskId: eventTask,
|
|
825
|
+
agentId: record.agent_id ?? null,
|
|
826
|
+
planVersion: record.plan_version,
|
|
827
|
+
causalParent: record.causal_parent,
|
|
828
|
+
payload: JSON.stringify(record.payload),
|
|
829
|
+
project: record.project ?? projectForRef(eventTask),
|
|
830
|
+
actor: record.actor,
|
|
831
|
+
})
|
|
832
|
+
.run();
|
|
833
|
+
if (record.event_type === 'user_prompt') {
|
|
834
|
+
const p = record.payload;
|
|
835
|
+
txDb
|
|
836
|
+
.insert(schema.prompts)
|
|
837
|
+
.values({
|
|
838
|
+
eventId: event_id,
|
|
839
|
+
sessionId: record.session_id,
|
|
840
|
+
ts: record.ts,
|
|
841
|
+
prompt: p.prompt ?? p.text ?? '',
|
|
842
|
+
causalParent: record.causal_parent,
|
|
843
|
+
})
|
|
844
|
+
.run();
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
if (record.event_type === 'dispatch_decision') {
|
|
848
|
+
const p = record.payload;
|
|
849
|
+
if (!p.agent_role || !p.provider || !p.model_tier)
|
|
850
|
+
continue;
|
|
851
|
+
txDb
|
|
852
|
+
.insert(schema.dispatches)
|
|
853
|
+
.values({
|
|
854
|
+
eventId: event_id,
|
|
855
|
+
sessionId: record.session_id,
|
|
856
|
+
ts: record.ts,
|
|
857
|
+
taskId: eventTask,
|
|
858
|
+
agentId: record.agent_id ?? null,
|
|
859
|
+
agentRole: p.agent_role,
|
|
860
|
+
provider: p.provider,
|
|
861
|
+
modelTier: p.model_tier,
|
|
862
|
+
specRef: p.spec_ref ?? null,
|
|
863
|
+
reason: p.reason ?? null,
|
|
864
|
+
parentPromptId: p.parent_prompt_id ?? null,
|
|
865
|
+
causalParent: record.causal_parent,
|
|
866
|
+
project: record.project ?? projectForRef(eventTask),
|
|
867
|
+
})
|
|
868
|
+
.run();
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
if (record.event_type === 'edge-recorded' && record.edge && eventTask) {
|
|
872
|
+
const p = record.payload;
|
|
873
|
+
if (!p.depends_on)
|
|
874
|
+
continue;
|
|
875
|
+
txDb
|
|
876
|
+
.insert(schema.edges)
|
|
877
|
+
.values({
|
|
878
|
+
eventId: event_id,
|
|
879
|
+
sessionId: record.session_id,
|
|
880
|
+
ts: record.ts,
|
|
881
|
+
taskId: eventTask,
|
|
882
|
+
dependsOn: p.depends_on,
|
|
883
|
+
edgeType: record.edge.edge_type,
|
|
884
|
+
edgeProvenance: record.edge.edge_provenance,
|
|
885
|
+
})
|
|
886
|
+
.run();
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (record.event_type === 'error-logged') {
|
|
890
|
+
const p = record.payload;
|
|
891
|
+
if (!p.error || !p.severity)
|
|
892
|
+
continue;
|
|
893
|
+
const { group, cls } = splitErrorClass(p.error);
|
|
894
|
+
txDb
|
|
895
|
+
.insert(schema.errors)
|
|
896
|
+
.values({
|
|
897
|
+
eventId: event_id,
|
|
898
|
+
sessionId: record.session_id,
|
|
899
|
+
ts: record.ts,
|
|
900
|
+
taskRef: p.task_ref ?? eventTask,
|
|
901
|
+
agentId: record.agent_id ?? null,
|
|
902
|
+
errorGroup: group,
|
|
903
|
+
errorClass: cls,
|
|
904
|
+
severity: p.severity,
|
|
905
|
+
detail: p.detail ?? null,
|
|
906
|
+
project: record.project ?? projectForRef(p.task_ref ?? eventTask),
|
|
907
|
+
})
|
|
908
|
+
.run();
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (record.event_type === 'task-result-recorded') {
|
|
912
|
+
const p = record.payload;
|
|
913
|
+
// Envelope first, like every other reader — this line used to prefer
|
|
914
|
+
// the payload, the one place in the repo that resolved the pair the
|
|
915
|
+
// other way round (D-245).
|
|
916
|
+
const taskId = eventTask;
|
|
917
|
+
if (!taskId)
|
|
918
|
+
continue;
|
|
919
|
+
(p.artifacts ?? []).forEach((artifact, index) => {
|
|
920
|
+
if (!artifact.type || !artifact.path)
|
|
921
|
+
return;
|
|
922
|
+
txDb
|
|
923
|
+
.insert(schema.artifacts)
|
|
924
|
+
.values({
|
|
925
|
+
id: `${event_id}#${index}`,
|
|
926
|
+
sessionId: record.session_id,
|
|
927
|
+
taskId,
|
|
928
|
+
eventId: event_id,
|
|
929
|
+
ts: record.ts,
|
|
930
|
+
type: artifact.type,
|
|
931
|
+
path: artifact.path,
|
|
932
|
+
description: artifact.description ?? null,
|
|
933
|
+
})
|
|
934
|
+
.run();
|
|
935
|
+
});
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
if (record.event_type === 'waiver-granted' || record.event_type === 'waiver-denied') {
|
|
939
|
+
const p = record.payload;
|
|
940
|
+
if (!p.fingerprint)
|
|
941
|
+
continue;
|
|
942
|
+
txDb
|
|
943
|
+
.insert(schema.waivers)
|
|
944
|
+
.values({
|
|
945
|
+
eventId: event_id,
|
|
946
|
+
sessionId: record.session_id,
|
|
947
|
+
ts: record.ts,
|
|
948
|
+
fingerprint: p.fingerprint,
|
|
949
|
+
decision: record.event_type === 'waiver-granted' ? 'granted' : 'denied',
|
|
950
|
+
operatorNote: p.operator_note ?? null,
|
|
951
|
+
})
|
|
952
|
+
.run();
|
|
953
|
+
}
|
|
954
|
+
}
|
|
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
|
+
for (const epic of foldEpics(events)) {
|
|
977
|
+
txDb
|
|
978
|
+
.insert(schema.epics)
|
|
979
|
+
.values({
|
|
980
|
+
epicId: epic.epicId,
|
|
981
|
+
sessionId: epic.sessionId,
|
|
982
|
+
epicStatus: epic.epicStatus,
|
|
983
|
+
closedBy: epic.closedBy,
|
|
984
|
+
machineVerdict: epic.machineVerdict,
|
|
985
|
+
machineReason: epic.machineReason,
|
|
986
|
+
overrideRationale: epic.overrideRationale,
|
|
987
|
+
blockers: JSON.stringify(epic.blockers),
|
|
988
|
+
closedAt: epic.closedAt,
|
|
989
|
+
eventId: epic.eventId,
|
|
990
|
+
// An epic-closed names an epic and no task, and epic.ts writes it
|
|
991
|
+
// without a plan in hand -- its own tasks are the only thing that
|
|
992
|
+
// can place it (D-233).
|
|
993
|
+
project: epic.project ?? projectForEpic.get(epic.epicId) ?? null,
|
|
994
|
+
})
|
|
995
|
+
.run();
|
|
996
|
+
}
|
|
997
|
+
for (const agent of foldAgents(events)) {
|
|
998
|
+
txDb
|
|
999
|
+
.insert(schema.agents)
|
|
1000
|
+
.values({
|
|
1001
|
+
id: agent.id,
|
|
1002
|
+
sessionId: agent.sessionId,
|
|
1003
|
+
taskId: agent.taskId,
|
|
1004
|
+
epicId: agent.epicId,
|
|
1005
|
+
agentId: agent.agentId,
|
|
1006
|
+
agentRole: agent.agentRole,
|
|
1007
|
+
round: agent.round,
|
|
1008
|
+
provider: agent.provider,
|
|
1009
|
+
modelTier: agent.modelTier,
|
|
1010
|
+
dispatchedAt: agent.dispatchedAt,
|
|
1011
|
+
terminalEventId: agent.terminalEventId,
|
|
1012
|
+
terminalAt: agent.terminalAt,
|
|
1013
|
+
terminalType: agent.terminalType,
|
|
1014
|
+
status: agent.status,
|
|
1015
|
+
})
|
|
1016
|
+
.run();
|
|
1017
|
+
}
|
|
1018
|
+
// `lessons` is not written here — see projectLessons() (D-199).
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Fully replace the lessons table from EVERY session's log at once, in one
|
|
1023
|
+
* causal order (mergeSessionLogs). Not session-scoped, the same shape as
|
|
1024
|
+
* projectMilestones() above, and for a reason the rest of this file's
|
|
1025
|
+
* per-session partition cannot express.
|
|
1026
|
+
*
|
|
1027
|
+
* D-199. A lesson is raised in one session and approved in another BY DESIGN:
|
|
1028
|
+
* lessons.ts's transitionLesson() reads the lineage on purpose, because
|
|
1029
|
+
* "approving from a continuation the candidate the parent session raised is
|
|
1030
|
+
* the ordinary shape of a long epic". Folding one session at a time handed
|
|
1031
|
+
* foldLessons() a `lesson-status-changed` for a lesson_id it had no row for,
|
|
1032
|
+
* and the fold correctly continued — the truncation happened before it, in
|
|
1033
|
+
* what it was given. The projection kept saying `candidate`, which is what
|
|
1034
|
+
* lessonsPage() buckets on and what `smith lessons compile` writes
|
|
1035
|
+
* factory/policies/lessons.md from, so the approval reached neither the
|
|
1036
|
+
* operator's screen nor an agent's prompt block.
|
|
1037
|
+
*
|
|
1038
|
+
* The lineage is not enough here. Session B's lineage reaches back to A, but
|
|
1039
|
+
* A's never reaches forward to B, so a fix that folds each session's lineage
|
|
1040
|
+
* still loses the approval the moment A is projected last — and
|
|
1041
|
+
* listSessionIds() sorts by filename, which is nothing causal. The table's
|
|
1042
|
+
* primary key is the lesson id, not (session, lesson): one global key, one
|
|
1043
|
+
* global fold.
|
|
1044
|
+
*
|
|
1045
|
+
* The row still carries the RAISING session's id, so `lessonsPage({ sessionId })`
|
|
1046
|
+
* keeps meaning "lessons raised here" and an approval never moves a lesson.
|
|
1047
|
+
*
|
|
1048
|
+
* One deliberate consequence: two sessions raising the same lesson_id used to
|
|
1049
|
+
* abort a rebuild on the table's UNIQUE constraint and now resolve last-raise-
|
|
1050
|
+
* wins, because that is already what foldLessons() does with two raises inside
|
|
1051
|
+
* one session. The rule is now the same on both sides of a session boundary
|
|
1052
|
+
* instead of quiet within one and fatal across.
|
|
1053
|
+
*/
|
|
1054
|
+
function projectLessons(handle, events) {
|
|
1055
|
+
handle.db.transaction((txDb) => {
|
|
1056
|
+
txDb.delete(schema.lessons).run();
|
|
1057
|
+
for (const lesson of foldLessons(events)) {
|
|
1058
|
+
txDb
|
|
1059
|
+
.insert(schema.lessons)
|
|
1060
|
+
.values({
|
|
1061
|
+
lessonId: lesson.lessonId,
|
|
1062
|
+
sessionId: lesson.sessionId,
|
|
1063
|
+
lessonType: lesson.lessonType,
|
|
1064
|
+
lessonLevel: lesson.lessonLevel,
|
|
1065
|
+
lessonStatus: lesson.lessonStatus,
|
|
1066
|
+
lessonScope: lesson.lessonScope,
|
|
1067
|
+
statement: lesson.statement,
|
|
1068
|
+
validFrom: lesson.validFrom,
|
|
1069
|
+
supersededBy: lesson.supersededBy,
|
|
1070
|
+
invalidatedByEventId: lesson.invalidatedByEventId,
|
|
1071
|
+
provenanceEventIds: JSON.stringify(lesson.provenanceEventIds),
|
|
1072
|
+
evidence: lesson.evidence,
|
|
1073
|
+
timesPrevented: lesson.timesPrevented,
|
|
1074
|
+
findingCategory: lesson.findingCategory,
|
|
1075
|
+
claimPath: lesson.claimPath,
|
|
1076
|
+
agentRole: lesson.agentRole,
|
|
1077
|
+
caseType: lesson.caseType,
|
|
1078
|
+
})
|
|
1079
|
+
.run();
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
function findingTimestamps(events) {
|
|
1084
|
+
const stamps = new Map();
|
|
1085
|
+
for (const { event_id, record } of events) {
|
|
1086
|
+
if (record.event_type === 'finding-raised') {
|
|
1087
|
+
const findingId = record.payload.finding_id;
|
|
1088
|
+
if (findingId)
|
|
1089
|
+
stamps.set(findingId, {
|
|
1090
|
+
raisedAt: record.ts,
|
|
1091
|
+
updatedAt: record.ts,
|
|
1092
|
+
raisedByEventId: event_id,
|
|
1093
|
+
sessionId: record.session_id,
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
else if (record.event_type === 'finding-transitioned') {
|
|
1097
|
+
const findingId = record.payload.finding_id;
|
|
1098
|
+
const existing = findingId ? stamps.get(findingId) : undefined;
|
|
1099
|
+
if (findingId && existing)
|
|
1100
|
+
stamps.set(findingId, { ...existing, updatedAt: record.ts });
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return stamps;
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Fully replace the findings table from EVERY session's log at once, in one
|
|
1107
|
+
* causal order (mergeSessionLogs) — the same shape as projectLessons() above,
|
|
1108
|
+
* and for the same reason.
|
|
1109
|
+
*
|
|
1110
|
+
* D-200. A finding is raised in one session and closed in another BY DESIGN:
|
|
1111
|
+
* findings.ts's transition() reads the lineage on purpose, because "a finding
|
|
1112
|
+
* raised in the first session of a cross-session epic is otherwise not FOUND
|
|
1113
|
+
* from the second" (D-119), and it appends the `finding-transitioned` event to
|
|
1114
|
+
* the session it was called in. Folding one session at a time handed
|
|
1115
|
+
* foldFindingsDetailed() a transition for a finding_id it had no row for, and
|
|
1116
|
+
* the fold correctly continued — the truncation happened before it, in what it
|
|
1117
|
+
* was given. The projection kept the pre-transition status, which is what the
|
|
1118
|
+
* Kanban's worst-open-severity chip reads, so a refuted S2 kept flagging a task
|
|
1119
|
+
* nobody needed to look at and the board contradicted `smith findings list`.
|
|
1120
|
+
*
|
|
1121
|
+
* The lineage is not enough here, exactly as in projectLessons(): the
|
|
1122
|
+
* continuation's lineage reaches back to its parent, but the parent's never
|
|
1123
|
+
* reaches forward, so folding each session's lineage still loses the
|
|
1124
|
+
* transition whenever the parent is projected last — and listSessionIds()
|
|
1125
|
+
* sorts by filename, which is nothing causal. The table's primary key is the
|
|
1126
|
+
* finding id, not (session, finding): one global key, one global fold.
|
|
1127
|
+
*
|
|
1128
|
+
* The row still carries the RAISING session's id (the stamp's, no longer the
|
|
1129
|
+
* caller's), so a findings query scoped to a session keeps meaning "findings
|
|
1130
|
+
* raised here" and closing one never moves it.
|
|
1131
|
+
*
|
|
1132
|
+
* One deliberate consequence, again as in projectLessons(): two sessions
|
|
1133
|
+
* raising the same finding_id used to abort a rebuild on the table's primary
|
|
1134
|
+
* key and now resolve last-raise-wins, because that is already what
|
|
1135
|
+
* foldFindingsDetailed() does with two raises inside one session.
|
|
1136
|
+
*
|
|
1137
|
+
* Findings are folded via findings.ts's own fold — reused, not duplicated.
|
|
1138
|
+
*
|
|
1139
|
+
* Returns what it could not store. Three disjoint reasons, reported the same
|
|
1140
|
+
* way (D-141): the fold's own quarantine (D-135 — a payload the fold cannot
|
|
1141
|
+
* even dereference), records the fold returns happily but that cannot fill one
|
|
1142
|
+
* of the table's `notNull()` columns, and (since the fold is now global) a
|
|
1143
|
+
* finding whose raise is not in the projected logs at all, which has no
|
|
1144
|
+
* session to be owned by. The second kind used to abort the entire rebuild on
|
|
1145
|
+
* the first offender.
|
|
1146
|
+
*/
|
|
1147
|
+
function projectFindings(handle, events, opts) {
|
|
1148
|
+
const { findings, skipped } = foldFindingsDetailed([...events]);
|
|
1149
|
+
const stamps = findingTimestamps(events);
|
|
1150
|
+
// A finding has no `project` of its own on the wire (findings.ts's raiseFinding()
|
|
1151
|
+
// predates Phase 6b) — derive it from its owning task's project, the same fold
|
|
1152
|
+
// projectSession() computes, so the value always matches tasks.project exactly.
|
|
1153
|
+
// Same helper as projectSession() for exactly that reason: the plan-file
|
|
1154
|
+
// backfill (D-246) has to reach both folds or the invariant is a lie.
|
|
1155
|
+
const taskRows = foldTasksWithPlanProject(events, opts);
|
|
1156
|
+
// projectResolver and not a Map lookup, for the reason its own docblock
|
|
1157
|
+
// gives: the log spells one task both `epic/task-1` and `task-1`, and a
|
|
1158
|
+
// spelling is not an identity (D-130/D-143). D-233 already routed every
|
|
1159
|
+
// other child row through it and named findings as following the same rule
|
|
1160
|
+
// — findings were the one table that did not (D-247).
|
|
1161
|
+
const projectForTask = projectResolver(taskRows);
|
|
1162
|
+
// And an epic-level fallback, because the merge queue raises findings
|
|
1163
|
+
// against `<epic>/integration`, a pseudo-task that never gets a task row:
|
|
1164
|
+
// no task lookup can ever answer for it, its epic always can, and the row
|
|
1165
|
+
// already stores that epic. On the shipped logs this is 37 of 56 findings.
|
|
1166
|
+
const projectForEpic = new Map(taskRows.flatMap((t) => (t.project === null ? [] : [[t.epicId, t.project]])));
|
|
1167
|
+
const projectFromPlan = planProjectResolver(opts.specsDir);
|
|
1168
|
+
const rows = [];
|
|
1169
|
+
for (const finding of findings) {
|
|
1170
|
+
const stamp = stamps.get(finding.finding_id);
|
|
1171
|
+
const missing = missingProjectionFields(finding);
|
|
1172
|
+
if (missing.length > 0) {
|
|
1173
|
+
// Held back rather than inserted-with-nulls: a row that lies about what
|
|
1174
|
+
// it knows is worse than a row that is missing, and worse than the crash
|
|
1175
|
+
// this replaces only in that it is quiet. So it is named instead.
|
|
1176
|
+
skipped.push({
|
|
1177
|
+
event_id: stamp?.raisedByEventId ?? '?',
|
|
1178
|
+
finding_id: finding.finding_id,
|
|
1179
|
+
reason: `finding cannot be projected into the findings table: missing required string field(s): ${missing.join(', ')}`,
|
|
1180
|
+
});
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
if (stamp === undefined) {
|
|
1184
|
+
// Unreachable through the fold, which only returns findings it saw a
|
|
1185
|
+
// `finding-raised` for — named rather than defaulted because the row's
|
|
1186
|
+
// owning session would otherwise be a guess, and `session_id` is the
|
|
1187
|
+
// column every scoped findings query filters on.
|
|
1188
|
+
skipped.push({
|
|
1189
|
+
event_id: '?',
|
|
1190
|
+
finding_id: finding.finding_id,
|
|
1191
|
+
reason: 'finding cannot be projected into the findings table: no finding-raised event in the projected logs',
|
|
1192
|
+
});
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
// The carried field first; the task id's prefix only as back-compat
|
|
1196
|
+
// for records raised before the field existed (D-49/P9-10).
|
|
1197
|
+
const findingEpicId = finding.epic_id ?? epicOfTaskId(finding.task_id);
|
|
1198
|
+
rows.push({
|
|
1199
|
+
findingId: finding.finding_id,
|
|
1200
|
+
sessionId: stamp.sessionId,
|
|
1201
|
+
taskId: finding.task_id,
|
|
1202
|
+
epicId: findingEpicId,
|
|
1203
|
+
project: projectForTask(finding.task_id) ??
|
|
1204
|
+
projectForEpic.get(findingEpicId) ??
|
|
1205
|
+
projectFromPlan(findingEpicId),
|
|
1206
|
+
fingerprint: finding.fingerprint,
|
|
1207
|
+
findingCategory: finding.finding_category,
|
|
1208
|
+
severity: finding.severity,
|
|
1209
|
+
findingStatus: finding.finding_status,
|
|
1210
|
+
summary: finding.summary,
|
|
1211
|
+
foundBy: finding.found_by,
|
|
1212
|
+
foundByProvider: finding.found_by_provider ?? null,
|
|
1213
|
+
verifiedBy: finding.verified_by ?? null,
|
|
1214
|
+
verifiedByProvider: finding.verified_by_provider ?? null,
|
|
1215
|
+
sameMistakeOfLessonId: finding.same_mistake_of_lesson_id ?? null,
|
|
1216
|
+
waiverId: finding.waiver_id ?? null,
|
|
1217
|
+
raisedAt: stamp.raisedAt,
|
|
1218
|
+
updatedAt: stamp.updatedAt,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
handle.db.transaction((txDb) => {
|
|
1222
|
+
txDb.delete(schema.findings).run();
|
|
1223
|
+
for (const row of rows)
|
|
1224
|
+
txDb.insert(schema.findings).values(row).run();
|
|
1225
|
+
});
|
|
1226
|
+
return skipped;
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Every named session's log, read once (D-199 — projectLessons), oldest
|
|
1230
|
+
* session first.
|
|
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.
|
|
1240
|
+
*/
|
|
1241
|
+
async function readAllSessionLogs(sessionIds, stateDir) {
|
|
1242
|
+
const logs = [];
|
|
1243
|
+
for (const sessionId of sessionIds) {
|
|
1244
|
+
logs.push({ sessionId, events: await readEvents(sessionId, { stateDir }) });
|
|
1245
|
+
}
|
|
1246
|
+
logs.sort((a, b) => {
|
|
1247
|
+
const aStart = a.events[0]?.record.ts ?? '';
|
|
1248
|
+
const bStart = b.events[0]?.record.ts ?? '';
|
|
1249
|
+
if (aStart !== bStart)
|
|
1250
|
+
return aStart < bStart ? -1 : 1;
|
|
1251
|
+
return a.sessionId.localeCompare(b.sessionId);
|
|
1252
|
+
});
|
|
1253
|
+
return logs;
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Full rebuild: drop every table's rows, then replay every session (or the
|
|
1257
|
+
* given subset) from its complete event log. Idempotent by construction —
|
|
1258
|
+
* clearAll() + a deterministic fold of the same log always yields the same
|
|
1259
|
+
* rows.
|
|
1260
|
+
*/
|
|
1261
|
+
export async function rebuild(dbPath = STATE_DB_PATH, sessions = 'all', opts = {}) {
|
|
1262
|
+
const handle = openDb(dbPath, opts);
|
|
1263
|
+
try {
|
|
1264
|
+
clearAll(handle);
|
|
1265
|
+
const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
|
|
1266
|
+
const sessionIds = sessions === 'all' ? listSessionIds(stateDir) : [...sessions];
|
|
1267
|
+
// Read each log once: the per-session projection consumes them one at a
|
|
1268
|
+
// time, projectLessons() and projectFindings() need them all at once.
|
|
1269
|
+
const logs = await readAllSessionLogs(sessionIds, stateDir);
|
|
1270
|
+
let eventsApplied = 0;
|
|
1271
|
+
for (const { sessionId, events } of logs) {
|
|
1272
|
+
projectSession(handle, sessionId, events, opts);
|
|
1273
|
+
eventsApplied += events.length;
|
|
1274
|
+
}
|
|
1275
|
+
const merged = mergeSessionLogs(logs);
|
|
1276
|
+
const skippedFindings = projectFindings(handle, merged, opts);
|
|
1277
|
+
projectLessons(handle, merged);
|
|
1278
|
+
projectMilestones(handle, opts);
|
|
1279
|
+
return { sessionsProcessed: sessionIds.length, eventsApplied, skippedFindings };
|
|
1280
|
+
}
|
|
1281
|
+
finally {
|
|
1282
|
+
handle.sqlite.close();
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Incremental refresh for one session: re-folds ITS current event log and
|
|
1287
|
+
* replaces only its rows, leaving every other session's projection intact.
|
|
1288
|
+
* Safe to call repeatedly while a session is still running (tailing).
|
|
1289
|
+
*
|
|
1290
|
+
* The three tables that are not session-scoped are rewritten whole on every
|
|
1291
|
+
* 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 — `lessons` (D-199) and
|
|
1293
|
+
* `findings` (D-200).
|
|
1294
|
+
*/
|
|
1295
|
+
export async function apply(dbPath = STATE_DB_PATH, sessionId, opts = {}) {
|
|
1296
|
+
const handle = openDb(dbPath, opts);
|
|
1297
|
+
try {
|
|
1298
|
+
const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
|
|
1299
|
+
// A session whose first event has not landed yet has no file to list, and
|
|
1300
|
+
// readEvents answers `[]` for a log that does not exist (P9-28) — so
|
|
1301
|
+
// naming it anyway costs an empty read and keeps it in the global folds
|
|
1302
|
+
// below, which would otherwise drop the very session being applied.
|
|
1303
|
+
const listed = listSessionIds(stateDir);
|
|
1304
|
+
const logs = await readAllSessionLogs(listed.includes(sessionId) ? listed : [...listed, sessionId], stateDir);
|
|
1305
|
+
const events = logs.find((l) => l.sessionId === sessionId)?.events ?? [];
|
|
1306
|
+
projectSession(handle, sessionId, events, opts);
|
|
1307
|
+
const merged = mergeSessionLogs(logs);
|
|
1308
|
+
const skippedFindings = projectFindings(handle, merged, opts);
|
|
1309
|
+
projectLessons(handle, merged);
|
|
1310
|
+
projectMilestones(handle, opts);
|
|
1311
|
+
return { sessionsProcessed: 1, eventsApplied: events.length, skippedFindings };
|
|
1312
|
+
}
|
|
1313
|
+
finally {
|
|
1314
|
+
handle.sqlite.close();
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
export { detectStaleAgents as detectStale };
|