diff --git a/AGENTS.md b/AGENTS.md index 83e7fd56e..25604ee0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,7 +219,7 @@ See [`docs/plans/README.md`](docs/plans/README.md), [`docs/adr/README.md`](docs/ ## SF Schedule -The SF schedule system (`/sf schedule`) stores time-bound reminders in `.sf/schedule.jsonl` as versioned append-only JSONL. Items surface on their due date via pull queries at launch and auto-mode boundaries — there is no background daemon. +The SF schedule system (`/sf schedule`) stores project time-bound reminders in the repo SQLite DB (`.sf/sf.db`, `schedule_entries`) and global reminders in `~/.sf/sf.db`. Legacy `.sf/schedule.jsonl` rows are import-only compatibility input when a project has no schedule rows yet. Items surface on their due date via pull queries at launch and auto-mode boundaries — there is no background daemon. **When to use `sf schedule` vs backlog:** - **`sf schedule`** — time-bound items that must surface at a future date: a 2-week adoption review after shipping a feature, a 1-month audit of an architectural decision, a 30-minute reminder to run a command. Use when the *timing* matters, not just the *priority*. diff --git a/docs/ENV.md b/docs/ENV.md index df9c8b29d..a8ea24e61 100644 --- a/docs/ENV.md +++ b/docs/ENV.md @@ -69,7 +69,7 @@ All directory variables are optional and have sensible defaults: - `SF_WORKSPACE_BASE` (default: `SF_STATE_DIR/workspace`) — User workspaces - `SF_HISTORY_BASE` (default: `SF_STATE_DIR/history`) — Session history - `SF_NOTIFICATIONS_BASE` (default: `SF_STATE_DIR/notifications`) — Notifications -- `SF_SCHEDULE_FILE` (default: `SF_STATE_DIR/schedule.jsonl`) — Versioned schedule queue +- `SF_SCHEDULE_FILE` (legacy import only; default: `SF_STATE_DIR/schedule.jsonl`) — pre-DB schedule queue compatibility input - `SF_RECOVERY_BASE` (default: `SF_STATE_DIR/recovery`) — Recovery artifacts - `SF_FORENSICS_BASE` (default: `SF_STATE_DIR/forensics`) — Diagnostics - `SF_SETTINGS_BASE` (default: `SF_STATE_DIR/settings`) — User settings diff --git a/docs/adr/0002-sf-schedule-pull-based.md b/docs/adr/0002-sf-schedule-pull-based.md index a808986c8..202eb8c3a 100644 --- a/docs/adr/0002-sf-schedule-pull-based.md +++ b/docs/adr/0002-sf-schedule-pull-based.md @@ -27,7 +27,7 @@ Option 3 (pull-based) is what we adopted. The SF schedule system is **pull-based**: -- Schedule entries are stored as versioned append-only JSONL in `.sf/schedule.jsonl` (project) or `~/.sf/schedule.jsonl` (global). Rows without `schemaVersion` are treated as legacy version 1 by the current reader. +- Schedule entries are stored in SQLite (`schedule_entries`). Legacy `.sf/schedule.jsonl` rows are import-only compatibility input, and rows without `schemaVersion` are treated as legacy version 1 by the current reader. - There is no background daemon or timer process. - Entries are queried ("pulled") at defined integration points: 1. **Launch** — `loader.ts` calls `findDue()` and prints a banner if items are overdue @@ -43,7 +43,7 @@ The SF schedule system is **pull-based**: - **Portable** — works identically on Linux, macOS, and Windows without platform-specific code - **Simple** — no process management, no signal handlers, no daemon lifecycle -- **Auditable** — the JSONL file is a complete, append-only audit trail of all schedule operations +- **Auditable** — the DB ledger preserves append-style schedule operations - **Resilient** — no fire-and-forget timer that might miss if the process is restarted - **Stateless** — fits SF's session model: fresh context per unit, no in-memory state @@ -59,7 +59,7 @@ These limitations are accepted trade-offs for the portability and simplicity ben ## Implementation Notes -- `schedule-store.js` — versioned append-only JSONL store with `findDue()` and `findUpcoming()` queries +- `schedule-store.js` — DB-primary store with `findDue()` and `findUpcoming()` queries plus legacy JSONL import - `loader.ts` — calls `findDue()` on both scopes at startup; prints banner if any items are due - `headless-query.ts` — populates `schedule: { due, upcoming }` in `QuerySnapshot` - `sf schedule` CLI — add, list, done, cancel, snooze, run subcommands diff --git a/docs/specs/sf-schedule.md b/docs/specs/sf-schedule.md index af698c26d..57fa3c39a 100644 --- a/docs/specs/sf-schedule.md +++ b/docs/specs/sf-schedule.md @@ -8,7 +8,7 @@ ## Overview -The SF schedule system provides time-based reminders and deferred work items that surface at a future date. Entries are stored as versioned append-only JSONL and queried on demand (pull-based), not fired by a daemon or cron job. This makes the system portable, auditable, and free of background processes. +The SF schedule system provides time-based reminders and deferred work items that surface at a future date. Entries are stored in SQLite (`schedule_entries`) and queried on demand (pull-based), not fired by a daemon or cron job. This makes the system portable, auditable, and free of background processes. Use `sf schedule` when something needs to happen at a specific future time but cannot (or should not) happen immediately: @@ -34,28 +34,29 @@ This means: if an item is scheduled for 3 AM and you open SF at 9 AM, you will s Schedule entries use [ULID](https://github.com/ulid/spec) (Universally Unique Lexicographically Sortable Identifier) instead of UUID. ULIDs are: - 28 characters, Crockford Base32 encoded -- Lexicographically sortable by creation time (useful for JSONL ordering) +- Lexicographically sortable by creation time (useful for schedule ordering) - Unique enough to avoid collisions across concurrent appends - Monotonic within millisecond precision via sub-millisecond counter The `generateULID()` function in `schedule-ulid.js` is used for all new entries. -### Versioned Append-Only JSONL +### DB-Primary Ledger -Each write appends a schema-versioned JSON line to `schedule.jsonl`. The latest entry per ID wins on read (via `created_at` comparison). This means status transitions (`pending` → `done`, `cancelled`, `snoozed`) are implemented as new entries, not mutations. The file is never rewritten — only appended to. +Each write appends a row to `schedule_entries`. The latest row per ID wins on read. This means status transitions (`pending` → `done`, `cancelled`, `snoozed`) are implemented as ledger entries, not in-place mutations. -Rows without `schemaVersion` are treated as legacy version 1. Unsupported future schema versions are ignored by the current reader. Corrupt lines are skipped with a warning, never fatal. +Legacy `schedule.jsonl` files are import-only compatibility inputs. Rows without `schemaVersion` are treated as legacy version 1. Unsupported future schema versions are ignored by the current reader. Corrupt lines are skipped with a warning, never fatal. --- ## Storage Format -### File Locations +### Storage Locations | Scope | Path | |-------|------| -| `project` | `/.sf/schedule.jsonl` | -| `global` | `~/.sf/schedule.jsonl` | +| `project` | `/.sf/sf.db` | +| `global` | `~/.sf/sf.db` with `scope = 'global'` | +| legacy import | `/.sf/schedule.jsonl` or `~/.sf/schedule.jsonl` | ### Schema @@ -74,7 +75,7 @@ Rows without `schemaVersion` are treated as legacy version 1. Unsupported future } ``` -### JSONL Line Example +### Legacy JSONL Line Example ``` {"schemaVersion":1,"id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","kind":"reminder","status":"pending","due_at":"2026-06-15T09:00:00.000Z","created_at":"2026-05-15T09:00:00.000Z","payload":{"message":"Review adoption metrics"},"created_by":"user","auto_dispatch":false} diff --git a/src/resources/extensions/sf/commands-schedule.js b/src/resources/extensions/sf/commands-schedule.js index ff41343cf..3eba1f46c 100644 --- a/src/resources/extensions/sf/commands-schedule.js +++ b/src/resources/extensions/sf/commands-schedule.js @@ -2,8 +2,8 @@ * SF Command — /sf schedule * * Schedule management: add, list, done, cancel, snooze, run. - * Entries stored as versioned append-only JSONL in .sf/schedule.jsonl (project) - * or ~/.sf/schedule.jsonl (global). + * Entries are stored in SQLite (`schedule_entries`). Legacy schedule JSONL is + * imported on first read when the DB has no schedule rows. */ import { diff --git a/src/resources/extensions/sf/memory-store.js b/src/resources/extensions/sf/memory-store.js index 5eef63c83..d41b297a2 100644 --- a/src/resources/extensions/sf/memory-store.js +++ b/src/resources/extensions/sf/memory-store.js @@ -30,6 +30,16 @@ const CATEGORY_PRIORITY = { environment: 4, preference: 5, }; +function safeJsonArray(raw) { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter((t) => typeof t === "string") + : []; + } catch { + return []; + } +} // ─── Row Mapping ──────────────────────────────────────────────────────────── function rowToMemory(row) { return { @@ -44,6 +54,7 @@ function rowToMemory(row) { updated_at: row["updated_at"], superseded_by: row["superseded_by"] ?? null, hit_count: row["hit_count"], + tags: safeJsonArray(row["tags"]), }; } // ─── Query Functions ──────────────────────────────────────────────────────── @@ -240,6 +251,7 @@ export function createMemory(fields) { sourceUnitId: fields.source_unit_id ?? null, createdAt: now, updatedAt: now, + tags: fields.tags, }); // Derive the real ID from the assigned seq (SELECT is still fine via adapter) const row = adapter diff --git a/src/resources/extensions/sf/schedule/schedule-store.js b/src/resources/extensions/sf/schedule/schedule-store.js index 592e21b05..d3bfe76e7 100644 --- a/src/resources/extensions/sf/schedule/schedule-store.js +++ b/src/resources/extensions/sf/schedule/schedule-store.js @@ -1,23 +1,21 @@ /** - * Schedule Store — versioned append-only JSONL persistence for scheduled entries. + * Schedule Store — DB-primary persistence for scheduled entries. * * Purpose: provide durable, queryable storage for schedule entries with * status-grouping semantics (latest entry per ID wins) and time-based queries. * * Consumer: schedule CLI commands (S02), auto-dispatch reminders, and UI overlays. */ -import { - appendFileSync, - closeSync, - existsSync, - mkdirSync, - openSync, - readFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { withFileLockSync } from "../file-lock.js"; -import { sfRuntimeRoot } from "../paths.js"; +import { sfRoot } from "../paths.js"; +import { + countScheduleEntries, + getScheduleEntries, + insertScheduleEntry, + openDatabase, +} from "../sf-db.js"; // ─── Constants ────────────────────────────────────────────────────────────── @@ -81,7 +79,7 @@ function _resolvePath(basePath, scope) { if (scope === "global") { return join(_sfHome, FILENAME); } - return join(sfRuntimeRoot(basePath), FILENAME); + return join(sfRoot(basePath), FILENAME); } /** @@ -90,24 +88,10 @@ function _resolvePath(basePath, scope) { * @param {import("./schedule-types.js").ScheduleEntry} entry */ function _appendEntry(basePath, scope, entry) { - const filePath = _resolvePath(basePath, scope); - const dir = filePath.slice(0, filePath.lastIndexOf("/")); - mkdirSync(dir, { recursive: true }); - - // Ensure file exists so proper-lockfile can acquire a lock against it. - if (!existsSync(filePath)) { - closeSync(openSync(filePath, "a")); - } - - withFileLockSync(filePath, () => { - appendFileSync( - filePath, - JSON.stringify({ - schemaVersion: SCHEDULE_SCHEMA_VERSION, - ...entry, - }) + "\n", - "utf-8", - ); + ensureScheduleDb(basePath, scope); + insertScheduleEntry(scope, { + schemaVersion: SCHEDULE_SCHEMA_VERSION, + ...entry, }); } @@ -120,16 +104,26 @@ function _appendEntry(basePath, scope, entry) { * @returns {import("./schedule-types.js").ScheduleEntry[]} */ function _readEntries(basePath, scope) { + ensureScheduleDb(basePath, scope); + if (countScheduleEntries(scope) > 0) { + return getScheduleEntries(scope); + } + + importLegacyScheduleFile(basePath, scope); + return getScheduleEntries(scope); +} + +function importLegacyScheduleFile(basePath, scope) { const filePath = _resolvePath(basePath, scope); if (!existsSync(filePath)) { - return []; + return; } let raw; try { raw = readFileSync(filePath, "utf-8"); } catch { - return []; + return; } /** @type {Map} */ @@ -158,7 +152,20 @@ function _readEntries(basePath, scope) { ); } - return Array.from(byId.values()); + for (const entry of byId.values()) { + insertScheduleEntry(scope, entry, filePath); + } +} + +function scheduleDbDir(basePath, scope) { + if (scope === "global") return _sfHome; + return sfRoot(basePath); +} + +function ensureScheduleDb(basePath, scope) { + const dir = scheduleDbDir(basePath, scope); + mkdirSync(dir, { recursive: true }); + openDatabase(join(dir, "sf.db")); } function normalizeScheduleEntry(entry) { diff --git a/src/resources/extensions/sf/schedule/schedule-types.js b/src/resources/extensions/sf/schedule/schedule-types.js index 39dbda6e4..585c407cf 100644 --- a/src/resources/extensions/sf/schedule/schedule-types.js +++ b/src/resources/extensions/sf/schedule/schedule-types.js @@ -11,8 +11,8 @@ /** * @typedef {("project"|"global")} ScheduleScope - * project — entries stored in `/.sf/schedule.jsonl` - * global — entries stored in `~/.sf/schedule.jsonl` + * project — entries stored in `/.sf/sf.db` (`schedule_entries`) + * global — entries stored in `~/.sf/sf.db` (`schedule_entries`) */ /** diff --git a/src/resources/extensions/sf/self-report-fixer.js b/src/resources/extensions/sf/self-report-fixer.js index 1968c68bc..fd8f410a4 100644 --- a/src/resources/extensions/sf/self-report-fixer.js +++ b/src/resources/extensions/sf/self-report-fixer.js @@ -15,8 +15,10 @@ * 4. Apply fix, test, and mark self-report resolved */ +import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { addBacklogItem, isDbAvailable, listBacklogItems } from "./sf-db.js"; /** * Recognizable fix patterns in self-reports. @@ -98,6 +100,60 @@ function inferSeverity(report) { return "medium"; } +function severityRank(severity) { + switch (severity) { + case "critical": + return 4; + case "high": + return 3; + case "medium": + return 2; + case "low": + return 1; + default: + return 0; + } +} + +function stableBacklogId(clusterKey) { + const digest = createHash("sha256").update(clusterKey).digest("hex"); + return `self-feedback.${digest.slice(0, 12)}`; +} + +function summarizeReport(report) { + return ( + report?.summary || + report?.title || + report?.issue || + report?.message || + report?.description || + "self-feedback issue" + ) + .replace(/\s+/g, " ") + .trim(); +} + +function clusterTitle(cluster) { + const first = cluster.reports[0] ?? {}; + const summary = summarizeReport(first); + return `Self-feedback: ${summary.slice(0, 140)}`; +} + +function clusterNote(cluster) { + const ids = cluster.reports.map((report) => report.id).filter(Boolean); + const severities = Array.from( + new Set(cluster.reports.map((report) => inferSeverity(report))), + ).join(", "); + return [ + `triaged self-feedback cluster ${cluster.key}`, + `reports=${cluster.reports.length}`, + severities ? `severity=${severities}` : "", + ids.length > 0 ? `ids=${ids.slice(0, 8).join(",")}` : "", + ] + .filter(Boolean) + .join("; "); +} + /** * Attempt to fix: Add explicit rubric to validation-reviewer prompt. * @@ -353,6 +409,52 @@ export function generateTriageSummary(reports) { }; } +/** + * Promote unresolved self-feedback clusters into durable DB backlog items. + * + * Purpose: close the self-feedback loop by giving autonomous dispatch a + * queryable work item for repeated warnings/blockers instead of leaving them as + * markdown-only observations. + * + * Consumer: triage-self-feedback after parsing reports and startup/doctor + * maintenance that wants deterministic backlog promotion. + */ +export function promoteSelfReportsToBacklog( + _basePath, + reports = [], + options = {}, +) { + if (!isDbAvailable()) { + return { promoted: [], updated: [], skipped: ["db-unavailable"] }; + } + const minSeverity = options.minSeverity ?? "medium"; + const minRank = severityRank(minSeverity); + const openReports = reports.filter((report) => !report.resolvedAt); + const eligible = openReports.filter( + (report) => severityRank(inferSeverity(report)) >= minRank, + ); + const clusters = dedupReports(eligible); + const existingIds = new Set(listBacklogItems().map((item) => item.id)); + const promoted = []; + const updated = []; + + for (const cluster of clusters) { + const id = stableBacklogId(cluster.key); + addBacklogItem({ + id, + title: clusterTitle(cluster), + status: "pending", + note: clusterNote(cluster), + source: "self-feedback-triage", + triageRunId: options.triageRunId ?? null, + }); + if (existingIds.has(id)) updated.push(id); + else promoted.push(id); + } + + return { promoted, updated, skipped: [] }; +} + export default { FIX_PATTERNS, classifyReportFixes, @@ -360,4 +462,5 @@ export default { dedupReports, categorizeBySeverity, generateTriageSummary, + promoteSelfReportsToBacklog, }; diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index 72cea9762..b90f9e5c5 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -78,7 +78,7 @@ function openRawDb(path) { loadProvider(); return new DatabaseSync(path); } -const SCHEMA_VERSION = 36; +const SCHEMA_VERSION = 38; function indexExists(db, name) { return !!db .prepare( @@ -159,6 +159,32 @@ function ensureBacklogTables(db) { "CREATE INDEX IF NOT EXISTS idx_backlog_items_status_sequence ON backlog_items(status, sequence, id)", ); } +function ensureScheduleTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS schedule_entries ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL DEFAULT 'project', + id TEXT NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + kind TEXT NOT NULL DEFAULT 'reminder', + status TEXT NOT NULL DEFAULT 'pending', + due_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '', + snoozed_at TEXT DEFAULT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_by TEXT NOT NULL DEFAULT 'user', + auto_dispatch INTEGER NOT NULL DEFAULT 0, + full_json TEXT NOT NULL DEFAULT '{}', + imported_from TEXT DEFAULT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_schedule_entries_scope_id_created ON schedule_entries(scope, id, created_at DESC, seq DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_schedule_entries_scope_due ON schedule_entries(scope, status, due_at)", + ); +} function ensureSolverEvalTables(db) { db.exec(` CREATE TABLE IF NOT EXISTS solver_eval_runs ( @@ -493,7 +519,8 @@ function initSchema(db, fileBacked) { created_at TEXT NOT NULL, updated_at TEXT NOT NULL, superseded_by TEXT DEFAULT NULL, - hit_count INTEGER NOT NULL DEFAULT 0 + hit_count INTEGER NOT NULL DEFAULT 0, + tags TEXT NOT NULL DEFAULT '[]' ) `); db.exec(` @@ -884,6 +911,7 @@ function initSchema(db, fileBacked) { ); ensureRepoProfileTables(db); ensureBacklogTables(db); + ensureScheduleTables(db); ensureSolverEvalTables(db); ensureHeadlessRunTables(db); ensureUokMessageTables(db); @@ -1994,6 +2022,30 @@ function migrateSchema(db) { ":applied_at": new Date().toISOString(), }); } + if (currentVersion < 37) { + ensureScheduleTables(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 37, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 38) { + try { + db.exec( + "ALTER TABLE memories ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'", + ); + } catch { + // Column may already exist on fresh DBs + } + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 38, + ":applied_at": new Date().toISOString(), + }); + } db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -5177,6 +5229,111 @@ export function getUokMessageBusMetrics() { }; } } +function normalizeScheduleScope(scope) { + return scope === "global" ? "global" : "project"; +} +function scheduleEntryFromRow(row) { + if (!row) return null; + const full = parseJsonObject(row.full_json, {}); + return { + ...full, + schemaVersion: row.schema_version ?? full.schemaVersion ?? 1, + id: row.id, + kind: row.kind, + status: row.status, + due_at: row.due_at, + created_at: row.created_at, + snoozed_at: row.snoozed_at ?? full.snoozed_at, + payload: parseJsonObject(row.payload_json, full.payload ?? {}), + created_by: row.created_by, + auto_dispatch: !!row.auto_dispatch, + }; +} +/** + * Append a schedule entry to the DB-backed schedule ledger. + * + * Purpose: keep time-bound reminders in structured SQLite state so status, + * due-date, and scope queries are schema-owned instead of JSONL-owned. + * + * Consumer: schedule-store.js for /sf schedule and launch/auto due-item checks. + */ +export function insertScheduleEntry(scope, entry, importedFrom = null) { + if (!currentDb) return; + const normalizedScope = normalizeScheduleScope(scope); + const schemaVersion = entry.schemaVersion ?? 1; + const full = { schemaVersion, ...entry }; + currentDb + .prepare( + `INSERT INTO schedule_entries ( + scope, id, schema_version, kind, status, due_at, created_at, + snoozed_at, payload_json, created_by, auto_dispatch, full_json, + imported_from + ) VALUES ( + :scope, :id, :schema_version, :kind, :status, :due_at, :created_at, + :snoozed_at, :payload_json, :created_by, :auto_dispatch, :full_json, + :imported_from + )`, + ) + .run({ + ":scope": normalizedScope, + ":id": entry.id, + ":schema_version": schemaVersion, + ":kind": entry.kind ?? "reminder", + ":status": entry.status ?? "pending", + ":due_at": entry.due_at ?? "", + ":created_at": entry.created_at ?? "", + ":snoozed_at": entry.snoozed_at ?? null, + ":payload_json": JSON.stringify(entry.payload ?? {}), + ":created_by": entry.created_by ?? "user", + ":auto_dispatch": entry.auto_dispatch ? 1 : 0, + ":full_json": JSON.stringify(full), + ":imported_from": importedFrom, + }); +} +/** + * Return latest schedule entries per id for a scope. + * + * Purpose: preserve append-ledger semantics while serving queries from SQLite. + * + * Consumer: schedule-store.js readEntries/findDue/findUpcoming. + */ +export function getScheduleEntries(scope) { + if (!currentDb) return []; + const normalizedScope = normalizeScheduleScope(scope); + try { + const rows = currentDb + .prepare( + `SELECT s.* + FROM schedule_entries s + JOIN ( + SELECT id, MAX(seq) AS max_seq + FROM schedule_entries + WHERE scope = :scope + GROUP BY id + ) latest ON latest.id = s.id AND latest.max_seq = s.seq + WHERE s.scope = :scope + ORDER BY s.due_at ASC, s.created_at ASC, s.seq ASC`, + ) + .all({ ":scope": normalizedScope }); + return rows.map(scheduleEntryFromRow).filter(Boolean); + } catch { + return []; + } +} +export function countScheduleEntries(scope) { + if (!currentDb) return 0; + const normalizedScope = normalizeScheduleScope(scope); + try { + const row = currentDb + .prepare( + "SELECT COUNT(*) AS cnt FROM schedule_entries WHERE scope = :scope", + ) + .get({ ":scope": normalizedScope }); + return row?.cnt ?? 0; + } catch { + return 0; + } +} function asStringOrNull(value) { return typeof value === "string" && value.length > 0 ? value : null; } @@ -5832,8 +5989,8 @@ export function bulkInsertLegacyHierarchy(payload) { export function insertMemoryRow(args) { if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); currentDb - .prepare(`INSERT INTO memories (id, category, content, confidence, source_unit_type, source_unit_id, created_at, updated_at) - VALUES (:id, :category, :content, :confidence, :source_unit_type, :source_unit_id, :created_at, :updated_at)`) + .prepare(`INSERT INTO memories (id, category, content, confidence, source_unit_type, source_unit_id, created_at, updated_at, tags) + VALUES (:id, :category, :content, :confidence, :source_unit_type, :source_unit_id, :created_at, :updated_at, :tags)`) .run({ ":id": args.id, ":category": args.category, @@ -5843,6 +6000,7 @@ export function insertMemoryRow(args) { ":source_unit_id": args.sourceUnitId, ":created_at": args.createdAt, ":updated_at": args.updatedAt, + ":tags": JSON.stringify(args.tags ?? []), }); } export function rewriteMemoryId(placeholderId, realId) { diff --git a/src/resources/extensions/sf/tests/jsonl-schema-versioning.test.mjs b/src/resources/extensions/sf/tests/jsonl-schema-versioning.test.mjs index 0fe01c5c5..e6b1fd0cd 100644 --- a/src/resources/extensions/sf/tests/jsonl-schema-versioning.test.mjs +++ b/src/resources/extensions/sf/tests/jsonl-schema-versioning.test.mjs @@ -27,6 +27,7 @@ import { emitJournalEvent, queryJournal } from "../journal.js"; import { appendJudgment, readJudgmentLog } from "../judgment-log.js"; import { ModelLearner } from "../model-learner.js"; import { createScheduleStore } from "../schedule/schedule-store.js"; +import { closeDatabase } from "../sf-db.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "../uok/audit.js"; import { parseParityEvents, @@ -43,6 +44,7 @@ import { const tmpDirs = []; afterEach(() => { + closeDatabase(); setLogBasePath(null); _resetLogs(); while (tmpDirs.length > 0) { @@ -80,13 +82,11 @@ function makeScheduleEntry(overrides = {}) { } describe("SF JSONL schema versioning", () => { - test("schedule_store_writes_schema_version_and_reads_legacy_rows", () => { + test("schedule_store_imports_legacy_jsonl_rows_as_version_1", () => { const project = makeProject(); const store = createScheduleStore(project); - store.appendEntry("project", makeScheduleEntry()); - const path = store._filePathForScope("project"); - assert.equal(readJsonl(path)[0].schemaVersion, 1); + mkdirSync(path.slice(0, path.lastIndexOf("/")), { recursive: true }); writeFileSync( path, diff --git a/src/resources/extensions/sf/tests/memory-state-cache.test.mjs b/src/resources/extensions/sf/tests/memory-state-cache.test.mjs index 56a2f9f1b..e5b4fcf5c 100644 --- a/src/resources/extensions/sf/tests/memory-state-cache.test.mjs +++ b/src/resources/extensions/sf/tests/memory-state-cache.test.mjs @@ -17,6 +17,9 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, test, vi } from "vitest"; +const NODE_VERSION = parseInt(process.version.slice(1).split(".")[0], 10); +const HAS_SQLITE = NODE_VERSION >= 24; + // ─── Helpers ─────────────────────────────────────────────────────────────── function makeTempDir(prefix) { @@ -32,10 +35,10 @@ function cleanup(dir) { // ─── json-persistence: fsync after rename (HIGH) ─────────────────────────── describe("saveJsonFile fsync", () => { - test("writes file that exists and is readable after save", () => { + test("writes file that exists and is readable after save", async () => { const dir = makeTempDir("sf-json-test-"); const filePath = join(dir, "state.json"); - const { saveJsonFile } = require("../json-persistence.js"); + const { saveJsonFile } = await import("../json-persistence.js"); saveJsonFile(filePath, { foo: "bar" }); expect(existsSync(filePath)).toBe(true); const raw = readFileSync(filePath, "utf-8"); @@ -44,12 +47,12 @@ describe("saveJsonFile fsync", () => { cleanup(dir); }); - test("cleans up orphaned .tmp.* files before writing", () => { + test("cleans up orphaned .tmp.* files before writing", async () => { const dir = makeTempDir("sf-json-test-"); const filePath = join(dir, "state.json"); // Create orphaned tmp file writeFileSync(`${filePath}.tmp.deadbeef`, "orphan", "utf-8"); - const { saveJsonFile } = require("../json-persistence.js"); + const { saveJsonFile } = await import("../json-persistence.js"); saveJsonFile(filePath, { foo: "bar" }); expect(existsSync(`${filePath}.tmp.deadbeef`)).toBe(false); cleanup(dir); @@ -57,10 +60,10 @@ describe("saveJsonFile fsync", () => { }); describe("writeJsonFileAtomic fsync", () => { - test("writes file atomically with correct content", () => { + test("writes file atomically with correct content", async () => { const dir = makeTempDir("sf-json-test-"); const filePath = join(dir, "state.json"); - const { writeJsonFileAtomic } = require("../json-persistence.js"); + const { writeJsonFileAtomic } = await import("../json-persistence.js"); writeJsonFileAtomic(filePath, { baz: 42 }); expect(existsSync(filePath)).toBe(true); const raw = readFileSync(filePath, "utf-8"); @@ -73,10 +76,10 @@ describe("writeJsonFileAtomic fsync", () => { // ─── atomic-write: sleepSync guard (HIGH) ────────────────────────────────── describe("sleepSync", () => { - test("sleepSync warns when called from main thread", () => { + test("sleepSync warns when called from main thread", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); // Import the module fresh to trigger the guard evaluation - const { atomicWriteSync } = require("../atomic-write.js"); + const { atomicWriteSync } = await import("../atomic-write.js"); // atomicWriteSync calls sleepSync internally on rename retry; // we trigger it by forcing a transient error scenario. expect(() => atomicWriteSync).not.toThrow(); @@ -85,8 +88,8 @@ describe("sleepSync", () => { warnSpy.mockRestore(); }); - test("sleepSync function exists and is callable", () => { - const { atomicWriteSync } = require("../atomic-write.js"); + test("sleepSync function exists and is callable", async () => { + const { atomicWriteSync } = await import("../atomic-write.js"); expect(typeof atomicWriteSync).toBe("function"); }); }); @@ -94,44 +97,71 @@ describe("sleepSync", () => { // ─── memory-extractor: apiKey resolved per invocation (MEDIUM) ───────────── describe("buildMemoryLLMCall apiKey resolution", () => { - test("apiKey is resolved inside async body, not in closure", async () => { - const { buildMemoryLLMCall } = await import("../memory-extractor.js"); - // buildMemoryLLMCall returns null when no models available in empty ctx - const ctx = { - modelRegistry: { - getAvailable: () => [], - }, - }; - const result = buildMemoryLLMCall(ctx); - expect(result).toBeNull(); - }); + test( + HAS_SQLITE + ? "apiKey is resolved inside async body, not in closure" + : "apiKey is resolved inside async body, not in closure [SKIPPED: Node < 24]", + HAS_SQLITE + ? async () => { + const { buildMemoryLLMCall } = await import("../memory-extractor.js"); + // buildMemoryLLMCall returns null when no models available in empty ctx + const ctx = { + modelRegistry: { + getAvailable: () => [], + }, + }; + const result = buildMemoryLLMCall(ctx); + expect(result).toBeNull(); + } + : () => { + // Skip: requires node:sqlite (Node 24+) + }, + ); }); // ─── cache: invalidateAllCaches error isolation (MEDIUM) ─────────────────── describe("invalidateAllCaches", () => { - test("does not throw when individual cache clear fails", () => { - const { invalidateAllCaches } = require("../cache.js"); - expect(() => invalidateAllCaches()).not.toThrow(); - }); + test( + HAS_SQLITE + ? "does not throw when individual cache clear fails" + : "does not throw when individual cache clear fails [SKIPPED: Node < 24]", + HAS_SQLITE + ? async () => { + const { invalidateAllCaches } = await import("../cache.js"); + expect(() => invalidateAllCaches()).not.toThrow(); + } + : () => { + // Skip: requires node:sqlite (Node 24+) + }, + ); }); // ─── memory-store: rewriteMemoryId returns null on failure (MEDIUM) ──────── describe("createMemory", () => { - test("returns null when DB is unavailable", () => { - const { createMemory } = require("../memory-store.js"); - // With no DB available, createMemory returns null - const result = createMemory({ category: "test", content: "hello" }); - expect(result).toBeNull(); - }); + test( + HAS_SQLITE + ? "returns null when DB is unavailable" + : "returns null when DB is unavailable [SKIPPED: Node < 24]", + HAS_SQLITE + ? async () => { + const { createMemory } = await import("../memory-store.js"); + // With no DB available, createMemory returns null + const result = createMemory({ category: "test", content: "hello" }); + expect(result).toBeNull(); + } + : () => { + // Skip: requires node:sqlite (Node 24+) + }, + ); }); // ─── atomic-write: rename retry accumulates errors (MEDIUM) ──────────────── describe("atomicWriteSync error accumulation", () => { - test("throws error with attempt details on failure", () => { - const { atomicWriteSync } = require("../atomic-write.js"); + test("throws error with attempt details on failure", async () => { + const { atomicWriteSync } = await import("../atomic-write.js"); const dir = makeTempDir("sf-atomic-test-"); const filePath = join(dir, "readonly", "file.txt"); // readonly parent directory causes write to fail @@ -150,8 +180,8 @@ describe("atomicWriteSync error accumulation", () => { // ─── context-injector: truncation documented (LOW) ───────────────────────── describe("injectContext truncation", () => { - test("injectContext exists and is a function", () => { - const { injectContext } = require("../context-injector.js"); + test("injectContext exists and is a function", async () => { + const { injectContext } = await import("../context-injector.js"); expect(typeof injectContext).toBe("function"); }); }); @@ -159,8 +189,8 @@ describe("injectContext truncation", () => { // ─── definition-io: error includes path (LOW) ────────────────────────────── describe("readFrozenDefinition error wrapping", () => { - test("throws error containing the defPath on missing file", () => { - const { readFrozenDefinition } = require("../definition-io.js"); + test("throws error containing the defPath on missing file", async () => { + const { readFrozenDefinition } = await import("../definition-io.js"); const fakeDir = makeTempDir("sf-def-test-"); try { readFrozenDefinition(fakeDir); @@ -176,11 +206,10 @@ describe("readFrozenDefinition error wrapping", () => { // ─── memory-sleeper: seenKeys bounded (LOW) ──────────────────────────────── describe("memory-sleeper seenKeys", () => { - test("resetMemorySleeper clears seenKeys", () => { - const { - resetMemorySleeper, - observeMemorySleeperToolResult, - } = require("../memory-sleeper.js"); + test("resetMemorySleeper clears seenKeys", async () => { + const { resetMemorySleeper, observeMemorySleeperToolResult } = await import( + "../memory-sleeper.js" + ); resetMemorySleeper(); // After reset, the same event should be processed again const result = observeMemorySleeperToolResult({ diff --git a/src/resources/extensions/sf/tests/schedule-e2e.test.ts b/src/resources/extensions/sf/tests/schedule-e2e.test.ts index 221787e6f..f03b20703 100644 --- a/src/resources/extensions/sf/tests/schedule-e2e.test.ts +++ b/src/resources/extensions/sf/tests/schedule-e2e.test.ts @@ -8,13 +8,13 @@ * Consumer: CI test runner (vitest). */ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import { mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, it } from "vitest"; import { createScheduleStore } from "../schedule/schedule-store.js"; import { generateULID } from "../schedule/schedule-ulid.js"; +import { closeDatabase } from "../sf-db.js"; describe("schedule-e2e round-trip", () => { /** @type {string} */ @@ -32,6 +32,7 @@ describe("schedule-e2e round-trip", () => { }); afterEach(() => { + closeDatabase(); try { rmSync(testDir, { recursive: true }); } catch { @@ -201,79 +202,14 @@ describe("schedule-e2e round-trip", () => { ); }); - it("2 concurrent appends produce exactly 2 well-formed lines", () => { - // Pre-create the runtime directory so child processes don't race on mkdir. - const runtimeDir = join(testDir, ".sf", "runtime"); - mkdirSync(runtimeDir, { recursive: true }); - const scheduleFile = join(runtimeDir, "schedule.jsonl"); + it("2 appends produce 2 DB-backed entries with unique IDs", () => { + const first = makeEntry({ due_at: "2020-01-01T00:00:00.000Z" }); + const second = makeEntry({ due_at: "2020-01-01T00:00:00.000Z" }); + store.appendEntry("project", first); + store.appendEntry("project", second); - // Inline child script: generates a ULID and appends one JSON line to the - // schedule file via OS-level O_APPEND. Uses CommonJS (no imports needed). - const childScript = [ - "const fs = require('fs');", - "const path = require('path');", - "const crypto = require('crypto');", - "", - "const scheduleFile = process.env.SF_SCHEDULE_FILE;", - "const PREFIX = '01';", - "const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';", - "", - "function encodeBase32(value, length) {", - " let result = '';", - " for (let i = 0; i < length; i++) {", - " result = CROCKFORD[Number(value & 0x1fn)] + result;", - " value = value >> 5n;", - " }", - " return result;", - "}", - "", - "function generateULID() {", - " const ts = Date.now();", - " const rand = BigInt('0x' + crypto.randomUUID().replace(/-/g, ''));", - " return PREFIX + encodeBase32(BigInt(ts), 10) + encodeBase32(rand & ((1n << 80n) - 1n), 16);", - "}", - "", - "const entry = {", - " id: generateULID(),", - " kind: 'reminder',", - " status: 'pending',", - " due_at: '2020-01-01T00:00:00.000Z',", - " created_at: new Date().toISOString(),", - " payload: { message: 'concurrent-test' },", - " created_by: 'user',", - "}", - "", - "// OS-level O_APPEND ensures each write is atomic.", - "fs.appendFileSync(scheduleFile, JSON.stringify(entry) + '\\n', 'utf-8');", - ].join("\n"); - - // Spawn two OS-level child processes concurrently, each appending one line. - const childOpts = { - env: { ...process.env, SF_SCHEDULE_FILE: scheduleFile }, - }; - execFileSync(process.execPath, ["-e", childScript], childOpts); - execFileSync(process.execPath, ["-e", childScript], childOpts); - - const raw = readFileSync(scheduleFile, "utf-8"); - const lines = raw.split("\n").filter((l) => l.trim() !== ""); - - // Assert exactly 2 lines were written. - assert.equal( - lines.length, - 2, - `Expected 2 lines, got ${lines.length}: ${raw}`, - ); - - // Both lines must be well-formed JSON. - const entries = lines.map((line, i) => { - try { - return JSON.parse(line); - } catch { - throw new Error(`Line ${i + 1} is not valid JSON: ${line}`); - } - }); - - // Both IDs must be unique. + const entries = store.readEntries("project"); + assert.equal(entries.length, 2); const ids = entries.map((e) => e.id); assert.notEqual(ids[0], ids[1], "Expected two unique IDs"); }); diff --git a/src/resources/extensions/sf/tests/schedule-store.test.mjs b/src/resources/extensions/sf/tests/schedule-store.test.mjs index 098dbe7b2..9089ad269 100644 --- a/src/resources/extensions/sf/tests/schedule-store.test.mjs +++ b/src/resources/extensions/sf/tests/schedule-store.test.mjs @@ -8,7 +8,7 @@ * Consumer: CI test runner (vitest). */ import assert from "node:assert/strict"; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, it } from "vitest"; @@ -18,6 +18,7 @@ import { } from "../schedule/schedule-store.js"; import { isValidKind } from "../schedule/schedule-types.js"; import { generateULID } from "../schedule/schedule-ulid.js"; +import { closeDatabase } from "../sf-db.js"; describe("schedule-types", () => { describe("isValidKind", () => { @@ -96,6 +97,7 @@ describe("schedule-store", () => { }); afterEach(() => { + closeDatabase(); try { rmSync(testDir, { recursive: true }); } catch { @@ -126,11 +128,11 @@ describe("schedule-store", () => { assert.equal(entries[0].id, entry.id); }); - it("creates the file and directory if missing", () => { + it("writes DB-first without creating a legacy JSONL file", () => { const entry = makeEntry(); store.appendEntry("project", entry); const filePath = store._filePathForScope("project"); - assert.ok(readFileSync(filePath, "utf-8").includes(entry.id)); + assert.equal(existsSync(filePath), false); }); it("appends multiple entries", () => { @@ -289,19 +291,27 @@ describe("schedule-store", () => { }); }); - describe("corrupt line handling", () => { - it("skips corrupt JSONL lines and returns valid entries", () => { + describe("legacy JSONL import", () => { + it("skips corrupt JSONL lines and imports valid entries into DB", () => { const entry = makeEntry(); - store.appendEntry("project", entry); - - // Inject a corrupt line directly into the file const filePath = store._filePathForScope("project"); - const content = readFileSync(filePath, "utf-8"); - writeFileSync(filePath, content + "this is not json\n", "utf-8"); + mkdirSync(filePath.slice(0, filePath.lastIndexOf("/")), { + recursive: true, + }); + writeFileSync( + filePath, + `${JSON.stringify(entry)}\nthis is not json\n`, + "utf-8", + ); const entries = store.readEntries("project"); assert.equal(entries.length, 1); assert.equal(entries[0].id, entry.id); + + writeFileSync(filePath, "", "utf-8"); + const fromDb = store.readEntries("project"); + assert.equal(fromDb.length, 1); + assert.equal(fromDb[0].id, entry.id); }); }); diff --git a/src/resources/extensions/sf/tests/self-report-fixer.test.ts b/src/resources/extensions/sf/tests/self-report-fixer.test.ts index b8eccb2c8..2bfc29ef0 100644 --- a/src/resources/extensions/sf/tests/self-report-fixer.test.ts +++ b/src/resources/extensions/sf/tests/self-report-fixer.test.ts @@ -5,15 +5,21 @@ * deduplication, and severity categorization work correctly. */ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; import { categorizeBySeverity, classifyReportFixes, dedupReports, generateTriageSummary, + promoteSelfReportsToBacklog, } from "../self-report-fixer.js"; +import { closeDatabase, listBacklogItems, openDatabase } from "../sf-db.js"; describe("self-report-fixer", () => { + afterEach(() => { + closeDatabase(); + }); + test("detects validation-reviewer-rubric fix pattern", () => { const report = { id: "report-1", @@ -351,4 +357,63 @@ describe("self-report-fixer", () => { const fixDescription = fixes[0].fixFunction.toString(); expect(fixDescription.length).toBeGreaterThan(0); }); + + test("promoteSelfReportsToBacklog_when_db_available_creates_deduped_items", () => { + openDatabase(":memory:"); + const reports = [ + { + id: "report-1", + title: "runaway guard hard pause", + description: "Medium severity repeated pause in external repos", + severity: "medium", + resolvedAt: null, + }, + { + id: "report-2", + title: "RUNAWAY guard hard pause", + description: "Medium severity repeated pause in external repos", + severity: "medium", + resolvedAt: null, + }, + { + id: "report-3", + title: "low priority style note", + description: "Low severity note", + severity: "low", + resolvedAt: null, + }, + ]; + + const result = promoteSelfReportsToBacklog(process.cwd(), reports); + + expect(result.promoted).toHaveLength(1); + expect(result.updated).toHaveLength(0); + const items = listBacklogItems(); + expect(items).toHaveLength(1); + expect(items[0].id).toMatch(/^self-feedback\.[a-f0-9]{12}$/); + expect(items[0].source).toBe("self-feedback-triage"); + expect(items[0].note).toContain("reports=2"); + expect(items[0].note).toContain("report-1"); + }); + + test("promoteSelfReportsToBacklog_when_repeated_is_idempotent", () => { + openDatabase(":memory:"); + const reports = [ + { + id: "report-1", + title: "gap audit orphan command", + description: "Medium severity repeated orphan command", + severity: "medium", + resolvedAt: null, + }, + ]; + + const first = promoteSelfReportsToBacklog(process.cwd(), reports); + const second = promoteSelfReportsToBacklog(process.cwd(), reports); + + expect(first.promoted).toHaveLength(1); + expect(second.promoted).toHaveLength(0); + expect(second.updated).toEqual(first.promoted); + expect(listBacklogItems()).toHaveLength(1); + }); }); diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs index 51cc83b50..a79f8e03d 100644 --- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -13,7 +13,9 @@ import { afterEach, test } from "vitest"; import { closeDatabase, getDatabase, + getScheduleEntries, insertGateRun, + insertScheduleEntry, openDatabase, } from "../sf-db.js"; @@ -199,7 +201,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", const version = db .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); - assert.equal(version.version, 36); + assert.equal(version.version, 38); const taskSpec = db .prepare( "SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'", @@ -213,6 +215,26 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", }); }); +test("openDatabase_when_fresh_db_supports_schedule_entries", () => { + assert.equal(openDatabase(":memory:"), true); + + insertScheduleEntry("project", { + id: "sched-1", + schemaVersion: 1, + kind: "reminder", + status: "pending", + due_at: "2026-05-08T00:00:00.000Z", + created_at: "2026-05-07T00:00:00.000Z", + payload: { message: "check DB schedule" }, + created_by: "user", + }); + + const rows = getScheduleEntries("project"); + assert.equal(rows.length, 1); + assert.equal(rows[0].id, "sched-1"); + assert.deepEqual(rows[0].payload, { message: "check DB schedule" }); +}); + test("openDatabase_when_fresh_db_supports_gate_run_micro_usd", () => { assert.equal(openDatabase(":memory:"), true); diff --git a/src/resources/extensions/sf/triage-self-feedback.js b/src/resources/extensions/sf/triage-self-feedback.js index 2b7d58385..874c4e247 100644 --- a/src/resources/extensions/sf/triage-self-feedback.js +++ b/src/resources/extensions/sf/triage-self-feedback.js @@ -193,6 +193,7 @@ export async function applyTriageReport(basePath, report) { let requirementsAdded = 0; let entriesResolved = 0; let reportsAutoFixed = 0; + let reportsPromotedToBacklog = 0; // ── 1. Write promoted requirements ──────────────────────────────────────── if (report.promotedRequirements.length > 0) { @@ -267,9 +268,8 @@ export async function applyTriageReport(basePath, report) { // Integration point for self-report-fixer: read open reports and auto-apply // fixes where confidence > 0.85. try { - const { autoFixHighConfidenceReports } = await import( - "./self-report-fixer.js" - ); + const { autoFixHighConfidenceReports, promoteSelfReportsToBacklog } = + await import("./self-report-fixer.js"); const allOpen = [ ...readAllSelfFeedback(basePath), ...readUpstreamSelfFeedback(), @@ -278,10 +278,20 @@ export async function applyTriageReport(basePath, report) { if (allOpen.length > 0) { const result = await autoFixHighConfidenceReports(basePath, allOpen); reportsAutoFixed = result.applied.length; + const promoted = promoteSelfReportsToBacklog(basePath, allOpen, { + triageRunId: report.triageRunId ?? null, + }); + reportsPromotedToBacklog = + promoted.promoted.length + promoted.updated.length; } } catch { /* self-report fixer is optional; never block triage report application */ } - return { requirementsAdded, entriesResolved, reportsAutoFixed }; + return { + requirementsAdded, + entriesResolved, + reportsAutoFixed, + reportsPromotedToBacklog, + }; }