diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59d6087..38fa6f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,4 +13,5 @@ jobs: - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile - run: bun run typecheck + - run: bun run lint - run: bun test diff --git a/.oxlintrc.json b/.oxlintrc.json index 9d80910..52c4fb2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,12 +1,55 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["typescript", "unicorn", "oxc", "import", "promise"], + "plugins": ["typescript", "unicorn", "oxc", "import", "promise", "node"], "categories": { "correctness": "error", - "suspicious": "error" + "suspicious": "error", + "perf": "error", + "pedantic": "error" }, "options": { "typeAware": true }, - "ignorePatterns": ["node_modules", "scripts"] + "ignorePatterns": ["node_modules", "scripts"], + "rules": { + "no-await-in-loop": "off", + "typescript/prefer-readonly-parameter-types": "off", + "no-inline-comments": "off", + "typescript/strict-boolean-expressions": "off", + "require-await": "off", + "typescript/require-await": "off", + "max-lines-per-function": "off", + "max-lines": "off", + "max-depth": "off", + "max-classes-per-file": "off", + "import/max-dependencies": "off", + "require-unicode-regexp": "off", + "typescript/no-explicit-any": "error", + "typescript/use-unknown-in-catch-callback-variable": "error", + "typescript/no-require-imports": "error", + "typescript/no-var-requires": "error", + "import/no-cycle": "error", + "eqeqeq": ["error", "always", { "null": "ignore" }], + "unicorn/prefer-node-protocol": "error", + "no-negated-condition": "off", + "unicorn/no-negated-condition": "off", + "unicorn/prefer-top-level-await": "off" + }, + "overrides": [ + { + "files": ["test/**"], + "rules": { + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-unsafe-call": "off", + "typescript/no-unsafe-argument": "off", + "typescript/no-unsafe-return": "off", + "typescript/no-explicit-any": "off", + "typescript/no-confusing-void-expression": "off", + "typescript/strict-void-return": "off", + "no-promise-executor-return": "off", + "no-useless-return": "off" + } + } + ] } diff --git a/src/adapter/outbound.ts b/src/adapter/outbound.ts index 39f2893..c541488 100644 --- a/src/adapter/outbound.ts +++ b/src/adapter/outbound.ts @@ -17,7 +17,9 @@ export interface RetryOpts { } function defaultSleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); } export async function deliverPost(post: () => Promise, opts: RetryOpts): Promise { diff --git a/src/adapter/reply-stream.ts b/src/adapter/reply-stream.ts index 1f84c31..9d42e13 100644 --- a/src/adapter/reply-stream.ts +++ b/src/adapter/reply-stream.ts @@ -78,7 +78,9 @@ export class ReplyStream { for (const piece of pieces) { await this.opts.adapter .appendStream!(this.opts.venueId, m.messageId, piece) - .catch((e) => this.opts.log.warn("appendStream failed", { venueId: this.opts.venueId, error: String(e) })); + .catch((e: unknown) => { + this.opts.log.warn("appendStream failed", { venueId: this.opts.venueId, error: String(e) }); + }); } return m.messageId; }); @@ -154,7 +156,9 @@ export class ReplyStream { for (const [i, item] of this.cards.entries()) { await adapter .appendTaskUpdate(venueId, messageId, { id: `item-${i}`, title: item.text.slice(0, 250), status: item.done ? "complete" : "pending" }) - .catch((e) => log.warn("checklist card failed", { venueId, error: String(e) })); + .catch((e: unknown) => { + log.warn("checklist card failed", { venueId, error: String(e) }); + }); } } } diff --git a/src/ledger/conversations.ts b/src/ledger/conversations.ts index df6a14a..933a36b 100644 --- a/src/ledger/conversations.ts +++ b/src/ledger/conversations.ts @@ -99,7 +99,7 @@ function parseFiles(v: unknown): InboxMessage["files"] { ...(typeof item.size === "number" ? { size: item.size } : {}), }); } - return files.length ? files : undefined; + return files.length > 0 ? files : undefined; } function payloadOf(raw: unknown): { @@ -845,7 +845,7 @@ export function renderConversation(db: Database, identityId: string, key: Conver ...(lastNew ? { eventId: lastNew.id, principalId: lastNew.principalId } : {}), }); const address = cref ? `${cref} ${where}` : where; - const header = headerBits.length || cref ? `[${address}${headerBits.length ? `: ${headerBits.join(" | ")}` : ""}]\n` : ""; + const header = headerBits.length > 0 || cref ? `[${address}${headerBits.length > 0 ? `: ${headerBits.join(" | ")}` : ""}]\n` : ""; const tag = (surfaceTs: string | null, eventId?: string, principalId?: string | null): string => { if (!opts.refs || !surfaceTs) return ""; return `[${opts.refs.mint({ @@ -858,7 +858,7 @@ export function renderConversation(db: Database, identityId: string, key: Conver })}] `; }; const tail = tailOf(db, identityId, key, opts.beforeRowid, selfLabel); - const tailBlock = tail.length + const tailBlock = tail.length > 0 ? `earlier in ${where} (already heard — so you can tell who is talking to whom):\n${tail.map((t) => ` ${tag(t.surfaceTs, t.eventId, t.principalId)}${t.line}`).join("\n")}\n` : ""; const newLines = opts.newMessages.map((m) => `${tag(m.ts, m.id, m.principalId)}${mark(m)}${inboxLine(m)}`).join("\n"); diff --git a/src/ledger/db.ts b/src/ledger/db.ts index efdb3bc..687caad 100644 --- a/src/ledger/db.ts +++ b/src/ledger/db.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { Database, type SQLQueryBindings } from "bun:sqlite"; import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; import * as schema from "./schema"; @@ -306,13 +307,13 @@ const MIGRATIONS: Record = { export function openLedger(path: string): Database { const db = new Database(path, { create: true }); - db.exec("PRAGMA journal_mode = WAL"); - db.exec("PRAGMA foreign_keys = ON"); + db.run("PRAGMA journal_mode = WAL"); + db.run("PRAGMA foreign_keys = ON"); // The ladder must run BEFORE schema.sql on an existing database: schema.sql declares the // current shape (indexes included), and a migration may need to repair data (e.g. v5's timer // dedupe) before that shape can be enforced. - db.exec("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"); + db.run("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"); const row = one<{ version: number }>(db, "SELECT version FROM schema_version"); if (row !== null && row.version > SCHEMA_VERSION) { throw new Error(`ledger schema version ${row.version} is newer than this build supports (${SCHEMA_VERSION})`); @@ -324,20 +325,20 @@ export function openLedger(path: string): Database { // One step, one transaction, one version bump: a crash mid-ladder resumes at the failed // step on the next boot instead of wedging on non-idempotent DDL. db.transaction(() => { - db.exec(migration); + db.run(migration); db.query("UPDATE schema_version SET version = ?").run(v); })(); } } - db.exec(schemaSql()); + db.run(schemaSql()); if (row === null) db.query("INSERT INTO schema_version (version) VALUES (?)").run(SCHEMA_VERSION); return db; } function schemaSql(): string { const url = new URL("./schema.sql", import.meta.url); - return require("fs").readFileSync(url, "utf8"); + return readFileSync(url, "utf8"); } // M9: a database under WAL for weeks accumulates a growing -wal file if it's never checkpointed @@ -345,5 +346,5 @@ function schemaSql(): string { // TRUNCATE folds the WAL back into the main db and shrinks the -wal file. Safe to call // periodically on a low-frequency timer; a no-op on :memory: databases. export function checkpointWal(db: Database): void { - db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + db.run("PRAGMA wal_checkpoint(TRUNCATE)"); } diff --git a/src/ledger/inbox.ts b/src/ledger/inbox.ts index fc612a9..9fb3eda 100644 --- a/src/ledger/inbox.ts +++ b/src/ledger/inbox.ts @@ -48,7 +48,7 @@ function parseFiles(v: unknown): InboxMessage["files"] { ...(typeof item.size === "number" ? { size: item.size } : {}), }); } - return files.length ? files : undefined; + return files.length > 0 ? files : undefined; } export function messagesAfter(db: Database, identityId: string, afterRowid: number, limit = 200): InboxMessage[] { @@ -78,7 +78,7 @@ export function messagesAfter(db: Database, identityId: string, afterRowid: numb const p = isRecord(r.payload) ? r.payload : {}; const addressMode = asAddressMode(p.addressMode); const files = parseFiles(p.files); - return { + const msg: InboxMessage = { rowid: r.rowid, id: r.id, kind: asInboxKind(r.kind), @@ -88,10 +88,11 @@ export function messagesAfter(db: Database, identityId: string, afterRowid: numb text: asString(p.text), ts: typeof p.ts === "string" ? p.ts : null, receivedAt: r.receivedAt, - ...(typeof p.principalName === "string" ? { principalName: p.principalName } : {}), - ...(addressMode ? { addressMode } : {}), - ...(files?.length ? { files } : {}), }; + if (typeof p.principalName === "string") msg.principalName = p.principalName; + if (addressMode) msg.addressMode = addressMode; + if (files && files.length > 0) msg.files = files; + return msg; }); } diff --git a/src/ledger/tasks.ts b/src/ledger/tasks.ts index c9b223a..e9479cf 100644 --- a/src/ledger/tasks.ts +++ b/src/ledger/tasks.ts @@ -175,7 +175,7 @@ export function ledgerView(db: Database, identityId: string, recentTerminalsLimi .orderBy(desc(tasks.updatedAt)) .limit(recentTerminalsLimit) .all(); - return { open: openRows.map(rowToTask), recentTerminals: terminalRows.map(rowToTask) }; + return { open: openRows.map((row) => rowToTask(row)), recentTerminals: terminalRows.map((row) => rowToTask(row)) }; } export function requireTask(db: Database, taskId: string): Task { @@ -454,16 +454,16 @@ export function transition( cause: TransitionCause, opts: TransitionOpts = {}, ): Task { - db.exec("BEGIN IMMEDIATE"); + db.run("BEGIN IMMEDIATE"); try { const task = applyTransition(db, clock, taskId, to, cause); for (const entry of opts.extraAudit ?? []) { writeAudit(db, clock(), task.identityId, entry.kind, entry.payload); } - db.exec("COMMIT"); + db.run("COMMIT"); return task; } catch (err) { - db.exec("ROLLBACK"); + db.run("ROLLBACK"); throw err; } } @@ -506,12 +506,12 @@ export interface SteerResult { reply?: string; } -const TERMINAL_STATUSES: TaskStatus[] = ["done", "failed", "cancelled"]; +const TERMINAL_STATUSES = new Set(["done", "failed", "cancelled"]); export function steerTask(db: Database, clock: Clock, params: SteerParams): SteerResult { const task = requireTaskFor(db, params.identityId, params.taskId); - if (TERMINAL_STATUSES.includes(task.status)) { + if (TERMINAL_STATUSES.has(task.status)) { insertSteeringRow(db, clock, params.taskId, params.kind, params.payload, params.sourceEventId, true); return { applied: false, task, reply: `${task.id} already ${task.status}` }; } diff --git a/src/log.ts b/src/log.ts index 13a93ea..3ec8d1a 100644 --- a/src/log.ts +++ b/src/log.ts @@ -26,15 +26,23 @@ function redact(fields: Record): Record { } export function createLogger(opts: CreateLoggerOpts = {}): Logger { - const sink = opts.sink ?? ((line: string) => console.log(line)); + const sink = opts.sink ?? ((line: string) => { + console.log(line); + }); const clock = opts.clock ?? systemClock; const emit = (level: LogLevel, msg: string, fields?: Record) => { const record = { at: clock(), level, msg, ...(fields ? redact(fields) : {}) }; sink(JSON.stringify(record)); }; return { - info: (m, f) => emit("info", m, f), - warn: (m, f) => emit("warn", m, f), - error: (m, f) => emit("error", m, f), + info: (m, f) => { + emit("info", m, f); + }, + warn: (m, f) => { + emit("warn", m, f); + }, + error: (m, f) => { + emit("error", m, f); + }, }; } diff --git a/src/main.ts b/src/main.ts index 3a4b987..a177ac8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -85,7 +85,9 @@ async function cmdStart(): Promise { const db = openLedger(dbPath()); const clock = systemClock; const log = createLogger(); // structured JSON lines to stdout (§15) - const adapter = new SlackAdapter({ botToken, appToken, botUserId }, (line) => log.info("slack", { line })); + const adapter = new SlackAdapter({ botToken, appToken, botUserId }, (line) => { + log.info("slack", { line }); + }); // External tools an identity can be granted (KNOWN_TOOLS gates policy validation). The slack // registry (tools/slack.ts) needs the live adapter and the daemon's Slack credentials, so it's @@ -156,7 +158,9 @@ async function cmdStart(): Promise { }; process.on("SIGTERM", () => void shutdown("SIGTERM")); process.on("SIGINT", () => void shutdown("SIGINT")); - process.on("unhandledRejection", (e) => console.error("[main] unhandled rejection:", e)); + process.on("unhandledRejection", (e) => { + console.error("[main] unhandled rejection:", e); + }); } // The one real codex wiring, shared by start and replay — a replay that drives a different @@ -177,7 +181,9 @@ function makeCodexSessionFactory(log: ReturnType) { .filter(Boolean) .join(" "); const config = flags ? { ...DEFAULT_CODEX_CONFIG, command: `codex ${flags} app-server` } : DEFAULT_CODEX_CONFIG; - return new AppServerSession(config, tools, onEvent ?? ((e) => e.log && log.info("codex", { line: e.log })), { scrubEnv: allowlistEnv }); + return new AppServerSession(config, tools, onEvent ?? ((e) => { + if (e.log) log.info("codex", { line: e.log }); + }), { scrubEnv: allowlistEnv }); }; } @@ -325,14 +331,15 @@ function cmdStatus(): void { } async function main(): Promise { - const cmd = process.argv[2]; + const cmd = process.argv[2] ?? ""; switch (cmd) { case "start": return cmdStart(); case "doctor": return cmdDoctor(); case "status": - return cmdStatus(); + cmdStatus(); + return; case "replay": return cmdReplay(); default: @@ -340,7 +347,7 @@ async function main(): Promise { } } -main().catch((e) => { +main().catch((e: unknown) => { console.error(e); process.exit(1); }); diff --git a/src/policy/broker.ts b/src/policy/broker.ts index 01d260c..2c09240 100644 --- a/src/policy/broker.ts +++ b/src/policy/broker.ts @@ -138,7 +138,7 @@ function actionClassDecision(ctx: ToolCallContext, grant: IdentityConfig["grants // Canonical form of an action: sorted keys at every level, so the ref of "the call the human // approved" and "the call the worker retries" agree regardless of property order. export function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (Array.isArray(value)) return `[${value.map((v) => canonicalJson(v)).join(",")}]`; if (value !== null && typeof value === "object") { const entries = Object.entries(value).toSorted(([a], [b]) => (a < b ? -1 : 1)); return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`; diff --git a/src/policy/load.ts b/src/policy/load.ts index 4ec6417..191e0b5 100644 --- a/src/policy/load.ts +++ b/src/policy/load.ts @@ -13,6 +13,7 @@ import type { TasksConfig, TurnsConfig, } from "./schema"; +import { readFileSync } from "node:fs"; import { isRecord } from "../guard"; export function parsePolicyYaml(yamlText: string): unknown { @@ -87,7 +88,7 @@ function toIdentity(raw: unknown): IdentityConfig { persona: typeof i.persona === "string" ? i.persona : null, venueIds: strArr(i.venue_ids), learningSources: strArr(i.learning_sources), - grants: arr(i.grants).map(toGrant), + grants: arr(i.grants).map((g) => toGrant(g)), budget: toIdentityBudget(i.budget), ambient: toAmbient(i.ambient), venueInstructions: toVenueInstructions(i.venue_instructions), @@ -186,7 +187,7 @@ export function toPolicy(raw: unknown): Policy { operatorPrincipals: strArr(r.operator_principals), trustedBotPrincipals: strArr(r.trusted_bot_principals), defaultDmIdentity: typeof r.default_dm_identity === "string" ? r.default_dm_identity : null, - identities: arr(r.identities).map(toIdentity), + identities: arr(r.identities).map((i) => toIdentity(i)), turns: toTurns(r.turns), executions: toExecutions(r.executions), tasks: toTasks(r.tasks), @@ -289,7 +290,7 @@ export class PolicyValidationFailedError extends Error { } export function fileSource(path: string): () => string { - return () => require("fs").readFileSync(path, "utf8"); + return () => readFileSync(path, "utf8"); } // SPEC §16.2 — reload keeps the last-known-good policy on any invalid reload, with an @@ -335,6 +336,6 @@ export class PolicyStore { } const policy = toPolicy(raw); const errors = validatePolicy(policy, this.opts); - return errors.length ? { errors } : { policy }; + return errors.length > 0 ? { errors } : { policy }; } } diff --git a/src/replay/incident.ts b/src/replay/incident.ts index 627f375..75c970c 100644 --- a/src/replay/incident.ts +++ b/src/replay/incident.ts @@ -31,7 +31,7 @@ export function messageFiles(v: unknown): MessageFile[] | undefined { size: item.size, }); } - return files.length ? files : undefined; + return files.length > 0 ? files : undefined; } export interface IncidentWindow { diff --git a/src/replay/run.ts b/src/replay/run.ts index 68d6728..a79af92 100644 --- a/src/replay/run.ts +++ b/src/replay/run.ts @@ -102,8 +102,7 @@ class CaptureAdapter implements SurfaceAdapter { // today's world instead of the incident's (first run: failed lookups produced a duplicate // ticket and a fabricated "I checked"). export function recordingRegistries(captured: CapturedAction[], clock: Clock): ToolRegistry[] { - return INTEGRATION_REGISTRIES.map((r) => ({ - ...r, + return INTEGRATION_REGISTRIES.map((r) => Object.assign({}, r, { tools: Object.fromEntries( Object.entries(r.tools).map(([name, spec]) => [ name, @@ -147,7 +146,7 @@ export function snapshotSlackRegistry(db: Database): ToolRegistry { run: async (args: unknown) => { const a = isRecord(args) ? args : {}; const channel = typeof a.channel === "string" ? a.channel : ""; - const venueId = channel.replace(/^<#|[|>].*$/g, ""); + const venueId = channel.replaceAll(/^<#|[|>].*$/g, ""); if (!venueId) return { success: false, output: "read_channel needs a { channel }" }; return { success: true, output: JSON.stringify(messages([eq(events.venueId, venueId), isNull(events.threadRootId)], Math.min(typeof a.limit === "number" ? a.limit : 20, 100))) }; }, @@ -184,7 +183,9 @@ export interface ReplayOpts { // The db must already be rewound (incident.ts) — this function only relives and captures. export async function runReplay(opts: ReplayOpts): Promise { const clock = opts.clock ?? systemClock; - const out = opts.out ?? ((line: string) => console.log(line)); + const out = opts.out ?? ((line: string) => { + console.log(line); + }); const speed = opts.speed ?? 1; const adapter = new CaptureAdapter(clock, opts.db); const registries = [...recordingRegistries(adapter.captured, clock), snapshotSlackRegistry(opts.db)]; @@ -208,7 +209,11 @@ export async function runReplay(opts: ReplayOpts): Promise { const started = Date.now(); for (const e of opts.events) { const wait = started + (Date.parse(e.receivedAt) - t0) / speed - Date.now(); - if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + if (wait > 0) { + await new Promise((r) => { + setTimeout(r, wait); + }); + } const where = `${e.message.venueId}${e.message.threadRootTs ? ` thread=${e.message.threadRootTs}` : ""}`; out(`⟳ ${e.receivedAt} [${where}] <${e.message.principalId ?? "?"}>: ${e.message.text.slice(0, 120)}`); adapter.emit(e.message); diff --git a/src/service.ts b/src/service.ts index aa33ebc..1507468 100644 --- a/src/service.ts +++ b/src/service.ts @@ -140,7 +140,7 @@ export class Service { const recovery = recoverFromRestart(this.d.db, this.d.clock, { maxConsecutiveInterruptions: this.policy().executions.maxAttempts, }); - if (recovery.reopened.length || recovery.parked.length) { + if (recovery.reopened.length > 0 || recovery.parked.length > 0) { this.log.info("restart recovery", { reopened: recovery.reopened, parked: recovery.parked }); } // (1b) write earshot's "soul doc" to the workspace AGENTS.md — codex loads it as standing @@ -149,7 +149,9 @@ export class Service { // the daemon (it just falls back to codex's default voice). this.refreshSoul(); // (2) wire inbound + start the surface. - this.d.adapter.onMessage((msg) => this.onInbound(msg)); + this.d.adapter.onMessage((msg) => { + this.onInbound(msg); + }); await this.d.adapter.start(); this.log.info("service started"); // (2b) anything that arrived while we were down (or was never delivered before a crash) is @@ -172,8 +174,12 @@ export class Service { const sleep = msUntilNextTimer(this.d.db, this.d.clock, maxMs); this.heartbeat = setTimeout(() => { void this.tick() - .catch((e) => this.log.error("tick failed", { error: String(e) })) - .finally(() => this.scheduleHeartbeat()); + .catch((e: unknown) => { + this.log.error("tick failed", { error: String(e) }); + }) + .finally(() => { + this.scheduleHeartbeat(); + }); }, sleep); } @@ -181,7 +187,11 @@ export class Service { // Event-driven re-tick after work completes: a finished interactive turn may have created a // task (dispatch it), a finished execution frees a concurrency slot (fill it). Guarded so it // never fires during shutdown. - if (!this.stopping) void this.tick().catch((e) => this.log.error("tick failed", { error: String(e) })); + if (!this.stopping) { + void this.tick().catch((e: unknown) => { + this.log.error("tick failed", { error: String(e) }); + }); + } } // One scheduler pass (SPEC §17.3): fire due timers, then dispatch runnable tasks into freed @@ -228,7 +238,7 @@ export class Service { this.residentDebounce.delete(id); this.runWake(id); } - if (!this.wakes.size && !this.executions.size) return; + if (this.wakes.size === 0 && this.executions.size === 0) return; await Promise.allSettled([...this.wakes, ...this.executions]); } } @@ -275,23 +285,22 @@ export class Service { botPrincipalId: this.d.botPrincipalId, policy: this.policy(), newEventId: () => this.d.newId(), - onUnboundVenue: (venueId) => this.log.warn("message from unbound venue", { venueId }), + onUnboundVenue: (venueId) => { + this.log.warn("message from unbound venue", { venueId }); + }, }); if (result.kind === "addressed") { - if (result.event.addressMode === "thread_follow") { - // Thread-follow stays addressed for the ledger (participation, delivery, debts), but - // most of it is people talking to each other in a thread she's part of — whether it - // wakes her is the ear's judgment, same as observed chatter (SPEC §11). - this.scheduleEar(result.event.identityId); - } else { + if (result.event.addressMode !== "thread_follow") { // §5.2: the ack duty is met AT ADMISSION for a direct address (mention/DM), and a // direct address never waits on the ear — the mind wakes now. this.showThinking(result.event.venueId, result.event.threadRootId ?? result.event.ts); this.scheduleWake(result.event.identityId, 0); - // The ear bookkeeps direct addresses after the fact (never gating them): a direct ask - // becomes an attention item that outlives a whiffed wake. - this.scheduleEar(result.event.identityId); } + // Thread-follow stays addressed for the ledger (participation, delivery, debts), but + // most of it is people talking to each other in a thread she's part of — whether it + // wakes her is the ear's judgment, same as observed chatter (SPEC §11). A direct ask + // is bookkept after the fact (never gating): an attention item that outlives a whiffed wake. + this.scheduleEar(result.event.identityId); } else if (result.kind === "observed") { // The Ear: overheard chatter settles behind the debounce into an ear pass, which judges // whether the mind wakes. Every message reaches the inbox regardless — the ear gates @@ -322,7 +331,9 @@ export class Service { maxAttempts: 5, backoffMs: 500, maxBackoffMs: 30_000, - onExhausted: (error) => this.log.error("OUTBOUND DELIVERY FAILED — operator must convey this manually", { anchor, text, error: String(error) }), + onExhausted: (error) => { + this.log.error("OUTBOUND DELIVERY FAILED — operator must convey this manually", { anchor, text, error: String(error) }); + }, }).then((r) => r ?? { messageId: "undelivered" }); } @@ -381,7 +392,7 @@ export class Service { setTimeout(() => { this.earDebounce.delete(identityId); if (!this.stopping) this.runEarPass(identityId); - }, identity?.ambient.eventDebounceMs || 20_000), + }, identity?.ambient.eventDebounceMs ?? 20_000), ); } @@ -480,10 +491,8 @@ export class Service { }); } else if (decision === "close_ask") { if (!itemId || !closeAttentionItem(this.d.db, this.d.clock, identityId, itemId, why)) return { success: false, output: "no open item with that id" }; - } else if (decision === "reopen_ask") { - if (!itemId || !reopenAttentionItem(this.d.db, identityId, itemId)) { - return { success: false, output: "nothing to reopen with that id: either it does not exist, or the operator settled it and that stays settled" }; - } + } else if (decision === "reopen_ask" && (!itemId || !reopenAttentionItem(this.d.db, identityId, itemId))) { + return { success: false, output: "nothing to reopen with that id: either it does not exist, or the operator settled it and that stays settled" }; } return { success: true, output: "noted" }; }, @@ -513,7 +522,7 @@ export class Service { }), ) .join("\n\n"); - const debts = open.length + const debts = open.length > 0 ? `\n\nrecorded debts (close or reopen by itemId as the thread warrants):\n${open.map((i) => `- (${i.id}) <#${i.venueId}>${i.threadRootId ? ` thread=${i.threadRootId}` : ""}: ${i.what}`).join("\n")}` : ""; status = ( @@ -580,7 +589,7 @@ export class Service { // answered gate, and the typing shimmer. Thread-follow is addressed for the ledger but // not spoken TO her — a dead wake over thread chatter fails into the log, never the room // (SPEC §18: "a thread-follow turn's failure is ledger/log-only"). - const direct = pending.filter(isDirectAddress); + const direct = pending.filter((m) => isDirectAddress(m)); // Broker gating (guest checks) keys on the wake's most recent human addresser — policy, // not routing: no destination and no durable row derives from this pick. Everything that // lands somewhere (replies, reacts, cards, tasks, confirmations, the §14.2 fallback) @@ -794,11 +803,11 @@ export class Service { const heldDrafts = peekDrafts(this.d.db, identityId); // Draft and owed targets were read in an EARLIER wake, not this one — their refs carry // via='search', so speaking there starts with the conversation's card (read, then send). - const draftSection = heldDrafts.length + const draftSection = heldDrafts.length > 0 ? `\n\n[drafted last wake but not sent — the conversation had moved on; decide fresh what (if anything) to say]\n${heldDrafts.map((d) => `- [${refs.mint({ venueId: d.venueId, threadRootId: d.threadRootId, via: "search" })}] to <#${d.venueId}>${d.threadRootId ? ` thread=${d.threadRootId}` : ""}: ${d.text}`).join("\n")}` : ""; const owed = openItems(this.d.db, identityId); - const owedSection = owed.length + const owedSection = owed.length > 0 ? `\n\n[still owed]\n${owed .slice(0, ATTENTION_PROMPT_CAP) .map((i) => { @@ -811,16 +820,17 @@ export class Service { let status: TurnStatus = "failed"; // In-flight work finishes under the policy it started with (SPEC §16.2) — snapshot once. const turns = this.policy().turns; + const onResidentEvent = (e: AgentEvent) => { + if (e.event === "turn_failed" && e.log) failureCause = e.log; + if (e.log) this.log.info("codex", { line: e.log }); + }; try { // §14.2: retry a dead wake with backoff up to turns.max_retries, a fresh runtime // session each time — but only while it has touched nothing; replaying a turn that // already acted would duplicate its effects. for (let attempt = 0; attempt <= turns.maxRetries; attempt++) { failureCause = ""; - const session = this.d.sessionFactory(makeTools(), (e) => { - if (e.event === "turn_failed" && e.log) failureCause = e.log; - if (e.log) this.log.info("codex", { line: e.log }); - }); + const session = this.d.sessionFactory(makeTools(), onResidentEvent); try { await session.start(this.d.cwd); // SPEC §11 "No thread survives its wake": every wake (and every retry) is a fresh @@ -857,7 +867,11 @@ export class Service { if (status === "succeeded") break; this.log.error("resident wake attempt did not succeed", { identityId, attempt, status, cause: failureCause }); if (effects.length > 0) break; - if (attempt < turns.maxRetries) await new Promise((r) => setTimeout(r, turns.backoffMs * 2 ** attempt)); + if (attempt < turns.maxRetries) { + await new Promise((r) => { + setTimeout(r, turns.backoffMs * 2 ** attempt); + }); + } } // §14.2's one carve-out: someone directly addressed her and the model died before it // could answer. Honest, in the runtime's words when they read human. One fallback per @@ -882,9 +896,13 @@ export class Service { // own tail, never a post the ledger doesn't know about. const fallbackAct = recordAct(this.d.db, this.d.clock, identityId, wakeId, { kind: "posted", venueId: anchor.venueId, threadRootId: anchor.threadRootId, ts: null, text: fallbackText }); if (fallbackAct.inserted) { - await this.postMessage(anchor, fallbackText) - .then((r) => (r.messageId === "undelivered" ? deleteAct(this.d.db, wakeId, fallbackAct.actKey) : setActTs(this.d.db, wakeId, fallbackAct.actKey, r.messageId))) - .catch(() => deleteAct(this.d.db, wakeId, fallbackAct.actKey)); + try { + const r = await this.postMessage(anchor, fallbackText); + if (r.messageId === "undelivered") deleteAct(this.d.db, wakeId, fallbackAct.actKey); + else setActTs(this.d.db, wakeId, fallbackAct.actKey, r.messageId); + } catch { + deleteAct(this.d.db, wakeId, fallbackAct.actKey); + } } } } @@ -905,7 +923,7 @@ export class Service { for (const c of convos) consumeJudgment(this.d.db, this.d.clock, identityId, c, c.messages.at(-1)!.rowid); // Only the drafts THIS wake rendered, and only when the turn succeeded — a failed wake // returns them; the wake's own new withholds are untouched (they carry higher ids). - if (status === "succeeded" && heldDrafts.length) markDraftsConsumed(this.d.db, this.d.clock, identityId, heldDrafts.map((d) => d.id)); + if (status === "succeeded" && heldDrafts.length > 0) markDraftsConsumed(this.d.db, this.d.clock, identityId, heldDrafts.map((d) => d.id)); // The shimmer promised words; make sure it never outlives the wake. Only direct // addresses ever showed one (§5.2). for (const m of direct) { @@ -959,7 +977,7 @@ export class Service { }, buildPrompt: (turnNumber, guidance, tools) => { const spec = getTask(this.d.db, taskId)?.spec ?? ""; - const note = guidance.length ? `\n\nNew guidance:\n${guidance.join("\n")}` : ""; + const note = guidance.length > 0 ? `\n\nNew guidance:\n${guidance.join("\n")}` : ""; return turnNumber === 1 ? `${renderToolbox(buildToolbox(tools, this.registries))}\n\nYou are working ONE delegated task to a terminal state, as a background worker. Nothing you write is seen by anyone until you hand it back: end every run with exactly one outcome tool. task_complete when done, task_fail if it can't be done, task_ask if blocked on a human, or set_wake to check back later (a routine nothing-new check ends with set_wake alone). Your report goes to the main mind, who speaks to the room: write it as a complete handoff with receipts (links, ids, what changed), not a status diary.\n\n${spec}${note}` : `Continuation, turn ${turnNumber}. ${spec}${note}`; @@ -979,7 +997,7 @@ export class Service { this.deliverWorkerReport(taskId, r.outcome); return r; }) - .catch((e) => { + .catch((e: unknown) => { this.log.error("execution threw", { taskId, error: String(e) }); this.deliverWorkerReport(taskId, "failed"); }) @@ -1047,10 +1065,10 @@ export class Service { private refreshSoul(): void { try { const identities = this.policy().identities; - const personas = identities.map((i) => i.persona ?? "").filter((p) => p); + const personas = identities.map((i) => i.persona ?? "").filter((p) => p.length > 0); const knowledge = identities.map((i) => { const { kept, dropped } = coreWithinBudget(queryMemory(this.d.db, i.id, { tier: "core" }), this.policy().memory.coreCharBudget); - if (dropped.length) this.log.warn("core memory over budget — items truncated from the soul (§8.6 hygiene defect)", { identityId: i.id, dropped: dropped.length }); + if (dropped.length > 0) this.log.warn("core memory over budget — items truncated from the soul (§8.6 hygiene defect)", { identityId: i.id, dropped: dropped.length }); // The dropped count rides into the soul so SHE curates (§8.6: curation is the fix; // post-Collapse there is no distiller — an ordinary wake with memory tools is it). return { identity: i.id, facts: kept.map((m) => ({ content: m.content, asOf: m.lastConfirmedAt })), dropped: dropped.length }; @@ -1093,6 +1111,8 @@ export class Service { private track(set: Set>, promise: Promise): void { set.add(promise); - void promise.finally(() => set.delete(promise)); + void promise.finally(() => { + set.delete(promise); + }); } } diff --git a/src/tools/catalog.ts b/src/tools/catalog.ts index 12fa853..0d6f8fe 100644 --- a/src/tools/catalog.ts +++ b/src/tools/catalog.ts @@ -62,7 +62,7 @@ function grain(t: DynamicTool, opts: { description: string; write: boolean; wron export function topLevelMutationFields(query: string): string[] { const fields: string[] = []; // Strip string literals and comments so braces inside them don't skew depth. - const clean = query.replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/#[^\n]*/g, ""); + const clean = query.replaceAll(/"(?:[^"\\]|\\.)*"/g, '""').replaceAll(/#[^\n]*/g, ""); const opRe = /\bmutation\b[^{]*\{/g; let op: RegExpExecArray | null; while ((op = opRe.exec(clean))) { @@ -176,7 +176,7 @@ function linearRegistry(): ToolRegistry { if (fields.length === 0) return "couldn't identify the mutation being made — write one plain operation per call"; const allowed = new Set(Array.isArray(scope.mutations) ? scope.mutations.filter((x): x is string => typeof x === "string") : []); const outside = fields.filter((f) => !allowed.has(f)); - return outside.length ? `this workspace only lets me make these kinds of changes: ${[...allowed].join(", ")} — ${outside.join(", ")} isn't one of them` : null; + return outside.length > 0 ? `this workspace only lets me make these kinds of changes: ${[...allowed].join(", ")} — ${outside.join(", ")} isn't one of them` : null; }, }), }, @@ -327,7 +327,7 @@ export function buildToolbox(tools: DynamicTool[], registries: ToolRegistry[]): for (const r of registries) { const present = Object.keys(r.tools).filter((name) => exposed.has(name)); if (present.length === 0) continue; - present.forEach((name) => grouped.add(name)); + for (const name of present) grouped.add(name); const examples = (r.examples ?? []).filter((ex) => exposed.has(ex.tool)); toolbox.push({ registry: r.name, diff --git a/src/tools/slack.ts b/src/tools/slack.ts index a58fa19..a5a8115 100644 --- a/src/tools/slack.ts +++ b/src/tools/slack.ts @@ -48,7 +48,7 @@ type SlackApiResponse = { ok: boolean; error?: string } & Record ["outward"], run: async (args: unknown) => { const a = fields(args); - const name = optString(a.name)?.replace(/:/g, "").trim().toLowerCase(); + const name = optString(a.name)?.replaceAll(":", "").trim().toLowerCase(); const emojiUrl = optString(a.url); if (!name || !emojiUrl) return { success: false, output: "emoji_set needs { name, url } — the emoji's name (no colons) and a URL of its image" }; if (!deps.adminToken) return { success: false, output: "custom emoji aren't wired up here yet — an admin credential is missing; a workspace admin can add it by hand meanwhile" }; diff --git a/src/turn-runner/ear-soul.ts b/src/turn-runner/ear-soul.ts index 444660b..34ea9de 100644 --- a/src/turn-runner/ear-soul.ts +++ b/src/turn-runner/ear-soul.ts @@ -45,7 +45,7 @@ export function composeEarInstructions(botPrincipalId: string, identitySummaries const parts = [EAR_SOUL]; for (const s of identitySummaries) { const persona = s.persona ? `\n\n${s.persona.trim()}` : ""; - const facts = s.facts.length ? `\n\nWhat she knows:\n${s.facts.map((f) => `- ${f}`).join("\n")}` : ""; + const facts = s.facts.length > 0 ? `\n\nWhat she knows:\n${s.facts.map((f) => `- ${f}`).join("\n")}` : ""; parts.push( `## Who you listen for (${s.identity})\n\nIn the room she is <@${botPrincipalId}>. A message speaking to <@${botPrincipalId}> is speaking to her; a line from any other id is someone else's voice, never hers.${persona}${facts}`, ); diff --git a/src/turn-runner/soul.ts b/src/turn-runner/soul.ts index 07cde95..f6d98e8 100644 --- a/src/turn-runner/soul.ts +++ b/src/turn-runner/soul.ts @@ -234,8 +234,7 @@ export function composeInstructions( toolDigests: { identity: string; digest: string }[] = [], ): string { const voices = personas.map((p) => p.trim()).filter((p) => p.length > 0); - const parts = [SOUL]; - parts.push(...voices.map((v) => `## Persona\n\n${v}`)); + const parts = [SOUL, ...voices.map((v) => `## Persona\n\n${v}`)]; for (const k of knowledge) { if (k.facts.length === 0) continue; // §8.6: truncation is the safety net, curation is the fix — and post-Collapse the curator diff --git a/src/turn-runner/toolset.ts b/src/turn-runner/toolset.ts index d9e21fc..9122533 100644 --- a/src/turn-runner/toolset.ts +++ b/src/turn-runner/toolset.ts @@ -18,7 +18,7 @@ import { } from "../ledger/tasks"; import { writeMemory, retractMemory, queryMemory, setMemoryTier, type MemoryTier } from "../ledger/memory"; import { closeAttentionItemsForThread } from "../ledger/attention"; -import { searchArchive } from "../ledger/search"; +import { searchArchive, type SearchHit } from "../ledger/search"; import { engage, stepBack, conversationOf, convoKey, provenanceOfRef, lastSpeakerIn, type RefTable } from "../ledger/conversations"; import { queryAudit, type AuditKind } from "../ledger/audit"; import { decide, exposableForKind, actionRefFor, canonicalJson, type ToolCatalog, type TurnKind } from "../policy/broker"; @@ -533,7 +533,7 @@ function reactTool(ctx: ToolsetContext): ToolFactory { }, impl: async (args) => { const a = fields(args); - const emoji = asString(a.emoji).replace(/:/g, "").trim(); + const emoji = asString(a.emoji).replaceAll(":", "").trim(); if (!emoji) return { success: false, output: "empty emoji name" }; const ref = optString(a.ref); const target = ref ? ctx.refs?.get(ref) : undefined; @@ -824,21 +824,39 @@ function searchTool(ctx: ToolsetContext): ToolFactory { after: optString(a.after), before: optString(a.before), limit: typeof a.limit === "number" ? a.limit : undefined, - }).map((h) => ({ - kind: h.kind, - text: h.text.slice(0, 700), - at: h.at, + }).map((h) => { + const hit: { + kind: SearchHit["kind"]; + text: string; + at: string; + ref?: string; + venueId?: string; + threadRootId?: string; + principalId?: string; + memoryId?: string; + tier?: SearchHit["tier"]; + permalink?: string; + } = { + kind: h.kind, + text: h.text.slice(0, 700), + at: h.at, + }; // A search hit is addressable but UNREAD: its ref carries via='search', so the first // send there returns the conversation's card instead of posting. - ...(h.venueId && h.ts && ctx.refs - ? { ref: ctx.refs.mint({ venueId: h.venueId, threadRootId: h.threadRootId ?? null, ts: h.ts, via: "search" }) } - : {}), - ...(h.venueId ? { venueId: h.venueId } : {}), - ...(h.threadRootId ? { threadRootId: h.threadRootId } : {}), - ...(h.principalId ? { principalId: h.principalId } : {}), - ...(h.memoryId ? { memoryId: h.memoryId, tier: h.tier } : {}), - ...(h.venueId && h.ts && ctx.permalink?.(h.venueId, h.ts) ? { permalink: ctx.permalink(h.venueId, h.ts) } : {}), - })); + if (h.venueId && h.ts && ctx.refs) { + hit.ref = ctx.refs.mint({ venueId: h.venueId, threadRootId: h.threadRootId ?? null, ts: h.ts, via: "search" }); + } + if (h.venueId) hit.venueId = h.venueId; + if (h.threadRootId) hit.threadRootId = h.threadRootId; + if (h.principalId) hit.principalId = h.principalId; + if (h.memoryId) { + hit.memoryId = h.memoryId; + hit.tier = h.tier; + } + const permalink = h.venueId && h.ts ? ctx.permalink?.(h.venueId, h.ts) : undefined; + if (permalink) hit.permalink = permalink; + return hit; + }); return { success: true, output: JSON.stringify(hits) }; }, }; diff --git a/src/turn-runner/turn.ts b/src/turn-runner/turn.ts index cc61d3d..2f148d9 100644 --- a/src/turn-runner/turn.ts +++ b/src/turn-runner/turn.ts @@ -93,7 +93,11 @@ export async function runTurn(params: RunTurnParams): Promise { let status: TurnStatus; if (params.envelope) { const envelope = params.envelope; - const timeout = new Promise<"timed_out">((resolve) => setTimeout(() => resolve("timed_out"), envelope.timeoutMs)); + const timeout = new Promise<"timed_out">((resolve) => { + setTimeout(() => { + resolve("timed_out"); + }, envelope.timeoutMs); + }); // The envelope bounds honest work; the stall watchdog bounds a dead runtime. One number // cannot do both (2026-07-27: a 210s envelope starved multi-minute jobs; 2026-08-10: a // blackholed gateway burned the full envelope per attempt). Activity keeps a turn alive to diff --git a/test/ear.test.ts b/test/ear.test.ts index 1c929fb..cff0ea4 100644 --- a/test/ear.test.ts +++ b/test/ear.test.ts @@ -80,8 +80,10 @@ describe("the ear gates waking, never delivery", () => { const { db, adapter, service } = harness(async (_turn, tools, _act, prompt) => { const verdict = tools.get("verdict"); if (!verdict) return; // the mind: nothing needed - verdictResults.push(await verdict.run({ decision: "hold", why: "teammates have it" })); - verdictResults.push(await verdict.run({ decision: "hold", why: "teammates have it", ref: refIn(prompt, "lunch") })); + verdictResults.push( + await verdict.run({ decision: "hold", why: "teammates have it" }), + await verdict.run({ decision: "hold", why: "teammates have it", ref: refIn(prompt, "lunch") }), + ); }); await service.start(); adapter.emit(msg({ text: "who's in for lunch", ts: "3.1" })); diff --git a/test/helpers.ts b/test/helpers.ts index 7e04e16..28b342c 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,6 +1,6 @@ -import { existsSync, unlinkSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; +import { existsSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { Clock } from "../src/ledger/clock"; export function fakeClock(start = "2026-07-02T00:00:00Z"): Clock & { set: (iso: string) => void; advance: (iso: string) => void } { @@ -36,7 +36,7 @@ export function firstRef(sess: { prompts: string[] }): string { // or a conversation line ("[r1 <#C1> thread=…]"). Tests address exactly like the model: from // what was rendered, never from composed coordinates. export function refIn(prompt: string, pattern: string | RegExp): string { - const re = typeof pattern === "string" ? new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) : pattern; + const re = typeof pattern === "string" ? new RegExp(pattern.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")) : pattern; for (const line of prompt.split("\n")) { if (!re.test(line)) continue; const m = /\[(r\d+)[\] ]/.exec(line); diff --git a/test/migrations.test.ts b/test/migrations.test.ts index aac4413..d27d47d 100644 --- a/test/migrations.test.ts +++ b/test/migrations.test.ts @@ -32,7 +32,7 @@ describe("schema migrations", () => { test("openLedger migrates an on-disk v1 database all the way to the current version", () => { const path = tempDbPath("earshot-migration-test"); const seed = new Database(path, { create: true }); - seed.exec(` + seed.run(` CREATE TABLE schema_version (version INTEGER NOT NULL); CREATE TABLE tasks ( id TEXT PRIMARY KEY, @@ -148,7 +148,7 @@ describe("schema migrations", () => { seed.query("DROP INDEX timers_singleton_pending").run(); // Reconstruct the v4-era shape the current schema no longer carries: the ladder's later // steps (v6, v11, v13) expect these to exist so they can alter and finally drop them. - seed.exec(`CREATE TABLE thread_participation (venue_id TEXT NOT NULL, thread_root_id TEXT NOT NULL, identity_id TEXT NOT NULL, first_at TEXT NOT NULL, PRIMARY KEY (venue_id, thread_root_id)); + seed.run(`CREATE TABLE thread_participation (venue_id TEXT NOT NULL, thread_root_id TEXT NOT NULL, identity_id TEXT NOT NULL, first_at TEXT NOT NULL, PRIMARY KEY (venue_id, thread_root_id)); CREATE TABLE conversation_threads (identity_id TEXT NOT NULL, venue_id TEXT NOT NULL, thread_root_id TEXT NOT NULL, codex_thread_id TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (identity_id, venue_id, thread_root_id));`); seed.query("DROP TABLE conversations").run(); // v12 hasn't happened yet seed.query("DROP TABLE acts").run(); // v13 hasn't either @@ -156,7 +156,7 @@ describe("schema migrations", () => { // ...and v7 hasn't: drop the tier column and the FTS floor so the ladder rebuilds them seed.query("ALTER TABLE memory_items DROP COLUMN tier").run(); seed.query("ALTER TABLE tasks DROP COLUMN tier").run(); // v10 hasn't happened yet either - seed.exec("DROP TRIGGER events_fts_insert; DROP TRIGGER memory_fts_insert; DROP TABLE events_fts; DROP TABLE memory_fts"); + seed.run("DROP TRIGGER events_fts_insert; DROP TRIGGER memory_fts_insert; DROP TABLE events_fts; DROP TABLE memory_fts"); const insert = seed.query("INSERT INTO timers (id, kind, identity_id, subject_id, due_at, fired_at) VALUES (?, ?, ?, NULL, ?, ?)"); insert.run("ambient_tick:eng:a", "ambient_tick", "eng", "2026-07-04T01:10:00Z", null); insert.run("ambient_tick:eng:b", "ambient_tick", "eng", "2026-07-04T00:56:00Z", null); // earliest — survives @@ -185,7 +185,7 @@ describe("schema migrations", () => { const seed = openLedger(path); // fresh v13 shape... seed.query("UPDATE schema_version SET version = 12").run(); // ...rewound to the SHIPPED v12 shape: conversations WITH the CHECK, thread_participation present. - seed.exec(`DROP TABLE conversations; DROP TABLE acts; DROP TABLE drafts; + seed.run(`DROP TABLE conversations; DROP TABLE acts; DROP TABLE drafts; DROP INDEX IF EXISTS events_conversation; DROP INDEX IF EXISTS events_root_ts; CREATE TABLE conversations ( identity_id TEXT NOT NULL, venue_id TEXT NOT NULL, thread_root_id TEXT NOT NULL, diff --git a/test/search.test.ts b/test/search.test.ts index ef28192..5c0a641 100644 --- a/test/search.test.ts +++ b/test/search.test.ts @@ -68,7 +68,7 @@ describe("searchArchive (SPEC §8.7)", () => { expect(searchArchive(db, "eng", { query: "export slow", venueId: "C2", principalId: "U9" })).toHaveLength(1); const timeboxed = searchArchive(db, "eng", { query: "export slow", after: "2026-07-07T00:00:00Z", before: "2026-07-08T12:00:00Z" }); expect(timeboxed.filter((h) => h.kind === "message")).toHaveLength(1); - expect(timeboxed.filter((h) => h.kind === "message")[0]!.venueId).toBe("C2"); + expect(timeboxed.find((h) => h.kind === "message")!.venueId).toBe("C2"); // no venue filter → the memory participates expect(searchArchive(db, "eng", { query: "export" }).some((h) => h.kind === "memory")).toBe(true); }); diff --git a/test/toolset.test.ts b/test/toolset.test.ts index e0c37cc..2f47b3a 100644 --- a/test/toolset.test.ts +++ b/test/toolset.test.ts @@ -4,7 +4,7 @@ import { queryMemory } from "../src/ledger/memory"; import { getTask, transition } from "../src/ledger/tasks"; import { makeRefTable } from "../src/ledger/conversations"; import { buildToolset, BUILTIN_REGISTRIES, type ToolsetContext } from "../src/turn-runner/toolset"; -import { buildToolbox, integrationCatalog, INTEGRATION_REGISTRIES } from "../src/tools/catalog"; +import { buildToolbox, integrationCatalog, INTEGRATION_REGISTRIES, topLevelMutationFields } from "../src/tools/catalog"; import type { IdentityConfig } from "../src/policy/schema"; import type { ToolCatalog } from "../src/policy/broker"; import type { Clock } from "../src/ledger/clock"; @@ -773,8 +773,6 @@ describe("outward-call idempotency is durable (ladder audit)", () => { }); describe("linear_write mutation scoping (ladder: blast radius as configuration)", () => { - const { topLevelMutationFields } = require("../src/tools/catalog"); - test("extracts top-level mutation fields, resolving aliases, ignoring nested selections and string braces", () => { expect(topLevelMutationFields('mutation($input: X!) { commentCreate(input: $input) { comment { id body } } }')).toEqual(["commentCreate"]); expect( @@ -786,8 +784,8 @@ describe("linear_write mutation scoping (ladder: blast radius as configuration)" }); test("the grant's allowlist refuses an unlisted operation before any call, and passes listed ones", async () => { - const { integrationCatalog: catalogOf } = require("../src/tools/catalog"); - const check = catalogOf().linear_write.scopeCheck!; + const check = integrationCatalog().linear_write?.scopeCheck; + if (!check) throw new Error("expected linear_write.scopeCheck"); const scope = { mutations: ["commentCreate", "issueCreate", "issueUpdate", "attachmentCreate"] }; expect(check(scope, { query: "mutation($i: X!) { commentCreate(input: $i) { success } }" })).toBeNull(); const denied = check(scope, { query: "mutation { issueDelete(id: \"x\") { success } }" });