diff --git a/packages/asil-improvement-loop/src/__tests__/task-queue.test.ts b/packages/asil-improvement-loop/src/__tests__/task-queue.test.ts index 97f41e4..1d71506 100644 --- a/packages/asil-improvement-loop/src/__tests__/task-queue.test.ts +++ b/packages/asil-improvement-loop/src/__tests__/task-queue.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { TaskQueue, priorityFor } from '../task-queue.js'; +import { TaskQueue, priorityFor, meetsSeverityFloor } from '../task-queue.js'; import { mkCategoryTask, mkTask } from './helpers.js'; describe('TaskQueue', () => { @@ -119,4 +119,48 @@ describe('TaskQueue', () => { const q = new TaskQueue(path); expect(q.stats().total).toBe(0); }); + + describe('minSeverity floor (Codex #3)', () => { + it('drops below-floor tasks at enqueue, keeps at-or-above-floor', () => { + const q = new TaskQueue(path, { minSeverity: 'high' }); + q.enqueue(mkCategoryTask('type-error', 'critical', 'crit')); + q.enqueue(mkCategoryTask('type-error', 'high', 'high')); + q.enqueue(mkCategoryTask('type-error', 'medium', 'med')); + q.enqueue(mkCategoryTask('type-error', 'low', 'low')); + expect(q.stats().queued).toBe(2); + const ids = q.snapshot().map((i) => i.task.id).sort(); + expect(ids).toEqual(['crit', 'high']); + }); + + it('default floor (low) accepts every severity — backwards compatible', () => { + const q = new TaskQueue(path); + for (const sev of ['critical', 'high', 'medium', 'low'] as const) { + q.enqueue(mkCategoryTask('type-error', sev, sev)); + } + expect(q.stats().queued).toBe(4); + }); + + it('skips below-floor tasks persisted under an older lower floor', () => { + // Persist a medium task with the default (low) floor. + const lenient = new TaskQueue(path); + lenient.enqueue(mkCategoryTask('type-error', 'medium', 'med')); + lenient.enqueue(mkCategoryTask('type-error', 'critical', 'crit')); + + // Reload with a stricter floor — the medium one must not be served. + const strict = new TaskQueue(path, { minSeverity: 'high' }); + const first = strict.dequeue(); + expect(first?.task.id).toBe('crit'); + expect(strict.dequeue()).toBeNull(); + }); + }); + + describe('meetsSeverityFloor', () => { + it('passes when severity is at or above the floor', () => { + expect(meetsSeverityFloor('critical', 'high')).toBe(true); + expect(meetsSeverityFloor('high', 'high')).toBe(true); + expect(meetsSeverityFloor('medium', 'high')).toBe(false); + expect(meetsSeverityFloor('low', 'low')).toBe(true); + expect(meetsSeverityFloor('critical', 'low')).toBe(true); + }); + }); }); diff --git a/packages/asil-improvement-loop/src/index.ts b/packages/asil-improvement-loop/src/index.ts index f047ef9..dc9b902 100644 --- a/packages/asil-improvement-loop/src/index.ts +++ b/packages/asil-improvement-loop/src/index.ts @@ -7,7 +7,12 @@ * a PR if everything passes. Budget enforced via asil-cost-controller. */ export * from './types.js'; -export { TaskQueue, priorityFor } from './task-queue.js'; +export { + TaskQueue, + priorityFor, + meetsSeverityFloor, + type TaskQueueOptions, +} from './task-queue.js'; export { CycleDetector, type CycleCheck, diff --git a/packages/asil-improvement-loop/src/loop.ts b/packages/asil-improvement-loop/src/loop.ts index 37cc18c..4f2037a 100644 --- a/packages/asil-improvement-loop/src/loop.ts +++ b/packages/asil-improvement-loop/src/loop.ts @@ -107,7 +107,10 @@ export async function runLoop( const queue = deps.queue ?? - new TaskQueue(config.queuePath, { maxAttempts: config.maxAttempts }); + new TaskQueue(config.queuePath, { + maxAttempts: config.maxAttempts, + ...(config.minSeverity ? { minSeverity: config.minSeverity } : {}), + }); const cycleDetector = deps.cycleDetector ?? new CycleDetector(); const outcomes: TaskOutcome[] = []; let cyclesDetected = 0; diff --git a/packages/asil-improvement-loop/src/task-queue.ts b/packages/asil-improvement-loop/src/task-queue.ts index 4ae60da..4c20493 100644 --- a/packages/asil-improvement-loop/src/task-queue.ts +++ b/packages/asil-improvement-loop/src/task-queue.ts @@ -4,9 +4,18 @@ import type { ImprovementTask, QueueItem, QueueStatus, + Severity, TaskCategory, } from './types.js'; -import { CATEGORY_PRIORITY } from './types.js'; +import { CATEGORY_PRIORITY, SEVERITY_RANK } from './types.js'; + +/** True when `severity` is at least as severe as `floor`. */ +export function meetsSeverityFloor( + severity: Severity, + floor: Severity, +): boolean { + return SEVERITY_RANK[severity] <= SEVERITY_RANK[floor]; +} interface SerializedQueueItem { task: Omit & { discoveredAt: string }; @@ -21,20 +30,29 @@ interface SerializedQueueItem { export interface TaskQueueOptions { /** Default max attempts per task. Default: 2. */ maxAttempts?: number; + /** + * Severity floor. Tasks less severe than this are never enqueued, and any + * below-floor task persisted under an older, lower floor is skipped at + * dequeue. Default: `low` (accept everything). + */ + minSeverity?: Severity; } export class TaskQueue { private items: QueueItem[] = []; private readonly persistPath: string; private readonly maxAttempts: number; + private readonly minSeverity: Severity; constructor(persistPath: string, options: TaskQueueOptions = {}) { this.persistPath = persistPath; this.maxAttempts = options.maxAttempts ?? 2; + this.minSeverity = options.minSeverity ?? 'low'; this.load(); } enqueue(task: ImprovementTask): void { + if (!meetsSeverityFloor(task.severity, this.minSeverity)) return; if (this.items.some((i) => i.task.id === task.id)) return; this.items.push({ @@ -51,7 +69,10 @@ export class TaskQueue { dequeue(): QueueItem | null { const next = this.items.find( - (i) => i.status === 'queued' && i.attempts < i.maxAttempts, + (i) => + i.status === 'queued' && + i.attempts < i.maxAttempts && + meetsSeverityFloor(i.task.severity, this.minSeverity), ); if (!next) return null; diff --git a/packages/asil-improvement-loop/src/types.ts b/packages/asil-improvement-loop/src/types.ts index 43b65e2..ddf76fc 100644 --- a/packages/asil-improvement-loop/src/types.ts +++ b/packages/asil-improvement-loop/src/types.ts @@ -40,6 +40,18 @@ export const CATEGORY_SKILL_MAP: Record = { export type Severity = 'critical' | 'high' | 'medium' | 'low'; +/** + * Ordinal rank for severities — lower is more severe. Used to compare a + * task's severity against a configured floor (`minSeverity`): a task passes + * the floor when its rank is `<=` the floor's rank. + */ +export const SEVERITY_RANK: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, +}; + export interface ImprovementTask { id: string; category: TaskCategory; @@ -171,6 +183,12 @@ export interface ImprovementLoopConfig { repoRoot: string; queuePath: string; skipCategories: TaskCategory[]; + /** + * Severity floor. Tasks less severe than this are never enqueued or run — + * the loop spends its budget on what matters. Defaults to `low` (accept + * everything) when omitted, preserving prior behavior. + */ + minSeverity?: Severity; codexConfig: { apiKey: string; model: string; diff --git a/packages/asil-runners/src/run-a.ts b/packages/asil-runners/src/run-a.ts index 995d4bc..7d350d6 100644 --- a/packages/asil-runners/src/run-a.ts +++ b/packages/asil-runners/src/run-a.ts @@ -27,6 +27,7 @@ import { type ImprovementLoopConfig, type LanguageProfile, type LoopDeps, + type Severity, type TaskCategory, } from 'asil-improvement-loop'; import { @@ -56,8 +57,12 @@ interface Flags { transcriptsDir: string | null; /** Language profile selector. Default: ts. */ profile: 'ts' | 'python'; + /** Severity floor — tasks below this are never enqueued or run. Default: low. */ + minSeverity: Severity; } +const SEVERITIES: readonly Severity[] = ['critical', 'high', 'medium', 'low']; + function resolveProfile(name: 'ts' | 'python'): LanguageProfile { return name === 'python' ? pythonProfile : typescriptProfile; } @@ -70,6 +75,7 @@ function parseFlags(argv: readonly string[]): Flags | null { skipQuestions: false, transcriptsDir: null, profile: 'ts', + minSeverity: 'low', }; for (let i = 0; i < argv.length; i += 1) { const a = argv[i]; @@ -97,6 +103,16 @@ function parseFlags(argv: readonly string[]): Flags | null { } flags.profile = v; i += 1; + } else if (a === '--min-severity' && argv[i + 1]) { + const v = argv[i + 1]!; + if (!SEVERITIES.includes(v as Severity)) { + console.error( + `Unknown --min-severity value: ${v} (expected one of: ${SEVERITIES.join(', ')})`, + ); + return null; + } + flags.minSeverity = v as Severity; + i += 1; } else if (a === '--help' || a === '-h') { return null; } @@ -119,6 +135,8 @@ Options: --profile NAME Language profile for the scanner. One of: ts, python. Defaults to ts. Python requires pytest-json-report and mypy on PATH. + --min-severity SEV Severity floor. Tasks below SEV are never enqueued or + run. One of: critical, high, medium, low (default: low). --help, -h Show this help`; export async function main(): Promise { @@ -163,6 +181,7 @@ export async function main(): Promise { console.log( ` Skip: ${flags.skipCategories.length > 0 ? flags.skipCategories.join(', ') : 'none'}`, ); + console.log(` Min severity: ${flags.minSeverity}`); console.log(` Dry run: ${flags.dryRun}\n`); const profile = resolveProfile(flags.profile); @@ -277,6 +296,7 @@ export async function main(): Promise { process.env.ASIL_QUEUE_PATH ?? resolve(env.REPO_ROOT, '.asil', 'usage-data', 'queue.json'), skipCategories: flags.skipCategories, + minSeverity: flags.minSeverity, codexConfig: { apiKey: 'OPENAI_API_KEY', model: 'gpt-4o',