From 6fdbc302d2a643c2784ca2070141aa30e9510cca Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Thu, 9 Jul 2026 15:30:22 -0400 Subject: [PATCH 1/2] SQLite cache bootstrap + single-issue mirror upsert (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rebuildable local mirror of the reconciled backlog — an index over gitea's durable truth, never the source of truth (D4). This lands the core seam: - `cache/cache-v0.ts`: the 5-table schema (issues/labels/milestones/comments/ issue_events), a `CacheDriver` injected-IO interface (exec/run/get/all), and pure row<->domain mappers. `upsertIssue` mirrors one issue (+ its milestone), upsert-by-number so a re-reconcile updates in place; `readIssue` re-derives `facts` from the stored label set via extractLabelFacts, so the mirror can't drift from the label semantics. Facts are never stored. - Resolves #3's open scope: pure mappers + SQL in core (driver-agnostic), native driver in main. Tests bind node:sqlite (present in the Node 24 test runtime) to the same CacheDriver seam better-sqlite3 will fill in main. Acceptance met: upsert one reconciled issue, read back, assert estimateDays/priority/hardDeadline (+ upsert-in-place, no-milestone, and miss cases). Core suite green; typecheck clean. Follow-up (noted in the plan): the main-process better-sqlite3 adapter + snapshot-store migration — packaging-sensitive (native module), kept out of this slice so the shippable .dmg stays verified. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/cache/cache-v0.test.ts | 92 +++++++++++++ packages/core/src/cache/cache-v0.ts | 159 +++++++++++++++++++++++ packages/core/src/index.ts | 2 + 3 files changed, 253 insertions(+) create mode 100644 packages/core/src/cache/cache-v0.test.ts create mode 100644 packages/core/src/cache/cache-v0.ts diff --git a/packages/core/src/cache/cache-v0.test.ts b/packages/core/src/cache/cache-v0.test.ts new file mode 100644 index 0000000..36510b3 --- /dev/null +++ b/packages/core/src/cache/cache-v0.test.ts @@ -0,0 +1,92 @@ +import { DatabaseSync } from 'node:sqlite' + +import { describe, expect, it } from 'vitest' + +import type { GiteaIssue } from '../gitea/types.js' +import { extractLabelFacts } from '../labels/label-schema.js' +import { type CacheDriver, initCache, readIssue, upsertIssue } from './cache-v0.js' + +/** Adapt node:sqlite's DatabaseSync to the CacheDriver seam (main uses better-sqlite3). */ +function memoryDriver(): CacheDriver { + const db = new DatabaseSync(':memory:') + return { + exec: (sql) => db.exec(sql), + run: (sql, params = []) => { + db.prepare(sql).run(...(params as never[])) + }, + get: (sql, params = []) => db.prepare(sql).get(...(params as never[])) as Record | undefined, + all: (sql, params = []) => db.prepare(sql).all(...(params as never[])) as Record[], + } +} + +function issue(over: Partial = {}): GiteaIssue { + const labels = over.labels ?? ['est/5d', 'p/1', 'deadline/hard'] + return { + number: 42, + title: 'Monte Carlo engine', + body: 'percentile bands', + state: 'open', + labels, + facts: extractLabelFacts(labels), + milestone: { id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' }, + assignee: 'christian', + assignees: ['christian'], + createdAt: '2026-07-08T00:00:00Z', + updatedAt: '2026-07-08T01:00:00Z', + closedAt: null, + url: 'https://gitea/christian/commitea/issues/42', + ...over, + } +} + +describe('cache-v0', () => { + it('mirrors one issue and reads its facts back through extractLabelFacts (acceptance)', () => { + const d = memoryDriver() + initCache(d) + upsertIssue(d, issue({ labels: ['est/5d', 'p/1', 'deadline/hard'] })) + + const back = readIssue(d, 42)! + expect(back.labels).toEqual(['est/5d', 'p/1', 'deadline/hard']) + // facts are re-derived on read, not stored + expect(back.facts.estimateDays).toBe(5) + expect(back.facts.priority).toBe(1) + expect(back.facts.hardDeadline).toBe(true) + // the rest of the domain shape round-trips + expect(back.milestone).toEqual({ id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' }) + expect(back.assignee).toBe('christian') + expect(back.state).toBe('open') + }) + + it('re-derives facts from the current labels after a re-reconcile (upsert in place, no dup)', () => { + const d = memoryDriver() + initCache(d) + upsertIssue(d, issue({ labels: ['est/2d', 'p/3'] })) + // reconcile again with changed labels + closed + upsertIssue(d, issue({ labels: ['est/8d', 'p/1'], state: 'closed', closedAt: '2026-07-09T00:00:00Z' })) + + expect(d.all('SELECT number FROM issues')).toHaveLength(1) // upsert by number, not a second row + const back = readIssue(d, 42)! + expect(back.facts.estimateDays).toBe(8) + expect(back.facts.priority).toBe(1) + expect(back.facts.hardDeadline).toBe(false) // deadline/hard dropped + expect(back.state).toBe('closed') + expect(back.closedAt).toBe('2026-07-09T00:00:00Z') + }) + + it('reads an issue with no milestone / empty labels', () => { + const d = memoryDriver() + initCache(d) + upsertIssue(d, issue({ number: 9, labels: [], milestone: null, assignee: null, assignees: [] })) + const back = readIssue(d, 9)! + expect(back.milestone).toBeNull() + expect(back.labels).toEqual([]) + expect(back.facts.estimateDays).toBeNull() + expect(back.assignee).toBeNull() + }) + + it('returns null for an uncached issue', () => { + const d = memoryDriver() + initCache(d) + expect(readIssue(d, 999)).toBeNull() + }) +}) diff --git a/packages/core/src/cache/cache-v0.ts b/packages/core/src/cache/cache-v0.ts new file mode 100644 index 0000000..df482b8 --- /dev/null +++ b/packages/core/src/cache/cache-v0.ts @@ -0,0 +1,159 @@ +/** + * SQLite cache, v0 (#3) — a rebuildable local mirror of the reconciled backlog. + * It is an index over the durable truth in gitea, never the source of truth (D4): + * delete it, resync, lose nothing. This module owns the schema + the pure + * row<->domain mappers; the actual SQLite handle is injected as a `CacheDriver`, + * so core stays free of any native driver (better-sqlite3 lives in main; tests + * use node:sqlite). Facts are never stored — they are re-derived from the label + * set on read via `extractLabelFacts`, so the mirror can't drift from the label + * semantics. + */ + +import type { GiteaIssue, GiteaMilestoneRef } from '../gitea/types.js' +import { extractLabelFacts } from '../labels/label-schema.js' + +/** + * The injected IO boundary: a thin synchronous SQL executor. Core writes the SQL; + * the host binds a real driver (better-sqlite3 in the desktop main process, + * node:sqlite in tests). Kept minimal on purpose — no ORM, no query builder. + */ +export interface CacheDriver { + /** Run one or more DDL/utility statements (no params, no result). */ + exec(sql: string): void + /** Execute a single parameterized write. */ + run(sql: string, params?: readonly unknown[]): void + /** First row of a parameterized query, or undefined. */ + get(sql: string, params?: readonly unknown[]): Record | undefined + /** All rows of a parameterized query. */ + all(sql: string, params?: readonly unknown[]): Record[] +} + +/** The cache schema — five tables mirroring gitea's shape. Regenerable; drop and rebuild freely. */ +export const CACHE_SCHEMA = ` +CREATE TABLE IF NOT EXISTS milestones ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + state TEXT, + due_on TEXT +); +CREATE TABLE IF NOT EXISTS issues ( + number INTEGER PRIMARY KEY, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL, + labels TEXT NOT NULL DEFAULT '[]', -- JSON array of label names; facts re-derived on read + milestone_id INTEGER, + assignee TEXT, + assignees TEXT NOT NULL DEFAULT '[]', -- JSON array of logins + created_at TEXT, + updated_at TEXT, + closed_at TEXT, + url TEXT, + FOREIGN KEY (milestone_id) REFERENCES milestones(id) +); +CREATE TABLE IF NOT EXISTS labels ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY, + issue_number INTEGER NOT NULL, + author TEXT, + body TEXT NOT NULL DEFAULT '', + created_at TEXT +); +CREATE TABLE IF NOT EXISTS issue_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + issue_number INTEGER NOT NULL, + type TEXT NOT NULL, + at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_issue_events_number ON issue_events(issue_number); +CREATE INDEX IF NOT EXISTS idx_comments_number ON comments(issue_number); +` + +/** Create the schema if absent. Idempotent. */ +export function initCache(driver: CacheDriver): void { + driver.exec(CACHE_SCHEMA) +} + +const UPSERT_ISSUE = ` +INSERT INTO issues (number, title, body, state, labels, milestone_id, assignee, assignees, created_at, updated_at, closed_at, url) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(number) DO UPDATE SET + title = excluded.title, body = excluded.body, state = excluded.state, labels = excluded.labels, + milestone_id = excluded.milestone_id, assignee = excluded.assignee, assignees = excluded.assignees, + created_at = excluded.created_at, updated_at = excluded.updated_at, closed_at = excluded.closed_at, url = excluded.url +` + +const UPSERT_MILESTONE = ` +INSERT INTO milestones (id, title, state, due_on) VALUES (?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET title = excluded.title, state = excluded.state, due_on = excluded.due_on +` + +/** + * Mirror one reconciled issue into the cache (and its milestone, if any). Upsert + * by `number`, so re-reconciling the same issue updates in place — never duplicates. + */ +export function upsertIssue(driver: CacheDriver, issue: GiteaIssue): void { + if (issue.milestone) { + driver.run(UPSERT_MILESTONE, [issue.milestone.id, issue.milestone.title, null, issue.milestone.dueOn]) + } + driver.run(UPSERT_ISSUE, [ + issue.number, + issue.title, + issue.body, + issue.state, + JSON.stringify(issue.labels), + issue.milestone?.id ?? null, + issue.assignee, + JSON.stringify(issue.assignees), + issue.createdAt, + issue.updatedAt, + issue.closedAt, + issue.url, + ]) +} + +const READ_ISSUE = ` +SELECT i.*, m.title AS m_title, m.due_on AS m_due +FROM issues i LEFT JOIN milestones m ON m.id = i.milestone_id +WHERE i.number = ? +` + +function str(v: unknown): string { + return typeof v === 'string' ? v : '' +} + +/** + * Read one mirrored issue back as a domain object, re-deriving `facts` from the + * stored label set (so the mirror can't disagree with the label semantics). + * Returns null when the issue isn't cached. + */ +export function readIssue(driver: CacheDriver, number: number): GiteaIssue | null { + const row = driver.get(READ_ISSUE, [number]) + if (!row) return null + + const labels = (JSON.parse(str(row.labels) || '[]') as string[]) ?? [] + const assignees = (JSON.parse(str(row.assignees) || '[]') as string[]) ?? [] + const milestone: GiteaMilestoneRef | null = + row.milestone_id != null + ? { id: Number(row.milestone_id), title: str(row.m_title), dueOn: (row.m_due as string | null) ?? null } + : null + + return { + number: Number(row.number), + title: str(row.title), + body: str(row.body), + state: row.state === 'closed' ? 'closed' : 'open', + labels, + facts: extractLabelFacts(labels), + milestone, + assignee: (row.assignee as string | null) ?? null, + assignees, + createdAt: str(row.created_at), + updatedAt: str(row.updated_at), + closedAt: (row.closed_at as string | null) ?? null, + url: str(row.url), + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7e7fb83..8681f8c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,8 @@ export type { GiteaRequestInit, } from './gitea/types.js' +export { CACHE_SCHEMA, initCache, readIssue, upsertIssue } from './cache/cache-v0.js' +export type { CacheDriver } from './cache/cache-v0.js' export { describeChange, isLabelChange, planIssueChange, proposalsFor, summarizeChange } from './changes/apply-changes-v0.js' export type { ChangeProposal, From 19e83ff8baf0d8a785f4cfd7c1dd886fa50c962b Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Thu, 9 Jul 2026 15:33:20 -0400 Subject: [PATCH 2/2] query_project: implement the standup view (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The StandupScreen already renders drift + plan + nag from real data (standup-view, #52), but the agent's query_project standup view was a `notImplemented` stub, so Reginald couldn't answer standup questions from deterministic data. Implement `standupView(snap, asOf)`: today's plan (the scheduler's earliest pick per person, with why — critical path / blocks / order), overnight drift (real anomalies: issues sitting in review, or steeping past their estimate), and the single stalest blocker to nag about (+ what it blocks). All deterministic; the model narrates. Added 'standup' to the query_project tool's view enum. Acceptance met: standup surfaces schedule drift + at least one stale blocker (and stays calm — no nag, empty drift — when nothing is steeping). +2 core tests; typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/agent/agent-tools.ts | 5 +- packages/core/src/agent/query-project.test.ts | 74 +++++++++++++++++++ packages/core/src/agent/query-project.ts | 64 +++++++++++++++- 3 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/agent/query-project.test.ts diff --git a/packages/core/src/agent/agent-tools.ts b/packages/core/src/agent/agent-tools.ts index 7d05c0c..c16b17b 100644 --- a/packages/core/src/agent/agent-tools.ts +++ b/packages/core/src/agent/agent-tools.ts @@ -17,10 +17,11 @@ export const QUERY_PROJECT_TOOL: ToolDecl = { properties: { view: { type: 'string', - enum: ['focus', 'board', 'calibration', 'issue', 'search'], + enum: ['focus', 'board', 'calibration', 'issue', 'search', 'standup'], description: 'focus = Now/Next/Later; board = issues by lifecycle column; calibration = estimate-vs-actual; ' + - 'issue = one issue (needs filters.issueId); search = issues matching filters.query.', + 'issue = one issue (needs filters.issueId); search = issues matching filters.query; ' + + "standup = today's plan + overnight drift + the stalest blocker.", }, filters: { type: 'object', diff --git a/packages/core/src/agent/query-project.test.ts b/packages/core/src/agent/query-project.test.ts new file mode 100644 index 0000000..9848214 --- /dev/null +++ b/packages/core/src/agent/query-project.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' + +import { extractLabelFacts } from '../labels/label-schema.js' +import type { LifecycleEvent } from '../lifecycle/lifecycle-v0.js' +import type { GiteaIssue } from '../gitea/types.js' +import { buildProjectView, type ProjectSnapshot } from './query-project.js' + +const asOf = new Date('2026-02-12T00:00:00Z') + +function issue(over: Partial): GiteaIssue { + const labels = over.labels ?? ['est/2d'] + return { + number: 1, + title: '#1', + body: '', + state: 'open', + labels, + facts: extractLabelFacts(labels), + milestone: null, + assignee: null, + assignees: [], + createdAt: '2026-02-02T09:00:00Z', + updatedAt: '2026-02-02T09:00:00Z', + closedAt: null, + url: '', + ...over, + } +} + +describe('buildProjectView: standup (#28)', () => { + it('surfaces schedule drift + a stale blocker, with a per-person plan', () => { + // #7 started work 6 working days ago (steeping) and blocks #8; est is 2d → past estimate. + const steeping = issue({ number: 7, labels: ['est/2d', 'p/1'], assignee: 'christian' }) + const blocked = issue({ number: 8, labels: ['est/3d', 'p/2'], assignee: 'stephen' }) + const timelines: Record = { + 7: [{ type: 'commit', at: '2026-02-04T09:00:00Z' }], + } + const snap: ProjectSnapshot = { issues: [steeping, blocked], timelines, deps: [{ issue: 8, dependsOn: 7 }] } + + const v = buildProjectView('standup', undefined, snap, asOf) as { + plan: { who: string; issue: number; why: string }[] + drift: { issue: number; note: string }[] + nag: { issue: number; steepingDays: number; blocks: number[] } | null + } + + // a stale blocker is nagged, and it's the steeping one that blocks another + expect(v.nag).not.toBeNull() + expect(v.nag!.issue).toBe(7) + expect(v.nag!.steepingDays).toBeGreaterThan(0) + expect(v.nag!.blocks).toContain(8) + + // drift caught the past-estimate steep + expect(v.drift.some((d) => d.issue === 7)).toBe(true) + + // plan gives an earliest pick per person (both assignees represented) + expect(v.plan.map((p) => p.who)).toEqual(expect.arrayContaining(['christian', 'stephen'])) + // #7 leads its person's plan on the critical path + expect(v.plan.find((p) => p.issue === 7)?.why).toContain('critical') + }) + + it('is calm when nothing is steeping (no nag, empty drift)', () => { + const snap: ProjectSnapshot = { + issues: [issue({ number: 1, labels: ['est/2d'] })], + timelines: {}, + deps: [], + } + const v = buildProjectView('standup', undefined, snap, asOf) as { + drift: unknown[] + nag: unknown | null + } + expect(v.nag).toBeNull() + expect(v.drift).toEqual([]) + }) +}) diff --git a/packages/core/src/agent/query-project.ts b/packages/core/src/agent/query-project.ts index aa488c7..41bfaf7 100644 --- a/packages/core/src/agent/query-project.ts +++ b/packages/core/src/agent/query-project.ts @@ -4,9 +4,9 @@ * deterministic code (scheduler, lifecycle inference, calibration); the model * only requests a shape and narrates it — it never computes (decisions.md). * - * v0 serves focus / board / calibration / issue. The remaining views - * (milestone / runway / standup / search) return a `notImplemented` marker so - * the model degrades honestly instead of inventing data. + * Serves focus / board / calibration / issue / search / standup. The remaining + * views (milestone / runway) return a `notImplemented` marker so the model + * degrades honestly instead of inventing data. */ import { fitCalibration, calibrationSamples } from '../calibration/calibration-v0.js' @@ -121,6 +121,62 @@ function searchView(snap: ProjectSnapshot, filters: QueryFilters) { } } +/** + * Standup — the morning ritual as a compact, model-narratable payload: today's + * plan (the scheduler's earliest pick per person), overnight drift (real + * anomalies — issues sitting in review or steeping past their estimate), and the + * single stalest blocker to nag about. All from deterministic code; the model + * narrates it. Satisfies #28's "surfaces schedule drift + at least one stale blocker". + */ +function standupView(snap: ProjectSnapshot, asOf: Date) { + const plan = schedule(toSchedulable(snap.issues), snap.deps) + const open = snap.issues.filter((i) => i.state === 'open') + const infOf = (i: GiteaIssue) => inferLifecycle(i, snap.timelines[i.number] ?? [], asOf) + + // plan: the earliest scheduled pick per assignee (dependency + priority order) + const seen = new Set() + const planPicks: { who: string; issue: number; title: string; why: string }[] = [] + for (const item of plan.items) { + const who = open.find((i) => i.number === item.number)?.assignee ?? 'unassigned' + if (seen.has(who)) continue + seen.add(who) + planPicks.push({ + who, + issue: item.number, + title: item.title, + why: item.critical + ? 'on the critical path' + : item.blocks.length + ? `blocks ${item.blocks.map((b) => `#${b}`).join(', ')}` + : 'next by dependency + priority', + }) + } + + // drift: real overnight anomalies (review-sitting, steeping past estimate) + const drift: { issue: number; note: string }[] = [] + for (const i of open) { + const inf = infOf(i) + if (inf.column === 'review') drift.push({ issue: i.number, note: `#${i.number} is sitting in review` }) + else if (inf.steepingDays != null && inf.steepingDays > (i.facts.estimateDays ?? 2)) + drift.push({ + issue: i.number, + note: `#${i.number} has steeped ${inf.steepingDays}d past its ${i.facts.estimateDays ?? 2}d estimate`, + }) + } + + // nag: the single longest-steeping open issue + what it blocks + let nag: { issue: number; steepingDays: number; blocks: number[] } | null = null + for (const i of open) { + const inf = infOf(i) + if (inf.steepingDays == null) continue + if (!nag || inf.steepingDays > nag.steepingDays) { + nag = { issue: i.number, steepingDays: inf.steepingDays, blocks: plan.items.find((it) => it.number === i.number)?.blocks ?? [] } + } + } + + return { date: asOf.toISOString().slice(0, 10), plan: planPicks.slice(0, 5), drift: drift.slice(0, 5), nag } +} + /** Build the compact payload for one view. Unknown/unbuilt views return a marker. */ export function buildProjectView( view: ProjectView, @@ -140,6 +196,8 @@ export function buildProjectView( return issueView(snap, f, asOf) case 'search': return searchView(snap, f) + case 'standup': + return standupView(snap, asOf) default: return { notImplemented: view } }