akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
1,319 lines • 68.4 kB
JavaScript
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// The state.db schema migration registry. The MIGRATIONS array is the single
// append-only ordered source of truth: new migrations are APPENDED here and an
// existing fragment is NEVER renumbered or reordered (that would corrupt the
// schema_migrations ledger on already-deployed databases). The shared runner
// at src/storage/sqlite-migrations.ts applies them in array order.
import { assertMigrationRegistry } from "../../storage/sqlite-migrations.js";
/**
* Explicit safety classification for every released state migration ID.
*
* This registry is deliberately separate from the immutable migration bodies:
* released IDs, order, and SQL stay byte-for-byte unchanged. A runtime
* assertion below requires this key order to match {@link STATE_MIGRATIONS}
* exactly, so appending a migration also requires an explicit safety decision.
* The one consumer of the classification is `openStateDatabase`
* (src/core/state-db.ts): before a `historical-destructive` migration runs
* against an existing database, the file is copied beside itself first.
*/
export const STATE_MIGRATION_SAFETY_BY_ID = Object.freeze({
"001-initial-schema": "additive",
"002-task-history-per-run": "data-preserving-rebuild",
"003-improve-runs": "additive",
"004-extract-sessions-seen": "additive",
"005-proposal-fs-imports": "additive",
"006-proposals-pending-ref-source": "additive",
"007-consolidation-judged": "additive",
"008-body-embeddings": "additive",
"009-asset-salience": "additive",
"010-asset-outcome": "additive",
"011-asset-salience-homeostatic-demoted-at": "additive",
"012-improve-gate-thresholds": "additive",
"013-extract-sessions-content-hash": "additive",
"014-recombine-hypotheses": "additive",
"015-asset-salience-encoding-source": "additive",
"016-collapse-churn-detector": "additive",
"017-improve-run-strategy": "additive",
"018-drop-dead-lane-schema": "historical-destructive",
"019-proposal-fingerprints": "additive",
"020-three-db-cutover": "additive",
"021-asset-state-missing-since": "additive",
"022-workflow-unit-attempts": "additive",
"023-child-workflow-runs": "additive",
"024-workflow-run-outputs": "additive",
"025-task-history-vocabulary-backfill": "data-preserving-rebuild",
"026-proposals-strip-legacy-fragment-refs": "data-preserving-rebuild",
"027-extract-sessions-seen-harness-rename": "data-preserving-rebuild",
"028-improve-ledger": "historical-destructive",
"029-improve-ledger-content-hash": "additive",
});
export const STATE_MIGRATIONS = [
// ── Migration 001 — initial schema ──────────────────────────────────────────
{
id: "001-initial-schema",
up: `
-- ── events ──────────────────────────────────────────────────────────────
--
-- Replaces events.jsonl. Indexed (query) columns:
-- id INTEGER PK — monotonic rowid; replaces byte-offset cursor.
-- Callers store this as "sinceId" for resume.
-- event_type TEXT — indexed; replaces the type filter in readEvents().
-- ts TEXT — ISO-8601 UTC ms; indexed for range queries.
-- ref TEXT — nullable asset ref; indexed for ref-scoped queries.
--
-- Extensible (metadata_json) columns:
-- metadata_json TEXT — JSON object storing all non-indexed payload
-- fields (tags, any future structured fields).
-- Maps directly to EventEnvelope.metadata.
--
-- schema_version mirrors EventEnvelope.schemaVersion — always 1 for v1
-- rows. Stored as a column (not in the JSON blob) so future schema
-- changes can be detected and migrated row-by-row if ever needed.
--
-- TTL: rows where ts < NOW() - 90 days can be deleted by a maintenance job.
-- No automatic deletion occurs here — callers call purgeOldEvents().
--
-- ADD COLUMN extension points (future migrations):
-- ALTER TABLE events ADD COLUMN stash_dir TEXT DEFAULT NULL;
-- ALTER TABLE events ADD COLUMN correlation_id TEXT DEFAULT NULL;
-- ALTER TABLE events ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;
--
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
ts TEXT NOT NULL,
ref TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}'
);
-- Query patterns supported by these indexes:
-- SELECT … WHERE event_type = ? → idx_events_type
-- SELECT … WHERE ref = ? → idx_events_ref
-- SELECT … WHERE ts >= ? AND ts <= ? → idx_events_ts
-- SELECT … WHERE event_type = ? AND ref = ? → idx_events_type (prefix scan) + filter
-- SELECT … WHERE id > ? → PK (rowid) — no extra index needed
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
CREATE INDEX IF NOT EXISTS idx_events_ref ON events(ref);
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
-- ── proposals ────────────────────────────────────────────────────────────
--
-- Replaces per-uuid JSON directories under <stashDir>/.akm/proposals/.
--
-- Indexed (query) columns:
-- id TEXT PK — UUID (crypto.randomUUID()); stable directory name.
-- stash_dir TEXT — absolute stash root; multi-stash installs need
-- this to partition proposal lists per stash.
-- ref TEXT — target asset ref (e.g. "lessons/alpha");
-- indexed for ref-scoped queue views.
-- status TEXT — "pending" | "accepted" | "rejected"; indexed
-- so pending-queue queries are fast.
-- source TEXT — human-readable origin tag (e.g. "reflect").
-- created_at TEXT — ISO-8601; used for ORDER BY created_at ASC.
-- updated_at TEXT — ISO-8601; updated on accept/reject.
--
-- Large payload columns (NOT indexed):
-- content TEXT — full markdown text; the proposal payload body.
-- frontmatter_json TEXT — JSON of parsed frontmatter (may be NULL when
-- the content has no frontmatter block).
--
-- Extensible (metadata_json) columns:
-- metadata_json TEXT — JSON object for future proposal fields.
-- Current fields stored here: sourceRun,
-- review, confidence, gateDecision (#577),
-- backupContent.
--
-- ADD COLUMN extension points (future migrations):
-- ALTER TABLE proposals ADD COLUMN source_run TEXT DEFAULT NULL;
-- ALTER TABLE proposals ADD COLUMN review_outcome TEXT DEFAULT NULL;
-- ALTER TABLE proposals ADD COLUMN review_reason TEXT DEFAULT NULL;
-- ALTER TABLE proposals ADD COLUMN review_decided_at TEXT DEFAULT NULL;
-- ALTER TABLE proposals ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
--
CREATE TABLE IF NOT EXISTS proposals (
id TEXT PRIMARY KEY,
stash_dir TEXT NOT NULL,
ref TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
source TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
frontmatter_json TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}'
);
-- Query patterns:
-- SELECT … WHERE stash_dir = ? AND status = ? → idx_proposals_stash_status
-- SELECT … WHERE ref = ? AND status = ? → idx_proposals_ref_status
-- SELECT … WHERE id = ? → PK
CREATE INDEX IF NOT EXISTS idx_proposals_stash_status
ON proposals(stash_dir, status);
CREATE INDEX IF NOT EXISTS idx_proposals_ref_status
ON proposals(ref, status);
-- ── task_history ─────────────────────────────────────────────────────────
--
-- Replaces per-task JSONL files under <cacheDir>/tasks/history/.
--
-- Indexed (query) columns:
-- task_id TEXT PK — stable task identifier string.
-- status TEXT — terminal status (e.g. "completed", "failed",
-- "cancelled"); indexed for status-scoped queries.
-- started_at TEXT — ISO-8601; indexed for time-range queries.
-- target_kind TEXT — kind of the target entity (e.g. "issue",
-- "workflow", "agent"); indexed for kind-scoped queries.
-- target_ref TEXT — stable ref of the target entity; indexed for
-- per-target history lookups.
--
-- Non-indexed time columns:
-- completed_at TEXT — ISO-8601 or NULL if still running.
-- failed_at TEXT — ISO-8601 or NULL.
--
-- Non-indexed diagnostic columns:
-- log_path TEXT — absolute path to the task log file, if any.
--
-- Extensible (metadata_json) columns:
-- metadata_json TEXT — JSON object for future task fields (exit_code,
-- runner, priority, parent_task_id, …).
--
-- ADD COLUMN extension points (future migrations):
-- ALTER TABLE task_history ADD COLUMN exit_code INTEGER DEFAULT NULL;
-- ALTER TABLE task_history ADD COLUMN runner TEXT DEFAULT NULL;
-- ALTER TABLE task_history ADD COLUMN parent_task_id TEXT DEFAULT NULL;
-- ALTER TABLE task_history ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;
--
CREATE TABLE IF NOT EXISTS task_history (
task_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
started_at TEXT NOT NULL,
completed_at TEXT,
failed_at TEXT,
log_path TEXT,
target_kind TEXT,
target_ref TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}'
);
-- Query patterns:
-- SELECT … WHERE task_id = ? → PK
-- SELECT … WHERE started_at >= ? AND started_at <= ? → idx_task_history_started
-- SELECT … WHERE target_kind = ? AND target_ref = ? → idx_task_history_target
-- SELECT … WHERE status = ? → idx_task_history_status
CREATE INDEX IF NOT EXISTS idx_task_history_started
ON task_history(started_at);
CREATE INDEX IF NOT EXISTS idx_task_history_target
ON task_history(target_kind, target_ref);
CREATE INDEX IF NOT EXISTS idx_task_history_status
ON task_history(status);
`,
},
// Migration 002 — fix task_history to be a true per-run log.
//
// Migration 001 used task_id as PRIMARY KEY, meaning each task had exactly
// one row and every new run overwrote the previous one. This silently
// discarded all historical runs — the opposite of a history table.
//
// This migration recreates the table with an AUTOINCREMENT id so each run
// appends a new row. The old single-row table is renamed to _old, the new
// table is created, data is copied, and the old table is dropped.
{
id: "002-task-history-per-run",
up: `
ALTER TABLE task_history RENAME TO task_history_v1;
CREATE TABLE task_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
status TEXT NOT NULL,
started_at TEXT NOT NULL,
completed_at TEXT,
failed_at TEXT,
log_path TEXT,
target_kind TEXT,
target_ref TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}'
);
INSERT INTO task_history
(task_id, status, started_at, completed_at, failed_at,
log_path, target_kind, target_ref, metadata_json)
SELECT task_id, status, started_at, completed_at, failed_at,
log_path, target_kind, target_ref, metadata_json
FROM task_history_v1;
DROP TABLE task_history_v1;
-- Unique constraint: same task cannot have two runs with the same start time.
CREATE UNIQUE INDEX IF NOT EXISTS idx_task_history_run
ON task_history(task_id, started_at);
CREATE INDEX IF NOT EXISTS idx_task_history_task_id
ON task_history(task_id);
CREATE INDEX IF NOT EXISTS idx_task_history_started
ON task_history(started_at);
CREATE INDEX IF NOT EXISTS idx_task_history_target
ON task_history(target_kind, target_ref);
CREATE INDEX IF NOT EXISTS idx_task_history_status
ON task_history(status);
`,
},
// ── Migration 003 — improve_runs ────────────────────────────────────────────
//
// Records every `akm improve` invocation as a durable row, replacing the
// legacy `<stash>/.akm/runs/<runId>/improve-result.json` artifact files.
//
// The `dry_run` column is FIRST-CLASS and indexed so productivity audits can
// cleanly filter dry-run probes out of real-run analyses without parsing
// `result_json`. The dry-run/real-run artifact-trap (recorded in
// feedback_akm_dryrun_artifact_trap) was the specific motivating bug.
//
// Indexed (query) columns:
// id TEXT PK — runId (`buildImproveRunId()` output).
// started_at TEXT — ISO-8601; indexed for time-range queries.
// stash_dir TEXT — absolute stash root; multi-stash scoping.
// dry_run INTEGER — 0/1; indexed for productivity audits.
// scope_mode TEXT — "all" | "type" | "ref"; indexed via composite
// with stash_dir for stash-scoped scope queries.
//
// Non-indexed payload:
// completed_at TEXT — ISO-8601 or NULL if interrupted.
// profile TEXT — improve profile name (nullable).
// scope_value TEXT — type name or asset ref (nullable).
// guidance TEXT — user-provided guidance text, if any.
// ok INTEGER — 0/1; whether the run produced ok=true.
// result_json TEXT — full AkmImproveResult JSON.
// metrics_json TEXT — aggregate counts extracted from result, cheap
// to query without parsing result_json.
//
// Extensible (metadata_json) columns:
// metadata_json TEXT — JSON object for future improve-run fields.
//
// ADD COLUMN extension points (future migrations):
// ALTER TABLE improve_runs ADD COLUMN duration_ms INTEGER DEFAULT NULL;
// ALTER TABLE improve_runs ADD COLUMN host TEXT DEFAULT NULL;
//
// TTL: rows where started_at < NOW() - 90 days can be deleted by
// `purgeOldImproveRuns()`. No automatic deletion occurs here.
{
id: "003-improve-runs",
up: `
CREATE TABLE IF NOT EXISTS improve_runs (
id TEXT PRIMARY KEY,
started_at TEXT NOT NULL,
completed_at TEXT,
stash_dir TEXT NOT NULL,
dry_run INTEGER NOT NULL DEFAULT 0,
profile TEXT,
scope_mode TEXT NOT NULL,
scope_value TEXT,
guidance TEXT,
ok INTEGER NOT NULL,
result_json TEXT NOT NULL,
metrics_json TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}'
);
-- Query patterns supported:
-- SELECT … WHERE started_at >= ? AND started_at <= ?
-- → idx_improve_runs_started
-- SELECT … WHERE dry_run = 0
-- → idx_improve_runs_dry_run (productivity audits filter trap)
-- SELECT … WHERE stash_dir = ? AND scope_mode = ?
-- → idx_improve_runs_stash_scope
CREATE INDEX IF NOT EXISTS idx_improve_runs_started
ON improve_runs(started_at);
CREATE INDEX IF NOT EXISTS idx_improve_runs_dry_run
ON improve_runs(dry_run);
CREATE INDEX IF NOT EXISTS idx_improve_runs_stash_scope
ON improve_runs(stash_dir, scope_mode);
`,
},
// ── Migration 004 — extract_sessions_seen ───────────────────────────────────
//
// Tracks which platform sessions the extractor has processed, so the discovery
// pass in `akm extract --since <window>` skips sessions whose content hasn't
// changed since the last successful run. Replaces the akm-plugin
// session-checkpoint hook's implicit "write-once" memory of what's been
// captured — but persistent and queryable.
//
// Indexed (query) columns:
// harness TEXT — harness name (claude, opencode, ...).
// session_id TEXT — platform-native session identifier.
// processed_at TEXT — ISO-8601 UTC; when extract last ran on this session.
// session_ended_at TEXT — session.endedAt at processing time. When a
// later listSessions reports a *newer* endedAt
// for the same session_id, the extractor
// re-processes the appended events.
// outcome TEXT — "candidates_queued" | "no_candidates" |
// "skipped" | "failed".
//
// Non-indexed columns:
// candidate_count INTEGER — number of candidates the LLM produced.
// proposal_count INTEGER — number of proposals actually queued
// (candidates may fail downstream validation).
// rationale TEXT — for "no_candidates", the LLM's explanation.
// source_run TEXT — sourceRun id for PROV-DM traceability.
// metadata_json TEXT — future-proofing (pre-filter stats, LLM
// model+version, prompt token count, etc.).
//
// PK: (harness, session_id) — one row per session per harness. A re-extract
// updates the row in place via INSERT OR REPLACE.
//
// TTL: no automatic deletion. Sessions stay tracked as long as the source
// session files exist on disk. Operator can `DELETE FROM extract_sessions_seen
// WHERE processed_at < ?` for cleanup if desired.
{
id: "004-extract-sessions-seen",
up: `
CREATE TABLE IF NOT EXISTS extract_sessions_seen (
harness TEXT NOT NULL,
session_id TEXT NOT NULL,
processed_at TEXT NOT NULL,
session_ended_at TEXT,
outcome TEXT NOT NULL,
candidate_count INTEGER NOT NULL DEFAULT 0,
proposal_count INTEGER NOT NULL DEFAULT 0,
rationale TEXT,
source_run TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (harness, session_id)
);
-- Query patterns:
-- SELECT … WHERE harness = ? → idx_extract_sessions_harness
-- SELECT … WHERE processed_at >= ? → idx_extract_sessions_processed
-- SELECT … WHERE harness = ? AND session_id = ? → PK
CREATE INDEX IF NOT EXISTS idx_extract_sessions_harness
ON extract_sessions_seen(harness);
CREATE INDEX IF NOT EXISTS idx_extract_sessions_processed
ON extract_sessions_seen(processed_at);
`,
},
// ── Migration 005 — proposal_fs_imports ─────────────────────────────────────
//
// Released migration history is immutable. The filesystem proposal importer
// has been deleted from the current runtime, but existing databases can
// already contain this ledger id and table. Keeping the inert DDL here lets
// their migration prefix validate and lets fresh databases reproduce the
// same ordered schema history without restoring the importer.
{
id: "005-proposal-fs-imports",
up: `
CREATE TABLE IF NOT EXISTS proposal_fs_imports (
stash_dir TEXT PRIMARY KEY,
imported_at TEXT NOT NULL,
imported_count INTEGER NOT NULL DEFAULT 0
);
`,
},
// ── Migration 006 — pending proposal lookup index ──────────────────────────
//
// Supports the transaction-scoped dedup / queue-mutation hardening added in
// 0.9.x. The queue now acquires an IMMEDIATE write transaction before it
// reads pending proposals, so the hot path is a stash-scoped `status='pending'
// AND ref=?` probe followed by an update/insert. This composite index keeps
// that lookup index-covered under contention.
{
id: "006-proposals-pending-ref-source",
up: `
CREATE INDEX IF NOT EXISTS idx_proposals_stash_status_ref_source
ON proposals(stash_dir, status, ref, source);
`,
},
// ── Migration 007 — consolidation_judged ────────────────────────────────────
//
// Judged-state cache for nightly consolidation (#581). Lets one consolidation
// run cover the FULL memory corpus cheaply by SKIPPING memories already judged
// with unchanged content, instead of narrowing to a recent time-window slice
// (which leaves a near-duplicate backlog the corpus can never clear).
//
// The consolidate LLM judging loop UPSERTs a row for every memory it saw in a
// successfully-judged chunk; the next run hashes each candidate's current
// content and skips it when the hash equals the cached `content_hash`
// (judged-unchanged → no re-judge). A memory whose content changed produces a
// new hash and is re-judged. This converts coverage from O(window) to
// O(changed/new). DEFAULT OFF — gated behind
// `processes.consolidate.judgedCache.enabled`; when the feature is off this
// table is never read or written and behaviour is byte-identical to today.
//
// Indexed (query) columns:
// entry_key TEXT PK — `memory:<name>` ref; one row per judged memory.
//
// Non-indexed columns:
// content_hash TEXT — sha256 of the frontmatter-stripped, trimmed body.
// judged_at TEXT — ISO-8601 UTC; when the memory was last judged.
// outcome TEXT — coarse outcome of the last judge ("actioned" |
// "no_action"); observability only, never gates.
//
// TTL: no automatic deletion. Rows for memories deleted on disk become
// harmless dead entries (their entry_key never recurs); operators can prune
// with `DELETE FROM consolidation_judged WHERE judged_at < ?` if desired.
{
id: "007-consolidation-judged",
up: `
CREATE TABLE IF NOT EXISTS consolidation_judged (
entry_key TEXT PRIMARY KEY,
content_hash TEXT NOT NULL,
judged_at TEXT NOT NULL,
outcome TEXT NOT NULL
);
`,
},
// ── Migration 008 — body_embeddings ─────────────────────────────────────────
//
// cacheHash-keyed body-embedding cache (WS-3a). Stores the embedding of the
// case-preserving stripped body so the dedup pre-pass and the consolidation
// clustering step share one computed vector per unique body, eliminating
// redundant embedding calls across runs.
//
// Design:
// - PK is the `cacheHash` (sha256 of the stripped, case-preserving body).
// - `embedding` is a raw BLOB storing a Float32 array (384 floats × 4 B =
// 1 536 B per entry for the default bge-small-en-v1.5 model; ~20 MB at
// 13 k memories). This matches the native wire format and avoids JSON
// round-trip overhead.
// - `model_id` is MANDATORY. On mismatch (model changed) the entire table
// is dropped and rebuilt — stale vectors from the wrong metric space would
// produce silent cosine errors.
// - `created_at` is an INTEGER Unix ms timestamp for lazy orphan purges.
//
// Writes: one bulk `WHERE content_hash IN (…)` lookup → embed only misses →
// upsert all results in one transaction per run.
//
// TTL: no automatic row deletion. Orphaned rows for bodies no longer in the
// stash stay until an operator prunes them. The table is ~1.5 KB per row
// (~20 MB at 13 k memories — acceptable).
{
id: "008-body-embeddings",
up: `
CREATE TABLE IF NOT EXISTS body_embeddings (
content_hash TEXT PRIMARY KEY,
embedding BLOB NOT NULL,
model_id TEXT NOT NULL,
created_at INTEGER NOT NULL
);
`,
},
// ── Migration 009 — asset_salience (WS-1 salience vector) ───────────────────
//
// Per-asset salience vector persisted in state.db (canonical store).
//
// Three independently-stored, independently-decayable sub-scores:
// encoding_salience — intrinsic importance (Gap 1; v1 = type-weight stub).
// outcome_salience — differential usefulness (WS-2; 0 until that lands).
// retrieval_salience — frequency × recency (the decayable term).
//
// Plus the scalar projection for ranking:
// rank_score = (w_e·encoding + w_o·outcome + w_r·retrieval) × sizePenalty,
// normalized [0,1]. Every selector reads rank_score; individual sub-scores
// are available for telemetry and per-dimension thresholding.
//
// Plasticity column:
// consecutive_no_ops INTEGER — number of consecutive improve cycles where
// this asset produced a no-op (reflect/distill produced no change).
// Dampens CONSOLIDATION-SELECTION only — intentionally NOT applied to
// rank_score (stable assets stay retrievable but skip LLM merge passes).
//
// updated_at is an INTEGER Unix-ms timestamp for recency queries.
//
// The canonical store is state.db, not frontmatter. An optional frontmatter
// mirror of the stable encodingSalience is allowed for portability (#608).
//
// TTL: rows are overwritten on every run; orphaned rows for deleted assets
// accumulate harmlessly until an operator prunes them.
{
id: "009-asset-salience",
up: `
CREATE TABLE IF NOT EXISTS asset_salience (
asset_ref TEXT PRIMARY KEY,
encoding_salience REAL NOT NULL DEFAULT 0.5,
outcome_salience REAL NOT NULL DEFAULT 0.0,
retrieval_salience REAL NOT NULL DEFAULT 0.0,
rank_score REAL NOT NULL DEFAULT 0.0,
consecutive_no_ops INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
);
-- Hot path: sort / filter by rank_score for selector queries.
CREATE INDEX IF NOT EXISTS idx_asset_salience_rank
ON asset_salience(rank_score DESC);
`,
},
// ── Migration 010 — asset_outcome (WS-2 outcome loop) ───────────────────────
//
// Per-asset outcome loop persisted in state.db (S2 seam, WS-2).
//
// Stores the differential "was this retrieval useful" signal so the salience
// vector's `outcomeSalience` sub-score (WS-1 `W_OUTCOME` term) is non-zero.
//
// Columns:
// asset_ref TEXT PK — `type:name` asset ref (FK to asset_salience).
// last_retrieved_at INTEGER — Unix-ms of the most recent retrieval.
// retrieval_count INTEGER — total retrieval count from the index DB.
// expected_retrieval_rate REAL — EMA-smoothed expected count per cycle.
// negative_feedback_count INTEGER — cumulative negative-feedback events.
// accepted_change_count INTEGER — cumulative accepted proposals.
// review_pressure INTEGER — #613 pressure counter: repeated low-satisfaction
// retrievals increment it; feeds outcomeSalience.
// outcome_score REAL — differential outcome signal (can be negative).
// updated_at INTEGER — Unix-ms timestamp of last update.
//
// Design:
// - outcome_score is differential (prediction-error shaped), NOT a raw count,
// so it rewards assets that are retrieved MORE than their rolling mean AND
// accepted for change when retrieved. See outcome-loop.ts for the formula.
// - review_pressure (#613): repeated negative-feedback retrievals raise it,
// non-negative cycles decay it. Never mutates asset content directly.
// - warm_start: seeded from utility EMA at row creation, clipped to [0, 0.3]
// so the first negative delta does not cause a spurious rank inversion.
// - Orphaned rows (deleted assets) accumulate harmlessly; operators can prune
// with `DELETE FROM asset_outcome WHERE updated_at < ?` if desired.
{
id: "010-asset-outcome",
up: `
CREATE TABLE IF NOT EXISTS asset_outcome (
asset_ref TEXT PRIMARY KEY,
last_retrieved_at INTEGER NOT NULL DEFAULT 0,
retrieval_count INTEGER NOT NULL DEFAULT 0,
expected_retrieval_rate REAL NOT NULL DEFAULT 0.0,
negative_feedback_count INTEGER NOT NULL DEFAULT 0,
accepted_change_count INTEGER NOT NULL DEFAULT 0,
review_pressure INTEGER NOT NULL DEFAULT 0,
outcome_score REAL NOT NULL DEFAULT 0.0,
updated_at INTEGER NOT NULL DEFAULT 0
);
-- Hot path: sort assets by review_pressure DESC for #613 admission.
CREATE INDEX IF NOT EXISTS idx_asset_outcome_review_pressure
ON asset_outcome(review_pressure DESC);
-- Secondary: sort by outcome_score DESC for outcomeSalience reads.
CREATE INDEX IF NOT EXISTS idx_asset_outcome_score
ON asset_outcome(outcome_score DESC);
`,
},
// ── Migration 011 — asset_salience: homeostatic_demoted_at column ─────────────
//
// WS-3b step 0a (homeostatic demotion). Records the last time `retrievalSalience`
// was demoted for this asset so:
// (a) Each run can identify assets that have been demoted but not yet
// re-retrieved (they stay in the demoted state until a retrieval
// re-promotes them via `upsertAssetSalience`).
// (b) The homeostatic pass can log "N assets demoted this run".
//
// NULL = never demoted (or was re-promoted after last demotion, since a fresh
// `upsertAssetSalience` call clears the flag by updating retrieval_salience
// from live data rather than the demoted value — the column is informational,
// not the canonical source of the salience value).
{
id: "011-asset-salience-homeostatic-demoted-at",
up: `
ALTER TABLE asset_salience ADD COLUMN homeostatic_demoted_at INTEGER DEFAULT NULL;
`,
},
// ── Migration 012 — improve_gate_thresholds (WS-4 per-phase threshold store) ─
//
// Persists the auto-tuned accept-gate threshold PER PHASE so that each phase
// (reflect, distill, extract, consolidate) maintains its own calibrated
// threshold rather than sharing a single global `options.autoAccept`.
//
// Schema:
// phase TEXT PK — phase label, e.g. "reflect", "distill", "extract",
// "consolidate".
// threshold INTEGER — tuned threshold (0-100), matches the integer
// scale used everywhere else in the gate pipeline.
// updated_at INTEGER — Unix milliseconds of the last update.
//
// `makeGateConfig` reads the stored threshold for its phase (falling back to
// the caller-supplied `globalThreshold` when no row exists yet). WS-4's
// `persistPhaseThreshold` writes it after each auto-tune step.
{
id: "012-improve-gate-thresholds",
up: `
CREATE TABLE IF NOT EXISTS improve_gate_thresholds (
phase TEXT NOT NULL PRIMARY KEY,
threshold INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`,
},
// ── Migration 013 — extract_sessions_seen: content_hash column (#602) ────────
//
// Replaces the brittle timestamp-based incrementality (`session_ended_at`,
// compared against the live session metadata) with a content hash. The old
// clock/timestamp logic caused the Jun 11-12 double-extract + over-throttle
// incident (clock skew / out-of-order endedAt both double-processed AND
// over-suppressed sessions). The hash makes the skip decision byte-exact and
// clock-independent.
//
// Additive ADD COLUMN (migration-safe; mirrors migration 011's style). All
// pre-existing rows read back `content_hash = NULL`, which the skip logic
// treats as "seen before content-hash tracking existed → process once to
// backfill", after which the row gets a real hash and becomes hash-stable.
// Never mutate migration 004 (the original table) — this column is appended.
{
id: "013-extract-sessions-content-hash",
up: `
ALTER TABLE extract_sessions_seen ADD COLUMN content_hash TEXT DEFAULT NULL;
`,
},
// ── Migration 014 — recombine_hypotheses (#625 confirmation count) ───────────
//
// Second-pass promotion ledger for the recombine pass. The first pass (#609)
// only ever emits `type: hypothesis` proposals; this table tracks how many
// CONSECUTIVE runs re-induced the SAME generalization (keyed by the
// deterministic `deriveRecombineLessonRef` value — a hash of the sorted
// member entryKeys). Once `consecutive_count >= confirmThreshold`, the run
// promotes the generalization to a `type: lesson` proposal (through the same
// proposal queue + quality gate). A hypothesis NOT re-induced in a run has its
// consecutive streak reset (decay-to-zero), so confirmation is per exact
// member-set and conservative.
//
// Indexed columns:
// hypothesis_ref TEXT PK — the `lesson:recombined/<slug>-<hash>` ref; one
// row per re-inducible generalization. The ref is
// the promotion TARGET (a lesson in both the
// hypothesis and promoted states), so the ref never
// encodes the proposal type.
// last_seen_at TEXT (idx) — for forensic / pruning queries.
//
// Non-indexed columns:
// signature TEXT — the cluster's shared relatedness signal (tag /
// entity) at induction time; forensics only.
// member_key TEXT — sorted member entryKeys joined; the membership
// fingerprint behind the ref hash. Stored so a
// membership change (which yields a DIFFERENT ref)
// is auditable.
// consecutive_count INTEGER — current confirmation streak (reset on decay
// and on promotion).
// first_seen_at TEXT — ISO-8601 UTC of the first induction.
// last_run TEXT — sourceRun token of the last induction; the
// same-run idempotency guard.
// promoted_at TEXT — non-null once promoted; guards against
// double-promoting on every subsequent run.
// metadata_json TEXT — reserved for future forensics; defaults to '{}'.
{
id: "014-recombine-hypotheses",
up: `
CREATE TABLE IF NOT EXISTS recombine_hypotheses (
hypothesis_ref TEXT PRIMARY KEY,
signature TEXT NOT NULL,
member_key TEXT NOT NULL,
consecutive_count INTEGER NOT NULL DEFAULT 0,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
last_run TEXT,
promoted_at TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_recombine_hypotheses_last_seen
ON recombine_hypotheses(last_seen_at);
`,
},
// ── Migration 015 — asset_salience: encoding_source provenance column (#644) ──
//
// Records HOW the stored `encoding_salience` was derived so an improve run's
// type-weight fallback can no longer clobber a real content-derived score:
//
// "content" — written by the distill path from `scoreEncodingSalience`
// (novelty·0.40 + magnitude·0.35 + predictionError·0.25).
// "type-stub" — written by `computeSalience`'s `DEFAULT_TYPE_ENCODING_WEIGHTS`
// fallback (no content-based score available for this ref yet).
// NULL — legacy row written before this migration; provenance unknown.
// Treated as a stub by readers UNLESS its stored value differs
// from the pure type-weight (best-effort heuristic for old data).
//
// Why a column rather than inference: before #644, every improve run overwrote
// the distill-written content score with the type-weight stub, so the stored
// value alone cannot distinguish a real score from a stub. The provenance flag
// is the single source of truth going forward; `upsertAssetSalience` refuses to
// lower a "content" row to a "type-stub" so the high-salience gate (#608) keys
// on genuine novelty/magnitude/prediction-error, not the asset type.
{
id: "015-asset-salience-encoding-source",
up: `
ALTER TABLE asset_salience ADD COLUMN encoding_source TEXT DEFAULT NULL;
`,
},
// ── Migration 016 — collapse/churn detector (R5) ─────────────────────────────
//
// Longitudinal store-health history for the improve pipeline
// (docs/architecture/specs/improve-collapse-churn-detector-design.md).
//
// canary_queries — the fixed canary set, minted deterministically from the
// live stash on first detector run and NEVER auto-refreshed (silent
// re-baselining is how a slow collapse hides). `canary_set_id` groups one
// mint; deactivated sets keep their rows (active = 0) so historical cycle
// rows stay interpretable. Tens of rows; never purged.
//
// improve_cycle_metrics — one row per qualifying improve cycle (a run where
// consolidate processed ≥1 op). Every
// column is a scalar or a size-capped JSON blob (< 2 KB/row by
// construction — the result_json lesson applied). Retention: 365 days via
// purgeOldCycleMetrics. Trend queries drive the collapse/churn alert
// evaluation and the health advisory; `canary_set_id` scoping prevents
// comparing across canary re-mints.
{
id: "016-collapse-churn-detector",
up: `
CREATE TABLE IF NOT EXISTS canary_queries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
canary_set_id TEXT NOT NULL,
anchor_ref TEXT NOT NULL,
query TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'auto',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_canary_queries_active
ON canary_queries(active, canary_set_id);
CREATE TABLE IF NOT EXISTS improve_cycle_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
ts TEXT NOT NULL,
pass TEXT NOT NULL,
canary_set_id TEXT NOT NULL,
mean_recall REAL NOT NULL,
mean_ndcg REAL NOT NULL,
mean_mrr REAL NOT NULL,
canary_ranks_json TEXT NOT NULL,
store_total INTEGER NOT NULL,
store_by_type_json TEXT NOT NULL,
distinct_content_ratio REAL NOT NULL,
mean_bigram_diversity REAL NOT NULL,
over_generation_count INTEGER NOT NULL,
accepted_actions INTEGER NOT NULL,
merge_floor_violations INTEGER NOT NULL DEFAULT 0,
alerts_json TEXT NOT NULL DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS idx_improve_cycle_metrics_ts
ON improve_cycle_metrics(ts);
`,
},
// Keep the historical profile column untouched. New 0.9 runs identify the
// selected improve strategy in this additive column.
{
id: "017-improve-run-strategy",
up: `
ALTER TABLE improve_runs ADD COLUMN strategy TEXT;
CREATE INDEX IF NOT EXISTS idx_improve_runs_strategy_started ON improve_runs(strategy, started_at);
`,
},
// ── Migration 018 — drop dead-lane schema (Chunk 7, WI-7.3) ──────────────────
//
// Drops the on-disk schema for three lanes deleted by the 0.9.0 bundle-adapter
// refactor. Append-only: migrations 007/010/014 above are left verbatim; this
// migration only removes what they created.
//
// - recombine_hypotheses (+ its last-seen index) — the whole-corpus
// cross-episodic synthesis pass (migration 014) was deleted in Chunk 7
// WI-7.1 (R28).
// - consolidation_judged — the judged-state cache (#581, migration 007) was
// deleted in Chunk 7 WI-7.3 (plan §5). This is a LIVE behavior change:
// every consolidate run now re-judges unchanged memories (more LLM calls)
// instead of skipping them — see the chunk-7 ledger.
// - asset_outcome.review_pressure (+ its DESC index) — the review-pressure
// lane (#613, migration 010) was deleted in Chunk 7 WI-7.2 (R21); this
// migration finishes the column/index drop the code-side deletion left
// pending. `retrieval_count`/`outcome_score` and their index are untouched.
{
id: "018-drop-dead-lane-schema",
up: `
DROP INDEX IF EXISTS idx_recombine_hypotheses_last_seen;
DROP TABLE IF EXISTS recombine_hypotheses;
DROP TABLE IF EXISTS consolidation_judged;
DROP INDEX IF EXISTS idx_asset_outcome_review_pressure;
ALTER TABLE asset_outcome DROP COLUMN review_pressure;
`,
},
// ── Migration 019 — proposal input fingerprints (Chunk 6, WI-6.4) ────────────
//
// Durable store for the §23.6 input fingerprints that replace the
// dedup/cooldown content-hash machinery (plan §4.5): one row per processed
// fingerprint (scheme version + source + target ref + target before-hash +
// reserved evidence/guidance/evaluator terms + engine/model-id term). A
// matching fingerprint skips re-processing the same inputs unless
// explicitly forced; rows are pruned with the proposal retention window.
// Rejection backoff (the retained cooldown) keeps reading the proposals
// table itself and needs no schema.
{
id: "019-proposal-fingerprints",
up: `
CREATE TABLE IF NOT EXISTS proposal_fingerprints (
stash_dir TEXT NOT NULL,
fingerprint TEXT NOT NULL,
ref TEXT NOT NULL,
source TEXT NOT NULL,
model_id TEXT NOT NULL DEFAULT '',
proposal_id TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (stash_dir, fingerprint)
);
CREATE INDEX IF NOT EXISTS idx_proposal_fingerprints_ref
ON proposal_fingerprints(stash_dir, ref);
`,
},
// Migration 020 installs the durable workflow, usage, and quarantine tables
// in state.db. It is additive/idempotent DDL only: current managed databases
// advance through the ledger automatically, while unknown/divergent ledgers
// fail closed. There is no external data-movement coordinator.
{
id: "020-three-db-cutover",
up: `
-- ── workflow_runs (workflows/db.ts base + migrations 001/002/003/006/010) ──
CREATE TABLE IF NOT EXISTS workflow_runs (
id TEXT PRIMARY KEY,
workflow_ref TEXT NOT NULL,
workflow_entry_id INTEGER,
workflow_title TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'completed', 'blocked', 'failed')),
params_json TEXT NOT NULL DEFAULT '{}',
current_step_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
scope_key TEXT,
agent_harness TEXT,
agent_session_id TEXT,
checkin_armed_at TEXT,
plan_json TEXT,
plan_hash TEXT,
engine_lease_until TEXT,
engine_lease_holder TEXT,
plan_ir_version INTEGER
);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_ref ON workflow_runs(workflow_ref);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_scope_ref_status
ON workflow_runs(scope_key, workflow_ref, status);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_agent_session
ON workflow_runs(agent_harness, agent_session_id);
-- ── workflow_run_steps (base + migration 003 summary) ─────────────────────
CREATE TABLE IF NOT EXISTS workflow_run_steps (
run_id TEXT NOT NULL,
step_id TEXT NOT NULL,
step_title TEXT NOT NULL,
instructions TEXT NOT NULL,
completion_json TEXT,
sequence_index INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'blocked', 'failed', 'skipped')),
notes TEXT,
evidence_json TEXT,
completed_at TEXT,
summary TEXT,
PRIMARY KEY (run_id, step_id),
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_run_steps_run_sequence
ON workflow_run_steps(run_id, sequence_index);
-- ── workflow_run_units (migration 004 + 005/007/008/009/010 columns) ──────
CREATE TABLE IF NOT EXISTS workflow_run_units (
run_id TEXT NOT NULL,
unit_id TEXT NOT NULL,
step_id TEXT,
node_id TEXT NOT NULL,
parent_unit_id TEXT,
phase TEXT,
runner TEXT,
model TEXT,
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped')),
input_hash TEXT,
result_json TEXT,
tokens INTEGER,
failure_reason TEXT,
worktree_path TEXT,
started_at TEXT,
finished_at TEXT,
session_id TEXT,
last_checkin_at TEXT,
attempts INTEGER NOT NULL DEFAULT 1,
claim_holder TEXT,
claim_expires_at TEXT,
engine TEXT,
PRIMARY KEY (run_id, unit_id),
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_run_units_run_step
ON workflow_run_units(run_id, step_id);
-- ── usage_events (index.db ensureUsageEventsSchema — the durable new home) ─
CREATE TABLE IF NOT EXISTS usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
query TEXT,
entry_id INTEGER,
entry_ref TEXT,
signal TEXT,
metadata TEXT,
source TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_usage_events_entry ON usage_events(entry_id);
CREATE INDEX IF NOT EXISTS idx_usage_events_type ON usage_events(event_type);
CREATE INDEX IF NOT EXISTS idx_usage_events_ref ON usage_events(entry_ref);
CREATE INDEX IF NOT EXISTS idx_usage_events_source ON usage_events(source);
-- ── legacy_state (ensureLegacyStateTable — the orphan quarantine archive) ──
CREATE TABLE IF NOT EXISTS legacy_state (
surface TEXT NOT NULL,
old_ref TEXT NOT NULL,
row_count INTEGER NOT NULL DEFAULT 0,
reason TEXT NOT NULL,
quarantined_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (surface, old_ref)
);
`,
},
// ── Migration 021 — asset_salience / asset_outcome: missing_since (#733) ────
//
// Orphan-GC grace clock (Workstream C / #733,
// docs/architecture/specs/0.9.0-close-out-plan.md). Neither table carries a
// column that records when a row's ref STOPPED resolving against
// `entries.item_ref` — `updated_at` is bumped by improve itself on every
// touch, so it cannot double as a "went missing" timestamp.
//
// `missing_since` is stamped by `runOrphanStateGcPass`
// (src/commands/improve/loop-stages.ts) the first time a row's ref fails to
// resolve against the live index, cleared the moment the ref resolves
// again, and used to gate deletion once `STATE_GC_GRACE_MS` (7 days) has
// elapsed — and ONLY when `improve.stateGc.collect` is true (default
// false). Mirrors the additive ADD-COLUMN style of migration 011.
//
// NULL = never missing (the steady-state default for every existing row —
// both a fresh row and a pre-migration row read back NULL here).
{
id: "021-asset-state-missing-since",
up: `
ALTER TABLE asset_salience ADD COLUMN missing_since INTEGER DEFAULT NULL;
ALTER TABLE asset_outcome ADD COLUMN missing_since INTEGER DEFAULT NULL;
`,
},
// ── Migration 022 — append-only durable workflow dispatch attempts ────────
//
// `workflow_run_units` is the current status/reuse projection. V4 writes
// every external dispatch reservation and terminal result here so
// retry/reclaim history, stable dispatch identity, and known usage cannot be
// erased by an upsert of that projection row.
{
id: "022-workflow-unit-attempts",
up: `
CREATE TABLE workflow_run_unit_attempts (
run_id TEXT NOT NULL,
unit_id TEXT NOT NULL,
attempt INTEGER NOT NULL CHECK (attempt >= 1),
dispatch_id TEXT NOT NULL,
step_id TEXT NOT NULL,
node_id TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('unit', 'gate')),
runner TEXT,
engine TEXT,
model TEXT,
input_hash TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'skipped')),
result_json TEXT,
tokens INTEGER,
failure_reason TEXT,
session_id TEXT,
worktree_path TEXT,
started_at TEXT NOT NULL,
finished_at TEXT,
claim_holder TEXT NOT NULL,
claim_expires_at TEXT NOT NULL,
PRIMARY KEY (run_id, unit_id, attempt),
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_workflow_run_unit_attempts_dispatch
ON workflow_run_unit_attempts(dispatch_id);
CREATE INDEX idx_workflow_run_unit_attempts_run_step
ON workflow_run_unit_attempts(run_id, step_id, unit_id, attempt);
CREATE INDEX idx_workflow_run_unit_attempts_running_claim
ON workflow_run_unit_attempts(run_id, status, claim_expires_at);
`,
},
// ── Migration 023 — child workflow run parentage (P3a) ────────────────────
//
// Adds the columns and partial unique index `publishChildWorkflowRun`
// (src/storage/repositories/workflow-runs-repository.ts) needs to record a
// durable child workflow run under the parent unit that spawned it, and to
// make that publication idempotent per invocation
// (docs/plans/specs/p3a-plan-v5-child-freeze.md §5.1).
//
// Disambiguation: this migration's `workflow_runs.parent_unit_id` is the
// PARENT RUN's unit that spawned this CHILD RUN — a cross-run composition
// edge, new in P3a. It is NOT the same concept as the pre-existing
// `workflow_run_units.parent_unit_id` (migration 004, consolidated above
// into migration 020's `workflow_run_units` table, migrations.ts:934),
// which records MAP FAN-OUT TEMPLATE PARENTAGE — one expanded unit's row
// pointing back at the map-template unit it fanned out from, WITHIN a
// single run. Same column name, two tables, two different concepts. So the
// two are never confused at a call site, the TypeScript repository API
// deliberately never spells this one `parentUnitId`: it is always
// `spawnedByUnitId` on `PublishChildWorkflowRunInput` and on every child
// run row the repository returns.
//
// `invocation_key` is the deterministic identity of one spawn attempt
// (`computeChildInvocationKey`, src/workflows/exec/child-invocation.ts).
// The unique index is partial (`WHERE parent_run_id IS NOT NULL`) because a
// top-level run — every run before this migration, and every run started
// directly by `akm workflow run` — has no parent and no invocation key, and
// must never be constrained by this uniqueness rule.
{
id: "023-child-workflow-runs",
up: `
ALTER TABLE workflow_runs ADD COLUMN parent_run_id TEXT;
ALTER TABLE workflow_runs ADD COLUMN parent_unit_id TEXT;
ALTER TABLE workflow_runs ADD COLUMN invocation_key TEXT;
CREATE INDEX idx_workflow_runs_parent ON workflow_runs(parent_run_id);
CREATE UNIQUE INDEX idx_workflow_runs_invocation_key
ON workflow_runs(parent_run_id, invocation_key)
WHERE parent_run_id IS NOT NULL;
`,
},
// ── Migration 024 — workflow run outputs (P3b) ─────────────────────────────
//
// Adds workflow_runs.outputs_json — the resolved map of a plan's declared
// outputs:, persisted once at run completion in the SAME transaction as the
// final step's completion (docs/plans/specs/p3b-child-executor.md §4.3,
// B-N13). NULL for every run whose plan declares no outputs: (the
// overwhelming majority, including every run before this migration) and for
// every run that fails or blocks before completing — never `{}`.
// `WorkflowRunsRepository.setRunOutputs` is the only writer;
// `src/workflows/runtime/run-outputs.ts` is the only resolver.
{
id: "024-workflow-run-outputs",
up: `
ALTER TABLE workflow_runs ADD COLUMN outputs_json TEXT;
`,
},
// ── Migration 025 — task_history D8 result-vocabulary backfill ─────────────
//
// The D8 re-code (docs/architecture/decisions/0005-task-result-vocabulary-
// and-legacy-read-mapping.md) renamed the written `target_kind` vocabulary
// and marked every new row with `targetVocab: 2` in `metadata_json`, but
// left every row written before it in the OLD vocabulary forever, pushing
// the "which generation is this row" decision onto three separate read
// sites (src/tasks/run/task-history.ts, src/commands/health/
// improve-metrics.ts, src/commands/health/windows.ts). That is exactly the
// shape `docs/plans/specs/p4-deletions-closeout.md` row B-51 ratified
// keeping "forever" — SUPERSEDED here: `task_history` is DB-owned data, the
// remap is deterministic and total, so it belongs in a one-time migration,
// not three permanently-recurring read-side shims.
//
// The remap, applied only to rows with no `targetVocab` marker yet (a
// NULL/absent `$.targetVocab` in the JSON column — already-marked or
// malformed metadata_json is left untouched; malformed rows are a
// pre-existing, unrelated skip-and-warn concern at read time):
// - target_kind 'prompt' -> 'command' (the prepared command/agent-LLM
// arm; D8 renamed its written label, this backfills the old one)
// - target_kind 'command' -> 'shell' (the legacy native shell/script
// arm's shared old label; D8 split it into 'shell'/'script', and only
// 'shell' is reconstructable — the old vocabulary never distinguished
// script from shell, matching the read mapping this migration retires)
// Only 'prompt' and legacy 'command' rows actually change meaning between
// vocabularies (see the read mapping this migration retires); every other
// target_kind ('workflow', NULL, or anything else) already reads
// identically in both, so it is left alone — including its metadata_json,
// since no reader consults `targetVocab` once the shim is deleted.
{
id: "025-task-history-vocabulary-backfill",
up: `
UPDATE task_history
SET target_kind = 'command',
metadata_json = json_set(metadata_json, '$.targetVocab', 2)
WHERE target_kind = 'prompt'
AND json_valid(metadata_json)
AND json_extract(metadata_json, '$.targetVocab') IS NULL;
UPDATE task_history
SET target_kind = 'shell',
metadata_json = json_set(metadata_json, '$.targetVocab', 2)
WHERE target_kind = 'command'
AND json_valid(metadata_json)
AND json_extract(metadata_json, '$.targetVocab') IS NULL;
`,
},
// ── Migration 026 — strip the retired proposal-ref export fragment (#898) ──
//
// Older releases could write a proposal `ref` carrying the export-fragment
// selector (`[bundle//]conceptId#fragment`, see src/core/asset/asset-ref.ts).
// `currentProposalRef` (proposals-repository.ts) now rejects any fragment, so
// those rows failed to parse on every read and could not be listed, repaired,
// or deleted through the CLI. Every surface that still reads an archived row
// is concept-scoped, so the fragment carries nothing they use.
//
// `#` is legal in a ref only as the fragment separator, so truncating at the
// first `#` is exactly the split `parseBundleRef` performs. `ref` has no
// UNIQUE constraint, and no writer ever produced an empty concept id, so
// every legacy row normalizes without a drop path.
{
id: "026-proposals-strip-legacy-fragment-refs",
up: `
UPDATE proposals
SET ref = substr(ref, 1, instr(ref, '#') - 1)
WHERE ref LIKE '%#%';
`,
},
// ── Migration 027 — claude-code -> claude harness rename (#915) ──
//
// The 0.9.2 rename shipped without a data migration, so rows written under
// the old key were invisible to every reader keyed on "claude".
//
// `extract_sessions_seen` has PRIMARY KEY (harness, session_id), so a
// session already recorded under "claude" (written by a run that really
// happened post-rename) collides with its "claude-code" counterpart on
// rename. `UPDATE OR IGNORE` keeps the newer "claude" row exactly as it
// was — the older row's outcome is superseded, not more correct — and the
// trailing DELETE drops that now-unreachable duplicate so the "claude-code"
// key space is fully empty afterwards, not just mostly-migrated.
//
// `workflow_runs.agent_harness` (migration 020) has no uniqueness
// constraint on that column, so a plain UPDATE is sufficient there. Both
// statements are kept in one migration so the rename is a single ledger
// event. `improve_runs.result_json` is deliberately untouched: the harness
// name embedded in those JSON blobs is reporting data about a past run, not
// a lookup key, so rewriting it would not fix anything a reader depends on.
{
id: "027-extract-sessions-seen-harness-rename",
up: `
UPDATE OR IGNORE extract_sessions_seen SET harness = 'claude' WHERE harness = 'claude-code';
DELETE FROM extract_sessions_seen WHERE harness = 'claude-code';
UPDATE workflow_runs SET agent_harness = 'claude' WHERE agent_harness = 'claude-code';
`,
},
// ── Migration 028 — the improve ledger ──
//
// One table records what improve last did with each (stash, ref, source) and
// when it may try again (`src/storage/repositories/improve-ledger-repository.ts`
// owns the outcome → cadence rule). Backfilled from the latest proposals row
// per (stash_dir, ref, source) so the rejection windows and pending state an
// upgrade inherits are the ones the previous release was already honouring:
// pending → proposed (revisit in 7 d)
// accepted → accepted (eligible)
// rejected → expired when the review reason is the expiry pass's
// ("expired: …", 1 d grace), failed when the drain's
// stale-target auto-reject or the orphan purge (eligible now —
// neither was a content judgement), else rejected (reflect
// 14 d, distill 30 d, other 7 d)
// reverted → rejected (the operator undid the content)
// `strftime` returns NULL for an unparseable timestamp, which reads as
// "eligible now" — the safe direction.
//
// The same migration drops the tables the ledger supersedes or whose code is
// gone: `proposal_fingerprints` was a dedup cache derived from proposals
// rows, `improve_gate_thresholds` has had no reader since 0.9.0,
// `proposal_fs_imports` lost its importer, `canary_queries` /
// `improve_cycle_metrics` belonged to the removed collapse detector, and
// `consolidation_judged` was already dropped by 018 (a no-op here for any
// ledger that ran it). None holds operator state, but dropping tables is
// destructive, so it is classified that way: `openStateDatabase` copies an
// existing state.db beside itself before it runs.
{
id: "028-improve-ledger",
up: `
CREATE TABLE IF NOT EXISTS improve_ledger (
stash_dir TEXT NOT NULL,
ref TEXT NOT NULL,
source TEXT NOT NULL,
last_attempt_at TEXT NOT NULL,
outcome TEXT NOT NULL,
next_eligible_at TEXT,
proposal_id TEXT,
detail TEXT,
PRIMARY KEY (stash_dir, ref, source)
);
INSERT OR REPLACE INTO improve_ledger
(stash_dir, ref, source, last_attempt_at, outcome, next_eligible_at, proposal_id, detail)
SELECT
p.stash_dir,
p.ref,
p.source,
p.updated_at,
o.outcome,
CASE o.outcome
WHEN 'rejected' THEN strftime('%Y-%m-%dT%H:%M:%fZ', p.updated_at,
CASE p.source WHEN 'reflect' THEN '+14 days' WHEN 'distill' THEN '+30 days' ELSE '+7 days' END)
WHEN 'expired' THEN strftime('%Y-%m-%dT%H:%M:%fZ', p.updated_at, '+1 days')
WHEN 'proposed' THEN strftime('%Y-%m-%dT%H:%M:%fZ', p.updated_at, '+7 days')
ELSE NULL
END,
p.id,
CASE WHEN json_valid(p.metadata_json)
THEN json_extract(p.metadata_json, '$.review.reason') ELSE NULL END
FROM proposals p
JOIN (
SELECT stash_dir, ref, source, MAX(updated_at) AS latest_updated_at
FROM proposals GROUP BY stash_dir, ref, source
) latest
ON latest.stash_dir = p.stash_dir AND latest.ref = p.ref AND latest.source = p.source
AND latest.latest_updated_at = p.updated_at
JOIN (
SELECT rowid AS proposal_rowid,
CASE status
WHEN 'pending' THEN 'proposed'
WHEN 'accepted' THEN 'accepted'
WHEN 'reverted' THEN 'rejected'
WHEN 'rejected' THEN
CASE WHEN json_valid(metadata_json) THEN
CASE
WHEN json_extract(metadata_json, '$.review.reason') LIKE 'expired:%' THEN 'expired'
WHEN json_extract(metadata_json, '$.gateDecision.reason') = 'stale-target' THEN 'failed'
WHEN json_extract(metadata_json, '$.review.reason') = 'Asset no longer exists on disk' THEN 'failed'
ELSE 'rejected'
END
ELSE 'rejected' END
ELSE NULL
END AS outcome
FROM proposals
) o ON o.proposal_rowid = p.rowid
WHERE o.outcome IS NOT NULL
ORDER BY p.rowid ASC;
DROP INDEX IF EXISTS idx_proposal_fingerprints_ref;
DROP TABLE IF EXISTS proposal_fingerprints;
DROP TABLE IF EXISTS improve_gate_thresholds;
DROP TABLE IF EXISTS proposal_fs_imports;
DROP TABLE IF EXISTS consolidation_judged;
DROP TABLE IF EXISTS improve_cycle_metrics;
DROP TABLE IF EXISTS canary_queries;
`,
},
// ── Migration 029 — improve_ledger.content_hash ───────────────────────────
//
// The consolidate pair pass (alpha.9) selects initiators by content change,
// not a time window: no row means eligible, a row whose content_hash
// differs from the asset's current body means eligible, otherwise not
// (src/commands/improve/consolidate/pair-pass.ts, selectInitiators). Every
// other source still uses next_eligible_at; this column is additional, not
// a replacement.
{
id: "029-improve-ledger-content-hash",
up: `
ALTER TABLE improve_ledger ADD COLUMN content_hash TEXT DEFAULT NULL;
`,
},
];
assertMigrationRegistry(STATE_MIGRATIONS);
function assertStateMigrationSafetyRegistry() {
const migrationIds = STATE_MIGRATIONS.map((migration) => migration.id);
const classifiedIds = Object.keys(STATE_MIGRATION_SAFETY_BY_ID);
if (migrationIds.length !== classifiedIds.length ||
migrationIds.some((migrationId, index) => classifiedIds[index] !== migrationId)) {
throw new Error("State migration safety classifications must match the exact ordered migration registry.");
}
}
assertStateMigrationSafetyRegistry();
export function getStateMigrationSafety(migrationId) {
const safety = STATE_MIGRATION_SAFETY_BY_ID[migrationId];
if (!safety)
throw new Error(`State migration ${migrationId} has no safety classification.`);
return safety;
}