From ec71deca5d112d80cf6684aa0cfd3d07f4e184d3 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 18 Jun 2026 08:39:52 +0900 Subject: [PATCH 01/87] chore: bump to 26.6.1 Co-Authored-By: Claude --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6b166f4..1bf5900 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.6.0", + "version": "26.6.1", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From 225f3bcc92b55a466f866fee07c769ab643fbf91 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Fri, 19 Jun 2026 17:02:53 +0900 Subject: [PATCH 02/87] feat(cli): consent gate for legacy migration + star-repo nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - init --upgrade prompts Y/N before the destructive legacy-doc move; the new --migrate-legacy flag opts in non-interactively. -y/--yes deliberately does not auto-migrate — the destructive action names itself. - previewLegacyMigration (non-mutating) drives the prompt and the skip/instruction messaging; migrateLegacyDocuments is gated behind explicit consent at every layer. - New src/cli/star-nudge.ts: interactive init/upgrade ask to star the repo only when gh can't already confirm a star. A decline defers (re-asks next interactive run); an actual star (gh-confirmed, or accept + best-effort gh PUT) suppresses per-project via .tenet/.state/config.json under star_nudge. TENET_NO_STAR_NUDGE opts out entirely. - CLI-only — never fires from the autonomous skill boot loop; skipped when non-interactive (no TTY / --yes). Co-Authored-By: Claude --- CLAUDE.md | 2 +- README.md | 26 ++++++- src/cli/index.ts | 61 ++++++++++++++- src/cli/init.test.ts | 54 ++++++++++++-- src/cli/init.ts | 99 +++++++++++++++++++----- src/cli/star-nudge.test.ts | 147 ++++++++++++++++++++++++++++++++++++ src/cli/star-nudge.ts | 149 +++++++++++++++++++++++++++++++++++++ 7 files changed, 505 insertions(+), 33 deletions(-) create mode 100644 src/cli/star-nudge.test.ts create mode 100644 src/cli/star-nudge.ts diff --git a/CLAUDE.md b/CLAUDE.md index e97b73c..0929abd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ The system has four layers: 3. **MCP Server** (`src/mcp/`) — Exposes 18 tools via `@modelcontextprotocol/server`. Entry point at `src/mcp/index.ts`. Each tool in `src/mcp/tools/` registers itself with a Zod input schema and handler. Key tools: `tenet_start_job`, `tenet_continue` (server-side continuation), `tenet_compile_context`, `tenet_register_jobs` (loads job DAG, requires `feature` slug), `tenet_retry_job` (resets completed/failed jobs to pending), `tenet_report_blocking_finding` (report-only escalation), `tenet_validate_clarity`, `tenet_add_steer` (creates steer messages in SQLite), `tenet_start_eval` (dispatches code critic + test critic + playwright eval), `tenet_init` (initialize project from MCP). Agent selection is CLI-only via `tenet config --agent `. -4. **CLI** (`src/cli/`) — Commander.js program with `init`, `serve`, `status`, `config`, and `db` maintenance commands. `tenet init` scaffolds a `.tenet/` directory structure and copies skill files to `.claude/skills/tenet/` and `.agents/skills/tenet/` with generated package-version metadata in each installed `SKILL.md`. `tenet init --upgrade` creates a verified SQLite-safe backup, runs pending DB migrations, migrates legacy document dirs into `.tenet/archive/legacy-v1/`, and refreshes generated skills/MCP configs. `tenet db check` runs read-only integrity/index diagnostics, `tenet db backup` creates a verified standalone SQLite backup, and `tenet db snapshot`/`restore-snapshot` write and restore Git-safe portable snapshots under `.tenet/state-snapshot/`. +4. **CLI** (`src/cli/`) — Commander.js program with `init`, `serve`, `status`, `config`, and `db` maintenance commands. `tenet init` scaffolds a `.tenet/` directory structure and copies skill files to `.claude/skills/tenet/` and `.agents/skills/tenet/` with generated package-version metadata in each installed `SKILL.md`. `tenet init --upgrade` creates a verified SQLite-safe backup, runs pending DB migrations, then — only with consent — performs the one-time destructive move of legacy document dirs into `.tenet/archive/legacy-v1/`: it prompts Y/N (default No) interactively, or requires the explicit `--migrate-legacy` flag in non-interactive contexts (`-y/--yes` deliberately does not auto-migrate). It then refreshes generated skills/MCP configs. `init`/`--upgrade` also run a "star the repo" nudge (`src/cli/star-nudge.ts`; state per-project in `.tenet/.state/config.json` under `star_nudge`; a decline defers to the next run rather than suppressing permanently; opt out with `TENET_NO_STAR_NUDGE`) at the end of an interactive run — this is CLI-only and never fires from the autonomous skill boot loop. `tenet db check` runs read-only integrity/index diagnostics, `tenet db backup` creates a verified standalone SQLite backup, and `tenet db snapshot`/`restore-snapshot` write and restore Git-safe portable snapshots under `.tenet/state-snapshot/`. ## Key Types (`src/types/index.ts`) diff --git a/README.md b/README.md index ced39b4..6054ede 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,8 @@ Three classes: `context` (informational), `directive` (priority change), `emerge # Initialize project (interactive agent selection + optional Playwright MCP install) tenet init [path] tenet init --agent claude-code --skip-playwright-check -tenet init --upgrade # Update DB, skills/configs; MIGRATES legacy docs (see note below) +tenet init --upgrade # Update DB, skills/configs; prompts before moving legacy docs (see note below) +tenet init --upgrade --migrate-legacy # Non-interactive: run the destructive legacy-doc move without prompting # Start MCP server tenet serve @@ -161,9 +162,14 @@ tenet config --timeout 120 # Set job timeout (minutes) ### Upgrading from ≤ 26.6.0 Versions after 26.6.0 introduce the Tenet **document lifecycle**. Running -`tenet init --upgrade` on an existing project now performs a one-time, -**breaking** migration of your `.tenet/` layout: - +`tenet init --upgrade` on an existing project offers a one-time, **breaking** +migration of your `.tenet/` layout — and it asks first. + +- **Consent gate:** when legacy document directories are present, `tenet init --upgrade` + prompts Y/N (default **No**) before moving anything. In non-interactive contexts + (CI, agent-driven) it skips the move and prints the opt-in command: + `tenet init --upgrade --migrate-legacy`. `-y/--yes` does **not** auto-migrate — + the destructive move always has to name itself. - **Moves** legacy document directories — `spec/`, `interview/`, `decomposition/`, `harness/`, `journal/`, `visuals/`, `bootstrap/`, `steer/`, `knowledge/`, and `DESIGN.md` — into `.tenet/archive/legacy-v1/`. @@ -177,6 +183,18 @@ locations, and moving those files breaks `tenet_compile_context` and active runs first. The migration runs once (subsequent upgrades skip it), and upgrades warn at runtime if pending/running jobs are present. +### Support prompt + +On an interactive `tenet init` or `tenet init --upgrade`, Tenet asks whether +you'd like to star [github.com/JeiKeiLim/tenet](https://github.com/JeiKeiLim/tenet) +— and only if it can't already confirm you've starred it (via `gh`). It is +skipped in non-interactive/`--yes` runs and never fires from the autonomous +skill loop. Declining ("no") just defers to the next run; once you've actually +starred — confirmed via `gh`, or you accept and the one-key star succeeds — it +stops asking that project. The "starred" marker lives per-project in +`.tenet/.state/config.json` under `star_nudge`. Set `TENET_NO_STAR_NUDGE=1` to +suppress it entirely. + ## MCP Tools | Tool | Purpose | diff --git a/src/cli/index.ts b/src/cli/index.ts index 856bcb2..578e72d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -14,11 +14,14 @@ import { mergeClaudeLocalSettings, mergeCodexProjectTrust, mergeOpenCodePermission, + previewLegacyMigration, promptAgent, promptYesNo, readStateConfig, writeStateConfig, } from './init.js'; +import type { LegacyMigrationPreview } from './init.js'; +import { maybeStarNudge } from './star-nudge.js'; import { DEFAULT_JOB_TIMEOUT_MINUTES, formatMaxRetries, @@ -157,6 +160,23 @@ const runPlaywrightCheckFlow = async (projectPath: string): Promise => { } }; +/** + * Interactive consent prompt for the destructive legacy-document migration. + * Shows exactly what would move and the dangling-job risk, then asks Y/N + * (default No). Returns the user's decision. + */ +const promptMigrateLegacy = async (preview: LegacyMigrationPreview): Promise => { + console.log('\nTenet found legacy top-level document directories from an older layout.'); + console.log( + ` would move into .tenet/archive/legacy-v1/: ${[...preview.dirs, ...preview.files].join(', ')}`, + ); + console.log( + ' This is destructive for pre-migration jobs: their artifact_paths will dangle ' + + '(compile_context / tenet_retry_job will fail). Finish or cancel active runs first.', + ); + return promptYesNo('\nMigrate these legacy documents now?', false); +}; + const startBackgroundServer = (projectPath: string): void => { const entryPath = fileURLToPath(new URL('../../dist/mcp/index.js', import.meta.url)); const child = fork(entryPath, [], { @@ -189,13 +209,18 @@ const run = async (): Promise => { .argument('[path]', 'Project path', '.') .option('--agent ', 'Default agent adapter (claude-code, opencode, codex)') .option('--upgrade', 'Upgrade existing project: migrate DB, overwrite skills and MCP configs, preserve user docs') + .option( + '--migrate-legacy', + 'On upgrade, run the one-time destructive move of legacy doc dirs (spec/, interview/, …) into .tenet/archive/legacy-v1/. Required in non-interactive contexts; prompts interactively otherwise.', + ) .option('--skip-playwright-check', 'Skip the Playwright MCP availability check (useful for one-line installs)') .option('--skip-pre-approval', 'Skip the MCP tool pre-approval flow (do not touch .claude/settings.local.json, opencode.json permissions, .codex/config.toml trust)') - .option('-y, --yes', 'Assume yes for all interactive prompts (non-interactive init, useful for CI)') + .option('-y, --yes', 'Assume yes for all interactive prompts (non-interactive init, useful for CI). Does NOT auto-run the destructive --migrate-legacy move.') .description('Initialize Tenet project scaffold') .action(async (targetPath: string, options: { agent?: string; upgrade?: boolean; + migrateLegacy?: boolean; skipPlaywrightCheck?: boolean; skipPreApproval?: boolean; yes?: boolean; @@ -203,10 +228,32 @@ const run = async (): Promise => { const projectPath = path.resolve(targetPath); if (options.upgrade) { + const tenetRoot = path.join(projectPath, '.tenet'); + const preview = previewLegacyMigration(tenetRoot); + + // Consent gate for the destructive legacy move: + // - --migrate-legacy flag → explicit opt-in (any context) + // - interactive TTY with work to do → Y/N prompt (default No) + // - otherwise (non-TTY, no flag) → skip; tell the user how to opt in + // -y/--yes deliberately does NOT auto-migrate (it is a broad "yes" to + // prompts; the destructive action must name itself). + let migrate = false; + if (options.migrateLegacy) { + migrate = true; + } else if (process.stdin.isTTY && preview.hasWork && !preview.alreadyMigrated) { + migrate = await promptMigrateLegacy(preview); + } + try { - initProject(projectPath, { upgrade: true }); + initProject(projectPath, { upgrade: true, migrateLegacy: migrate }); console.log('Upgraded tenet DB, skills, and MCP configs.'); - console.log('Legacy document directories migrated to .tenet/archive/legacy-v1/; project/ docs and runtime state preserved.'); + if (migrate && preview.hasWork) { + console.log('Legacy document directories migrated to .tenet/archive/legacy-v1/; project/ docs and runtime state preserved.'); + } else if (!migrate && preview.hasWork && !preview.alreadyMigrated) { + console.log('\nSkipped legacy document migration (destructive). To move these into .tenet/archive/legacy-v1/, re-run:'); + console.log(' tenet init --upgrade --migrate-legacy'); + console.log(` would move: ${[...preview.dirs, ...preview.files].join(', ')}`); + } } catch (error) { if (error instanceof Error) { console.error(error.message); @@ -222,6 +269,10 @@ const run = async (): Promise => { if (!options.skipPreApproval) { await runMcpPreApprovalFlow(projectPath, { assumeYes: options.yes }); } + + if (process.stdin.isTTY && !options.yes) { + await maybeStarNudge(projectPath); + } return; } @@ -255,6 +306,10 @@ const run = async (): Promise => { console.log('- Run Tenet context bootstrap to replace .tenet/project/*.md placeholders with current project doctrine'); console.log(`- Start ${agent ?? 'your agent'} in this directory`); console.log('- To change agent later: tenet config --agent '); + + if (process.stdin.isTTY && !options.yes) { + await maybeStarNudge(projectPath); + } }); program diff --git a/src/cli/init.test.ts b/src/cli/init.test.ts index 72b4b2b..983d0fb 100644 --- a/src/cli/init.test.ts +++ b/src/cli/init.test.ts @@ -12,6 +12,7 @@ import { mergeCodexProjectTrust, mergeOpenCodePermission, mergeOpenCodePlaywrightPermission, + previewLegacyMigration, } from './init.js'; import { TENET_MCP_TOOL_NAMES } from '../mcp/tools/tool-names.js'; @@ -340,7 +341,7 @@ describe('initProject', () => { } fs.writeFileSync(path.join(tenetRoot, 'DESIGN.md'), '# legacy design\n', 'utf8'); - initProject(projectPath, { upgrade: true }); + initProject(projectPath, { upgrade: true, migrateLegacy: true }); for (const dir of legacyDirs) { expect(fs.existsSync(path.join(tenetRoot, dir))).toBe(false); @@ -356,7 +357,7 @@ describe('initProject', () => { fs.mkdirSync(path.join(tenetRoot, 'knowledge'), { recursive: true }); fs.writeFileSync(path.join(tenetRoot, 'knowledge', 'worker-queue.md'), '# Worker Queue\n', 'utf8'); - initProject(projectPath, { upgrade: true }); + initProject(projectPath, { upgrade: true, migrateLegacy: true }); // Legacy knowledge content is archived; the active-lane dir stays (empty) for bootstrap to refill. expect(fs.existsSync(path.join(tenetRoot, 'knowledge'))).toBe(true); @@ -387,7 +388,7 @@ describe('initProject', () => { fs.writeFileSync(path.join(tenetRoot, 'status', 'status.md'), '# runtime status\n', 'utf8'); fs.writeFileSync(path.join(tenetRoot, 'state-snapshot', 'keep.md'), '# keep\n', 'utf8'); - initProject(projectPath, { upgrade: true }); + initProject(projectPath, { upgrade: true, migrateLegacy: true }); expect(fs.readFileSync(path.join(tenetRoot, 'status', 'status.md'), 'utf8')).toBe('# runtime status\n'); expect(fs.readFileSync(path.join(tenetRoot, 'state-snapshot', 'keep.md'), 'utf8')).toBe('# keep\n'); @@ -401,7 +402,7 @@ describe('initProject', () => { fs.mkdirSync(tenetRoot, { recursive: true }); fs.mkdirSync(path.join(tenetRoot, 'spec'), { recursive: true }); // empty - initProject(projectPath, { upgrade: true }); + initProject(projectPath, { upgrade: true, migrateLegacy: true }); expect(fs.existsSync(path.join(tenetRoot, 'spec'))).toBe(true); expect(fs.existsSync(path.join(tenetRoot, 'archive', 'legacy-v1', 'spec'))).toBe(false); @@ -414,18 +415,59 @@ describe('initProject', () => { fs.mkdirSync(path.join(tenetRoot, 'spec'), { recursive: true }); fs.writeFileSync(path.join(tenetRoot, 'spec', 'oauth.md'), '# legacy oauth spec\n', 'utf8'); - initProject(projectPath, { upgrade: true }); + initProject(projectPath, { upgrade: true, migrateLegacy: true }); expect(fs.existsSync(path.join(tenetRoot, 'archive', 'legacy-v1', 'spec', 'oauth.md'))).toBe(true); // A legacy-looking dir recreated after migration must NOT be re-archived. fs.mkdirSync(path.join(tenetRoot, 'spec'), { recursive: true }); fs.writeFileSync(path.join(tenetRoot, 'spec', 'payments.md'), '# stays at top level\n', 'utf8'); - initProject(projectPath, { upgrade: true }); + initProject(projectPath, { upgrade: true, migrateLegacy: true }); expect(fs.readFileSync(path.join(tenetRoot, 'spec', 'payments.md'), 'utf8')).toBe('# stays at top level\n'); expect(fs.existsSync(path.join(tenetRoot, 'archive', 'legacy-v1', 'spec', 'payments.md'))).toBe(false); }); + + it('does not migrate legacy dirs when migrateLegacy is not set', () => { + const projectPath = createTempDir(); + const tenetRoot = path.join(projectPath, '.tenet'); + fs.mkdirSync(tenetRoot, { recursive: true }); + fs.mkdirSync(path.join(tenetRoot, 'spec'), { recursive: true }); + fs.writeFileSync(path.join(tenetRoot, 'spec', 'note.md'), '# legacy spec\n', 'utf8'); + + initProject(projectPath, { upgrade: true }); + + // No consent flag → the destructive move does not run. + expect(fs.readFileSync(path.join(tenetRoot, 'spec', 'note.md'), 'utf8')).toBe('# legacy spec\n'); + expect(fs.existsSync(path.join(tenetRoot, 'archive', 'legacy-v1'))).toBe(false); + }); + + it('previewLegacyMigration reports alreadyMigrated and hasWork correctly', () => { + const projectPath = createTempDir(); + const tenetRoot = path.join(projectPath, '.tenet'); + fs.mkdirSync(tenetRoot, { recursive: true }); + + // Empty legacy dir is not listed as work. + fs.mkdirSync(path.join(tenetRoot, 'spec'), { recursive: true }); + let preview = previewLegacyMigration(tenetRoot); + expect(preview.alreadyMigrated).toBe(false); + expect(preview.hasWork).toBe(false); + expect(preview.dirs).not.toContain('spec'); + + // Non-empty legacy dir + DESIGN.md show up as work. + fs.writeFileSync(path.join(tenetRoot, 'spec', 'oauth.md'), '# legacy oauth\n', 'utf8'); + fs.writeFileSync(path.join(tenetRoot, 'DESIGN.md'), '# legacy design\n', 'utf8'); + preview = previewLegacyMigration(tenetRoot); + expect(preview.hasWork).toBe(true); + expect(preview.dirs).toContain('spec'); + expect(preview.files).toContain('DESIGN.md'); + + // Once the archive marker exists, the migration reads as already done. + fs.mkdirSync(path.join(tenetRoot, 'archive', 'legacy-v1'), { recursive: true }); + preview = previewLegacyMigration(tenetRoot); + expect(preview.alreadyMigrated).toBe(true); + expect(preview.hasWork).toBe(false); + }); }); describe('mergeClaudeLocalSettings', () => { diff --git a/src/cli/init.ts b/src/cli/init.ts index faf39a4..2826fec 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -45,6 +45,13 @@ const VALID_AGENTS = ['claude-code', 'opencode', 'codex'] as const; type InitOptions = { agent?: string; upgrade?: boolean; + /** + * Opt in to the one-time destructive legacy-document migration on upgrade. + * Defaults to false: the move never runs unless the caller (CLI consent gate) + * explicitly enables it. Destructive for pre-migration jobs — their + * artifact_paths dangle after the move. + */ + migrateLegacy?: boolean; }; const PORTABLE_STATE_README = `# Tenet State Snapshot @@ -159,6 +166,8 @@ type StateConfig = { opencode_args_playwright_eval?: string; codex_args_playwright_eval?: string; claude_args_playwright_eval?: string; + /** Per-project star-nudge state (see src/cli/star-nudge.ts). */ + star_nudge?: { starredAt?: string }; }; export const writeStateConfig = (tenetRoot: string, config: StateConfig): void => { @@ -329,7 +338,7 @@ export function initProject(projectPath: string, options?: InitOptions): void { throw new Error('No .tenet directory found. Run `tenet init` first.'); } // Upgrade: overwrite skills and MCP configs, preserve user docs - upgradeProject(projectPath); + upgradeProject(projectPath, options); return; } @@ -362,7 +371,7 @@ export function initProject(projectPath: string, options?: InitOptions): void { * Upgrade an existing tenet project: overwrite skills and MCP configs, * ensure new directories exist, but preserve all user docs and state. */ -function upgradeProject(projectPath: string): void { +function upgradeProject(projectPath: string, options?: InitOptions): void { const tenetRoot = path.join(projectPath, '.tenet'); // Ensure any new directories exist (added in newer versions) @@ -378,7 +387,7 @@ function upgradeProject(projectPath: string): void { warnIfJobsActive(stateStore); stateStore.close(); - migrateLegacyDocuments(tenetRoot); + migrateLegacyDocuments(tenetRoot, { enabled: options?.migrateLegacy === true }); // Overwrite skill files (these are tenet-owned, not user-edited) copySkillDirs(projectPath); @@ -415,18 +424,75 @@ const isDirNonEmpty = (dir: string): boolean => { return fs.readdirSync(dir).length > 0; }; +/** + * Non-mutating read of which legacy top-level dirs/files a migration would move. + * Shared by previewLegacyMigration (for the CLI consent prompt) and + * migrateLegacyDocuments (the actual move) so both agree on the plan. + */ +const computeLegacyMigrationPlan = (tenetRoot: string): { dirs: string[]; files: string[] } => { + const dirs: string[] = []; + for (const dir of LEGACY_DOC_DIRS) { + if (isDirNonEmpty(path.join(tenetRoot, dir))) { + dirs.push(dir); + } + } + + const files: string[] = []; + for (const file of LEGACY_FILES) { + const source = path.join(tenetRoot, file); + if (fs.existsSync(source) && fs.statSync(source).isFile()) { + files.push(file); + } + } + + return { dirs, files }; +}; + +export type LegacyMigrationPreview = { + /** archive/legacy-v1/ marker exists — a prior upgrade already migrated. */ + alreadyMigrated: boolean; + /** Non-empty legacy dirs that would move. */ + dirs: string[]; + /** Legacy files (e.g. DESIGN.md) that would move. */ + files: string[]; + /** Whether there is anything to move (dirs + files). */ + hasWork: boolean; +}; + +/** + * Read-only preview of the legacy-document migration. Used by the CLI consent + * gate to decide whether to prompt and to show the user what would move. Does + * not touch the filesystem. + */ +export const previewLegacyMigration = (tenetRoot: string): LegacyMigrationPreview => { + const alreadyMigrated = fs.existsSync(path.join(tenetRoot, ARCHIVE_LEGACY_ROOT)); + if (alreadyMigrated) { + return { alreadyMigrated: true, dirs: [], files: [], hasWork: false }; + } + const { dirs, files } = computeLegacyMigrationPlan(tenetRoot); + return { alreadyMigrated: false, dirs, files, hasWork: dirs.length + files.length > 0 }; +}; + /** * One-time migration of legacy top-level document directories into * .tenet/archive/legacy-v1/. Moves legacy doc dirs + DESIGN.md (rename) and - * snapshots knowledge/ (copy). Idempotent: the presence of the - * archive/legacy-v1/ marker means a prior upgrade already migrated, so this - * is a no-op on re-run. + * archives knowledge/ (the active-lane dir is recreated empty). Idempotent: the + * presence of the archive/legacy-v1/ marker means a prior upgrade already + * migrated, so this is a no-op on re-run. + * + * Gated by `enabled`: the destructive move only runs when the CLI consent gate + * (interactive Y/N or the --migrate-legacy flag) explicitly opts in. When + * disabled, this is a silent no-op — the CLI owns the messaging. * * Destructive for pre-migration jobs: their artifact_paths point at the old * top-level locations and will dangle after the move. Callers should warn * before running this while jobs are active (see warnIfJobsActive). */ -const migrateLegacyDocuments = (tenetRoot: string): void => { +const migrateLegacyDocuments = (tenetRoot: string, opts: { enabled: boolean }): void => { + if (!opts.enabled) { + return; + } + const archiveLegacyRoot = path.join(tenetRoot, ARCHIVE_LEGACY_ROOT); if (fs.existsSync(archiveLegacyRoot)) { return; @@ -434,13 +500,11 @@ const migrateLegacyDocuments = (tenetRoot: string): void => { fs.mkdirSync(archiveLegacyRoot, { recursive: true }); + const plan = computeLegacyMigrationPlan(tenetRoot); + const movedDirs: string[] = []; - for (const dir of LEGACY_DOC_DIRS) { - const source = path.join(tenetRoot, dir); - if (!isDirNonEmpty(source)) { - continue; - } - fs.renameSync(source, path.join(archiveLegacyRoot, dir)); + for (const dir of plan.dirs) { + fs.renameSync(path.join(tenetRoot, dir), path.join(archiveLegacyRoot, dir)); movedDirs.push(dir); } @@ -450,12 +514,9 @@ const migrateLegacyDocuments = (tenetRoot: string): void => { fs.mkdirSync(path.join(tenetRoot, 'knowledge'), { recursive: true }); const movedFiles: string[] = []; - for (const file of LEGACY_FILES) { - const source = path.join(tenetRoot, file); - if (fs.existsSync(source) && fs.statSync(source).isFile()) { - fs.renameSync(source, path.join(archiveLegacyRoot, file)); - movedFiles.push(file); - } + for (const file of plan.files) { + fs.renameSync(path.join(tenetRoot, file), path.join(archiveLegacyRoot, file)); + movedFiles.push(file); } if (movedDirs.length === 0 && movedFiles.length === 0) { diff --git a/src/cli/star-nudge.test.ts b/src/cli/star-nudge.test.ts new file mode 100644 index 0000000..365ec85 --- /dev/null +++ b/src/cli/star-nudge.test.ts @@ -0,0 +1,147 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { maybeStarNudge } from './star-nudge.js'; +import { readStateConfig } from './init.js'; + +const tempDirs: string[] = []; +let prevNoNudge: string | undefined; + +const createProject = (): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenet-star-')); + tempDirs.push(dir); + fs.mkdirSync(path.join(dir, '.tenet', '.state'), { recursive: true }); + return dir; +}; + +const starredAt = (projectPath: string): string | undefined => + readStateConfig(path.join(projectPath, '.tenet')).star_nudge?.starredAt; + +beforeEach(() => { + prevNoNudge = process.env.TENET_NO_STAR_NUDGE; + delete process.env.TENET_NO_STAR_NUDGE; +}); + +afterEach(() => { + if (prevNoNudge === undefined) { + delete process.env.TENET_NO_STAR_NUDGE; + } else { + process.env.TENET_NO_STAR_NUDGE = prevNoNudge; + } + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe('maybeStarNudge', () => { + it('is a no-op when TENET_NO_STAR_NUDGE is set (no prompt, no state)', async () => { + process.env.TENET_NO_STAR_NUDGE = '1'; + const projectPath = createProject(); + const ghCheck = vi.fn(() => true); + const prompt = vi.fn(async () => true); + await maybeStarNudge(projectPath, { isTty: true, ghCheck, prompt, now: () => 't0' }); + expect(ghCheck).not.toHaveBeenCalled(); + expect(prompt).not.toHaveBeenCalled(); + expect(starredAt(projectPath)).toBeUndefined(); + }); + + it('returns without writing state when non-interactive', async () => { + const projectPath = createProject(); + const ghCheck = vi.fn(() => null); + const prompt = vi.fn(async () => true); + await maybeStarNudge(projectPath, { isTty: false, ghCheck, prompt, now: () => 't0' }); + expect(ghCheck).not.toHaveBeenCalled(); + expect(prompt).not.toHaveBeenCalled(); + expect(starredAt(projectPath)).toBeUndefined(); + }); + + it('records starredAt silently and never prompts when gh confirms starred', async () => { + const projectPath = createProject(); + const ghCheck = vi.fn(() => true); + const prompt = vi.fn(async () => true); + await maybeStarNudge(projectPath, { isTty: true, ghCheck, prompt, now: () => 't0' }); + expect(prompt).not.toHaveBeenCalled(); + expect(starredAt(projectPath)).toBe('t0'); + }); + + it('a decline ("no") writes nothing so it asks again next time', async () => { + const projectPath = createProject(); + const ghCheck = vi.fn(() => false); + const prompt = vi.fn(async () => false); + await maybeStarNudge(projectPath, { isTty: true, ghCheck, prompt, now: () => 't0' }); + expect(prompt).toHaveBeenCalledTimes(1); + expect(starredAt(projectPath)).toBeUndefined(); + + // Second interactive run re-asks — a decline is "not now", not "never". + await maybeStarNudge(projectPath, { isTty: true, ghCheck, prompt, now: () => 't1' }); + expect(prompt).toHaveBeenCalledTimes(2); + }); + + it('records starredAt when the user accepts and the star succeeds', async () => { + const projectPath = createProject(); + const ghCheck = vi.fn(() => false); + const prompt = vi.fn(async () => true); + const star = vi.fn(() => true); + await maybeStarNudge(projectPath, { isTty: true, ghCheck, star, prompt, now: () => 't0' }); + expect(star).toHaveBeenCalledTimes(1); + expect(starredAt(projectPath)).toBe('t0'); + }); + + it('writes nothing when the user accepts but the star PUT fails (re-asks)', async () => { + const projectPath = createProject(); + const ghCheck = vi.fn(() => false); + const prompt = vi.fn(async () => true); + const star = vi.fn(() => false); + await maybeStarNudge(projectPath, { isTty: true, ghCheck, star, prompt, now: () => 't0' }); + expect(starredAt(projectPath)).toBeUndefined(); + }); + + it('does not prompt once starredAt is recorded', async () => { + const projectPath = createProject(); + const ghCheck = vi.fn(() => false); + const prompt = vi.fn(async () => true); + const star = vi.fn(() => true); + await maybeStarNudge(projectPath, { isTty: true, ghCheck, star, prompt, now: () => 't0' }); + expect(prompt).toHaveBeenCalledTimes(1); + + // Subsequent run sees starredAt and stays silent (ghCheck not even called). + ghCheck.mockClear(); + prompt.mockClear(); + await maybeStarNudge(projectPath, { isTty: true, ghCheck, prompt, now: () => 't1' }); + expect(ghCheck).not.toHaveBeenCalled(); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('treats a throwing ghCheck as "cannot tell" and still asks', async () => { + const projectPath = createProject(); + const ghCheck = vi.fn(() => { + throw new Error('boom'); + }); + const prompt = vi.fn(async () => false); + await maybeStarNudge(projectPath, { isTty: true, ghCheck, prompt, now: () => 't0' }); + expect(prompt).toHaveBeenCalledTimes(1); + expect(starredAt(projectPath)).toBeUndefined(); + }); + + it('preserves existing config fields when writing starredAt', async () => { + const projectPath = createProject(); + const tenetRoot = path.join(projectPath, '.tenet'); + fs.writeFileSync( + path.join(tenetRoot, '.state', 'config.json'), + JSON.stringify({ default_agent: 'codex' }), + 'utf8', + ); + await maybeStarNudge(projectPath, { + isTty: true, + ghCheck: () => true, + prompt: vi.fn(async () => true), + now: () => 't0', + }); + const config = readStateConfig(tenetRoot); + expect(config.default_agent).toBe('codex'); + expect(config.star_nudge?.starredAt).toBe('t0'); + }); +}); diff --git a/src/cli/star-nudge.ts b/src/cli/star-nudge.ts new file mode 100644 index 0000000..f9ed221 --- /dev/null +++ b/src/cli/star-nudge.ts @@ -0,0 +1,149 @@ +import { execSync } from 'node:child_process'; +import path from 'node:path'; +import { promptYesNo, readStateConfig, writeStateConfig } from './init.js'; + +const TENET_REPO = 'JeiKeiLim/tenet'; +const REPO_URL = 'https://github.com/JeiKeiLim/tenet'; +const GH_TIMEOUT_MS = 6_000; + +/** + * Read the per-project "already a stargazer" timestamp from + * .tenet/.state/config.json (under the `star_nudge` key). Returns undefined when + * not set, the file is missing, or the JSON is invalid. + */ +const readStarredAt = (projectPath: string): string | undefined => { + const tenetRoot = path.join(projectPath, '.tenet'); + return readStateConfig(tenetRoot).star_nudge?.starredAt; +}; + +/** + * Record the stargazer timestamp into .tenet/.state/config.json, preserving all + * other config fields. Lives under .state/ (gitignored) so it is local + * machine state, not committed or shared. + */ +const writeStarredAt = (projectPath: string, starredAt: string): void => { + const tenetRoot = path.join(projectPath, '.tenet'); + const config = readStateConfig(tenetRoot); + writeStateConfig(tenetRoot, { + ...config, + star_nudge: { ...(config.star_nudge ?? {}), starredAt }, + }); +}; + +/** + * Silent check via the GitHub CLI for whether the authenticated user has + * starred the Tenet repo. + * + * @returns true if starred (HTTP 204); false if confirmed NOT starred (404); + * null if we cannot tell (gh missing / not authenticated / offline). Never + * throws. + */ +export const hasStarredViaGh = (): boolean | null => { + try { + execSync(`gh api user/starred/${TENET_REPO}`, { + stdio: ['ignore', 'ignore', 'pipe'], + timeout: GH_TIMEOUT_MS, + }); + return true; + } catch (error) { + const stderr = + typeof error === 'object' && error !== null && 'stderr' in error + ? String((error as { stderr: Buffer | string }).stderr) + : ''; + const message = error instanceof Error ? error.message : String(error); + // gh maps any non-2xx HTTP response to exit code 1; the HTTP status lives in + // stderr text. A 404 confirms "not starred"; anything else (auth, network, + // gh-not-installed) means we cannot tell. + return /404/.test(`${stderr} ${message}`) ? false : null; + } +}; + +/** Best-effort star via gh. Returns true on success. Never throws. */ +const starViaGh = (): boolean => { + try { + execSync(`gh api -X PUT user/starred/${TENET_REPO}`, { + stdio: ['ignore', 'ignore', 'ignore'], + timeout: GH_TIMEOUT_MS, + }); + return true; + } catch { + return false; + } +}; + +type MaybeStarNudgeOptions = { + /** Defaults to process.stdin.isTTY — injectable for tests. */ + isTty?: boolean; + /** Defaults to hasStarredViaGh — injectable for tests. */ + ghCheck?: () => boolean | null; + /** Defaults to the real starViaGh (best-effort gh PUT) — injectable for tests. */ + star?: () => boolean; + /** Defaults to the real promptYesNo — injectable for tests. */ + prompt?: (question: string, defaultYes?: boolean) => Promise; + /** Defaults to () => new Date().toISOString() — injectable for tests. */ + now?: () => string; +}; + +/** + * Polite, opt-out star nudge run at the end of interactive `tenet init` / + * `tenet init --upgrade`. State is per-project in .tenet/.state/config.json. + * + * Suppression is permanent only once the user is recorded as a stargazer: gh + * confirms it, or the user accepts and the best-effort gh PUT succeeds. + * Declining ("no") records nothing, so the next interactive run asks again — a + * decline is "not now", not "never". + * + * Skipped (no state change) when TENET_NO_STAR_NUDGE is set or the run is + * non-interactive (no TTY / --yes). Never throws. + */ +export const maybeStarNudge = async ( + projectPath: string, + opts: MaybeStarNudgeOptions = {}, +): Promise => { + const isTty = opts.isTty ?? process.stdin.isTTY; + const ghCheck = opts.ghCheck ?? hasStarredViaGh; + const star = opts.star ?? starViaGh; + const prompt = opts.prompt ?? promptYesNo; + const now = opts.now ?? (() => new Date().toISOString()); + + try { + if (process.env.TENET_NO_STAR_NUDGE) { + return; + } + + if (!isTty) { + return; + } + + if (readStarredAt(projectPath)) { + return; // already a stargazer — never ask this project again + } + + let starred: boolean | null = null; + try { + starred = ghCheck(); + } catch { + starred = null; + } + + if (starred === true) { + writeStarredAt(projectPath, now()); + return; // silent — never ask a supporter + } + + console.log(`\n⭐ Enjoying Tenet? Star it on GitHub: ${REPO_URL}`); + const yes = await prompt('Star Tenet on GitHub now?', false); + if (yes) { + if (star()) { + writeStarredAt(projectPath, now()); + console.log(' Starred. Thanks for supporting Tenet!'); + } else { + console.log(` Could not star automatically — please star manually: ${REPO_URL}`); + // Not starred yet → will re-ask on the next interactive run. + } + } + // "no" → record nothing → re-ask next interactive run. + } catch { + // The nudge must never break init/upgrade. + } +}; From bf2334ccd699243f71afb8ba5b5dc94bffb2c7f0 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Fri, 19 Jun 2026 17:03:14 +0900 Subject: [PATCH 03/87] chore: bump to 26.6.2 Co-Authored-By: Claude --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1bf5900..8fe4532 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.6.1", + "version": "26.6.2", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From 63bbbacdc197add5cb5725ece7d27171663b314b Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Mon, 22 Jun 2026 16:52:55 +0900 Subject: [PATCH 04/87] feat: enforce design-components in bootstrap/visual phases + detect git-tracked state DB - context bootstrap populates project/design-components/ when a visual/UI surface is detected, and flags (not silently skips) an empty dir in a clearly-frontend project (note.md #1) - visual phase makes design-components/ a MUST-inspect when non-empty, reinforced in the run design-delta checklist (note.md #3) - tenet init / --upgrade detects an already-tracked tenet.db/-wal/-shm and warns with the exact `git rm --cached --ignore-unmatch` command (detect+instruct only, never auto-runs); also appends .tenet/.state/ to the repo-root .gitignore as defense-in-depth (note.md #4) - 06-evaluation.md: eval records default to the run journal, not .tenet/knowledge/ - export statePaths() from state-store for reuse; add 7 init tests Co-Authored-By: Claude --- skills/tenet/phases/00-context-bootstrap.md | 4 +- skills/tenet/phases/03-visuals.md | 4 +- skills/tenet/phases/06-evaluation.md | 2 +- src/cli/init.test.ts | 105 +++++++++++++++++++ src/cli/init.ts | 108 +++++++++++++++++++- src/core/state-store.ts | 2 +- 6 files changed, 219 insertions(+), 6 deletions(-) diff --git a/skills/tenet/phases/00-context-bootstrap.md b/skills/tenet/phases/00-context-bootstrap.md index 197719b..2892df3 100644 --- a/skills/tenet/phases/00-context-bootstrap.md +++ b/skills/tenet/phases/00-context-bootstrap.md @@ -72,11 +72,13 @@ Write or refresh: product.md testing.md design.md - design-components/ # optional accepted examples + design-components/ # expected when the project has a visual/UI surface ``` `project/design.md` is required for every project. It is experience-design doctrine: public/user-facing flows, operational surfaces, language and feedback, accessibility and responsiveness when relevant, visual system when relevant, and anti-patterns that would make the project feel wrong. Technical architecture belongs in `project/architecture.md`. +`project/design-components/` holds self-contained accepted component examples — one HTML/MD file per component (buttons, cards, forms, empty states, navigation, etc.) using realistic sample data and pointing at the real source that implements each pattern. It is the canonical reference future visual and implementation work must preserve. It is optional ONLY for projects with no visual/UI interaction surface. When the "Live design" investigation lane finds a frontend, web UI, mobile UI, TUI, or other visual interaction surface, populate `design-components/` with the patterns already implemented in the codebase rather than leaving it empty. An empty or missing `design-components/` in a project that clearly has a frontend is a gap to flag for follow-up (record it as a follow-up or ask the user) — not a reason to silently skip it. + Bootstrap may also curate durable reusable facts into top-level `.tenet/knowledge/`. Do not promote raw research dumps or run-local history as knowledge unless they have been deduplicated into concern-oriented facts that will help future work. ## Output Rules diff --git a/skills/tenet/phases/03-visuals.md b/skills/tenet/phases/03-visuals.md index 23b8b03..816395b 100644 --- a/skills/tenet/phases/03-visuals.md +++ b/skills/tenet/phases/03-visuals.md @@ -9,7 +9,7 @@ Visual generation is mandatory in Full mode. These artifacts bridge the gap betw - **Naming**: `{date}-NN-description.html` (e.g., `2026-04-08-00-architecture.html`, `2026-04-08-01-mockup-minimal.html`). The date prefix tells future sessions when the visual was created so they can decide if it needs updating. - **Self-Contained**: No external dependencies. Inline all CSS, SVG, and JS. - **Realistic**: Use plausible sample data. No "Lorem ipsum". -- **Doctrine**: Read `.tenet/project/design.md` before creating artifacts. If `.tenet/project/design-components/` has relevant accepted examples, inspect them and preserve the established patterns. +- **Doctrine**: Read `.tenet/project/design.md` before creating artifacts. If `.tenet/project/design-components/` exists and is non-empty, you MUST inspect every file in it and preserve the established component patterns in every mockup and prototype you produce. Do not skip this directory — it holds the accepted examples that define the look and feel all future visual work must match. ## 1. Architecture Diagrams Required for all multi-component systems. @@ -52,7 +52,7 @@ After the user approves a mockup design, write `.tenet/runs/{run_slug}/design.md - Selected mockup: [reference to approved file] - Design rationale: [why this direction was chosen] - Project design doctrine used: `.tenet/project/design.md` -- Accepted component examples consulted: [relevant `.tenet/project/design-components/*` files, or "none"] +- Accepted component examples consulted: [list every `.tenet/project/design-components/*` file inspected, or "none — directory absent/empty" with a one-line reason] ## Run-Local Visual Principles - Color palette: [primary, secondary, accent, background colors with hex codes] diff --git a/skills/tenet/phases/06-evaluation.md b/skills/tenet/phases/06-evaluation.md index 740b8e6..b4aa5a7 100644 --- a/skills/tenet/phases/06-evaluation.md +++ b/skills/tenet/phases/06-evaluation.md @@ -121,7 +121,7 @@ Integration test jobs (`integration_test` type) have a different eval flow: The orchestrator should parse the integration test output to identify specific failures and report a focused blocking finding rather than retrying the entire feature. ## Evaluation Result Format -Record results via `tenet_update_knowledge` with a descriptive title. Example: `title="eval project-scaffold mechanical-and-spec-compliance"`. The tool generates a dated markdown file in `.tenet/knowledge/`. +Record results via `tenet_update_knowledge` with a descriptive title. Example: `title="eval project-scaffold mechanical-and-spec-compliance"`. The tool defaults to `type="journal"` and writes a dated markdown file in the run journal (`.tenet/runs//journal/`); pass `type="knowledge"` only for durable facts worth promoting to `.tenet/knowledge/`. ```markdown # Evaluation: job-{id} diff --git a/src/cli/init.test.ts b/src/cli/init.test.ts index 983d0fb..cf695f5 100644 --- a/src/cli/init.test.ts +++ b/src/cli/init.test.ts @@ -1,3 +1,4 @@ +import { execSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -6,6 +7,8 @@ import { addPlaywrightAgentConfigs, addPlaywrightToCodexConfig, addPlaywrightToOpenCodeConfig, + detectTrackedStateFiles, + ensureRootGitignore, initProject, mergeClaudePlaywrightSettings, mergeClaudeLocalSettings, @@ -470,6 +473,108 @@ describe('initProject', () => { }); }); +describe('state DB git safety', () => { + const gitAvailable = (() => { + try { + execSync('git --version', { stdio: 'ignore' }); + return true; + } catch { + return false; + } + })(); + + describe('ensureRootGitignore', () => { + it('appends .tenet/.state/ to an existing root .gitignore without overwriting custom rules', () => { + const projectPath = createTempDir(); + fs.writeFileSync(path.join(projectPath, '.gitignore'), 'node_modules\nbuild\n', 'utf8'); + + ensureRootGitignore(projectPath); + + const content = fs.readFileSync(path.join(projectPath, '.gitignore'), 'utf8'); + expect(content).toContain('node_modules'); + expect(content).toContain('build'); + expect(content).toContain('.tenet/.state/'); + }); + + it('does not create a root .gitignore when none exists', () => { + const projectPath = createTempDir(); + ensureRootGitignore(projectPath); + expect(fs.existsSync(path.join(projectPath, '.gitignore'))).toBe(false); + }); + + it('is idempotent — does not append the rule twice', () => { + const projectPath = createTempDir(); + fs.writeFileSync(path.join(projectPath, '.gitignore'), 'node_modules\n', 'utf8'); + ensureRootGitignore(projectPath); + ensureRootGitignore(projectPath); + + const content = fs.readFileSync(path.join(projectPath, '.gitignore'), 'utf8'); + expect((content.match(/\.tenet\/\.state\//g) ?? []).length).toBe(1); + }); + }); + + (gitAvailable ? describe : describe.skip)('detectTrackedStateFiles', () => { + it('warns with the untrack command when the live DB is tracked by git', () => { + const projectPath = createTempDir(); + execSync('git init', { cwd: projectPath, stdio: 'ignore' }); + fs.mkdirSync(path.join(projectPath, '.tenet', '.state'), { recursive: true }); + fs.writeFileSync(path.join(projectPath, '.tenet', '.state', 'tenet.db'), 'fake-db', 'utf8'); + execSync('git add -f .tenet/.state/tenet.db', { cwd: projectPath, stdio: 'ignore' }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + detectTrackedStateFiles(projectPath); + + expect(warnSpy).toHaveBeenCalled(); + const message = warnSpy.mock.calls.map((call) => String(call[0])).join('\n'); + expect(message).toContain('tenet.db'); + expect(message).toContain('git rm --cached --ignore-unmatch'); + warnSpy.mockRestore(); + }); + + it('also flags WAL sidecars when they are tracked', () => { + const projectPath = createTempDir(); + execSync('git init', { cwd: projectPath, stdio: 'ignore' }); + fs.mkdirSync(path.join(projectPath, '.tenet', '.state'), { recursive: true }); + fs.writeFileSync(path.join(projectPath, '.tenet', '.state', 'tenet.db'), 'db'); + fs.writeFileSync(path.join(projectPath, '.tenet', '.state', 'tenet.db-wal'), 'wal'); + execSync('git add -f .tenet/.state/tenet.db .tenet/.state/tenet.db-wal', { + cwd: projectPath, + stdio: 'ignore', + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + detectTrackedStateFiles(projectPath); + + const message = warnSpy.mock.calls.map((call) => String(call[0])).join('\n'); + expect(message).toContain('tenet.db-wal'); + warnSpy.mockRestore(); + }); + + it('does not warn when .tenet/.state exists but is untracked', () => { + const projectPath = createTempDir(); + execSync('git init', { cwd: projectPath, stdio: 'ignore' }); + fs.mkdirSync(path.join(projectPath, '.tenet', '.state'), { recursive: true }); + fs.writeFileSync(path.join(projectPath, '.tenet', '.state', 'tenet.db'), 'db'); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + detectTrackedStateFiles(projectPath); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('is a silent no-op outside a git work tree', () => { + const projectPath = createTempDir(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(() => detectTrackedStateFiles(projectPath)).not.toThrow(); + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + }); +}); + describe('mergeClaudeLocalSettings', () => { it('creates .claude/settings.local.json with Tenet tool allowlist when missing', () => { const projectPath = createTempDir(); diff --git a/src/cli/init.ts b/src/cli/init.ts index 2826fec..05a1128 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import readline from 'node:readline'; import { fileURLToPath } from 'node:url'; -import { StateStore } from '../core/state-store.js'; +import { StateStore, statePaths } from '../core/state-store.js'; import { TENET_MCP_TOOL_NAMES } from '../mcp/tools/tool-names.js'; import { getPackageVersion } from './version.js'; @@ -144,6 +144,110 @@ const ensureTenetGitignore = (tenetRoot: string): void => { ); }; +const REQUIRED_ROOT_GITIGNORE_LINES = ['.tenet/.state/']; + +/** + * Defense-in-depth: ensure the repo-ROOT .gitignore also ignores the live SQLite + * state, not just .tenet/.gitignore. The nested .tenet/.gitignore (written by + * ensureTenetGitignore) protects locally, but a root rule also covers collaborators + * before .tenet/.gitignore is committed. + * + * Merges into an existing root .gitignore only — never creates one, to avoid + * surprising repos that intentionally omit it. Idempotent; preserves custom rules. + */ +export const ensureRootGitignore = (projectPath: string): void => { + const gitignorePath = path.join(projectPath, '.gitignore'); + if (!fs.existsSync(gitignorePath)) { + return; + } + + const existing = fs.readFileSync(gitignorePath, 'utf8'); + const existingLines = new Set(existing.split(/\r?\n/).map((line) => line.trim())); + const missingLines = REQUIRED_ROOT_GITIGNORE_LINES.filter((line) => !existingLines.has(line)); + if (missingLines.length === 0) { + return; + } + + const separator = existing.endsWith('\n') ? '\n' : '\n\n'; + fs.appendFileSync( + gitignorePath, + `${separator}# Tenet live SQLite runtime state. Do not track this in Git.\n${missingLines.join('\n')}\n`, + 'utf8', + ); +}; + +/** + * Detect whether the live SQLite DB (or its WAL/SHM sidecars) under .tenet/.state/ + * is already tracked by Git, and warn with the exact untrack command. A tracked DB + * is the main source of DB corruption: git checkout/merge/stash can overwrite a + * live WAL database mid-write. .tenet/.gitignore only prevents tracking NEW files — + * it cannot untrack a DB committed before the rule existed. + * + * Non-blocking and advisory: prints a warning only. Does not run `git rm` itself + * (that mutates the index and would surprise the user). No-op when the project is + * not inside a git work tree or git is unavailable. + */ +export const detectTrackedStateFiles = (projectPath: string): void => { + let insideWorkTree: string; + try { + insideWorkTree = execSync('git rev-parse --is-inside-work-tree', { + cwd: projectPath, + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5_000, + encoding: 'utf8', + }).trim(); + } catch { + return; // not a git repo or git unavailable — nothing to detect + } + if (insideWorkTree !== 'true') { + return; + } + + let tracked: string; + try { + tracked = execSync('git ls-files -- .tenet/.state', { + cwd: projectPath, + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5_000, + encoding: 'utf8', + }).trim(); + } catch { + return; + } + if (!tracked) { + return; + } + + const { dbPath, walPath, shmPath } = statePaths(projectPath); + const dbBasenames = new Set([dbPath, walPath, shmPath].map((p) => path.basename(p))); + const offending = tracked + .split(/\r?\n/) + .map((rel) => rel.trim()) + .filter((rel) => rel.length > 0 && dbBasenames.has(path.basename(rel))); + + if (offending.length === 0) { + return; + } + + console.warn( + `\nWarning: ${offending.join(', ')} under .tenet/.state/ is tracked by Git. ` + + 'Git operations (checkout/merge/stash) can corrupt a live SQLite WAL database. ' + + 'Untrack it (the local file is kept), then commit the removal:\n' + + ` git rm --cached --ignore-unmatch ${offending.join(' ')}\n` + + '.tenet/.gitignore already ignores .state/ for future files.', + ); +}; + +/** + * Ensure the live SQLite state is neither newly tracked nor silently corrupted by + * an already-tracked DB: merge a defense-in-depth rule into the root .gitignore, + * then warn if a DB file is already committed. + */ +const ensureStateDbGitSafety = (projectPath: string): void => { + ensureRootGitignore(projectPath); + detectTrackedStateFiles(projectPath); +}; + const ensurePortableStateFiles = (tenetRoot: string): void => { fs.mkdirSync(path.join(tenetRoot, 'state-snapshot'), { recursive: true }); ensureFile(path.join(tenetRoot, 'state-snapshot', 'README.md'), PORTABLE_STATE_README); @@ -355,6 +459,7 @@ export function initProject(projectPath: string, options?: InitOptions): void { ensureTemplateFiles(tenetRoot); ensurePortableStateFiles(tenetRoot); + ensureStateDbGitSafety(projectPath); if (options?.agent) { writeStateConfig(tenetRoot, { default_agent: options.agent }); @@ -381,6 +486,7 @@ function upgradeProject(projectPath: string, options?: InitOptions): void { fs.mkdirSync(path.join(tenetRoot, '.state'), { recursive: true }); ensureTemplateFiles(tenetRoot); ensurePortableStateFiles(tenetRoot); + ensureStateDbGitSafety(projectPath); backupStateDb(tenetRoot); const stateStore = new StateStore(projectPath, { migrate: true }); diff --git a/src/core/state-store.ts b/src/core/state-store.ts index 4cf6c6b..d622330 100644 --- a/src/core/state-store.ts +++ b/src/core/state-store.ts @@ -134,7 +134,7 @@ const parseJson = (value: string | null, fallback: T): T => { } }; -const statePaths = (projectPath: string): { stateDir: string; dbPath: string; walPath: string; shmPath: string } => { +export const statePaths = (projectPath: string): { stateDir: string; dbPath: string; walPath: string; shmPath: string } => { const stateDir = path.join(projectPath, '.tenet', '.state'); const dbPath = path.join(stateDir, 'tenet.db'); return { From c1d28ceb2126c3ab8ca54c57c3c8aebe29d2ef7a Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Mon, 22 Jun 2026 16:53:16 +0900 Subject: [PATCH 05/87] chore(ci): add eslint harness and enforce lint in CI ESLint was never wired into this repo (not a devDep, no config), so `make lint` and `make check` could not run. Set up the tooling and enforce it: - add eslint, typescript-eslint, @eslint/js devDeps; eslint.config.mjs flat config (recommended rules + _-prefix unused-var convention) - remove one stale unused `beforeEach` import surfaced by lint - new ci.yml: typecheck + lint + test + build on every PR/push to main - publish.yml: add a Lint step to the pre-publish gate; drop the stale "lint is skipped" comment Co-Authored-By: Claude --- .github/workflows/ci.yml | 45 ++ .github/workflows/publish.yml | 9 +- eslint.config.mjs | 24 ++ package.json | 3 + pnpm-lock.yaml | 737 +++++++++++++++++++++++++++++++++ src/core/status-writer.test.ts | 2 +- 6 files changed, 816 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 eslint.config.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ac513a5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +# Continuous integration: run the quality gate (typecheck + lint + test + build) +# on every pull request and push to main, so failures are caught before merge — +# not only at publish time (see publish.yml). +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.17.1 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm run typecheck + + - name: Lint + run: pnpm run lint + + - name: Test + run: pnpm run test + + - name: Build + run: pnpm run build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 85f8645..d7c5749 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,9 +6,9 @@ # Auth: OIDC trusted publishing (no NPM_TOKEN secret). One-time setup required # on npmjs.com — see docs/release-runbook.md for the steps. # -# Pre-publish gate: typecheck + unit/integration tests MUST pass. Lint is -# skipped (eslint isn't currently a devDep). E2E canaries are NOT run here — -# they cost real money and are meant for manual maintainer runs. +# Pre-publish gate: typecheck + lint + unit/integration tests MUST pass. +# E2E canaries are NOT run here — they cost real money and are meant for +# manual maintainer runs. name: Publish to npm on: @@ -65,6 +65,9 @@ jobs: - name: Typecheck run: pnpm run typecheck + - name: Lint + run: pnpm run lint + - name: Test run: pnpm run test diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..30af2d5 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,24 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['dist/**', 'node_modules/**'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + rules: { + // Honor the conventional `_`-prefix for intentionally-unused args/vars + // (e.g. `_invocation` in test doubles). + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, + }, +); diff --git a/package.json b/package.json index 8fe4532..6472548 100644 --- a/package.json +++ b/package.json @@ -73,10 +73,13 @@ "zod": "^4.3.6" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.5.2", "@vitest/coverage-v8": "^4.1.2", + "eslint": "^10.5.0", "typescript": "^6.0.2", + "typescript-eslint": "^8.61.1", "vitest": "^4.1.2" }, "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e5c6de..821d1f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.5.0) '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 @@ -36,9 +39,15 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.2 version: 4.1.2(vitest@4.1.2(@types/node@25.5.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(yaml@2.9.0))) + eslint: + specifier: ^10.5.0 + version: 10.5.0 typescript: specifier: ^6.0.2 version: 6.0.2 + typescript-eslint: + specifier: ^8.61.1 + version: 8.61.1(eslint@10.5.0)(typescript@6.0.2) vitest: specifier: ^4.1.2 version: 4.1.2(@types/node@25.5.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(yaml@2.9.0)) @@ -78,6 +87,65 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -213,12 +281,77 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.5.2': resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + '@typescript-eslint/eslint-plugin@8.61.1': + resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.61.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.61.1': + resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.61.1': + resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.61.1': + resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.61.1': + resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.61.1': + resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.61.1': + resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.61.1': + resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.61.1': + resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/coverage-v8@4.1.2': resolution: {integrity: sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==} peerDependencies: @@ -257,6 +390,19 @@ packages: '@vitest/utils@4.1.2': resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -264,6 +410,10 @@ packages: ast-v8-to-istanbul@1.0.0: resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -277,6 +427,10 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -294,6 +448,19 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -302,6 +469,9 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -312,9 +482,55 @@ packages: es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -323,6 +539,15 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -332,9 +557,24 @@ packages: picomatch: optional: true + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -346,6 +586,10 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -356,12 +600,35 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -377,6 +644,22 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -447,6 +730,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -461,12 +748,19 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -475,6 +769,9 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-abi@3.89.0: resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} engines: {node: '>=10'} @@ -485,6 +782,26 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -505,9 +822,17 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -529,6 +854,14 @@ packages: engines: {node: '>=10'} hasBin: true + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -581,12 +914,29 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.61.1: + resolution: {integrity: sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@6.0.2: resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} engines: {node: '>=14.17'} @@ -595,6 +945,9 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -676,11 +1029,20 @@ packages: jsdom: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -689,6 +1051,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -727,6 +1093,56 @@ snapshots: tslib: 2.8.1 optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0)': + dependencies: + eslint: 10.5.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.5.0)': + optionalDependencies: + eslint: 10.5.0 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -821,12 +1237,107 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.8': {} + '@types/json-schema@7.0.15': {} + '@types/node@25.5.2': dependencies: undici-types: 7.18.2 + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.2))(eslint@10.5.0)(typescript@6.0.2)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.61.1(eslint@10.5.0)(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0)(typescript@6.0.2) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0)(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.61.1 + eslint: 10.5.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.2)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3 + eslint: 10.5.0 + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.61.1(typescript@6.0.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.2) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3 + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + + '@typescript-eslint/tsconfig-utils@8.61.1(typescript@6.0.2)': + dependencies: + typescript: 6.0.2 + + '@typescript-eslint/type-utils@8.61.1(eslint@10.5.0)(typescript@6.0.2)': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.2) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0)(typescript@6.0.2) + debug: 4.4.3 + eslint: 10.5.0 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.61.1': {} + + '@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.2)': + dependencies: + '@typescript-eslint/project-service': 8.61.1(typescript@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.2) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@6.0.2) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.61.1(eslint@10.5.0)(typescript@6.0.2)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.2) + eslint: 10.5.0 + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + eslint-visitor-keys: 5.0.1 + '@vitest/coverage-v8@4.1.2(vitest@4.1.2(@types/node@25.5.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(yaml@2.9.0)))': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -882,6 +1393,19 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.0: @@ -890,6 +1414,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + balanced-match@4.0.4: {} + base64-js@1.5.1: {} better-sqlite3@12.8.0: @@ -907,6 +1433,10 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -920,12 +1450,24 @@ snapshots: convert-source-map@2.0.0: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 deep-extend@0.6.0: {} + deep-is@0.1.4: {} + detect-libc@2.1.2: {} end-of-stream@1.4.5: @@ -934,20 +1476,108 @@ snapshots: es-module-lexer@2.0.0: {} + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.5.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + expand-template@2.0.3: {} expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + file-uri-to-path@1.0.0: {} + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + fs-constants@1.0.0: {} fsevents@2.3.3: @@ -955,16 +1585,34 @@ snapshots: github-from-package@0.0.0: {} + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + has-flag@4.0.0: {} html-escaper@2.0.2: {} ieee754@1.2.1: {} + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + inherits@2.0.4: {} ini@1.3.8: {} + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -980,6 +1628,21 @@ snapshots: js-tokens@10.0.0: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: optional: true @@ -1029,6 +1692,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1045,14 +1712,22 @@ snapshots: mimic-response@3.1.0: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + minimist@1.2.8: {} mkdirp-classic@0.5.3: {} + ms@2.1.3: {} + nanoid@3.3.11: {} napi-build-utils@2.0.0: {} + natural-compare@1.4.0: {} + node-abi@3.89.0: dependencies: semver: 7.7.4 @@ -1063,6 +1738,27 @@ snapshots: dependencies: wrappy: 1.0.2 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -1090,11 +1786,15 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -1136,6 +1836,12 @@ snapshots: semver@7.7.4: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} simple-concat@1.0.1: {} @@ -1188,6 +1894,10 @@ snapshots: tinyrainbow@3.1.0: {} + ts-api-utils@2.5.0(typescript@6.0.2): + dependencies: + typescript: 6.0.2 + tslib@2.8.1: optional: true @@ -1195,10 +1905,29 @@ snapshots: dependencies: safe-buffer: 5.2.1 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.61.1(eslint@10.5.0)(typescript@6.0.2): + dependencies: + '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.2))(eslint@10.5.0)(typescript@6.0.2) + '@typescript-eslint/parser': 8.61.1(eslint@10.5.0)(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.2) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0)(typescript@6.0.2) + eslint: 10.5.0 + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + typescript@6.0.2: {} undici-types@7.18.2: {} + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + util-deprecate@1.0.2: {} vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(yaml@2.9.0): @@ -1243,13 +1972,21 @@ snapshots: transitivePeerDependencies: - msw + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + word-wrap@1.2.5: {} + wrappy@1.0.2: {} yaml@2.9.0: {} + yocto-queue@0.1.0: {} + zod@4.3.6: {} diff --git a/src/core/status-writer.test.ts b/src/core/status-writer.test.ts index eb0abff..d86da8b 100644 --- a/src/core/status-writer.test.ts +++ b/src/core/status-writer.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import type { Job } from '../types/index.js'; import { writeStatusFiles } from './status-writer.js'; From 59009c1f64b997754b0c87d1200ed4b92a989a68 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Mon, 22 Jun 2026 17:00:59 +0900 Subject: [PATCH 06/87] docs(claude): fix publish.yml lint drift; document ci.yml + init git-safety - Versioning: publish.yml now runs typecheck + lint + tests + build (was "typecheck + tests + build"); add that ci.yml enforces the gate on PRs and pushes to main - CLI: document the init/--upgrade git-safety check (tracked-DB warning + root-.gitignore defense-in-depth) Co-Authored-By: Claude --- CLAUDE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0929abd..e0416ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ The system has four layers: 3. **MCP Server** (`src/mcp/`) — Exposes 18 tools via `@modelcontextprotocol/server`. Entry point at `src/mcp/index.ts`. Each tool in `src/mcp/tools/` registers itself with a Zod input schema and handler. Key tools: `tenet_start_job`, `tenet_continue` (server-side continuation), `tenet_compile_context`, `tenet_register_jobs` (loads job DAG, requires `feature` slug), `tenet_retry_job` (resets completed/failed jobs to pending), `tenet_report_blocking_finding` (report-only escalation), `tenet_validate_clarity`, `tenet_add_steer` (creates steer messages in SQLite), `tenet_start_eval` (dispatches code critic + test critic + playwright eval), `tenet_init` (initialize project from MCP). Agent selection is CLI-only via `tenet config --agent `. -4. **CLI** (`src/cli/`) — Commander.js program with `init`, `serve`, `status`, `config`, and `db` maintenance commands. `tenet init` scaffolds a `.tenet/` directory structure and copies skill files to `.claude/skills/tenet/` and `.agents/skills/tenet/` with generated package-version metadata in each installed `SKILL.md`. `tenet init --upgrade` creates a verified SQLite-safe backup, runs pending DB migrations, then — only with consent — performs the one-time destructive move of legacy document dirs into `.tenet/archive/legacy-v1/`: it prompts Y/N (default No) interactively, or requires the explicit `--migrate-legacy` flag in non-interactive contexts (`-y/--yes` deliberately does not auto-migrate). It then refreshes generated skills/MCP configs. `init`/`--upgrade` also run a "star the repo" nudge (`src/cli/star-nudge.ts`; state per-project in `.tenet/.state/config.json` under `star_nudge`; a decline defers to the next run rather than suppressing permanently; opt out with `TENET_NO_STAR_NUDGE`) at the end of an interactive run — this is CLI-only and never fires from the autonomous skill boot loop. `tenet db check` runs read-only integrity/index diagnostics, `tenet db backup` creates a verified standalone SQLite backup, and `tenet db snapshot`/`restore-snapshot` write and restore Git-safe portable snapshots under `.tenet/state-snapshot/`. +4. **CLI** (`src/cli/`) — Commander.js program with `init`, `serve`, `status`, `config`, and `db` maintenance commands. `tenet init` scaffolds a `.tenet/` directory structure and copies skill files to `.claude/skills/tenet/` and `.agents/skills/tenet/` with generated package-version metadata in each installed `SKILL.md`. `tenet init --upgrade` creates a verified SQLite-safe backup, runs pending DB migrations, then — only with consent — performs the one-time destructive move of legacy document dirs into `.tenet/archive/legacy-v1/`: it prompts Y/N (default No) interactively, or requires the explicit `--migrate-legacy` flag in non-interactive contexts (`-y/--yes` deliberately does not auto-migrate). It then refreshes generated skills/MCP configs. `init`/`--upgrade` also run a "star the repo" nudge (`src/cli/star-nudge.ts`; state per-project in `.tenet/.state/config.json` under `star_nudge`; a decline defers to the next run rather than suppressing permanently; opt out with `TENET_NO_STAR_NUDGE`) at the end of an interactive run — this is CLI-only and never fires from the autonomous skill boot loop. `tenet db check` runs read-only integrity/index diagnostics, `tenet db backup` creates a verified standalone SQLite backup, and `tenet db snapshot`/`restore-snapshot` write and restore Git-safe portable snapshots under `.tenet/state-snapshot/`. `init`/`--upgrade` also run a git-safety check: if `.tenet/.state/tenet.db` or its WAL sidecars are tracked by Git (the main DB-corruption vector), they warn with the exact `git rm --cached` command — detect-only, never auto-untrack — and merge a `.tenet/.state/` rule into the repo-root `.gitignore` as defense-in-depth. ## Key Types (`src/types/index.ts`) @@ -137,7 +137,9 @@ Uses **CalVer** (`YY.MM.PATCH`): e.g., `26.4.0` is the first release in April 20 Write notes in user-facing language (what changed for the user, not what files moved). Pull structure from any planning doc that motivated the release; skip mechanical commits like `chore: bump`. 7. Tell the user: "Draft release created with notes at https://github.com/JeiKeiLim/tenet/releases/tag/vYY.MM.PATCH. Review and click 'Publish release' to trigger npm publishing." -The user clicking "Publish release" fires `.github/workflows/publish.yml`, which runs typecheck + tests + build + `npm publish --provenance` via OIDC. No manual `npm publish` needed. +The user clicking "Publish release" fires `.github/workflows/publish.yml`, which runs typecheck + lint + tests + build + `npm publish --provenance` via OIDC. No manual `npm publish` needed. + +PRs and pushes to `main` run `.github/workflows/ci.yml` (typecheck + lint + test + build) so the gate is enforced at review time, not only at publish. Manual fallback (for automation outages only): `make release` still works locally if the maintainer has `npm login`. From 1f554a20f7d64fbe97f72635aaa6f9d2c26db89a Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Mon, 22 Jun 2026 17:21:08 +0900 Subject: [PATCH 07/87] chore: bump to 26.6.3 Co-Authored-By: Claude --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6472548..2e14354 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.6.2", + "version": "26.6.3", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From f933853d5f3d40440dedc2f51f33b58e77b2f70f Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Mon, 22 Jun 2026 20:36:13 +0900 Subject: [PATCH 08/87] feat(steer): self-maintaining inbox + user/agent separation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steer messages could be added and read but never retired — updateSteerStatus existed in the DB layer with no MCP tool calling it — so the inbox grew without bound (observed 400k+ unresolved) and agent self-notes drowned out user directives. - tenet_update_steer: retire by id or sweep agent-context in bulk; user steers and directives retire only by explicit id (standing rules persist) - tenet_process_steer: user steers returned in full, agent steers capped to a recent window (default 50, agent-tunable), with total_unresolved + truncated so truncation is visible rather than silent - state-store: getSteerInbox / countUnprocessedSteers / updateSteersStatus / sweepAgentContextSteers replace the unbounded getUnprocessedSteers and the single-id updateSteerStatus - tenet status shows the user/agent split; health-check uses the cheap count - skill hygiene: loop reads user steers first, retires consumed context, retires directives only when clearly done, logs agent self-notes as context Co-Authored-By: Claude --- CLAUDE.md | 2 +- skills/tenet/SKILL.md | 13 ++- skills/tenet/phases/05-execution-loop.md | 15 ++- src/cli/status.ts | 8 +- src/core/state-store.test.ts | 120 ++++++++++++++++--- src/core/state-store.ts | 134 +++++++++++++++++++--- src/mcp/tools/index.ts | 2 + src/mcp/tools/tenet-health-check.ts | 2 +- src/mcp/tools/tenet-process-steer.test.ts | 67 +++++++++++ src/mcp/tools/tenet-process-steer.ts | 39 +++++-- src/mcp/tools/tenet-update-steer.test.ts | 88 ++++++++++++++ src/mcp/tools/tenet-update-steer.ts | 70 +++++++++++ src/mcp/tools/tool-names.ts | 1 + src/types/index.ts | 11 +- 14 files changed, 521 insertions(+), 51 deletions(-) create mode 100644 src/mcp/tools/tenet-process-steer.test.ts create mode 100644 src/mcp/tools/tenet-update-steer.test.ts create mode 100644 src/mcp/tools/tenet-update-steer.ts diff --git a/CLAUDE.md b/CLAUDE.md index e0416ad..796ab52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ The system has four layers: 2. **Adapters** (`src/adapters/`) — Pluggable agent adapters that spawn CLI subprocesses with a 120-minute default timeout, configurable through `tenet config --timeout `. Each adapter implements `AgentAdapter` from `base.ts`: `isAvailable()`, `invoke(invocation)`. Three built-in: `ClaudeAdapter` (`claude --print`), `OpenCodeAdapter` (`opencode run`), `CodexAdapter` (`codex exec --sandbox workspace-write` by default). Global adapter args and job-scoped args (for example `codex_args_playwright_eval`) are read at MCP server startup. JobManager resolves the configured adapter strictly by name and fails closed if that adapter is unavailable. -3. **MCP Server** (`src/mcp/`) — Exposes 18 tools via `@modelcontextprotocol/server`. Entry point at `src/mcp/index.ts`. Each tool in `src/mcp/tools/` registers itself with a Zod input schema and handler. Key tools: `tenet_start_job`, `tenet_continue` (server-side continuation), `tenet_compile_context`, `tenet_register_jobs` (loads job DAG, requires `feature` slug), `tenet_retry_job` (resets completed/failed jobs to pending), `tenet_report_blocking_finding` (report-only escalation), `tenet_validate_clarity`, `tenet_add_steer` (creates steer messages in SQLite), `tenet_start_eval` (dispatches code critic + test critic + playwright eval), `tenet_init` (initialize project from MCP). Agent selection is CLI-only via `tenet config --agent `. +3. **MCP Server** (`src/mcp/`) — Exposes 19 tools via `@modelcontextprotocol/server`. Entry point at `src/mcp/index.ts`. Each tool in `src/mcp/tools/` registers itself with a Zod input schema and handler. Key tools: `tenet_start_job`, `tenet_continue` (server-side continuation), `tenet_compile_context`, `tenet_register_jobs` (loads job DAG, requires `feature` slug), `tenet_retry_job` (resets completed/failed jobs to pending), `tenet_report_blocking_finding` (report-only escalation), `tenet_validate_clarity`, `tenet_add_steer` / `tenet_update_steer` (create steer messages; retire/sweep them — `tenet_process_steer` returns user steers in full and caps agent steers, so user input can't be crowded out by agent noise), `tenet_start_eval` (dispatches code critic + test critic + playwright eval), `tenet_init` (initialize project from MCP). Agent selection is CLI-only via `tenet config --agent `. 4. **CLI** (`src/cli/`) — Commander.js program with `init`, `serve`, `status`, `config`, and `db` maintenance commands. `tenet init` scaffolds a `.tenet/` directory structure and copies skill files to `.claude/skills/tenet/` and `.agents/skills/tenet/` with generated package-version metadata in each installed `SKILL.md`. `tenet init --upgrade` creates a verified SQLite-safe backup, runs pending DB migrations, then — only with consent — performs the one-time destructive move of legacy document dirs into `.tenet/archive/legacy-v1/`: it prompts Y/N (default No) interactively, or requires the explicit `--migrate-legacy` flag in non-interactive contexts (`-y/--yes` deliberately does not auto-migrate). It then refreshes generated skills/MCP configs. `init`/`--upgrade` also run a "star the repo" nudge (`src/cli/star-nudge.ts`; state per-project in `.tenet/.state/config.json` under `star_nudge`; a decline defers to the next run rather than suppressing permanently; opt out with `TENET_NO_STAR_NUDGE`) at the end of an interactive run — this is CLI-only and never fires from the autonomous skill boot loop. `tenet db check` runs read-only integrity/index diagnostics, `tenet db backup` creates a verified standalone SQLite backup, and `tenet db snapshot`/`restore-snapshot` write and restore Git-safe portable snapshots under `.tenet/state-snapshot/`. `init`/`--upgrade` also run a git-safety check: if `.tenet/.state/tenet.db` or its WAL sidecars are tracked by Git (the main DB-corruption vector), they warn with the exact `git rm --cached` command — detect-only, never auto-untrack — and merge a `.tenet/.state/` rule into the repo-root `.gitignore` as defense-in-depth. diff --git a/skills/tenet/SKILL.md b/skills/tenet/SKILL.md index 5812331..a83066b 100644 --- a/skills/tenet/SKILL.md +++ b/skills/tenet/SKILL.md @@ -23,6 +23,7 @@ allowed-tools: - tenet_update_knowledge - tenet_add_steer - tenet_process_steer + - tenet_update_steer - tenet_health_check - tenet_get_status - tenet_retry_job @@ -191,11 +192,17 @@ Read `phases/05-execution-loop.md` before starting execution. ## Steering +The steer inbox is a live to-do list, not a log — it must be maintained, or it swells with agent self-notes and stops being useful. + When the user sends input during execution: -1. Classify it as `context`, `directive`, or `emergency`. -2. Persist it with `tenet_add_steer(...)`; use `affected_job_ids` when known. -3. Process steering at every loop checkpoint with `tenet_process_steer()`. +1. Classify it as `context` (one-time info), `directive` (a rule/scope change that holds until lifted), or `emergency` (immediate halt). +2. Persist it with `tenet_add_steer(...)`; use `affected_job_ids` when known. **Agent self-notes go in as `context`, never `directive`**, so they stay sweepable. +3. Process steering at every loop checkpoint with `tenet_process_steer()`. Read `user_messages` first, always — user steers are returned in full and can never be crowded out by agent noise. `agent_messages` is capped to a recent window; `truncated: true` and `total_unresolved` tell you there is more to drain. +4. **Retire steers you have handled** with `tenet_update_steer`: + - A `context` steer once consumed — or sweep all agent-context at a run/slice boundary: `tenet_update_steer(sweep="agent_context", status="resolved")`. + - A `directive` once the work it governed is done or clearly superseded. If you are unsure whether a directive still applies, **keep it** — never discard user input on a guess. A directive that must hold across jobs stays until you explicitly retire it by id. + - The sweep only ever touches agent-context steers — user steers and directives of any source retire only by explicit id. User steers outrank agent steers. Emergency steer cancels active jobs via `tenet_cancel_job` and stops dispatch until resolved. diff --git a/skills/tenet/phases/05-execution-loop.md b/skills/tenet/phases/05-execution-loop.md index 479bd76..df7f8a4 100644 --- a/skills/tenet/phases/05-execution-loop.md +++ b/skills/tenet/phases/05-execution-loop.md @@ -19,7 +19,7 @@ The pre-execution confirmation gate in `phases/04-decomposition.md` MUST also be Execute this sequence for every job cycle: 1. **Check Steering**: `tenet_process_steer()` - Ensure no emergency overrides or new directives exist before starting. + Read `user_messages` first (always returned in full — human input can never be crowded out by agent noise), then `agent_messages`. Ensure no emergency overrides or new directives exist before starting. `truncated: true` means there are more agent steers than shown — widen `limit` or run a sweep to drain them. Retire steers you have handled this cycle with `tenet_update_steer` (see **Steer Hygiene** below). 2. **Get Next Job**: `tenet_continue()` Retrieves the next pending job from the runtime queue. The response includes `next_job` with its runtime `id`. 3. **Compile Context**: `tenet_compile_context(job_id="")` @@ -79,6 +79,16 @@ After every job: - Write a journal entry via `tenet_update_knowledge(type="journal")` to log job completion. Journals write to `.tenet/runs//journal/`. - If the job produced reusable technical insight, also write a knowledge entry via `tenet_update_knowledge(type="knowledge")` with appropriate confidence tag. +### Steer Hygiene + +The steer inbox must be maintained or it stops being useful — agent self-notes accumulate and crowd out real signal. Every cycle: + +- **Read `user_messages` first.** They are always returned in full; agent noise can never hide them. +- **Retire what you've handled.** A `context` steer is one-time — resolve it with `tenet_update_steer(ids=[...], status="resolved")` once consumed, or sweep all agent-context at a run/slice boundary: `tenet_update_steer(sweep="agent_context", status="resolved")`. +- **Retire directives only when clearly done.** Resolve a `directive` once the work it governed is complete or clearly superseded. If you are unsure it still applies, **leave it** — never discard user input on a guess. +- **Keep standing rules.** A directive that must hold across jobs (e.g. "all DB changes need a migration") stays active every cycle until you retire it by id. The sweep never touches directives or user steers, so they are safe. +- **Self-notes are `context`.** Anything you add for yourself (contention notes, self-unblocking) goes in as `class="context"` with the default agent source, so it stays sweepable and won't pile up as `directive`s. + ## Report-Only Jobs (blocking finding escape hatch) Some jobs are **report-only** — their deliverable is an assessment or final acceptance report, not code. They must NOT edit project files (other than writing the report itself). @@ -131,7 +141,8 @@ for finding in code_output.findings + test_output.findings: tenet_retry_job(job_id=source_job.id, enhanced_prompt="Refresh evidence from current commands: " + finding.detail) elif finding.category == "contention": # If we're in parallel mode for this feature, switch to sequential: - tenet_add_steer(content=f"set eval_parallel_safe=false for {feature}", class="directive") + # Agent self-note -> context (sweepable), not directive + tenet_add_steer(content=f"set eval_parallel_safe=false for {feature}", class="context") tenet_retry_job(job_id=source_job.id) elif finding.category == "scope_conflict": # If this is a report-only job that discovered a blocking finding, use the diff --git a/src/cli/status.ts b/src/cli/status.ts index 1cb70a4..e0a27cb 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -163,10 +163,12 @@ export function showStatus(projectPath: string, options?: StatusOptions): void { } } - const unprocessedSteers = stateStore.getUnprocessedSteers().length; - if (unprocessedSteers > 0) { + const steerCounts = stateStore.countUnprocessedSteers(); + if (steerCounts.total > 0) { console.log(''); - console.log(`Unprocessed steer messages: ${unprocessedSteers}`); + console.log( + `Unprocessed steer messages: ${steerCounts.total} (${steerCounts.user} user, ${steerCounts.agent} agent)`, + ); } } finally { stateStore.close(); diff --git a/src/core/state-store.test.ts b/src/core/state-store.test.ts index f914fcb..ed93030 100644 --- a/src/core/state-store.test.ts +++ b/src/core/state-store.test.ts @@ -158,30 +158,112 @@ describe('StateStore', () => { expect(store.getNextRunnableJob()?.id).toBe(join.id); }); - it('reads and updates steer messages from SQLite table', () => { - const { tempDir, store } = createStore(); - const dbPath = path.join(tempDir, '.tenet', '.state', 'tenet.db'); - const db = new Database(dbPath); + describe('steer inbox', () => { + const insertSteer = ( + db: Database, + id: string, + ts: string, + cls: 'context' | 'directive' | 'emergency', + status: string, + source: string, + content = `steer ${id}`, + ): void => { + db.prepare( + `INSERT INTO steer_messages (id, timestamp, class, content, status, source, agent_response, affected_job_ids) + VALUES (?, ?, ?, ?, ?, ?, NULL, '[]')`, + ).run(id, ts, cls, content, status, source); + }; + + it('returns the inbox split by source and retires steers by id', () => { + const { tempDir, store } = createStore(); + const db = new Database(path.join(tempDir, '.tenet', '.state', 'tenet.db')); + insertSteer(db, 'u1', '2026-01-01T00:00:00.000Z', 'directive', 'received', 'user', 'Prioritize tests'); + db.close(); + + const inbox = store.getSteerInbox({ agentLimit: 50 }); + expect(inbox.userMessages).toHaveLength(1); + expect(inbox.userMessages[0]?.id).toBe('u1'); + expect(inbox.userMessages[0]?.status).toBe('received'); + expect(inbox.agentMessages).toHaveLength(0); + expect(inbox.totals).toEqual({ user: 1, agent: 0 }); + + const { updated } = store.updateSteersStatus(['u1'], 'resolved', 'Done'); + expect(updated).toBe(1); + + const after = store.getSteerInbox({ agentLimit: 50 }); + expect(after.userMessages).toHaveLength(0); + expect(after.totals).toEqual({ user: 0, agent: 0 }); + }); - db.prepare( - ` - INSERT INTO steer_messages (id, timestamp, class, content, status, source, agent_response, affected_job_ids) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run('steer-1', '2026-01-01T00:00:00.000Z', 'directive', 'Prioritize tests', 'received', 'user', null, JSON.stringify(['job-1'])); + it('returns all user steers uncapped but caps agent steers to the most recent N', () => { + const { tempDir, store } = createStore(); + const db = new Database(path.join(tempDir, '.tenet', '.state', 'tenet.db')); + insertSteer(db, 'u1', '2026-01-01T00:00:01.000Z', 'context', 'received', 'user'); + insertSteer(db, 'u2', '2026-01-01T00:00:02.000Z', 'context', 'received', 'user'); + insertSteer(db, 'u3', '2026-01-01T00:00:03.000Z', 'context', 'received', 'user'); + insertSteer(db, 'a1', '2026-01-01T00:00:01.000Z', 'context', 'received', 'agent'); + insertSteer(db, 'a2', '2026-01-01T00:00:02.000Z', 'context', 'received', 'agent'); + insertSteer(db, 'a3', '2026-01-01T00:00:03.000Z', 'context', 'received', 'agent'); + insertSteer(db, 'a4', '2026-01-01T00:00:04.000Z', 'context', 'received', 'agent'); + insertSteer(db, 'a5', '2026-01-01T00:00:05.000Z', 'context', 'received', 'agent'); + db.close(); + + const inbox = store.getSteerInbox({ agentLimit: 3 }); + expect(inbox.userMessages.map((m) => m.id)).toEqual(['u1', 'u2', 'u3']); // uncapped, ASC + expect(inbox.agentMessages.map((m) => m.id)).toEqual(['a3', 'a4', 'a5']); // most-recent 3, ASC + expect(inbox.totals).toEqual({ user: 3, agent: 5 }); // true counts, not the capped slice + }); - db.close(); + it('counts unresolved steers by source without loading bodies', () => { + const { store } = createStore(); + store.createSteer({ class: 'directive', content: 'user d', source: 'user' }); + store.createSteer({ class: 'context', content: 'agent c1', source: 'agent' }); + store.createSteer({ class: 'context', content: 'agent c2', source: 'agent' }); - const unprocessed = store.getUnprocessedSteers(); - expect(unprocessed).toHaveLength(1); - expect(unprocessed[0]?.id).toBe('steer-1'); - expect(unprocessed[0]?.status).toBe('received'); - expect(unprocessed[0]?.affectedJobIds).toEqual(['job-1']); + expect(store.countUnprocessedSteers()).toEqual({ user: 1, agent: 2, total: 3 }); + }); - store.updateSteerStatus('steer-1', 'resolved', 'Done'); + it('sweeps only agent-context steers, leaving user steers and directives untouched', () => { + const { tempDir, store } = createStore(); + const db = new Database(path.join(tempDir, '.tenet', '.state', 'tenet.db')); + insertSteer(db, 'ac1', '2026-01-01T00:00:01.000Z', 'context', 'received', 'agent'); // swept + insertSteer(db, 'ac2', '2026-01-01T00:00:02.000Z', 'context', 'received', 'agent'); // swept + insertSteer(db, 'ad1', '2026-01-01T00:00:03.000Z', 'directive', 'received', 'agent'); // kept (directive) + insertSteer(db, 'uc1', '2026-01-01T00:00:04.000Z', 'context', 'received', 'user'); // kept (user) + insertSteer(db, 'ud1', '2026-01-01T00:00:05.000Z', 'directive', 'received', 'user'); // kept (user) + db.close(); + + const result = store.sweepAgentContextSteers('resolved', 'slice boundary'); + expect(result.swept).toBe(2); + expect(result.ids.sort()).toEqual(['ac1', 'ac2']); + + const inbox = store.getSteerInbox({ agentLimit: 50 }); + expect(inbox.userMessages.map((m) => m.id).sort()).toEqual(['uc1', 'ud1']); + expect(inbox.agentMessages.map((m) => m.id)).toEqual(['ad1']); // agent directive survived + expect(inbox.totals).toEqual({ user: 2, agent: 1 }); + }); - const remaining = store.getUnprocessedSteers(); - expect(remaining).toHaveLength(0); + it('filters to a specific job (and broadcasts) when jobId is given', () => { + const { tempDir, store } = createStore(); + const db = new Database(path.join(tempDir, '.tenet', '.state', 'tenet.db')); + db.prepare( + `INSERT INTO steer_messages (id, timestamp, class, content, status, source, agent_response, affected_job_ids) + VALUES (?, ?, ?, ?, ?, ?, NULL, ?)`, + ).run('t1', '2026-01-01T00:00:00.000Z', 'directive', 'for job-1', 'received', 'user', JSON.stringify(['job-1'])); + db.prepare( + `INSERT INTO steer_messages (id, timestamp, class, content, status, source, agent_response, affected_job_ids) + VALUES (?, ?, ?, ?, ?, ?, NULL, ?)`, + ).run('other', '2026-01-01T00:00:00.500Z', 'directive', 'for job-2', 'received', 'user', JSON.stringify(['job-2'])); + db.prepare( + `INSERT INTO steer_messages (id, timestamp, class, content, status, source, agent_response, affected_job_ids) + VALUES (?, ?, ?, ?, ?, ?, NULL, ?)`, + ).run('b1', '2026-01-01T00:00:01.000Z', 'context', 'broadcast', 'received', 'agent', JSON.stringify([])); + db.close(); + + const inbox = store.getSteerInbox({ jobId: 'job-1', agentLimit: 50 }); + expect(inbox.userMessages.map((m) => m.id)).toEqual(['t1']); // targeted, not 'other' + expect(inbox.agentMessages.map((m) => m.id)).toEqual(['b1']); // broadcast included + }); }); it('round-trips config values', () => { diff --git a/src/core/state-store.ts b/src/core/state-store.ts index d622330..cbf5698 100644 --- a/src/core/state-store.ts +++ b/src/core/state-store.ts @@ -652,24 +652,130 @@ export class StateStore { }; } - getUnprocessedSteers(jobId?: string): SteerMessage[] { - const rows = this.db - .prepare("SELECT * FROM steer_messages WHERE status != 'resolved' ORDER BY timestamp ASC") + /** + * Fetch the unresolved steer inbox, split by source. + * + * User steers are returned in full (uncapped) so human input is never crowded + * out by agent noise. Agent steers are capped to the most recent `agentLimit` + * so a pile-up can't flood context every cycle. `totals` reflects the true + * unresolved count per source (independent of the agent cap) so truncation is + * visible to the caller rather than silently dropped. + */ + getSteerInbox({ + jobId, + agentLimit, + }: { + jobId?: string; + agentLimit: number; + }): { + userMessages: SteerMessage[]; + agentMessages: SteerMessage[]; + totals: { user: number; agent: number }; + } { + if (jobId) { + // Targeted path: filter to this job's steers (+ broadcasts), then partition. + const rows = this.db + .prepare("SELECT * FROM steer_messages WHERE status != 'resolved' ORDER BY timestamp ASC") + .all() as SteerRow[]; + const filtered = rows + .map((row) => this.toSteerMessage(row)) + .filter( + (m) => !m.affectedJobIds || m.affectedJobIds.length === 0 || m.affectedJobIds.includes(jobId), + ); + const userMessages = filtered.filter((m) => m.source !== 'agent'); + const allAgent = filtered.filter((m) => m.source === 'agent'); + const agentMessages = allAgent.slice(Math.max(0, allAgent.length - agentLimit)); + return { + userMessages, + agentMessages, + totals: { user: userMessages.length, agent: allAgent.length }, + }; + } + + // Global path (the every-cycle orchestrator fetch): per-bucket SQL. + const userRows = this.db + .prepare( + "SELECT * FROM steer_messages WHERE status != 'resolved' AND source != 'agent' ORDER BY timestamp ASC", + ) .all() as SteerRow[]; - const messages = rows.map((row) => this.toSteerMessage(row)); - if (!jobId) { - return messages; + const agentRows = this.db + .prepare( + "SELECT * FROM (SELECT * FROM steer_messages WHERE status != 'resolved' AND source = 'agent' ORDER BY timestamp DESC LIMIT ?) ORDER BY timestamp ASC", + ) + .all(agentLimit) as SteerRow[]; + const totals = this.countUnprocessedSteers(); + return { + userMessages: userRows.map((row) => this.toSteerMessage(row)), + agentMessages: agentRows.map((row) => this.toSteerMessage(row)), + totals: { user: totals.user, agent: totals.agent }, + }; + } + + /** + * Cheap unresolved counts split by source — for the CLI status line and the + * health report, without loading any message bodies. + */ + countUnprocessedSteers(): { user: number; agent: number; total: number } { + const rows = this.db + .prepare( + "SELECT CASE WHEN source = 'agent' THEN 'agent' ELSE 'user' END AS bucket, COUNT(*) AS n " + + "FROM steer_messages WHERE status != 'resolved' GROUP BY bucket", + ) + .all() as { bucket: string; n: number }[]; + let user = 0; + let agent = 0; + for (const row of rows) { + if (row.bucket === 'agent') { + agent = row.n; + } else { + user = row.n; + } } - // Filter to messages that target this specific job or have no target (broadcast) - return messages.filter( - (m) => !m.affectedJobIds || m.affectedJobIds.length === 0 || m.affectedJobIds.includes(jobId), - ); + return { user, agent, total: user + agent }; } - updateSteerStatus(id: string, status: SteerMessageStatus, agentResponse?: string): void { - this.db - .prepare('UPDATE steer_messages SET status = ?, agent_response = ? WHERE id = ?') - .run(status, agentResponse ?? null, id); + /** Transition one or more steers by id (precise retire — only these are touched). */ + updateSteersStatus( + ids: string[], + status: SteerMessageStatus, + agentResponse?: string, + ): { updated: number } { + if (ids.length === 0) { + return { updated: 0 }; + } + const placeholders = ids.map(() => '?').join(','); + const info = this.db + .prepare(`UPDATE steer_messages SET status = ?, agent_response = ? WHERE id IN (${placeholders})`) + .run(status, agentResponse ?? null, ...ids); + return { updated: info.changes }; + } + + /** + * Bulk-transition every agent-originated context steer. Never touches user + * steers or directives of any source — those are retired only by explicit id, + * so a standing rule or a pending user directive can't be swept away. + */ + sweepAgentContextSteers( + status: SteerMessageStatus, + agentResponse?: string, + ): { swept: number; ids: string[] } { + const run = this.db.transaction((): { swept: number; ids: string[] } => { + const rows = this.db + .prepare( + "SELECT id FROM steer_messages WHERE source = 'agent' AND class = 'context' AND status != 'resolved'", + ) + .all() as { id: string }[]; + if (rows.length === 0) { + return { swept: 0, ids: [] }; + } + const ids = rows.map((row) => row.id); + const placeholders = ids.map(() => '?').join(','); + this.db + .prepare(`UPDATE steer_messages SET status = ?, agent_response = ? WHERE id IN (${placeholders})`) + .run(status, agentResponse ?? null, ...ids); + return { swept: ids.length, ids }; + }); + return run(); } getJobOutput(jobId: string): unknown { diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index 38f7195..2e02c12 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -11,6 +11,7 @@ import { registerTenetJobResultTool } from './tenet-job-result.js'; import { registerTenetJobWaitTool } from './tenet-job-wait.js'; import { registerTenetAddSteerTool } from './tenet-add-steer.js'; import { registerTenetProcessSteerTool } from './tenet-process-steer.js'; +import { registerTenetUpdateSteerTool } from './tenet-update-steer.js'; import { registerTenetRegisterJobsTool } from './tenet-register-jobs.js'; import { registerTenetReportBlockingFindingTool } from './tenet-report-blocking-finding.js'; import { registerTenetRetryJobTool } from './tenet-retry-job.js'; @@ -51,6 +52,7 @@ export const registerAllTools = (server: McpServer, jobManager: JobManager, stat safeRegister(() => registerTenetUpdateKnowledgeTool(registerTool, stateStore)); safeRegister(() => registerTenetAddSteerTool(registerTool, stateStore)); safeRegister(() => registerTenetProcessSteerTool(registerTool, stateStore)); + safeRegister(() => registerTenetUpdateSteerTool(registerTool, stateStore)); safeRegister(() => registerTenetHealthCheckTool(registerTool, stateStore, jobManager)); safeRegister(() => registerTenetGetStatusTool(registerTool, stateStore)); // tenet_set_agent removed from MCP — available via CLI only diff --git a/src/mcp/tools/tenet-health-check.ts b/src/mcp/tools/tenet-health-check.ts index 6c647ba..843b5f3 100644 --- a/src/mcp/tools/tenet-health-check.ts +++ b/src/mcp/tools/tenet-health-check.ts @@ -28,7 +28,7 @@ export const registerTenetHealthCheckTool = ( stale_documents: [], missing_updates: [], broken_references: [], - unacknowledged_steers: stateStore.getUnprocessedSteers().length, + unacknowledged_steers: stateStore.countUnprocessedSteers().total, }; if (jobManager.getActiveConcurrency() > activeJobs) { diff --git a/src/mcp/tools/tenet-process-steer.test.ts b/src/mcp/tools/tenet-process-steer.test.ts new file mode 100644 index 0000000..5535047 --- /dev/null +++ b/src/mcp/tools/tenet-process-steer.test.ts @@ -0,0 +1,67 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { StateStore } from '../../core/state-store.js'; +import { registerTenetProcessSteerTool } from './tenet-process-steer.js'; + +type Handler = (args: { job_id?: string; limit?: number }) => Promise; + +const tempDirs: string[] = []; +const stores: StateStore[] = []; + +const createHarness = (): { store: StateStore; handler: Handler } => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenet-process-steer-test-')); + tempDirs.push(tempDir); + const store = new StateStore(tempDir); + stores.push(store); + + let captured: Handler | undefined; + const registerTool = ((_name: string, _def: unknown, handler: Handler) => { + captured = handler; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + registerTenetProcessSteerTool(registerTool, store); + if (!captured) throw new Error('handler not captured'); + return { store, handler: captured }; +}; + +const parse = (result: CallToolResult): Record => { + const first = result.content[0]; + if (first.type !== 'text') throw new Error('expected text'); + return JSON.parse(first.text) as Record; +}; + +afterEach(() => { + while (stores.length > 0) stores.pop()?.close(); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('tenet_process_steer', () => { + it('returns user steers in full, caps agent steers, and surfaces totals + truncation', async () => { + const { store, handler } = createHarness(); + store.createSteer({ class: 'directive', content: 'user d', source: 'user' }); + store.createSteer({ class: 'context', content: 'a1', source: 'agent' }); + store.createSteer({ class: 'context', content: 'a2', source: 'agent' }); + store.createSteer({ class: 'context', content: 'a3', source: 'agent' }); + + const result = parse(await handler({ limit: 2 })); + expect((result.user_messages as unknown[]).length).toBe(1); + expect((result.agent_messages as unknown[]).length).toBe(2); // capped + expect(result.total_unresolved).toEqual({ user: 1, agent: 3 }); // true counts + expect(result.returned).toEqual({ user: 1, agent: 2 }); + expect(result.truncated).toBe(true); + }); + + it('reports truncated=false when the agent bucket fits within the limit', async () => { + const { store, handler } = createHarness(); + store.createSteer({ class: 'context', content: 'a1', source: 'agent' }); + + const result = parse(await handler({ limit: 50 })); + expect(result.truncated).toBe(false); + expect(result.total_unresolved).toEqual({ user: 0, agent: 1 }); + }); +}); diff --git a/src/mcp/tools/tenet-process-steer.ts b/src/mcp/tools/tenet-process-steer.ts index 1d7254f..bb22eec 100644 --- a/src/mcp/tools/tenet-process-steer.ts +++ b/src/mcp/tools/tenet-process-steer.ts @@ -3,23 +3,48 @@ import { StateStore } from '../../core/state-store.js'; import type { SteerResult } from '../../types/index.js'; import { jsonResult, type RegisterTool } from './utils.js'; +const DEFAULT_STEER_AGENT_LIMIT = 50; + export const registerTenetProcessSteerTool = (registerTool: RegisterTool, stateStore: StateStore): void => { registerTool( 'tenet_process_steer', { description: - 'Check steer inbox and summarize pending steer state. ' + + 'Check the steer inbox and summarize pending steer state, split by source. ' + + 'User steers (from the human) are returned in full; agent self-steers are capped to the most recent `limit` ' + + 'so agent noise can never crowd out user input or flood context. ' + + '`total_unresolved` shows the true count per source even when the agent bucket is capped, and `truncated` ' + + 'signals there are more agent steers than returned — widen `limit` for a deliberate cleanup pass. ' + + 'Retire steers you have handled with `tenet_update_steer`. ' + 'Optionally filter by job_id to get only messages targeted at (or broadcast to) a specific job.', inputSchema: z.object({ - job_id: z.string().uuid().optional().describe('Optional job ID to filter messages for a specific job'), + job_id: z + .string() + .uuid() + .optional() + .describe('Optional job ID to filter messages for a specific job'), + limit: z + .number() + .int() + .positive() + .default(DEFAULT_STEER_AGENT_LIMIT) + .describe( + 'Max agent self-steers returned. User steers are always returned in full. Default 50; widen for a cleanup pass.', + ), }), }, - async ({ job_id }) => { - const messages = stateStore.getUnprocessedSteers(job_id); + async ({ job_id, limit }) => { + const inbox = stateStore.getSteerInbox({ jobId: job_id, agentLimit: limit }); + const all = [...inbox.userMessages, ...inbox.agentMessages]; + const returnedAgent = inbox.agentMessages.length; const result: SteerResult = { - has_emergency: messages.some((message) => message.class === 'emergency'), - has_directive: messages.some((message) => message.class === 'directive'), - messages, + has_emergency: all.some((message) => message.class === 'emergency'), + has_directive: all.some((message) => message.class === 'directive'), + user_messages: inbox.userMessages, + agent_messages: inbox.agentMessages, + total_unresolved: inbox.totals, + returned: { user: inbox.userMessages.length, agent: returnedAgent }, + truncated: inbox.totals.agent > returnedAgent, }; return jsonResult(result); }, diff --git a/src/mcp/tools/tenet-update-steer.test.ts b/src/mcp/tools/tenet-update-steer.test.ts new file mode 100644 index 0000000..acca914 --- /dev/null +++ b/src/mcp/tools/tenet-update-steer.test.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { StateStore } from '../../core/state-store.js'; +import { registerTenetUpdateSteerTool } from './tenet-update-steer.js'; + +type Handler = (args: { + ids?: string[]; + sweep?: 'agent_context'; + status: 'acknowledged' | 'acted_on' | 'resolved'; + agent_response?: string; +}) => Promise; + +const tempDirs: string[] = []; +const stores: StateStore[] = []; + +const createHarness = (): { store: StateStore; handler: Handler } => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenet-update-steer-test-')); + tempDirs.push(tempDir); + const store = new StateStore(tempDir); + stores.push(store); + + let captured: Handler | undefined; + const registerTool = ((_name: string, _def: unknown, handler: Handler) => { + captured = handler; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + registerTenetUpdateSteerTool(registerTool, store); + if (!captured) throw new Error('handler not captured'); + return { store, handler: captured }; +}; + +const parse = (result: CallToolResult): Record => { + const first = result.content[0]; + if (first.type !== 'text') throw new Error('expected text'); + return JSON.parse(first.text) as Record; +}; + +afterEach(() => { + while (stores.length > 0) stores.pop()?.close(); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('tenet_update_steer', () => { + it('retires a steer by id so it leaves the inbox', async () => { + const { store, handler } = createHarness(); + const steer = store.createSteer({ class: 'directive', content: 'do the thing', source: 'user' }); + expect(store.getSteerInbox({ agentLimit: 50 }).userMessages).toHaveLength(1); + + const result = parse(await handler({ ids: [steer.id], status: 'resolved', agent_response: 'done' })); + expect(result.updated).toBe(1); + expect(result.status).toBe('resolved'); + + expect(store.getSteerInbox({ agentLimit: 50 }).userMessages).toHaveLength(0); + }); + + it('sweeps agent-context steers but leaves user steers and directives of any source', async () => { + const { store, handler } = createHarness(); + const ac = store.createSteer({ class: 'context', content: 'agent self-note', source: 'agent' }); + const ad = store.createSteer({ class: 'directive', content: 'agent directive', source: 'agent' }); + const uc = store.createSteer({ class: 'context', content: 'user context', source: 'user' }); + const ud = store.createSteer({ class: 'directive', content: 'user directive', source: 'user' }); + + const result = parse(await handler({ sweep: 'agent_context', status: 'resolved' })); + expect(result.swept).toBe(1); + expect(result.ids).toEqual([ac.id]); + + const inbox = store.getSteerInbox({ agentLimit: 50 }); + expect(inbox.userMessages.map((m) => m.id).sort()).toEqual([uc.id, ud.id].sort()); + expect(inbox.agentMessages.map((m) => m.id)).toEqual([ad.id]); // agent directive survived the sweep + }); + + it('errors when no steer matches the given ids', async () => { + const { handler } = createHarness(); + const result = await handler({ ids: ['no-such-id'], status: 'resolved' }); + expect(result.isError).toBe(true); + }); + + it('errors when both or neither of ids/sweep are provided', async () => { + const { handler } = createHarness(); + expect((await handler({ status: 'resolved' })).isError).toBe(true); // neither + expect((await handler({ ids: ['x'], sweep: 'agent_context', status: 'resolved' })).isError).toBe(true); // both + }); +}); diff --git a/src/mcp/tools/tenet-update-steer.ts b/src/mcp/tools/tenet-update-steer.ts new file mode 100644 index 0000000..3f5a6f9 --- /dev/null +++ b/src/mcp/tools/tenet-update-steer.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; +import { StateStore } from '../../core/state-store.js'; +import { jsonResult, asToolError, type RegisterTool } from './utils.js'; + +const steerStatusSchema = z.enum(['acknowledged', 'acted_on', 'resolved']); + +export const registerTenetUpdateSteerTool = (registerTool: RegisterTool, stateStore: StateStore): void => { + registerTool( + 'tenet_update_steer', + { + description: + 'Transition one or more steer messages through their lifecycle (acknowledged / acted_on / resolved), ' + + 'or bulk-sweep agent self-notes. Two mutually exclusive modes:\n' + + '- `ids`: transition exactly the steers listed. Use this for directives/emergencies once handled, and to retire ' + + 'any steer you want gone. Only the ids you list are touched — a steer you want to keep applying is safe, just ' + + "leave it out of the list.\n" + + '- `sweep: "agent_context"`: resolve every agent-originated context steer in one call (the ephemeral pile). ' + + 'Never touches user steers or directives of any source — those retire only by explicit id, so a standing rule ' + + 'or a pending user directive can never be swept away.\n' + + 'Lifetime contract: `context` steers are one-time (retire once consumed); `directive`/`emergency` persist until ' + + 'retired. Add agent self-notes as `context`, not `directive`, so they stay sweepable.', + inputSchema: z.object({ + ids: z + .array(z.string().min(1)) + .optional() + .describe('Steer ids to transition (precise retire). Mutually exclusive with `sweep`.'), + sweep: z + .enum(['agent_context']) + .optional() + .describe('Bulk-resolve all agent-originated context steers. Mutually exclusive with `ids`.'), + status: steerStatusSchema.describe('Status to set: acknowledged, acted_on, or resolved.'), + agent_response: z + .string() + .optional() + .describe('Optional note recorded on each transitioned steer (e.g. reason for resolving).'), + }), + }, + async ({ ids, sweep, status, agent_response }) => { + const hasIds = Array.isArray(ids) && ids.length > 0; + if (hasIds === Boolean(sweep)) { + return asToolError( + new Error('Provide exactly one of `ids` (one or more) or `sweep`. They are mutually exclusive.'), + ); + } + + if (sweep === 'agent_context') { + const result = stateStore.sweepAgentContextSteers(status, agent_response); + return jsonResult({ + swept: result.swept, + ids: result.ids, + status, + message: + result.swept === 0 + ? 'No agent-context steers to sweep.' + : `Swept ${result.swept} agent-context steer(s) to "${status}". User steers and directives were not touched.`, + }); + } + + const result = stateStore.updateSteersStatus(ids as string[], status, agent_response); + if (result.updated === 0) { + return asToolError(new Error('No steer matched the given ids. Nothing was transitioned.')); + } + return jsonResult({ + updated: result.updated, + status, + message: `Transitioned ${result.updated} steer(s) to "${status}".`, + }); + }, + ); +}; diff --git a/src/mcp/tools/tool-names.ts b/src/mcp/tools/tool-names.ts index a7aca8c..0a5be1c 100644 --- a/src/mcp/tools/tool-names.ts +++ b/src/mcp/tools/tool-names.ts @@ -22,6 +22,7 @@ export const TENET_MCP_TOOL_NAMES = [ 'tenet_update_knowledge', 'tenet_add_steer', 'tenet_process_steer', + 'tenet_update_steer', 'tenet_health_check', 'tenet_get_status', 'tenet_retry_job', diff --git a/src/types/index.ts b/src/types/index.ts index 26df191..ac63f77 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -86,7 +86,16 @@ export interface SteerMessage { export interface SteerResult { has_emergency: boolean; has_directive: boolean; - messages: SteerMessage[]; + /** All unresolved user steers — uncapped, so human input is never crowded out. */ + user_messages: SteerMessage[]; + /** Most-recent-`limit` unresolved agent steers. */ + agent_messages: SteerMessage[]; + /** True unresolved counts per source (independent of the agent cap). */ + total_unresolved: { user: number; agent: number }; + /** How many were actually returned per source this call. */ + returned: { user: number; agent: number }; + /** True when the agent bucket was capped — there are more agent steers than returned. */ + truncated: boolean; } export interface HealthReport { From 1ead68233f7c29b5abae4ec63738e2561b9a8996 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Tue, 23 Jun 2026 09:05:04 +0900 Subject: [PATCH 09/87] feat(doctrine): run-end drift review + durable proposals (no new tool) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.tenet/project/**` is read by every run but never re-scanned, so once it drifts, every run starts on stale context — and nothing flagged it. The skill already told jobs to "write the proposed update to the journal and mention it in the final report", but that prescription was too vague to act on, nothing collected it, and nothing applied accepted updates. The gap was a missing skill step, not a missing capability — doctrine proposals are documents the agent writes natively, and apply dispatches a tracked job via the existing tenet_start_job. So this adds no MCP tool and no DB change. - 05-execution-loop.md: structured doctrine-drift note convention (file, current_claim, observed_reality, proposed_change); new "Run Completion — Doctrine Drift Review" step consolidates drift notes into per-document proposals appended to .tenet/runs//doctrine-proposals.md (append-only, survives compaction/session loss, never blocks); apply path via an existing dev job with allow_project_doctrine_edits + bootstrap re-gate - 00-context-bootstrap.md: closes the "belongs to lifecycle maintenance" pointer — the maintenance job re-runs this gate - 06-evaluation.md: a job with allow_project_doctrine_edits editing .tenet/project/** is NOT a scope_conflict (so the apply path can't be wrongly failed) - SKILL.md: Doctrine Maintenance section + phase-map line - CLAUDE.md: doctrine-proposals.md as a per-run artifact; maintenance line Co-Authored-By: Claude --- CLAUDE.md | 4 +- skills/tenet/SKILL.md | 12 +++++- skills/tenet/phases/00-context-bootstrap.md | 2 +- skills/tenet/phases/05-execution-loop.md | 47 ++++++++++++++++++++- skills/tenet/phases/06-evaluation.md | 2 +- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 796ab52..da15a74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,8 +65,8 @@ The system has four layers: Tenet uses a document lifecycle layout. `tenet init` scaffolds only this layout; legacy top-level artifact directories only appear via migration (see `src/cli/init.ts` → `migrateLegacyDocuments`). -- **Durable doctrine** — `.tenet/project/` (`overview.md`, `architecture.md`, `product.md`, `testing.md`, `design.md`, `design-components/`). Authored by the context-bootstrap phase (brownfield) or post-interview crystallization (greenfield); normal implementation jobs must not edit it. -- **Per-run artifacts** — `.tenet/runs//` where `` = `YYYY-MM-DD-`. Holds `interview.md`, `spec.md`, `harness.md`, `scenarios.md`, `decomposition.md`, plus `research/`, `journal/`, and `visuals/` subdirs. +- **Durable doctrine** — `.tenet/project/` (`overview.md`, `architecture.md`, `product.md`, `testing.md`, `design.md`, `design-components/`). Authored by the context-bootstrap phase (brownfield) or post-interview crystallization (greenfield); normal implementation jobs must not edit it. Doctrine stays current via the run-end drift review: jobs flag stale doctrine as drift notes, the run consolidates them into `.tenet/runs//doctrine-proposals.md`, and an authorized `dev` job (`allow_project_doctrine_edits: true`) applies accepted proposals then re-runs the bootstrap gate. +- **Per-run artifacts** — `.tenet/runs//` where `` = `YYYY-MM-DD-`. Holds `interview.md`, `spec.md`, `harness.md`, `scenarios.md`, `decomposition.md`, `doctrine-proposals.md` (run-end doctrine-drift proposals, append-only), plus `research/`, `journal/`, and `visuals/` subdirs. - **Curated knowledge** — `.tenet/knowledge/` (durable, concern-oriented facts promoted via `tenet_update_knowledge`). - **Legacy evidence** — `.tenet/archive/legacy-v1/` (one-time migration target for pre-lifecycle top-level dirs: `spec/`, `interview/`, `harness/`, `decomposition/`, `journal/`, `visuals/`, `bootstrap/`, `steer/`, `knowledge/`, `DESIGN.md`). Reference-only, not active doctrine. - **Auto-generated from DB** — `.tenet/status/` (`status.md`, `job-queue.md`). diff --git a/skills/tenet/SKILL.md b/skills/tenet/SKILL.md index a83066b..46d58af 100644 --- a/skills/tenet/SKILL.md +++ b/skills/tenet/SKILL.md @@ -141,7 +141,7 @@ Read the relevant phase file before executing that phase. The phrase "read" mean 3. Pre-spec research, spec, harness, readiness gate: `phases/02-spec-and-harness.md` 4. Visual artifacts and prototypes: `phases/03-visuals.md` 5. DAG decomposition and job registration: `phases/04-decomposition.md` -6. Autonomous execution loop, report-only blocking findings, finding dispatch, and git behavior: `phases/05-execution-loop.md` +6. Autonomous execution loop, run-completion doctrine drift review, report-only blocking findings, finding dispatch, and git behavior: `phases/05-execution-loop.md` 7. Evaluation pipeline and failure handling: `phases/06-evaluation.md` 8. Agile checkpoints and redirects: `phases/07-agile-checkpoints.md` @@ -215,6 +215,16 @@ Use `tenet_update_knowledge` for both reusable knowledge and session journal ent Use confidence tags such as `[implemented-and-tested]`, `[implemented-not-tested]`, `[decision-only]`, `[scanned-not-verified]`, `[research-verified]`, or `[research-inconclusive]`. +## Doctrine Maintenance + +`.tenet/project/**` is read by every run but never re-scanned, so it can drift stale silently. The lifecycle is prompt-driven — there is no dedicated tool: + +- During a run, a job that finds doctrine missing/stale/wrong writes a **doctrine-drift note** to the run journal (see `phases/05-execution-loop.md` → *Project Doctrine Write Boundary*). +- At run completion (`tenet_continue()` → `all_done`), the orchestrator consolidates drift notes into per-document proposals appended to `.tenet/runs//doctrine-proposals.md`. That file is append-only and survives compaction / a lost session. The loop **never blocks** on it. +- To apply an accepted proposal, dispatch a `dev` doctrine-maintenance job via the existing `tenet_start_job` with `allow_project_doctrine_edits: true`, then re-run the bootstrap gate (`phases/00-context-bootstrap.md`). + +Normal jobs never edit `.tenet/project/**` directly — only an authorized doctrine-maintenance job does. + ## Safety - Detect stagnation: repeated identical failures, edit/revert cycles, repeated rereads without new decisions, or repeated tool-call loops. diff --git a/skills/tenet/phases/00-context-bootstrap.md b/skills/tenet/phases/00-context-bootstrap.md index 2892df3..8e5e67f 100644 --- a/skills/tenet/phases/00-context-bootstrap.md +++ b/skills/tenet/phases/00-context-bootstrap.md @@ -14,7 +14,7 @@ The gate passes only when all required files exist and are usable: - `.tenet/project/testing.md` - `.tenet/project/design.md` -Fail the gate when `.tenet/project/` is missing, any required file is missing, or a required file is empty, placeholder-only, or an obvious template. Do not score documents as thin, stale, incomplete, or improvable. Those judgments belong to explicit lifecycle maintenance, not bootstrap. +Fail the gate when `.tenet/project/` is missing, any required file is missing, or a required file is empty, placeholder-only, or an obvious template. Do not score documents as thin, stale, incomplete, or improvable. Those judgments belong to explicit lifecycle maintenance, not bootstrap. That lifecycle is the **doctrine drift review** at run completion (`phases/05-execution-loop.md`): jobs flag stale doctrine as drift notes, the run end consolidates them into proposals under `.tenet/runs//doctrine-proposals.md`, and an authorized doctrine-maintenance job (`allow_project_doctrine_edits: true`) applies accepted ones and **re-runs this gate** to confirm coherence. When the gate fails, stop normal Tenet work and run this phase. Do not proceed to interview, spec, decomposition, execution, or eval until the gate passes. diff --git a/skills/tenet/phases/05-execution-loop.md b/skills/tenet/phases/05-execution-loop.md index df7f8a4..bed199e 100644 --- a/skills/tenet/phases/05-execution-loop.md +++ b/skills/tenet/phases/05-execution-loop.md @@ -45,6 +45,44 @@ Execute this sequence for every job cycle: `.tenet/status/job-queue.md` and `.tenet/status/status.md` are generated from MCP state transitions. Do not edit them manually to advance runtime. 13. **Loop**: Return to Step 1. +## Run Completion — Doctrine Drift Review + +Steps 1–13 are the **per-job** cycle. The loop exits when `tenet_continue()` returns `all_done: true` (no `next_job`, nothing running), or when agile mode reaches its final checkpoint. At run completion, **before** the final `tenet_get_status()` report, run the doctrine drift review. + +`.tenet/project/**` doctrine is read at the start of every run (`tenet_compile_context`) but never re-scanned, so once it drifts, every subsequent run starts on stale context. This step keeps it from silently rotting — and it never blocks the loop. + +1. **Collect drift notes.** Read the run's journal (`.tenet/runs//journal/`) for any **doctrine-drift notes** written during the run (see *Project Doctrine Write Boundary* above). +2. **If there are none, stop.** Doctrine is current — write no proposal, no overhead. Continue to the final report. +3. **Consolidate.** For each affected `.tenet/project/**` file, read the current doctrine and weigh the drift notes against it; draft one consolidated proposal per file (merge related notes, drop contradictions). +4. **Append the proposals** to `.tenet/runs//doctrine-proposals.md` (create the file if absent). One section per proposal: + + ```markdown + ## project/.md — + - status: proposed + - current_claim: ... + - observed_reality: ... + - proposed_change: ... + - rationale: ... + - source_notes: [drift-note titles from the journal] + ``` + + This file is **append-only** and lives with the run, so it survives compaction and a lost session — proposals are never silently dropped. +5. **Never block.** In autonomous mode, state the count and path in the final report and continue — the user applies proposals between runs. In attended/agile mode you may offer to apply accepted proposals inline (see *Applying a proposal*). Do not stall the run waiting on a decision. + +### Applying a proposal + +Proposals are applied by a doctrine-maintenance job through the **existing** tools — there is no new tool for this. To apply an accepted proposal: + +``` +tenet_start_job(job_type="dev", params={ + name: "doctrine maintenance: ", + prompt: "", + allow_project_doctrine_edits: true +}) +``` + +`allow_project_doctrine_edits: true` authorizes the `.tenet/project/**` edit and is eval-safe — the code critic's `scope_conflict` check honors it (see `phases/06-evaluation.md`). After the job passes eval, **re-run the bootstrap gate** (`phases/00-context-bootstrap.md`) to confirm doctrine is coherent, then set the proposal's `status: applied`. Doctrine is re-synthesized and re-gated, not raw-patched — that is what "maintained correctly" means. + ## Operational Rules ### Use MCP Tools, Not Subagents @@ -53,7 +91,14 @@ Dispatch work via `tenet_start_job`. Do not call subagents directly. Do not writ ### Project Doctrine Write Boundary Normal implementation, integration, eval, spec, decomposition, harness, and visual jobs must not edit `.tenet/project/**`. They may read project doctrine and write run-local evidence under `.tenet/runs//**`. -If a normal job discovers that project doctrine is missing, stale, or wrong, it should write the proposed update to the run-local journal or design/spec notes and mention it in the final report. Only explicit context-bootstrap, migration/bootstrap synthesis, document lifecycle cleanup, or direct user-requested doctrine jobs may edit `.tenet/project/**`. +If a normal job discovers that project doctrine is missing, stale, or wrong, it must record a **doctrine-drift note** — it must NOT edit `.tenet/project/**`. Write the note to the run journal via `tenet_update_knowledge(type="journal", title="doctrine drift: ")` with these `findings` fields: + +- **doctrine_file** — which `.tenet/project/**` file is affected (e.g. `project/architecture.md`) +- **current_claim** — what the doctrine currently asserts +- **observed_reality** — what the code or run actually shows +- **proposed_change** — the specific edit that would bring doctrine back in line + +Only explicit context-bootstrap, an authorized doctrine-maintenance job (`allow_project_doctrine_edits: true`), or direct user-requested doctrine work may edit `.tenet/project/**`. Drift notes are the input that keeps `.tenet/project/**` from silently rotting — they are collected into durable proposals at run completion (see **Run Completion — Doctrine Drift Review** below). ### Background Status Check Pattern `tenet_job_wait` returns instantly by default, or long-polls when `wait_seconds` is set. The orchestrator dispatches bounded waits as background tasks and waits between checks using exponential backoff: start at 30 seconds, multiply by 1.5× each cycle, cap at 120 seconds. Between checks: diff --git a/skills/tenet/phases/06-evaluation.md b/skills/tenet/phases/06-evaluation.md index b4aa5a7..ececc29 100644 --- a/skills/tenet/phases/06-evaluation.md +++ b/skills/tenet/phases/06-evaluation.md @@ -82,7 +82,7 @@ The code critic checks: - Does the implementation match the spec's intent? - Are any anti-scenarios violated? - Are there obvious gaps or missing edge cases? -- Did the job edit `.tenet/project/**` without explicit context-bootstrap, doctrine-maintenance, or direct user authorization? If yes, fail with `category: "scope_conflict"`. +- Did the job edit `.tenet/project/**` without authorization? Authorization = the job was dispatched with `allow_project_doctrine_edits: true` (context-bootstrap, or a doctrine-maintenance job — see `phases/05-execution-loop.md` → *Applying a proposal*), or a direct user request. If edits are unauthorized, fail with `category: "scope_conflict"`. **Zero-findings rule**: If the critic finds nothing, it must re-analyze using an alternate attack vector like security, performance, or concurrency. Zero findings trigger a mandatory second pass. From 3e22172c565be756a0f1d48468532804a37a0a69 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Tue, 23 Jun 2026 16:16:19 +0900 Subject: [PATCH 10/87] docs: align authoritative docs and skills with current code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified doc/code drift surfaced across docs-review rounds. Docs/skills only; no source changes. - README, CLAUDE.md, planning/06, planning/10, planning/14: correct the MCP tool count to 19 and show the full adapter commands (claude --print --output-format json, opencode run --format json, codex exec --sandbox workspace-write). - README: add the tenet_update_steer row to the MCP tools table. - release-runbook: lint is fully automated (eslint ^10.5.0 is a devDependency; ci.yml and publish.yml both run `pnpm run lint`) — drop the stale "not automated" bullet and add lint to the publish.yml step list. - CLAUDE.md: reword "three pre-approval configs" to "three agent-config surfaces ... driven by one source-of-truth tool-name list" (the list below it has four items). - planning/10: add a banner mapping the proposed names tenet_request_remediation -> tenet_report_blocking_finding and blocked_remediation_required -> blocked_on_finding to what shipped. - planning/14: add a supersession banner noting agile mode is implemented (phases 02/04/07) and the pre-lifecycle layout is historical. - skill files (SKILL.md, phases/01, phases/02): align research confidence tags with the live enum ([scanned-not-verified] / [decision-only]) instead of the non-existent [research-verified] / [research-inconclusive]. Co-Authored-By: Claude --- CLAUDE.md | 4 ++-- README.md | 9 +++++---- docs/planning/06_status_2026-04-08.md | 2 +- docs/planning/10_eval_mode_and_remediation.md | 6 +++++- docs/planning/14_agile_mode.md | 3 ++- docs/release-runbook.md | 3 +-- skills/tenet/SKILL.md | 2 +- skills/tenet/phases/01-interview.md | 2 +- skills/tenet/phases/02-spec-and-harness.md | 2 +- 9 files changed, 19 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index da15a74..21eaf22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ The system has four layers: 1. **Core** (`src/core/`) — Job orchestration (`job-manager.ts`), SQLite persistence (`state-store.ts`), DB migrations (`migrations.ts`), runtime defaults (`runtime-config.ts`), and status file sync (`status-writer.ts`). JobManager handles DAG-based job execution with heartbeat stall detection (30-min default heartbeat timeout), retry logic (`retryJob()`), and configurable concurrency. Retry defaults to unlimited (`max_retries = -1` internally); finite budgets are configured with `tenet config --max-retries `. Each JobManager instance generates a UUID (`serverId`) on startup; stale running jobs from a different server instance are reset to "pending" only after their heartbeat exceeds the timeout (orphan detection via `resetOrphanedJobs()`). StateStore manages the `jobs`, `events`, `steer_messages`, and `config` tables in `.tenet/.state/tenet.db` (WAL mode). The `config.db_schema_version` key tracks the DB schema. Normal StateStore startup refuses legacy or newer DB schemas with a clear `tenet init --upgrade` instruction; real migrations only run through `new StateStore(projectPath, { migrate: true })`, which is wired to `tenet init --upgrade`. The `jobs` table includes a `server_id` column for crash recovery tracking. Status files (`.tenet/status/status.md`, `job-queue.md`) auto-update on every job state transition. -2. **Adapters** (`src/adapters/`) — Pluggable agent adapters that spawn CLI subprocesses with a 120-minute default timeout, configurable through `tenet config --timeout `. Each adapter implements `AgentAdapter` from `base.ts`: `isAvailable()`, `invoke(invocation)`. Three built-in: `ClaudeAdapter` (`claude --print`), `OpenCodeAdapter` (`opencode run`), `CodexAdapter` (`codex exec --sandbox workspace-write` by default). Global adapter args and job-scoped args (for example `codex_args_playwright_eval`) are read at MCP server startup. JobManager resolves the configured adapter strictly by name and fails closed if that adapter is unavailable. +2. **Adapters** (`src/adapters/`) — Pluggable agent adapters that spawn CLI subprocesses with a 120-minute default timeout, configurable through `tenet config --timeout `. Each adapter implements `AgentAdapter` from `base.ts`: `isAvailable()`, `invoke(invocation)`. Three built-in: `ClaudeAdapter` (`claude --print --output-format json`), `OpenCodeAdapter` (`opencode run --format json`), `CodexAdapter` (`codex exec --sandbox workspace-write` by default). Global adapter args and job-scoped args (for example `codex_args_playwright_eval`) are read at MCP server startup. JobManager resolves the configured adapter strictly by name and fails closed if that adapter is unavailable. 3. **MCP Server** (`src/mcp/`) — Exposes 19 tools via `@modelcontextprotocol/server`. Entry point at `src/mcp/index.ts`. Each tool in `src/mcp/tools/` registers itself with a Zod input schema and handler. Key tools: `tenet_start_job`, `tenet_continue` (server-side continuation), `tenet_compile_context`, `tenet_register_jobs` (loads job DAG, requires `feature` slug), `tenet_retry_job` (resets completed/failed jobs to pending), `tenet_report_blocking_finding` (report-only escalation), `tenet_validate_clarity`, `tenet_add_steer` / `tenet_update_steer` (create steer messages; retire/sweep them — `tenet_process_steer` returns user steers in full and caps agent steers, so user input can't be crowded out by agent noise), `tenet_start_eval` (dispatches code critic + test critic + playwright eval), `tenet_init` (initialize project from MCP). Agent selection is CLI-only via `tenet config --agent `. @@ -89,7 +89,7 @@ Dev-type jobs get a "Deliverable Requirements" preamble prepended to their promp ## MCP Tool Pre-Approval (agent configs) -When adding or removing an MCP tool, three pre-approval configs must stay in sync: +When adding or removing an MCP tool, three agent-config surfaces must stay in sync — all driven by one source-of-truth tool-name list: 1. **Tool name list** — `src/mcp/tools/tool-names.ts` (`TENET_MCP_TOOL_NAMES`). This is the single source of truth. A test in `src/mcp/tools/index.ts` asserts this list matches actual registrations. diff --git a/README.md b/README.md index 6054ede..23b243f 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ Three classes: `context` (informational), `directive` (priority change), `emerge MCP Protocol | +--------v--------+ - | MCP Server | 18 tools (start_job, eval, steer, etc.) + | MCP Server | 19 tools (start_job, eval, steer, etc.) +--------+--------+ | +--------------+--------------+ @@ -116,8 +116,8 @@ Three classes: `context` (informational), `directive` (priority change), `emerge | (DAG, retry| | (SQLite+WAL)| | (subprocess)| | heartbeat) | | | | | +------------+ +-------------+ +-------------+ - claude --print - opencode run + claude --print --output-format json + opencode run --format json codex exec --sandbox workspace-write ``` @@ -125,7 +125,7 @@ Three classes: `context` (informational), `directive` (priority change), `emerge 1. **Core** — Job orchestration with DAG execution, heartbeat stall detection, configurable retry logic, and server-ID crash recovery 2. **Adapters** — Pluggable agent adapters that spawn CLI subprocesses. 120-minute default timeout, configurable. -3. **MCP Server** — 18 tools via `@modelcontextprotocol/server`. Zod-validated inputs. +3. **MCP Server** — 19 tools via `@modelcontextprotocol/server`. Zod-validated inputs. 4. **CLI** — `init`, `serve`, `status`, `config` commands. Scaffolds `.tenet/`, copies skills to agent-specific locations, and runs explicit DB upgrades. ## CLI Reference @@ -215,6 +215,7 @@ suppress it entirely. | `tenet_update_knowledge` | Write knowledge/journal entries | | `tenet_add_steer` | Submit a steer message (context/directive/emergency) | | `tenet_process_steer` | Acknowledge and act on steer messages | +| `tenet_update_steer` | Retire resolved steers by id or sweep agent-context steers | | `tenet_health_check` | Verify system consistency | | `tenet_get_status` | Get current job counts and progress | diff --git a/docs/planning/06_status_2026-04-08.md b/docs/planning/06_status_2026-04-08.md index e0c38af..00bca18 100644 --- a/docs/planning/06_status_2026-04-08.md +++ b/docs/planning/06_status_2026-04-08.md @@ -5,7 +5,7 @@ ### Core MCP Server + CLI - Job orchestration with DAG-based execution, heartbeat stall detection, configurable concurrency - SQLite persistence (WAL mode) for jobs, events, steer messages, config -- 16 MCP tools (see CLAUDE.md for key tools) +- 16 MCP tools at the time of writing (the current count is 19 — see CLAUDE.md) - Three agent adapters: ClaudeAdapter (`claude --print`), OpenCodeAdapter, CodexAdapter - CLI with `init`, `serve`, `status`, `config` commands diff --git a/docs/planning/10_eval_mode_and_remediation.md b/docs/planning/10_eval_mode_and_remediation.md index 3fcac9a..ac97afd 100644 --- a/docs/planning/10_eval_mode_and_remediation.md +++ b/docs/planning/10_eval_mode_and_remediation.md @@ -2,6 +2,10 @@ **Created**: 2026-04-17 **Status**: Design +**Update (2026-06-23)**: Names below changed when shipped: + - `tenet_request_remediation` → `tenet_report_blocking_finding` (tool-names.ts:29; used in skills/tenet/phases/05-execution-loop.md and 06-evaluation.md) + - `blocked_remediation_required` → `blocked_on_finding` (src/core/migrations.ts:52 rewrites legacy rows to the shipped name at runtime) + The body keeps the proposal names as historical design narrative. **Origin**: `tenet-manual-test-codex` 10-hour autonomous run retrospective (`TENET_PIPELINE_RETROSPECTIVE.md`, 2026-04-16) --- @@ -330,7 +334,7 @@ None. `extra_args` is empty by default; today's behavior is preserved for existi ## Part 5 — Pre-approve Tenet MCP tools during `tenet init` -**Origin:** On a fresh `tenet init`, every first invocation of every Tenet MCP tool triggers an approval prompt in the host agent (Claude Code, OpenCode, or Codex). Since Tenet has 17 MCP tools, this is 17 prompts per agent switch. Previously listed as a Round-3 open idea in `project_tenet_open_ideas.md`; promoting here. +**Origin:** On a fresh `tenet init`, every first invocation of every Tenet MCP tool triggers an approval prompt in the host agent (Claude Code, OpenCode, or Codex). Since Tenet has 19 MCP tools (at time of writing), this is one prompt per tool per agent switch. Previously listed as a Round-3 open idea in `project_tenet_open_ideas.md`; promoting here. ### Problem diff --git a/docs/planning/14_agile_mode.md b/docs/planning/14_agile_mode.md index c3a490d..032a623 100644 --- a/docs/planning/14_agile_mode.md +++ b/docs/planning/14_agile_mode.md @@ -2,6 +2,7 @@ **Created**: 2026-04-28 **Status**: Design (locked, ready for implementation) +**Update (2026-06-23)**: Agile mode has since been implemented (phases 02, 04, 07). The artifact paths and phase list below predate the document lifecycle (see `docs/planning/16_document_lifecycle.md`) — shipped agile mode uses run-local `.tenet/runs//` artifacts, `visuals/` (not `mockup/`), and 8 phases including context bootstrap. Treat the layout/phase details below as historical design narrative. **Origin**: Obsidian inbox note `2026-04-21-tenet-agile-autonomous-loop-idea.md` **Visual references**: `12_agile_mode_design.html` (static diagrams), `13_agile_mode_simulator.html` (interactive walkthrough) @@ -123,7 +124,7 @@ Critic + test critic + playwright_eval fire on every job, just as in autonomous ## What's reused (everything else) - All 7 phases: interview · spec · mockup · harness · decomposition · execution · eval -- All 17 MCP tools — notably: +- All current MCP tools (19 at time of writing) — notably: - `tenet_init` — scaffold (unchanged) - `tenet_validate_clarity` — after interview (unchanged) - `tenet_compile_context` — each phase (unchanged; resolves the same single doc per feature) diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 103257f..41da571 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -42,7 +42,7 @@ EOF Then visit the GitHub Releases page, review the final draft, and click **Publish release**. That click triggers `.github/workflows/publish.yml`: 1. Checks out the tagged commit. 2. Verifies `package.json` version matches the tag. -3. Runs typecheck + tests + build. +3. Runs typecheck + lint + tests + build. 4. Runs `npm publish --provenance` via OIDC. Total time: ~2-3 minutes after you click Publish. @@ -121,7 +121,6 @@ Useful when the repo is temporarily unable to use Actions (outage, credential is ## What's NOT automated - **E2E canaries** (`make e2e-*`). They cost real money and are maintainer-discretion. Run before a release if the change is risky. -- **Lint** (`make lint`). eslint isn't currently in devDeps; the workflow skips it. Add eslint + re-enable the lint step if desired. - **Version bump**. Still manual via `make bump-patch` or `make bump-month`. Intentional — we want the commit with the version change to be deliberate and reviewable. ## Verifying a successful release diff --git a/skills/tenet/SKILL.md b/skills/tenet/SKILL.md index 46d58af..0a69dc4 100644 --- a/skills/tenet/SKILL.md +++ b/skills/tenet/SKILL.md @@ -213,7 +213,7 @@ Use `tenet_update_knowledge` for both reusable knowledge and session journal ent - `type="knowledge"` for facts future jobs or future features can reuse. - `type="journal"` for job/session history and failure attempts. Journals route to `.tenet/runs//journal/`. -Use confidence tags such as `[implemented-and-tested]`, `[implemented-not-tested]`, `[decision-only]`, `[scanned-not-verified]`, `[research-verified]`, or `[research-inconclusive]`. +Use confidence tags such as `[implemented-and-tested]`, `[implemented-not-tested]`, `[decision-only]`, or `[scanned-not-verified]`. (For research notes, prefer `[scanned-not-verified]` when a claim is unproven and `[decision-only]` once it has been adopted.) ## Doctrine Maintenance diff --git a/skills/tenet/phases/01-interview.md b/skills/tenet/phases/01-interview.md index 90571d8..84b2bc7 100644 --- a/skills/tenet/phases/01-interview.md +++ b/skills/tenet/phases/01-interview.md @@ -158,7 +158,7 @@ When the user's requirements involve unfamiliar technologies, complex integratio - What was researched and why - Key findings (capabilities, limitations, compatibility) - Recommended approach based on findings - - Confidence tag: `[research-verified]` or `[research-inconclusive]` + - Confidence tag: `[scanned-not-verified]` (unproven) or `[decision-only]` (adopted) - Promote only durable, reusable facts to top-level `.tenet/knowledge/` via `tenet_update_knowledge(type="knowledge", title="{concern}")`. **Example research triggers during interview:** diff --git a/skills/tenet/phases/02-spec-and-harness.md b/skills/tenet/phases/02-spec-and-harness.md index f8e52fc..c255bbf 100644 --- a/skills/tenet/phases/02-spec-and-harness.md +++ b/skills/tenet/phases/02-spec-and-harness.md @@ -23,7 +23,7 @@ Before writing the spec, conduct comprehensive research on the technologies, API **Save ALL research results:** - Write raw or run-specific findings to `.tenet/runs/{run_slug}/research/{topic}.md` - Include: what was researched, key findings, limitations discovered, recommended approach -- Tag with `[research-verified]` confidence level +- Tag with `[scanned-not-verified]` confidence level - Promote only durable reusable facts to top-level `.tenet/knowledge/` via `tenet_update_knowledge(type="knowledge", title="{concern}")` - These become reference material for the current run; curated knowledge becomes reference material for future runs From b70ea3ba5e9723e42f6f550a4c6ae1509932ce9e Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Tue, 23 Jun 2026 16:52:54 +0900 Subject: [PATCH 11/87] chore: bump to 26.6.4 Co-Authored-By: Claude --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2e14354..2121180 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.6.3", + "version": "26.6.4", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From 401bdfadc901fa62405680eb3c57b246029e8c8a Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 25 Jun 2026 07:14:24 +0900 Subject: [PATCH 12/87] feat(eval): configurable critic roster via .tenet/critics.json The eval gate was hardwired to exactly 3 critics. The critic set is now a project artifact: .tenet/critics.json, read live at every eval (no restart, no DB change). The 3 built-ins (code / test / interaction-e2e) are enabled by default; any can be disabled and custom critics appended with prompts under .tenet/critics/*.md. Phase 1 (enable/disable) and Phase 2 (custom critics) ship together as one roster model. - src/core/critic-roster.ts: roster parser (resolveRoster / loadCriticRoster) with safe fallback to the 3 built-ins on a missing or invalid file. - src/mcp/tools/tenet-start-eval.ts: roster-driven dispatch; N-critic sequential chaining in roster order; variable-length jobs[] return shape; stamps expected_eval_stages onto every dispatched critic. - src/core/job-manager.ts: the blocking-finding resume gate reads expected_eval_stages dynamically (defaults to the 3 built-ins for rows predating the stamp) so disabling a critic no longer strands a blocked report-only parent. - src/cli/init.ts: scaffold .tenet/critics.json + .tenet/critics/. - skills/tenet/critics.md: on-demand critic-designer doc (not a numbered loop phase) so a user asks Tenet to author a repo-specific critic instead of hand-writing prompts; enforces the {passed, stage, findings:[{category}]} output contract. - Tests: migrate named-ID destructuring to jobs[]; add roster fallback / disable / custom / skip / N-chain cases plus dynamic-gate resume tests (disabled critic + custom critic). - Docs/skills: "three critics" -> "configured critics" across phase 06/05/02/04, SKILL.md, README, CLAUDE.md; annotate (don't rewrite) the frozen body of planning/10. No new MCP tool, no DB change, no new JobType (custom critics reuse critic_eval / playwright_eval). Co-Authored-By: Claude --- CLAUDE.md | 3 +- README.md | 9 +- docs/planning/10_eval_mode_and_remediation.md | 2 + skills/tenet/SKILL.md | 5 +- skills/tenet/critics.md | 138 ++++++++++ skills/tenet/phases/02-spec-and-harness.md | 2 +- skills/tenet/phases/04-decomposition.md | 2 +- skills/tenet/phases/05-execution-loop.md | 4 +- skills/tenet/phases/06-evaluation.md | 8 +- src/adapters/fake-adapter.ts | 8 +- src/cli/init.ts | 27 ++ src/core/critic-roster.test.ts | 158 +++++++++++ src/core/critic-roster.ts | 203 ++++++++++++++ src/core/integration.test.ts | 29 +- src/core/job-manager.ts | 44 ++- .../tenet-report-blocking-finding.test.ts | 96 +++++++ src/mcp/tools/tenet-start-eval.test.ts | 260 +++++++++++++++--- src/mcp/tools/tenet-start-eval.ts | 250 +++++++++++------ 18 files changed, 1097 insertions(+), 151 deletions(-) create mode 100644 skills/tenet/critics.md create mode 100644 src/core/critic-roster.test.ts create mode 100644 src/core/critic-roster.ts diff --git a/CLAUDE.md b/CLAUDE.md index 21eaf22..7f9f951 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ The system has four layers: 2. **Adapters** (`src/adapters/`) — Pluggable agent adapters that spawn CLI subprocesses with a 120-minute default timeout, configurable through `tenet config --timeout `. Each adapter implements `AgentAdapter` from `base.ts`: `isAvailable()`, `invoke(invocation)`. Three built-in: `ClaudeAdapter` (`claude --print --output-format json`), `OpenCodeAdapter` (`opencode run --format json`), `CodexAdapter` (`codex exec --sandbox workspace-write` by default). Global adapter args and job-scoped args (for example `codex_args_playwright_eval`) are read at MCP server startup. JobManager resolves the configured adapter strictly by name and fails closed if that adapter is unavailable. -3. **MCP Server** (`src/mcp/`) — Exposes 19 tools via `@modelcontextprotocol/server`. Entry point at `src/mcp/index.ts`. Each tool in `src/mcp/tools/` registers itself with a Zod input schema and handler. Key tools: `tenet_start_job`, `tenet_continue` (server-side continuation), `tenet_compile_context`, `tenet_register_jobs` (loads job DAG, requires `feature` slug), `tenet_retry_job` (resets completed/failed jobs to pending), `tenet_report_blocking_finding` (report-only escalation), `tenet_validate_clarity`, `tenet_add_steer` / `tenet_update_steer` (create steer messages; retire/sweep them — `tenet_process_steer` returns user steers in full and caps agent steers, so user input can't be crowded out by agent noise), `tenet_start_eval` (dispatches code critic + test critic + playwright eval), `tenet_init` (initialize project from MCP). Agent selection is CLI-only via `tenet config --agent `. +3. **MCP Server** (`src/mcp/`) — Exposes 19 tools via `@modelcontextprotocol/server`. Entry point at `src/mcp/index.ts`. Each tool in `src/mcp/tools/` registers itself with a Zod input schema and handler. Key tools: `tenet_start_job`, `tenet_continue` (server-side continuation), `tenet_compile_context`, `tenet_register_jobs` (loads job DAG, requires `feature` slug), `tenet_retry_job` (resets completed/failed jobs to pending), `tenet_report_blocking_finding` (report-only escalation), `tenet_validate_clarity`, `tenet_add_steer` / `tenet_update_steer` (create steer messages; retire/sweep them — `tenet_process_steer` returns user steers in full and caps agent steers, so user input can't be crowded out by agent noise), `tenet_start_eval` (dispatches the configured critics from `.tenet/critics.json` — 3 built-in by default: code critic + test critic + interaction-e2e, plus any custom critics; return is a variable-length `jobs[]`), `tenet_init` (initialize project from MCP). Agent selection is CLI-only via `tenet config --agent `. 4. **CLI** (`src/cli/`) — Commander.js program with `init`, `serve`, `status`, `config`, and `db` maintenance commands. `tenet init` scaffolds a `.tenet/` directory structure and copies skill files to `.claude/skills/tenet/` and `.agents/skills/tenet/` with generated package-version metadata in each installed `SKILL.md`. `tenet init --upgrade` creates a verified SQLite-safe backup, runs pending DB migrations, then — only with consent — performs the one-time destructive move of legacy document dirs into `.tenet/archive/legacy-v1/`: it prompts Y/N (default No) interactively, or requires the explicit `--migrate-legacy` flag in non-interactive contexts (`-y/--yes` deliberately does not auto-migrate). It then refreshes generated skills/MCP configs. `init`/`--upgrade` also run a "star the repo" nudge (`src/cli/star-nudge.ts`; state per-project in `.tenet/.state/config.json` under `star_nudge`; a decline defers to the next run rather than suppressing permanently; opt out with `TENET_NO_STAR_NUDGE`) at the end of an interactive run — this is CLI-only and never fires from the autonomous skill boot loop. `tenet db check` runs read-only integrity/index diagnostics, `tenet db backup` creates a verified standalone SQLite backup, and `tenet db snapshot`/`restore-snapshot` write and restore Git-safe portable snapshots under `.tenet/state-snapshot/`. `init`/`--upgrade` also run a git-safety check: if `.tenet/.state/tenet.db` or its WAL sidecars are tracked by Git (the main DB-corruption vector), they warn with the exact `git rm --cached` command — detect-only, never auto-untrack — and merge a `.tenet/.state/` rule into the repo-root `.gitignore` as defense-in-depth. @@ -71,6 +71,7 @@ Tenet uses a document lifecycle layout. `tenet init` scaffolds only this layout; - **Legacy evidence** — `.tenet/archive/legacy-v1/` (one-time migration target for pre-lifecycle top-level dirs: `spec/`, `interview/`, `harness/`, `decomposition/`, `journal/`, `visuals/`, `bootstrap/`, `steer/`, `knowledge/`, `DESIGN.md`). Reference-only, not active doctrine. - **Auto-generated from DB** — `.tenet/status/` (`status.md`, `job-queue.md`). - **Portable snapshots** — `.tenet/state-snapshot/` (Git-safe snapshots from `tenet db snapshot`). +- **Configurable eval critics** — `.tenet/critics.json` (roster of enabled built-in + custom critics) and `.tenet/critics/*.md` (custom-critic prompts). Read live by `tenet_start_eval` on every eval; missing/invalid file falls back to the 3 built-ins. Authored via the critic-designer doc `skills/tenet/critics.md`. The blocking-finding resume gate tracks the same configured set via the `expected_eval_stages` each critic job carries. Current-run document identity flows through `artifact_paths`: `tenet_validate_readiness` validates exact spec/harness/scenarios/interview paths, `tenet_register_jobs` stores those plus `decomposition` (and `run_path`/`run_slug`) on every job, and `tenet_compile_context` reads the stored paths. Feature-only filename lookup is a compatibility fallback only; it uses strict dated document patterns rather than loose `*-{feature}.md` matching. diff --git a/README.md b/README.md index 23b243f..9629a4a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ You: "Add social features — reactions, badges, user profiles, share cards" Tenet: interviews you, writes the spec, generates visual mockups, decomposes into a dependency graph, implements each job, - asks workers to commit per job, evaluates with 3 independent critics, + asks workers to commit per job, evaluates with independent critics (3 built-in, configurable), and loops for 6+ hours until everything passes. ``` @@ -22,7 +22,7 @@ AI coding agents are powerful but short-lived. They lose context, drift off-spec - **Structured phases** — Context bootstrap, Interview, Spec, Visuals, Decomposition, Execution, Evaluation, and Agile checkpoints. Full mode runs all of them; Standard skips the interview and Quick skips interview/spec/decomposition (see Execution Modes). - **DAG-based job orchestration** — Dependencies are explicit. Parallel jobs run in parallel. Blocked jobs wait. -- **3-critic evaluation pipeline** — Code critic, Test critic, and Playwright e2e eval. All independent, all with fresh context (no author bias). All findings are blocking. +- **Configurable critic pipeline** — 3 built-in critics by default (code, test, interaction-e2e), plus project-defined custom critics via `.tenet/critics.json`. All independent, all with fresh context (no author bias). All findings are blocking. - **Crash recovery** — Server-ID-based orphan detection. If the MCP server dies, jobs auto-retry on restart. - **Agent-agnostic** — Works with Claude Code, OpenCode, and Codex. Switch agents mid-project without losing state. - **Persistent state** — Versioned SQLite + WAL mode. Jobs, events, steer messages, and config survive crashes. @@ -64,12 +64,12 @@ npx @jeikeilim/tenet init --agent claude-code --skip-playwright-check | **3. Visuals** | Generates architecture diagrams, UI mockups, DESIGN.md | | **4. Decomposition** | Breaks spec into a dependency graph (DAG) of jobs | | **5. Execution Loop** | Implements each job, prompts per-job commits, evaluates, retries on failure | -| **6. Evaluation** | 3 independent critics: code, tests, and Playwright e2e | +| **6. Evaluation** | Independent critics: code, tests, interaction-e2e (+ project-defined custom critics) | | **7. Agile Checkpoints** | Handles plan/use checkpoints and redirect loops in agile mode | ### The Evaluation Pipeline -Every completed job faces three independent critics, each with fresh context and no access to the author's reasoning: +Every completed job faces the configured critics — 3 built-in by default (code, test, interaction-e2e), plus any project-defined custom critics from `.tenet/critics.json`. Each runs with fresh context and no access to the author's reasoning: ``` Job Complete @@ -77,6 +77,7 @@ Job Complete +---> Code Critic (spec alignment, security, edge cases) +---> Test Critic (oracle problem detection, behavioral coverage) +---> Playwright Eval (scripted tests + agent-driven exploratory e2e) + +---> [custom critics] (repo-specific: security, a11y, API contract, ...) | ALL must pass --> Next job ANY fails --> Retry with failure context diff --git a/docs/planning/10_eval_mode_and_remediation.md b/docs/planning/10_eval_mode_and_remediation.md index ac97afd..8fd9005 100644 --- a/docs/planning/10_eval_mode_and_remediation.md +++ b/docs/planning/10_eval_mode_and_remediation.md @@ -6,6 +6,8 @@ - `tenet_request_remediation` → `tenet_report_blocking_finding` (tool-names.ts:29; used in skills/tenet/phases/05-execution-loop.md and 06-evaluation.md) - `blocked_remediation_required` → `blocked_on_finding` (src/core/migrations.ts:52 rewrites legacy rows to the shipped name at runtime) The body keeps the proposal names as historical design narrative. +**Update (2026-06-25)**: The critic set is no longer fixed at 3. Critics are now configurable via `.tenet/critics.json` (3 built-in by default + custom critics; see `skills/tenet/critics.md`). "The 3 critic jobs" / "all three" below should be read as "the configured critics" — the parallel/sequential dispatch and the blocking-resume gate both track whatever the roster enables. The `expected_eval_stages` the gate waits for is stamped onto each critic job by `tenet_start_eval`. + **Origin**: `tenet-manual-test-codex` 10-hour autonomous run retrospective (`TENET_PIPELINE_RETROSPECTIVE.md`, 2026-04-16) --- diff --git a/skills/tenet/SKILL.md b/skills/tenet/SKILL.md index 0a69dc4..1c24943 100644 --- a/skills/tenet/SKILL.md +++ b/skills/tenet/SKILL.md @@ -174,7 +174,8 @@ Examples: - Readiness gate: after spec/harness/scenarios and required visuals, call `tenet_validate_readiness` with exact `artifact_paths` and resolve blockers before decomposition. - Plan/use checkpoints: in agile mode, block until the user responds with `approve`, `redirect: ...`, `cancel`, or `done` as defined in `phases/07-agile-checkpoints.md`. - Pre-execution gate: before dispatching a DAG or slice DAG, summarize mode, job count, key spec decisions, and harness constraints. Ask for confirmation unless the user explicitly asked to start without oversight. -- Eval gate: after every completed job, call `tenet_start_eval(...)` and wait according to the returned `execution_mode`. All returned eval jobs must pass. +- Eval gate: after every completed job, call `tenet_start_eval(...)` and wait according to the returned `execution_mode`. The critic set comes from `.tenet/critics.json` (3 built-in by default; the project may disable any and add custom critics). All returned eval jobs must pass. +- To add a repo-specific critic (security, a11y, API contract, etc.), follow `critics.md` (the critic designer) — it produces the roster entry + prompt file so you don't hand-write prompts. ## Execution Rules @@ -183,7 +184,7 @@ Read `phases/05-execution-loop.md` before starting execution. - Use Tenet MCP tools only. Do not call host subagents directly and do not manually implement job code during the execution loop. - Dispatch `tenet_job_wait` as background/non-blocking status checks with backoff. Stay responsive to user steering between checks. - Pass the original job ID to `tenet_start_eval`; pass `feature` when known. -- `tenet_start_eval` may return parallel or sequential eval execution. Wait according to the returned job IDs and `execution_mode`; do not assume all critics are already running. +- `tenet_start_eval` dispatches the configured critics and returns them as a variable-length `jobs[]` list (plus `execution_mode`). Wait on every job in that list; do not assume a fixed count or that all critics are already running. - Treat all eval failures as blocking for the current job or report-only parent. - Prefer `tenet_retry_job(job_id, enhanced_prompt)` for failed implementation jobs. - Use `tenet_report_blocking_finding` only for report-only jobs that discover blocking findings they must not fix directly. diff --git a/skills/tenet/critics.md b/skills/tenet/critics.md new file mode 100644 index 0000000..dbb0803 --- /dev/null +++ b/skills/tenet/critics.md @@ -0,0 +1,138 @@ +# Critic Designer — authoring a custom evaluation critic + +On demand. Not a numbered loop phase — read this when the user asks Tenet to +"create a critic for this repo," or when a run keeps hitting a failure class +the three built-in critics (code, test, interaction-e2e) under-cover. + +Tenet's eval gate is configurable. The critic set lives in +`.tenet/critics.json`; each critic runs as an **independent-context eval job** +(sees the job scope + your prompt, never the author's reasoning). This doc is how +you design one so it actually plugs into the gate and the fix-routing. + +## What a critic is + +A critic is a focused prompt that ends by emitting a structured verdict. The +orchestrator: + +- dispatches it (alongside the built-ins) on every job's eval, +- reads its `passed` flag to decide pass/fail, and +- reads each finding's `category` to route follow-up work (retry the dev job, + strengthen tests, fix the harness, etc.). + +A good critic is **narrow and specific to this repo's real risk surface** — not a +generic "review the code." "Reject any SQL query built by string concatenation" +beats "check for security issues." + +## The roster file (`.tenet/critics.json`) + +```json +{ + "version": 1, + "critics": [ + { "id": "code_critic", "builtin": true, "enabled": true }, + { "id": "test_critic", "builtin": true, "enabled": true }, + { "id": "playwright_eval", "builtin": true, "enabled": true }, + { + "id": "security", + "builtin": false, + "enabled": true, + "stage": "security_critic", + "job_type": "critic_eval", + "prompt_file": ".tenet/critics/security.md" + } + ] +} +``` + +- **Built-ins** (`builtin: true`): only `enabled` (and order) matter. Omitting + one leaves it enabled at its default position. Set `enabled: false` to drop it + (e.g. disable `playwright_eval` for a CLI-only project). +- **Custom** (`builtin: false`): + - `id` — stable identifier; also the default `stage` name if `stage` is omitted. + - `stage` — the `eval_stage` name. Must be unique across the roster. + - `job_type` — `critic_eval` (default) or `playwright_eval`. Use + `playwright_eval` only if the critic needs browser tools / emits + `layer2_status`; otherwise `critic_eval`. + - `prompt_file` — project-relative path to the prompt markdown. Missing file → + the critic is skipped at dispatch with a warning (never fatal). + +The file is read live on every eval — edit it and the next `tenet_start_eval` +reflects the change with no restart. Invalid JSON falls back to the 3 built-ins. + +## Output contract (mandatory) + +Every custom critic prompt MUST end by instructing the model to emit exactly this +shape — it is what the eval gate parses and what routes fixes: + +``` +End with: {"passed": true/false, "stage": "", "findings": [{"category": "...", "detail": "..."}]} +``` + +- `passed` — `true` only if the work is acceptable for THIS critic's focus. + A critic with no findings still emits `"passed": true`. There is no "minor / + non-blocking": if you find something, `passed` is `false`. +- `stage` — your roster `stage` (e.g. `security_critic`). +- `findings[].category` — MUST be one of the standard enum so the orchestrator + routes the fix correctly (see `phases/06-evaluation.md`): + - `product_bug` — implementation doesn't match intent → retry the dev job + - `test_bug` — tests assert the wrong thing → retry with test-strengthening + - `harness_bug` — build/lint/test infra itself is broken → remediate infra + - `evidence_mismatch` — report numbers contradict fresh command output + - `contention` — looks like a sibling eval stepping on shared state + - `scope_conflict` — work outside the job's declared scope + +If a critic's output doesn't parse to this shape, the eval gate treats it as +not-passed. So end the prompt with the literal contract line above. + +## Design workflow + +1. **Find the gap.** Read `.tenet/project/**` (especially `testing.md`, + `architecture.md`) and recent run journals under `.tenet/runs/*/journal/`. + What failure class keeps slipping past the three built-ins? Pick a concrete + focus — e.g. "authz checks," "N+1 queries," "unbounded memory," "API contract + drift," "a11y regressions." +2. **Write the prompt** at `.tenet/critics/.md`. State the focus, what counts + as a finding, the severity rule (everything is blocking), and end with the + output contract line. The prompt receives the job scope preamble (eval-only + within this job) plus a `## Implementation Output` section automatically — + tell it to inspect that output. +3. **Register** the critic in `.tenet/critics.json` with `enabled: true`. +4. **Smoke-test.** Run `tenet_start_eval` against one completed job, then + `tenet_job_result` on the critic's job id. Confirm its output parses (has + `passed` + `findings` with valid `category`) and that a deliberate violation + in the output makes it fail. +5. **Watch reliability.** If the critic routinely fails to emit the contract, + tighten the prompt's closing instruction before trusting its verdict. + +## Worked example — a security critic + +`.tenet/critics/security.md`: + +```markdown +## Security Critic + +You are the SECURITY CRITIC. You review ONLY the Implementation Output below, +against THIS job's scope. Focus narrowly on: + +- Injection (SQL, shell, template, command) — any query/command built by string concatenation. +- Secret exposure — keys, tokens, passwords logged, embedded, or committed. +- Auth/authz gaps — endpoints reachable without the required permission check. + +SEVERITY RULE: every finding is blocking. A single confirmed issue → passed:false. + +### Finding categories (required) +Use "product_bug" for an implementation gap, "test_bug" if a security test is +missing/weak, "harness_bug" if security tooling is misconfigured. + +End with: {"passed": true/false, "stage": "security_critic", "findings": [{"category": "product_bug", "detail": "..."}]} +``` + +`.tenet/critics.json` entry (built-ins omitted stay enabled): + +```json +{ "id": "security", "builtin": false, "enabled": true, "stage": "security_critic", "job_type": "critic_eval", "prompt_file": ".tenet/critics/security.md" } +``` + +Now every job's eval runs code critic + test critic + interaction-e2e + security +critic, and a security finding routes as `product_bug` (retry the dev job) — the +gate won't pass until it's fixed. diff --git a/skills/tenet/phases/02-spec-and-harness.md b/skills/tenet/phases/02-spec-and-harness.md index c255bbf..ca9bf6d 100644 --- a/skills/tenet/phases/02-spec-and-harness.md +++ b/skills/tenet/phases/02-spec-and-harness.md @@ -189,7 +189,7 @@ For each returned blocker, pick ONE: ### Also decides: eval execution mode In addition to pass/fail, the readiness verdict answers **one more question**: do this feature's tests share mutable state (DB rows, sessions, rate limits, ports, files, Playwright lock dirs)? -- If yes → verdict sets `eval_parallel_safe = false`. Tenet will later serialize the three critics (code → test → playwright) instead of running them in parallel. This prevents false failures from contention on shared state. +- If yes → verdict sets `eval_parallel_safe = false`. Tenet will later serialize the configured critics (code → test → playwright by default, in roster order) instead of running them in parallel. This prevents false failures from contention on shared state. - If no → verdict sets `eval_parallel_safe = true`. Critics run in parallel (today's behavior). The verdict is persisted to the config table keyed by feature (`eval_parallel_safe:{feature}`) and consumed by `tenet_start_eval` automatically. No extra orchestration step needed. If the verdict is missing (e.g., quick mode skipped readiness), the eval tool defaults to **sequential** as a safe fallback. diff --git a/skills/tenet/phases/04-decomposition.md b/skills/tenet/phases/04-decomposition.md index 7699001..1c6e2b7 100644 --- a/skills/tenet/phases/04-decomposition.md +++ b/skills/tenet/phases/04-decomposition.md @@ -304,4 +304,4 @@ When slice 2 fires later, the registration call would include only slice-2-* job - Section 3 (acceptance test generation from scenarios) — tests are still generated upfront, still mandatory. Tagging by slice is the only addition. - Section 5 (integration test checkpoints) — still required; agile mode just adds per-slice granularity on top. - Section 7 (execution protocol) — same MCP loop within a slice. The orchestrator's checkpoint pauses (steps 4 and 5 of the agile rollout) sit *outside* the MCP loop, between slice fires. -- Per-job eval (critic + test critic + playwright_eval, defined in `phases/06-evaluation.md`) fires on every job, in both modes. +- Per-job eval (the configured critics from `.tenet/critics.json`, defined in `phases/06-evaluation.md`) fires on every job, in both modes. diff --git a/skills/tenet/phases/05-execution-loop.md b/skills/tenet/phases/05-execution-loop.md index bed199e..bc7772c 100644 --- a/skills/tenet/phases/05-execution-loop.md +++ b/skills/tenet/phases/05-execution-loop.md @@ -161,7 +161,7 @@ When a report-only job's context is compiled, `tenet_compile_context` prepends a 2. Agent calls `tenet_report_blocking_finding(job_id=, finding=..., why_it_blocks_report=..., recommended_followup=..., suspected_files=[...])`. 3. Tenet marks the agent's job as `blocked_on_finding` and spawns a linked child `dev` follow-up job. 4. The report-only worker stops report-only work and does not edit files for the finding. -5. Orchestrator processes the child like any other dev job: dispatch → eval via `tenet_start_eval` → if all three critics pass, Tenet **auto-resumes** the report-only parent (flips it from `blocked_on_finding` → `pending`). +5. Orchestrator processes the child like any other dev job: dispatch → eval via `tenet_start_eval` → if all configured critics pass, Tenet **auto-resumes** the report-only parent (flips it from `blocked_on_finding` → `pending`). The gate tracks the configured critic set, so disabling a built-in or adding a custom critic does not strand a blocked parent. 6. Orchestrator picks up the parent via `tenet_continue()` and redispatches it with fresh context (it now sees the post-fix state). ### Why this shape @@ -208,7 +208,7 @@ Plain "just retry" wastes cycles on test/harness/evidence bugs — route by cate ## Eval-mode decision (reminder) -The three critics dispatched by `tenet_start_eval` run **in parallel** or **sequentially** based on the readiness gate's `eval_parallel_safe:{feature}` verdict (see `phases/02-spec-and-harness.md`). If the verdict is missing, Tenet defaults to sequential (safe fallback). The orchestrator doesn't need a separate step — just call `tenet_start_eval` and wait for all three job IDs it returns. +The critics dispatched by `tenet_start_eval` (the configured set from `.tenet/critics.json`) run **in parallel** or **sequentially** based on the readiness gate's `eval_parallel_safe:{feature}` verdict (see `phases/02-spec-and-harness.md`). If the verdict is missing, Tenet defaults to sequential (safe fallback). The orchestrator doesn't need a separate step — just call `tenet_start_eval` and wait for every job id in the `jobs[]` list it returns. ## Git-Aware Pipeline diff --git a/skills/tenet/phases/06-evaluation.md b/skills/tenet/phases/06-evaluation.md index ececc29..33b847b 100644 --- a/skills/tenet/phases/06-evaluation.md +++ b/skills/tenet/phases/06-evaluation.md @@ -32,12 +32,12 @@ If the harness/spec says browser or visual exploration is required, missing Play ## Parallel vs Sequential Critics -The three critic jobs (code critic, test critic, Playwright eval) may run **in parallel** or **sequentially**, decided by the readiness gate verdict (`eval_parallel_safe:{feature}` in the config table): +The configured critic jobs may run **in parallel** or **sequentially**, decided by the readiness gate verdict (`eval_parallel_safe:{feature}` in the config table). The critic set comes from `.tenet/critics.json` — 3 built-in by default (code critic, test critic, interaction-e2e), plus any project-defined custom critics (see `../critics.md`): - **Parallel** (verdict `true`) — pure libraries, CLIs, data pipelines with no shared mutable state. Critics start concurrently; completion time ≈ slowest single critic. -- **Sequential** (verdict `false` or missing) — stateful web apps where critics would collide on shared state (DB rows, sessions, rate-limit counters, ports, Playwright lock dirs). Critics run code → test → playwright. The caller still receives all three job IDs up front; job-manager auto-dispatches each downstream critic when its predecessor completes. +- **Sequential** (verdict `false` or missing) — stateful web apps where critics would collide on shared state (DB rows, sessions, rate-limit counters, ports, Playwright lock dirs). Critics run in roster order (code → test → playwright by default). The caller receives every critic's job id up front in `jobs[]`; job-manager auto-dispatches each downstream critic when its predecessor completes. -`tenet_start_eval` reads the verdict automatically — no orchestrator code change needed. If the verdict is missing, Tenet defaults to sequential (safe fallback). +`tenet_start_eval` reads the verdict automatically — no orchestrator code change needed. If the verdict is missing, Tenet defaults to sequential (safe fallback). Disabling a built-in or adding a custom critic in `.tenet/critics.json` changes which (and how many) critics run; the blocking-finding resume gate tracks the same configured set, so disabling a critic does not strand a blocked parent. ## The 5 Evaluation Stages @@ -215,4 +215,4 @@ For CLI/API/library projects, do not force Playwright: **FAIL**: Any scripted test fails OR exploratory testing finds visual/behavioral bugs. Retry or report a blocking finding with screenshots and findings as evidence. ## Anti-Skip Enforcement -Evaluation is mandatory. Every job must pass Stage 1 and 1.5. Full mode requires Stage 3 (code critic), Stage 4 (test critic), and Stage 5 (agent-driven e2e). Both critics run in separate agent sessions with no access to the author's reasoning. The author cannot evaluate their own work. +Evaluation is mandatory. Every job must pass Stage 1 and 1.5. Full mode runs every enabled critic — Stage 3 (code critic), Stage 4 (test critic), Stage 5 (interaction e2e), plus any custom critics enabled in `.tenet/critics.json`. All critics run in separate agent sessions with no access to the author's reasoning. The author cannot evaluate their own work. diff --git a/src/adapters/fake-adapter.ts b/src/adapters/fake-adapter.ts index 9fa2cc1..243e19a 100644 --- a/src/adapters/fake-adapter.ts +++ b/src/adapters/fake-adapter.ts @@ -147,17 +147,21 @@ export const matchers = { /** Match when the prompt mentions the given eval stage. */ evalStage: - (stage: 'code_critic' | 'test_critic' | 'playwright_eval' | 'readiness_validation'): FixturePredicate => + (stage: string): FixturePredicate => (inv) => { // eval_stage isn't on AgentInvocation directly — it's embedded in the prompt // via tenet_start_eval's preambles ("## Code Critic ...") / validate-readiness rubric. + // Known built-in stages use dedicated markers; custom critic stages fall back + // to matching the stage name itself (custom prompts are user-authored under + // .tenet/critics/, so tests can also just use `promptContains`). const markers: Record = { code_critic: ['Code Critic', '"stage": "code_critic"'], test_critic: ['Test Critic', '"stage": "test_critic"'], playwright_eval: ['Playwright', 'PLAYWRIGHT EVAL'], readiness_validation: ['IMPLEMENTATION READINESS', 'readiness'], }; - return markers[stage].some((m) => inv.prompt.includes(m)); + const candidates = markers[stage] ?? [stage]; + return candidates.some((m) => inv.prompt.includes(m)); }, /** Match when the prompt looks like a dev job (Deliverable Requirements preamble). */ diff --git a/src/cli/init.ts b/src/cli/init.ts index 05a1128..3a1d72c 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -15,6 +15,7 @@ const REQUIRED_DIRS = [ 'knowledge', 'status', 'state-snapshot', + 'critics', ]; /** @@ -63,6 +64,30 @@ This directory is for portable Tenet SQLite snapshots that are safe to track in - Do not track \`.tenet/.state/\`; it is the live SQLite WAL database. `; +/** + * Default critic roster. The 3 built-ins are enabled (today's behavior); the + * disabled `security` entry documents the custom-critic shape — flip `enabled` + * to true and write its prompt file under .tenet/critics/ to activate it. + * See skills/tenet/critics.md (the critic designer) for how to author one. + */ +const CRITICS_ROSTER_TEMPLATE = `{ + "version": 1, + "critics": [ + { "id": "code_critic", "builtin": true, "enabled": true }, + { "id": "test_critic", "builtin": true, "enabled": true }, + { "id": "playwright_eval", "builtin": true, "enabled": true }, + { + "id": "security", + "builtin": false, + "enabled": false, + "stage": "security_critic", + "job_type": "critic_eval", + "prompt_file": ".tenet/critics/security.md" + } + ] +} +`; + const TEMPLATE_FILES: Record = { 'project/overview.md': `# Project Overview @@ -98,6 +123,8 @@ Bootstrap placeholder. Run Tenet context bootstrap to synthesize the current use 'status/job-queue.md': '# Job Queue\n\n', 'status/backlog.md': '# Backlog\n\n', 'state-snapshot/README.md': PORTABLE_STATE_README, + 'critics.json': CRITICS_ROSTER_TEMPLATE, + 'critics/.gitkeep': '', }; const REQUIRED_TENET_GITIGNORE_LINES = [ diff --git a/src/core/critic-roster.test.ts b/src/core/critic-roster.test.ts new file mode 100644 index 0000000..158f869 --- /dev/null +++ b/src/core/critic-roster.test.ts @@ -0,0 +1,158 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { loadCriticRoster, resolveRoster, DEFAULT_ROSTER, type ResolvedCritic } from './critic-roster.js'; + +const byId = (critics: ResolvedCritic[], id: string): ResolvedCritic | undefined => + critics.find((c) => c.id === id); + +describe('resolveRoster', () => { + it('falls back to the 3 built-ins for invalid payloads', () => { + expect(resolveRoster(null).map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(resolveRoster(undefined).map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(resolveRoster('nope').map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(resolveRoster({}).map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(resolveRoster({ critics: 'not-an-array' }).map((c) => c.id)).toEqual([ + 'code_critic', + 'test_critic', + 'playwright_eval', + ]); + }); + + it('resolves the 3 built-ins enabled by default', () => { + const roster = resolveRoster({ version: 1, critics: [] }); + expect(roster).toHaveLength(3); + expect(roster.every((c) => c.builtin && c.enabled)).toBe(true); + expect(roster.map((c) => c.stage)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(roster.map((c) => c.jobType)).toEqual(['critic_eval', 'eval', 'playwright_eval']); + }); + + it('honors enabled:false on a built-in', () => { + const roster = resolveRoster({ + critics: [{ id: 'playwright_eval', builtin: true, enabled: false }], + }); + expect(byId(roster, 'playwright_eval')?.enabled).toBe(false); + expect(byId(roster, 'code_critic')?.enabled).toBe(true); + }); + + it('appends built-ins omitted from an otherwise-valid file', () => { + const roster = resolveRoster({ + critics: [{ id: 'code_critic', builtin: true, enabled: true }], + }); + expect(roster.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + }); + + it('resolves a custom critic with explicit stage/job_type/prompt_file', () => { + const roster = resolveRoster({ + critics: [ + { id: 'code_critic', builtin: true, enabled: true }, + { id: 'test_critic', builtin: true, enabled: true }, + { id: 'playwright_eval', builtin: true, enabled: true }, + { + id: 'security', + builtin: false, + enabled: true, + stage: 'security_critic', + job_type: 'critic_eval', + prompt_file: '.tenet/critics/security.md', + }, + ], + }); + const sec = byId(roster, 'security'); + expect(sec).toEqual({ + id: 'security', + builtin: false, + enabled: true, + stage: 'security_critic', + jobType: 'critic_eval', + promptFile: '.tenet/critics/security.md', + }); + }); + + it('defaults a custom critic stage to its id and job_type to critic_eval', () => { + const roster = resolveRoster({ critics: [{ id: 'lint', prompt_file: '.tenet/critics/lint.md' }] }); + const lint = byId(roster, 'lint'); + expect(lint?.stage).toBe('lint'); + expect(lint?.jobType).toBe('critic_eval'); + expect(lint?.promptFile).toBe('.tenet/critics/lint.md'); + }); + + it('rejects an invalid custom job_type, falling back to critic_eval', () => { + const roster = resolveRoster({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + critics: [{ id: 'x', job_type: 'dev' as any, prompt_file: '.tenet/critics/x.md' }], + }); + expect(byId(roster, 'x')?.jobType).toBe('critic_eval'); + }); + + it('drops duplicate ids (first wins) and skips malformed entries', () => { + const roster = resolveRoster({ + critics: [ + { id: 'code_critic', builtin: true, enabled: false }, + { id: 'code_critic', builtin: true, enabled: true }, // dup → dropped + { builtin: true }, // no id → skipped + { id: '', builtin: true }, // empty id → skipped + 'not-an-object', + ], + }); + const codeCritics = roster.filter((c) => c.id === 'code_critic'); + expect(codeCritics).toHaveLength(1); + expect(codeCritics[0].enabled).toBe(false); // first wins + expect(roster.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + }); + + it('DEFAULT_ROSTER is the 3 built-ins enabled', () => { + expect(DEFAULT_ROSTER.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(DEFAULT_ROSTER.every((c) => c.enabled)).toBe(true); + }); +}); + +describe('loadCriticRoster', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tenet-roster-test-')); + + afterAll(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('returns defaults when no roster file exists', () => { + const { critics, warning } = loadCriticRoster(tmp); + expect(warning).toBeUndefined(); + expect(critics.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + }); + + it('returns defaults + a warning when the roster file is invalid JSON', () => { + const rosterPath = path.join(mkdirTenet(tmp), 'critics.json'); + fs.writeFileSync(rosterPath, '{ broken', 'utf8'); + const { critics, warning } = loadCriticRoster(tmp); + expect(critics.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(warning).toEqual(expect.stringContaining('Could not parse')); + }); + + it('reads and resolves a valid roster file', () => { + const rosterPath = path.join(mkdirTenet(tmp), 'critics.json'); + fs.writeFileSync( + rosterPath, + JSON.stringify({ + version: 1, + critics: [ + { id: 'code_critic', builtin: true, enabled: true }, + { id: 'test_critic', builtin: true, enabled: true }, + { id: 'playwright_eval', builtin: true, enabled: false }, + { id: 'a11y', stage: 'a11y_critic', prompt_file: '.tenet/critics/a11y.md' }, + ], + }), + 'utf8', + ); + const { critics, warning } = loadCriticRoster(tmp); + expect(warning).toBeUndefined(); + expect(byId(critics, 'playwright_eval')?.enabled).toBe(false); + expect(byId(critics, 'a11y')?.stage).toBe('a11y_critic'); + }); +}); + +/** Ensure `.tenet` exists under the temp project root; return its path. */ +function mkdirTenet(projectPath: string): string { + const tenet = path.join(projectPath, '.tenet'); + fs.mkdirSync(tenet, { recursive: true }); + return tenet; +} diff --git a/src/core/critic-roster.ts b/src/core/critic-roster.ts new file mode 100644 index 0000000..fef7d05 --- /dev/null +++ b/src/core/critic-roster.ts @@ -0,0 +1,203 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { JobType } from '../types/index.js'; + +/** + * Configurable evaluation critics (#6). + * + * The critic set is a project artifact at `.tenet/critics.json`, read live from + * disk at every `tenet_start_eval` call (same live-read precedent as the + * `eval_parallel_safe:{feature}` config key). The three built-in critics are the + * default; a project can disable any built-in and append custom critics whose + * prompts live as markdown under `.tenet/critics/`. + * + * This module is deliberately free of DB/state concerns — it parses a file into + * a resolved roster. `tenet_start_eval` consumes it; `job-manager`'s blocking- + * resume gate reads the `expected_eval_stages` the tool stamps onto each critic. + */ + +export type BuiltinCriticId = 'code_critic' | 'test_critic' | 'playwright_eval'; + +export const BUILTIN_CRITIC_IDS: readonly BuiltinCriticId[] = [ + 'code_critic', + 'test_critic', + 'playwright_eval', +]; + +/** Stages the resume gate waits for when a job predates roster stamping. */ +export const DEFAULT_EVAL_STAGES: readonly string[] = [ + 'code_critic', + 'test_critic', + 'playwright_eval', +]; + +/** Custom critics may reuse either of these job types (no new JobType needed). */ +const VALID_CUSTOM_JOB_TYPES: readonly JobType[] = ['critic_eval', 'playwright_eval']; + +const BUILTIN_STAGE: Record = { + code_critic: 'code_critic', + test_critic: 'test_critic', + playwright_eval: 'playwright_eval', +}; + +const BUILTIN_JOB_TYPE: Record = { + code_critic: 'critic_eval', + test_critic: 'eval', + playwright_eval: 'playwright_eval', +}; + +/** Raw shape of one entry in `.tenet/critics.json`. */ +export type CriticRosterEntry = { + id: string; + builtin?: boolean; + enabled?: boolean; + /** Custom only — the `eval_stage` name. Defaults to `id`. */ + stage?: string; + /** Custom only — `critic_eval` (default) or `playwright_eval`. */ + job_type?: JobType; + /** Custom only — project-relative path to a markdown prompt. */ + prompt_file?: string; +}; + +/** A critic after resolution, ready for dispatch. */ +export type ResolvedCritic = { + id: string; + builtin: boolean; + enabled: boolean; + stage: string; + jobType: JobType; + /** Custom only — project-relative path to the prompt markdown. */ + promptFile?: string; +}; + +export const DEFAULT_ROSTER: readonly ResolvedCritic[] = BUILTIN_CRITIC_IDS.map((id) => ({ + id, + builtin: true, + enabled: true, + stage: BUILTIN_STAGE[id], + jobType: BUILTIN_JOB_TYPE[id], +})); + +const isBuiltinId = (id: string): id is BuiltinCriticId => + (BUILTIN_CRITIC_IDS as readonly string[]).includes(id); + +const isJobType = (value: unknown): value is JobType => + typeof value === 'string' && (VALID_CUSTOM_JOB_TYPES as readonly string[]).includes(value as JobType); + +/** + * Resolve a parsed `.tenet/critics.json` payload into an ordered roster. + * + * Pure (no fs) so it can be unit-tested directly. Semantics: + * - Invalid payload → default 3 built-ins. + * - Built-ins omitted from an otherwise-valid file stay enabled and are appended + * in canonical order (lenient — a file that only lists customs still gets the + * 3 built-ins). + * - Duplicate ids are dropped (first wins). + * - A custom entry with a missing/invalid `job_type` defaults to `critic_eval`; + * a missing `stage` defaults to its `id`. A missing `prompt_file` is kept + * (resolved later — the tool skips the critic with a warning if the file + * doesn't exist at dispatch time). + */ +export const resolveRoster = (raw: unknown): ResolvedCritic[] => { + if (!raw || typeof raw !== 'object') { + return DEFAULT_ROSTER.map((c) => ({ ...c })); + } + + const critics = (raw as { critics?: unknown }).critics; + if (!Array.isArray(critics)) { + return DEFAULT_ROSTER.map((c) => ({ ...c })); + } + + const resolved: ResolvedCritic[] = []; + const usedIds = new Set(); + const seenBuiltins = new Set(); + + for (const entry of critics) { + if (!entry || typeof entry !== 'object') { + continue; + } + const e = entry as CriticRosterEntry; + if (typeof e.id !== 'string' || e.id.length === 0) { + continue; + } + if (usedIds.has(e.id)) { + continue; + } + + if (e.builtin === true || isBuiltinId(e.id)) { + // Built-in: only `enabled` (and presence/order) are meaningful. + if (!isBuiltinId(e.id)) { + // `builtin: true` asserted for an unknown id — treat as misconfigured, skip. + continue; + } + const id = e.id; + seenBuiltins.add(id); + resolved.push({ + id, + builtin: true, + enabled: e.enabled !== false, + stage: BUILTIN_STAGE[id], + jobType: BUILTIN_JOB_TYPE[id], + }); + usedIds.add(id); + } else { + const stage = typeof e.stage === 'string' && e.stage.length > 0 ? e.stage : e.id; + const jobType: JobType = isJobType(e.job_type) ? e.job_type : 'critic_eval'; + const promptFile = typeof e.prompt_file === 'string' && e.prompt_file.length > 0 ? e.prompt_file : undefined; + resolved.push({ + id: e.id, + builtin: false, + enabled: e.enabled !== false, + stage, + jobType, + promptFile, + }); + usedIds.add(e.id); + } + } + + // Append built-ins the file omitted, in canonical order. + for (const id of BUILTIN_CRITIC_IDS) { + if (!seenBuiltins.has(id)) { + resolved.push({ + id, + builtin: true, + enabled: true, + stage: BUILTIN_STAGE[id], + jobType: BUILTIN_JOB_TYPE[id], + }); + } + } + + return resolved; +}; + +export type LoadedRoster = { + critics: ResolvedCritic[]; + /** Present when the roster file existed but could not be parsed. */ + warning?: string; +}; + +/** + * Read + resolve `.tenet/critics.json` for a project. Never throws — a missing + * or unreadable file falls back to the 3 built-ins (today's behavior). + */ +export const loadCriticRoster = (projectPath: string): LoadedRoster => { + const rosterPath = path.join(projectPath, '.tenet', 'critics.json'); + if (!fs.existsSync(rosterPath)) { + return { critics: DEFAULT_ROSTER.map((c) => ({ ...c })) }; + } + + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(rosterPath, 'utf8')); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + critics: DEFAULT_ROSTER.map((c) => ({ ...c })), + warning: `Could not parse ${rosterPath} (${message}); using the 3 built-in critics.`, + }; + } + + return { critics: resolveRoster(raw) }; +}; diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index ee27061..67fcb36 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -98,6 +98,13 @@ const parseResult = (r: CallToolResult): Record => { return JSON.parse(first.text); }; +const jobId = (parsed: Record, role: string): string => { + const jobs = parsed.jobs as Array>; + const entry = jobs.find((j) => j.role === role); + if (!entry) throw new Error(`no dispatched critic with role '${role}'`); + return entry.job_id as string; +}; + afterEach(() => { while (stores.length > 0) stores.pop()?.close(); while (tempDirs.length > 0) { @@ -182,13 +189,13 @@ describe('integration: sequential critic chain', () => { expect(parsed.execution_mode).toBe('sequential'); // Sequentially: code critic starts running immediately, others pending with parent - await manager.waitForJob(parsed.code_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.test_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.playwright_eval_job_id as string, null, 5_000); + await manager.waitForJob(jobId(parsed, 'code_critic'), null, 5_000); + await manager.waitForJob(jobId(parsed, 'test_critic'), null, 5_000); + await manager.waitForJob(jobId(parsed, 'playwright_eval'), null, 5_000); - expect(store.getJob(parsed.code_critic_job_id as string)?.status).toBe('completed'); - expect(store.getJob(parsed.test_critic_job_id as string)?.status).toBe('completed'); - expect(store.getJob(parsed.playwright_eval_job_id as string)?.status).toBe('completed'); + expect(store.getJob(jobId(parsed, 'code_critic'))?.status).toBe('completed'); + expect(store.getJob(jobId(parsed, 'test_critic'))?.status).toBe('completed'); + expect(store.getJob(jobId(parsed, 'playwright_eval'))?.status).toBe('completed'); }); it('B2: safe verdict → 3 critics launch in parallel', async () => { @@ -214,14 +221,14 @@ describe('integration: sequential critic chain', () => { expect(parsed.execution_mode).toBe('parallel'); // All three should be running (or just-completed) — none were left pending waiting for a parent. - const test = store.getJob(parsed.test_critic_job_id as string); - const play = store.getJob(parsed.playwright_eval_job_id as string); + const test = store.getJob(jobId(parsed, 'test_critic')); + const play = store.getJob(jobId(parsed, 'playwright_eval')); expect(test?.parentJobId).toBeUndefined(); expect(play?.parentJobId).toBeUndefined(); - await manager.waitForJob(parsed.code_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.test_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.playwright_eval_job_id as string, null, 5_000); + await manager.waitForJob(jobId(parsed, 'code_critic'), null, 5_000); + await manager.waitForJob(jobId(parsed, 'test_critic'), null, 5_000); + await manager.waitForJob(jobId(parsed, 'playwright_eval'), null, 5_000); }); }); diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index fa328ad..4f3d33f 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -10,6 +10,7 @@ import { parseTimeoutMinutes, } from './runtime-config.js'; import { StateStore } from './state-store.js'; +import { DEFAULT_EVAL_STAGES } from './critic-roster.js'; type JobManagerConfig = { maxParallelAgents?: number; @@ -839,9 +840,28 @@ export class JobManager { } } + /** + * Resolve the critic stages the resume gate should wait for. `tenet_start_eval` + * stamps `expected_eval_stages` onto every critic it dispatches (reflecting the + * project's `.tenet/critics.json` roster — so disabling a built-in or adding a + * custom critic shrinks/grows the set). Sibling jobs from before that stamping + * existed fall back to the 3 built-ins. + */ + private resolveExpectedEvalStages(sourceJobId: string): Set { + const siblings = this.stateStore.getEvalsForSource(sourceJobId); + for (const s of siblings) { + const stamped = s.params.expected_eval_stages; + if (Array.isArray(stamped) && stamped.length > 0) { + return new Set(stamped.filter((stage): stage is string => typeof stage === 'string')); + } + } + return new Set(DEFAULT_EVAL_STAGES); + } + private checkBlockingFindingResume(completedJob: Job, rawOutput: unknown): void { - const evalStages = new Set(['code_critic', 'test_critic', 'playwright_eval']); - if (!evalStages.has(typeof completedJob.params.eval_stage === 'string' ? completedJob.params.eval_stage : '')) { + const completedStage = + typeof completedJob.params.eval_stage === 'string' ? completedJob.params.eval_stage : ''; + if (!completedStage) { return; } @@ -873,16 +893,24 @@ export class JobManager { return; } + const expectedStages = this.resolveExpectedEvalStages(sourceJobId); + if (!expectedStages.has(completedStage)) { + return; + } + const siblings = this.stateStore.getEvalsForSource(sourceJobId); const evalSiblings = siblings.filter((s) => { const stage = typeof s.params.eval_stage === 'string' ? s.params.eval_stage : ''; - return evalStages.has(stage); + return expectedStages.has(stage); }); - // Need all three critic stages present and all completed with passed:true - const stages = new Set(evalSiblings.map((s) => s.params.eval_stage as string)); - if (stages.size < 3) { - return; + // Wait until every expected stage has a sibling before deciding — a disabled + // built-in shrinks this set, a custom critic grows it. + const presentStages = new Set(evalSiblings.map((s) => s.params.eval_stage as string)); + for (const expected of expectedStages) { + if (!presentStages.has(expected)) { + return; + } } for (const s of evalSiblings) { @@ -897,7 +925,7 @@ export class JobManager { } } - // All three critics passed — let the report-only parent run again with fresh context. + // All expected critics passed — let the report-only parent run again with fresh context. this.stateStore.updateJob(blockedParentId, { status: 'pending', startedAt: undefined, diff --git a/src/mcp/tools/tenet-report-blocking-finding.test.ts b/src/mcp/tools/tenet-report-blocking-finding.test.ts index 9113b79..efeb2ee 100644 --- a/src/mcp/tools/tenet-report-blocking-finding.test.ts +++ b/src/mcp/tools/tenet-report-blocking-finding.test.ts @@ -292,6 +292,102 @@ describe('tenet_report_blocking_finding', () => { expect(store.getJob(reportJob.id)?.status).toBe('pending'); }); + it('auto-resumes parent when a disabled critic shrinks expected_eval_stages', async () => { + const { store, manager, handler } = createHarness(); + + const reportJob = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const result = await handler({ + job_id: reportJob.id, + finding: 'harness flaky', + why_it_blocks_report: 'acceptance report would be unreliable', + recommended_followup: 'cleanup hook', + }); + const parsed = parseResult(result); + const childId = parsed.child_job_id as string; + + await manager.waitForJob(childId, null, 5_000); + expect(store.getJob(reportJob.id)?.status).toBe('blocked_on_finding'); + + // Project disabled the playwright critic, so only code + test run. + // expected_eval_stages must reflect that, or the gate would wait for a 3rd that never comes. + const expected = ['code_critic', 'test_critic']; + const codeCritic = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + expected_eval_stages: expected, + prompt: 'code critique', + }); + const testCritic = manager.startJob('eval', { + source_job_id: childId, + eval_stage: 'test_critic', + expected_eval_stages: expected, + prompt: 'test critique', + }); + + await manager.waitForJob(codeCritic.id, null, 5_000); + await manager.waitForJob(testCritic.id, null, 5_000); + + // Only two critics dispatched, both passed → parent resumes despite no playwright critic. + expect(store.getJob(reportJob.id)?.status).toBe('pending'); + }); + + it('auto-resumes parent when a custom critic is part of expected_eval_stages', async () => { + const { store, manager, handler } = createHarness(); + + const reportJob = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const result = await handler({ + job_id: reportJob.id, + finding: 'bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix', + }); + const parsed = parseResult(result); + const childId = parsed.child_job_id as string; + + await manager.waitForJob(childId, null, 5_000); + + // A custom security critic was added to the roster, so it must also pass. + const expected = ['code_critic', 'test_critic', 'security_critic']; + const codeCritic = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + expected_eval_stages: expected, + prompt: 'code critique', + }); + const testCritic = manager.startJob('eval', { + source_job_id: childId, + eval_stage: 'test_critic', + expected_eval_stages: expected, + prompt: 'test critique', + }); + const securityCritic = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'security_critic', + expected_eval_stages: expected, + prompt: 'security critique', + }); + + await manager.waitForJob(codeCritic.id, null, 5_000); + await manager.waitForJob(testCritic.id, null, 5_000); + await manager.waitForJob(securityCritic.id, null, 5_000); + + expect(store.getJob(reportJob.id)?.status).toBe('pending'); + }); + it('does not auto-resume parent if only some critics have passed', async () => { const { store, manager, handler } = createHarness(); diff --git a/src/mcp/tools/tenet-start-eval.test.ts b/src/mcp/tools/tenet-start-eval.test.ts index a3fd3b6..31c2e04 100644 --- a/src/mcp/tools/tenet-start-eval.test.ts +++ b/src/mcp/tools/tenet-start-eval.test.ts @@ -76,6 +76,32 @@ const parseResult = (result: CallToolResult): Record => { return JSON.parse(first.text); }; +const jobId = (parsed: Record, role: string): string => { + const jobs = parsed.jobs as Array>; + const entry = jobs.find((j) => j.role === role); + if (!entry) { + throw new Error(`no dispatched critic with role '${role}'; roles were: ${jobs.map((j) => j.role).join(', ')}`); + } + return entry.job_id as string; +}; + +const jobRoles = (parsed: Record): string[] => + (parsed.jobs as Array>).map((j) => j.role as string); + +const writeRoster = (store: StateStore, roster: unknown): void => { + fs.writeFileSync( + path.join(store.projectPath, '.tenet', 'critics.json'), + typeof roster === 'string' ? roster : JSON.stringify(roster), + 'utf8', + ); +}; + +const writeCriticPrompt = (store: StateStore, name: string, body: string): void => { + const dir = path.join(store.projectPath, '.tenet', 'critics'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, name), body, 'utf8'); +}; + const createSourceJob = ( store: StateStore, feature?: string, @@ -89,6 +115,13 @@ const createSourceJob = ( maxRetries: 3, }).id; +const waitForAll = async (manager: JobManager, parsed: Record): Promise => { + const jobs = parsed.jobs as Array>; + for (const j of jobs) { + await manager.waitForJob(j.job_id as string, null, 5_000); + } +}; + afterEach(() => { while (stores.length > 0) { stores.pop()?.close(); @@ -111,10 +144,11 @@ describe('tenet_start_eval eval mode resolution', () => { expect(parsed.eval_parallel_safe).toBe(false); expect(parsed.execution_mode).toBe('sequential'); + expect(parsed.critics_dispatched).toBe(3); - const codeCriticId = parsed.code_critic_job_id as string; - const testCriticId = parsed.test_critic_job_id as string; - const playwrightId = parsed.playwright_eval_job_id as string; + const codeCriticId = jobId(parsed, 'code_critic'); + const testCriticId = jobId(parsed, 'test_critic'); + const playwrightId = jobId(parsed, 'playwright_eval'); const codeCritic = store.getJob(codeCriticId); const testCritic = store.getJob(testCriticId); @@ -128,9 +162,7 @@ describe('tenet_start_eval eval mode resolution', () => { expect(playwright?.parentJobId).toBe(testCriticId); expect(playwright?.params.auto_dispatch_on_parent_complete).toBe(true); - await manager.waitForJob(codeCriticId, null, 5_000); - await manager.waitForJob(testCriticId, null, 5_000); - await manager.waitForJob(playwrightId, null, 5_000); + await waitForAll(manager, parsed); }); it('runs critics in parallel when verdict is true', async () => { @@ -144,9 +176,9 @@ describe('tenet_start_eval eval mode resolution', () => { expect(parsed.eval_parallel_safe).toBe(true); expect(parsed.execution_mode).toBe('parallel'); - const codeCritic = store.getJob(parsed.code_critic_job_id as string); - const testCritic = store.getJob(parsed.test_critic_job_id as string); - const playwright = store.getJob(parsed.playwright_eval_job_id as string); + const codeCritic = store.getJob(jobId(parsed, 'code_critic')); + const testCritic = store.getJob(jobId(parsed, 'test_critic')); + const playwright = store.getJob(jobId(parsed, 'playwright_eval')); expect(codeCritic?.status).toBe('running'); expect(testCritic?.status).toBe('running'); @@ -154,9 +186,7 @@ describe('tenet_start_eval eval mode resolution', () => { expect(testCritic?.parentJobId).toBeUndefined(); expect(playwright?.parentJobId).toBeUndefined(); - await manager.waitForJob(parsed.code_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.test_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.playwright_eval_job_id as string, null, 5_000); + await waitForAll(manager, parsed); }); it('runs sequentially when verdict is explicitly false', async () => { @@ -170,9 +200,7 @@ describe('tenet_start_eval eval mode resolution', () => { expect(parsed.eval_parallel_safe).toBe(false); expect(parsed.execution_mode).toBe('sequential'); - await manager.waitForJob(parsed.code_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.test_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.playwright_eval_job_id as string, null, 5_000); + await waitForAll(manager, parsed); }); it('auto-dispatches the next critic when parent completes', async () => { @@ -182,13 +210,11 @@ describe('tenet_start_eval eval mode resolution', () => { const result = await handler({ job_id: sourceId, output: { summary: 'ok' } }); const parsed = parseResult(result); - const codeCriticId = parsed.code_critic_job_id as string; - const testCriticId = parsed.test_critic_job_id as string; - const playwrightId = parsed.playwright_eval_job_id as string; + const codeCriticId = jobId(parsed, 'code_critic'); + const testCriticId = jobId(parsed, 'test_critic'); + const playwrightId = jobId(parsed, 'playwright_eval'); - await manager.waitForJob(codeCriticId, null, 5_000); - await manager.waitForJob(testCriticId, null, 5_000); - await manager.waitForJob(playwrightId, null, 5_000); + await waitForAll(manager, parsed); expect(store.getJob(codeCriticId)?.status).toBe('completed'); expect(store.getJob(testCriticId)?.status).toBe('completed'); @@ -205,9 +231,7 @@ describe('tenet_start_eval eval mode resolution', () => { expect(parsed.eval_parallel_safe).toBe(true); - await manager.waitForJob(parsed.code_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.test_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.playwright_eval_job_id as string, null, 5_000); + await waitForAll(manager, parsed); }); it('code critic prompt treats unauthorized project doctrine edits as scope_conflict', async () => { @@ -223,7 +247,7 @@ describe('tenet_start_eval eval mode resolution', () => { const result = await handler({ job_id: sourceId, output: { summary: 'ok' } }); const parsed = parseResult(result); - const codeCritic = store.getJob(parsed.code_critic_job_id as string); + const codeCritic = store.getJob(jobId(parsed, 'code_critic')); const prompt = codeCritic?.params.prompt as string; expect(prompt).toContain('Project doctrine edits authorized**: no'); @@ -232,9 +256,7 @@ describe('tenet_start_eval eval mode resolution', () => { expect(prompt).toContain('**Run path**: .tenet/runs/2026-06-12-oauth'); expect(prompt).toContain('**Artifact paths**'); - await manager.waitForJob(parsed.code_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.test_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.playwright_eval_job_id as string, null, 5_000); + await waitForAll(manager, parsed); }); it('playwright eval prompt uses exact artifacts and project docs instead of legacy harness path', async () => { @@ -243,7 +265,7 @@ describe('tenet_start_eval eval mode resolution', () => { const result = await handler({ job_id: sourceId, output: { summary: 'ok' } }); const parsed = parseResult(result); - const playwright = store.getJob(parsed.playwright_eval_job_id as string); + const playwright = store.getJob(jobId(parsed, 'playwright_eval')); const prompt = playwright?.params.prompt as string; expect(prompt).toContain('Use exact artifact_paths when provided'); @@ -251,8 +273,182 @@ describe('tenet_start_eval eval mode resolution', () => { expect(prompt).toContain('.tenet/project/design.md'); expect(prompt).not.toContain('.tenet/harness/current.md'); - await manager.waitForJob(parsed.code_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.test_critic_job_id as string, null, 5_000); - await manager.waitForJob(parsed.playwright_eval_job_id as string, null, 5_000); + await waitForAll(manager, parsed); + }); +}); + +describe('tenet_start_eval configurable critic roster', () => { + it('falls back to the 3 built-in critics when no roster file exists', async () => { + const { store, manager, handler } = createHarness(); + const sourceId = createSourceJob(store, 'oauth'); + + const result = await handler({ job_id: sourceId, output: { summary: 'ok' } }); + const parsed = parseResult(result); + + expect(parsed.critics_dispatched).toBe(3); + expect(jobRoles(parsed)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + // expected_eval_stages is stamped on every dispatched critic + expect(store.getJob(jobId(parsed, 'code_critic'))?.params.expected_eval_stages).toEqual([ + 'code_critic', + 'test_critic', + 'playwright_eval', + ]); + + await waitForAll(manager, parsed); + }); + + it('falls back to the 3 built-ins and warns when the roster is invalid JSON', async () => { + const { store, manager, handler } = createHarness(); + writeRoster(store, '{ not valid json'); + const sourceId = createSourceJob(store, 'oauth'); + + const result = await handler({ job_id: sourceId, output: { summary: 'ok' } }); + const parsed = parseResult(result); + + expect(parsed.critics_dispatched).toBe(3); + expect(parsed.roster_warning).toEqual(expect.stringContaining('Could not parse')); + expect(jobRoles(parsed)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + + await waitForAll(manager, parsed); + }); + + it('skips a disabled built-in and shrinks expected_eval_stages', async () => { + const { store, manager, handler } = createHarness(); + writeRoster(store, { + version: 1, + critics: [ + { id: 'code_critic', builtin: true, enabled: true }, + { id: 'test_critic', builtin: true, enabled: true }, + { id: 'playwright_eval', builtin: true, enabled: false }, + ], + }); + const sourceId = createSourceJob(store, 'oauth'); + + const result = await handler({ job_id: sourceId, output: { summary: 'ok' } }); + const parsed = parseResult(result); + + expect(parsed.critics_dispatched).toBe(2); + expect(jobRoles(parsed)).toEqual(['code_critic', 'test_critic']); + expect(store.getJob(jobId(parsed, 'code_critic'))?.params.expected_eval_stages).toEqual([ + 'code_critic', + 'test_critic', + ]); + + await waitForAll(manager, parsed); + }); + + it('dispatches a custom critic from a prompt file', async () => { + const { store, manager, handler } = createHarness(); + writeCriticPrompt( + store, + 'security.md', + '## Security Critic\n\nReview the diff for injection, secret exposure, and auth gaps.\n\nEnd with: {"passed": true/false, "stage": "security_critic", "findings": [{"category": "product_bug", "detail": "..."}]}', + ); + writeRoster(store, { + version: 1, + critics: [ + { id: 'code_critic', builtin: true, enabled: true }, + { id: 'test_critic', builtin: true, enabled: true }, + { id: 'playwright_eval', builtin: true, enabled: true }, + { + id: 'security', + builtin: false, + enabled: true, + stage: 'security_critic', + job_type: 'critic_eval', + prompt_file: '.tenet/critics/security.md', + }, + ], + }); + store.setConfig('eval_parallel_safe:oauth', 'true'); + const sourceId = createSourceJob(store, 'oauth'); + + const result = await handler({ job_id: sourceId, output: { summary: 'ok' } }); + const parsed = parseResult(result); + + expect(parsed.critics_dispatched).toBe(4); + expect(jobRoles(parsed)).toContain('security_critic'); + + const securityJob = store.getJob(jobId(parsed, 'security_critic')); + expect(securityJob?.type).toBe('critic_eval'); + const prompt = securityJob?.params.prompt as string; + expect(prompt).toContain('## Security Critic'); + expect(prompt).toContain('Review the diff for injection'); + // custom critics receive the implementation output section + expect(prompt).toContain('## Implementation Output'); + // expected_eval_stages includes the custom stage + expect(securityJob?.params.expected_eval_stages).toEqual([ + 'code_critic', + 'test_critic', + 'playwright_eval', + 'security_critic', + ]); + + await waitForAll(manager, parsed); + }); + + it('skips a custom critic whose prompt file is missing and reports it', async () => { + const { store, manager, handler } = createHarness(); + writeRoster(store, { + version: 1, + critics: [ + { id: 'code_critic', builtin: true, enabled: true }, + { id: 'test_critic', builtin: true, enabled: true }, + { id: 'playwright_eval', builtin: true, enabled: true }, + { + id: 'security', + builtin: false, + enabled: true, + stage: 'security_critic', + job_type: 'critic_eval', + prompt_file: '.tenet/critics/security.md', // never written + }, + ], + }); + const sourceId = createSourceJob(store, 'oauth'); + + const result = await handler({ job_id: sourceId, output: { summary: 'ok' } }); + const parsed = parseResult(result); + + expect(parsed.critics_dispatched).toBe(3); + expect(parsed.skipped_critics).toEqual(['security']); + expect(jobRoles(parsed)).not.toContain('security_critic'); + + await waitForAll(manager, parsed); + }); + + it('chains N critics in roster order when sequential', async () => { + const { store, manager, handler } = createHarness(); + writeCriticPrompt(store, 'extra.md', '## Extra Critic — reachability.\n'); + writeRoster(store, { + version: 1, + critics: [ + { id: 'code_critic', builtin: true, enabled: true }, + { id: 'test_critic', builtin: true, enabled: true }, + { id: 'playwright_eval', builtin: true, enabled: true }, + { + id: 'extra', + builtin: false, + enabled: true, + stage: 'extra_critic', + job_type: 'critic_eval', + prompt_file: '.tenet/critics/extra.md', + }, + ], + }); + // no eval_parallel_safe verdict → sequential + const sourceId = createSourceJob(store, 'oauth'); + + const result = await handler({ job_id: sourceId, output: { summary: 'ok' } }); + const parsed = parseResult(result); + + expect(parsed.execution_mode).toBe('sequential'); + const jobs = parsed.jobs as Array>; + expect(jobs.map((j) => j.role)).toEqual(['code_critic', 'test_critic', 'playwright_eval', 'extra_critic']); + expect(jobs[1].parent_job_id).toBe(jobs[0].job_id); + expect(jobs[2].parent_job_id).toBe(jobs[1].job_id); + expect(jobs[3].parent_job_id).toBe(jobs[2].job_id); + + await waitForAll(manager, parsed); }); }); diff --git a/src/mcp/tools/tenet-start-eval.ts b/src/mcp/tools/tenet-start-eval.ts index db05368..0992f06 100644 --- a/src/mcp/tools/tenet-start-eval.ts +++ b/src/mcp/tools/tenet-start-eval.ts @@ -1,6 +1,10 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { z } from 'zod'; import { JobManager } from '../../core/job-manager.js'; +import { loadCriticRoster, type ResolvedCritic } from '../../core/critic-roster.js'; import { StateStore } from '../../core/state-store.js'; +import type { Job, JobType } from '../../types/index.js'; import { jsonResult, type RegisterTool } from './utils.js'; const buildJobScopeSection = (stateStore: StateStore, jobId: string): string => { @@ -210,6 +214,75 @@ const TEST_CRITIC_PREAMBLE = [ '', ].join('\n'); +type CriticDispatch = { + jobType: JobType; + evalStage: string; + prompt: string; +}; + +/** + * Resolve one roster critic into a dispatchable job spec (job type + eval stage + * + full prompt). Returns null when the critic should be skipped: + * - unknown built-in id (shouldn't happen — resolver guards it), or + * - a custom critic whose `prompt_file` is missing/unreadable. + * + * The job scope (eval-only-within-this-scope preamble) is prepended to every + * critic; built-ins append their fixed preamble, customs append their prompt + * file. Built-ins don't receive the implementation output verbatim except where + * their preamble expects it; customs always get the output so the prompt file + * can instruct the critic to use it. + */ +const buildCriticDispatch = ( + critic: ResolvedCritic, + jobScope: string, + outputStr: string, + projectPath: string, +): CriticDispatch | null => { + if (critic.builtin) { + switch (critic.id) { + case 'code_critic': + return { + jobType: critic.jobType, + evalStage: critic.stage, + prompt: jobScope + CODE_CRITIC_PREAMBLE + '## Implementation Output\n\n' + outputStr, + }; + case 'test_critic': + return { + jobType: critic.jobType, + evalStage: critic.stage, + prompt: jobScope + TEST_CRITIC_PREAMBLE + '## Test Files and Spec\n\n' + outputStr, + }; + case 'playwright_eval': + return { + jobType: critic.jobType, + evalStage: critic.stage, + prompt: jobScope + PLAYWRIGHT_EVAL_PREAMBLE, + }; + default: + return null; + } + } + + // Custom critic: read its prompt file (project-relative or absolute). + if (!critic.promptFile) { + return null; + } + const absPromptPath = path.isAbsolute(critic.promptFile) + ? critic.promptFile + : path.join(projectPath, critic.promptFile); + let promptBody: string; + try { + promptBody = fs.readFileSync(absPromptPath, 'utf8'); + } catch { + return null; + } + return { + jobType: critic.jobType, + evalStage: critic.stage, + prompt: jobScope + promptBody + '\n## Implementation Output\n\n' + outputStr, + }; +}; + const resolveEvalParallelSafe = (stateStore: StateStore, feature?: string): boolean => { if (!feature) { // No feature → default to sequential (safe fallback) @@ -228,16 +301,17 @@ export const registerTenetStartEvalTool = (registerTool: RegisterTool, jobManage 'tenet_start_eval', { description: - 'Start evaluation pipeline for a completed job. Dispatches THREE eval jobs: ' + - '(1) Code critic — independent purpose alignment check (spec + diff only, no author reasoning), ' + - '(2) Test critic — reviews whether tests are sufficient to prove features work (spec + tests only), ' + - '(3) Interaction e2e eval — runs the public-surface e2e checks declared by the harness; ' + - 'uses scripted Playwright plus Playwright MCP only when browser/visual verification applies. ' + - 'All three evaluate ONLY against the specific job\'s scope, not the full spec. ' + - 'Execution mode (parallel vs sequential) is decided by the readiness verdict stored at ' + - 'eval_parallel_safe:{feature}. When the verdict is missing or false, critics run sequentially ' + - 'to avoid contention on shared state (DB, sessions, rate limits, ports). ' + - 'Returns all three job IDs. Wait for all three to complete. ALL must pass.', + 'Start evaluation pipeline for a completed job. Dispatches the configured critics from ' + + '.tenet/critics.json (3 built-in by default: code critic, test critic, interaction-e2e; plus any ' + + 'user-defined custom critics). Each critic runs in an independent context with no author reasoning ' + + 'and evaluates ONLY against the specific job\'s scope, not the full spec. ' + + 'Code critic — purpose alignment check (spec + diff). Test critic — test sufficiency (spec + tests). ' + + 'Interaction-e2e — public-surface e2e checks declared by the harness; uses scripted Playwright plus ' + + 'Playwright MCP only when browser/visual verification applies. Custom critics use their own prompt ' + + 'under .tenet/critics/. Execution mode (parallel vs sequential) is decided by the readiness verdict ' + + 'stored at eval_parallel_safe:{feature}; when missing or false, critics run sequentially in roster ' + + 'order to avoid contention on shared state (DB, sessions, rate limits, ports). ' + + 'Returns a jobs[] list of every dispatched critic (variable length). Wait for all to complete. ALL must pass.', inputSchema: z.object({ job_id: z.string().uuid(), output: z.record(z.string(), z.unknown()), @@ -252,6 +326,7 @@ export const registerTenetStartEvalTool = (registerTool: RegisterTool, jobManage async ({ job_id, output, feature }) => { const outputStr = typeof output === 'string' ? output : JSON.stringify(output, null, 2); const jobScope = buildJobScopeSection(stateStore, job_id); + const projectPath = stateStore.projectPath; const resolvedFeature = feature ?? (() => { const source = stateStore.getJob(job_id); @@ -260,94 +335,103 @@ export const registerTenetStartEvalTool = (registerTool: RegisterTool, jobManage const parallelSafe = resolveEvalParallelSafe(stateStore, resolvedFeature); - const codeCriticParams = { - source_job_id: job_id, - eval_stage: 'code_critic', - name: `code_critic for ${job_id.slice(0, 8)}`, - prompt: jobScope + CODE_CRITIC_PREAMBLE + '## Implementation Output\n\n' + outputStr, - output, - ...(resolvedFeature ? { feature: resolvedFeature } : {}), - }; + // Resolve the roster and build the dispatch list. A critic whose prompt + // can't be built (e.g. a custom critic with a missing prompt file) is + // skipped and reported, never fatal. + const { critics: roster, warning: rosterWarning } = loadCriticRoster(projectPath); + const enabledCritics = roster.filter((c) => c.enabled); + const dispatchList: CriticDispatch[] = []; + const skippedCritics: string[] = []; + for (const critic of enabledCritics) { + const dispatch = buildCriticDispatch(critic, jobScope, outputStr, projectPath); + if (!dispatch) { + skippedCritics.push(critic.id); + continue; + } + dispatchList.push(dispatch); + } - const testCriticParams = { - source_job_id: job_id, - eval_stage: 'test_critic', - name: `test_critic for ${job_id.slice(0, 8)}`, - prompt: jobScope + TEST_CRITIC_PREAMBLE + '## Test Files and Spec\n\n' + outputStr, - output, - ...(resolvedFeature ? { feature: resolvedFeature } : {}), - }; + // Stages the resume gate waits for = exactly the stages we dispatched. + const expectedEvalStages = dispatchList.map((d) => d.evalStage); - const playwrightParams = { + const buildParams = (d: CriticDispatch) => ({ source_job_id: job_id, - eval_stage: 'playwright_eval', - name: `playwright_eval for ${job_id.slice(0, 8)}`, - prompt: jobScope + PLAYWRIGHT_EVAL_PREAMBLE, + eval_stage: d.evalStage, + name: `${d.evalStage} for ${job_id.slice(0, 8)}`, + prompt: d.prompt, output, + expected_eval_stages: expectedEvalStages, ...(resolvedFeature ? { feature: resolvedFeature } : {}), - }; + }); - let codeCriticJob; - let testCriticJob; - let playwrightEvalJob; + type Dispatched = { role: string; id: string; status: Job['status']; parentJobId?: string }; + const dispatched: Dispatched[] = []; + + if (dispatchList.length === 0) { + return jsonResult({ + jobs: [], + eval_parallel_safe: parallelSafe, + execution_mode: parallelSafe ? 'parallel' : 'sequential', + critics_dispatched: 0, + ...(rosterWarning ? { roster_warning: rosterWarning } : {}), + ...(skippedCritics.length ? { skipped_critics: skippedCritics } : {}), + message: + 'No critics dispatched — the roster has no enabled critics with readable prompts. Eval cannot pass without at least one critic.', + }); + } if (parallelSafe) { - codeCriticJob = jobManager.startJob('critic_eval', codeCriticParams); - testCriticJob = jobManager.startJob('eval', testCriticParams); - playwrightEvalJob = jobManager.startJob('playwright_eval', playwrightParams); + for (const d of dispatchList) { + const job = jobManager.startJob(d.jobType, buildParams(d)); + dispatched.push({ role: d.evalStage, id: job.id, status: job.status }); + } } else { - // Sequential: dispatch code critic; register test critic + playwright as pending - // with auto_dispatch_on_parent_complete so job-manager chains them on success. - codeCriticJob = jobManager.startJob('critic_eval', codeCriticParams); - testCriticJob = jobManager.createPendingJob( - 'eval', - { ...testCriticParams, auto_dispatch_on_parent_complete: true }, - codeCriticJob.id, - ); - playwrightEvalJob = jobManager.createPendingJob( - 'playwright_eval', - { ...playwrightParams, auto_dispatch_on_parent_complete: true }, - testCriticJob.id, - ); + // Sequential: start the first critic; register the rest as pending with + // auto_dispatch_on_parent_complete so job-manager chains them in roster order. + let prevId: string | undefined; + for (const d of dispatchList) { + if (prevId === undefined) { + const job = jobManager.startJob(d.jobType, buildParams(d)); + dispatched.push({ role: d.evalStage, id: job.id, status: job.status }); + prevId = job.id; + } else { + const job = jobManager.createPendingJob( + d.jobType, + { ...buildParams(d), auto_dispatch_on_parent_complete: true }, + prevId, + ); + dispatched.push({ role: d.evalStage, id: job.id, status: job.status, parentJobId: job.parentJobId }); + prevId = job.id; + } + } } return jsonResult({ - code_critic_job_id: codeCriticJob.id, - test_critic_job_id: testCriticJob.id, - playwright_eval_job_id: playwrightEvalJob.id, - jobs: [ - { - role: 'code_critic', - job_id: codeCriticJob.id, - status: codeCriticJob.status, - lifecycle: codeCriticJob.status === 'running' ? 'running' : codeCriticJob.status, - next_tool: 'tenet_job_wait', - next_args: { job_id: codeCriticJob.id, wait_seconds: 30 }, - }, - { - role: 'test_critic', - job_id: testCriticJob.id, - status: testCriticJob.status, - lifecycle: parallelSafe ? 'running' : 'queued_after_parent', - parent_job_id: testCriticJob.parentJobId, - next_tool: 'tenet_job_wait', - next_args: { job_id: testCriticJob.id, wait_seconds: 30 }, - }, - { - role: 'playwright_eval', - job_id: playwrightEvalJob.id, - status: playwrightEvalJob.status, - lifecycle: parallelSafe ? 'running' : 'queued_after_parent', - parent_job_id: playwrightEvalJob.parentJobId, - next_tool: 'tenet_job_wait', - next_args: { job_id: playwrightEvalJob.id, wait_seconds: 30 }, - }, - ], + jobs: dispatched.map(({ role, id, status, parentJobId }, idx) => ({ + role, + job_id: id, + status, + lifecycle: parallelSafe + ? status === 'running' + ? 'running' + : status + : idx === 0 + ? status === 'running' + ? 'running' + : status + : 'queued_after_parent', + ...(parentJobId ? { parent_job_id: parentJobId } : {}), + next_tool: 'tenet_job_wait', + next_args: { job_id: id, wait_seconds: 30 }, + })), eval_parallel_safe: parallelSafe, execution_mode: parallelSafe ? 'parallel' : 'sequential', + critics_dispatched: dispatched.length, + ...(rosterWarning ? { roster_warning: rosterWarning } : {}), + ...(skippedCritics.length ? { skipped_critics: skippedCritics } : {}), message: parallelSafe - ? 'Code critic, test critic, and Playwright eval dispatched in parallel. Wait for all three using tenet_job_wait + tenet_job_result. ALL must pass.' - : 'Critics dispatched sequentially (code → test → playwright) based on readiness verdict. Wait for each via tenet_job_wait + tenet_job_result. ALL must pass.', + ? `${dispatched.length} critic(s) dispatched in parallel. Wait for all via tenet_job_wait + tenet_job_result. ALL must pass.` + : `Critics dispatched sequentially in roster order. Wait for each via tenet_job_wait + tenet_job_result. ALL must pass.`, }); }, ); From df76d03a9c195cc9dc8a68fa9df74c0b7ccb24ac Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 25 Jun 2026 07:38:12 +0900 Subject: [PATCH 13/87] docs(readme): document .tenet/critics.json setup + fix stale eval tool row Add a "Configurable Critics" subsection (roster format, disable a built-in, add a custom critic, the mandatory output contract, pointer to the designer doc) so the front-door README is self-sufficient instead of only naming the file. Also fix the MCP tools table row for tenet_start_eval, which still described the old hardcoded 3-critic dispatch. Co-Authored-By: Claude --- README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9629a4a..743fdff 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,31 @@ Job Complete **The Oracle Problem**: Research shows AI-written tests have ~6% precision when the same agent writes both code and tests. Tenet's test critic explicitly checks for oracle leakage — tests that verify implementation behavior rather than intended behavior. +### Configurable Critics + +The critic set is a project file: `.tenet/critics.json`, scaffolded by `tenet init` and read live on every eval (just edit it — no restart). A missing or invalid file falls back to the 3 built-ins. + +```json +{ + "version": 1, + "critics": [ + { "id": "code_critic", "builtin": true, "enabled": true }, + { "id": "test_critic", "builtin": true, "enabled": true }, + { "id": "playwright_eval", "builtin": true, "enabled": false }, + { "id": "security", "builtin": false, "enabled": true, + "stage": "security_critic", "job_type": "critic_eval", + "prompt_file": ".tenet/critics/security.md" } + ] +} +``` + +- **Built-ins** — flip `enabled: false` to drop one (e.g. skip the e2e critic for a CLI-only project). +- **Custom critics** — two steps: write a prompt at `.tenet/critics/.md`, then add an entry. A custom prompt must end by emitting the verdict Tenet parses — + `{"passed": true/false, "stage": "", "findings": [{"category": "product_bug", "detail": "..."}]}` — + where `category` is one of `product_bug | test_bug | harness_bug | evidence_mismatch | contention | scope_conflict` so findings route to the right fix. + +Prefer not to hand-write the prompt? In Claude Code, ask *"tenet, create a security critic for this repo"* and it authors both the prompt and the roster entry, then smoke-tests it. Full reference: `skills/tenet/critics.md`. + ### Steer Messages Redirect the agent mid-run without breaking the loop: @@ -211,7 +236,7 @@ suppress it entirely. | `tenet_job_result` | Retrieve job output and status | | `tenet_retry_job` | Reset a failed/completed job to pending | | `tenet_cancel_job` | Cancel a running or pending job | -| `tenet_start_eval` | Dispatch code critic + test critic + playwright eval | +| `tenet_start_eval` | Dispatch the configured critics (3 built-in + custom) from `.tenet/critics.json` | | `tenet_report_blocking_finding` | Let report-only jobs pause and spawn a linked follow-up job | | `tenet_update_knowledge` | Write knowledge/journal entries | | `tenet_add_steer` | Submit a steer message (context/directive/emergency) | From 7ace21625612fa61179a86a1c3835e13857cd732 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 25 Jun 2026 09:35:32 +0900 Subject: [PATCH 14/87] chore: bump to 26.6.5 Co-Authored-By: Claude --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2121180..5456dee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.6.4", + "version": "26.6.5", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From 4e743fa76c46193804e4f0c165c2c6890d1c2b3d Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 25 Jun 2026 10:54:17 +0900 Subject: [PATCH 15/87] feat(eval): interaction-e2e critic gets CLI/API/library exploratory parity The interaction-e2e critic already handled non-UI surfaces (CLI/API/library path added in b260078); it never skipped on non-UI projects. So the ask was a framing + parity gap, not a missing feature. This closes the real gaps: - Preamble now leads with an agent-brain QA principle for ALL surfaces, with browser demoted to one peer branch. CLI/API/library branches get real exploratory depth (invalid/edge/unicode inputs, error paths, undocumented flags, chained workflows, exit-code/stderr + response-status semantics) - parity with the browser branch. - Docs/README/critic-designer stop signaling "browser-only" and no longer tell users to disable the critic for CLI projects (that threw away the CLI e2e they actually wanted). - 06-evaluation.md Stage 5 + honesty section reframed; non-browser not_applicable is "browser Layer 2 didn't apply," not "unverified." Internal identifiers (playwright_eval job type/id/stage, latest_playwright_layer2_status field, *_args_playwright_eval config keys, output contract) stay stable for back-compat with just-shipped .tenet/critics.json. No schema/DB/agent-config ripple. Co-Authored-By: Claude --- README.md | 6 +- skills/tenet/critics.md | 7 +- skills/tenet/phases/02-spec-and-harness.md | 2 +- skills/tenet/phases/06-evaluation.md | 85 ++++++++++--------- src/mcp/tools/tenet-start-eval.test.ts | 16 ++++ src/mcp/tools/tenet-start-eval.ts | 99 ++++++++++++++-------- 6 files changed, 131 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 743fdff..133576e 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Job Complete | +---> Code Critic (spec alignment, security, edge cases) +---> Test Critic (oracle problem detection, behavioral coverage) - +---> Playwright Eval (scripted tests + agent-driven exploratory e2e) + +---> Interaction E2E (agent-driven e2e on the public surface — browser via Playwright MCP, CLI/API/library via shell) +---> [custom critics] (repo-specific: security, a11y, API contract, ...) | ALL must pass --> Next job @@ -95,7 +95,7 @@ The critic set is a project file: `.tenet/critics.json`, scaffolded by `tenet in "critics": [ { "id": "code_critic", "builtin": true, "enabled": true }, { "id": "test_critic", "builtin": true, "enabled": true }, - { "id": "playwright_eval", "builtin": true, "enabled": false }, + { "id": "playwright_eval", "builtin": true, "enabled": true }, { "id": "security", "builtin": false, "enabled": true, "stage": "security_critic", "job_type": "critic_eval", "prompt_file": ".tenet/critics/security.md" } @@ -103,7 +103,7 @@ The critic set is a project file: `.tenet/critics.json`, scaffolded by `tenet in } ``` -- **Built-ins** — flip `enabled: false` to drop one (e.g. skip the e2e critic for a CLI-only project). +- **Built-ins** — flip `enabled: false` to drop any one. The interaction-e2e critic covers CLI/API/library surfaces too (agent-brain shell e2e), so keep it enabled for CLI-only projects — only disable it if you want no public-surface e2e at all. - **Custom critics** — two steps: write a prompt at `.tenet/critics/.md`, then add an entry. A custom prompt must end by emitting the verdict Tenet parses — `{"passed": true/false, "stage": "", "findings": [{"category": "product_bug", "detail": "..."}]}` — where `category` is one of `product_bug | test_bug | harness_bug | evidence_mismatch | contention | scope_conflict` so findings route to the right fix. diff --git a/skills/tenet/critics.md b/skills/tenet/critics.md index dbb0803..405a22e 100644 --- a/skills/tenet/critics.md +++ b/skills/tenet/critics.md @@ -45,8 +45,11 @@ beats "check for security issues." ``` - **Built-ins** (`builtin: true`): only `enabled` (and order) matter. Omitting - one leaves it enabled at its default position. Set `enabled: false` to drop it - (e.g. disable `playwright_eval` for a CLI-only project). + one leaves it enabled at its default position. Set `enabled: false` to drop it. + Note: the `playwright_eval` (interaction-e2e) critic handles CLI/API/library + surfaces too — agent-brain shell e2e, not just browser — so for a CLI-only + project you usually want it **enabled**. Only disable it if you want no + public-surface e2e at all. - **Custom** (`builtin: false`): - `id` — stable identifier; also the default `stage` name if `stage` is omitted. - `stage` — the `eval_stage` name. Must be unique across the roster. diff --git a/skills/tenet/phases/02-spec-and-harness.md b/skills/tenet/phases/02-spec-and-harness.md index ca9bf6d..161f922 100644 --- a/skills/tenet/phases/02-spec-and-harness.md +++ b/skills/tenet/phases/02-spec-and-harness.md @@ -172,7 +172,7 @@ A fresh agent reads spec + harness (+ scenarios + interview) and scores 8 catego 4. **External service access** — credentials for services the agent CALLS (LLM keys, payment sandboxes, webhook secrets). NOT for services the feature itself implements. 5. **Environment & runtime** — app start command, env vars, services, ports, health-check. 6. **Test data & fixtures** — seed data the agent cannot synthesize (real PDFs, sandbox users). -7. **Test strategy** — per layer (unit/integration/e2e) declared as live/sandboxed/mocked/skipped with reason; e2e surface declared as browser UI, visual/canvas/game, CLI, API, library, or not applicable; Playwright Layer 2 declared as required/optional/skipped with reason; non-UI verification (logs/metrics/DB assertions) for async/background surfaces. +7. **Test strategy** — per layer (unit/integration/e2e) declared as live/sandboxed/mocked/skipped with reason; e2e surface declared as browser UI, visual/canvas/game, CLI, API, library, or not applicable (the interaction-e2e critic then applies agent-driven probing to whichever is declared — Playwright for browser, shell for CLI/API/library, so keep it enabled for non-UI projects); Playwright Layer 2 declared as required/optional/skipped with reason; non-UI verification (logs/metrics/DB assertions) for async/background surfaces. 8. **Dependencies & tooling** — libs/runtimes installable; build/test commands runnable. ### How to resolve a failing readiness check diff --git a/skills/tenet/phases/06-evaluation.md b/skills/tenet/phases/06-evaluation.md index 33b847b..55fd45d 100644 --- a/skills/tenet/phases/06-evaluation.md +++ b/skills/tenet/phases/06-evaluation.md @@ -17,18 +17,18 @@ Every critic finding (code critic, test critic) MUST include a `category` so the The critic emits findings as objects: `{"category": "...", "detail": "..."}`. Orchestrators MUST read the category to pick the right response — plain retry loops waste cycles on bugs that aren't product bugs. -## E2E Surface And Playwright Layer 2 Honesty +## E2E Surface And Verification Honesty -The e2e eval job returns a `surface` field and a `layer2_status` field so downstream readers can distinguish browser-verified work from scripted-only or non-browser verification: +The interaction-e2e critic does agent-driven verification through whatever public surface the job exposes — browser UI, CLI, API, library, or none. It returns a `surface` field (what it classified) and a `layer2_status` field (whether browser/visual exploration applied), so downstream readers can tell what was actually exercised: -- `completed` — Layer 2 exploratory testing ran via Playwright MCP. Findings reflect real interactive use. -- `skipped_no_mcp` — Playwright MCP was not installed and the harness/spec allowed browser exploration to be skipped. Only scripted (Layer 1) results are reported. -- `not_applicable` — browser exploration is not part of this feature's declared e2e surface (for example CLI, API, library, or no e2e surface). -- `failed` — Layer 2 was attempted but could not complete (app wouldn't start, MCP errors). +- `completed` — a browser surface was exercised interactively via Playwright MCP. Findings reflect real interactive use. +- `skipped_no_mcp` — browser/visual exploration was required-but-unavailable, and the harness/spec allowed skipping it. Only scripted results are reported. +- `not_applicable` — this surface is not a browser (cli, api, library, or none). This is honest and expected, **not** a gap: the critic still ran agent-brain e2e on that surface and reported the result in `exploratory_findings`. +- `failed` — browser Layer 2 or a required runtime was attempted but could not run (app wouldn't start, MCP errors). -`tenet_get_status` surfaces the latest completed playwright_eval's `layer2_status` as `latest_playwright_layer2_status` so final reports can state honestly whether visual verification happened. A "passed" verdict with `layer2_status: skipped_no_mcp` is NOT the same as fully verified — say so in the final report. +`tenet_get_status` surfaces the latest completed `playwright_eval` job's `layer2_status` as `latest_playwright_layer2_status` (the field name is kept stable for back-compat; the critic itself is surface-agnostic). For non-browser surfaces treat `not_applicable` as "browser Layer 2 didn't apply — read the non-browser e2e result," **not** as "unverified." -If the harness/spec says browser or visual exploration is required, missing Playwright MCP is a failure. If the harness/spec says it is optional or skipped with reason, `skipped_no_mcp` is acceptable. For CLI/API/library projects, do the declared public-surface e2e checks instead of forcing Playwright. +If the harness/spec requires browser/visual exploration, missing Playwright MCP is a failure. If it is optional or skipped with reason, `skipped_no_mcp` is acceptable. For CLI/API/library surfaces the critic uses the shell — it does not skip, and it does not force a browser. ## Parallel vs Sequential Critics @@ -160,17 +160,28 @@ Record results via `tenet_update_knowledge` with a descriptive title. Example: ` ### Stage 5: Interaction E2E (Independent Job) -Dispatched as a separate `playwright_eval` job by `tenet_start_eval` alongside code critic and test critic. The job type remains `playwright_eval` for compatibility, but the worker first reads the exact run artifacts and project doctrine supplied through compiled context or `artifact_paths`, then classifies the declared e2e surface: browser UI, visual/canvas/game, CLI, API, library, or not applicable. The worker has independent context — does NOT see implementation code or author reasoning. Receives only the exact run artifacts, project doctrine, scenarios, and the running public surface. +Dispatched as a separate `playwright_eval` job by `tenet_start_eval` alongside code critic and test critic. The job type stays `playwright_eval` for compatibility — "Playwright" is only the browser tool, not the critic's identity. The worker has independent context (no implementation code, no author reasoning): it receives only the exact run artifacts, project doctrine, scenarios, and the running public surface. -For browser UI, game/canvas, visual, or other browser-interactive features, the worker MUST do BOTH layers unless the harness/spec explicitly marks Layer 2 optional or skipped with reason: +The worker **classifies the declared e2e surface** first (`web_ui`, `visual`, `cli`, `api`, `library`, or `none`), then applies **the same agent-brain, exploratory rigor to whichever surface it is** — it never skips a non-browser surface, and never forces a browser onto one. -#### Layer 1: Scripted Playwright Tests (regression) +#### Agent-brain QA (ALL surfaces) +Beyond the happy-path checks the author declared, the worker probes like a real, hostile user: +- Edge and invalid inputs — empty, huge, unicode, wrong type, missing required values, off-by-one boundaries. +- Error paths — confirm failures surface the right non-zero exit / error status / clear message, not a silent success or a crash. +- Undocumented surface area — flags/endpoints/arguments the scenarios omit but a real user would try (`--help`, `--version`, unknown subcommands, default no-arg behavior). +- Chained workflows — commands/calls run in sequence the way a user actually operates. +- Regression traps — anything a scripted test passes but a human would notice is wrong. + +#### Browser surface (web_ui / visual / canvas) +The worker MUST do BOTH layers unless the harness/spec explicitly marks Layer 2 optional or skipped with reason. + +**Layer 1 — Scripted Playwright Tests (regression)** 1. Locate existing Playwright test files (`tests/e2e/`, `e2e/`, `tests/playwright/`) 2. Ensure the application is running (start dev server or docker compose) 3. Run `npx playwright test` (or the project's test command) 4. Report pass/fail counts and any failing tests -#### Layer 2: Exploratory Agent-Driven Testing (Playwright MCP) +**Layer 2 — Exploratory Agent-Driven Testing (Playwright MCP)** The worker uses Playwright MCP tools to interact with the app like a real user: - `browser_navigate(url)` — go to a page - `browser_click(selector)` — click buttons/links @@ -179,40 +190,32 @@ The worker uses Playwright MCP tools to interact with the app like a real user: - `browser_take_screenshot()` — capture state visually - `browser_evaluate(...)` — inspect page state when visible output is insufficient -**For each scenario in scope, the worker:** -1. Navigates to the entry point -2. Performs the user actions (click, fill, submit) -3. Takes screenshots at each step -4. Verifies the EXPECTED OUTCOME (not just absence of errors): - - After login: did the URL change to /dashboard? Is user info visible? - - After create: does the new item appear in the list? - - After form submit: did it redirect to the correct page? -5. Tests edge cases scripted tests miss: - - Click every button on every page - - Try invalid inputs and verify error messages - - Test navigation between pages - - Verify all features in spec are reachable from the UI - -#### Non-browser e2e -For CLI/API/library projects, do not force Playwright: -- CLI: run the public commands from scenarios and verify exit code, stdout/stderr, files, and side effects. -- API: run acceptance/integration tests or direct HTTP workflow checks declared in the harness. -- Library: run integration tests through the public API. -- No e2e surface: set `layer2_status: "not_applicable"` and report the reason from the harness/spec. +For each scenario in scope, the worker navigates, performs the user actions, takes screenshots, and verifies the EXPECTED OUTCOME (not just absence of errors) — then applies the agent-brain QA list above in the browser (click every button, try invalid inputs, test navigation, confirm every spec feature is reachable). + +#### CLI surface +Run the public commands declared in scenarios and verify exit code, stdout/stderr, files, and side effects — then go beyond them: probe `--help`/`--version`/unknown flags/default behavior; feed invalid/empty/huge/unicode/wrong-type args and confirm a non-zero exit with accurate stderr (not a stack trace or silent success); chain commands across one session; exercise pipes/stdin/interactive prompts/signals; check disk/env/config side effects. + +#### API surface +Run the acceptance/integration tests or HTTP checks declared in the harness — then probe beyond them: hit endpoints/parameters NOT in the scenarios; try wrong method, unauthenticated, malformed/empty body, boundary inputs; verify response body and status SEMANTICS, not just non-5xx (a create that 200s without persisting, a 404 that should be a 401); check auth boundaries and error envelopes. + +#### Library surface +Exercise the public API exploratorially: boundary/invalid inputs, contract-vs-docs drift, error paths — not just the happy-path integration tests. Do not invent a CLI/browser surface if the package exposes only a programmatic API. + +#### No e2e surface +If the harness/spec declares no public surface for this job (pure internal module), set `surface: "none"` and `layer2_status: "not_applicable"`, state the reason, and pass — unless the harness REQUIRED a surface that is missing, in which case FAIL. **What this catches that scripted tests miss:** -- Stats page implemented but not wired to navigation -- Buttons with wrong sizes or misaligned layouts -- Login form that submits but doesn't redirect correctly -- Copy button that doesn't actually copy to clipboard -- Broken CSS/styling that doesn't affect test assertions +- Browser: stats page implemented but not wired to navigation; login form that submits but doesn't redirect; copy button that doesn't copy; broken CSS/styling that doesn't affect assertions. +- CLI: a flag that silently no-ops; an invalid argument that exits 0; a command that mishandles unicode paths. +- API: an endpoint that 200s on malformed input; a delete that returns success without removing; a 404 where auth should have returned 401. +- Library: a public function that throws on documented valid input; a return shape that drifts from the docs. -**When Playwright MCP is not available:** If browser/visual Layer 2 is required, fail the eval. If the harness/spec says it is optional or skipped with reason, report "Playwright MCP not installed — exploratory testing skipped" and pass with Layer 1 results only. If browser exploration is not applicable, use the non-browser e2e path and report `layer2_status: "not_applicable"`. +**When Playwright MCP is not available (browser surface only):** If browser/visual Layer 2 is required, fail the eval. If the harness/spec says it is optional or skipped with reason, report "Playwright MCP not installed — exploratory browser testing skipped" and pass on Layer 1 results only. For CLI/API/library/none surfaces Playwright MCP is irrelevant — proceed with that branch. -**When the application won't start:** FAIL the eval. The application must start to be tested. +**When a required runtime won't start:** Only surfaces that need a running app/server apply here (browser, API). FAIL the eval — it must run to be tested. CLI/library surfaces that need no server are unaffected. -**PASS**: Scripted tests pass AND exploratory testing finds no issues. -**FAIL**: Any scripted test fails OR exploratory testing finds visual/behavioral bugs. Retry or report a blocking finding with screenshots and findings as evidence. +**PASS**: Scripted/declared checks pass AND agent-brain probing finds no issues. +**FAIL**: Any declared check fails OR agent-brain probing finds a behavioral bug. Retry or report a blocking finding with evidence (screenshots for browser; command output / request-response for CLI/API). ## Anti-Skip Enforcement Evaluation is mandatory. Every job must pass Stage 1 and 1.5. Full mode runs every enabled critic — Stage 3 (code critic), Stage 4 (test critic), Stage 5 (interaction e2e), plus any custom critics enabled in `.tenet/critics.json`. All critics run in separate agent sessions with no access to the author's reasoning. The author cannot evaluate their own work. diff --git a/src/mcp/tools/tenet-start-eval.test.ts b/src/mcp/tools/tenet-start-eval.test.ts index 31c2e04..607df3f 100644 --- a/src/mcp/tools/tenet-start-eval.test.ts +++ b/src/mcp/tools/tenet-start-eval.test.ts @@ -273,6 +273,22 @@ describe('tenet_start_eval eval mode resolution', () => { expect(prompt).toContain('.tenet/project/design.md'); expect(prompt).not.toContain('.tenet/harness/current.md'); + // Reframe (#10): agent-brain QA is a first-class principle for EVERY surface, + // with browser demoted to one peer branch and CLI/API/library getting real + // exploratory guidance (not just "run the declared commands"). + expect(prompt).toContain('Apply agent-brain QA to your surface (ALL surfaces)'); + expect(prompt).toContain('### Browser surface (web_ui / visual / canvas)'); + expect(prompt).toContain('### CLI surface'); + expect(prompt).toContain('### API surface'); + expect(prompt).toContain('### Library surface'); + expect(prompt).toContain('--help'); // CLI exploratory probing + expect(prompt).toContain('confirm the exit code is non-zero'); // CLI error-path rigor + expect(prompt).toContain('SEMANTICS, not just non-5xx'); // API exploratory rigor + // Output contract is unchanged (back-compat for the gate + status surfacing). + expect(prompt).toContain( + 'End with: {"passed": true/false, "stage": "playwright_eval", "surface": "web_ui|visual|cli|api|library|none", "layer2_status": "completed|skipped_no_mcp|not_applicable|failed", "scripted_results": "...", "exploratory_findings": ["..."], "screenshots": ["..."]}', + ); + await waitForAll(manager, parsed); }); }); diff --git a/src/mcp/tools/tenet-start-eval.ts b/src/mcp/tools/tenet-start-eval.ts index 0992f06..798301a 100644 --- a/src/mcp/tools/tenet-start-eval.ts +++ b/src/mcp/tools/tenet-start-eval.ts @@ -83,21 +83,32 @@ const CODE_CRITIC_PREAMBLE = [ ].join('\n'); const PLAYWRIGHT_EVAL_PREAMBLE = [ - '## Interaction E2E — Live Application Verification', + '## Interaction E2E — Agent-Driven Verification Through the Public Surface', '', 'You are the INTERACTION E2E worker. You do NOT review code.', - 'You verify the project ACTUALLY WORKS through the public user-facing surface declared in the spec/harness.', + 'You verify the project ACTUALLY WORKS by exercising its public user-facing surface the way a real user would — with exploratory, agent-driven probing, not just the scripted checks the author wrote.', '', - '### First: classify the project surface', + 'The job type stays `playwright_eval` for compatibility, but "Playwright" is only the browser tool. The surface may be a browser UI, a TUI, a CLI, an API, a library, or nothing. You classify first and apply the SAME exploratory rigor to whichever it is — never skip a non-browser surface, and never force a browser onto one.', + '', + '### First: classify the surface', 'Read the source job scope above. Use exact artifact_paths when provided, the run-local spec/harness/scenarios, and `.tenet/project/testing.md` / `.tenet/project/design.md` as the authoritative context.', - 'Determine whether this job needs web/browser UI, game/canvas/visual, CLI, API, library, or no e2e surface.', - 'Do NOT force Playwright for CLI/API/library work unless the harness explicitly requires browser verification.', + 'Classify the declared e2e surface: `web_ui` (browser), `visual` (canvas/game/rendered), `cli`, `api`, `library`, or `none`.', + 'Honor the harness/spec policy: if it declares e2e or a surface as skipped, optional, mocked, or not applicable, follow it and state the reason. Do not invent a surface the harness says is absent, and do not force Playwright for CLI/API/library work unless browser verification is explicitly required.', + '', + '### Apply agent-brain QA to your surface (ALL surfaces)', + '', + 'Whatever surface you classified, do not stop at the happy path the author declared. Probe it like a real, hostile user:', + '- Edge and invalid inputs — empty, huge, unicode, wrong type, missing required values, off-by-one boundaries.', + '- Error paths — confirm failures produce the right non-zero exit / error status / clear message, not a silent success or a crash.', + '- Undocumented surface area — flags/endpoints/arguments the scenarios omit but a real user would try (`--help`, `--version`, unknown subcommands, default no-arg behavior).', + '- Chained workflows — run commands/calls in sequence the way a user actually operates, not only in isolation.', + '- Regression traps — anything a scripted/declared check would pass but a human would notice is wrong.', '', - 'If the harness/spec declares e2e or visual exploration as skipped, optional, mocked, or not applicable, honor that policy and state the reason.', + 'Then follow the branch for your surface.', '', - '### Browser/UI path — Two-Layer Testing', + '### Browser surface (web_ui / visual / canvas)', '', - 'If the feature is browser UI, game/canvas, visual, or otherwise requires browser interaction, do BOTH layers unless the harness explicitly says Layer 2 is optional or skipped:', + 'Do BOTH layers unless the harness/spec explicitly marks Layer 2 optional or skipped with reason.', '', '#### Layer 1: Scripted Playwright Tests (regression)', '1. Locate existing Playwright test files (tests/e2e/, e2e/, tests/playwright/, or similar)', @@ -117,43 +128,57 @@ const PLAYWRIGHT_EVAL_PREAMBLE = [ ' - After login: did the URL change to /dashboard? Is user info visible?', ' - After create: does the new item appear in the list?', ' - After form submit: did it redirect to the correct page?', - '5. Test edge cases the scripted tests miss:', - ' - Click every button on every page', - ' - Try invalid inputs and verify error messages', - ' - Test navigation between pages', - ' - Verify all features mentioned in spec are reachable from the UI', + '5. Apply the agent-brain QA list above in the browser: click every button on every page, try invalid inputs and verify error messages, test navigation between pages, confirm every spec feature is reachable from the UI.', + '', + '### CLI surface', + '', + 'Run the public commands declared in scenarios and verify exit code, stdout/stderr, files, and side effects — then go beyond them:', + '- Probe `--help`, `--version`, unknown flags/subcommands, and default (no-arg) behavior.', + '- Feed invalid/empty/huge/unicode/wrong-type arguments and confirm the exit code is non-zero and stderr is accurate and helpful (not a stack trace or silent success).', + '- Chain commands across one session the way a real user operates (init → configure → run → inspect output).', + '- Exercise pipes, stdin, interactive prompts, and signals where the CLI exposes them.', + '- Check disk/env/config side effects, not just stdout.', + '', + '### API surface', + '', + 'Run the acceptance/integration tests or HTTP workflow checks declared in the harness — then probe beyond them:', + '- Hit endpoints and parameters NOT in the scenarios; try wrong HTTP method, unauthenticated, malformed/empty body, and boundary inputs.', + '- Verify response body and status SEMANTICS, not just non-5xx (e.g. a create that 200s without persisting, or a 404 that should be a 401).', + '- Check auth/authorization boundaries and error envelopes for consistency.', + '', + '### Library surface', + '', + 'Exercise the public API exploratorially, not just the happy-path integration tests:', + '- Boundary and invalid inputs to public functions/methods.', + '- Contract-vs-docs drift — does the public signature actually behave as documented?', + '- Error paths — documented exceptions/returns vs what actually happens.', + '- Do not invent a CLI/browser surface if the package exposes only a programmatic API.', '', - '### CLI/API/library path', + '### No e2e surface', '', - 'If this is not a browser UI feature:', - '- CLI: run the public CLI commands from scenarios and verify exit code, stdout/stderr, files, and side effects', - '- API: run the project acceptance/integration tests or direct HTTP workflow checks declared in the harness', - '- Library: run integration tests through the public API; do not invent browser checks', - '- Set layer2_status to "not_applicable" when browser exploration is not part of the declared e2e surface', + 'If the harness/spec declares no public surface for this job (pure internal module), set `surface: "none"` and `layer2_status: "not_applicable"`, state the reason, and pass — unless the harness REQUIRED a surface that is missing, in which case FAIL.', '', '### What to Report', - '- Surface classification and harness policy used', - '- Layer 1 results: scripted test pass/fail counts', - '- Layer 2 findings: bugs found via exploration that scripted tests missed', - '- Visual issues observed in screenshots (broken layouts, missing elements)', - '- Features in spec that have NO accessible UI path', + '- Surface classification and the harness/spec policy you followed', + '- Scripted results (Layer 1 / declared-scenario / declared-test pass-fail counts)', + '- Exploratory findings: bugs your agent-brain probing found that the scripted/declared checks missed, for ANY surface', + '- Visual issues observed in screenshots (browser surface)', + '- Declared features with NO reachable path through the surface', '', - '### If Playwright MCP is not available', - 'If browser/visual Layer 2 is REQUIRED by the harness/spec: FAIL the eval.', - 'If browser/visual Layer 2 is optional or skipped with reason: report "Playwright MCP not installed — exploratory testing skipped" and pass with Layer 1 results only.', - 'If browser/visual Layer 2 is not applicable to this project surface: do the non-browser e2e path and report not_applicable.', + '### If Playwright MCP is not available (browser surface only)', + 'This only matters when your classified surface needs the browser. If browser/visual Layer 2 is REQUIRED by the harness/spec: FAIL the eval. If it is optional or skipped with reason: report "Playwright MCP not installed — exploratory browser testing skipped" and pass on Layer 1 results only. For CLI/API/library/none surfaces Playwright MCP is irrelevant — proceed with that branch.', '', - '### If the application won\'t start', - 'FAIL the eval. The application must start to be tested.', + '### If a required runtime won\'t start', + 'Only surfaces that need a running app/server apply here (browser, API). If the application won\'t start, FAIL the eval — it must run to be tested. CLI/library surfaces that need no server are unaffected.', '', '### Required output fields', 'You MUST set layer2_status to one of:', - '- "completed" — you exercised the app interactively via Playwright MCP and the findings below reflect that exploration', - '- "skipped_no_mcp" — Playwright MCP was not available and the harness/spec allowed skipping browser exploration', - '- "not_applicable" — browser exploration is not part of this feature\'s declared e2e surface', - '- "failed" — Layer 2 was attempted but failed to run (app would not start, MCP tool errors, etc.)', + '- "completed" — you exercised a BROWSER surface interactively via Playwright MCP and the findings below reflect that exploration', + '- "skipped_no_mcp" — browser Layer 2 was required-but-unavailable and the harness/spec allowed skipping it', + '- "not_applicable" — this surface is not a browser (cli/api/library/none); the non-browser e2e still ran and its result is in exploratory_findings', + '- "failed" — Layer 2 (browser) or a required runtime was attempted but could not run', '', - 'The final status summary will show layer2_status directly — do not treat "passed" as equivalent to "fully verified". If Layer 2 was skipped, that must be visible downstream.', + 'The final status summary will show layer2_status directly — do not treat "passed" as equivalent to "fully verified". For non-browser surfaces "not_applicable" is honest and expected, not a gap; report the real e2e result in exploratory_findings.', '', 'End with: {"passed": true/false, "stage": "playwright_eval", "surface": "web_ui|visual|cli|api|library|none", "layer2_status": "completed|skipped_no_mcp|not_applicable|failed", "scripted_results": "...", "exploratory_findings": ["..."], "screenshots": ["..."]}', '', @@ -306,8 +331,8 @@ export const registerTenetStartEvalTool = (registerTool: RegisterTool, jobManage 'user-defined custom critics). Each critic runs in an independent context with no author reasoning ' + 'and evaluates ONLY against the specific job\'s scope, not the full spec. ' + 'Code critic — purpose alignment check (spec + diff). Test critic — test sufficiency (spec + tests). ' + - 'Interaction-e2e — public-surface e2e checks declared by the harness; uses scripted Playwright plus ' + - 'Playwright MCP only when browser/visual verification applies. Custom critics use their own prompt ' + + 'Interaction-e2e — agent-driven verification through the job\'s public surface (browser UI via ' + + 'Playwright MCP; CLI/API/library via shell), exploratory not just scripted. Custom critics use their own prompt ' + 'under .tenet/critics/. Execution mode (parallel vs sequential) is decided by the readiness verdict ' + 'stored at eval_parallel_safe:{feature}; when missing or false, critics run sequentially in roster ' + 'order to avoid contention on shared state (DB, sessions, rate limits, ports). ' + From 86e3a68be478d07fadc86bb83153299d471975bc Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 25 Jun 2026 14:08:25 +0900 Subject: [PATCH 16/87] refactor(eval): rename playwright_eval -> interaction_e2e (eradicate) The interaction-e2e critic is surface-agnostic (browser via Playwright MCP, CLI/API/library via shell) but its stored identifier was still `playwright_eval` - misleading and a future-reader trap. Rename it to `interaction_e2e` everywhere and eradicate the old term from stored data. - JobType / built-in id / stage / DEFAULT_EVAL_STAGES: playwright_eval -> interaction_e2e - Status field: latest_playwright_layer2_status -> latest_e2e_status - Config keys + CLI flags: *_args_playwright_eval -> *_args_interaction_e2e (and agent_override_playwright_eval -> agent_override_interaction_e2e) - DB schema 1->2 migration rewrites jobs.type, expected_eval_stages in params, and the config keys on `tenet init --upgrade`. No structural change - pure value rewrites (jobs.type is free TEXT, no CHECK constraint). - Roster resolver keeps a single documented legacy-id alias so old user-authored .tenet/critics.json files still resolve. Unchanged (no "playwright" in them / out of scope): the layer2_status output field + its values, the surface enum, and the Playwright MCP integration itself (browser tooling still used for browser surfaces). Co-Authored-By: Claude --- CLAUDE.md | 4 +- README.md | 2 +- skills/tenet-diagnose/SKILL.md | 2 +- skills/tenet/critics.md | 13 ++-- skills/tenet/phases/06-evaluation.md | 4 +- src/adapters/adapter.test.ts | 10 +-- src/adapters/fake-adapter.ts | 2 +- src/adapters/index.ts | 2 +- src/cli/index.ts | 40 +++++----- src/cli/init.ts | 8 +- src/core/critic-roster.test.ts | 64 +++++++++++----- src/core/critic-roster.ts | 41 ++++++---- src/core/integration.test.ts | 74 +++++++++---------- src/core/job-manager.test.ts | 4 +- src/core/job-manager.ts | 7 +- src/core/migrations.test.ts | 43 +++++++++++ src/core/migrations.ts | 53 ++++++++++++- src/mcp/tools/tenet-get-status.ts | 14 ++-- .../tenet-report-blocking-finding.test.ts | 8 +- src/mcp/tools/tenet-start-eval.test.ts | 30 ++++---- src/mcp/tools/tenet-start-eval.ts | 6 +- src/mcp/tools/tenet-start-job.test.ts | 2 +- src/mcp/tools/utils.ts | 2 +- src/types/index.ts | 2 +- .../playwright-layer2-completed.json | 2 +- .../fake-agents/playwright-layer2-failed.json | 2 +- .../playwright-layer2-skipped.json | 2 +- 27 files changed, 290 insertions(+), 153 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7f9f951..8d34e87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ The system has four layers: 1. **Core** (`src/core/`) — Job orchestration (`job-manager.ts`), SQLite persistence (`state-store.ts`), DB migrations (`migrations.ts`), runtime defaults (`runtime-config.ts`), and status file sync (`status-writer.ts`). JobManager handles DAG-based job execution with heartbeat stall detection (30-min default heartbeat timeout), retry logic (`retryJob()`), and configurable concurrency. Retry defaults to unlimited (`max_retries = -1` internally); finite budgets are configured with `tenet config --max-retries `. Each JobManager instance generates a UUID (`serverId`) on startup; stale running jobs from a different server instance are reset to "pending" only after their heartbeat exceeds the timeout (orphan detection via `resetOrphanedJobs()`). StateStore manages the `jobs`, `events`, `steer_messages`, and `config` tables in `.tenet/.state/tenet.db` (WAL mode). The `config.db_schema_version` key tracks the DB schema. Normal StateStore startup refuses legacy or newer DB schemas with a clear `tenet init --upgrade` instruction; real migrations only run through `new StateStore(projectPath, { migrate: true })`, which is wired to `tenet init --upgrade`. The `jobs` table includes a `server_id` column for crash recovery tracking. Status files (`.tenet/status/status.md`, `job-queue.md`) auto-update on every job state transition. -2. **Adapters** (`src/adapters/`) — Pluggable agent adapters that spawn CLI subprocesses with a 120-minute default timeout, configurable through `tenet config --timeout `. Each adapter implements `AgentAdapter` from `base.ts`: `isAvailable()`, `invoke(invocation)`. Three built-in: `ClaudeAdapter` (`claude --print --output-format json`), `OpenCodeAdapter` (`opencode run --format json`), `CodexAdapter` (`codex exec --sandbox workspace-write` by default). Global adapter args and job-scoped args (for example `codex_args_playwright_eval`) are read at MCP server startup. JobManager resolves the configured adapter strictly by name and fails closed if that adapter is unavailable. +2. **Adapters** (`src/adapters/`) — Pluggable agent adapters that spawn CLI subprocesses with a 120-minute default timeout, configurable through `tenet config --timeout `. Each adapter implements `AgentAdapter` from `base.ts`: `isAvailable()`, `invoke(invocation)`. Three built-in: `ClaudeAdapter` (`claude --print --output-format json`), `OpenCodeAdapter` (`opencode run --format json`), `CodexAdapter` (`codex exec --sandbox workspace-write` by default). Global adapter args and job-scoped args (for example `codex_args_interaction_e2e`) are read at MCP server startup. JobManager resolves the configured adapter strictly by name and fails closed if that adapter is unavailable. 3. **MCP Server** (`src/mcp/`) — Exposes 19 tools via `@modelcontextprotocol/server`. Entry point at `src/mcp/index.ts`. Each tool in `src/mcp/tools/` registers itself with a Zod input schema and handler. Key tools: `tenet_start_job`, `tenet_continue` (server-side continuation), `tenet_compile_context`, `tenet_register_jobs` (loads job DAG, requires `feature` slug), `tenet_retry_job` (resets completed/failed jobs to pending), `tenet_report_blocking_finding` (report-only escalation), `tenet_validate_clarity`, `tenet_add_steer` / `tenet_update_steer` (create steer messages; retire/sweep them — `tenet_process_steer` returns user steers in full and caps agent steers, so user input can't be crowded out by agent noise), `tenet_start_eval` (dispatches the configured critics from `.tenet/critics.json` — 3 built-in by default: code critic + test critic + interaction-e2e, plus any custom critics; return is a variable-length `jobs[]`), `tenet_init` (initialize project from MCP). Agent selection is CLI-only via `tenet config --agent `. @@ -56,7 +56,7 @@ The system has four layers: ## Key Types (`src/types/index.ts`) -- **Job**: id, type (`dev|eval|critic_eval|playwright_eval|mechanical_eval|integration_test|compile_context|health_check`), status (`pending|running|completed|failed|cancelled|blocked|blocked_on_finding`), params, agentName +- **Job**: id, type (`dev|eval|critic_eval|interaction_e2e|mechanical_eval|integration_test|compile_context|health_check`), status (`pending|running|completed|failed|cancelled|blocked|blocked_on_finding`), params, agentName - **SteerMessage**: class (`context|directive|emergency`), status (`received|acknowledged|acted_on|resolved`) - **ContinuationState**: tracks DAG progress — next_job, blocked_jobs, completed/total counts - **Config**: explicit agent selection (default and per-type overrides), concurrency limits diff --git a/README.md b/README.md index 133576e..56a175a 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ The critic set is a project file: `.tenet/critics.json`, scaffolded by `tenet in "critics": [ { "id": "code_critic", "builtin": true, "enabled": true }, { "id": "test_critic", "builtin": true, "enabled": true }, - { "id": "playwright_eval", "builtin": true, "enabled": true }, + { "id": "interaction_e2e", "builtin": true, "enabled": true }, { "id": "security", "builtin": false, "enabled": true, "stage": "security_critic", "job_type": "critic_eval", "prompt_file": ".tenet/critics/security.md" } diff --git a/skills/tenet-diagnose/SKILL.md b/skills/tenet-diagnose/SKILL.md index dd84bd6..22d695c 100644 --- a/skills/tenet-diagnose/SKILL.md +++ b/skills/tenet-diagnose/SKILL.md @@ -271,7 +271,7 @@ Pass an empty string to clear a setting: `tenet config --opencode-args ""`. **Restart the Tenet MCP server after changing adapter args** — they're read at server startup. Notes on the mechanism: -- Extra args are stored in `.tenet/.state/config.json` under `claude_args` / `opencode_args` / `codex_args`, with optional job-scoped keys such as `codex_args_playwright_eval`. +- Extra args are stored in `.tenet/.state/config.json` under `claude_args` / `opencode_args` / `codex_args`, with optional job-scoped keys such as `codex_args_interaction_e2e`. - They're injected per-CLI at the known-safe position. Codex defaults to `--sandbox workspace-write`; global or job-scoped sandbox flags override that default. - Argument splitting is whitespace-based — values cannot contain embedded spaces in v1. If you need quoted values, file an issue. diff --git a/skills/tenet/critics.md b/skills/tenet/critics.md index 405a22e..aa10c95 100644 --- a/skills/tenet/critics.md +++ b/skills/tenet/critics.md @@ -31,7 +31,7 @@ beats "check for security issues." "critics": [ { "id": "code_critic", "builtin": true, "enabled": true }, { "id": "test_critic", "builtin": true, "enabled": true }, - { "id": "playwright_eval", "builtin": true, "enabled": true }, + { "id": "interaction_e2e", "builtin": true, "enabled": true }, { "id": "security", "builtin": false, @@ -46,15 +46,14 @@ beats "check for security issues." - **Built-ins** (`builtin: true`): only `enabled` (and order) matter. Omitting one leaves it enabled at its default position. Set `enabled: false` to drop it. - Note: the `playwright_eval` (interaction-e2e) critic handles CLI/API/library - surfaces too — agent-brain shell e2e, not just browser — so for a CLI-only - project you usually want it **enabled**. Only disable it if you want no - public-surface e2e at all. + Note: the `interaction_e2e` critic handles CLI/API/library surfaces too — + agent-brain shell e2e, not just browser — so for a CLI-only project you usually + want it **enabled**. Only disable it if you want no public-surface e2e at all. - **Custom** (`builtin: false`): - `id` — stable identifier; also the default `stage` name if `stage` is omitted. - `stage` — the `eval_stage` name. Must be unique across the roster. - - `job_type` — `critic_eval` (default) or `playwright_eval`. Use - `playwright_eval` only if the critic needs browser tools / emits + - `job_type` — `critic_eval` (default) or `interaction_e2e`. Use + `interaction_e2e` only if the critic needs browser tools / emits `layer2_status`; otherwise `critic_eval`. - `prompt_file` — project-relative path to the prompt markdown. Missing file → the critic is skipped at dispatch with a warning (never fatal). diff --git a/skills/tenet/phases/06-evaluation.md b/skills/tenet/phases/06-evaluation.md index 55fd45d..8d5f88d 100644 --- a/skills/tenet/phases/06-evaluation.md +++ b/skills/tenet/phases/06-evaluation.md @@ -26,7 +26,7 @@ The interaction-e2e critic does agent-driven verification through whatever publi - `not_applicable` — this surface is not a browser (cli, api, library, or none). This is honest and expected, **not** a gap: the critic still ran agent-brain e2e on that surface and reported the result in `exploratory_findings`. - `failed` — browser Layer 2 or a required runtime was attempted but could not run (app wouldn't start, MCP errors). -`tenet_get_status` surfaces the latest completed `playwright_eval` job's `layer2_status` as `latest_playwright_layer2_status` (the field name is kept stable for back-compat; the critic itself is surface-agnostic). For non-browser surfaces treat `not_applicable` as "browser Layer 2 didn't apply — read the non-browser e2e result," **not** as "unverified." +`tenet_get_status` surfaces the latest completed `interaction_e2e` critic job's status as `latest_e2e_status` (the value comes from the critic's `layer2_status` field). For non-browser surfaces treat `not_applicable` as "browser Layer 2 didn't apply — read the non-browser e2e result," **not** as "unverified." If the harness/spec requires browser/visual exploration, missing Playwright MCP is a failure. If it is optional or skipped with reason, `skipped_no_mcp` is acceptable. For CLI/API/library surfaces the critic uses the shell — it does not skip, and it does not force a browser. @@ -160,7 +160,7 @@ Record results via `tenet_update_knowledge` with a descriptive title. Example: ` ### Stage 5: Interaction E2E (Independent Job) -Dispatched as a separate `playwright_eval` job by `tenet_start_eval` alongside code critic and test critic. The job type stays `playwright_eval` for compatibility — "Playwright" is only the browser tool, not the critic's identity. The worker has independent context (no implementation code, no author reasoning): it receives only the exact run artifacts, project doctrine, scenarios, and the running public surface. +Dispatched as a separate `interaction_e2e` job by `tenet_start_eval` alongside code critic and test critic. "Playwright" is only the browser tool the critic uses for browser surfaces, not the critic's identity. The worker has independent context (no implementation code, no author reasoning): it receives only the exact run artifacts, project doctrine, scenarios, and the running public surface. The worker **classifies the declared e2e surface** first (`web_ui`, `visual`, `cli`, `api`, `library`, or `none`), then applies **the same agent-brain, exploratory rigor to whichever surface it is** — it never skips a non-browser surface, and never forces a browser onto one. diff --git a/src/adapters/adapter.test.ts b/src/adapters/adapter.test.ts index d4d18f9..e0abd5c 100644 --- a/src/adapters/adapter.test.ts +++ b/src/adapters/adapter.test.ts @@ -84,16 +84,16 @@ describe('AdapterRegistry', () => { const registry = new AdapterRegistry({ byJobType: { codex: { - playwright_eval: ['--dangerously-bypass-approvals-and-sandbox'], + interaction_e2e: ['--dangerously-bypass-approvals-and-sandbox'], }, }, }); - expect(registry.getJobExtraArgs('codex', 'playwright_eval')).toEqual([ + expect(registry.getJobExtraArgs('codex', 'interaction_e2e')).toEqual([ '--dangerously-bypass-approvals-and-sandbox', ]); expect(registry.getJobExtraArgs('codex', 'dev')).toEqual([]); - expect(registry.getJobExtraArgs('mock-adapter', 'playwright_eval')).toEqual([]); + expect(registry.getJobExtraArgs('mock-adapter', 'interaction_e2e')).toEqual([]); }); it('throws when no adapters are available for default selection', async () => { @@ -111,12 +111,12 @@ describe('parseAdapterExtraArgs', () => { claude_args: '--allowedTools Bash,Read,Write', opencode_args: '--model github-copilot/claude-opus-4-5', codex_args: '--sandbox danger-full-access', - codex_args_playwright_eval: '--dangerously-bypass-approvals-and-sandbox', + codex_args_interaction_e2e: '--dangerously-bypass-approvals-and-sandbox', }); expect(parsed.claude).toEqual(['--allowedTools', 'Bash,Read,Write']); expect(parsed.opencode).toEqual(['--model', 'github-copilot/claude-opus-4-5']); expect(parsed.codex).toEqual(['--sandbox', 'danger-full-access']); - expect(parsed.byJobType?.codex?.playwright_eval).toEqual(['--dangerously-bypass-approvals-and-sandbox']); + expect(parsed.byJobType?.codex?.interaction_e2e).toEqual(['--dangerously-bypass-approvals-and-sandbox']); }); it('returns empty arrays for missing or empty values', () => { diff --git a/src/adapters/fake-adapter.ts b/src/adapters/fake-adapter.ts index 243e19a..87ec788 100644 --- a/src/adapters/fake-adapter.ts +++ b/src/adapters/fake-adapter.ts @@ -157,7 +157,7 @@ export const matchers = { const markers: Record = { code_critic: ['Code Critic', '"stage": "code_critic"'], test_critic: ['Test Critic', '"stage": "test_critic"'], - playwright_eval: ['Playwright', 'PLAYWRIGHT EVAL'], + interaction_e2e: ['Interaction E2E', '"stage": "interaction_e2e"'], readiness_validation: ['IMPLEMENTATION READINESS', 'readiness'], }; const candidates = markers[stage] ?? [stage]; diff --git a/src/adapters/index.ts b/src/adapters/index.ts index e00aad2..fa8f3fa 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -19,7 +19,7 @@ const JOB_TYPES: JobType[] = [ 'dev', 'eval', 'critic_eval', - 'playwright_eval', + 'interaction_e2e', 'mechanical_eval', 'integration_test', 'compile_context', diff --git a/src/cli/index.ts b/src/cli/index.ts index 578e72d..d18bb83 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -428,16 +428,16 @@ const run = async (): Promise => { 'Extra CLI args to pass to every codex subprocess (e.g. "--sandbox danger-full-access"). Use "" to clear.', ) .option( - '--claude-args-playwright-eval ', - 'Extra CLI args to pass only to claude-code playwright_eval subprocesses. Use "" to clear.', + '--claude-args-interaction-e2e ', + 'Extra CLI args to pass only to claude-code interaction-e2e critic subprocesses. Use "" to clear.', ) .option( - '--opencode-args-playwright-eval ', - 'Extra CLI args to pass only to opencode playwright_eval subprocesses. Use "" to clear.', + '--opencode-args-interaction-e2e ', + 'Extra CLI args to pass only to opencode interaction-e2e critic subprocesses. Use "" to clear.', ) .option( - '--codex-args-playwright-eval ', - 'Extra CLI args to pass only to codex playwright_eval subprocesses (e.g. "--dangerously-bypass-approvals-and-sandbox"). Use "" to clear.', + '--codex-args-interaction-e2e ', + 'Extra CLI args to pass only to codex interaction-e2e critic subprocesses (e.g. "--dangerously-bypass-approvals-and-sandbox"). Use "" to clear.', ) .action(async (options: { project: string; @@ -447,9 +447,9 @@ const run = async (): Promise => { claudeArgs?: string; opencodeArgs?: string; codexArgs?: string; - claudeArgsPlaywrightEval?: string; - opencodeArgsPlaywrightEval?: string; - codexArgsPlaywrightEval?: string; + claudeArgsInteractionE2e?: string; + opencodeArgsInteractionE2e?: string; + codexArgsInteractionE2e?: string; }) => { const projectPath = resolveProjectPath(options.project); const tenetRoot = path.join(projectPath, '.tenet'); @@ -530,7 +530,7 @@ const run = async (): Promise => { const setScopedArgs = ( optionValue: string | undefined, - key: 'claude_args_playwright_eval' | 'opencode_args_playwright_eval' | 'codex_args_playwright_eval', + key: 'claude_args_interaction_e2e' | 'opencode_args_interaction_e2e' | 'codex_args_interaction_e2e', ): void => { if (optionValue === undefined) { return; @@ -547,9 +547,9 @@ const run = async (): Promise => { changed = true; }; - setScopedArgs(options.claudeArgsPlaywrightEval, 'claude_args_playwright_eval'); - setScopedArgs(options.opencodeArgsPlaywrightEval, 'opencode_args_playwright_eval'); - setScopedArgs(options.codexArgsPlaywrightEval, 'codex_args_playwright_eval'); + setScopedArgs(options.claudeArgsInteractionE2e, 'claude_args_interaction_e2e'); + setScopedArgs(options.opencodeArgsInteractionE2e, 'opencode_args_interaction_e2e'); + setScopedArgs(options.codexArgsInteractionE2e, 'codex_args_interaction_e2e'); if (changed) { writeStateConfig(tenetRoot, config); @@ -557,9 +557,9 @@ const run = async (): Promise => { options.claudeArgs !== undefined || options.opencodeArgs !== undefined || options.codexArgs !== undefined || - options.claudeArgsPlaywrightEval !== undefined || - options.opencodeArgsPlaywrightEval !== undefined || - options.codexArgsPlaywrightEval !== undefined + options.claudeArgsInteractionE2e !== undefined || + options.opencodeArgsInteractionE2e !== undefined || + options.codexArgsInteractionE2e !== undefined ) { console.log('Restart the Tenet MCP server for adapter arg changes to take effect.'); } @@ -578,9 +578,9 @@ const run = async (): Promise => { console.log(` claude_args: ${config.claude_args ?? '(none)'}`); console.log(` opencode_args: ${config.opencode_args ?? '(none)'}`); console.log(` codex_args: ${config.codex_args ?? '(none)'}`); - console.log(` claude_args_playwright_eval: ${config.claude_args_playwright_eval ?? '(none)'}`); - console.log(` opencode_args_playwright_eval: ${config.opencode_args_playwright_eval ?? '(none)'}`); - console.log(` codex_args_playwright_eval: ${config.codex_args_playwright_eval ?? '(none)'}`); + console.log(` claude_args_interaction_e2e: ${config.claude_args_interaction_e2e ?? '(none)'}`); + console.log(` opencode_args_interaction_e2e: ${config.opencode_args_interaction_e2e ?? '(none)'}`); + console.log(` codex_args_interaction_e2e: ${config.codex_args_interaction_e2e ?? '(none)'}`); console.log( '\nTo change: tenet config --agent --max-retries --timeout \\', ); @@ -588,7 +588,7 @@ const run = async (): Promise => { ' --claude-args "..." --opencode-args "..." --codex-args "..." \\', ); console.log( - ' --codex-args-playwright-eval "..."', + ' --codex-args-interaction-e2e "..."', ); }); diff --git a/src/cli/init.ts b/src/cli/init.ts index 3a1d72c..45650d8 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -75,7 +75,7 @@ const CRITICS_ROSTER_TEMPLATE = `{ "critics": [ { "id": "code_critic", "builtin": true, "enabled": true }, { "id": "test_critic", "builtin": true, "enabled": true }, - { "id": "playwright_eval", "builtin": true, "enabled": true }, + { "id": "interaction_e2e", "builtin": true, "enabled": true }, { "id": "security", "builtin": false, @@ -294,9 +294,9 @@ type StateConfig = { opencode_args?: string; codex_args?: string; claude_args?: string; - opencode_args_playwright_eval?: string; - codex_args_playwright_eval?: string; - claude_args_playwright_eval?: string; + opencode_args_interaction_e2e?: string; + codex_args_interaction_e2e?: string; + claude_args_interaction_e2e?: string; /** Per-project star-nudge state (see src/cli/star-nudge.ts). */ star_nudge?: { starredAt?: string }; }; diff --git a/src/core/critic-roster.test.ts b/src/core/critic-roster.test.ts index 158f869..70b7378 100644 --- a/src/core/critic-roster.test.ts +++ b/src/core/critic-roster.test.ts @@ -8,14 +8,14 @@ const byId = (critics: ResolvedCritic[], id: string): ResolvedCritic | undefined describe('resolveRoster', () => { it('falls back to the 3 built-ins for invalid payloads', () => { - expect(resolveRoster(null).map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); - expect(resolveRoster(undefined).map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); - expect(resolveRoster('nope').map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); - expect(resolveRoster({}).map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(resolveRoster(null).map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); + expect(resolveRoster(undefined).map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); + expect(resolveRoster('nope').map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); + expect(resolveRoster({}).map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); expect(resolveRoster({ critics: 'not-an-array' }).map((c) => c.id)).toEqual([ 'code_critic', 'test_critic', - 'playwright_eval', + 'interaction_e2e', ]); }); @@ -23,15 +23,15 @@ describe('resolveRoster', () => { const roster = resolveRoster({ version: 1, critics: [] }); expect(roster).toHaveLength(3); expect(roster.every((c) => c.builtin && c.enabled)).toBe(true); - expect(roster.map((c) => c.stage)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); - expect(roster.map((c) => c.jobType)).toEqual(['critic_eval', 'eval', 'playwright_eval']); + expect(roster.map((c) => c.stage)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); + expect(roster.map((c) => c.jobType)).toEqual(['critic_eval', 'eval', 'interaction_e2e']); }); it('honors enabled:false on a built-in', () => { const roster = resolveRoster({ - critics: [{ id: 'playwright_eval', builtin: true, enabled: false }], + critics: [{ id: 'interaction_e2e', builtin: true, enabled: false }], }); - expect(byId(roster, 'playwright_eval')?.enabled).toBe(false); + expect(byId(roster, 'interaction_e2e')?.enabled).toBe(false); expect(byId(roster, 'code_critic')?.enabled).toBe(true); }); @@ -39,15 +39,45 @@ describe('resolveRoster', () => { const roster = resolveRoster({ critics: [{ id: 'code_critic', builtin: true, enabled: true }], }); - expect(roster.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(roster.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); }); - it('resolves a custom critic with explicit stage/job_type/prompt_file', () => { + it('maps the legacy `playwright_eval` id onto the interaction_e2e built-in', () => { + // A critics.json authored before the rename keeps resolving without an edit. const roster = resolveRoster({ critics: [ { id: 'code_critic', builtin: true, enabled: true }, { id: 'test_critic', builtin: true, enabled: true }, + { id: 'playwright_eval', builtin: true, enabled: false }, + ], + }); + expect(roster.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); + const e2e = byId(roster, 'interaction_e2e'); + expect(e2e?.builtin).toBe(true); + expect(e2e?.stage).toBe('interaction_e2e'); + expect(e2e?.jobType).toBe('interaction_e2e'); + expect(e2e?.enabled).toBe(false); // honors enabled:false carried by the legacy id + }); + + it('dedups a legacy id against its current equivalent', () => { + const roster = resolveRoster({ + critics: [ { id: 'playwright_eval', builtin: true, enabled: true }, + { id: 'interaction_e2e', builtin: true, enabled: false }, + ], + }); + // Both normalize to interaction_e2e → first wins, no duplicate. + const e2e = roster.filter((c) => c.id === 'interaction_e2e'); + expect(e2e).toHaveLength(1); + expect(e2e[0].enabled).toBe(true); + }); + + it('resolves a custom critic with explicit stage/job_type/prompt_file', () => { + const roster = resolveRoster({ + critics: [ + { id: 'code_critic', builtin: true, enabled: true }, + { id: 'test_critic', builtin: true, enabled: true }, + { id: 'interaction_e2e', builtin: true, enabled: true }, { id: 'security', builtin: false, @@ -98,11 +128,11 @@ describe('resolveRoster', () => { const codeCritics = roster.filter((c) => c.id === 'code_critic'); expect(codeCritics).toHaveLength(1); expect(codeCritics[0].enabled).toBe(false); // first wins - expect(roster.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(roster.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); }); it('DEFAULT_ROSTER is the 3 built-ins enabled', () => { - expect(DEFAULT_ROSTER.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(DEFAULT_ROSTER.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); expect(DEFAULT_ROSTER.every((c) => c.enabled)).toBe(true); }); }); @@ -117,14 +147,14 @@ describe('loadCriticRoster', () => { it('returns defaults when no roster file exists', () => { const { critics, warning } = loadCriticRoster(tmp); expect(warning).toBeUndefined(); - expect(critics.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(critics.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); }); it('returns defaults + a warning when the roster file is invalid JSON', () => { const rosterPath = path.join(mkdirTenet(tmp), 'critics.json'); fs.writeFileSync(rosterPath, '{ broken', 'utf8'); const { critics, warning } = loadCriticRoster(tmp); - expect(critics.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(critics.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); expect(warning).toEqual(expect.stringContaining('Could not parse')); }); @@ -137,7 +167,7 @@ describe('loadCriticRoster', () => { critics: [ { id: 'code_critic', builtin: true, enabled: true }, { id: 'test_critic', builtin: true, enabled: true }, - { id: 'playwright_eval', builtin: true, enabled: false }, + { id: 'interaction_e2e', builtin: true, enabled: false }, { id: 'a11y', stage: 'a11y_critic', prompt_file: '.tenet/critics/a11y.md' }, ], }), @@ -145,7 +175,7 @@ describe('loadCriticRoster', () => { ); const { critics, warning } = loadCriticRoster(tmp); expect(warning).toBeUndefined(); - expect(byId(critics, 'playwright_eval')?.enabled).toBe(false); + expect(byId(critics, 'interaction_e2e')?.enabled).toBe(false); expect(byId(critics, 'a11y')?.stage).toBe('a11y_critic'); }); }); diff --git a/src/core/critic-roster.ts b/src/core/critic-roster.ts index fef7d05..c1c2228 100644 --- a/src/core/critic-roster.ts +++ b/src/core/critic-roster.ts @@ -16,34 +16,45 @@ import type { JobType } from '../types/index.js'; * resume gate reads the `expected_eval_stages` the tool stamps onto each critic. */ -export type BuiltinCriticId = 'code_critic' | 'test_critic' | 'playwright_eval'; +export type BuiltinCriticId = 'code_critic' | 'test_critic' | 'interaction_e2e'; export const BUILTIN_CRITIC_IDS: readonly BuiltinCriticId[] = [ 'code_critic', 'test_critic', - 'playwright_eval', + 'interaction_e2e', ]; /** Stages the resume gate waits for when a job predates roster stamping. */ export const DEFAULT_EVAL_STAGES: readonly string[] = [ 'code_critic', 'test_critic', - 'playwright_eval', + 'interaction_e2e', ]; /** Custom critics may reuse either of these job types (no new JobType needed). */ -const VALID_CUSTOM_JOB_TYPES: readonly JobType[] = ['critic_eval', 'playwright_eval']; +const VALID_CUSTOM_JOB_TYPES: readonly JobType[] = ['critic_eval', 'interaction_e2e']; const BUILTIN_STAGE: Record = { code_critic: 'code_critic', test_critic: 'test_critic', - playwright_eval: 'playwright_eval', + interaction_e2e: 'interaction_e2e', }; const BUILTIN_JOB_TYPE: Record = { code_critic: 'critic_eval', test_critic: 'eval', - playwright_eval: 'playwright_eval', + interaction_e2e: 'interaction_e2e', +}; + +/** + * Legacy `.tenet/critics.json` files authored before the rename use the id + * `playwright_eval`. Map that onto the current built-in so those files keep + * resolving without a manual edit. This is the only place the legacy string is + * recognized — the DB migration rewrites stored rows, but user-authored files + * can't be auto-rewritten, hence this alias. + */ +const LEGACY_BUILTIN_ID_ALIAS: Readonly> = { + playwright_eval: 'interaction_e2e', }; /** Raw shape of one entry in `.tenet/critics.json`. */ @@ -53,7 +64,7 @@ export type CriticRosterEntry = { enabled?: boolean; /** Custom only — the `eval_stage` name. Defaults to `id`. */ stage?: string; - /** Custom only — `critic_eval` (default) or `playwright_eval`. */ + /** Custom only — `critic_eval` (default) or `interaction_e2e`. */ job_type?: JobType; /** Custom only — project-relative path to a markdown prompt. */ prompt_file?: string; @@ -120,17 +131,19 @@ export const resolveRoster = (raw: unknown): ResolvedCritic[] => { if (typeof e.id !== 'string' || e.id.length === 0) { continue; } - if (usedIds.has(e.id)) { + // Legacy critics.json files use the pre-rename id `playwright_eval`; map it + // onto the current built-in so those files keep resolving without an edit. + const id = LEGACY_BUILTIN_ID_ALIAS[e.id] ?? e.id; + if (usedIds.has(id)) { continue; } - if (e.builtin === true || isBuiltinId(e.id)) { + if (e.builtin === true || isBuiltinId(id)) { // Built-in: only `enabled` (and presence/order) are meaningful. - if (!isBuiltinId(e.id)) { + if (!isBuiltinId(id)) { // `builtin: true` asserted for an unknown id — treat as misconfigured, skip. continue; } - const id = e.id; seenBuiltins.add(id); resolved.push({ id, @@ -141,18 +154,18 @@ export const resolveRoster = (raw: unknown): ResolvedCritic[] => { }); usedIds.add(id); } else { - const stage = typeof e.stage === 'string' && e.stage.length > 0 ? e.stage : e.id; + const stage = typeof e.stage === 'string' && e.stage.length > 0 ? e.stage : id; const jobType: JobType = isJobType(e.job_type) ? e.job_type : 'critic_eval'; const promptFile = typeof e.prompt_file === 'string' && e.prompt_file.length > 0 ? e.prompt_file : undefined; resolved.push({ - id: e.id, + id, builtin: false, enabled: e.enabled !== false, stage, jobType, promptFile, }); - usedIds.add(e.id); + usedIds.add(id); } } diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index 67fcb36..014ecf7 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -53,7 +53,7 @@ const createHarness = (rules: FakeFixtureRule[]): Harness => { store.setConfig('agent_override_dev', 'fake'); store.setConfig('agent_override_eval', 'fake'); store.setConfig('agent_override_critic_eval', 'fake'); - store.setConfig('agent_override_playwright_eval', 'fake'); + store.setConfig('agent_override_interaction_e2e', 'fake'); const registry = new AdapterRegistry(); (registry as unknown as { adapters: Map }).adapters.clear(); @@ -169,7 +169,7 @@ describe('integration: sequential critic chain', () => { const { store, manager, startEval } = createHarness([ { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, - { match: matchers.evalStage('playwright_eval'), fixture: 'playwright-layer2-completed.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, ]); // Mark the feature as unsafe — sequential mode @@ -191,18 +191,18 @@ describe('integration: sequential critic chain', () => { // Sequentially: code critic starts running immediately, others pending with parent await manager.waitForJob(jobId(parsed, 'code_critic'), null, 5_000); await manager.waitForJob(jobId(parsed, 'test_critic'), null, 5_000); - await manager.waitForJob(jobId(parsed, 'playwright_eval'), null, 5_000); + await manager.waitForJob(jobId(parsed, 'interaction_e2e'), null, 5_000); expect(store.getJob(jobId(parsed, 'code_critic'))?.status).toBe('completed'); expect(store.getJob(jobId(parsed, 'test_critic'))?.status).toBe('completed'); - expect(store.getJob(jobId(parsed, 'playwright_eval'))?.status).toBe('completed'); + expect(store.getJob(jobId(parsed, 'interaction_e2e'))?.status).toBe('completed'); }); it('B2: safe verdict → 3 critics launch in parallel', async () => { const { store, manager, startEval } = createHarness([ { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, - { match: matchers.evalStage('playwright_eval'), fixture: 'playwright-layer2-completed.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, ]); store.setConfig('eval_parallel_safe:pure', 'true'); @@ -222,13 +222,13 @@ describe('integration: sequential critic chain', () => { // All three should be running (or just-completed) — none were left pending waiting for a parent. const test = store.getJob(jobId(parsed, 'test_critic')); - const play = store.getJob(jobId(parsed, 'playwright_eval')); + const play = store.getJob(jobId(parsed, 'interaction_e2e')); expect(test?.parentJobId).toBeUndefined(); expect(play?.parentJobId).toBeUndefined(); await manager.waitForJob(jobId(parsed, 'code_critic'), null, 5_000); await manager.waitForJob(jobId(parsed, 'test_critic'), null, 5_000); - await manager.waitForJob(jobId(parsed, 'playwright_eval'), null, 5_000); + await manager.waitForJob(jobId(parsed, 'interaction_e2e'), null, 5_000); }); }); @@ -240,7 +240,7 @@ describe('integration: blocking finding auto-resume', () => { { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, - { match: matchers.evalStage('playwright_eval'), fixture: 'playwright-layer2-completed.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, ]); const parent = store.createJob({ @@ -274,10 +274,10 @@ describe('integration: blocking finding auto-resume', () => { eval_stage: 'test_critic', prompt: 'Test Critic review', }); - const play = manager.startJob('playwright_eval', { + const play = manager.startJob('interaction_e2e', { source_job_id: childId, - eval_stage: 'playwright_eval', - prompt: 'Playwright eval', + eval_stage: 'interaction_e2e', + prompt: 'Interaction E2E eval', }); await manager.waitForJob(code.id, null, 5_000); @@ -326,17 +326,17 @@ describe('integration: blocking finding auto-resume', () => { // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── -describe('integration: latest_playwright_layer2_status surfacing', () => { - const driveOnePlaywright = async ( +describe('integration: latest_e2e_status surfacing', () => { + const driveOneE2e = async ( h: Harness, rules: FakeFixtureRule[], ): Promise => { // Update the adapter with new rules by re-registering. Simpler: create a fresh job // and wait. We rely on a playwright-specific rule already being in the harness. - const job = h.manager.startJob('playwright_eval', { + const job = h.manager.startJob('interaction_e2e', { source_job_id: 'dummy', - eval_stage: 'playwright_eval', - prompt: 'Playwright eval', + eval_stage: 'interaction_e2e', + prompt: 'Interaction E2E eval', }); await h.manager.waitForJob(job.id, null, 5_000); void rules; @@ -344,32 +344,32 @@ describe('integration: latest_playwright_layer2_status surfacing', () => { it('D1: completed → surfaces "completed"', async () => { const h = createHarness([ - { match: matchers.evalStage('playwright_eval'), fixture: 'playwright-layer2-completed.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, ]); - await driveOnePlaywright(h, []); + await driveOneE2e(h, []); const r = await h.getStatus({}); const parsed = parseResult(r); - expect(parsed.latest_playwright_layer2_status).toBe('completed'); + expect(parsed.latest_e2e_status).toBe('completed'); }); it('D2: skipped_no_mcp → surfaces "skipped_no_mcp"', async () => { const h = createHarness([ - { match: matchers.evalStage('playwright_eval'), fixture: 'playwright-layer2-skipped.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-skipped.json' }, ]); - await driveOnePlaywright(h, []); + await driveOneE2e(h, []); const r = await h.getStatus({}); const parsed = parseResult(r); - expect(parsed.latest_playwright_layer2_status).toBe('skipped_no_mcp'); + expect(parsed.latest_e2e_status).toBe('skipped_no_mcp'); }); it('D3: failed → surfaces "failed"', async () => { const h = createHarness([ - { match: matchers.evalStage('playwright_eval'), fixture: 'playwright-layer2-failed.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-failed.json' }, ]); - await driveOnePlaywright(h, []); + await driveOneE2e(h, []); const r = await h.getStatus({}); const parsed = parseResult(r); - expect(parsed.latest_playwright_layer2_status).toBe('failed'); + expect(parsed.latest_e2e_status).toBe('failed'); }); }); @@ -383,7 +383,7 @@ describe('integration: parser robustness', () => { { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-trailing-prose.md' }, { match: matchers.evalStage('test_critic'), fixture: 'critic-passing-trailing-prose.md' }, - { match: matchers.evalStage('playwright_eval'), fixture: 'playwright-layer2-completed.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, ]); const parent = store.createJob({ @@ -414,10 +414,10 @@ describe('integration: parser robustness', () => { eval_stage: 'test_critic', prompt: 'Test Critic review', }); - const play = manager.startJob('playwright_eval', { + const play = manager.startJob('interaction_e2e', { source_job_id: childId, - eval_stage: 'playwright_eval', - prompt: 'Playwright eval', + eval_stage: 'interaction_e2e', + prompt: 'Interaction E2E eval', }); await manager.waitForJob(code.id, null, 5_000); @@ -432,7 +432,7 @@ describe('integration: parser robustness', () => { { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, { match: matchers.evalStage('code_critic'), fixture: 'critic-truncated.txt' }, { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, - { match: matchers.evalStage('playwright_eval'), fixture: 'playwright-layer2-completed.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, ]); const parent = store.createJob({ @@ -463,10 +463,10 @@ describe('integration: parser robustness', () => { eval_stage: 'test_critic', prompt: 'Test Critic review', }); - const play = manager.startJob('playwright_eval', { + const play = manager.startJob('interaction_e2e', { source_job_id: childId, - eval_stage: 'playwright_eval', - prompt: 'Playwright eval', + eval_stage: 'interaction_e2e', + prompt: 'Interaction E2E eval', }); await manager.waitForJob(code.id, null, 5_000); @@ -483,7 +483,7 @@ describe('integration: parser robustness', () => { { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json' }, { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, - { match: matchers.evalStage('playwright_eval'), fixture: 'playwright-layer2-completed.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, ]); const parent = store.createJob({ @@ -514,10 +514,10 @@ describe('integration: parser robustness', () => { eval_stage: 'test_critic', prompt: 'Test Critic review', }); - const play = manager.startJob('playwright_eval', { + const play = manager.startJob('interaction_e2e', { source_job_id: childId, - eval_stage: 'playwright_eval', - prompt: 'Playwright eval', + eval_stage: 'interaction_e2e', + prompt: 'Interaction E2E eval', }); await manager.waitForJob(code.id, null, 5_000); diff --git a/src/core/job-manager.test.ts b/src/core/job-manager.test.ts index 84cac32..67a030a 100644 --- a/src/core/job-manager.test.ts +++ b/src/core/job-manager.test.ts @@ -167,7 +167,7 @@ describe('JobManager', () => { const registry = new AdapterRegistry({ byJobType: { codex: { - playwright_eval: ['--dangerously-bypass-approvals-and-sandbox'], + interaction_e2e: ['--dangerously-bypass-approvals-and-sandbox'], }, }, }); @@ -180,7 +180,7 @@ describe('JobManager', () => { defaultJobTimeoutMs: 2_000, }); - const job = manager.startJob('playwright_eval', { prompt: 'verify browser' }); + const job = manager.startJob('interaction_e2e', { prompt: 'verify browser' }); await manager.waitForJob(job.id, null, 5_000); expect(codex.lastInvocation?.extraArgs).toEqual(['--dangerously-bypass-approvals-and-sandbox']); diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 4f3d33f..90f7c8d 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -640,9 +640,10 @@ export class JobManager { const configuredTimeout = parseTimeoutMinutes(this.stateStore.getConfig('timeout_minutes')); const timeoutMs = configuredTimeout ? configuredTimeout * 60 * 1000 : undefined; - // Browser/visual e2e jobs need access to Playwright MCP tools for exploratory testing. - // CLI/API/library jobs with this legacy job type simply won't use them. - const allowedTools = job.type === 'playwright_eval' + // The interaction-e2e critic gets the Playwright MCP tool allowlist so it can + // drive a browser when the surface is web_ui/visual. CLI/API/library surfaces + // simply don't use them. + const allowedTools = job.type === 'interaction_e2e' ? [ 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'mcp__playwright__browser_navigate', diff --git a/src/core/migrations.test.ts b/src/core/migrations.test.ts index f18d5b8..14ae14c 100644 --- a/src/core/migrations.test.ts +++ b/src/core/migrations.test.ts @@ -201,4 +201,47 @@ describe('DB migrations', () => { }); expect(store.getJob('child-job')?.params.remediation_for).toBeUndefined(); }); + + it('renames the legacy playwright_eval identifier to interaction_e2e in upgrade mode', () => { + const projectPath = createTempDir(); + const dbPath = path.join(projectPath, '.tenet', '.state', 'tenet.db'); + + // Bootstrap a real current-shape DB, seed legacy rows, then regress the + // schema stamp to 1 so the v2 migration runs on reopen. + const bootstrap = new StateStore(projectPath); + stores.push(bootstrap); + bootstrap.close(); + + const seed = new Database(dbPath); + const now = Date.now(); + seed.prepare( + 'INSERT INTO jobs (id, type, status, params, agent_name, created_at, retry_count, max_retries, parent_job_id) VALUES (?,?,?,?,?,?,?,?,?)', + ).run( + 'e2e-job', + 'playwright_eval', + 'completed', + JSON.stringify({ name: 'e2e', expected_eval_stages: ['code_critic', 'test_critic', 'playwright_eval'] }), + 'claude', + now, + 0, + 3, + null, + ); + seed.prepare('INSERT INTO config (key, value) VALUES (?, ?)').run('agent_override_playwright_eval', 'claude'); + seed.prepare('INSERT INTO config (key, value) VALUES (?, ?)').run('codex_args_playwright_eval', '--foo'); + seed.prepare('UPDATE config SET value = ? WHERE key = ?').run('1', DB_SCHEMA_VERSION_KEY); + seed.close(); + + const migrated = new StateStore(projectPath, { migrate: true }); + stores.push(migrated); + + expect(migrated.getConfig(DB_SCHEMA_VERSION_KEY)).toBe(String(CURRENT_DB_SCHEMA_VERSION)); + const job = migrated.getJob('e2e-job'); + expect(job?.type).toBe('interaction_e2e'); + expect(job?.params.expected_eval_stages).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); + expect(migrated.getConfig('agent_override_interaction_e2e')).toBe('claude'); + expect(migrated.getConfig('agent_override_playwright_eval')).toBeNull(); + expect(migrated.getConfig('codex_args_interaction_e2e')).toBe('--foo'); + expect(migrated.getConfig('codex_args_playwright_eval')).toBeNull(); + }); }); diff --git a/src/core/migrations.ts b/src/core/migrations.ts index 2f411a9..cabbf54 100644 --- a/src/core/migrations.ts +++ b/src/core/migrations.ts @@ -1,7 +1,7 @@ import type Database from 'better-sqlite3'; export const DB_SCHEMA_VERSION_KEY = 'db_schema_version'; -export const CURRENT_DB_SCHEMA_VERSION = 1; +export const CURRENT_DB_SCHEMA_VERSION = 2; export class UpgradeRequiredError extends Error { constructor( @@ -78,10 +78,61 @@ const migrateLegacyRemediationState = (db: Database.Database): void => { ).run(); }; +/** + * Rename the interaction-e2e critic's stored identifier from the legacy + * `playwright_eval` to `interaction_e2e`. The critic is surface-agnostic + * (browser via Playwright MCP, CLI/API/library via shell), so the old name was + * misleading. This rewrites the three places the literal persisted: job rows, + * `expected_eval_stages` arrays embedded in job params, and the job-type-scoped + * config keys. All statements are no-ops where the legacy term is absent + * (fresh projects, non-e2e jobs), so this is safe to run on any DB. + */ +const renamePlaywrightEvalToInteractionE2e = (db: Database.Database): void => { + db.prepare("UPDATE jobs SET type = 'interaction_e2e' WHERE type = 'playwright_eval'").run(); + + const rows = db + .prepare("SELECT id, params FROM jobs WHERE params LIKE '%playwright_eval%'") + .all() as Array<{ id: string; params: string }>; + const updateParams = db.prepare('UPDATE jobs SET params = ? WHERE id = ?'); + + for (const row of rows) { + let parsed: Record; + try { + parsed = JSON.parse(row.params) as Record; + } catch { + continue; + } + + const stages = parsed.expected_eval_stages; + if (Array.isArray(stages)) { + parsed.expected_eval_stages = stages.map((stage) => + stage === 'playwright_eval' ? 'interaction_e2e' : stage, + ); + updateParams.run(JSON.stringify(parsed), row.id); + } + } + + const configRenames: ReadonlyArray = [ + ['agent_override_playwright_eval', 'agent_override_interaction_e2e'], + ['claude_args_playwright_eval', 'claude_args_interaction_e2e'], + ['opencode_args_playwright_eval', 'opencode_args_interaction_e2e'], + ['codex_args_playwright_eval', 'codex_args_interaction_e2e'], + ]; + const renameConfig = db.prepare('UPDATE config SET key = ? WHERE key = ?'); + for (const [from, to] of configRenames) { + renameConfig.run(to, from); + } +}; + export const MIGRATIONS: readonly Migration[] = [ { version: 1, name: 'baseline_legacy_db_and_blocking_finding_rename', up: migrateLegacyRemediationState, }, + { + version: 2, + name: 'rename_playwright_eval_to_interaction_e2e', + up: renamePlaywrightEvalToInteractionE2e, + }, ]; diff --git a/src/mcp/tools/tenet-get-status.ts b/src/mcp/tools/tenet-get-status.ts index 176fb70..82752e8 100644 --- a/src/mcp/tools/tenet-get-status.ts +++ b/src/mcp/tools/tenet-get-status.ts @@ -46,11 +46,11 @@ const extractJsonObject = (raw: string | undefined): Record | u return undefined; }; -const findLatestPlaywrightLayer2Status = (stateStore: StateStore): string | undefined => { - // Scan completed playwright_eval jobs in reverse-chronological order +const findLatestE2eStatus = (stateStore: StateStore): string | undefined => { + // Scan completed interaction_e2e critic jobs in reverse-chronological order const completed = stateStore .getJobsByStatus('completed' as Job['status']) - .filter((j) => j.type === 'playwright_eval') + .filter((j) => j.type === 'interaction_e2e') .sort((a, b) => (b.completedAt ?? 0) - (a.completedAt ?? 0)); if (completed.length === 0) { @@ -70,8 +70,8 @@ export const registerTenetGetStatusTool = (registerTool: RegisterTool, stateStor 'tenet_get_status', { description: - 'Get high-level project summary status. Also surfaces the most recent Playwright eval layer2_status ' + - 'so callers can distinguish "fully verified" from "passed with Layer 2 skipped".', + 'Get high-level project summary status. Also surfaces the most recent interaction-e2e critic status ' + + 'so callers can distinguish "fully verified" from "passed with browser exploration skipped/applicable".', inputSchema: z.object({}), }, async () => { @@ -79,7 +79,7 @@ export const registerTenetGetStatusTool = (registerTool: RegisterTool, stateStor const total = stateStore.getTotalCount(); const blocked = stateStore.getBlockedJobs().length; const running = stateStore.getJobsByStatus('running'); - const latestLayer2 = findLatestPlaywrightLayer2Status(stateStore); + const latestLayer2 = findLatestE2eStatus(stateStore); const status: ProjectStatus = { project_path: stateStore.projectPath, @@ -92,7 +92,7 @@ export const registerTenetGetStatusTool = (registerTool: RegisterTool, stateStor last_activity: new Date().toISOString(), }; - const extras = latestLayer2 ? { latest_playwright_layer2_status: latestLayer2 } : {}; + const extras = latestLayer2 ? { latest_e2e_status: latestLayer2 } : {}; const updateInfo = await checkForUpdate(); if (updateInfo?.update_available) { diff --git a/src/mcp/tools/tenet-report-blocking-finding.test.ts b/src/mcp/tools/tenet-report-blocking-finding.test.ts index efeb2ee..5a975af 100644 --- a/src/mcp/tools/tenet-report-blocking-finding.test.ts +++ b/src/mcp/tools/tenet-report-blocking-finding.test.ts @@ -83,7 +83,7 @@ const createHarness = (): { store: StateStore; manager: JobManager; handler: Han store.setConfig('agent_override_dev', 'mock-adapter'); store.setConfig('agent_override_eval', 'mock-adapter'); store.setConfig('agent_override_critic_eval', 'mock-adapter'); - store.setConfig('agent_override_playwright_eval', 'mock-adapter'); + store.setConfig('agent_override_interaction_e2e', 'mock-adapter'); const registry = new AdapterRegistry(); registry.register(new PassingAdapter()); @@ -116,7 +116,7 @@ const createHarnessWithAdapter = ( store.setConfig('agent_override_dev', adapter.name); store.setConfig('agent_override_eval', adapter.name); store.setConfig('agent_override_critic_eval', adapter.name); - store.setConfig('agent_override_playwright_eval', adapter.name); + store.setConfig('agent_override_interaction_e2e', adapter.name); const registry = new AdapterRegistry(); registry.register(adapter); @@ -278,9 +278,9 @@ describe('tenet_report_blocking_finding', () => { eval_stage: 'test_critic', prompt: 'test critique', }); - const playwright = manager.startJob('playwright_eval', { + const playwright = manager.startJob('interaction_e2e', { source_job_id: childId, - eval_stage: 'playwright_eval', + eval_stage: 'interaction_e2e', prompt: 'playwright', }); diff --git a/src/mcp/tools/tenet-start-eval.test.ts b/src/mcp/tools/tenet-start-eval.test.ts index 607df3f..d4b2c24 100644 --- a/src/mcp/tools/tenet-start-eval.test.ts +++ b/src/mcp/tools/tenet-start-eval.test.ts @@ -42,7 +42,7 @@ const createHarness = (): { stores.push(store); store.setConfig('agent_override_eval', 'mock-adapter'); store.setConfig('agent_override_critic_eval', 'mock-adapter'); - store.setConfig('agent_override_playwright_eval', 'mock-adapter'); + store.setConfig('agent_override_interaction_e2e', 'mock-adapter'); const registry = new AdapterRegistry(); registry.register(new MockAdapter('mock-adapter')); @@ -148,7 +148,7 @@ describe('tenet_start_eval eval mode resolution', () => { const codeCriticId = jobId(parsed, 'code_critic'); const testCriticId = jobId(parsed, 'test_critic'); - const playwrightId = jobId(parsed, 'playwright_eval'); + const playwrightId = jobId(parsed, 'interaction_e2e'); const codeCritic = store.getJob(codeCriticId); const testCritic = store.getJob(testCriticId); @@ -178,7 +178,7 @@ describe('tenet_start_eval eval mode resolution', () => { const codeCritic = store.getJob(jobId(parsed, 'code_critic')); const testCritic = store.getJob(jobId(parsed, 'test_critic')); - const playwright = store.getJob(jobId(parsed, 'playwright_eval')); + const playwright = store.getJob(jobId(parsed, 'interaction_e2e')); expect(codeCritic?.status).toBe('running'); expect(testCritic?.status).toBe('running'); @@ -212,7 +212,7 @@ describe('tenet_start_eval eval mode resolution', () => { const codeCriticId = jobId(parsed, 'code_critic'); const testCriticId = jobId(parsed, 'test_critic'); - const playwrightId = jobId(parsed, 'playwright_eval'); + const playwrightId = jobId(parsed, 'interaction_e2e'); await waitForAll(manager, parsed); @@ -265,7 +265,7 @@ describe('tenet_start_eval eval mode resolution', () => { const result = await handler({ job_id: sourceId, output: { summary: 'ok' } }); const parsed = parseResult(result); - const playwright = store.getJob(jobId(parsed, 'playwright_eval')); + const playwright = store.getJob(jobId(parsed, 'interaction_e2e')); const prompt = playwright?.params.prompt as string; expect(prompt).toContain('Use exact artifact_paths when provided'); @@ -286,7 +286,7 @@ describe('tenet_start_eval eval mode resolution', () => { expect(prompt).toContain('SEMANTICS, not just non-5xx'); // API exploratory rigor // Output contract is unchanged (back-compat for the gate + status surfacing). expect(prompt).toContain( - 'End with: {"passed": true/false, "stage": "playwright_eval", "surface": "web_ui|visual|cli|api|library|none", "layer2_status": "completed|skipped_no_mcp|not_applicable|failed", "scripted_results": "...", "exploratory_findings": ["..."], "screenshots": ["..."]}', + 'End with: {"passed": true/false, "stage": "interaction_e2e", "surface": "web_ui|visual|cli|api|library|none", "layer2_status": "completed|skipped_no_mcp|not_applicable|failed", "scripted_results": "...", "exploratory_findings": ["..."], "screenshots": ["..."]}', ); await waitForAll(manager, parsed); @@ -302,12 +302,12 @@ describe('tenet_start_eval configurable critic roster', () => { const parsed = parseResult(result); expect(parsed.critics_dispatched).toBe(3); - expect(jobRoles(parsed)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(jobRoles(parsed)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); // expected_eval_stages is stamped on every dispatched critic expect(store.getJob(jobId(parsed, 'code_critic'))?.params.expected_eval_stages).toEqual([ 'code_critic', 'test_critic', - 'playwright_eval', + 'interaction_e2e', ]); await waitForAll(manager, parsed); @@ -323,7 +323,7 @@ describe('tenet_start_eval configurable critic roster', () => { expect(parsed.critics_dispatched).toBe(3); expect(parsed.roster_warning).toEqual(expect.stringContaining('Could not parse')); - expect(jobRoles(parsed)).toEqual(['code_critic', 'test_critic', 'playwright_eval']); + expect(jobRoles(parsed)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); await waitForAll(manager, parsed); }); @@ -335,7 +335,7 @@ describe('tenet_start_eval configurable critic roster', () => { critics: [ { id: 'code_critic', builtin: true, enabled: true }, { id: 'test_critic', builtin: true, enabled: true }, - { id: 'playwright_eval', builtin: true, enabled: false }, + { id: 'interaction_e2e', builtin: true, enabled: false }, ], }); const sourceId = createSourceJob(store, 'oauth'); @@ -365,7 +365,7 @@ describe('tenet_start_eval configurable critic roster', () => { critics: [ { id: 'code_critic', builtin: true, enabled: true }, { id: 'test_critic', builtin: true, enabled: true }, - { id: 'playwright_eval', builtin: true, enabled: true }, + { id: 'interaction_e2e', builtin: true, enabled: true }, { id: 'security', builtin: false, @@ -396,7 +396,7 @@ describe('tenet_start_eval configurable critic roster', () => { expect(securityJob?.params.expected_eval_stages).toEqual([ 'code_critic', 'test_critic', - 'playwright_eval', + 'interaction_e2e', 'security_critic', ]); @@ -410,7 +410,7 @@ describe('tenet_start_eval configurable critic roster', () => { critics: [ { id: 'code_critic', builtin: true, enabled: true }, { id: 'test_critic', builtin: true, enabled: true }, - { id: 'playwright_eval', builtin: true, enabled: true }, + { id: 'interaction_e2e', builtin: true, enabled: true }, { id: 'security', builtin: false, @@ -441,7 +441,7 @@ describe('tenet_start_eval configurable critic roster', () => { critics: [ { id: 'code_critic', builtin: true, enabled: true }, { id: 'test_critic', builtin: true, enabled: true }, - { id: 'playwright_eval', builtin: true, enabled: true }, + { id: 'interaction_e2e', builtin: true, enabled: true }, { id: 'extra', builtin: false, @@ -460,7 +460,7 @@ describe('tenet_start_eval configurable critic roster', () => { expect(parsed.execution_mode).toBe('sequential'); const jobs = parsed.jobs as Array>; - expect(jobs.map((j) => j.role)).toEqual(['code_critic', 'test_critic', 'playwright_eval', 'extra_critic']); + expect(jobs.map((j) => j.role)).toEqual(['code_critic', 'test_critic', 'interaction_e2e', 'extra_critic']); expect(jobs[1].parent_job_id).toBe(jobs[0].job_id); expect(jobs[2].parent_job_id).toBe(jobs[1].job_id); expect(jobs[3].parent_job_id).toBe(jobs[2].job_id); diff --git a/src/mcp/tools/tenet-start-eval.ts b/src/mcp/tools/tenet-start-eval.ts index 798301a..c45a407 100644 --- a/src/mcp/tools/tenet-start-eval.ts +++ b/src/mcp/tools/tenet-start-eval.ts @@ -88,7 +88,7 @@ const PLAYWRIGHT_EVAL_PREAMBLE = [ 'You are the INTERACTION E2E worker. You do NOT review code.', 'You verify the project ACTUALLY WORKS by exercising its public user-facing surface the way a real user would — with exploratory, agent-driven probing, not just the scripted checks the author wrote.', '', - 'The job type stays `playwright_eval` for compatibility, but "Playwright" is only the browser tool. The surface may be a browser UI, a TUI, a CLI, an API, a library, or nothing. You classify first and apply the SAME exploratory rigor to whichever it is — never skip a non-browser surface, and never force a browser onto one.', + 'The job type is `interaction_e2e`. "Playwright" is only the browser tool — the surface may be a browser UI, a TUI, a CLI, an API, a library, or nothing. You classify first and apply the SAME exploratory rigor to whichever it is — never skip a non-browser surface, and never force a browser onto one.', '', '### First: classify the surface', 'Read the source job scope above. Use exact artifact_paths when provided, the run-local spec/harness/scenarios, and `.tenet/project/testing.md` / `.tenet/project/design.md` as the authoritative context.', @@ -180,7 +180,7 @@ const PLAYWRIGHT_EVAL_PREAMBLE = [ '', 'The final status summary will show layer2_status directly — do not treat "passed" as equivalent to "fully verified". For non-browser surfaces "not_applicable" is honest and expected, not a gap; report the real e2e result in exploratory_findings.', '', - 'End with: {"passed": true/false, "stage": "playwright_eval", "surface": "web_ui|visual|cli|api|library|none", "layer2_status": "completed|skipped_no_mcp|not_applicable|failed", "scripted_results": "...", "exploratory_findings": ["..."], "screenshots": ["..."]}', + 'End with: {"passed": true/false, "stage": "interaction_e2e", "surface": "web_ui|visual|cli|api|library|none", "layer2_status": "completed|skipped_no_mcp|not_applicable|failed", "scripted_results": "...", "exploratory_findings": ["..."], "screenshots": ["..."]}', '', ].join('\n'); @@ -277,7 +277,7 @@ const buildCriticDispatch = ( evalStage: critic.stage, prompt: jobScope + TEST_CRITIC_PREAMBLE + '## Test Files and Spec\n\n' + outputStr, }; - case 'playwright_eval': + case 'interaction_e2e': return { jobType: critic.jobType, evalStage: critic.stage, diff --git a/src/mcp/tools/tenet-start-job.test.ts b/src/mcp/tools/tenet-start-job.test.ts index 7fd7fca..b8c0e92 100644 --- a/src/mcp/tools/tenet-start-job.test.ts +++ b/src/mcp/tools/tenet-start-job.test.ts @@ -25,7 +25,7 @@ class SlowAdapter implements AgentAdapter { type Handler = (args: { job_id?: string; - job_type?: 'dev' | 'eval' | 'critic_eval' | 'playwright_eval' | 'mechanical_eval' | 'compile_context' | 'health_check'; + job_type?: 'dev' | 'eval' | 'critic_eval' | 'interaction_e2e' | 'mechanical_eval' | 'compile_context' | 'health_check'; params?: Record; }) => Promise; diff --git a/src/mcp/tools/utils.ts b/src/mcp/tools/utils.ts index 02cd8a4..b4ca11a 100644 --- a/src/mcp/tools/utils.ts +++ b/src/mcp/tools/utils.ts @@ -16,7 +16,7 @@ export const asToolError = (error: unknown): CallToolResult => { }; }; -export const jobTypeSchema = z.enum(['dev', 'eval', 'critic_eval', 'playwright_eval', 'mechanical_eval', 'compile_context', 'health_check']); +export const jobTypeSchema = z.enum(['dev', 'eval', 'critic_eval', 'interaction_e2e', 'mechanical_eval', 'compile_context', 'health_check']); export const parseJobType = (value: string): JobType => { const parsed = jobTypeSchema.safeParse(value); diff --git a/src/types/index.ts b/src/types/index.ts index ac63f77..a562e6e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,7 +7,7 @@ export type JobStatus = | 'blocked' | 'blocked_on_finding'; -export type JobType = 'dev' | 'eval' | 'critic_eval' | 'playwright_eval' | 'mechanical_eval' | 'integration_test' | 'compile_context' | 'health_check'; +export type JobType = 'dev' | 'eval' | 'critic_eval' | 'interaction_e2e' | 'mechanical_eval' | 'integration_test' | 'compile_context' | 'health_check'; export interface Job { id: string; diff --git a/tests/fixtures/fake-agents/playwright-layer2-completed.json b/tests/fixtures/fake-agents/playwright-layer2-completed.json index a361dbb..f7d5039 100644 --- a/tests/fixtures/fake-agents/playwright-layer2-completed.json +++ b/tests/fixtures/fake-agents/playwright-layer2-completed.json @@ -1 +1 @@ -{"passed": true, "stage": "playwright_eval", "layer2_status": "completed", "scripted_results": "42 passed, 0 failed", "exploratory_findings": ["Login flow verified end-to-end via Playwright MCP: entered creds, redirected to /dashboard, user avatar visible.", "Navigation bar links all reachable from every page."], "screenshots": ["screenshot-login.png", "screenshot-dashboard.png"]} +{"passed": true, "stage": "interaction_e2e", "layer2_status": "completed", "scripted_results": "42 passed, 0 failed", "exploratory_findings": ["Login flow verified end-to-end via Playwright MCP: entered creds, redirected to /dashboard, user avatar visible.", "Navigation bar links all reachable from every page."], "screenshots": ["screenshot-login.png", "screenshot-dashboard.png"]} diff --git a/tests/fixtures/fake-agents/playwright-layer2-failed.json b/tests/fixtures/fake-agents/playwright-layer2-failed.json index c2caf92..f322a0a 100644 --- a/tests/fixtures/fake-agents/playwright-layer2-failed.json +++ b/tests/fixtures/fake-agents/playwright-layer2-failed.json @@ -1 +1 @@ -{"passed": false, "stage": "playwright_eval", "layer2_status": "failed", "scripted_results": "42 passed, 0 failed", "exploratory_findings": ["Dev server failed to start: port 3000 already in use. Layer 2 could not run."], "screenshots": []} +{"passed": false, "stage": "interaction_e2e", "layer2_status": "failed", "scripted_results": "42 passed, 0 failed", "exploratory_findings": ["Dev server failed to start: port 3000 already in use. Layer 2 could not run."], "screenshots": []} diff --git a/tests/fixtures/fake-agents/playwright-layer2-skipped.json b/tests/fixtures/fake-agents/playwright-layer2-skipped.json index d3b9c82..7bbaa2d 100644 --- a/tests/fixtures/fake-agents/playwright-layer2-skipped.json +++ b/tests/fixtures/fake-agents/playwright-layer2-skipped.json @@ -1 +1 @@ -{"passed": true, "stage": "playwright_eval", "layer2_status": "skipped_no_mcp", "scripted_results": "42 passed, 0 failed", "exploratory_findings": [], "screenshots": []} +{"passed": true, "stage": "interaction_e2e", "layer2_status": "skipped_no_mcp", "scripted_results": "42 passed, 0 failed", "exploratory_findings": [], "screenshots": []} From ec82167e905cdc73a528a8c861dcce4d3a93bfa1 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 25 Jun 2026 14:19:47 +0900 Subject: [PATCH 17/87] chore: bump to 26.6.6 Co-Authored-By: Claude --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5456dee..c2885b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.6.5", + "version": "26.6.6", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From 0ef0a1feb2ca4c1ee52cacc7ba1fe934d363acf6 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 25 Jun 2026 15:04:08 +0900 Subject: [PATCH 18/87] fix(init): rewrite legacy playwright_eval id in critics.json on upgrade The rename (PR #10) migrated the DB but not a project's .tenet/critics.json file, so upgrading users kept seeing `playwright_eval` in the file they open - even though the roster resolver aliased it correctly. The alias makes it functionally fine, but the stale name is exactly the "wait, is this broken?" trap a user hits when they inspect the file. Add a targeted, idempotent rewrite on `tenet init --upgrade`: replace `playwright_eval` -> `interaction_e2e` in .tenet/critics.json (covers the built-in id and any custom critic's job_type), preserving order, enabled flags, customs, and formatting. The roster alias stays as defense-in-depth for hand-edited or never-upgraded files. Co-Authored-By: Claude --- src/cli/init.test.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++ src/cli/init.ts | 33 ++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/cli/init.test.ts b/src/cli/init.test.ts index cf695f5..53cdc9c 100644 --- a/src/cli/init.test.ts +++ b/src/cli/init.test.ts @@ -315,6 +315,54 @@ describe('initProject', () => { expect(fs.existsSync(path.join(tenetRoot, 'state-snapshot', 'README.md'))).toBe(true); }); + it('rewrites the legacy playwright_eval id in .tenet/critics.json on upgrade', () => { + const projectPath = createTempDir(); + const tenetRoot = path.join(projectPath, '.tenet'); + fs.mkdirSync(tenetRoot, { recursive: true }); + fs.writeFileSync( + path.join(tenetRoot, 'critics.json'), + JSON.stringify( + { + version: 1, + critics: [ + { id: 'code_critic', builtin: true, enabled: true }, + { id: 'playwright_eval', builtin: true, enabled: true }, + { id: 'my_custom', job_type: 'playwright_eval', prompt_file: '.tenet/critics/x.md' }, + ], + }, + null, + 2, + ), + 'utf8', + ); + + initProject(projectPath, { upgrade: true }); + + const after = fs.readFileSync(path.join(tenetRoot, 'critics.json'), 'utf8'); + expect(after).not.toContain('playwright_eval'); + expect(after).toContain('"id": "interaction_e2e"'); + expect(after).toContain('"job_type": "interaction_e2e"'); + // Custom critic + enabled flags + structure preserved. + expect(after).toContain('my_custom'); + expect(after).toContain('"enabled": true'); + }); + + it('leaves a clean critics.json untouched on upgrade (idempotent)', () => { + const projectPath = createTempDir(); + const tenetRoot = path.join(projectPath, '.tenet'); + fs.mkdirSync(tenetRoot, { recursive: true }); + const clean = JSON.stringify( + { version: 1, critics: [{ id: 'interaction_e2e', builtin: true, enabled: true }] }, + null, + 2, + ); + fs.writeFileSync(path.join(tenetRoot, 'critics.json'), clean, 'utf8'); + + initProject(projectPath, { upgrade: true }); + + expect(fs.readFileSync(path.join(tenetRoot, 'critics.json'), 'utf8')).toBe(clean); + }); + it('creates lifecycle docs during upgrade without overwriting existing project docs', () => { const projectPath = createTempDir(); const tenetRoot = path.join(projectPath, '.tenet'); diff --git a/src/cli/init.ts b/src/cli/init.ts index 45650d8..7033c5d 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -521,6 +521,7 @@ function upgradeProject(projectPath: string, options?: InitOptions): void { stateStore.close(); migrateLegacyDocuments(tenetRoot, { enabled: options?.migrateLegacy === true }); + migrateLegacyCriticRosterId(projectPath); // Overwrite skill files (these are tenet-owned, not user-edited) copySkillDirs(projectPath); @@ -537,6 +538,38 @@ function upgradeProject(projectPath: string, options?: InitOptions): void { // status/, state-snapshot/, and .state/config.json are never overwritten. } +/** + * Rewrite the legacy `playwright_eval` built-in id (and any custom critic's + * `job_type: "playwright_eval"`) to `interaction_e2e` in a project's + * `.tenet/critics.json`. Run during `tenet init --upgrade` so the file a user + * opens matches the post-rename identifier. + * + * The roster resolver ALSO aliases the old id as a safety net, so this is + * cosmetic — but it removes the "wait, is this broken?" moment when a user + * sees the old name still sitting in their file. Targeted string replace: only + * the legacy token changes (order, enabled flags, custom critics, and formatting + * are preserved). Idempotent — a no-op once the file is clean. + */ +const migrateLegacyCriticRosterId = (projectPath: string): void => { + const rosterPath = path.join(projectPath, '.tenet', 'critics.json'); + if (!fs.existsSync(rosterPath)) { + return; + } + let raw: string; + try { + raw = fs.readFileSync(rosterPath, 'utf8'); + } catch { + return; + } + if (!raw.includes('playwright_eval')) { + return; + } + // `playwright_eval` only appears in a critics.json as a built-in id or a + // custom critic's job_type — both should become interaction_e2e. + fs.writeFileSync(rosterPath, raw.replace(/playwright_eval/g, 'interaction_e2e'), 'utf8'); + console.log('Updated .tenet/critics.json: renamed playwright_eval → interaction_e2e.'); +}; + const backupStateDb = (tenetRoot: string): string | null => { const stateDir = path.join(tenetRoot, '.state'); const dbPath = path.join(stateDir, 'tenet.db'); From 4b304b5d23d5a9aee8991990725f1d94a27e81cf Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Sat, 27 Jun 2026 08:01:20 +0900 Subject: [PATCH 19/87] fix(loop): reliable doctrine drift, job visibility, critic context-limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #12 doctrine-drift → proposals: the dev-job preamble was a vague paraphrase ("write to journal or final report") that dropped the drift-note contract the run-end review filters on, so drift notes landed in design.md and doctrine-proposals.md stayed empty. The preamble now carries the actual contract (tenet_update_knowledge type=journal, title="doctrine drift: ", structured findings) plus a `### doctrine-drift:` marker for run docs; the run-end review scans journal + run docs (marker + freeform), deduped by doctrine_file. #17 stale-job blindness: tenet_get_status returned only counts + the one running job's id, so the agent could not see or cancel stale pending jobs (tenet_cancel_job needs an id it could not obtain). Add view="queue" (+ optional include_blocked) returning pending/running jobs with id/type/status/name/age_ms/stale; stale derives only from the existing heartbeat bar (pending has no staleness bar — judged by age_ms). Default call unchanged; no new tool, no DB change. #20 critic context-limit false-pass: a critic exit with no valid rubric JSON (context-limit/error) was not explicitly treated as not-passed. The orchestrator now retries as-is, splits into reduced-scope critics after 2 consecutive context-limits, and never accepts a context-limited result as a pass. The core resume-gate already refused to unblock on unparseable output; a regression test locks that invariant. Co-Authored-By: Claude --- skills/tenet/phases/05-execution-loop.md | 15 +- src/core/integration.test.ts | 2 +- src/core/job-manager.test.ts | 31 +++ src/core/job-manager.ts | 15 +- src/mcp/tools/index.ts | 2 +- src/mcp/tools/tenet-get-status.test.ts | 176 ++++++++++++++++++ src/mcp/tools/tenet-get-status.ts | 102 +++++++++- .../tenet-report-blocking-finding.test.ts | 63 +++++++ 8 files changed, 390 insertions(+), 16 deletions(-) create mode 100644 src/mcp/tools/tenet-get-status.test.ts diff --git a/skills/tenet/phases/05-execution-loop.md b/skills/tenet/phases/05-execution-loop.md index bc7772c..35df0bb 100644 --- a/skills/tenet/phases/05-execution-loop.md +++ b/skills/tenet/phases/05-execution-loop.md @@ -34,11 +34,17 @@ Execute this sequence for every job cycle: - If `is_terminal` is true: proceed to step 7. 7. **Get Result**: `tenet_job_result(job_id="...")` Retrieve the final output and execution metadata. If the project is a git repository, read the worker's final output for the commit SHA. If the worker produced dirty changes but did not commit, make a best-effort fallback commit for the same job before evaluation. If git is unavailable or the fallback commit fails, write a journal note with the reason and continue. + **Context-limit / error exit (worker):** if the worker's result has no real deliverable — a context-limit or error string (e.g. "prompt is too long", "maximum context length exceeded"), an empty/truncated body, or a hard failure — do NOT proceed to evaluation as if it succeeded. Treat it as a failed run: retry as-is with `tenet_retry_job` so it re-runs with fresh context (apply backoff between attempts). If the same worker context-limits **twice in a row**, the job is too large for one pass — split it into smaller sub-jobs rather than retrying identical a third time. Never accept a context-limited worker result as a done deliverable. 8. **Start Evaluation**: `tenet_start_eval(job_id="", output={...}, feature="")` Dispatches the output to the evaluation pipeline and returns eval job IDs plus `execution_mode`. 9. **Background Wait for Eval**: Same pattern as step 6. If `execution_mode` is sequential, later eval jobs may remain pending until parents complete; keep waiting on the returned IDs until all are terminal. 10. **Get Eval Results**: `tenet_job_result(job_id="")` - Retrieve every returned eval result. ALL must pass. + Retrieve every returned eval result. ALL must pass. A critic/eval result passes **only** when it returns a valid rubric JSON with `passed: true`. A result with **no valid rubric JSON — a context-limit / error exit** (e.g. "prompt is too long", "maximum context length exceeded", a raw error string, or an empty/truncated body) — **is NOT a pass**, even though the job reached a terminal state. Do not treat a context-limited critic as "passed with partial result"; the resume gate already refuses to unblock on unparseable output, and you must do the same. + + **On a context-limit / no-rubric critic exit:** + - **Retry as-is first.** `tenet_retry_job(job_id)` the affected critic so it re-runs with fresh context. This counts against the job's normal retry budget (default unlimited); apply backoff between attempts. + - **Split after 2 consecutive context-limits.** If the same critic context-limits twice in a row, the scope is too large for one pass — do not retry identical a third time. Split the critic's scope into multiple reduced-scope critic jobs (divide the files/diff into smaller batches, each its own eval job) and require all of those to pass. For a worker, redispatch as smaller sub-jobs. + - **Never accept a context-limited result as a pass** to move on. If retries and splits keep failing, treat it as a failed eval: `tenet_retry_job` the source, or `tenet_report_blocking_finding` if a specific cause is suspected. 11. **Update Knowledge or Retry**: `tenet_update_knowledge(...)` on success; `tenet_retry_job(...)` or `tenet_report_blocking_finding(...)` on failure, as described below. Persist any architectural discoveries or critical findings. 12. **Trust Status Sync**: @@ -51,7 +57,10 @@ Steps 1–13 are the **per-job** cycle. The loop exits when `tenet_continue()` r `.tenet/project/**` doctrine is read at the start of every run (`tenet_compile_context`) but never re-scanned, so once it drifts, every subsequent run starts on stale context. This step keeps it from silently rotting — and it never blocks the loop. -1. **Collect drift notes.** Read the run's journal (`.tenet/runs//journal/`) for any **doctrine-drift notes** written during the run (see *Project Doctrine Write Boundary* above). +1. **Collect drift notes.** Drift notes do not always land in the journal, so scan every place a dev job might have written one: + - **Run journal** (`.tenet/runs//journal/`): entries titled `doctrine drift: ` — the exact contract dev jobs are told to use. + - **Run docs** (`.tenet/runs//**`, e.g. `design.md`, `spec.md`): lines carrying the `### doctrine-drift: ` marker, **plus** any freeform prose describing doctrine as stale/wrong/missing even without the marker. LLMs do not always use the marker they were given — read for the *intent*, not just the token. + Match each note to its `doctrine_file`, then **dedupe by `doctrine_file`**: if the same drift surfaces via the journal, a marker, and freeform prose, that is ONE proposal, not three. See *Project Doctrine Write Boundary* below for the note shape. 2. **If there are none, stop.** Doctrine is current — write no proposal, no overhead. Continue to the final report. 3. **Consolidate.** For each affected `.tenet/project/**` file, read the current doctrine and weigh the drift notes against it; draft one consolidated proposal per file (merge related notes, drop contradictions). 4. **Append the proposals** to `.tenet/runs//doctrine-proposals.md` (create the file if absent). One section per proposal: @@ -98,6 +107,8 @@ If a normal job discovers that project doctrine is missing, stale, or wrong, it - **observed_reality** — what the code or run actually shows - **proposed_change** — the specific edit that would bring doctrine back in line +Also drop a `### doctrine-drift: ` marker at the spot in the run doc (e.g. `design.md`) where the drift is noted inline. The run-end review scans both the journal and run docs, and dedupes by `doctrine_file`, so writing the note either way is fine — but the marker guarantees it is found even when the note is written freeform. + Only explicit context-bootstrap, an authorized doctrine-maintenance job (`allow_project_doctrine_edits: true`), or direct user-requested doctrine work may edit `.tenet/project/**`. Drift notes are the input that keeps `.tenet/project/**` from silently rotting — they are collected into durable proposals at run completion (see **Run Completion — Doctrine Drift Review** below). ### Background Status Check Pattern diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index 014ecf7..f97a970 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -82,7 +82,7 @@ const createHarness = (rules: FakeFixtureRule[]): Harness => { ); const getStatus = captureHandler((rt) => // eslint-disable-next-line @typescript-eslint/no-explicit-any - registerTenetGetStatusTool(rt as any, store), + registerTenetGetStatusTool(rt as any, store, manager), ); const reportBlockingFinding = captureHandler((rt) => // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/core/job-manager.test.ts b/src/core/job-manager.test.ts index 67a030a..e93eacc 100644 --- a/src/core/job-manager.test.ts +++ b/src/core/job-manager.test.ts @@ -116,6 +116,37 @@ describe('JobManager', () => { expect(output.output).toContain('Deliverable Requirements'); }); + it('dev preamble carries the doctrine-drift note contract (journal entry + run-doc marker)', async () => { + const { manager } = createHarness(); + const job = manager.startJob('dev', { prompt: 'build feature' }); + await manager.waitForJob(job.id, null, 5_000); + + const prompt = (manager.getJobResult(job.id).output as { output: string }).output; + + // The vague "or final report" misroute is gone... + expect(prompt).not.toContain('or final report'); + // ...replaced by the actual discoverable contract the run-end review depends on. + expect(prompt).toContain('tenet_update_knowledge(type="journal"'); + expect(prompt).toContain('title="doctrine drift: "'); + expect(prompt).toContain('doctrine_file'); + expect(prompt).toContain('current_claim'); + expect(prompt).toContain('observed_reality'); + expect(prompt).toContain('proposed_change'); + expect(prompt).toContain('### doctrine-drift: '); + }); + + it('dev preamble authorizes doctrine edits when allow_project_doctrine_edits is set', async () => { + const { manager } = createHarness(); + const job = manager.startJob('dev', { prompt: 'maintain doctrine', allow_project_doctrine_edits: true }); + await manager.waitForJob(job.id, null, 5_000); + + const prompt = (manager.getJobResult(job.id).output as { output: string }).output; + + expect(prompt).toContain('explicitly authorized to edit `.tenet/project/**`'); + // Authorized jobs edit doctrine directly, so they are NOT told to write drift notes. + expect(prompt).not.toContain('title="doctrine drift: "'); + }); + it('cancels a running job', async () => { const { store, manager } = createHarness(1_000); diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 90f7c8d..8fb1470 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -348,6 +348,11 @@ export class JobManager { this.detectStalledJobs(); } + /** Heartbeat staleness threshold (ms) — a running job whose last heartbeat is older than this is stale. */ + getHeartbeatTimeoutMs(): number { + return this.heartbeatTimeoutMs; + } + private detectStalledJobs(): void { const now = Date.now(); this.stateStore.resetOrphanedJobs(this.serverId, this.heartbeatTimeoutMs); @@ -711,9 +716,13 @@ export class JobManager { '- Do NOT write tests that only check absence of errors or internal state — a separate test critic will reject them', '- Every new endpoint, page, or feature MUST have at least one test that verifies it works correctly', '- Do NOT just explore, research, or describe what could be done — actually implement it', - projectDoctrineAuthorized - ? '- This job is explicitly authorized to edit `.tenet/project/**` project doctrine.' - : '- Do NOT edit `.tenet/project/**`; write proposed doctrine updates to the run-local journal or final report instead.', + ...(projectDoctrineAuthorized + ? ['- This job is explicitly authorized to edit `.tenet/project/**` project doctrine.'] + : [ + '- Do NOT edit `.tenet/project/**`. If you discover project doctrine (`.tenet/project/**`) is missing, stale, or wrong, record a **doctrine-drift note** — do NOT patch doctrine directly.', + '- Write the drift note to the run journal via `tenet_update_knowledge(type="journal", title="doctrine drift: ", findings={"doctrine_file": "", "current_claim": "", "observed_reality": "", "proposed_change": ""})`.', + '- ALSO drop a `### doctrine-drift: ` marker at the spot in the run doc (e.g. `design.md`) where you note the drift inline, so the run-end review finds it even when written freeform. One note per affected doctrine file; the review dedupes by `doctrine_file`.', + ]), '', '## Smoke Check (mandatory before exiting)', '- If this is a server/API feature: start the server, verify your endpoints respond (non-5xx)', diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index 2e02c12..1c6ce7c 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -54,7 +54,7 @@ export const registerAllTools = (server: McpServer, jobManager: JobManager, stat safeRegister(() => registerTenetProcessSteerTool(registerTool, stateStore)); safeRegister(() => registerTenetUpdateSteerTool(registerTool, stateStore)); safeRegister(() => registerTenetHealthCheckTool(registerTool, stateStore, jobManager)); - safeRegister(() => registerTenetGetStatusTool(registerTool, stateStore)); + safeRegister(() => registerTenetGetStatusTool(registerTool, stateStore, jobManager)); // tenet_set_agent removed from MCP — available via CLI only safeRegister(() => registerTenetRegisterJobsTool(registerTool, stateStore)); safeRegister(() => registerTenetReportBlockingFindingTool(registerTool, jobManager, stateStore)); diff --git a/src/mcp/tools/tenet-get-status.test.ts b/src/mcp/tools/tenet-get-status.test.ts new file mode 100644 index 0000000..37fc97d --- /dev/null +++ b/src/mcp/tools/tenet-get-status.test.ts @@ -0,0 +1,176 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { AdapterRegistry } from '../../adapters/index.js'; +import { JobManager } from '../../core/job-manager.js'; +import { StateStore } from '../../core/state-store.js'; +import type { Job } from '../../types/index.js'; +import { registerTenetGetStatusTool } from './tenet-get-status.js'; + +type GetStatusHandler = (args: { + view?: 'summary' | 'queue'; + include_blocked?: boolean; +}) => Promise; + +const HEARTBEAT_TIMEOUT_MS = 60_000; + +const tempDirs: string[] = []; +const stores: StateStore[] = []; +const managers: JobManager[] = []; + +interface Harness { + store: StateStore; + manager: JobManager; + handler: GetStatusHandler; +} + +const createHarness = (): Harness => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenet-get-status-test-')); + tempDirs.push(tempDir); + + const store = new StateStore(tempDir); + stores.push(store); + + const registry = new AdapterRegistry(); + // Constructed before any jobs exist, so the constructor-time orphan reset + // runs on an empty store and cannot touch jobs seeded afterwards. + const manager = new JobManager(store, registry, { + heartbeatTimeoutMs: HEARTBEAT_TIMEOUT_MS, + defaultJobTimeoutMs: 5_000, + }); + managers.push(manager); + + let captured: GetStatusHandler | undefined; + const registerTool = ((_name: string, _def: unknown, handler: GetStatusHandler) => { + captured = handler; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + registerTenetGetStatusTool(registerTool, store, manager); + if (!captured) throw new Error('handler not captured'); + + return { store, manager, handler: captured }; +}; + +const parseResult = (result: CallToolResult): Record => { + const first = result.content[0]; + if (first.type !== 'text') throw new Error('expected text'); + return JSON.parse(first.text); +}; + +const seed = ( + store: StateStore, + opts: Partial> & Pick, +): Job => store.createJob({ retryCount: 0, maxRetries: 3, ...opts }); + +afterEach(() => { + while (managers.length > 0) managers.pop()?.shutdown(); + while (stores.length > 0) stores.pop()?.close(); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('tenet_get_status', () => { + it('default call (no view) is the unchanged high-level summary — no jobs list', async () => { + const { store, handler } = createHarness(); + seed(store, { type: 'dev', status: 'pending', params: { name: 'p1', prompt: 'x' } }); + + const parsed = parseResult(await handler({})); + + expect(parsed).not.toHaveProperty('jobs'); + expect(parsed).not.toHaveProperty('truncated'); + expect(parsed.jobs_completed).toBe(0); + expect(parsed.jobs_remaining).toBe(1); + expect(typeof parsed.last_activity).toBe('string'); + }); + + it('view=queue returns pending+running with id/age/stale; pending never stale, heartbeat-stale running flagged', async () => { + const { store, handler } = createHarness(); + const now = Date.now(); + const pending = seed(store, { type: 'dev', status: 'pending', params: { name: 'p1', prompt: 'x' } }); + const runningFresh = seed(store, { + type: 'dev', + status: 'running', + params: { name: 'r1', prompt: 'x' }, + startedAt: now, + lastHeartbeat: now, + }); + const runningStale = seed(store, { + type: 'dev', + status: 'running', + params: { name: 'r2', prompt: 'secret-prompt' }, + startedAt: now - 120_000, + lastHeartbeat: now - 120_000, + }); + + const parsed = parseResult(await handler({ view: 'queue' })); + const jobs = parsed.jobs as Array>; + const ids = jobs.map((j) => j.id); + + expect(ids).toEqual(expect.arrayContaining([pending.id, runningFresh.id, runningStale.id])); + expect(jobs).toHaveLength(3); + + const stale = jobs.find((j) => j.id === runningStale.id) as Record; + expect(stale.stale).toBe(true); + expect(stale.stale_reason).toBe('heartbeat_timeout'); + + const fresh = jobs.find((j) => j.id === runningFresh.id) as Record; + expect(fresh.stale).toBe(false); + expect(fresh).not.toHaveProperty('stale_reason'); + + const p = jobs.find((j) => j.id === pending.id) as Record; + expect(p.stale).toBe(false); + expect(typeof p.age_ms).toBe('number'); + + // No params/prompt leaked — only identity + status + age + staleness. + for (const j of jobs) { + expect(j).not.toHaveProperty('params'); + expect(JSON.stringify(j)).not.toContain('secret-prompt'); + expect(j.type).toBeDefined(); + expect(j.status).toBeDefined(); + expect(j.name).toBeDefined(); + } + }); + + it('include_blocked=true surfaces blocked and blocked_on_finding jobs (default hides them)', async () => { + const { store, handler } = createHarness(); + const pending = seed(store, { type: 'dev', status: 'pending', params: { name: 'p1', prompt: 'x' } }); + const blocked = seed(store, { type: 'dev', status: 'blocked', params: { name: 'b1', prompt: 'x' } }); + const bof = seed(store, { + type: 'dev', + status: 'blocked_on_finding', + params: { name: 'bof1', prompt: 'x' }, + }); + + const withoutBlocked = parseResult(await handler({ view: 'queue' })); + const withoutIds = (withoutBlocked.jobs as Array<{ id: string }>).map((j) => j.id); + expect(withoutIds).toEqual(expect.arrayContaining([pending.id])); + expect(withoutIds).not.toContain(blocked.id); + expect(withoutIds).not.toContain(bof.id); + + const withBlocked = parseResult(await handler({ view: 'queue', include_blocked: true })); + const ids = (withBlocked.jobs as Array<{ id: string }>).map((j) => j.id); + expect(ids).toEqual(expect.arrayContaining([pending.id, blocked.id, bof.id])); + }); + + it('caps the queue at 100 rows oldest-first and sets truncated=true', async () => { + const { store, handler } = createHarness(); + const first = seed(store, { type: 'dev', status: 'pending', params: { name: 'first', prompt: 'x' } }); + const created = [first]; + for (let i = 1; i < 105; i += 1) { + created.push(seed(store, { type: 'dev', status: 'pending', params: { name: `j${i}`, prompt: 'x' } })); + } + const last = created[created.length - 1]; + + const parsed = parseResult(await handler({ view: 'queue' })); + expect(parsed.truncated).toBe(true); + const jobs = parsed.jobs as Array<{ id: string }>; + expect(jobs).toHaveLength(100); + // Oldest retained (first inserted), newest beyond cap dropped (last inserted). + expect(jobs.some((j) => j.id === first.id)).toBe(true); + expect(jobs.some((j) => j.id === last.id)).toBe(false); + }); +}); diff --git a/src/mcp/tools/tenet-get-status.ts b/src/mcp/tools/tenet-get-status.ts index 82752e8..e918632 100644 --- a/src/mcp/tools/tenet-get-status.ts +++ b/src/mcp/tools/tenet-get-status.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; +import { JobManager } from '../../core/job-manager.js'; import { StateStore } from '../../core/state-store.js'; import { checkForUpdate } from '../../core/update-checker.js'; -import type { Job, ProjectStatus } from '../../types/index.js'; +import type { Job, JobStatus, ProjectStatus } from '../../types/index.js'; import { jsonResult, type RegisterTool } from './utils.js'; const extractRawOutput = (output: unknown): string | undefined => { @@ -63,7 +64,57 @@ const findLatestE2eStatus = (stateStore: StateStore): string | undefined => { return typeof status === 'string' ? status : undefined; }; -export const registerTenetGetStatusTool = (registerTool: RegisterTool, stateStore: StateStore): void => { +/** + * Maximum number of job rows returned by view="queue". The list is oldest-first + * (by created_at), so truncation keeps the longest-waiting jobs visible — the + * ones most likely to be stale cleanup targets. + */ +const QUEUE_CAP = 100; + +const QUEUE_STATUSES_DEFAULT: JobStatus[] = ['pending', 'running']; +const QUEUE_STATUSES_FULL: JobStatus[] = ['pending', 'running', 'blocked', 'blocked_on_finding']; + +const jobDisplayName = (job: Job): string => { + const name = job.params.name; + return typeof name === 'string' && name.length > 0 ? name : job.id.slice(0, 8); +}; + +/** + * Summarize a job for the queue view. Exposes only identity + status + age + + * staleness — deliberately NOT params/prompt (keeps the payload small and avoids + * leaking prompt content). `stale` is derived only from the existing heartbeat + * bar (running jobs); pending jobs are never auto-stale, so callers judge them + * by age_ms. + */ +const summarizeJob = (job: Job, now: number, heartbeatTimeoutMs: number): Record => { + let stale = false; + let staleReason: string | undefined; + if (job.status === 'running' && typeof job.lastHeartbeat === 'number') { + if (now - job.lastHeartbeat > heartbeatTimeoutMs) { + stale = true; + staleReason = 'heartbeat_timeout'; + } + } + + const row: Record = { + id: job.id, + type: job.type, + status: job.status, + name: jobDisplayName(job), + age_ms: Math.max(0, now - job.createdAt), + stale, + }; + if (staleReason) { + row.stale_reason = staleReason; + } + return row; +}; + +export const registerTenetGetStatusTool = ( + registerTool: RegisterTool, + stateStore: StateStore, + jobManager: JobManager, +): void => { const startedAt = Date.now(); registerTool( @@ -71,10 +122,28 @@ export const registerTenetGetStatusTool = (registerTool: RegisterTool, stateStor { description: 'Get high-level project summary status. Also surfaces the most recent interaction-e2e critic status ' + - 'so callers can distinguish "fully verified" from "passed with browser exploration skipped/applicable".', - inputSchema: z.object({}), + 'so callers can distinguish "fully verified" from "passed with browser exploration skipped/applicable". ' + + 'Pass view="queue" to also receive the non-terminal job list (id/type/status/name/age_ms/stale) — the ' + + 'default pending+running set, or blocked/blocked_on_finding too with include_blocked=true — so a caller ' + + 'can inspect stale jobs and cancel them via tenet_cancel_job. Pending jobs have no staleness bar; judge ' + + 'them by age_ms. The list is oldest-first, capped at 100 rows (truncated=true if more exist).', + inputSchema: z.object({ + view: z + .enum(['summary', 'queue']) + .optional() + .describe( + 'summary = high-level counts only (default, unchanged). queue = also return the non-terminal job list with ids so stale jobs can be inspected and cancelled.', + ), + include_blocked: z + .boolean() + .optional() + .describe( + 'With view="queue": also include blocked and blocked_on_finding jobs. Default false (pending + running only). Ignored for summary.', + ), + }), }, - async () => { + async ({ view, include_blocked }) => { + const now = Date.now(); const completed = stateStore.getCompletedCount(); const total = stateStore.getTotalCount(); const blocked = stateStore.getBlockedJobs().length; @@ -88,17 +157,32 @@ export const registerTenetGetStatusTool = (registerTool: RegisterTool, stateStor jobs_remaining: Math.max(0, total - completed), jobs_blocked: blocked, current_job: running[0]?.id, - elapsed_ms: Date.now() - startedAt, + elapsed_ms: now - startedAt, last_activity: new Date().toISOString(), }; const extras = latestLayer2 ? { latest_e2e_status: latestLayer2 } : {}; + // view="queue" enriches the summary with a cancellable job list. Default + // (no view, or view="summary") stays byte-identical to the legacy output. + const queueExtras: Record = {}; + if (view === 'queue') { + const statuses = include_blocked ? QUEUE_STATUSES_FULL : QUEUE_STATUSES_DEFAULT; + const jobs = statuses.flatMap((s) => stateStore.getJobsByStatus(s)); + jobs.sort((a, b) => a.createdAt - b.createdAt); + const truncated = jobs.length > QUEUE_CAP; + const heartbeatTimeoutMs = jobManager.getHeartbeatTimeoutMs(); + queueExtras.jobs = (truncated ? jobs.slice(0, QUEUE_CAP) : jobs).map((job) => + summarizeJob(job, now, heartbeatTimeoutMs), + ); + queueExtras.truncated = truncated; + } + const updateInfo = await checkForUpdate(); + const base = { ...status, ...extras, ...queueExtras }; if (updateInfo?.update_available) { return jsonResult({ - ...status, - ...extras, + ...base, update_available: updateInfo.latest, update_command: updateInfo.update_command, current_version: updateInfo.current, @@ -107,7 +191,7 @@ export const registerTenetGetStatusTool = (registerTool: RegisterTool, stateStor }); } - return jsonResult({ ...status, ...extras }); + return jsonResult(base); }, ); }; diff --git a/src/mcp/tools/tenet-report-blocking-finding.test.ts b/src/mcp/tools/tenet-report-blocking-finding.test.ts index 5a975af..e3ee45e 100644 --- a/src/mcp/tools/tenet-report-blocking-finding.test.ts +++ b/src/mcp/tools/tenet-report-blocking-finding.test.ts @@ -63,6 +63,22 @@ class ControlledParentAdapter implements AgentAdapter { } } +/** Returns a context-limit error string (no rubric JSON) for every invocation — + * models a CLI that hit its context window and exited with an error message. */ +class ContextLimitAdapter implements AgentAdapter { + public readonly name = 'mock-adapter'; + async invoke(_invocation: AgentInvocation): Promise { + return { + success: true, + output: 'Error: prompt is too long: 1048576 tokens > 200000 maximum', + durationMs: 0, + }; + } + async isAvailable(): Promise { + return true; + } +} + type Handler = (args: { job_id: string; finding: string; @@ -421,4 +437,51 @@ describe('tenet_report_blocking_finding', () => { // Parent stays blocked (only one of three critics has passed) expect(store.getJob(reportJob.id)?.status).toBe('blocked_on_finding'); }); + + it('does not auto-resume parent when a critic exits with context-limit / unparseable output', async () => { + const { store, manager, handler } = createHarnessWithAdapter(new ContextLimitAdapter()); + + const reportJob = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const result = await handler({ + job_id: reportJob.id, + finding: 'bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix', + }); + const childId = parseResult(result).child_job_id as string; + + await manager.waitForJob(childId, null, 5_000); + expect(store.getJob(reportJob.id)?.status).toBe('blocked_on_finding'); + + // Both expected critics "complete" but with a context-limit error string — + // no valid rubric JSON. Neither counts as a pass, so the parent must stay blocked. + const expected = ['code_critic', 'test_critic']; + const codeCritic = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + expected_eval_stages: expected, + prompt: 'code critique', + }); + const testCritic = manager.startJob('eval', { + source_job_id: childId, + eval_stage: 'test_critic', + expected_eval_stages: expected, + prompt: 'test critique', + }); + + await manager.waitForJob(codeCritic.id, null, 5_000); + await manager.waitForJob(testCritic.id, null, 5_000); + expect(store.getJob(codeCritic.id)?.status).toBe('completed'); + expect(store.getJob(testCritic.id)?.status).toBe('completed'); + + // Unparseable critic output is NOT a pass → parent stays blocked_on_finding. + expect(store.getJob(reportJob.id)?.status).toBe('blocked_on_finding'); + }); }); From 5cab809837162061c5a831910728a4e55b9659b1 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Sat, 27 Jun 2026 08:26:38 +0900 Subject: [PATCH 20/87] docs: apply doc/code consistency review r1 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the three findings from the doc/code consistency review (r1): - skills/tenet-diagnose: --codex-args-playwright-eval → --codex-args-interaction-e2e. The flag was renamed in 26.6.6 (playwright_eval → interaction_e2e); the diagnose skill still documented the old, now-broken flag. - tests/README: "five targets" → "five canaries, plus an e2e-all runner". The bullet list had six entries (five canaries + the run-all runner), contradicting "five". - docs/e2e-runbook + tests/README: retire the "Playwright eval" stage label in favor of "interaction-e2e" (the stage was renamed in 26.6.6). Doc-only; no code or behavior change. Co-Authored-By: Claude --- docs/e2e-runbook.md | 4 ++-- skills/tenet-diagnose/SKILL.md | 2 +- tests/README.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/e2e-runbook.md b/docs/e2e-runbook.md index 2f4d6f6..2cbe5ab 100644 --- a/docs/e2e-runbook.md +++ b/docs/e2e-runbook.md @@ -10,7 +10,7 @@ See `docs/planning/11_auto_testing_plan.md` for the full three-tier strategy. |---|---|---| | `make e2e-cli` | key-count CLI | readiness with `eval_parallel_safe=true` → parallel critics | | `make e2e-api` | note-store API | readiness with `eval_parallel_safe=false` → sequential critics | -| `make e2e-web` | click-counter HTML | Playwright eval Layer 2 reporting path | +| `make e2e-web` | click-counter HTML | interaction-e2e Layer 2 reporting path | | `make e2e-agile` | 2-slice agile CLI | per-slice registration and slice progress status | | `make e2e-agile-full` | agent-driven agile pipeline | planning prompt produces agile spec, decomposition, and slice jobs | | `make e2e-all` | all five sequentially | full coverage of the canary paths | @@ -42,7 +42,7 @@ Switch to a cheaper model globally via `tenet config --opencode-args "--model .. ```bash make e2e-cli # quickest smoke make e2e-api # stateful-app path -make e2e-web # static + playwright-eval path +make e2e-web # static + interaction-e2e path make e2e-agile # per-slice agile path make e2e-agile-full # agent-driven agile planning path make e2e-all # all five diff --git a/skills/tenet-diagnose/SKILL.md b/skills/tenet-diagnose/SKILL.md index 22d695c..b553ef5 100644 --- a/skills/tenet-diagnose/SKILL.md +++ b/skills/tenet-diagnose/SKILL.md @@ -262,7 +262,7 @@ claude --help | head -30 ```bash tenet config --opencode-args "--model github-copilot/claude-opus-4-5" tenet config --codex-args '--config approval_policy="never"' -tenet config --codex-args-playwright-eval "--dangerously-bypass-approvals-and-sandbox" +tenet config --codex-args-interaction-e2e "--dangerously-bypass-approvals-and-sandbox" tenet config --claude-args "--allowedTools Bash,Read,Write,Edit" ``` diff --git a/tests/README.md b/tests/README.md index b07ca06..f802184 100644 --- a/tests/README.md +++ b/tests/README.md @@ -70,11 +70,11 @@ Those are covered by Tier 2 manual E2E canaries today; Tier 3 replay harnesses a ## Tier 2 E2E canaries -Tier 2 canaries are manual because they run real agent CLIs and cost time/money. The Makefile exposes five targets: +Tier 2 canaries are manual because they run real agent CLIs and cost time/money. The Makefile exposes five canaries, plus an `e2e-all` runner: - `make e2e-cli` — key-count CLI canary. - `make e2e-api` — stateful note-store API canary. -- `make e2e-web` — click-counter web canary with Playwright eval reporting. +- `make e2e-web` — click-counter web canary with interaction-e2e reporting. - `make e2e-agile` — two-slice agile CLI canary. - `make e2e-agile-full` — full-pipeline agile canary where the agent produces the agile spec and slice DAGs. - `make e2e-all` — runs all five sequentially. From 3426512e9294f2166d01259dfe05f17ba30459ed Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Sat, 27 Jun 2026 08:42:39 +0900 Subject: [PATCH 21/87] docs: add db command to README architecture summary (review r2) README's four-layer CLI summary listed only init/serve/status/config, omitting the top-level `db` maintenance command (check/backup/snapshot/restore-snapshot). This gap was flagged twice (critic-review-r0 and doc-review-r2). Now matches CLAUDE.md and src/cli/index.ts. Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 56a175a..103e050 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Three classes: `context` (informational), `directive` (priority change), `emerge 1. **Core** — Job orchestration with DAG execution, heartbeat stall detection, configurable retry logic, and server-ID crash recovery 2. **Adapters** — Pluggable agent adapters that spawn CLI subprocesses. 120-minute default timeout, configurable. 3. **MCP Server** — 19 tools via `@modelcontextprotocol/server`. Zod-validated inputs. -4. **CLI** — `init`, `serve`, `status`, `config` commands. Scaffolds `.tenet/`, copies skills to agent-specific locations, and runs explicit DB upgrades. +4. **CLI** — `init`, `serve`, `status`, `config`, and `db` maintenance commands. Scaffolds `.tenet/`, copies skills to agent-specific locations, and runs explicit DB upgrades. ## CLI Reference From 79ed8e84a5a1676ab0a2681df9ba86048d096142 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Sat, 27 Jun 2026 08:52:15 +0900 Subject: [PATCH 22/87] chore: bump to 26.6.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c2885b7..cf6170c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.6.6", + "version": "26.6.7", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From d9d991d416da25641408501a33c45acd47687b8b Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Mon, 29 Jun 2026 10:43:31 +0900 Subject: [PATCH 23/87] feat(cli): sort tenet status + job-queue by dag_id plan order tenet status and the generated job-queue.md listed jobs in raw insertion/rowid order, which read as "unsorted" when batch-registered jobs tied on created_at. Sort by status priority (running -> pending -> blocked -> ... -> history), then within each group by dag_id in natural/numeric order, falling back to created_at for ad-hoc jobs. A shared compareJobsByPlan in status-writer.ts drives both surfaces. Closes #23 Co-Authored-By: Claude --- src/cli/status.test.ts | 91 ++++++++++++++++++++++++++++++++++ src/cli/status.ts | 20 ++++++++ src/core/status-writer.test.ts | 33 +++++++++++- src/core/status-writer.ts | 42 ++++++++++++++-- 4 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 src/cli/status.test.ts diff --git a/src/cli/status.test.ts b/src/cli/status.test.ts new file mode 100644 index 0000000..82f5b78 --- /dev/null +++ b/src/cli/status.test.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { StateStore } from '../core/state-store.js'; +import { showStatus } from './status.js'; + +const tempDirs: string[] = []; +const stores: StateStore[] = []; + +const createTempDir = (): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenet-status-test-')); + tempDirs.push(dir); + return dir; +}; + +const captureLog = (fn: () => void): string => { + const lines: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + try { + fn(); + } finally { + console.log = original; + } + return lines.join('\n'); +}; + +afterEach(() => { + while (stores.length > 0) { + stores.pop()?.close(); + } + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe('tenet status ordering (#23)', () => { + it('lists jobs within a status in dag_id natural order, not insertion order', () => { + const projectPath = createTempDir(); + const store = new StateStore(projectPath); + stores.push(store); + // Insert in an order that is neither numeric nor lexical dag order. + for (const dagId of ['slice-1-003', 'slice-1-001', 'slice-1-010', 'slice-1-002']) { + store.createJob({ + type: 'dev', + status: 'pending', + params: { dag_id: dagId, name: `${dagId}-job` }, + retryCount: 0, + maxRetries: 3, + }); + } + + const out = captureLog(() => showStatus(projectPath)); + + // Numeric dag order: 001 < 002 < 003 < 010 (lexical would put 010 before 003). + expect(out.indexOf('slice-1-001:')).toBeLessThan(out.indexOf('slice-1-002:')); + expect(out.indexOf('slice-1-002:')).toBeLessThan(out.indexOf('slice-1-003:')); + expect(out.indexOf('slice-1-003:')).toBeLessThan(out.indexOf('slice-1-010:')); + }); + + it('groups by status priority (running before pending) even when dag_id would order otherwise', () => { + const projectPath = createTempDir(); + const store = new StateStore(projectPath); + stores.push(store); + store.createJob({ + type: 'dev', + status: 'running', + params: { dag_id: 'p-002', name: 'runnable' }, + retryCount: 0, + maxRetries: 3, + }); + store.createJob({ + type: 'dev', + status: 'pending', + params: { dag_id: 'p-001', name: 'pending' }, + retryCount: 0, + maxRetries: 3, + }); + + const out = captureLog(() => showStatus(projectPath)); + + // Running p-002 prints before pending p-001 despite p-001 < p-002. + expect(out.indexOf('p-002:')).toBeLessThan(out.indexOf('p-001:')); + }); +}); diff --git a/src/cli/status.ts b/src/cli/status.ts index e0a27cb..bfed7a9 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { UpgradeRequiredError, UnsupportedDbVersionError } from '../core/migrations.js'; import { formatMaxRetries } from '../core/runtime-config.js'; import { StateStore } from '../core/state-store.js'; +import { compareJobsByPlan } from '../core/status-writer.js'; import type { Job } from '../types/index.js'; const isProcessAlive = (pid: number): boolean => { @@ -55,6 +56,17 @@ const formatTimestamp = (ts: number): string => { return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; }; +// Display order across statuses: in-progress work first, then waiting, then history. +const STATUS_PRIORITY: Record = { + running: 0, + pending: 1, + blocked: 2, + blocked_on_finding: 3, + failed: 4, + completed: 5, + cancelled: 6, +}; + const printJobTable = (jobs: Job[]): void => { if (jobs.length === 0) { console.log(' (no jobs registered)'); @@ -132,6 +144,14 @@ export function showStatus(projectPath: string, options?: StatusOptions): void { ...stateStore.getJobsByStatus('cancelled'), ]; + // Order by status priority, then plan order (dag_id natural, else created_at) + // so the queue reads top-to-bottom as running → waiting → history, each in + // dependency order — not raw insertion/rowid order. + allJobs.sort((a, b) => { + const priority = (STATUS_PRIORITY[a.status] ?? 99) - (STATUS_PRIORITY[b.status] ?? 99); + return priority !== 0 ? priority : compareJobsByPlan(a, b); + }); + const completed = allJobs.filter((j) => j.status === 'completed').length; const cancelled = allJobs.filter((j) => j.status === 'cancelled').length; const failed = allJobs.filter((j) => j.status === 'failed').length; diff --git a/src/core/status-writer.test.ts b/src/core/status-writer.test.ts index d86da8b..f04696b 100644 --- a/src/core/status-writer.test.ts +++ b/src/core/status-writer.test.ts @@ -3,7 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { describe, it, expect, afterEach } from 'vitest'; import type { Job } from '../types/index.js'; -import { writeStatusFiles } from './status-writer.js'; +import { writeStatusFiles, compareJobsByPlan } from './status-writer.js'; const tempDirs: string[] = []; @@ -55,6 +55,37 @@ afterEach(() => { } }); +describe('compareJobsByPlan (#23)', () => { + const j = (dagId: string | undefined, createdAt: number): Job => + ({ + id: dagId ?? 'adhoc', + type: 'dev', + status: 'pending', + params: dagId ? { dag_id: dagId, name: dagId } : { name: 'adhoc' }, + agentName: 'default', + retryCount: 0, + maxRetries: 3, + createdAt, + updatedAt: createdAt, + serverId: '', + }) as Job; + + it('orders dag_ids numerically (10 after 2), not lexically', () => { + const jobs = [j('s-10', 3), j('s-2', 1), j('s-1', 2)].sort(compareJobsByPlan); + expect(jobs.map((x) => x.params.dag_id)).toEqual(['s-1', 's-2', 's-10']); + }); + + it('falls back to created_at when dag_id is absent (ad-hoc jobs)', () => { + const jobs = [j(undefined, 30), j(undefined, 10), j(undefined, 20)].sort(compareJobsByPlan); + expect(jobs.map((x) => x.createdAt)).toEqual([10, 20, 30]); + }); + + it('places dag_id jobs before ad-hoc jobs', () => { + const jobs = [j(undefined, 1), j('a-1', 5)].sort(compareJobsByPlan); + expect(jobs.map((x) => x.params.dag_id)).toEqual(['a-1', undefined]); + }); +}); + describe('status-writer slice progress (agile mode)', () => { const SPEC_AGILE = `--- delivery_mode: agile diff --git a/src/core/status-writer.ts b/src/core/status-writer.ts index cd04f27..b7b807e 100644 --- a/src/core/status-writer.ts +++ b/src/core/status-writer.ts @@ -107,6 +107,33 @@ const jobStatusIcon = (status: Job['status']): string => { } }; +const dagIdOf = (job: Job): string | null => { + const { dag_id } = job.params; + return typeof dag_id === 'string' && dag_id.length > 0 ? dag_id : null; +}; + +/** + * Plan-order comparator: dag_id (natural/numeric) first, falling back to + * created_at so ad-hoc jobs (no dag_id) still order deterministically. + * Shared by the CLI `tenet status` table and the generated job-queue.md so + * the queue reads in dependency order instead of insertion/rowid order. + */ +export const compareJobsByPlan = (a: Job, b: Job): number => { + const aDag = dagIdOf(a); + const bDag = dagIdOf(b); + if (aDag && bDag) { + const cmp = aDag.localeCompare(bDag, undefined, { numeric: true, sensitivity: 'base' }); + if (cmp !== 0) return cmp; + } else if (aDag) { + return -1; + } else if (bDag) { + return 1; + } + return (a.createdAt ?? 0) - (b.createdAt ?? 0); +}; + +export const sortJobsByPlan = (jobs: readonly Job[]): Job[] => [...jobs].sort(compareJobsByPlan); + const renderJobLine = (job: Job): string => { const icon = jobStatusIcon(job.status); const name = typeof job.params.name === 'string' ? job.params.name : job.id.slice(0, 8); @@ -130,6 +157,11 @@ export const writeStatusFiles = (projectPath: string, summary: JobSummary): void ? computeSliceProgress(summary.jobs, sliceInfo.slices) : null; + // Sort rendered lists in plan order (dag_id natural, then created_at). + const runningSorted = sortJobsByPlan(summary.running); + const failedSorted = sortJobsByPlan(summary.failed); + const queueSorted = sortJobsByPlan(summary.jobs); + // status.md — high-level summary const now = new Date().toISOString(); const statusLines = [ @@ -154,17 +186,17 @@ export const writeStatusFiles = (projectPath: string, summary: JobSummary): void '', ); - if (summary.running.length > 0) { + if (runningSorted.length > 0) { statusLines.push('## Currently Running', ''); - for (const job of summary.running) { + for (const job of runningSorted) { statusLines.push(renderJobLine(job)); } statusLines.push(''); } - if (summary.failed.length > 0) { + if (failedSorted.length > 0) { statusLines.push('## Failed', ''); - for (const job of summary.failed) { + for (const job of failedSorted) { statusLines.push(renderJobLine(job)); } statusLines.push(''); @@ -180,7 +212,7 @@ export const writeStatusFiles = (projectPath: string, summary: JobSummary): void '', ]; - for (const job of summary.jobs) { + for (const job of queueSorted) { queueLines.push(renderJobLine(job)); } queueLines.push(''); From f9a460b8c79e4d803f2971da29298b99e2afd7ee Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Mon, 29 Jun 2026 10:43:44 +0900 Subject: [PATCH 24/87] feat(cli): gzip db snapshots by default with auto-detecting restore tenet db snapshot now writes a gzip-compressed tenet.db.gz by default (real SQLite typically compresses 70-90%, keeping portable snapshots under GitHub's 100MB push limit). --no-compress keeps the plain tenet.db for direct sqlite3 tooling. restore-snapshot auto-detects gzip vs plain via magic bytes and resolves the default source preferring tenet.db.gz, then legacy tenet.db. No schema change; init README + README CLI reference updated. Closes #18 Co-Authored-By: Claude --- README.md | 4 +- src/cli/db.test.ts | 47 +++++++++++++++++---- src/cli/db.ts | 102 ++++++++++++++++++++++++++++++++++++++------- src/cli/index.ts | 7 ++-- src/cli/init.ts | 4 +- 5 files changed, 136 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 103e050..9c942ca 100644 --- a/README.md +++ b/README.md @@ -174,8 +174,8 @@ tenet status --all # Include completed/failed jobs # SQLite state maintenance tenet db check # Read-only integrity/index diagnostics tenet db backup # Verified SQLite-safe backup -tenet db snapshot # Git-safe portable snapshot to .tenet/state-snapshot/ -tenet db restore-snapshot # Restore live SQLite state from a portable snapshot +tenet db snapshot # Git-safe portable snapshot to .tenet/state-snapshot/ (gzip by default; --no-compress for plain) +tenet db restore-snapshot # Restore live SQLite state from a portable snapshot (auto-detects gzip/plain) # Configure tenet config # View current config diff --git a/src/cli/db.test.ts b/src/cli/db.test.ts index 4b743aa..1425c36 100644 --- a/src/cli/db.test.ts +++ b/src/cli/db.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import zlib from 'node:zlib'; import Database from 'better-sqlite3'; import { StateStore } from '../core/state-store.js'; import { runDbRestoreSnapshot, runDbSnapshot } from './db.js'; @@ -38,7 +39,7 @@ afterEach(() => { }); describe('db snapshot commands', () => { - it('creates a portable snapshot that includes live WAL state', () => { + it('creates a gzip-compressed portable snapshot by default that includes live WAL state', () => { const projectPath = createTempDir(); const store = new StateStore(projectPath); stores.push(store); @@ -52,11 +53,42 @@ describe('db snapshot commands', () => { const snapshotPath = runDbSnapshot(projectPath); + expect(snapshotPath).toBe(path.join(projectPath, '.tenet', 'state-snapshot', 'tenet.db.gz')); + expect(fs.existsSync(snapshotPath)).toBe(true); + // Gzip magic bytes. + const head = fs.readFileSync(snapshotPath).subarray(0, 2); + expect(head[0]).toBe(0x1f); + expect(head[1]).toBe(0x8b); + // Content round-trips: decompress and read as a normal SQLite DB. + const plain = zlib.gunzipSync(fs.readFileSync(snapshotPath)); + const peekPath = path.join(path.dirname(snapshotPath), 'peek.db'); + fs.writeFileSync(peekPath, plain); + try { + expect(readJobNames(peekPath)).toEqual(['from-live-wal']); + } finally { + fs.rmSync(peekPath, { force: true }); + } + }); + + it('writes a plain uncompressed snapshot with compress:false', () => { + const projectPath = createTempDir(); + const store = new StateStore(projectPath); + stores.push(store); + store.createJob({ + type: 'dev', + status: 'completed', + params: { name: 'plain-job' }, + retryCount: 0, + maxRetries: 1, + }); + + const snapshotPath = runDbSnapshot(projectPath, undefined, { compress: false }); + expect(snapshotPath).toBe(path.join(projectPath, '.tenet', 'state-snapshot', 'tenet.db')); - expect(readJobNames(snapshotPath)).toEqual(['from-live-wal']); + expect(readJobNames(snapshotPath)).toEqual(['plain-job']); }); - it('restores live state from the portable snapshot and removes sidecars', () => { + it('restores live state from the compressed snapshot (default source) and removes sidecars', () => { const projectPath = createTempDir(); const store = new StateStore(projectPath); stores.push(store); @@ -67,7 +99,7 @@ describe('db snapshot commands', () => { retryCount: 0, maxRetries: 1, }); - const snapshotPath = runDbSnapshot(projectPath); + runDbSnapshot(projectPath); // writes tenet.db.gz store.createJob({ type: 'dev', status: 'completed', @@ -81,7 +113,8 @@ describe('db snapshot commands', () => { fs.writeFileSync(path.join(stateDir, 'tenet.db-wal'), 'stale wal placeholder', 'utf8'); fs.writeFileSync(path.join(stateDir, 'tenet.db-shm'), 'stale shm placeholder', 'utf8'); - runDbRestoreSnapshot(projectPath, snapshotPath, { force: true }); + // No explicit source: must resolve the default tenet.db.gz and auto-decompress. + runDbRestoreSnapshot(projectPath, undefined, { force: true }); expect(readJobNames(path.join(stateDir, 'tenet.db'))).toEqual(['snapshotted']); expect(fs.existsSync(path.join(stateDir, 'tenet.db-wal'))).toBe(false); @@ -99,13 +132,13 @@ describe('db snapshot commands', () => { retryCount: 0, maxRetries: 1, }); - const snapshotPath = runDbSnapshot(projectPath); + runDbSnapshot(projectPath); stores.pop()?.close(); const walPath = path.join(projectPath, '.tenet', '.state', 'tenet.db-wal'); fs.writeFileSync(walPath, 'stale wal placeholder', 'utf8'); - expect(() => runDbRestoreSnapshot(projectPath, snapshotPath)).toThrow(/Refusing to restore/); + expect(() => runDbRestoreSnapshot(projectPath)).toThrow(/Refusing to restore/); expect(fs.readFileSync(walPath, 'utf8')).toBe('stale wal placeholder'); }); }); diff --git a/src/cli/db.ts b/src/cli/db.ts index 05cad48..d6a5fe1 100644 --- a/src/cli/db.ts +++ b/src/cli/db.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import zlib from 'node:zlib'; import { StateStore, type DbHealthReport, type RestoreDatabaseOptions } from '../core/state-store.js'; const timestamp = (): string => @@ -81,26 +82,76 @@ export const runDbBackup = (projectPath: string, destination?: string): string = return backupPath; }; -const defaultSnapshotPath = (projectPath: string): string => - path.join(projectPath, '.tenet', 'state-snapshot', 'tenet.db'); +export type SnapshotOptions = { compress?: boolean }; -export const runDbSnapshot = (projectPath: string, destination?: string): string => { - const snapshotPath = destination ? path.resolve(destination) : defaultSnapshotPath(projectPath); +// Gzip magic number (first two bytes): 0x1f 0x8b. +const isGzipFile = (filePath: string): boolean => { + let fd: number | undefined; + try { + fd = fs.openSync(filePath, 'r'); + const head = Buffer.alloc(2); + const bytesRead = fs.readSync(fd, head, 0, 2, 0); + return bytesRead === 2 && head[0] === 0x1f && head[1] === 0x8b; + } catch { + return false; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + /* ignore */ + } + } + } +}; + +const defaultSnapshotPath = (projectPath: string, compress: boolean): string => + path.join(projectPath, '.tenet', 'state-snapshot', compress ? 'tenet.db.gz' : 'tenet.db'); + +// Prefer the compressed snapshot when restoring without an explicit source; +// fall back to a legacy plain tenet.db so old snapshots keep restoring. +const resolveDefaultSnapshotSource = (projectPath: string): string => { + const dir = path.join(projectPath, '.tenet', 'state-snapshot'); + const gz = path.join(dir, 'tenet.db.gz'); + return fs.existsSync(gz) ? gz : path.join(dir, 'tenet.db'); +}; + +export const runDbSnapshot = ( + projectPath: string, + destination?: string, + options?: SnapshotOptions, +): string => { + const compress = options?.compress ?? true; + const snapshotPath = destination + ? path.resolve(destination) + : defaultSnapshotPath(projectPath, compress); const snapshotDir = path.dirname(snapshotPath); fs.mkdirSync(snapshotDir, { recursive: true }); - const tempPath = path.join(snapshotDir, `tenet.db.tmp-${process.pid}-${Date.now()}`); + const tempPlain = path.join(snapshotDir, `tenet.db.tmp-${process.pid}-${Date.now()}`); try { - StateStore.backupDatabase(projectPath, tempPath); - fs.renameSync(tempPath, snapshotPath); + // VACUUM INTO a temp plain SQLite file (also asserts the live DB is healthy). + StateStore.backupDatabase(projectPath, tempPlain); + const rawSize = fs.statSync(tempPlain).size; + + if (compress) { + const packed = zlib.gzipSync(fs.readFileSync(tempPlain), { level: 9 }); + fs.writeFileSync(snapshotPath, packed); + fs.rmSync(tempPlain, { force: true }); + const ratio = rawSize > 0 ? Math.max(0, Math.round((1 - packed.length / rawSize) * 100)) : 0; + const savings = ratio > 0 ? `, ${ratio}% smaller than ${formatBytes(rawSize)} raw` : ''; + console.log(`Snapshot created: ${snapshotPath} (${formatBytes(packed.length)}${savings})`); + } else { + fs.renameSync(tempPlain, snapshotPath); + console.log(`Snapshot created: ${snapshotPath} (${formatBytes(rawSize)})`); + } } catch (error) { - if (fs.existsSync(tempPath)) { - fs.rmSync(tempPath, { force: true }); + if (fs.existsSync(tempPlain)) { + fs.rmSync(tempPlain, { force: true }); } throw error; } - console.log(`Snapshot created: ${snapshotPath}`); return snapshotPath; }; @@ -109,8 +160,31 @@ export const runDbRestoreSnapshot = ( source?: string, options?: RestoreDatabaseOptions, ): string => { - const snapshotPath = source ? path.resolve(source) : defaultSnapshotPath(projectPath); - StateStore.restoreDatabase(projectPath, snapshotPath, options); - console.log(`Snapshot restored: ${snapshotPath}`); - return snapshotPath; + const resolvedSource = source ? path.resolve(source) : resolveDefaultSnapshotSource(projectPath); + if (!fs.existsSync(resolvedSource)) { + throw new Error(`snapshot does not exist: ${resolvedSource}`); + } + + const snapshotDir = path.dirname(resolvedSource); + let restoreSource = resolvedSource; + let tempDecompressed: string | null = null; + + // Auto-detect gzip via magic bytes so restore accepts both .db.gz and plain .db + // regardless of filename (handles legacy snapshots and custom destinations). + if (isGzipFile(resolvedSource)) { + tempDecompressed = path.join(snapshotDir, `tenet.db.tmp-restore-${process.pid}-${Date.now()}`); + fs.writeFileSync(tempDecompressed, zlib.gunzipSync(fs.readFileSync(resolvedSource))); + restoreSource = tempDecompressed; + } + + try { + StateStore.restoreDatabase(projectPath, restoreSource, options); + console.log(`Snapshot restored: ${resolvedSource}${tempDecompressed ? ' (decompressed)' : ''}`); + } finally { + if (tempDecompressed) { + fs.rmSync(tempDecompressed, { force: true }); + } + } + + return resolvedSource; }; diff --git a/src/cli/index.ts b/src/cli/index.ts index d18bb83..d218275 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -375,13 +375,14 @@ const run = async (): Promise => { dbCommand .command('snapshot') - .description('Write a Git-safe portable SQLite snapshot') + .description('Write a Git-safe portable SQLite snapshot (gzip-compressed by default)') .option('--project ', 'Project path', '.') .option('--output ', 'Snapshot destination path') - .action((options: { project: string; output?: string }) => { + .option('--no-compress', 'Write a plain uncompressed SQLite snapshot (tenet.db) instead of gzip') + .action((options: { project: string; output?: string; compress: boolean }) => { const projectPath = resolveProjectPath(options.project); try { - runDbSnapshot(projectPath, options.output); + runDbSnapshot(projectPath, options.output, { compress: options.compress }); } catch (error) { if (error instanceof Error) { console.error(error.message); diff --git a/src/cli/init.ts b/src/cli/init.ts index 7033c5d..4d4ed64 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -59,8 +59,8 @@ const PORTABLE_STATE_README = `# Tenet State Snapshot This directory is for portable Tenet SQLite snapshots that are safe to track in Git. -- Run \`tenet db snapshot\` to write \`state-snapshot/tenet.db\`. -- Run \`tenet db restore-snapshot\` to restore live runtime state from the snapshot. +- Run \`tenet db snapshot\` to write a gzip-compressed \`state-snapshot/tenet.db.gz\` (use \`--no-compress\` for a plain \`tenet.db\`). +- Run \`tenet db restore-snapshot\` to restore live runtime state from the snapshot (auto-detects compressed or plain). - Do not track \`.tenet/.state/\`; it is the live SQLite WAL database. `; From c57494c2d3d7b2d1ab9eebcb36b85f1bfe8119c8 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Mon, 29 Jun 2026 10:44:10 +0900 Subject: [PATCH 25/87] chore: bump to 26.6.8 Co-Authored-By: Claude --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cf6170c..e437894 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.6.7", + "version": "26.6.8", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From cee03f51321027e627e774bf7443dc887a859c40 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 09:49:32 +0900 Subject: [PATCH 26/87] docs(planning): model tier & worker context design (doc 18) Authoritative handoff spec for the model_tier + worker-context work, plus an interactive HTML simulator. Four independent units: - U1: inline run docs (spec/decomposition/harness) into worker dispatch context - U2: reframe tenet_compile_context as the orchestrator's aid + role-preamble - U3: model_tier (local|frontier) as a prompt-only interview decision - U4: strengthen spec research accuracy + readiness gate Investigation finding: the worker subprocess never received compiled context (tenet_compile_context returns to the orchestrator; job.params.context was a dead read with no writer). This is the gap U1 closes. Co-Authored-By: Claude --- .../18_model_tier_and_worker_context.md | 234 +++++++++ docs/planning/18_model_tier_simulator.html | 444 ++++++++++++++++++ 2 files changed, 678 insertions(+) create mode 100644 docs/planning/18_model_tier_and_worker_context.md create mode 100644 docs/planning/18_model_tier_simulator.html diff --git a/docs/planning/18_model_tier_and_worker_context.md b/docs/planning/18_model_tier_and_worker_context.md new file mode 100644 index 0000000..9b41e15 --- /dev/null +++ b/docs/planning/18_model_tier_and_worker_context.md @@ -0,0 +1,234 @@ +# 18 — Model Tier & Worker Context + +**Created**: 2026-07-02 +**Status**: Design (ready for implementation) +**Origin**: Design discussion 2026-07-02. Evolved from `note.md` #9 (model-aware planning), #13 (weak-model critics), #21 (tenet rules in CLAUDE.md/AGENTS.md), plus a code investigation of the `tenet_compile_context` → worker wiring. +**Visual companion**: `18_model_tier_simulator.html` (open in a browser — interactive). + +--- + +## TL;DR + +Four **independent** work units. Ship U1 + U2 first (correctness fixes, immediate wins), then U3 (needs U1's worker-context hook), U4 anytime. + +| Unit | What | Tier-link | New tool / DB? | +|---|---|---|---| +| **U1** | Fix the blind worker — inject `run_path` + exact `artifact_paths` + read-directive (+ migrate the report-only preamble) into the **worker dispatch path** | none (baseline) | no | +| **U2** | Reframe `tenet_compile_context` as the **orchestrator's** context aid (it never reached the worker); add an orchestrator role-preamble; fix docs that falsely claim worker delivery | none (robust constant) | no | +| **U3** | `model_tier` (local \| frontier) — interview decision; decomposition branches on it. **No persisted field** (the effect lives in the decomposition artifact) | yes (one consumer) | none — prompt-only (two skill-doc edits) | +| **U4** | Strengthen spec research (phase 02) + readiness gate — accuracy, not detailness | none | no | + +--- + +## Problem statement (what the investigation found) + +The worker subprocess **does not receive the compiled context.** Confirmed by reading the dispatch path end to end: + +1. `tenet_compile_context` (`src/mcp/tools/tenet-compile-context.ts`) assembles spec + decomposition + project doctrine + harness + knowledge/journal/research/visuals **listings** and returns them via `jsonResult({ context })` — to the **orchestrator** (the host agent running the skill). It never writes onto the job. +2. `tenet_start_job` takes only `job_id` (`src/mcp/tools/tenet-start-job.ts`) — there is no parameter to pass context through. +3. `toInvocation` (`src/core/job-manager.ts:628`) reads `context = job.params.context` (`:635`) — and **nothing in the codebase ever sets `job.params.context`** (verified by grep; the only reader is `:635`, there is no writer). Not `tenet_register_jobs`, not retry, not anything. +4. All three adapters fall through to their `else` branch (`claude-adapter.ts:48`, `codex-adapter.ts:17`, `opencode-adapter.ts:17`) → the worker receives **only** `withDevPreamble(taskPrompt)` (`job-manager.ts:689`) — the Deliverable Requirements / Smoke Check / Git Commit / doctrine-drift boilerplate plus the job's task text. + +**What the worker never sees:** spec, decomposition (DAG context / sibling-job rationale), project architecture/doctrine, harness, or even the run directory path — except a narrow mention of `${run_path}/journal/` that appears **only in the retry note** (`job-manager.ts:693,699`), only for failure logs. + +**Second instance of the same bug:** the **report-only scope preamble** (`tenet-compile-context.ts:237–256`, inlined at `:269`) is instructions *for the worker* ("You are in REPORT-ONLY mode. You MUST NOT edit project files…") but it lives in `tenet_compile_context`, so it reaches the orchestrator, not the worker. Report-only workers are missing their scope instructions. + +**Why runs have been fine anyway:** frontier on both ends. The orchestrator lived the context and self-serves by `Read`-ing files; the worker is usually frontier too, so it explores `.tenet/runs//`, finds `spec.md`, and recovers. The gap is a **silent tax frontier pays and a catastrophic one a local/weak model pays** — which is exactly why surface this now, alongside the local-model work. + +**The original sin:** the docs claim worker delivery that never happens: +- `skills/tenet/phases/04-decomposition.md:182` — "implementation workers read the same spec/harness/scenarios/interview/decomposition…" +- `skills/tenet/phases/05-execution-loop.md:164` — "tenet_compile_context prepends a Report-Only Scope preamble telling the worker…" + +These must be corrected as part of U2, or we leave the same landmine that caused this investigation. + +--- + +## Decisions and rationale (do not re-litigate without new evidence) + +1. **`tenet_compile_context` stays, as the orchestrator's context aid.** Keep the name. It is a convenience aggregator (one call returns the assembled, drift-safe bundle via exact `artifact_paths`); the orchestrator could `Read` files itself but benefits from the call. Measured cost is acceptable (~5k words/run on real projects). It is **not** a worker-context mechanism and must stop being documented as one. + +2. **Worker context is a separate mechanism, in the dispatch path.** Built server-side in `toInvocation` from the job's stored `run_path` + `artifact_paths`, set on `invocation.context` (the half-built bridge — adapters already prepend it). Do **not** route worker context back through `tenet_compile_context`, or we recreate the original confusion. + +3. **`model_tier` is binary: `local | frontier`.** No middle tier — only Local and Frontier have been tested; a middle value is speculation (the user's own `note.md` #9 floated three, then retired it). Default/absent = `frontier` = today's behavior, byte-identical. + +4. **`model_tier` modulates ONE thing: decomposition granularity (phase 04).** Worker context is uniform — spec + decomposition are inlined for every worker regardless of tier. The decomposition *content* already carries the tier signal (detailed for `local`, coarse for `frontier`), so inlining it always propagates the right detail for free — no second knob on the delivery mechanism. `model_tier` does not touch worker dispatch, jobs, spec, critics, or eval. (This dissolves the earlier "plan-detailness vs worker-hand-holding" conflation — was gap-review item 1, now resolved.) Spec strengthening (U4) is tier-independent. + +5. **`model_tier` is static per run; the model running each role is a dynamic user choice.** The field is set once at planning. The frontier→local orchestrator handoff (a local model takes over the loop once planning is done) is a user-controlled runtime decision, not encoded in `model_tier`. The orchestrator can be a local model — so the role-preamble must be **robust by default**, strong enough to hold the weakest model that might orchestrate. + +6. **`model_tier` is an interview decision, not a persisted field.** It is asked in the interview alongside `delivery_mode` (one post-interview checkpoint for both mode-like decisions), recorded in the interview transcript, and consumed once by decomposition. Unlike `delivery_mode` (read continuously across the run → needs a durable frontmatter handle), `model_tier` is read once, immediately, in the same session that asked it — so it is **not** stored as a field anywhere (no spec frontmatter, no job param, no DB column). Its effect lives in the decomposition artifact. The concept is capability-framed (local/frontier) for the interview/docs; since there is no persisted field, there is no field-name collision with the overloaded "mode" vocabulary — only consistent wording in the phase prompts. + +7. **Orchestrator role-preamble is a runtime constant, not tier-modulated.** Robust-by-default (decision 5). `note.md` #21 (baking tenet rules into CLAUDE.md/AGENTS.md as system prompt) remains as the **heavy fallback** if the runtime preamble alone cannot hold a weak orchestrator — not built now. + +8. **#13 (weak-model critics shallow-pass) stays parked.** Covered today by custom critics (`.tenet/critics.json`); future multi-model/dynamic critics will address it. Out of scope here. + +--- + +## Work units + +### U1 — Worker baseline context (ships first) + +**Goal:** the worker knows where the run docs live and is told to read them, on every job, regardless of tier. + +**Change:** in `toInvocation` (`src/core/job-manager.ts:628`), replace the dead `job.params.context` read with a server-side builder: + +``` +const context = this.buildWorkerContext(job); +``` + +`buildWorkerContext(job)` returns a `## Run Context` block assembled from the job's stored `run_path` + `artifact_paths` (spec/decomposition/harness), using the already-exported `readArtifactFile` (`src/mcp/tools/artifact-paths.ts`) — no duplication with compile_context. The baseline is **tier-independent and always inlines** the foundational planning docs (the worker is fresh-context and shouldn't have to explore for them): inline `spec.md` + `decomposition.md` + `harness.md` (small + universal); path-reference the bulky/selective ones (`journal/`, `research/`, `visuals/`). e.g.: + +``` +## Run Context +run_path: .tenet/runs/2026-07-02-user-auth +feature: user-auth + +## Spec (inlined) + + +## Decomposition (inlined — already tier-appropriate: detailed for local, coarse for frontier) + + +## Harness (inlined) + + +## Selective references (read if relevant) +journal/ · research/ · visuals/ (under run_path above) +``` + +Set this on `invocation.context` — all three adapters already prepend it (`claude-adapter.ts:48`, `codex-adapter.ts:17`, `opencode-adapter.ts:17`). No orchestrator change, no new tool. + +**Migrate the report-only preamble:** move the Report-Only Scope block out of `tenet-compile-context.ts:237–256,269` into the worker dispatch path (a `report_only` branch in `withDevPreamble`, or in `buildWorkerContext`). It is worker-bound and currently lands on the orchestrator. + +**Invariant:** every dispatched worker invocation carries `run_path` + **inlined** spec/decomposition/harness + path-refs to journal/research/visuals + a read directive; report-only workers additionally carry the Report-Only Scope block. **Identical shape for both tiers** — `model_tier` does not branch this. + +**Tests:** +- FakeAdapter captures the dispatched prompt; assert it contains `run_path` + **inlined** spec/decomposition/harness contents + the read directive, for a registered dev job (same for both tiers). +- A `report_only: true` job's dispatched prompt contains the Report-Only Scope block. +- Default-path jobs (no tier) still pass existing tests (no regression). + +**Tier-link:** none — this is the baseline. + +--- + +### U2 — compile_context reframe + orchestrator role-preamble + doc fixes (independent) + +**Goal:** make `tenet_compile_context`'s stated purpose match reality (orchestrator aid), and re-assert orchestrator discipline every cycle. + +**Change A — role-preamble at top of output.** In `tenet-compile-context.ts`, prepend a constant block above `# Compiled Context`: + +> **You are the orchestrator, not the worker.** Do not implement code directly. Every implementation action goes through `tenet_start_job`. If you are unsure of the loop rules, re-read the current phase file (`phases/05-execution-loop.md`) before acting. + +Robust enough for a local orchestrator (decision 5). It fires once per cycle, right before dispatch — the moment the orchestrator is tempted to "just fix it myself." + +**Change B — doc fixes (mandatory).** +- `skills/tenet/phases/04-decomposition.md:182` — rewrite "implementation workers read…" to "this assembles the **orchestrator's** working context; workers receive their own context via the dispatch path." +- `skills/tenet/phases/05-execution-loop.md:164` — the Report-Only Scope preamble now lives in the worker dispatch path, not compile_context. +- `skills/tenet/SKILL.md` invariant #2 ("Context is compiled per job…") — keep, re-rationale as orchestrator context. +- `CLAUDE.md` — update the "Deliverable Requirements preamble" note to mention it now carries `run_path`/`artifact_paths` (U1) and that compile_context is the orchestrator's aid. +- After U1's migration: remove the report-only preamble block from `tenet-compile-context.ts`. + +**Invariant:** `tenet_compile_context` return begins with the role-preamble and contains no worker-bound instructions. + +**Tests:** compile_context return starts with the role-preamble; no `report_only` block in its output after migration. + +**Tier-link:** none. + +--- + +### U3 — model_tier (prompt-only, independent) + +**Goal:** let the run declare its executioner tier so decomposition adapts. + +**No persisted field.** `model_tier` is an **interview decision**, consumed once by decomposition: +- **Asked in the interview** (phase 01) alongside `delivery_mode`. +- **Recorded in the interview transcript** (`.tenet/runs//interview.md`) — already a persisted run artifact. +- **Consumed once by decomposition** (phase 04): read from session context, or re-read from the transcript if compaction fired between interview and decomposition. `local` → detailed DAG (many small, single-responsibility jobs, explicit per-job acceptance criteria); `frontier` → today's goal-oriented DAG. + +Why no field: it is read **once, immediately after it is asked, in the same session** that holds the interview answer. Contrast `delivery_mode`, which is read continuously (every slice, redirect router, status) and so genuinely needs a durable frontmatter handle. `model_tier`'s effect is captured in the **decomposition artifact** (detailed vs coarse), which is what downstream actually consumes — the decision itself does not need to persist separately. + +**No TypeScript code change for `model_tier`** — two skill-doc edits: phase 01 asks the question, phase 04 branches on the answer. No frontmatter field, no job param, no dispatch read, no DB column. + +**Invariant:** `frontier` (or absent) → decomposition instructions byte-identical to today. + +**Tests / verification:** +- Skill-doc review: phase 04 contains tier-conditional decomposition instructions. +- Real-run watch (consistent with other recently-shipped prompt-level work): confirm a `local`-tier run produces a finer-grained DAG than `frontier`. + +**Tier-link:** yes — single consumer (decomposition). Independent of U1 (worker context is uniform). + +**Composes with agile mode:** a run may be `delivery_mode: agile` + `model_tier: local`; decomposition then reads both fields (sliced + detailed). Add a composition test. + +--- + +### U4 — Spec research strengthening (independent) + +**Goal:** more accurate specs — benefits every tier, every run. + +**Change:** strengthen the phase-02 spec-writing prompt for **research accuracy** (verify claims against the actual codebase; cite real files), and strengthen `tenet_validate_readiness`'s spec-completeness checks (the gate that refuses to let a thin spec through to decomposition). + +**Why both:** prompt = the instruction, gate = the verification, pointing at one contract. A raised gate without the strengthened prompt fails incoherently; a strengthened prompt without the gate is advisory. Since spec-writing runs on a frontier model, there is no over-constraint risk. + +**Invariant:** a spec that passes the strengthened gate demonstrably cites real project files/structure. + +**Tests:** readiness gate rejects a placeholder/vague spec fixture that the old gate accepted. + +**Tier-link:** none. + +--- + +## Gap review (seams, assumptions, deferred) + +1. ~~`model_tier` conflates "plan detailness" and "worker needs hand-holding."~~ **Resolved** — worker context is now uniform (always inline), so `model_tier` has a single effect (decomposition granularity). No conflation, no second knob. +2. **`model_tier` static vs role-model-choice dynamic** — do not couple them (decision 5). The role-preamble is robust regardless of which model orchestrates. +3. **Two context consumers, one artifact-reader helper** — orchestrator via `tenet_compile_context`, worker via `toInvocation`/`buildWorkerContext`, both reuse `readArtifactFile`. Keep the paths separate. +4. **Composes with agile mode** (two decomposition modifiers). Low risk; add a composition test. +5. **#21 (CLAUDE.md/AGENTS.md system-prompt rules)** — heavy fallback if U2's runtime preamble can't hold a weak orchestrator. Triggered only if observed, not built now. +6. **#13 (weak-model critics)** — parked; custom critics today, multi-model/dynamic critics later. +7. **Naming** — `model_tier: local|frontier` collides with nothing. `tenet_compile_context` keeps its name. + +--- + +## Non-goals + +- A third (middle) model tier. +- Routing worker context through `tenet_compile_context`. +- Renaming `tenet_compile_context`. +- Per-stage / per-critic model selection or a tier→model binding (#24, #25) — `model_tier` sets plan granularity only; it does not select a model. +- Auto-detecting the actual worker model — `model_tier` is a **declaration** (asked in interview), not a detection. Tenet tracks the adapter (which CLI) via `default_agent`/`agent_override`, never the model. Worker context is built *before* execution, so it can only rely on the declaration; detection from worker output would arrive too late to shape that job's context. +- #13 critic-capability work. + +--- + +## Acceptance criteria + +| # | Criterion | Test | +|---|---|---| +| AC1 | A dispatched dev worker's prompt contains `run_path` + **inlined** spec/decomposition/harness + path-refs + read directive | FakeAdapter prompt assertion | +| AC2 | A `report_only` worker's prompt contains the Report-Only Scope block; compile_context output does **not** | both assertions | +| AC3 | compile_context return begins with the orchestrator role-preamble | unit test | +| AC4 | Docs (`04:182`, `05:164`, SKILL invariant #2, CLAUDE.md) describe compile_context as orchestrator aid and worker context as separate | review | +| AC5 | `model_tier` absent or `frontier` → decomposition prompt + worker context byte-identical to today | snapshot test | +| AC6 | phase-04 decomposition instructions branch on executioner tier (detailed for `local`) | skill-doc review + real-run watch | +| AC7 | `model_tier` is an interview decision; **no persisted field** anywhere (not spec frontmatter, not jobs, not DB) | skill-doc review | +| AC8 | agile + local composes (sliced + detailed decomposition) | composition test | +| AC9 | Strengthened readiness gate rejects a vague/placeholder spec the old gate accepted | fixture test | + +--- + +## Sequence + +1. **U1** (worker baseline context + report-only migration) — correctness, immediate. +2. **U2** (compile_context reframe + role-preamble + doc fixes) — correctness, independent; can land with U1. +3. **U3** (`model_tier`) — prompt-only (phase 01 asks, phase 04 branches); independent of U1. +4. **U4** (spec strengthening) — independent, anytime. + +All four are independently shippable. No DB migration, no new MCP tool, no breaking change to the default path. + +--- + +## References + +- Code: `src/core/job-manager.ts` (`toInvocation:628`, `withDevPreamble:689`), `src/mcp/tools/tenet-compile-context.ts`, `src/mcp/tools/artifact-paths.ts` (`readArtifactFile`), `src/mcp/tools/tenet-register-jobs.ts`, `src/adapters/{claude,codex,opencode}-adapter.ts`. +- Skills: `skills/tenet/phases/02-spec-and-harness.md` (`delivery_mode` precedent), `04-decomposition.md`, `05-execution-loop.md`, `skills/tenet/SKILL.md`. +- Prior art: `docs/planning/14_agile_mode.md` (same prompt-driven, no-new-tool pattern; `delivery_mode` field precedent). +- `note.md` open items this advances/addresses: #9 (model-aware planning), #21 (runtime portion), and surfaces the wiring gap behind #13's "blind execution" observation. diff --git a/docs/planning/18_model_tier_simulator.html b/docs/planning/18_model_tier_simulator.html new file mode 100644 index 0000000..c5f1c08 --- /dev/null +++ b/docs/planning/18_model_tier_simulator.html @@ -0,0 +1,444 @@ + + + + + +Tenet — Model Tier & Worker Context Simulator + + + + +
+

Tenet — Model Tier & Worker Context

+
Interactive simulator for the design in 18_model_tier_and_worker_context.md
+
Toggle the controls to see how context flow, decomposition granularity, and the worker prompt change.
+
+ +
+
+ model_tier +
+ + +
+
+
+ worker context +
+ + +
+
+
+ +
+ + +
+

1Two context paths

+

+ Today there is a half-built bridge. tenet_compile_context returns the assembled + context to the orchestrator only; the worker subprocess is dispatched with just the + dev preamble + task. The fix (U1) builds the worker's context in the dispatch path — these stay + two separate mechanisms. +

+
+
+
+

Orchestrator path

+
host agent running the tenet skill · frontier or local
+
+
tenet_compile_context(job_id)
+
assembles spec · decomposition · project doctrine · harness · listings
+
+
↓ returns to
+
+
Orchestrator context U2 role-preamble
+
"you are the orchestrator, not the worker — dispatch via tenet_start_job"
+
+
+
+

Worker path

+
fresh subprocess · context-cold · tier = frontier
+
+
tenet_start_job → toInvocation → adapter
+
builds worker context from stored run_path + artifact_paths
+
+
↓ prepends as
+
+
Worker prompt
+
Run Context + dev preamble + task
+
+
+
+
+
+
+ + +
+

2Decomposition granularity

+

+ model_tier changes the shape of the DAG the decomposition phase produces. + Frontier = goal-oriented jobs with latitude; Local = smaller, single-responsibility jobs with + explicit acceptance criteria. +

+
+
+
+
+ frontier shape + local shape +
+
+
+ + +
+

3What the worker actually receives

+

+ The exact prompt sent to the worker subprocess. Flip Before fix to see the blind-worker gap. + Both tiers get spec + decomposition + harness inlined — only the decomposition content differs + (detailed for local, coarse for frontier). +

+
+

+    
+
+ + +
+

4One job cycle, step through

+

What happens between tenet_continue and the eval gate, with the fix applied.

+
+
+
+
+
+ +
+ +
+ Companion to docs/planning/18_model_tier_and_worker_context.md · Tenet design simulator +
+ + + + From b91011734339ed23d28b3133a1cc55581f612763 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 09:49:54 +0900 Subject: [PATCH 27/87] fix(worker): inline run docs into worker dispatch context (U1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker subprocess never received compiled context: tenet_compile_context returns its bundle to the orchestrator, while toInvocation read job.params.context — a field nothing in the codebase ever wrote (dead read). Workers ran blind, recovering only by exploring .tenet/ themselves (a silent tax frontier pays and a catastrophic one a local/weak model pays). Close the gap by building the worker context server-side in toInvocation: - buildWorkerContext(job) inlines the foundational run docs (spec/decomposition/ harness) and path-references the bulky/selective ones (journal/research/visuals), set on invocation.context (which all three adapters already prepend). - Degrades to undefined for legacy jobs with no run_path/artifact_paths, so the default dispatch path stays byte-identical. - Dangling artifact paths degrade to empty string rather than crashing the dispatch. - Tier-independent: the decomposition artifact already carries the run's granularity, so inlining it always propagates the right detail. Migrate the Report-Only Scope preamble out of tenet_compile_context (where it only reached the orchestrator) into the worker dispatch path, where it belongs. Relocate artifact-paths.ts from src/mcp/tools/ to src/core/ so core can reuse readArtifactFile without inverting the core<-mcp layering. Pure module (fs/path/zod only); no behavior change. Tests (job-manager-worker-context.test.ts): FakeAdapter captures the dispatched invocation and asserts inlined docs + run_path + read directive reach the worker; report-only workers carry the scope block; legacy jobs get undefined context; dangling paths degrade gracefully. Co-Authored-By: Claude --- src/{mcp/tools => core}/artifact-paths.ts | 0 src/core/job-manager-worker-context.test.ts | 181 ++++++++++++++++++++ src/core/job-manager.ts | 108 +++++++++++- src/mcp/tools/tenet-compile-context.ts | 22 +-- src/mcp/tools/tenet-register-jobs.ts | 2 +- src/mcp/tools/tenet-update-knowledge.ts | 2 +- src/mcp/tools/tenet-validate-readiness.ts | 2 +- 7 files changed, 292 insertions(+), 25 deletions(-) rename src/{mcp/tools => core}/artifact-paths.ts (100%) create mode 100644 src/core/job-manager-worker-context.test.ts diff --git a/src/mcp/tools/artifact-paths.ts b/src/core/artifact-paths.ts similarity index 100% rename from src/mcp/tools/artifact-paths.ts rename to src/core/artifact-paths.ts diff --git a/src/core/job-manager-worker-context.test.ts b/src/core/job-manager-worker-context.test.ts new file mode 100644 index 0000000..fb7a372 --- /dev/null +++ b/src/core/job-manager-worker-context.test.ts @@ -0,0 +1,181 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { AdapterRegistry } from '../adapters/index.js'; +import type { AgentInvocation } from '../adapters/base.js'; +import { FakeAdapter } from '../adapters/fake-adapter.js'; +import { JobManager } from './job-manager.js'; +import { StateStore } from './state-store.js'; + +// Verifies the worker-context bridge (U1): toInvocation builds invocation.context from the +// job's stored run_path + artifact_paths so the fresh-context worker subprocess receives +// the foundational run docs (spec/decomposition/harness) inline — it no longer has to +// explore .tenet/ blind. The FakeAdapter captures the dispatched invocation via onInvoke. + +const stores: StateStore[] = []; +const dirs: string[] = []; + +const setup = (): { + projectPath: string; + store: StateStore; + manager: JobManager; + captured: AgentInvocation[]; +} => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'tenet-wctx-')); + dirs.push(projectPath); + + const store = new StateStore(projectPath); + stores.push(store); + store.setConfig('agent_override_dev', 'fake'); + store.setConfig('agent_override_eval', 'fake'); + + const captured: AgentInvocation[] = []; + const registry = new AdapterRegistry(); + (registry as unknown as { adapters: Map }).adapters.clear(); + registry.register( + new FakeAdapter([], { + onMiss: 'return-empty', + onInvoke: (inv) => { + captured.push(inv); + }, + }), + ); + + const manager = new JobManager(store, registry, { + heartbeatTimeoutMs: 5_000, + defaultJobTimeoutMs: 5_000, + }); + + return { projectPath, store, manager, captured }; +}; + +const writeRunDocs = (projectPath: string, runPath: string): void => { + const abs = path.join(projectPath, runPath); + fs.mkdirSync(abs, { recursive: true }); + fs.writeFileSync(path.join(abs, 'spec.md'), '# Worker Spec\n\nSpec body for the worker.'); + fs.writeFileSync(path.join(abs, 'decomposition.md'), '# Worker Decomposition\n\nDAG body for the worker.'); + fs.writeFileSync(path.join(abs, 'harness.md'), '# Worker Harness\n\nHarness body for the worker.'); + fs.mkdirSync(path.join(abs, 'journal'), { recursive: true }); +}; + +afterEach(() => { + while (stores.length > 0) stores.pop()?.close(); +}); + +describe('worker baseline context (buildWorkerContext via dispatch)', () => { + it('inlines spec/decomposition/harness + run_path + read directive into the worker context', async () => { + const { projectPath, manager, captured } = setup(); + const runPath = '.tenet/runs/2026-07-02-worker-ctx'; + writeRunDocs(projectPath, runPath); + + const job = manager.createPendingJob('dev', { + name: 'impl-core', + prompt: 'Implement the core feature.', + feature: 'worker-ctx', + run_path: runPath, + artifact_paths: { + spec: `${runPath}/spec.md`, + harness: `${runPath}/harness.md`, + decomposition: `${runPath}/decomposition.md`, + }, + }); + + manager.dispatchJob(job.id); + const wait = await manager.waitForJob(job.id, null, 5_000); + + expect(wait.is_terminal).toBe(true); + expect(captured).toHaveLength(1); + + const ctx = captured[0].context ?? ''; + expect(ctx).toContain('## Run Context (worker)'); + expect(ctx).toContain(`run_path: ${runPath}`); + expect(ctx).toContain('feature: worker-ctx'); + // Foundational docs are inlined (the worker is fresh-context — it must not have to explore). + expect(ctx).toContain('# Worker Spec'); + expect(ctx).toContain('# Worker Decomposition'); + expect(ctx).toContain('# Worker Harness'); + // Bulky/selective docs are path-referenced, not inlined. + expect(ctx).toContain('journal/'); + expect(ctx).toContain('research/'); + expect(ctx).toContain('visuals/'); + // Read directive. + expect(ctx).toContain('Do not work blind from the task text alone'); + // A non-report-only worker must not carry the report-only scope block. + expect(ctx).not.toContain('## Report-Only Scope'); + }); + + it('adds the Report-Only Scope block to a report-only worker', async () => { + const { projectPath, manager, captured } = setup(); + const runPath = '.tenet/runs/2026-07-02-report-only'; + writeRunDocs(projectPath, runPath); + + const job = manager.createPendingJob('dev', { + name: 'final-sweep', + prompt: 'Verify the feature end to end and report.', + feature: 'report-only', + run_path: runPath, + report_only: true, + artifact_paths: { + spec: `${runPath}/spec.md`, + harness: `${runPath}/harness.md`, + decomposition: `${runPath}/decomposition.md`, + }, + }); + + manager.dispatchJob(job.id); + await manager.waitForJob(job.id, null, 5_000); + + expect(captured).toHaveLength(1); + const ctx = captured[0].context ?? ''; + expect(ctx).toContain('## Report-Only Scope'); + expect(ctx).toContain('tenet_report_blocking_finding'); + expect(ctx).toContain(job.id); + }); + + it('leaves context undefined for a legacy job with no run_path or artifact_paths', async () => { + const { manager, captured } = setup(); + + const job = manager.createPendingJob('dev', { + name: 'legacy-quick', + prompt: 'Quick fix with no run docs.', + }); + + manager.dispatchJob(job.id); + await manager.waitForJob(job.id, null, 5_000); + + expect(captured).toHaveLength(1); + // Default dispatch path stays byte-identical when there is nothing worker-specific to inject. + expect(captured[0].context).toBeUndefined(); + }); + + it('degrades gracefully when an inlined artifact path dangles', async () => { + const { projectPath, manager, captured } = setup(); + const runPath = '.tenet/runs/2026-07-02-dangling'; + writeRunDocs(projectPath, runPath); + // Point decomposition at a path that does not exist on disk. + fs.unlinkSync(path.join(projectPath, runPath, 'decomposition.md')); + + const job = manager.createPendingJob('dev', { + name: 'impl-core', + prompt: 'Implement the core feature.', + feature: 'dangling', + run_path: runPath, + artifact_paths: { + spec: `${runPath}/spec.md`, + harness: `${runPath}/harness.md`, + decomposition: `${runPath}/decomposition.md`, + }, + }); + + manager.dispatchJob(job.id); + const wait = await manager.waitForJob(job.id, null, 5_000); + + // The dispatch must not crash on a dangling doc — partial context beats blocking the job. + expect(wait.is_terminal).toBe(true); + const ctx = captured[0].context ?? ''; + expect(ctx).toContain('# Worker Spec'); + expect(ctx).toContain('# Worker Harness'); + expect(ctx).not.toContain('# Worker Decomposition'); + }); +}); diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 8fb1470..018bedc 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -11,6 +11,53 @@ import { } from './runtime-config.js'; import { StateStore } from './state-store.js'; import { DEFAULT_EVAL_STAGES } from './critic-roster.js'; +import { readArtifactFile, type ArtifactPaths } from './artifact-paths.js'; + +/** + * Extract a typed {@link ArtifactPaths} from an untyped `job.params.artifact_paths` + * value. Returns undefined for missing/non-object values so the worker-context + * builder degrades gracefully for legacy/quick jobs that carry no exact paths. + */ +const getJobArtifactPaths = (value: unknown): ArtifactPaths | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + return value as ArtifactPaths; +}; + +/** + * Read an artifact file for the worker, degrading to empty string if the path dangles. + * Unlike the strict {@link readArtifactFile} used at registration (which throws to fail + * fast on a bad path), the worker dispatch path must not crash a whole job because one + * doc is missing post-migration — partial context beats blocking the job. + */ +const safeReadArtifact = (projectPath: string, relativePath: string, label: string): string => { + try { + return readArtifactFile(projectPath, relativePath, label); + } catch { + return ''; + } +}; + +/** + * Report-Only Scope block — worker-bound instructions for report-only jobs. Lives in the + * worker dispatch path (not `tenet_compile_context`) because it must reach the worker + * subprocess, which never sees compile_context output. + */ +const reportOnlyScopeLines = (jobId: string): string[] => [ + '## Report-Only Scope', + '', + 'You are in REPORT-ONLY mode. You MUST NOT edit project files (other than writing your final report).', + '', + 'If verification reveals a blocking finding that must be resolved before this report can be trustworthy:', + '', + `1. Call \`tenet_report_blocking_finding({ job_id: "${jobId}", finding, why_it_blocks_report, recommended_followup, suspected_files })\`.`, + '2. Your job will be paused (status: blocked_on_finding).', + '3. A linked child dev job will investigate/resolve the finding and pass its own evals.', + '4. Your job will auto-resume with fresh context once the finding is resolved.', + '', + 'Do NOT edit files yourself. Do NOT silently work around the bug. Do NOT abandon the report.', +]; type JobManagerConfig = { maxParallelAgents?: number; @@ -632,7 +679,7 @@ export class JobManager { : job.type === 'integration_test' ? this.withIntegrationTestPreamble(rawPrompt, job) : rawPrompt; - const context = typeof job.params.context === 'string' ? job.params.context : undefined; + const context = this.buildWorkerContext(job); const maxTurnsRaw = job.params.maxTurns; const maxTurns = @@ -686,6 +733,65 @@ export class JobManager { }; } + /** + * Build the worker's run context (set on `invocation.context`, which every adapter + * prepends to the task prompt). The worker is a fresh-context subprocess — unlike the + * orchestrator it never sees `tenet_compile_context` output — so the foundational run + * docs (spec / decomposition / harness) are inlined here and the bulky/selective ones + * (journal / research / visuals) are path-referenced. Tier-independent and identical + * for every run: the decomposition artifact already carries whatever granularity the + * run needs, so inlining it always propagates the right level of detail. + * + * Returns undefined when there is nothing worker-specific to inject (e.g. a legacy job + * with no run_path/artifact_paths) so the default dispatch path stays byte-identical. + */ + private buildWorkerContext(job: Job): string | undefined { + const projectPath = this.stateStore.projectPath; + const runPath = typeof job.params.run_path === 'string' ? job.params.run_path : undefined; + const feature = typeof job.params.feature === 'string' ? job.params.feature : ''; + const artifactPaths = getJobArtifactPaths(job.params.artifact_paths); + const reportOnly = job.params.report_only === true; + + const specMd = artifactPaths?.spec ? safeReadArtifact(projectPath, artifactPaths.spec, 'spec') : ''; + const decompositionMd = artifactPaths?.decomposition + ? safeReadArtifact(projectPath, artifactPaths.decomposition, 'decomposition') + : ''; + const harnessMd = artifactPaths?.harness ? safeReadArtifact(projectPath, artifactPaths.harness, 'harness') : ''; + + if (!runPath && !specMd && !decompositionMd && !harnessMd && !reportOnly) { + return undefined; + } + + const sections: string[] = ['## Run Context (worker)']; + if (feature) sections.push(`feature: ${feature}`); + if (runPath) sections.push(`run_path: ${runPath}`); + + if (specMd) sections.push('', '## Spec (inlined — source of truth for this job)', specMd); + if (decompositionMd) { + sections.push('', '## Decomposition (inlined — your job and its DAG context)', decompositionMd); + } + if (harnessMd) sections.push('', '## Harness (inlined)', harnessMd); + + if (runPath) { + sections.push( + '', + '## Selective references (read the ones relevant to this job)', + `Under ${runPath}/: journal/ (prior attempts + failure logs), research/ (current-run research), visuals/ (UI/architecture mockups).`, + ); + } + + sections.push( + '', + 'Do not work blind from the task text alone — read the run docs above first; they are the source of truth.', + ); + + if (reportOnly) { + sections.push('', ...reportOnlyScopeLines(job.id)); + } + + return sections.join('\n'); + } + private withDevPreamble(prompt: string, job: Job): string { const feature = typeof job.params.feature === 'string' ? job.params.feature : ''; const jobName = typeof job.params.name === 'string' ? job.params.name : job.id.slice(0, 8); diff --git a/src/mcp/tools/tenet-compile-context.ts b/src/mcp/tools/tenet-compile-context.ts index 4344148..f01d4ef 100644 --- a/src/mcp/tools/tenet-compile-context.ts +++ b/src/mcp/tools/tenet-compile-context.ts @@ -8,7 +8,7 @@ import { resolveLatestScenariosDoc, toProjectRelativePath, type ArtifactPaths, -} from './artifact-paths.js'; +} from '../../core/artifact-paths.js'; import { jsonResult, type RegisterTool } from './utils.js'; const readIfExists = (filePath: string): string => { @@ -236,25 +236,6 @@ export const registerTenetCompileContextTool = (registerTool: RegisterTool, stat const jobDeps = Array.isArray(job.params.depends_on) ? (job.params.depends_on as string[]).join(', ') : 'none'; const reportOnly = job.params.report_only === true; - const reportOnlyPreamble = reportOnly - ? [ - '', - '## Report-Only Scope', - '', - 'You are in REPORT-ONLY mode. You MUST NOT edit project files (other than writing your final report).', - '', - 'If verification reveals a blocking finding that must be resolved before this report can be trustworthy:', - '', - `1. Call \`tenet_report_blocking_finding({ job_id: "${job.id}", finding, why_it_blocks_report, recommended_followup, suspected_files })\`.`, - '2. Your job will be paused (status: blocked_on_finding).', - '3. A linked child dev job will investigate/resolve the finding and pass its own evals.', - '4. Your job will auto-resume with fresh context once the finding is resolved.', - '', - 'Do NOT edit files yourself. Do NOT silently work around the bug. Do NOT abandon the report.', - '', - ].join('\n') - : ''; - const compiled = [ `# Compiled Context`, `job_id: ${job.id}`, @@ -266,7 +247,6 @@ export const registerTenetCompileContextTool = (registerTool: RegisterTool, stat ...(artifactPaths ? [`artifact_paths: ${JSON.stringify(artifactPaths)}`] : []), `job_dependencies: ${jobDeps}`, ...(reportOnly ? ['report_only: true'] : []), - reportOnlyPreamble, '## Job Assignment', jobPrompt, ...projectDocs, diff --git a/src/mcp/tools/tenet-register-jobs.ts b/src/mcp/tools/tenet-register-jobs.ts index fd8d490..23219e2 100644 --- a/src/mcp/tools/tenet-register-jobs.ts +++ b/src/mcp/tools/tenet-register-jobs.ts @@ -6,7 +6,7 @@ import { normalizeArtifactPaths, toProjectRelativePath, type ArtifactPaths, -} from './artifact-paths.js'; +} from '../../core/artifact-paths.js'; import { jsonResult, type RegisterTool } from './utils.js'; const jobEntrySchema = z.object({ diff --git a/src/mcp/tools/tenet-update-knowledge.ts b/src/mcp/tools/tenet-update-knowledge.ts index 302582a..adf4083 100644 --- a/src/mcp/tools/tenet-update-knowledge.ts +++ b/src/mcp/tools/tenet-update-knowledge.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { z } from 'zod'; import { StateStore } from '../../core/state-store.js'; -import { toProjectRelativePath } from './artifact-paths.js'; +import { toProjectRelativePath } from '../../core/artifact-paths.js'; import { jsonResult, type RegisterTool } from './utils.js'; const slugify = (text: string): string => diff --git a/src/mcp/tools/tenet-validate-readiness.ts b/src/mcp/tools/tenet-validate-readiness.ts index 30ac5f7..c4edb0d 100644 --- a/src/mcp/tools/tenet-validate-readiness.ts +++ b/src/mcp/tools/tenet-validate-readiness.ts @@ -12,7 +12,7 @@ import { resolveLatestScenariosDoc, toProjectRelativePath, type ArtifactPaths, -} from './artifact-paths.js'; +} from '../../core/artifact-paths.js'; import { jsonResult, type RegisterTool } from './utils.js'; const READINESS_RUBRIC = `Score this feature's IMPLEMENTATION READINESS. You are reading the spec + harness (+ optional interview) and deciding whether the agent has enough information to BUILD AND VERIFY the feature. From bce6605ed47f3b19a562565145dcf1d383c0e5c4 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 09:53:26 +0900 Subject: [PATCH 28/87] feat(compile-context): reframe as orchestrator aid + fix worker-delivery docs (U2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tenet_compile_context assembles context for the orchestrator (the host agent running the skill), not the worker — its return value was never forwarded to the worker subprocess. Make the stated purpose match reality: - Prepend an orchestrator role-preamble at the top of the compiled output ("You are the orchestrator, not the worker..."), robust enough to hold even a local/weak model that might be tempted to implement directly. Fires once per cycle, right before dispatch. - Update the tool description to state the context returns to the orchestrator only. Doc fixes (the docs claimed worker delivery that never happened — the same landmine that caused the investigation): - 04-decomposition.md: workers receive their own context on dispatch, not via tenet_compile_context. - 05-execution-loop.md: the Report-Only Scope preamble now lives in the worker dispatch path (moved in U1), not compile_context. - SKILL.md invariant #2: compile_context is the orchestrator's working context; workers get their own run context on dispatch. - CLAUDE.md: note the worker run-context block built on dispatch (U1) and that compile_context is the orchestrator's aid. Test: compile_context output starts with the role-preamble and contains no Report-Only Scope block. Co-Authored-By: Claude --- CLAUDE.md | 4 +-- skills/tenet/SKILL.md | 2 +- skills/tenet/phases/04-decomposition.md | 2 +- skills/tenet/phases/05-execution-loop.md | 2 +- src/mcp/tools/tenet-compile-context.test.ts | 29 +++++++++++++++++++++ src/mcp/tools/tenet-compile-context.ts | 10 +++++-- 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8d34e87..ca23312 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,11 +73,11 @@ Tenet uses a document lifecycle layout. `tenet init` scaffolds only this layout; - **Portable snapshots** — `.tenet/state-snapshot/` (Git-safe snapshots from `tenet db snapshot`). - **Configurable eval critics** — `.tenet/critics.json` (roster of enabled built-in + custom critics) and `.tenet/critics/*.md` (custom-critic prompts). Read live by `tenet_start_eval` on every eval; missing/invalid file falls back to the 3 built-ins. Authored via the critic-designer doc `skills/tenet/critics.md`. The blocking-finding resume gate tracks the same configured set via the `expected_eval_stages` each critic job carries. -Current-run document identity flows through `artifact_paths`: `tenet_validate_readiness` validates exact spec/harness/scenarios/interview paths, `tenet_register_jobs` stores those plus `decomposition` (and `run_path`/`run_slug`) on every job, and `tenet_compile_context` reads the stored paths. Feature-only filename lookup is a compatibility fallback only; it uses strict dated document patterns rather than loose `*-{feature}.md` matching. +Current-run document identity flows through `artifact_paths`: `tenet_validate_readiness` validates exact spec/harness/scenarios/interview paths, `tenet_register_jobs` stores those plus `decomposition` (and `run_path`/`run_slug`) on every job, and `tenet_compile_context` reads the stored paths. `tenet_compile_context` assembles the **orchestrator's** working context (its output returns to the host agent running the skill — it is not forwarded to the worker subprocess, which gets its own run context built on dispatch). Feature-only filename lookup is a compatibility fallback only; it uses strict dated document patterns rather than loose `*-{feature}.md` matching. `tenet_register_jobs` requires a `feature` slug that propagates to all jobs in the DAG, and current runs should also pass `artifact_paths` so job context cannot drift to stale documents. -Dev-type jobs get a "Deliverable Requirements" preamble prepended to their prompt, with extra retry context when `retryCount > 0`. +Dev-type jobs get a "Deliverable Requirements" preamble prepended to their prompt, with extra retry context when `retryCount > 0`. Every dispatched worker also receives a **run-context block** on `invocation.context` (which all adapters prepend to the prompt): the foundational run docs (spec/decomposition/harness) are inlined, journal/research/visuals are path-referenced, plus a read directive — built from the job's stored `run_path`/`artifact_paths` by the dispatch path in `toInvocation`, not by `tenet_compile_context`. Report-only jobs additionally carry a Report-Only Scope block on this same path. ## Conventions diff --git a/skills/tenet/SKILL.md b/skills/tenet/SKILL.md index 1c24943..ea6c60a 100644 --- a/skills/tenet/SKILL.md +++ b/skills/tenet/SKILL.md @@ -76,7 +76,7 @@ Do not invent tool calls or runtime state. If a named Tenet MCP tool is missing, ## Core Invariants 1. Every implementation job runs through Tenet MCP: `tenet_continue` -> `tenet_compile_context` -> `tenet_start_job` -> `tenet_job_wait` -> `tenet_job_result`. -2. Context is compiled per job with `tenet_compile_context`; do not substitute raw file dumps. +2. Context is compiled per job with `tenet_compile_context` for the **orchestrator's** working context; do not substitute raw file dumps. This context is not forwarded to workers — the dispatch path builds each worker's own run context (spec/decomposition/harness inlined) automatically. 3. Generation and validation are separate contexts. 4. Eval is a hard blocking gate. A failed eval must be retried, remediated, or blocked before dependent work proceeds. 5. Harness enforcement applies in all modes. diff --git a/skills/tenet/phases/04-decomposition.md b/skills/tenet/phases/04-decomposition.md index 1c6e2b7..f99b9eb 100644 --- a/skills/tenet/phases/04-decomposition.md +++ b/skills/tenet/phases/04-decomposition.md @@ -179,7 +179,7 @@ tenet_register_jobs({ }) ``` -Do not rely on feature-only document lookup for new runs. The registered jobs carry `run_slug`, `run_path`, and exact `artifact_paths` into `tenet_compile_context`, so implementation workers read the same spec/harness/scenarios/interview/decomposition that passed the readiness gate. +Do not rely on feature-only document lookup for new runs. The registered jobs carry `run_slug`, `run_path`, and exact `artifact_paths`. `tenet_compile_context` assembles the **orchestrator's** working context from them — it is not forwarded to workers. Workers receive their own run context on dispatch: the dispatch path inlines the spec/decomposition/harness and path-references journal/research/visuals automatically, so every role reads the same docs that passed the readiness gate. ## 7. Execution Protocol (CRITICAL) 1. **Write Acceptance Tests First**: Generate test stubs from scenarios before writing the DAG. diff --git a/skills/tenet/phases/05-execution-loop.md b/skills/tenet/phases/05-execution-loop.md index 35df0bb..9324b5b 100644 --- a/skills/tenet/phases/05-execution-loop.md +++ b/skills/tenet/phases/05-execution-loop.md @@ -161,7 +161,7 @@ Typical cases: final acceptance sweeps, architectural reviews, test-flakiness au ### What happens automatically -When a report-only job's context is compiled, `tenet_compile_context` prepends a **Report-Only Scope** preamble telling the worker: +When a report-only job is dispatched, the worker dispatch path prepends a **Report-Only Scope** preamble telling the worker: - You MUST NOT edit project files. - If you find a blocking finding that must be resolved for the report to be trustworthy, call `tenet_report_blocking_finding({ job_id, finding, why_it_blocks_report, recommended_followup, suspected_files })` instead of editing. diff --git a/src/mcp/tools/tenet-compile-context.test.ts b/src/mcp/tools/tenet-compile-context.test.ts index e4dbbb4..2b738f0 100644 --- a/src/mcp/tools/tenet-compile-context.test.ts +++ b/src/mcp/tools/tenet-compile-context.test.ts @@ -50,6 +50,35 @@ afterEach(() => { }); describe('tenet_compile_context artifact paths', () => { + it('opens with the orchestrator role-preamble and never carries worker-bound report-only scope', async () => { + const { store, handler } = createHarness(); + writeFile(store.projectPath, '.tenet/spec/2026-04-16-oauth.md', '# Spec'); + writeFile(store.projectPath, '.tenet/harness/current.md', '# Harness'); + writeFile(store.projectPath, '.tenet/decomposition/2026-04-16-oauth.md', '# Decomposition'); + + const job = store.createJob({ + type: 'dev', + status: 'pending', + params: { + feature: 'oauth', + name: 'final-report', + prompt: 'verify', + report_only: true, + }, + retryCount: 0, + maxRetries: 0, + }); + + const parsed = parseResult(await handler({ job_id: job.id })); + + // Orchestrator discipline is re-asserted at the very top of the compiled context. + expect(parsed.context.startsWith('# Compiled Context (orchestrator aid)')).toBe(true); + expect(parsed.context).toContain('You are the orchestrator, not the worker'); + expect(parsed.context).toContain('tenet_start_job'); + // The Report-Only Scope block is worker-bound and lives in the dispatch path, not here. + expect(parsed.context).not.toContain('## Report-Only Scope'); + }); + it('reads exact artifact paths stored on the job instead of feature fallback files', async () => { const { store, handler } = createHarness(); writeFile(store.projectPath, '.tenet/spec/custom-current.md', '# Current Spec'); diff --git a/src/mcp/tools/tenet-compile-context.ts b/src/mcp/tools/tenet-compile-context.ts index f01d4ef..389b0a3 100644 --- a/src/mcp/tools/tenet-compile-context.ts +++ b/src/mcp/tools/tenet-compile-context.ts @@ -127,7 +127,8 @@ export const registerTenetCompileContextTool = (registerTool: RegisterTool, stat registerTool( 'tenet_compile_context', { - description: 'Compile bootstrap context for a job', + description: + 'Compile the orchestrator working context for a job (spec/harness/decomposition/doctrine + evidence listings). Returns context to the orchestrator only — it is not forwarded to the worker subprocess, which receives its own run context on dispatch.', inputSchema: z.object({ job_id: z.string().uuid(), }), @@ -237,7 +238,12 @@ export const registerTenetCompileContextTool = (registerTool: RegisterTool, stat const reportOnly = job.params.report_only === true; const compiled = [ - `# Compiled Context`, + `# Compiled Context (orchestrator aid)`, + '', + '**You are the orchestrator, not the worker.** Do not implement code directly — every implementation action goes through `tenet_start_job`, which dispatches a fresh worker subprocess. This compiled context is YOUR working context; it is not forwarded to workers (workers receive their own run context on dispatch).', + '', + 'If you are unsure of the loop rules, re-read `phases/05-execution-loop.md` before acting.', + '', `job_id: ${job.id}`, `job_type: ${job.type}`, `job_name: ${jobName}`, From 65e100485d6782c51bc5954aa992e754d7c319d5 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 09:58:33 +0900 Subject: [PATCH 29/87] feat(model-tier): prompt-only executioner tier for decomposition granularity (U3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let a run declare its worker tier so decomposition adapts. Pure prompt-level — no TypeScript, no persisted field, no DB column. - Phase 01 interview: the Full-mode checkpoint now captures two mode-like decisions in one gate — delivery_mode (hard gate, unchanged) and model_tier (advisory). model_tier (local|frontier) is a declaration of the worker's capability tier, not a detection. Recorded in the transcript under ## Model Tier Decision. - Phase 04 decomposition: branches on the transcript's model_tier — frontier/absent produces today's goal-oriented DAG (byte-identical); local produces a finer-grained DAG (more single-responsibility jobs, explicit per-job acceptance criteria). Composes with delivery_mode (agile + local = sliced and fine-grained). - SKILL.md Mode Selection: documents the checkpoint captures both; notes model_tier is advisory and not copied to spec front matter. Why no persisted field: model_tier is read once, immediately, in the same session that asked it (unlike delivery_mode, which is read continuously and needs a durable frontmatter handle). Its effect lives in the decomposition artifact. Default/absent = frontier = today's behavior. No code change; validate-clarity's hard gate stays delivery_mode-only (model_tier is advisory, single-consumer). Co-Authored-By: Claude --- skills/tenet/SKILL.md | 2 ++ skills/tenet/phases/01-interview.md | 24 ++++++++++++++++++++++-- skills/tenet/phases/04-decomposition.md | 7 +++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/skills/tenet/SKILL.md b/skills/tenet/SKILL.md index ea6c60a..2a4f299 100644 --- a/skills/tenet/SKILL.md +++ b/skills/tenet/SKILL.md @@ -132,6 +132,8 @@ Ask a dedicated question that presents both options. Do not bury delivery mode i Default to `autonomous` only after the user has seen both options and responds with uncertainty or no preference. Record the prompt, response, selected mode, and selection basis in the interview transcript; copy the selected mode to spec front matter as `delivery_mode`. +The same checkpoint also captures **model_tier** (`frontier` | `local`) — a declaration of the worker's capability tier that shapes decomposition granularity only (`frontier` = today's goal-oriented DAG; `local` = finer-grained DAG with explicit per-job acceptance criteria). Unlike `delivery_mode`, `model_tier` is advisory and is NOT copied to spec front matter: it stays in the transcript and is consumed once by decomposition. Default `frontier` (byte-identical to today). See `phases/01-interview.md` § 3 and `phases/04-decomposition.md`. + ## Phase Map Read the relevant phase file before executing that phase. The phrase "read" means opening the file contents in the current session, not assuming prior knowledge. diff --git a/skills/tenet/phases/01-interview.md b/skills/tenet/phases/01-interview.md index 84b2bc7..aaeb4de 100644 --- a/skills/tenet/phases/01-interview.md +++ b/skills/tenet/phases/01-interview.md @@ -27,9 +27,11 @@ Ask at least one question from each category in the first round. | **Integration** | List external APIs, services, and third-party dependencies. | | **Edge Cases** | Address failure modes, rate limits, and concurrent user behavior. | -## 3. Delivery Mode Gate +## 3. Mode Decisions Gate (delivery_mode + model_tier) -In Full mode, before calling `tenet_validate_clarity()`, ask one standalone delivery-mode question. +In Full mode, before calling `tenet_validate_clarity()`, run a standalone checkpoint that captures two mode-like decisions: **delivery_mode** (how the run is sliced) and **model_tier** (the capability tier of the worker that will execute the jobs). Ask each as its own question — do not bury either inside a bundled defaults question, and do not infer either from approval of unrelated defaults. + +### 3a. Delivery mode (hard gate) Required prompt content: - Explain `autonomous`: one end-to-end run after pre-execution confirmation. @@ -49,6 +51,17 @@ Invalid outcomes: - User said "okay", "sounds good", or equivalent to unrelated defaults. - Pre-execution confirmation was used as retroactive delivery-mode approval. +### 3b. Model tier (advisory — shapes decomposition granularity only) + +Ask which tier of model will execute the implementation jobs: + +- `frontier` (default): a strong frontier model executes the jobs. Decomposition produces today's goal-oriented DAG — fewer, larger jobs, each trusted to carry a goal and resolve its own details. +- `local`: a smaller/local model executes the jobs. Decomposition produces a finer-grained DAG — more, smaller, single-responsibility jobs with explicit per-job acceptance criteria, because a weaker executor needs a tighter plan to stay on-spec. + +This is a **declaration**, not a detection — Tenet does not auto-detect the worker model; the user states it here. It is consumed **once**, by decomposition (phase 04), and is NOT written to spec front matter or any persisted field — its effect lives in the decomposition artifact. Default to `frontier` when the user is unsure: `frontier` (or absent) is byte-identical to today's behavior. + +Record the choice under `## Model Tier Decision` in the transcript (see § 5). Unlike delivery_mode, this is advisory, not a hard gate — a missing `## Model Tier Decision` is valid and means `frontier`. + ## 4. Clarity Gate Mechanics After writing the interview transcript, call `tenet_validate_clarity()` to dispatch an independent agent that scores the transcript. Do NOT compute the score yourself. @@ -111,6 +124,12 @@ Rounds: [N] - Selected delivery_mode: autonomous|agile - Selection basis: explicit_user_choice|defaulted_after_explicit_choice_prompt|yolo_agent_decision +## Model Tier Decision +- Prompt shown: [exact text or concise summary] +- User response: [exact response, or YOLO confirmation] +- Selected model_tier: local|frontier +- Selection basis: explicit_user_choice|defaulted_after_uncertainty_prompt|yolo_agent_decision + ## Summary [Concise summary of project agreement] ``` @@ -133,6 +152,7 @@ Once confirmed, the agent: - Skips interactive interview questions — makes all decisions autonomously based on codebase analysis and brownfield scan - Still writes the interview transcript with decisions made and assumptions - Still records `## Delivery Mode Decision` with `Selection basis: yolo_agent_decision` +- Still records `## Model Tier Decision` with `Selection basis: yolo_agent_decision` (default `frontier` unless the run is clearly local-executed) - Still runs `tenet_validate_clarity()` — if clarity is low, the agent fills gaps by reading the codebase rather than asking the user - Still generates spec, scenarios, and decomposition — but without user confirmation at each step - YOLO mode ends at the pre-execution confirmation gate — the user always confirms before autonomous execution begins diff --git a/skills/tenet/phases/04-decomposition.md b/skills/tenet/phases/04-decomposition.md index f99b9eb..db3426f 100644 --- a/skills/tenet/phases/04-decomposition.md +++ b/skills/tenet/phases/04-decomposition.md @@ -13,6 +13,13 @@ For Full mode runs with an interview transcript, verify the spec front-matter `delivery_mode` matches `## Delivery Mode Decision` in the transcript. If the transcript decision is missing or mismatched, stop before acceptance tests, decomposition, or job registration; return to the relevant crystallization phase. Pre-execution confirmation cannot retroactively satisfy delivery-mode selection. +**Also read the interview transcript's `## Model Tier Decision`** (phase 01 § 3b) to shape DAG granularity. Unlike `delivery_mode`, model_tier lives only in the transcript — it is not a spec front-matter field, because it is consumed once (here) and its effect is captured in the decomposition artifact you are about to write. + +- `model_tier: frontier` (or the section is absent — e.g. Standard/Quick mode, or a run that skipped the Full-mode gate) → produce today's goal-oriented DAG: fewer, larger jobs, each trusted to carry a goal and resolve its own details. Byte-identical to default behavior. +- `model_tier: local` → produce a finer-grained DAG: more, smaller, single-responsibility jobs, each with explicit per-job acceptance criteria and minimal implicit context. A weaker executor needs a tighter, more explicit plan to stay on-spec. + +This composes with `delivery_mode`: `agile` + `local` means sliced **and** fine-grained (apply both — per-slice DAG, fine-grained within the slice). + Before decomposition, verify required visual artifacts exist when the feature has a user-facing or interactive surface (UI, game/canvas, visual app, TUI, CLI workflow, API workflow, or similar). If required visuals are missing, stop and run `phases/03-visuals.md` before writing the DAG. ## 2. File Structure (STRICT) From 1e261bacbb86ed8c11f0a3621a5dc066fe2e2a5c Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 10:05:03 +0900 Subject: [PATCH 30/87] feat(spec): strengthen spec research accuracy + readiness substance gate (U4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More accurate specs — benefits every tier, every run. Two halves pointing at one contract: the prompt (phase 02) instructs accuracy, the readiness gate verifies it. Phase 02 (instruction): - Add a "ground every codebase claim in real files" requirement to pre-spec research: verify claims against the actual code, cite file paths inline, mark unverified claims [unverified]. Un-cited or fabricated codebase claims become execution-time landmines. (Greenfield has no existing code to cite — scoped to claims about code that should already exist.) tenet_validate_readiness (verification): - Add a deterministic spec-substance gate to the preflight: reject specs carrying unresolved placeholder markers (TODO/TBD/FIXME/placeholder/lorem ipsum/???) before dispatching the readiness model. Only prose is scanned — YAML front matter and fenced code blocks are stripped first, so a `// TODO` stub the worker will replace inside a code sample is not a false positive. - Strengthen the AI rubric's spec_sufficiency category: un-cited/invented codebase claims are a blocker, not a soft gap. Tests: a placeholder spec fixture the old gate dispatched is now deterministically blocked; a spec whose only TODO is inside a fenced code block still dispatches. Co-Authored-By: Claude --- skills/tenet/phases/02-spec-and-harness.md | 2 + .../tools/tenet-validate-readiness.test.ts | 80 +++++++++++++++++++ src/mcp/tools/tenet-validate-readiness.ts | 35 ++++++++ 3 files changed, 117 insertions(+) diff --git a/skills/tenet/phases/02-spec-and-harness.md b/skills/tenet/phases/02-spec-and-harness.md index 161f922..4c30511 100644 --- a/skills/tenet/phases/02-spec-and-harness.md +++ b/skills/tenet/phases/02-spec-and-harness.md @@ -34,6 +34,8 @@ Before writing the spec, conduct comprehensive research on the technologies, API **Do NOT skip this step.** Writing a spec without researching the technologies leads to specs that can't be implemented, or implementations that use the wrong patterns. +**Ground every codebase claim in real files.** When the spec references the existing project (a module, an API, a table, current behavior, a config value), verify it by reading the actual code and cite the file path inline (e.g. `src/auth/session.ts`). Do not assert how the codebase works from memory or assumption — an un-cited or fabricated codebase claim becomes a silent landmine at execution time, when the worker finds the code doesn't match the spec. If you cannot verify a claim against the real project, mark it `[unverified]` and resolve it before the spec passes the readiness gate. The readiness gate treats un-cited/invented codebase claims as a blocker. (Greenfield runs have no existing code to cite — this applies only to claims about code that should already exist.) + ## 1. Exact File Paths CRITICAL: Write all run-local artifacts under `.tenet/runs/{run_slug}/` (paths below). Run artifacts do not live anywhere else. diff --git a/src/mcp/tools/tenet-validate-readiness.test.ts b/src/mcp/tools/tenet-validate-readiness.test.ts index 8e00051..44dd0f3 100644 --- a/src/mcp/tools/tenet-validate-readiness.test.ts +++ b/src/mcp/tools/tenet-validate-readiness.test.ts @@ -480,4 +480,84 @@ describe('tenet_validate_readiness', () => { }), ).rejects.toThrow(/must be inside the project/); }); + + it('blocks decomposition when the spec is a placeholder (deterministic substance gate)', async () => { + const { handler, projectPath, store, manager } = createHarness(); + writeFile( + projectPath, + '.tenet/runs/2026-06-12-oauth/spec.md', + [ + '---', + 'delivery_mode: autonomous', + '---', + '', + '# OAuth Spec', + '', + 'TODO: write the actual spec. This is a placeholder — fill in later.', + ].join('\n'), + ); + writeFile(projectPath, '.tenet/runs/2026-06-12-oauth/harness.md', '# Harness'); + + const result = await handler({ + feature: 'oauth', + artifact_paths: { + spec: '.tenet/runs/2026-06-12-oauth/spec.md', + harness: '.tenet/runs/2026-06-12-oauth/harness.md', + scenarios: null, + interview: null, + }, + }); + const parsed = parseResult(result); + const job = store.getJob(parsed.job_id as string); + const output = manager.getJobResult(parsed.job_id as string).output as { + passed: boolean; + blockers: string[]; + }; + + // The old gate would have dispatched this to the readiness model; the substance + // gate now blocks it deterministically before dispatch. + expect(job?.status).toBe('completed'); + expect(output.passed).toBe(false); + expect(output.blockers.join('\n')).toMatch(/placeholder/i); + }); + + it('does not block a spec whose TODO lives inside a fenced code sample', async () => { + const { handler, projectPath, store, manager } = createHarness(); + writeFile( + projectPath, + '.tenet/runs/2026-06-12-oauth/spec.md', + [ + '---', + 'delivery_mode: autonomous', + '---', + '', + '# OAuth Spec', + '', + 'Users authenticate via OAuth2. A valid token returns 200 and a session cookie.', + '', + '```ts', + '// TODO: implement token exchange', + 'export function exchange(code: string) {}', + '```', + ].join('\n'), + ); + writeFile(projectPath, '.tenet/runs/2026-06-12-oauth/harness.md', '# Harness'); + + const result = await handler({ + feature: 'oauth', + artifact_paths: { + spec: '.tenet/runs/2026-06-12-oauth/spec.md', + harness: '.tenet/runs/2026-06-12-oauth/harness.md', + scenarios: null, + interview: null, + }, + }); + const parsed = parseResult(result); + const job = store.getJob(parsed.job_id as string); + + // No placeholder markers in prose (only inside a code block) → dispatched to the + // readiness model (rubric embedded in the prompt), not deterministically blocked. + expect(job?.params.prompt).toContain('IMPLEMENTATION READINESS'); + await manager.waitForJob(parsed.job_id as string, null, 5_000); + }); }); diff --git a/src/mcp/tools/tenet-validate-readiness.ts b/src/mcp/tools/tenet-validate-readiness.ts index c4edb0d..11f61fe 100644 --- a/src/mcp/tools/tenet-validate-readiness.ts +++ b/src/mcp/tools/tenet-validate-readiness.ts @@ -38,6 +38,7 @@ For each category, assign one of: "ready", "partial", "blocked". - Are the acceptance criteria concrete enough to write tests against? - Are error-handling policies, rate-limit behavior, and edge cases specified? - Ambiguities that survived the clarity gate but only matter at build time (e.g., "what happens on 429?", "what's the retry policy?"). +- Are factual claims about the existing codebase (file paths, current behavior, existing modules/APIs) grounded in real files cited in the spec, not assumed or invented? An un-cited or fabricated codebase claim is a blocker — the spec must reflect the actual project (greenfield runs have no existing code to cite; this applies only to claims about code that should already exist). ### 2. Research & prior art - If the approach uses a specific library, algorithm, or protocol — has it been decided and investigated? @@ -141,6 +142,38 @@ const SELECTED_DELIVERY_MODE_RE = /^\s*-\s*Selected delivery_mode:\s*(autonomous const SELECTION_BASIS_RE = /^\s*-\s*Selection basis:\s*(explicit_user_choice|defaulted_after_explicit_choice_prompt|yolo_agent_decision)\b/im; +// Deterministic spec-substance gate. Catches unambiguous placeholder specs (TODO / +// FIXME / "placeholder" / lorem ipsum / ???) before dispatching the readiness model — +// a spec that carries unresolved placeholders into decomposition is not ready to plan +// against. Only PROSE is scanned: YAML front matter and fenced code blocks are stripped +// first, so a `// TODO` stub the worker will legitimately replace inside a code sample +// is not a false positive. +const SPEC_PLACEHOLDER_PATTERNS: ReadonlyArray = [ + /\bTODO\b/i, + /\bTBD\b/i, + /\bFIXME\b/i, + /\blorem ipsum\b/i, + /\bplaceholder\b/i, + /\bto be determined\b/i, + /\bto be decided\b/i, + /\?\?\?+/, +]; + +const stripSpecNonProse = (specMd: string): string => + specMd + .replace(/^---\s*\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, '') + .replace(/```[\s\S]*?```/g, '') + .replace(/~~~[\s\S]*?~~~/g, ''); + +const getSpecSubstanceFailures = (specMd: string): string[] => { + const prose = stripSpecNonProse(specMd); + return SPEC_PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(prose)) + ? [ + 'Spec contains unresolved placeholder markers (TODO/TBD/FIXME/placeholder/lorem ipsum/???). A spec carried into decomposition must be concrete — resolve or remove them, then re-run readiness.', + ] + : []; +}; + type FrontMatterResult = { data: Record | null; error?: string; @@ -202,6 +235,8 @@ const getReadinessPreflightFailures = (specMd: string, interviewMd?: string): st failures.push(specDeliveryMode.error); } + failures.push(...getSpecSubstanceFailures(specMd)); + if (specMode === 'agile' && !SLICE_PLAN_RE.test(specMd)) { failures.push('Spec declares delivery_mode: agile but is missing ## Slice plan.'); } From 8273e406d44c2323ff9357827ffb1b588061a434 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 11:07:19 +0900 Subject: [PATCH 31/87] feat(skill): mode selection is a user-facing checkpoint; Quick no longer skips interview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mode (Full/Standard/Quick) selection was a silent, unrecorded internal classification — the orchestrator inferred it from the task's surface form and could assert it retroactively to justify skipping ceremony. Observed in a live brownfield bug-fix run: "fix these 3 concrete bugs" silently dropped to Quick mode and skipped the interview phase entirely (no boot health check, no phase file read, no transcript). Users only got the full procedure by knowing to say "full mode" explicitly — misleading for anyone who doesn't know the magic words. Two structural gaps caused it: 1. No checkpoint between boot and phase work where the mode must be committed and recorded. 2. Quick mode's relationship to the interview was undefined (01-interview was scoped to Full mode), so "Quick" got read as "skip interview." Changes (docs-only, skills/tenet/): - Mode selection is now a required, user-facing checkpoint after boot: recommend a mode with a one-line basis, let the user confirm/override, record it in the transcript's ## Mode Selection block (mirrors the existing delivery-mode gate). - Quick mode is explicitly a shallower interview, not a skip: it still completes boot, reads the phase file, writes the transcript, runs the clarity + readiness gates, and confirms scope + acceptance criteria. - SKILL.md boot step 11 gates boot -> phase work on the recorded mode. - "Apparent clarity is not a skip signal" stated in SKILL.md + the anti-skip section, closing the core failure: the procedure must run especially when the task looks obvious. Verification: make check passes (243 tests). Readiness regex anchor `Mode: Full` preserved at 01-interview.md:98 so the Full-mode delivery-mode hard gate is unaffected. This is a skill-procedure change — not lockable by an automated test; verified by doc review + real-run watch. Co-Authored-By: Claude --- skills/tenet/SKILL.md | 15 ++++++++++----- skills/tenet/phases/01-interview.md | 15 +++++++++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/skills/tenet/SKILL.md b/skills/tenet/SKILL.md index 2a4f299..ade2f4a 100644 --- a/skills/tenet/SKILL.md +++ b/skills/tenet/SKILL.md @@ -110,18 +110,23 @@ Run this before mode selection or execution: 8. In the greenfield deferred state, proceed to mode selection and the interview. The bootstrap gate is satisfied post-interview once `phases/01-interview.md` § 11 has written real `project/**` doctrine from interview decisions. 9. Detect whether `.git/` exists. Git behavior is defined in `phases/05-execution-loop.md`. 10. Probe Playwright MCP availability if possible. If unavailable, warn once. This is only non-blocking when the run-local harness/spec marks browser exploration optional, skipped with reason, or not applicable; required browser/visual Layer 2 cannot pass without it. +11. **Mode-selection checkpoint.** Once boot is otherwise complete, run the mode checkpoint (see *Mode Selection* below): recommend Full/Standard/Quick with a one-line rationale, have the user confirm or override, and record the selected mode + basis. This checkpoint opens the interview phase — it creates the run/transcript scaffold (`phases/01-interview.md` § 1) and records the decision in the transcript's `## Mode Selection` block — so read `phases/01-interview.md` before running it. This is the gate between boot and phase work: until the mode is selected and recorded, do not begin phase work (no task diagnosis, no spec/decomposition, no questions beyond the mode checkpoint itself). The context-bootstrap code scan in step 7 is the only sanctioned source reading during boot. Do not enter execution while health is bad. ## Mode Selection -Choose one scale-adaptive mode at start and re-evaluate only at major scope changes: +Mode selection (Full / Standard / Quick) is a **required, user-facing checkpoint** that runs once the boot sequence passes (including the context bootstrap gate). Do not infer the mode silently from the task's surface form, and never assert a mode retroactively to justify skipping ceremony — surface your recommendation and let the user confirm or override it. -- Full: new feature, greenfield work, unclear edges, major refactor, broad multi-module change. -- Standard: medium complexity, known architecture, moderate unknowns. -- Quick: small isolated bug/config/content tweak with low ambiguity. +Recommend a mode with a one-line rationale, then ask the user to confirm or change it (prefer an interactive prompt, per `phases/01-interview.md` § 6). Record the selected mode and a selection basis in the transcript's `## Mode Selection` block before any phase work. Re-evaluate only at major scope changes. -Full mode runs crystallization before execution. Standard mode keeps clarification/spec/decomposition compact. Quick mode may register a single-job DAG, but still uses the MCP execution and eval gates. +- **Full**: new feature, greenfield work, unclear edges, major refactor, broad multi-module change. Full crystallization: full interview → spec/harness/readiness → decomposition. +- **Standard**: medium complexity, known architecture, moderate unknowns. Compact interview (3–5 questions), then spec/harness/readiness → decomposition. +- **Quick**: small isolated bug/config/content tweak with low ambiguity. A **minimum** interview — confirm scope and acceptance criteria; never zero questions (see `phases/01-interview.md` § 9 anti-skip) — then a compact spec/harness update or a trivial single-job DAG. + +Every mode, Quick included, still: completes the boot sequence first, reads the phase file before entering any phase, writes the interview transcript, and runs the clarity + readiness gates. Quick is a shallower interview, **not** a license to skip the phase structure, the phase-file read, or the gates. If a task looks obvious enough to "just fix," that is exactly when the procedure must still govern — apparent clarity is not a skip signal. + +In YOLO mode (`phases/01-interview.md` § 7), the agent selects and records the mode autonomously with `Selection basis: yolo_agent_decision` and no interactive prompt; it still records the `## Mode Selection` block. In Full mode, delivery mode selection is a standalone required checkpoint at the end of the interview: diff --git a/skills/tenet/phases/01-interview.md b/skills/tenet/phases/01-interview.md index aaeb4de..ddde9d4 100644 --- a/skills/tenet/phases/01-interview.md +++ b/skills/tenet/phases/01-interview.md @@ -1,6 +1,8 @@ # Phase 01: Interview -This reference defines the mandatory interview phase for Tenet Full mode. Read and follow these instructions exactly to ensure project crystallization success. +This reference defines the interview phase. **Full mode** runs it at full strength; **Standard** and **Quick** run a proportional subset (see § 10) but never skip it — Quick is a shallower interview, not a license to skip the phase. Read and follow these instructions exactly. + +The phase opens with the **mode-selection checkpoint** (selected mode + basis recorded in the `## Mode Selection` block in § 5) before the first interview question. See `SKILL.md` → Mode Selection. ## 1. Run Identity And Output File Path The interview transcript MUST be saved before proceeding to the next phase: @@ -96,6 +98,12 @@ Date: [ISO date] Mode: Full Rounds: [N] +## Mode Selection +- Prompt shown: [the recommendation + one-line basis you presented] +- User response: [confirm, or override to a different mode] +- Selected mode: full|standard|quick +- Selection basis: explicit_user_choice|defaulted_after_explicit_choice_prompt|yolo_agent_decision + ## Clarity Score - Goal: [Score] (weight 0.4) - Constraints: [Score] (weight 0.3) @@ -151,6 +159,7 @@ When the user triggers YOLO mode, **confirm before activating**: "Entering yolo Once confirmed, the agent: - Skips interactive interview questions — makes all decisions autonomously based on codebase analysis and brownfield scan - Still writes the interview transcript with decisions made and assumptions +- Still records `## Mode Selection` with `Selection basis: yolo_agent_decision` (mode chosen deliberately — default Full unless the task is clearly a small isolated tweak, in which case Quick) - Still records `## Delivery Mode Decision` with `Selection basis: yolo_agent_decision` - Still records `## Model Tier Decision` with `Selection basis: yolo_agent_decision` (default `frontier` unless the run is clearly local-executed) - Still runs `tenet_validate_clarity()` — if clarity is low, the agent fills gaps by reading the codebase rather than asking the user @@ -192,11 +201,13 @@ When the user's requirements involve unfamiliar technologies, complex integratio - Do NOT proceed to spec or harness generation until the transcript file is written and the clarity gate passes. - If the user says "just build it" (without triggering YOLO mode), you MUST still ask the minimum required questions and record the answers. - In Full mode, do NOT proceed to spec unless `## Delivery Mode Decision` exists and records a valid selection basis. +- Quick mode is a shallower interview, NOT a skip of the interview phase. Even in Quick mode, record the `## Mode Selection` block and confirm scope + acceptance criteria before spec/decomposition — apparent task clarity is not a license to skip the phase structure. ## 10. Adaptive Interview Length - **Greenfield project:** 2-3 rounds, 8-15 questions total. - **Brownfield/known scope:** 1-2 rounds, 5-8 questions total. -- **Standard mode (quick clarification):** 1 round, 3-5 questions total. +- **Standard mode:** 1 round, 3-5 questions total. +- **Quick mode:** confirm scope + acceptance criteria — minimum 1-3 targeted questions or confirmations. Never zero (see § 9). The transcript still records the `## Mode Selection` block and these confirmations before spec/decomposition. ## 11. Crystallize Project Doctrine (greenfield only) From 2975e33bea3570d6350e9a908acede67b9e87f57 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 13:05:46 +0900 Subject: [PATCH 32/87] feat(eval): give critics the same inlined run docs as dev workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eval/critic workers ran with none of the foundational docs inlined — only their critic prompt (job-scope preamble + critic instructions + the implementation output). Yet a critic's whole job is to judge whether the code matches the spec, and we never handed it the spec. This was the eval-side mirror of the blind-worker bug U1 fixed for dev workers: U1 inlined spec/decomposition/harness for dev workers, but critics were left getting nothing. Worse, the code-critic and test-critic preambles claimed the docs were attached when they weren't ("You receive ONLY the spec, scenarios, harness..."). A critic taking its preamble literally evaluated against its own mental model of the spec. Only interaction-e2e was handled right (told to read the docs via artifact_paths). Changes: - tenet_start_eval buildParams propagates the source job's artifact_paths + run_path onto every critic job. buildWorkerContext already runs for eval jobs on the shared dispatch path, so critics now get spec/scenarios/ decomposition/harness inlined automatically — same path as dev workers, no new mechanism. - buildWorkerContext now inlines scenarios too (it had spec/decomp/harness only). Scenarios define success/failure shapes; the test critic needs them to judge test sufficiency. Dev workers pick up scenarios as well (small bump, observable per job — dial back if it proves too much in practice). - Rewrote the two false preamble claims to point at the run context that now actually carries the docs. make check: 245 tests pass (+2: critic params carry artifact_paths/run_path; eval-type job inlines spec/scenarios/decomp/harness). Co-Authored-By: Claude --- src/core/job-manager-worker-context.test.ts | 35 +++++++++++++++++++++ src/core/job-manager.ts | 16 +++++++--- src/mcp/tools/tenet-start-eval.test.ts | 30 ++++++++++++++++++ src/mcp/tools/tenet-start-eval.ts | 17 ++++++---- 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/src/core/job-manager-worker-context.test.ts b/src/core/job-manager-worker-context.test.ts index fb7a372..8b894ac 100644 --- a/src/core/job-manager-worker-context.test.ts +++ b/src/core/job-manager-worker-context.test.ts @@ -178,4 +178,39 @@ describe('worker baseline context (buildWorkerContext via dispatch)', () => { expect(ctx).toContain('# Worker Harness'); expect(ctx).not.toContain('# Worker Decomposition'); }); + + it('inlines spec/scenarios/decomposition/harness for an eval-type job (critic context)', async () => { + const { projectPath, manager, captured } = setup(); + const runPath = '.tenet/runs/2026-07-02-eval-ctx'; + writeRunDocs(projectPath, runPath); + // Scenarios is now part of the inlined set so a critic evaluates against real + // success/failure shapes instead of guessing them. + fs.writeFileSync(path.join(projectPath, runPath, 'scenarios.md'), '# Worker Scenarios\n\nSuccess/failure shapes.'); + + const job = manager.createPendingJob('eval', { + name: 'code-critic', + prompt: 'Criticize the implementation against the spec.', + feature: 'eval-ctx', + run_path: runPath, + artifact_paths: { + spec: `${runPath}/spec.md`, + scenarios: `${runPath}/scenarios.md`, + harness: `${runPath}/harness.md`, + decomposition: `${runPath}/decomposition.md`, + }, + }); + + manager.dispatchJob(job.id); + await manager.waitForJob(job.id, null, 5_000); + + expect(captured).toHaveLength(1); + const ctx = captured[0].context ?? ''; + // buildWorkerContext is type-agnostic — an eval/critic job with artifact_paths gets the + // same inlined foundational docs as a dev worker (the mechanism tenet_start_eval relies on). + expect(ctx).toContain('## Run Context (worker)'); + expect(ctx).toContain('# Worker Spec'); + expect(ctx).toContain('# Worker Scenarios'); + expect(ctx).toContain('# Worker Decomposition'); + expect(ctx).toContain('# Worker Harness'); + }); }); diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 018bedc..be157ec 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -737,10 +737,12 @@ export class JobManager { * Build the worker's run context (set on `invocation.context`, which every adapter * prepends to the task prompt). The worker is a fresh-context subprocess — unlike the * orchestrator it never sees `tenet_compile_context` output — so the foundational run - * docs (spec / decomposition / harness) are inlined here and the bulky/selective ones - * (journal / research / visuals) are path-referenced. Tier-independent and identical + * docs (spec / scenarios / decomposition / harness) are inlined here and the bulky/selective + * ones (journal / research / visuals) are path-referenced. Tier-independent and identical * for every run: the decomposition artifact already carries whatever granularity the - * run needs, so inlining it always propagates the right level of detail. + * run needs, so inlining it always propagates the right level of detail. Eval/critic jobs + * receive the same context — `tenet_start_eval` propagates the source job's artifact_paths + * and run_path, so a critic evaluates against the real spec instead of a label pointing at it. * * Returns undefined when there is nothing worker-specific to inject (e.g. a legacy job * with no run_path/artifact_paths) so the default dispatch path stays byte-identical. @@ -753,12 +755,15 @@ export class JobManager { const reportOnly = job.params.report_only === true; const specMd = artifactPaths?.spec ? safeReadArtifact(projectPath, artifactPaths.spec, 'spec') : ''; + const scenariosMd = artifactPaths?.scenarios + ? safeReadArtifact(projectPath, artifactPaths.scenarios, 'scenarios') + : ''; const decompositionMd = artifactPaths?.decomposition ? safeReadArtifact(projectPath, artifactPaths.decomposition, 'decomposition') : ''; const harnessMd = artifactPaths?.harness ? safeReadArtifact(projectPath, artifactPaths.harness, 'harness') : ''; - if (!runPath && !specMd && !decompositionMd && !harnessMd && !reportOnly) { + if (!runPath && !specMd && !scenariosMd && !decompositionMd && !harnessMd && !reportOnly) { return undefined; } @@ -767,6 +772,9 @@ export class JobManager { if (runPath) sections.push(`run_path: ${runPath}`); if (specMd) sections.push('', '## Spec (inlined — source of truth for this job)', specMd); + if (scenariosMd) { + sections.push('', '## Scenarios (inlined — success/failure shapes for this job)', scenariosMd); + } if (decompositionMd) { sections.push('', '## Decomposition (inlined — your job and its DAG context)', decompositionMd); } diff --git a/src/mcp/tools/tenet-start-eval.test.ts b/src/mcp/tools/tenet-start-eval.test.ts index d4b2c24..55f0b1c 100644 --- a/src/mcp/tools/tenet-start-eval.test.ts +++ b/src/mcp/tools/tenet-start-eval.test.ts @@ -291,6 +291,36 @@ describe('tenet_start_eval eval mode resolution', () => { await waitForAll(manager, parsed); }); + + it('propagates source artifact_paths + run_path onto critic jobs so they get inlined run docs', async () => { + const { store, handler } = createHarness(); + const runPath = '.tenet/runs/2026-07-02-critic-ctx'; + const sourceId = createSourceJob(store, 'critic-ctx', { + run_path: runPath, + artifact_paths: { + spec: `${runPath}/spec.md`, + scenarios: `${runPath}/scenarios.md`, + harness: `${runPath}/harness.md`, + decomposition: `${runPath}/decomposition.md`, + }, + }); + + const parsed = parseResult(await handler({ job_id: sourceId, output: { done: true } })); + const codeCritic = store.getJob(jobId(parsed, 'code_critic')); + + // The critic job carries the run's artifact_paths + run_path so buildWorkerContext + // (run on dispatch) inlines the same spec/scenarios/decomposition/harness the dev + // worker sees — a critic evaluates against the real spec, not a label pointing at it. + expect(codeCritic?.params.run_path).toBe(runPath); + expect(codeCritic?.params.artifact_paths).toEqual({ + spec: `${runPath}/spec.md`, + scenarios: `${runPath}/scenarios.md`, + harness: `${runPath}/harness.md`, + decomposition: `${runPath}/decomposition.md`, + }); + // The false "you receive the spec" claim is gone (the docs are now actually inlined). + expect(codeCritic?.params.prompt).not.toContain('You receive ONLY'); + }); }); describe('tenet_start_eval configurable critic roster', () => { diff --git a/src/mcp/tools/tenet-start-eval.ts b/src/mcp/tools/tenet-start-eval.ts index c45a407..c827a48 100644 --- a/src/mcp/tools/tenet-start-eval.ts +++ b/src/mcp/tools/tenet-start-eval.ts @@ -45,7 +45,7 @@ const CODE_CRITIC_PREAMBLE = [ '## Code Critic — Purpose Alignment Check', '', 'You are the CODE CRITIC. You have NO access to the author\'s reasoning or conversation.', - 'You receive ONLY the spec, scenarios, harness, and the code diff.', + 'Your run context (above) inlines the spec, scenarios, and harness; the implementation diff is under ## Implementation Output below.', '', 'Check independently:', '- Does the implementation match the spec\'s intent FOR THIS JOB\'S SCOPE?', @@ -188,7 +188,7 @@ const TEST_CRITIC_PREAMBLE = [ '## Test Critic — Test Sufficiency Check', '', 'You are the TEST CRITIC. You do NOT review the implementation code.', - 'You receive ONLY the spec, scenarios, and the acceptance/integration test files.', + 'Your run context (above) inlines the spec and scenarios; the test files are under ## Test Files and Spec below.', '', 'Your job: determine whether these tests are SUFFICIENT to prove the features', 'IN THIS JOB\'S SCOPE actually work. Do NOT fail for missing tests that cover', @@ -352,11 +352,10 @@ export const registerTenetStartEvalTool = (registerTool: RegisterTool, jobManage const outputStr = typeof output === 'string' ? output : JSON.stringify(output, null, 2); const jobScope = buildJobScopeSection(stateStore, job_id); const projectPath = stateStore.projectPath; + const sourceJob = stateStore.getJob(job_id); - const resolvedFeature = feature ?? (() => { - const source = stateStore.getJob(job_id); - return source && typeof source.params.feature === 'string' ? source.params.feature : undefined; - })(); + const resolvedFeature = feature ?? + (sourceJob && typeof sourceJob.params.feature === 'string' ? sourceJob.params.feature : undefined); const parallelSafe = resolveEvalParallelSafe(stateStore, resolvedFeature); @@ -387,6 +386,12 @@ export const registerTenetStartEvalTool = (registerTool: RegisterTool, jobManage output, expected_eval_stages: expectedEvalStages, ...(resolvedFeature ? { feature: resolvedFeature } : {}), + // Propagate the run's artifact_paths + run_path so the critic's worker context + // (built on dispatch via buildWorkerContext) inlines the same spec/scenarios/ + // decomposition/harness the dev worker sees. A critic must evaluate against the + // actual spec — not a label pointing at it. + ...(sourceJob?.params.artifact_paths ? { artifact_paths: sourceJob.params.artifact_paths } : {}), + ...(typeof sourceJob?.params.run_path === 'string' ? { run_path: sourceJob.params.run_path } : {}), }); type Dispatched = { role: string; id: string; status: Job['status']; parentJobId?: string }; From 7d39d864577bb784cf188fa434e4ad35bc96ac53 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 13:57:38 +0900 Subject: [PATCH 33/87] feat(eval): per-critic full_context option (grounded vs ungrounded review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inlining the run docs into every critic (the prior commit) anchors them all to the spec — they become conformance checkers and lose the ability to find issues the spec itself missed. Research on LLM-as-judge anchoring, the oracle/shared- blind-spot problem, and the conformance-vs-exploratory split converges on one resolution: don't ground ALL the critics — keep some grounded (conformance) and let at least one review independently (adversarial). This adds the per-critic switch that enables that diversity. Changes: - Critic roster gains an optional `full_context` (default true). Resolved onto every critic (built-in and custom). Honored on built-ins too. - tenet_start_eval buildParams propagates artifact_paths + run_path onto a critic job ONLY when its full_context is true. An ungrounded critic (full_context: false) gets just its prompt + diff — no spec inlined — so it reviews independently. buildWorkerContext already returns undefined when a job carries no artifact_paths, so no new dispatch path is needed. The artifact_paths labels stay in the job scope, so an ungrounded critic can still consult the spec on demand — independent, not blind. - Documented in skills/tenet/critics.md (the critic designer). Defaults keep current behavior: all three built-ins stay grounded (code/test are conformance; interaction_e2e probes the running app and needs the scenarios). A user adds an independent/adversarial critic by setting full_context: false on a custom critic. Model diversity (the other half of the research — uncorrelated blind spots across models) is a separate, upcoming delivery. make check: 247 tests pass (+2: roster full_context default/honored; ungrounded critic gets no artifact_paths/run_path). Co-Authored-By: Claude --- skills/tenet/critics.md | 15 ++++++++++-- src/core/critic-roster.test.ts | 18 ++++++++++++++ src/core/critic-roster.ts | 16 ++++++++++++ src/mcp/tools/tenet-start-eval.test.ts | 34 ++++++++++++++++++++++++++ src/mcp/tools/tenet-start-eval.ts | 25 ++++++++++++++----- 5 files changed, 100 insertions(+), 8 deletions(-) diff --git a/skills/tenet/critics.md b/skills/tenet/critics.md index aa10c95..970d030 100644 --- a/skills/tenet/critics.md +++ b/skills/tenet/critics.md @@ -6,8 +6,10 @@ the three built-in critics (code, test, interaction-e2e) under-cover. Tenet's eval gate is configurable. The critic set lives in `.tenet/critics.json`; each critic runs as an **independent-context eval job** -(sees the job scope + your prompt, never the author's reasoning). This doc is how -you design one so it actually plugs into the gate and the fix-routing. +(sees the job scope + your prompt, never the author's reasoning). Grounded critics +also get the run docs (spec/harness/...) inlined; an ungrounded critic reviews from +the code alone — see `full_context` below. This doc is how you design one so it +actually plugs into the gate and the fix-routing. ## What a critic is @@ -57,6 +59,15 @@ beats "check for security issues." `layer2_status`; otherwise `critic_eval`. - `prompt_file` — project-relative path to the prompt markdown. Missing file → the critic is skipped at dispatch with a warning (never fatal). + - `full_context` — optional, default `true`. When `true` (default), the critic + receives the run docs (spec/scenarios/decomposition/harness) inlined into its + context, same as a dev worker — use this for **conformance** critics that check + the work against the spec. When `false`, the critic gets ONLY its prompt + the + implementation output, with NO spec inlined — use this for an **independent / + adversarial** critic that should review without being anchored to the spec, so it + can catch issues the spec itself missed. (The artifact_paths labels still appear in + its job scope, so it can consult the spec on demand — independent, not blind.) + Applies to built-ins too; the built-ins default to `true`. The file is read live on every eval — edit it and the next `tenet_start_eval` reflects the change with no restart. Invalid JSON falls back to the 3 built-ins. diff --git a/src/core/critic-roster.test.ts b/src/core/critic-roster.test.ts index 70b7378..db2514d 100644 --- a/src/core/critic-roster.test.ts +++ b/src/core/critic-roster.test.ts @@ -96,6 +96,7 @@ describe('resolveRoster', () => { stage: 'security_critic', jobType: 'critic_eval', promptFile: '.tenet/critics/security.md', + fullContext: true, }); }); @@ -115,6 +116,22 @@ describe('resolveRoster', () => { expect(byId(roster, 'x')?.jobType).toBe('critic_eval'); }); + it('defaults full_context to true and honors full_context:false on built-in and custom', () => { + const roster = resolveRoster({ + critics: [ + { id: 'code_critic', builtin: true, full_context: false }, + { id: 'adversarial', prompt_file: '.tenet/critics/adversarial.md', full_context: false }, + { id: 'lint', prompt_file: '.tenet/critics/lint.md' }, // default true + ], + }); + expect(byId(roster, 'code_critic')?.fullContext).toBe(false); + expect(byId(roster, 'adversarial')?.fullContext).toBe(false); + expect(byId(roster, 'lint')?.fullContext).toBe(true); + // Built-ins omitted from the file default to true. + expect(byId(roster, 'test_critic')?.fullContext).toBe(true); + expect(byId(roster, 'interaction_e2e')?.fullContext).toBe(true); + }); + it('drops duplicate ids (first wins) and skips malformed entries', () => { const roster = resolveRoster({ critics: [ @@ -134,6 +151,7 @@ describe('resolveRoster', () => { it('DEFAULT_ROSTER is the 3 built-ins enabled', () => { expect(DEFAULT_ROSTER.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); expect(DEFAULT_ROSTER.every((c) => c.enabled)).toBe(true); + expect(DEFAULT_ROSTER.every((c) => c.fullContext)).toBe(true); }); }); diff --git a/src/core/critic-roster.ts b/src/core/critic-roster.ts index c1c2228..3f05c45 100644 --- a/src/core/critic-roster.ts +++ b/src/core/critic-roster.ts @@ -68,6 +68,16 @@ export type CriticRosterEntry = { job_type?: JobType; /** Custom only — project-relative path to a markdown prompt. */ prompt_file?: string; + /** + * Whether the critic receives the run docs (spec/scenarios/decomposition/harness) + * inlined into its worker context. Default `true` — conformance critics need the + * spec to check it. Set `false` for an independent/adversarial critic that should + * review WITHOUT being anchored to the spec: it gets only its prompt + diff, so it + * can catch issues the spec itself missed (diversity of grounding, not universal + * anchoring). The artifact_paths labels still reach it via the job scope, so an + * ungrounded critic can consult the spec on demand if its review raises a question. + */ + full_context?: boolean; }; /** A critic after resolution, ready for dispatch. */ @@ -79,6 +89,8 @@ export type ResolvedCritic = { jobType: JobType; /** Custom only — project-relative path to the prompt markdown. */ promptFile?: string; + /** Resolved from `full_context`: `true` = inline the run docs, `false` = review ungrounded. */ + fullContext: boolean; }; export const DEFAULT_ROSTER: readonly ResolvedCritic[] = BUILTIN_CRITIC_IDS.map((id) => ({ @@ -87,6 +99,7 @@ export const DEFAULT_ROSTER: readonly ResolvedCritic[] = BUILTIN_CRITIC_IDS.map( enabled: true, stage: BUILTIN_STAGE[id], jobType: BUILTIN_JOB_TYPE[id], + fullContext: true, })); const isBuiltinId = (id: string): id is BuiltinCriticId => @@ -151,6 +164,7 @@ export const resolveRoster = (raw: unknown): ResolvedCritic[] => { enabled: e.enabled !== false, stage: BUILTIN_STAGE[id], jobType: BUILTIN_JOB_TYPE[id], + fullContext: e.full_context !== false, }); usedIds.add(id); } else { @@ -164,6 +178,7 @@ export const resolveRoster = (raw: unknown): ResolvedCritic[] => { stage, jobType, promptFile, + fullContext: e.full_context !== false, }); usedIds.add(id); } @@ -178,6 +193,7 @@ export const resolveRoster = (raw: unknown): ResolvedCritic[] => { enabled: true, stage: BUILTIN_STAGE[id], jobType: BUILTIN_JOB_TYPE[id], + fullContext: true, }); } } diff --git a/src/mcp/tools/tenet-start-eval.test.ts b/src/mcp/tools/tenet-start-eval.test.ts index 55f0b1c..4bc0145 100644 --- a/src/mcp/tools/tenet-start-eval.test.ts +++ b/src/mcp/tools/tenet-start-eval.test.ts @@ -321,6 +321,40 @@ describe('tenet_start_eval eval mode resolution', () => { // The false "you receive the spec" claim is gone (the docs are now actually inlined). expect(codeCritic?.params.prompt).not.toContain('You receive ONLY'); }); + + it('does not propagate artifact_paths/run_path to a full_context:false critic (ungrounded review)', async () => { + const { store, handler } = createHarness(); + writeRoster(store, { + version: 1, + critics: [ + { id: 'code_critic', builtin: true, enabled: true }, + { id: 'test_critic', builtin: true, enabled: true }, + { id: 'interaction_e2e', builtin: true, enabled: true }, + { id: 'adversarial', prompt_file: '.tenet/critics/adversarial.md', full_context: false }, + ], + }); + writeCriticPrompt(store, 'adversarial.md', '# Adversarial Critic\n\nReview the diff independently, without the spec.'); + + const runPath = '.tenet/runs/2026-07-02-adversarial'; + const sourceId = createSourceJob(store, 'adversarial', { + run_path: runPath, + artifact_paths: { spec: `${runPath}/spec.md`, harness: `${runPath}/harness.md` }, + }); + + const parsed = parseResult(await handler({ job_id: sourceId, output: { done: true } })); + const adversarial = store.getJob(jobId(parsed, 'adversarial')); + const codeCritic = store.getJob(jobId(parsed, 'code_critic')); + + // Ungrounded critic: no inlined run docs — it reviews independently of the spec. + expect(adversarial?.params.artifact_paths).toBeUndefined(); + expect(adversarial?.params.run_path).toBeUndefined(); + // Grounded built-in still gets them. + expect(codeCritic?.params.artifact_paths).toEqual({ + spec: `${runPath}/spec.md`, + harness: `${runPath}/harness.md`, + }); + expect(codeCritic?.params.run_path).toBe(runPath); + }); }); describe('tenet_start_eval configurable critic roster', () => { diff --git a/src/mcp/tools/tenet-start-eval.ts b/src/mcp/tools/tenet-start-eval.ts index c827a48..3a10997 100644 --- a/src/mcp/tools/tenet-start-eval.ts +++ b/src/mcp/tools/tenet-start-eval.ts @@ -243,6 +243,8 @@ type CriticDispatch = { jobType: JobType; evalStage: string; prompt: string; + /** Whether to inline the run docs into this critic's worker context (resolved full_context). */ + fullContext: boolean; }; /** @@ -269,18 +271,21 @@ const buildCriticDispatch = ( return { jobType: critic.jobType, evalStage: critic.stage, + fullContext: critic.fullContext, prompt: jobScope + CODE_CRITIC_PREAMBLE + '## Implementation Output\n\n' + outputStr, }; case 'test_critic': return { jobType: critic.jobType, evalStage: critic.stage, + fullContext: critic.fullContext, prompt: jobScope + TEST_CRITIC_PREAMBLE + '## Test Files and Spec\n\n' + outputStr, }; case 'interaction_e2e': return { jobType: critic.jobType, evalStage: critic.stage, + fullContext: critic.fullContext, prompt: jobScope + PLAYWRIGHT_EVAL_PREAMBLE, }; default: @@ -304,6 +309,7 @@ const buildCriticDispatch = ( return { jobType: critic.jobType, evalStage: critic.stage, + fullContext: critic.fullContext, prompt: jobScope + promptBody + '\n## Implementation Output\n\n' + outputStr, }; }; @@ -386,12 +392,19 @@ export const registerTenetStartEvalTool = (registerTool: RegisterTool, jobManage output, expected_eval_stages: expectedEvalStages, ...(resolvedFeature ? { feature: resolvedFeature } : {}), - // Propagate the run's artifact_paths + run_path so the critic's worker context - // (built on dispatch via buildWorkerContext) inlines the same spec/scenarios/ - // decomposition/harness the dev worker sees. A critic must evaluate against the - // actual spec — not a label pointing at it. - ...(sourceJob?.params.artifact_paths ? { artifact_paths: sourceJob.params.artifact_paths } : {}), - ...(typeof sourceJob?.params.run_path === 'string' ? { run_path: sourceJob.params.run_path } : {}), + // Grounded critics (full_context !== false, the default) get the run's + // artifact_paths + run_path propagated so buildWorkerContext (run on dispatch) + // inlines the same spec/scenarios/decomposition/harness the dev worker sees — a + // conformance critic must evaluate against the actual spec. An ungrounded critic + // (full_context: false) reviews independently: no docs inlined, so it can catch + // issues the spec itself missed. The artifact_paths labels still reach it via the + // job scope, so it can consult the spec on demand. + ...(d.fullContext && sourceJob?.params.artifact_paths + ? { artifact_paths: sourceJob.params.artifact_paths } + : {}), + ...(d.fullContext && typeof sourceJob?.params.run_path === 'string' + ? { run_path: sourceJob.params.run_path } + : {}), }); type Dispatched = { role: string; id: string; status: Job['status']; parentJobId?: string }; From f707958b6d0531d98844350e7c096095928a08b7 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 14:03:45 +0900 Subject: [PATCH 34/87] docs(critics): full_context applies to built-ins too + backward-compat note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit documented full_context only under the Custom bullet, leaving the Built-ins bullet saying "only enabled matters" — stale, since full_context IS honored on built-ins (and tested: the roster test sets full_context:false on code_critic). Existing .tenet/critics.json files keep working unchanged (full_context defaults true everywhere; no schema bump) — this is the migration story: add full_context to any entry, built-in or custom, when you want to control grounding. - Built-ins bullet now notes full_context is honored (default true). - Added a "Grounding & backward compatibility" section with a mixed example showing full_context:false on a built-in (code_critic) alongside an ungrounded custom critic — demonstrates built-in support and the diversity-of-grounding intent. Docs-only. Co-Authored-By: Claude --- skills/tenet/critics.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/skills/tenet/critics.md b/skills/tenet/critics.md index 970d030..a5a6944 100644 --- a/skills/tenet/critics.md +++ b/skills/tenet/critics.md @@ -46,8 +46,11 @@ beats "check for security issues." } ``` -- **Built-ins** (`builtin: true`): only `enabled` (and order) matter. Omitting - one leaves it enabled at its default position. Set `enabled: false` to drop it. +- **Built-ins** (`builtin: true`): `enabled` and order are the usual levers + (omit one to leave it enabled at its default position; `enabled: false` drops + it). `full_context` is honored here too — set `"full_context": false` on a + built-in (e.g. `code_critic`) to make it review independently of the spec. + Default `true`. Note: the `interaction_e2e` critic handles CLI/API/library surfaces too — agent-brain shell e2e, not just browser — so for a CLI-only project you usually want it **enabled**. Only disable it if you want no public-surface e2e at all. @@ -72,6 +75,31 @@ beats "check for security issues." The file is read live on every eval — edit it and the next `tenet_start_eval` reflects the change with no restart. Invalid JSON falls back to the 3 built-ins. +## Grounding & backward compatibility + +`full_context` is optional and defaults to `true` on every critic — built-in or +custom — so any existing `.tenet/critics.json` keeps working unchanged. No +migration, no schema bump. Add `"full_context": false` to any entry when you want +that critic to review independently of the spec (no docs inlined). It is honored +on built-ins too — for example, to run `code_critic` ungrounded alongside an +ungrounded custom critic: + +```json +{ + "version": 1, + "critics": [ + { "id": "code_critic", "builtin": true, "full_context": false }, + { "id": "test_critic", "builtin": true }, + { "id": "interaction_e2e", "builtin": true }, + { "id": "adversarial", "prompt_file": ".tenet/critics/adversarial.md", "full_context": false } + ] +} +``` + +Here `code_critic` (a built-in) and `adversarial` (custom) review from the code +alone; `test_critic` and `interaction_e2e` stay grounded (default). Mixing is the +point — diversity of grounding, not all-or-nothing. + ## Output contract (mandatory) Every custom critic prompt MUST end by instructing the model to emit exactly this From 39e870e2f561a9e17ad28741c8cee08572732b9c Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 14:14:25 +0900 Subject: [PATCH 35/87] feat(init): surface full_context in the scaffolded critics.json + canonical example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discoverability: a feature only a doc subsection mentions is invisible to anyone who just opens their .tenet/critics.json. The config file itself should advertise it. The scaffolded critics.json (CRITICS_ROSTER_TEMPLATE, what `tenet init` writes) and the canonical example in critics.md now carry "full_context": true on every entry — including the built-ins — so a user sees the field the moment they open the file. (The disabled `security` example already used this self-documenting pattern; full_context follows it.) Existing configs are not auto-rewritten on upgrade — full_context defaults to true so they keep working unchanged, and forcing a no-op field into user-authored files is invasive (unlike the playwright_eval → interaction_e2e rename, which fixed real confusion). Existing users discover it via the scaffold/example. make check: 247 tests pass. Co-Authored-By: Claude --- skills/tenet/critics.md | 9 +++++---- src/cli/init.ts | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/skills/tenet/critics.md b/skills/tenet/critics.md index a5a6944..3c85a9b 100644 --- a/skills/tenet/critics.md +++ b/skills/tenet/critics.md @@ -31,16 +31,17 @@ beats "check for security issues." { "version": 1, "critics": [ - { "id": "code_critic", "builtin": true, "enabled": true }, - { "id": "test_critic", "builtin": true, "enabled": true }, - { "id": "interaction_e2e", "builtin": true, "enabled": true }, + { "id": "code_critic", "builtin": true, "enabled": true, "full_context": true }, + { "id": "test_critic", "builtin": true, "enabled": true, "full_context": true }, + { "id": "interaction_e2e", "builtin": true, "enabled": true, "full_context": true }, { "id": "security", "builtin": false, "enabled": true, "stage": "security_critic", "job_type": "critic_eval", - "prompt_file": ".tenet/critics/security.md" + "prompt_file": ".tenet/critics/security.md", + "full_context": true } ] } diff --git a/src/cli/init.ts b/src/cli/init.ts index 4d4ed64..0b4d68c 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -73,16 +73,17 @@ This directory is for portable Tenet SQLite snapshots that are safe to track in const CRITICS_ROSTER_TEMPLATE = `{ "version": 1, "critics": [ - { "id": "code_critic", "builtin": true, "enabled": true }, - { "id": "test_critic", "builtin": true, "enabled": true }, - { "id": "interaction_e2e", "builtin": true, "enabled": true }, + { "id": "code_critic", "builtin": true, "enabled": true, "full_context": true }, + { "id": "test_critic", "builtin": true, "enabled": true, "full_context": true }, + { "id": "interaction_e2e", "builtin": true, "enabled": true, "full_context": true }, { "id": "security", "builtin": false, "enabled": false, "stage": "security_critic", "job_type": "critic_eval", - "prompt_file": ".tenet/critics/security.md" + "prompt_file": ".tenet/critics/security.md", + "full_context": true } ] } From c2bab203a41fce7595abbdee0a1272eaf1b2e8f5 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 14:57:07 +0900 Subject: [PATCH 36/87] feat(init): migrate pre-existing critics.json to add full_context on upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing .tenet/critics.json files (authored before full_context shipped) never get the field, so their users never discover the grounded/ungrounded option — the exact gap the prior commits tried to close. The scaffold only helps new projects. This adds the missing piece: `tenet init --upgrade` now appends "full_context": true to every critic entry that lacks it. - migrateCriticRosterFullContext runs in the upgrade flow right after the playwright_eval → interaction_e2e rename, and follows the same philosophy: a targeted transform, not parse+reserialize, so the user's formatting is preserved. One-line entries stay one-line; multi-line entries keep their indentation and key order; only the missing key is appended. Critic entries are flat objects, so each `{ ... }` (no nested braces) is matched and patched in its own style. - Idempotent (no-op once every entry carries full_context) and guarded: invalid JSON is parsed first and left untouched rather than risk a corrupt rewrite. make check: 249 tests pass (+2: full_context added to one-line + multi-line entries with formatting preserved; invalid JSON untouched). The existing idempotent test's "clean" fixture now includes full_context so it stays a true no-op. Co-Authored-By: Claude --- src/cli/init.test.ts | 61 +++++++++++++++++++++++++++++++++++++++- src/cli/init.ts | 66 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/cli/init.test.ts b/src/cli/init.test.ts index 53cdc9c..66ad020 100644 --- a/src/cli/init.test.ts +++ b/src/cli/init.test.ts @@ -352,7 +352,12 @@ describe('initProject', () => { const tenetRoot = path.join(projectPath, '.tenet'); fs.mkdirSync(tenetRoot, { recursive: true }); const clean = JSON.stringify( - { version: 1, critics: [{ id: 'interaction_e2e', builtin: true, enabled: true }] }, + { + version: 1, + critics: [ + { id: 'interaction_e2e', builtin: true, enabled: true, full_context: true }, + ], + }, null, 2, ); @@ -363,6 +368,60 @@ describe('initProject', () => { expect(fs.readFileSync(path.join(tenetRoot, 'critics.json'), 'utf8')).toBe(clean); }); + it('adds full_context to pre-existing critic entries on upgrade, preserving formatting', () => { + const projectPath = createTempDir(); + const tenetRoot = path.join(projectPath, '.tenet'); + fs.mkdirSync(tenetRoot, { recursive: true }); + // A pre-feature critics.json: compact one-line built-ins + a multi-line custom + // critic, none carrying full_context. + fs.writeFileSync( + path.join(tenetRoot, 'critics.json'), + '{\n "version": 1,\n "critics": [\n' + + ' { "id": "code_critic", "builtin": true, "enabled": true },\n' + + ' { "id": "test_critic", "builtin": true, "enabled": true },\n' + + ' {\n' + + ' "id": "security",\n' + + ' "builtin": false,\n' + + ' "enabled": false,\n' + + ' "stage": "security_critic",\n' + + ' "prompt_file": ".tenet/critics/security.md"\n' + + ' }\n' + + ' ]\n}\n', + 'utf8', + ); + + initProject(projectPath, { upgrade: true }); + + const after = fs.readFileSync(path.join(tenetRoot, 'critics.json'), 'utf8'); + // One-line built-ins gained full_context inline and stayed one-line. + expect(after).toContain( + '{ "id": "code_critic", "builtin": true, "enabled": true, "full_context": true }', + ); + expect(after).toContain( + '{ "id": "test_critic", "builtin": true, "enabled": true, "full_context": true }', + ); + // Multi-line custom critic: comma on the prior value, new key at the keys' indent. + expect(after).toContain( + '"prompt_file": ".tenet/critics/security.md",\n "full_context": true\n }', + ); + + // Idempotent: a second upgrade changes nothing. + initProject(projectPath, { upgrade: true }); + expect(fs.readFileSync(path.join(tenetRoot, 'critics.json'), 'utf8')).toBe(after); + }); + + it('leaves an invalid critics.json untouched on upgrade', () => { + const projectPath = createTempDir(); + const tenetRoot = path.join(projectPath, '.tenet'); + fs.mkdirSync(tenetRoot, { recursive: true }); + const broken = '{ "version": 1, "critics": [ { "id": "code_critic" ] }'; + fs.writeFileSync(path.join(tenetRoot, 'critics.json'), broken, 'utf8'); + + initProject(projectPath, { upgrade: true }); + + expect(fs.readFileSync(path.join(tenetRoot, 'critics.json'), 'utf8')).toBe(broken); + }); + it('creates lifecycle docs during upgrade without overwriting existing project docs', () => { const projectPath = createTempDir(); const tenetRoot = path.join(projectPath, '.tenet'); diff --git a/src/cli/init.ts b/src/cli/init.ts index 0b4d68c..59dd8ac 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -523,6 +523,7 @@ function upgradeProject(projectPath: string, options?: InitOptions): void { migrateLegacyDocuments(tenetRoot, { enabled: options?.migrateLegacy === true }); migrateLegacyCriticRosterId(projectPath); + migrateCriticRosterFullContext(projectPath); // Overwrite skill files (these are tenet-owned, not user-edited) copySkillDirs(projectPath); @@ -571,6 +572,71 @@ const migrateLegacyCriticRosterId = (projectPath: string): void => { console.log('Updated .tenet/critics.json: renamed playwright_eval → interaction_e2e.'); }; +/** + * Append `"full_context": true` to a single flat critic-entry object that lacks it, + * preserving the entry's one-line or multi-line style. See + * migrateCriticRosterFullContext. + */ +const addFullContextToEntry = (entry: string): string => { + if (entry.includes('"full_context"')) { + return entry; + } + // Multi-line entry whose closing brace sits on its own line: comma the last value + // and insert a new key line at the keys' indent. + const indentMatch = entry.match(/\n([ \t]+)"/); + const indent = indentMatch ? indentMatch[1] : ' '; + const multiline = entry.replace( + /(\S)(\s*\n[ \t]*\})$/, + `$1,\n${indent}"full_context": true$2`, + ); + if (multiline !== entry) { + return multiline; + } + // Brace on the value's line (incl. pure one-liners): insert before the captured + // trailing whitespace + brace so the closing brace is preserved. + return entry.replace(/(\s*\})$/, `, "full_context": true$1`); +}; + +/** + * Add `"full_context": true` to every critic entry in a project's + * `.tenet/critics.json` that lacks it. Surfaces the grounded/ungrounded option in + * the file itself so pre-existing configs discover it on `tenet init --upgrade` + * without reading the docs. + * + * Like migrateLegacyCriticRosterId above, this is a targeted transform that + * preserves the user's formatting: one-line entries stay one-line, multi-line + * entries keep their indentation and key order, and only the missing key is + * appended. Idempotent — a no-op once every entry already carries full_context. + * Invalid JSON is left untouched (parsed first as a guard; never write back a + * corrupt transform). + */ +const migrateCriticRosterFullContext = (projectPath: string): void => { + const rosterPath = path.join(projectPath, '.tenet', 'critics.json'); + if (!fs.existsSync(rosterPath)) { + return; + } + let raw: string; + try { + raw = fs.readFileSync(rosterPath, 'utf8'); + } catch { + return; + } + // Guard: only transform well-formed JSON — a corrupt file is left for the user. + try { + JSON.parse(raw); + } catch { + return; + } + // Critic entries are flat objects (no nested braces); each `{ ... }` here is one + // entry. Append full_context to those missing it, preserving style. + const migrated = raw.replace(/\{[^{}]*\}/g, (entry) => addFullContextToEntry(entry)); + if (migrated === raw) { + return; + } + fs.writeFileSync(rosterPath, migrated, 'utf8'); + console.log('Updated .tenet/critics.json: added full_context to critic entries.'); +}; + const backupStateDb = (tenetRoot: string): string | null => { const stateDir = path.join(tenetRoot, '.state'); const dbPath = path.join(stateDir, 'tenet.db'); From 87bd6e0c3e7c18686ef0c203b9fb5bba129be6f6 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 16:16:04 +0900 Subject: [PATCH 37/87] feat(eval): interaction_e2e defaults to ungrounded (it acts like a user) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interaction_e2e is the exploratory lane — its preamble says "verify the project ACTUALLY WORKS … the way a real user would … not just the scripted checks the author wrote." Grounding it to the spec/scenarios pre-anchors it to the declared happy paths, cutting against that purpose. So it now defaults to full_context: false: explore the surface like a user, consult the spec on demand (its preamble already instructs reading spec/harness/scenarios via artifact_paths). This puts interaction_e2e on the exploratory side of the conformance-vs-exploratory split the research pointed at; code_critic and test_critic stay grounded (conformance). - critic-roster.ts: per-builtin default map (code_critic/test_critic → true, interaction_e2e → false). Explicit full_context in the roster file overrides. - Scaffold + critics.md example show interaction_e2e as full_context: false. - Existing configs are left as-is: the migration (c2bab20) still writes true to entries lacking full_context, so pre-existing projects keep their current grounded interaction_e2e — no surprise flip on upgrade. New projects get the user-like ungrounded default. Anyone overrides via the file. make check: 249 tests pass (roster defaults cover interaction_e2e → false and explicit overrides both directions; dispatch test locks interaction_e2e getting no artifact_paths/run_path by default). Co-Authored-By: Claude --- skills/tenet/critics.md | 17 +++++++------ src/cli/init.ts | 2 +- src/core/critic-roster.test.ts | 32 +++++++++++++++--------- src/core/critic-roster.ts | 34 +++++++++++++++++++------- src/mcp/tools/tenet-start-eval.test.ts | 5 ++++ 5 files changed, 62 insertions(+), 28 deletions(-) diff --git a/skills/tenet/critics.md b/skills/tenet/critics.md index 3c85a9b..8e8d716 100644 --- a/skills/tenet/critics.md +++ b/skills/tenet/critics.md @@ -33,7 +33,7 @@ beats "check for security issues." "critics": [ { "id": "code_critic", "builtin": true, "enabled": true, "full_context": true }, { "id": "test_critic", "builtin": true, "enabled": true, "full_context": true }, - { "id": "interaction_e2e", "builtin": true, "enabled": true, "full_context": true }, + { "id": "interaction_e2e", "builtin": true, "enabled": true, "full_context": false }, { "id": "security", "builtin": false, @@ -49,9 +49,11 @@ beats "check for security issues." - **Built-ins** (`builtin: true`): `enabled` and order are the usual levers (omit one to leave it enabled at its default position; `enabled: false` drops - it). `full_context` is honored here too — set `"full_context": false` on a - built-in (e.g. `code_critic`) to make it review independently of the spec. - Default `true`. + it). `full_context` is honored here too. `code_critic` and `test_critic` default + to `true` (conformance — they check against the spec/tests); `interaction_e2e` + defaults to `false` (it acts like a user — explore the surface, don't anchor to + the declared spec). Set `false` on a conformance built-in, or `true` on + `interaction_e2e`, to override either default. Note: the `interaction_e2e` critic handles CLI/API/library surfaces too — agent-brain shell e2e, not just browser — so for a CLI-only project you usually want it **enabled**. Only disable it if you want no public-surface e2e at all. @@ -97,9 +99,10 @@ ungrounded custom critic: } ``` -Here `code_critic` (a built-in) and `adversarial` (custom) review from the code -alone; `test_critic` and `interaction_e2e` stay grounded (default). Mixing is the -point — diversity of grounding, not all-or-nothing. +Here `code_critic` (overridden to `false`), `interaction_e2e` (ungrounded by +default — it acts like a user), and `adversarial` (custom) review from the code +alone; `test_critic` stays grounded. Mixing is the point — diversity of grounding, +not all-or-nothing. ## Output contract (mandatory) diff --git a/src/cli/init.ts b/src/cli/init.ts index 59dd8ac..4b9c6dd 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -75,7 +75,7 @@ const CRITICS_ROSTER_TEMPLATE = `{ "critics": [ { "id": "code_critic", "builtin": true, "enabled": true, "full_context": true }, { "id": "test_critic", "builtin": true, "enabled": true, "full_context": true }, - { "id": "interaction_e2e", "builtin": true, "enabled": true, "full_context": true }, + { "id": "interaction_e2e", "builtin": true, "enabled": true, "full_context": false }, { "id": "security", "builtin": false, diff --git a/src/core/critic-roster.test.ts b/src/core/critic-roster.test.ts index db2514d..ee44206 100644 --- a/src/core/critic-roster.test.ts +++ b/src/core/critic-roster.test.ts @@ -116,20 +116,28 @@ describe('resolveRoster', () => { expect(byId(roster, 'x')?.jobType).toBe('critic_eval'); }); - it('defaults full_context to true and honors full_context:false on built-in and custom', () => { - const roster = resolveRoster({ + it('defaults full_context per built-in and honors explicit overrides', () => { + // interaction_e2e defaults to false (acts like a user); the conformance built-ins + // and custom critics default to true. Explicit full_context always wins. + const explicit = resolveRoster({ critics: [ - { id: 'code_critic', builtin: true, full_context: false }, + { id: 'code_critic', builtin: true, full_context: false }, // override true → false + { id: 'interaction_e2e', builtin: true, full_context: true }, // override false → true { id: 'adversarial', prompt_file: '.tenet/critics/adversarial.md', full_context: false }, - { id: 'lint', prompt_file: '.tenet/critics/lint.md' }, // default true ], }); - expect(byId(roster, 'code_critic')?.fullContext).toBe(false); - expect(byId(roster, 'adversarial')?.fullContext).toBe(false); - expect(byId(roster, 'lint')?.fullContext).toBe(true); - // Built-ins omitted from the file default to true. - expect(byId(roster, 'test_critic')?.fullContext).toBe(true); - expect(byId(roster, 'interaction_e2e')?.fullContext).toBe(true); + expect(byId(explicit, 'code_critic')?.fullContext).toBe(false); + expect(byId(explicit, 'interaction_e2e')?.fullContext).toBe(true); + expect(byId(explicit, 'adversarial')?.fullContext).toBe(false); + + // Omitted entries take their per-builtin default. + const defaults = resolveRoster({ + critics: [{ id: 'lint', prompt_file: '.tenet/critics/lint.md' }], + }); + expect(byId(defaults, 'code_critic')?.fullContext).toBe(true); + expect(byId(defaults, 'test_critic')?.fullContext).toBe(true); + expect(byId(defaults, 'interaction_e2e')?.fullContext).toBe(false); + expect(byId(defaults, 'lint')?.fullContext).toBe(true); // custom default true }); it('drops duplicate ids (first wins) and skips malformed entries', () => { @@ -151,7 +159,9 @@ describe('resolveRoster', () => { it('DEFAULT_ROSTER is the 3 built-ins enabled', () => { expect(DEFAULT_ROSTER.map((c) => c.id)).toEqual(['code_critic', 'test_critic', 'interaction_e2e']); expect(DEFAULT_ROSTER.every((c) => c.enabled)).toBe(true); - expect(DEFAULT_ROSTER.every((c) => c.fullContext)).toBe(true); + expect(DEFAULT_ROSTER.find((c) => c.id === 'code_critic')?.fullContext).toBe(true); + expect(DEFAULT_ROSTER.find((c) => c.id === 'test_critic')?.fullContext).toBe(true); + expect(DEFAULT_ROSTER.find((c) => c.id === 'interaction_e2e')?.fullContext).toBe(false); }); }); diff --git a/src/core/critic-roster.ts b/src/core/critic-roster.ts index 3f05c45..5a26df9 100644 --- a/src/core/critic-roster.ts +++ b/src/core/critic-roster.ts @@ -46,6 +46,20 @@ const BUILTIN_JOB_TYPE: Record = { interaction_e2e: 'interaction_e2e', }; +/** + * Default `full_context` per built-in. code_critic and test_critic are conformance + * critics — they need the spec/scenarios inlined to check against them. interaction_e2e + * is the exploratory/user lane: it verifies the app works by using it like a real + * user, so it defaults to reviewing from the surface (ungrounded) instead of anchored + * to the declared spec/scenarios. An explicit `full_context` in the roster file always + * overrides these defaults. + */ +const BUILTIN_DEFAULT_FULL_CONTEXT: Record = { + code_critic: true, + test_critic: true, + interaction_e2e: false, +}; + /** * Legacy `.tenet/critics.json` files authored before the rename use the id * `playwright_eval`. Map that onto the current built-in so those files keep @@ -70,12 +84,14 @@ export type CriticRosterEntry = { prompt_file?: string; /** * Whether the critic receives the run docs (spec/scenarios/decomposition/harness) - * inlined into its worker context. Default `true` — conformance critics need the - * spec to check it. Set `false` for an independent/adversarial critic that should - * review WITHOUT being anchored to the spec: it gets only its prompt + diff, so it - * can catch issues the spec itself missed (diversity of grounding, not universal - * anchoring). The artifact_paths labels still reach it via the job scope, so an - * ungrounded critic can consult the spec on demand if its review raises a question. + * inlined into its worker context. Defaults to `true` for the conformance built-ins + * (code_critic, test_critic) and for custom critics, and `false` for interaction_e2e + * (it acts like a user — explore the surface, don't anchor to the spec). Set `false` + * explicitly for an independent/adversarial critic that should review WITHOUT being + * anchored to the spec: it gets only its prompt + diff, so it can catch issues the + * spec itself missed (diversity of grounding, not universal anchoring). The + * artifact_paths labels still reach it via the job scope, so an ungrounded critic + * can consult the spec on demand if its review raises a question. */ full_context?: boolean; }; @@ -99,7 +115,7 @@ export const DEFAULT_ROSTER: readonly ResolvedCritic[] = BUILTIN_CRITIC_IDS.map( enabled: true, stage: BUILTIN_STAGE[id], jobType: BUILTIN_JOB_TYPE[id], - fullContext: true, + fullContext: BUILTIN_DEFAULT_FULL_CONTEXT[id], })); const isBuiltinId = (id: string): id is BuiltinCriticId => @@ -164,7 +180,7 @@ export const resolveRoster = (raw: unknown): ResolvedCritic[] => { enabled: e.enabled !== false, stage: BUILTIN_STAGE[id], jobType: BUILTIN_JOB_TYPE[id], - fullContext: e.full_context !== false, + fullContext: typeof e.full_context === 'boolean' ? e.full_context : BUILTIN_DEFAULT_FULL_CONTEXT[id], }); usedIds.add(id); } else { @@ -193,7 +209,7 @@ export const resolveRoster = (raw: unknown): ResolvedCritic[] => { enabled: true, stage: BUILTIN_STAGE[id], jobType: BUILTIN_JOB_TYPE[id], - fullContext: true, + fullContext: BUILTIN_DEFAULT_FULL_CONTEXT[id], }); } } diff --git a/src/mcp/tools/tenet-start-eval.test.ts b/src/mcp/tools/tenet-start-eval.test.ts index 4bc0145..482518b 100644 --- a/src/mcp/tools/tenet-start-eval.test.ts +++ b/src/mcp/tools/tenet-start-eval.test.ts @@ -320,6 +320,11 @@ describe('tenet_start_eval eval mode resolution', () => { }); // The false "you receive the spec" claim is gone (the docs are now actually inlined). expect(codeCritic?.params.prompt).not.toContain('You receive ONLY'); + + // interaction_e2e defaults to ungrounded (it acts like a user) — no inlined docs. + const e2e = store.getJob(jobId(parsed, 'interaction_e2e')); + expect(e2e?.params.artifact_paths).toBeUndefined(); + expect(e2e?.params.run_path).toBeUndefined(); }); it('does not propagate artifact_paths/run_path to a full_context:false critic (ungrounded review)', async () => { From 9ff5f21dfd290ea0a80d9fd09dc2cc3c3c510056 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 16:44:29 +0900 Subject: [PATCH 38/87] chore: bump to 26.7.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e437894..276bbda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.6.8", + "version": "26.7.0", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From 5ef895855f31cbc244b58d455af893b1df2509c1 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 22:37:19 +0900 Subject: [PATCH 39/87] fix(eval): stop critics being misled by their run context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related honesty fixes to the context handed to workers/critics: - Wrap the auto-compiled run docs in a delimited block and strip the role/imperative language ("(worker)", "do not work blind"). The block is now pure role-agnostic reference; the closing tag separates it from the task preamble. This stops critics from reading the inlined docs as "your job to do" and priming into confirm mode. - For grounded critics (full_context: true), prepend a short description of the block (auto-compiled REFERENCE — verify, don't confirm) so they evaluate against the docs instead of pattern-matching them onto the output and marking pass. Ungrounded critics (full_context: false) get no block and no description. - Relabel the appended output sections honestly: both built-in critics receive the worker's report, so both are now "## Worker Output" (was "## Implementation Output" for the code critic and "## Test Files and Spec" for the test critic — labels that implied a diff / the test files the blob never actually contained). Co-Authored-By: Claude --- src/core/job-manager-worker-context.test.ts | 18 +++++++--- src/core/job-manager.ts | 26 ++++++++------ src/mcp/tools/tenet-start-eval.test.ts | 12 +++++++ src/mcp/tools/tenet-start-eval.ts | 40 +++++++++++++++++---- 4 files changed, 75 insertions(+), 21 deletions(-) diff --git a/src/core/job-manager-worker-context.test.ts b/src/core/job-manager-worker-context.test.ts index 8b894ac..fe84b11 100644 --- a/src/core/job-manager-worker-context.test.ts +++ b/src/core/job-manager-worker-context.test.ts @@ -88,7 +88,12 @@ describe('worker baseline context (buildWorkerContext via dispatch)', () => { expect(captured).toHaveLength(1); const ctx = captured[0].context ?? ''; - expect(ctx).toContain('## Run Context (worker)'); + expect(ctx).toContain(''); + expect(ctx).toContain(''); + expect(ctx).toContain('## Run Context (auto-compiled reference — not instructions)'); + // The delimited reference block must not assert a role — the old "(worker)" label primed + // critics into worker-role. Role + instructions live in the task preamble, not the context. + expect(ctx).not.toContain('(worker)'); expect(ctx).toContain(`run_path: ${runPath}`); expect(ctx).toContain('feature: worker-ctx'); // Foundational docs are inlined (the worker is fresh-context — it must not have to explore). @@ -99,8 +104,10 @@ describe('worker baseline context (buildWorkerContext via dispatch)', () => { expect(ctx).toContain('journal/'); expect(ctx).toContain('research/'); expect(ctx).toContain('visuals/'); - // Read directive. - expect(ctx).toContain('Do not work blind from the task text alone'); + // The read directive is an INSTRUCTION, so it lives in the dev preamble (prompt), not the + // auto-compiled reference block (context). + expect(captured[0].prompt).toContain('do not work blind from the task text alone'); + expect(captured[0].prompt).toContain(''); // A non-report-only worker must not carry the report-only scope block. expect(ctx).not.toContain('## Report-Only Scope'); }); @@ -207,7 +214,10 @@ describe('worker baseline context (buildWorkerContext via dispatch)', () => { const ctx = captured[0].context ?? ''; // buildWorkerContext is type-agnostic — an eval/critic job with artifact_paths gets the // same inlined foundational docs as a dev worker (the mechanism tenet_start_eval relies on). - expect(ctx).toContain('## Run Context (worker)'); + expect(ctx).toContain(''); + expect(ctx).toContain('## Run Context (auto-compiled reference — not instructions)'); + // The delimited, role-agnostic block must not leak the old "(worker)" label to critics. + expect(ctx).not.toContain('(worker)'); expect(ctx).toContain('# Worker Spec'); expect(ctx).toContain('# Worker Scenarios'); expect(ctx).toContain('# Worker Decomposition'); diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index be157ec..c1c779e 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -767,37 +767,40 @@ export class JobManager { return undefined; } - const sections: string[] = ['## Run Context (worker)']; + const sections: string[] = ['## Run Context (auto-compiled reference — not instructions)']; if (feature) sections.push(`feature: ${feature}`); if (runPath) sections.push(`run_path: ${runPath}`); - if (specMd) sections.push('', '## Spec (inlined — source of truth for this job)', specMd); + if (specMd) sections.push('', '## Spec (inlined — source of truth for this run)', specMd); if (scenariosMd) { - sections.push('', '## Scenarios (inlined — success/failure shapes for this job)', scenariosMd); + sections.push('', '## Scenarios (inlined — success/failure shapes for this run)', scenariosMd); } if (decompositionMd) { - sections.push('', '## Decomposition (inlined — your job and its DAG context)', decompositionMd); + sections.push('', '## Decomposition (inlined — the run\'s plan / DAG)', decompositionMd); } if (harnessMd) sections.push('', '## Harness (inlined)', harnessMd); if (runPath) { sections.push( '', - '## Selective references (read the ones relevant to this job)', + '## Selective references (consult as relevant)', `Under ${runPath}/: journal/ (prior attempts + failure logs), research/ (current-run research), visuals/ (UI/architecture mockups).`, ); } - sections.push( - '', - 'Do not work blind from the task text alone — read the run docs above first; they are the source of truth.', - ); + // Wrap the auto-compiled reference in a delimited block so the recipient (worker OR critic) + // treats it as provided material, not as addressed-to-them instructions. This boundary matters + // most for critics: without it the inlined docs read as "your job to do" and prime the critic + // into confirm mode (it sees the plan + output match and marks pass). The closing tag + // terminates "reference" before the task preamble that follows (which carries role + instructions). + const reference = `\n${sections.join('\n')}\n`; + // Report-only scope is an INSTRUCTION, not reference, so it lives outside the delimited block. if (reportOnly) { - sections.push('', ...reportOnlyScopeLines(job.id)); + return `${reference}\n\n${reportOnlyScopeLines(job.id).join('\n')}`; } - return sections.join('\n'); + return reference; } private withDevPreamble(prompt: string, job: Job): string { @@ -822,6 +825,7 @@ export class JobManager { '## Deliverable Requirements', '', 'You are a worker agent executing a development job. You MUST produce concrete deliverables:', + 'If a block appears above, read it first — it is the source of truth for this job; do not work blind from the task text alone.', '- Write or modify source code files that implement the described feature', '- Ensure the code compiles/passes type-checking', '- Run existing tests to verify no regressions', diff --git a/src/mcp/tools/tenet-start-eval.test.ts b/src/mcp/tools/tenet-start-eval.test.ts index 482518b..9911a5c 100644 --- a/src/mcp/tools/tenet-start-eval.test.ts +++ b/src/mcp/tools/tenet-start-eval.test.ts @@ -325,6 +325,14 @@ describe('tenet_start_eval eval mode resolution', () => { const e2e = store.getJob(jobId(parsed, 'interaction_e2e')); expect(e2e?.params.artifact_paths).toBeUndefined(); expect(e2e?.params.run_path).toBeUndefined(); + + // A grounded critic (code_critic, full_context: true default) leads its prompt with a + // description of the inlined block — auto-compiled REFERENCE, not + // instructions — so it verifies against the docs instead of pattern-matching them onto the + // output and marking pass. An ungrounded critic (no block) gets no such description. + expect(codeCritic?.params.prompt).toContain('## Run Context (auto-compiled reference)'); + expect(codeCritic?.params.prompt).toContain('AUTO-COMPILED REFERENCE'); + expect(e2e?.params.prompt).not.toContain('## Run Context (auto-compiled reference)'); }); it('does not propagate artifact_paths/run_path to a full_context:false critic (ungrounded review)', async () => { @@ -359,6 +367,10 @@ describe('tenet_start_eval eval mode resolution', () => { harness: `${runPath}/harness.md`, }); expect(codeCritic?.params.run_path).toBe(runPath); + + // Grounded critic leads with the run-context description; the ungrounded custom critic does not. + expect(codeCritic?.params.prompt).toContain('## Run Context (auto-compiled reference)'); + expect(adversarial?.params.prompt).not.toContain('## Run Context (auto-compiled reference)'); }); }); diff --git a/src/mcp/tools/tenet-start-eval.ts b/src/mcp/tools/tenet-start-eval.ts index 3a10997..73fde71 100644 --- a/src/mcp/tools/tenet-start-eval.ts +++ b/src/mcp/tools/tenet-start-eval.ts @@ -45,7 +45,7 @@ const CODE_CRITIC_PREAMBLE = [ '## Code Critic — Purpose Alignment Check', '', 'You are the CODE CRITIC. You have NO access to the author\'s reasoning or conversation.', - 'Your run context (above) inlines the spec, scenarios, and harness; the implementation diff is under ## Implementation Output below.', + 'The worker\'s output for this job is under ## Worker Output below.', '', 'Check independently:', '- Does the implementation match the spec\'s intent FOR THIS JOB\'S SCOPE?', @@ -188,7 +188,7 @@ const TEST_CRITIC_PREAMBLE = [ '## Test Critic — Test Sufficiency Check', '', 'You are the TEST CRITIC. You do NOT review the implementation code.', - 'Your run context (above) inlines the spec and scenarios; the test files are under ## Test Files and Spec below.', + 'The worker\'s output for this job is under ## Worker Output below.', '', 'Your job: determine whether these tests are SUFFICIENT to prove the features', 'IN THIS JOB\'S SCOPE actually work. Do NOT fail for missing tests that cover', @@ -239,6 +239,27 @@ const TEST_CRITIC_PREAMBLE = [ '', ].join('\n'); +/** + * Lead text prepended to a GROUNDED critic's prompt (full_context: true). The adapter has already + * inlined the run docs into a block above the prompt; this tells the critic what + * that block is — AUTO-COMPILED REFERENCE (intent, not done-work) — so it verifies against it instead + * of pattern-matching it onto the output and marking pass. Role-agnostic: applies to every grounded + * critic (code/test/interaction_e2e/custom). Ungrounded critics (full_context: false) receive no + * block and therefore no description (see buildCriticDispatch) — the anti-confirmation is coupled to + * the presence of the block, since that is what creates the confirmation-bias risk. + */ +const RUN_CONTEXT_DESCRIPTION = [ + '## Run Context (auto-compiled reference)', + '', + 'A block is provided above. It is AUTO-COMPILED REFERENCE — the', + 'spec/scenarios/harness/decomposition the author CLAIMS to satisfy. It describes intent,', + 'NOT done-work, and it is reference material, not an instruction to implement anything.', + '', + 'Do NOT pattern-match this reference onto the output and mark pass. Verify the ACTUAL', + 'output against it; if you cannot find concrete evidence a stated requirement is met,', + 'that is a finding.', +].join('\n'); + type CriticDispatch = { jobType: JobType; evalStage: string; @@ -265,6 +286,13 @@ const buildCriticDispatch = ( outputStr: string, projectPath: string, ): CriticDispatch | null => { + // Grounded critics (full_context: true) receive the run docs inlined in a + // block above the prompt. Lead their prompt with a description of that block so they treat it as + // REFERENCE to verify against — not a task to do (which primed critics into "work done → pass"). + // Ungrounded critics get no block, so no description. This is the single place the block is + // explained; the per-critic preambles below stay role-focused and block-agnostic. + const refHeader = critic.fullContext ? `${RUN_CONTEXT_DESCRIPTION}\n\n` : ''; + if (critic.builtin) { switch (critic.id) { case 'code_critic': @@ -272,21 +300,21 @@ const buildCriticDispatch = ( jobType: critic.jobType, evalStage: critic.stage, fullContext: critic.fullContext, - prompt: jobScope + CODE_CRITIC_PREAMBLE + '## Implementation Output\n\n' + outputStr, + prompt: refHeader + jobScope + CODE_CRITIC_PREAMBLE + '## Worker Output\n\n' + outputStr, }; case 'test_critic': return { jobType: critic.jobType, evalStage: critic.stage, fullContext: critic.fullContext, - prompt: jobScope + TEST_CRITIC_PREAMBLE + '## Test Files and Spec\n\n' + outputStr, + prompt: refHeader + jobScope + TEST_CRITIC_PREAMBLE + '## Worker Output\n\n' + outputStr, }; case 'interaction_e2e': return { jobType: critic.jobType, evalStage: critic.stage, fullContext: critic.fullContext, - prompt: jobScope + PLAYWRIGHT_EVAL_PREAMBLE, + prompt: refHeader + jobScope + PLAYWRIGHT_EVAL_PREAMBLE, }; default: return null; @@ -310,7 +338,7 @@ const buildCriticDispatch = ( jobType: critic.jobType, evalStage: critic.stage, fullContext: critic.fullContext, - prompt: jobScope + promptBody + '\n## Implementation Output\n\n' + outputStr, + prompt: refHeader + jobScope + promptBody + '\n## Implementation Output\n\n' + outputStr, }; }; From 6d487aea7f11303bb91ef1fdbda5488273478072 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 2 Jul 2026 22:40:54 +0900 Subject: [PATCH 40/87] chore: bump to 26.7.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 276bbda..3571cca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.7.0", + "version": "26.7.1", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From fa21292a7ebe434dbf2a3ce94bc7f0d3bbdaf24f Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Sat, 4 Jul 2026 09:48:13 +0900 Subject: [PATCH 41/87] docs(readme): restructure around model tiers, independent critics, doctrine drift Re-pitch the README around the now-complete story instead of the older phase/critic inventory. - Tagline + Why Tenet? lead with the differentiators: model-tier-adaptive plans, independent (grounded/ungrounded) critics, self-correcting doctrine, then DAG / long-run / agent-agnostic. - How It Works reframed as three pillars: the plan adapts to the model, every job is judged independently, the project's truth self-corrects. - Configurable Critics example shows full_context and explains grounded vs ungrounded review. - Fix stale Execution Modes table: no mode skips the interview (since 8273e40); they differ only in interview depth and ceremony. - Add .tenet/critics.json + critics/ to the project-structure tree. - Drop the fabricated "~6% precision" stat; state the oracle mechanism. - Drop the `tenet serve` block (auto-started by the MCP configs) and the `npx` one-liner (breaks the `command: tenet` MCP launch). Reference sections (CLI, 19 MCP tools, adapters, crash recovery) preserved verbatim; `make docs-review` confirms all code-checked facts still match. Co-Authored-By: Claude --- README.md | 168 +++++++++++++++++++++++++++--------------------------- 1 file changed, 84 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 9c942ca..4939648 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,28 @@ > **T**alk. **E**stablish. **N**onstop. **E**valuate. **T**enet. -**Cross-platform AI agent plugin for 12+ hour autonomous development cycles.** +**The agent harness for long, reliable autonomous development.** -*tenet* — a principle held to be true. Also a palindrome: it reads the same forward and backward, just like the process. **Talk** through what you want. **Establish** the spec and plan. **Nonstop** execution through the DAG. **Evaluate** every line with independent critics. Each cycle produces a **tenet** — a verified feature that feeds the next cycle. +AI coding agents are powerful but short-lived — they lose context, drift off-spec, skip tests, and can't hold a multi-hour session together. Tenet is the harness around them: it interviews you, writes the spec, plans the work as a dependency graph, **adapts that plan to your model's capability**, executes each job, and **judges every job with independent critics** before moving on. Runs loop for hours; the project's durable truth **keeps itself current** between them. + +*tenet* — a principle held to be true. Also a palindrome: it reads the same forward and backward, just like the process. Each cycle produces a tenet — a verified feature, and a truer project — that feeds the next. ``` -You: "Add social features — reactions, badges, user profiles, share cards" -Tenet: interviews you, writes the spec, generates visual mockups, - decomposes into a dependency graph, implements each job, - asks workers to commit per job, evaluates with independent critics (3 built-in, configurable), - and loops for 6+ hours until everything passes. +You: "Add social features — reactions, badges, profiles, share cards" +Tenet: interviews you, writes the spec, mocks up the UI, + decomposes into a DAG, implements each job with per-job commits, + and evaluates every job with independent critics until it passes — + then folds what it learned back into the project doctrine. ``` ## Why Tenet? -AI coding agents are powerful but short-lived. They lose context, drift off-spec, skip tests, and can't sustain multi-hour development sessions. Tenet solves this: - -- **Structured phases** — Context bootstrap, Interview, Spec, Visuals, Decomposition, Execution, Evaluation, and Agile checkpoints. Full mode runs all of them; Standard skips the interview and Quick skips interview/spec/decomposition (see Execution Modes). -- **DAG-based job orchestration** — Dependencies are explicit. Parallel jobs run in parallel. Blocked jobs wait. -- **Configurable critic pipeline** — 3 built-in critics by default (code, test, interaction-e2e), plus project-defined custom critics via `.tenet/critics.json`. All independent, all with fresh context (no author bias). All findings are blocking. -- **Crash recovery** — Server-ID-based orphan detection. If the MCP server dies, jobs auto-retry on restart. -- **Agent-agnostic** — Works with Claude Code, OpenCode, and Codex. Switch agents mid-project without losing state. -- **Persistent state** — Versioned SQLite + WAL mode. Jobs, events, steer messages, and config survive crashes. +- **Plans that adapt to your model.** Declare `frontier` or `local` and Tenet reshapes the DAG to match — local models get a finer-grained plan with explicit per-job acceptance criteria; frontier models get a coarse, goal-oriented one. Every worker receives the spec inlined, so no one works blind. Run cheaper models without drifting off-spec. +- **Independent, adversarial critics.** Every job faces critics with fresh context and no access to the author's reasoning. Grounded critics check conformance to the spec; ungrounded critics review free of it, catching issues the spec itself missed. The oracle problem — AI tests that verify implementation behavior rather than intent — is checked explicitly. +- **Self-correcting project doctrine.** Durable project truth (architecture, testing, design) stays current on its own: jobs flag stale doctrine as drift, the run consolidates proposals, and an authorized job applies the accepted ones. Your `.tenet/project/` never silently rots. +- **DAG-based orchestration.** Dependencies are explicit. Parallel jobs run in parallel; blocked jobs wait. +- **Built for long runs.** 12+ hour autonomy, server-ID crash recovery, heartbeat stall detection, unlimited retry by default. +- **Agent-agnostic.** Claude Code, OpenCode, and Codex. Switch agents mid-project without losing state. ## Built With Tenet @@ -46,81 +46,90 @@ tenet init # In Codex: use the tenet skill ``` -### One-liner (skip interactive prompts) - -```bash -npx @jeikeilim/tenet init --agent claude-code --skip-playwright-check -``` - ## How It Works -### The 8 Phases +Tenet runs an autonomous loop — **interview → spec → plan → execute → evaluate → fold learnings back into doctrine** — repeating until the feature passes every critic. Three things make that loop reliable. -| Phase | What Happens | +### The phases + +| Phase | What happens | |-------|-------------| | **0. Context Bootstrap** | Establishes durable project doctrine — live-scans existing code (brownfield) or defers to the interview (greenfield) | -| **1. Interview** | Agent asks clarifying questions, researches technologies | -| **2. Spec & Harness** | Writes formal spec with scenarios + quality contract | +| **1. Interview** | Asks clarifying questions, researches technologies; captures `delivery_mode` and `model_tier` | +| **2. Spec & Harness** | Writes a formal spec with scenarios + a quality contract | | **3. Visuals** | Generates architecture diagrams, UI mockups, DESIGN.md | -| **4. Decomposition** | Breaks spec into a dependency graph (DAG) of jobs | -| **5. Execution Loop** | Implements each job, prompts per-job commits, evaluates, retries on failure | +| **4. Decomposition** | Breaks the spec into a dependency graph (DAG) of jobs — granularity follows `model_tier` | +| **5. Execution Loop** | Implements each job (spec inlined), prompts per-job commits, evaluates, retries on failure | | **6. Evaluation** | Independent critics: code, tests, interaction-e2e (+ project-defined custom critics) | | **7. Agile Checkpoints** | Handles plan/use checkpoints and redirect loops in agile mode | -### The Evaluation Pipeline +### 1 · The plan adapts to the model + +In the interview you declare a `model_tier` — `frontier` (default, today's behavior) or `local`. Decomposition branches on it: `local` produces a finer-grained DAG of small, single-responsibility jobs each with explicit acceptance criteria and minimal implicit context; `frontier` produces fewer, larger, goal-oriented jobs. Either way, every dispatched worker receives the foundational run docs (spec, decomposition, harness) **inlined** into its context — it doesn't have to explore `.tenet/` blind. So a weaker executor gets a tighter plan and never starts a job short on context. -Every completed job faces the configured critics — 3 built-in by default (code, test, interaction-e2e), plus any project-defined custom critics from `.tenet/critics.json`. Each runs with fresh context and no access to the author's reasoning: +### 2 · Every job is judged independently + +When a job completes, the configured critics run — each as a fresh-context eval job with no access to the author's reasoning: ``` Job Complete | - +---> Code Critic (spec alignment, security, edge cases) - +---> Test Critic (oracle problem detection, behavioral coverage) - +---> Interaction E2E (agent-driven e2e on the public surface — browser via Playwright MCP, CLI/API/library via shell) - +---> [custom critics] (repo-specific: security, a11y, API contract, ...) + +---> Code Critic (spec alignment, security, edge cases) grounded + +---> Test Critic (oracle-problem detection, coverage) grounded + +---> Interaction E2E (agent-driven e2e on the public surface) ungrounded + +---> [custom critics] (repo-specific: security, a11y, contracts) your choice | - ALL must pass --> Next job - ANY fails --> Retry with failure context + ALL pass --> next job + ANY fails --> retry with failure context ``` -**The Oracle Problem**: Research shows AI-written tests have ~6% precision when the same agent writes both code and tests. Tenet's test critic explicitly checks for oracle leakage — tests that verify implementation behavior rather than intended behavior. +Each critic can be **grounded** (`full_context: true` — the run docs are inlined, so it checks the work against the spec) or **ungrounded** (`full_context: false` — no spec inlined, so it reviews independently and can catch what the spec itself missed). Diversity of grounding is the point: not all-or-nothing. + +> **The Oracle Problem.** When one agent writes both the code and its tests, the tests tend to ratify the implementation rather than the intent — they encode the same assumptions the code did, so they pass even when the behavior is wrong. Tenet's test critic runs with fresh context (no access to the author's reasoning) and explicitly hunts for this leakage. + +### 3 · The project's truth self-corrects + +Durable doctrine in `.tenet/project/` (overview, architecture, product, testing, design) isn't write-once. As jobs run, they flag doctrine that no longer matches reality as drift notes. At run end, those notes consolidate into `doctrine-proposals.md`, and an authorized job applies the accepted ones — then the bootstrap gate re-runs to keep doctrine coherent. Your project's source of truth heals itself between runs instead of silently going stale. -### Configurable Critics +### Steer mid-run -The critic set is a project file: `.tenet/critics.json`, scaffolded by `tenet init` and read live on every eval (just edit it — no restart). A missing or invalid file falls back to the 3 built-ins. +Redirect the agent without breaking the loop: + +``` +You: "Focus on the API first, skip the frontend for now" +Tenet: classifies as directive, adjusts job priority, continues +``` + +Three classes: `context` (informational), `directive` (priority change), `emergency` (halt everything). User steers are returned in full and protected from being crowded out by agent-generated noise. + +## Configurable Critics + +The critic set is a project file: `.tenet/critics.json`, scaffolded by `tenet init` and read live on every eval — edit it, no restart. A missing or invalid file falls back to the 3 built-ins. ```json { "version": 1, "critics": [ - { "id": "code_critic", "builtin": true, "enabled": true }, - { "id": "test_critic", "builtin": true, "enabled": true }, - { "id": "interaction_e2e", "builtin": true, "enabled": true }, - { "id": "security", "builtin": false, "enabled": true, + { "id": "code_critic", "builtin": true, "enabled": true, "full_context": true }, + { "id": "test_critic", "builtin": true, "enabled": true, "full_context": true }, + { "id": "interaction_e2e", "builtin": true, "enabled": true, "full_context": false }, + { + "id": "security", "builtin": false, "enabled": true, "full_context": true, "stage": "security_critic", "job_type": "critic_eval", - "prompt_file": ".tenet/critics/security.md" } + "prompt_file": ".tenet/critics/security.md" + } ] } ``` +- **`full_context`** — `true` (default) grounds the critic: the run docs are inlined so it checks conformance. `false` reviews independently of the spec — adversarial, able to catch what the spec missed. The artifact paths still appear in its job scope, so an ungrounded critic can consult the spec on demand — independent, not blind. Built-ins honor it too: `code_critic`/`test_critic` default `true`; `interaction_e2e` defaults `false` (it acts like a user — explore, don't anchor to the declared spec). - **Built-ins** — flip `enabled: false` to drop any one. The interaction-e2e critic covers CLI/API/library surfaces too (agent-brain shell e2e), so keep it enabled for CLI-only projects — only disable it if you want no public-surface e2e at all. -- **Custom critics** — two steps: write a prompt at `.tenet/critics/.md`, then add an entry. A custom prompt must end by emitting the verdict Tenet parses — +- **Custom critics** — write a prompt at `.tenet/critics/.md`, then add an entry. The prompt must end by emitting the verdict Tenet parses — `{"passed": true/false, "stage": "", "findings": [{"category": "product_bug", "detail": "..."}]}` — where `category` is one of `product_bug | test_bug | harness_bug | evidence_mismatch | contention | scope_conflict` so findings route to the right fix. Prefer not to hand-write the prompt? In Claude Code, ask *"tenet, create a security critic for this repo"* and it authors both the prompt and the roster entry, then smoke-tests it. Full reference: `skills/tenet/critics.md`. -### Steer Messages - -Redirect the agent mid-run without breaking the loop: - -``` -You: "Focus on the API first, skip the frontend for now" -Tenet: classifies as directive, adjusts job priority, continues -``` - -Three classes: `context` (informational), `directive` (priority change), `emergency` (halt everything). - ## Architecture ``` @@ -132,7 +141,7 @@ Three classes: `context` (informational), `directive` (priority change), `emerge MCP Protocol | +--------v--------+ - | MCP Server | 19 tools (start_job, eval, steer, etc.) + | MCP Server | 19 tools (start_job, eval, steer, ...) +--------+--------+ | +--------------+--------------+ @@ -142,17 +151,9 @@ Three classes: `context` (informational), `directive` (priority change), `emerge | (DAG, retry| | (SQLite+WAL)| | (subprocess)| | heartbeat) | | | | | +------------+ +-------------+ +-------------+ - claude --print --output-format json - opencode run --format json - codex exec --sandbox workspace-write ``` -**Four layers:** - -1. **Core** — Job orchestration with DAG execution, heartbeat stall detection, configurable retry logic, and server-ID crash recovery -2. **Adapters** — Pluggable agent adapters that spawn CLI subprocesses. 120-minute default timeout, configurable. -3. **MCP Server** — 19 tools via `@modelcontextprotocol/server`. Zod-validated inputs. -4. **CLI** — `init`, `serve`, `status`, `config`, and `db` maintenance commands. Scaffolds `.tenet/`, copies skills to agent-specific locations, and runs explicit DB upgrades. +Four layers: **Core** (DAG execution, heartbeat stall detection, retry, server-ID crash recovery) · **Adapters** (pluggable agent subprocess spawners, 120-min default timeout) · **MCP Server** (19 tools, Zod-validated) · **CLI** (`init`, `serve`, `status`, `config`, `db`). ## CLI Reference @@ -163,10 +164,6 @@ tenet init --agent claude-code --skip-playwright-check tenet init --upgrade # Update DB, skills/configs; prompts before moving legacy docs (see note below) tenet init --upgrade --migrate-legacy # Non-interactive: run the destructive legacy-doc move without prompting -# Start MCP server -tenet serve -tenet serve --background - # Check project status tenet status tenet status --all # Include completed/failed jobs @@ -253,12 +250,14 @@ After `tenet init`, your project gets: your-project/ .tenet/ project/ # Durable doctrine: overview.md, architecture.md, product.md, - # testing.md, design.md (+ design-components/) + # testing.md, design.md (+ design-components/) — self-correcting via drift review runs// # Per-run artifacts for YYYY-MM-DD-feature: # interview.md, spec.md, harness.md, scenarios.md, - # decomposition.md, research/, journal/, visuals/ + # decomposition.md, doctrine-proposals.md, research/, journal/, visuals/ archive/legacy-v1/ # One-time migration target for pre-lifecycle layouts (populated by `tenet init --upgrade`) knowledge/ # Curated, reusable technical knowledge + critics.json # Configurable critic roster (3 built-ins + custom critics) — see "Configurable Critics" + critics/ # Custom-critic prompt files (.md); empty until you author one status/ # status.md + job-queue.md are auto-generated from DB; backlog.md is a static scaffold state-snapshot/ # Git-safe portable SQLite snapshots (`tenet db snapshot`) .state/ @@ -273,29 +272,30 @@ your-project/ ## Execution Modes -| Mode | Phases | Use Case | -|------|--------|----------| -| **Full** (default) | All 8 phases | New features, major refactors | -| **Standard** | Skip interview (use existing spec) | Spec already written | -| **Quick** | Skip interview + spec + decomposition | Bug fixes, small changes | +Every mode still runs the full phase structure — interview, spec, decomposition, execution, evaluation — and the clarity + readiness gates. The modes differ in **interview depth and ceremony**, not in which phases they skip. + +| Mode | What runs | Use Case | +|------|-----------|----------| +| **Full** (default) | All 8 phases at full strength | New features, major refactors | +| **Standard** | Compact interview (3–5 questions), then spec/harness/readiness → decomposition | Known architecture, moderate unknowns | +| **Quick** | Minimum interview (confirm scope + acceptance), compact spec or a trivial single-job DAG | Small isolated bug/config/content tweak | ## Crash Recovery -Tenet is designed for long autonomous runs where crashes are expected: +Tenet is built for long autonomous runs where crashes are expected: -- **Server restart**: Stale "running" jobs are reset to "pending" only after their heartbeat exceeds the timeout +- **Server restart**: stale "running" jobs reset to "pending" only after their heartbeat exceeds the timeout - **Adapter timeout**: 120-minute default (configurable), prevents zombie subprocesses -- **Heartbeat monitoring**: Detects truly stuck jobs within a session -- **MCP disconnect**: Skill instructs agents to attempt server restart, halt if unrecoverable -- **Update checks**: Health/status can surface newer npm versions with manual upgrade guidance; Tenet does not auto-update during an active run -- **DB upgrades**: Normal startup refuses old/newer DB schemas with guidance. Close the agent, run `tenet init --upgrade`, then restart; upgrade creates a verified SQLite-safe DB backup first. +- **Heartbeat monitoring**: detects truly stuck jobs within a session +- **MCP disconnect**: skill instructs agents to attempt a server restart, halt if unrecoverable +- **Update checks**: health/status can surface newer npm versions with manual upgrade guidance; Tenet does not auto-update during an active run +- **DB upgrades**: normal startup refuses old/newer DB schemas with guidance. Close the agent, run `tenet init --upgrade`, then restart; upgrade creates a verified SQLite-safe DB backup first ## Diagnostics -When things go wrong, use the `tenet:diagnose` skill: +When things go wrong, use the `tenet:diagnose` skill, or inspect directly: ```bash -# Or manually inspect: sqlite3 .tenet/.state/tenet.db "SELECT type, status, COUNT(*) FROM jobs GROUP BY type, status" sqlite3 .tenet/.state/tenet.db "SELECT * FROM jobs WHERE status='failed'" ``` From ab8ea9a3cddb463b25c02947dc8f163e1cc0e82a Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Sat, 4 Jul 2026 10:29:54 +0900 Subject: [PATCH 42/87] docs(readme): add Delivery Modes section for autonomous | agile The restructured README referenced "agile mode" exactly once, in an orphaned phase-7 row, with no explanation of what it is or that it's a user-facing cadence choice. Add a dedicated section parallel to Execution Modes so both interview decisions are documented together: autonomous (default, end-to-end, no checkpoints) vs agile (sliced delivery with plan/use checkpoints and redirects). States that agile changes cadence, not eval rigor. Co-Authored-By: Claude --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 4939648..ddff36a 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,17 @@ Every mode still runs the full phase structure — interview, spec, decompositio | **Standard** | Compact interview (3–5 questions), then spec/harness/readiness → decomposition | Known architecture, moderate unknowns | | **Quick** | Minimum interview (confirm scope + acceptance), compact spec or a trivial single-job DAG | Small isolated bug/config/content tweak | +## Delivery Modes + +Every run also picks a cadence in the interview — `delivery_mode: autonomous | agile`: + +| Mode | Cadence | +|------|---------| +| **autonomous** (default) | One end-to-end run, no mid-run checkpoints — fire and walk away | +| **agile** | Sliced delivery: a plan-checkpoint after the upfront visuals, then per-slice decompose → build → eval → use-checkpoint. Each slice ships a runnable, eval-passing app. At every checkpoint you can `approve`, `redirect`, or `cancel` | + +Agile is for when you want to steer mid-run; autonomous is for when you want to fire-and-forget. Either way, every job and every slice still runs the full independent-critic / eval gate — agile changes cadence, not rigor. + ## Crash Recovery Tenet is built for long autonomous runs where crashes are expected: From 6b1d340754799c29c2765b8f4359098257707926 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Sat, 4 Jul 2026 20:22:23 +0900 Subject: [PATCH 43/87] fix(eval): route journal entries through job ancestry when run_path is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tenet_update_knowledge resolved the run journal path only from the single job named by job_id, falling back to the top-level .tenet/journal/ whenever that job lacked run_path. That fired for real cases — ungrounded critics (full_context:false, e.g. interaction_e2e) and ad-hoc children carry no run_path of their own — so run journal entries kept landing at the top level instead of under .tenet/runs//journal/. getRunPath now walks job ancestry (source_job_id first — a critic points at the dev/eval job it evaluates, which carries run_path — then parent_job_id), bounded to 5 hops with a visited-set cycle guard. Jobs with no run-bearing ancestor still fall back to .tenet/journal/, preserving legacy routing. Co-Authored-By: Claude --- src/mcp/tools/tenet-update-knowledge.test.ts | 61 ++++++++++++++++++++ src/mcp/tools/tenet-update-knowledge.ts | 39 ++++++++++--- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/mcp/tools/tenet-update-knowledge.test.ts b/src/mcp/tools/tenet-update-knowledge.test.ts index 03e5f26..07eadd1 100644 --- a/src/mcp/tools/tenet-update-knowledge.test.ts +++ b/src/mcp/tools/tenet-update-knowledge.test.ts @@ -101,6 +101,67 @@ describe('tenet_update_knowledge', () => { expect(content).toContain(`source_job: ${job.id}`); }); + it('routes a journal entry through source_job_id ancestry when the named job lacks run_path', async () => { + const { store, handler } = createHarness(); + // The source dev job carries the run_path. + const sourceJob = store.createJob({ + type: 'dev', + status: 'completed', + params: { name: 'source job', run_path: '.tenet/runs/2026-06-12-oauth' }, + retryCount: 0, + maxRetries: 0, + }); + // The named job is an ungrounded critic: source_job_id → the dev job, but no run_path of its + // own (tenet_start_eval only propagates run_path to grounded critics). The entry must still + // land in the source job's run journal, not the top-level .tenet/journal/. + const criticJob = store.createJob({ + type: 'critic_eval', + status: 'completed', + params: { name: 'code critic', source_job_id: sourceJob.id }, + retryCount: 0, + maxRetries: 0, + }); + + const result = parseResult(await handler({ + title: 'Critic Note', + job_id: criticJob.id, + type: 'journal', + findings: { summary: 'via ancestry' }, + })); + + expect(result.file).toMatch(/^\.tenet\/runs\/2026-06-12-oauth\/journal\/\d{4}-\d{2}-\d{2}_critic-note\.md$/); + const content = fs.readFileSync(path.join(store.projectPath, result.file), 'utf8'); + expect(content).toContain(`source_job: ${criticJob.id}`); + }); + + it('routes through parent_job_id ancestry when source_job_id is absent', async () => { + const { store, handler } = createHarness(); + const parentJob = store.createJob({ + type: 'dev', + status: 'completed', + params: { name: 'parent', run_path: '.tenet/runs/2026-06-12-oauth' }, + retryCount: 0, + maxRetries: 0, + }); + const childJob = store.createJob({ + type: 'dev', + status: 'completed', + params: { name: 'child' }, + retryCount: 0, + maxRetries: 0, + parentJobId: parentJob.id, + }); + + const result = parseResult(await handler({ + title: 'Child Note', + job_id: childJob.id, + type: 'journal', + findings: { summary: 'via parent' }, + })); + + expect(result.file).toMatch(/^\.tenet\/runs\/2026-06-12-oauth\/journal\/\d{4}-\d{2}-\d{2}_child-note\.md$/); + }); + it('keeps legacy journal routing for jobs without run_path', async () => { const { store, handler } = createHarness(); const job = store.createJob({ diff --git a/src/mcp/tools/tenet-update-knowledge.ts b/src/mcp/tools/tenet-update-knowledge.ts index adf4083..532986e 100644 --- a/src/mcp/tools/tenet-update-knowledge.ts +++ b/src/mcp/tools/tenet-update-knowledge.ts @@ -14,17 +14,38 @@ const slugify = (text: string): string => const datePrefix = (): string => new Date().toISOString().slice(0, 10); -const getRunPath = (stateStore: StateStore, jobId: string): string | undefined => { - const job = stateStore.getJob(jobId); - if (!job || typeof job.params.run_path !== 'string') { - return undefined; - } +// Cap the ancestry walk so a malformed parent/source chain can never loop unboundedly. +const MAX_RUN_PATH_HOPS = 5; - try { - return toProjectRelativePath(stateStore.projectPath, job.params.run_path, 'job.params.run_path'); - } catch { - return undefined; +/** + * Resolve the run-relative journal path for a job. If the named job has no `run_path`, walk + * its ancestry — `source_job_id` first (a critic points at the dev/eval job it evaluates, + * which carries `run_path`), then `parent_job_id` (chained children) — until a job with a + * usable `run_path` is found. This routes a journal entry to the run it belongs to even when + * the named job itself lacks `run_path` (an ungrounded critic, an ad-hoc child), instead of + * dropping it to the top-level `.tenet/journal/`. Returns undefined when no ancestor carries + * a `run_path`, preserving the legacy `.tenet/journal/` fallback for genuinely run-less jobs. + */ +const getRunPath = (stateStore: StateStore, jobId: string): string | undefined => { + const visited = new Set(); + let currentId: string | undefined = jobId; + for (let hop = 0; hop < MAX_RUN_PATH_HOPS && currentId && !visited.has(currentId); hop++) { + visited.add(currentId); + const job = stateStore.getJob(currentId); + if (!job) { + break; + } + if (typeof job.params.run_path === 'string') { + try { + return toProjectRelativePath(stateStore.projectPath, job.params.run_path, 'job.params.run_path'); + } catch { + // Invalid path on this job — keep walking; an ancestor may carry a valid one. + } + } + const sourceId = typeof job.params.source_job_id === 'string' ? job.params.source_job_id : undefined; + currentId = sourceId ?? job.parentJobId ?? undefined; } + return undefined; }; export const registerTenetUpdateKnowledgeTool = (registerTool: RegisterTool, stateStore: StateStore): void => { From 53437461ff3546792e9f2e272ce44de7750a9818 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Sat, 4 Jul 2026 20:22:37 +0900 Subject: [PATCH 44/87] fix(eval): make start_eval output optional + drop fabricated critic stat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small hardening fixes to the eval dispatcher: 1. tenet_start_eval's `output` arg was hard-required, so a host agent that omitted or malformed it under context pressure wedged itself looping on "requires output parameter". It now defaults to {} — critics just get less to review instead of the tool failing closed. Downstream already tolerated an object, so this degrades cleanly. 2. The test-critic preamble asserted "Research shows ... Test precision drops to ~6%" — a fabricated statistic with no source. Replaced with an honest statement of the same oracle-leakage concept (tests written from the implementation's context assert current behavior, not required behavior), preserving the directive force without the fake number. Co-Authored-By: Claude --- src/mcp/tools/tenet-start-eval.test.ts | 23 ++++++++++++++++++++++- src/mcp/tools/tenet-start-eval.ts | 22 +++++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/mcp/tools/tenet-start-eval.test.ts b/src/mcp/tools/tenet-start-eval.test.ts index 9911a5c..0c86b48 100644 --- a/src/mcp/tools/tenet-start-eval.test.ts +++ b/src/mcp/tools/tenet-start-eval.test.ts @@ -23,7 +23,7 @@ class MockAdapter implements AgentAdapter { type CapturedHandler = (args: { job_id: string; - output: Record; + output?: Record; feature?: string; }) => Promise; @@ -234,6 +234,27 @@ describe('tenet_start_eval eval mode resolution', () => { await waitForAll(manager, parsed); }); + it('degrades gracefully when output is omitted — dispatches critics with empty output (#29)', async () => { + const { store, manager, handler } = createHarness(); + const sourceId = createSourceJob(store, 'oauth'); + + // No `output` supplied. The schema defaults it to {} instead of failing closed, so a stuck + // host agent can no longer wedge itself on a missing required arg — critics just get less. + const result = await handler({ job_id: sourceId }); + const parsed = parseResult(result); + + expect(parsed.critics_dispatched).toBe(3); + const codeCritic = store.getJob(jobId(parsed, 'code_critic')); + const testCritic = store.getJob(jobId(parsed, 'test_critic')); + expect((codeCritic?.params.prompt as string).includes('## Worker Output\n\n{}')).toBe(true); + expect((testCritic?.params.prompt as string).includes('## Worker Output\n\n{}')).toBe(true); + // The fabricated precision statistic is gone from every critic prompt. + expect((codeCritic?.params.prompt as string).includes('~6%')).toBe(false); + expect((testCritic?.params.prompt as string).includes('~6%')).toBe(false); + + await waitForAll(manager, parsed); + }); + it('code critic prompt treats unauthorized project doctrine edits as scope_conflict', async () => { const { store, manager, handler } = createHarness(); const sourceId = createSourceJob(store, 'oauth', { diff --git a/src/mcp/tools/tenet-start-eval.ts b/src/mcp/tools/tenet-start-eval.ts index 73fde71..a5667d6 100644 --- a/src/mcp/tools/tenet-start-eval.ts +++ b/src/mcp/tools/tenet-start-eval.ts @@ -195,9 +195,10 @@ const TEST_CRITIC_PREAMBLE = [ 'features assigned to later jobs.', '', '### THE ORACLE PROBLEM (critical awareness)', - 'The same AI agent wrote both the code AND the tests. Research shows this creates', + 'The same AI agent wrote both the code AND the tests. This creates', '"oracle leakage" — tests that verify what was IMPLEMENTED rather than what was INTENDED.', - 'Test precision drops to ~6% when the same context writes both. You MUST check for this.', + 'Tests written from the same context as the implementation tend to assert the code\'s current', + 'behavior, not the required behavior — so they pass even when the feature is wrong. You MUST check for this.', '', '### Test Quality Checklist', 'For each scenario IN THIS JOB\'S SCOPE, check:', @@ -373,7 +374,15 @@ export const registerTenetStartEvalTool = (registerTool: RegisterTool, jobManage 'Returns a jobs[] list of every dispatched critic (variable length). Wait for all to complete. ALL must pass.', inputSchema: z.object({ job_id: z.string().uuid(), - output: z.record(z.string(), z.unknown()), + output: z + .record(z.string(), z.unknown()) + .default({}) + .describe( + 'Worker output to hand to the critics (diff summary, file list, report, etc.). ' + + 'Optional — if omitted, critics receive an empty object and simply have less to review. ' + + 'Supply it when you can; degrading gracefully here is preferable to the tool failing closed ' + + 'and wedging the caller on a missing arg.', + ), feature: z .string() .optional() @@ -383,7 +392,10 @@ export const registerTenetStartEvalTool = (registerTool: RegisterTool, jobManage }), }, async ({ job_id, output, feature }) => { - const outputStr = typeof output === 'string' ? output : JSON.stringify(output, null, 2); + // `output` defaults to {} via the schema, but normalize defensively so a caller that + // somehow supplies undefined still degrades gracefully instead of producing "undefined". + const outputObj = output ?? {}; + const outputStr = typeof outputObj === 'string' ? outputObj : JSON.stringify(outputObj, null, 2); const jobScope = buildJobScopeSection(stateStore, job_id); const projectPath = stateStore.projectPath; const sourceJob = stateStore.getJob(job_id); @@ -417,7 +429,7 @@ export const registerTenetStartEvalTool = (registerTool: RegisterTool, jobManage eval_stage: d.evalStage, name: `${d.evalStage} for ${job_id.slice(0, 8)}`, prompt: d.prompt, - output, + output: outputObj, expected_eval_stages: expectedEvalStages, ...(resolvedFeature ? { feature: resolvedFeature } : {}), // Grounded critics (full_context !== false, the default) get the run's From fa6761e1b8d8cd988f58492cdf7a3d434859e101 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Sat, 4 Jul 2026 21:53:15 +0900 Subject: [PATCH 45/87] chore: bump to 26.7.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3571cca..21a7a5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.7.1", + "version": "26.7.2", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From f7b87d46944216ca975b88f7a3a9d4baac0c9a8a Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Tue, 7 Jul 2026 12:48:10 +0900 Subject: [PATCH 46/87] Add backlog.md --- .backlog/config.yml | 18 +++++++++++++ ...-001 - Weak-model-critics-investigation.md | 23 +++++++++++++++++ ...-002 - Custom-critics-scoped-within-run.md | 24 ++++++++++++++++++ ...-instruction-in-CLAUDE.md-and-AGENTS.md.md | 24 ++++++++++++++++++ .../task-004 - Critic-model-selection.md | 25 +++++++++++++++++++ .backlog/tasks/task-005 - Kanban-feature.md | 23 +++++++++++++++++ .../tasks/task-006 - Introduce-PM-agent.md | 24 ++++++++++++++++++ .../task-007 - Dynamic-critics-per-job.md | 24 ++++++++++++++++++ .../task-008 - Tenet-version-MCP-tool.md | 24 ++++++++++++++++++ .../task-009 - Plugin-execution-prompt.md | 24 ++++++++++++++++++ ...2\200\224-narrow-inlined-decomposition.md" | 24 ++++++++++++++++++ ...erview-\342\200\224-divide-and-conquer.md" | 24 ++++++++++++++++++ ...ob-waiting-loop-delegating-to-sub-agent.md | 24 ++++++++++++++++++ .../tasks/task-013 - Critic-output-as-JSON.md | 24 ++++++++++++++++++ ...k-014 - Job-runner-model-aware-planning.md | 25 +++++++++++++++++++ ...ty-\342\200\224-orchestrator-vs-worker.md" | 24 ++++++++++++++++++ ...Git-Worktree-for-multi-feature-requests.md | 24 ++++++++++++++++++ ...et-.state-tenet.db-from-being-committed.md | 24 ++++++++++++++++++ ...18 - tenet-status-and-MCP-tool-misalign.md | 24 ++++++++++++++++++ .../task-019 - tenet-status-job-ordering.md | 24 ++++++++++++++++++ ... - Doctrine-proposals.md-never-produced.md | 24 ++++++++++++++++++ .../tasks/task-021 - Customizable-harness.md | 25 +++++++++++++++++++ ...342\200\224-host-agent-fails-to-supply.md" | 24 ++++++++++++++++++ ...nt-lifecycle-recommendation-at-last-run.md | 25 +++++++++++++++++++ ...ase-\342\200\224-context-length-exceed.md" | 25 +++++++++++++++++++ ...25 - Knowledge-update-bug-investigation.md | 24 ++++++++++++++++++ ...ement-\342\200\224-run-path-resolution.md" | 24 ++++++++++++++++++ ...ign-component-skip-in-context-bootstrap.md | 24 ++++++++++++++++++ .../task-028 - tenet-snapshot-too-large.md | 25 +++++++++++++++++++ ...\342\200\224-inspect-design-components.md" | 24 ++++++++++++++++++ ...ht-critic-\342\200\224-interaction-e2e.md" | 25 +++++++++++++++++++ .../task-031 - Improving-Steer-Messages.md | 24 ++++++++++++++++++ AGENTS.md | 23 +++++++++++++++++ CLAUDE.md | 23 +++++++++++++++++ 34 files changed, 813 insertions(+) create mode 100644 .backlog/config.yml create mode 100644 .backlog/tasks/task-001 - Weak-model-critics-investigation.md create mode 100644 .backlog/tasks/task-002 - Custom-critics-scoped-within-run.md create mode 100644 .backlog/tasks/task-003 - Tenet-instruction-in-CLAUDE.md-and-AGENTS.md.md create mode 100644 .backlog/tasks/task-004 - Critic-model-selection.md create mode 100644 .backlog/tasks/task-005 - Kanban-feature.md create mode 100644 .backlog/tasks/task-006 - Introduce-PM-agent.md create mode 100644 .backlog/tasks/task-007 - Dynamic-critics-per-job.md create mode 100644 .backlog/tasks/task-008 - Tenet-version-MCP-tool.md create mode 100644 .backlog/tasks/task-009 - Plugin-execution-prompt.md create mode 100644 ".backlog/tasks/task-010 - Worker-critic-context-size-\342\200\224-narrow-inlined-decomposition.md" create mode 100644 ".backlog/tasks/task-011 - Better-interview-\342\200\224-divide-and-conquer.md" create mode 100644 .backlog/tasks/task-012 - Job-waiting-loop-delegating-to-sub-agent.md create mode 100644 .backlog/tasks/task-013 - Critic-output-as-JSON.md create mode 100644 .backlog/tasks/task-014 - Job-runner-model-aware-planning.md create mode 100644 ".backlog/tasks/task-015 - Steer-message-clarity-\342\200\224-orchestrator-vs-worker.md" create mode 100644 .backlog/tasks/task-016 - Git-Worktree-for-multi-feature-requests.md create mode 100644 .backlog/tasks/task-017 - Prevent-.tenet-.state-tenet.db-from-being-committed.md create mode 100644 .backlog/tasks/task-018 - tenet-status-and-MCP-tool-misalign.md create mode 100644 .backlog/tasks/task-019 - tenet-status-job-ordering.md create mode 100644 .backlog/tasks/task-020 - Doctrine-proposals.md-never-produced.md create mode 100644 .backlog/tasks/task-021 - Customizable-harness.md create mode 100644 ".backlog/tasks/task-022 - start_eval-output-arg-\342\200\224-host-agent-fails-to-supply.md" create mode 100644 .backlog/tasks/task-023 - Document-lifecycle-recommendation-at-last-run.md create mode 100644 ".backlog/tasks/task-024 - Spawned-job-edge-case-\342\200\224-context-length-exceed.md" create mode 100644 .backlog/tasks/task-025 - Knowledge-update-bug-investigation.md create mode 100644 ".backlog/tasks/task-026 - Journal-improvement-\342\200\224-run-path-resolution.md" create mode 100644 .backlog/tasks/task-027 - Design-component-skip-in-context-bootstrap.md create mode 100644 .backlog/tasks/task-028 - tenet-snapshot-too-large.md create mode 100644 ".backlog/tasks/task-029 - Visual-phase-\342\200\224-inspect-design-components.md" create mode 100644 ".backlog/tasks/task-030 - Better-playwright-critic-\342\200\224-interaction-e2e.md" create mode 100644 .backlog/tasks/task-031 - Improving-Steer-Messages.md diff --git a/.backlog/config.yml b/.backlog/config.yml new file mode 100644 index 0000000..8f5dc3f --- /dev/null +++ b/.backlog/config.yml @@ -0,0 +1,18 @@ +project_name: "tenet" +default_status: "To Do" +statuses: ["To Do", "In Progress", "Done"] +labels: [] +definition_of_done: [] +date_format: yyyy-mm-dd +max_column_width: 20 +default_editor: "nvim" +auto_open_browser: true +default_port: 6420 +remote_operations: false +auto_commit: false +filesystem_only: false +zero_padded_ids: 3 +bypass_git_hooks: true +check_active_branches: false +active_branch_days: 30 +task_prefix: "task" diff --git a/.backlog/tasks/task-001 - Weak-model-critics-investigation.md b/.backlog/tasks/task-001 - Weak-model-critics-investigation.md new file mode 100644 index 0000000..fe0d946 --- /dev/null +++ b/.backlog/tasks/task-001 - Weak-model-critics-investigation.md @@ -0,0 +1,23 @@ +--- +id: TASK-001 +title: Weak model critics investigation +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: medium +ordinal: 1000 +--- + +## Description + + +When planning with frontier model and executing with less capable model (qwen3.6, deepseek-v4-flash), the weaker model tends to pass all critics while frontier model finds issues. Investigate whether this is a prompt issue or a capability limitation. + + +## Acceptance Criteria + +- [ ] #1 Root cause identified: prompt issue vs capability limitation +- [ ] #2 Recommendation documented for addressing weak model critic behavior + diff --git a/.backlog/tasks/task-002 - Custom-critics-scoped-within-run.md b/.backlog/tasks/task-002 - Custom-critics-scoped-within-run.md new file mode 100644 index 0000000..7cdeb69 --- /dev/null +++ b/.backlog/tasks/task-002 - Custom-critics-scoped-within-run.md @@ -0,0 +1,24 @@ +--- +id: TASK-002 +title: Custom critics scoped within run +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: medium +ordinal: 2000 +--- + +## Description + + +On top of default critics + user custom critics, tenet should suggest run-specific critics based on what it learned during interview → spec phase. Evolve from general critics to job-aware tailored critics. + + +## Acceptance Criteria + +- [ ] #1 Tenet generates run-specific critic suggestions from interview/spec context +- [ ] #2 Run-specific critics are applied alongside default and user custom critics +- [ ] #3 Related to #13 (weak model critics) and #24 (critic model selection) + diff --git a/.backlog/tasks/task-003 - Tenet-instruction-in-CLAUDE.md-and-AGENTS.md.md b/.backlog/tasks/task-003 - Tenet-instruction-in-CLAUDE.md-and-AGENTS.md.md new file mode 100644 index 0000000..8c04dff --- /dev/null +++ b/.backlog/tasks/task-003 - Tenet-instruction-in-CLAUDE.md-and-AGENTS.md.md @@ -0,0 +1,24 @@ +--- +id: TASK-003 +title: Tenet instruction in CLAUDE.md and AGENTS.md +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: medium +ordinal: 3000 +--- + +## Description + + +Add tenet orchestration rules to CLAUDE.md and AGENTS.md so weaker models follow them better (frontier models already follow via tenet skill). Mark with boundaries to avoid touching user instructions. + + +## Acceptance Criteria + +- [ ] #1 Tenet orchestration rules are appended to CLAUDE.md and AGENTS.md +- [ ] #2 Rules are wrapped in clearly marked instruction blocks +- [ ] #3 User's own instructions are preserved untouched + diff --git a/.backlog/tasks/task-004 - Critic-model-selection.md b/.backlog/tasks/task-004 - Critic-model-selection.md new file mode 100644 index 0000000..5e35ed1 --- /dev/null +++ b/.backlog/tasks/task-004 - Critic-model-selection.md @@ -0,0 +1,25 @@ +--- +id: TASK-004 +title: Critic model selection +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: high +ordinal: 4000 +--- + +## Description + + +Add per-critic model selection so correlated blind spots don't survive. Dispatch critics with different models for diversity. Consider frontier model critic review gate, dynamic course correction, and PM agent integration. v26.7.0 shipped per-critic full_context precursor. + + +## Acceptance Criteria + +- [ ] #1 Critics can be dispatched with different models per critic +- [ ] #2 Model selection is configurable by critic type +- [ ] #3 Frontier model critic review gate is available as option +- [ ] #4 Configuration management for growing config complexity is addressed + diff --git a/.backlog/tasks/task-005 - Kanban-feature.md b/.backlog/tasks/task-005 - Kanban-feature.md new file mode 100644 index 0000000..6269bf1 --- /dev/null +++ b/.backlog/tasks/task-005 - Kanban-feature.md @@ -0,0 +1,23 @@ +--- +id: TASK-005 +title: Kanban feature +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: medium +ordinal: 5000 +--- + +## Description + + +Offer project-level kanban board or tracking system within tenet, since the workflow naturally writes notes about what to fix next or improvement ideas. + + +## Acceptance Criteria + +- [ ] #1 Users can view and manage a kanban board for project tasks within tenet +- [ ] #2 Board integrates with the existing note-taking workflow + diff --git a/.backlog/tasks/task-006 - Introduce-PM-agent.md b/.backlog/tasks/task-006 - Introduce-PM-agent.md new file mode 100644 index 0000000..8421de7 --- /dev/null +++ b/.backlog/tasks/task-006 - Introduce-PM-agent.md @@ -0,0 +1,24 @@ +--- +id: TASK-006 +title: Introduce PM agent +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: medium +ordinal: 6000 +--- + +## Description + + +When something goes wrong or periodically, report to a frontier-level model to check status and investigate. Like having smart juniors (smaller model) with a smart PM structure that oversees execution. + + +## Acceptance Criteria + +- [ ] #1 PM agent receives periodic status reports from execution agents +- [ ] #2 PM agent investigates when execution drifts or issues surface +- [ ] #3 PM agent can re-plan or adjust course dynamically + diff --git a/.backlog/tasks/task-007 - Dynamic-critics-per-job.md b/.backlog/tasks/task-007 - Dynamic-critics-per-job.md new file mode 100644 index 0000000..3109ca4 --- /dev/null +++ b/.backlog/tasks/task-007 - Dynamic-critics-per-job.md @@ -0,0 +1,24 @@ +--- +id: TASK-007 +title: Dynamic critics per job +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: medium +ordinal: 7000 +--- + +## Description + + +When planning jobs, tenet agent creates critics tailored to each job. Add critic descriptions and let orchestrator choose critics after each job. Related to #19 (custom critics scoped within run). + + +## Acceptance Criteria + +- [ ] #1 Planning phase generates job-specific critic descriptions +- [ ] #2 Orchestrator can select appropriate critics after each job +- [ ] #3 Critic quality consistency is maintained + diff --git a/.backlog/tasks/task-008 - Tenet-version-MCP-tool.md b/.backlog/tasks/task-008 - Tenet-version-MCP-tool.md new file mode 100644 index 0000000..785ac9e --- /dev/null +++ b/.backlog/tasks/task-008 - Tenet-version-MCP-tool.md @@ -0,0 +1,24 @@ +--- +id: TASK-008 +title: Tenet version MCP tool +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: medium +ordinal: 8000 +--- + +## Description + + +Introduce a tenet version MCP tool so the agent is aware of version mismatches when user upgrades tenet mid-session. Also add guardrail prompt preventing agent from restarting tenet MCP server via raw commands. + + +## Acceptance Criteria + +- [ ] #1 MCP tool reports current tenet version +- [ ] #2 Agent detects version mismatch and suggests user restart +- [ ] #3 Guardrail prompt prevents agent from running 'tenet serve' or killing MCP process + diff --git a/.backlog/tasks/task-009 - Plugin-execution-prompt.md b/.backlog/tasks/task-009 - Plugin-execution-prompt.md new file mode 100644 index 0000000..4c34ae8 --- /dev/null +++ b/.backlog/tasks/task-009 - Plugin-execution-prompt.md @@ -0,0 +1,24 @@ +--- +id: TASK-009 +title: Plugin execution prompt +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: medium +ordinal: 9000 +--- + +## Description + + +Introduce plugin prompt for some stages so users can insert custom instructions in the middle of the tenet loop. Useful for company-specific harnesses, central knowledge references, etc. + + +## Acceptance Criteria + +- [ ] #1 Users can inject custom instruction prompts at configurable stages +- [ ] #2 Plugin prompts are loaded and applied during execution +- [ ] #3 Backward compatible — existing runs without plugins work unchanged + diff --git "a/.backlog/tasks/task-010 - Worker-critic-context-size-\342\200\224-narrow-inlined-decomposition.md" "b/.backlog/tasks/task-010 - Worker-critic-context-size-\342\200\224-narrow-inlined-decomposition.md" new file mode 100644 index 0000000..9df8994 --- /dev/null +++ "b/.backlog/tasks/task-010 - Worker-critic-context-size-\342\200\224-narrow-inlined-decomposition.md" @@ -0,0 +1,24 @@ +--- +id: TASK-010 +title: Worker/critic context size — narrow inlined decomposition +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: low +ordinal: 10000 +--- + +## Description + + +v26.7.0 inlines full spec/scenarios/decomposition/harness into every worker and critic. Consider narrowing to current job's slice + dependency interface contracts to save tokens and reduce sibling noise, especially for local-tier runs with finer-grained DAGs. + + +## Acceptance Criteria + +- [ ] #1 Decomposition inlining is scoped to current job + dependency contracts +- [ ] #2 Token savings are measurable +- [ ] #3 Per-job heading convention or heuristic is established for slicing + diff --git "a/.backlog/tasks/task-011 - Better-interview-\342\200\224-divide-and-conquer.md" "b/.backlog/tasks/task-011 - Better-interview-\342\200\224-divide-and-conquer.md" new file mode 100644 index 0000000..6fee666 --- /dev/null +++ "b/.backlog/tasks/task-011 - Better-interview-\342\200\224-divide-and-conquer.md" @@ -0,0 +1,24 @@ +--- +id: TASK-011 +title: Better interview — divide and conquer +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: high +ordinal: 11000 +--- + +## Description + + +Current interview phase treats entire request with a single process regardless of size. Use divide-and-conquer: split user requests into sub-requests (like DAG but at interview level). Also review whether the question category list is generally suitable. + + +## Acceptance Criteria + +- [ ] #1 Interview phase detects when a request should be split into sub-requests +- [ ] #2 Sub-requests are analyzed independently +- [ ] #3 Question categories are reviewed and validated against references (bmad-method, ouroboros harness) + diff --git a/.backlog/tasks/task-012 - Job-waiting-loop-delegating-to-sub-agent.md b/.backlog/tasks/task-012 - Job-waiting-loop-delegating-to-sub-agent.md new file mode 100644 index 0000000..c9e9930 --- /dev/null +++ b/.backlog/tasks/task-012 - Job-waiting-loop-delegating-to-sub-agent.md @@ -0,0 +1,24 @@ +--- +id: TASK-012 +title: Job waiting loop delegating to sub-agent +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: low +ordinal: 12000 +--- + +## Description + + +Observed qwen3.6 delegating job waiting loop to sub-agent, which is clever context management. Evaluate whether this pattern should be formalized, and whether indefinite wait (remove 120s timeout) makes sense. + + +## Acceptance Criteria + +- [ ] #1 Sub-agent delegation pattern is evaluated for formal support +- [ ] #2 Indefinite wait vs timeout behavior is decided +- [ ] #3 Pattern works across opencode, codex, and claude code + diff --git a/.backlog/tasks/task-013 - Critic-output-as-JSON.md b/.backlog/tasks/task-013 - Critic-output-as-JSON.md new file mode 100644 index 0000000..2de8fc0 --- /dev/null +++ b/.backlog/tasks/task-013 - Critic-output-as-JSON.md @@ -0,0 +1,24 @@ +--- +id: TASK-013 +title: Critic output as JSON +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: medium +ordinal: 13000 +--- + +## Description + + +Opencode often struggles to find critic output. Consider having critics write JSON format verdict first, then write findings incrementally for partial results. Address file storage concerns for intermediate JSON files. + + +## Acceptance Criteria + +- [ ] #1 Critics output verdict in JSON format as primary output +- [ ] #2 Incremental findings writing provides partial results +- [ ] #3 JSON file storage strategy addresses cleanup concerns + diff --git a/.backlog/tasks/task-014 - Job-runner-model-aware-planning.md b/.backlog/tasks/task-014 - Job-runner-model-aware-planning.md new file mode 100644 index 0000000..5a0c48a --- /dev/null +++ b/.backlog/tasks/task-014 - Job-runner-model-aware-planning.md @@ -0,0 +1,25 @@ +--- +id: TASK-014 +title: Job runner model-aware planning +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: high +ordinal: 14000 +--- + +## Description + + +Make tenet loop aware of which model is running and adapt planning accordingly. Local models may need finer-grained DAG splitting. Consider 3-tier model (Local/Standard/Frontier) and smart model selection per task type. v26.7.0 shipped partial prompt-only model_tier. + + +## Acceptance Criteria + +- [ ] #1 Planning phase adapts decomposition granularity based on model tier +- [ ] #2 Model tier is configurable (Local/Standard/Frontier or similar) +- [ ] #3 Subprocess model args are wired from the tier selection +- [ ] #4 Orchestrator can smart-select model per task type + diff --git "a/.backlog/tasks/task-015 - Steer-message-clarity-\342\200\224-orchestrator-vs-worker.md" "b/.backlog/tasks/task-015 - Steer-message-clarity-\342\200\224-orchestrator-vs-worker.md" new file mode 100644 index 0000000..ed4d827 --- /dev/null +++ "b/.backlog/tasks/task-015 - Steer-message-clarity-\342\200\224-orchestrator-vs-worker.md" @@ -0,0 +1,24 @@ +--- +id: TASK-015 +title: Steer message clarity — orchestrator vs worker +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: medium +ordinal: 15000 +--- + +## Description + + +Clarify whether steer messages go to orchestrator, worker, or both. Consider separating steer messages for orchestrator vs worker. Evaluate whether redirect-in-execution is needed and what the role of steer should be. + + +## Acceptance Criteria + +- [ ] #1 Steer message routing (orchestrator vs worker) is clearly defined +- [ ] #2 Separate steer channels for orchestrator and worker if needed +- [ ] #3 Redirect-in-execution mechanism evaluated and documented + diff --git a/.backlog/tasks/task-016 - Git-Worktree-for-multi-feature-requests.md b/.backlog/tasks/task-016 - Git-Worktree-for-multi-feature-requests.md new file mode 100644 index 0000000..f9ce672 --- /dev/null +++ b/.backlog/tasks/task-016 - Git-Worktree-for-multi-feature-requests.md @@ -0,0 +1,24 @@ +--- +id: TASK-016 +title: Git Worktree for multi-feature requests +status: To Do +assignee: [] +created_date: '2026-07-07 02:16' +labels: [] +dependencies: [] +priority: low +ordinal: 16000 +--- + +## Description + + +For multi-feature requests in a single run, use git worktree to spread independent features into separate worktrees, enabling parallel tenet loops. Investigate merge strategy and use-case frequency. + + +## Acceptance Criteria + +- [ ] #1 Multi-feature requests are detected and split into independent worktrees +- [ ] #2 Each worktree runs its own tenet loop in parallel +- [ ] #3 Merge strategy for worktree results is defined + diff --git a/.backlog/tasks/task-017 - Prevent-.tenet-.state-tenet.db-from-being-committed.md b/.backlog/tasks/task-017 - Prevent-.tenet-.state-tenet.db-from-being-committed.md new file mode 100644 index 0000000..1b35a05 --- /dev/null +++ b/.backlog/tasks/task-017 - Prevent-.tenet-.state-tenet.db-from-being-committed.md @@ -0,0 +1,24 @@ +--- +id: TASK-017 +title: Prevent .tenet/.state/tenet.db from being committed +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 17000 +--- + +## Description + + +tenet.db corruption likely from git. Delivered: tenet init/--upgrade detects tracked tenet.db via git ls-files and warns with exact git rm --cached command. Also appends .tenet/.state/ to .gitignore. + + +## Acceptance Criteria + +- [ ] #1 tenet init detects already-tracked tenet.db and warns user +- [ ] #2 .tenet/.state/ is appended to .gitignore + diff --git a/.backlog/tasks/task-018 - tenet-status-and-MCP-tool-misalign.md b/.backlog/tasks/task-018 - tenet-status-and-MCP-tool-misalign.md new file mode 100644 index 0000000..85bc95f --- /dev/null +++ b/.backlog/tasks/task-018 - tenet-status-and-MCP-tool-misalign.md @@ -0,0 +1,24 @@ +--- +id: TASK-018 +title: tenet status and MCP tool misalign +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 18000 +--- + +## Description + + +Agent couldn't see job IDs through tenet MCP tool to clean up stale jobs. Delivered (v26.6.7): tenet_get_status now takes view='summary'|'queue' with optional include_blocked. Queue lists non-terminal jobs with id, type, status, name, age_ms, stale. + + +## Acceptance Criteria + +- [ ] #1 tenet_get_status supports queue view with job details +- [ ] #2 Agent can see and cancel stale pending jobs via MCP + diff --git a/.backlog/tasks/task-019 - tenet-status-job-ordering.md b/.backlog/tasks/task-019 - tenet-status-job-ordering.md new file mode 100644 index 0000000..bbced9c --- /dev/null +++ b/.backlog/tasks/task-019 - tenet-status-job-ordering.md @@ -0,0 +1,24 @@ +--- +id: TASK-019 +title: tenet status job ordering +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 19000 +--- + +## Description + + +tenet status showed unsorted jobs. Delivered (v26.6.8): sorts by status priority then dag_id in natural/numeric order, falling back to created_at for ad-hoc jobs. Shared compareJobsByPlan drives both CLI table and job-queue.md. + + +## Acceptance Criteria + +- [ ] #1 Jobs sorted by status priority then dag_id natural order +- [ ] #2 Both CLI and job-queue.md use same sort logic + diff --git a/.backlog/tasks/task-020 - Doctrine-proposals.md-never-produced.md b/.backlog/tasks/task-020 - Doctrine-proposals.md-never-produced.md new file mode 100644 index 0000000..7c5a8c3 --- /dev/null +++ b/.backlog/tasks/task-020 - Doctrine-proposals.md-never-produced.md @@ -0,0 +1,24 @@ +--- +id: TASK-020 +title: Doctrine-proposals.md never produced +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 20000 +--- + +## Description + + +doctrine-proposals.md never produced; drift notes landed in design.md instead. Delivered (v26.6.7): root cause was dev-job preamble lossy paraphrase. Fixed preamble to carry real contract and broadened run-end review. + + +## Acceptance Criteria + +- [ ] #1 Dev-job preamble carries the real drift-note contract +- [ ] #2 Run-end review scans journal and run docs for drift markers + diff --git a/.backlog/tasks/task-021 - Customizable-harness.md b/.backlog/tasks/task-021 - Customizable-harness.md new file mode 100644 index 0000000..2e6ee32 --- /dev/null +++ b/.backlog/tasks/task-021 - Customizable-harness.md @@ -0,0 +1,25 @@ +--- +id: TASK-021 +title: Customizable harness +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 21000 +--- + +## Description + + +Tenet uses 3 critics; make it configurable. Delivered: critics are now a project artifact in .tenet/critics.json. Phase 1 (enable/disable) and Phase 2 (custom prompts) shipped together. + + +## Acceptance Criteria + +- [ ] #1 3 built-in critics (code/test/interaction-e2e) are configurable +- [ ] #2 Custom critics can be added with prompts under .tenet/critics/*.md +- [ ] #3 Blocking-finding resume gate reads expected_eval_stages + diff --git "a/.backlog/tasks/task-022 - start_eval-output-arg-\342\200\224-host-agent-fails-to-supply.md" "b/.backlog/tasks/task-022 - start_eval-output-arg-\342\200\224-host-agent-fails-to-supply.md" new file mode 100644 index 0000000..1a6942d --- /dev/null +++ "b/.backlog/tasks/task-022 - start_eval-output-arg-\342\200\224-host-agent-fails-to-supply.md" @@ -0,0 +1,24 @@ +--- +id: TASK-022 +title: start_eval output arg — host agent fails to supply +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 22000 +--- + +## Description + + +Host agent got stuck looping on tenet_start_eval requiring 'output' parameter. Delivered (v26.7.2): output is now optional (default {}), so stuck agent can't wedge itself. Critics just get less to chew on. + + +## Acceptance Criteria + +- [ ] #1 output parameter is optional with default {} +- [ ] #2 Regression test locks omitted-output dispatch path + diff --git a/.backlog/tasks/task-023 - Document-lifecycle-recommendation-at-last-run.md b/.backlog/tasks/task-023 - Document-lifecycle-recommendation-at-last-run.md new file mode 100644 index 0000000..3430b2b --- /dev/null +++ b/.backlog/tasks/task-023 - Document-lifecycle-recommendation-at-last-run.md @@ -0,0 +1,25 @@ +--- +id: TASK-023 +title: Document lifecycle recommendation at last run +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 23000 +--- + +## Description + + +Need AI to suggest whether a run requires updating .tenet/project contents. Delivered: jobs flag stale .tenet/project/ as structured doctrine-drift notes; run-end consolidates into doctrine-proposals.md. + + +## Acceptance Criteria + +- [ ] #1 Doctrine drift notes are flagged during jobs +- [ ] #2 Run-end consolidates proposals into doctrine-proposals.md +- [ ] #3 Authorized dev job can apply accepted proposals + diff --git "a/.backlog/tasks/task-024 - Spawned-job-edge-case-\342\200\224-context-length-exceed.md" "b/.backlog/tasks/task-024 - Spawned-job-edge-case-\342\200\224-context-length-exceed.md" new file mode 100644 index 0000000..d99b7f9 --- /dev/null +++ "b/.backlog/tasks/task-024 - Spawned-job-edge-case-\342\200\224-context-length-exceed.md" @@ -0,0 +1,25 @@ +--- +id: TASK-024 +title: Spawned job edge case — context length exceed +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 24000 +--- + +## Description + + +When dispatched job reaches context limit, some agents return error. Critic jobs were accepted as-is with partial result. Delivered (v26.6.7): orchestrator treats context-limit exit as not-passed, retries as-is, then splits into reduced-scope critics after 2 consecutive limits. + + +## Acceptance Criteria + +- [ ] #1 Context-limit exit treated as not-passed (retry) +- [ ] #2 Split into reduced-scope critics after 2 consecutive limits +- [ ] #3 Regression test locks the invariant + diff --git a/.backlog/tasks/task-025 - Knowledge-update-bug-investigation.md b/.backlog/tasks/task-025 - Knowledge-update-bug-investigation.md new file mode 100644 index 0000000..356abc6 --- /dev/null +++ b/.backlog/tasks/task-025 - Knowledge-update-bug-investigation.md @@ -0,0 +1,24 @@ +--- +id: TASK-025 +title: Knowledge update bug investigation +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 25000 +--- + +## Description + + +Since v26.6.1, tenet_update_knowledge tool didn't seem to write knowledge documents under .tenet/knowledge/. Closed (not a bug): orchestrator was writing type='journal' (tool default), which correctly lands in .tenet/runs//journal/, not .tenet/knowledge/. + + +## Acceptance Criteria + +- [ ] #1 Root cause identified: tool default type=journal routes to run journal, not knowledge +- [ ] #2 06-evaluation.md updated to not claim eval records land in .tenet/knowledge/ + diff --git "a/.backlog/tasks/task-026 - Journal-improvement-\342\200\224-run-path-resolution.md" "b/.backlog/tasks/task-026 - Journal-improvement-\342\200\224-run-path-resolution.md" new file mode 100644 index 0000000..198986a --- /dev/null +++ "b/.backlog/tasks/task-026 - Journal-improvement-\342\200\224-run-path-resolution.md" @@ -0,0 +1,24 @@ +--- +id: TASK-026 +title: Journal improvement — run path resolution +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 26000 +--- + +## Description + + +Agent wrote journal at top-level .tenet/journal/ instead of .tenet/runs/run-slug/journal/. Delivered (v26.7.2): root cause was mechanism gap in getRunPath. Fixed by walking job ancestry with 5-hop bound and visited-set cycle guard. + + +## Acceptance Criteria + +- [ ] #1 Ungrounded critic journal entries land in correct run journal/ +- [ ] #2 Job ancestry walk resolves run path correctly + diff --git a/.backlog/tasks/task-027 - Design-component-skip-in-context-bootstrap.md b/.backlog/tasks/task-027 - Design-component-skip-in-context-bootstrap.md new file mode 100644 index 0000000..db6eb68 --- /dev/null +++ b/.backlog/tasks/task-027 - Design-component-skip-in-context-bootstrap.md @@ -0,0 +1,24 @@ +--- +id: TASK-027 +title: Design-component skip in context bootstrap +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 27000 +--- + +## Description + + +design-component mention was too thin. After migration from v26.6.0 to v26.6.1, context bootstrap didn't generate design-components document for a SaaS project with frontend design. Delivered: 00-context-bootstrap.md now tells bootstrap to populate project/design-components/ when visual/UI surface is detected. + + +## Acceptance Criteria + +- [ ] #1 Context bootstrap populates design-components/ when visual/UI surface detected +- [ ] #2 Empty dir in clearly-frontend project is flagged, not silently skipped + diff --git a/.backlog/tasks/task-028 - tenet-snapshot-too-large.md b/.backlog/tasks/task-028 - tenet-snapshot-too-large.md new file mode 100644 index 0000000..c595c58 --- /dev/null +++ b/.backlog/tasks/task-028 - tenet-snapshot-too-large.md @@ -0,0 +1,25 @@ +--- +id: TASK-028 +title: tenet snapshot too large +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 28000 +--- + +## Description + + +tenet snapshot became >100MB blocking git push. Delivered (v26.6.8): tenet db snapshot writes gzip-compressed tenet.db.gz by default. --no-compress keeps plain file. restore-snapshot auto-detects format. + + +## Acceptance Criteria + +- [ ] #1 Snapshot compresses with gzip level 9 by default +- [ ] #2 Restore auto-detects gzip vs plain via magic bytes +- [ ] #3 CLI smoke test showed 98% compression on tiny DB + diff --git "a/.backlog/tasks/task-029 - Visual-phase-\342\200\224-inspect-design-components.md" "b/.backlog/tasks/task-029 - Visual-phase-\342\200\224-inspect-design-components.md" new file mode 100644 index 0000000..6d28faf --- /dev/null +++ "b/.backlog/tasks/task-029 - Visual-phase-\342\200\224-inspect-design-components.md" @@ -0,0 +1,24 @@ +--- +id: TASK-029 +title: Visual phase — inspect design-components/ +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 29000 +--- + +## Description + + +Agent in visual mockup phase looked at design.md only, not design-components/ directory. Delivered: 03-visuals.md now makes design-components/ a MUST-inspect when dir exists and is non-empty. + + +## Acceptance Criteria + +- [ ] #1 Visual phase inspects every file in design-components/ when dir exists +- [ ] #2 Run design-delta checklist reinforces the inspection + diff --git "a/.backlog/tasks/task-030 - Better-playwright-critic-\342\200\224-interaction-e2e.md" "b/.backlog/tasks/task-030 - Better-playwright-critic-\342\200\224-interaction-e2e.md" new file mode 100644 index 0000000..d5ffdeb --- /dev/null +++ "b/.backlog/tasks/task-030 - Better-playwright-critic-\342\200\224-interaction-e2e.md" @@ -0,0 +1,25 @@ +--- +id: TASK-030 +title: Better playwright critic — interaction e2e +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 30000 +--- + +## Description + + +Playwright critic became playwright-only and skipped non-GUI projects. Should run e2e CLI tests too. Delivered: CLI/API/library branches get same exploratory agent-brain rigor as browser branch. Renamed from playwright_eval to interaction_e2e. + + +## Acceptance Criteria + +- [ ] #1 Non-browser e2e paths get same exploratory rigor as browser +- [ ] #2 Docs no longer signal 'browser-only' +- [ ] #3 Internal identifiers renamed with DB schema migration + diff --git a/.backlog/tasks/task-031 - Improving-Steer-Messages.md b/.backlog/tasks/task-031 - Improving-Steer-Messages.md new file mode 100644 index 0000000..8faeede --- /dev/null +++ b/.backlog/tasks/task-031 - Improving-Steer-Messages.md @@ -0,0 +1,24 @@ +--- +id: TASK-031 +title: Improving Steer Messages +status: Done +assignee: [] +created_date: '2026-07-07 02:17' +updated_date: '2026-07-07 02:17' +labels: [] +dependencies: [] +priority: low +ordinal: 31000 +--- + +## Description + + +Agent kept adding steer messages without cleanup, leading to 400k+ steer messages. Delivered: tenet_update_steer retires/sweeps steers; tenet_process_steer returns user steers in full and caps agent steers. + + +## Acceptance Criteria + +- [ ] #1 Steer messages are sweepable and capped +- [ ] #2 User steers retire only by explicit ID + diff --git a/AGENTS.md b/AGENTS.md index 4212ffc..9e7918f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1 +1,24 @@ Read CLAUDE.md + + + + +## Backlog.md Workflow + +This project uses Backlog.md for task and project management. + +**For every user request in this project, run `backlog instructions overview` before answering or taking action.** + +Use the overview to decide whether to search, read, create, or update Backlog tasks. + +Use the detailed guides when needed: +- `backlog instructions task-creation` for creating or splitting tasks +- `backlog instructions task-execution` for planning and implementation workflow +- `backlog instructions task-finalization` for completion and handoff + +Use `backlog --help` before running unfamiliar commands. Help shows options, fields, and examples. + +Do not edit Backlog task, draft, document, decision, or milestone markdown files directly. Use the `backlog` CLI so metadata, relationships, and history stay consistent. + + + diff --git a/CLAUDE.md b/CLAUDE.md index ca23312..71a76c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,3 +154,26 @@ Design documents in `docs/planning/` are numbered chronologically. Key docs: - `05_test_observations_2026-04-08.md` — 23 observations from manual testing - `06_status_2026-04-08.md` — Current implementation status and what's remaining - `07_round5_fixes_2026-04-14.md` — 6 issues from round 5 testing (timeout, stall recovery, playwright, wiring) + + + + +## Backlog.md Workflow + +This project uses Backlog.md for task and project management. + +**For every user request in this project, run `backlog instructions overview` before answering or taking action.** + +Use the overview to decide whether to search, read, create, or update Backlog tasks. + +Use the detailed guides when needed: +- `backlog instructions task-creation` for creating or splitting tasks +- `backlog instructions task-execution` for planning and implementation workflow +- `backlog instructions task-finalization` for completion and handoff + +Use `backlog --help` before running unfamiliar commands. Help shows options, fields, and examples. + +Do not edit Backlog task, draft, document, decision, or milestone markdown files directly. Use the `backlog` CLI so metadata, relationships, and history stay consistent. + + + From e22db9ba29c57d665e81129c62abc9d65c50b416 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 9 Jul 2026 08:09:51 +0900 Subject: [PATCH 47/87] docs(skill): permit and recommend tracked sub-agent delegation in execution loop The execution loop banned host sub-agents outright, but the ban was over-broad: its stated rationale was "bypasses job tracking," and a sub-agent that routes every operation through tenet MCP tools bypasses nothing. A weak orchestrator (qwen3.6) spontaneously invented delegating the wait->eval->wait->gather span to a sub-agent and ran it reliably. Narrow the three prohibitions (05-execution-loop.md intro and Operational Rule, SKILL.md Execution Rule) to ban untracked work, not the sub-agent mechanism. Add a "Tracked Sub-Agent Delegation (recommended)" subsection carrying the invariants a naive delegation drops: backoff (not a single blocking wait), variable-length jobs[] (not a fixed count), rubric-JSON parsing (no top-level passed), the three-way pass/fail classifier, steer processing inside the delegated window, and the orchestrator-only duties. Feasibility confirmed: host sub-agents can call tenet MCP tools on all three CLIs (Claude Code, Codex, OpenCode). Co-Authored-By: Claude --- skills/tenet/SKILL.md | 2 +- skills/tenet/phases/05-execution-loop.md | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/skills/tenet/SKILL.md b/skills/tenet/SKILL.md index ade2f4a..a053c94 100644 --- a/skills/tenet/SKILL.md +++ b/skills/tenet/SKILL.md @@ -188,7 +188,7 @@ Examples: Read `phases/05-execution-loop.md` before starting execution. -- Use Tenet MCP tools only. Do not call host subagents directly and do not manually implement job code during the execution loop. +- Use Tenet MCP tools only. Do not manually implement job code during the execution loop. You may delegate a slice of the loop (the wait→eval→wait→gather span) to a host sub-agent provided every operation it performs is a tenet MCP tool call (see `phases/05-execution-loop.md` § Tracked Sub-Agent Delegation). - Dispatch `tenet_job_wait` as background/non-blocking status checks with backoff. Stay responsive to user steering between checks. - Pass the original job ID to `tenet_start_eval`; pass `feature` when known. - `tenet_start_eval` dispatches the configured critics and returns them as a variable-length `jobs[]` list (plus `execution_mode`). Wait on every job in that list; do not assume a fixed count or that all critics are already running. diff --git a/skills/tenet/phases/05-execution-loop.md b/skills/tenet/phases/05-execution-loop.md index 9324b5b..a708f3d 100644 --- a/skills/tenet/phases/05-execution-loop.md +++ b/skills/tenet/phases/05-execution-loop.md @@ -1,6 +1,6 @@ # Autonomous Execution Loop -The core of Tenet is the tracked execution loop. You must use the `tenet_*` MCP tools for all job operations. Direct subagent calls or manual code writing during this phase bypasses job tracking, evaluation, and steering. +The core of Tenet is the tracked execution loop. You must use the `tenet_*` MCP tools for all job operations. All job work — run inline or delegated to a host sub-agent — must flow through tenet MCP tools. Untracked work (manual code writing, direct file edits, or a sub-agent that bypasses tenet tools) breaks job tracking, evaluation, and steering. ## Prerequisite @@ -29,6 +29,7 @@ Execute this sequence for every job cycle: 5. **Brief User**: Tell the user which job was dispatched and that they can interact while it runs. 6. **Background Status Check**: Dispatch `tenet_job_wait(job_id="...")` as a **background task**. Omit `wait_seconds` for an instant status check, or set a bounded `wait_seconds` for long-polling. Use exponential backoff between checks: 30s → 45s → 67s → 100s → 120s (cap). + **Delegate this span (steps 6–10: wait → eval → wait → gather) to a single tracked sub-agent** (see **Tracked Sub-Agent Delegation** under Operational Rules) instead of running it inline — it is mechanical and pollutes your context, especially on long runs. Run it inline only for short runs, or if your host can't grant a sub-agent tenet MCP access. When the background task completes: - If `is_terminal` is false: check steer, report progress to user, wait (backoff), dispatch another check with the returned `cursor`. - If `is_terminal` is true: proceed to step 7. @@ -94,8 +95,25 @@ tenet_start_job(job_type="dev", params={ ## Operational Rules -### Use MCP Tools, Not Subagents -Dispatch work via `tenet_start_job`. Do not call subagents directly. Do not write implementation code yourself during the execution loop. If `tenet_start_job` returns a failure about missing adapters, tell the user to configure the agent via `tenet config --agent `. +### Use MCP Tools, Not Untracked Work +Dispatch work via `tenet_start_job`. Do not write implementation code yourself during the execution loop. You MAY delegate a slice of the loop (e.g. the wait→eval→wait→gather span) to a host sub-agent, but only if every operation the sub-agent performs is a tenet MCP tool call (see **Tracked Sub-Agent Delegation** below). A sub-agent that edits files, writes code, or otherwise bypasses tenet tools is forbidden for the same reason manual code writing is. If `tenet_start_job` returns a failure about missing adapters, tell the user to configure the agent via `tenet config --agent `. + +### Tracked Sub-Agent Delegation (recommended) + +Hand the wait→eval→wait→gather span (steps 6–10) to a single host sub-agent so your main context stays clean — the repeated status checks of a long run otherwise fill it with poll noise. The sub-agent checks the worker's status, dispatches critics via `tenet_start_eval`, waits on every returned job, and returns a per-critic PASS/FAIL summary. You then resume the work only the orchestrator owns: steer check (step 1), brief-user (step 5), git fallback commit + context-limit/split decisions (step 7), `tenet_update_knowledge` (step 11), status sync (step 12), and finding-category routing. + +Run the span inline only for short runs, or when your host cannot grant a sub-agent tenet MCP access. + +The sub-agent must: + +- **Wait with backoff, never one blocking call.** Loop `tenet_job_wait` on the 30s → 45s → 67s → 100s → 120s (cap) schedule, re-calling with the returned `cursor` until `is_terminal`. A single `wait_seconds=120` returns non-terminal after at most 120s and cannot cover a long job. +- **Read the critic count from the tool, never hardcode it.** `tenet_start_eval` returns a variable-length `jobs[]`; loop `tenet_job_wait` across every returned ID until all are terminal. In sequential `execution_mode`, later critics stay pending until their parent completes. +- **Parse the rubric, not a top-level `passed`.** `tenet_job_result` returns `{job_id, status, output, error, duration_ms}` — there is no top-level `passed` field. The critic's verdict is rubric JSON nested in `output.output`; extract it and read `passed` from there. +- **Apply the three-way classifier.** A terminal critic with no valid rubric JSON (context-limit / error / empty body) is **not** a pass — retry as-is, then split after 2 consecutive context-limits. Never accept a context-limited critic as a pass. +- **Process steer within the delegated window.** The sub-agent re-checks `tenet_process_steer()` between waits so an emergency halt or new directive is not missed while you are not driving the loop directly. +- **Return a structured summary** (per-critic PASS/FAIL + failure reasons). The orchestrator resumes from there. + +The sub-agent must NOT edit files, write code, or perform any non-tenet operation — if it does, it has left the tracked loop and the run is no longer reliable. ### Project Doctrine Write Boundary Normal implementation, integration, eval, spec, decomposition, harness, and visual jobs must not edit `.tenet/project/**`. They may read project doctrine and write run-local evidence under `.tenet/runs//**`. From d0e43f61da5fee35c4d6c3ad070937b72ad9cd2e Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 9 Jul 2026 08:10:58 +0900 Subject: [PATCH 48/87] Add backlogs --- ...to-send-to-worker-from-project-document.md | 63 +++++++++++ ...ops-loop-without-calling-tenet_wait_job.md | 26 +++++ ...-instead-of-relying-on-job_wait-timeout.md | 27 +++++ ...\224-orchestrator-should-not-skip-them.md" | 31 +++++ ...rs-\342\200\224-must-moderate-advisory.md" | 27 +++++ ...n-grounding-audit-parked-measure-first.md" | 107 ++++++++++++++++++ 6 files changed, 281 insertions(+) create mode 100644 .backlog/tasks/task-032 - Context-filtering-what-to-send-to-worker-from-project-document.md create mode 100644 .backlog/tasks/task-033 - Orchestrator-stops-loop-without-calling-tenet_wait_job.md create mode 100644 .backlog/tasks/task-034 - Use-worker-heartbeat-signal-instead-of-relying-on-job_wait-timeout.md create mode 100644 ".backlog/tasks/task-035 - Critics-must-be-mandatory-\342\200\224-orchestrator-should-not-skip-them.md" create mode 100644 ".backlog/tasks/task-036 - Critic-tiers-\342\200\224-must-moderate-advisory.md" create mode 100644 ".backlog/tasks/task-037 - Loop-reliability-redesign-\342\200\224-design-investigation-grounding-audit-parked-measure-first.md" diff --git a/.backlog/tasks/task-032 - Context-filtering-what-to-send-to-worker-from-project-document.md b/.backlog/tasks/task-032 - Context-filtering-what-to-send-to-worker-from-project-document.md new file mode 100644 index 0000000..891b263 --- /dev/null +++ b/.backlog/tasks/task-032 - Context-filtering-what-to-send-to-worker-from-project-document.md @@ -0,0 +1,63 @@ +--- +id: TASK-032 +title: 'Context filtering: what to send to worker from project document?' +status: To Do +assignee: [] +created_date: '2026-07-07 21:57' +labels: + - design +dependencies: [] +ordinal: 32000 +--- + +## Description + + +Sending all context (spec, harness, all decomposition, etc.) to the worker feels wasteful. There may be truly good context to include, but there is also unnecessary context being sent that could distract the worker. This also creates a design decision: how do we pass partial context from the project document to the worker? Needs discussion. + +### Investigation Findings + +**Two separate context assembly paths exist:** + +1. **Orchestrator context** (`tenet_compile_context` in `src/mcp/tools/tenet-compile-context.ts:126-291`) — inlines spec, decomposition, interview, scenarios, harness, and all 5 project doctrine docs. Lists knowledge/design/journal/research/visuals as filenames only. This is for the orchestrator agent only, NOT forwarded to workers. + +2. **Worker context** (`buildWorkerContext` in `src/core/job-manager.ts:750-804`) — inlines spec, scenarios, decomposition, and harness in full for EVERY worker and EVERY grounded critic. References journal/research/visuals as paths only. + +**Wasteful patterns identified:** + +- Every dev worker gets the full spec, full decomposition, full scenarios, and full harness — even for tiny focused tasks (e.g., "fix login button color" gets the entire multi-page spec) +- Every grounded critic (code, test, custom) receives the same full documents — for a run with 10 jobs × 3 grounded critics = 30 copies of the full spec sent to subprocesses +- The decomposition (DAG) is inlined for every worker — a worker doing job-3 sees all jobs, not just its own slice +- No per-job context tailoring — `buildWorkerContext` is type-agnostic and identical for every run; it does not look at the job's `prompt`, `dag_id`, or `depends_on` +- No context size budget — no mechanism to measure, truncate, summarize, or limit context size + +**Existing filtering is minimal:** +- `full_context` flag on critics (code/test default `true`, interaction_e2e default `false`) +- Bulky docs (journal/research/visuals) are path-referenced, not inlined +- Agent steer capping (default 50) +- No section-level filtering, no token budgeting, no per-job relevance scoring + +**Key files:** +| File | Lines | Purpose | +|------|-------|---------| +| `src/core/job-manager.ts` | 675-804 | `toInvocation()` + `buildWorkerContext()` | +| `src/core/job-manager.ts` | 806-864 | `withDevPreamble()` — dev worker prompt | +| `src/mcp/tools/tenet-compile-context.ts` | 126-291 | Orchestrator context compilation | +| `src/mcp/tools/tenet-start-eval.ts` | 10-42 | Job scope builder for critics | +| `src/mcp/tools/tenet-start-eval.ts` | 284-344 | `buildCriticDispatch()` — critic prompt assembly | +| `src/core/critic-roster.ts` | 1-248 | Critic roster with `full_context` flags | +| `src/core/artifact-paths.ts` | 1-130 | Artifact path types and resolution | +| `src/adapters/base.ts` | 1-22 | `AgentInvocation` interface (context + prompt) | +| `src/core/job-manager-worker-context.test.ts` | 1-226 | Tests for worker context assembly | + + +## Acceptance Criteria + +- [ ] #1 Determine which parts of the project document are relevant to a specific worker task +- [ ] #2 Design a mechanism to pass partial/selected context from the project document to the worker +- [ ] #3 Establish criteria for context filtering (e.g., task scope, worker role, dependency graph) +- [ ] #4 Decide on approach: section-level filtering, token budgeting, per-job relevance scoring, or a combination +- [ ] #5 Consider backward compatibility — existing runs should not break + + + diff --git a/.backlog/tasks/task-033 - Orchestrator-stops-loop-without-calling-tenet_wait_job.md b/.backlog/tasks/task-033 - Orchestrator-stops-loop-without-calling-tenet_wait_job.md new file mode 100644 index 0000000..e403b80 --- /dev/null +++ b/.backlog/tasks/task-033 - Orchestrator-stops-loop-without-calling-tenet_wait_job.md @@ -0,0 +1,26 @@ +--- +id: TASK-033 +title: Orchestrator stops loop without calling tenet_wait_job +status: To Do +assignee: [] +created_date: '2026-07-07 22:11' +labels: + - bug + - orchestrator +dependencies: [] +priority: high +ordinal: 33000 +--- + +## Description + + +When using qwen3.6 27b as orchestrator, it sometimes says it will wait for a job but never calls the tenet_wait_job MCP tool — it just stops doing nothing. This breaks the tenet loop. Need to investigate root cause and find a way to prevent this (e.g. forcing the tool call, adding validation, or restructuring the loop). + + +## Acceptance Criteria + +- [ ] #1 Identify root cause of orchestrator skipping tenet_wait_job +- [ ] #2 Implement a mechanism to prevent the orchestrator from stopping without calling wait +- [ ] #3 Verify fix works with qwen3.6 27b or similar weaker models + diff --git a/.backlog/tasks/task-034 - Use-worker-heartbeat-signal-instead-of-relying-on-job_wait-timeout.md b/.backlog/tasks/task-034 - Use-worker-heartbeat-signal-instead-of-relying-on-job_wait-timeout.md new file mode 100644 index 0000000..4da8d46 --- /dev/null +++ b/.backlog/tasks/task-034 - Use-worker-heartbeat-signal-instead-of-relying-on-job_wait-timeout.md @@ -0,0 +1,27 @@ +--- +id: TASK-034 +title: Use worker heartbeat signal instead of relying on job_wait timeout +status: To Do +assignee: [] +created_date: '2026-07-07 22:11' +labels: + - orchestrator + - worker + - reliability +dependencies: [] +priority: medium +ordinal: 34000 +--- + +## Description + + +The worker appears to send heartbeats regularly. Currently the orchestrator relies on its own ability to keep calling job_wait MCP tool with a maximum 120s timeout to determine if the worker is alive. Instead, we should use the worker's heartbeat signal to track liveness, which is more reliable and doesn't depend on the orchestrator's tool-calling discipline. + + +## Acceptance Criteria + +- [ ] #1 Verify that worker sends heartbeat signals +- [ ] #2 Design heartbeat-based liveness detection mechanism +- [ ] #3 Replace or supplement job_wait timeout with heartbeat monitoring + diff --git "a/.backlog/tasks/task-035 - Critics-must-be-mandatory-\342\200\224-orchestrator-should-not-skip-them.md" "b/.backlog/tasks/task-035 - Critics-must-be-mandatory-\342\200\224-orchestrator-should-not-skip-them.md" new file mode 100644 index 0000000..d1ae0ab --- /dev/null +++ "b/.backlog/tasks/task-035 - Critics-must-be-mandatory-\342\200\224-orchestrator-should-not-skip-them.md" @@ -0,0 +1,31 @@ +--- +id: TASK-035 +title: Critics must be mandatory — orchestrator should not skip them +status: To Do +assignee: [] +created_date: '2026-07-07 22:11' +updated_date: '2026-07-07 22:12' +labels: + - bug + - orchestrator + - critics +dependencies: [] +priority: high +ordinal: 35000 +--- + +## Description + + +The orchestrator sometimes skips critics because they hang for a long time, or because most critics passed and many retries happened. The user's stance is clear: critics must ALL pass — they are not optional. Need to enforce this. Restructure job processing with critic gates so the orchestrator cannot proceed until all critics have passed. + + +## Acceptance Criteria + +- [ ] #1 Orchestrator must wait for all critics to complete before proceeding +- [ ] #2 Orchestrator must not skip critics under any circumstance +- [ ] #3 Explore and decide on critic gate mechanism or tier system +- [ ] #4 Orchestrator must wait for all critics to complete before proceeding +- [ ] #5 Orchestrator must not skip critics under any circumstance +- [ ] #6 Implement critic gate mechanism that blocks job completion until all critics pass + diff --git "a/.backlog/tasks/task-036 - Critic-tiers-\342\200\224-must-moderate-advisory.md" "b/.backlog/tasks/task-036 - Critic-tiers-\342\200\224-must-moderate-advisory.md" new file mode 100644 index 0000000..8406090 --- /dev/null +++ "b/.backlog/tasks/task-036 - Critic-tiers-\342\200\224-must-moderate-advisory.md" @@ -0,0 +1,27 @@ +--- +id: TASK-036 +title: Critic tiers — must / moderate / advisory +status: To Do +assignee: [] +created_date: '2026-07-07 22:12' +labels: + - feature + - critics + - orchestrator +dependencies: [] +priority: medium +ordinal: 36000 +--- + +## Description + + +Introduce critic levels/tiers to give the orchestrator flexibility without skipping mandatory critics. Three tiers: 'must' (blocking — all must pass), 'moderate' (should pass but can proceed with caution), 'advisory' (informational, non-blocking). This allows the orchestrator to make smart decisions about when to proceed while still enforcing hard gates for critical critics. + + +## Acceptance Criteria + +- [ ] #1 Define critic tier system (must / moderate / advisory) +- [ ] #2 Orchestrator respects tier: 'must' critics are blocking, 'moderate' and 'advisory' are non-blocking +- [ ] #3 Backward compatible — existing critics default to 'must' + diff --git "a/.backlog/tasks/task-037 - Loop-reliability-redesign-\342\200\224-design-investigation-grounding-audit-parked-measure-first.md" "b/.backlog/tasks/task-037 - Loop-reliability-redesign-\342\200\224-design-investigation-grounding-audit-parked-measure-first.md" new file mode 100644 index 0000000..40d0447 --- /dev/null +++ "b/.backlog/tasks/task-037 - Loop-reliability-redesign-\342\200\224-design-investigation-grounding-audit-parked-measure-first.md" @@ -0,0 +1,107 @@ +--- +id: TASK-037 +title: >- + Loop-reliability redesign — design investigation & grounding audit (parked: + measure first) +status: To Do +assignee: [] +created_date: '2026-07-08 22:09' +updated_date: '2026-07-08 23:03' +labels: + - design + - orchestrator + - critics + - reliability +dependencies: [] +priority: medium +ordinal: 37000 +--- + +## Description + + +**Status: design investigation — PARKED.** An independent 9-agent code-grounding audit rated the proposed redesign **SHAKY (not sound, not dead)**: the direction is half-right, the full proposed stack is premature, and several pillars rested on unverified or refuted premises. **Do not implement until the Open Questions are resolved.** This task supersedes the ad-hoc design discussion and is the single source of truth for it. + +## Problem + +Weak orchestrator models (e.g. qwen3.6 27b) break the Tenet loop two ways: + +- **TASK-035** — skip critics (they are slow, or most already passed) and advance anyway. +- **TASK-033** — say "I'll wait for the job" then never call `tenet_job_wait`; the loop freezes. + +Today the rules "wait for the job" and "don't skip critics" live ONLY in the skill prompt. Nothing in the server enforces them, so a weak model that ignores the prompt breaks the loop. (Related: TASK-034 liveness, TASK-036 critic tiers.) + +## Design that was explored (consolidated shape) + +Server-side **critic gate** (dev job can't advance until its critics pass) + **`eval_round`** id (latest critic round wins after retry) + **`tenet_report`** MCP tool (structured critic verdicts, replacing fragile JSON scavenging) + server-side **auto-dispatch** of critics on dev completion + heartbeat **watchdog** + **kill-switch** config flag. + +## Audit verdict: SHAKY — direction half-right, oversold, full stack premature + +"Move invariants server-side, enforce at the action layer" is a sound *principle* (worthwhile defense-in-depth) but was oversold as a *complete fix*. **Measure the real problem before building anything.** + +## Verified — these HOLD (confirmed with file:line) + +- `tenet_continue` is strictly READ-ONLY (`job-manager.ts:370-387`). It dispatches/starts nothing. +- The ONLY pending→running transitions are `dispatchJob` (`job-manager.ts:146`) and `startJob` (`job-manager.ts:203`). Both check only `status==='pending'` — no dependency check, no critic check. So a non-bypassable gate MUST live in BOTH. A gate in `dispatchJob` alone is insufficient. +- `checkBlockingFindingResume` (`job-manager.ts:989`) is the only existing engine-level critic gate, but it is wired ONLY to the report-only `blocked_on_finding` path and fires on SUCCESS only (no failure/stall branch). +- NO dev→critic dependency edge exists today (critics carry `source_job_id`/`eval_stage` but no `depends_on` to the dev job; dev jobs complete unconditionally). **The gate is NEW architecture, not wiring.** +- `eval_round`: no concept today. `resolveExpectedEvalStages` reads the OLDEST cohort's stamp (`job-manager.ts:978`). `retryJob` keeps the same id and never touches critic rows → stale failed critics wedge the gate. `params` is a JSON column, so adding `eval_round` needs **NO DB migration**. +- Heartbeat = 2s `setInterval` (`job-manager.ts:442`). `detectStalledJobs` runs ONLY lazily (4 call sites: 251/268/371/395) — **NO wall-clock watchdog exists.** +- `all_done = completedCount === totalCount` (`job-manager.ts:376`) and `getCompletedCount` counts only `completed` → once any job terminally fails, `all_done` can NEVER become true. `max_retries` defaults to -1 (unlimited). +- No push/notification mechanism exists (MCP is request/response). +- The skill MANDATES `tenet_continue` before `tenet_start_job` (SKILL.md Core Invariant #1). Bypassing `continue` is a skill violation, not a sanctioned pattern. + +## Refuted / shaky — these were WRONG + +- **"Unbreakable regardless of what the agent does" — FALSE.** The orchestrator is unsandboxed (Write/Edit + subagents; SKILL.md:191 is a request, not enforcement). A weak model can edit files directly or spawn a subagent and never touch the job machine. A server gate only blocks JOB-STATE advancement, not out-of-band work. +- **"Kill switch is server-side, zero prompt complexity" — FALSE (audit G4).** The skill mandates the orchestrator call `tenet_start_eval` (step 8). Server auto-dispatch makes that contradictory; gate-OFF + gate-ON-prompt = critics silently stop. Needs a prompt rewrite, or no kill switch. +- **`dispatchJob` is NOT the only chokepoint.** `startJob` is a second pending→running path, and naively gating it DEADLOCKS critic dispatch (critics route through `startJob`). The gate must be specific to the dev→critic edge, not a blanket block on all pending→running transitions. +- **Auto-dispatch deliverable check is NOT reliable.** The only server-side check (`checkDeliverables`, `job-manager.ts:912`) catches only zero-git-diff; cannot detect context-limit/error/empty output. +- **A watchdog does NOT fix TASK-033.** A server timer cannot make a host call a tool. 033 is a host-boundary problem. +- **Strict all-critics-must-pass is BRITTLE** without a finite retry budget + terminal escape (conflicts with `max_retries=-1` default and the `all_done`-never-true bug; reconcile with TASK-036 tiering). + +## OPEN QUESTIONS — must be resolved before any implementation + +1. **`tenet_report` feasibility (UNVERIFIED).** The adapter spawns subprocesses with `--allowedTools` containing NO `mcp__tenet__` entries (`job-manager.ts:698` sets `allowedTools=undefined` for dev/code/test critics → adapter default list; `interaction_e2e` gets a Playwright list — neither includes tenet). BUT `tenet init` separately writes `mcp__tenet__*` permissions to the project config (`.claude/settings.local.json`, `.codex/config.toml`, `opencode.json` — `init.ts:1168`). **Which one wins is CLI-external behavior (differs Claude/Codex/OpenCode) and was NEVER tested.** The audit's "workers can't call tenet tools" was half-checked (only the adapter flag, not the project config). Resolution: empirically run a real worker/critic subprocess and try to call a tenet tool. +2. **No failure data.** No qwen3.6 run logs/transcripts exist in the repo. The actual locus/rate of failure is unmeasured — we do not even know whether the weak model routes work through the job machine at all vs. working out-of-band (direct edits/subagents), in which case **no server-side gate has anything to act on.** This is the #1 thing to establish. + +## Recommended next step (audit recommendation, agreed) + +1. **Measure first** — collect real failure data from a qwen3.6 run (where does it break: `job_wait`? `start_eval`? all-must-pass? out-of-band?). +2. **Most defensible single piece** (if anything is built): server-side critic auto-dispatch + a REAL deliverable-quality check (commit-SHA presence, error-string detection, non-empty output). Targets the eval-skip half of TASK-035; clear correctness argument; only helps work that goes through the job machine. +3. **Watchdog = state-hygiene only**, NOT the TASK-033 fix. Attack 033 at the host boundary (louder `tenet_continue` signal / push-based loop / accept a skip rate). +4. **Do NOT build** the full gate / `eval_round` / kill-switch / `tenet_report` stack until the failure data is in and the open questions are closed. + +## Key files + +| File | Lines | Purpose | +|------|-------|---------| +| `src/core/job-manager.ts` | 146-179 | `dispatchJob` — action-layer gate site #1 | +| `src/core/job-manager.ts` | 203-248 | `startJob` — action-layer gate site #2 | +| `src/core/job-manager.ts` | 370-387 | `continue()` — read-only, NOT a gate site | +| `src/core/job-manager.ts` | 989-1067 | `checkBlockingFindingResume` (existing report-only gate) | +| `src/core/job-manager.ts` | 912-942 | `checkDeliverables` (only catches zero-git-diff) | +| `src/core/state-store.ts` | 529-590 | `getNextRunnableJob` / `dagDependenciesCompleted` (advisory only) | +| `src/core/state-store.ts` | 618-627 | `getEvalsForSource` (all siblings, ASC, unfiltered) | +| `src/adapters/claude-adapter.ts` | 13-22, 51 | `DEFAULT_ALLOWED_TOOLS` (no `mcp__tenet__`) | +| `src/cli/init.ts` | 1168 | writes `mcp__tenet__*` perms to project config | +| `skills/tenet/phases/05-execution-loop.md` | 17-52 | the 13-step per-cycle sequence | + + +## Acceptance Criteria + +- [ ] #1 Empirical failure data collected from a real weak-orchestrator (qwen3.6) run: where the loop actually breaks (job_wait / start_eval / all-must-pass / out-of-band), with transcript or log evidence +- [ ] #2 tenet_report feasibility empirically verified: a real worker/critic subprocess is run and confirmed able (or unable) to call a tenet MCP tool, given the adapter --allowedTools flag vs the project-config permissions written by tenet init +- [ ] #3 Decision recorded on whether the weak orchestrator routes work through the job machine at all (if it works out-of-band via direct edits/subagents, no server-side gate helps — documented and approach adjusted) +- [ ] #4 If proceeding to build: gate placed at BOTH dispatchJob AND startJob (never at continue/getNextRunnableJob), specific to the dev→critic edge (not a blanket pending→running block), with a fail-branch + terminal escape + atomic evaluation +- [ ] #5 Strict must-pass reconciled with TASK-036 tiering and a finite retry budget / terminal escape (no infinite loop under the max_retries=-1 default) + + +## Comments + + +created: 2026-07-08 23:03 +--- +2026-07-09 resolution: Open Question #1 (sub-agent feasibility) is CONFIRMED — user verified host sub-agents can call tenet MCP tools on all 3 CLIs (Claude Code, Codex, OpenCode). Chose the cheap prompt-only path over the full server-side stack: the 3 execution-loop subagent prohibitions were over-broad (they targeted the mechanism, not the harm — the stated rationale was "bypasses job tracking", and a sub-agent that routes through tenet tools bypasses nothing). Narrowed them to ban untracked work, and added a "Tracked Sub-Agent Delegation (recommended)" subsection to skills/tenet/phases/05-execution-loop.md plus a matching SKILL.md Execution Rule. The full server-side gate / eval_round / tenet_report / watchdog stack remains PARKED — only revisit if the delegation recommendation fails to hold. Acceptance test: run qwen3.6 with the new prompt and confirm the runs that previously did NOT delegate now do. +--- + From a7cb6ee5cb820786dae17b6569bdee06597bede2 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 9 Jul 2026 08:12:41 +0900 Subject: [PATCH 49/87] chore: bump to 26.7.3 Co-Authored-By: Claude --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 21a7a5a..42cc37a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.7.2", + "version": "26.7.3", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From b7c9eadb9ae4c4066fdde28e6e5e87396db28ede Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 9 Jul 2026 08:36:20 +0900 Subject: [PATCH 50/87] ci(publish): co-install sigstore for npx npm publish --provenance v26.7.3 publish failed with "Cannot find module 'sigstore'" from libnpmpublish: recent npm@latest does not bundle sigstore into the npx sandbox, so --provenance crashes. Co-installing sigstore (-p sigstore) makes it resolvable. v26.7.0-.2 happened to resolve an npm@latest that still bundled it. Co-Authored-By: Claude --- .github/workflows/publish.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d7c5749..f034cbc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -80,6 +80,11 @@ jobs: # npm 10.9.7, and `npm install -g npm@latest` fails on hosted runners # with MODULE_NOT_FOUND during self-upgrade. npx sidesteps both by # running a sandboxed copy of the latest npm directly. + # Co-install `sigstore` (-p sigstore): recent npm@latest does not bundle + # sigstore into the npx sandbox, so --provenance crashes with + # "Cannot find module 'sigstore'" from libnpmpublish. -p sigstore makes + # it resolvable. (Hit on v26.7.3; v26.7.0-.2 happened to resolve an + # npm@latest that still bundled it.) # --provenance requires id-token: write (set above). # --access public because this is a scoped package (@jeikeilim/*). - run: npx --yes npm@latest publish --provenance --access public + run: npx --yes -p npm@latest -p sigstore -- npm publish --provenance --access public From 5bec210698967b6d42e90c01320742bf82d56b9e Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Sat, 18 Jul 2026 23:01:17 +0900 Subject: [PATCH 51/87] Update backlog --- .../task-007 - Dynamic-critics-per-job.md | 17 +++- ...ob-waiting-loop-delegating-to-sub-agent.md | 15 +++- ...ops-loop-without-calling-tenet_wait_job.md | 10 +++ ...\224-orchestrator-should-not-skip-them.md" | 11 ++- ...n-grounding-audit-parked-measure-first.md" | 14 +++- ...task-038 - tenet-db-cleanup-CLI-command.md | 40 ++++++++++ ...- Root-cause-focus-across-tenet-prompts.md | 31 ++++++++ ...ent-spins-when-wait_seconds-is-omitted.md" | 26 +++++++ ...om-Claude-Code-dynamic-workflow-harness.md | 77 +++++++++++++++++++ ...ersion-differs-from-project-skill-docs.md" | 37 +++++++++ ...et-user-choose-which-agents-to-install.md" | 41 ++++++++++ ...mode-for-visibility-into-agent-activity.md | 34 ++++++++ ...200\224-split-critics-for-local-models.md" | 41 ++++++++++ ...4-supports-Tenet-multi-critic-approach.md" | 33 ++++++++ ...-\342\200\224-parallel-then-sequential.md" | 24 ++++++ 15 files changed, 440 insertions(+), 11 deletions(-) create mode 100644 .backlog/tasks/task-038 - tenet-db-cleanup-CLI-command.md create mode 100644 .backlog/tasks/task-039 - Root-cause-focus-across-tenet-prompts.md create mode 100644 ".backlog/tasks/task-040 - Fix-job_wait-default-timeout-\342\200\224-agent-spins-when-wait_seconds-is-omitted.md" create mode 100644 .backlog/tasks/task-041 - Study-and-adopt-patterns-from-Claude-Code-dynamic-workflow-harness.md create mode 100644 ".backlog/tasks/task-042 - Add-version-mismatch-detection-on-boot-\342\200\224-warn-when-tenet-binary-version-differs-from-project-skill-docs.md" create mode 100644 ".backlog/tasks/task-043 - Add-coding-agent-selection-during-tenet-install-\342\200\224-let-user-choose-which-agents-to-install.md" create mode 100644 .backlog/tasks/task-044 - Add-tmux-mode-for-visibility-into-agent-activity.md create mode 100644 ".backlog/tasks/task-045 - Critic-designer-should-adapt-to-executioner-model-tier-\342\200\224-split-critics-for-local-models.md" create mode 100644 ".backlog/tasks/task-046 - Research-paper-Whats-Your-Agents-GPA-\342\200\224-supports-Tenet-multi-critic-approach.md" create mode 100644 ".backlog/tasks/task-047 - Hybrid-critic-dispatch-mode-\342\200\224-parallel-then-sequential.md" diff --git a/.backlog/tasks/task-007 - Dynamic-critics-per-job.md b/.backlog/tasks/task-007 - Dynamic-critics-per-job.md index 3109ca4..39f4dea 100644 --- a/.backlog/tasks/task-007 - Dynamic-critics-per-job.md +++ b/.backlog/tasks/task-007 - Dynamic-critics-per-job.md @@ -4,16 +4,25 @@ title: Dynamic critics per job status: To Do assignee: [] created_date: '2026-07-07 02:16' +updated_date: '2026-07-14 04:33' labels: [] dependencies: [] priority: medium ordinal: 7000 --- - ## Description -When planning jobs, tenet agent creates critics tailored to each job. Add critic descriptions and let orchestrator choose critics after each job. Related to #19 (custom critics scoped within run). +When planning jobs, tenet agent creates critics tailored to each job. Add critic descriptions and let orchestrator choose critics after each job. + +Extended thinking: +- Critics should be assigned per job, not globally. Some critics are relevant only for certain job types. +- The decomposition DAG could specify which critics apply to each job. +- Challenge: agents may create ad-hoc jobs for the same logical work — what happens to critic assignment then? + - Option A: job itself declares its required critics + - Option B: decomposition assigns critics, ad-hoc jobs inherit from parent + - Option C: critics are selected dynamically based on job type/scope at dispatch time +- Related to TASK-002 (custom critics scoped within run) and TASK-036 (critic tiers — must/moderate/advisory). ## Acceptance Criteria @@ -21,4 +30,8 @@ When planning jobs, tenet agent creates critics tailored to each job. Add critic - [ ] #1 Planning phase generates job-specific critic descriptions - [ ] #2 Orchestrator can select appropriate critics after each job - [ ] #3 Critic quality consistency is maintained +- [ ] #4 Critics can be assigned per job, not just globally +- [ ] #5 Decomposition DAG or job definition specifies which critics apply +- [ ] #6 Ad-hoc jobs have a clear critic assignment strategy +- [ ] #7 Backward compatible — existing jobs without critic specs get default set diff --git a/.backlog/tasks/task-012 - Job-waiting-loop-delegating-to-sub-agent.md b/.backlog/tasks/task-012 - Job-waiting-loop-delegating-to-sub-agent.md index c9e9930..9906c54 100644 --- a/.backlog/tasks/task-012 - Job-waiting-loop-delegating-to-sub-agent.md +++ b/.backlog/tasks/task-012 - Job-waiting-loop-delegating-to-sub-agent.md @@ -1,9 +1,10 @@ --- id: TASK-012 title: Job waiting loop delegating to sub-agent -status: To Do +status: Done assignee: [] created_date: '2026-07-07 02:16' +updated_date: '2026-07-08 23:28' labels: [] dependencies: [] priority: low @@ -18,7 +19,13 @@ Observed qwen3.6 delegating job waiting loop to sub-agent, which is clever conte ## Acceptance Criteria -- [ ] #1 Sub-agent delegation pattern is evaluated for formal support -- [ ] #2 Indefinite wait vs timeout behavior is decided -- [ ] #3 Pattern works across opencode, codex, and claude code +- [x] #1 Sub-agent delegation pattern is evaluated for formal support +- [x] #2 Indefinite wait vs timeout behavior is decided +- [x] #3 Pattern works across opencode, codex, and claude code + +## Final Summary + + +Formalized in v26.7.3: the execution-loop skill now recommends delegating the wait->eval->gather span to a tracked host sub-agent (narrowed the sub-agent ban to untracked work; added a 'Tracked Sub-Agent Delegation' subsection). Wait behavior decided: kept bounded backoff (30->120s cap), not indefinite. Cross-CLI confirmed by user - host sub-agents can call tenet MCP tools on Claude Code, Codex, OpenCode. + diff --git a/.backlog/tasks/task-033 - Orchestrator-stops-loop-without-calling-tenet_wait_job.md b/.backlog/tasks/task-033 - Orchestrator-stops-loop-without-calling-tenet_wait_job.md index e403b80..0de2d75 100644 --- a/.backlog/tasks/task-033 - Orchestrator-stops-loop-without-calling-tenet_wait_job.md +++ b/.backlog/tasks/task-033 - Orchestrator-stops-loop-without-calling-tenet_wait_job.md @@ -4,6 +4,7 @@ title: Orchestrator stops loop without calling tenet_wait_job status: To Do assignee: [] created_date: '2026-07-07 22:11' +updated_date: '2026-07-08 23:28' labels: - bug - orchestrator @@ -24,3 +25,12 @@ When using qwen3.6 27b as orchestrator, it sometimes says it will wait for a job - [ ] #2 Implement a mechanism to prevent the orchestrator from stopping without calling wait - [ ] #3 Verify fix works with qwen3.6 27b or similar weaker models + +## Comments + + +created: 2026-07-08 23:28 +--- +2026-07-09: root cause identified (AC#1) - weak model loses the wait-call in long context; host-boundary problem, no server timer can force a tool call (per TASK-037 audit). Mitigation shipped in v26.7.3: the execution-loop skill now recommends delegating the wait span to a tracked sub-agent, so the wait happens in the sub-agent rather than relying on the main orchestrator remembering to call tenet_job_wait. Advisory, not enforced. AC#3 (verify fix with qwen3.6) remains OPEN - needs a run to confirm prompting makes the non-delegating runs delegate. +--- + diff --git "a/.backlog/tasks/task-035 - Critics-must-be-mandatory-\342\200\224-orchestrator-should-not-skip-them.md" "b/.backlog/tasks/task-035 - Critics-must-be-mandatory-\342\200\224-orchestrator-should-not-skip-them.md" index d1ae0ab..cbdef2e 100644 --- "a/.backlog/tasks/task-035 - Critics-must-be-mandatory-\342\200\224-orchestrator-should-not-skip-them.md" +++ "b/.backlog/tasks/task-035 - Critics-must-be-mandatory-\342\200\224-orchestrator-should-not-skip-them.md" @@ -4,7 +4,7 @@ title: Critics must be mandatory — orchestrator should not skip them status: To Do assignee: [] created_date: '2026-07-07 22:11' -updated_date: '2026-07-07 22:12' +updated_date: '2026-07-08 23:28' labels: - bug - orchestrator @@ -29,3 +29,12 @@ The orchestrator sometimes skips critics because they hang for a long time, or b - [ ] #5 Orchestrator must not skip critics under any circumstance - [ ] #6 Implement critic gate mechanism that blocks job completion until all critics pass + +## Comments + + +created: 2026-07-08 23:28 +--- +2026-07-09: as written this task demands ENFORCEMENT (orchestrator 'cannot proceed until all critics pass'). That enforcement was investigated in TASK-037 and deliberately PARKED as premature/oversold (orchestrator is unsandboxed; server-side gating only blocks job-state, not out-of-band work). Shipped instead: a prompt-level delegation recommendation (v26.7.3) where a tracked sub-agent waits on all critics with all-must-pass + three-way classifier invariants - makes skipping far less likely, but is NOT enforcement. The 'enforce / cannot proceed' ACs (#2/#5/#6) are unmet by design. Decision needed: accept the prompt mitigation as the resolution (close), or revisit building the server-side gate (TASK-037 stack). +--- + diff --git "a/.backlog/tasks/task-037 - Loop-reliability-redesign-\342\200\224-design-investigation-grounding-audit-parked-measure-first.md" "b/.backlog/tasks/task-037 - Loop-reliability-redesign-\342\200\224-design-investigation-grounding-audit-parked-measure-first.md" index 40d0447..6c8a0fd 100644 --- "a/.backlog/tasks/task-037 - Loop-reliability-redesign-\342\200\224-design-investigation-grounding-audit-parked-measure-first.md" +++ "b/.backlog/tasks/task-037 - Loop-reliability-redesign-\342\200\224-design-investigation-grounding-audit-parked-measure-first.md" @@ -3,10 +3,10 @@ id: TASK-037 title: >- Loop-reliability redesign — design investigation & grounding audit (parked: measure first) -status: To Do +status: Done assignee: [] created_date: '2026-07-08 22:09' -updated_date: '2026-07-08 23:03' +updated_date: '2026-07-08 23:28' labels: - design - orchestrator @@ -91,8 +91,8 @@ Server-side **critic gate** (dev job can't advance until its critics pass) + **` ## Acceptance Criteria - [ ] #1 Empirical failure data collected from a real weak-orchestrator (qwen3.6) run: where the loop actually breaks (job_wait / start_eval / all-must-pass / out-of-band), with transcript or log evidence -- [ ] #2 tenet_report feasibility empirically verified: a real worker/critic subprocess is run and confirmed able (or unable) to call a tenet MCP tool, given the adapter --allowedTools flag vs the project-config permissions written by tenet init -- [ ] #3 Decision recorded on whether the weak orchestrator routes work through the job machine at all (if it works out-of-band via direct edits/subagents, no server-side gate helps — documented and approach adjusted) +- [x] #2 tenet_report feasibility empirically verified: a real worker/critic subprocess is run and confirmed able (or unable) to call a tenet MCP tool, given the adapter --allowedTools flag vs the project-config permissions written by tenet init +- [x] #3 Decision recorded on whether the weak orchestrator routes work through the job machine at all (if it works out-of-band via direct edits/subagents, no server-side gate helps — documented and approach adjusted) - [ ] #4 If proceeding to build: gate placed at BOTH dispatchJob AND startJob (never at continue/getNextRunnableJob), specific to the dev→critic edge (not a blanket pending→running block), with a fail-branch + terminal escape + atomic evaluation - [ ] #5 Strict must-pass reconciled with TASK-036 tiering and a finite retry budget / terminal escape (no infinite loop under the max_retries=-1 default) @@ -105,3 +105,9 @@ created: 2026-07-08 23:03 2026-07-09 resolution: Open Question #1 (sub-agent feasibility) is CONFIRMED — user verified host sub-agents can call tenet MCP tools on all 3 CLIs (Claude Code, Codex, OpenCode). Chose the cheap prompt-only path over the full server-side stack: the 3 execution-loop subagent prohibitions were over-broad (they targeted the mechanism, not the harm — the stated rationale was "bypasses job tracking", and a sub-agent that routes through tenet tools bypasses nothing). Narrowed them to ban untracked work, and added a "Tracked Sub-Agent Delegation (recommended)" subsection to skills/tenet/phases/05-execution-loop.md plus a matching SKILL.md Execution Rule. The full server-side gate / eval_round / tenet_report / watchdog stack remains PARKED — only revisit if the delegation recommendation fails to hold. Acceptance test: run qwen3.6 with the new prompt and confirm the runs that previously did NOT delegate now do. --- + +## Final Summary + + +Investigation concluded; decision = do NOT build the full server-side stack (gate/eval_round/tenet_report/watchdog) - parked as premature. Shipped the cheap prompt-only path instead (tracked sub-agent delegation, v26.7.3; see TASK-012). Feasibility (AC#2) confirmed for all 3 CLIs. Routing (AC#3): qwen routes through the job machine. AC#1 (committed transcript) not collected - anecdotal + user confirmation deemed sufficient for the downscoped decision. ACs #4/#5 N/A (gate not built by decision). + diff --git a/.backlog/tasks/task-038 - tenet-db-cleanup-CLI-command.md b/.backlog/tasks/task-038 - tenet-db-cleanup-CLI-command.md new file mode 100644 index 0000000..97a6c9b --- /dev/null +++ b/.backlog/tasks/task-038 - tenet-db-cleanup-CLI-command.md @@ -0,0 +1,40 @@ +--- +id: TASK-038 +title: tenet db cleanup CLI command +status: To Do +assignee: [] +created_date: '2026-07-09 03:59' +updated_date: '2026-07-18 13:38' +labels: [] +dependencies: [] +priority: high +ordinal: 38000 +--- + +## Description + + +Tenet SQLite DB grows unboundedly (observed ~500MB on a project with local models). Need a CLI command to clean up/compact the DB. Approach TBD — needs discussion on what to prune (old job results? full event history? snapshots?) and whether cleanup helps agent history tracking at all. + + +## Acceptance Criteria + +- [ ] #1 CLI command exists (e.g. tenet db cleanup or tenet db vacuum) +- [ ] #2 Command is safe to run on an active project +- [x] #3 Approach for what to prune is documented and agreed upon +- [ ] #4 DB size reduction is measurable after cleanup + + +## Comments + + +created: 2026-07-18 13:34 +--- +2026-07-18: cleanup approach designed + agreed — documented in docs/planning/19_db_cleanup.md (grounds TASK-038 AC#3). Shape: 'tenet db cleanup', interactive + flag-driven. Manual only (no reaper), no silent default cutoff. Keyed on age + status (NOT run_slug — only 7% of jobs on the real 1.32 GiB DB carry it; agent can't be forced). Non-terminal jobs are immortal (status gate in the delete query). Delete + VACUUM (freelist=0 on the real DB → VACUUM-alone is a no-op). Archive job params/output/error (not events) before delete. First-class 'events-only' option — events are 53% of bulk + lowest value, keeps all verdicts. Cascade job→events + sweep orphans (332 already exist; events.job_id not a FK). Refuse while live. Resolve in-use DB path, don't hardcode .tenet/.state/ (audit found the 1.3 GiB DB at repo root — separate path bug to file). Forensic audit of the live DB drove two design flips: (1) VACUUM-alone reclaims nothing, (2) bloat is recent — 30d retention frees ~1%, only 7-15d moves the needle, so the menu must show per-cutoff reclaim. Detailed ACs + non-goals + sequence in the doc. Implementation ACs (#1/#2/#4) remain open. +--- + +created: 2026-07-18 13:38 +--- +Correction (same day): the 'audit found the DB at repo root / separate path bug to file' note in the prior comment was wrong — that tenet.db was a copy I placed in the working dir for inspection. The expected + only location is .tenet/.state/tenet.db (resolved via the project path / StateStore like the rest of tenet). Doc updated: decision #10, AC9, and the audit note revised; the false 'path anomaly' side-finding removed. No path-resolution bug to file. +--- + diff --git a/.backlog/tasks/task-039 - Root-cause-focus-across-tenet-prompts.md b/.backlog/tasks/task-039 - Root-cause-focus-across-tenet-prompts.md new file mode 100644 index 0000000..b9c4cfb --- /dev/null +++ b/.backlog/tasks/task-039 - Root-cause-focus-across-tenet-prompts.md @@ -0,0 +1,31 @@ +--- +id: TASK-039 +title: Root-cause focus across tenet prompts +status: To Do +assignee: [] +created_date: '2026-07-11 11:11' +labels: + - prompts + - quality + - architecture +dependencies: [] +priority: high +ordinal: 39000 +--- + +## Description + + +Tenet currently tends to prioritize immediate band-aid fixes over root-cause or architectural correctness. This task adds guidance across interview, spec, decomposition, critics, and readiness validation prompts to bias the agent toward identifying and fixing root causes rather than applying surface-level patches. + + +## Acceptance Criteria + +- [ ] #1 Interview phase prompts include a 'Root Cause' question category to probe underlying causes before solutioning +- [ ] #2 Spec phase requires a 'Root Cause Analysis' section that documents the true source of the problem +- [ ] #3 Decomposition phase includes root-cause verification as a job dependency check +- [ ] #4 Readiness rubric (tenet-validate-readiness.ts) scores 'Root Cause Identification' as a readiness category +- [ ] #5 Clarity rubric (tenet-validate-clarity.ts) includes root-cause clarity as a scoring dimension +- [ ] #6 Evaluation phase critics check whether the implementation addresses root cause, not just symptoms +- [ ] #7 Existing tests pass after all changes + diff --git "a/.backlog/tasks/task-040 - Fix-job_wait-default-timeout-\342\200\224-agent-spins-when-wait_seconds-is-omitted.md" "b/.backlog/tasks/task-040 - Fix-job_wait-default-timeout-\342\200\224-agent-spins-when-wait_seconds-is-omitted.md" new file mode 100644 index 0000000..9dfad99 --- /dev/null +++ "b/.backlog/tasks/task-040 - Fix-job_wait-default-timeout-\342\200\224-agent-spins-when-wait_seconds-is-omitted.md" @@ -0,0 +1,26 @@ +--- +id: TASK-040 +title: Fix job_wait default timeout — agent spins when wait_seconds is omitted +status: To Do +assignee: [] +created_date: '2026-07-13 04:00' +labels: + - orchestrator + - agent + - bug +dependencies: [] +ordinal: 40000 +--- + +## Description + + +When an agent calls job_wait without passing wait_seconds, the tool returns immediately instead of waiting. The agent then incorrectly believes it waited ~60s and keeps retrying in a tight loop. This is a tool-calling discipline issue: job_wait should either have a sensible default timeout (e.g. 30s) or the agent should be instructed to always pass wait_seconds. Observed behavior: agent says 'let's wait 60s' but job_wait returns instantly, agent retries immediately. + + +## Acceptance Criteria + +- [ ] #1 job_wait with no wait_seconds defaults to a reasonable timeout (e.g. 30s) instead of returning instantly +- [ ] #2 Or alternatively, agent instructions are updated to always pass wait_seconds explicitly +- [ ] #3 No tight retry loop when wait_seconds is omitted + diff --git a/.backlog/tasks/task-041 - Study-and-adopt-patterns-from-Claude-Code-dynamic-workflow-harness.md b/.backlog/tasks/task-041 - Study-and-adopt-patterns-from-Claude-Code-dynamic-workflow-harness.md new file mode 100644 index 0000000..1c4789e --- /dev/null +++ b/.backlog/tasks/task-041 - Study-and-adopt-patterns-from-Claude-Code-dynamic-workflow-harness.md @@ -0,0 +1,77 @@ +--- +id: TASK-041 +title: Study and adopt patterns from Claude Code dynamic workflow harness +status: To Do +assignee: [] +created_date: '2026-07-13 23:45' +updated_date: '2026-07-14 00:16' +labels: + - research + - architecture + - orchestrator +dependencies: [] +references: + - >- + https://claude.com/blog/a-harness-for-every-task-dynamic-workflows-in-claude-code + - 'https://code.claude.com/docs/en/workflows' +ordinal: 41000 +--- +## Description + + +Claude Code's dynamic workflows let it write a custom JavaScript harness on the fly — spawning subagents, fanning out work, adversarially verifying results, and synthesizing output. Tenet should study and adopt relevant patterns. + +Key insight: The dynamic workflow is just a JavaScript file with simple primitives — agent() and pipeline() — that Claude writes on the fly. This code structure IS what makes dynamic workflow possible. It's not a complex framework; it's a thin runtime that executes a generated script. + +Example structure from Claude Code docs: +``` +export const meta = { + name: 'audit-routes', + description: 'Audit every route handler for missing auth checks', +} + +const found = await agent('List every .ts file under src/routes/.', { + schema: { type: 'object', required: ['files'], properties: { files: { type: 'array', items: { type: 'string' } } } }, +}) + +const audits = await pipeline(found.files, file => + agent(`Audit ${file} for missing authentication checks.`, { label: file }), +) + +return audits.filter(Boolean) +``` + +Key patterns from the article + docs: +- Fan-out-and-synthesize: split work into parallel agents, merge results +- Adversarial verification: each spawned agent gets a separate verifier agent +- Tournament: N agents compete on same task, judge picks winner +- Loop-until-done: keep spawning agents until stop condition met (no new findings, no more errors) +- Classify-and-act: classifier routes to different agent behavior +- Generate-and-filter: generate ideas, filter by rubric +- Workflows are JS scripts with agent() and pipeline() primitives — resumable, saveable, shareable +- Subagents run in isolated worktrees, can use different models +- Runtime caps: 16 concurrent agents, 1000 total per run +- Progress view shows per-agent token usage, status, results +- /deep-research is a bundled workflow example (fan-out search, cross-check, synthesize) + +What Tenet can adopt: +- Dynamic harness generation (Tenet currently uses static decomposition DAG) +- Adversarial verification as a first-class pattern (not just post-hoc critics) +- Fan-out for parallel exploration (e.g., multiple hypotheses for root-cause) +- Tournament pattern for qualitative ranking/sorting +- Loop-until-done for flaky test detection, bug sweeps +- Per-agent model routing (cheap model for simple tasks, expensive for complex) +- Resumable runs with cached intermediate results +- The agent()/pipeline() primitive pattern — a thin runtime executing a generated script, not a heavy framework + +Related articles: +- https://claude.com/blog/a-harness-for-every-task-dynamic-workflows-in-claude-code +- https://code.claude.com/docs/en/workflows + + +## Acceptance Criteria + +- [ ] #1 Document the key patterns from Claude Code dynamic workflows +- [ ] #2 Identify which patterns apply to Tenet's architecture +- [ ] #3 Propose concrete adoption plan for at least 2 patterns + diff --git "a/.backlog/tasks/task-042 - Add-version-mismatch-detection-on-boot-\342\200\224-warn-when-tenet-binary-version-differs-from-project-skill-docs.md" "b/.backlog/tasks/task-042 - Add-version-mismatch-detection-on-boot-\342\200\224-warn-when-tenet-binary-version-differs-from-project-skill-docs.md" new file mode 100644 index 0000000..18b954e --- /dev/null +++ "b/.backlog/tasks/task-042 - Add-version-mismatch-detection-on-boot-\342\200\224-warn-when-tenet-binary-version-differs-from-project-skill-docs.md" @@ -0,0 +1,37 @@ +--- +id: TASK-042 +title: >- + Add version mismatch detection on boot — warn when tenet binary version + differs from project skill docs +status: To Do +assignee: [] +created_date: '2026-07-14 01:05' +updated_date: '2026-07-14 04:34' +labels: + - boot + - reliability + - ux +dependencies: [] +ordinal: 42000 +--- +## Description + + +When a user updates the tenet npm package (e.g. v26.7.3) but the project hasn't run 'tenet init --upgrade' (still on v26.7.1), the MCP tools load the new binary while skill documents/AGENTS.md/CLAUDE.md reference the old version. This mismatch can break tenet behavior in subtle ways. + +Scenario: +- tenet npm updated to v26.7.3 → binary is v26.7.3 +- Project .tenet/ skill docs still at v26.7.1 (no 'tenet init --upgrade' run) +- Coding agent loads v26.7.3 MCP tools but follows v26.7.1 skill instructions +- Mismatch may cause tool call failures, missing features, or behavioral drift + +Fix: On boot sequence (when tenet init runs or when the orchestrator starts), compare the binary version against the version recorded in project docs. If mismatch, warn the user and suggest running 'tenet init --upgrade'. + + +## Acceptance Criteria + +- [ ] #1 Boot sequence compares tenet binary version against project doc version +- [ ] #2 Warning message is shown when versions mismatch +- [ ] #3 Suggests running 'tenet init --upgrade' to sync +- [ ] #4 Does not block execution — warning only + diff --git "a/.backlog/tasks/task-043 - Add-coding-agent-selection-during-tenet-install-\342\200\224-let-user-choose-which-agents-to-install.md" "b/.backlog/tasks/task-043 - Add-coding-agent-selection-during-tenet-install-\342\200\224-let-user-choose-which-agents-to-install.md" new file mode 100644 index 0000000..a2fb5d7 --- /dev/null +++ "b/.backlog/tasks/task-043 - Add-coding-agent-selection-during-tenet-install-\342\200\224-let-user-choose-which-agents-to-install.md" @@ -0,0 +1,41 @@ +--- +id: TASK-043 +title: >- + Add coding agent selection during tenet install — let user choose which agents + to install +status: To Do +assignee: [] +created_date: '2026-07-14 01:12' +updated_date: '2026-07-14 01:13' +labels: + - install + - ux + - design +dependencies: [] +ordinal: 43000 +--- +## Description + + +Currently tenet installs all supported coding agent adapters (opencode, claude code, etc.). As more adapters get added (e.g. cursor via PR), installing unused agents becomes redundant. + +Proposal: +- Fresh install: prompt user with a selection UI to pick which coding agents to install +- Re-run with --agents: re-open the selection UI to add/change agent support +- Re-run with --agents --upgrade: select agents AND upgrade project docs + +Open questions (captured for later design): +- Should removing an agent be possible? That adds complexity. +- How does --agents interact with --upgrade? +- What about headless/CI installs where interactive selection isn't possible? (maybe --all flag) + +Reference: community PR adding cursor adapter sparked this. + + +## Acceptance Criteria + +- [ ] #1 Fresh install shows interactive agent selection +- [ ] #2 --agents flag re-opens selection UI +- [ ] #3 Non-interactive mode (CI) installs all agents by default or respects a flag +- [ ] #4 Existing installs are not broken by this change + diff --git a/.backlog/tasks/task-044 - Add-tmux-mode-for-visibility-into-agent-activity.md b/.backlog/tasks/task-044 - Add-tmux-mode-for-visibility-into-agent-activity.md new file mode 100644 index 0000000..eb3b64e --- /dev/null +++ b/.backlog/tasks/task-044 - Add-tmux-mode-for-visibility-into-agent-activity.md @@ -0,0 +1,34 @@ +--- +id: TASK-044 +title: Add tmux mode for visibility into agent activity +status: To Do +assignee: [] +created_date: '2026-07-14 02:19' +updated_date: '2026-07-14 04:34' +labels: + - ux + - observability + - design +dependencies: [] +ordinal: 44000 +--- +## Description + + +Default: disabled. When enabled, dispatching worker/critic agents opens separate tmux panes/sessions so the user can see what each agent is doing in real time. + +Design challenge: How to launch a coding agent non-interactively and have it exit cleanly after finishing? The agent needs to run headless (no stdio interactive loop), do its work, and terminate. If this isn't possible, tmux mode may need a different approach — e.g. streaming logs to a visible pane instead of running the full agent TUI. + +Open questions: +- Can coding agents (opencode, claude code) run in non-interactive/headless mode? +- If not, maybe tmux mode shows live logs/stdio instead of full agent TUI? +- How does this interact with MCP tool-based job dispatch (stdio vs session-based)? + + +## Acceptance Criteria + +- [ ] #1 Tmux mode is opt-in (default disabled) +- [ ] #2 When enabled, user can see agent activity in separate panes +- [ ] #3 Agent exits cleanly after job completion +- [ ] #4 Does not break existing non-tmux workflow + diff --git "a/.backlog/tasks/task-045 - Critic-designer-should-adapt-to-executioner-model-tier-\342\200\224-split-critics-for-local-models.md" "b/.backlog/tasks/task-045 - Critic-designer-should-adapt-to-executioner-model-tier-\342\200\224-split-critics-for-local-models.md" new file mode 100644 index 0000000..309f5c4 --- /dev/null +++ "b/.backlog/tasks/task-045 - Critic-designer-should-adapt-to-executioner-model-tier-\342\200\224-split-critics-for-local-models.md" @@ -0,0 +1,41 @@ +--- +id: TASK-045 +title: >- + Critic designer should adapt to executioner model tier — split critics for + local models +status: To Do +assignee: [] +created_date: '2026-07-14 04:15' +updated_date: '2026-07-14 04:33' +labels: + - critics + - local-models + - design +dependencies: [] +ordinal: 45000 +--- +## Description + + +The custom critic designer document should be aware of the executioner model tier. When the model is a local/smaller model, critics should be: +- Simpler roles (not diminished quality, but narrower scope) +- Split into more granular sub-critics rather than one big critic +- A local model handling a single focused check is more reliable than one big critic trying to do everything + +This means the critic designer should produce different critic configurations depending on model capability: +- Frontier model: fewer, broader critics (can handle complex multi-faceted checks) +- Local model: more, narrower critics (each does one simple thing well) + +**Backstory / why this matters:** +A local model orchestrator was getting frustrated by too many retries (expected in tenet loop). But the root cause was different: the local model often fails at tool calls. The naive fix would be to simplify critic prompts to make them pass more easily — but that's wrong. Simplifying the prompt for a local model means simplifying the *critic itself*, which defeats the purpose. The correct approach is to keep critic quality but split into smaller, focused checks that a local model can reliably execute. Each sub-critic does one thing well, so the model doesn't need to juggle multiple concerns in one tool call. + +Related to TASK-007 (dynamic critics per job), TASK-036 (critic tiers), TASK-004 (critic model selection). + + +## Acceptance Criteria + +- [ ] #1 Critic designer document includes model tier awareness +- [ ] #2 Local model critics are split into smaller, focused checks +- [ ] #3 Frontier model critics can be broader and fewer +- [ ] #4 Quality of checking is maintained regardless of model tier + diff --git "a/.backlog/tasks/task-046 - Research-paper-Whats-Your-Agents-GPA-\342\200\224-supports-Tenet-multi-critic-approach.md" "b/.backlog/tasks/task-046 - Research-paper-Whats-Your-Agents-GPA-\342\200\224-supports-Tenet-multi-critic-approach.md" new file mode 100644 index 0000000..4f42afb --- /dev/null +++ "b/.backlog/tasks/task-046 - Research-paper-Whats-Your-Agents-GPA-\342\200\224-supports-Tenet-multi-critic-approach.md" @@ -0,0 +1,33 @@ +--- +id: TASK-046 +title: >- + Research paper: What's Your Agent's GPA? — supports Tenet multi-critic + approach +status: To Do +assignee: [] +created_date: '2026-07-15 01:21' +labels: + - research + - tenet + - reference +dependencies: [] +ordinal: 46000 +--- + +## Description + + +Research paper "What Is Your Agent's GPA? A Framework for Evaluating Agent Goal-Plan-Action Alignment" (arXiv:2510.08847, Oct 2025) by Jia et al. from Carnegie Mellon / TruEra. + +Key findings relevant to Tenet: +- Uses 6 specialized LLM judges (Logical Consistency, Execution Efficiency, Plan Adherence, Plan Quality, Tool Selection, Tool Calling) instead of a single monolithic judge +- Single monolithic LLM judge is fragile — TRAIL benchmark shows even strongest LLM achieves only 11% accuracy as a single judge +- "No single judge is universally optimal" +- "No single judge is reliable across all conditions" +- "Specialized judges provide more reliable and interpretable assessments than monolithic evaluators" +- Strong inter-rater agreement (Krippendorff's alpha > 0.7 for most metrics) +- 5 independent runs per judge for consistency + +This directly supports Tenet's multi-critic evaluation architecture. Worth reading the full paper for deeper methodology alignment. + + diff --git "a/.backlog/tasks/task-047 - Hybrid-critic-dispatch-mode-\342\200\224-parallel-then-sequential.md" "b/.backlog/tasks/task-047 - Hybrid-critic-dispatch-mode-\342\200\224-parallel-then-sequential.md" new file mode 100644 index 0000000..f0cfe63 --- /dev/null +++ "b/.backlog/tasks/task-047 - Hybrid-critic-dispatch-mode-\342\200\224-parallel-then-sequential.md" @@ -0,0 +1,24 @@ +--- +id: TASK-047 +title: Hybrid critic dispatch mode — parallel then sequential +status: To Do +assignee: [] +created_date: '2026-07-15 08:02' +labels: + - tenet + - enhancement +dependencies: [] +ordinal: 47000 +--- + +## Description + + +Currently critics run either fully sequential or fully parallel. Add a hybrid mode where critics marked as parallel_safe run concurrently first, then remaining sequential critics run after. This saves wall-clock time when a mix of independent and dependent critics exist. + +Key design questions: +- How to mark a critic as parallel_safe vs sequential? Config-level flag? Per-critic metadata? +- What about critics that are parallel_safe but share a resource (DB, rate-limited API)? +- Should the orchestrator auto-detect parallelism (no shared resources) or require explicit marking? +- How does this interact with eval_parallel_safe:{feature} config key? + From aefea6efe539e9513e87fe01069132027c716c16 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Mon, 20 Jul 2026 07:28:28 +0900 Subject: [PATCH 52/87] feat(db): add `tenet db cleanup` to reclaim SQLite bloat (#17) Interactive + flag-driven command that deletes old finished jobs / activity logs and compacts tenet.db. Keyed on age + status (never run_slug); the status gate is enforced in the DELETE WHERE, so non-terminal jobs are immortal. Archives pruned job rows (params/output/error) to .tenet/archive/cleanup-.jsonl before delete; events are not archived. - state-store.ts: getCleanupPreview (read-only reclaim curve) + pruneCleanup (archive stream -> txn delete -> cascade + orphan sweep -> checkpoint -> VACUUM -> checkpoint). VACUUM writes through the WAL, so a post-VACUUM checkpoint(TRUNCATE) is required for the main file to shrink immediately. - db-cleanup.ts: unconditional warning, adaptive preview renderer, no-op- dropping menu, interactive (TTY) + flag-driven paths. - index.ts: register under the `tenet db` group. No live-detection (design #9): SQLite WAL lets the separate cleanup process delete + VACUUM while the server is open (verified); the status gate is the real safety net, plus an unconditional heads-up warning (design #12). Validated on a copy of a real 1.32 GiB DB: --keep-days 7 shrank it to 667 MiB, archived 3,253 records; all 76 in-progress jobs survived. Closes TASK-038. Design: docs/planning/19_db_cleanup.md. Co-authored-by: Claude --- ...task-038 - tenet-db-cleanup-CLI-command.md | 25 +- docs/planning/19_db_cleanup.md | 255 +++++++++++ src/cli/db-cleanup.test.ts | 376 ++++++++++++++++ src/cli/db-cleanup.ts | 414 ++++++++++++++++++ src/cli/db.ts | 9 +- src/cli/index.ts | 50 +++ src/core/state-store.ts | 280 ++++++++++++ 7 files changed, 1401 insertions(+), 8 deletions(-) create mode 100644 docs/planning/19_db_cleanup.md create mode 100644 src/cli/db-cleanup.test.ts create mode 100644 src/cli/db-cleanup.ts diff --git a/.backlog/tasks/task-038 - tenet-db-cleanup-CLI-command.md b/.backlog/tasks/task-038 - tenet-db-cleanup-CLI-command.md index 97a6c9b..16b122a 100644 --- a/.backlog/tasks/task-038 - tenet-db-cleanup-CLI-command.md +++ b/.backlog/tasks/task-038 - tenet-db-cleanup-CLI-command.md @@ -1,10 +1,10 @@ --- id: TASK-038 title: tenet db cleanup CLI command -status: To Do +status: In Progress assignee: [] created_date: '2026-07-09 03:59' -updated_date: '2026-07-18 13:38' +updated_date: '2026-07-18 14:50' labels: [] dependencies: [] priority: high @@ -19,10 +19,10 @@ Tenet SQLite DB grows unboundedly (observed ~500MB on a project with local model ## Acceptance Criteria -- [ ] #1 CLI command exists (e.g. tenet db cleanup or tenet db vacuum) -- [ ] #2 Command is safe to run on an active project +- [x] #1 CLI command exists (e.g. tenet db cleanup or tenet db vacuum) +- [x] #2 Command is safe to run on an active project - [x] #3 Approach for what to prune is documented and agreed upon -- [ ] #4 DB size reduction is measurable after cleanup +- [x] #4 DB size reduction is measurable after cleanup ## Comments @@ -37,4 +37,19 @@ created: 2026-07-18 13:38 --- Correction (same day): the 'audit found the DB at repo root / separate path bug to file' note in the prior comment was wrong — that tenet.db was a copy I placed in the working dir for inspection. The expected + only location is .tenet/.state/tenet.db (resolved via the project path / StateStore like the rest of tenet). Doc updated: decision #10, AC9, and the audit note revised; the false 'path anomaly' side-finding removed. No path-resolution bug to file. --- + +created: 2026-07-18 14:11 +--- +Implementation plan grounded (2026-07-18): doc 19 now has an 'Implementation plan' section — 4 components with file:line anchors, an ordered 7-step build sequence, and implementation decisions A/B/C. A (live-detection, elaborates design decision #9): pid-file is the signal — tenet serve writes .tenet/.state/server.pid (index.ts:194 in startBackgroundServer:180); status.ts:107-112 reads it via readPid/isProcessAlive; --force mirrors restoreDatabase (state-store.ts:332-353). Gap noted: the host-agent stdio path doesn't write the pid, so pid-presence is sufficient-not-necessary and the status gate (non-terminal jobs immortal) is the real guarantee; SQLITE_BUSY from VACUUM handled as an explicit error path. B: reclaim is a LENGTH()-sum '~' estimate, exact in rank-order so no-op cutoffs drop correctly. C: new src/cli/db-cleanup.ts (separate from db.ts). Build starts at StateStore read methods. +--- + +created: 2026-07-18 14:21 +--- +Design reversal (2026-07-18): dropped 'refuse while live' (design decision #9 + impl decision A). Reason: the stdio-MCP server is always live and writes no pid (only 'tenet serve' writes server.pid, index.ts:194), and -wal/-shm sidecars are an unreliable live signal (present under light load, absent after autocheckpoint, lingering after crash). Empirical two-process WAL probe (better-sqlite3, faithful to tenet): DELETE + VACUUM shrank the file 20MB->7.5MB with an open server-stand-in connection -- idle AND mid-transaction, no SQLITE_BUSY. So cleanup runs while the agent is open; the status gate (non-terminal jobs immortal) is the only correctness net. Kept for robustness only: busy_timeout + graceful SQLITE_BUSY retry/report. No pid file, no sidecar check, no --force. Doc updated: TL;DR, decision #9, AC8, impl decision A, references. +--- + +created: 2026-07-18 14:50 +--- +Implemented on branch feat/db-cleanup (2026-07-18). tenet db cleanup shipped: StateStore.getCleanupPreview (read-only reclaim curve) + pruneCleanup (archive stream -> txn delete w/ status gate -> cascade -> orphan sweep -> checkpoint(TRUNCATE) -> VACUUM -> checkpoint(TRUNCATE)) in state-store.ts; src/cli/db-cleanup.ts (unconditional warning, adaptive render, no-op-dropping menu, interactive + flag-driven); wired into the db group in index.ts. Unconditional warning per design decision #12 (no live-detection). Gate: typecheck + lint + 267 tests pass (15 new in db-cleanup.test.ts covering status gate, cascade+orphan, archive shape, dry-run no-op, age-banding, VACUUM shrink, adaptive render/menu). Validated on a COPY of the real 1.32 GiB DB: --keep-days 7 removed 3,253 finished jobs + 23,854 events + 332 orphans, archived 3,253 records (346 MB), shrank tenet.db 1.32 GiB -> 667 MiB; all 76 in-progress jobs survived. Implementation note (design decision D): VACUUM writes through the WAL, so a post-VACUUM checkpoint(TRUNCATE) is required for the main file to shrink immediately. +--- diff --git a/docs/planning/19_db_cleanup.md b/docs/planning/19_db_cleanup.md new file mode 100644 index 0000000..e9df731 --- /dev/null +++ b/docs/planning/19_db_cleanup.md @@ -0,0 +1,255 @@ +# 19 — Tenet DB Cleanup + +**Created**: 2026-07-18 +**Status**: Implemented (TASK-038) +**Origin**: Design discussion 2026-07-18 (TASK-038), grounded by a read-only forensic audit of a live **1.32 GiB** project `tenet.db`. Supersedes the ad-hoc discussion; this file is the single source of truth for the cleanup approach. + +--- + +## TL;DR + +`tenet db cleanup` — an **interactive, user-driven** command that reclaims SQLite bloat. Keyed on **age + status** (engine-set, reliable), **never run-keyed**. Deletes rows then **VACUUMs** (VACUUM-alone is a no-op on real data). **Non-terminal jobs are immortal** — cleanup cannot touch active/resumable work. Archives **job records** (not events) before delete. No background reaper, **no silent default cutoff** — the user picks a cutoff with the reclaim shown. **Concurrency-safe — no live-detection:** cleanup runs while the agent's server is open (SQLite WAL lets a separate cleanup process delete + VACUUM concurrently; verified 2026-07-18 on a faithful two-process probe); the status gate is the only correctness net. + +| Lever | Decision | +|---|---| +| Trigger | Manual command only — nothing auto-deletes | +| Key | Age + status (not `run_slug`) | +| Shrink mechanism | Delete + VACUUM (freelist is 0 on real DBs) | +| Safety gate | Non-terminal jobs never prunable, regardless of age | +| Archive | Job `params`/`output`/`error` only; events are noise | +| Headline option | "Trim logs only" — biggest chunk, lowest value, keeps all verdicts | + +--- + +## Problem statement + +The Tenet SQLite store (`.tenet/.state/tenet.db`) grows unboundedly. A live project running qwen workers reached **1.32 GiB**. There is no reclaim path today beyond `tenet db snapshot` (which copies the whole thing). TASK-038 asks for a cleanup command — but *what to prune* and *how to do it safely* needed both a design and real data before building. + +--- + +## The key reframe — what the DB is actually for + +The DB is **not** long-term memory. Verified facts (file:line): + +1. **Run-to-run memory lives in the file layer, not the DB.** The schema is four tables — `jobs`, `events`, `steer_messages`, `config` — with **no knowledge table** (`state-store.ts:862`). `tenet_update_knowledge` writes to `.tenet/knowledge/` *files* (`tenet-update-knowledge.ts:80-81,118`); journals go to the run dir. Doctrine lives in `.tenet/project/**`. A new run consults files, not prior DB rows. +2. **The loop only reads non-terminal jobs** + the active run's evals (`getEvalsForSource`) + retry context. Every `getJob()` read is by a specific id from the active run — no tool scans terminal history to drive a new run. +3. **The only cross-run DB reads are non-load-bearing:** status display (capped at `QUEUE_CAP = 100`), `findLatestE2eStatus` (a soft last-known-status signal), and the `all_done` count. +4. **`all_done = completedCount === totalCount` is unscoped** (`getTotalCount` = `SELECT COUNT(*) FROM jobs`, all runs; `state-store.ts:597`). So a prior run's rows affect the current run's completion flag — but this is a **wart, not a feature** (a stale failed job from run N can block `all_done` in run N+1; see TASK-037). Pruning prior runs incidentally *fixes* this for each fresh run. + +**Conclusion:** the DB's run-driving value is **within-run** + crash-recovery of the immediately-prior running state. Terminal history is archaeology — safe to reclaim once archived. + +--- + +## Why not run-keyed + +A run identifier (`run_slug`) is tracked only **indirectly** — spread into each job's `params` JSON at registration (`tenet-register-jobs.ts:109-110`), with no column and no index. Worse: + +- **Forensic:** only **7%** of jobs on the real 1.32 GiB DB carry `run_slug`. A run-keyed UI would silently miss 93%. +- **Structural:** the orchestrator is unsandboxed, so it cannot be *forced* to set `run_slug` every time — same root failure mode as TASK-033/035/037 (weak model drops a prompt-level expectation). Critic/child jobs often lack it and inherit via an ancestry walk (`getRunPath`, `tenet-update-knowledge.ts:21-40`). + +Reliable axes are **engine-set**, not agent-set: `status`, `created_at`/`completed_at`, and the dispatch links `parent_job_id` / `source_job_id`. Cleanup keys on those. + +--- + +## Forensic findings — the real 1.32 GiB DB + +Read-only audit (2026-07-18) of a live project DB. These numbers shape the design. + +**File:** 1.32 GiB, `page_size=4096`, `page_count=346430`, **`freelist_count=0`** → the file is ~99% live row/blob data. **VACUUM-alone reclaims zero bytes.** + +**Where the bytes are:** + +| Table / column | Bytes | % of live | +|---|---|---| +| `events.data` (transition logs) | 746 MB | 53% | +| `jobs.output` (verdicts/results) | 622 MB | 44% | +| `jobs.params` (prompts) | 35 MB | 3% | +| `steer_messages` / `config` | negligible | <0.1% | + +By job type (params+output): `critic_eval` 304 MB / 1,935 rows, `interaction_e2e` 152 MB / 1,113, `eval` 110 MB / 1,075, `dev` 82 MB / 504 (largest avg, 199 KB/job), `integration_test` 8 MB / 132. + +**Prunable vs protected:** 4,687 terminal (98.5%) vs **76 non-terminal protected** (75 pending + 1 running). The protected set is tiny and cheap to gate by status. + +**Two findings that flip the naive design:** + +1. **VACUUM-alone is a no-op** (freelist 0). The command must be delete + VACUUM, and must *tell the user* there is no free shrink. +2. **The bloat is recent, not old.** ~98% of bytes are from the **last 30 days**; everything past 30 d is ~18 MB of rounding error (the DB is 80 days old). This **kills the "safe 30-day default"** — a 30-day retention frees ~1%. Only 7–15 d retention moves the needle: + + | Keep last | Frees | % of DB | + |---|---|---| + | 7 d | ~707 MB | ~50% | + | 15 d | ~482 MB | ~34% | + | 30 d | ~18 MB | ~1% | + | 90 d | ~0 | 0% | + + → the menu shows per-cutoff reclaim (computed), so the user reads the shape directly — no hard-coded narrative. + +**Other:** not retry-driven (avg `retry_count` 0.15, only 6.2% of jobs ever retried). **332 orphaned events** already exist (`events.job_id` is not a FK) — evidence a partial prune happened before; cleanup must cascade and sweep. + +> 📌 **Audit setup:** the audited DB was a copy of a real project's `.tenet/.state/tenet.db` placed in the working directory for inspection. The expected live location is `.tenet/.state/tenet.db` (resolved via the project path, same as the rest of tenet) — no path anomaly. + +--- + +## Design + +### Command + +`tenet db cleanup` — slots into the existing `tenet db` group (`check` / `backup` / `snapshot` / `restore-snapshot`). Interactive when a TTY is present; flag-driven otherwise (`--keep-days`, `--before `, `--mode {all|events-only}`, `--dry-run`, `--yes`, `--no-archive`). + +### Flow + +1. **Scan** the in-use DB (read-only aggregates). +2. **Show the lay of the land** — total size; freelist-reclaimable-without-delete (sets expectation: "VACUUM alone reclaims nothing"); per-category bytes; **protected** (non-terminal) count; **orphan** event count (swept silently). +3. **Show per-cutoff reclaim** — computed per cutoff; the table *is* the guidance (no hard-coded narrative line, which would be false on a differently-shaped DB). +4. **Menu** — pick a cutoff / mode (see mockup below). +5. **Dry-run confirm** — exact counts + bytes to reclaim; yes/no (default). +6. **Archive** prunable job records → **delete** (terminal jobs + cascaded events + orphan sweep) → **VACUUM**. +7. **Report** before/after file size. + +### Interface mockup (real numbers) + +All figures are **computed at runtime** from the actual DB, so the output is correct for any shape (1 MB, 10 GB, day-old or year-old). Numbers below are from the 1.32 GiB audit. + +``` +$ tenet db cleanup + +⚠ tenet db cleanup deletes old finished jobs and compacts the database. + It is safe to run any time (in-progress jobs are never touched), but for a + large DB prefer running it between autonomous runs, not during one. + +tenet.db is 1.32 GiB — almost entirely real data, so there's no quick +"compact"; shrinking it means deleting old work. Two things you can count on: + • In-progress work (76 jobs still running or queued) is never touched. + • Finished jobs we remove are archived to .tenet/archive/ first, so your + results and critic verdicts stay recoverable. + +What's taking up the space: + Activity logs — the step-by-step history of each job .... 746 MB 53% + Results & reviews — job outputs and critic verdicts ...... 622 MB 44% + Prompts sent to workers .................................. 35 MB 3% + +What would you like to do? + + Remove old finished work (finished = completed, failed, or cancelled) + [1] keep the last 7 days → removes 3,264 jobs, frees ~707 MB + [2] keep the last 15 days → removes 2,583 jobs, frees ~482 MB + [3] keep the last 30 days → removes 1,511 jobs, frees ~18 MB + [4] keep everything since a specific date… + + Trim logs only — keep all your results and finished jobs; just drop + old activity logs (these are just history, not archived) + [5] drop logs older than 7 days → frees ~382 MB, all results kept + [6] drop logs older than 15 days → frees ~263 MB, all results kept + + Reset + [7] remove ALL finished work → frees ~1.3 GB + (in-progress kept; everything else archived) + + [8] dry-run — show exactly what would happen, change nothing + [0] cancel +``` + +### The events-only lever + +`events` is the single biggest table **and** the lowest-value data (transition logs vs. verdicts in `jobs.output`). "Drop old events, keep every job row and its output" reclaims ~382 MB at a 7 d cutoff while **preserving the entire analysis record**. This is the move when the user wants space but not to lose verdict data — and it directly feeds the run-transcript evidence TASK-037 said was missing. + +--- + +## Decisions and rationale (do not re-litigate without new evidence) + +1. **Cleanup is manual, never automatic.** No background reaper, no timer. "Forgot for a month" cannot cause loss — nothing deletes itself; the user runs the command deliberately. +2. **No silent default cutoff.** The menu forces a deliberate choice with reclaim shown. A 30 d default would be cosmetic and misleading on a DB shaped like the audited one. +3. **Status gate: non-terminal jobs are immortal.** Cleanup physically cannot touch `pending`/`running`/`blocked`/`blocked_on_finding`, regardless of age — enforced in the delete query (`WHERE status IN ('completed','failed','cancelled')`), not just hidden in the UI. This is the guarantee that a resumable in-flight run survives. +4. **Age-keyed, not run-keyed.** `run_slug` is unreliable on real data (7%) and the agent can't be forced to set it. Age + status are engine-set. +5. **Delete + VACUUM, not VACUUM-only.** freelist is 0 on real DBs; VACUUM-only would be a no-op sold as cleanup. +6. **Archive job records, not events.** `output` holds verdicts (analytically valuable + undo-worthy); `events` are transition logs (noise + 53% of bulk — archiving them would double the bulk into a file). Archive path `.tenet/archive/cleanup-.jsonl`, one record per pruned job with `params`/`output`/`error`/`type`/`status`/timestamps/retry_count plus its `parent_job_id`/`source_job_id`/`run_slug`-if-present — relationships embedded so grouping is deferred to analysis time, no run-keying needed at archive time. +7. **"Trim logs only" (events-only) is a first-class menu option.** Biggest single lever; uniquely preserves verdicts. +8. **Cascade + sweep.** Deleting a job deletes its events by `job_id`; existing orphan events are swept. (`events.job_id` is not a FK; 332 orphans already exist on the audited DB.) +9. **No live-detection — cleanup is concurrency-safe.** (Reversed 2026-07-18 after empirical testing; originally "refuse while live".) The stdio-MCP server is always live and writes no pid (`server.pid` only comes from `tenet serve`'s `startBackgroundServer`, `index.ts:194`), and `-wal`/`-shm` sidecars are an unreliable signal — present under light load, absent after an autocheckpoint on heavy writes, lingering after a crash. Verified instead on a faithful two-process WAL probe (better-sqlite3: a separate cleanup process + an open server-stand-in connection): `DELETE` + `VACUUM` shrinks the file with the connection open — idle *or* mid-transaction (20 MB → 7.5 MB, no `SQLITE_BUSY`). The status gate (#3) is the real guarantee; a `busy_timeout` on the cleanup connection absorbs momentary write contention. Running cleanup while the agent is open is supported. +10. **Operate on `.tenet/.state/tenet.db` via the existing path resolution** (project path → StateStore). No special path logic; the audit DB was a copy placed in the working dir for inspection. +11. **Output is generated, not templated.** Every figure shown is computed at runtime from the actual DB, so the message is correct for any DB shape (1 MB, 10 GB, day-old, year-old). No hard-coded narrative line — the reclaim table is the guidance. No-op options (a cutoff that frees ~0) are dropped from the menu rather than shown as zeros. +12. **Unconditional warning, no live-detection gate.** Every invocation prints a one-line heads-up that cleanup deletes data and compacts, and to prefer running it between autonomous runs. Unconditional (not triggered by a heuristic) — for stdio users the server is always live, so a conditional / "refuse while live" gate would either always fire or lean on an unreliable signal (see #9). The status gate (#3) is the real safety guarantee; the warning is plain user guidance. + +--- + +## Gap review (seams, assumptions, deferred) + +1. **Archive growth** → each cleanup writes an archive file; over many runs the archive dir accumulates. Acceptable for now (user-controlled frequency; each archive is only that pass's deleted jobs). Consider archive retention/rotation only if it becomes a nuisance. +2. **`all_done` cross-run coupling** (decision-3 wart) → pruning prior runs incidentally helps each fresh run's `all_done`. No separate fix needed here; noted as a benefit. +3. **WAL sidecar bloat** → none on the audited DB, but the command reports `-wal`/`-shm` sizes and may need a checkpoint before VACUUM reclaims on other DBs. +4. **Slim-in-DB mode** (keep job rows, null blobs) → superseded by events-only + archive. Not built now. +5. **First-class `run_slug` column + index** → deferred unless age-based proves insufficient on a future DB. +6. **Resume safety after a long absence** → covered by the status gate (mid-flight runs stay non-terminal → immortal) + file-layer context (finished runs resume from `.tenet/project` + `.tenet/runs`, not DB rows). The only residual edge is a `failed`-but-unretried job aged out; the grace window + dry-run + archive cover it. + +--- + +## Non-goals + +- Auto-reaping / scheduled cleanup. +- Run-keyed UI or a `run_slug` schema migration. +- Archiving events. +- Slim-in-DB (null-the-blobs) mode. +- Touching file-layer memory (`.tenet/project`, knowledge, journal). + +--- + +## Acceptance criteria + +| # | Criterion | +|---|---| +| AC1 | `tenet db cleanup` exists; interactive when TTY, flag-driven otherwise (`--keep-days`, `--before`, `--mode`, `--dry-run`, `--yes`, `--no-archive`) | +| AC2 | Non-terminal jobs are never deletable — enforced in the delete query, not just the UI | +| AC3 | Approach documented and agreed — **this doc** (TASK-038 AC#3 ✅) | +| AC4 | Preview computes total size, freelist-reclaimable (VACUUM-without-delete), per-category bytes, protected count, orphan count, and per-cutoff reclaim — and renders them in user-facing terms (activity logs / results / prompts), dropping any no-op cutoff | +| AC5 | Delete cascades job→events and sweeps orphan events | +| AC6 | Job `params`/`output`/`error` archived to `.tenet/archive/` before delete; events not archived | +| AC7 | VACUUM runs after delete; before/after file size reported | +| AC8 | Concurrency-safe: runs while the server is live — status gate protects in-flight work, `busy_timeout` absorbs write contention, `DELETE`+`VACUUM` verified to shrink the file under an open WAL connection (idle and mid-transaction). No live-detection, no `--force` | +| AC9 | Operates on the project's `.tenet/.state/tenet.db` via existing StateStore path resolution | +| AC10 | Tests cover status-gate safety, cascade, orphan sweep, archive shape, dry-run no-op, age-banding, and adaptive output (tiny / recent-bloat / old-bloat shapes) | + +--- + +## Implementation plan (grounded 2026-07-18) + +A read-only codebase mapping confirmed every mechanism below exists to reuse. File:line anchors are current as of this date. + +### Components + +**1. StateStore read methods — `src/core/state-store.ts`** (alongside `checkDatabase:241`, `getTotalCount:597`). The `db` handle is a private better-sqlite3 field (`:198`); PRAGMA helpers exist (`pragmaTextRows` / `simpleNumberPragma:158-182`); `checkDatabase` already returns `page_size` / `page_count` / `freelist_count` in `DbHealthReport` (`:78-93`). Add read-only: +- `getDbStats()` — file + WAL/SHM sizes (`fs.statSync`) + freelist/page stats (reuse `checkDatabase`'s numbers). +- `getCategoryBytes()` — `SUM(LENGTH(events.data))`, `SUM(LENGTH(jobs.output))`, `SUM(LENGTH(jobs.params))` → the Activity-logs / Results / Prompts rows. +- `getStatusCounts()` + `getOrphanEventCount()` (`events LEFT JOIN jobs WHERE jobs.id IS NULL`). +- `getCleanupReport(cutoffs[], mode)` — the reclaim curve. For each cutoff, estimate bytes = `Σ LENGTH(params)+LENGTH(output)+LENGTH(error)` over terminal jobs older than the cutoff **+** `Σ LENGTH(events.data)` over their child events **+** orphan bytes. This is a `~` estimate (excludes page overhead) but its **rank ordering is exact**, which is what lets the menu drop no-op cutoffs. + +**2. StateStore prune method — `src/core/state-store.ts`** (using the `this.db.transaction()` wrapper at `:762-778`). One transactional step: archive write → `DELETE FROM jobs WHERE status IN ('completed','failed','cancelled') AND completed_at < cutoff` → `DELETE FROM events WHERE job_id NOT IN (SELECT id FROM jobs)` (cascade + orphan sweep in one statement, since `events.job_id` is not a FK) → then **outside** the txn: `checkpoint(TRUNCATE)` (`:819-823`) → `db.exec('VACUUM')`. The `WHERE status IN (...)` clause **is** the immortal-work gate (AC2) — enforced in SQL, not the UI. + +**3. CLI — `src/cli/db-cleanup.ts`** (new file) + registration in `index.ts:343-410`. Mirrors `runDbSnapshot` (`db.ts:119-156`): `runDbCleanup(projectPath, opts)`, `console.log` output, throws on error; action handler uses `resolveProjectPath` (`index.ts:35`) + standard try/catch (`index.ts:364-374`). Reuses `formatBytes` / `timestamp` (`db.ts:6-15`). Flags: `--keep-days` / `--before ` / `--mode {all|events-only}` / `--dry-run` / `--yes` / `--no-archive`. The interactive renderer generates the menu from the reclaim curve (drop no-ops) mirroring the `promptAgent` numbered-menu (`init.ts:342-375`) and `promptYesNo` (`init.ts:326-340`) — readline only, no new dependency. Archive → `path.join(projectPath, '.tenet', 'archive', 'cleanup-.jsonl')` via `fs.appendFileSync(rec+'\n')` (`.tenet/archive/` is already scaffolded by `init`, `init.ts:10-19`). + +**4. Tests — `src/cli/db-cleanup.test.ts`** mirroring `db.test.ts` (temp dir via `mkdtempSync`, real StateStore, `afterEach` rm). Seed normal jobs via `createJob`; seed controlled-age / orphan rows via raw `prepare().run()` INSERT (`state-store.test.ts:161-175`). No adapter needed — pure CLI/StateStore. + +### Build sequence (each layer lands tested before the next) + +1. StateStore read methods + unit tests (preview numbers). +2. `getCleanupReport` reclaim curve + age-band tests. +3. Prune transaction (status-gate, cascade, orphan sweep, VACUUM) + tests — the safety-critical core. +4. Archive writer + archive-shape test. +5. Preview renderer (adaptive output across tiny / recent-bloat / old-bloat fixtures). +6. Interactive menu + flag/TTY modes + dry-run. +7. Wire `tenet db cleanup` into the `db` group. + +### Implementation decisions + +- **A. No live-detection (reverses design decision #9; supersedes the earlier pid-file plan).** Empirically verified 2026-07-18 on a faithful two-process WAL probe: `DELETE` + `VACUUM` shrinks the file with a live server connection open — idle and mid-transaction, no `SQLITE_BUSY`. So cleanup runs while the agent is open; the status gate (#3) is the only correctness net it needs. `-wal`/`-shm` presence was tested and rejected as a heuristic (present under light load, absent after autocheckpoint, lingering after a crash — unreliable both ways). For robustness only: set a `busy_timeout` (e.g. 30 s) on the cleanup connection and handle a `SQLITE_BUSY` from `VACUUM` as a retry-then-report path. No pid file, no sidecar check, no `--force`. +- **B. Reclaim estimate is a `~`.** Post-`VACUUM` shrink can't be known before deleting (page overhead / fragmentation). The `LENGTH()`-sum in `getCleanupReport` is exact in rank-order (no-op cutoffs drop correctly) but approximate in magnitude — the menu renders it as "→ frees ~N". Acceptable. +- **C. Separate `db-cleanup.ts`, not folded into `db.ts`.** House style is one `db.ts`, but cleanup (preview + menu + prune) is materially larger than the other db subcommands; a separate file keeps `db.ts` coherent. +- **D. VACUUM needs a post-VACUUM `checkpoint(TRUNCATE)` to shrink the main file immediately.** Verified during implementation: in WAL mode `VACUUM` writes the compacted db through the WAL, and for a small resulting db (under `wal_autocheckpoint`) it stays in the WAL — the main file only shrinks on connection close. So `pruneCleanup` runs `checkpoint(TRUNCATE)` both before and after `VACUUM`; without the post-VACUUM checkpoint, `bytesAfter` wouldn't reflect the reclaim and the `-wal` sidecar would linger. Confirmed on the 1.32 GiB validation DB: a `--keep-days 7` prune shrank it to 667 MiB and archived 3,253 job records; the 76 in-progress jobs survived untouched. + +--- + +## References + +- **Code:** `src/core/state-store.ts` (schema `initSchema:862`, `getTotalCount:597`, `getCompletedCount:592`, `appendEvent:491`), `src/core/job-manager.ts` (`continue:370`, `all_done` math), `src/mcp/tools/tenet-update-knowledge.ts:118` (knowledge→files), the `tenet db` snapshot path (VACUUM / restore-swap patterns). +- **Audit:** read-only characterization of a real 1.32 GiB project DB (a copy placed in the working dir for inspection, 2026-07-18) — numbers in *Forensic findings* above. +- **Tasks:** TASK-038 (this), TASK-037 (loop-reliability; the archive feeds its missing run-transcript evidence), TASK-033/035 (unsandboxed-agent failure mode shared with "can't force run_slug"). +- **Prior art:** `docs/planning/18_model_tier_and_worker_context.md` (doc style; "decisions do not re-litigate without new evidence" pattern). diff --git a/src/cli/db-cleanup.test.ts b/src/cli/db-cleanup.test.ts new file mode 100644 index 0000000..e0b8cb0 --- /dev/null +++ b/src/cli/db-cleanup.test.ts @@ -0,0 +1,376 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { StateStore } from '../core/state-store.js'; +import type { CleanupPreview } from './db-cleanup.js'; +import { + buildMenu, + cutoffFromKeepDays, + executeCleanup, + renderCleanupPreview, + runCleanupCommand, +} from './db-cleanup.js'; +import type { JobStatus, JobType } from '../types/index.js'; + +const MS_PER_DAY = 86_400_000; + +const tempDirs: string[] = []; +const stores: StateStore[] = []; + +const createTempDir = (): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenet-cleanup-test-')); + tempDirs.push(dir); + return dir; +}; + +const dbPathFor = (projectPath: string): string => + path.join(projectPath, '.tenet', '.state', 'tenet.db'); + +const openRaw = (dbPath: string, readonly = true): Database.Database => + new Database(dbPath, readonly ? { readonly: true, fileMustExist: true } : {}); + +const scalar = (dbPath: string, sql: string): number => { + const db = openRaw(dbPath); + try { + const row = db.prepare(sql).get() as { c: number }; + return row.c; + } finally { + db.close(); + } +}; + +const countJobs = (dbPath: string): number => scalar(dbPath, 'SELECT COUNT(*) AS c FROM jobs'); +const countEvents = (dbPath: string): number => scalar(dbPath, 'SELECT COUNT(*) AS c FROM events'); +const jobIds = (dbPath: string): Set => { + const db = openRaw(dbPath); + try { + return new Set((db.prepare('SELECT id FROM jobs').all() as { id: string }[]).map((r) => r.id)); + } finally { + db.close(); + } +}; + +/** Insert an event with an explicit (possibly old) timestamp, bypassing appendEvent's Date.now(). */ +const insertEvent = (projectPath: string, jobId: string, event: string, ts: number, bytes = 0): void => { + const db = openRaw(dbPathFor(projectPath), false); + try { + db.prepare('INSERT INTO events (job_id, event, data, timestamp) VALUES (?, ?, ?, ?)').run( + jobId, + event, + JSON.stringify({ payload: 'x'.repeat(bytes) }), + ts, + ); + } finally { + db.close(); + } +}; + +let counter = 0; +const seedJob = ( + store: StateStore, + overrides: { status?: JobStatus; completedAt?: number; outputBytes?: number; type?: JobType } = {}, +): string => { + const job = store.createJob({ + type: overrides.type ?? 'dev', + status: overrides.status ?? 'completed', + params: { name: `job-${counter++}` }, + retryCount: 0, + maxRetries: -1, + completedAt: overrides.completedAt, + }); + if (overrides.outputBytes && overrides.outputBytes > 0) { + store.setJobOutput(job.id, { verdict: 'ok', blob: 'x'.repeat(overrides.outputBytes) }); + } + return job.id; +}; + +const scanPreview = (projectPath: string, cutoffs: number[]): CleanupPreview => { + const store = StateStore.openReadonly(projectPath); + try { + return store.getCleanupPreview(cutoffs); + } finally { + store.close(); + } +}; + +afterEach(() => { + while (stores.length > 0) { + stores.pop()?.close(); + } + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +const now = (): number => Date.now(); + +describe('StateStore cleanup — data layer', () => { + it('status gate: non-terminal jobs are never deleted, regardless of age (AC2)', () => { + const projectPath = createTempDir(); + const dbPath = dbPathFor(projectPath); + const store = new StateStore(projectPath); + stores.push(store); + + const terminal: JobStatus[] = ['completed', 'failed', 'cancelled']; + const protectedStatuses: JobStatus[] = ['pending', 'running', 'blocked', 'blocked_on_finding']; + const t = now() - 100 * MS_PER_DAY; + for (const status of terminal) seedJob(store, { status, completedAt: t }); + for (const status of protectedStatuses) seedJob(store, { status, completedAt: t }); + + expect(countJobs(dbPath)).toBe(7); + + const writable = new StateStore(projectPath); + stores.push(writable); + writable.pruneCleanup({ mode: 'all', cutoffMs: now(), archivePath: undefined }); + + expect(writable.getTotalCount()).toBe(4); // only the protected statuses survive + for (const status of protectedStatuses) { + expect(writable.getJobsByStatus(status).length).toBe(1); + } + for (const status of terminal) { + expect(writable.getJobsByStatus(status).length).toBe(0); + } + }); + + it('cascade + orphan sweep: deleting jobs removes their events and sweeps orphans (AC5)', () => { + const projectPath = createTempDir(); + const dbPath = dbPathFor(projectPath); + const store = new StateStore(projectPath); + stores.push(store); + + const oldJob = seedJob(store, { completedAt: now() - 30 * MS_PER_DAY }); + insertEvent(projectPath, oldJob, 'ev1', now() - 30 * MS_PER_DAY); + insertEvent(projectPath, oldJob, 'ev2', now() - 30 * MS_PER_DAY); + insertEvent(projectPath, 'no-such-job', 'orphan', now() - 30 * MS_PER_DAY); // orphan + + // createJob also appended a 'job_created' event for oldJob. + expect(countEvents(dbPath)).toBe(4); + + const writable = new StateStore(projectPath); + stores.push(writable); + const result = writable.pruneCleanup({ mode: 'all', cutoffMs: now() - 7 * MS_PER_DAY }); + + expect(result.deletedJobs).toBe(1); + expect(result.orphanEventsSwept).toBe(1); + expect(countEvents(dbPath)).toBe(0); // both cascade events + the orphan gone + expect(countJobs(dbPath)).toBe(0); + }); + + it('age-banding: only finished work older than the cutoff is removed', () => { + const projectPath = createTempDir(); + const dbPath = dbPathFor(projectPath); + const store = new StateStore(projectPath); + stores.push(store); + + const oldId = seedJob(store, { completedAt: now() - 30 * MS_PER_DAY }); + const recentId = seedJob(store, { completedAt: now() - 1 * MS_PER_DAY }); + const runningId = seedJob(store, { status: 'running', completedAt: now() - 30 * MS_PER_DAY }); + + const writable = new StateStore(projectPath); + stores.push(writable); + writable.pruneCleanup({ mode: 'all', cutoffMs: now() - 7 * MS_PER_DAY }); + + const survivors = jobIds(dbPath); + expect(survivors.has(oldId)).toBe(false); + expect(survivors.has(recentId)).toBe(true); + expect(survivors.has(runningId)).toBe(true); // status gate beats age + }); + + it('archive: pruned job rows (with params/output) are written to JSONL; events are not archived (AC6)', () => { + const projectPath = createTempDir(); + const store = new StateStore(projectPath); + stores.push(store); + + const oldId = seedJob(store, { completedAt: now() - 30 * MS_PER_DAY, outputBytes: 2_000 }); + const archivePath = path.join(projectPath, '.tenet', 'archive', 'cleanup-test.jsonl'); + + const writable = new StateStore(projectPath); + stores.push(writable); + const result = writable.pruneCleanup({ mode: 'all', cutoffMs: now() - 7 * MS_PER_DAY, archivePath }); + + expect(result.archivedJobs).toBe(1); + expect(fs.existsSync(archivePath)).toBe(true); + + const lines = fs.readFileSync(archivePath, 'utf8').trim().split('\n'); + expect(lines.length).toBe(1); + const record = JSON.parse(lines[0]) as Record; + expect(record.id).toBe(oldId); + expect(typeof record.params).toBe('string'); // raw column (params holds run_slug/source_job_id when set) + expect(typeof record.output).toBe('string'); + expect(String(record.output)).toContain('blob'); // the verdict payload survived + expect(record.status).toBe('completed'); + }); + + it('VACUUM: file shrinks after deleting bulky output (AC7)', () => { + const projectPath = createTempDir(); + const dbPath = dbPathFor(projectPath); + const store = new StateStore(projectPath); + stores.push(store); + for (let i = 0; i < 8; i++) seedJob(store, { completedAt: now() - 30 * MS_PER_DAY, outputBytes: 300_000 }); + + stores.pop()?.close(); + const sizeBefore = fs.statSync(dbPath).size; + expect(sizeBefore).toBeGreaterThan(2_000_000); + + const writable = new StateStore(projectPath); + const result = writable.pruneCleanup({ mode: 'all', cutoffMs: now() - 7 * MS_PER_DAY }); + writable.close(); + + expect(result.vacuumed).toBe(true); + expect(result.bytesBefore).toBe(sizeBefore); + expect(result.bytesAfter).toBeLessThan(sizeBefore); + expect(result.bytesAfter).toBeLessThan(sizeBefore * 0.25); // reclaimed the bulk + }); + + it('preview: per-cutoff reclaim reflects age-banding', () => { + const projectPath = createTempDir(); + const store = new StateStore(projectPath); + stores.push(store); + seedJob(store, { completedAt: now() - 10 * MS_PER_DAY, outputBytes: 50_000 }); + seedJob(store, { completedAt: now() - 40 * MS_PER_DAY, outputBytes: 50_000 }); + + const c7 = cutoffFromKeepDays(7); + const c30 = cutoffFromKeepDays(30); + const c90 = cutoffFromKeepDays(90); + const preview = scanPreview(projectPath, [c7, c30, c90]); + const at7 = preview.reclaim.find((r) => r.cutoffMs === c7)!; + const at30 = preview.reclaim.find((r) => r.cutoffMs === c30)!; + const at90 = preview.reclaim.find((r) => r.cutoffMs === c90)!; + + expect(at7.all.jobCount).toBe(2); // both jobs older than 7d + expect(at30.all.jobCount).toBe(1); // only the 40d job is older than 30d + expect(at90.all.jobCount).toBe(0); // neither older than 90d + expect(at7.all.bytes).toBeGreaterThan(at30.all.bytes); + }); +}); + +describe('executeCleanup / runCleanupCommand', () => { + it('dry-run changes nothing', () => { + const projectPath = createTempDir(); + const dbPath = dbPathFor(projectPath); + const store = new StateStore(projectPath); + stores.push(store); + seedJob(store, { completedAt: now() - 30 * MS_PER_DAY, outputBytes: 10_000 }); + + const before = countJobs(dbPath); + const preview = scanPreview(projectPath, [cutoffFromKeepDays(7)]); + const result = executeCleanup(projectPath, { mode: 'all', cutoffMs: cutoffFromKeepDays(7), dryRun: true, noArchive: true }, preview); + + expect(result.kind).toBe('dry-run'); + expect(countJobs(dbPath)).toBe(before); // untouched + }); + + it('non-interactive prune via runCleanupCommand removes old finished work and archives', async () => { + const projectPath = createTempDir(); + const dbPath = dbPathFor(projectPath); + const store = new StateStore(projectPath); + stores.push(store); + seedJob(store, { completedAt: now() - 30 * MS_PER_DAY, outputBytes: 5_000 }); + seedJob(store, { status: 'running' }); + expect(countJobs(dbPath)).toBe(2); + + // stdin is not a TTY under vitest -> non-interactive path. + await runCleanupCommand(projectPath, { keepDays: 7, yes: true }); + + expect(countJobs(dbPath)).toBe(1); // only the running job remains + const archives = fs.readdirSync(path.join(projectPath, '.tenet', 'archive')); + expect(archives.some((f) => /^cleanup-.*\.jsonl$/.test(f))).toBe(true); + }); + + it('non-interactive with no decision flag is read-only (never auto-prunes)', async () => { + const projectPath = createTempDir(); + const dbPath = dbPathFor(projectPath); + const store = new StateStore(projectPath); + stores.push(store); + seedJob(store, { completedAt: now() - 30 * MS_PER_DAY, outputBytes: 5_000 }); + + const before = countJobs(dbPath); + await runCleanupCommand(projectPath, {}); + expect(countJobs(dbPath)).toBe(before); + }); + + it('events-only mode drops old events but keeps every job', async () => { + const projectPath = createTempDir(); + const dbPath = dbPathFor(projectPath); + const store = new StateStore(projectPath); + stores.push(store); + + const keepId = seedJob(store, { completedAt: now() - 30 * MS_PER_DAY }); + insertEvent(projectPath, keepId, 'old_event', now() - 30 * MS_PER_DAY, 10_000); // old -> dropped + insertEvent(projectPath, keepId, 'recent_event', now() - 1 * MS_PER_DAY, 10_000); // recent -> kept + insertEvent(projectPath, 'orphan', 'orphan_event', now() - 30 * MS_PER_DAY); // swept + + // createJob also appended a 'job_created' event for keepId (recent -> kept). + expect(countEvents(dbPath)).toBe(4); + await runCleanupCommand(projectPath, { keepDays: 7, mode: 'events-only', yes: true }); + + expect(countJobs(dbPath)).toBe(1); // job kept + expect(countEvents(dbPath)).toBe(2); // job_created + recent_event remain; old_event + orphan gone + }); + + it('empty DB: reports nothing-to-clean and does not throw', async () => { + const projectPath = createTempDir(); + const store = new StateStore(projectPath); // creates an empty DB + stores.push(store); + store.close(); + stores.pop(); + + await expect(runCleanupCommand(projectPath, {})).resolves.toBeUndefined(); + expect(countJobs(dbPathFor(projectPath))).toBe(0); + }); +}); + +describe('rendering + menu (pure, adaptive — AC10)', () => { + const fakePreview = (over: Partial): CleanupPreview => ({ + fileBytes: 0, + walBytes: 0, + shmBytes: 0, + pageSize: 4096, + pageCount: 0, + freelistCount: 0, + categoryBytes: { eventsData: 0, jobsOutput: 0, jobsParams: 0, jobsError: 0 }, + statusCounts: {}, + orphanEvents: { count: 0, bytes: 0 }, + reclaim: [], + ...over, + }); + + it('characterizes a freelist-heavy DB as VACUUM-shrinkable without deleting', () => { + const out = renderCleanupPreview( + fakePreview({ fileBytes: 10 * 1024 * 1024, pageCount: 2600, freelistCount: 2000 }), + ); + expect(out).toContain('VACUUM can shrink it without deleting'); + }); + + it('characterizes a real-data DB as having no quick compact', () => { + const out = renderCleanupPreview(fakePreview({ fileBytes: 10 * 1024 * 1024, freelistCount: 0 })); + expect(out).toContain('no quick "compact"'); + }); + + it('buildMenu drops no-op cutoffs and keeps reclaiming ones', () => { + const t = now(); + const big = 100 * 1024 * 1024; + const preview = fakePreview({ + statusCounts: { completed: 500 }, + reclaim: [ + { cutoffMs: t, all: { jobCount: 500, eventCount: 0, bytes: big }, eventsOnly: { eventCount: 0, bytes: 0 } }, + { cutoffMs: cutoffFromKeepDays(7, t), all: { jobCount: 300, eventCount: 0, bytes: big }, eventsOnly: { eventCount: 0, bytes: big } }, + { cutoffMs: cutoffFromKeepDays(30, t), all: { jobCount: 0, eventCount: 0, bytes: 0 }, eventsOnly: { eventCount: 0, bytes: 0 } }, + ], + }); + const labels = buildMenu(preview, t).groups.flatMap((g) => g.items).map((i) => i.label); + expect(labels.some((l) => l.includes('keep the last 7 days'))).toBe(true); // reclaims + expect(labels.some((l) => l.includes('keep the last 30 days'))).toBe(false); // no-op, dropped + expect(labels.some((l) => l.includes('drop logs older than 7 days'))).toBe(true); // events-only reclaims + expect(labels.some((l) => l.includes('remove ALL finished work'))).toBe(true); // reset: terminal jobs exist + }); + + it('buildMenu on a DB with no finished work offers no destructive reset', () => { + const t = now(); + const preview = fakePreview({ statusCounts: { running: 3 } }); // no terminal jobs + const labels = buildMenu(preview, t).groups.flatMap((g) => g.items).map((i) => i.label); + expect(labels.some((l) => l.includes('remove ALL finished work'))).toBe(false); + }); +}); diff --git a/src/cli/db-cleanup.ts b/src/cli/db-cleanup.ts new file mode 100644 index 0000000..587884c --- /dev/null +++ b/src/cli/db-cleanup.ts @@ -0,0 +1,414 @@ +import path from 'node:path'; +import readline from 'node:readline/promises'; +import { + NON_TERMINAL_JOB_STATUSES, + StateStore, + TERMINAL_JOB_STATUSES, + type CleanupPreview, + type CleanupPruneResult, + type CleanupReclaimEntry, +} from '../core/state-store.js'; +import { formatBytes, timestamp } from './db.js'; +import { promptYesNo } from './init.js'; + +export type CleanupMode = 'all' | 'events-only'; + +export type CleanupOptions = { + mode: CleanupMode; + cutoffMs: number; + dryRun: boolean; + noArchive: boolean; +}; + +export type CleanupFlags = { + keepDays?: number; + before?: string; + mode?: CleanupMode; + dryRun?: boolean; + yes?: boolean; + noArchive?: boolean; +}; + +export type CleanupRunResult = + | ({ kind: 'dry-run' } & Pick) + | CleanupPruneResult; + +const MS_PER_DAY = 86_400_000; +const CANONICAL_KEEP_DAYS = [7, 15, 30] as const; +/** A cutoff reclaiming less than this is a no-op and dropped from the menu. */ +const NOOP_RECLAIM_BYTES = 1024 * 1024; + +export const cutoffFromKeepDays = (days: number, now = Date.now()): number => now - days * MS_PER_DAY; + +const canonicalCutoffsMs = (now: number): number[] => { + const cutoffs = [now, ...CANONICAL_KEEP_DAYS.map((days) => cutoffFromKeepDays(days, now))]; + return Array.from(new Set(cutoffs)); +}; + +const formatCutoffDate = (cutoffMs: number): string => new Date(cutoffMs).toISOString().slice(0, 10); + +const nonTerminalCount = (preview: CleanupPreview): number => + NON_TERMINAL_JOB_STATUSES.reduce((sum, status) => sum + (preview.statusCounts[status] ?? 0), 0); + +const resultsBytes = (preview: CleanupPreview): number => + preview.categoryBytes.jobsOutput + preview.categoryBytes.jobsError; + +const liveTextTotal = (preview: CleanupPreview): number => + preview.categoryBytes.eventsData + resultsBytes(preview) + preview.categoryBytes.jobsParams; + +const pctOf = (bytes: number, total: number): string => { + if (total <= 0) return ' -'; + return `${String(Math.round((bytes / total) * 100)).padStart(3)}%`; +}; + +/** Unconditional heads-up — printed on every invocation (design decision #12). */ +export const CLEANUP_WARNING = + '⚠ tenet db cleanup deletes old finished jobs and compacts the database.\n' + + ' It is safe to run any time (in-progress jobs are never touched), but for a\n' + + ' large DB prefer running it between autonomous runs, not during one.'; + +const printWarning = (): void => { + console.warn(CLEANUP_WARNING); +}; + +const archivePathFor = (projectPath: string): string => + path.join(projectPath, '.tenet', 'archive', `cleanup-${timestamp()}.jsonl`); + +const scanPreview = (projectPath: string, cutoffsMs: number[]): CleanupPreview => { + const store = StateStore.openReadonly(projectPath); + try { + return store.getCleanupPreview(cutoffsMs); + } finally { + store.close(); + } +}; + +/** + * Characterize the DB so the opening line is honest for any shape. If most of + * the file is already-reclaimed freelist, say so (VACUUM-alone would help); + * otherwise tell the user shrinking means deleting real data. Pure. + */ +const characterizeSize = (preview: CleanupPreview): string => { + const vacuumReclaimable = preview.freelistCount * preview.pageSize; + if (preview.fileBytes > 0 && vacuumReclaimable > preview.fileBytes * 0.05) { + return ( + `tenet.db is ${formatBytes(preview.fileBytes)} — about ${formatBytes(vacuumReclaimable)} of that ` + + `is already-reclaimed space; VACUUM can shrink it without deleting anything.` + ); + } + return ( + `tenet.db is ${formatBytes(preview.fileBytes)} — almost entirely real data, so there's no ` + + `quick "compact"; shrinking it means deleting old work.` + ); +}; + +/** + * The "lay of the land" block: total size + characterization, the two + * guarantees, and the per-category byte breakdown. Pure (returns the string; + * the caller prints). Every figure is computed from the live DB upstream. + */ +export const renderCleanupPreview = (preview: CleanupPreview): string => { + const inProgress = nonTerminalCount(preview); + const total = liveTextTotal(preview); + const lines: string[] = []; + + lines.push(characterizeSize(preview)); + lines.push('Two things you can count on:'); + lines.push( + ` • In-progress work (${inProgress} job${inProgress === 1 ? '' : 's'} running or queued) is never touched.`, + ); + lines.push(' • Finished jobs we remove are archived to .tenet/archive/ first, so your'); + lines.push(' results and critic verdicts stay recoverable.'); + lines.push(''); + lines.push("What's taking up the space:"); + lines.push( + ` Activity logs — the step-by-step history of each job ... ${formatBytes(preview.categoryBytes.eventsData).padStart(9)} ${pctOf(preview.categoryBytes.eventsData, total)}`, + ); + lines.push( + ` Results & reviews — job outputs and critic verdicts ..... ${formatBytes(resultsBytes(preview)).padStart(9)} ${pctOf(resultsBytes(preview), total)}`, + ); + lines.push( + ` Prompts sent to workers ................................. ${formatBytes(preview.categoryBytes.jobsParams).padStart(9)} ${pctOf(preview.categoryBytes.jobsParams, total)}`, + ); + return lines.join('\n'); +}; + +export const renderPruneResult = (result: CleanupPruneResult, archivePath?: string): string => { + const lines: string[] = []; + lines.push( + `Removed ${result.deletedJobs} finished job(s) and ${result.deletedEvents} event log(s)` + + (result.orphanEventsSwept > 0 ? `; swept ${result.orphanEventsSwept} orphan event log(s)` : '') + + '.', + ); + if (archivePath && result.archivedJobs > 0) { + lines.push(`Archived ${result.archivedJobs} job record(s) to ${path.relative(process.cwd(), archivePath) || archivePath}.`); + } else if (archivePath && result.archivedJobs === 0) { + lines.push('No jobs removed; archive not written.'); + } + const reclaimed = Math.max(0, result.bytesBefore - result.bytesAfter); + if (result.vacuumed) { + lines.push(`tenet.db: ${formatBytes(result.bytesBefore)} → ${formatBytes(result.bytesAfter)} (reclaimed ${formatBytes(reclaimed)}).`); + } else { + lines.push( + `tenet.db: ${formatBytes(result.bytesBefore)} → ${formatBytes(result.bytesAfter)}. ` + + `VACUUM was skipped (${result.vacuumError ?? 'unknown reason'}); re-run later to compact.`, + ); + } + return lines.join('\n'); +}; + +// --- Menu (pure construction) ------------------------------------------------- + +type MenuPick = + | { kind: 'run'; mode: CleanupMode; cutoffMs: number } + | { kind: 'prompt-date' } + | { kind: 'dry-run-info' }; + +type MenuItem = { n: number; label: string; pick: MenuPick }; +type MenuGroup = { title: string; items: MenuItem[] }; +type Menu = { groups: MenuGroup[]; byNumber: Map }; + +const reclaimEntry = (preview: CleanupPreview, cutoffMs: number): CleanupReclaimEntry | undefined => + preview.reclaim.find((r) => r.cutoffMs === cutoffMs); + +/** + * Build the numbered menu from the live reclaim curve, dropping no-op cutoffs. + * Pure — the readline interaction lives in promptCleanupChoice. Numbers are + * assigned 1..N; [0] is cancel (handled by the prompt). + */ +export const buildMenu = (preview: CleanupPreview, now: number): Menu => { + const groups: MenuGroup[] = []; + const byNumber = new Map(); + let n = 0; + const addItem = (groupTitle: string, label: string, pick: MenuPick): { group: string } => { + n += 1; + const item: MenuItem = { n, label, pick }; + byNumber.set(n, item); + let group = groups.find((g) => g.title === groupTitle); + if (!group) { + group = { title: groupTitle, items: [] }; + groups.push(group); + } + group.items.push(item); + return { group: groupTitle }; + }; + + for (const days of CANONICAL_KEEP_DAYS) { + const entry = reclaimEntry(preview, cutoffFromKeepDays(days, now)); + if (entry && entry.all.bytes >= NOOP_RECLAIM_BYTES) { + addItem( + 'Remove old finished work (finished = completed, failed, or cancelled)', + `keep the last ${days} days → removes ${entry.all.jobCount} job(s), frees ~${formatBytes(entry.all.bytes)}`, + { kind: 'run', mode: 'all', cutoffMs: entry.cutoffMs }, + ); + } + } + addItem( + 'Remove old finished work (finished = completed, failed, or cancelled)', + 'keep everything since a specific date…', + { kind: 'prompt-date' }, + ); + + for (const days of [7, 15]) { + const entry = reclaimEntry(preview, cutoffFromKeepDays(days, now)); + if (entry && entry.eventsOnly.bytes >= NOOP_RECLAIM_BYTES) { + addItem( + 'Trim logs only — keep all results and finished jobs; drop old activity logs', + `drop logs older than ${days} days → frees ~${formatBytes(entry.eventsOnly.bytes)}, all results kept`, + { kind: 'run', mode: 'events-only', cutoffMs: entry.cutoffMs }, + ); + } + } + + const resetEntry = reclaimEntry(preview, now); + if (resetEntry && resetEntry.all.jobCount > 0) { + addItem( + 'Reset', + `remove ALL finished work → removes ${resetEntry.all.jobCount} job(s), frees ~${formatBytes(resetEntry.all.bytes)}`, + { kind: 'run', mode: 'all', cutoffMs: now }, + ); + } + + addItem('More', 'show the full reclaim breakdown (changes nothing)', { kind: 'dry-run-info' }); + + return { groups, byNumber }; +}; + +const printMenu = (menu: Menu): void => { + console.log('\nWhat would you like to do?\n'); + for (const group of menu.groups) { + console.log(` ${group.title}`); + for (const item of group.items) { + console.log(` [${item.n}] ${item.label}`); + } + console.log(''); + } + console.log(' [0] cancel'); +}; + +const printFullBreakdown = (preview: CleanupPreview): void => { + console.log('\nReclaim by cutoff (computed from the live DB):'); + for (const entry of preview.reclaim) { + const when = entry.cutoffMs >= Date.now() ? 'all finished work' : `older than ${formatCutoffDate(entry.cutoffMs)}`; + console.log( + ` ${when.padEnd(16)} remove-work: ${String(entry.all.jobCount).padStart(5)} job(s), ~${formatBytes(entry.all.bytes).padStart(9)}` + + ` | logs-only: ~${formatBytes(entry.eventsOnly.bytes).padStart(9)}`, + ); + } + if (preview.orphanEvents.count > 0) { + console.log(` (+ ${preview.orphanEvents.count} orphan event log(s), ~${formatBytes(preview.orphanEvents.bytes)} — swept in every mode)`); + } +}; + +// --- Interaction -------------------------------------------------------------- + +const promptCleanupChoice = async ( + preview: CleanupPreview, + now: number, + flags: CleanupFlags, +): Promise => { + const menu = buildMenu(preview, now); + printMenu(menu); + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + while (true) { + const answer = (await rl.question('\nSelect: ')).trim(); + if (answer === '0' || answer === '') return null; + const num = Number.parseInt(answer, 10); + const item = menu.byNumber.get(num); + if (!item) { + console.log('Invalid selection.'); + continue; + } + if (item.pick.kind === 'dry-run-info') { + printFullBreakdown(preview); + printMenu(menu); + continue; + } + if (item.pick.kind === 'prompt-date') { + const dateStr = (await rl.question('Keep everything since (YYYY-MM-DD): ')).trim(); + const ms = Date.parse(dateStr); + if (!Number.isFinite(ms)) { + console.log('Invalid date — use YYYY-MM-DD.'); + continue; + } + return { mode: flags.mode ?? 'all', cutoffMs: ms, dryRun: false, noArchive: flags.noArchive === true }; + } + return { + mode: item.pick.mode, + cutoffMs: item.pick.cutoffMs, + dryRun: false, + noArchive: flags.noArchive === true, + }; + } + } finally { + rl.close(); + } +}; + +const resolveFlaggedCutoff = (flags: CleanupFlags, now: number): number | null => { + if (typeof flags.keepDays === 'number') return cutoffFromKeepDays(flags.keepDays, now); + if (typeof flags.before === 'string') { + const ms = Date.parse(flags.before); + if (!Number.isFinite(ms)) { + throw new Error(`--before could not be parsed as a date: ${flags.before} (try YYYY-MM-DD)`); + } + return ms; + } + return null; +}; + +/** Execute a decided plan. Prints the plan + result. Core, side-effecting. */ +export const executeCleanup = ( + projectPath: string, + opts: CleanupOptions, + preview: CleanupPreview, +): CleanupRunResult => { + const entry = reclaimEntry(preview, opts.cutoffMs); + if (opts.mode === 'all') { + console.log( + `\nPlan: remove ${entry?.all.jobCount ?? 0} finished job(s) older than ${formatCutoffDate(opts.cutoffMs)} → frees ~${formatBytes(entry?.all.bytes ?? 0)}.`, + ); + } else { + console.log( + `\nPlan: drop ${entry?.eventsOnly.eventCount ?? 0} activity log(s) older than ${formatCutoffDate(opts.cutoffMs)} → frees ~${formatBytes(entry?.eventsOnly.bytes ?? 0)}; all results kept.`, + ); + } + + if (opts.dryRun) { + console.log('Dry run — nothing was changed.'); + return { kind: 'dry-run', mode: opts.mode, cutoffMs: opts.cutoffMs }; + } + + const archivePath = opts.noArchive ? undefined : archivePathFor(projectPath); + const store = new StateStore(projectPath); + try { + const result = store.pruneCleanup({ mode: opts.mode, cutoffMs: opts.cutoffMs, archivePath }); + console.log(renderPruneResult(result, archivePath)); + return result; + } finally { + store.close(); + } +}; + +const hasReclaimableWork = (preview: CleanupPreview): boolean => + preview.reclaim.some( + (r) => r.all.bytes >= NOOP_RECLAIM_BYTES || r.eventsOnly.bytes >= NOOP_RECLAIM_BYTES, + ) || TERMINAL_JOB_STATUSES.some((status) => (preview.statusCounts[status] ?? 0) > 0); + +/** + * Entry point for the `tenet db cleanup` action handler. Interactive (TTY + no + * decision flags) shows the menu; otherwise flag-driven. Always prints the + * unconditional warning and the lay-of-the-land preview first. + */ +export const runCleanupCommand = async (projectPath: string, flags: CleanupFlags): Promise => { + printWarning(); + const now = Date.now(); + const preview = scanPreview(projectPath, canonicalCutoffsMs(now)); + console.log(`\n${renderCleanupPreview(preview)}`); + + if (!hasReclaimableWork(preview)) { + console.log('\nNothing to clean up — tenet.db has no old finished work to reclaim yet.'); + return; + } + + const hasDecisionFlag = typeof flags.keepDays === 'number' || typeof flags.before === 'string'; + const interactive = process.stdin.isTTY === true && !hasDecisionFlag; + + let opts: CleanupOptions; + if (interactive) { + const choice = await promptCleanupChoice(preview, now, flags); + if (choice === null) { + console.log('\nCancelled. Nothing was changed.'); + return; + } + opts = choice; + } else { + const cutoffMs = resolveFlaggedCutoff(flags, now); + if (cutoffMs === null) { + console.log( + '\nNon-interactive mode — showing the preview only. To reclaim space, pass ' + + '--keep-days , --before , and/or --mode events-only.', + ); + return; + } + opts = { + mode: flags.mode ?? 'all', + cutoffMs, + dryRun: flags.dryRun === true, + noArchive: flags.noArchive === true, + }; + } + + if (!opts.dryRun && interactive && flags.yes !== true) { + console.log(''); + const proceed = await promptYesNo('Proceed with cleanup?', false); + if (!proceed) { + console.log('Cancelled. Nothing was changed.'); + return; + } + } + + executeCleanup(projectPath, opts, preview); +}; diff --git a/src/cli/db.ts b/src/cli/db.ts index d6a5fe1..42b8fda 100644 --- a/src/cli/db.ts +++ b/src/cli/db.ts @@ -3,15 +3,18 @@ import path from 'node:path'; import zlib from 'node:zlib'; import { StateStore, type DbHealthReport, type RestoreDatabaseOptions } from '../core/state-store.js'; -const timestamp = (): string => +export const timestamp = (): string => new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-'); -const formatBytes = (bytes: number): string => { +export const formatBytes = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; const kib = bytes / 1024; if (kib < 1024) return `${kib.toFixed(1)} KiB`; const mib = kib / 1024; - return `${mib.toFixed(1)} MiB`; + if (mib < 1024) return `${mib.toFixed(1)} MiB`; + const gib = mib / 1024; + if (gib < 1024) return `${gib.toFixed(2)} GiB`; + return `${(gib / 1024).toFixed(2)} TiB`; }; const printFileInfo = (label: string, filePath: string): void => { diff --git a/src/cli/index.ts b/src/cli/index.ts index d218275..cd8f3ff 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -30,6 +30,7 @@ import { UNLIMITED_RETRIES, } from '../core/runtime-config.js'; import { runDbBackup, runDbCheck, runDbRestoreSnapshot, runDbSnapshot } from './db.js'; +import { runCleanupCommand, type CleanupFlags } from './db-cleanup.js'; import { showStatus } from './status.js'; const resolveProjectPath = (project?: string): string => path.resolve(project ?? process.cwd()); @@ -409,6 +410,55 @@ const run = async (): Promise => { } }); + dbCommand + .command('cleanup') + .description('Reclaim SQLite bloat: delete old finished jobs / activity logs, then compact') + .option('--project ', 'Project path', '.') + .option('--keep-days ', 'Remove finished work older than N days') + .option('--before ', 'Remove finished work older than a date (YYYY-MM-DD)') + .option('--mode ', 'all (remove finished work) | events-only (trim logs, keep all results)', 'all') + .option('--dry-run', 'Show what would happen; change nothing') + .option('--yes', 'Skip the interactive confirmation') + .option('--no-archive', 'Do not archive removed job records before deleting') + .action( + async (options: { + project: string; + keepDays?: string; + before?: string; + mode?: string; + dryRun?: boolean; + yes?: boolean; + archive: boolean; + }) => { + const projectPath = resolveProjectPath(options.project); + let keepDays: number | undefined; + if (options.keepDays !== undefined) { + keepDays = Number.parseInt(options.keepDays, 10); + if (Number.isNaN(keepDays)) { + console.error('--keep-days must be a whole number of days'); + process.exitCode = 1; + return; + } + } + const flags: CleanupFlags = { + keepDays, + before: options.before, + mode: options.mode === 'events-only' ? 'events-only' : 'all', + dryRun: options.dryRun === true, + yes: options.yes === true, + noArchive: options.archive === false, + }; + try { + await runCleanupCommand(projectPath, flags); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } + process.exitCode = 1; + } + }, + ); + program .command('config') .description('View or update Tenet project configuration') diff --git a/src/core/state-store.ts b/src/core/state-store.ts index cbf5698..5b89c0a 100644 --- a/src/core/state-store.ts +++ b/src/core/state-store.ts @@ -96,6 +96,66 @@ export type RestoreDatabaseOptions = { force?: boolean; }; +/** + * Job statuses that represent finished work — the only statuses cleanup ever + * deletes. The cleanup DELETE clauses filter on this list, so the "in-flight + * work is immortal" gate is enforced in SQL, not just hidden in the UI. + */ +export const TERMINAL_JOB_STATUSES: readonly JobStatus[] = ['completed', 'failed', 'cancelled']; + +/** Job statuses that represent in-flight / resumable work — never prunable. */ +export const NON_TERMINAL_JOB_STATUSES: readonly JobStatus[] = [ + 'pending', + 'running', + 'blocked', + 'blocked_on_finding', +]; + +const TERMINAL_STATUS_LIST_SQL = TERMINAL_JOB_STATUSES.map((status) => `'${status}'`).join(', '); + +export type CleanupCategoryBytes = { + eventsData: number; + jobsOutput: number; + jobsParams: number; + jobsError: number; +}; + +export type CleanupReclaimTotals = { + jobCount: number; + eventCount: number; + bytes: number; +}; + +export type CleanupReclaimEntry = { + cutoffMs: number; + all: CleanupReclaimTotals; + eventsOnly: { eventCount: number; bytes: number }; +}; + +export type CleanupPreview = { + fileBytes: number; + walBytes: number; + shmBytes: number; + pageSize: number; + pageCount: number; + freelistCount: number; + categoryBytes: CleanupCategoryBytes; + statusCounts: Record; + orphanEvents: { count: number; bytes: number }; + reclaim: CleanupReclaimEntry[]; +}; + +export type CleanupPruneResult = { + deletedJobs: number; + deletedEvents: number; + orphanEventsSwept: number; + archivedJobs: number; + bytesBefore: number; + bytesAfter: number; + vacuumed: boolean; + vacuumError?: string; +}; + export class DbHealthError extends Error { constructor(public readonly report: DbHealthReport) { const details = [ @@ -155,6 +215,26 @@ const fileSize = (filePath: string): number | null => { const sqliteString = (value: string): string => `'${value.replace(/'/g, "''")}'`; +/** + * Fold a series of (timestamp, bytes) samples into the {count, bytes} strictly + * older than a cutoff. Module-level so the reclaim-curve logic is unit-testable + * without a DB. + */ +const tallyOlderThan = ( + samples: ReadonlyArray<{ t: number; bytes: number }>, + cutoffMs: number, +): { count: number; bytes: number } => { + let count = 0; + let bytes = 0; + for (const sample of samples) { + if (sample.t < cutoffMs) { + count += 1; + bytes += sample.bytes; + } + } + return { count, bytes }; +}; + const pragmaTextRows = (db: Database.Database, pragma: string): string[] => { const result = db.pragma(pragma) as unknown; if (!Array.isArray(result)) { @@ -599,6 +679,206 @@ export class StateStore { return row.count; } + /** + * Sum of LENGTH(column) across a table — the byte footprint of one category + * (events.data, jobs.output, jobs.params, jobs.error). Used by the cleanup + * preview to break the DB down by what's actually taking up space. + */ + private byteSum(sql: string): number { + const row = this.db.prepare(sql).get() as { n: number } | undefined; + return row?.n ?? 0; + } + + /** + * Read-only preview for `tenet db cleanup`: file/page stats, per-category + * bytes, status counts, orphan events, and a per-cutoff reclaim curve for + * both "remove old finished work" (all) and "trim logs only" (events-only). + * Every figure is computed from the live DB so the rendered output is correct + * for any DB shape (1 MB, 10 GB, day-old or year-old). Performs no writes. + * + * The reclaim curve is built by pulling three (age, bytes) series once and + * folding them per cutoff in JS — so N cutoffs cost three scans, not N*3. + */ + getCleanupPreview(cutoffsMs: readonly number[]): CleanupPreview { + const { dbPath, walPath, shmPath } = statePaths(this.projectPath); + + const categoryBytes: CleanupCategoryBytes = { + eventsData: this.byteSum('SELECT COALESCE(SUM(LENGTH(data)), 0) AS n FROM events'), + jobsOutput: this.byteSum('SELECT COALESCE(SUM(LENGTH(output)), 0) AS n FROM jobs'), + jobsParams: this.byteSum('SELECT COALESCE(SUM(LENGTH(params)), 0) AS n FROM jobs'), + jobsError: this.byteSum('SELECT COALESCE(SUM(LENGTH(error)), 0) AS n FROM jobs'), + }; + + const statusRows = this.db + .prepare('SELECT status, COUNT(*) AS count FROM jobs GROUP BY status') + .all() as StatusCountRow[]; + const statusCounts: Record = {}; + for (const row of statusRows) { + statusCounts[row.status] = row.count; + } + + const orphanRow = this.db + .prepare( + 'SELECT COUNT(*) AS count, COALESCE(SUM(LENGTH(data)), 0) AS bytes ' + + 'FROM events WHERE job_id NOT IN (SELECT id FROM jobs)', + ) + .get() as { count: number; bytes: number }; + + // (age, bytes) series — one scan each, lengths only (no blob bodies). + const terminalJobs = this.db + .prepare( + `SELECT COALESCE(completed_at, created_at) AS t, + LENGTH(params) + COALESCE(LENGTH(output), 0) + COALESCE(LENGTH(error), 0) AS bytes + FROM jobs WHERE status IN (${TERMINAL_STATUS_LIST_SQL})`, + ) + .all() as Array<{ t: number; bytes: number }>; + + const terminalJobEvents = this.db + .prepare( + `SELECT COALESCE(j.completed_at, j.created_at) AS t, COALESCE(LENGTH(e.data), 0) AS bytes + FROM events e JOIN jobs j ON e.job_id = j.id + WHERE j.status IN (${TERMINAL_STATUS_LIST_SQL})`, + ) + .all() as Array<{ t: number; bytes: number }>; + + const eventsByTime = this.db + .prepare('SELECT timestamp AS t, COALESCE(LENGTH(data), 0) AS bytes FROM events') + .all() as Array<{ t: number; bytes: number }>; + + const reclaim: CleanupReclaimEntry[] = cutoffsMs.map((cutoffMs) => { + const jobs = tallyOlderThan(terminalJobs, cutoffMs); + const evts = tallyOlderThan(terminalJobEvents, cutoffMs); + const oldEvents = tallyOlderThan(eventsByTime, cutoffMs); + return { + cutoffMs, + all: { jobCount: jobs.count, eventCount: evts.count, bytes: jobs.bytes + evts.bytes }, + eventsOnly: { eventCount: oldEvents.count, bytes: oldEvents.bytes }, + }; + }); + + return { + fileBytes: fileSize(dbPath) ?? 0, + walBytes: fileSize(walPath) ?? 0, + shmBytes: fileSize(shmPath) ?? 0, + pageSize: simpleNumberPragma(this.db, 'page_size') ?? 0, + pageCount: simpleNumberPragma(this.db, 'page_count') ?? 0, + freelistCount: simpleNumberPragma(this.db, 'freelist_count') ?? 0, + categoryBytes, + statusCounts, + orphanEvents: { count: orphanRow.count, bytes: orphanRow.bytes }, + reclaim, + }; + } + + /** + * Destructive cleanup: archive prunable job rows (optional, "all" mode only), + * delete old finished work or old event logs, sweep orphan events, then + * checkpoint + VACUUM. The status gate is re-checked in every DELETE WHERE, + * so a job whose status changed between the archive SELECT and the delete is + * archived harmlessly but never deleted. Safe to run while a server has the + * DB open — SQLite WAL lets the delete + VACUUM run concurrently (verified); + * a busy_timeout absorbs momentary write contention. + * + * VACUUM can't run inside a transaction, so it runs after the delete txn + * commits. If it fails (e.g. SQLITE_BUSY under heavy concurrent load) the + * deletes still stand; the result carries `vacuumed: false` + `vacuumError`. + */ + pruneCleanup(opts: { + mode: 'all' | 'events-only'; + cutoffMs: number; + archivePath?: string; + vacuum?: boolean; + }): CleanupPruneResult { + if (this.readonlyMode) { + throw new Error('cannot prune on a read-only StateStore'); + } + const { dbPath } = statePaths(this.projectPath); + const bytesBefore = fileSize(dbPath) ?? 0; + const vacuum = opts.vacuum !== false; + + // 1. Archive prunable job rows (streamed — low memory even for a 1 GiB+ + // output column). Events are not archived: they are transition logs. + let archivedJobs = 0; + if (opts.archivePath && opts.mode === 'all') { + fs.mkdirSync(path.dirname(opts.archivePath), { recursive: true }); + const fd = fs.openSync(opts.archivePath, 'w'); + try { + const stmt = this.db.prepare( + `SELECT * FROM jobs WHERE status IN (${TERMINAL_STATUS_LIST_SQL}) AND COALESCE(completed_at, created_at) < ?`, + ); + for (const row of stmt.iterate(opts.cutoffMs) as IterableIterator) { + fs.writeSync(fd, `${JSON.stringify(row)}\n`); + archivedJobs += 1; + } + } finally { + fs.closeSync(fd); + } + } + + // 2. Delete in one transaction; the status gate is re-checked in each WHERE. + const deletes = this.db.transaction(() => { + let deletedJobs = 0; + let deletedEvents = 0; + if (opts.mode === 'all') { + const cascade = this.db + .prepare( + `DELETE FROM events WHERE job_id IN ( + SELECT id FROM jobs WHERE status IN (${TERMINAL_STATUS_LIST_SQL}) + AND COALESCE(completed_at, created_at) < ?)`, + ) + .run(opts.cutoffMs); + deletedEvents += cascade.changes; + deletedJobs = this.db + .prepare( + `DELETE FROM jobs WHERE status IN (${TERMINAL_STATUS_LIST_SQL}) + AND COALESCE(completed_at, created_at) < ?`, + ) + .run(opts.cutoffMs).changes; + } else { + deletedEvents += this.db.prepare('DELETE FROM events WHERE timestamp < ?').run(opts.cutoffMs).changes; + } + // Sweep orphan events (job gone, or never existed) in both modes. + const orphanSweep = this.db + .prepare('DELETE FROM events WHERE job_id NOT IN (SELECT id FROM jobs)') + .run(); + return { deletedJobs, deletedEvents, orphanEventsSwept: orphanSweep.changes }; + })(); + + // 3. Checkpoint + VACUUM (outside the txn). Best-effort checkpoint; a VACUUM + // failure is reported, not fatal — the deletes already committed. + let vacuumed = false; + let vacuumError: string | undefined; + if (vacuum) { + try { + this.checkpoint('TRUNCATE'); + } catch { + /* checkpoint before VACUUM is best-effort */ + } + try { + this.db.exec('VACUUM'); + vacuumed = true; + // VACUUM writes the compacted db through the WAL. Checkpoint again so the + // main file actually shrinks (and the WAL sidecar truncates) now, not on a + // later close — otherwise a small resulting DB stays in the WAL and the + // file-size win is invisible until the process exits. + this.checkpoint('TRUNCATE'); + } catch (error) { + vacuumError = error instanceof Error ? error.message : String(error); + } + } + + this.syncStatusFiles(); + + return { + ...deletes, + archivedJobs, + bytesBefore, + bytesAfter: fileSize(dbPath) ?? 0, + vacuumed, + vacuumError, + }; + } + getBlockedJobs(): Job[] { const rows = this.db .prepare( From b94b3d547c58693f6032a7d382a4e0ffc70ba63e Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Mon, 20 Jul 2026 07:30:38 +0900 Subject: [PATCH 53/87] chore: bump to 26.7.4 (#18) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 42cc37a..6f1c69f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.7.3", + "version": "26.7.4", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From 6fdfdc72f3b954cc20f1513d7e77a3bf0a657621 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Mon, 20 Jul 2026 15:32:06 +0900 Subject: [PATCH 54/87] docs(agents): consolidate operational guide into AGENTS.md Fold the verified architecture, defaults, conventions, and repo-specific gotchas previously in CLAUDE.md into a self-contained AGENTS.md so future OpenCode sessions ramp up without an extra hop. Drop the 'Read CLAUDE.md' pointer; keep the Backlog.md workflow block. --- AGENTS.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9e7918f..fa1a7c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,6 @@ -Read CLAUDE.md +# AGENTS.md + +Operational guide for OpenCode sessions working in this repo. Compact by design — verify against source if anything here seems stale. @@ -22,3 +24,91 @@ Do not edit Backlog task, draft, document, decision, or milestone markdown files + +## What is Tenet + +Cross-platform AI agent plugin for 12+ hour autonomous development cycles. It orchestrates long-running jobs across multiple AI agent CLIs (Claude Code, OpenCode, Codex) using a persistent SQLite state store, exposed via an MCP server and CLI. + +## Commands + +Prefer `make` targets over raw `pnpm` scripts (the Makefile keeps the pre-publish gate and version flow consistent). `make help` lists targets. + +- `make check` — pre-publish gate. Order is **clean → build → typecheck → lint → test**. Note build runs *before* typecheck/lint/test (composite build must succeed first); this differs from `.github/workflows/ci.yml`, which runs typecheck → lint → test → build. Run `make check` (or the full sequence) before declaring work done. +- `make test-migrations` — DB migration tests only. +- Single test file: `npx vitest run src/core/state-store.test.ts` +- Tests matching a name pattern: `npx vitest run -t "job lifecycle"` +- `make docs-review` / `make docs-review-e2e` — AI doc/code consistency review; spawns real agent CLIs (costs time/money), repo-maintenance only, not shipped runtime. Flags via `DOCS_REVIEW_ARGS`, e.g. `DOCS_REVIEW_ARGS="--agents claude,codex" make docs-review`. +- E2E canaries: `make e2e-cli|api|web|agile|agile-full|all` — run real agent CLIs, cost money/time, use `vitest.e2e.config.ts`, **not** part of `pnpm test` or `make check`. Run only when explicitly requested. See `docs/e2e-runbook.md`. +- `make link` / `unlink` — global pnpm link for local dev. + +## Testing constraints + +- **Never spawn real agent CLIs in unit or Tier-1 integration tests.** Use the inline `MockAdapter` (unit) or `FakeAdapter` (`src/adapters/fake-adapter.ts`, Tier-1). Fixtures live in `tests/fixtures/fake-agents/` and are returned verbatim — do not pre-parse them. +- Tier-1 integration tests (`src/core/integration.test.ts`): use the `createHarness` helper + `matchers.*` (not inline predicates), always `await manager.waitForJob(...)`, and assert against DB state — not against your own mock bookkeeping. +- When adding a Tier-1 scenario: drop a fixture under `tests/fixtures/fake-agents/`, name it for intent, write an `it(...)` block, and confirm the test goes red when you break the code path it covers. Rules of thumb live in `tests/README.md`. +- E2E canaries are manual (Tier 2): `make e2e-cli|api|web|agile|agile-full|all`. They cost real money/time; never gate on them. + +## Architecture + +Four layers under `src/`: + +1. **Core** (`src/core/`) — `job-manager.ts` (DAG execution, heartbeat stall detection, retry, orphan recovery), `state-store.ts` (SQLite persistence in `.tenet/.state/tenet.db`, WAL mode), `migrations.ts` (DB schema), `runtime-config.ts` (defaults), `status-writer.ts` (status files). Tables: `jobs`, `events`, `steer_messages`, `config`. Each JobManager instance gets a UUID `serverId`; orphaned running jobs from a stale server are reset to `pending` only after the heartbeat timeout fires (`resetOrphanedJobs()`). Status files (`.tenet/status/status.md`, `job-queue.md`) auto-update on every job state transition. + +2. **Adapters** (`src/adapters/`) — `AgentAdapter` interface (`base.ts`): `isAvailable()`, `invoke(invocation)`. Three built-ins: `ClaudeAdapter` (`claude --print --output-format json`), `OpenCodeAdapter` (`opencode run --format json`), `CodexAdapter` (`codex exec --sandbox workspace-write`). `FakeAdapter` for Tier-1 tests. JobManager resolves the configured adapter strictly by name and fails closed if unavailable. Agent selection is CLI-only via `tenet config --agent `. + +3. **MCP Server** (`src/mcp/`) — 19 tools via `@modelcontextprotocol/server`, entry `src/mcp/index.ts`, one file per tool in `src/mcp/tools/`. Tools register with a Zod input schema and handler; return `jsonResult({...})` on success or `asToolError(error)` on failure. Key tools: `tenet_start_job`, `tenet_continue` (server-side continuation), `tenet_compile_context` (orchestrator context — not forwarded to worker subprocess), `tenet_register_jobs` (loads job DAG, requires `feature` slug), `tenet_retry_job`, `tenet_report_blocking_finding` (report-only escalation), `tenet_validate_clarity` / `tenet_validate_readiness` (hard gate before decomposition), `tenet_add_steer` / `tenet_process_steer` / `tenet_update_steer`, `tenet_start_eval` (dispatches critics from `.tenet/critics.json` — 3 built-in: code critic + test critic + interaction-e2e, plus any custom), `tenet_init`. + +4. **CLI** (`src/cli/`) — Commander.js: `init`, `serve`, `status`, `config`, `db`. `tenet init` scaffolds `.tenet/` and copies skills to `.claude/skills/tenet/` and `.agents/skills/tenet/` with version metadata. `tenet init --upgrade` runs pending DB migrations (`new StateStore(projectPath, { migrate: true })`) and, only with explicit consent (`--migrate-legacy` flag or interactive Y/N; `-y` does *not* auto-migrate), moves legacy doc dirs into `.tenet/archive/legacy-v1/`. It also runs a git-safety check: if `.tenet/.state/tenet.db` or its WAL sidecars are Git-tracked (the main DB-corruption vector), it warns with the exact `git rm --cached` command — detect-only, never auto-untrack. `tenet db check|backup|snapshot|restore-snapshot` provide read-only diagnostics, verified backup, and Git-safe portable snapshots under `.tenet/state-snapshot/`. A "star the repo" nudge (`src/cli/star-nudge.ts`) fires at the end of an interactive `init`/`--upgrade` — CLI-only, never from the autonomous skill boot loop; opt out with `TENET_NO_STAR_NUDGE`. + +## Defaults (verify in `src/core/runtime-config.ts` / `job-manager.ts`) + +- Job timeout: 120 minutes (`DEFAULT_JOB_TIMEOUT_MINUTES`); configurable via `tenet config --timeout `. +- Max retries: unlimited (`DEFAULT_MAX_RETRIES = -1`); finite budget via `tenet config --max-retries ` (values `unlimited`/`infinite`/`inf` accepted). +- Heartbeat stall timeout: 30 minutes (`heartbeatTimeoutMs ?? 30 * 60 * 1000`). + +## .tenet/ document layout + +`tenet init` scaffolds this; legacy top-level artifact dirs only appear via migration (`src/cli/init.ts` → `migrateLegacyDocuments`). + +- **Durable doctrine** — `.tenet/project/` (`overview.md`, `architecture.md`, `product.md`, `testing.md`, `design.md`, `design-components/`). Authored by context-bootstrap (brownfield) or post-interview crystallization (greenfield). Normal implementation jobs must **not** edit it. Stays current via the run-end drift review: jobs flag stale doctrine, the run consolidates proposals into `.tenet/runs//doctrine-proposals.md`, and an authorized `dev` job (`allow_project_doctrine_edits: true`) applies accepted ones. +- **Per-run artifacts** — `.tenet/runs//` where `` = `YYYY-MM-DD-`. Holds `interview.md`, `spec.md`, `harness.md`, `scenarios.md`, `decomposition.md`, `doctrine-proposals.md` (append-only), plus `research/`, `journal/`, `visuals/`. +- **Curated knowledge** — `.tenet/knowledge/` (durable facts promoted via `tenet_update_knowledge`). +- **Legacy evidence** — `.tenet/archive/legacy-v1/` (one-time migration target; reference-only). +- **Auto-generated from DB** — `.tenet/status/` (`status.md`, `job-queue.md`). +- **Portable snapshots** — `.tenet/state-snapshot/` (Git-safe, from `tenet db snapshot`). +- **Configurable eval critics** — `.tenet/critics.json` (roster) + `.tenet/critics/*.md` (custom-critic prompts). Read live by `tenet_start_eval` on every eval; missing/invalid falls back to the 3 built-ins. + +Current-run document identity flows through `artifact_paths`: `tenet_validate_readiness` validates exact spec/harness/scenarios/interview paths, `tenet_register_jobs` stores those plus `decomposition` (and `run_path`/`run_slug`) on every job, and `tenet_compile_context` reads the stored paths. Feature-only filename lookup is a compatibility fallback (strict dated patterns, not loose `*-{feature}.md`). Dev-type jobs get a "Deliverable Requirements" preamble (with retry context when `retryCount > 0`); every dispatched worker also gets a run-context block on `invocation.context` built from the job's stored `run_path`/`artifact_paths` by the dispatch path in `toInvocation`, not by `tenet_compile_context`. + +## Repo-specific gotchas + +- **MCP tool registry:** `src/mcp/tools/tool-names.ts` (`TENET_MCP_TOOL_NAMES`) is the single source of truth; a test in `src/mcp/tools/index.ts` asserts it matches actual registrations. To add a tool, add its name there only — `tenet init` reads this list to pre-approve tools in Claude/Codex/OpenCode configs. No other config edits needed. Removing a tool: drop it from the list; stale entries in existing user `.codex/config.toml` are harmless and `--upgrade` does not prune them. +- **DB schema changes belong only in `src/core/migrations.ts`.** Do not put semantic migrations inside normal `StateStore` startup. Normal startup detects incompatible (legacy or newer) schemas and instructs the user to run `tenet init --upgrade`; real migrations run only via `new StateStore(projectPath, { migrate: true })`, wired to that command. The `config.db_schema_version` key tracks schema version. +- **Never commit `.tenet/.state/`** (SQLite DB + WAL sidecars) — it is gitignored, and `tenet init` warns if it is Git-tracked. The root `tenet.db` is a local dev artifact, not source. +- `opencode.json` is generated/gitignored — do not hand-edit expecting it to persist. +- ESM throughout (`"type": "module"`, `NodeNext` module resolution, `strict` TS). Node >= 20, pnpm@10.17.1. ESLint honors the `_`-prefix for intentionally-unused args/vars/caught errors. +- Playwright MCP opt-in must update every supported agent config surface: `.mcp.json` (Claude Code), `.codex/config.toml` (Codex), `opencode.json` (OpenCode). +- `tenet_register_jobs` requires a `feature` slug that propagates to all jobs; pass `artifact_paths` so job context can't drift to stale documents. + +## Key types (`src/types/index.ts`) + +- **Job**: id, type (`dev|eval|critic_eval|interaction_e2e|mechanical_eval|integration_test|compile_context|health_check`), status (`pending|running|completed|failed|cancelled|blocked|blocked_on_finding`), params, agentName. +- **SteerMessage**: class (`context|directive|emergency`), status (`received|acknowledged|acted_on|resolved`). `tenet_process_steer` returns user steers in full and caps agent steers, so user input can't be crowded out by agent noise. +- **ContinuationState**: tracks DAG progress — next_job, blocked_jobs, completed/total counts. +- **Config**: explicit agent selection (default and per-type overrides), concurrency limits. + +## Versioning & release + +CalVer `YY.MM.PATCH` (e.g., `26.7.4`): same-month bump via `make bump-patch`, new-month reset via `make bump-month`. + +**Never tag, bump, or create a GitHub release without an explicit user request.** Release flow is automated and user-initiated — see `docs/release-runbook.md` for the full runbook including OIDC setup and failure recovery. In short: bump → commit → push → tag with user-facing annotated-tag notes → push tag → `.github/workflows/release.yml` creates a draft release → overwrite the draft notes with the same content → user clicks "Publish release" → `.github/workflows/publish.yml` runs typecheck + lint + test + build + `npm publish --provenance` via OIDC (no manual `npm publish`). PRs/pushes to `main` run `.github/workflows/ci.yml` (typecheck → lint → test → build). Manual `make release` is an emergency fallback only. + +## Planning docs + +Design documents in `docs/planning/` are numbered chronologically. Key references: +- `04_implementation_architecture.md` — architecture decisions and Ouroboros lessons +- `11_auto_testing_plan.md` — Tier-1 integration test plan + +## Layout (entrypoints) + +Built entrypoints: `dist/cli/index.js` (`tenet`) and `dist/mcp/index.js` (`tenet-mcp`). Package exports: `.` (root) and `./mcp`. `files` shipped to npm: `dist`, `skills`, `templates`. \ No newline at end of file From 26f7e4b74a4bfcb618494302abf6f1709ed1286c Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Mon, 20 Jul 2026 15:38:21 +0900 Subject: [PATCH 55/87] feat(critics): run-scoped critics + Critic Tailoring step (TASK-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a two-scope custom-critic layout so the eval gate is no longer generic when the spec is specific: - Global (durable): .tenet/critics/*.md — hand-authored via critics.md. - Run-scoped (ephemeral): .tenet/runs//critics/*.md — generated per run from interview/spec context, pruned or promoted at run end. No code change: prompt_file already resolves project-relative or absolute (src/mcp/tools/tenet-start-eval.ts), so run-scoped paths dispatch today. Skill changes: - skills/tenet/critics.md: two-scope layout, run-tailoring workflow, and the run-end prune/promote lifecycle. - skills/tenet/phases/02-spec-and-harness.md: new §7 Critic Tailoring, after the readiness gate, before decomposition (Standard/Full; Quick skips). - skills/tenet/phases/05-execution-loop.md: run-end run-scoped critic lifecycle alongside the doctrine drift review. - skills/tenet/SKILL.md: Phase Map / Gates pointer to the new step. Docs: AGENTS.md and CLAUDE.md .tenet/ document layout updated to describe both critic scopes and the per-run critics/ directory. Deferred follow-ups (linked on TASK-002): TASK-007 per-job critics in the DAG, TASK-036 must/moderate/advisory tiers, TASK-045 model-tier-aware critic splitting, TASK-004 critic model selection. --- ...-002 - Custom-critics-scoped-within-run.md | 42 ++++++- ...ask-048 - Run-local-critic-roster-merge.md | 40 +++++++ AGENTS.md | 4 +- CLAUDE.md | 4 +- skills/tenet/SKILL.md | 4 +- skills/tenet/critics.md | 108 +++++++++++++++++- skills/tenet/phases/02-spec-and-harness.md | 58 ++++++++++ skills/tenet/phases/05-execution-loop.md | 2 + 8 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 .backlog/tasks/task-048 - Run-local-critic-roster-merge.md diff --git a/.backlog/tasks/task-002 - Custom-critics-scoped-within-run.md b/.backlog/tasks/task-002 - Custom-critics-scoped-within-run.md index 7cdeb69..62be8ee 100644 --- a/.backlog/tasks/task-002 - Custom-critics-scoped-within-run.md +++ b/.backlog/tasks/task-002 - Custom-critics-scoped-within-run.md @@ -4,7 +4,9 @@ title: Custom critics scoped within run status: To Do assignee: [] created_date: '2026-07-07 02:16' -labels: [] +updated_date: '2026-07-20 22:28' +labels: + - skill dependencies: [] priority: medium ordinal: 2000 @@ -22,3 +24,41 @@ On top of default critics + user custom critics, tenet should suggest run-specif - [ ] #2 Run-specific critics are applied alongside default and user custom critics - [ ] #3 Related to #13 (weak model critics) and #24 (critic model selection) + +## Implementation Plan + + +Two-scope critic layout (no code change — prompt_file already resolves project-relative paths): +- Global (durable): .tenet/critics/*.md, hand-authored via critics.md. +- Run-scoped (ephemeral): .tenet/runs//critics/*.md, generated per run, pruned or promoted at run end. + +New loop step (Critic Tailoring) in phases/02-spec-and-harness.md after the readiness gate, before decomposition: +1. Read interview.md + spec.md + scenarios.md + existing .tenet/critics.json + global .tenet/critics/*.md. +2. Identify run-specific risk surfaces the 3 built-ins under-cover (concrete focus, not generic review). +3. For each gap: write .tenet/runs//critics/.md following the critics.md output contract; add a roster entry pointing at it. Reuse an existing global critic if one already covers the gap. +4. Quick mode skips tailoring; Standard/Full run it. + +Run-end prune/promote in phases/05-execution-loop.md run-completion section: +- At all_done, for each run-scoped critic: drop by default; promote to global if it caught a real failure this run (move file to .tenet/critics/, rewrite roster entry). + +Files: skills/tenet/critics.md (two-scope layout + tailoring workflow + prune/promote), skills/tenet/phases/02-spec-and-harness.md (new §7), skills/tenet/phases/05-execution-loop.md (run-end critic lifecycle), skills/tenet/SKILL.md (Phase Map pointer), AGENTS.md + CLAUDE.md (.tenet/ document layout note). + +Deferred (linked follow-ups): TASK-007 per-job critics in DAG, TASK-036 must/moderate/advisory tiers, TASK-045 model-tier-aware critic splitting, TASK-004 critic model selection. + + +## Implementation Notes + + +Workflow/skill only. No code change (prompt_file already resolves project-relative paths). + +§4.5 structure (three steps): +- Step 0 — Orphan sweep: scan .tenet/critics.json for entries whose prompt_file is under .tenet/runs//critics/ (not the current run). Drop as stale. Defensive — matches Tenet orphan-recovery pattern. Becomes a no-op once TASK-048 (run-local roster + merge) lands. +- Step 1 — Review global custom critics against this run's spec: keep / disable for this run (enabled:false, do not delete file) / flag stale scope as doctrine drift. Globals earn their place once but don't apply to every feature. +- Step 2 — Generate run-scoped critics for gaps the enabled globals don't cover. + +Run-end lifecycle (§4.5 subsection): drop (default) / promote to global (if caught real failure AND repo-wide) / restore disabled globals (disable is per-run by default, must not silently persist). + +Decision (2026-07-20): run-local roster file (.tenet/runs//critics.json) deferred to new TASK-048. It is load-bearing for TASK-007 (per-job critics), TASK-036 (tiers), TASK-047 (hybrid dispatch), TASK-004/045/001 (model selection) — shared infrastructure, not a TASK-002 detail. TASK-002 ships with the file-path-scan orphan sweep; TASK-048 makes it structural. + +Follow-ups: TASK-048 (run-local roster + merge), TASK-007 (per-job critics), TASK-036 (tiers), TASK-045 (model-tier-aware critic splitting), TASK-004 (critic model selection). + diff --git a/.backlog/tasks/task-048 - Run-local-critic-roster-merge.md b/.backlog/tasks/task-048 - Run-local-critic-roster-merge.md new file mode 100644 index 0000000..9e9d738 --- /dev/null +++ b/.backlog/tasks/task-048 - Run-local-critic-roster-merge.md @@ -0,0 +1,40 @@ +--- +id: TASK-048 +title: Run-local critic roster + merge +status: To Do +assignee: [] +created_date: '2026-07-20 22:28' +labels: + - feature + - critics + - infrastructure +dependencies: + - TASK-002 +priority: medium +ordinal: 48000 +--- + +## Description + + +Introduce a run-local critic roster at .tenet/runs//critics.json that tenet_start_eval merges with the global .tenet/critics.json per job. Shared infrastructure that unblocks the critic-config cluster. + +Why separate from TASK-002: TASK-002 shipped workflow-only (orphan sweep via file-path scan in phases/02-spec-and-harness.md §4.5 Step 0). The run-local roster is a code change to critic-roster.ts + tenet-start-eval.ts and is load-bearing for several downstream tasks, so it gets its own task rather than being bundled into a workflow change. + +Unblocks: +- TASK-007 (per-job critics): per-job assignment needs a per-run home, not a mutable global file. +- TASK-036 (must/moderate/advisory tiers): per-run tier overrides need a run-local home if tier varies by feature. +- TASK-047 (hybrid parallel/sequential dispatch): per-critic parallel_safe metadata needs a home if it varies by run. +- TASK-004/045/001 (model selection & model-tier-aware splitting): per-run model assignments keyed by model_tier. + +Side effect: makes TASK-002's Step 0 orphan sweep structural (run-scoped state dies with the run dir, no orphans possible) — Step 0 becomes a no-op once this lands. + + +## Acceptance Criteria + +- [ ] #1 critic-roster.ts loads .tenet/runs//critics.json when present and merges with global roster (run-local entries win on id collision) +- [ ] #2 tenet_start_eval resolves the merged roster per job +- [ ] #3 Missing/invalid run-local roster falls back to global-only (no regression) +- [ ] #4 Backward compatible: existing .tenet/critics.json-only projects work unchanged +- [ ] #5 TASK-002 Step 0 orphan sweep becomes a no-op once run-scoped critics live in the run-local roster + diff --git a/AGENTS.md b/AGENTS.md index fa1a7c6..1e5cf31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,12 +71,12 @@ Four layers under `src/`: `tenet init` scaffolds this; legacy top-level artifact dirs only appear via migration (`src/cli/init.ts` → `migrateLegacyDocuments`). - **Durable doctrine** — `.tenet/project/` (`overview.md`, `architecture.md`, `product.md`, `testing.md`, `design.md`, `design-components/`). Authored by context-bootstrap (brownfield) or post-interview crystallization (greenfield). Normal implementation jobs must **not** edit it. Stays current via the run-end drift review: jobs flag stale doctrine, the run consolidates proposals into `.tenet/runs//doctrine-proposals.md`, and an authorized `dev` job (`allow_project_doctrine_edits: true`) applies accepted ones. -- **Per-run artifacts** — `.tenet/runs//` where `` = `YYYY-MM-DD-`. Holds `interview.md`, `spec.md`, `harness.md`, `scenarios.md`, `decomposition.md`, `doctrine-proposals.md` (append-only), plus `research/`, `journal/`, `visuals/`. +- **Per-run artifacts** — `.tenet/runs//` where `` = `YYYY-MM-DD-`. Holds `interview.md`, `spec.md`, `harness.md`, `scenarios.md`, `decomposition.md`, `doctrine-proposals.md` (append-only), plus `research/`, `journal/`, `visuals/`, and `critics/` (run-scoped custom critics from the Critic Tailoring step in `skills/tenet/phases/02-spec-and-harness.md` § 4.5 — pruned or promoted at run end by the same section's *Run-end critic lifecycle*). - **Curated knowledge** — `.tenet/knowledge/` (durable facts promoted via `tenet_update_knowledge`). - **Legacy evidence** — `.tenet/archive/legacy-v1/` (one-time migration target; reference-only). - **Auto-generated from DB** — `.tenet/status/` (`status.md`, `job-queue.md`). - **Portable snapshots** — `.tenet/state-snapshot/` (Git-safe, from `tenet db snapshot`). -- **Configurable eval critics** — `.tenet/critics.json` (roster) + `.tenet/critics/*.md` (custom-critic prompts). Read live by `tenet_start_eval` on every eval; missing/invalid falls back to the 3 built-ins. +- **Configurable eval critics** — two scopes, both wired through `.tenet/critics.json`: **global** durable critics at `.tenet/critics/*.md` (hand-authored via `skills/tenet/critics.md`) and **run-scoped** ephemeral critics at `.tenet/runs//critics/*.md` (generated per run by Critic Tailoring). `critics.json` is the roster, read live by `tenet_start_eval` on every eval; missing/invalid falls back to the 3 built-ins. `prompt_file` resolves project-relative or absolute, so either scope works with no code change. Current-run document identity flows through `artifact_paths`: `tenet_validate_readiness` validates exact spec/harness/scenarios/interview paths, `tenet_register_jobs` stores those plus `decomposition` (and `run_path`/`run_slug`) on every job, and `tenet_compile_context` reads the stored paths. Feature-only filename lookup is a compatibility fallback (strict dated patterns, not loose `*-{feature}.md`). Dev-type jobs get a "Deliverable Requirements" preamble (with retry context when `retryCount > 0`); every dispatched worker also gets a run-context block on `invocation.context` built from the job's stored `run_path`/`artifact_paths` by the dispatch path in `toInvocation`, not by `tenet_compile_context`. diff --git a/CLAUDE.md b/CLAUDE.md index 71a76c0..3898900 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,12 +66,12 @@ The system has four layers: Tenet uses a document lifecycle layout. `tenet init` scaffolds only this layout; legacy top-level artifact directories only appear via migration (see `src/cli/init.ts` → `migrateLegacyDocuments`). - **Durable doctrine** — `.tenet/project/` (`overview.md`, `architecture.md`, `product.md`, `testing.md`, `design.md`, `design-components/`). Authored by the context-bootstrap phase (brownfield) or post-interview crystallization (greenfield); normal implementation jobs must not edit it. Doctrine stays current via the run-end drift review: jobs flag stale doctrine as drift notes, the run consolidates them into `.tenet/runs//doctrine-proposals.md`, and an authorized `dev` job (`allow_project_doctrine_edits: true`) applies accepted proposals then re-runs the bootstrap gate. -- **Per-run artifacts** — `.tenet/runs//` where `` = `YYYY-MM-DD-`. Holds `interview.md`, `spec.md`, `harness.md`, `scenarios.md`, `decomposition.md`, `doctrine-proposals.md` (run-end doctrine-drift proposals, append-only), plus `research/`, `journal/`, and `visuals/` subdirs. +- **Per-run artifacts** — `.tenet/runs//` where `` = `YYYY-MM-DD-`. Holds `interview.md`, `spec.md`, `harness.md`, `scenarios.md`, `decomposition.md`, `doctrine-proposals.md` (run-end doctrine-drift proposals, append-only), plus `research/`, `journal/`, `visuals/`, and `critics/` (run-scoped custom critics from the Critic Tailoring step in `skills/tenet/phases/02-spec-and-harness.md` § 4.5 — pruned or promoted at run end by the same section's *Run-end critic lifecycle*). - **Curated knowledge** — `.tenet/knowledge/` (durable, concern-oriented facts promoted via `tenet_update_knowledge`). - **Legacy evidence** — `.tenet/archive/legacy-v1/` (one-time migration target for pre-lifecycle top-level dirs: `spec/`, `interview/`, `harness/`, `decomposition/`, `journal/`, `visuals/`, `bootstrap/`, `steer/`, `knowledge/`, `DESIGN.md`). Reference-only, not active doctrine. - **Auto-generated from DB** — `.tenet/status/` (`status.md`, `job-queue.md`). - **Portable snapshots** — `.tenet/state-snapshot/` (Git-safe snapshots from `tenet db snapshot`). -- **Configurable eval critics** — `.tenet/critics.json` (roster of enabled built-in + custom critics) and `.tenet/critics/*.md` (custom-critic prompts). Read live by `tenet_start_eval` on every eval; missing/invalid file falls back to the 3 built-ins. Authored via the critic-designer doc `skills/tenet/critics.md`. The blocking-finding resume gate tracks the same configured set via the `expected_eval_stages` each critic job carries. +- **Configurable eval critics** — two scopes, both wired through `.tenet/critics.json`: **global** durable critics at `.tenet/critics/*.md` (hand-authored via `skills/tenet/critics.md`) and **run-scoped** ephemeral critics at `.tenet/runs//critics/*.md` (generated per run by Critic Tailoring). `critics.json` is the roster, read live by `tenet_start_eval` on every eval; missing/invalid falls back to the 3 built-ins. `prompt_file` resolves project-relative or absolute, so either scope works with no code change. The blocking-finding resume gate tracks the same configured set via the `expected_eval_stages` each critic job carries. Current-run document identity flows through `artifact_paths`: `tenet_validate_readiness` validates exact spec/harness/scenarios/interview paths, `tenet_register_jobs` stores those plus `decomposition` (and `run_path`/`run_slug`) on every job, and `tenet_compile_context` reads the stored paths. `tenet_compile_context` assembles the **orchestrator's** working context (its output returns to the host agent running the skill — it is not forwarded to the worker subprocess, which gets its own run context built on dispatch). Feature-only filename lookup is a compatibility fallback only; it uses strict dated document patterns rather than loose `*-{feature}.md` matching. diff --git a/skills/tenet/SKILL.md b/skills/tenet/SKILL.md index a053c94..b8d8fd5 100644 --- a/skills/tenet/SKILL.md +++ b/skills/tenet/SKILL.md @@ -181,8 +181,8 @@ Examples: - Readiness gate: after spec/harness/scenarios and required visuals, call `tenet_validate_readiness` with exact `artifact_paths` and resolve blockers before decomposition. - Plan/use checkpoints: in agile mode, block until the user responds with `approve`, `redirect: ...`, `cancel`, or `done` as defined in `phases/07-agile-checkpoints.md`. - Pre-execution gate: before dispatching a DAG or slice DAG, summarize mode, job count, key spec decisions, and harness constraints. Ask for confirmation unless the user explicitly asked to start without oversight. -- Eval gate: after every completed job, call `tenet_start_eval(...)` and wait according to the returned `execution_mode`. The critic set comes from `.tenet/critics.json` (3 built-in by default; the project may disable any and add custom critics). All returned eval jobs must pass. -- To add a repo-specific critic (security, a11y, API contract, etc.), follow `critics.md` (the critic designer) — it produces the roster entry + prompt file so you don't hand-write prompts. +- Eval gate: after every completed job, call `tenet_start_eval(...)` and wait according to the returned `execution_mode`. The critic set comes from `.tenet/critics.json` (3 built-in by default; the project may disable any and add custom critics — **global** at `.tenet/critics/*.md` and **run-scoped** at `.tenet/runs//critics/*.md` from the Critic Tailoring step). All returned eval jobs must pass. +- To add a repo-specific critic (security, a11y, API contract, etc.) by hand, follow `critics.md` (the critic designer) — it produces the roster entry + prompt file so you don't hand-write prompts. To tailor critics to a feature's risks automatically during a run, the spec phase runs Critic Tailoring (`phases/02-spec-and-harness.md` § 4.5); the run-end lifecycle prunes or promotes them (same section, *Run-end critic lifecycle*). ## Execution Rules diff --git a/skills/tenet/critics.md b/skills/tenet/critics.md index 8e8d716..6c1b5c7 100644 --- a/skills/tenet/critics.md +++ b/skills/tenet/critics.md @@ -1,8 +1,10 @@ # Critic Designer — authoring a custom evaluation critic On demand. Not a numbered loop phase — read this when the user asks Tenet to -"create a critic for this repo," or when a run keeps hitting a failure class -the three built-in critics (code, test, interaction-e2e) under-cover. +"create a critic for this repo," when a run keeps hitting a failure class +the three built-in critics (code, test, interaction-e2e) under-cover, or when +**Critic Tailoring** (`phases/02-spec-and-harness.md` § 4.5) sends you here to +draft a run-scoped critic from the just-written spec. Tenet's eval gate is configurable. The critic set lives in `.tenet/critics.json`; each critic runs as an **independent-context eval job** @@ -78,6 +80,44 @@ beats "check for security issues." The file is read live on every eval — edit it and the next `tenet_start_eval` reflects the change with no restart. Invalid JSON falls back to the 3 built-ins. +## Two scopes: global vs run-scoped critics + +Custom critics live in **two scopes**, both wired through the same roster file. +`prompt_file` is resolved as project-relative or absolute, so either path shape +just works — no code change is needed to use either scope. + +- **Global (durable):** `.tenet/critics/.md`. Hand-authored (via the Design + Workflow below) for risk surfaces that apply to every run in this repo — e.g. + a security critic for a payments API, an a11y critic for a UI project. Listed + in `.tenet/critics.json` with `prompt_file: ".tenet/critics/.md"`. Persists + across runs; revise by hand or via the critic designer on demand. +- **Run-scoped (ephemeral):** `.tenet/runs//critics/.md`. Generated + by the **Critic Tailoring** step (`phases/02-spec-and-harness.md` § 4.5) from the + just-written interview/spec — for risks *this* feature surfaces that the + built-ins and existing global critics under-cover. Listed in `.tenet/critics.json` + with `prompt_file: ".tenet/runs//critics/.md"`. Pruned or promoted + at run end (see *Run-end critic lifecycle* below). + +The roster is the single dispatch list — it mixes global and run-scoped entries +freely. At run end, the run-scoped entries are either dropped (the default) or +promoted to global (if the critic caught a real failure this run and the risk +applies repo-wide). This keeps the roster from accumulating stale per-run critics +while preserving the ones that earned a durable place. + +A run-scoped roster entry looks identical to a global one except for the path: + +```json +{ + "id": "oauth-token-leak", + "builtin": false, + "enabled": true, + "stage": "oauth_token_leak_critic", + "job_type": "critic_eval", + "prompt_file": ".tenet/runs/2026-07-20-oauth/critics/oauth-token-leak.md", + "full_context": true +} +``` + ## Grounding & backward compatibility `full_context` is optional and defaults to `true` on every critic — built-in or @@ -131,6 +171,12 @@ not-passed. So end the prompt with the literal contract line above. ## Design workflow +This section covers **two entry points**: the durable global-critic workflow +(on-demand, hand-authored) and the **run-tailoring workflow** (called from +`phases/02-spec-and-harness.md` § 4.5 with the spec already in hand). + +### A. Global critic (durable, on-demand) + 1. **Find the gap.** Read `.tenet/project/**` (especially `testing.md`, `architecture.md`) and recent run journals under `.tenet/runs/*/journal/`. What failure class keeps slipping past the three built-ins? Pick a concrete @@ -141,7 +187,8 @@ not-passed. So end the prompt with the literal contract line above. output contract line. The prompt receives the job scope preamble (eval-only within this job) plus a `## Implementation Output` section automatically — tell it to inspect that output. -3. **Register** the critic in `.tenet/critics.json` with `enabled: true`. +3. **Register** the critic in `.tenet/critics.json` with `enabled: true` and + `prompt_file: ".tenet/critics/.md"`. 4. **Smoke-test.** Run `tenet_start_eval` against one completed job, then `tenet_job_result` on the critic's job id. Confirm its output parses (has `passed` + `findings` with valid `category`) and that a deliberate violation @@ -149,6 +196,61 @@ not-passed. So end the prompt with the literal contract line above. 5. **Watch reliability.** If the critic routinely fails to emit the contract, tighten the prompt's closing instruction before trusting its verdict. +### B. Run-tailored critic (ephemeral, called from the spec phase) + +You are invoked by `phases/02-spec-and-harness.md` § 4.5 with `interview.md`, +`spec.md`, and `scenarios.md` already written for this run. §4.5 owns the full +procedure — three steps: orphan sweep → review global critics against this run's +spec → generate run-scoped critics for gaps the enabled globals don't cover. +Read §4.5 and follow it; don't re-derive the procedure here. + +This section is the **prompt-shape reference** for Step 2's generate part — how +to write the critic prompt file once §4.5 has identified a gap. + +- **Write the prompt** at `.tenet/runs//critics/.md` (create the + directory). Same prompt shape as global critics: state the focus, what counts + as a finding, the severity rule (everything is blocking), and end with the + output contract line (see *Output contract* above). Use a `stage` name that + matches the critic's focus (e.g. `oauth_token_leak_critic`). +- **Register** each run-scoped critic in `.tenet/critics.json` with + `prompt_file: ".tenet/runs//critics/.md"`. Preserve all existing + entries (built-ins + global customs, including any Step 1 disabled) — append + only. +- **Prefer reuse over creation.** If a still-enabled global critic already covers + the gap, do not create a run-scoped duplicate — the global one already runs on + every eval. +- **Defer if no gaps.** If the spec surfaces no risks beyond what the built-ins + and enabled globals already cover, write no run-scoped critic and record that + decision in the run journal (`tenet_update_knowledge(type="journal", ...)`) + so the run-end lifecycle step knows tailoring was considered, not skipped by + mistake. + +### Run-end critic lifecycle + +At run completion (`phases/05-execution-loop.md` → *Run Completion*), after the +doctrine drift review, handle run-scoped critics before the final report: + +1. **For each run-scoped critic** (roster entries whose `prompt_file` is under + `.tenet/runs//critics/`): + - **Default: drop.** Remove its roster entry from `.tenet/critics.json` and + leave the prompt file in `.tenet/runs//critics/` (it dies with the + run directory; no cleanup needed). This is the right call when the critic + passed on every job it ran against, or the run had no jobs that exercised + its focus. + - **Promote to global** if **both** are true: (a) the critic caught at least + one real failure this run (a `passed: false` that triggered a dev retry and + the retry fixed the issue), and (b) the risk applies repo-wide, not just to + this feature. To promote: move the prompt file to `.tenet/critics/.md`, + rewrite the roster entry's `prompt_file` to the new path, and note the + promotion in the run journal. +2. **Restore disabled globals.** For any global critic Step 1 disabled for this + run, set `enabled: true` again unless the run surfaced a reason to keep it + off (in which case a doctrine-drift note was already written in Step 1). A + disable is per-run by default; it must not silently persist into the next run. +3. **Never block the run** on this step. State the count (dropped / promoted / + disabled-restored) in the final report and continue. The user can revise + global promotions between runs by hand. + ## Worked example — a security critic `.tenet/critics/security.md`: diff --git a/skills/tenet/phases/02-spec-and-harness.md b/skills/tenet/phases/02-spec-and-harness.md index 4c30511..20cc308 100644 --- a/skills/tenet/phases/02-spec-and-harness.md +++ b/skills/tenet/phases/02-spec-and-harness.md @@ -135,6 +135,63 @@ Define success and failure shapes: ### Anti-Scenarios (Failure) 1. [Concrete failure mode to prevent] +## 4.5 Critic Tailoring (Standard/Full only — adversarial eval design, peer to the harness) + +The harness (§3) is the *declarative infra* the dev job obeys (lint, tests, danger zones, iron laws). This step designs the *independent adversarial lane* — the custom critics that judge the dev job's output on every eval. It operates on **two scopes** of custom critics: + +- **Global** (`.tenet/critics/*.md`, durable) — authored by hand or promoted from a prior run. Apply to every run, so they can drift out of relevance to a specific feature. +- **Run-scoped** (`.tenet/runs/{run_slug}/critics/*.md`, ephemeral) — tailored to this feature's risks. Generated by this step, pruned or promoted at run end (see *Run-end critic lifecycle* below). + +The 3 built-in critics (code, test, interaction-e2e) run on every eval regardless. This step keeps the custom-critic set *current* for this run: sweeps orphans, reviews globals against the spec, and generates run-scoped critics for risks the globals don't cover. + +This step is **skipped in Quick mode** (Quick's compact spec doesn't carry enough risk-surface detail to tailor against). Standard and Full run it. + +Read `critics.md` (the Critic Designer) before this step — it owns the two-scope layout, the prompt shape, and the mandatory output contract. + +#### Step 0 — Orphan sweep (before generating anything) + +Scan `.tenet/critics.json` for entries whose `prompt_file` is under `.tenet/runs//critics/` where `` is **not** the current run. These are leftovers from an interrupted previous run that never reached its run-end lifecycle (crash, user halt, emergency steer). They are stale by definition — they were tailored to a different feature. + +- **Drop** each orphan entry from `.tenet/critics.json`. The prompt file stays in its run directory (dies with that run; no file cleanup needed here). +- If the orphan's risk still applies to *this* feature, it will be re-generated as a run-scoped critic for the current run in Step 2, or already exists as a global critic — so dropping loses nothing. +- Note the sweep in the run journal via `tenet_update_knowledge(type="journal", title="critic tailoring: swept orphaned run-scoped critics", ...)` with the dropped ids. This matches Tenet's orphan-recovery pattern (`resetOrphanedJobs()` in `job-manager.ts`) — the assumption is that runs get interrupted, so startup must clean up. + +If no orphaned entries exist (previous run completed its lifecycle, or this is the first run), Step 0 is a no-op — continue. + +#### Step 1 — Review global custom critics against this run's spec + +Read `.tenet/critics.json` and `.tenet/critics/*.md`. For each enabled global custom critic, weigh it against `.tenet/runs/{run_slug}/interview.md`, `spec.md`, and `scenarios.md` (§4, just written): + +- **Still justified** — the critic's focus applies to a risk this feature surfaces. Leave it enabled. +- **Not relevant this run** — the critic's focus doesn't apply (e.g. a security critic for a feature that touches no auth or input handling). Set `enabled: false` in its roster entry for this run. **Do not delete the prompt file** — it's durable and will be re-enabled on the next run that needs it. Note the disable in the run journal (`title="critic tailoring: disabled global critic for this run", ...` with the id and one-line reason). +- **Stale scope** — the critic's focus was right once but the project moved; its prompt no longer matches the current risk surface. Do not edit the global critic here — note in the run journal that it seems stale and why (e.g. `title="stale global critic: ", ...` with the id and one-line reason), so the user or run-end review can revise it directly. + +This step is the *review* counterpart to the run-end *promote*: globals earned their place, but earning it once doesn't mean it applies to every feature. + +#### Step 2 — Generate run-scoped critics for gaps the globals don't cover + +After Step 1, the global critic set is settled for this run. Now look for risks this feature surfaces that the built-ins + the still-enabled globals under-cover: + +1. **Identify run-specific gaps.** Pick concrete foci grounded in spec sections — e.g. "spec § 4 adds OAuth token refresh → token leak in logs critic", "spec § 7 adds a raw SQL migration → injection critic", "spec § 9 adds a public DELETE endpoint → authz critic." Do not propose a generic "review the code" critic. +2. **For each gap, write the prompt** at `.tenet/runs/{run_slug}/critics/.md` (create the directory) following `critics.md`'s prompt shape and mandatory output contract. Use a `stage` name matching the focus. +3. **Register** each in `.tenet/critics.json` with `prompt_file: ".tenet/runs/{run_slug}/critics/.md"`. **Append only** — preserve every existing entry (built-ins + global customs, including any Step 1 disabled). +4. **Prefer reuse over creation.** If a still-enabled global critic already covers the gap, do not create a run-scoped duplicate — the global one already runs on every eval. +5. **Defer if no gaps.** If the spec surfaces no risks beyond what built-ins + enabled globals already cover, write no run-scoped critic and record that decision in the run journal via `tenet_update_knowledge(type="journal", title="critic tailoring: no run-scoped critics needed", ...)` so the run-end lifecycle step knows tailoring was considered, not skipped by mistake. + +The roster is live-read on every `tenet_start_eval`, so the new critics apply to every job dispatched after this step — no restart, no extra wiring. + +### Run-end critic lifecycle + +This is the closing bookend of Critic Tailoring, so it lives here. At run completion (`phases/05-execution-loop.md` → *Run Completion*), after the doctrine drift review and before the final `tenet_get_status()` report, handle run-scoped critics per `critics.md` → *Run-end critic lifecycle*. The short version: + +1. **Find run-scoped entries** in `.tenet/critics.json` — any custom critic whose `prompt_file` is under `.tenet/runs/{run_slug}/critics/`. +2. **Default: drop.** Remove the roster entry. The prompt file stays in the run directory (it dies with the run; no file cleanup needed). Right call when the critic passed on every job it ran against, or no job exercised its focus. +3. **Promote to global** if **both** are true: (a) the critic caught at least one real failure this run (a `passed: false` that triggered a dev retry and the retry fixed the issue), and (b) the risk applies repo-wide, not just to this feature. To promote: move the prompt file to `.tenet/critics/.md`, rewrite the roster entry's `prompt_file` to the new path, note the promotion in the run journal. +4. **Restore disabled globals.** For any global critic Step 1 disabled for this run, set `enabled: true` again unless the run surfaced a reason to keep it off (in which case write a doctrine-drift note per Step 1). A disable is per-run by default; it should not silently persist into the next run. +5. **Never block.** State the count (dropped / promoted / disabled-restored) in the final report and continue. The user can revise global promotions between runs by hand. + +If Critic Tailoring recorded "no run-scoped critics needed" in the journal, there is nothing to do for Step 2's output — continue to the final report. + ## 5. Validation Checklist Verify these before proceeding: - [ ] `.tenet/runs/{run_slug}/spec.md` exists with the YAML front matter (`delivery_mode: autonomous | agile`) and all required sections. @@ -144,6 +201,7 @@ Verify these before proceeding: - [ ] `.tenet/runs/{run_slug}/scenarios.md` has 3+ scenarios and 3+ anti-scenarios. - [ ] If the project is UI-facing, game/canvas-based, visual, TUI, CLI workflow-oriented, API workflow-oriented, or otherwise user-interactive, required artifacts from `phases/03-visuals.md` exist in `.tenet/runs/{run_slug}/visuals/` and the spec references them. - [ ] Harness danger zones are populated. +- [ ] If Standard/Full mode: Critic Tailoring (§4.5) ran — run-scoped critics written OR "no run-scoped critics needed" journaled. **Do NOT proceed to decomposition until all three files are written and this checklist passes.** diff --git a/skills/tenet/phases/05-execution-loop.md b/skills/tenet/phases/05-execution-loop.md index a708f3d..c3a8cb3 100644 --- a/skills/tenet/phases/05-execution-loop.md +++ b/skills/tenet/phases/05-execution-loop.md @@ -93,6 +93,8 @@ tenet_start_job(job_type="dev", params={ `allow_project_doctrine_edits: true` authorizes the `.tenet/project/**` edit and is eval-safe — the code critic's `scope_conflict` check honors it (see `phases/06-evaluation.md`). After the job passes eval, **re-run the bootstrap gate** (`phases/00-context-bootstrap.md`) to confirm doctrine is coherent, then set the proposal's `status: applied`. Doctrine is re-synthesized and re-gated, not raw-patched — that is what "maintained correctly" means. +After the doctrine drift review, handle run-scoped critics per the **Run-end critic lifecycle** in `phases/02-spec-and-harness.md` § 4.5 (prune or promote run-scoped critics; never block the run). That step owns the bookkeeping — from the execution loop's perspective a critic is just another eval job. + ## Operational Rules ### Use MCP Tools, Not Untracked Work From 589005f909c7443a609f2ea8f672d69d09431990 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Wed, 22 Jul 2026 07:17:31 +0900 Subject: [PATCH 56/87] docs(skill): split mode selection by timing, ask model_tier in all modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SKILL.md's 'Mode Selection' section conflated two decisions at two different times: the boot-time Full/Standard/Quick pick and the end-of-interview delivery-mode + model-tier checkpoint. The latter also duplicated phases/01-interview.md §3 with its own wording for the options and defaults, which had drifted. - SKILL.md: collapse the end-of-interview block to a 4-line pointer to interview §3. 'Mode Selection' is now boot-time only. - 01-interview.md §3: split so delivery_mode is explicitly Full-only (Standard/Quick are always autonomous) and model_tier is explicitly asked in all three modes. - 01-interview.md §5/§7/§9/§10: mark delivery-mode as Full-only across the transcript template, YOLO section, anti-skip, and Quick mode. - 04-decomposition.md: drop the stale Standard/Quick carve-out for a missing model_tier (now asked everywhere; missing is a default fallback). --- skills/tenet/SKILL.md | 12 +++--------- skills/tenet/phases/01-interview.md | 16 ++++++++++------ skills/tenet/phases/04-decomposition.md | 2 +- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/skills/tenet/SKILL.md b/skills/tenet/SKILL.md index b8d8fd5..38ac37e 100644 --- a/skills/tenet/SKILL.md +++ b/skills/tenet/SKILL.md @@ -128,16 +128,10 @@ Every mode, Quick included, still: completes the boot sequence first, reads the In YOLO mode (`phases/01-interview.md` § 7), the agent selects and records the mode autonomously with `Selection basis: yolo_agent_decision` and no interactive prompt; it still records the `## Mode Selection` block. -In Full mode, delivery mode selection is a standalone required checkpoint at the end of the interview: +At the end of the interview, two mode-like decisions are captured (see `phases/01-interview.md` § 3 for the full procedure): -- `autonomous`: one end-to-end run with no mid-run user checkpoints. -- `agile`: sliced delivery with an initial plan-checkpoint and use-checkpoints after each slice. - -Ask a dedicated question that presents both options. Do not bury delivery mode inside a bundled defaults question, and do not infer it from approval of unrelated defaults. - -Default to `autonomous` only after the user has seen both options and responds with uncertainty or no preference. Record the prompt, response, selected mode, and selection basis in the interview transcript; copy the selected mode to spec front matter as `delivery_mode`. - -The same checkpoint also captures **model_tier** (`frontier` | `local`) — a declaration of the worker's capability tier that shapes decomposition granularity only (`frontier` = today's goal-oriented DAG; `local` = finer-grained DAG with explicit per-job acceptance criteria). Unlike `delivery_mode`, `model_tier` is advisory and is NOT copied to spec front matter: it stays in the transcript and is consumed once by decomposition. Default `frontier` (byte-identical to today). See `phases/01-interview.md` § 3 and `phases/04-decomposition.md`. +- **delivery_mode** (`autonomous` | `agile`) — Full mode only. Copied to spec front matter. +- **model_tier** (`frontier` | `local`) — all modes (Full, Standard, Quick). Advisory; stays in the transcript, consumed once by decomposition. Default `frontier`. ## Phase Map diff --git a/skills/tenet/phases/01-interview.md b/skills/tenet/phases/01-interview.md index ddde9d4..5384932 100644 --- a/skills/tenet/phases/01-interview.md +++ b/skills/tenet/phases/01-interview.md @@ -31,9 +31,11 @@ Ask at least one question from each category in the first round. ## 3. Mode Decisions Gate (delivery_mode + model_tier) -In Full mode, before calling `tenet_validate_clarity()`, run a standalone checkpoint that captures two mode-like decisions: **delivery_mode** (how the run is sliced) and **model_tier** (the capability tier of the worker that will execute the jobs). Ask each as its own question — do not bury either inside a bundled defaults question, and do not infer either from approval of unrelated defaults. +Before calling `tenet_validate_clarity()`, run two mode-like decisions as standalone checkpoints: **delivery_mode** (how the run is sliced — Full mode only) and **model_tier** (the capability tier of the worker that will execute the jobs — all modes). Ask each as its own question — do not bury either inside a bundled defaults question, and do not infer either from approval of unrelated defaults. -### 3a. Delivery mode (hard gate) +### 3a. Delivery mode (hard gate, Full mode only) + +**Skipped in Standard and Quick mode** — those modes are always `autonomous` (single end-to-end run). Only Full mode asks this question. Required prompt content: - Explain `autonomous`: one end-to-end run after pre-execution confirmation. @@ -53,9 +55,9 @@ Invalid outcomes: - User said "okay", "sounds good", or equivalent to unrelated defaults. - Pre-execution confirmation was used as retroactive delivery-mode approval. -### 3b. Model tier (advisory — shapes decomposition granularity only) +### 3b. Model tier (advisory — shapes decomposition granularity only; all modes) -Ask which tier of model will execute the implementation jobs: +Asked in Full, Standard, and Quick mode. Ask which tier of model will execute the implementation jobs: - `frontier` (default): a strong frontier model executes the jobs. Decomposition produces today's goal-oriented DAG — fewer, larger jobs, each trusted to carry a goal and resolve its own details. - `local`: a smaller/local model executes the jobs. Decomposition produces a finer-grained DAG — more, smaller, single-responsibility jobs with explicit per-job acceptance criteria, because a weaker executor needs a tighter plan to stay on-spec. @@ -127,6 +129,7 @@ Rounds: [N] - [Ambiguity 1] ## Delivery Mode Decision +(Full mode only. Standard/Quick omit this section — they are always autonomous.) - Prompt shown: [exact text or concise summary] - User response: [exact response, or YOLO confirmation] - Selected delivery_mode: autonomous|agile @@ -160,7 +163,7 @@ Once confirmed, the agent: - Skips interactive interview questions — makes all decisions autonomously based on codebase analysis and brownfield scan - Still writes the interview transcript with decisions made and assumptions - Still records `## Mode Selection` with `Selection basis: yolo_agent_decision` (mode chosen deliberately — default Full unless the task is clearly a small isolated tweak, in which case Quick) -- Still records `## Delivery Mode Decision` with `Selection basis: yolo_agent_decision` +- Still records `## Delivery Mode Decision` with `Selection basis: yolo_agent_decision` (Full mode only; Standard/Quick are always autonomous) - Still records `## Model Tier Decision` with `Selection basis: yolo_agent_decision` (default `frontier` unless the run is clearly local-executed) - Still runs `tenet_validate_clarity()` — if clarity is low, the agent fills gaps by reading the codebase rather than asking the user - Still generates spec, scenarios, and decomposition — but without user confirmation at each step @@ -201,13 +204,14 @@ When the user's requirements involve unfamiliar technologies, complex integratio - Do NOT proceed to spec or harness generation until the transcript file is written and the clarity gate passes. - If the user says "just build it" (without triggering YOLO mode), you MUST still ask the minimum required questions and record the answers. - In Full mode, do NOT proceed to spec unless `## Delivery Mode Decision` exists and records a valid selection basis. +- In all modes, do NOT proceed to spec unless `## Model Tier Decision` exists and records a valid selection basis (default `frontier` after the user has been asked). - Quick mode is a shallower interview, NOT a skip of the interview phase. Even in Quick mode, record the `## Mode Selection` block and confirm scope + acceptance criteria before spec/decomposition — apparent task clarity is not a license to skip the phase structure. ## 10. Adaptive Interview Length - **Greenfield project:** 2-3 rounds, 8-15 questions total. - **Brownfield/known scope:** 1-2 rounds, 5-8 questions total. - **Standard mode:** 1 round, 3-5 questions total. -- **Quick mode:** confirm scope + acceptance criteria — minimum 1-3 targeted questions or confirmations. Never zero (see § 9). The transcript still records the `## Mode Selection` block and these confirmations before spec/decomposition. +- **Quick mode:** confirm scope + acceptance criteria — minimum 1-3 targeted questions or confirmations. Never zero (see § 9). The transcript still records the `## Mode Selection` block, the `## Model Tier Decision` block, and these confirmations before spec/decomposition. ## 11. Crystallize Project Doctrine (greenfield only) diff --git a/skills/tenet/phases/04-decomposition.md b/skills/tenet/phases/04-decomposition.md index db3426f..0b7cc1c 100644 --- a/skills/tenet/phases/04-decomposition.md +++ b/skills/tenet/phases/04-decomposition.md @@ -15,7 +15,7 @@ For Full mode runs with an interview transcript, verify the spec front-matter `d **Also read the interview transcript's `## Model Tier Decision`** (phase 01 § 3b) to shape DAG granularity. Unlike `delivery_mode`, model_tier lives only in the transcript — it is not a spec front-matter field, because it is consumed once (here) and its effect is captured in the decomposition artifact you are about to write. -- `model_tier: frontier` (or the section is absent — e.g. Standard/Quick mode, or a run that skipped the Full-mode gate) → produce today's goal-oriented DAG: fewer, larger jobs, each trusted to carry a goal and resolve its own details. Byte-identical to default behavior. +- `model_tier: frontier` (or the section is absent — valid only as a fallback; model_tier is asked in all modes, so a missing section means the user deferred and the default applies) → produce today's goal-oriented DAG: fewer, larger jobs, each trusted to carry a goal and resolve its own details. Byte-identical to default behavior. - `model_tier: local` → produce a finer-grained DAG: more, smaller, single-responsibility jobs, each with explicit per-job acceptance criteria and minimal implicit context. A weaker executor needs a tighter, more explicit plan to stay on-spec. This composes with `delivery_mode`: `agile` + `local` means sliced **and** fine-grained (apply both — per-slice DAG, fine-grained within the slice). From 1ad15026959af33c6c6e1f79c828091eb1a4f32d Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Wed, 22 Jul 2026 07:23:28 +0900 Subject: [PATCH 57/87] Update backlog items --- ...ask-002 - Custom-critics-scoped-within-run.md | 16 +++++++++++----- ...task-014 - Job-runner-model-aware-planning.md | 7 +++++++ .../task-038 - tenet-db-cleanup-CLI-command.md | 6 +++--- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.backlog/tasks/task-002 - Custom-critics-scoped-within-run.md b/.backlog/tasks/task-002 - Custom-critics-scoped-within-run.md index 62be8ee..e2b646c 100644 --- a/.backlog/tasks/task-002 - Custom-critics-scoped-within-run.md +++ b/.backlog/tasks/task-002 - Custom-critics-scoped-within-run.md @@ -1,10 +1,10 @@ --- id: TASK-002 title: Custom critics scoped within run -status: To Do +status: Done assignee: [] created_date: '2026-07-07 02:16' -updated_date: '2026-07-20 22:28' +updated_date: '2026-07-21 22:21' labels: - skill dependencies: [] @@ -20,9 +20,9 @@ On top of default critics + user custom critics, tenet should suggest run-specif ## Acceptance Criteria -- [ ] #1 Tenet generates run-specific critic suggestions from interview/spec context -- [ ] #2 Run-specific critics are applied alongside default and user custom critics -- [ ] #3 Related to #13 (weak model critics) and #24 (critic model selection) +- [x] #1 Tenet generates run-specific critic suggestions from interview/spec context +- [x] #2 Run-specific critics are applied alongside default and user custom critics +- [x] #3 Related to #13 (weak model critics) and #24 (critic model selection) ## Implementation Plan @@ -62,3 +62,9 @@ Decision (2026-07-20): run-local roster file (.tenet/runs//critics.jso Follow-ups: TASK-048 (run-local roster + merge), TASK-007 (per-job critics), TASK-036 (tiers), TASK-045 (model-tier-aware critic splitting), TASK-004 (critic model selection). + +## Final Summary + + +Implemented run-scoped critics + Critic Tailoring step (commit 5ab0d5e). Two-scope layout: global .tenet/critics/*.md (durable, hand-authored via critics.md) + run-scoped .tenet/runs//critics/*.md (ephemeral, generated per run). New §4.5 in phases/02-spec-and-harness.md runs after readiness gate: orphan sweep, global-critic review, run-scoped critic generation. Run-end lifecycle prunes/promotes at run completion. No code change — prompt_file already resolves project-relative paths (src/mcp/tools/tenet-start-eval.ts:325-331). Follow-up: TASK-048 (run-local roster + merge), TASK-045 (critic designer adapts to local model tier), TASK-014 (wire model_tier to subprocess args). Mode-selection timing fix (commit b477571) split boot-time Full/Standard/Quick from end-of-interview delivery_mode + model_tier, made model_tier asked in all three modes. + diff --git a/.backlog/tasks/task-014 - Job-runner-model-aware-planning.md b/.backlog/tasks/task-014 - Job-runner-model-aware-planning.md index 5a0c48a..9def731 100644 --- a/.backlog/tasks/task-014 - Job-runner-model-aware-planning.md +++ b/.backlog/tasks/task-014 - Job-runner-model-aware-planning.md @@ -4,6 +4,7 @@ title: Job runner model-aware planning status: To Do assignee: [] created_date: '2026-07-07 02:16' +updated_date: '2026-07-21 22:21' labels: [] dependencies: [] priority: high @@ -23,3 +24,9 @@ Make tenet loop aware of which model is running and adapt planning accordingly. - [ ] #3 Subprocess model args are wired from the tier selection - [ ] #4 Orchestrator can smart-select model per task type + +## Implementation Notes + + +Prompt-side progress (commit b477571): model_tier is now asked in all three modes (Full/Standard/Quick), recorded in ## Model Tier Decision, and gated by anti-skip (01-interview.md §9). Previously Full-only. Decomposition (04-decomposition.md §1) already consumes model_tier to shape DAG granularity — frontier = goal-oriented DAG, local = finer-grained. AC #1 (planning adapts) is satisfied at the prompt layer. AC #2/#3/#4 (configurable tiers, subprocess arg wiring, smart-select per task type) still need code work. + diff --git a/.backlog/tasks/task-038 - tenet-db-cleanup-CLI-command.md b/.backlog/tasks/task-038 - tenet-db-cleanup-CLI-command.md index 16b122a..f06d20b 100644 --- a/.backlog/tasks/task-038 - tenet-db-cleanup-CLI-command.md +++ b/.backlog/tasks/task-038 - tenet-db-cleanup-CLI-command.md @@ -1,14 +1,14 @@ --- id: TASK-038 title: tenet db cleanup CLI command -status: In Progress +status: Done assignee: [] created_date: '2026-07-09 03:59' -updated_date: '2026-07-18 14:50' +updated_date: '2026-07-19 22:46' labels: [] dependencies: [] priority: high -ordinal: 38000 +ordinal: 6000 --- ## Description From 511531682764a8a433f50c3c83a56a6a9cca44f1 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Wed, 22 Jul 2026 07:24:30 +0900 Subject: [PATCH 58/87] chore: bump to 26.7.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6f1c69f..fae65e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.7.4", + "version": "26.7.5", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From 50eec04860a25310805df10b4a96bb2762f7ed2a Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 23 Jul 2026 09:14:57 +0900 Subject: [PATCH 59/87] docs(skill): make sub-agent delegation the default for wait span The orchestrator was polling jobs inline instead of spawning a sub-agent for the wait->eval->wait->gather span. Root cause was two self-certified escape hatches ('short runs' and 'host can't grant MCP access') that let any model justify inline polling without testing. Both were judgment calls that nudged toward inline. Flip delegation to the default. Escape hatches are now evidence-based: single dev job with no eval, a spawn attempt returned an error, or the host exposes no sub-agent spawning mechanism. Dropped host-specific tool names (task/Agent/orchestration-driven) and 'background task' terminology in favor of host-agnostic 'spawn a sub-agent' phrasing. Applies to SKILL.md Execution Rules and phases/05-execution-loop.md steps 6, Tracked Sub-Agent Delegation, Sub-Agent Wait Pattern, and Non-Blocking Execution. --- skills/tenet/SKILL.md | 2 +- skills/tenet/phases/05-execution-loop.md | 23 ++++++++++------------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/skills/tenet/SKILL.md b/skills/tenet/SKILL.md index 38ac37e..d97f29f 100644 --- a/skills/tenet/SKILL.md +++ b/skills/tenet/SKILL.md @@ -182,7 +182,7 @@ Examples: Read `phases/05-execution-loop.md` before starting execution. -- Use Tenet MCP tools only. Do not manually implement job code during the execution loop. You may delegate a slice of the loop (the wait→eval→wait→gather span) to a host sub-agent provided every operation it performs is a tenet MCP tool call (see `phases/05-execution-loop.md` § Tracked Sub-Agent Delegation). +- Use Tenet MCP tools only. Do not manually implement job code during the execution loop. Delegate the wait→eval→wait→gather span to a tracked sub-agent by default; every operation the sub-agent performs must be a tenet MCP tool call (see `phases/05-execution-loop.md` § Tracked Sub-Agent Delegation). Run that span inline only when delegation is provably impossible — single `dev` job with no eval, a spawn attempt returned an error, or your host exposes no sub-agent spawning mechanism. - Dispatch `tenet_job_wait` as background/non-blocking status checks with backoff. Stay responsive to user steering between checks. - Pass the original job ID to `tenet_start_eval`; pass `feature` when known. - `tenet_start_eval` dispatches the configured critics and returns them as a variable-length `jobs[]` list (plus `execution_mode`). Wait on every job in that list; do not assume a fixed count or that all critics are already running. diff --git a/skills/tenet/phases/05-execution-loop.md b/skills/tenet/phases/05-execution-loop.md index c3a8cb3..78736f0 100644 --- a/skills/tenet/phases/05-execution-loop.md +++ b/skills/tenet/phases/05-execution-loop.md @@ -10,9 +10,9 @@ The pre-execution confirmation gate in `phases/04-decomposition.md` MUST also be ## Non-Blocking Execution (CRITICAL) -`tenet_job_wait` returns instantly when `wait_seconds` is omitted or `0`. It can also long-poll for up to 120 seconds when `wait_seconds` is provided. The recommended orchestration pattern is periodic background checks with exponential backoff: 30s -> 45s -> 67s -> 100s -> 120s cap. +`tenet_job_wait` returns instantly when `wait_seconds` is omitted or `0`. It can also long-poll for up to 120 seconds when `wait_seconds` is provided. The recommended orchestration pattern is a spawned sub-agent doing periodic checks with exponential backoff: 30s -> 45s -> 67s -> 100s -> 120s cap. -**Never** call `tenet_job_wait` in a tight foreground loop. Use a bounded `wait_seconds` or schedule each check as a separate background task. Between checks, the orchestrator remains responsive to user interaction. +**Never** call `tenet_job_wait` in a tight foreground loop. Use a bounded `wait_seconds` or delegate the check cycle to a spawned sub-agent. While the sub-agent owns the wait span, the orchestrator remains responsive to user interaction. ## Mandatory Tool Sequence @@ -27,10 +27,10 @@ Execute this sequence for every job cycle: 4. **Start Job**: `tenet_start_job(job_id="")` Dispatches the registered job for execution. The MCP server transitions it from pending to running and allocates an agent. 5. **Brief User**: Tell the user which job was dispatched and that they can interact while it runs. -6. **Background Status Check**: Dispatch `tenet_job_wait(job_id="...")` as a **background task**. +6. **Spawn Sub-Agent**: Spawn a tracked sub-agent to own this span (steps 6–10), and dispatch `tenet_job_wait(job_id="...")` from within it. Omit `wait_seconds` for an instant status check, or set a bounded `wait_seconds` for long-polling. Use exponential backoff between checks: 30s → 45s → 67s → 100s → 120s (cap). - **Delegate this span (steps 6–10: wait → eval → wait → gather) to a single tracked sub-agent** (see **Tracked Sub-Agent Delegation** under Operational Rules) instead of running it inline — it is mechanical and pollutes your context, especially on long runs. Run it inline only for short runs, or if your host can't grant a sub-agent tenet MCP access. - When the background task completes: + **Delegate this span (steps 6–10: wait → eval → wait → gather) to a single tracked sub-agent by default** (see **Tracked Sub-Agent Delegation** under Operational Rules) — it is mechanical and pollutes your context, especially on long runs. Run it inline only when delegation is provably impossible: the DAG is a single `dev` job with no eval, **or** a spawn attempt returned an error, **or** your host exposes no sub-agent spawning mechanism. Do not choose inline based on a guess that the run is "short" — at job #1 every run looks short, and the cost of an unnecessary spawn is far smaller than poll noise across a long run. + When the sub-agent completes: - If `is_terminal` is false: check steer, report progress to user, wait (backoff), dispatch another check with the returned `cursor`. - If `is_terminal` is true: proceed to step 7. 7. **Get Result**: `tenet_job_result(job_id="...")` @@ -100,11 +100,11 @@ After the doctrine drift review, handle run-scoped critics per the **Run-end cri ### Use MCP Tools, Not Untracked Work Dispatch work via `tenet_start_job`. Do not write implementation code yourself during the execution loop. You MAY delegate a slice of the loop (e.g. the wait→eval→wait→gather span) to a host sub-agent, but only if every operation the sub-agent performs is a tenet MCP tool call (see **Tracked Sub-Agent Delegation** below). A sub-agent that edits files, writes code, or otherwise bypasses tenet tools is forbidden for the same reason manual code writing is. If `tenet_start_job` returns a failure about missing adapters, tell the user to configure the agent via `tenet config --agent `. -### Tracked Sub-Agent Delegation (recommended) +### Tracked Sub-Agent Delegation (default) Hand the wait→eval→wait→gather span (steps 6–10) to a single host sub-agent so your main context stays clean — the repeated status checks of a long run otherwise fill it with poll noise. The sub-agent checks the worker's status, dispatches critics via `tenet_start_eval`, waits on every returned job, and returns a per-critic PASS/FAIL summary. You then resume the work only the orchestrator owns: steer check (step 1), brief-user (step 5), git fallback commit + context-limit/split decisions (step 7), `tenet_update_knowledge` (step 11), status sync (step 12), and finding-category routing. -Run the span inline only for short runs, or when your host cannot grant a sub-agent tenet MCP access. +Delegation is the default. Run the span inline only when delegation is provably impossible — the DAG is a single `dev` job with no eval, **or** a spawn attempt returned an error, **or** your host exposes no sub-agent spawning mechanism. Do not default to inline because the run "looks short" — every run looks short at job #1, and a sub-agent is cheap relative to poll noise across a long run. If you are unsure whether your host can spawn a sub-agent with tenet MCP access, **try the spawn** and let the result decide; never assume no. The sub-agent must: @@ -131,14 +131,11 @@ Also drop a `### doctrine-drift: ` marker at the spot in the run doc (e.g. Only explicit context-bootstrap, an authorized doctrine-maintenance job (`allow_project_doctrine_edits: true`), or direct user-requested doctrine work may edit `.tenet/project/**`. Drift notes are the input that keeps `.tenet/project/**` from silently rotting — they are collected into durable proposals at run completion (see **Run Completion — Doctrine Drift Review** below). -### Background Status Check Pattern -`tenet_job_wait` returns instantly by default, or long-polls when `wait_seconds` is set. The orchestrator dispatches bounded waits as background tasks and waits between checks using exponential backoff: start at 30 seconds, multiply by 1.5× each cycle, cap at 120 seconds. Between checks: -- The orchestrator is fully responsive to user interaction -- Steer messages are processed on each check cycle -- The user sees progress updates +### Sub-Agent Wait Pattern +`tenet_job_wait` returns instantly by default, or long-polls when `wait_seconds` is set. The sub-agent dispatches bounded waits and re-checks on the exponential backoff schedule: start at 30 seconds, multiply by 1.5× each cycle, cap at 120 seconds. Between checks, the sub-agent re-runs `tenet_process_steer()` so an emergency halt or new directive is not missed while the orchestrator is not driving the loop directly. ### User Interaction During Execution -Between background wait notifications, the user can: +While the sub-agent owns the wait span, the user can: - Send messages to the orchestrator - Add steer directives (DIRECTIVE: prefix) - Request emergency halt (EMERGENCY: prefix) From 9706c8870dd2c06ce9a91144112f22338697d659 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Tue, 28 Jul 2026 21:44:40 +0900 Subject: [PATCH 60/87] fix: use cross-spawn for Windows .cmd shim resolution (#20) On Windows, npm global binaries are .cmd files that Node.js spawn() cannot resolve without shell:true. Replace node:child_process spawn with cross-spawn which handles this automatically across all platforms. Fixes #19 --- package.json | 2 ++ pnpm-lock.yaml | 13 +++++++++++++ src/adapters/adapter.test.ts | 10 +++------- src/adapters/claude-adapter.ts | 10 +++++----- src/adapters/codex-adapter.ts | 6 +++--- src/adapters/opencode-adapter.ts | 6 +++--- 6 files changed, 29 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index fae65e5..a26be19 100644 --- a/package.json +++ b/package.json @@ -69,12 +69,14 @@ "@modelcontextprotocol/server": "2.0.0-alpha.2", "better-sqlite3": "^12.8.0", "commander": "^14.0.3", + "cross-spawn": "^7.0.6", "yaml": "^2.9.0", "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/better-sqlite3": "^7.6.13", + "@types/cross-spawn": "^6.0.6", "@types/node": "^25.5.2", "@vitest/coverage-v8": "^4.1.2", "eslint": "^10.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 821d1f9..de49ceb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: commander: specifier: ^14.0.3 version: 14.0.3 + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -33,6 +36,9 @@ importers: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + '@types/cross-spawn': + specifier: ^6.0.6 + version: 6.0.6 '@types/node': specifier: ^25.5.2 version: 25.5.2 @@ -278,6 +284,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1235,6 +1244,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 25.5.2 + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} diff --git a/src/adapters/adapter.test.ts b/src/adapters/adapter.test.ts index e0abd5c..87ce481 100644 --- a/src/adapters/adapter.test.ts +++ b/src/adapters/adapter.test.ts @@ -5,13 +5,9 @@ import { AdapterRegistry, parseAdapterExtraArgs } from './index.js'; const spawnMock = vi.fn(); -vi.mock('node:child_process', async () => { - const actual = await vi.importActual('node:child_process'); - return { - ...actual, - spawn: (...args: unknown[]) => spawnMock(...args), - }; -}); +vi.mock('cross-spawn', () => ({ + default: (...args: unknown[]) => spawnMock(...args), +})); // Dynamic import so vi.mock takes effect before the adapters bind their spawn reference. const { ClaudeAdapter } = await import('./claude-adapter.js'); diff --git a/src/adapters/claude-adapter.ts b/src/adapters/claude-adapter.ts index 876aee1..590a936 100644 --- a/src/adapters/claude-adapter.ts +++ b/src/adapters/claude-adapter.ts @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import spawn from 'cross-spawn'; import { DEFAULT_JOB_TIMEOUT_MS } from '../core/runtime-config.js'; import type { AgentAdapter, AgentInvocation, AgentResponse } from './base.js'; @@ -64,8 +64,8 @@ export class ClaudeAdapter implements AgentAdapter { ); // Write prompt to stdin and close it - child.stdin.write(prompt); - child.stdin.end(); + child.stdin!.write(prompt); + child.stdin!.end(); let stdout = ''; let stderr = ''; @@ -77,11 +77,11 @@ export class ClaudeAdapter implements AgentAdapter { child.kill('SIGTERM'); }, effectiveTimeout); - child.stdout.on('data', (chunk: Buffer | string) => { + child.stdout!.on('data', (chunk: Buffer | string) => { stdout += chunk.toString(); }); - child.stderr.on('data', (chunk: Buffer | string) => { + child.stderr!.on('data', (chunk: Buffer | string) => { stderr += chunk.toString(); }); diff --git a/src/adapters/codex-adapter.ts b/src/adapters/codex-adapter.ts index 2678ed7..350fba2 100644 --- a/src/adapters/codex-adapter.ts +++ b/src/adapters/codex-adapter.ts @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import spawn from 'cross-spawn'; import { DEFAULT_JOB_TIMEOUT_MS } from '../core/runtime-config.js'; import type { AgentAdapter, AgentInvocation, AgentResponse } from './base.js'; @@ -44,11 +44,11 @@ export class CodexAdapter implements AgentAdapter { child.kill('SIGTERM'); }, effectiveTimeout); - child.stdout.on('data', (chunk: Buffer | string) => { + child.stdout!.on('data', (chunk: Buffer | string) => { stdout += chunk.toString(); }); - child.stderr.on('data', (chunk: Buffer | string) => { + child.stderr!.on('data', (chunk: Buffer | string) => { stderr += chunk.toString(); }); diff --git a/src/adapters/opencode-adapter.ts b/src/adapters/opencode-adapter.ts index 7478090..54c4acf 100644 --- a/src/adapters/opencode-adapter.ts +++ b/src/adapters/opencode-adapter.ts @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import spawn from 'cross-spawn'; import { DEFAULT_JOB_TIMEOUT_MS } from '../core/runtime-config.js'; import type { AgentAdapter, AgentInvocation, AgentResponse } from './base.js'; @@ -39,11 +39,11 @@ export class OpenCodeAdapter implements AgentAdapter { child.kill('SIGTERM'); }, effectiveTimeout); - child.stdout.on('data', (chunk: Buffer | string) => { + child.stdout!.on('data', (chunk: Buffer | string) => { stdout += chunk.toString(); }); - child.stderr.on('data', (chunk: Buffer | string) => { + child.stderr!.on('data', (chunk: Buffer | string) => { stderr += chunk.toString(); }); From 14397a43523d667f623c62ee44d1911df5919c75 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Tue, 28 Jul 2026 21:45:11 +0900 Subject: [PATCH 61/87] bump version 26.7.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a26be19..3109f09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jeikeilim/tenet", - "version": "26.7.5", + "version": "26.7.6", "description": "Cross-platform AI agent plugin for 12+ hour autonomous development cycles", "repository": { "type": "git", From a3570e943de1c2eefb85a7d893b29b76bbfae02c Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Tue, 4 Aug 2026 09:36:39 +0900 Subject: [PATCH 62/87] fix(eval): make opencode NDJSON output parseable + gate only latest eval round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs surfaced from the 2026-07-30 credit-fixes run (podcast-gen-web-service): 1. OpenCodeAdapter returned raw NDJSON event streams (`--format json`) as AgentResponse.output. extractRubricJson cannot parse the stream, so every opencode critic's verdict was NULL and the blocking-finding auto-resume gate correctly-but-permanently refused to unblock report-only parents. The adapter now collapses the stream into its `text` event parts (prose + trailing verdict, same shape claude --print produces), with a raw fallback and truncated-tail tolerance. Verified against the stored run: 12/14 previously unreadable verdicts now parse. 2. Re-firing tenet_start_eval after a child retry creates new critic rows but leaves prior rounds' rows in place. getEvalsForSource returns all rounds, and the gate required EVERY sibling to be completed+passed — a failed critic in round 1 poisoned rounds 2 and 3 forever. The gate now considers only the newest critic per stage; resolveExpectedEvalStages reads the roster stamp from the newest sibling. Adds unit tests for the NDJSON collapse and Tier-1 integration test C3 (stale failed round 1 + green round 2 → parent resumes), verified red against the previous gate logic. --- src/adapters/adapter.test.ts | 57 +++++++++++++++++ src/adapters/opencode-adapter.ts | 31 +++++++++- src/core/integration.test.ts | 62 +++++++++++++++++++ src/core/job-manager.ts | 27 +++++++- .../opencode-ndjson-text-parts.json | 6 ++ 5 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/fake-agents/opencode-ndjson-text-parts.json diff --git a/src/adapters/adapter.test.ts b/src/adapters/adapter.test.ts index 87ce481..280a6ce 100644 --- a/src/adapters/adapter.test.ts +++ b/src/adapters/adapter.test.ts @@ -1,4 +1,6 @@ import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import path from 'node:path'; import { vi } from 'vitest'; import type { AgentAdapter, AgentInvocation, AgentResponse } from './base.js'; import { AdapterRegistry, parseAdapterExtraArgs } from './index.js'; @@ -207,3 +209,58 @@ describe('adapter extraArgs passthrough', () => { expect(argv[1]).toBe('--dangerously-bypass-approvals-and-sandbox'); }); }); + +describe('OpenCodeAdapter NDJSON output collapse', () => { + beforeEach(() => { + spawnMock.mockReset(); + }); + + const fixturePath = path.resolve( + __dirname, + '..', + '..', + 'tests', + 'fixtures', + 'fake-agents', + 'opencode-ndjson-text-parts.json', + ); + + it('collapses the event stream into text-part contents (prose + trailing verdict)', async () => { + const ndjson = fs.readFileSync(fixturePath, 'utf8'); + spawnMock.mockImplementation(() => makeFakeChild(0, ndjson)); + + const adapter = new OpenCodeAdapter(); + const response = await adapter.invoke({ prompt: 'review this' }); + + expect(response.success).toBe(true); + expect(response.output).toContain('All 12 tests pass.'); + expect(response.output).toContain('"passed": true'); + // Raw NDJSON event framing must not leak through. + expect(response.output).not.toContain('"type":"step_start"'); + expect(response.output).not.toContain('"type":"tool_use"'); + }); + + it('preserves the verdict even when the final line is truncated (context-limit tail)', async () => { + const ndjson = fs.readFileSync(fixturePath, 'utf8'); + // Cut mid-event: a context-limit kill can truncate the stream before the + // final step_finish. The text part (with the rubric) must still survive. + const truncated = ndjson.split('\n').slice(0, -1).join('\n') + '\n{"type":"step_finish","timestamp":1785417871'; + spawnMock.mockImplementation(() => makeFakeChild(0, truncated)); + + const adapter = new OpenCodeAdapter(); + const response = await adapter.invoke({ prompt: 'review this' }); + + expect(response.success).toBe(true); + expect(response.output).toContain('"passed": true'); + }); + + it('falls back to raw stdout when no text part parses', async () => { + const garbage = 'opencode: something went wrong\n{"type":"error","message":"boom"'; + spawnMock.mockImplementation(() => makeFakeChild(0, garbage)); + + const adapter = new OpenCodeAdapter(); + const response = await adapter.invoke({ prompt: 'hi' }); + + expect(response.output).toBe(garbage); + }); +}); diff --git a/src/adapters/opencode-adapter.ts b/src/adapters/opencode-adapter.ts index 54c4acf..28307e8 100644 --- a/src/adapters/opencode-adapter.ts +++ b/src/adapters/opencode-adapter.ts @@ -2,6 +2,33 @@ import spawn from 'cross-spawn'; import { DEFAULT_JOB_TIMEOUT_MS } from '../core/runtime-config.js'; import type { AgentAdapter, AgentInvocation, AgentResponse } from './base.js'; +/** + * opencode --format json emits a stream of NDJSON events (step_start, tool_use, + * text, step_finish...). The assistant's actual message content lives in the + * `text` events' `part.text`. Collapse the stream into those text parts joined + * by newlines so downstream consumers (rubric extraction, job_result, worker + * output) see plain text like the claude adapter produces — not raw event + * bytes. Returns null when no text part parsed (e.g. an error banner or empty + * stdout) so callers can fall back to the raw stream. + */ +const extractTextPartsFromNdjson = (stdout: string): string | null => { + const parts: string[] = []; + for (const line of stdout.split('\n')) { + if (!line.trim()) { + continue; + } + try { + const event = JSON.parse(line) as { type?: string; part?: { text?: unknown } }; + if (event.type === 'text' && typeof event.part?.text === 'string') { + parts.push(event.part.text); + } + } catch { + // Skip malformed lines — a context-limit kill can truncate the tail mid-event. + } + } + return parts.length > 0 ? parts.join('\n') : null; +}; + export class OpenCodeAdapter implements AgentAdapter { public readonly name = 'opencode'; private readonly timeoutMs: number; @@ -54,7 +81,7 @@ export class OpenCodeAdapter implements AgentAdapter { if (timedOut) { resolve({ success: false, - output: stdout, + output: extractTextPartsFromNdjson(stdout) ?? stdout, error: `opencode invocation timed out after ${effectiveTimeout}ms`, durationMs, }); @@ -63,7 +90,7 @@ export class OpenCodeAdapter implements AgentAdapter { resolve({ success: code === 0, - output: stdout, + output: extractTextPartsFromNdjson(stdout) ?? stdout, error: code === 0 ? undefined : stderr || `opencode exited with code ${code ?? 'unknown'}`, durationMs, }); diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index f97a970..37bc029 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -322,6 +322,68 @@ describe('integration: blocking finding auto-resume', () => { expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); }); + + it('C3: stale failed critic from an earlier eval round does not block a later all-green round', async () => { + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // Round 1: code_critic FAILS (served once), then falls through to the passing fixture. + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const parsed = parseResult(r); + const childId = parsed.child_job_id as string; + + await manager.waitForJob(childId, null, 5_000); + + const dispatchRound = async (): Promise => { + const code = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + prompt: 'Code Critic review', + }); + const test = manager.startJob('eval', { + source_job_id: childId, + eval_stage: 'test_critic', + prompt: 'Test Critic review', + }); + const play = manager.startJob('interaction_e2e', { + source_job_id: childId, + eval_stage: 'interaction_e2e', + prompt: 'Interaction E2E eval', + }); + await manager.waitForJob(code.id, null, 5_000); + await manager.waitForJob(test.id, null, 5_000); + await manager.waitForJob(play.id, null, 5_000); + }; + + // Round 1: code_critic fails → parent stays blocked. + await dispatchRound(); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Round 2 (re-fired eval after the child retry): all three pass. + await dispatchRound(); + + // The round-1 failed code_critic must not poison the gate — only the newest + // critic per stage counts, and round 2 is fully green. + expect(store.getJob(parent.id)?.status).toBe('pending'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index c1c779e..46ffa2f 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -974,10 +974,16 @@ export class JobManager { * project's `.tenet/critics.json` roster — so disabling a built-in or adding a * custom critic shrinks/grows the set). Sibling jobs from before that stamping * existed fall back to the 3 built-ins. + * + * When a source job was evaluated multiple times (re-fired `tenet_start_eval` + * after retries), the newest round's stamp is authoritative — an older round's + * roster could reflect a disabled critic that a later round re-enabled, or a + * custom critic added mid-run. */ private resolveExpectedEvalStages(sourceJobId: string): Set { const siblings = this.stateStore.getEvalsForSource(sourceJobId); - for (const s of siblings) { + const newestFirst = [...siblings].sort((a, b) => b.createdAt - a.createdAt); + for (const s of newestFirst) { const stamped = s.params.expected_eval_stages; if (Array.isArray(stamped) && stamped.length > 0) { return new Set(stamped.filter((stage): stage is string => typeof stage === 'string')); @@ -1032,16 +1038,31 @@ export class JobManager { return expectedStages.has(stage); }); + // A source job may have been evaluated multiple times (re-fired + // `tenet_start_eval` after retries). Older rounds' critics — including ones + // that failed — are history: only the newest critic per stage decides the + // gate, otherwise a stale failure from an earlier round blocks the parent + // forever even after the current round is fully green. + const latestByStage = new Map(); + for (const s of evalSiblings) { + const stage = typeof s.params.eval_stage === 'string' ? s.params.eval_stage : ''; + const existing = latestByStage.get(stage); + if (!existing || s.createdAt > existing.createdAt) { + latestByStage.set(stage, s); + } + } + const currentRound = [...latestByStage.values()]; + // Wait until every expected stage has a sibling before deciding — a disabled // built-in shrinks this set, a custom critic grows it. - const presentStages = new Set(evalSiblings.map((s) => s.params.eval_stage as string)); + const presentStages = new Set(currentRound.map((s) => s.params.eval_stage as string)); for (const expected of expectedStages) { if (!presentStages.has(expected)) { return; } } - for (const s of evalSiblings) { + for (const s of currentRound) { if (s.status !== 'completed') { return; } diff --git a/tests/fixtures/fake-agents/opencode-ndjson-text-parts.json b/tests/fixtures/fake-agents/opencode-ndjson-text-parts.json new file mode 100644 index 0000000..0927623 --- /dev/null +++ b/tests/fixtures/fake-agents/opencode-ndjson-text-parts.json @@ -0,0 +1,6 @@ +{"type":"step_start","timestamp":1785417868273,"sessionID":"ses_04ccd6af3ffeN0sAaNmRq1iLOi","part":{"id":"prt_a","messageID":"msg_a","type":"step-start"}} +{"type":"tool_use","timestamp":1785417869000,"sessionID":"ses_04ccd6af3ffeN0sAaNmRq1iLOi","part":{"type":"tool","tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"uv run pytest tests/unit -q"},"output":"12 passed","metadata":{"output":"12 passed","exit":0,"truncated":false}},"id":"prt_b","messageID":"msg_a"}} +{"type":"step_finish","timestamp":1785417869500,"sessionID":"ses_04ccd6af3ffeN0sAaNmRq1iLOi","part":{"id":"prt_c","reason":"tool-calls","messageID":"msg_a","type":"step-finish","tokens":{"total":1000,"input":900,"output":100,"cost":0}}} +{"type":"step_start","timestamp":1785417870000,"sessionID":"ses_04ccd6af3ffeN0sAaNmRq1iLOi","part":{"id":"prt_d","messageID":"msg_b","type":"step-start"}} +{"type":"text","timestamp":1785417870500,"sessionID":"ses_04ccd6af3ffeN0sAaNmRq1iLOi","part":{"id":"prt_e","messageID":"msg_b","type":"text","text":"All 12 tests pass. The fix was already applied in commit `4b8b12f`.\n\n**Verdict**: The finding is resolved. The tests now pass with the credit gate in place.\n\n{\"passed\": true, \"stage\": \"code_critic\", \"findings\": []}"}} +{"type":"step_finish","timestamp":1785417871000,"sessionID":"ses_04ccd6af3ffeN0sAaNmRq1iLOi","part":{"id":"prt_f","reason":"stop","messageID":"msg_b","type":"step-finish","tokens":{"total":1100,"input":990,"output":110,"cost":0}}} From 22c2b3b80509fc86a8894c52d414cd0aa74beec3 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Tue, 4 Aug 2026 10:01:42 +0900 Subject: [PATCH 63/87] =?UTF-8?q?fix(eval):=20address=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20guard=20non-object=20NDJSON=20lines,=20>=3D=20tie-b?= =?UTF-8?q?reak,=20end-to-end=20rubric=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extractTextPartsFromNdjson: guard against valid-JSON non-object lines (null, numbers) instead of relying on the catch; pin the expected opencode event schema in a comment so a version bump can't silently regress to unparseable output. - Gate dedup tie-break uses >= so an equal-createdAt job never keeps the stale copy. - Fixture now emits two text events; new test asserts stream-order join. - New end-to-end test: adapter collapse → extractRubricJson yields the verdict object (the exact production contract that broke). --- src/adapters/adapter.test.ts | 63 +++++++++++++++++++ src/adapters/opencode-adapter.ts | 25 ++++++-- src/core/job-manager.ts | 4 +- .../opencode-ndjson-text-parts.json | 3 +- 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/adapters/adapter.test.ts b/src/adapters/adapter.test.ts index 280a6ce..2a4cbd0 100644 --- a/src/adapters/adapter.test.ts +++ b/src/adapters/adapter.test.ts @@ -240,6 +240,58 @@ describe('OpenCodeAdapter NDJSON output collapse', () => { expect(response.output).not.toContain('"type":"tool_use"'); }); + it('joins multiple text events in stream order (real streams emit many)', async () => { + const ndjson = fs.readFileSync(fixturePath, 'utf8'); + spawnMock.mockImplementation(() => makeFakeChild(0, ndjson)); + + const adapter = new OpenCodeAdapter(); + const response = await adapter.invoke({ prompt: 'review this' }); + + const zeroFindings = response.output.indexOf('zero-findings recheck'); + const verdict = response.output.indexOf('All 12 tests pass'); + expect(zeroFindings).toBeGreaterThanOrEqual(0); + expect(verdict).toBeGreaterThan(zeroFindings); + }); + + it('end-to-end: collapsed output yields a passed rubric through extractRubricJson', async () => { + const ndjson = fs.readFileSync(fixturePath, 'utf8'); + spawnMock.mockImplementation(() => makeFakeChild(0, ndjson)); + + const adapter = new OpenCodeAdapter(); + const response = await adapter.invoke({ prompt: 'review this' }); + + // Mirror of JobManager.extractRubricJson — the production gate path. + const extractRubricJson = (rawOutput: string): Record | null => { + const stripped = rawOutput.trim(); + const fenced = stripped.match(/```(?:json)?\s*([\s\S]*?)```/); + const candidates = fenced ? [fenced[1].trim(), stripped] : [stripped]; + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate) as Record; + if (parsed && typeof parsed === 'object') return parsed; + } catch { + // try next candidate + } + const start = candidate.indexOf('{'); + const end = candidate.lastIndexOf('}'); + if (start >= 0 && end > start) { + try { + const parsed = JSON.parse(candidate.slice(start, end + 1)) as Record; + if (parsed && typeof parsed === 'object') return parsed; + } catch { + // give up + } + } + } + return null; + }; + + const parsed = extractRubricJson(response.output); + expect(parsed).not.toBeNull(); + expect(parsed?.passed).toBe(true); + expect(parsed?.stage).toBe('code_critic'); + }); + it('preserves the verdict even when the final line is truncated (context-limit tail)', async () => { const ndjson = fs.readFileSync(fixturePath, 'utf8'); // Cut mid-event: a context-limit kill can truncate the stream before the @@ -263,4 +315,15 @@ describe('OpenCodeAdapter NDJSON output collapse', () => { expect(response.output).toBe(garbage); }); + + it('skips valid-JSON non-event lines (null, numbers) without crashing', async () => { + const mixed = 'null\n42\n{"type":"step_start"}\n{"type":"text","part":{"text":"verdict here"}}'; + spawnMock.mockImplementation(() => makeFakeChild(0, mixed)); + + const adapter = new OpenCodeAdapter(); + const response = await adapter.invoke({ prompt: 'hi' }); + + expect(response.success).toBe(true); + expect(response.output).toBe('verdict here'); + }); }); diff --git a/src/adapters/opencode-adapter.ts b/src/adapters/opencode-adapter.ts index 28307e8..3bee9d1 100644 --- a/src/adapters/opencode-adapter.ts +++ b/src/adapters/opencode-adapter.ts @@ -10,20 +10,35 @@ import type { AgentAdapter, AgentInvocation, AgentResponse } from './base.js'; * output) see plain text like the claude adapter produces — not raw event * bytes. Returns null when no text part parsed (e.g. an error banner or empty * stdout) so callers can fall back to the raw stream. + * + * Schema pin: this matches the event shape opencode emits today (each line is + * `{"type":"text", ..., "part":{"type":"text","text":"..."}}`). If a future + * opencode bump changes the part shape (e.g. a `parts[]` array), this collapse + * silently degrades to the raw-stream fallback — the fixture + * `tests/fixtures/fake-agents/opencode-ndjson-text-parts.json` and the + * end-to-end test in `src/adapters/adapter.test.ts` will catch it. Re-verify + * against live output when bumping opencode. */ -const extractTextPartsFromNdjson = (stdout: string): string | null => { +export const extractTextPartsFromNdjson = (stdout: string): string | null => { const parts: string[] = []; for (const line of stdout.split('\n')) { if (!line.trim()) { continue; } + let event: unknown; try { - const event = JSON.parse(line) as { type?: string; part?: { text?: unknown } }; - if (event.type === 'text' && typeof event.part?.text === 'string') { - parts.push(event.part.text); - } + event = JSON.parse(line); } catch { // Skip malformed lines — a context-limit kill can truncate the tail mid-event. + continue; + } + if (!event || typeof event !== 'object') { + // Valid JSON that isn't an event object (e.g. `null`, numbers) — skip. + continue; + } + const record = event as { type?: unknown; part?: { text?: unknown } }; + if (record.type === 'text' && typeof record.part?.text === 'string') { + parts.push(record.part.text); } } return parts.length > 0 ? parts.join('\n') : null; diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 46ffa2f..180b7b5 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -1047,7 +1047,9 @@ export class JobManager { for (const s of evalSiblings) { const stage = typeof s.params.eval_stage === 'string' ? s.params.eval_stage : ''; const existing = latestByStage.get(stage); - if (!existing || s.createdAt > existing.createdAt) { + // >= (not >): equal createdAt (same-ms dispatch in tests) keeps the + // later-seen job — never let the stale one win a tie. + if (!existing || s.createdAt >= existing.createdAt) { latestByStage.set(stage, s); } } diff --git a/tests/fixtures/fake-agents/opencode-ndjson-text-parts.json b/tests/fixtures/fake-agents/opencode-ndjson-text-parts.json index 0927623..bd9a2e7 100644 --- a/tests/fixtures/fake-agents/opencode-ndjson-text-parts.json +++ b/tests/fixtures/fake-agents/opencode-ndjson-text-parts.json @@ -2,5 +2,6 @@ {"type":"tool_use","timestamp":1785417869000,"sessionID":"ses_04ccd6af3ffeN0sAaNmRq1iLOi","part":{"type":"tool","tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"uv run pytest tests/unit -q"},"output":"12 passed","metadata":{"output":"12 passed","exit":0,"truncated":false}},"id":"prt_b","messageID":"msg_a"}} {"type":"step_finish","timestamp":1785417869500,"sessionID":"ses_04ccd6af3ffeN0sAaNmRq1iLOi","part":{"id":"prt_c","reason":"tool-calls","messageID":"msg_a","type":"step-finish","tokens":{"total":1000,"input":900,"output":100,"cost":0}}} {"type":"step_start","timestamp":1785417870000,"sessionID":"ses_04ccd6af3ffeN0sAaNmRq1iLOi","part":{"id":"prt_d","messageID":"msg_b","type":"step-start"}} -{"type":"text","timestamp":1785417870500,"sessionID":"ses_04ccd6af3ffeN0sAaNmRq1iLOi","part":{"id":"prt_e","messageID":"msg_b","type":"text","text":"All 12 tests pass. The fix was already applied in commit `4b8b12f`.\n\n**Verdict**: The finding is resolved. The tests now pass with the credit gate in place.\n\n{\"passed\": true, \"stage\": \"code_critic\", \"findings\": []}"}} +{"type":"text","timestamp":1785417870300,"sessionID":"ses_04ccd6af3ffeN0sAaNmRq1iLOi","part":{"id":"prt_e1","messageID":"msg_b","type":"text","text":"Running the zero-findings recheck from alternate angles."}} +{"type":"text","timestamp":1785417870500,"sessionID":"ses_04ccd6af3ffeN0sAaNmRq1iLOi","part":{"id":"prt_e2","messageID":"msg_b","type":"text","text":"All 12 tests pass. The fix was already applied in commit `4b8b12f`.\n\n**Verdict**: The finding is resolved. The tests now pass with the credit gate in place.\n\n{\"passed\": true, \"stage\": \"code_critic\", \"findings\": []}"}} {"type":"step_finish","timestamp":1785417871000,"sessionID":"ses_04ccd6af3ffeN0sAaNmRq1iLOi","part":{"id":"prt_f","reason":"stop","messageID":"msg_b","type":"step-finish","tokens":{"total":1100,"input":990,"output":110,"cost":0}}} From 278e2f00a60d366fc80489e7227e0c1b0ad23648 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Tue, 4 Aug 2026 10:23:52 +0900 Subject: [PATCH 64/87] fix(eval): export extractRubricJson for tests + brace-slice regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export extractRubricJson from job-manager so the adapter end-to-end test imports the real parser instead of an inline mirror (mirrors drift). - New test: a text event after the verdict containing braces must not break extractRubricJson's first-{ to last-} slice — pins the fragility the follow-up review flagged. --- src/adapters/adapter.test.ts | 46 ++++++++++++++++-------------------- src/core/job-manager.ts | 2 +- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/adapters/adapter.test.ts b/src/adapters/adapter.test.ts index 2a4cbd0..628fe6e 100644 --- a/src/adapters/adapter.test.ts +++ b/src/adapters/adapter.test.ts @@ -260,32 +260,8 @@ describe('OpenCodeAdapter NDJSON output collapse', () => { const adapter = new OpenCodeAdapter(); const response = await adapter.invoke({ prompt: 'review this' }); - // Mirror of JobManager.extractRubricJson — the production gate path. - const extractRubricJson = (rawOutput: string): Record | null => { - const stripped = rawOutput.trim(); - const fenced = stripped.match(/```(?:json)?\s*([\s\S]*?)```/); - const candidates = fenced ? [fenced[1].trim(), stripped] : [stripped]; - for (const candidate of candidates) { - try { - const parsed = JSON.parse(candidate) as Record; - if (parsed && typeof parsed === 'object') return parsed; - } catch { - // try next candidate - } - const start = candidate.indexOf('{'); - const end = candidate.lastIndexOf('}'); - if (start >= 0 && end > start) { - try { - const parsed = JSON.parse(candidate.slice(start, end + 1)) as Record; - if (parsed && typeof parsed === 'object') return parsed; - } catch { - // give up - } - } - } - return null; - }; - + // The real production parser — not a mirror. Mirrors drift. + const { extractRubricJson } = await import('../core/job-manager.js'); const parsed = extractRubricJson(response.output); expect(parsed).not.toBeNull(); expect(parsed?.passed).toBe(true); @@ -326,4 +302,22 @@ describe('OpenCodeAdapter NDJSON output collapse', () => { expect(response.success).toBe(true); expect(response.output).toBe('verdict here'); }); + + it('verdict survives a later text event containing braces (extractRubricJson slice)', async () => { + const stream = [ + '{"type":"step_start","part":{"id":"a","type":"step-start"}}', + '{"type":"text","part":{"text":"{\\"passed\\": true, \\"stage\\": \\"code_critic\\", \\"findings\\": []}"}}', + '{"type":"text","part":{"text":"Note: the fix touched src/foo.ts and src/bar.ts (see commit 4b8b12f)."}}', + '{"type":"step_finish","part":{"id":"b","reason":"stop","type":"step-finish"}}', + ].join('\n'); + spawnMock.mockImplementation(() => makeFakeChild(0, stream)); + + const adapter = new OpenCodeAdapter(); + const response = await adapter.invoke({ prompt: 'review this' }); + + const { extractRubricJson } = await import('../core/job-manager.js'); + const parsed = extractRubricJson(response.output); + expect(parsed).not.toBeNull(); + expect(parsed?.passed).toBe(true); + }); }); diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 180b7b5..3de79ca 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -73,7 +73,7 @@ const sleep = async (ms: number): Promise => setTimeout(resolve, ms); }); -const extractRubricJson = (rawOutput: unknown): Record | null => { +export const extractRubricJson = (rawOutput: unknown): Record | null => { if (rawOutput && typeof rawOutput === 'object') { return rawOutput as Record; } From aa627ff1657fc3479c5ec8f5fa079b4931bd8ad1 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Tue, 4 Aug 2026 10:41:34 +0900 Subject: [PATCH 65/87] feat(eval): round-id stamp so gate keys on complete rounds, not per-stage mixing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tenet_start_eval now stamps every critic in one dispatch with a shared eval_round UUID. The blocking-finding resume gate keys on the NEWEST round only: it finds the newest round by max createdAt, reads that round's own expected_eval_stages stamp, and requires every stage in it to be present + completed + passed — all from critics sharing that round id. No mixing across code revisions: a round-1 test_critic PASS can never combine with a round-2 code_critic PASS. If any sibling predates the stamp (existing DBs), the gate falls back to per-stage-newest so old stuck parents still recover via the adapter fix. C4 (startEval-stamped): round 1 fails → blocked, round 2 all pass → resumes. C5 (manual stamp): round 1 code_critic FAIL + test_critic PASS, round 2 code_critic PASS + no test_critic → stays blocked. Verified red against per-stage (which unblocks = cross-round mixing on different code states). --- src/core/integration.test.ts | 115 ++++++++++++++++++++++++++++++ src/core/job-manager.ts | 96 +++++++++++++++++++++++-- src/mcp/tools/tenet-start-eval.ts | 8 +++ 3 files changed, 215 insertions(+), 4 deletions(-) diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index 37bc029..dc2daf0 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -384,6 +384,121 @@ describe('integration: blocking finding auto-resume', () => { // critic per stage counts, and round 2 is fully green. expect(store.getJob(parent.id)?.status).toBe('pending'); }); + + it('C4 (round-id): failing round 1 + all-green round 2 → parent resumes on round 2', async () => { + const { store, manager, startEval, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + store.setConfig('eval_parallel_safe:credit-fixes', 'true'); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const runRound = async (): Promise => { + const result = await startEval({ job_id: childId, output: {}, feature: 'credit-fixes' }); + const parsed = parseResult(result); + for (const role of ['code_critic', 'test_critic', 'interaction_e2e']) { + const id = jobId(parsed, role); + await manager.waitForJob(id, null, 5_000); + } + }; + + // Round 1: code_critic fails → parent stays blocked. + await runRound(); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Round 2: all pass → parent resumes. The gate keys on the newest round + // (newest eval_round id), not the newest critic per stage. startEval stamps + // eval_round on every critic, so this exercises the round-based path. + await runRound(); + expect(store.getJob(parent.id)?.status).toBe('pending'); + }); + + it('C5 (round-id): newest round missing a stage does NOT mix with older round (no cross-round unblock)', async () => { + // The exact scenario the user flagged: round 1's test_critic PASS must NOT + // combine with round 2's code_critic PASS when round 2 is missing + // test_critic. Per-stage-newest unblocks (mixing across code revisions); + // round-based stays blocked (round 2 is incomplete). + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // code_critic: round 1 FAILS, round 2 PASSES (maxUses trick). + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const stamp = (round: string, stage: string) => { + const promptLabel = stage === 'code_critic' ? 'Code Critic' + : stage === 'test_critic' ? 'Test Critic' + : 'Interaction E2E'; + return { + source_job_id: childId, + eval_stage: stage, + eval_round: round, + expected_eval_stages: ['code_critic', 'test_critic', 'interaction_e2e'], + prompt: `${promptLabel} review — stage: ${stage}`, + }; + }; + + // Round 1: code_critic FAIL (failing fixture), test_critic PASS, interaction_e2e PASS. + const r1c = manager.startJob('critic_eval', stamp('round-1', 'code_critic')); + const r1t = manager.startJob('eval', stamp('round-1', 'test_critic')); + const r1p = manager.startJob('interaction_e2e', stamp('round-1', 'interaction_e2e')); + await manager.waitForJob(r1c.id, null, 5_000); + await manager.waitForJob(r1t.id, null, 5_000); + await manager.waitForJob(r1p.id, null, 5_000); + // Round 1: code_critic failed → parent stays blocked. + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Round 2: code_critic PASS, interaction_e2e PASS, but NO test_critic + // (simulates a critic that wasn't dispatched or context-limited away). + const r2c = manager.startJob('critic_eval', stamp('round-2', 'code_critic')); + const r2p = manager.startJob('interaction_e2e', stamp('round-2', 'interaction_e2e')); + await manager.waitForJob(r2c.id, null, 5_000); + await manager.waitForJob(r2p.id, null, 5_000); + + // Per-stage-newest would pick: code_critic = round 2 PASS, test_critic = + // round 1 PASS → UNBLOCK (mixing). Round-based: round 2 is incomplete + // (missing test_critic) → stays blocked. This is the exact behavior change. + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 3de79ca..b12ec8a 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -1021,6 +1021,98 @@ export class JobManager { return; } + const siblings = this.stateStore.getEvalsForSource(sourceJobId); + + // Round-based resume gate. `tenet_start_eval` stamps every critic in one + // dispatch with a shared `eval_round` id. A re-fire (after a child retry) + // gets a fresh id. Only the NEWEST round ever decides the gate — every + // critic in a round evaluated the same source state, so requiring the + // whole round to pass avoids mixing verdicts across code revisions (the + // "green gate, still wrong code" failure). If any sibling predates the + // stamp (existing DBs), fall back to per-stage-newest so old stuck + // parents still recover. + const allStamped = siblings.every((s) => typeof s.params.eval_round === 'string'); + if (allStamped && siblings.length > 0) { + this.checkBlockingFindingResumeByRound(siblings, blockedParentId, sourceJobId); + return; + } + + // Fallback: per-stage-newest (pre-round-id behavior). + this.checkBlockingFindingResumeByStage(siblings, sourceJobId, completedStage, rawOutput, blockedParentId); + } + + private checkBlockingFindingResumeByRound( + siblings: Job[], + blockedParentId: string, + sourceJobId: string, + ): void { + // Group by round id; pick the newest round by max createdAt of its critics. + const byRound = new Map(); + for (const s of siblings) { + const roundId = typeof s.params.eval_round === 'string' ? s.params.eval_round : ''; + if (!roundId) continue; + const arr = byRound.get(roundId) ?? []; + arr.push(s); + byRound.set(roundId, arr); + } + let newestRoundId = ''; + let newestCreatedAt = -1; + for (const [roundId, jobs] of byRound) { + const maxCreated = jobs.reduce((m, j) => (j.createdAt > m ? j.createdAt : m), -1); + if (maxCreated > newestCreatedAt) { + newestCreatedAt = maxCreated; + newestRoundId = roundId; + } + } + if (!newestRoundId) return; + const currentRound = byRound.get(newestRoundId) ?? []; + + // Read this round's own expected_eval_stages stamp (shared by all its critics). + const stamp = currentRound.find((s) => Array.isArray(s.params.expected_eval_stages))?.params.expected_eval_stages; + const expectedStages = Array.isArray(stamp) && stamp.length > 0 + ? new Set(stamp.filter((st): st is string => typeof st === 'string')) + : new Set(DEFAULT_EVAL_STAGES); + + const presentStages = new Set( + currentRound + .map((s) => (typeof s.params.eval_stage === 'string' ? s.params.eval_stage : '')) + .filter((st) => expectedStages.has(st)), + ); + for (const expected of expectedStages) { + if (!presentStages.has(expected)) return; + } + + for (const s of currentRound) { + const stage = typeof s.params.eval_stage === 'string' ? s.params.eval_stage : ''; + if (!expectedStages.has(stage)) continue; + if (s.status !== 'completed') return; + const siblingOutput = this.stateStore.getJobOutput(s.id); + const rawSibling = this.extractAdapterRawOutput(siblingOutput); + const parsed = extractRubricJson(rawSibling); + if (!parsed || parsed.passed !== true) return; + } + + // Newest round fully passed — let the report-only parent run again. + this.stateStore.updateJob(blockedParentId, { + status: 'pending', + startedAt: undefined, + completedAt: undefined, + lastHeartbeat: undefined, + error: undefined, + }); + this.stateStore.appendEvent(blockedParentId, 'blocking_finding_resolved', { + child_job_id: sourceJobId, + eval_round: newestRoundId, + }); + } + + private checkBlockingFindingResumeByStage( + siblings: Job[], + sourceJobId: string, + completedStage: string, + rawOutput: unknown, + blockedParentId: string, + ): void { // Parse this critic's output to confirm it passed const thisCritic = extractRubricJson(rawOutput); if (!thisCritic || thisCritic.passed !== true) { @@ -1032,7 +1124,6 @@ export class JobManager { return; } - const siblings = this.stateStore.getEvalsForSource(sourceJobId); const evalSiblings = siblings.filter((s) => { const stage = typeof s.params.eval_stage === 'string' ? s.params.eval_stage : ''; return expectedStages.has(stage); @@ -1055,8 +1146,6 @@ export class JobManager { } const currentRound = [...latestByStage.values()]; - // Wait until every expected stage has a sibling before deciding — a disabled - // built-in shrinks this set, a custom critic grows it. const presentStages = new Set(currentRound.map((s) => s.params.eval_stage as string)); for (const expected of expectedStages) { if (!presentStages.has(expected)) { @@ -1076,7 +1165,6 @@ export class JobManager { } } - // All expected critics passed — let the report-only parent run again with fresh context. this.stateStore.updateJob(blockedParentId, { status: 'pending', startedAt: undefined, diff --git a/src/mcp/tools/tenet-start-eval.ts b/src/mcp/tools/tenet-start-eval.ts index a5667d6..d2b3f71 100644 --- a/src/mcp/tools/tenet-start-eval.ts +++ b/src/mcp/tools/tenet-start-eval.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { JobManager } from '../../core/job-manager.js'; import { loadCriticRoster, type ResolvedCritic } from '../../core/critic-roster.js'; @@ -424,9 +425,16 @@ export const registerTenetStartEvalTool = (registerTool: RegisterTool, jobManage // Stages the resume gate waits for = exactly the stages we dispatched. const expectedEvalStages = dispatchList.map((d) => d.evalStage); + // One round id per call so the resume gate can key on "newest complete + // round" rather than "newest critic per stage" — every critic in this + // dispatch evaluated the same source state, and a re-fire (after a child + // retry) gets a fresh id, preventing cross-round verdict mixing. + const evalRound = randomUUID(); + const buildParams = (d: CriticDispatch) => ({ source_job_id: job_id, eval_stage: d.evalStage, + eval_round: evalRound, name: `${d.evalStage} for ${job_id.slice(0, 8)}`, prompt: d.prompt, output: outputObj, From 18a9a3d6ca6d36c632ad027cf78c72afa1495273 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Tue, 4 Aug 2026 16:33:34 +0900 Subject: [PATCH 66/87] =?UTF-8?q?fix(eval):=20robust=20rubric=20extraction?= =?UTF-8?q?=20=E2=80=94=20rightmost=20object=20with=20a=20passed=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces extractRubricJson's first-{ to last-} slice (which spanned multiple objects or prose and returned null whenever any earlier text contained braces) with a brace-matching scan that returns the rightmost JSON object carrying a boolean passed key — the shape every critic preamble mandates. Robust against: prose quoting code (try { } isn't valid JSON), balanced non-verdict objects ({"mode": "strict"} has no passed key), and trailing notes after the verdict. Verified against the production run: e2e-4's 6/7 critics now parse PASS (qa_explorer genuinely has no verdict — NULL is correct), e2e-1 round 3 fully green except the same no-verdict critic. Tests: brace test now uses real braces (was parentheses — passed for the wrong reason); new tests for the passed-key discriminator and the no-verdict NULL case. --- src/adapters/adapter.test.ts | 23 ++++++++++-- src/core/job-manager.ts | 69 +++++++++++++++++++++++++++++------- 2 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/adapters/adapter.test.ts b/src/adapters/adapter.test.ts index 628fe6e..50f54b2 100644 --- a/src/adapters/adapter.test.ts +++ b/src/adapters/adapter.test.ts @@ -303,11 +303,11 @@ describe('OpenCodeAdapter NDJSON output collapse', () => { expect(response.output).toBe('verdict here'); }); - it('verdict survives a later text event containing braces (extractRubricJson slice)', async () => { + it('verdict survives a later text event containing real braces (extractRubricJson scan)', async () => { const stream = [ '{"type":"step_start","part":{"id":"a","type":"step-start"}}', '{"type":"text","part":{"text":"{\\"passed\\": true, \\"stage\\": \\"code_critic\\", \\"findings\\": []}"}}', - '{"type":"text","part":{"text":"Note: the fix touched src/foo.ts and src/bar.ts (see commit 4b8b12f)."}}', + '{"type":"text","part":{"text":"Note: the fix touched { src/foo.ts } and { src/bar.ts } (see commit 4b8b12f)."}}', '{"type":"step_finish","part":{"id":"b","reason":"stop","type":"step-finish"}}', ].join('\n'); spawnMock.mockImplementation(() => makeFakeChild(0, stream)); @@ -320,4 +320,23 @@ describe('OpenCodeAdapter NDJSON output collapse', () => { expect(parsed).not.toBeNull(); expect(parsed?.passed).toBe(true); }); + + it('extractRubricJson ignores a trailing object without a passed key', async () => { + const { extractRubricJson } = await import('../core/job-manager.js'); + const output = [ + 'I reviewed the diff. Verdict:', + '{"passed": true, "stage": "code_critic", "findings": []}', + '{"note": "this is a trailing note, not a verdict"}', + ].join('\n'); + const parsed = extractRubricJson(output); + expect(parsed).not.toBeNull(); + expect(parsed?.passed).toBe(true); + expect(parsed?.stage).toBe('code_critic'); + }); + + it('extractRubricJson returns null when no object has a passed key', async () => { + const { extractRubricJson } = await import('../core/job-manager.js'); + const output = 'I began my review but ran out of context before finishing.'; + expect(extractRubricJson(output)).toBeNull(); + }); }); diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index b12ec8a..6e3fb10 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -73,6 +73,54 @@ const sleep = async (ms: number): Promise => setTimeout(resolve, ms); }); +/** + * Scan a string for JSON objects and return the rightmost one that carries a + * boolean `passed` key — the rubric shape every critic preamble mandates + * ("End with: {…passed…}"). Robust against prose containing braces (`try { }` + * isn't valid JSON; `{"mode": "strict"}` has no `passed` key), so a code + * snippet quoted before/after the verdict can't mis-target the parse. + */ +const findRightmostPassedObject = (text: string): Record | null => { + const stack: number[] = []; + let inString = false; + let escaped = false; + let best: Record | null = null; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === '{') { + stack.push(i); + continue; + } + if (ch === '}' && stack.length > 0) { + const start = stack.pop() as number; + try { + const parsed = JSON.parse(text.slice(start, i + 1)) as unknown; + if (parsed && typeof parsed === 'object' && typeof (parsed as Record).passed === 'boolean') { + best = parsed as Record; + } + } catch { + // Not valid JSON — prose braces, skip. + } + } + } + return best; +}; + export const extractRubricJson = (rawOutput: unknown): Record | null => { if (rawOutput && typeof rawOutput === 'object') { return rawOutput as Record; @@ -95,20 +143,15 @@ export const extractRubricJson = (rawOutput: unknown): Record | } catch { // Try next candidate } + } - // Fallback: locate the outermost JSON object substring - const start = candidate.indexOf('{'); - const end = candidate.lastIndexOf('}'); - if (start >= 0 && end > start) { - try { - const sliced = candidate.slice(start, end + 1); - const parsed = JSON.parse(sliced); - if (parsed && typeof parsed === 'object') { - return parsed as Record; - } - } catch { - // Give up - } + // Fallback: rightmost JSON object carrying a boolean `passed` key. Replaces + // the old first-{ to last-} slice, which spanned multiple objects/prose and + // returned null whenever any earlier text contained braces. + for (const candidate of candidates) { + const found = findRightmostPassedObject(candidate); + if (found) { + return found; } } From 1c62cc9cf9bba1656406ddf917281fe1023cefd0 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Tue, 4 Aug 2026 17:07:51 +0900 Subject: [PATCH 67/87] fix(eval): only top-level passed objects count as rubrics Review round 2 found a real MAJOR: findRightmostPassedObject could pick a nested or trailing passed-bearing object (custom critics embed assertions like {"assertions": [{"passed": true}]}; tool results quoted in prose carry nested passed keys). Both directions were wrong: a failing verdict followed by a nested passed:true false-greened the gate; a passing verdict followed by a nested passed:false stranded it. The scan now counts only TOP-LEVEL objects (stack empty after the pop). The production custom-critic shape is a single top-level object, so its nested assertions are still contained and it parses correctly. Verified against repro cases t1/t2 plus the production shape. Also: whole-string JSON path now requires a boolean passed key (was returning any object/array); round-selection tie uses >= for same-ms determinism; allStamped requires non-empty eval_round so a ''-stamped critic routes to the per-stage fallback instead of vanishing; spawn-error path now collapses output like the other paths. New tests: nested passed never overrides top-level verdict (t1/t2), custom-critic nested-assertions shape. --- src/adapters/adapter.test.ts | 43 ++++++++++++++++++++++++++++++++ src/adapters/opencode-adapter.ts | 2 +- src/core/job-manager.ts | 34 ++++++++++++++++++------- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/adapters/adapter.test.ts b/src/adapters/adapter.test.ts index 50f54b2..a1f2c66 100644 --- a/src/adapters/adapter.test.ts +++ b/src/adapters/adapter.test.ts @@ -339,4 +339,47 @@ describe('OpenCodeAdapter NDJSON output collapse', () => { const output = 'I began my review but ran out of context before finishing.'; expect(extractRubricJson(output)).toBeNull(); }); + + it('extractRubricJson never picks a nested passed object over the top-level verdict', async () => { + const { extractRubricJson } = await import('../core/job-manager.js'); + + // t1: failing verdict + trailing tool result with nested passed:true — + // must return the FAILING verdict, not false-green the gate. + const t1 = [ + 'V: {"passed": false, "stage": "code_critic", "findings": [{"category":"product_bug","detail":"x"}]}', + 'Tool output: {"results": [{"name": "syntax", "passed": true}]}', + ].join('\n'); + const r1 = extractRubricJson(t1); + expect(r1).not.toBeNull(); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + + // t2: passing verdict + trailing object with nested passed:false — + // must return the PASSING verdict, not false-strand the parent. + const t2 = [ + 'V: {"passed": true, "stage": "code_critic", "findings": []}', + '{"checks": {"lint": {"passed": false}}}', + ].join('\n'); + const r2 = extractRubricJson(t2); + expect(r2).not.toBeNull(); + expect(r2?.passed).toBe(true); + expect(r2?.stage).toBe('code_critic'); + }); + + it('extractRubricJson handles the custom-critic shape (nested assertions in the top-level verdict)', async () => { + const { extractRubricJson } = await import('../core/job-manager.js'); + const output = JSON.stringify({ + passed: true, + stage: 'credit_ledger_integrity', + assertions: [ + { name: 'append_only', passed: true, evidence: 'ledger is append-only' }, + { name: 'audit_fields', passed: true, evidence: 'created_at set' }, + ], + findings: [], + }); + const parsed = extractRubricJson(output); + expect(parsed).not.toBeNull(); + expect(parsed?.passed).toBe(true); + expect(parsed?.stage).toBe('credit_ledger_integrity'); + }); }); diff --git a/src/adapters/opencode-adapter.ts b/src/adapters/opencode-adapter.ts index 3bee9d1..25c6478 100644 --- a/src/adapters/opencode-adapter.ts +++ b/src/adapters/opencode-adapter.ts @@ -115,7 +115,7 @@ export class OpenCodeAdapter implements AgentAdapter { clearTimeout(timeout); resolve({ success: false, - output: stdout, + output: extractTextPartsFromNdjson(stdout) ?? stdout, error: error.message, durationMs: Date.now() - startedAt, }); diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 6e3fb10..15fe9df 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -74,11 +74,13 @@ const sleep = async (ms: number): Promise => }); /** - * Scan a string for JSON objects and return the rightmost one that carries a - * boolean `passed` key — the rubric shape every critic preamble mandates - * ("End with: {…passed…}"). Robust against prose containing braces (`try { }` - * isn't valid JSON; `{"mode": "strict"}` has no `passed` key), so a code - * snippet quoted before/after the verdict can't mis-target the parse. + * Scan a string for JSON objects and return the rightmost TOP-LEVEL one that + * carries a boolean `passed` key — the rubric shape every critic preamble + * mandates ("End with: {…passed…}"). Robust against prose containing braces + * (`try { }` isn't valid JSON; `{"mode": "strict"}` has no `passed` key), and + * against nested `passed` objects (custom critics embed assertions like + * `{"assertions": [{"passed": true}]}` — those must never be picked over the + * top-level verdict, or a failing critic could false-green the gate). */ const findRightmostPassedObject = (text: string): Record | null => { const stack: number[] = []; @@ -108,6 +110,11 @@ const findRightmostPassedObject = (text: string): Record | null } if (ch === '}' && stack.length > 0) { const start = stack.pop() as number; + // Only top-level objects count. Nested objects (assertion arrays, tool + // results quoted in prose) are never verdicts. + if (stack.length !== 0) { + continue; + } try { const parsed = JSON.parse(text.slice(start, i + 1)) as unknown; if (parsed && typeof parsed === 'object' && typeof (parsed as Record).passed === 'boolean') { @@ -137,7 +144,12 @@ export const extractRubricJson = (rawOutput: unknown): Record | for (const candidate of candidates) { try { const parsed = JSON.parse(candidate); - if (parsed && typeof parsed === 'object') { + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + typeof (parsed as Record).passed === 'boolean' + ) { return parsed as Record; } } catch { @@ -1074,8 +1086,10 @@ export class JobManager { // "green gate, still wrong code" failure). If any sibling predates the // stamp (existing DBs), fall back to per-stage-newest so old stuck // parents still recover. - const allStamped = siblings.every((s) => typeof s.params.eval_round === 'string'); - if (allStamped && siblings.length > 0) { + const allStamped = + siblings.length > 0 && + siblings.every((s) => typeof s.params.eval_round === 'string' && s.params.eval_round !== ''); + if (allStamped) { this.checkBlockingFindingResumeByRound(siblings, blockedParentId, sourceJobId); return; } @@ -1102,7 +1116,9 @@ export class JobManager { let newestCreatedAt = -1; for (const [roundId, jobs] of byRound) { const maxCreated = jobs.reduce((m, j) => (j.createdAt > m ? j.createdAt : m), -1); - if (maxCreated > newestCreatedAt) { + // >= (not >): on a same-ms tie, keep the later-seen round (iteration + // order is createdAt ASC), never the stale one. + if (maxCreated >= newestCreatedAt) { newestCreatedAt = maxCreated; newestRoundId = roundId; } From c60bc20391cabf1805c75310073084152a6263b4 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Tue, 4 Aug 2026 17:14:08 +0900 Subject: [PATCH 68/87] =?UTF-8?q?test(eval):=20C6=20=E2=80=94=20sequential?= =?UTF-8?q?-mode=20round=20chaining=20(round-id=20stamped,=20parent=20resu?= =?UTF-8?q?mes=20after=20last=20critic)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the untested sequential path: startEval with eval_parallel_safe=false chains test_critic and interaction_e2e as pending behind code_critic (parentJobId set, eval_round stamped, not running yet), and the parent only resumes after the whole chained round completes. Round 1 with a failing code_critic keeps the parent blocked. --- src/core/integration.test.ts | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index dc2daf0..90b9ab7 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -433,6 +433,74 @@ describe('integration: blocking finding auto-resume', () => { expect(store.getJob(parent.id)?.status).toBe('pending'); }); + it('C6 (round-id, sequential): parent stays blocked until the LAST chained critic of the green round completes', async () => { + const { store, manager, startEval, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + // Sequential mode (default when no parallel verdict): critics run in roster + // order, each chained after its predecessor completes. + store.setConfig('eval_parallel_safe:credit-fixes', 'false'); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const ids = async (): Promise> => { + const result = await startEval({ job_id: childId, output: {}, feature: 'credit-fixes' }); + const parsed = parseResult(result); + const out: Record = {}; + for (const role of ['code_critic', 'test_critic', 'interaction_e2e']) { + out[role] = jobId(parsed, role); + } + return out; + }; + + // Round 1: all three chain, code_critic fails → parent stays blocked. + const round1 = await ids(); + await manager.waitForJob(round1.code_critic, null, 5_000); + await manager.waitForJob(round1.test_critic, null, 5_000); + await manager.waitForJob(round1.interaction_e2e, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Round 2: startEval in sequential mode chains test_critic and + // interaction_e2e as pending behind code_critic — assert the chaining + // (parentJobId set, eval_round stamped, not running yet) deterministically + // before any critic completes. + const round2 = await ids(); + const t2 = store.getJob(round2.test_critic); + const p2 = store.getJob(round2.interaction_e2e); + expect(t2?.status).toBe('pending'); + expect(t2?.parentJobId).toBe(round2.code_critic); + expect(typeof t2?.params.eval_round).toBe('string'); + expect(p2?.status).toBe('pending'); + expect(p2?.parentJobId).toBe(round2.test_critic); + + // Wait for the whole chained round; only after the LAST critic completes + // may the parent resume. + await manager.waitForJob(round2.code_critic, null, 5_000); + await manager.waitForJob(round2.test_critic, null, 5_000); + await manager.waitForJob(round2.interaction_e2e, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('pending'); + }); + it('C5 (round-id): newest round missing a stage does NOT mix with older round (no cross-round unblock)', async () => { // The exact scenario the user flagged: round 1's test_critic PASS must NOT // combine with round 2's code_critic PASS when round 2 is missing From cb0c1f806be280f64c69d7a749d13b9c79801ef5 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 08:03:45 +0900 Subject: [PATCH 69/87] fix(eval): harden rubric extraction + round-gate entry - Shared rubric module (src/core/rubric.ts): drop the fenced-first fast path (a fenced tool echo before the verdict could false-green the gate), prefer staged verdicts over stage-less echoes, and recover from unbalanced braces in prose so a stray { no longer strands the parent. - Round gate: run whenever any stamped round exists (was: every sibling stamped) so an ad-hoc/legacy unstamped critic can't fall back to the cross-round-mixing per-stage path. - tenet_get_status: use the shared parser so latest_e2e_status survives prose braces. - Tests: rubric unit cases (fenced-echo, echo, unbalanced brace, stage preference), C7/C8/C9 round-gate regressions, D4 status regression. Co-Authored-By: Claude --- src/adapters/adapter.test.ts | 12 +- src/core/integration.test.ts | 207 ++++++++++++++++++ src/core/job-manager.ts | 113 +--------- src/core/rubric.test.ts | 163 ++++++++++++++ src/core/rubric.ts | 164 ++++++++++++++ src/mcp/tools/tenet-get-status.ts | 35 +-- ...laywright-layer2-completed-prose-braces.md | 5 + 7 files changed, 567 insertions(+), 132 deletions(-) create mode 100644 src/core/rubric.test.ts create mode 100644 src/core/rubric.ts create mode 100644 tests/fixtures/fake-agents/playwright-layer2-completed-prose-braces.md diff --git a/src/adapters/adapter.test.ts b/src/adapters/adapter.test.ts index a1f2c66..212801b 100644 --- a/src/adapters/adapter.test.ts +++ b/src/adapters/adapter.test.ts @@ -261,7 +261,7 @@ describe('OpenCodeAdapter NDJSON output collapse', () => { const response = await adapter.invoke({ prompt: 'review this' }); // The real production parser — not a mirror. Mirrors drift. - const { extractRubricJson } = await import('../core/job-manager.js'); + const { extractRubricJson } = await import('../core/rubric.js'); const parsed = extractRubricJson(response.output); expect(parsed).not.toBeNull(); expect(parsed?.passed).toBe(true); @@ -315,14 +315,14 @@ describe('OpenCodeAdapter NDJSON output collapse', () => { const adapter = new OpenCodeAdapter(); const response = await adapter.invoke({ prompt: 'review this' }); - const { extractRubricJson } = await import('../core/job-manager.js'); + const { extractRubricJson } = await import('../core/rubric.js'); const parsed = extractRubricJson(response.output); expect(parsed).not.toBeNull(); expect(parsed?.passed).toBe(true); }); it('extractRubricJson ignores a trailing object without a passed key', async () => { - const { extractRubricJson } = await import('../core/job-manager.js'); + const { extractRubricJson } = await import('../core/rubric.js'); const output = [ 'I reviewed the diff. Verdict:', '{"passed": true, "stage": "code_critic", "findings": []}', @@ -335,13 +335,13 @@ describe('OpenCodeAdapter NDJSON output collapse', () => { }); it('extractRubricJson returns null when no object has a passed key', async () => { - const { extractRubricJson } = await import('../core/job-manager.js'); + const { extractRubricJson } = await import('../core/rubric.js'); const output = 'I began my review but ran out of context before finishing.'; expect(extractRubricJson(output)).toBeNull(); }); it('extractRubricJson never picks a nested passed object over the top-level verdict', async () => { - const { extractRubricJson } = await import('../core/job-manager.js'); + const { extractRubricJson } = await import('../core/rubric.js'); // t1: failing verdict + trailing tool result with nested passed:true — // must return the FAILING verdict, not false-green the gate. @@ -367,7 +367,7 @@ describe('OpenCodeAdapter NDJSON output collapse', () => { }); it('extractRubricJson handles the custom-critic shape (nested assertions in the top-level verdict)', async () => { - const { extractRubricJson } = await import('../core/job-manager.js'); + const { extractRubricJson } = await import('../core/rubric.js'); const output = JSON.stringify({ passed: true, stage: 'credit_ledger_integrity', diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index 90b9ab7..0d7b604 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -386,6 +386,11 @@ describe('integration: blocking finding auto-resume', () => { }); it('C4 (round-id): failing round 1 + all-green round 2 → parent resumes on round 2', async () => { + // NOTE: C4 is a REGRESSION test, not a discriminator — it passes under + // BOTH per-stage-newest and round-based logic (round 2 is the newest per + // stage AND the newest complete round). C5 is the true discriminator: it + // asserts the round gate does NOT mix a round-2 pass with a round-1 pass + // when round 2 is missing a stage. Keep C5 green when touching the gate. const { store, manager, startEval, reportBlockingFinding } = createHarness([ { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, @@ -492,6 +497,11 @@ describe('integration: blocking finding auto-resume', () => { expect(typeof t2?.params.eval_round).toBe('string'); expect(p2?.status).toBe('pending'); expect(p2?.parentJobId).toBe(round2.test_critic); + // Mid-round: the chained critics are still pending, so round 2 is + // incomplete — the parent must NOT have resumed yet. (The fake adapter + // completes too fast to assert this after the round finishes, so the + // pending-chaining assert above is the deterministic mid-round signal.) + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); // Wait for the whole chained round; only after the LAST critic completes // may the parent resume. @@ -567,6 +577,190 @@ describe('integration: blocking finding auto-resume', () => { // (missing test_critic) → stays blocked. This is the exact behavior change. expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); }); + + it('C7 (round-id): a job-level failed critic in the newest round keeps the parent blocked', async () => { + const { store, manager, startEval, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // Round 1: code_critic FAILS at the rubric level (job completes). + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, + // Round 2: code_critic FAILS at the JOB level (adapter success:false). + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json', success: false }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + store.setConfig('eval_parallel_safe:credit-fixes', 'true'); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const runRound = async (): Promise => { + const result = await startEval({ job_id: childId, output: {}, feature: 'credit-fixes' }); + const parsed = parseResult(result); + for (const role of ['code_critic', 'test_critic', 'interaction_e2e']) { + const id = jobId(parsed, role); + await manager.waitForJob(id, null, 5_000); + } + }; + + // Round 1: code_critic rubric-fails → parent stays blocked. + await runRound(); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Round 2: code_critic fails at the job level. The gate must NOT unblock — + // the status check is the only guard against a failed critic that carried + // stored passing output. + await runRound(); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); + + it('C8 (round-id): an ad-hoc unstamped critic cannot disable the round gate (no cross-round unblock)', async () => { + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // Round 1 code_critic FAILS (served once), then the ad-hoc re-fire PASSES. + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const stamp = (round: string, stage: string) => { + const promptLabel = stage === 'code_critic' ? 'Code Critic' + : stage === 'test_critic' ? 'Test Critic' + : 'Interaction E2E'; + return { + source_job_id: childId, + eval_stage: stage, + eval_round: round, + expected_eval_stages: ['code_critic', 'test_critic', 'interaction_e2e'], + prompt: `${promptLabel} review — stage: ${stage}`, + }; + }; + + // Round 1 (stamped): code_critic FAILS, test_critic + interaction_e2e PASS. + const r1c = manager.startJob('critic_eval', stamp('round-1', 'code_critic')); + const r1t = manager.startJob('eval', stamp('round-1', 'test_critic')); + const r1p = manager.startJob('interaction_e2e', stamp('round-1', 'interaction_e2e')); + await manager.waitForJob(r1c.id, null, 5_000); + await manager.waitForJob(r1t.id, null, 5_000); + await manager.waitForJob(r1p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Ad-hoc re-fire of code_critic (tenet_start_job-style dispatch): NO + // eval_round. It PASSES. The round gate must ignore it — round 1's failed + // code_critic still decides, so the parent stays blocked. (Per-stage-newest + // would mix the ad-hoc pass with round 1's green critics and unblock.) + const adHoc = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + prompt: 'Code Critic review — ad-hoc re-fire', + }); + await manager.waitForJob(adHoc.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); + + it('C9 (round-id): a legacy unstamped critic newer than the newest round does not unblock', async () => { + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // code_critic FAILS in round 1 and round 2 (served twice), then the + // legacy unstamped critic PASSES. + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 2 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const stamp = (round: string, stage: string) => { + const promptLabel = stage === 'code_critic' ? 'Code Critic' + : stage === 'test_critic' ? 'Test Critic' + : 'Interaction E2E'; + return { + source_job_id: childId, + eval_stage: stage, + eval_round: round, + expected_eval_stages: ['code_critic', 'test_critic', 'interaction_e2e'], + prompt: `${promptLabel} review — stage: ${stage}`, + }; + }; + + // Round 1 (stamped): code_critic FAILS, test_critic + interaction_e2e PASS. + const r1c = manager.startJob('critic_eval', stamp('round-1', 'code_critic')); + const r1t = manager.startJob('eval', stamp('round-1', 'test_critic')); + const r1p = manager.startJob('interaction_e2e', stamp('round-1', 'interaction_e2e')); + await manager.waitForJob(r1c.id, null, 5_000); + await manager.waitForJob(r1t.id, null, 5_000); + await manager.waitForJob(r1p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Round 2 (stamped): code_critic FAILS again, test_critic + interaction_e2e PASS. + const r2c = manager.startJob('critic_eval', stamp('round-2', 'code_critic')); + const r2t = manager.startJob('eval', stamp('round-2', 'test_critic')); + const r2p = manager.startJob('interaction_e2e', stamp('round-2', 'interaction_e2e')); + await manager.waitForJob(r2c.id, null, 5_000); + await manager.waitForJob(r2t.id, null, 5_000); + await manager.waitForJob(r2p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Legacy unstamped code_critic PASSES, created after round 2. The round + // gate must ignore it — round 2 (newest) has a failed code_critic, so the + // parent stays blocked. (Per-stage-newest would pick the legacy pass + + // round 2's green critics and unblock.) + const legacy = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + prompt: 'Code Critic review — legacy unstamped', + }); + await manager.waitForJob(legacy.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── @@ -616,6 +810,19 @@ describe('integration: latest_e2e_status surfacing', () => { const parsed = parseResult(r); expect(parsed.latest_e2e_status).toBe('failed'); }); + + it('D4: prose braces after the verdict still surface layer2_status (shared-parser regression)', async () => { + // The old first-{ to last-} slice in tenet_get_status spanned the verdict + // plus the prose braces, JSON.parse failed, and latest_e2e_status was + // silently dropped. The shared parser (rubric.ts) must surface it. + const h = createHarness([ + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed-prose-braces.md' }, + ]); + await driveOneE2e(h, []); + const r = await h.getStatus({}); + const parsed = parseResult(r); + expect(parsed.latest_e2e_status).toBe('completed'); + }); }); // ─── E. Parser stress tests ───────────────────────────────────────────────── diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 15fe9df..1ecd133 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -12,6 +12,7 @@ import { import { StateStore } from './state-store.js'; import { DEFAULT_EVAL_STAGES } from './critic-roster.js'; import { readArtifactFile, type ArtifactPaths } from './artifact-paths.js'; +import { extractRubricJson } from './rubric.js'; /** * Extract a typed {@link ArtifactPaths} from an untyped `job.params.artifact_paths` @@ -73,103 +74,6 @@ const sleep = async (ms: number): Promise => setTimeout(resolve, ms); }); -/** - * Scan a string for JSON objects and return the rightmost TOP-LEVEL one that - * carries a boolean `passed` key — the rubric shape every critic preamble - * mandates ("End with: {…passed…}"). Robust against prose containing braces - * (`try { }` isn't valid JSON; `{"mode": "strict"}` has no `passed` key), and - * against nested `passed` objects (custom critics embed assertions like - * `{"assertions": [{"passed": true}]}` — those must never be picked over the - * top-level verdict, or a failing critic could false-green the gate). - */ -const findRightmostPassedObject = (text: string): Record | null => { - const stack: number[] = []; - let inString = false; - let escaped = false; - let best: Record | null = null; - - for (let i = 0; i < text.length; i++) { - const ch = text[i]; - if (inString) { - if (escaped) { - escaped = false; - } else if (ch === '\\') { - escaped = true; - } else if (ch === '"') { - inString = false; - } - continue; - } - if (ch === '"') { - inString = true; - continue; - } - if (ch === '{') { - stack.push(i); - continue; - } - if (ch === '}' && stack.length > 0) { - const start = stack.pop() as number; - // Only top-level objects count. Nested objects (assertion arrays, tool - // results quoted in prose) are never verdicts. - if (stack.length !== 0) { - continue; - } - try { - const parsed = JSON.parse(text.slice(start, i + 1)) as unknown; - if (parsed && typeof parsed === 'object' && typeof (parsed as Record).passed === 'boolean') { - best = parsed as Record; - } - } catch { - // Not valid JSON — prose braces, skip. - } - } - } - return best; -}; - -export const extractRubricJson = (rawOutput: unknown): Record | null => { - if (rawOutput && typeof rawOutput === 'object') { - return rawOutput as Record; - } - - if (typeof rawOutput !== 'string') { - return null; - } - - const stripped = rawOutput.trim(); - const fenced = stripped.match(/```(?:json)?\s*([\s\S]*?)```/); - const candidates = fenced ? [fenced[1].trim(), stripped] : [stripped]; - - for (const candidate of candidates) { - try { - const parsed = JSON.parse(candidate); - if ( - parsed && - typeof parsed === 'object' && - !Array.isArray(parsed) && - typeof (parsed as Record).passed === 'boolean' - ) { - return parsed as Record; - } - } catch { - // Try next candidate - } - } - - // Fallback: rightmost JSON object carrying a boolean `passed` key. Replaces - // the old first-{ to last-} slice, which spanned multiple objects/prose and - // returned null whenever any earlier text contained braces. - for (const candidate of candidates) { - const found = findRightmostPassedObject(candidate); - if (found) { - return found; - } - } - - return null; -}; - export class JobManager { private readonly stateStore: StateStore; private readonly adapterRegistry: AdapterRegistry; @@ -1083,13 +987,16 @@ export class JobManager { // gets a fresh id. Only the NEWEST round ever decides the gate — every // critic in a round evaluated the same source state, so requiring the // whole round to pass avoids mixing verdicts across code revisions (the - // "green gate, still wrong code" failure). If any sibling predates the - // stamp (existing DBs), fall back to per-stage-newest so old stuck - // parents still recover. - const allStamped = + // "green gate, still wrong code" failure). The gate runs whenever at least + // one stamped round exists; unstamped siblings (ad-hoc re-fires via + // tenet_start_job, legacy pre-stamp evals) are ignored by the round gate + // and must not disable it — otherwise the per-stage fallback would mix + // verdicts across rounds. Only when NO sibling is stamped (all-legacy DB) + // do we fall back to per-stage-newest so old stuck parents still recover. + const hasStampedRound = siblings.length > 0 && - siblings.every((s) => typeof s.params.eval_round === 'string' && s.params.eval_round !== ''); - if (allStamped) { + siblings.some((s) => typeof s.params.eval_round === 'string' && s.params.eval_round !== ''); + if (hasStampedRound) { this.checkBlockingFindingResumeByRound(siblings, blockedParentId, sourceJobId); return; } diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts new file mode 100644 index 0000000..d3fcef7 --- /dev/null +++ b/src/core/rubric.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { extractRubricJson, findRightmostTopLevelObject } from './rubric.js'; + +describe('extractRubricJson', () => { + it('returns a bare verdict object as-is', () => { + const parsed = extractRubricJson({ passed: true, stage: 'code_critic', findings: [] }); + expect(parsed?.passed).toBe(true); + }); + + it('parses a bare JSON string verdict', () => { + const parsed = extractRubricJson('{"passed": true, "stage": "code_critic", "findings": []}'); + expect(parsed?.passed).toBe(true); + expect(parsed?.stage).toBe('code_critic'); + }); + + it('parses a fenced verdict block', () => { + const parsed = extractRubricJson('```json\n{"passed": true, "stage": "code_critic", "findings": []}\n```'); + expect(parsed?.passed).toBe(true); + }); + + it('returns null when no object has a passed key', () => { + expect(extractRubricJson('I began my review but ran out of context before finishing.')).toBeNull(); + }); + + it('ignores a trailing object without a passed key', () => { + const output = [ + 'I reviewed the diff. Verdict:', + '{"passed": true, "stage": "code_critic", "findings": []}', + '{"note": "this is a trailing note, not a verdict"}', + ].join('\n'); + const parsed = extractRubricJson(output); + expect(parsed?.passed).toBe(true); + expect(parsed?.stage).toBe('code_critic'); + }); + + it('never picks a nested passed object over the top-level verdict', () => { + // Failing verdict + trailing tool result with nested passed:true — must + // return the FAILING verdict, not false-green the gate. + const t1 = [ + 'V: {"passed": false, "stage": "code_critic", "findings": [{"category":"product_bug","detail":"x"}]}', + 'Tool output: {"results": [{"name": "syntax", "passed": true}]}', + ].join('\n'); + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + + // Passing verdict + trailing object with nested passed:false — must + // return the PASSING verdict, not false-strand the parent. + const t2 = [ + 'V: {"passed": true, "stage": "code_critic", "findings": []}', + '{"checks": {"lint": {"passed": false}}}', + ].join('\n'); + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(true); + expect(r2?.stage).toBe('code_critic'); + }); + + it('handles the custom-critic shape (nested assertions in the top-level verdict)', () => { + const output = JSON.stringify({ + passed: true, + stage: 'credit_ledger_integrity', + assertions: [ + { name: 'append_only', passed: true, evidence: 'ledger is append-only' }, + { name: 'audit_fields', passed: true, evidence: 'created_at set' }, + ], + findings: [], + }); + const parsed = extractRubricJson(output); + expect(parsed?.passed).toBe(true); + expect(parsed?.stage).toBe('credit_ledger_integrity'); + }); + + it('does NOT return the first fenced object when a later object is the verdict (fenced-echo regression)', () => { + // A critic quotes tool output in a fenced block carrying passed:true, then + // gives a FAILING verdict. The old fenced-first fast path returned the tool + // object and false-greened the gate. + const t1 = [ + 'The tool reported:', + '```json', + '{"tool": "pytest", "passed": true, "count": 12}', + '```', + 'Verdict: {"passed": false, "stage": "code_critic", "findings": [{"category":"product_bug","detail":"x"}]}', + ].join('\n'); + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + + // Two fenced blocks, verdict in the second. + const t2 = [ + '```json', + '{"tool": "pytest", "passed": true}', + '```', + '```json', + '{"passed": false, "stage": "code_critic", "findings": []}', + '```', + ].join('\n'); + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(false); + expect(r2?.stage).toBe('code_critic'); + }); + + it('prefers a staged verdict over a later stage-less echo (echoed-verdict regression)', () => { + // Failing staged verdict + trailing stage-less tool echo with passed:true. + const t1 = [ + 'V: {"passed": false, "stage": "code_critic", "findings": [{"category":"product_bug","detail":"x"}]}', + 'Tool output: {"passed": true, "tool": "syntax-check"}', + ].join('\n'); + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + + // Passing staged verdict + trailing stage-less echo with passed:false. + const t2 = [ + 'V: {"passed": true, "stage": "code_critic", "findings": []}', + 'Tool output: {"passed": false, "tool": "syntax-check"}', + ].join('\n'); + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(true); + expect(r2?.stage).toBe('code_critic'); + }); + + it('recovers a verdict after an unbalanced { in prose (unbalanced-brace regression)', () => { + // A stray { in prose before the verdict used to leave a permanent stack + // entry, so the verdict was never top-level and the parent stranded. + const t1 = 'The signature is foo({ and then the verdict: {"passed": true, "stage": "code_critic", "findings": []}'; + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(true); + expect(r1?.stage).toBe('code_critic'); + + // Truncated code block before the verdict. + const t2 = 'I checked the diff:\n```ts\nfunction foo() {\n return 1;\n```\nVerdict: {"passed": false, "stage": "code_critic", "findings": ["x"]}'; + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(false); + expect(r2?.stage).toBe('code_critic'); + }); + + it('keeps the top-level invariant for a stage-less verdict followed by a nested passed object', () => { + const output = [ + 'V: {"passed": true}', + '{"checks": {"lint": {"passed": false}}}', + ].join('\n'); + const parsed = extractRubricJson(output); + expect(parsed?.passed).toBe(true); + }); +}); + +describe('findRightmostTopLevelObject (tenet_get_status surface)', () => { + it('extracts layer2_status from e2e output with prose braces after the verdict', () => { + const output = [ + 'I explored the UI and ran the scripted checks.', + '{"passed": true, "stage": "interaction_e2e", "layer2_status": "completed", "scripted_results": "all green"}', + 'Note: the fix touched { src/foo.ts } and { src/bar.ts }.', + ].join('\n'); + const parsed = findRightmostTopLevelObject(output); + expect(parsed?.layer2_status).toBe('completed'); + }); + + it('recovers layer2_status after an unbalanced { in prose', () => { + const output = 'Note: { src/foo.ts and then {"passed": true, "stage": "interaction_e2e", "layer2_status": "completed"}'; + const parsed = findRightmostTopLevelObject(output); + expect(parsed?.layer2_status).toBe('completed'); + }); +}); diff --git a/src/core/rubric.ts b/src/core/rubric.ts new file mode 100644 index 0000000..bd5aead --- /dev/null +++ b/src/core/rubric.ts @@ -0,0 +1,164 @@ +/** + * Shared rubric extraction for critic verdicts. + * + * Every critic preamble mandates the verdict shape "End with: {…passed…}" and + * the built-in critics additionally mandate a `stage` key. This module is the + * single parser for that shape, shared by the resume gate (job-manager.ts) and + * the status surface (tenet-get-status.ts) so the two consumers of the same + * stored critic output can never drift apart. + */ + +/** + * Scan a string for top-level JSON objects, returning the rightmost one that + * `accept` approves. When `prefer` approves an object, it wins over any + * non-preferred object even if the non-preferred one appears later — used to + * prefer verdicts that carry a `stage` key over stage-less tool-result echoes. + */ +const scanTopLevel = ( + text: string, + accept: (record: Record) => boolean, + prefer: (record: Record) => boolean, +): Record | null => { + const stack: number[] = []; + let inString = false; + let escaped = false; + let bestPreferred: Record | null = null; + let bestAny: Record | null = null; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === '{') { + stack.push(i); + continue; + } + if (ch === '}' && stack.length > 0) { + const start = stack.pop() as number; + // Only top-level objects count. Nested objects (assertion arrays, tool + // results quoted in prose) are never verdicts. + if (stack.length !== 0) { + continue; + } + try { + const parsed = JSON.parse(text.slice(start, i + 1)) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const record = parsed as Record; + if (accept(record)) { + if (prefer(record)) { + bestPreferred = record; + } else { + bestAny = record; + } + } + } + } catch { + // Not valid JSON — prose braces, skip. + } + } + } + return bestPreferred ?? bestAny; +}; + +/** + * Best-effort recovery for unbalanced braces in prose. The strict top-level + * scan treats a stray `{` (a code snippet, a truncated block) as an open + * object, so a verdict that follows it is never top-level and the scan returns + * null. The critic preamble mandates the verdict at the END of the output, so + * the verdict is the last JSON object: walk `{` positions from the end, parse + * each to the first `}` after it, and return the first object `accept` + * approves. Only reached when the strict scan found nothing. + */ +const recoverFromUnbalancedBraces = ( + text: string, + accept: (record: Record) => boolean, +): Record | null => { + for (let i = text.lastIndexOf('{'); i >= 0; i = text.lastIndexOf('{', i - 1)) { + const end = text.indexOf('}', i); + if (end < 0) { + break; + } + try { + const parsed = JSON.parse(text.slice(i, end + 1)) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const record = parsed as Record; + if (accept(record)) { + return record; + } + } + } catch { + // Not valid JSON — prose braces, skip. + } + } + return null; +}; + +/** + * Rightmost TOP-LEVEL JSON object in a string, regardless of keys. Used by + * tenet_get_status to surface layer2_status from e2e critic output. + */ +export const findRightmostTopLevelObject = (text: string): Record | null => + scanTopLevel(text, () => true, () => false) ?? recoverFromUnbalancedBraces(text, () => true); + +/** + * Rightmost TOP-LEVEL object carrying a boolean `passed` key — the rubric shape + * every critic preamble mandates. Prefers an object that also carries a + * `stage` key (the built-in critics' verdict shape): a tool-result echo like + * `{"passed": true, "tool": "syntax-check"}` rarely carries `stage`, so a + * staged verdict wins over a later stage-less echo — a failing critic that + * pastes a passing tool result after its verdict must not false-green the gate. + */ +export const findRightmostPassedObject = (text: string): Record | null => + scanTopLevel( + text, + (r) => typeof r.passed === 'boolean', + (r) => typeof r.stage === 'string', + ) ?? recoverFromUnbalancedBraces(text, (r) => typeof r.passed === 'boolean'); + +/** + * Extract the critic verdict from a worker's raw output. Accepts a bare object + * (already-parsed output), a bare JSON string, or prose containing the verdict + * JSON. Returns null when no passed-bearing object is found. + */ +export const extractRubricJson = (rawOutput: unknown): Record | null => { + if (rawOutput && typeof rawOutput === 'object') { + return rawOutput as Record; + } + + if (typeof rawOutput !== 'string') { + return null; + } + + const stripped = rawOutput.trim(); + + // Whole-output fast path: the output is exactly a verdict object. No + // fenced-first shortcut — a fenced block earlier in the output is never the + // verdict over a later one, so the scan below is the single source of truth. + try { + const parsed = JSON.parse(stripped) as unknown; + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + typeof (parsed as Record).passed === 'boolean' + ) { + return parsed as Record; + } + } catch { + // Not a bare JSON object — scan below. + } + + return findRightmostPassedObject(stripped); +}; diff --git a/src/mcp/tools/tenet-get-status.ts b/src/mcp/tools/tenet-get-status.ts index e918632..8264b8c 100644 --- a/src/mcp/tools/tenet-get-status.ts +++ b/src/mcp/tools/tenet-get-status.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { JobManager } from '../../core/job-manager.js'; +import { findRightmostTopLevelObject } from '../../core/rubric.js'; import { StateStore } from '../../core/state-store.js'; import { checkForUpdate } from '../../core/update-checker.js'; import type { Job, JobStatus, ProjectStatus } from '../../types/index.js'; @@ -20,31 +21,19 @@ const extractRawOutput = (output: unknown): string | undefined => { const extractJsonObject = (raw: string | undefined): Record | undefined => { if (!raw) return undefined; const stripped = raw.trim(); - const fenced = stripped.match(/```(?:json)?\s*([\s\S]*?)```/); - const candidates = fenced ? [fenced[1].trim(), stripped] : [stripped]; - for (const candidate of candidates) { - try { - const parsed = JSON.parse(candidate); - if (parsed && typeof parsed === 'object') { - return parsed as Record; - } - } catch { - /* continue */ - } - const start = candidate.indexOf('{'); - const end = candidate.lastIndexOf('}'); - if (start >= 0 && end > start) { - try { - const parsed = JSON.parse(candidate.slice(start, end + 1)); - if (parsed && typeof parsed === 'object') { - return parsed as Record; - } - } catch { - /* continue */ - } + try { + const parsed = JSON.parse(stripped) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; } + } catch { + // Not a bare JSON object — scan below. } - return undefined; + // Shared parser with the resume gate (job-manager.ts) so the two consumers + // of the same stored critic output can never drift apart. The old first-{ + // to last-} slice spanned multiple objects/prose and dropped layer2_status + // whenever prose contained braces. + return findRightmostTopLevelObject(stripped) ?? undefined; }; const findLatestE2eStatus = (stateStore: StateStore): string | undefined => { diff --git a/tests/fixtures/fake-agents/playwright-layer2-completed-prose-braces.md b/tests/fixtures/fake-agents/playwright-layer2-completed-prose-braces.md new file mode 100644 index 0000000..86f956d --- /dev/null +++ b/tests/fixtures/fake-agents/playwright-layer2-completed-prose-braces.md @@ -0,0 +1,5 @@ +I explored the UI via Playwright MCP. The login flow works end-to-end and the navigation bar links are all reachable. + +{"passed": true, "stage": "interaction_e2e", "layer2_status": "completed", "scripted_results": "42 passed, 0 failed", "exploratory_findings": ["Login flow verified end-to-end"], "screenshots": ["screenshot-login.png"]} + +Note: the fix touched { src/foo.ts } and { src/bar.ts } (see commit 4b8b12f). From b85948741d16a87724c35ca8b6c782eaf92d9398 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 08:21:28 +0900 Subject: [PATCH 70/87] fix(eval): track bracket depth in rubric scan + gate brace recovery on unbalanced input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scanTopLevel now tracks [] alongside {} so an object wrapped in a top-level array ([{"passed": true}]) is never treated as a verdict — the whole-string fast path already rejected arrays, the scan now agrees. - The unbalanced-brace recovery only runs when the scan left the stack non-empty. Previously it fired on any balanced output with no top-level verdict and could pick up a nested passed object (false-strand/false-green). Co-Authored-By: Claude --- src/core/rubric.test.ts | 27 +++++++++++++++++++++++++++ src/core/rubric.ts | 35 +++++++++++++++++++++++------------ 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts index d3fcef7..3ca52a4 100644 --- a/src/core/rubric.test.ts +++ b/src/core/rubric.test.ts @@ -142,6 +142,33 @@ describe('extractRubricJson', () => { const parsed = extractRubricJson(output); expect(parsed?.passed).toBe(true); }); + + it('rejects an object wrapped in a top-level array (bracket-depth regression)', () => { + // The scanner tracks {} but must also track [] — an array of verdicts is + // not a verdict object, and the whole-string fast path already rejects it. + const t1 = '[{"passed": true, "stage": "code_critic"}]'; + expect(extractRubricJson(t1)).toBeNull(); + + // A verdict followed by an array of passed objects must keep the verdict. + const t2 = [ + 'V: {"passed": false, "stage": "code_critic", "findings": ["x"]}', + '[{"passed": true, "tool": "syntax-check"}]', + ].join('\n'); + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(false); + expect(r2?.stage).toBe('code_critic'); + }); + + it('does not run the brace recovery on balanced output with no top-level verdict', () => { + // The recovery exists for unbalanced braces in prose. On balanced output + // whose only passed object is nested, it must NOT fire and pick up the + // nested object — that would false-strand (or false-green) the gate. + const t1 = '{"checks": {"lint": {"passed": false}}}'; + expect(extractRubricJson(t1)).toBeNull(); + + const t2 = '{"results": [{"name": "syntax", "passed": true}]}'; + expect(extractRubricJson(t2)).toBeNull(); + }); }); describe('findRightmostTopLevelObject (tenet_get_status surface)', () => { diff --git a/src/core/rubric.ts b/src/core/rubric.ts index bd5aead..2f1966d 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -13,12 +13,18 @@ * `accept` approves. When `prefer` approves an object, it wins over any * non-preferred object even if the non-preferred one appears later — used to * prefer verdicts that carry a `stage` key over stage-less tool-result echoes. + * + * Tracks both `{}` and `[]` depth so an object wrapped in a top-level array + * (`[{"passed": true}]`) is never treated as a verdict — the whole-string fast + * path rejects arrays, and the scan must agree. `unbalanced` reports whether + * the stack was left non-empty (a stray `{`/`[` in prose), which is the only + * condition under which the brace-recovery fallback may run. */ const scanTopLevel = ( text: string, accept: (record: Record) => boolean, prefer: (record: Record) => boolean, -): Record | null => { +): { best: Record | null; unbalanced: boolean } => { const stack: number[] = []; let inString = false; let escaped = false; @@ -41,15 +47,16 @@ const scanTopLevel = ( inString = true; continue; } - if (ch === '{') { + if (ch === '{' || ch === '[') { stack.push(i); continue; } - if (ch === '}' && stack.length > 0) { + if ((ch === '}' || ch === ']') && stack.length > 0) { const start = stack.pop() as number; - // Only top-level objects count. Nested objects (assertion arrays, tool - // results quoted in prose) are never verdicts. - if (stack.length !== 0) { + // Only objects at brace-depth 0 AND bracket-depth 0 count. Nested objects + // (assertion arrays, tool results quoted in prose) are never verdicts — + // and neither is an object wrapped in a top-level array. + if (stack.length !== 0 || ch === ']') { continue; } try { @@ -69,7 +76,7 @@ const scanTopLevel = ( } } } - return bestPreferred ?? bestAny; + return { best: bestPreferred ?? bestAny, unbalanced: stack.length > 0 }; }; /** @@ -109,8 +116,10 @@ const recoverFromUnbalancedBraces = ( * Rightmost TOP-LEVEL JSON object in a string, regardless of keys. Used by * tenet_get_status to surface layer2_status from e2e critic output. */ -export const findRightmostTopLevelObject = (text: string): Record | null => - scanTopLevel(text, () => true, () => false) ?? recoverFromUnbalancedBraces(text, () => true); +export const findRightmostTopLevelObject = (text: string): Record | null => { + const { best, unbalanced } = scanTopLevel(text, () => true, () => false); + return best ?? (unbalanced ? recoverFromUnbalancedBraces(text, () => true) : null); +}; /** * Rightmost TOP-LEVEL object carrying a boolean `passed` key — the rubric shape @@ -120,12 +129,14 @@ export const findRightmostTopLevelObject = (text: string): Record | null => - scanTopLevel( +export const findRightmostPassedObject = (text: string): Record | null => { + const { best, unbalanced } = scanTopLevel( text, (r) => typeof r.passed === 'boolean', (r) => typeof r.stage === 'string', - ) ?? recoverFromUnbalancedBraces(text, (r) => typeof r.passed === 'boolean'); + ); + return best ?? (unbalanced ? recoverFromUnbalancedBraces(text, (r) => typeof r.passed === 'boolean') : null); +}; /** * Extract the critic verdict from a worker's raw output. Accepts a bare object From fb51c0e5f318cd0c6cbb39818d20c44571656176 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 08:44:54 +0900 Subject: [PATCH 71/87] fix(eval): rewrite brace recovery with matching-close + stage-preference; singleton rounds for unstamped critics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review found the brace-recovery fallback could false-green the gate: - It sliced each { to the FIRST } (not the matching one), so a verdict with nested objects in findings was sliced unterminated and stranded the parent. - It had no stage-preference or top-level check, so a passing tool echo after a failing verdict (with a stray { in prose) was returned as the verdict and unblocked the parent on a false-green. Now the recovery walks { positions from the end, parses each to its matching } (bracket/string-aware), and applies the same accept/prefer semantics as the strict scan. Also closes the unstamped blind spot: unstamped siblings (ad-hoc tenet_start_job re-fires, legacy pre-stamp evals) become singleton rounds keyed by job id, so a NEWER unstamped critic forces the gate to wait for a fresh stamped round (fail-closed) instead of being invisible while the parent unblocks on an older round's stale green. Tests: recovery cases (echo, nested findings, finding-carries-passed), NDJSON non-string part.text guard, C10 (newer unstamped RED critic keeps the parent blocked — verified red against the old skip behavior). Co-Authored-By: Claude --- src/adapters/adapter.test.ts | 20 +++++++++++ src/core/integration.test.ts | 68 ++++++++++++++++++++++++++++++++++++ src/core/job-manager.ts | 10 ++++-- src/core/rubric.test.ts | 37 ++++++++++++++++++++ src/core/rubric.ts | 63 +++++++++++++++++++++++++++++---- 5 files changed, 189 insertions(+), 9 deletions(-) diff --git a/src/adapters/adapter.test.ts b/src/adapters/adapter.test.ts index 212801b..d087af2 100644 --- a/src/adapters/adapter.test.ts +++ b/src/adapters/adapter.test.ts @@ -303,6 +303,26 @@ describe('OpenCodeAdapter NDJSON output collapse', () => { expect(response.output).toBe('verdict here'); }); + it('skips text events whose part.text is not a string (shape-change guard)', async () => { + // The typeof guard is the load-bearing line against a future opencode + // shape change (e.g. part.text becoming an object). A truthy check would + // push a non-string into parts and emit "[object Object]" into the output + // that feeds rubric extraction — this test pins the guard. + const mixed = [ + '{"type":"text","part":{"text":{"nested":true}}}', + '{"type":"text","part":{"text":42}}', + '{"type":"text","part":{"text":"real verdict here"}}', + ].join('\n'); + spawnMock.mockImplementation(() => makeFakeChild(0, mixed)); + + const adapter = new OpenCodeAdapter(); + const response = await adapter.invoke({ prompt: 'hi' }); + + expect(response.success).toBe(true); + expect(response.output).toBe('real verdict here'); + expect(response.output).not.toContain('[object Object]'); + }); + it('verdict survives a later text event containing real braces (extractRubricJson scan)', async () => { const stream = [ '{"type":"step_start","part":{"id":"a","type":"step-start"}}', diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index 0d7b604..eb0d81b 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -761,6 +761,74 @@ describe('integration: blocking finding auto-resume', () => { await manager.waitForJob(legacy.id, null, 5_000); expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); }); + + it('C10 (round-id): a newer unstamped RED critic cannot be ignored while the parent unblocks on an older green round', async () => { + // The fail-open direction of the unstamped blind spot: an ad-hoc re-fire + // (tenet_start_job, no eval_round) that comes back RED must force the gate + // to wait — not be invisible while the parent unblocks on an older round's + // stale green. Unstamped siblings become singleton rounds, which can never + // satisfy "every expected stage present", so the gate stays blocked. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // Round 1 code_critic PASSES (served once), then the ad-hoc re-fire FAILS. + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const stamp = (round: string, stage: string) => { + const promptLabel = stage === 'code_critic' ? 'Code Critic' + : stage === 'test_critic' ? 'Test Critic' + : 'Interaction E2E'; + return { + source_job_id: childId, + eval_stage: stage, + eval_round: round, + expected_eval_stages: ['code_critic', 'test_critic', 'interaction_e2e'], + prompt: `${promptLabel} review — stage: ${stage}`, + }; + }; + + // Round 1 (stamped): code_critic + test_critic PASS. + const r1c = manager.startJob('critic_eval', stamp('round-1', 'code_critic')); + const r1t = manager.startJob('eval', stamp('round-1', 'test_critic')); + await manager.waitForJob(r1c.id, null, 5_000); + await manager.waitForJob(r1t.id, null, 5_000); + + // Round 1's last critic (interaction_e2e) is created, THEN an ad-hoc + // unstamped code_critic re-fire is created — so the ad-hoc is the NEWEST + // sibling. It FAILS (passed:false) and must not be invisible to the gate. + const r1p = manager.startJob('interaction_e2e', stamp('round-1', 'interaction_e2e')); + const adHoc = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + prompt: 'Code Critic review — ad-hoc re-fire', + }); + await manager.waitForJob(adHoc.id, null, 5_000); + await manager.waitForJob(r1p.id, null, 5_000); + + // The gate must NOT unblock on round 1's stale green while a newer red + // evaluation exists. + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 1ecd133..2f37216 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -1011,10 +1011,16 @@ export class JobManager { sourceJobId: string, ): void { // Group by round id; pick the newest round by max createdAt of its critics. + // Unstamped siblings (ad-hoc re-fires via tenet_start_job, legacy pre-stamp + // evals) become singleton rounds keyed by job id. A singleton can never + // satisfy "every expected stage present", so a NEWER unstamped critic + // forces the gate to wait for a fresh stamped round (fail-closed) instead + // of being invisible — otherwise a red ad-hoc re-fire could be ignored + // while the parent unblocks on an older round's stale green. const byRound = new Map(); for (const s of siblings) { - const roundId = typeof s.params.eval_round === 'string' ? s.params.eval_round : ''; - if (!roundId) continue; + const stamped = typeof s.params.eval_round === 'string' && s.params.eval_round !== ''; + const roundId = stamped ? (s.params.eval_round as string) : `__unstamped__${s.id}`; const arr = byRound.get(roundId) ?? []; arr.push(s); byRound.set(roundId, arr); diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts index 3ca52a4..b05c321 100644 --- a/src/core/rubric.test.ts +++ b/src/core/rubric.test.ts @@ -169,6 +169,43 @@ describe('extractRubricJson', () => { const t2 = '{"results": [{"name": "syntax", "passed": true}]}'; expect(extractRubricJson(t2)).toBeNull(); }); + + it('recovery: a passing tool echo after a failing verdict never wins (stray-brace + echo)', () => { + // A stray { in prose makes the strict scan fail; the recovery must still + // prefer the staged failing verdict over the stage-less passing echo. + const t1 = [ + 'The signature is foo({ and then the verdict:', + '{"passed": false, "stage": "code_critic", "findings": ["x"]}', + 'and the tool said {"results": [{"name": "syntax", "passed": true}]}', + ].join('\n'); + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + + const t2 = [ + 'prose { then verdict {"passed": false, "stage": "code_critic", "findings": []}', + 'then tool {"passed": true, "tool": "syntax-check"}', + ].join('\n'); + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(false); + expect(r2?.stage).toBe('code_critic'); + }); + + it('recovery: a verdict with nested objects in findings still parses (matching-close)', () => { + // The recovery must slice to the verdict's MATCHING } — the first } after + // the { would slice an unterminated findings array and strand the parent. + const t1 = 'The signature is foo({ and then the verdict: {"passed": false, "stage": "code_critic", "findings": [{"category":"product_bug","detail":"x"}]}'; + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + + // A finding object carrying its own passed key must never be picked over + // the staged verdict. + const t2 = 'Note: { and then {"passed": false, "stage": "code_critic", "findings": [{"category":"product_bug","detail":"x","passed": true}]}'; + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(false); + expect(r2?.stage).toBe('code_critic'); + }); }); describe('findRightmostTopLevelObject (tenet_get_status surface)', () => { diff --git a/src/core/rubric.ts b/src/core/rubric.ts index 2f1966d..065f0f3 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -79,21 +79,64 @@ const scanTopLevel = ( return { best: bestPreferred ?? bestAny, unbalanced: stack.length > 0 }; }; +/** + * Find the index of the `}` that closes the object opened at `open`, tracking + * nested braces, brackets, and strings. Returns -1 when no matching close + * exists (a stray `{` in prose). + */ +const findMatchingClose = (text: string, open: number): number => { + let depth = 0; + let inString = false; + let escaped = false; + for (let i = open; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === '{' || ch === '[') { + depth++; + } else if (ch === '}' || ch === ']') { + depth--; + if (depth === 0) { + return i; + } + } + } + return -1; +}; + /** * Best-effort recovery for unbalanced braces in prose. The strict top-level * scan treats a stray `{` (a code snippet, a truncated block) as an open * object, so a verdict that follows it is never top-level and the scan returns * null. The critic preamble mandates the verdict at the END of the output, so * the verdict is the last JSON object: walk `{` positions from the end, parse - * each to the first `}` after it, and return the first object `accept` - * approves. Only reached when the strict scan found nothing. + * each to its MATCHING `}` (not the first `}` — a verdict with nested objects + * in `findings` would otherwise be sliced unterminated), and apply the same + * accept/prefer semantics as the strict scan so a passing tool echo after a + * failing verdict can never win. Only reached when the strict scan found + * nothing AND the stack was left unbalanced. */ const recoverFromUnbalancedBraces = ( text: string, accept: (record: Record) => boolean, + prefer: (record: Record) => boolean, ): Record | null => { + let bestPreferred: Record | null = null; + let bestAny: Record | null = null; for (let i = text.lastIndexOf('{'); i >= 0; i = text.lastIndexOf('{', i - 1)) { - const end = text.indexOf('}', i); + const end = findMatchingClose(text, i); if (end < 0) { break; } @@ -102,14 +145,18 @@ const recoverFromUnbalancedBraces = ( if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const record = parsed as Record; if (accept(record)) { - return record; + if (prefer(record)) { + bestPreferred = record; + } else { + bestAny = record; + } } } } catch { // Not valid JSON — prose braces, skip. } } - return null; + return bestPreferred ?? bestAny; }; /** @@ -118,7 +165,7 @@ const recoverFromUnbalancedBraces = ( */ export const findRightmostTopLevelObject = (text: string): Record | null => { const { best, unbalanced } = scanTopLevel(text, () => true, () => false); - return best ?? (unbalanced ? recoverFromUnbalancedBraces(text, () => true) : null); + return best ?? (unbalanced ? recoverFromUnbalancedBraces(text, () => true, () => false) : null); }; /** @@ -135,7 +182,9 @@ export const findRightmostPassedObject = (text: string): Record (r) => typeof r.passed === 'boolean', (r) => typeof r.stage === 'string', ); - return best ?? (unbalanced ? recoverFromUnbalancedBraces(text, (r) => typeof r.passed === 'boolean') : null); + return best ?? (unbalanced + ? recoverFromUnbalancedBraces(text, (r) => typeof r.passed === 'boolean', (r) => typeof r.stage === 'string') + : null); }; /** From 6b8824f9a06dd9c7c469366aaeb4860181699a5a Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 09:21:47 +0900 Subject: [PATCH 72/87] fix(eval): merge strict+recovery verdicts, fix recovery walk termination, gate empty-stamp fail-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop-round review found three more rubric-parser holes and one gate edge: - A stage-less passing echo BEFORE a stray { short-circuited the recovery (best ?? recovery), so a failing verdict hidden by the stray brace was ignored and the gate false-greened. Now the recovery always runs when the stack is unbalanced and its staged verdict wins over the strict scan's echo. - The recovery walk used lastIndexOf('{', i-1), which clamps -1 to 0 — a { at position 0 looped forever once the walk no longer broke on stray braces. Rewritten as an explicit while loop with an i===0 break. - A trailing stray { after the verdict broke the walk before reaching the verdict (false-strand); the walk now skips strays and continues. - findRightmostTopLevelObject (tenet_get_status) accepted any top-level object, so a valid-JSON tool echo after the e2e verdict falsified/dropped layer2_status; it now prefers staged objects like the gate parser. - Round gate: a malformed expected_eval_stages stamp filtering to an empty set made both gate loops pass trivially (fail-open); now falls back to DEFAULT_EVAL_STAGES. Tests: echo-before-stray-brace, trailing-stray-brace, get_status echo (rubric.test.ts); C11 malformed-stamp fail-open (integration.test.ts, verified red against the old behavior). Co-Authored-By: Claude --- src/core/integration.test.ts | 44 +++++++++++++++++++ src/core/job-manager.ts | 6 ++- src/core/rubric.test.ts | 70 ++++++++++++++++++++++++++++++ src/core/rubric.ts | 82 ++++++++++++++++++++++++++---------- 4 files changed, 179 insertions(+), 23 deletions(-) diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index eb0d81b..6606918 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -829,6 +829,50 @@ describe('integration: blocking finding auto-resume', () => { // evaluation exists. expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); }); + + it('C11 (round-id): a malformed expected_eval_stages stamp cannot fail the gate open', async () => { + // A stamp that filters to an empty set ([123]) must fall back to + // DEFAULT_EVAL_STAGES — otherwise the stage-presence and completion loops + // pass trivially and the parent unblocks with no critic evaluated. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + // A single-critic round with a malformed stamp: only code_critic exists, + // so even with the DEFAULT_EVAL_STAGES fallback the round is incomplete. + const malformed = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + eval_round: 'round-1', + expected_eval_stages: [123], + prompt: 'Code Critic review — malformed stamp', + }); + await manager.waitForJob(malformed.id, null, 5_000); + + // With an empty expectedStages the gate would unblock (both loops pass + // trivially). With the DEFAULT_EVAL_STAGES fallback it stays blocked. + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 2f37216..e49e291 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -1041,9 +1041,13 @@ export class JobManager { // Read this round's own expected_eval_stages stamp (shared by all its critics). const stamp = currentRound.find((s) => Array.isArray(s.params.expected_eval_stages))?.params.expected_eval_stages; - const expectedStages = Array.isArray(stamp) && stamp.length > 0 + const filteredStages = Array.isArray(stamp) && stamp.length > 0 ? new Set(stamp.filter((st): st is string => typeof st === 'string')) : new Set(DEFAULT_EVAL_STAGES); + // A stamp that filters to an empty set (malformed non-string entries) must + // NOT produce an empty expectedStages — both the stage-presence loop and + // the completion loop would pass trivially and the gate would fail open. + const expectedStages = filteredStages.size > 0 ? filteredStages : new Set(DEFAULT_EVAL_STAGES); const presentStages = new Set( currentRound diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts index b05c321..6867c95 100644 --- a/src/core/rubric.test.ts +++ b/src/core/rubric.test.ts @@ -206,6 +206,76 @@ describe('extractRubricJson', () => { expect(r2?.passed).toBe(false); expect(r2?.stage).toBe('code_critic'); }); + + it('recovery: a passing echo BEFORE a stray { cannot mask a failing verdict (merge regression)', () => { + // The strict scan accepts the stage-less echo as bestAny; the stray { + // hides the staged failing verdict. The recovery must still run and its + // staged verdict must win over the echo. + const t1 = [ + 'Tool output: {"passed": true, "tool": "pytest", "count": 12}', + 'The signature is foo({ and then the verdict: {"passed": false, "stage": "code_critic", "findings": ["x"]}', + ].join('\n'); + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + + const t2 = '{"passed": true} some prose {unterminated {"passed": false, "stage": "code_critic"}'; + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(false); + expect(r2?.stage).toBe('code_critic'); + }); + + it('recovery: a trailing stray { after the verdict does not strand it (continue regression)', () => { + // The recovery walks { from the end; a trailing { with no matching close + // must be skipped, not break the walk before reaching the verdict. + const t1 = 'The signature is foo({ and then the verdict: {"passed": true, "stage": "code_critic", "findings": []} and then more prose {'; + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(true); + expect(r1?.stage).toBe('code_critic'); + + const t2 = 'stray { {"passed": true, "stage": "code_critic", "findings": []} more { no close'; + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(true); + expect(r2?.stage).toBe('code_critic'); + }); +}); + +describe('findRightmostTopLevelObject (tenet_get_status surface)', () => { + it('extracts layer2_status from e2e output with prose braces after the verdict', () => { + const output = [ + 'I explored the UI and ran the scripted checks.', + '{"passed": true, "stage": "interaction_e2e", "layer2_status": "completed", "scripted_results": "all green"}', + 'Note: the fix touched { src/foo.ts } and { src/bar.ts }.', + ].join('\n'); + const parsed = findRightmostTopLevelObject(output); + expect(parsed?.layer2_status).toBe('completed'); + }); + + it('recovers layer2_status after an unbalanced { in prose', () => { + const output = 'Note: { src/foo.ts and then {"passed": true, "stage": "interaction_e2e", "layer2_status": "completed"}'; + const parsed = findRightmostTopLevelObject(output); + expect(parsed?.layer2_status).toBe('completed'); + }); + + it('a valid-JSON tool echo after the verdict does not override layer2_status (stage-preference)', () => { + // The old accept-any scan returned the echo object, falsifying or dropping + // layer2_status. The e2e verdict carries stage; the echo does not. + const t1 = [ + '{"passed": true, "stage": "interaction_e2e", "layer2_status": "completed"}', + 'Tool: {"layer2_status": "failed", "tool": "syntax-check"}', + ].join('\n'); + const r1 = findRightmostTopLevelObject(t1); + expect(r1?.layer2_status).toBe('completed'); + + const t2 = [ + '```json', + '{"passed": true, "stage": "interaction_e2e", "layer2_status": "completed"}', + '```', + 'Then I checked: {"note": "all good"}', + ].join('\n'); + const r2 = findRightmostTopLevelObject(t2); + expect(r2?.layer2_status).toBe('completed'); + }); }); describe('findRightmostTopLevelObject (tenet_get_status surface)', () => { diff --git a/src/core/rubric.ts b/src/core/rubric.ts index 065f0f3..503992a 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -135,37 +135,71 @@ const recoverFromUnbalancedBraces = ( ): Record | null => { let bestPreferred: Record | null = null; let bestAny: Record | null = null; - for (let i = text.lastIndexOf('{'); i >= 0; i = text.lastIndexOf('{', i - 1)) { + let i = text.lastIndexOf('{'); + while (i >= 0) { const end = findMatchingClose(text, i); - if (end < 0) { - break; - } - try { - const parsed = JSON.parse(text.slice(i, end + 1)) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - const record = parsed as Record; - if (accept(record)) { - if (prefer(record)) { - bestPreferred = record; - } else { - bestAny = record; + if (end >= 0) { + try { + const parsed = JSON.parse(text.slice(i, end + 1)) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const record = parsed as Record; + if (accept(record)) { + if (prefer(record)) { + bestPreferred = record; + } else { + bestAny = record; + } } } + } catch { + // Not valid JSON — prose braces, skip. } - } catch { - // Not valid JSON — prose braces, skip. } + // A `{` with no matching close is a stray that cannot be the verdict — + // skip it and keep walking, or a trailing stray `{` (e.g. a truncated + // tail) would strand an earlier valid verdict. NOTE: lastIndexOf('{', -1) + // clamps to 0 and would re-find a `{` at position 0 forever, so break + // explicitly at i === 0. + if (i === 0) { + break; + } + i = text.lastIndexOf('{', i - 1); } return bestPreferred ?? bestAny; }; /** - * Rightmost TOP-LEVEL JSON object in a string, regardless of keys. Used by - * tenet_get_status to surface layer2_status from e2e critic output. + * Merge the strict scan's result with the recovery's when the stack was + * unbalanced. A staged verdict from either wins — the recovery sees objects + * the strict scan missed behind a stray brace, so a stage-less echo the strict + * scan accepted must not short-circuit the recovery's staged verdict. Otherwise + * prefer the strict scan's top-level result over the recovery's (which may be + * a nested object). + */ +const mergeStrictAndRecovered = ( + strictBest: Record | null, + recovered: Record | null, +): Record | null => { + const recoveredStaged = recovered && typeof recovered.stage === 'string' ? recovered : null; + const strictStaged = strictBest && typeof strictBest.stage === 'string' ? strictBest : null; + return recoveredStaged ?? strictStaged ?? strictBest ?? recovered; +}; + +/** + * Rightmost TOP-LEVEL JSON object in a string, preferring one that carries a + * `stage` key (the e2e verdict shape). Used by tenet_get_status to surface + * layer2_status from e2e critic output — a valid-JSON tool echo after the + * verdict must not override it. */ export const findRightmostTopLevelObject = (text: string): Record | null => { - const { best, unbalanced } = scanTopLevel(text, () => true, () => false); - return best ?? (unbalanced ? recoverFromUnbalancedBraces(text, () => true, () => false) : null); + const { best, unbalanced } = scanTopLevel(text, () => true, (r) => typeof r.stage === 'string'); + if (!unbalanced) { + return best; + } + return mergeStrictAndRecovered( + best, + recoverFromUnbalancedBraces(text, () => true, (r) => typeof r.stage === 'string'), + ); }; /** @@ -182,9 +216,13 @@ export const findRightmostPassedObject = (text: string): Record (r) => typeof r.passed === 'boolean', (r) => typeof r.stage === 'string', ); - return best ?? (unbalanced - ? recoverFromUnbalancedBraces(text, (r) => typeof r.passed === 'boolean', (r) => typeof r.stage === 'string') - : null); + if (!unbalanced) { + return best; + } + return mergeStrictAndRecovered( + best, + recoverFromUnbalancedBraces(text, (r) => typeof r.passed === 'boolean', (r) => typeof r.stage === 'string'), + ); }; /** From b599ccd51b391378e42492f462772900bed24b32 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 09:46:06 +0900 Subject: [PATCH 73/87] fix(eval): recovery keeps rightmost verdict; tests for escaped-quote/cancelled paths; sync docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop-round review found: - recoverFromUnbalancedBraces walked { right-to-left but overwrote bestPreferred/bestAny on every accepted object, so the LEFTMOST staged object won — inverting the rightmost-verdict intent. With two staged objects and a stray brace, a passing echo before the verdict false-greened the gate (or a stale failing verdict false-stranded it). Now only the first accepted object per class (the rightmost) is kept. - findMatchingClose's string-state handling (escaped quotes, unterminated strings) was correct but untested — added recovery tests pinning it. - A cancelled critic in the newest round was untested — added C12 (parent stays blocked). - Stale docs: the in-code comment and PR body said unstamped siblings are "ignored", but the shipped code makes them fail-closed singletons. Both updated to describe the actual behavior. - Removed a duplicate findRightmostTopLevelObject describe block in rubric.test.ts (leftover from an earlier edit). Co-Authored-By: Claude --- src/core/integration.test.ts | 56 +++++++++++++++++++++++++++++++++++ src/core/job-manager.ts | 10 ++++--- src/core/rubric.test.ts | 57 ++++++++++++++++++++++++------------ src/core/rubric.ts | 11 +++++-- 4 files changed, 110 insertions(+), 24 deletions(-) diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index 6606918..bf85789 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -873,6 +873,62 @@ describe('integration: blocking finding auto-resume', () => { // trivially). With the DEFAULT_EVAL_STAGES fallback it stays blocked. expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); }); + + it('C12 (round-id): a cancelled critic in the newest round keeps the parent blocked', async () => { + // The status check (s.status !== 'completed') is the only guard against a + // cancelled critic. A cancelled critic must keep the round incomplete — + // the parent stays blocked until a fresh round is dispatched. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const stamp = (round: string, stage: string) => { + const promptLabel = stage === 'code_critic' ? 'Code Critic' + : stage === 'test_critic' ? 'Test Critic' + : 'Interaction E2E'; + return { + source_job_id: childId, + eval_stage: stage, + eval_round: round, + expected_eval_stages: ['code_critic', 'test_critic', 'interaction_e2e'], + prompt: `${promptLabel} review — stage: ${stage}`, + }; + }; + + // Round 1 (stamped): cancel test_critic synchronously while it is still + // pending (before the dispatch loop picks it up). + const r1c = manager.startJob('critic_eval', stamp('round-1', 'code_critic')); + const r1t = manager.startJob('eval', stamp('round-1', 'test_critic')); + const r1p = manager.startJob('interaction_e2e', stamp('round-1', 'interaction_e2e')); + manager.cancelJob(r1t.id); + expect(store.getJob(r1t.id)?.status).toBe('cancelled'); + + // The other two critics complete green, but the round is incomplete + // (test_critic cancelled) — the parent must stay blocked. + await manager.waitForJob(r1c.id, null, 5_000); + await manager.waitForJob(r1p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index e49e291..4369871 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -989,10 +989,12 @@ export class JobManager { // whole round to pass avoids mixing verdicts across code revisions (the // "green gate, still wrong code" failure). The gate runs whenever at least // one stamped round exists; unstamped siblings (ad-hoc re-fires via - // tenet_start_job, legacy pre-stamp evals) are ignored by the round gate - // and must not disable it — otherwise the per-stage fallback would mix - // verdicts across rounds. Only when NO sibling is stamped (all-legacy DB) - // do we fall back to per-stage-newest so old stuck parents still recover. + // tenet_start_job, legacy pre-stamp evals) become singleton rounds inside + // the round gate, so a NEWER unstamped critic forces the gate to wait for + // a fresh stamped round (fail-closed) instead of being invisible — a red + // ad-hoc re-fire must not be ignored while the parent unblocks on an older + // round's stale green. Only when NO sibling is stamped (all-legacy DB) do + // we fall back to per-stage-newest so old stuck parents still recover. const hasStampedRound = siblings.length > 0 && siblings.some((s) => typeof s.params.eval_round === 'string' && s.params.eval_round !== ''); diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts index 6867c95..591cef7 100644 --- a/src/core/rubric.test.ts +++ b/src/core/rubric.test.ts @@ -238,6 +238,45 @@ describe('extractRubricJson', () => { expect(r2?.passed).toBe(true); expect(r2?.stage).toBe('code_critic'); }); + + it('recovery: two staged objects with a stray brace keep the RIGHTMOST verdict (rightmost-wins regression)', () => { + // The recovery walks { right-to-left but must keep the first accepted + // object per class (the rightmost), not overwrite with the leftmost. + const t1 = [ + '{"passed": true, "stage": "code_critic", "findings": []}', + 'The signature is foo({ and then the verdict:', + '{"passed": false, "stage": "code_critic", "findings": ["x"]}', + ].join('\n'); + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + + // Mirrored: failing verdict before the stray brace, passing verdict after. + const t2 = [ + '{"passed": false, "stage": "code_critic", "findings": ["x"]}', + 'The signature is foo({ and then the verdict:', + '{"passed": true, "stage": "code_critic", "findings": []}', + ].join('\n'); + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(true); + expect(r2?.stage).toBe('code_critic'); + }); + + it('recovery: escaped quotes and unterminated strings in the verdict still parse (string-state)', () => { + // findMatchingClose must treat \" inside a string as escaped, not a string + // terminator — a regression would close the string early and strand the + // verdict. + const t1 = 'The signature is foo({ and then the verdict: {"passed": false, "stage": "code_critic", "findings": [{"category":"product_bug","detail":"the fix broke \\"login\\""}]}'; + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + + // A trailing unterminated string after a green verdict must be skipped. + const t2 = 'stray { {"passed": true, "stage": "code_critic", "findings": []} then {"note": "unterminated'; + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(true); + expect(r2?.stage).toBe('code_critic'); + }); }); describe('findRightmostTopLevelObject (tenet_get_status surface)', () => { @@ -277,21 +316,3 @@ describe('findRightmostTopLevelObject (tenet_get_status surface)', () => { expect(r2?.layer2_status).toBe('completed'); }); }); - -describe('findRightmostTopLevelObject (tenet_get_status surface)', () => { - it('extracts layer2_status from e2e output with prose braces after the verdict', () => { - const output = [ - 'I explored the UI and ran the scripted checks.', - '{"passed": true, "stage": "interaction_e2e", "layer2_status": "completed", "scripted_results": "all green"}', - 'Note: the fix touched { src/foo.ts } and { src/bar.ts }.', - ].join('\n'); - const parsed = findRightmostTopLevelObject(output); - expect(parsed?.layer2_status).toBe('completed'); - }); - - it('recovers layer2_status after an unbalanced { in prose', () => { - const output = 'Note: { src/foo.ts and then {"passed": true, "stage": "interaction_e2e", "layer2_status": "completed"}'; - const parsed = findRightmostTopLevelObject(output); - expect(parsed?.layer2_status).toBe('completed'); - }); -}); diff --git a/src/core/rubric.ts b/src/core/rubric.ts index 503992a..2c69c23 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -144,9 +144,16 @@ const recoverFromUnbalancedBraces = ( if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const record = parsed as Record; if (accept(record)) { + // The walk goes right-to-left, so the FIRST accepted object per + // class is the RIGHTMOST — keep it (only set when null), matching + // the strict scan's rightmost-wins semantics. Overwriting would + // let a leftmost staged object (e.g. a quoted earlier verdict) + // beat the real verdict. if (prefer(record)) { - bestPreferred = record; - } else { + if (!bestPreferred) { + bestPreferred = record; + } + } else if (!bestAny) { bestAny = record; } } From e1f3d0324a4fe7dfe5de8ca5a720fbbda948a508 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 10:10:05 +0900 Subject: [PATCH 74/87] fix(eval): top-level guard in brace recovery; status surface uses the gate parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop-round review found the recovery accepted nested and array-wrapped objects: - A nested object inside the real verdict carrying passed+stage won over the verdict (false-green); an array-wrapped staged echo won; a stage-less verdict followed by a nested passed:true echo lost. The recovery now applies isTopLevelish: it rejects objects inside arrays or inside another VALID JSON object, but accepts the verdict behind a stray prose brace (whose enclosing slice is not valid JSON). - A stray { balanced by a stray } after the verdict left the stack balanced so the recovery never ran (false-strand). The gate now runs the recovery whenever the strict scan found nothing, not only when the stack is unbalanced. - tenet_get_status used a different accept predicate (findRightmostTopLevelObject) than the gate, so the two consumers could select different objects from the same output. It now uses extractRubricJson — the e2e verdict carries both passed and layer2_status, so the consumers cannot drift apart. - PR body: corrected the claim that the NDJSON collapse matches claude --print shape (collapse joins all text parts; claude returns only the final message). Tests: nested passed+stage, array-wrapped staged echo, stage-less + nested echo, balanced stray pair (rubric.test.ts); D5 unbalanced-brace status path (integration.test.ts). Co-Authored-By: Claude --- src/core/integration.test.ts | 13 +++ src/core/rubric.test.ts | 39 +++++++++ src/core/rubric.ts | 84 ++++++++++++++++++- src/mcp/tools/tenet-get-status.ts | 22 ++--- ...right-layer2-completed-unbalanced-brace.md | 5 ++ 5 files changed, 145 insertions(+), 18 deletions(-) create mode 100644 tests/fixtures/fake-agents/playwright-layer2-completed-unbalanced-brace.md diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index bf85789..66b1e1e 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -991,6 +991,19 @@ describe('integration: latest_e2e_status surfacing', () => { const parsed = parseResult(r); expect(parsed.latest_e2e_status).toBe('completed'); }); + + it('D5: unbalanced { in prose before the verdict still surfaces layer2_status (recovery path)', async () => { + // D4 uses balanced prose braces, so the recovery branch never runs through + // the tool. This fixture has a stray { (truncated code snippet) before the + // verdict — the recovery path must surface layer2_status end-to-end. + const h = createHarness([ + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed-unbalanced-brace.md' }, + ]); + await driveOneE2e(h, []); + const r = await h.getStatus({}); + const parsed = parseResult(r); + expect(parsed.latest_e2e_status).toBe('completed'); + }); }); // ─── E. Parser stress tests ───────────────────────────────────────────────── diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts index 591cef7..7e05d44 100644 --- a/src/core/rubric.test.ts +++ b/src/core/rubric.test.ts @@ -277,6 +277,45 @@ describe('extractRubricJson', () => { expect(r2?.passed).toBe(true); expect(r2?.stage).toBe('code_critic'); }); + + it('recovery: a nested passed+stage object inside the verdict never wins (top-level guard)', () => { + // The recovery must reject objects nested inside a valid JSON object, even + // when they echo the verdict shape (passed + stage). + const t1 = 'stray { {"passed": false, "detail": {"passed": true, "stage": "code_critic"}}'; + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + + const t2 = 'stray { {"passed": false, "stage": "code_critic", "findings": [{"category":"x","detail":"y","passed": true, "stage": "code_critic"}]}'; + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(false); + expect(r2?.stage).toBe('code_critic'); + }); + + it('recovery: an array-wrapped staged echo never wins (bracket-depth guard)', () => { + const t1 = 'stray { {"passed": false, "stage": "code_critic", "findings": ["x"]} [{"passed": true, "stage": "code_critic"}]'; + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + }); + + it('recovery: a stage-less verdict followed by a nested passed:true echo keeps the verdict', () => { + const t1 = [ + 'I checked the signature foo({ and the verdict:', + '{"passed": false}', + 'Tool output: {"checks": {"lint": {"passed": true}}}', + ].join('\n'); + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + }); + + it('recovery: a stray { balanced by a stray } after the verdict does not strand it', () => { + // The stack ends balanced (unbalanced=false) but the verdict is hidden + // behind the stray pair — the recovery must still run and find it. + const t1 = 'The signature is foo({ and the verdict: {"passed": true, "stage": "code_critic", "findings": []} and the closing brace }'; + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(true); + expect(r1?.stage).toBe('code_critic'); + }); }); describe('findRightmostTopLevelObject (tenet_get_status surface)', () => { diff --git a/src/core/rubric.ts b/src/core/rubric.ts index 2c69c23..4ac3e6f 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -79,6 +79,76 @@ const scanTopLevel = ( return { best: bestPreferred ?? bestAny, unbalanced: stack.length > 0 }; }; +/** + * True when the object opened at `open` is "top-level-ish": not inside an + * array, and not nested inside another VALID JSON object. An object nested + * under a stray prose brace (whose enclosing slice is not valid JSON) IS + * top-level-ish — that is the verdict the recovery exists to find. This + * mirrors the strict scan's top-level invariant (scanTopLevel rejects nested + * and array-wrapped objects) so the recovery cannot pick a nested finding or + * tool echo over the real verdict. + */ +const isTopLevelish = (text: string, open: number): boolean => { + let braceDepth = 0; + let bracketDepth = 0; + let enclosingOpen = -1; + let inString = false; + let escaped = false; + for (let i = 0; i < open; i++) { + const ch = text[i]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === '{') { + if (braceDepth === 0) { + enclosingOpen = i; + } + braceDepth++; + } else if (ch === '}') { + braceDepth--; + } else if (ch === '[') { + bracketDepth++; + } else if (ch === ']') { + bracketDepth--; + } + } + if (bracketDepth > 0) { + return false; + } + if (braceDepth === 0) { + return true; + } + if (braceDepth === 1 && enclosingOpen >= 0) { + const enclosingClose = findMatchingClose(text, enclosingOpen); + if (enclosingClose < 0) { + // Enclosing brace never closes — a stray prose brace; the object is the + // verdict behind it. + return true; + } + try { + JSON.parse(text.slice(enclosingOpen, enclosingClose + 1)); + // Enclosing brace forms a valid object — the object is nested inside it. + return false; + } catch { + // Enclosing slice is prose braces (e.g. a stray { balanced by a stray }) + // — the object is the verdict behind them. + return true; + } + } + return false; +}; + /** * Find the index of the `}` that closes the object opened at `open`, tracking * nested braces, brackets, and strings. Returns -1 when no matching close @@ -138,7 +208,7 @@ const recoverFromUnbalancedBraces = ( let i = text.lastIndexOf('{'); while (i >= 0) { const end = findMatchingClose(text, i); - if (end >= 0) { + if (end >= 0 && isTopLevelish(text, i)) { try { const parsed = JSON.parse(text.slice(i, end + 1)) as unknown; if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { @@ -200,9 +270,13 @@ const mergeStrictAndRecovered = ( */ export const findRightmostTopLevelObject = (text: string): Record | null => { const { best, unbalanced } = scanTopLevel(text, () => true, (r) => typeof r.stage === 'string'); - if (!unbalanced) { + if (best && !unbalanced) { return best; } + // Either the stack is unbalanced (a stray brace may hide a better verdict) + // or the strict scan found nothing (e.g. a stray { balanced by a stray } + // strands the verdict). Run the recovery — isTopLevelish keeps it from + // picking nested objects. return mergeStrictAndRecovered( best, recoverFromUnbalancedBraces(text, () => true, (r) => typeof r.stage === 'string'), @@ -223,9 +297,13 @@ export const findRightmostPassedObject = (text: string): Record (r) => typeof r.passed === 'boolean', (r) => typeof r.stage === 'string', ); - if (!unbalanced) { + if (best && !unbalanced) { return best; } + // Either the stack is unbalanced (a stray brace may hide a better verdict) + // or the strict scan found nothing (e.g. a stray { balanced by a stray } + // strands the verdict). Run the recovery — isTopLevelish keeps it from + // picking nested objects. return mergeStrictAndRecovered( best, recoverFromUnbalancedBraces(text, (r) => typeof r.passed === 'boolean', (r) => typeof r.stage === 'string'), diff --git a/src/mcp/tools/tenet-get-status.ts b/src/mcp/tools/tenet-get-status.ts index 8264b8c..d617f1a 100644 --- a/src/mcp/tools/tenet-get-status.ts +++ b/src/mcp/tools/tenet-get-status.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { JobManager } from '../../core/job-manager.js'; -import { findRightmostTopLevelObject } from '../../core/rubric.js'; +import { extractRubricJson } from '../../core/rubric.js'; import { StateStore } from '../../core/state-store.js'; import { checkForUpdate } from '../../core/update-checker.js'; import type { Job, JobStatus, ProjectStatus } from '../../types/index.js'; @@ -20,20 +20,12 @@ const extractRawOutput = (output: unknown): string | undefined => { const extractJsonObject = (raw: string | undefined): Record | undefined => { if (!raw) return undefined; - const stripped = raw.trim(); - try { - const parsed = JSON.parse(stripped) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record; - } - } catch { - // Not a bare JSON object — scan below. - } - // Shared parser with the resume gate (job-manager.ts) so the two consumers - // of the same stored critic output can never drift apart. The old first-{ - // to last-} slice spanned multiple objects/prose and dropped layer2_status - // whenever prose contained braces. - return findRightmostTopLevelObject(stripped) ?? undefined; + // The SAME parser the resume gate uses (extractRubricJson) — the e2e verdict + // carries both `passed` and `layer2_status`, so the two consumers of the same + // stored critic output select the same object and cannot drift apart. The old + // first-{ to last-} slice spanned multiple objects/prose and dropped + // layer2_status whenever prose contained braces. + return extractRubricJson(raw) ?? undefined; }; const findLatestE2eStatus = (stateStore: StateStore): string | undefined => { diff --git a/tests/fixtures/fake-agents/playwright-layer2-completed-unbalanced-brace.md b/tests/fixtures/fake-agents/playwright-layer2-completed-unbalanced-brace.md new file mode 100644 index 0000000..85bff3a --- /dev/null +++ b/tests/fixtures/fake-agents/playwright-layer2-completed-unbalanced-brace.md @@ -0,0 +1,5 @@ +I explored the UI via Playwright MCP. The login flow works end-to-end and the navigation bar links are all reachable. + +Note: the fix touched { src/foo.ts (unclosed brace from a truncated code snippet + +{"passed": true, "stage": "interaction_e2e", "layer2_status": "completed", "scripted_results": "42 passed, 0 failed", "exploratory_findings": ["Login flow verified end-to-end"], "screenshots": ["screenshot-login.png"]} From 0ebdff57a9a59b4b80d620f33b618c7fd55ce92c Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 11:23:52 +0900 Subject: [PATCH 75/87] fix(eval): isTopLevelish handles any stray-brace depth; unstamped rounds can't self-stamp; drop dead parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop-round review found: - isTopLevelish only handled braceDepth 0/1, so a verdict behind TWO+ stray braces (e.g. a truncated `if (x) { if (y) {` snippet) was rejected and a passing echo before it false-greened the gate. Rewritten with a stack: the object is top-level-ish unless its INNERMOST enclosing brace forms a valid JSON object (a nested finding/echo) or it sits inside an array. - An unstamped singleton (ad-hoc re-fire) could carry its own expected_eval_stages: ['code_critic'] stamp and satisfy the gate on one critic's verdict. Unstamped rounds now always require the full DEFAULT_EVAL_STAGES — only stamped rounds' stamps are trusted. - findRightmostTopLevelObject was dead in production (tenet_get_status uses extractRubricJson) but its tests gave false confidence. Removed it; the status-surface tests now exercise extractRubricJson, including the fail-closed non-passed-verdict case. Tests: two-stray-brace + echo, two-stray-brace no echo (rubric.test.ts); C13 self-stamping singleton (integration.test.ts, verified red against the old behavior). Co-Authored-By: Claude --- src/core/integration.test.ts | 64 ++++++++++++++++++++++++++++++++ src/core/job-manager.ts | 13 ++++++- src/core/rubric.test.ts | 39 ++++++++++++++++--- src/core/rubric.ts | 72 +++++++++++++----------------------- 4 files changed, 133 insertions(+), 55 deletions(-) diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index 66b1e1e..9c04507 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -929,6 +929,70 @@ describe('integration: blocking finding auto-resume', () => { await manager.waitForJob(r1p.id, null, 5_000); expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); }); + + it('C13 (round-id): an unstamped singleton cannot self-stamp its way to a complete round', async () => { + // An ad-hoc re-fire (tenet_start_job) could pass expected_eval_stages: + // ['code_critic'] and satisfy the gate on one critic's verdict. Unstamped + // rounds must always require the full DEFAULT_EVAL_STAGES. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // Round 1 code_critic FAILS (served once), then the ad-hoc re-fire PASSES. + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const stamp = (round: string, stage: string) => { + const promptLabel = stage === 'code_critic' ? 'Code Critic' + : stage === 'test_critic' ? 'Test Critic' + : 'Interaction E2E'; + return { + source_job_id: childId, + eval_stage: stage, + eval_round: round, + expected_eval_stages: ['code_critic', 'test_critic', 'interaction_e2e'], + prompt: `${promptLabel} review — stage: ${stage}`, + }; + }; + + // Round 1 (stamped): code_critic FAILS, test_critic + interaction_e2e PASS. + const r1c = manager.startJob('critic_eval', stamp('round-1', 'code_critic')); + const r1t = manager.startJob('eval', stamp('round-1', 'test_critic')); + const r1p = manager.startJob('interaction_e2e', stamp('round-1', 'interaction_e2e')); + await manager.waitForJob(r1c.id, null, 5_000); + await manager.waitForJob(r1t.id, null, 5_000); + await manager.waitForJob(r1p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Ad-hoc unstamped code_critic re-fire carrying a self-serving single-stage + // stamp. It PASSES and is the newest sibling. It must NOT satisfy the gate. + const adHoc = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + expected_eval_stages: ['code_critic'], + prompt: 'Code Critic review — self-stamped re-fire', + }); + await manager.waitForJob(adHoc.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 4369871..97a67d1 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -1041,8 +1041,17 @@ export class JobManager { if (!newestRoundId) return; const currentRound = byRound.get(newestRoundId) ?? []; - // Read this round's own expected_eval_stages stamp (shared by all its critics). - const stamp = currentRound.find((s) => Array.isArray(s.params.expected_eval_stages))?.params.expected_eval_stages; + // Read this round's own expected_eval_stages stamp (shared by all its + // critics). Only trust the stamp for a STAMPED round — an unstamped + // singleton (ad-hoc re-fire) could carry a self-serving single-stage stamp + // and satisfy the gate on one critic's verdict, defeating the whole-round + // invariant. Unstamped rounds always require the full DEFAULT_EVAL_STAGES. + const isStampedRound = currentRound.some( + (s) => typeof s.params.eval_round === 'string' && s.params.eval_round !== '', + ); + const stamp = isStampedRound + ? currentRound.find((s) => Array.isArray(s.params.expected_eval_stages))?.params.expected_eval_stages + : undefined; const filteredStages = Array.isArray(stamp) && stamp.length > 0 ? new Set(stamp.filter((st): st is string => typeof st === 'string')) : new Set(DEFAULT_EVAL_STAGES); diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts index 7e05d44..4591817 100644 --- a/src/core/rubric.test.ts +++ b/src/core/rubric.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { extractRubricJson, findRightmostTopLevelObject } from './rubric.js'; +import { extractRubricJson } from './rubric.js'; describe('extractRubricJson', () => { it('returns a bare verdict object as-is', () => { @@ -316,22 +316,41 @@ describe('extractRubricJson', () => { expect(r1?.passed).toBe(true); expect(r1?.stage).toBe('code_critic'); }); + + it('recovery: a verdict behind TWO+ stray braces still wins over a passing echo', () => { + // isTopLevelish must accept the verdict behind any number of stray prose + // braces (e.g. a truncated `if (x) { if (y) {` snippet), not just one. + const t1 = [ + 'Tool output: {"passed": true, "tool": "pytest", "count": 12}', + 'The code: if (x) { if (y) { and then the verdict:', + '{"passed": false, "stage": "code_critic", "findings": ["x"]}', + ].join('\n'); + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + + // No echo: the verdict behind two stray braces must still be found. + const t2 = 'The code: if (x) { if (y) { and then the verdict: {"passed": true, "stage": "code_critic", "findings": []}'; + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(true); + expect(r2?.stage).toBe('code_critic'); + }); }); -describe('findRightmostTopLevelObject (tenet_get_status surface)', () => { +describe('tenet_get_status surface (extractRubricJson — the production parser)', () => { it('extracts layer2_status from e2e output with prose braces after the verdict', () => { const output = [ 'I explored the UI and ran the scripted checks.', '{"passed": true, "stage": "interaction_e2e", "layer2_status": "completed", "scripted_results": "all green"}', 'Note: the fix touched { src/foo.ts } and { src/bar.ts }.', ].join('\n'); - const parsed = findRightmostTopLevelObject(output); + const parsed = extractRubricJson(output); expect(parsed?.layer2_status).toBe('completed'); }); it('recovers layer2_status after an unbalanced { in prose', () => { const output = 'Note: { src/foo.ts and then {"passed": true, "stage": "interaction_e2e", "layer2_status": "completed"}'; - const parsed = findRightmostTopLevelObject(output); + const parsed = extractRubricJson(output); expect(parsed?.layer2_status).toBe('completed'); }); @@ -342,7 +361,7 @@ describe('findRightmostTopLevelObject (tenet_get_status surface)', () => { '{"passed": true, "stage": "interaction_e2e", "layer2_status": "completed"}', 'Tool: {"layer2_status": "failed", "tool": "syntax-check"}', ].join('\n'); - const r1 = findRightmostTopLevelObject(t1); + const r1 = extractRubricJson(t1); expect(r1?.layer2_status).toBe('completed'); const t2 = [ @@ -351,7 +370,15 @@ describe('findRightmostTopLevelObject (tenet_get_status surface)', () => { '```', 'Then I checked: {"note": "all good"}', ].join('\n'); - const r2 = findRightmostTopLevelObject(t2); + const r2 = extractRubricJson(t2); expect(r2?.layer2_status).toBe('completed'); }); + + it('a verdict without a passed key drops layer2_status (fail-closed, matches the gate)', () => { + // tenet_get_status uses extractRubricJson, which requires `passed` — the + // same requirement as the resume gate. A malformed verdict without passed + // drops layer2_status rather than surfacing a value the gate would reject. + const output = '{"layer2_status": "completed", "stage": "interaction_e2e"}'; + expect(extractRubricJson(output)).toBeNull(); + }); }); diff --git a/src/core/rubric.ts b/src/core/rubric.ts index 4ac3e6f..2b558aa 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -82,16 +82,15 @@ const scanTopLevel = ( /** * True when the object opened at `open` is "top-level-ish": not inside an * array, and not nested inside another VALID JSON object. An object nested - * under a stray prose brace (whose enclosing slice is not valid JSON) IS + * under stray prose braces (whose enclosing slice is not valid JSON) IS * top-level-ish — that is the verdict the recovery exists to find. This * mirrors the strict scan's top-level invariant (scanTopLevel rejects nested * and array-wrapped objects) so the recovery cannot pick a nested finding or * tool echo over the real verdict. */ const isTopLevelish = (text: string, open: number): boolean => { - let braceDepth = 0; + const stack: number[] = []; let bracketDepth = 0; - let enclosingOpen = -1; let inString = false; let escaped = false; for (let i = 0; i < open; i++) { @@ -111,12 +110,9 @@ const isTopLevelish = (text: string, open: number): boolean => { continue; } if (ch === '{') { - if (braceDepth === 0) { - enclosingOpen = i; - } - braceDepth++; + stack.push(i); } else if (ch === '}') { - braceDepth--; + stack.pop(); } else if (ch === '[') { bracketDepth++; } else if (ch === ']') { @@ -126,27 +122,30 @@ const isTopLevelish = (text: string, open: number): boolean => { if (bracketDepth > 0) { return false; } - if (braceDepth === 0) { + if (stack.length === 0) { return true; } - if (braceDepth === 1 && enclosingOpen >= 0) { - const enclosingClose = findMatchingClose(text, enclosingOpen); - if (enclosingClose < 0) { - // Enclosing brace never closes — a stray prose brace; the object is the - // verdict behind it. - return true; - } - try { - JSON.parse(text.slice(enclosingOpen, enclosingClose + 1)); - // Enclosing brace forms a valid object — the object is nested inside it. - return false; - } catch { - // Enclosing slice is prose braces (e.g. a stray { balanced by a stray }) - // — the object is the verdict behind them. - return true; - } + // Nested under one or more {. Accept only if the INNERMOST enclosing brace + // is a stray (its slice is not valid JSON) — i.e. the object is the verdict + // behind prose braces. If the innermost enclosing brace forms a valid + // object, the object is nested inside it (a finding, a tool echo) and is + // never the verdict. + const enclosingOpen = stack[stack.length - 1]; + const enclosingClose = findMatchingClose(text, enclosingOpen); + if (enclosingClose < 0) { + // Enclosing brace never closes — a stray prose brace; the object is the + // verdict behind it. + return true; + } + try { + JSON.parse(text.slice(enclosingOpen, enclosingClose + 1)); + // Enclosing brace forms a valid object — the object is nested inside it. + return false; + } catch { + // Enclosing slice is prose braces (e.g. a stray { balanced by a stray }) + // — the object is the verdict behind them. + return true; } - return false; }; /** @@ -262,27 +261,6 @@ const mergeStrictAndRecovered = ( return recoveredStaged ?? strictStaged ?? strictBest ?? recovered; }; -/** - * Rightmost TOP-LEVEL JSON object in a string, preferring one that carries a - * `stage` key (the e2e verdict shape). Used by tenet_get_status to surface - * layer2_status from e2e critic output — a valid-JSON tool echo after the - * verdict must not override it. - */ -export const findRightmostTopLevelObject = (text: string): Record | null => { - const { best, unbalanced } = scanTopLevel(text, () => true, (r) => typeof r.stage === 'string'); - if (best && !unbalanced) { - return best; - } - // Either the stack is unbalanced (a stray brace may hide a better verdict) - // or the strict scan found nothing (e.g. a stray { balanced by a stray } - // strands the verdict). Run the recovery — isTopLevelish keeps it from - // picking nested objects. - return mergeStrictAndRecovered( - best, - recoverFromUnbalancedBraces(text, () => true, (r) => typeof r.stage === 'string'), - ); -}; - /** * Rightmost TOP-LEVEL object carrying a boolean `passed` key — the rubric shape * every critic preamble mandates. Prefers an object that also carries a From eb2c5aba4e07db2c8d625f7aecb44a88602358fa Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 12:19:39 +0900 Subject: [PATCH 76/87] fix(eval): stray-[ recovery, per-stage cohort guards; document staged-echo limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop-round review found: - isTopLevelish rejected any object with an unclosed [ before it, so a stray [ in prose (truncated list/code) stranded a valid verdict. It now mirrors the brace logic: the object is top-level-ish unless its innermost enclosing bracket forms a valid array. - The per-stage fallback gate was fail-open to ad-hoc re-fires: a green single-critic re-fire created long after the round masked a red original (latestByStage picks it as the newest for its stage), and a self-serving single-stage expected_eval_stages stamp satisfied the gate on one critic. Added two guards: the newest critic per stage must be created within COHORT_WINDOW_MS of the completing critic (a full re-evaluation dispatches synchronously), and a single-stage "round" is a partial re-evaluation. - Documented a known limitation: a staged echo (quoted prior verdict) after a failing verdict wins — the parser cannot distinguish a real verdict from a quoted one by shape; the preamble mandates the verdict at the end. Tests: stray-[ recovery, staged-echo limitation (rubric.test.ts); C14 cohort guard, C15 self-serving stamp (integration.test.ts, both verified red without the guards). Co-Authored-By: Claude --- src/core/integration.test.ts | 101 +++++++++++++++++++++++++++++++++++ src/core/job-manager.ts | 32 ++++++++++- src/core/rubric.test.ts | 27 ++++++++++ src/core/rubric.ts | 33 ++++++++---- 4 files changed, 182 insertions(+), 11 deletions(-) diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index 9c04507..7d8faab 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -993,6 +993,107 @@ describe('integration: blocking finding auto-resume', () => { await manager.waitForJob(adHoc.id, null, 5_000); expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); }); + + it('C14 (per-stage): a green ad-hoc re-fire created long after the round cannot mask a red original', async () => { + // The 5.5s delay to cross COHORT_WINDOW_MS exceeds the default 5s timeout. + // All-legacy DB (no eval_round anywhere) → the per-stage fallback runs. + // A single ad-hoc re-fire created > COHORT_WINDOW_MS after the original + // round is a partial re-evaluation and must not mask the red original. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // Original code_critic FAILS (served once), then the ad-hoc re-fire PASSES. + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + // Original round (no eval_round): code_critic FAILS, test + interaction PASS. + const c = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + prompt: 'Code Critic review', + }); + const t = manager.startJob('eval', { + source_job_id: childId, + eval_stage: 'test_critic', + prompt: 'Test Critic review', + }); + const p = manager.startJob('interaction_e2e', { + source_job_id: childId, + eval_stage: 'interaction_e2e', + prompt: 'Interaction E2E eval', + }); + await manager.waitForJob(c.id, null, 5_000); + await manager.waitForJob(t.id, null, 5_000); + await manager.waitForJob(p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Ad-hoc code_critic re-fire created > COHORT_WINDOW_MS later. It PASSES. + // The per-stage gate must NOT pick it as the newest code_critic and unblock + // on a partial re-evaluation. + await new Promise((resolve) => setTimeout(resolve, 5_500)); + const adHoc = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + prompt: 'Code Critic review — ad-hoc re-fire', + }); + await manager.waitForJob(adHoc.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }, 15_000); + + it('C15 (per-stage): a self-serving single-stage stamp cannot satisfy the fallback gate', async () => { + // All-legacy DB → per-stage fallback. A single code_critic carrying + // expected_eval_stages: ['code_critic'] is a partial re-evaluation — the + // gate must not unblock on one critic's verdict. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const single = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + expected_eval_stages: ['code_critic'], + prompt: 'Code Critic review — self-stamped', + }); + await manager.waitForJob(single.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 97a67d1..ea7db2a 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -69,6 +69,16 @@ type JobManagerConfig = { const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']); +/** + * Window for grouping legacy (unstamped) eval critics into "cohorts" in the + * per-stage fallback gate. A full re-evaluation dispatches all critics + * synchronously (ms apart); a single ad-hoc re-fire via tenet_start_job is a + * separate dispatch created later. The newest critic per stage must be created + * within this window of the completing critic, or the round is a partial + * re-evaluation and the gate stays closed. + */ +const COHORT_WINDOW_MS = 5_000; + const sleep = async (ms: number): Promise => new Promise((resolve) => { setTimeout(resolve, ms); @@ -1004,7 +1014,7 @@ export class JobManager { } // Fallback: per-stage-newest (pre-round-id behavior). - this.checkBlockingFindingResumeByStage(siblings, sourceJobId, completedStage, rawOutput, blockedParentId); + this.checkBlockingFindingResumeByStage(completedJob, siblings, sourceJobId, completedStage, rawOutput, blockedParentId); } private checkBlockingFindingResumeByRound( @@ -1094,6 +1104,7 @@ export class JobManager { } private checkBlockingFindingResumeByStage( + completedJob: Job, siblings: Job[], sourceJobId: string, completedStage: string, @@ -1140,6 +1151,25 @@ export class JobManager { } } + // A single ad-hoc re-fire (tenet_start_job) created long after the other + // stages' critics is a partial re-evaluation, not a full round — it must + // not mask an older red critic for its stage. A full re-evaluation + // dispatches all critics synchronously, so require the newest critic per + // stage to be created within a window of the completing critic. + const completingCreatedAt = completedJob.createdAt; + for (const s of currentRound) { + if (Math.abs(s.createdAt - completingCreatedAt) > COHORT_WINDOW_MS) { + return; + } + } + + // A single-stage "round" (a self-serving expected_eval_stages stamp on an + // ad-hoc re-fire) must not satisfy the gate on one critic's verdict — the + // built-in roster has 3 stages, so a partial re-evaluation is incomplete. + if (presentStages.size < 2) { + return; + } + for (const s of currentRound) { if (s.status !== 'completed') { return; diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts index 4591817..58f00a1 100644 --- a/src/core/rubric.test.ts +++ b/src/core/rubric.test.ts @@ -335,6 +335,33 @@ describe('extractRubricJson', () => { expect(r2?.passed).toBe(true); expect(r2?.stage).toBe('code_critic'); }); + + it('recovery: a stray [ in prose does not strand the verdict (bracket-stray)', () => { + // isTopLevelish must distinguish a stray [ in prose from a genuine array. + const t1 = 'The list was [1, 2, 3 and then the verdict: {"passed": true, "stage": "code_critic", "findings": []}'; + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(true); + expect(r1?.stage).toBe('code_critic'); + + // A balanced array before the verdict is fine too. + const t2 = '[1, 2, 3] and then the verdict: {"passed": true, "stage": "code_critic", "findings": []}'; + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(true); + }); + + it('KNOWN LIMITATION: a staged echo (quoted prior verdict) after a failing verdict wins', () => { + // The parser cannot distinguish a real verdict from a quoted earlier + // verdict by shape — both carry passed + stage. The preamble mandates the + // verdict at the END, so a critic that quotes a same-stage verdict after + // its own violates the preamble and the rightmost staged object wins. + // This is a documented limitation, not a regression to fix silently. + const t1 = [ + 'Final: {"passed": false, "stage": "code_critic", "findings": ["x"]}', + '(quoted from round 1: {"passed": true, "stage": "code_critic"})', + ].join('\n'); + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(true); + }); }); describe('tenet_get_status surface (extractRubricJson — the production parser)', () => { diff --git a/src/core/rubric.ts b/src/core/rubric.ts index 2b558aa..28142c1 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -89,8 +89,8 @@ const scanTopLevel = ( * tool echo over the real verdict. */ const isTopLevelish = (text: string, open: number): boolean => { - const stack: number[] = []; - let bracketDepth = 0; + const braceStack: number[] = []; + const bracketStack: number[] = []; let inString = false; let escaped = false; for (let i = 0; i < open; i++) { @@ -110,19 +110,32 @@ const isTopLevelish = (text: string, open: number): boolean => { continue; } if (ch === '{') { - stack.push(i); + braceStack.push(i); } else if (ch === '}') { - stack.pop(); + braceStack.pop(); } else if (ch === '[') { - bracketDepth++; + bracketStack.push(i); } else if (ch === ']') { - bracketDepth--; + bracketStack.pop(); } } - if (bracketDepth > 0) { - return false; + if (bracketStack.length > 0) { + // Inside an array. Accept only if the INNERMOST enclosing bracket is a + // stray (its slice is not valid JSON) — i.e. the object is the verdict + // behind a stray `[` in prose, not genuinely array-wrapped. + const enclosingOpen = bracketStack[bracketStack.length - 1]; + const enclosingClose = findMatchingClose(text, enclosingOpen); + if (enclosingClose < 0) { + return true; + } + try { + JSON.parse(text.slice(enclosingOpen, enclosingClose + 1)); + return false; + } catch { + return true; + } } - if (stack.length === 0) { + if (braceStack.length === 0) { return true; } // Nested under one or more {. Accept only if the INNERMOST enclosing brace @@ -130,7 +143,7 @@ const isTopLevelish = (text: string, open: number): boolean => { // behind prose braces. If the innermost enclosing brace forms a valid // object, the object is nested inside it (a finding, a tool echo) and is // never the verdict. - const enclosingOpen = stack[stack.length - 1]; + const enclosingOpen = braceStack[braceStack.length - 1]; const enclosingClose = findMatchingClose(text, enclosingOpen); if (enclosingClose < 0) { // Enclosing brace never closes — a stray prose brace; the object is the From d96ea3dac8b646fca0e5a1886ca13e6453fadeeb Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 13:17:25 +0900 Subject: [PATCH 77/87] fix(eval): recovery runs for stage-less best; per-stage roster uses consensus stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop-round review found: - findRightmostPassedObject short-circuited `if (best && !unbalanced)`, so a stage-less passing echo + a balanced stray brace pair hid a staged failing verdict (false-green). The recovery now runs whenever best is not a staged top-level verdict. - The per-stage fallback trusted the NEWEST sibling's expected_eval_stages stamp, so a self-serving partial stamp on an ad-hoc re-fire could exclude a red stage and unblock on a partial re-evaluation. It now uses the stamp shared by the MOST siblings (the roster at dispatch) — a legitimate dispatch stamps every critic identically, so disabled built-ins and custom critics still work, while a single self-serving stamp cannot shrink the roster. - The COHORT_WINDOW_MS blind spot for re-fires created shortly after the round was narrowed (5s -> 1s) and documented as a heuristic. Tests: echo + balanced pair (rubric.test.ts); C16 self-serving 2-stage stamp (verified red against newest-sibling stamp-trusting), C17 unstamped-older-than- newest-round (integration.test.ts). Co-Authored-By: Claude --- src/core/integration.test.ts | 142 +++++++++++++++++++++++++++++++++++ src/core/job-manager.ts | 42 +++++++++-- src/core/rubric.test.ts | 13 ++++ src/core/rubric.ts | 11 +-- 4 files changed, 197 insertions(+), 11 deletions(-) diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index 7d8faab..99a6431 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -1094,6 +1094,148 @@ describe('integration: blocking finding auto-resume', () => { await manager.waitForJob(single.id, null, 5_000); expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); }); + + it('C16 (per-stage): a self-serving 2-stage stamp cannot exclude a red stage from the gate', async () => { + // All-legacy DB → per-stage fallback. The fallback always requires the + // full DEFAULT_EVAL_STAGES roster — a self-serving 2-stage stamp on an + // ad-hoc re-fire must not exclude a red stage and unblock. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // Original code_critic FAILS (served once), then the ad-hoc re-fire PASSES. + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + // test_critic FAILS (a red non-re-fired stage the 2-stage stamp excludes). + { match: matchers.evalStage('test_critic'), fixture: 'critic-failing-with-findings.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + // Original round (no eval_round): code FAILS, test FAILS, interaction + // PASSES. The originals carry the full roster stamp (as a real legacy + // dispatch would). + const fullRoster = ['code_critic', 'test_critic', 'interaction_e2e']; + const c = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + expected_eval_stages: fullRoster, + prompt: 'Code Critic review', + }); + const t = manager.startJob('eval', { + source_job_id: childId, + eval_stage: 'test_critic', + expected_eval_stages: fullRoster, + prompt: 'Test Critic review', + }); + const p = manager.startJob('interaction_e2e', { + source_job_id: childId, + eval_stage: 'interaction_e2e', + expected_eval_stages: fullRoster, + prompt: 'Interaction E2E eval', + }); + await manager.waitForJob(c.id, null, 5_000); + await manager.waitForJob(t.id, null, 5_000); + await manager.waitForJob(p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Ad-hoc code_critic re-fire with a 2-stage stamp excluding the red + // test_critic. It PASSES. The gate must still require test_critic (red). + const adHoc = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + expected_eval_stages: ['code_critic', 'interaction_e2e'], + prompt: 'Code Critic review — 2-stage self-stamp', + }); + await manager.waitForJob(adHoc.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); + + it('C17 (round-id): an unstamped critic older than the newest stamped round is history', async () => { + // The round gate keys on the newest stamped round. An unstamped critic + // created between two stamped rounds is an older singleton — it never + // decides the gate, and a newer full stamped round wins. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // R1 code_critic FAILS (served once), ad-hoc FAILS (served again), R2 PASSES. + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 2 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const stamp = (round: string, stage: string) => { + const promptLabel = stage === 'code_critic' ? 'Code Critic' + : stage === 'test_critic' ? 'Test Critic' + : 'Interaction E2E'; + return { + source_job_id: childId, + eval_stage: stage, + eval_round: round, + expected_eval_stages: ['code_critic', 'test_critic', 'interaction_e2e'], + prompt: `${promptLabel} review — stage: ${stage}`, + }; + }; + + // R1 (stamped): code_critic FAILS, test + interaction PASS → blocked. + const r1c = manager.startJob('critic_eval', stamp('round-1', 'code_critic')); + const r1t = manager.startJob('eval', stamp('round-1', 'test_critic')); + const r1p = manager.startJob('interaction_e2e', stamp('round-1', 'interaction_e2e')); + await manager.waitForJob(r1c.id, null, 5_000); + await manager.waitForJob(r1t.id, null, 5_000); + await manager.waitForJob(r1p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Ad-hoc unstamped code_critic re-fire (FAILS) created after R1. + const adHoc = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + prompt: 'Code Critic review — ad-hoc re-fire', + }); + await manager.waitForJob(adHoc.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // R2 (stamped): all green. The gate keys on R2 (newest stamped round) and + // unblocks — the older unstamped ad-hoc is history. + const r2c = manager.startJob('critic_eval', stamp('round-2', 'code_critic')); + const r2t = manager.startJob('eval', stamp('round-2', 'test_critic')); + const r2p = manager.startJob('interaction_e2e', stamp('round-2', 'interaction_e2e')); + await manager.waitForJob(r2c.id, null, 5_000); + await manager.waitForJob(r2t.id, null, 5_000); + await manager.waitForJob(r2p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('pending'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index ea7db2a..d7f1413 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -75,9 +75,12 @@ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancel * synchronously (ms apart); a single ad-hoc re-fire via tenet_start_job is a * separate dispatch created later. The newest critic per stage must be created * within this window of the completing critic, or the round is a partial - * re-evaluation and the gate stays closed. + * re-evaluation and the gate stays closed. Kept small (1s) to narrow the + * blind spot for re-fires created shortly after the round — a heuristic, since + * the per-stage path has no round ids to distinguish a full re-evaluation from + * a partial re-fire. */ -const COHORT_WINDOW_MS = 5_000; +const COHORT_WINDOW_MS = 1_000; const sleep = async (ms: number): Promise => new Promise((resolve) => { @@ -951,12 +954,39 @@ export class JobManager { */ private resolveExpectedEvalStages(sourceJobId: string): Set { const siblings = this.stateStore.getEvalsForSource(sourceJobId); - const newestFirst = [...siblings].sort((a, b) => b.createdAt - a.createdAt); - for (const s of newestFirst) { + // Use the expected_eval_stages stamp shared by the MOST siblings — the + // roster at dispatch. A legitimate dispatch stamps every critic with the + // same roster (so a disabled built-in or a custom critic shrinks/grows the + // set consistently); a self-serving partial stamp on a single ad-hoc + // re-fire must not override it, or the gate would exclude a red stage and + // unblock on a partial re-evaluation. Falls back to DEFAULT_EVAL_STAGES + // when no stamp is shared. + const counts = new Map(); + for (const s of siblings) { const stamped = s.params.expected_eval_stages; - if (Array.isArray(stamped) && stamped.length > 0) { - return new Set(stamped.filter((stage): stage is string => typeof stage === 'string')); + if (!Array.isArray(stamped) || stamped.length === 0) { + continue; } + const stages = stamped.filter((st): st is string => typeof st === 'string'); + if (stages.length === 0) { + continue; + } + const key = stages.join(','); + const entry = counts.get(key); + if (entry) { + entry.count++; + } else { + counts.set(key, { stages, count: 1 }); + } + } + let best: { stages: string[]; count: number } | undefined; + for (const entry of counts.values()) { + if (!best || entry.count > best.count) { + best = entry; + } + } + if (best) { + return new Set(best.stages); } return new Set(DEFAULT_EVAL_STAGES); } diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts index 58f00a1..87a091b 100644 --- a/src/core/rubric.test.ts +++ b/src/core/rubric.test.ts @@ -349,6 +349,19 @@ describe('extractRubricJson', () => { expect(r2?.passed).toBe(true); }); + it('recovery: a stage-less echo + balanced stray pair cannot mask a failing verdict', () => { + // The strict scan accepts the echo (bestAny) and the stray pair balances + // (unbalanced=false), so the recovery must still run — a stage-less best + // must not short-circuit the recovery's staged verdict. + const t1 = [ + 'Tool output: {"passed": true, "tool": "pytest", "count": 12}', + 'and prose { and the verdict: {"passed": false, "stage": "code_critic", "findings": ["x"]} }', + ].join('\n'); + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + }); + it('KNOWN LIMITATION: a staged echo (quoted prior verdict) after a failing verdict wins', () => { // The parser cannot distinguish a real verdict from a quoted earlier // verdict by shape — both carry passed + stage. The preamble mandates the diff --git a/src/core/rubric.ts b/src/core/rubric.ts index 28142c1..22b1a32 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -288,13 +288,14 @@ export const findRightmostPassedObject = (text: string): Record (r) => typeof r.passed === 'boolean', (r) => typeof r.stage === 'string', ); - if (best && !unbalanced) { + if (best && !unbalanced && typeof best.stage === 'string') { + // A staged top-level verdict with a balanced stack — no recovery needed. return best; } - // Either the stack is unbalanced (a stray brace may hide a better verdict) - // or the strict scan found nothing (e.g. a stray { balanced by a stray } - // strands the verdict). Run the recovery — isTopLevelish keeps it from - // picking nested objects. + // Otherwise run the recovery: the stack may be unbalanced (a stray brace + // hides a better verdict), the strict scan may have found nothing (a stray + // { balanced by a stray } strands the verdict), or best may be a stage-less + // echo that must not short-circuit the recovery's staged verdict. return mergeStrictAndRecovered( best, recoverFromUnbalancedBraces(text, (r) => typeof r.passed === 'boolean', (r) => typeof r.stage === 'string'), From b6e3a571c26c5f4d8ae29eaf61cc69eb16d68647 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 13:58:11 +0900 Subject: [PATCH 78/87] fix(eval): isTopLevelish string-state; drop 2-stage minimum; singleton-round consensus; pin tie-break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop-round review found: - isTopLevelish never checked whether the candidate's { sits inside a string, so a string-quoted JSON object (a quoted tool result or prior verdict) was accepted as a real verdict and could false-green the gate. Now rejects objects whose opening brace is inside an unclosed string. - The per-stage fallback's presentStages.size < 2 guard (added earlier to block self-serving single-stage stamps) stranded legitimate 1-critic rosters in all-legacy DBs — a regression vs main and inconsistent with the round gate, which has no minimum. Removed; a single-critic roster now unblocks (the self-serving single-critic case is indistinguishable by shape and shared by both gates). - The round gate trusted a STAMPED singleton round's own expected_eval_stages, so a forged re-fire (eval_round + self-serving stamp) could bypass the whole-round invariant. A stamped singleton now uses the consensus stamp across all siblings (the roster at dispatch); multi-critic rounds still trust their own stamp. - The >= tie-break in newest-round selection was unpinned by any test. Added C18, which forces a same-ms createdAt tie via DB rewrite and asserts the newer round wins (verified red against >). - Fixed the stale resolveExpectedEvalStages JSDoc (said "newest round's stamp is authoritative"; the implementation uses the consensus). Tests: string-quoted object (rubric.test.ts); C18 tie-break (integration.test.ts). Co-Authored-By: Claude --- src/core/integration.test.ts | 83 ++++++++++++++++++++++++++++++++++-- src/core/job-manager.ts | 27 ++++++------ src/core/rubric.ts | 5 +++ 3 files changed, 99 insertions(+), 16 deletions(-) diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index 99a6431..aa50733 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -1059,10 +1059,13 @@ describe('integration: blocking finding auto-resume', () => { expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); }, 15_000); - it('C15 (per-stage): a self-serving single-stage stamp cannot satisfy the fallback gate', async () => { + it('C15 (per-stage): a single-critic roster unblocks (consistent with the round gate)', async () => { // All-legacy DB → per-stage fallback. A single code_critic carrying - // expected_eval_stages: ['code_critic'] is a partial re-evaluation — the - // gate must not unblock on one critic's verdict. + // expected_eval_stages: ['code_critic'] is a legitimate 1-critic roster + // (or a self-serving stamp — the two are indistinguishable by shape, and + // the round gate has no minimum either). The gate unblocks on the one + // critic's verdict, matching the round gate's behavior for a stamped + // 1-critic round. const { store, manager, reportBlockingFinding } = createHarness([ { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, @@ -1092,7 +1095,7 @@ describe('integration: blocking finding auto-resume', () => { prompt: 'Code Critic review — self-stamped', }); await manager.waitForJob(single.id, null, 5_000); - expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + expect(store.getJob(parent.id)?.status).toBe('pending'); }); it('C16 (per-stage): a self-serving 2-stage stamp cannot exclude a red stage from the gate', async () => { @@ -1236,6 +1239,78 @@ describe('integration: blocking finding auto-resume', () => { await manager.waitForJob(r2p.id, null, 5_000); expect(store.getJob(parent.id)?.status).toBe('pending'); }); + + it('C18 (round-id): on a same-ms createdAt tie, the NEWER round wins (>= tie-break)', async () => { + // The >= tie-break keeps the later-seen round when two rounds share a max + // createdAt — with > the older round would win and unblock on stale green. + // Force a tie by rewriting created_at in the DB. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // R1 code_critic FAILS (served once), R2 code_critic PASSES. + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const stamp = (round: string, stage: string) => { + const promptLabel = stage === 'code_critic' ? 'Code Critic' + : stage === 'test_critic' ? 'Test Critic' + : 'Interaction E2E'; + return { + source_job_id: childId, + eval_stage: stage, + eval_round: round, + expected_eval_stages: ['code_critic', 'test_critic', 'interaction_e2e'], + prompt: `${promptLabel} review — stage: ${stage}`, + }; + }; + + // R1 (stamped): code_critic FAILS, test + interaction PASS → blocked. + const r1c = manager.startJob('critic_eval', stamp('round-1', 'code_critic')); + const r1t = manager.startJob('eval', stamp('round-1', 'test_critic')); + const r1p = manager.startJob('interaction_e2e', stamp('round-1', 'interaction_e2e')); + await manager.waitForJob(r1c.id, null, 5_000); + await manager.waitForJob(r1t.id, null, 5_000); + await manager.waitForJob(r1p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // R2 (stamped): all green. Force a same-ms tie on created_at BEFORE the + // dispatch loop runs R2's critics, so both rounds share a max createdAt. + const r2c = manager.startJob('critic_eval', stamp('round-2', 'code_critic')); + const r2t = manager.startJob('eval', stamp('round-2', 'test_critic')); + const r2p = manager.startJob('interaction_e2e', stamp('round-2', 'interaction_e2e')); + const db = (store as unknown as { db: { prepare: (sql: string) => { run: (...a: unknown[]) => void } } }).db; + const tieAt = store.getJob(r1c.id)?.createdAt ?? Date.now(); + for (const id of [r1c.id, r1t.id, r1p.id, r2c.id, r2t.id, r2p.id]) { + db.prepare('UPDATE jobs SET created_at = ? WHERE id = ?').run(tieAt, id); + } + + await manager.waitForJob(r2c.id, null, 5_000); + await manager.waitForJob(r2t.id, null, 5_000); + await manager.waitForJob(r2p.id, null, 5_000); + + // With >= the later-seen round (R2, green) wins the tie → unblock. With > + // the older round (R1, red) would win → stay blocked. + expect(store.getJob(parent.id)?.status).toBe('pending'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index d7f1413..dd56f33 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -948,9 +948,11 @@ export class JobManager { * existed fall back to the 3 built-ins. * * When a source job was evaluated multiple times (re-fired `tenet_start_eval` - * after retries), the newest round's stamp is authoritative — an older round's - * roster could reflect a disabled critic that a later round re-enabled, or a - * custom critic added mid-run. + * after retries), the stamp shared by the MOST siblings is authoritative — the + * roster at dispatch. A legitimate dispatch stamps every critic identically, + * so a self-serving partial stamp on a single ad-hoc re-fire cannot shrink + * the roster (a disabled built-in or a custom critic shrinks/grows the set + * consistently across all critics of a dispatch). */ private resolveExpectedEvalStages(sourceJobId: string): Set { const siblings = this.stateStore.getEvalsForSource(sourceJobId); @@ -1086,10 +1088,16 @@ export class JobManager { // singleton (ad-hoc re-fire) could carry a self-serving single-stage stamp // and satisfy the gate on one critic's verdict, defeating the whole-round // invariant. Unstamped rounds always require the full DEFAULT_EVAL_STAGES. + // A STAMPED SINGLETON round (a forged re-fire via tenet_start_job carrying + // eval_round + a self-serving stamp) is likewise not trusted: it uses the + // consensus stamp across all siblings (the roster at dispatch), so a + // single-critic re-fire can never shrink the roster. Multi-critic rounds + // trust their own stamp (a legitimate dispatch stamps every critic + // identically). const isStampedRound = currentRound.some( (s) => typeof s.params.eval_round === 'string' && s.params.eval_round !== '', ); - const stamp = isStampedRound + const stamp = isStampedRound && currentRound.length > 1 ? currentRound.find((s) => Array.isArray(s.params.expected_eval_stages))?.params.expected_eval_stages : undefined; const filteredStages = Array.isArray(stamp) && stamp.length > 0 @@ -1098,7 +1106,9 @@ export class JobManager { // A stamp that filters to an empty set (malformed non-string entries) must // NOT produce an empty expectedStages — both the stage-presence loop and // the completion loop would pass trivially and the gate would fail open. - const expectedStages = filteredStages.size > 0 ? filteredStages : new Set(DEFAULT_EVAL_STAGES); + const expectedStages = filteredStages.size > 0 + ? filteredStages + : this.resolveExpectedEvalStages(sourceJobId); const presentStages = new Set( currentRound @@ -1193,13 +1203,6 @@ export class JobManager { } } - // A single-stage "round" (a self-serving expected_eval_stages stamp on an - // ad-hoc re-fire) must not satisfy the gate on one critic's verdict — the - // built-in roster has 3 stages, so a partial re-evaluation is incomplete. - if (presentStages.size < 2) { - return; - } - for (const s of currentRound) { if (s.status !== 'completed') { return; diff --git a/src/core/rubric.ts b/src/core/rubric.ts index 22b1a32..5bb21ae 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -119,6 +119,11 @@ const isTopLevelish = (text: string, open: number): boolean => { bracketStack.pop(); } } + if (inString) { + // The object's `{` sits inside an unclosed string — a quoted tool result + // or prior verdict, not a real verdict. + return false; + } if (bracketStack.length > 0) { // Inside an array. Accept only if the INNERMOST enclosing bracket is a // stray (its slice is not valid JSON) — i.e. the object is the verdict From dd43e4e818e5eb539aa523f25777675444ba9c93 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 14:25:03 +0900 Subject: [PATCH 79/87] fix(eval): stamped-singleton consensus actually wired; merge order; unstamped-originals consensus Loop-round review found my round-7 singleton fix was broken and two more holes: - The stamped-singleton consensus was dead code: `currentRound.length > 1` made stamp undefined for a stamped singleton, so expectedStages fell back to DEFAULT_EVAL_STAGES (3 stages) and a legitimate 1-critic roster stranded forever. Now a stamped singleton uses the consensus stamp across all siblings (the roster at dispatch); unstamped rounds still use DEFAULT. - mergeStrictAndRecovered returned `strictBest ?? recovered` when neither object is staged, so a stage-less echo before a stray brace masked the rightmost stage-less verdict (false-green/strand). The recovery's rightmost result now wins (the "verdict at the END" preamble). - resolveExpectedEvalStages let a single partial-stamp ad-hoc re-fire become the consensus when the originals were unstamped (legacy shape), excluding a red stage. A stamp shared by only ONE sibling while others are unstamped now falls back to DEFAULT_EVAL_STAGES. - Documented the forged multi-critic round limitation (defense-in-depth) and corrected the overstated "cannot drift apart" comment in tenet_get_status (the parser is unified; job selection still differs). Tests: stage-less echo + stray brace (rubric.test.ts); C19 stamped 1-critic round unblocks, C20 unstamped-originals + partial-stamp (integration.test.ts, both verified red against the old behavior). Co-Authored-By: Claude --- src/core/integration.test.ts | 105 ++++++++++++++++++++++++++++++ src/core/job-manager.ts | 40 +++++++----- src/core/rubric.test.ts | 12 ++++ src/core/rubric.ts | 9 +-- src/mcp/tools/tenet-get-status.ts | 11 ++-- 5 files changed, 154 insertions(+), 23 deletions(-) diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index aa50733..58e44a3 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -1311,6 +1311,111 @@ describe('integration: blocking finding auto-resume', () => { // the older round (R1, red) would win → stay blocked. expect(store.getJob(parent.id)?.status).toBe('pending'); }); + + it('C19 (round-id): a stamped 1-critic round (legitimate roster) unblocks', async () => { + // A project with a 1-critic roster dispatches a stamped singleton via + // tenet_start_eval. The round gate must use the consensus roster stamp + // (['code_critic']), not the full DEFAULT_EVAL_STAGES, or the parent + // strands forever. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + // A stamped singleton round: eval_round + a single-stage roster stamp. + const single = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + eval_round: 'round-1', + expected_eval_stages: ['code_critic'], + prompt: 'Code Critic review — 1-critic roster', + }); + await manager.waitForJob(single.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('pending'); + }); + + it('C20 (per-stage): a partial-stamp ad-hoc re-fire cannot shrink the roster when originals are unstamped', async () => { + // All-legacy DB where the originals predate the expected_eval_stages stamp. + // A single ad-hoc re-fire carrying a partial stamp must NOT become the + // consensus roster and exclude a red stage. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // Original code_critic FAILS (served once), then the ad-hoc re-fire PASSES. + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + // test_critic FAILS (a red stage the partial stamp would exclude). + { match: matchers.evalStage('test_critic'), fixture: 'critic-failing-with-findings.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + // Original round (no eval_round, NO expected_eval_stages — legacy shape): + // code FAILS, test FAILS, interaction PASSES. + const c = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + prompt: 'Code Critic review', + }); + const t = manager.startJob('eval', { + source_job_id: childId, + eval_stage: 'test_critic', + prompt: 'Test Critic review', + }); + const p = manager.startJob('interaction_e2e', { + source_job_id: childId, + eval_stage: 'interaction_e2e', + prompt: 'Interaction E2E eval', + }); + await manager.waitForJob(c.id, null, 5_000); + await manager.waitForJob(t.id, null, 5_000); + await manager.waitForJob(p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Ad-hoc code_critic re-fire with a partial stamp. It PASSES. The gate + // must still require test_critic (red) — the partial stamp must not + // become the consensus when the originals are unstamped. + const adHoc = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + expected_eval_stages: ['code_critic', 'interaction_e2e'], + prompt: 'Code Critic review — partial-stamp re-fire', + }); + await manager.waitForJob(adHoc.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index dd56f33..9b1be47 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -964,13 +964,16 @@ export class JobManager { // unblock on a partial re-evaluation. Falls back to DEFAULT_EVAL_STAGES // when no stamp is shared. const counts = new Map(); + let unstampedCount = 0; for (const s of siblings) { const stamped = s.params.expected_eval_stages; if (!Array.isArray(stamped) || stamped.length === 0) { + unstampedCount++; continue; } const stages = stamped.filter((st): st is string => typeof st === 'string'); if (stages.length === 0) { + unstampedCount++; continue; } const key = stages.join(','); @@ -987,7 +990,10 @@ export class JobManager { best = entry; } } - if (best) { + // A stamp shared by only ONE sibling while others are unstamped (a + // self-serving partial stamp on a single ad-hoc re-fire against unstamped + // legacy originals) must not become the roster — fall back to DEFAULT. + if (best && (best.count > 1 || unstampedCount === 0)) { return new Set(best.stages); } return new Set(DEFAULT_EVAL_STAGES); @@ -1084,31 +1090,35 @@ export class JobManager { const currentRound = byRound.get(newestRoundId) ?? []; // Read this round's own expected_eval_stages stamp (shared by all its - // critics). Only trust the stamp for a STAMPED round — an unstamped - // singleton (ad-hoc re-fire) could carry a self-serving single-stage stamp - // and satisfy the gate on one critic's verdict, defeating the whole-round - // invariant. Unstamped rounds always require the full DEFAULT_EVAL_STAGES. - // A STAMPED SINGLETON round (a forged re-fire via tenet_start_job carrying - // eval_round + a self-serving stamp) is likewise not trusted: it uses the - // consensus stamp across all siblings (the roster at dispatch), so a - // single-critic re-fire can never shrink the roster. Multi-critic rounds - // trust their own stamp (a legitimate dispatch stamps every critic - // identically). + // critics). Only trust the stamp for a STAMPED MULTI-critic round — a + // legitimate dispatch stamps every critic identically. An unstamped round + // (ad-hoc re-fire) and a STAMPED SINGLETON round (a legitimate 1-critic + // roster, or a forged re-fire carrying eval_round + a self-serving stamp) + // both use the consensus stamp across all siblings (the roster at + // dispatch), so a single-critic re-fire can never shrink the roster and a + // 1-critic roster is not stranded against the full DEFAULT_EVAL_STAGES. + // KNOWN LIMITATION: a FORGED multi-critic round (2+ ad-hoc re-fires + // sharing a made-up eval_round + a self-serving partial stamp) is trusted + // outright — defense-in-depth, since a determined caller could instead + // create a full passing round and unblock legitimately. const isStampedRound = currentRound.some( (s) => typeof s.params.eval_round === 'string' && s.params.eval_round !== '', ); - const stamp = isStampedRound && currentRound.length > 1 + const isSingleton = currentRound.length === 1; + const stamp = isStampedRound && !isSingleton ? currentRound.find((s) => Array.isArray(s.params.expected_eval_stages))?.params.expected_eval_stages : undefined; const filteredStages = Array.isArray(stamp) && stamp.length > 0 ? new Set(stamp.filter((st): st is string => typeof st === 'string')) - : new Set(DEFAULT_EVAL_STAGES); + : undefined; // A stamp that filters to an empty set (malformed non-string entries) must // NOT produce an empty expectedStages — both the stage-presence loop and // the completion loop would pass trivially and the gate would fail open. - const expectedStages = filteredStages.size > 0 + const expectedStages = filteredStages && filteredStages.size > 0 ? filteredStages - : this.resolveExpectedEvalStages(sourceJobId); + : isStampedRound && isSingleton + ? this.resolveExpectedEvalStages(sourceJobId) + : new Set(DEFAULT_EVAL_STAGES); const presentStages = new Set( currentRound diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts index 87a091b..1de1519 100644 --- a/src/core/rubric.test.ts +++ b/src/core/rubric.test.ts @@ -349,6 +349,18 @@ describe('extractRubricJson', () => { expect(r2?.passed).toBe(true); }); + it('recovery: a stage-less echo before a stray brace cannot mask a stage-less verdict (merge order)', () => { + // When neither object is staged, the recovery's rightmost result must win + // over the strict scan's echo (the "verdict at the END" preamble). + const t1 = 'Tool output: {"passed": true, "tool": "syntax-check"} and then a stray { and then the verdict: {"passed": false}'; + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + + const t2 = 'Tool output: {"passed": false, "tool": "syntax-check"} and then a stray { and then the verdict: {"passed": true}'; + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(true); + }); + it('recovery: a stage-less echo + balanced stray pair cannot mask a failing verdict', () => { // The strict scan accepts the echo (bestAny) and the stray pair balances // (unbalanced=false), so the recovery must still run — a stage-less best diff --git a/src/core/rubric.ts b/src/core/rubric.ts index 5bb21ae..7dde7ef 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -266,9 +266,10 @@ const recoverFromUnbalancedBraces = ( * Merge the strict scan's result with the recovery's when the stack was * unbalanced. A staged verdict from either wins — the recovery sees objects * the strict scan missed behind a stray brace, so a stage-less echo the strict - * scan accepted must not short-circuit the recovery's staged verdict. Otherwise - * prefer the strict scan's top-level result over the recovery's (which may be - * a nested object). + * scan accepted must not short-circuit the recovery's staged verdict. When + * neither is staged, the recovery's result wins: it walks `{` from the end and + * finds the RIGHTMOST object (the "verdict at the END" preamble), while the + * strict scan's stage-less best may be an echo before a stray brace. */ const mergeStrictAndRecovered = ( strictBest: Record | null, @@ -276,7 +277,7 @@ const mergeStrictAndRecovered = ( ): Record | null => { const recoveredStaged = recovered && typeof recovered.stage === 'string' ? recovered : null; const strictStaged = strictBest && typeof strictBest.stage === 'string' ? strictBest : null; - return recoveredStaged ?? strictStaged ?? strictBest ?? recovered; + return recoveredStaged ?? strictStaged ?? recovered ?? strictBest; }; /** diff --git a/src/mcp/tools/tenet-get-status.ts b/src/mcp/tools/tenet-get-status.ts index d617f1a..ff6a208 100644 --- a/src/mcp/tools/tenet-get-status.ts +++ b/src/mcp/tools/tenet-get-status.ts @@ -21,10 +21,13 @@ const extractRawOutput = (output: unknown): string | undefined => { const extractJsonObject = (raw: string | undefined): Record | undefined => { if (!raw) return undefined; // The SAME parser the resume gate uses (extractRubricJson) — the e2e verdict - // carries both `passed` and `layer2_status`, so the two consumers of the same - // stored critic output select the same object and cannot drift apart. The old - // first-{ to last-} slice spanned multiple objects/prose and dropped - // layer2_status whenever prose contained braces. + // carries both `passed` and `layer2_status`, so the two consumers select the + // same object from the same stored output. (They still select different JOBS: + // this surface keys on the most recently completed interaction_e2e, while the + // gate keys on the newest round — a slow old-round e2e completing late can + // surface a stale layer2_status.) The old first-{ to last-} slice spanned + // multiple objects/prose and dropped layer2_status whenever prose contained + // braces. return extractRubricJson(raw) ?? undefined; }; From c42136b85f52bfe97f02755e576c504806055d59 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 15:19:59 +0900 Subject: [PATCH 80/87] fix(eval): truncated-enclosing guard; stamped singleton trusts own roster; per-stage status-guard test Loop-round review found: - isTopLevelish treated a TRUNCATED enclosing JSON object (a context-limit kill cut it mid-JSON) as a stray prose brace, so a nested passing object inside it was accepted as the verdict (false-green). Now distinguishes a truncated JSON object (a quoted key followed by a colon) from a stray prose brace. - The stamped-singleton consensus (added to block forged singletons) stranded a legitimate roster shrink: a 1-critic dispatch after a larger round was outvoted by the older round's consensus and never unblocked. A stamped round now trusts its OWN stamp (the current roster), honoring roster changes; the forged-singleton attack (deliberate forgery via tenet_start_job) is documented as a defense-in-depth limitation. Removed a leftover verifier probe test that asserted the opposite trade-off. - The per-stage fallback's status guard (s.status !== 'completed') was untested with a non-completed critic. Added C22 (a job-level failed critic with stored passing output cannot unblock). - Documented the mid-round re-fire cohort-window limitation (fail-closed, recoverable). Tests: truncated-enclosing (rubric.test.ts); C21 roster shrink, C22 per-stage status guard (integration.test.ts, both verified red against the old behavior). Co-Authored-By: Claude --- src/core/integration.test.ts | 121 +++++++++++++++++++++++++++++++++++ src/core/job-manager.ts | 32 ++++----- src/core/rubric.test.ts | 10 +++ src/core/rubric.ts | 19 +++++- 4 files changed, 164 insertions(+), 18 deletions(-) diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index 58e44a3..02aad76 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -1416,6 +1416,127 @@ describe('integration: blocking finding auto-resume', () => { await manager.waitForJob(adHoc.id, null, 5_000); expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); }); + + it('C21 (round-id): a stamped 1-critic round after a larger round (roster shrink) unblocks', async () => { + // A roster shrink between rounds (a built-in disabled) must be honored: + // the newest stamped singleton's own roster stamp decides, not the older + // round's larger consensus. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // R1 code_critic FAILS (served once), R2 code_critic PASSES. + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const stamp = (round: string, stage: string) => { + const promptLabel = stage === 'code_critic' ? 'Code Critic' + : stage === 'test_critic' ? 'Test Critic' + : 'Interaction E2E'; + return { + source_job_id: childId, + eval_stage: stage, + eval_round: round, + expected_eval_stages: ['code_critic', 'test_critic', 'interaction_e2e'], + prompt: `${promptLabel} review — stage: ${stage}`, + }; + }; + + // R1 (stamped, 3-critic roster): code_critic FAILS → blocked. + const r1c = manager.startJob('critic_eval', stamp('round-1', 'code_critic')); + const r1t = manager.startJob('eval', stamp('round-1', 'test_critic')); + const r1p = manager.startJob('interaction_e2e', stamp('round-1', 'interaction_e2e')); + await manager.waitForJob(r1c.id, null, 5_000); + await manager.waitForJob(r1t.id, null, 5_000); + await manager.waitForJob(r1p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // R2 (stamped, 1-critic roster after a shrink): code_critic PASSES. The + // newest singleton's own roster stamp must decide, not the older 3-stage + // consensus. + const r2c = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + eval_round: 'round-2', + expected_eval_stages: ['code_critic'], + prompt: 'Code Critic review — 1-critic roster', + }); + await manager.waitForJob(r2c.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('pending'); + }); + + it('C22 (per-stage): a job-level failed critic with stored passing output cannot unblock', async () => { + // The per-stage fallback's status guard (s.status !== 'completed') is the + // only defense against a failed critic that carried stored passing output + // (setJobOutput runs before the success check). + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // code_critic FAILS at the JOB level but serves passing output. + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json', success: false }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + // All-legacy round (no eval_round): code_critic FAILS at the job level + // (stored passing output), test + interaction PASS. + const c = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + prompt: 'Code Critic review', + }); + const t = manager.startJob('eval', { + source_job_id: childId, + eval_stage: 'test_critic', + prompt: 'Test Critic review', + }); + const p = manager.startJob('interaction_e2e', { + source_job_id: childId, + eval_stage: 'interaction_e2e', + prompt: 'Interaction E2E eval', + }); + await manager.waitForJob(c.id, null, 5_000); + await manager.waitForJob(t.id, null, 5_000); + await manager.waitForJob(p.id, null, 5_000); + + // The failed code_critic's stored passing output must not count — the + // status guard keeps the parent blocked. + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index 9b1be47..f1083ec 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -1090,22 +1090,20 @@ export class JobManager { const currentRound = byRound.get(newestRoundId) ?? []; // Read this round's own expected_eval_stages stamp (shared by all its - // critics). Only trust the stamp for a STAMPED MULTI-critic round — a - // legitimate dispatch stamps every critic identically. An unstamped round - // (ad-hoc re-fire) and a STAMPED SINGLETON round (a legitimate 1-critic - // roster, or a forged re-fire carrying eval_round + a self-serving stamp) - // both use the consensus stamp across all siblings (the roster at - // dispatch), so a single-critic re-fire can never shrink the roster and a - // 1-critic roster is not stranded against the full DEFAULT_EVAL_STAGES. - // KNOWN LIMITATION: a FORGED multi-critic round (2+ ad-hoc re-fires - // sharing a made-up eval_round + a self-serving partial stamp) is trusted - // outright — defense-in-depth, since a determined caller could instead - // create a full passing round and unblock legitimately. + // critics). A STAMPED round — multi-critic or singleton — trusts its own + // stamp: a legitimate dispatch stamps every critic with the CURRENT + // roster, so a roster shrink between rounds (a built-in disabled) is + // honored and a 1-critic roster is not stranded against an older round's + // larger roster or the full DEFAULT_EVAL_STAGES. An UNSTAMPED round + // (ad-hoc re-fire) always requires the full DEFAULT_EVAL_STAGES. + // KNOWN LIMITATION: a FORGED stamped round (ad-hoc re-fires via + // tenet_start_job carrying a made-up eval_round + a self-serving partial + // stamp) is trusted outright — defense-in-depth, since a determined caller + // could instead create a full passing round and unblock legitimately. const isStampedRound = currentRound.some( (s) => typeof s.params.eval_round === 'string' && s.params.eval_round !== '', ); - const isSingleton = currentRound.length === 1; - const stamp = isStampedRound && !isSingleton + const stamp = isStampedRound ? currentRound.find((s) => Array.isArray(s.params.expected_eval_stages))?.params.expected_eval_stages : undefined; const filteredStages = Array.isArray(stamp) && stamp.length > 0 @@ -1116,9 +1114,7 @@ export class JobManager { // the completion loop would pass trivially and the gate would fail open. const expectedStages = filteredStages && filteredStages.size > 0 ? filteredStages - : isStampedRound && isSingleton - ? this.resolveExpectedEvalStages(sourceJobId) - : new Set(DEFAULT_EVAL_STAGES); + : new Set(DEFAULT_EVAL_STAGES); const presentStages = new Set( currentRound @@ -1206,6 +1202,10 @@ export class JobManager { // not mask an older red critic for its stage. A full re-evaluation // dispatches all critics synchronously, so require the newest critic per // stage to be created within a window of the completing critic. + // KNOWN LIMITATION: a re-fire created >1s after the round dispatch but + // BEFORE the round completes strands the green round (every subsequent + // completion fails the window) — fail-closed, recoverable by a fresh full + // round, but requires manual intervention. const completingCreatedAt = completedJob.createdAt; for (const s of currentRound) { if (Math.abs(s.createdAt - completingCreatedAt) > COHORT_WINDOW_MS) { diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts index 1de1519..4c26ae9 100644 --- a/src/core/rubric.test.ts +++ b/src/core/rubric.test.ts @@ -349,6 +349,16 @@ describe('extractRubricJson', () => { expect(r2?.passed).toBe(true); }); + it('recovery: a nested passing object inside a TRUNCATED enclosing object never wins', () => { + // A context-limit kill can truncate the enclosing object mid-JSON, leaving + // the nested object's } as the last char. isTopLevelish must not treat the + // truncated enclosing brace as a stray prose brace. + const t1 = 'Verdict: {"passed": false, "stage": "code_critic", "findings": ["x"]} then tool: {"checks": {"lint": {"passed": true, "stage": "code_critic"}'; + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + }); + it('recovery: a stage-less echo before a stray brace cannot mask a stage-less verdict (merge order)', () => { // When neither object is staged, the recovery's rightmost result must win // over the strict scan's echo (the "verdict at the END" preamble). diff --git a/src/core/rubric.ts b/src/core/rubric.ts index 7dde7ef..7c4028f 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -151,8 +151,14 @@ const isTopLevelish = (text: string, open: number): boolean => { const enclosingOpen = braceStack[braceStack.length - 1]; const enclosingClose = findMatchingClose(text, enclosingOpen); if (enclosingClose < 0) { - // Enclosing brace never closes — a stray prose brace; the object is the - // verdict behind it. + // Enclosing brace never closes — either a stray prose brace (the object + // is the verdict behind it) or a TRUNCATED JSON object (the object is + // nested inside it, e.g. a context-limit kill cut the enclosing object + // mid-JSON). Distinguish by whether the enclosing slice looks like a + // JSON object (a quoted key followed by a colon). + if (looksLikeJsonObject(text.slice(enclosingOpen))) { + return false; + } return true; } try { @@ -166,6 +172,15 @@ const isTopLevelish = (text: string, open: number): boolean => { } }; +/** + * True when a string starts like a JSON object — `{` followed by a quoted key + * and a colon. Used to distinguish a TRUNCATED JSON object (a valid object cut + * off mid-JSON, whose enclosing brace never closes) from a stray prose brace + * (`foo({ and then ...`), so a nested object inside a truncated enclosing + * object is never mistaken for the verdict. + */ +const looksLikeJsonObject = (s: string): boolean => /^\{\s*"[^"]*"\s*:/.test(s); + /** * Find the index of the `}` that closes the object opened at `open`, tracking * nested braces, brackets, and strings. Returns -1 when no matching close From e194d06abdf2eaec44c136a756942100b39aa990 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 15:56:34 +0900 Subject: [PATCH 81/87] fix(eval): truncated-array guard; document balanced-pair short-circuit and adapter divergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop-round review found: - isTopLevelish's bracket branch returned true for a TRUNCATED array without consulting the brace stack, so a nested passing object inside a valid brace object within the truncated array was accepted as the verdict (false-green). The bracket branch now falls through to the brace check when the bracket is truncated or its slice is prose, so the object is still rejected if nested inside a valid brace object. - Documented the balanced-stray-pair short-circuit limitation (a later verdict inside a balanced pair is ignored when a staged verdict was found — the trade-off that protects against quoted prior verdicts). - Documented the opencode adapter's error-path divergence (collapse on all paths vs claude/codex raw stdout on failure — observability only). - Corrected the tenet_get_status comment to mention the per-stage fallback. Tests: truncated-array, balanced-pair limitation (rubric.test.ts). Co-Authored-By: Claude --- src/adapters/opencode-adapter.ts | 7 +++++++ src/core/rubric.test.ts | 23 +++++++++++++++++++++++ src/core/rubric.ts | 28 +++++++++++++++++----------- src/mcp/tools/tenet-get-status.ts | 9 +++++---- 4 files changed, 52 insertions(+), 15 deletions(-) diff --git a/src/adapters/opencode-adapter.ts b/src/adapters/opencode-adapter.ts index 25c6478..691466d 100644 --- a/src/adapters/opencode-adapter.ts +++ b/src/adapters/opencode-adapter.ts @@ -11,6 +11,13 @@ import type { AgentAdapter, AgentInvocation, AgentResponse } from './base.js'; * bytes. Returns null when no text part parsed (e.g. an error banner or empty * stdout) so callers can fall back to the raw stream. * + * NOTE: the collapse is applied on ALL resolve paths (success, timeout, + * non-zero exit, spawn error), unlike the claude/codex adapters which return + * raw stdout on failure. This is deliberate (a partial NDJSON stream is more + * useful collapsed), but it means a failed opencode job's stored output drops + * the raw event stream (including any error-type event) — observability only, + * since rubric extraction runs only on success. + * * Schema pin: this matches the event shape opencode emits today (each line is * `{"type":"text", ..., "part":{"type":"text","text":"..."}}`). If a future * opencode bump changes the part shape (e.g. a `parts[]` array), this collapse diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts index 4c26ae9..d26adff 100644 --- a/src/core/rubric.test.ts +++ b/src/core/rubric.test.ts @@ -349,6 +349,29 @@ describe('extractRubricJson', () => { expect(r2?.passed).toBe(true); }); + it('recovery: a nested passing object inside a TRUNCATED array never wins', () => { + // A context-limit kill can truncate an array mid-JSON. The object inside + // it is still nested inside a valid brace object and must be rejected. + const t1 = 'Tool output: [{"checks": {"lint": {"passed": true}}}'; + const r1 = extractRubricJson(t1); + expect(r1).toBeNull(); + + // A stray [ in prose still recovers the verdict behind it. + const t2 = 'The list was [1, 2, 3 and then the verdict: {"passed": true, "stage": "code_critic", "findings": []}'; + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(true); + }); + + it('KNOWN LIMITATION: a later verdict inside a balanced stray pair is ignored when a staged verdict was found', () => { + // The short-circuit (staged top-level verdict + balanced stack) skips the + // recovery, so a later verdict written inside a stray balanced brace pair + // is ignored. This protects against a quoted prior verdict overriding the + // real one, at the cost of this corner case. + const t1 = 'Verdict: {"passed": false, "stage": "code_critic", "findings": ["x"]} then prose { and the real final verdict {"passed": true, "stage": "code_critic", "findings": []} }'; + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + }); + it('recovery: a nested passing object inside a TRUNCATED enclosing object never wins', () => { // A context-limit kill can truncate the enclosing object mid-JSON, leaving // the nested object's } as the last char. isTopLevelish must not treat the diff --git a/src/core/rubric.ts b/src/core/rubric.ts index 7c4028f..fd3b0dd 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -125,19 +125,20 @@ const isTopLevelish = (text: string, open: number): boolean => { return false; } if (bracketStack.length > 0) { - // Inside an array. Accept only if the INNERMOST enclosing bracket is a - // stray (its slice is not valid JSON) — i.e. the object is the verdict - // behind a stray `[` in prose, not genuinely array-wrapped. + // Inside an array. Reject if the INNERMOST enclosing bracket forms a + // valid array (genuinely array-wrapped). If the bracket is truncated or + // its slice is prose (a stray `[`), fall through to the brace check — the + // object may still be nested inside a VALID brace object within the + // truncated array, which must be rejected too. const enclosingOpen = bracketStack[bracketStack.length - 1]; const enclosingClose = findMatchingClose(text, enclosingOpen); - if (enclosingClose < 0) { - return true; - } - try { - JSON.parse(text.slice(enclosingOpen, enclosingClose + 1)); - return false; - } catch { - return true; + if (enclosingClose >= 0) { + try { + JSON.parse(text.slice(enclosingOpen, enclosingClose + 1)); + return false; + } catch { + // Prose brackets — fall through to the brace check. + } } } if (braceStack.length === 0) { @@ -311,6 +312,11 @@ export const findRightmostPassedObject = (text: string): Record ); if (best && !unbalanced && typeof best.stage === 'string') { // A staged top-level verdict with a balanced stack — no recovery needed. + // KNOWN LIMITATION: a later verdict written INSIDE a stray balanced brace + // pair (e.g. a code snippet) is ignored when an earlier staged verdict + // exists — the short-circuit protects against a quoted prior verdict + // wrapped in prose braces overriding the real one, at the cost of this + // corner case. return best; } // Otherwise run the recovery: the stack may be unbalanced (a stray brace diff --git a/src/mcp/tools/tenet-get-status.ts b/src/mcp/tools/tenet-get-status.ts index ff6a208..fef8505 100644 --- a/src/mcp/tools/tenet-get-status.ts +++ b/src/mcp/tools/tenet-get-status.ts @@ -24,10 +24,11 @@ const extractJsonObject = (raw: string | undefined): Record | u // carries both `passed` and `layer2_status`, so the two consumers select the // same object from the same stored output. (They still select different JOBS: // this surface keys on the most recently completed interaction_e2e, while the - // gate keys on the newest round — a slow old-round e2e completing late can - // surface a stale layer2_status.) The old first-{ to last-} slice spanned - // multiple objects/prose and dropped layer2_status whenever prose contained - // braces. + // gate keys on the newest round when a stamped round exists, or the newest + // critic per stage in the all-legacy fallback — a slow old-round e2e + // completing late can surface a stale layer2_status.) The old first-{ to + // last-} slice spanned multiple objects/prose and dropped layer2_status + // whenever prose contained braces. return extractRubricJson(raw) ?? undefined; }; From 6321cc1ddc2acc146934c5fc1b23ab309acf44ed Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 16:39:39 +0900 Subject: [PATCH 82/87] test(eval): pin opencode failure-path collapse; per-stage cancelled-critic cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop-round review found two test gaps: - The opencode NDJSON collapse on the timeout / non-zero-exit / spawn-error paths (a documented divergence from claude/codex) was pinned by no test — every adapter test used exit code 0. Added tests for all three failure paths. - The per-stage fallback's status guard was tested with a job-level FAILED critic (C22) and the round gate with a cancelled one (C12), but the per-stage + cancelled combination was the missing cell. Added C23 (a cancelled critic in the fallback keeps the parent blocked — the output check also catches it, since cancelJob stores no output). Co-Authored-By: Claude --- src/adapters/adapter.test.ts | 59 ++++++++++++++++++++++++++++++++++++ src/core/integration.test.ts | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/src/adapters/adapter.test.ts b/src/adapters/adapter.test.ts index d087af2..04dbe31 100644 --- a/src/adapters/adapter.test.ts +++ b/src/adapters/adapter.test.ts @@ -402,4 +402,63 @@ describe('OpenCodeAdapter NDJSON output collapse', () => { expect(parsed?.passed).toBe(true); expect(parsed?.stage).toBe('credit_ledger_integrity'); }); + + it('collapses NDJSON on a non-zero exit (failure path)', async () => { + const ndjson = fs.readFileSync(fixturePath, 'utf8'); + spawnMock.mockImplementation(() => makeFakeChild(1, ndjson)); + + const adapter = new OpenCodeAdapter(); + const response = await adapter.invoke({ prompt: 'review this' }); + + expect(response.success).toBe(false); + // The collapse applies on failure paths too (documented divergence from + // claude/codex, which return raw stdout). + expect(response.output).toContain('"passed": true'); + expect(response.output).not.toContain('"type":"step_start"'); + }); + + it('collapses NDJSON on a spawn error (failure path)', async () => { + const ndjson = fs.readFileSync(fixturePath, 'utf8'); + const child = new EventEmitter() as FakeChild; + child.stdin = { write: () => undefined, end: () => undefined }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = () => undefined; + setImmediate(() => { + child.stdout.emit('data', Buffer.from(ndjson)); + child.emit('error', new Error('spawn ENOENT')); + }); + spawnMock.mockImplementation(() => child); + + const adapter = new OpenCodeAdapter(); + const response = await adapter.invoke({ prompt: 'review this' }); + + expect(response.success).toBe(false); + expect(response.output).toContain('"passed": true'); + }); + + it('collapses NDJSON on a timeout (failure path)', async () => { + const ndjson = fs.readFileSync(fixturePath, 'utf8'); + const child = new EventEmitter() as FakeChild; + child.stdin = { write: () => undefined, end: () => undefined }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + // The adapter's timeout handler kills the child; a real child then emits + // 'close'. Emit it so the promise resolves through the timedOut branch. + child.kill = () => { + setImmediate(() => child.emit('close', 1)); + }; + setImmediate(() => { + child.stdout.emit('data', Buffer.from(ndjson)); + // Never emit 'close' on our own — the adapter's timeout must fire. + }); + spawnMock.mockImplementation(() => child); + + const adapter = new OpenCodeAdapter(50); + const response = await adapter.invoke({ prompt: 'review this' }); + + expect(response.success).toBe(false); + expect(response.error).toContain('timed out'); + expect(response.output).toContain('"passed": true'); + }); }); diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index 02aad76..ac518a2 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -1537,6 +1537,61 @@ describe('integration: blocking finding auto-resume', () => { // status guard keeps the parent blocked. expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); }); + + it('C23 (per-stage): a cancelled critic in the fallback gate keeps the parent blocked', async () => { + // The per-stage fallback's status guard (s.status !== 'completed') is the + // only defense against a cancelled critic too — the missing cell in the + // status-guard matrix (C12 covers the round gate, C22 the failed case). + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + // All-legacy round (no eval_round): cancel test_critic synchronously + // while it is still pending. + const c = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + prompt: 'Code Critic review', + }); + const t = manager.startJob('eval', { + source_job_id: childId, + eval_stage: 'test_critic', + prompt: 'Test Critic review', + }); + const p = manager.startJob('interaction_e2e', { + source_job_id: childId, + eval_stage: 'interaction_e2e', + prompt: 'Interaction E2E eval', + }); + manager.cancelJob(t.id); + expect(store.getJob(t.id)?.status).toBe('cancelled'); + + // code + interaction pass, but the cancelled test_critic keeps the round + // incomplete — the parent must stay blocked. + await manager.waitForJob(c.id, null, 5_000); + await manager.waitForJob(p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── From 6235bc6dcae3a121e38cf0e9a54c72d1520ac57b Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 17:18:28 +0900 Subject: [PATCH 83/87] fix(eval): consensus requires majority; round gate keys on round start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop-round review found two fail-open holes: - resolveExpectedEvalStages adopted a partial expected_eval_stages stamp whenever it was shared by 2+ siblings, even when all the originals were unstamped legacy critics — two ad-hoc re-fires carrying the same partial stamp shrank the roster and excluded a red stage. The stamp is now adopted only when shared by a MAJORITY of the total siblings (or all are stamped). - The round gate keyed on each round's MAX createdAt, so an unstamped ad-hoc critic created BETWEEN a stamped round's critics (after the round started, before its last critic) was assigned to an older singleton round and never forced the gate to wait — the parent unblocked on the round's stale green. The gate now keys on the round's START (min createdAt), so a newer ad-hoc evaluation is never invisible. Tests: C24 (2+ partial-stamp re-fires), C25 (ad-hoc between a round's critics) — both verified red against the old behavior. Co-Authored-By: Claude --- src/core/integration.test.ts | 147 +++++++++++++++++++++++++++++++++++ src/core/job-manager.ts | 21 +++-- 2 files changed, 161 insertions(+), 7 deletions(-) diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index ac518a2..7eff5e8 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -1592,6 +1592,153 @@ describe('integration: blocking finding auto-resume', () => { await manager.waitForJob(p.id, null, 5_000); expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); }); + + it('C24 (per-stage): a partial stamp shared by 2+ ad-hoc re-fires cannot shrink the roster', async () => { + // All-legacy DB with unstamped originals. Two ad-hoc re-fires sharing a + // partial stamp must NOT become the consensus roster (a minority of the + // total siblings) and exclude a red stage. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // Original code_critic FAILS (served once), then the ad-hoc re-fires PASS. + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json' }, + // test_critic FAILS (a red stage the partial stamp would exclude). + { match: matchers.evalStage('test_critic'), fixture: 'critic-failing-with-findings.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + // Original round (no eval_round, NO expected_eval_stages — legacy shape): + // code FAILS, test FAILS, interaction PASSES. + const c = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + prompt: 'Code Critic review', + }); + const t = manager.startJob('eval', { + source_job_id: childId, + eval_stage: 'test_critic', + prompt: 'Test Critic review', + }); + const p = manager.startJob('interaction_e2e', { + source_job_id: childId, + eval_stage: 'interaction_e2e', + prompt: 'Interaction E2E eval', + }); + await manager.waitForJob(c.id, null, 5_000); + await manager.waitForJob(t.id, null, 5_000); + await manager.waitForJob(p.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + + // Two ad-hoc re-fires (code + interaction) sharing a partial stamp that + // excludes the red test_critic. Both PASS. The partial stamp is a minority + // of the 5 siblings — it must NOT become the consensus roster. + const partial = ['code_critic', 'interaction_e2e']; + const adHocC = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + expected_eval_stages: partial, + prompt: 'Code Critic review — partial-stamp re-fire', + }); + const adHocP = manager.startJob('interaction_e2e', { + source_job_id: childId, + eval_stage: 'interaction_e2e', + expected_eval_stages: partial, + prompt: 'Interaction E2E review — partial-stamp re-fire', + }); + await manager.waitForJob(adHocC.id, null, 5_000); + await manager.waitForJob(adHocP.id, null, 5_000); + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); + + it('C25 (round-id): an unstamped red critic created BETWEEN a round\'s critics forces the gate to wait', async () => { + // The round gate keys on the round's START (min createdAt). An unstamped + // ad-hoc created between a stamped round's critics (after the round + // started, before its last critic) is a newer evaluation and must not be + // invisible — otherwise the parent unblocks on the round's stale green. + const { store, manager, reportBlockingFinding } = createHarness([ + { match: matchers.devJob(), fixture: 'dev-with-changes.md' }, + // Round-1 code_critic PASSES (served once), then the ad-hoc re-fire FAILS. + { match: matchers.evalStage('code_critic'), fixture: 'critic-passing-clean.json', maxUses: 1 }, + { match: matchers.evalStage('code_critic'), fixture: 'critic-failing-with-findings.json' }, + { match: matchers.evalStage('test_critic'), fixture: 'test-critic-passing.json' }, + { match: matchers.evalStage('interaction_e2e'), fixture: 'playwright-layer2-completed.json' }, + ]); + + const parent = store.createJob({ + type: 'dev', + status: 'running', + params: { name: 'final-report', prompt: 'verify', report_only: true }, + retryCount: 0, + maxRetries: 3, + }); + + const r = await reportBlockingFinding({ + job_id: parent.id, + finding: 'some bug', + why_it_blocks_report: 'report cannot pass', + recommended_followup: 'fix it', + }); + const childId = parseResult(r).child_job_id as string; + await manager.waitForJob(childId, null, 5_000); + + const stamp = (round: string, stage: string) => { + const promptLabel = stage === 'code_critic' ? 'Code Critic' + : stage === 'test_critic' ? 'Test Critic' + : 'Interaction E2E'; + return { + source_job_id: childId, + eval_stage: stage, + eval_round: round, + expected_eval_stages: ['code_critic', 'test_critic', 'interaction_e2e'], + prompt: `${promptLabel} review — stage: ${stage}`, + }; + }; + + // Round-1 (stamped): all green. Ad-hoc unstamped code_critic re-fire + // (RED) created after the round started. Force distinct created_at so the + // ad-hoc lands BETWEEN the round's critics (round start t, ad-hoc t+1, + // round's last critic t+2). + const r1c = manager.startJob('critic_eval', stamp('round-1', 'code_critic')); + const r1t = manager.startJob('eval', stamp('round-1', 'test_critic')); + const r1p = manager.startJob('interaction_e2e', stamp('round-1', 'interaction_e2e')); + const adHoc = manager.startJob('critic_eval', { + source_job_id: childId, + eval_stage: 'code_critic', + prompt: 'Code Critic review — ad-hoc re-fire', + }); + const db = (store as unknown as { db: { prepare: (sql: string) => { run: (...a: unknown[]) => void } } }).db; + const t0 = store.getJob(r1c.id)?.createdAt ?? Date.now(); + db.prepare('UPDATE jobs SET created_at = ? WHERE id = ?').run(t0, r1c.id); + db.prepare('UPDATE jobs SET created_at = ? WHERE id = ?').run(t0, r1t.id); + db.prepare('UPDATE jobs SET created_at = ? WHERE id = ?').run(t0 + 1, adHoc.id); + db.prepare('UPDATE jobs SET created_at = ? WHERE id = ?').run(t0 + 2, r1p.id); + + await manager.waitForJob(r1c.id, null, 5_000); + await manager.waitForJob(r1t.id, null, 5_000); + await manager.waitForJob(r1p.id, null, 5_000); + await manager.waitForJob(adHoc.id, null, 5_000); + + // The ad-hoc (t+1) is newer than the round's start (t) — the gate must + // wait, not unblock on the round's stale green. + expect(store.getJob(parent.id)?.status).toBe('blocked_on_finding'); + }); }); // ─── D. Layer 2 status surfacing via tenet_get_status ─────────────────────── diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index f1083ec..bda1175 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -990,10 +990,11 @@ export class JobManager { best = entry; } } - // A stamp shared by only ONE sibling while others are unstamped (a - // self-serving partial stamp on a single ad-hoc re-fire against unstamped - // legacy originals) must not become the roster — fall back to DEFAULT. - if (best && (best.count > 1 || unstampedCount === 0)) { + // Adopt the stamp only when it is shared by a MAJORITY of the total + // siblings (or all siblings are stamped). A partial stamp shared by a + // minority of ad-hoc re-fires against unstamped legacy originals must not + // become the roster — fall back to DEFAULT. + if (best && (best.count > siblings.length / 2 || unstampedCount === 0)) { return new Set(best.stages); } return new Set(DEFAULT_EVAL_STAGES); @@ -1078,11 +1079,17 @@ export class JobManager { let newestRoundId = ''; let newestCreatedAt = -1; for (const [roundId, jobs] of byRound) { - const maxCreated = jobs.reduce((m, j) => (j.createdAt > m ? j.createdAt : m), -1); + // Key on the round's START (min createdAt), not its max: a round's + // source state is its dispatch time, and an unstamped ad-hoc critic + // created BETWEEN a round's critics (after the round started but before + // its last critic) is a newer evaluation that must force the gate to + // wait — with max-based selection it would be invisible (the round's + // max beats it). + const minCreated = jobs.reduce((m, j) => (j.createdAt < m ? j.createdAt : m), Infinity); // >= (not >): on a same-ms tie, keep the later-seen round (iteration // order is createdAt ASC), never the stale one. - if (maxCreated >= newestCreatedAt) { - newestCreatedAt = maxCreated; + if (minCreated >= newestCreatedAt) { + newestCreatedAt = minCreated; newestRoundId = roundId; } } From 8a7f10d6e8148af89bef4c7676279f5cf073e0dc Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Thu, 6 Aug 2026 17:43:11 +0900 Subject: [PATCH 84/87] fix(eval): truncated-array direct-object guard; escaped-quote key regex; stale docs Loop-round review found: - isTopLevelish fell through to the brace check when the enclosing bracket was truncated, so an object DIRECTLY inside a truncated top-level array was treated as top-level-ish (false-green). Now rejects objects whose enclosing truncated array starts with { (a stray [ in prose still recovers). - looksLikeJsonObject's regex failed on keys with escaped quotes, so a truncated enclosing object with such a key was misclassified as a stray prose brace (false-green). The regex now handles escaped characters. - Fixed stale docs: the round-gate header still said max-createdAt selection (the code keys on the round's start), and the recovery JSDoc still claimed it only runs on an unbalanced stack. - Added a test for the inString guard (quoted verdict inside an unclosed string), the only untested branch of isTopLevelish. Tests: truncated-array direct object, escaped-quote key, inString guard (rubric.test.ts). Co-Authored-By: Claude --- src/core/job-manager.ts | 3 ++- src/core/rubric.test.ts | 30 ++++++++++++++++++++++++++++++ src/core/rubric.ts | 19 +++++++++++++------ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/core/job-manager.ts b/src/core/job-manager.ts index bda1175..2e86824 100644 --- a/src/core/job-manager.ts +++ b/src/core/job-manager.ts @@ -1061,7 +1061,8 @@ export class JobManager { blockedParentId: string, sourceJobId: string, ): void { - // Group by round id; pick the newest round by max createdAt of its critics. + // Group by round id; pick the newest round by its START (min createdAt of + // its critics) — see the selection loop below. // Unstamped siblings (ad-hoc re-fires via tenet_start_job, legacy pre-stamp // evals) become singleton rounds keyed by job id. A singleton can never // satisfy "every expected stage present", so a NEWER unstamped critic diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts index d26adff..c3b1819 100644 --- a/src/core/rubric.test.ts +++ b/src/core/rubric.test.ts @@ -349,6 +349,36 @@ describe('extractRubricJson', () => { expect(r2?.passed).toBe(true); }); + it('recovery: an object DIRECTLY inside a truncated top-level array never wins', () => { + // A context-limit kill can truncate a top-level array. An object directly + // inside it (the slice starts with {) is still array-wrapped. + const t1 = '{"passed": false, "stage": "code_critic", "findings": ["x"]} Tool output: [{"passed": true, "stage": "code_critic", "findings": []}'; + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + + // A stray [ in prose still recovers the verdict behind it. + const t2 = 'The list was [1, 2, 3 and then the verdict: {"passed": true, "stage": "code_critic", "findings": []}'; + const r2 = extractRubricJson(t2); + expect(r2?.passed).toBe(true); + }); + + it('recovery: a truncated enclosing object with an escaped-quote key never wins', () => { + // looksLikeJsonObject must handle escaped quotes in the first key. + const t1 = '{"passed": false, "stage": "code_critic", "findings": ["x"]} then {"a\\"b": 1, "nested": {"passed": true, "stage": "code_critic"}'; + const r1 = extractRubricJson(t1); + expect(r1?.passed).toBe(false); + expect(r1?.stage).toBe('code_critic'); + }); + + it('recovery: a quoted verdict inside an unclosed string is never accepted (inString guard)', () => { + // The inString guard is the sole defense against a well-formed quoted + // verdict being accepted as the real verdict. + const t1 = 'The tool said "the result was {"passed": true, "stage": "code_critic"}'; + const r1 = extractRubricJson(t1); + expect(r1).toBeNull(); + }); + it('recovery: a nested passing object inside a TRUNCATED array never wins', () => { // A context-limit kill can truncate an array mid-JSON. The object inside // it is still nested inside a valid brace object and must be rejected. diff --git a/src/core/rubric.ts b/src/core/rubric.ts index fd3b0dd..8e60f9d 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -17,8 +17,9 @@ * Tracks both `{}` and `[]` depth so an object wrapped in a top-level array * (`[{"passed": true}]`) is never treated as a verdict — the whole-string fast * path rejects arrays, and the scan must agree. `unbalanced` reports whether - * the stack was left non-empty (a stray `{`/`[` in prose), which is the only - * condition under which the brace-recovery fallback may run. + * the stack was left non-empty (a stray `{`/`[` in prose); the brace-recovery + * fallback runs whenever the caller decides the strict scan's result is not + * authoritative (see findRightmostPassedObject). */ const scanTopLevel = ( text: string, @@ -129,7 +130,9 @@ const isTopLevelish = (text: string, open: number): boolean => { // valid array (genuinely array-wrapped). If the bracket is truncated or // its slice is prose (a stray `[`), fall through to the brace check — the // object may still be nested inside a VALID brace object within the - // truncated array, which must be rejected too. + // truncated array, which must be rejected too. An object DIRECTLY inside + // a truncated array (the slice starts with `{`) is still array-wrapped + // and must be rejected. const enclosingOpen = bracketStack[bracketStack.length - 1]; const enclosingClose = findMatchingClose(text, enclosingOpen); if (enclosingClose >= 0) { @@ -140,6 +143,9 @@ const isTopLevelish = (text: string, open: number): boolean => { // Prose brackets — fall through to the brace check. } } + if (/^\[\s*\{/.test(text.slice(enclosingOpen))) { + return false; + } } if (braceStack.length === 0) { return true; @@ -180,7 +186,7 @@ const isTopLevelish = (text: string, open: number): boolean => { * (`foo({ and then ...`), so a nested object inside a truncated enclosing * object is never mistaken for the verdict. */ -const looksLikeJsonObject = (s: string): boolean => /^\{\s*"[^"]*"\s*:/.test(s); +const looksLikeJsonObject = (s: string): boolean => /^\{\s*"(?:[^"\\]|\\.)*"\s*:/.test(s); /** * Find the index of the `}` that closes the object opened at `open`, tracking @@ -228,8 +234,9 @@ const findMatchingClose = (text: string, open: number): number => { * each to its MATCHING `}` (not the first `}` — a verdict with nested objects * in `findings` would otherwise be sliced unterminated), and apply the same * accept/prefer semantics as the strict scan so a passing tool echo after a - * failing verdict can never win. Only reached when the strict scan found - * nothing AND the stack was left unbalanced. + * failing verdict can never win. Reached whenever the caller runs it — the + * strict scan found nothing, the stack is unbalanced, or best is a stage-less + * echo that must not short-circuit a staged verdict. */ const recoverFromUnbalancedBraces = ( text: string, From d176826e4e5d66e0dca3893f417d218a7320dc15 Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Fri, 7 Aug 2026 10:20:08 +0900 Subject: [PATCH 85/87] test(eval): production-data golden test for the rubric parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract 12 real critic outputs from the production DB with their ground-truth verdicts (established with a simple rightmost-object walk). This is the regression baseline for the planned parser simplification: it ensures any refactor behaves the same on REAL data, not just synthetic fixtures. 11/12 pass with the current parser. 5248d039 is intentionally RED — the current parser returns null because prose before the verdict contains an unmatched double quote that confuses the string-state walk; the simpler rightmost-walk handles it. This documents the bug the simplification fixes. Co-Authored-By: Claude --- src/core/rubric.production.test.ts | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/core/rubric.production.test.ts diff --git a/src/core/rubric.production.test.ts b/src/core/rubric.production.test.ts new file mode 100644 index 0000000..b5e189a --- /dev/null +++ b/src/core/rubric.production.test.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { extractRubricJson } from './rubric.js'; + +// ─── Production-data golden test ──────────────────────────────────────────── +// Real critic outputs extracted from the production DB (tenet.db), with the +// ground-truth verdict for each. This is the regression baseline for the +// parser: it ensures any refactor behaves the same on REAL data, not just on +// synthetic fixtures. The expected verdicts were established with a simple +// rightmost-object walk (walk { from the end, parse to its matching }, keep the +// first object with a boolean `passed` key, preferring a `stage` key). +// +// KNOWN BUG (5248d039): the current parser returns null here because prose +// before the verdict contains an unmatched double quote that confuses the +// string-state walk. The simpler parser handles it — this is the case the +// planned simplification fixes. + +type GoldenCase = { + id: string; + type: string; + output: string; + expected: { passed: boolean; stage: string } | null; +}; + +const fixtures: GoldenCase[] = JSON.parse( + fs.readFileSync( + path.resolve(__dirname, '..', '..', 'tests', 'fixtures', 'fake-agents', 'production-critic-outputs.json'), + 'utf8', + ), +); + +describe('extractRubricJson on production critic outputs', () => { + it.each(fixtures.map((f) => [f.id, f] as const))('%s — matches the ground-truth verdict', (_id, f) => { + const got = extractRubricJson(f.output); + const expected = f.expected; + if (expected === null) { + expect(got).toBeNull(); + return; + } + expect(got).not.toBeNull(); + expect(got?.passed).toBe(expected.passed); + expect(got?.stage).toBe(expected.stage); + }); +}); From 1ac154652f490bcbb1640707201655f475f6ba5b Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Fri, 7 Aug 2026 10:46:59 +0900 Subject: [PATCH 86/87] refactor(eval): simplify rubric parser to a single rightmost-walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the meta-review: the scan+merge+recovery three-way design (373 lines) was disproportionate to the actual problem. The high-value core is a single rightmost-object walk with matching-close + stage-preference + a top-level check that rejects objects nested inside valid JSON containers. Removed isTopLevelish's truncated-container/escaped-quote/inString machinery, looksLikeJsonObject, mergeStrictAndRecovered, and the separate recovery pass. This fixes a real production regression the old string-state walk caused: an unmatched quote in prose (e.g. 'uv tool install "mkdocs-material') false- rejected a valid verdict. The simpler parser handles it — verified against all 1659 production critic outputs (1465 parsed vs 1464, zero disagreements) and the new golden test (12/12). Accepted trade-offs (documented + tested): objects inside truncated containers and JSON quoted in strings are now accepted — neither shape observed in production, and the old guards caused the unmatched-quote regression. The balanced-pair false-negative the two-pass short-circuit produced is gone. Tests: rubric units 38 -> 35 (5 contrived pruned/consolidated), golden 12/12. Co-Authored-By: Claude --- src/core/rubric.test.ts | 75 +++------- src/core/rubric.ts | 314 +++++++++------------------------------- 2 files changed, 87 insertions(+), 302 deletions(-) diff --git a/src/core/rubric.test.ts b/src/core/rubric.test.ts index c3b1819..34deef6 100644 --- a/src/core/rubric.test.ts +++ b/src/core/rubric.test.ts @@ -349,67 +349,32 @@ describe('extractRubricJson', () => { expect(r2?.passed).toBe(true); }); - it('recovery: an object DIRECTLY inside a truncated top-level array never wins', () => { - // A context-limit kill can truncate a top-level array. An object directly - // inside it (the slice starts with {) is still array-wrapped. - const t1 = '{"passed": false, "stage": "code_critic", "findings": ["x"]} Tool output: [{"passed": true, "stage": "code_critic", "findings": []}'; - const r1 = extractRubricJson(t1); - expect(r1?.passed).toBe(false); - expect(r1?.stage).toBe('code_critic'); - - // A stray [ in prose still recovers the verdict behind it. - const t2 = 'The list was [1, 2, 3 and then the verdict: {"passed": true, "stage": "code_critic", "findings": []}'; - const r2 = extractRubricJson(t2); - expect(r2?.passed).toBe(true); - }); - - it('recovery: a truncated enclosing object with an escaped-quote key never wins', () => { - // looksLikeJsonObject must handle escaped quotes in the first key. - const t1 = '{"passed": false, "stage": "code_critic", "findings": ["x"]} then {"a\\"b": 1, "nested": {"passed": true, "stage": "code_critic"}'; + it('a later verdict inside a balanced stray pair IS found (rightmost-wins)', () => { + // The single rightmost-walk has no short-circuit, so a real later verdict + // written inside a stray balanced brace pair is found — the balanced-pair + // false-negative the old two-pass design accepted is gone. + const t1 = 'Verdict: {"passed": false, "stage": "code_critic", "findings": ["x"]} then prose { and the real final verdict {"passed": true, "stage": "code_critic", "findings": []} }'; const r1 = extractRubricJson(t1); - expect(r1?.passed).toBe(false); + expect(r1?.passed).toBe(true); expect(r1?.stage).toBe('code_critic'); }); - it('recovery: a quoted verdict inside an unclosed string is never accepted (inString guard)', () => { - // The inString guard is the sole defense against a well-formed quoted - // verdict being accepted as the real verdict. - const t1 = 'The tool said "the result was {"passed": true, "stage": "code_critic"}'; - const r1 = extractRubricJson(t1); - expect(r1).toBeNull(); - }); - - it('recovery: a nested passing object inside a TRUNCATED array never wins', () => { - // A context-limit kill can truncate an array mid-JSON. The object inside - // it is still nested inside a valid brace object and must be rejected. - const t1 = 'Tool output: [{"checks": {"lint": {"passed": true}}}'; - const r1 = extractRubricJson(t1); - expect(r1).toBeNull(); - - // A stray [ in prose still recovers the verdict behind it. - const t2 = 'The list was [1, 2, 3 and then the verdict: {"passed": true, "stage": "code_critic", "findings": []}'; - const r2 = extractRubricJson(t2); - expect(r2?.passed).toBe(true); - }); + it('KNOWN LIMITATION: truncated containers and quoted verdicts are accepted', () => { + // The simplified parser deliberately does NOT reject objects nested inside + // a truncated JSON container (a context-limit kill cut it mid-JSON) or a + // JSON object quoted inside a string. Neither shape has been observed in + // production output (the golden test covers the observed shapes), and the + // old inString/truncated guards caused a real regression (an unmatched + // quote in prose false-rejected a valid verdict). Documented as accepted + // trade-offs rather than defended. + const t1 = '{"passed": false, "stage": "code_critic", "findings": ["x"]} Tool output: [{"passed": true, "stage": "code_critic", "findings": []}'; + expect(extractRubricJson(t1)?.passed).toBe(true); - it('KNOWN LIMITATION: a later verdict inside a balanced stray pair is ignored when a staged verdict was found', () => { - // The short-circuit (staged top-level verdict + balanced stack) skips the - // recovery, so a later verdict written inside a stray balanced brace pair - // is ignored. This protects against a quoted prior verdict overriding the - // real one, at the cost of this corner case. - const t1 = 'Verdict: {"passed": false, "stage": "code_critic", "findings": ["x"]} then prose { and the real final verdict {"passed": true, "stage": "code_critic", "findings": []} }'; - const r1 = extractRubricJson(t1); - expect(r1?.passed).toBe(false); - }); + const t2 = '{"passed": false, "stage": "code_critic", "findings": ["x"]} then tool: {"checks": {"lint": {"passed": true, "stage": "code_critic"}'; + expect(extractRubricJson(t2)?.passed).toBe(true); - it('recovery: a nested passing object inside a TRUNCATED enclosing object never wins', () => { - // A context-limit kill can truncate the enclosing object mid-JSON, leaving - // the nested object's } as the last char. isTopLevelish must not treat the - // truncated enclosing brace as a stray prose brace. - const t1 = 'Verdict: {"passed": false, "stage": "code_critic", "findings": ["x"]} then tool: {"checks": {"lint": {"passed": true, "stage": "code_critic"}'; - const r1 = extractRubricJson(t1); - expect(r1?.passed).toBe(false); - expect(r1?.stage).toBe('code_critic'); + const t3 = 'The tool said "the result was {"passed": true, "stage": "code_critic"}'; + expect(extractRubricJson(t3)?.passed).toBe(true); }); it('recovery: a stage-less echo before a stray brace cannot mask a stage-less verdict (merge order)', () => { diff --git a/src/core/rubric.ts b/src/core/rubric.ts index 8e60f9d..3555cc4 100644 --- a/src/core/rubric.ts +++ b/src/core/rubric.ts @@ -6,33 +6,25 @@ * single parser for that shape, shared by the resume gate (job-manager.ts) and * the status surface (tenet-get-status.ts) so the two consumers of the same * stored critic output can never drift apart. + * + * The verdict is the LAST top-level object with a boolean `passed` key. The + * parser walks `{` positions from the end (the preamble mandates the verdict + * at the END), parses each to its matching `}`, and keeps the first accepted + * object per class — preferring one with a `stage` key over a stage-less tool + * echo. An object nested inside a VALID JSON object or array is never the + * verdict. */ /** - * Scan a string for top-level JSON objects, returning the rightmost one that - * `accept` approves. When `prefer` approves an object, it wins over any - * non-preferred object even if the non-preferred one appears later — used to - * prefer verdicts that carry a `stage` key over stage-less tool-result echoes. - * - * Tracks both `{}` and `[]` depth so an object wrapped in a top-level array - * (`[{"passed": true}]`) is never treated as a verdict — the whole-string fast - * path rejects arrays, and the scan must agree. `unbalanced` reports whether - * the stack was left non-empty (a stray `{`/`[` in prose); the brace-recovery - * fallback runs whenever the caller decides the strict scan's result is not - * authoritative (see findRightmostPassedObject). + * Find the index of the `}` that closes the object opened at `open`, tracking + * nested braces, brackets, and strings. Returns -1 when no matching close + * exists (a stray `{` in prose). */ -const scanTopLevel = ( - text: string, - accept: (record: Record) => boolean, - prefer: (record: Record) => boolean, -): { best: Record | null; unbalanced: boolean } => { - const stack: number[] = []; +const findMatchingClose = (text: string, open: number): number => { + let depth = 0; let inString = false; let escaped = false; - let bestPreferred: Record | null = null; - let bestAny: Record | null = null; - - for (let i = 0; i < text.length; i++) { + for (let i = open; i < text.length; i++) { const ch = text[i]; if (inString) { if (escaped) { @@ -49,67 +41,36 @@ const scanTopLevel = ( continue; } if (ch === '{' || ch === '[') { - stack.push(i); - continue; - } - if ((ch === '}' || ch === ']') && stack.length > 0) { - const start = stack.pop() as number; - // Only objects at brace-depth 0 AND bracket-depth 0 count. Nested objects - // (assertion arrays, tool results quoted in prose) are never verdicts — - // and neither is an object wrapped in a top-level array. - if (stack.length !== 0 || ch === ']') { - continue; - } - try { - const parsed = JSON.parse(text.slice(start, i + 1)) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - const record = parsed as Record; - if (accept(record)) { - if (prefer(record)) { - bestPreferred = record; - } else { - bestAny = record; - } - } - } - } catch { - // Not valid JSON — prose braces, skip. + depth++; + } else if (ch === '}' || ch === ']') { + depth--; + if (depth === 0) { + return i; } } } - return { best: bestPreferred ?? bestAny, unbalanced: stack.length > 0 }; + return -1; }; /** - * True when the object opened at `open` is "top-level-ish": not inside an - * array, and not nested inside another VALID JSON object. An object nested - * under stray prose braces (whose enclosing slice is not valid JSON) IS - * top-level-ish — that is the verdict the recovery exists to find. This - * mirrors the strict scan's top-level invariant (scanTopLevel rejects nested - * and array-wrapped objects) so the recovery cannot pick a nested finding or - * tool echo over the real verdict. + * True when the object opened at `open` is top-level: not nested inside a + * VALID JSON object or array. Prose braces/brackets around it (a stray `{` + * before the verdict, a truncated container) are fine — that is the recovery + * case the walk exists to handle. Deliberately does NOT track strings while + * scanning the prefix: an unmatched quote in prose (e.g. a substring check + * like `'uv tool install "mkdocs-material'`) must not false-reject a real + * verdict that follows. + * + * KNOWN LIMITATION: an object nested inside a truncated JSON container (a + * context-limit kill cut it mid-JSON) is accepted as top-level — and a JSON + * object quoted inside a string is too. Neither shape has been observed in + * production output (the golden test covers the observed shapes). */ const isTopLevelish = (text: string, open: number): boolean => { const braceStack: number[] = []; const bracketStack: number[] = []; - let inString = false; - let escaped = false; for (let i = 0; i < open; i++) { const ch = text[i]; - if (inString) { - if (escaped) { - escaped = false; - } else if (ch === '\\') { - escaped = true; - } else if (ch === '"') { - inString = false; - } - continue; - } - if (ch === '"') { - inString = true; - continue; - } if (ch === '{') { braceStack.push(i); } else if (ch === '}') { @@ -120,130 +81,48 @@ const isTopLevelish = (text: string, open: number): boolean => { bracketStack.pop(); } } - if (inString) { - // The object's `{` sits inside an unclosed string — a quoted tool result - // or prior verdict, not a real verdict. - return false; - } if (bracketStack.length > 0) { // Inside an array. Reject if the INNERMOST enclosing bracket forms a - // valid array (genuinely array-wrapped). If the bracket is truncated or - // its slice is prose (a stray `[`), fall through to the brace check — the - // object may still be nested inside a VALID brace object within the - // truncated array, which must be rejected too. An object DIRECTLY inside - // a truncated array (the slice starts with `{`) is still array-wrapped - // and must be rejected. - const enclosingOpen = bracketStack[bracketStack.length - 1]; - const enclosingClose = findMatchingClose(text, enclosingOpen); - if (enclosingClose >= 0) { + // valid array (genuinely array-wrapped). + const enclosing = bracketStack[bracketStack.length - 1]; + const close = findMatchingClose(text, enclosing); + if (close >= 0) { try { - JSON.parse(text.slice(enclosingOpen, enclosingClose + 1)); + JSON.parse(text.slice(enclosing, close + 1)); return false; } catch { - // Prose brackets — fall through to the brace check. + // Stray/truncated bracket — accept. } } - if (/^\[\s*\{/.test(text.slice(enclosingOpen))) { - return false; - } - } - if (braceStack.length === 0) { - return true; - } - // Nested under one or more {. Accept only if the INNERMOST enclosing brace - // is a stray (its slice is not valid JSON) — i.e. the object is the verdict - // behind prose braces. If the innermost enclosing brace forms a valid - // object, the object is nested inside it (a finding, a tool echo) and is - // never the verdict. - const enclosingOpen = braceStack[braceStack.length - 1]; - const enclosingClose = findMatchingClose(text, enclosingOpen); - if (enclosingClose < 0) { - // Enclosing brace never closes — either a stray prose brace (the object - // is the verdict behind it) or a TRUNCATED JSON object (the object is - // nested inside it, e.g. a context-limit kill cut the enclosing object - // mid-JSON). Distinguish by whether the enclosing slice looks like a - // JSON object (a quoted key followed by a colon). - if (looksLikeJsonObject(text.slice(enclosingOpen))) { - return false; - } - return true; - } - try { - JSON.parse(text.slice(enclosingOpen, enclosingClose + 1)); - // Enclosing brace forms a valid object — the object is nested inside it. - return false; - } catch { - // Enclosing slice is prose braces (e.g. a stray { balanced by a stray }) - // — the object is the verdict behind them. - return true; } -}; - -/** - * True when a string starts like a JSON object — `{` followed by a quoted key - * and a colon. Used to distinguish a TRUNCATED JSON object (a valid object cut - * off mid-JSON, whose enclosing brace never closes) from a stray prose brace - * (`foo({ and then ...`), so a nested object inside a truncated enclosing - * object is never mistaken for the verdict. - */ -const looksLikeJsonObject = (s: string): boolean => /^\{\s*"(?:[^"\\]|\\.)*"\s*:/.test(s); - -/** - * Find the index of the `}` that closes the object opened at `open`, tracking - * nested braces, brackets, and strings. Returns -1 when no matching close - * exists (a stray `{` in prose). - */ -const findMatchingClose = (text: string, open: number): number => { - let depth = 0; - let inString = false; - let escaped = false; - for (let i = open; i < text.length; i++) { - const ch = text[i]; - if (inString) { - if (escaped) { - escaped = false; - } else if (ch === '\\') { - escaped = true; - } else if (ch === '"') { - inString = false; - } - continue; - } - if (ch === '"') { - inString = true; - continue; - } - if (ch === '{' || ch === '[') { - depth++; - } else if (ch === '}' || ch === ']') { - depth--; - if (depth === 0) { - return i; + if (braceStack.length > 0) { + // Nested under one or more {. Reject if the INNERMOST enclosing brace + // forms a valid object (a finding, a tool echo); accept if it is a stray + // prose brace (the verdict behind it). + const enclosing = braceStack[braceStack.length - 1]; + const close = findMatchingClose(text, enclosing); + if (close >= 0) { + try { + JSON.parse(text.slice(enclosing, close + 1)); + return false; + } catch { + // Stray/truncated brace — accept. } } } - return -1; + return true; }; /** - * Best-effort recovery for unbalanced braces in prose. The strict top-level - * scan treats a stray `{` (a code snippet, a truncated block) as an open - * object, so a verdict that follows it is never top-level and the scan returns - * null. The critic preamble mandates the verdict at the END of the output, so - * the verdict is the last JSON object: walk `{` positions from the end, parse - * each to its MATCHING `}` (not the first `}` — a verdict with nested objects - * in `findings` would otherwise be sliced unterminated), and apply the same - * accept/prefer semantics as the strict scan so a passing tool echo after a - * failing verdict can never win. Reached whenever the caller runs it — the - * strict scan found nothing, the stack is unbalanced, or best is a stage-less - * echo that must not short-circuit a staged verdict. + * Rightmost TOP-LEVEL object carrying a boolean `passed` key — the rubric shape + * every critic preamble mandates. Prefers an object that also carries a + * `stage` key (the built-in critics' verdict shape): a tool-result echo like + * `{"passed": true, "tool": "syntax-check"}` rarely carries `stage`, so a + * staged verdict wins over a later stage-less echo — a failing critic that + * pastes a passing tool result after its verdict must not false-green the gate. */ -const recoverFromUnbalancedBraces = ( - text: string, - accept: (record: Record) => boolean, - prefer: (record: Record) => boolean, -): Record | null => { - let bestPreferred: Record | null = null; +export const findRightmostPassedObject = (text: string): Record | null => { + let bestStaged: Record | null = null; let bestAny: Record | null = null; let i = text.lastIndexOf('{'); while (i >= 0) { @@ -253,15 +132,12 @@ const recoverFromUnbalancedBraces = ( const parsed = JSON.parse(text.slice(i, end + 1)) as unknown; if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const record = parsed as Record; - if (accept(record)) { + if (typeof record.passed === 'boolean') { // The walk goes right-to-left, so the FIRST accepted object per - // class is the RIGHTMOST — keep it (only set when null), matching - // the strict scan's rightmost-wins semantics. Overwriting would - // let a leftmost staged object (e.g. a quoted earlier verdict) - // beat the real verdict. - if (prefer(record)) { - if (!bestPreferred) { - bestPreferred = record; + // class is the RIGHTMOST — keep it (only set when null). + if (typeof record.stage === 'string') { + if (!bestStaged) { + bestStaged = record; } } else if (!bestAny) { bestAny = record; @@ -272,68 +148,14 @@ const recoverFromUnbalancedBraces = ( // Not valid JSON — prose braces, skip. } } - // A `{` with no matching close is a stray that cannot be the verdict — - // skip it and keep walking, or a trailing stray `{` (e.g. a truncated - // tail) would strand an earlier valid verdict. NOTE: lastIndexOf('{', -1) - // clamps to 0 and would re-find a `{` at position 0 forever, so break - // explicitly at i === 0. + // NOTE: lastIndexOf('{', -1) clamps to 0 and would re-find a `{` at + // position 0 forever, so break explicitly at i === 0. if (i === 0) { break; } i = text.lastIndexOf('{', i - 1); } - return bestPreferred ?? bestAny; -}; - -/** - * Merge the strict scan's result with the recovery's when the stack was - * unbalanced. A staged verdict from either wins — the recovery sees objects - * the strict scan missed behind a stray brace, so a stage-less echo the strict - * scan accepted must not short-circuit the recovery's staged verdict. When - * neither is staged, the recovery's result wins: it walks `{` from the end and - * finds the RIGHTMOST object (the "verdict at the END" preamble), while the - * strict scan's stage-less best may be an echo before a stray brace. - */ -const mergeStrictAndRecovered = ( - strictBest: Record | null, - recovered: Record | null, -): Record | null => { - const recoveredStaged = recovered && typeof recovered.stage === 'string' ? recovered : null; - const strictStaged = strictBest && typeof strictBest.stage === 'string' ? strictBest : null; - return recoveredStaged ?? strictStaged ?? recovered ?? strictBest; -}; - -/** - * Rightmost TOP-LEVEL object carrying a boolean `passed` key — the rubric shape - * every critic preamble mandates. Prefers an object that also carries a - * `stage` key (the built-in critics' verdict shape): a tool-result echo like - * `{"passed": true, "tool": "syntax-check"}` rarely carries `stage`, so a - * staged verdict wins over a later stage-less echo — a failing critic that - * pastes a passing tool result after its verdict must not false-green the gate. - */ -export const findRightmostPassedObject = (text: string): Record | null => { - const { best, unbalanced } = scanTopLevel( - text, - (r) => typeof r.passed === 'boolean', - (r) => typeof r.stage === 'string', - ); - if (best && !unbalanced && typeof best.stage === 'string') { - // A staged top-level verdict with a balanced stack — no recovery needed. - // KNOWN LIMITATION: a later verdict written INSIDE a stray balanced brace - // pair (e.g. a code snippet) is ignored when an earlier staged verdict - // exists — the short-circuit protects against a quoted prior verdict - // wrapped in prose braces overriding the real one, at the cost of this - // corner case. - return best; - } - // Otherwise run the recovery: the stack may be unbalanced (a stray brace - // hides a better verdict), the strict scan may have found nothing (a stray - // { balanced by a stray } strands the verdict), or best may be a stage-less - // echo that must not short-circuit the recovery's staged verdict. - return mergeStrictAndRecovered( - best, - recoverFromUnbalancedBraces(text, (r) => typeof r.passed === 'boolean', (r) => typeof r.stage === 'string'), - ); + return bestStaged ?? bestAny; }; /** @@ -352,9 +174,7 @@ export const extractRubricJson = (rawOutput: unknown): Record | const stripped = rawOutput.trim(); - // Whole-output fast path: the output is exactly a verdict object. No - // fenced-first shortcut — a fenced block earlier in the output is never the - // verdict over a later one, so the scan below is the single source of truth. + // Whole-output fast path: the output is exactly a verdict object. try { const parsed = JSON.parse(stripped) as unknown; if ( From 914ca611a62a11d91bd0b59a3d66f10a19f5f1cf Mon Sep 17 00:00:00 2001 From: Jongkuk Lim Date: Fri, 7 Aug 2026 13:26:56 +0900 Subject: [PATCH 87/87] =?UTF-8?q?test(eval):=20sanitize=20golden=20fixture?= =?UTF-8?q?=20=E2=80=94=20remove=20production-identifying=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The golden fixture originally contained real critic outputs from a production DB, which exposed company-specific project details (project names, file paths, internal tooling). Replaced with rephrased, generic content that preserves the parser-testing shapes (prose + verdict, fenced, tool echoes, truncated, unmatched-quote-in-prose, raw NDJSON) and the same ground-truth verdicts. The sensitive fixture was purged from git history. Co-Authored-By: Claude --- src/core/rubric.production.test.ts | 20 ++--- .../production-critic-outputs.json | 74 +++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/fake-agents/production-critic-outputs.json diff --git a/src/core/rubric.production.test.ts b/src/core/rubric.production.test.ts index b5e189a..1fd5aee 100644 --- a/src/core/rubric.production.test.ts +++ b/src/core/rubric.production.test.ts @@ -3,18 +3,18 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { extractRubricJson } from './rubric.js'; -// ─── Production-data golden test ──────────────────────────────────────────── -// Real critic outputs extracted from the production DB (tenet.db), with the +// ─── Production-shape golden test ─────────────────────────────────────────── +// Critic outputs shaped like real production data (prose + verdict, fenced, +// tool echoes, truncated, unmatched-quote-in-prose, raw NDJSON), with the // ground-truth verdict for each. This is the regression baseline for the -// parser: it ensures any refactor behaves the same on REAL data, not just on -// synthetic fixtures. The expected verdicts were established with a simple -// rightmost-object walk (walk { from the end, parse to its matching }, keep the -// first object with a boolean `passed` key, preferring a `stage` key). +// parser: it ensures any refactor behaves the same on realistic shapes, not +// just on synthetic fixtures. The expected verdicts were established with a +// simple rightmost-object walk (walk { from the end, parse to its matching }, +// keep the first object with a boolean `passed` key, preferring a `stage` key). // -// KNOWN BUG (5248d039): the current parser returns null here because prose -// before the verdict contains an unmatched double quote that confuses the -// string-state walk. The simpler parser handles it — this is the case the -// planned simplification fixes. +// case-07 pins the unmatched-quote shape: prose before the verdict contains +// an unmatched double quote that a string-state walk would misread as opening +// a string, false-rejecting a valid verdict. The rightmost-walk handles it. type GoldenCase = { id: string; diff --git a/tests/fixtures/fake-agents/production-critic-outputs.json b/tests/fixtures/fake-agents/production-critic-outputs.json new file mode 100644 index 0000000..e6b859c --- /dev/null +++ b/tests/fixtures/fake-agents/production-critic-outputs.json @@ -0,0 +1,74 @@ +[ + { + "id": "case-01", + "type": "critic_eval", + "output": "I reviewed the changes and found no in-scope blockers. The implementation matches the spec.\n\n## Verdict\n\n{\"passed\": true, \"stage\": \"code_critic\", \"findings\": []}", + "expected": { "passed": true, "stage": "code_critic" } + }, + { + "id": "case-02", + "type": "eval", + "output": "No findings. The strengthened regression test covers the previously-missed edge case.\n\n## Test Critic Verdict: PASS\n\n{\"passed\": true, \"stage\": \"test_critic\", \"findings\": [], \"missing_tests\": []}", + "expected": { "passed": true, "stage": "test_critic" } + }, + { + "id": "case-03", + "type": "critic_eval", + "output": "{\"passed\": false, \"stage\": \"code_critic\", \"findings\": [{\"category\": \"product_bug\", \"detail\": \"the retry path does not handle the timeout case\"}]}", + "expected": { "passed": false, "stage": "code_critic" } + }, + { + "id": "case-04", + "type": "critic_eval", + "output": "Findings:\n- `product_bug`: the config loader ignores unknown keys instead of failing\n- `product_bug`: the redaction hook runs after the audit log is written\n\n{\"passed\": false, \"stage\": \"code_critic\", \"findings\": [{\"category\": \"product_bug\", \"detail\": \"config loader ignores unknown keys\"}]}", + "expected": { "passed": false, "stage": "code_critic" } + }, + { + "id": "case-05", + "type": "eval", + "output": "Verdict: FAIL. The regression tests called out in the retry are still missing coverage for the empty-input path.\n\n{\"passed\": false, \"stage\": \"test_critic\", \"findings\": [{\"category\": \"test_bug\", \"detail\": \"empty-input path untested\"}], \"missing_tests\": [\"test_empty_input\"]}", + "expected": { "passed": false, "stage": "test_critic" } + }, + { + "id": "case-06", + "type": "interaction_e2e", + "output": "Surface: `cli` / runtime tests. Harness policy explicitly maps the CLI surface to scripted checks.\n\n{\"passed\": true, \"stage\": \"playwright_eval\", \"surface\": \"cli\", \"layer2_status\": \"completed\", \"scripted_results\": \"all green\", \"exploratory_findings\": [], \"screenshots\": []}", + "expected": { "passed": true, "stage": "playwright_eval" } + }, + { + "id": "case-07", + "type": "critic_eval", + "output": "I have everything I need. Let me record my independent verification before delivering the verdict.\n\n## Code Critic — Purpose Alignment Verdict\n\nI hand-traced all the requirements against the committed config. The install step guard checks `'uv tool install \"the-package' is not present in the run command — the substring check is the regression guard.\n\n### Second-pass (alternate angles, zero-findings rule)\n\n{\"passed\": true, \"stage\": \"code_critic\", \"findings\": []}", + "expected": { "passed": true, "stage": "code_critic" } + }, + { + "id": "case-08", + "type": "eval", + "output": "{\"passed\": true, \"stage\": \"test_critic\", \"findings\": [], \"missing_tests\": []}", + "expected": { "passed": true, "stage": "test_critic" } + }, + { + "id": "case-09", + "type": "interaction_e2e", + "output": "Surface: CLI/scripted with the pilot tests for the TUI. The scripted checks all pass.\n\n{\"passed\": true, \"stage\": \"playwright_eval\", \"surface\": \"cli\", \"layer2_status\": \"completed\", \"scripted_results\": \"all green\", \"exploratory_findings\": [], \"screenshots\": []}", + "expected": { "passed": true, "stage": "playwright_eval" } + }, + { + "id": "case-10", + "type": "interaction_e2e", + "output": "**Eval Result**\nSurface: `cli`. Harness policy: the CLI surface is scripted. The scripted checks fail on the error path.\n\n{\"passed\": false, \"stage\": \"playwright_eval\", \"surface\": \"cli\", \"layer2_status\": \"failed\", \"scripted_results\": \"2 failed\", \"exploratory_findings\": [\"error path not handled\"], \"screenshots\": []}", + "expected": { "passed": false, "stage": "playwright_eval" } + }, + { + "id": "case-11", + "type": "eval", + "output": "The targeted regression test is sufficient for this job scope. The fix is covered.\n\n{\"passed\": true, \"stage\": \"test_critic\", \"findings\": [], \"missing_tests\": []}", + "expected": { "passed": true, "stage": "test_critic" } + }, + { + "id": "case-12", + "type": "eval", + "output": "{\"type\":\"step_start\",\"timestamp\":1783582108303,\"sessionID\":\"ses_000000000000000000000000000000000000\",\"part\":{\"id\":\"prt_a\",\"messageID\":\"msg_a\",\"type\":\"step-start\"}}\n{\"type\":\"text\",\"timestamp\":1783582108400,\"sessionID\":\"ses_000000000000000000000000000000000000\",\"part\":{\"id\":\"prt_b\",\"messageID\":\"msg_b\",\"type\":\"text\",\"text\":\"I reviewed the code.\"}}\n{\"type\":\"step_finish\",\"timestamp\":1783582108500,\"sessionID\":\"ses_000000000000000000000000000000000000\",\"part\":{\"id\":\"prt_c\",\"reason\":\"stop\",\"messageID\":\"msg_b\",\"type\":\"step-finish\"}}", + "expected": null + } +]