From 10694440e32e82ff1b1d4e424b01e6de7f90b43e Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Fri, 8 May 2026 06:43:53 +0200 Subject: [PATCH] feat(sf): align uok task state and steering --- docs/specs/agent-mode-system.md | 67 ++- docs/specs/sf-operating-model.md | 34 +- scripts/parallel-monitor.mjs | 83 +--- .../extensions/sf/bootstrap/db-tools.js | 54 +++ .../extensions/sf/bootstrap/register-hooks.js | 28 +- .../extensions/sf/commands-handlers.js | 14 +- .../extensions/sf/commands/catalog.js | 4 +- .../extensions/sf/commands/handlers/core.js | 20 +- .../extensions/sf/extension-manifest.json | 2 +- .../extensions/sf/parallel-eligibility.js | 29 ++ .../extensions/sf/parallel-monitor-overlay.js | 76 +-- .../extensions/sf/parallel-monitor-store.js | 108 +++++ .../extensions/sf/remote-steering.js | 188 ++++++++ src/resources/extensions/sf/sf-db.js | 456 +++++++++++++++++- .../extensions/sf/subagent-inheritance.js | 170 +++++++ .../extensions/sf/task-frontmatter.js | 356 ++++++++++++++ .../sf/tests/direct-command-surface.test.mjs | 15 + .../sf/tests/parallel-monitor-store.test.mjs | 117 +++++ .../sf/tests/plan-slice-evidence.test.mjs | 12 + .../sf/tests/remote-steering.test.mjs | 151 ++++++ .../sf/tests/sf-db-migration.test.mjs | 8 +- .../sf/tests/sf-db-task-frontmatter.test.mjs | 93 ++++ .../tests/spec-tables-live-planning.test.mjs | 50 ++ .../sf/tests/subagent-inheritance.test.mjs | 183 +++++++ .../sf/tests/task-frontmatter.test.mjs | 144 ++++++ .../uok-execution-graph-persist.test.mjs | 12 +- .../sf/tests/uok-task-state.test.mjs | 70 ++- .../extensions/sf/tools/plan-slice.js | 30 ++ .../sf/uok/execution-graph-persist.js | 62 ++- src/resources/extensions/sf/uok/task-state.js | 107 +++- src/resources/extensions/subagent/index.js | 94 +++- 31 files changed, 2583 insertions(+), 254 deletions(-) create mode 100644 src/resources/extensions/sf/parallel-monitor-store.js create mode 100644 src/resources/extensions/sf/remote-steering.js create mode 100644 src/resources/extensions/sf/subagent-inheritance.js create mode 100644 src/resources/extensions/sf/task-frontmatter.js create mode 100644 src/resources/extensions/sf/tests/parallel-monitor-store.test.mjs create mode 100644 src/resources/extensions/sf/tests/remote-steering.test.mjs create mode 100644 src/resources/extensions/sf/tests/sf-db-task-frontmatter.test.mjs create mode 100644 src/resources/extensions/sf/tests/subagent-inheritance.test.mjs create mode 100644 src/resources/extensions/sf/tests/task-frontmatter.test.mjs diff --git a/docs/specs/agent-mode-system.md b/docs/specs/agent-mode-system.md index 395ffba52..43dac72ce 100644 --- a/docs/specs/agent-mode-system.md +++ b/docs/specs/agent-mode-system.md @@ -54,6 +54,7 @@ review | manual | restricted | deep → user reviews with reasoning mo - `runControl` never implies `permissionProfile`. Manual run with `unrestricted` permissions is valid. - Denylists and safety gates override `permissionProfile` regardless of value. - Every risk decision logs all five axis values. +- `sandboxProfile` may become a sixth axis later. It is separate from `permissionProfile`: sandboxing controls process/filesystem/network containment, while permission profile controls what SF may approve. --- @@ -90,9 +91,10 @@ Inspect diffs, tests, risks, regressions, security issues, missing evidence. Req ### 3.5 `repair` -Fix SF health, repo health, runtime drift, broken generated state, bad command surfaces, failing workflow infrastructure, stale locks, broken installed runtime copies. +Fix SF health, repo health, runtime drift, broken generated state, bad command surfaces, failing workflow infrastructure, stale locks, broken installed runtime copies, failed gates, generated/runtime drift, and broken state. **Doctor is the diagnostic engine, not the mode.** `/doctor` inspects. `/repair` switches work mode. +`repair` is a `workMode`, not a separate subsystem. Commands: ```text @@ -191,10 +193,10 @@ Permission profile is enforced at three layers: ### 5.2 Commands ```text -/trust restricted -/trust normal -/trust trusted -/trust unrestricted +/permission-profile restricted +/permission-profile normal +/permission-profile trusted +/permission-profile unrestricted ``` --- @@ -225,10 +227,10 @@ Permission profile is enforced at three layers: /control manual /control assisted /control autonomous -/trust restricted -/trust normal -/trust trusted -/trust unrestricted +/permission-profile restricted +/permission-profile normal +/permission-profile trusted +/permission-profile unrestricted /model-mode fast /model-mode smart /model-mode deep @@ -237,9 +239,9 @@ Permission profile is enforced at three layers: ### 7.2 Combined Forms ```text -/mode repair --autonomous --trust normal -/mode build --autonomous --trust trusted -/mode research --autonomous --trust restricted --model-mode deep +/mode repair --autonomous --permission-profile normal +/mode build --autonomous --permission-profile trusted +/mode research --autonomous --permission-profile restricted --model-mode deep ``` ### 7.3 Autonomous Steering @@ -247,7 +249,7 @@ Permission profile is enforced at three layers: ```text /steer mode repair /steer mode review after-current-unit -/steer trust restricted now +/steer permission-profile restricted now /steer model-mode deep for-next-unit ``` @@ -332,9 +334,9 @@ Unified view of all background work. Replaces scattered `/status`, `/queue`, `/p ### 9.1 What `/tasks` Shows -- autonomous units (current + queued) +- autonomous task lifecycle rows - parallel workers -- scheduled autonomous dispatches +- scheduled autonomous dispatches and queued scheduler rows - background shell sessions - stuck or resumable sessions - remote questions waiting for answers @@ -353,7 +355,7 @@ CREATE TABLE tasks ( run_control TEXT NOT NULL, permission_profile TEXT NOT NULL, model_mode TEXT NOT NULL, - status TEXT NOT NULL, -- pending | running | review | done | retrying | failed | cancelled + status TEXT NOT NULL, -- todo | running | verifying | reviewing | done | blocked | paused | failed | cancelled | retrying dependency_blockers TEXT, -- JSON array of task IDs retry_count INTEGER DEFAULT 0, max_retries INTEGER DEFAULT 3, @@ -367,6 +369,17 @@ CREATE TABLE tasks ( intent_claim TEXT -- for parallel workers: "I will edit src/foo.ts lines 10-50" ); +-- Scheduler state is separate from task lifecycle state +CREATE TABLE task_scheduler ( + task_id TEXT PRIMARY KEY REFERENCES tasks(id), + status TEXT NOT NULL, -- queued | due | claimed | dispatched | consumed | expired + due_at TEXT, + claimed_by TEXT, + dispatched_at TEXT, + consumed_at TEXT, + expires_at TEXT +); + -- Ephemeral running state CREATE TABLE task_runtime ( task_id TEXT PRIMARY KEY REFERENCES tasks(id), @@ -390,6 +403,9 @@ CREATE TABLE task_transitions ( ); ``` +Parallel workers must stay worktree-isolated and report heartbeat/status into +`.sf` state. Scheduler rows may use `queued`; task lifecycle rows use `todo`. + ### 9.3 Complementary Commands `/tasks` does not replace: @@ -451,8 +467,8 @@ Dangerous skills (`production-mutation`) are never model-invoked by default. 1. Detect repeated repo-specific evidence (same files, commands, failure modes, rules) 2. Propose skill in manual/restricted contexts 3. Generate/update automatically only when policy allows -4. Record source evidence in `.sf` state -5. Keep narrow and testable +4. Record repeated source evidence in `.sf` state +5. Keep narrow, linted, and evaled like code 6. Commit with repo when accepted ### 10.5 Skill Eval Cases @@ -493,6 +509,10 @@ SF registers direct command roots only: `/sf` is not a command root. TUI and browser command parity tests reject it so compatibility shims do not grow back. +`/remote` is a full-session steering surface. Remote answers may change +`workMode`, `runControl`, `permissionProfile`, and `modelMode`; they are not +limited to question delivery. + ### 11.2 Shell Surface Machine surface remains prefixed: @@ -575,22 +595,20 @@ sf --print "ping" | Priority | Item | Effort | |----------|------|--------| -| P2 | Schema-backed task frontmatter (risk, mutation, verification) | Medium | -| P2 | Audit subagent provider/model/permission inheritance | Medium | -| P2 | Audit remote steering as full-session surface | Medium | +| P2 | Decide whether `sandboxProfile` becomes a sixth persisted axis | Medium | ### 13.3 Completed | Priority | Item | Status | |----------|------|--------| | P0 | Make mode state durable in SQLite | ✓ | -| P0 | Add direct `/mode`, `/control`, `/trust`, `/model-mode` commands | ✓ | +| P0 | Add direct `/mode`, `/control`, `/permission-profile`, `/model-mode` commands | ✓ | | P0 | Add visible mode badge to TUI header/status bar | ✓ | | P1 | Make `--autonomous` chain into direct `/autonomous` | ✓ | | P1 | Expose autonomous continuation limits in settings and status | ✓ | | P1 | Add `/tasks` backed by DB execution graph state | ✓ | | P1 | Make `repair` first-class workflow over `doctor` | ✓ | -| P1 | Enhanced `/steer` with mode/trust/model-mode transitions | ✓ | +| P1 | Enhanced `/steer` with mode/permission-profile/model-mode transitions | ✓ | | P1 | TUI keyboard shortcuts for mode cycling (Ctrl+Shift+M/R/A/S/P) | ✓ | | P1 | Minimal auto-mode header/footer (badge visible during autonomy) | ✓ | | P1 | Remove `/sf` namespace registration and parity-test against fallback | ✓ | @@ -598,6 +616,9 @@ sf --print "ping" | P1 | Skill eval harness foundation | ✓ | | P1 | Terminal title mode indicator | ✓ | | P2 | Policy-aware project skill suggestion/generation with DB cooldown | ✓ | +| P2 | Schema-backed task frontmatter (risk, mutation, verification, approval) | ✓ | +| P2 | Subagent provider/model/permission inheritance audit and guard | ✓ | +| P2 | Remote steering as full-session surface from remote answers | ✓ | --- diff --git a/docs/specs/sf-operating-model.md b/docs/specs/sf-operating-model.md index c8c2a3ff2..86fb5ced5 100644 --- a/docs/specs/sf-operating-model.md +++ b/docs/specs/sf-operating-model.md @@ -9,7 +9,7 @@ - Guidance: `.sf/ANTI-GOALS.md` (present) - Optional knowledge: `.sf/KNOWLEDGE.md` (missing) - Optional preferences: `.sf/PREFERENCES.md` (present) -- Database schema version: 42 +- Source schema version: 45 - DB planning rows: milestones=1, slices=0, tasks=0 - DB spec rows: milestone_specs=1, slice_specs=0, task_specs=0 - Source roots analyzed as implementation evidence: `src/resources/extensions/sf/`, `src/headless*.ts`, `src/cli.ts`, `src/help-text.ts`, `web/`, `vscode-extension/`, `packages/` @@ -92,6 +92,36 @@ Run control and permission profile are independent. For example, `autonomous + r UOK kernel records and execution-policy decisions carry `permissionProfile` as the trust posture. Permission expansion never implies autonomous continuation. +## Work Mode + +A work mode describes the kind of work SF is doing. `repair` is one work mode, not a separate subsystem. It owns self-healing, stale locks, installed-runtime drift, broken state, failed gates, generated/runtime drift, and other cases where SF must repair its own ability to continue safely. + +`doctor` remains a diagnostic engine. It can inspect and report problems, but switching into repair work is a `workMode` transition. + +## Task And Scheduler Status + +Durable task lifecycle state uses the ORCH-style status machine: + +```text +todo -> running -> verifying -> reviewing -> done | blocked | paused | failed | cancelled | retrying +``` + +Use `todo`, not `queued`, for work that exists but has not started. `queued` belongs to scheduler state only: + +```text +queued -> due -> claimed -> dispatched -> consumed | expired +``` + +Parallel workers must stay worktree-isolated and report heartbeat/status into `.sf` state. Their lifecycle rows use `task_status`; timed dispatch and reminder rows use `task_scheduler.status`. + +## Remote Steering + +Remote is a full-session steering surface. It may change `workMode`, `runControl`, `permissionProfile`, and `modelMode`; it is not only a question delivery channel. + +## Future Sandbox Profile + +`sandboxProfile` may become a sixth independent axis later. Keep it separate from `permissionProfile`: sandbox profile controls containment, while permission profile controls what SF may approve. + ## Naming Rules - Say **flow** for the shared planning/execution engine. @@ -100,6 +130,8 @@ UOK kernel records and execution-policy decisions carry `permissionProfile` as t - Say **output format** for `text`, `json`, and `stream-json`. - Say **run control** for `manual`, `assisted`, and `autonomous`. - Say **permission profile** for `restricted`, `normal`, `trusted`, and `unrestricted`. +- Say **task status** for `todo`, `running`, `verifying`, `reviewing`, `done`, `blocked`, `paused`, `failed`, `cancelled`, and `retrying`. +- Say **scheduler status** for `queued`, `due`, `claimed`, `dispatched`, `consumed`, and `expired`. - Use **headless** only for the current `sf headless` command and implementation path. Product docs should explain it as the machine surface. ## Working State Contract diff --git a/scripts/parallel-monitor.mjs b/scripts/parallel-monitor.mjs index 8172c826c..06da016be 100755 --- a/scripts/parallel-monitor.mjs +++ b/scripts/parallel-monitor.mjs @@ -4,7 +4,8 @@ * SF Parallel Worker Monitor * * Real-time TUI dashboard for monitoring parallel SF auto-mode workers. - * Zero dependencies — uses raw ANSI escape codes, Node.js builtins only. + * Zero external dependencies — uses raw ANSI escape codes, Node.js builtins, + * and the shared SF monitor projection store. * * Usage: * node scripts/parallel-monitor.mjs # live dashboard, 5s refresh @@ -44,7 +45,10 @@ import { execSync, spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; +import { + queryParallelRecentCompletionRows, + queryParallelSliceProgress, +} from "../src/resources/extensions/sf/parallel-monitor-store.js"; // ─── Configuration ─────────────────────────────────────────────────────────── @@ -176,46 +180,6 @@ function readAutoLock(mid) { return readJsonSafe(lockPath); } -function queryRows(dbPath, sql, params = []) { - const db = new DatabaseSync(dbPath, { readOnly: true }); - try { - return db - .prepare(sql) - .all(...params) - .map((row) => ({ ...row })); - } finally { - db.close(); - } -} - -function querySliceProgress(mid) { - const dbPath = path.resolve(PROJECT_ROOT, `.sf/worktrees/${mid}/.sf/sf.db`); - if (!fs.existsSync(dbPath)) return []; - - try { - return queryRows( - dbPath, - `SELECT s.id AS id, - s.status AS status, - COUNT(t.id) AS total, - SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) AS done - FROM slices s - LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id - WHERE s.milestone_id=? - GROUP BY s.id - ORDER BY s.id`, - [mid], - ).map((row) => ({ - id: row.id, - status: row.status, - total: Number(row.total ?? 0), - done: Number(row.done ?? 0), - })); - } catch { - return []; - } -} - function readRecentEvents(mid, maxLines = 5) { const stdoutPath = path.resolve( PROJECT_ROOT, @@ -642,34 +606,11 @@ function truncate(str, maxLen) { * Get recently completed tasks/slices from the worktree DB for the event feed. */ function queryRecentCompletions(mid) { - const dbPath = path.resolve(PROJECT_ROOT, `.sf/worktrees/${mid}/.sf/sf.db`); - if (!fs.existsSync(dbPath)) return []; - - try { - // Completed tasks with timestamps, most recent first - return queryRows( - dbPath, - `SELECT id AS taskId, - slice_id AS sliceId, - one_liner AS oneLiner, - completed_at AS completedAt - FROM tasks - WHERE milestone_id=? - AND status='complete' - AND completed_at IS NOT NULL - ORDER BY completed_at DESC - LIMIT 5`, - [mid], - ).map((row) => { - return { - ts: row.completedAt ? new Date(row.completedAt).getTime() : Date.now(), - msg: `✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`, - mid, - }; - }); - } catch { - return []; - } + return queryParallelRecentCompletionRows(PROJECT_ROOT, mid).map((row) => ({ + ts: row.completedAt ? new Date(row.completedAt).getTime() : Date.now(), + msg: `✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`, + mid, + })); } // ─── Rendering ─────────────────────────────────────────────────────────────── @@ -687,7 +628,7 @@ function collectWorkerData() { for (const mid of mids) { const status = readWorkerStatus(mid); const lock = readAutoLock(mid); - const slices = querySliceProgress(mid); + const slices = queryParallelSliceProgress(PROJECT_ROOT, mid); const { notifications, errors } = readRecentEvents(mid, 3); // Prefer auto.lock PID (written by the running worker) over status.json PID diff --git a/src/resources/extensions/sf/bootstrap/db-tools.js b/src/resources/extensions/sf/bootstrap/db-tools.js index f6e51e3d2..d7d38b6be 100644 --- a/src/resources/extensions/sf/bootstrap/db-tools.js +++ b/src/resources/extensions/sf/bootstrap/db-tools.js @@ -1523,6 +1523,60 @@ export function registerDbTools(pi) { observabilityImpact: Type.Optional( Type.String({ description: "Task observability impact" }), ), + risk: Type.Optional( + Type.String({ + description: + "Task risk level: none, low, medium, high, or critical", + }), + ), + mutationScope: Type.Optional( + Type.String({ + description: + "Declared mutation scope: none, docs-only, config, test-only, isolated, bounded, cross-cutting, or systemic", + }), + ), + verification: Type.Optional( + Type.String({ + description: + "Verification requirement type: none, self-check, review, test, integration, or manual-qa", + }), + ), + planApproval: Type.Optional( + Type.String({ + description: + "Approval state: not-required, pending, approved, rejected, or auto-approved", + }), + ), + estimatedEffort: Type.Optional( + Type.Number({ + description: "Estimated effort in minutes when known", + }), + ), + dependencies: Type.Optional( + Type.Array(Type.String(), { + description: "Task IDs this task depends on", + }), + ), + blocksParallel: Type.Optional( + Type.Boolean({ + description: "True when no other task should run concurrently", + }), + ), + requiresUserInput: Type.Optional( + Type.Boolean({ + description: "True when execution is expected to need user input", + }), + ), + autoRetry: Type.Optional( + Type.Boolean({ + description: "Whether transient failures may retry automatically", + }), + ), + maxRetries: Type.Optional( + Type.Number({ + description: "Maximum automatic retry attempts, 0-10", + }), + ), }), { description: "Planned tasks for the slice" }, ), diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.js b/src/resources/extensions/sf/bootstrap/register-hooks.js index c4a014c2f..290d0207a 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.js +++ b/src/resources/extensions/sf/bootstrap/register-hooks.js @@ -52,6 +52,11 @@ import { resolveSlicePath, } from "../paths.js"; import { cleanupQuickBranch } from "../quick.js"; +import { + applyRemoteSteeringDirectives, + formatRemoteSteeringResults, + parseRemoteSteeringDirectives, +} from "../remote-steering.js"; import { classifyCommand } from "../safety/destructive-guard.js"; import { recordToolCall as safetyRecordToolCall, @@ -598,7 +603,7 @@ export function registerHooks(pi, ecosystemHandlers = []) { // ── Execution policy enforcement: block based on permission profile ── // When autonomous mode is active, enforce the session's permission profile // at the tool boundary. This is the enforcement layer that makes - // /trust restricted|normal|trusted|unrestricted meaningful. + // /permission-profile restricted|normal|trusted|unrestricted meaningful. if (isAutoActive()) { const { getAutoSession } = await import("../auto/session.js"); const session = getAutoSession(); @@ -768,6 +773,27 @@ export function registerHooks(pi, ecosystemHandlers = []) { const questions = event.input?.questions ?? []; const currentPendingGate = getPendingGate(); if (details?.cancelled || !details?.response) return; + if (details.remote === true) { + const steering = parseRemoteSteeringDirectives(details.response); + if (steering.steering) { + const results = applyRemoteSteeringDirectives(steering.directives); + pi.sendMessage( + { + customType: "sf-remote-steering", + content: formatRemoteSteeringResults(results), + display: false, + details: { + toolName: event.toolName, + toolCallId: event.toolCallId, + promptId: details.promptId, + channel: details.channel, + results, + }, + }, + { deliverAs: "steer" }, + ); + } + } for (const question of questions) { if (typeof question.id !== "string") continue; // Check if this is a depth_verification question (either directly or via pending gate) diff --git a/src/resources/extensions/sf/commands-handlers.js b/src/resources/extensions/sf/commands-handlers.js index c66e52321..8201f4d85 100644 --- a/src/resources/extensions/sf/commands-handlers.js +++ b/src/resources/extensions/sf/commands-handlers.js @@ -439,16 +439,16 @@ export async function handleSteer(change, ctx, pi) { return; } - // ── Trust steering: /steer trust [scope] ───────────────────── - const trustSteerRe = /^trust\s+(\S+)(?:\s+(\S+))?/; - const trustMatch = trimmed.match(trustSteerRe); - if (trustMatch) { - const permissionProfile = trustMatch[1]; - const scope = trustMatch[2] ?? "now"; + // ── Permission-profile steering: /steer permission-profile [scope] + const permissionProfileSteerRe = /^permission-profile\s+(\S+)(?:\s+(\S+))?/; + const permissionProfileMatch = trimmed.match(permissionProfileSteerRe); + if (permissionProfileMatch) { + const permissionProfile = permissionProfileMatch[1]; + const scope = permissionProfileMatch[2] ?? "now"; const s = getAutoSession(); const transition = s.setMode({ permissionProfile, reason: "steer", scope }); ctx.ui.notify( - `Steer trust: ${transition.from.permissionProfile} → ${transition.to.permissionProfile} (${scope})`, + `Steer permission profile: ${transition.from.permissionProfile} → ${transition.to.permissionProfile} (${scope})`, "info", ); return; diff --git a/src/resources/extensions/sf/commands/catalog.js b/src/resources/extensions/sf/commands/catalog.js index 195a2c19a..4789fa389 100644 --- a/src/resources/extensions/sf/commands/catalog.js +++ b/src/resources/extensions/sf/commands/catalog.js @@ -12,7 +12,7 @@ const sfHome = process.env.SF_HOME || join(homedir(), ".sf"); * Comprehensive description of all available SF commands for help text. */ export const SF_COMMAND_DESCRIPTION = - "SF — Singularity Forge: /help|start|templates|next|autonomous|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|cleanup|mode|control|trust|model-mode|show-config|prefs|config|keys|hooks|run-hook|skill-health|doctor|uok|logs|forensics|migrate|remote|steer|knowledge|harness|solver-eval|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold|extract-learnings|eval-review|plan"; + "SF — Singularity Forge: /help|start|templates|next|autonomous|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|cleanup|mode|control|permission-profile|model-mode|show-config|prefs|config|keys|hooks|run-hook|skill-health|doctor|uok|logs|forensics|migrate|remote|steer|knowledge|harness|solver-eval|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold|extract-learnings|eval-review|plan"; export const BASE_RUNTIME_COMMANDS = new Set([ "settings", @@ -108,7 +108,7 @@ export const TOP_LEVEL_SUBCOMMANDS = [ }, { cmd: "control", desc: "Switch run control (manual/assisted/autonomous)" }, { - cmd: "trust", + cmd: "permission-profile", desc: "Switch permission profile (restricted/normal/trusted/unrestricted)", }, { cmd: "model-mode", desc: "Switch model mode (fast/smart/deep)" }, diff --git a/src/resources/extensions/sf/commands/handlers/core.js b/src/resources/extensions/sf/commands/handlers/core.js index f392b5253..0e42890f8 100644 --- a/src/resources/extensions/sf/commands/handlers/core.js +++ b/src/resources/extensions/sf/commands/handlers/core.js @@ -42,7 +42,7 @@ export function showHelp(ctx, args = "") { "COURSE CORRECTION", " /steer Apply user override to active work", " /steer mode [scope] Change work mode (now|after-current-unit|next-milestone)", - " /steer trust

[scope] Change permission profile", + " /steer permission-profile

[scope] Change permission profile", " /steer model-mode Change model mode for next unit", " /capture Quick-capture a thought to CAPTURES.md", " /triage Classify and route pending captures", @@ -110,7 +110,7 @@ export function showHelp(ctx, args = "") { " /model Switch active session model [provider/model|model-id]", " /mode Switch work mode (chat/plan/build/review/repair/research)", " /control Switch run control (manual/assisted/autonomous)", - " /trust Switch permission profile (restricted/normal/trusted/unrestricted)", + " /permission-profile Switch permission profile (restricted/normal/trusted/unrestricted)", " /model-mode Switch model mode (fast/smart/deep)", " /prefs Manage preferences [global|project|status|wizard|setup|import-claude]", " /cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]", @@ -433,17 +433,17 @@ function handleControlCommand(args, ctx) { ); return true; } -function handleTrustCommand(args, ctx) { +function handlePermissionProfileCommand(args, ctx) { const s = getAutoSession(); const permissionProfile = args.trim(); if (!permissionProfile) { const mode = s.getMode(); - ctx.ui.notify(`Trust: ${mode.permissionProfile}`, "info"); + ctx.ui.notify(`Permission profile: ${mode.permissionProfile}`, "info"); return true; } const transition = s.setMode({ permissionProfile }); ctx.ui.notify( - `Trust: ${transition.from.permissionProfile} → ${transition.to.permissionProfile}`, + `Permission profile: ${transition.from.permissionProfile} → ${transition.to.permissionProfile}`, "info", ); return true; @@ -520,8 +520,14 @@ export async function handleCoreCommand(trimmed, ctx, pi) { handleControlCommand(trimmed.replace(/^control\s*/, "").trim(), ctx); return true; } - if (trimmed === "trust" || trimmed.startsWith("trust ")) { - handleTrustCommand(trimmed.replace(/^trust\s*/, "").trim(), ctx); + if ( + trimmed === "permission-profile" || + trimmed.startsWith("permission-profile ") + ) { + handlePermissionProfileCommand( + trimmed.replace(/^permission-profile\s*/, "").trim(), + ctx, + ); return true; } if (trimmed === "model-mode" || trimmed.startsWith("model-mode ")) { diff --git a/src/resources/extensions/sf/extension-manifest.json b/src/resources/extensions/sf/extension-manifest.json index 6854fda0c..ff736dc5c 100644 --- a/src/resources/extensions/sf/extension-manifest.json +++ b/src/resources/extensions/sf/extension-manifest.json @@ -114,7 +114,7 @@ "templates", "todo", "triage", - "trust", + "permission-profile", "undo", "undo-task", "unpark", diff --git a/src/resources/extensions/sf/parallel-eligibility.js b/src/resources/extensions/sf/parallel-eligibility.js index 57e61bd17..294fb96db 100644 --- a/src/resources/extensions/sf/parallel-eligibility.js +++ b/src/resources/extensions/sf/parallel-eligibility.js @@ -33,6 +33,25 @@ async function collectTouchedFiles(_basePath, milestoneId) { // When DB unavailable, return empty file set — parallel eligibility cannot be determined return [...files]; } +function collectParallelMetadataBlockers(milestoneId) { + const blockers = []; + if (!isDbAvailable()) return blockers; + const slices = getMilestoneSlices(milestoneId); + for (const slice of slices) { + const tasks = getSliceTasks(milestoneId, slice.id); + for (const task of tasks) { + const frontmatter = task.frontmatter; + if (!frontmatter) continue; + if (frontmatter.blocksParallel) { + blockers.push(`${slice.id}/${task.id} blocks parallel execution`); + } + if (frontmatter.mutationScope === "systemic") { + blockers.push(`${slice.id}/${task.id} has systemic mutation scope`); + } + } + } + return blockers; +} // ─── Overlap Detection ────────────────────────────────────────────────────── /** * Compare file sets across milestones and return pairs with overlapping files. @@ -118,6 +137,16 @@ export async function analyzeParallelEligibility(basePath) { }); continue; } + const metadataBlockers = collectParallelMetadataBlockers(mid); + if (metadataBlockers.length > 0) { + ineligible.push({ + milestoneId: mid, + title, + eligible: false, + reason: `Task metadata blocks parallel execution: ${metadataBlockers.join("; ")}.`, + }); + continue; + } eligible.push({ milestoneId: mid, title, diff --git a/src/resources/extensions/sf/parallel-monitor-overlay.js b/src/resources/extensions/sf/parallel-monitor-overlay.js index 61ad54cd2..56bdfa54e 100644 --- a/src/resources/extensions/sf/parallel-monitor-overlay.js +++ b/src/resources/extensions/sf/parallel-monitor-overlay.js @@ -17,23 +17,14 @@ import { statSync, } from "node:fs"; import { join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; import { Key, matchesKey } from "@singularity-forge/pi-tui"; import { formatDuration } from "../shared/mod.js"; +import { + queryParallelRecentCompletionRows, + queryParallelSliceProgress, +} from "./parallel-monitor-store.js"; import { formattedShortcutPair } from "./shortcut-defs.js"; -// ─── SQLite Helper ──────────────────────────────────────────────────────── -function queryRows(dbPath, sql, params = []) { - const db = new DatabaseSync(dbPath, { readOnly: true }); - try { - return db - .prepare(sql) - .all(...params) - .map((row) => ({ ...row })); - } finally { - db.close(); - } -} // ─── Data Helpers ───────────────────────────────────────────────────────── function readJsonSafe(filePath) { try { @@ -94,32 +85,6 @@ function discoverWorkers(basePath) { } return [...mids].sort(); } -function querySliceProgress(basePath, mid) { - const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db"); - if (!existsSync(dbPath)) return []; - try { - return queryRows( - dbPath, - `SELECT s.id AS id, - s.status AS status, - COUNT(t.id) AS total, - SUM(CASE WHEN t.status='complete' THEN 1 ELSE 0 END) AS done - FROM slices s - LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id - WHERE s.milestone_id=? - GROUP BY s.id - ORDER BY s.id`, - [mid], - ).map((row) => ({ - id: row.id, - status: row.status, - total: Number(row.total ?? 0), - done: Number(row.done ?? 0), - })); - } catch { - return []; - } -} function extractCostFromNdjson(basePath, mid) { const stdoutPath = join(basePath, ".sf", "parallel", `${mid}.stdout.log`); if (!existsSync(stdoutPath)) return 0; @@ -143,35 +108,14 @@ function extractCostFromNdjson(basePath, mid) { return 0; } } -function queryRecentCompletions(basePath, mid) { - const dbPath = join(basePath, ".sf", "worktrees", mid, ".sf", "sf.db"); - if (!existsSync(dbPath)) return []; - try { - return queryRows( - dbPath, - `SELECT id AS taskId, - slice_id AS sliceId, - one_liner AS oneLiner - FROM tasks - WHERE milestone_id=? - AND status='complete' - AND completed_at IS NOT NULL - ORDER BY completed_at DESC - LIMIT 5`, - [mid], - ).map( - (row) => - `✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`, - ); - } catch { - return []; - } +function formatCompletionEvent(mid, row) { + return `✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`; } async function collectWorkerData(basePath) { const mids = discoverWorkers(basePath); const parallelDir = join(basePath, ".sf", "parallel"); const allSlices = await Promise.all( - mids.map((mid) => querySliceProgress(basePath, mid)), + mids.map((mid) => queryParallelSliceProgress(basePath, mid)), ); const workers = []; for (let i = 0; i < mids.length; i++) { @@ -309,7 +253,11 @@ export class ParallelMonitorOverlay { this.workers = workers; // Collect completion events in parallel across workers const allCompletions = await Promise.all( - this.workers.map((wk) => queryRecentCompletions(this.basePath, wk.mid)), + this.workers.map((wk) => + queryParallelRecentCompletionRows(this.basePath, wk.mid).map((row) => + formatCompletionEvent(wk.mid, row), + ), + ), ); if (this.disposed) return; for (const completions of allCompletions) { diff --git a/src/resources/extensions/sf/parallel-monitor-store.js b/src/resources/extensions/sf/parallel-monitor-store.js new file mode 100644 index 000000000..b4fdcc622 --- /dev/null +++ b/src/resources/extensions/sf/parallel-monitor-store.js @@ -0,0 +1,108 @@ +/** + * parallel-monitor-store.js — read-only projections for parallel worker monitors. + * + * Purpose: centralize the Node SQLite reads used by the standalone monitor and + * TUI overlay so parallel worker status is rendered from one DB-first contract. + * + * Consumer: `scripts/parallel-monitor.mjs` and `parallel-monitor-overlay.js`. + */ + +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +function queryRows(dbPath, sql, params = []) { + const db = new DatabaseSync(dbPath, { readOnly: true }); + try { + return db + .prepare(sql) + .all(...params) + .map((row) => ({ ...row })); + } finally { + db.close(); + } +} + +/** + * Resolve the canonical DB path for a parallel worker worktree. + * + * Purpose: keep monitor-side DB reads pointed at the worker-owned `.sf/sf.db` + * projection instead of ad hoc sidecar files. + * + * Consumer: monitor projection query helpers in this module. + */ +export function getParallelWorkerDbPath(basePath, milestoneId) { + return join(basePath, ".sf", "worktrees", milestoneId, ".sf", "sf.db"); +} + +/** + * Read slice/task progress for one parallel worker. + * + * Purpose: render worker progress from structured task rows so the script and + * overlay do not drift into separate SQL contracts. + * + * Consumer: parallel monitor script and TUI overlay worker summaries. + */ +export function queryParallelSliceProgress(basePath, milestoneId) { + const dbPath = getParallelWorkerDbPath(basePath, milestoneId); + if (!existsSync(dbPath)) return []; + + try { + return queryRows( + dbPath, + `SELECT s.id AS id, + s.status AS status, + COUNT(t.id) AS total, + SUM(CASE WHEN t.task_status='done' THEN 1 ELSE 0 END) AS done + FROM slices s + LEFT JOIN tasks t ON s.milestone_id=t.milestone_id AND s.id=t.slice_id + WHERE s.milestone_id=? + GROUP BY s.id + ORDER BY s.id`, + [milestoneId], + ).map((row) => ({ + id: row.id, + status: row.status, + total: Number(row.total ?? 0), + done: Number(row.done ?? 0), + })); + } catch { + return []; + } +} + +/** + * Read recently completed task rows for one parallel worker. + * + * Purpose: feed completion events from the same structured state used for + * dispatch instead of scraping monitor logs for finished work. + * + * Consumer: parallel monitor script and TUI overlay event feeds. + */ +export function queryParallelRecentCompletionRows( + basePath, + milestoneId, + limit = 5, +) { + const dbPath = getParallelWorkerDbPath(basePath, milestoneId); + if (!existsSync(dbPath)) return []; + + try { + return queryRows( + dbPath, + `SELECT id AS taskId, + slice_id AS sliceId, + one_liner AS oneLiner, + completed_at AS completedAt + FROM tasks + WHERE milestone_id=? + AND task_status='done' + AND completed_at IS NOT NULL + ORDER BY completed_at DESC + LIMIT ?`, + [milestoneId, limit], + ); + } catch { + return []; + } +} diff --git a/src/resources/extensions/sf/remote-steering.js b/src/resources/extensions/sf/remote-steering.js new file mode 100644 index 000000000..652f4023e --- /dev/null +++ b/src/resources/extensions/sf/remote-steering.js @@ -0,0 +1,188 @@ +/** + * Remote Steering - full-session surface for remote mode changes + * + * Purpose: allow remote questions to steer session mode (workMode, + * runControl, permissionProfile, modelMode) in addition to answering + * questions. This makes remote channels a first-class steering surface, + * not just a question delivery mechanism. + * + * Consumer: remote-questions manager when parsing answers, and + * auto-dispatch when checking for remote steering directives. + */ + +import { getAutoSession } from "./auto/session.js"; +import { + buildModeState, + MODEL_MODES, + PERMISSION_PROFILES, + RUN_CONTROL_MODES, + WORK_MODES, +} from "./operating-model.js"; + +/** + * Parse a remote answer for steering directives. + * Looks for patterns like: + * /mode build + * /control autonomous + * /permission-profile trusted + * /model-mode deep + * + * @param {object} answer - the parsed remote answer object + * @returns {{ steering: boolean, directives: Array<{cmd: string, value: string}> }} + */ +export function parseRemoteSteeringDirectives(answer) { + if (!answer) { + return { steering: false, directives: [] }; + } + + const text = extractAnswerText(answer); + if (!text) { + return { steering: false, directives: [] }; + } + + const directives = []; + const patterns = [ + { + regex: /\/(?:mode|work-mode)\s+([\w-]+)/g, + cmd: "mode", + valid: WORK_MODES, + }, + { + regex: /\/(?:control|run-control)\s+([\w-]+)/g, + cmd: "control", + valid: RUN_CONTROL_MODES, + }, + { + regex: /\/permission-profile\s+([\w-]+)/g, + cmd: "permission-profile", + valid: PERMISSION_PROFILES, + }, + { + regex: /\/model-mode\s+([\w-]+)/g, + cmd: "model-mode", + valid: MODEL_MODES, + }, + ]; + + for (const { regex, cmd, valid } of patterns) { + let match; + while ((match = regex.exec(text)) !== null) { + const value = match[1].toLowerCase(); + if (valid.includes(value)) { + directives.push({ cmd, value }); + } + } + } + + return { + steering: directives.length > 0, + directives, + }; +} + +function extractAnswerText(value) { + const parts = []; + const seen = new WeakSet(); + function visit(node) { + if (typeof node === "string") { + parts.push(node); + return; + } + if (Array.isArray(node)) { + for (const item of node) visit(item); + return; + } + if (!node || typeof node !== "object") return; + if (seen.has(node)) return; + seen.add(node); + const preferredKeys = ["text", "selected", "notes", "user_note", "answers"]; + const visitedKeys = new Set(); + for (const key of preferredKeys) { + if (node[key] !== undefined) { + visitedKeys.add(key); + visit(node[key]); + } + } + for (const [key, child] of Object.entries(node)) { + if (!visitedKeys.has(key)) visit(child); + } + } + visit(value); + return parts.join(" "); +} + +/** + * Apply steering directives to the current session. + * + * @param {Array<{cmd: string, value: string}>} directives + * @returns {Array<{cmd: string, value: string, applied: boolean, error?: string}>} + */ +export function applyRemoteSteeringDirectives(directives) { + const session = getAutoSession(); + const results = []; + + for (const { cmd, value } of directives) { + try { + switch (cmd) { + case "mode": + session.setMode({ workMode: value, reason: "remote-steering" }); + break; + case "control": + session.setMode({ runControl: value, reason: "remote-steering" }); + break; + case "permission-profile": + session.setMode({ + permissionProfile: value, + reason: "remote-steering", + }); + break; + case "model-mode": + session.setMode({ modelMode: value, reason: "remote-steering" }); + break; + default: + results.push({ + cmd, + value, + applied: false, + error: "unknown command", + }); + continue; + } + results.push({ cmd, value, applied: true }); + } catch (err) { + results.push({ + cmd, + value, + applied: false, + error: err.message, + }); + } + } + + return results; +} + +/** + * Format steering results for remote display. + * + * @param {Array} results - from applyRemoteSteeringDirectives + * @returns {string} + */ +export function formatRemoteSteeringResults(results) { + const lines = ["SF Mode Steering"]; + for (const r of results) { + const marker = r.applied ? "[ok]" : "[blocked]"; + lines.push(` ${marker} /${r.cmd} ${r.value}`); + if (r.error) lines.push(` Error: ${r.error}`); + } + let mode = buildModeState(); + try { + mode = getAutoSession().getMode(); + } catch { + // Formatting still has value in tests and detached remote contexts. + } + lines.push( + `\nCurrent: ${mode.workMode} | ${mode.runControl} | ${mode.permissionProfile} | ${mode.modelMode}`, + ); + return lines.join("\n"); +} diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index 6ef676b7f..ceea87a12 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -23,6 +23,12 @@ import { dirname } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { SF_STALE_STATE, SFError } from "./errors.js"; import { getGateIdsForTurn } from "./gate-registry.js"; +import { + normalizeSchedulerStatus, + normalizeTaskStatus, + taskFrontmatterFromRecord, + withTaskFrontmatter, +} from "./task-frontmatter.js"; import { logError, logWarning } from "./workflow-logger.js"; let loadAttempted = false; @@ -78,7 +84,7 @@ function openRawDb(path) { loadProvider(); return new DatabaseSync(path); } -const SCHEMA_VERSION = 43; +const SCHEMA_VERSION = 45; function indexExists(db, name) { return !!db .prepare( @@ -418,6 +424,16 @@ function ensureSpecSchemaTables(db) { verify TEXT NOT NULL DEFAULT '', inputs TEXT DEFAULT '', expected_output TEXT DEFAULT '', + risk TEXT NOT NULL DEFAULT 'low', + mutation_scope TEXT NOT NULL DEFAULT 'isolated', + verification_type TEXT NOT NULL DEFAULT 'self-check', + plan_approval TEXT NOT NULL DEFAULT 'not-required', + estimated_effort INTEGER DEFAULT NULL, + dependencies TEXT NOT NULL DEFAULT '[]', + blocks_parallel INTEGER NOT NULL DEFAULT 0, + requires_user_input INTEGER NOT NULL DEFAULT 0, + auto_retry INTEGER NOT NULL DEFAULT 1, + max_retries INTEGER NOT NULL DEFAULT 2, spec_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, PRIMARY KEY (milestone_id, slice_id, task_id), @@ -722,6 +738,17 @@ function initSchema(db, fileBacked) { full_plan_md TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT '', verification_status TEXT NOT NULL DEFAULT '', + risk TEXT NOT NULL DEFAULT 'low', + mutation_scope TEXT NOT NULL DEFAULT 'isolated', + verification_type TEXT NOT NULL DEFAULT 'self-check', + plan_approval TEXT NOT NULL DEFAULT 'not-required', + task_status TEXT NOT NULL DEFAULT 'todo', + estimated_effort INTEGER DEFAULT NULL, + dependencies TEXT NOT NULL DEFAULT '[]', + blocks_parallel INTEGER NOT NULL DEFAULT 0, + requires_user_input INTEGER NOT NULL DEFAULT 0, + auto_retry INTEGER NOT NULL DEFAULT 1, + max_retries INTEGER NOT NULL DEFAULT 2, sequence INTEGER DEFAULT 0, -- Ordering hint: tools may set this to control execution order escalation_pending INTEGER NOT NULL DEFAULT 0, -- ADR-011 P2 (SF): pause-on-escalation flag escalation_awaiting_review INTEGER NOT NULL DEFAULT 0, -- ADR-011 P2 (SF): continueWithDefault=true marker (no pause) @@ -731,6 +758,7 @@ function initSchema(db, fileBacked) { FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) ) `); + ensureTaskSchedulerTable(db); if (columnExists(db, "tasks", "escalation_pending")) { db.exec(` CREATE INDEX IF NOT EXISTS idx_tasks_escalation_pending ON tasks(milestone_id, slice_id, escalation_pending) @@ -982,6 +1010,7 @@ function initSchema(db, fileBacked) { ensureHeadlessRunTables(db); ensureUokMessageTables(db); ensureSpecSchemaTables(db); + ensureTaskFrontmatterColumns(db); ensureRetrievalEvidenceTables(db); db.exec( `CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`, @@ -1068,6 +1097,158 @@ function ensureTaskCreatedAtColumn(db) { `ALTER TABLE tasks ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`, ); } +function ensureTaskFrontmatterColumns(db) { + ensureColumn( + db, + "tasks", + "risk", + `ALTER TABLE tasks ADD COLUMN risk TEXT NOT NULL DEFAULT 'low'`, + ); + ensureColumn( + db, + "tasks", + "mutation_scope", + `ALTER TABLE tasks ADD COLUMN mutation_scope TEXT NOT NULL DEFAULT 'isolated'`, + ); + ensureColumn( + db, + "tasks", + "verification_type", + `ALTER TABLE tasks ADD COLUMN verification_type TEXT NOT NULL DEFAULT 'self-check'`, + ); + ensureColumn( + db, + "tasks", + "plan_approval", + `ALTER TABLE tasks ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'not-required'`, + ); + ensureColumn( + db, + "tasks", + "task_status", + `ALTER TABLE tasks ADD COLUMN task_status TEXT NOT NULL DEFAULT 'todo'`, + ); + ensureColumn( + db, + "tasks", + "estimated_effort", + `ALTER TABLE tasks ADD COLUMN estimated_effort INTEGER DEFAULT NULL`, + ); + ensureColumn( + db, + "tasks", + "dependencies", + `ALTER TABLE tasks ADD COLUMN dependencies TEXT NOT NULL DEFAULT '[]'`, + ); + ensureColumn( + db, + "tasks", + "blocks_parallel", + `ALTER TABLE tasks ADD COLUMN blocks_parallel INTEGER NOT NULL DEFAULT 0`, + ); + ensureColumn( + db, + "tasks", + "requires_user_input", + `ALTER TABLE tasks ADD COLUMN requires_user_input INTEGER NOT NULL DEFAULT 0`, + ); + ensureColumn( + db, + "tasks", + "auto_retry", + `ALTER TABLE tasks ADD COLUMN auto_retry INTEGER NOT NULL DEFAULT 1`, + ); + ensureColumn( + db, + "tasks", + "max_retries", + `ALTER TABLE tasks ADD COLUMN max_retries INTEGER NOT NULL DEFAULT 2`, + ); + for (const table of ["task_specs"]) { + ensureColumn( + db, + table, + "risk", + `ALTER TABLE ${table} ADD COLUMN risk TEXT NOT NULL DEFAULT 'low'`, + ); + ensureColumn( + db, + table, + "mutation_scope", + `ALTER TABLE ${table} ADD COLUMN mutation_scope TEXT NOT NULL DEFAULT 'isolated'`, + ); + ensureColumn( + db, + table, + "verification_type", + `ALTER TABLE ${table} ADD COLUMN verification_type TEXT NOT NULL DEFAULT 'self-check'`, + ); + ensureColumn( + db, + table, + "plan_approval", + `ALTER TABLE ${table} ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'not-required'`, + ); + ensureColumn( + db, + table, + "estimated_effort", + `ALTER TABLE ${table} ADD COLUMN estimated_effort INTEGER DEFAULT NULL`, + ); + ensureColumn( + db, + table, + "dependencies", + `ALTER TABLE ${table} ADD COLUMN dependencies TEXT NOT NULL DEFAULT '[]'`, + ); + ensureColumn( + db, + table, + "blocks_parallel", + `ALTER TABLE ${table} ADD COLUMN blocks_parallel INTEGER NOT NULL DEFAULT 0`, + ); + ensureColumn( + db, + table, + "requires_user_input", + `ALTER TABLE ${table} ADD COLUMN requires_user_input INTEGER NOT NULL DEFAULT 0`, + ); + ensureColumn( + db, + table, + "auto_retry", + `ALTER TABLE ${table} ADD COLUMN auto_retry INTEGER NOT NULL DEFAULT 1`, + ); + ensureColumn( + db, + table, + "max_retries", + `ALTER TABLE ${table} ADD COLUMN max_retries INTEGER NOT NULL DEFAULT 2`, + ); + } +} +function ensureTaskSchedulerTable(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS task_scheduler ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + task_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + due_at TEXT DEFAULT NULL, + claimed_by TEXT DEFAULT NULL, + dispatched_at TEXT DEFAULT NULL, + consumed_at TEXT DEFAULT NULL, + expires_at TEXT DEFAULT NULL, + updated_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (milestone_id, slice_id, task_id), + FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id) + ) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_task_scheduler_status + ON task_scheduler(status, due_at) + `); +} function migrateCostUsdToMicroUsd(db) { // Tier 2.7: Migrate cost_usd REAL to cost_micro_usd INTEGER // Converts floating-point USD values to integer micro-USD (multiply by 1,000,000) @@ -2244,6 +2425,89 @@ function migrateSchema(db) { ":applied_at": new Date().toISOString(), }); } + if (currentVersion < 44) { + ensureSpecSchemaTables(db); + ensureTaskFrontmatterColumns(db); + db.exec(` + UPDATE tasks + SET task_status = CASE status + WHEN 'complete' THEN 'done' + WHEN 'completed' THEN 'done' + WHEN 'done' THEN 'done' + WHEN 'running' THEN 'running' + WHEN 'in_progress' THEN 'running' + WHEN 'blocked' THEN 'blocked' + WHEN 'failed' THEN 'failed' + WHEN 'cancelled' THEN 'cancelled' + ELSE COALESCE(NULLIF(task_status, ''), 'todo') + END + `); + db.exec(` + UPDATE task_specs + SET risk = COALESCE((SELECT tasks.risk FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), risk), + mutation_scope = COALESCE((SELECT tasks.mutation_scope FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), mutation_scope), + verification_type = COALESCE((SELECT tasks.verification_type FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), verification_type), + plan_approval = COALESCE((SELECT tasks.plan_approval FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), plan_approval), + estimated_effort = COALESCE((SELECT tasks.estimated_effort FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), estimated_effort), + dependencies = COALESCE((SELECT tasks.dependencies FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), dependencies), + blocks_parallel = COALESCE((SELECT tasks.blocks_parallel FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), blocks_parallel), + requires_user_input = COALESCE((SELECT tasks.requires_user_input FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), requires_user_input), + auto_retry = COALESCE((SELECT tasks.auto_retry FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), auto_retry), + max_retries = COALESCE((SELECT tasks.max_retries FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), max_retries) + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 44, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 45) { + ensureTaskSchedulerTable(db); + db.exec(` + INSERT OR IGNORE INTO task_scheduler ( + milestone_id, slice_id, task_id, status, updated_at + ) + SELECT milestone_id, slice_id, id, 'queued', datetime('now') + FROM tasks + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 45, + ":applied_at": new Date().toISOString(), + }); + } db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -3070,12 +3334,12 @@ export function insertTask(t) { if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); currentDb .prepare(`INSERT INTO tasks ( - milestone_id, slice_id, id, title, status, one_liner, narrative, + milestone_id, slice_id, id, title, status, task_status, one_liner, narrative, verification_result, verification_status, duration, completed_at, blocker_discovered, deviations, known_issues, key_files, key_decisions, full_summary_md, description, estimate, files, verify, inputs, expected_output, observability_impact, sequence ) VALUES ( - :milestone_id, :slice_id, :id, :title, :status, :one_liner, :narrative, + :milestone_id, :slice_id, :id, :title, :status, :task_status, :one_liner, :narrative, :verification_result, :verification_status, :duration, :completed_at, :blocker_discovered, :deviations, :known_issues, :key_files, :key_decisions, :full_summary_md, :description, :estimate, :files, :verify, :inputs, :expected_output, :observability_impact, :sequence @@ -3083,6 +3347,7 @@ export function insertTask(t) { ON CONFLICT(milestone_id, slice_id, id) DO UPDATE SET title = CASE WHEN NULLIF(:title, '') IS NOT NULL THEN :title ELSE tasks.title END, status = :status, + task_status = :task_status, one_liner = :one_liner, narrative = :narrative, verification_result = :verification_result, @@ -3109,6 +3374,7 @@ export function insertTask(t) { ":id": t.id, ":title": t.title ?? "", ":status": t.status ?? "pending", + ":task_status": normalizeTaskStatus(t.taskStatus ?? t.status) ?? "todo", ":one_liner": t.oneLiner ?? "", ":narrative": t.narrative ?? "", ":verification_result": t.verificationResult ?? "", @@ -3133,15 +3399,60 @@ export function insertTask(t) { ":observability_impact": t.planning?.observabilityImpact ?? "", ":sequence": t.sequence ?? 0, }); - insertTaskSpecIfAbsent(t.milestoneId, t.sliceId, t.id, t.planning ?? {}); + if (hasTaskSpecIntent(t.planning)) { + insertTaskSpecIfAbsent(t.milestoneId, t.sliceId, t.id, t.planning ?? {}); + } + insertTaskSchedulerIfAbsent(t.milestoneId, t.sliceId, t.id); +} +function hasTaskSpecIntent(planning = {}) { + if (!planning || typeof planning !== "object") return false; + if (typeof planning.verify === "string" && planning.verify.trim()) + return true; + if (Array.isArray(planning.inputs) && planning.inputs.length > 0) return true; + if ( + Array.isArray(planning.expectedOutput) && + planning.expectedOutput.length > 0 + ) { + return true; + } + for (const key of [ + "risk", + "mutationScope", + "mutation_scope", + "verification", + "verificationType", + "verification_type", + "planApproval", + "plan_approval", + "estimatedEffort", + "estimated_effort", + "dependencies", + "blocksParallel", + "blocks_parallel", + "requiresUserInput", + "requires_user_input", + "autoRetry", + "auto_retry", + "maxRetries", + "max_retries", + ]) { + if (planning[key] !== undefined) return true; + } + return false; } function insertTaskSpecIfAbsent(milestoneId, sliceId, taskId, planning = {}) { + if (!hasTaskSpecIntent(planning)) return; + const frontmatter = taskFrontmatterFromRecord(planning).normalized; currentDb .prepare(`INSERT OR IGNORE INTO task_specs ( milestone_id, slice_id, task_id, verify, inputs, expected_output, + risk, mutation_scope, verification_type, plan_approval, estimated_effort, + dependencies, blocks_parallel, requires_user_input, auto_retry, max_retries, spec_version, created_at ) VALUES ( :milestone_id, :slice_id, :task_id, :verify, :inputs, :expected_output, + :risk, :mutation_scope, :verification_type, :plan_approval, :estimated_effort, + :dependencies, :blocks_parallel, :requires_user_input, :auto_retry, :max_retries, 1, :created_at )`) .run({ @@ -3151,9 +3462,63 @@ function insertTaskSpecIfAbsent(milestoneId, sliceId, taskId, planning = {}) { ":verify": planning.verify ?? "", ":inputs": JSON.stringify(planning.inputs ?? []), ":expected_output": JSON.stringify(planning.expectedOutput ?? []), + ":risk": frontmatter.risk, + ":mutation_scope": frontmatter.mutationScope, + ":verification_type": frontmatter.verification, + ":plan_approval": frontmatter.planApproval, + ":estimated_effort": frontmatter.estimatedEffort, + ":dependencies": JSON.stringify(frontmatter.dependencies), + ":blocks_parallel": frontmatter.blocksParallel ? 1 : 0, + ":requires_user_input": frontmatter.requiresUserInput ? 1 : 0, + ":auto_retry": frontmatter.autoRetry ? 1 : 0, + ":max_retries": frontmatter.maxRetries, ":created_at": new Date().toISOString(), }); } +function insertTaskSchedulerIfAbsent(milestoneId, sliceId, taskId) { + upsertTaskSchedulerStatus(milestoneId, sliceId, taskId, "queued", { + onlyIfAbsent: true, + }); +} +/** + * Upsert a task scheduler row without changing the task lifecycle row. + * + * Purpose: keep due/claimed/dispatched/consumed scheduling separate from + * task_status so automation level and timing do not overwrite work progress. + * + * Consumer: task scheduling/dispatch surfaces and task planning row creation. + */ +export function upsertTaskSchedulerStatus( + milestoneId, + sliceId, + taskId, + status = "queued", + { onlyIfAbsent = false } = {}, +) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + const schedulerStatus = normalizeSchedulerStatus(status) ?? "queued"; + const sql = onlyIfAbsent + ? `INSERT OR IGNORE INTO task_scheduler ( + milestone_id, slice_id, task_id, status, updated_at + ) VALUES ( + :milestone_id, :slice_id, :task_id, :status, :updated_at + )` + : `INSERT INTO task_scheduler ( + milestone_id, slice_id, task_id, status, updated_at + ) VALUES ( + :milestone_id, :slice_id, :task_id, :status, :updated_at + ) + ON CONFLICT(milestone_id, slice_id, task_id) DO UPDATE SET + status = excluded.status, + updated_at = excluded.updated_at`; + currentDb.prepare(sql).run({ + ":milestone_id": milestoneId, + ":slice_id": sliceId, + ":task_id": taskId, + ":status": schedulerStatus, + ":updated_at": new Date().toISOString(), + }); +} export function updateTaskStatus( milestoneId, sliceId, @@ -3162,12 +3527,17 @@ export function updateTaskStatus( completedAt, ) { if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + const taskStatus = normalizeTaskStatus(status) ?? "todo"; currentDb - .prepare(`UPDATE tasks SET status = :status, completed_at = :completed_at + .prepare(`UPDATE tasks SET + status = :status, + completed_at = :completed_at, + task_status = :task_status WHERE milestone_id = :milestone_id AND slice_id = :slice_id AND id = :id`) .run({ ":status": status, ":completed_at": completedAt ?? null, + ":task_status": taskStatus, ":milestone_id": milestoneId, ":slice_id": sliceId, ":id": taskId, @@ -3293,6 +3663,11 @@ export function setTaskBlockerDiscovered( export function upsertTaskPlanning(milestoneId, sliceId, taskId, planning) { if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); insertTaskSpecIfAbsent(milestoneId, sliceId, taskId, planning); + const frontmatter = taskFrontmatterFromRecord(planning).normalized; + const hasTaskStatus = + planning.taskStatus !== undefined || + planning.task_status !== undefined || + planning.status !== undefined; currentDb .prepare(`UPDATE tasks SET title = COALESCE(:title, title), @@ -3303,7 +3678,18 @@ export function upsertTaskPlanning(milestoneId, sliceId, taskId, planning) { inputs = COALESCE(:inputs, inputs), expected_output = COALESCE(:expected_output, expected_output), observability_impact = COALESCE(:observability_impact, observability_impact), - full_plan_md = COALESCE(:full_plan_md, full_plan_md) + full_plan_md = COALESCE(:full_plan_md, full_plan_md), + risk = :risk, + mutation_scope = :mutation_scope, + verification_type = :verification_type, + plan_approval = :plan_approval, + task_status = CASE WHEN :has_task_status = 1 THEN :task_status ELSE task_status END, + estimated_effort = :estimated_effort, + dependencies = :dependencies, + blocks_parallel = :blocks_parallel, + requires_user_input = :requires_user_input, + auto_retry = :auto_retry, + max_retries = :max_retries WHERE milestone_id = :milestone_id AND slice_id = :slice_id AND id = :id`) .run({ ":milestone_id": milestoneId, @@ -3320,7 +3706,32 @@ export function upsertTaskPlanning(milestoneId, sliceId, taskId, planning) { : null, ":observability_impact": planning.observabilityImpact ?? null, ":full_plan_md": planning.fullPlanMd ?? null, + ":risk": frontmatter.risk, + ":mutation_scope": frontmatter.mutationScope, + ":verification_type": frontmatter.verification, + ":plan_approval": frontmatter.planApproval, + ":task_status": frontmatter.taskStatus, + ":has_task_status": hasTaskStatus ? 1 : 0, + ":estimated_effort": frontmatter.estimatedEffort, + ":dependencies": JSON.stringify(frontmatter.dependencies), + ":blocks_parallel": frontmatter.blocksParallel ? 1 : 0, + ":requires_user_input": frontmatter.requiresUserInput ? 1 : 0, + ":auto_retry": frontmatter.autoRetry ? 1 : 0, + ":max_retries": frontmatter.maxRetries, }); + if ( + planning.schedulerStatus !== undefined || + planning.scheduler_status !== undefined + ) { + upsertTaskSchedulerStatus( + milestoneId, + sliceId, + taskId, + frontmatter.schedulerStatus, + ); + } else { + insertTaskSchedulerIfAbsent(milestoneId, sliceId, taskId); + } } function parsePlanningMeeting(raw) { if (typeof raw !== "string" || raw.trim() === "") return null; @@ -3447,7 +3858,7 @@ function rowToTask(row) { .map((entry) => entry.trim()) .filter(Boolean); }; - return { + return withTaskFrontmatter({ milestone_id: row["milestone_id"], slice_id: row["slice_id"], id: row["id"], @@ -3474,17 +3885,35 @@ function rowToTask(row) { full_plan_md: row["full_plan_md"] ?? "", sequence: row["sequence"] ?? 0, verification_status: row["verification_status"] ?? "", + risk: row["risk"] ?? "low", + mutation_scope: row["mutation_scope"] ?? "isolated", + verification_type: row["verification_type"] ?? "self-check", + plan_approval: row["plan_approval"] ?? "not-required", + task_status: row["task_status"] ?? row["status"] ?? "todo", + scheduler_status: row["scheduler_status"] ?? "queued", + estimated_effort: row["estimated_effort"] ?? null, + dependencies: parseTaskArray(row["dependencies"]), + blocks_parallel: row["blocks_parallel"] ?? 0, + requires_user_input: row["requires_user_input"] ?? 0, + auto_retry: row["auto_retry"] ?? 1, + max_retries: row["max_retries"] ?? 2, escalation_pending: row["escalation_pending"] ?? 0, escalation_awaiting_review: row["escalation_awaiting_review"] ?? 0, escalation_override_applied: row["escalation_override_applied"] ?? 0, escalation_artifact_path: row["escalation_artifact_path"] ?? null, - }; + }); } export function getTask(milestoneId, sliceId, taskId) { if (!currentDb) return null; const row = currentDb .prepare( - "SELECT * FROM tasks WHERE milestone_id = :mid AND slice_id = :sid AND id = :tid", + `SELECT t.*, ts.status AS scheduler_status + FROM tasks t + LEFT JOIN task_scheduler ts + ON t.milestone_id = ts.milestone_id + AND t.slice_id = ts.slice_id + AND t.id = ts.task_id + WHERE t.milestone_id = :mid AND t.slice_id = :sid AND t.id = :tid`, ) .get({ ":mid": milestoneId, ":sid": sliceId, ":tid": taskId }); if (!row) return null; @@ -3494,7 +3923,14 @@ export function getSliceTasks(milestoneId, sliceId) { if (!currentDb) return []; const rows = currentDb .prepare( - "SELECT * FROM tasks WHERE milestone_id = :mid AND slice_id = :sid ORDER BY sequence, id", + `SELECT t.*, ts.status AS scheduler_status + FROM tasks t + LEFT JOIN task_scheduler ts + ON t.milestone_id = ts.milestone_id + AND t.slice_id = ts.slice_id + AND t.id = ts.task_id + WHERE t.milestone_id = :mid AND t.slice_id = :sid + ORDER BY t.sequence, t.id`, ) .all({ ":mid": milestoneId, ":sid": sliceId }); return rows.map(rowToTask); diff --git a/src/resources/extensions/sf/subagent-inheritance.js b/src/resources/extensions/sf/subagent-inheritance.js new file mode 100644 index 000000000..c01d9b635 --- /dev/null +++ b/src/resources/extensions/sf/subagent-inheritance.js @@ -0,0 +1,170 @@ +/** + * subagent-inheritance.js — parent mode envelope for subagent dispatch. + * + * Purpose: keep delegated agents inside the parent's provider, model-mode, and + * permission-profile constraints so swarms cannot silently widen authority. + * + * Consumer: `subagent` extension before it starts worker processes. + */ + +import { getAutoSession } from "./auto/session.js"; +import { + resolveModelMode, + resolvePermissionProfile, + resolveRunControlMode, + resolveWorkMode, +} from "./operating-model.js"; +import { isProviderAllowedByLists } from "./preferences-models.js"; + +function providerFromModelId(modelId) { + if (!modelId || typeof modelId !== "string") return null; + const [provider] = modelId.split("/", 1); + return provider && provider !== modelId ? provider : null; +} + +function isHeavyModelId(modelId) { + if (!modelId || typeof modelId !== "string") return false; + const normalized = modelId.toLowerCase(); + return [ + "opus", + "o1-", + "gpt-4-turbo", + "gpt-5", + "claude-3-opus", + "deepseek-reasoner", + ].some((indicator) => normalized.includes(indicator)); +} + +/** + * Build an inheritance envelope from the current parent session. + * + * Purpose: capture orthogonal mode axes and provider policy once, then pass the + * same envelope through validation and child-process environment. + * + * Consumer: `subagent` tool execution. + */ +export function buildSubagentInheritanceEnvelope({ + mode, + preferences = {}, + surface, +} = {}) { + const session = getAutoSession(); + const sessionMode = mode ?? session.getMode?.() ?? {}; + + return { + workMode: resolveWorkMode(sessionMode.workMode), + modelMode: resolveModelMode(sessionMode.modelMode), + permissionProfile: resolvePermissionProfile(sessionMode.permissionProfile), + runControl: resolveRunControlMode(sessionMode.runControl), + surface: surface ?? sessionMode.surface ?? "tui", + allowedProviders: preferences.allowed_providers ?? null, + blockedProviders: preferences.blocked_providers ?? null, + }; +} + +/** + * Validate a proposed subagent dispatch against the parent envelope. + * + * Purpose: reject delegated work that would bypass parent provider allowlists, + * fast-mode routing posture, or restricted mutation rules. + * + * Consumer: `subagent` tool before single, chain, parallel, debate, or + * background dispatch. + */ +export function validateSubagentDispatch(envelope, proposal) { + const modelId = proposal.model ?? null; + const provider = proposal.provider ?? providerFromModelId(modelId); + + if ( + provider && + !isProviderAllowedByLists( + provider, + envelope.allowedProviders, + envelope.blockedProviders, + ) + ) { + return { + ok: false, + reason: `Provider "${provider}" is blocked by parent provider policy`, + }; + } + + if (envelope.modelMode === "fast" && isHeavyModelId(modelId)) { + return { + ok: false, + reason: `Model mode "fast" blocks heavy subagent model "${modelId}"`, + }; + } + + if (envelope.permissionProfile === "restricted") { + const proposedTools = proposal.tools ?? []; + const blocked = proposedTools.filter((toolName) => + ["write", "edit", "bash", "mac_launch_app"].some((restrictedTool) => + toolName.toLowerCase().includes(restrictedTool), + ), + ); + if (blocked.length > 0) { + return { + ok: false, + reason: `Permission profile "restricted" blocks subagent tools: ${blocked.join(", ")}`, + }; + } + } + + return { ok: true }; +} + +/** + * Return only the environment keys that carry parent inheritance. + * + * Purpose: let direct spawn and generated cmux launchers propagate the same + * constraint envelope without copying the full parent environment. + * + * Consumer: `resolveSubagentLaunchSpec`. + */ +function buildSubagentInheritanceEnvPatch(envelope) { + if (!envelope) return {}; + return { + SF_PARENT_WORK_MODE: envelope.workMode, + SF_PARENT_MODEL_MODE: envelope.modelMode, + SF_PARENT_PERMISSION_PROFILE: envelope.permissionProfile, + SF_PARENT_RUN_CONTROL: envelope.runControl, + SF_PARENT_SURFACE: envelope.surface, + }; +} + +/** + * Apply parent inheritance keys to a process environment. + * + * Purpose: make spawned subagents aware of the parent posture for downstream + * enforcement and diagnostics. + * + * Consumer: direct subagent process launch. + */ +export function applyInheritanceToEnv(envelope, env = process.env) { + return { + ...env, + ...buildSubagentInheritanceEnvPatch(envelope), + }; +} + +/** + * Read parent inheritance from a child-process environment. + * + * Purpose: let subagent startup and diagnostics recover the parent posture + * without reading parent-only session state. + * + * Consumer: subagent child process startup paths. + */ +export function readParentInheritanceFromEnv(env = process.env) { + if (!env.SF_PARENT_WORK_MODE) return null; + return { + workMode: resolveWorkMode(env.SF_PARENT_WORK_MODE), + modelMode: resolveModelMode(env.SF_PARENT_MODEL_MODE), + permissionProfile: resolvePermissionProfile( + env.SF_PARENT_PERMISSION_PROFILE, + ), + runControl: resolveRunControlMode(env.SF_PARENT_RUN_CONTROL), + surface: env.SF_PARENT_SURFACE ?? "tui", + }; +} diff --git a/src/resources/extensions/sf/task-frontmatter.js b/src/resources/extensions/sf/task-frontmatter.js new file mode 100644 index 000000000..11c58ae8b --- /dev/null +++ b/src/resources/extensions/sf/task-frontmatter.js @@ -0,0 +1,356 @@ +/** + * Task Frontmatter - schema-backed task metadata + * + * Purpose: add structured fields to task records for risk assessment, + * mutation scope declaration, verification requirements, plan approval, and + * task lifecycle status while keeping scheduler status as a separate view field. + * + * Consumer: plan-v2 task creation, UOK gate runner, parallel orchestrator, + * sf-db row mapping, and task state machine. + */ + +export const RISK_LEVELS = ["none", "low", "medium", "high", "critical"]; + +export const MUTATION_SCOPES = [ + "none", + "docs-only", + "config", + "test-only", + "isolated", + "bounded", + "cross-cutting", + "systemic", +]; + +export const VERIFICATION_TYPES = [ + "none", + "self-check", + "review", + "test", + "integration", + "manual-qa", +]; + +export const PLAN_APPROVAL_STATES = [ + "not-required", + "pending", + "approved", + "rejected", + "auto-approved", +]; + +export const TASK_STATUSES = [ + "todo", + "running", + "verifying", + "reviewing", + "done", + "blocked", + "paused", + "failed", + "cancelled", + "retrying", +]; + +export const SCHEDULER_STATUSES = [ + "queued", + "due", + "claimed", + "dispatched", + "consumed", + "expired", +]; + +const TASK_STATUS_ALIASES = { + complete: "done", + completed: "done", + in_progress: "running", + "manual-attention": "reviewing", + manual_attention: "reviewing", + pending: "todo", + review: "reviewing", +}; + +const SCHEDULER_STATUS_ALIASES = { + completed: "consumed", + done: "consumed", + pending: "queued", +}; + +export const DEFAULT_TASK_FRONTMATTER = { + risk: "low", + mutationScope: "isolated", + verification: "self-check", + planApproval: "not-required", + taskStatus: "todo", + schedulerStatus: "queued", + estimatedEffort: null, + keyFiles: [], + dependencies: [], + blocksParallel: false, + requiresUserInput: false, + autoRetry: true, + maxRetries: 2, +}; + +export function normalizeTaskStatus(value) { + if (typeof value !== "string" || value.trim() === "") return "todo"; + const status = value.trim().toLowerCase(); + if (TASK_STATUSES.includes(status)) return status; + return TASK_STATUS_ALIASES[status] ?? null; +} + +export function normalizeSchedulerStatus(value) { + if (typeof value !== "string" || value.trim() === "") return "queued"; + const status = value.trim().toLowerCase(); + if (SCHEDULER_STATUSES.includes(status)) return status; + return SCHEDULER_STATUS_ALIASES[status] ?? null; +} + +function normalizeArray(value) { + if (Array.isArray(value)) return value.filter((v) => typeof v === "string"); + if (typeof value !== "string" || value.trim() === "") return []; + try { + return normalizeArray(JSON.parse(value)); + } catch { + return value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + } +} + +function normalizeBoolean(value) { + if (value === true || value === 1) return true; + if (value === false || value === 0 || value == null) return false; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "y"].includes(normalized)) return true; + if (["0", "false", "no", "n", ""].includes(normalized)) return false; + } + return Boolean(value); +} + +function validateChoice(field, value, allowed, normalized, errors) { + if (value === undefined || value === null || value === "") return; + if (allowed.includes(value)) { + normalized[field] = value; + return; + } + errors.push( + `Invalid ${field} "${value}". Must be one of: ${allowed.join(", ")}`, + ); +} + +export function validateTaskFrontmatter(frontmatter = {}) { + const errors = []; + const normalized = { + ...DEFAULT_TASK_FRONTMATTER, + keyFiles: [], + dependencies: [], + }; + + validateChoice("risk", frontmatter.risk, RISK_LEVELS, normalized, errors); + validateChoice( + "mutationScope", + frontmatter.mutationScope, + MUTATION_SCOPES, + normalized, + errors, + ); + validateChoice( + "verification", + frontmatter.verification, + VERIFICATION_TYPES, + normalized, + errors, + ); + validateChoice( + "planApproval", + frontmatter.planApproval, + PLAN_APPROVAL_STATES, + normalized, + errors, + ); + + if (frontmatter.taskStatus !== undefined) { + const status = normalizeTaskStatus(frontmatter.taskStatus); + if (status) { + normalized.taskStatus = status; + } else { + errors.push( + `Invalid taskStatus "${frontmatter.taskStatus}". Must be one of: ${TASK_STATUSES.join(", ")}`, + ); + } + } + + if (frontmatter.schedulerStatus !== undefined) { + const status = normalizeSchedulerStatus(frontmatter.schedulerStatus); + if (status) { + normalized.schedulerStatus = status; + } else { + errors.push( + `Invalid schedulerStatus "${frontmatter.schedulerStatus}". Must be one of: ${SCHEDULER_STATUSES.join(", ")}`, + ); + } + } + + if (frontmatter.estimatedEffort !== undefined) { + const effort = Number(frontmatter.estimatedEffort); + if (!Number.isNaN(effort) && effort >= 0) { + normalized.estimatedEffort = effort; + } else if (frontmatter.estimatedEffort !== null) { + errors.push( + `Invalid estimatedEffort "${frontmatter.estimatedEffort}". Must be a non-negative number or null.`, + ); + } + } + + if (frontmatter.keyFiles !== undefined) { + normalized.keyFiles = normalizeArray(frontmatter.keyFiles); + } + if (frontmatter.dependencies !== undefined) { + normalized.dependencies = normalizeArray(frontmatter.dependencies); + } + + for (const field of ["blocksParallel", "requiresUserInput", "autoRetry"]) { + if (frontmatter[field] !== undefined) { + normalized[field] = normalizeBoolean(frontmatter[field]); + } + } + + if (frontmatter.maxRetries !== undefined) { + const retries = Number(frontmatter.maxRetries); + if (Number.isInteger(retries) && retries >= 0 && retries <= 10) { + normalized.maxRetries = retries; + } else { + errors.push( + `Invalid maxRetries "${frontmatter.maxRetries}". Must be an integer 0-10.`, + ); + } + } + + return { + valid: errors.length === 0, + errors, + normalized, + }; +} + +export function taskFrontmatterFromRecord(task = {}, overrides = {}) { + const rawFrontmatter = { + risk: task.risk, + mutationScope: task.mutation_scope ?? task.mutationScope, + verification: + task.verification_type ?? task.verificationType ?? task.verification, + planApproval: task.plan_approval ?? task.planApproval, + taskStatus: task.task_status ?? task.taskStatus ?? task.status, + schedulerStatus: task.scheduler_status ?? task.schedulerStatus, + estimatedEffort: task.estimated_effort ?? task.estimatedEffort, + keyFiles: + task.frontmatter_key_files ?? + task.frontmatterKeyFiles ?? + task.files ?? + task.key_files ?? + task.keyFiles ?? + [], + dependencies: + task.dependencies ?? + task.depends_on ?? + task.dependsOn ?? + task.depends ?? + [], + blocksParallel: task.blocks_parallel ?? task.blocksParallel, + requiresUserInput: task.requires_user_input ?? task.requiresUserInput, + autoRetry: task.auto_retry ?? task.autoRetry, + maxRetries: task.max_retries ?? task.maxRetries, + ...overrides, + }; + + return validateTaskFrontmatter(rawFrontmatter); +} + +export function buildTaskRecord(task = {}, overrides = {}) { + const validation = taskFrontmatterFromRecord(task, overrides); + return { + ...task, + frontmatter: validation.normalized, + frontmatterValid: validation.valid, + frontmatterErrors: validation.errors, + }; +} + +export function withTaskFrontmatter(task = {}, overrides = {}) { + return buildTaskRecord(task, overrides); +} + +export function canRunInParallel(taskA, taskB) { + const fmA = taskA.frontmatter ?? buildTaskRecord(taskA).frontmatter; + const fmB = taskB.frontmatter ?? buildTaskRecord(taskB).frontmatter; + + if (fmA.blocksParallel || fmB.blocksParallel) { + return { + canParallel: false, + reason: "One or both tasks block parallel execution", + }; + } + + if (fmA.mutationScope === "systemic" || fmB.mutationScope === "systemic") { + return { + canParallel: false, + reason: "One or both tasks have systemic mutation scope", + }; + } + + const highRisk = ["high", "critical"]; + if (highRisk.includes(fmA.risk) && highRisk.includes(fmB.risk)) { + return { canParallel: false, reason: "Both tasks are high/critical risk" }; + } + + if (fmA.keyFiles.length > 0 && fmB.keyFiles.length > 0) { + const filesB = new Set(fmB.keyFiles); + const overlap = fmA.keyFiles.filter((file) => filesB.has(file)); + if (overlap.length > 0) { + return { + canParallel: false, + reason: `File overlap: ${overlap.join(", ")}`, + }; + } + } + + return { canParallel: true }; +} + +export function canTasksRunInParallel(taskA, taskB) { + return canRunInParallel(taskA, taskB); +} + +export function computeTaskPriority(task) { + const fm = task.frontmatter ?? buildTaskRecord(task).frontmatter; + let score = 50; + + const riskScores = { none: 0, low: 5, medium: 15, high: 30, critical: 50 }; + score += riskScores[fm.risk] ?? 0; + + const scopeScores = { + none: 0, + "docs-only": 2, + config: 5, + "test-only": 3, + isolated: 5, + bounded: 10, + "cross-cutting": 25, + systemic: 40, + }; + score += scopeScores[fm.mutationScope] ?? 0; + + if (fm.blocksParallel) score += 20; + if (fm.requiresUserInput) score += 10; + if (fm.planApproval === "pending") score += 10; + + return Math.min(100, score); +} + +export function scoreTaskFrontmatterPriority(task) { + return computeTaskPriority(task); +} diff --git a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs index 9a8935849..a2b37a14e 100644 --- a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs +++ b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs @@ -45,6 +45,21 @@ test("direct command completions strip the already typed command name", () => { ]); }); +test("extension_manifest_uses_permission_profile_command_name", () => { + const manifest = JSON.parse( + readFileSync( + join( + process.cwd(), + "src/resources/extensions/sf/extension-manifest.json", + ), + "utf8", + ), + ); + const commands = manifest.provides?.commands ?? []; + assert.equal(commands.includes("permission-profile"), true); + assert.equal(commands.includes("trust"), false); +}); + test("human_facing_cli_help_when_describing_sf_surfaces_uses_direct_commands", () => { const sourceFiles = ["src/help-text.ts", "src/cli.ts", "src/loader.ts"]; for (const sourceFile of sourceFiles) { diff --git a/src/resources/extensions/sf/tests/parallel-monitor-store.test.mjs b/src/resources/extensions/sf/tests/parallel-monitor-store.test.mjs new file mode 100644 index 000000000..f35ba708c --- /dev/null +++ b/src/resources/extensions/sf/tests/parallel-monitor-store.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, beforeEach, test } from "vitest"; + +import { + getParallelWorkerDbPath, + queryParallelRecentCompletionRows, + queryParallelSliceProgress, +} from "../parallel-monitor-store.js"; + +let project; + +function createWorkerDb(milestoneId) { + const dbPath = getParallelWorkerDbPath(project, milestoneId); + mkdirSync(join(project, ".sf", "worktrees", milestoneId, ".sf"), { + recursive: true, + }); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE slices ( + milestone_id TEXT NOT NULL, + id TEXT NOT NULL, + status TEXT NOT NULL + ); + CREATE TABLE tasks ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + id TEXT NOT NULL, + task_status TEXT NOT NULL, + one_liner TEXT, + completed_at TEXT + ); + `); + return db; +} + +beforeEach(() => { + project = mkdtempSync(join(tmpdir(), "sf-parallel-monitor-store-")); +}); + +afterEach(() => { + rmSync(project, { recursive: true, force: true }); +}); + +test("queryParallelSliceProgress_when_worker_db_missing_returns_empty_projection", () => { + assert.deepEqual(queryParallelSliceProgress(project, "M404"), []); +}); + +test("queryParallelSliceProgress_reads_task_progress_from_worker_db", () => { + const db = createWorkerDb("M001"); + try { + db.prepare( + "INSERT INTO slices (milestone_id, id, status) VALUES (?, ?, ?)", + ).run("M001", "S01", "running"); + db.prepare( + "INSERT INTO slices (milestone_id, id, status) VALUES (?, ?, ?)", + ).run("M001", "S02", "complete"); + db.prepare( + "INSERT INTO tasks (milestone_id, slice_id, id, task_status, one_liner, completed_at) VALUES (?, ?, ?, ?, ?, ?)", + ).run("M001", "S01", "T01", "done", "done", "2026-05-08T01:00:00.000Z"); + db.prepare( + "INSERT INTO tasks (milestone_id, slice_id, id, task_status, one_liner, completed_at) VALUES (?, ?, ?, ?, ?, ?)", + ).run("M001", "S01", "T02", "todo", "todo", null); + db.prepare( + "INSERT INTO tasks (milestone_id, slice_id, id, task_status, one_liner, completed_at) VALUES (?, ?, ?, ?, ?, ?)", + ).run("M001", "S02", "T03", "done", "done", "2026-05-08T02:00:00.000Z"); + } finally { + db.close(); + } + + assert.deepEqual(queryParallelSliceProgress(project, "M001"), [ + { id: "S01", status: "running", total: 2, done: 1 }, + { id: "S02", status: "complete", total: 1, done: 1 }, + ]); +}); + +test("queryParallelRecentCompletionRows_returns_latest_completed_tasks", () => { + const db = createWorkerDb("M002"); + try { + db.prepare( + "INSERT INTO slices (milestone_id, id, status) VALUES (?, ?, ?)", + ).run("M002", "S01", "running"); + for (let i = 0; i < 6; i++) { + db.prepare( + "INSERT INTO tasks (milestone_id, slice_id, id, task_status, one_liner, completed_at) VALUES (?, ?, ?, ?, ?, ?)", + ).run( + "M002", + "S01", + `T0${i}`, + "done", + `task ${i}`, + `2026-05-08T0${i}:00:00.000Z`, + ); + } + db.prepare( + "INSERT INTO tasks (milestone_id, slice_id, id, task_status, one_liner, completed_at) VALUES (?, ?, ?, ?, ?, ?)", + ).run("M002", "S01", "T99", "todo", "not done", null); + } finally { + db.close(); + } + + const rows = queryParallelRecentCompletionRows(project, "M002", 3); + + assert.deepEqual( + rows.map((row) => row.taskId), + ["T05", "T04", "T03"], + ); + assert.deepEqual(rows[0], { + taskId: "T05", + sliceId: "S01", + oneLiner: "task 5", + completedAt: "2026-05-08T05:00:00.000Z", + }); +}); diff --git a/src/resources/extensions/sf/tests/plan-slice-evidence.test.mjs b/src/resources/extensions/sf/tests/plan-slice-evidence.test.mjs index e3cf299dc..c8540882c 100644 --- a/src/resources/extensions/sf/tests/plan-slice-evidence.test.mjs +++ b/src/resources/extensions/sf/tests/plan-slice-evidence.test.mjs @@ -12,6 +12,7 @@ import { afterEach, test } from "vitest"; import { closeDatabase, getSliceAuditTrail, + getTask, insertMilestone, insertSlice, openDatabase, @@ -78,6 +79,11 @@ test("handlePlanSlice_when_successful_records_plan_slice_evidence", async () => verify: "npm test -- plan-slice", inputs: ["DB-backed slice"], expectedOutput: ["Evidence row"], + risk: "high", + mutationScope: "cross-cutting", + verification: "integration", + planApproval: "pending", + blocksParallel: true, }, ], }, @@ -90,4 +96,10 @@ test("handlePlanSlice_when_successful_records_plan_slice_evidence", async () => assert.equal(trail[0].evidence_type, "plan_slice"); assert.match(trail[0].content, /Plan with evidence/); assert.match(trail[0].content, /Implement evidence/); + const task = getTask("M001", "S01", "T01"); + assert.equal(task.frontmatter.risk, "high"); + assert.equal(task.frontmatter.mutationScope, "cross-cutting"); + assert.equal(task.frontmatter.verification, "integration"); + assert.equal(task.frontmatter.planApproval, "pending"); + assert.equal(task.frontmatter.blocksParallel, true); }); diff --git a/src/resources/extensions/sf/tests/remote-steering.test.mjs b/src/resources/extensions/sf/tests/remote-steering.test.mjs new file mode 100644 index 000000000..97387cddd --- /dev/null +++ b/src/resources/extensions/sf/tests/remote-steering.test.mjs @@ -0,0 +1,151 @@ +/** + * Tests for remote steering surface. + */ +import assert from "node:assert"; +import { test } from "vitest"; +import { + applyRemoteSteeringDirectives, + formatRemoteSteeringResults, + parseRemoteSteeringDirectives, +} from "../remote-steering.js"; + +// --- Tests --- + +const testParseNoDirectives = () => { + const result = parseRemoteSteeringDirectives({ answers: ["yes", "no"] }); + assert.strictEqual(result.steering, false); + assert.strictEqual(result.directives.length, 0); +}; + +const testParseModeDirective = () => { + const result = parseRemoteSteeringDirectives({ + answers: ["/mode build", "proceed"], + }); + assert.strictEqual(result.steering, true); + assert.strictEqual(result.directives.length, 1); + assert.strictEqual(result.directives[0].cmd, "mode"); + assert.strictEqual(result.directives[0].value, "build"); +}; + +const testParseMultipleDirectives = () => { + const result = parseRemoteSteeringDirectives({ + text: "/mode review /permission-profile trusted /model-mode deep", + }); + assert.strictEqual(result.steering, true); + assert.strictEqual(result.directives.length, 3); + assert.deepStrictEqual(result.directives[0], { + cmd: "mode", + value: "review", + }); + assert.deepStrictEqual(result.directives[1], { + cmd: "permission-profile", + value: "trusted", + }); + assert.deepStrictEqual(result.directives[2], { + cmd: "model-mode", + value: "deep", + }); +}; + +const testParseStringAndFullAxisAliases = () => { + const result = parseRemoteSteeringDirectives( + "/work-mode repair /run-control autonomous /permission-profile trusted", + ); + assert.strictEqual(result.steering, true); + assert.deepStrictEqual(result.directives, [ + { cmd: "mode", value: "repair" }, + { cmd: "control", value: "autonomous" }, + { cmd: "permission-profile", value: "trusted" }, + ]); +}; + +const testParseRoundResultNotes = () => { + const result = parseRemoteSteeringDirectives({ + answers: { + mode_note: { + selected: "Other", + notes: "/mode build /permission-profile normal", + }, + }, + }); + assert.strictEqual(result.steering, true); + assert.deepStrictEqual(result.directives, [ + { cmd: "mode", value: "build" }, + { cmd: "permission-profile", value: "normal" }, + ]); +}; + +const testParseInvalidDirectiveIgnored = () => { + const result = parseRemoteSteeringDirectives({ + text: "/mode invalidmode /control autonomous", + }); + assert.strictEqual(result.steering, true); + assert.strictEqual(result.directives.length, 1); + assert.strictEqual(result.directives[0].cmd, "control"); +}; + +const testApplyDirectives = () => { + // This will fail if getAutoSession returns null (no session) + // In test environment without a full session, we just verify it doesn't throw + try { + const results = applyRemoteSteeringDirectives([ + { cmd: "mode", value: "build" }, + ]); + // If session exists, should apply + assert.ok(Array.isArray(results)); + } catch (err) { + // Expected in test environment without session + assert.ok( + err.message.includes("getAutoSession") || err.message.includes("null"), + ); + } +}; + +const testFormatResults = () => { + const results = [ + { cmd: "mode", value: "build", applied: true }, + { + cmd: "permission-profile", + value: "trusted", + applied: false, + error: "test error", + }, + ]; + const formatted = formatRemoteSteeringResults(results); + assert.ok(formatted.includes("SF Mode Steering")); + assert.ok(formatted.includes("[ok] /mode build")); + assert.ok(formatted.includes("[blocked] /permission-profile trusted")); +}; + +test( + "parseRemoteSteeringDirectives_without_directives_returns_false", + testParseNoDirectives, +); +test( + "parseRemoteSteeringDirectives_accepts_mode_directive", + testParseModeDirective, +); +test( + "parseRemoteSteeringDirectives_accepts_multiple_axes", + testParseMultipleDirectives, +); +test( + "parseRemoteSteeringDirectives_accepts_string_and_full_axis_aliases", + testParseStringAndFullAxisAliases, +); +test( + "parseRemoteSteeringDirectives_accepts_round_result_notes", + testParseRoundResultNotes, +); +test( + "parseRemoteSteeringDirectives_ignores_invalid_values", + testParseInvalidDirectiveIgnored, +); +test( + "applyRemoteSteeringDirectives_returns_results_or_session_error", + testApplyDirectives, +); +test( + "formatRemoteSteeringResults_renders_current_mode_summary", + testFormatResults, +); diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs index f31fb3a07..ee5c37547 100644 --- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -217,7 +217,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", const version = db .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); - assert.equal(version.version, 43); + assert.equal(version.version, 45); const taskSpec = db .prepare( "SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'", @@ -229,6 +229,12 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", task_id: "T01", verify: "go test ./portal", }); + const schedulerRow = db + .prepare( + "SELECT status FROM task_scheduler WHERE milestone_id = 'M010' AND slice_id = 'S03' AND task_id = 'T01'", + ) + .get(); + assert.deepEqual(schedulerRow, { status: "queued" }); }); test("openDatabase_when_fresh_db_supports_schedule_entries", () => { diff --git a/src/resources/extensions/sf/tests/sf-db-task-frontmatter.test.mjs b/src/resources/extensions/sf/tests/sf-db-task-frontmatter.test.mjs new file mode 100644 index 000000000..3b37eecb7 --- /dev/null +++ b/src/resources/extensions/sf/tests/sf-db-task-frontmatter.test.mjs @@ -0,0 +1,93 @@ +/** + * sf-db-task-frontmatter.test.mjs - DB task metadata integration contracts. + * + * Purpose: prove the SQLite task and task_specs tables persist normalized + * task metadata and expose it through getTask without a sidecar state store. + */ + +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { + closeDatabase, + getTask, + getTaskSpec, + insertMilestone, + insertSlice, + insertTask, + openDatabase, + updateTaskStatus, + upsertTaskPlanning, +} from "../sf-db.js"; + +test("sfDb_task_frontmatter_round_trips_through_task_and_spec_rows", () => { + closeDatabase(); + assert.equal(openDatabase(":memory:"), true); + try { + insertMilestone({ id: "M001", title: "Milestone", status: "active" }); + insertSlice({ + milestoneId: "M001", + id: "S01", + title: "Slice", + status: "pending", + }); + insertTask({ + milestoneId: "M001", + sliceId: "S01", + id: "T01", + title: "Task", + status: "pending", + }); + upsertTaskPlanning("M001", "S01", "T01", { + title: "Task", + description: "Purpose: prove metadata persistence.", + estimate: "45m", + files: ["src/planned.ts"], + verify: "npm test", + inputs: ["src/input.ts"], + expectedOutput: ["src/output.ts"], + risk: "high", + mutationScope: "systemic", + verification: "integration", + planApproval: "pending", + schedulerStatus: "dispatched", + estimatedEffort: 45, + dependencies: ["T00"], + blocksParallel: true, + requiresUserInput: true, + autoRetry: false, + maxRetries: 0, + }); + + const task = getTask("M001", "S01", "T01"); + assert.equal(task.frontmatter.risk, "high"); + assert.equal(task.frontmatter.mutationScope, "systemic"); + assert.equal(task.frontmatter.verification, "integration"); + assert.equal(task.frontmatter.planApproval, "pending"); + assert.equal(task.frontmatter.schedulerStatus, "dispatched"); + assert.equal(task.frontmatter.estimatedEffort, 45); + assert.deepEqual(task.frontmatter.dependencies, ["T00"]); + assert.equal(task.frontmatter.blocksParallel, true); + assert.equal(task.frontmatter.autoRetry, false); + assert.deepEqual(task.frontmatter.keyFiles, ["src/planned.ts"]); + + const spec = getTaskSpec("M001", "S01", "T01"); + assert.equal(spec.risk, "high"); + assert.equal(spec.mutation_scope, "systemic"); + assert.equal(spec.verification_type, "integration"); + assert.equal(spec.plan_approval, "pending"); + assert.equal(spec.estimated_effort, 45); + assert.equal(spec.dependencies, JSON.stringify(["T00"])); + assert.equal(spec.blocks_parallel, 1); + assert.equal(spec.requires_user_input, 1); + assert.equal(spec.auto_retry, 0); + assert.equal(spec.max_retries, 0); + assert.equal("scheduler_status" in spec, false); + + updateTaskStatus("M001", "S01", "T01", "done", "2026-05-08T00:00:00.000Z"); + const completed = getTask("M001", "S01", "T01"); + assert.equal(completed.frontmatter.taskStatus, "done"); + assert.equal(completed.frontmatter.schedulerStatus, "dispatched"); + } finally { + closeDatabase(); + } +}); diff --git a/src/resources/extensions/sf/tests/spec-tables-live-planning.test.mjs b/src/resources/extensions/sf/tests/spec-tables-live-planning.test.mjs index e05225cb9..39f5b8531 100644 --- a/src/resources/extensions/sf/tests/spec-tables-live-planning.test.mjs +++ b/src/resources/extensions/sf/tests/spec-tables-live-planning.test.mjs @@ -163,3 +163,53 @@ test("specTables_when_shell_milestone_created_before_planning_captures_first_rea "Capture first real product research.", ); }); + +test("specTables_when_shell_task_created_before_planning_captures_first_real_plan", () => { + openDatabase(":memory:"); + + insertMilestone({ + id: "M003", + title: "Task shell milestone", + status: "active", + }); + insertSlice({ + milestoneId: "M003", + id: "S01", + title: "Task shell slice", + status: "pending", + }); + insertTask({ + milestoneId: "M003", + sliceId: "S01", + id: "T01", + title: "Shell task", + status: "pending", + }); + + assert.equal(getTaskSpec("M003", "S01", "T01"), undefined); + + upsertTaskPlanning("M003", "S01", "T01", { + verify: "npm test -- task-shell", + inputs: ["src/task-input.ts"], + expectedOutput: ["src/task-output.ts"], + risk: "high", + mutationScope: "bounded", + }); + upsertTaskPlanning("M003", "S01", "T01", { + verify: "npm test -- changed", + inputs: ["src/changed-input.ts"], + expectedOutput: ["src/changed-output.ts"], + risk: "low", + mutationScope: "isolated", + }); + + const taskSpec = getTaskSpec("M003", "S01", "T01"); + assert.equal(taskSpec.verify, "npm test -- task-shell"); + assert.deepEqual(JSON.parse(taskSpec.inputs), ["src/task-input.ts"]); + assert.deepEqual(JSON.parse(taskSpec.expected_output), [ + "src/task-output.ts", + ]); + assert.equal(taskSpec.risk, "high"); + assert.equal(taskSpec.mutation_scope, "bounded"); + assert.equal(taskSpec.spec_version, 1); +}); diff --git a/src/resources/extensions/sf/tests/subagent-inheritance.test.mjs b/src/resources/extensions/sf/tests/subagent-inheritance.test.mjs new file mode 100644 index 000000000..e6dff6de0 --- /dev/null +++ b/src/resources/extensions/sf/tests/subagent-inheritance.test.mjs @@ -0,0 +1,183 @@ +/** + * Tests for subagent inheritance audit and enforcement. + */ +import assert from "node:assert"; +import { test } from "vitest"; +import { + applyInheritanceToEnv, + buildSubagentInheritanceEnvelope, + readParentInheritanceFromEnv, + validateSubagentDispatch, +} from "../subagent-inheritance.js"; + +// --- Tests --- + +const testBuildEnvelopeDefaults = () => { + // Without a session, should still return defaults + const envelope = buildSubagentInheritanceEnvelope({}); + assert.ok(envelope.workMode); + assert.ok(envelope.modelMode); + assert.ok(envelope.permissionProfile); + assert.ok(envelope.runControl); + assert.ok(envelope.surface); +}; + +const testBuildEnvelopeWithMode = () => { + const envelope = buildSubagentInheritanceEnvelope({ + mode: { + workMode: "build", + modelMode: "fast", + permissionProfile: "restricted", + runControl: "assisted", + surface: "headless", + }, + }); + assert.strictEqual(envelope.workMode, "build"); + assert.strictEqual(envelope.modelMode, "fast"); + assert.strictEqual(envelope.permissionProfile, "restricted"); + assert.strictEqual(envelope.runControl, "assisted"); + assert.strictEqual(envelope.surface, "headless"); +}; + +const testValidateAllowedProvider = () => { + const envelope = { + workMode: "build", + modelMode: "smart", + permissionProfile: "normal", + runControl: "assisted", + surface: "tui", + allowedProviders: ["anthropic"], + blockedProviders: null, + }; + const result = validateSubagentDispatch(envelope, { + model: "anthropic/claude-sonnet", + provider: "anthropic", + tools: ["read"], + }); + assert.strictEqual(result.ok, true); +}; + +const testValidateBlockedProvider = () => { + const envelope = { + workMode: "build", + modelMode: "smart", + permissionProfile: "normal", + runControl: "assisted", + surface: "tui", + allowedProviders: null, + blockedProviders: ["openai"], + }; + const result = validateSubagentDispatch(envelope, { + model: "openai/gpt-4", + provider: "openai", + tools: ["read"], + }); + assert.strictEqual(result.ok, false); + assert.ok(result.reason.includes("blocked")); +}; + +const testValidateFastBlocksHeavy = () => { + const envelope = { + workMode: "build", + modelMode: "fast", + permissionProfile: "trusted", + runControl: "assisted", + surface: "tui", + allowedProviders: null, + blockedProviders: null, + }; + const result = validateSubagentDispatch(envelope, { + model: "claude-3-opus", + provider: "anthropic", + tools: ["read"], + }); + assert.strictEqual(result.ok, false); + assert.ok(result.reason.includes("fast")); +}; + +const testValidateRestrictedBlocksDestructive = () => { + const envelope = { + workMode: "build", + modelMode: "smart", + permissionProfile: "restricted", + runControl: "assisted", + surface: "tui", + allowedProviders: null, + blockedProviders: null, + }; + const result = validateSubagentDispatch(envelope, { + model: "claude-sonnet", + provider: "anthropic", + tools: ["write", "read"], + }); + assert.strictEqual(result.ok, false); + assert.ok(result.reason.includes("write")); +}; + +const testApplyInheritanceToEnv = () => { + const envelope = { + workMode: "review", + modelMode: "deep", + permissionProfile: "trusted", + runControl: "autonomous", + surface: "headless", + }; + const env = applyInheritanceToEnv(envelope, {}); + assert.strictEqual(env.SF_PARENT_WORK_MODE, "review"); + assert.strictEqual(env.SF_PARENT_MODEL_MODE, "deep"); + assert.strictEqual(env.SF_PARENT_PERMISSION_PROFILE, "trusted"); + assert.strictEqual(env.SF_PARENT_RUN_CONTROL, "autonomous"); + assert.strictEqual(env.SF_PARENT_SURFACE, "headless"); +}; + +const testReadParentFromEnv = () => { + const env = { + SF_PARENT_WORK_MODE: "repair", + SF_PARENT_MODEL_MODE: "smart", + SF_PARENT_PERMISSION_PROFILE: "normal", + SF_PARENT_RUN_CONTROL: "assisted", + SF_PARENT_SURFACE: "tui", + }; + const result = readParentInheritanceFromEnv(env); + assert.strictEqual(result.workMode, "repair"); + assert.strictEqual(result.modelMode, "smart"); + assert.strictEqual(result.permissionProfile, "normal"); + assert.strictEqual(result.runControl, "assisted"); + assert.strictEqual(result.surface, "tui"); +}; + +const testReadParentFromEnvMissing = () => { + const result = readParentInheritanceFromEnv({}); + assert.strictEqual(result, null); +}; + +test( + "buildSubagentInheritanceEnvelope_defaults_are_resolved", + testBuildEnvelopeDefaults, +); +test( + "buildSubagentInheritanceEnvelope_uses_explicit_mode_axes", + testBuildEnvelopeWithMode, +); +test( + "validateSubagentDispatch_allows_allowed_provider", + testValidateAllowedProvider, +); +test( + "validateSubagentDispatch_blocks_blocked_provider", + testValidateBlockedProvider, +); +test( + "validateSubagentDispatch_fast_blocks_heavy_model", + testValidateFastBlocksHeavy, +); +test( + "validateSubagentDispatch_restricted_blocks_destructive_tools", + testValidateRestrictedBlocksDestructive, +); +test("applyInheritanceToEnv_writes_parent_axes", testApplyInheritanceToEnv); +test("readParentInheritanceFromEnv_reads_parent_axes", testReadParentFromEnv); +test( + "readParentInheritanceFromEnv_returns_null_without_parent", + testReadParentFromEnvMissing, +); diff --git a/src/resources/extensions/sf/tests/task-frontmatter.test.mjs b/src/resources/extensions/sf/tests/task-frontmatter.test.mjs new file mode 100644 index 000000000..ab4f01ae8 --- /dev/null +++ b/src/resources/extensions/sf/tests/task-frontmatter.test.mjs @@ -0,0 +1,144 @@ +/** + * task-frontmatter.test.mjs — Task metadata contract tests. + * + * Purpose: verify the DB-backed task frontmatter helper keeps task lifecycle + * status separate from scheduler lifecycle status. + */ + +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { + buildTaskRecord, + canRunInParallel, + computeTaskPriority, + DEFAULT_TASK_FRONTMATTER, + normalizeSchedulerStatus, + normalizeTaskStatus, + validateTaskFrontmatter, +} from "../task-frontmatter.js"; + +test("validateTaskFrontmatter_defaults_are_canonical", () => { + const result = validateTaskFrontmatter({}); + assert.equal(result.valid, true); + assert.equal(result.errors.length, 0); + assert.equal(result.normalized.taskStatus, "todo"); + assert.equal(result.normalized.schedulerStatus, "queued"); +}); + +test("validateTaskFrontmatter_valid_fields_normalize", () => { + const result = validateTaskFrontmatter({ + risk: "high", + mutationScope: "cross-cutting", + verification: "test", + planApproval: "approved", + taskStatus: "verifying", + schedulerStatus: "dispatched", + estimatedEffort: 120, + keyFiles: ["src/foo.ts", "src/bar.ts"], + dependencies: ["T01"], + blocksParallel: true, + maxRetries: 3, + }); + assert.equal(result.valid, true); + assert.equal(result.normalized.taskStatus, "verifying"); + assert.equal(result.normalized.schedulerStatus, "dispatched"); + assert.deepEqual(result.normalized.keyFiles, ["src/foo.ts", "src/bar.ts"]); + assert.equal(result.normalized.maxRetries, 3); +}); + +test("validateTaskFrontmatter_rejects_invalid_choices", () => { + assert.equal(validateTaskFrontmatter({ risk: "extreme" }).valid, false); + assert.equal( + validateTaskFrontmatter({ mutationScope: "everything" }).valid, + false, + ); + assert.equal(validateTaskFrontmatter({ taskStatus: "queued" }).valid, false); + assert.equal( + validateTaskFrontmatter({ schedulerStatus: "todo" }).valid, + false, + ); +}); + +test("task_and_scheduler_status_normalizers_keep_lifecycles_separate", () => { + assert.equal(normalizeTaskStatus("in_progress"), "running"); + assert.equal(normalizeTaskStatus("manual-attention"), "reviewing"); + assert.equal(normalizeTaskStatus("completed"), "done"); + assert.equal(normalizeSchedulerStatus("pending"), "queued"); + assert.equal(normalizeSchedulerStatus("done"), "consumed"); +}); + +test("buildTaskRecord_attaches_frontmatter", () => { + const record = buildTaskRecord({ + id: "T01", + title: "Test task", + risk: "medium", + mutation_scope: "bounded", + status: "in_progress", + }); + assert.equal(record.frontmatter.risk, "medium"); + assert.equal(record.frontmatter.mutationScope, "bounded"); + assert.equal(record.frontmatter.taskStatus, "running"); + assert.equal(record.frontmatterValid, true); +}); + +test("canRunInParallel_low_risk_disjoint_files_passes", () => { + const taskA = { + frontmatter: { + ...DEFAULT_TASK_FRONTMATTER, + risk: "low", + keyFiles: ["src/a.ts"], + }, + }; + const taskB = { + frontmatter: { + ...DEFAULT_TASK_FRONTMATTER, + risk: "low", + keyFiles: ["src/b.ts"], + }, + }; + assert.equal(canRunInParallel(taskA, taskB).canParallel, true); +}); + +test("canRunInParallel_file_overlap_blocks", () => { + const taskA = { + frontmatter: { ...DEFAULT_TASK_FRONTMATTER, keyFiles: ["src/shared.ts"] }, + }; + const taskB = { + frontmatter: { ...DEFAULT_TASK_FRONTMATTER, keyFiles: ["src/shared.ts"] }, + }; + const result = canRunInParallel(taskA, taskB); + assert.equal(result.canParallel, false); + assert.match(result.reason, /overlap/i); +}); + +test("canRunInParallel_blocks_parallel_and_high_risk_pairs_block", () => { + assert.equal( + canRunInParallel( + { frontmatter: { ...DEFAULT_TASK_FRONTMATTER, blocksParallel: true } }, + { frontmatter: { ...DEFAULT_TASK_FRONTMATTER } }, + ).canParallel, + false, + ); + assert.equal( + canRunInParallel( + { frontmatter: { ...DEFAULT_TASK_FRONTMATTER, risk: "high" } }, + { frontmatter: { ...DEFAULT_TASK_FRONTMATTER, risk: "high" } }, + ).canParallel, + false, + ); +}); + +test("computeTaskPriority_prioritizes_critical_and_systemic_work", () => { + const lowRisk = { frontmatter: { ...DEFAULT_TASK_FRONTMATTER, risk: "low" } }; + const critical = { + frontmatter: { ...DEFAULT_TASK_FRONTMATTER, risk: "critical" }, + }; + const isolated = { + frontmatter: { ...DEFAULT_TASK_FRONTMATTER, mutationScope: "isolated" }, + }; + const systemic = { + frontmatter: { ...DEFAULT_TASK_FRONTMATTER, mutationScope: "systemic" }, + }; + assert.ok(computeTaskPriority(critical) > computeTaskPriority(lowRisk)); + assert.ok(computeTaskPriority(systemic) > computeTaskPriority(isolated)); +}); diff --git a/src/resources/extensions/sf/tests/uok-execution-graph-persist.test.mjs b/src/resources/extensions/sf/tests/uok-execution-graph-persist.test.mjs index 47b1fd2f8..00c3aea68 100644 --- a/src/resources/extensions/sf/tests/uok-execution-graph-persist.test.mjs +++ b/src/resources/extensions/sf/tests/uok-execution-graph-persist.test.mjs @@ -220,7 +220,7 @@ test("queryTasksByState_filters_by_state", () => { id: "n3", kind: "unit", unitId: "U3", - state: "in_progress", + state: "running", }); const todo = queryTasksByState(db, { graphId: "g1", states: ["todo"] }); @@ -230,6 +230,14 @@ test("queryTasksByState_filters_by_state", () => { const done = queryTasksByState(db, { graphId: "g1", states: ["done"] }); assert.equal(done.length, 1); assert.equal(done[0].id, "n2"); + + const running = queryTasksByState(db, { + graphId: "g1", + states: ["in_progress"], + }); + assert.equal(running.length, 1); + assert.equal(running[0].id, "n3"); + assert.equal(running[0].status, "running"); }); test("queryTasksByState_filters_by_milestone", () => { @@ -313,7 +321,7 @@ test("getGraphStateSummary_computes_counts_and_progress", () => { assert.equal(summary.progress, 50); assert.equal(summary.isComplete, false); assert.equal(summary.counts.todo, 1); - assert.equal(summary.counts.in_progress, 1); + assert.equal(summary.counts.running, 1); assert.equal(summary.counts.done, 2); }); diff --git a/src/resources/extensions/sf/tests/uok-task-state.test.mjs b/src/resources/extensions/sf/tests/uok-task-state.test.mjs index c3eb0c3f3..0aa665867 100644 --- a/src/resources/extensions/sf/tests/uok-task-state.test.mjs +++ b/src/resources/extensions/sf/tests/uok-task-state.test.mjs @@ -12,6 +12,7 @@ import { buildTaskRecord, canTransitionTaskState, gateOutcomesToTaskState, + normalizeTaskState, TASK_STATES, TASK_TERMINAL_STATES, unitRuntimeToTaskState, @@ -22,9 +23,12 @@ import { test("TASK_STATES_has_all_orch_states", () => { assert.deepEqual(TASK_STATES, [ "todo", - "in_progress", - "review", + "running", + "verifying", + "reviewing", "done", + "blocked", + "paused", "retrying", "failed", "cancelled", @@ -36,7 +40,8 @@ test("TASK_TERMINAL_STATES_includes_done_failed_cancelled", () => { assert.equal(TASK_TERMINAL_STATES.has("failed"), true); assert.equal(TASK_TERMINAL_STATES.has("cancelled"), true); assert.equal(TASK_TERMINAL_STATES.has("todo"), false); - assert.equal(TASK_TERMINAL_STATES.has("in_progress"), false); + assert.equal(TASK_TERMINAL_STATES.has("running"), false); + assert.equal(TASK_TERMINAL_STATES.has("blocked"), false); }); // ─── Gate outcomes → task state ──────────────────────────────────────────── @@ -63,12 +68,12 @@ test("gateOutcomesToTaskState_any_fail_returns_failed", () => { assert.equal(gateOutcomesToTaskState(results), "failed"); }); -test("gateOutcomesToTaskState_any_manual_attention_returns_review", () => { +test("gateOutcomesToTaskState_any_manual_attention_returns_reviewing", () => { const results = [ { outcome: "pass", gateId: "security" }, { outcome: "manual-attention", gateId: "verification" }, ]; - assert.equal(gateOutcomesToTaskState(results), "review"); + assert.equal(gateOutcomesToTaskState(results), "reviewing"); }); test("gateOutcomesToTaskState_any_retry_returns_retrying", () => { @@ -79,7 +84,15 @@ test("gateOutcomesToTaskState_any_retry_returns_retrying", () => { assert.equal(gateOutcomesToTaskState(results), "retrying"); }); -test("gateOutcomesToTaskState_mixed_nonterminal_returns_in_progress", () => { +test("gateOutcomesToTaskState_any_blocked_returns_blocked", () => { + const results = [ + { outcome: "pass", gateId: "security" }, + { outcome: "blocked", gateId: "verification" }, + ]; + assert.equal(gateOutcomesToTaskState(results), "blocked"); +}); + +test("gateOutcomesToTaskState_mixed_nonterminal_returns_running", () => { const results = [ { outcome: "pass", gateId: "security" }, { outcome: "pass", gateId: "cost" }, @@ -103,12 +116,16 @@ test("unitRuntimeToTaskState_claimed_returns_todo", () => { assert.equal(unitRuntimeToTaskState({ status: "claimed" }), "todo"); }); -test("unitRuntimeToTaskState_running_returns_in_progress", () => { - assert.equal(unitRuntimeToTaskState({ status: "running" }), "in_progress"); +test("unitRuntimeToTaskState_running_returns_running", () => { + assert.equal(unitRuntimeToTaskState({ status: "running" }), "running"); }); -test("unitRuntimeToTaskState_progress_returns_in_progress", () => { - assert.equal(unitRuntimeToTaskState({ status: "progress" }), "in_progress"); +test("unitRuntimeToTaskState_progress_returns_running", () => { + assert.equal(unitRuntimeToTaskState({ status: "progress" }), "running"); +}); + +test("unitRuntimeToTaskState_validating_returns_verifying", () => { + assert.equal(unitRuntimeToTaskState({ status: "validating" }), "verifying"); }); test("unitRuntimeToTaskState_completed_returns_done", () => { @@ -119,8 +136,12 @@ test("unitRuntimeToTaskState_failed_returns_failed", () => { assert.equal(unitRuntimeToTaskState({ status: "failed" }), "failed"); }); -test("unitRuntimeToTaskState_blocked_returns_review", () => { - assert.equal(unitRuntimeToTaskState({ status: "blocked" }), "review"); +test("unitRuntimeToTaskState_blocked_returns_blocked", () => { + assert.equal(unitRuntimeToTaskState({ status: "blocked" }), "blocked"); +}); + +test("unitRuntimeToTaskState_review_returns_reviewing", () => { + assert.equal(unitRuntimeToTaskState({ status: "review" }), "reviewing"); }); test("unitRuntimeToTaskState_cancelled_returns_cancelled", () => { @@ -141,14 +162,16 @@ test("unitRuntimeToTaskState_runaway_recovered_returns_retrying", () => { // ─── State transitions ───────────────────────────────────────────────────── test("canTransitionTaskState_valid_returns_true", () => { - assert.equal(canTransitionTaskState("todo", "in_progress"), true); - assert.equal(canTransitionTaskState("in_progress", "done"), true); + assert.equal(canTransitionTaskState("todo", "running"), true); + assert.equal(canTransitionTaskState("running", "verifying"), true); + assert.equal(canTransitionTaskState("verifying", "reviewing"), true); + assert.equal(canTransitionTaskState("running", "done"), true); assert.equal(canTransitionTaskState("failed", "retrying"), true); }); test("canTransitionTaskState_invalid_returns_false", () => { assert.equal(canTransitionTaskState("todo", "done"), false); - assert.equal(canTransitionTaskState("done", "in_progress"), false); + assert.equal(canTransitionTaskState("done", "running"), false); assert.equal(canTransitionTaskState("cancelled", "todo"), false); }); @@ -170,20 +193,29 @@ test("aggregateTaskStates_counts_correctly", () => { const agg = aggregateTaskStates([ "todo", "in_progress", + "review", "done", "done", "failed", ]); - assert.equal(agg.total, 5); + assert.equal(agg.total, 6); assert.equal(agg.terminal, 3); // done(2) + failed(1) - assert.equal(agg.progress, 60); + assert.equal(agg.progress, 50); assert.equal(agg.isComplete, false); assert.equal(agg.counts.todo, 1); - assert.equal(agg.counts.in_progress, 1); + assert.equal(agg.counts.running, 1); + assert.equal(agg.counts.reviewing, 1); assert.equal(agg.counts.done, 2); assert.equal(agg.counts.failed, 1); }); +test("normalizeTaskState_maps_source_labels_to_canonical_names", () => { + assert.equal(normalizeTaskState("in_progress"), "running"); + assert.equal(normalizeTaskState("review"), "reviewing"); + assert.equal(normalizeTaskState("queued"), "todo"); + assert.equal(normalizeTaskState("completed"), "done"); +}); + test("aggregateTaskStates_complete_when_all_terminal", () => { const agg = aggregateTaskStates(["done", "done", "cancelled"]); assert.equal(agg.progress, 100); @@ -220,7 +252,7 @@ test("buildTaskRecord_with_runtime_uses_runtime_state", () => { runtimeRecord: { status: "running" }, gateResults: [], }); - assert.equal(task.state, "in_progress"); + assert.equal(task.state, "running"); }); test("buildTaskRecord_includes_worker_id", () => { diff --git a/src/resources/extensions/sf/tools/plan-slice.js b/src/resources/extensions/sf/tools/plan-slice.js index 4acfa1633..45b146c41 100644 --- a/src/resources/extensions/sf/tools/plan-slice.js +++ b/src/resources/extensions/sf/tools/plan-slice.js @@ -17,6 +17,7 @@ import { } from "../sf-db.js"; import { invalidateStateCache } from "../state.js"; import { isClosedStatus } from "../status-guards.js"; +import { taskFrontmatterFromRecord } from "../task-frontmatter.js"; import { isNonEmptyString, normalizePlanningText } from "../validation.js"; import { appendEvent } from "../workflow-events.js"; import { logWarning } from "../workflow-logger.js"; @@ -89,6 +90,15 @@ function validateTasks(value) { `tasks[${index}].observabilityImpact must be a non-empty string when provided`, ); } + const frontmatter = taskFrontmatterFromRecord({ + ...obj, + keyFiles: files, + }); + if (!frontmatter.valid) { + throw new Error( + `tasks[${index}] metadata invalid: ${frontmatter.errors.join("; ")}`, + ); + } return { taskId: normalizePlanningText(taskId, `tasks[${index}].taskId`), title: normalizePlanningText(title, `tasks[${index}].title`), @@ -108,6 +118,16 @@ function validateTasks(value) { `tasks[${index}].observabilityImpact`, ) : "", + risk: frontmatter.normalized.risk, + mutationScope: frontmatter.normalized.mutationScope, + verification: frontmatter.normalized.verification, + planApproval: frontmatter.normalized.planApproval, + estimatedEffort: frontmatter.normalized.estimatedEffort, + dependencies: frontmatter.normalized.dependencies, + blocksParallel: frontmatter.normalized.blocksParallel, + requiresUserInput: frontmatter.normalized.requiresUserInput, + autoRetry: frontmatter.normalized.autoRetry, + maxRetries: frontmatter.normalized.maxRetries, }; }); } @@ -290,6 +310,16 @@ export async function handlePlanSlice(rawParams, basePath) { expectedOutput: task.expectedOutput, observabilityImpact: task.observabilityImpact ?? "", fullPlanMd: task.fullPlanMd, + risk: task.risk, + mutationScope: task.mutationScope, + verification: task.verification, + planApproval: task.planApproval, + estimatedEffort: task.estimatedEffort, + dependencies: task.dependencies, + blocksParallel: task.blocksParallel, + requiresUserInput: task.requiresUserInput, + autoRetry: task.autoRetry, + maxRetries: task.maxRetries, }); } // Seed quality gate rows inside the transaction — all-or-nothing with diff --git a/src/resources/extensions/sf/uok/execution-graph-persist.js b/src/resources/extensions/sf/uok/execution-graph-persist.js index 71d3613d1..d1e8c220a 100644 --- a/src/resources/extensions/sf/uok/execution-graph-persist.js +++ b/src/resources/extensions/sf/uok/execution-graph-persist.js @@ -10,6 +10,33 @@ * background-work tracking. */ +import { + normalizeTaskState, + TASK_STATES, + TASK_TERMINAL_STATES, +} from "./task-state.js"; + +const TASK_STATE_QUERY_ALIASES = { + done: ["done", "completed", "complete"], + reviewing: ["reviewing", "review", "manual-attention", "manual_attention"], + running: ["running", "in_progress", "progress"], + todo: ["todo", "queued", "claimed", "pending"], + verifying: ["verifying", "validating"], +}; + +function expandTaskStateFilters(states) { + const expanded = new Set(); + for (const state of states) { + const normalized = normalizeTaskState(state); + expanded.add(normalized); + expanded.add(state); + for (const alias of TASK_STATE_QUERY_ALIASES[normalized] ?? []) { + expanded.add(alias); + } + } + return [...expanded].filter(Boolean); +} + /** * Ensure the execution graph tables exist. * @@ -141,6 +168,7 @@ export function persistGraphNode(db, graphId, node) { gateResults = [], error, } = node; + const normalizedState = normalizeTaskState(state); db.prepare( ` @@ -186,7 +214,7 @@ export function persistGraphNode(db, graphId, node) { title: title ?? null, dependsOn: JSON.stringify(dependsOn), writes: JSON.stringify(writes), - state, + state: normalizedState, workerId: workerId ?? null, startedAt: startedAt ?? null, endedAt: endedAt ?? null, @@ -255,9 +283,12 @@ export function queryTasksByState(db, filters = {}) { params.sliceId = sliceId; } if (states.length > 0) { - conditions.push(`status IN (${states.map((_, i) => `:s${i}`).join(", ")})`); - for (let i = 0; i < states.length; i++) { - params[`s${i}`] = states[i]; + const stateFilters = expandTaskStateFilters(states); + conditions.push( + `status IN (${stateFilters.map((_, i) => `:s${i}`).join(", ")})`, + ); + for (let i = 0; i < stateFilters.length; i++) { + params[`s${i}`] = stateFilters[i]; } } @@ -276,6 +307,7 @@ export function queryTasksByState(db, filters = {}) { return rows.map((r) => ({ ...r, + status: normalizeTaskState(r.status), dependsOn: r.depends_on ? JSON.parse(r.depends_on) : [], writes: r.writes ? JSON.parse(r.writes) : [], gateResults: r.gate_results ? JSON.parse(r.gate_results) : [], @@ -297,27 +329,17 @@ export function getGraphStateSummary(db, graphId) { ) .all({ graphId }); - const counts = Object.fromEntries( - [ - "todo", - "in_progress", - "review", - "done", - "retrying", - "failed", - "cancelled", - ].map((s) => [s, 0]), - ); + const counts = Object.fromEntries(TASK_STATES.map((s) => [s, 0])); let total = 0; for (const r of rows) { - counts[r.status] = r.count; + const status = normalizeTaskState(r.status); + counts[status] = (counts[status] ?? 0) + r.count; total += r.count; } - const terminal = ["done", "failed", "cancelled"].reduce( - (sum, s) => sum + (counts[s] ?? 0), - 0, - ); + const terminal = TASK_STATES.filter((s) => + TASK_TERMINAL_STATES.has(s), + ).reduce((sum, s) => sum + (counts[s] ?? 0), 0); return { graphId, diff --git a/src/resources/extensions/sf/uok/task-state.js b/src/resources/extensions/sf/uok/task-state.js index e0599aa8d..e87f9244a 100644 --- a/src/resources/extensions/sf/uok/task-state.js +++ b/src/resources/extensions/sf/uok/task-state.js @@ -14,9 +14,12 @@ import { isTerminalUnitRuntimeStatus } from "./unit-runtime.js"; export const TASK_STATES = [ "todo", - "in_progress", - "review", + "running", + "verifying", + "reviewing", "done", + "blocked", + "paused", "retrying", "failed", "cancelled", @@ -25,15 +28,72 @@ export const TASK_STATES = [ export const TASK_TERMINAL_STATES = new Set(["done", "failed", "cancelled"]); export const TASK_STATE_TRANSITIONS = { - todo: ["in_progress", "cancelled"], - in_progress: ["review", "done", "retrying", "failed", "cancelled"], - review: ["in_progress", "done", "failed", "cancelled"], - retrying: ["in_progress", "failed", "cancelled"], + todo: ["running", "cancelled"], + running: [ + "verifying", + "reviewing", + "done", + "blocked", + "paused", + "retrying", + "failed", + "cancelled", + ], + verifying: [ + "reviewing", + "done", + "blocked", + "paused", + "retrying", + "failed", + "cancelled", + ], + reviewing: [ + "running", + "verifying", + "done", + "blocked", + "paused", + "failed", + "cancelled", + ], done: [], + blocked: ["todo", "running", "retrying", "cancelled"], + paused: ["running", "retrying", "cancelled"], + retrying: ["running", "failed", "cancelled"], failed: ["retrying", "cancelled"], cancelled: [], }; +export const TASK_STATE_SOURCE_LABELS = { + claimed: "todo", + completed: "done", + complete: "done", + in_progress: "running", + "manual-attention": "reviewing", + manual_attention: "reviewing", + pending: "todo", + progress: "running", + queued: "todo", + review: "reviewing", + validating: "verifying", +}; + +/** + * Normalize runtime and scheduler source labels into task lifecycle labels. + * + * Purpose: store one canonical task.status vocabulary even when inputs arrive + * from unit runtime, scheduler, graph, or gate outcome sources. + * + * Consumer: task-state derivation, SQLite graph persistence, and /tasks filters. + */ +export function normalizeTaskState(value) { + if (typeof value !== "string" || value.trim() === "") return "todo"; + const normalized = value.trim().toLowerCase(); + if (TASK_STATES.includes(normalized)) return normalized; + return TASK_STATE_SOURCE_LABELS[normalized] ?? "running"; +} + /** * Derive task state from a collection of gate results. * @@ -47,20 +107,21 @@ export function gateOutcomesToTaskState(gateResults) { const outcomes = gateResults.map((r) => r.outcome); - if (outcomes.some((o) => o === "manual-attention")) return "review"; + if (outcomes.some((o) => o === "manual-attention")) return "reviewing"; + if (outcomes.some((o) => o === "blocked")) return "blocked"; + if (outcomes.some((o) => o === "paused")) return "paused"; if (outcomes.some((o) => o === "retry")) return "retrying"; if (outcomes.every((o) => o === "pass")) return "done"; if (outcomes.some((o) => o === "fail")) return "failed"; - return "in_progress"; + return "running"; } /** * Derive task state from a unit runtime record. * - * Purpose: bridge the legacy unit-runtime projection (queued/claimed/running/ - * completed/failed/blocked/cancelled/stale) to the ORCH-style task state - * vocabulary so /tasks shows a unified view. + * Purpose: bridge the unit-runtime projection to the ORCH-style task state + * vocabulary so /tasks shows one unified lifecycle view. * * Consumer: /tasks query when no active gate run exists for a unit. */ @@ -75,22 +136,31 @@ export function unitRuntimeToTaskState(record) { return "todo"; case "running": case "progress": - return "in_progress"; + return "running"; + case "verifying": + case "validating": + return "verifying"; + case "review": + case "reviewing": + case "manual-attention": + return "reviewing"; case "completed": return "done"; case "failed": return "failed"; case "blocked": - return "review"; + return "blocked"; + case "paused": + return "paused"; case "cancelled": return "cancelled"; case "stale": case "runaway-recovered": return "retrying"; case "notified": - return isTerminalUnitRuntimeStatus(status) ? "done" : "in_progress"; + return isTerminalUnitRuntimeStatus(status) ? "done" : "running"; default: - return "in_progress"; + return normalizeTaskState(status); } } @@ -100,9 +170,9 @@ export function unitRuntimeToTaskState(record) { * Purpose: prevent illegal state jumps in /tasks UI and background scheduler. */ export function canTransitionTaskState(from, to) { - const allowed = TASK_STATE_TRANSITIONS[from]; + const allowed = TASK_STATE_TRANSITIONS[normalizeTaskState(from)]; if (!allowed) return false; - return allowed.includes(to); + return allowed.includes(normalizeTaskState(to)); } /** @@ -115,7 +185,8 @@ export function canTransitionTaskState(from, to) { export function aggregateTaskStates(taskStates) { const counts = Object.fromEntries(TASK_STATES.map((s) => [s, 0])); for (const s of taskStates) { - if (s in counts) counts[s]++; + const state = normalizeTaskState(s); + if (state in counts) counts[state]++; } const total = taskStates.length; const terminal = TASK_STATES.filter((s) => diff --git a/src/resources/extensions/subagent/index.js b/src/resources/extensions/subagent/index.js index 0615cc524..7cb234cdd 100644 --- a/src/resources/extensions/subagent/index.js +++ b/src/resources/extensions/subagent/index.js @@ -30,6 +30,11 @@ import { } from "../sf/code-intelligence.js"; import { loadEffectiveSFPreferences } from "../sf/preferences.js"; import { recordRetrievalEvidence } from "../sf/retrieval-evidence.js"; +import { + applyInheritanceToEnv, + buildSubagentInheritanceEnvelope, + validateSubagentDispatch, +} from "../sf/subagent-inheritance.js"; import { formatTokenCount } from "../shared/mod.js"; import { getCurrentPhase } from "../shared/sf-phase-state.js"; import { discoverAgents } from "./agents.js"; @@ -247,6 +252,41 @@ function summarizeBackgroundInvocation(params) { if (params.agent) return `single:${params.agent}`; return "subagent"; } +function collectSubagentDispatchItems(params) { + if (params.chain?.length) { + return params.chain.map((step) => ({ + agentName: step.agent, + model: step.model ?? params.model, + })); + } + if (params.tasks?.length) { + return params.tasks.map((task) => ({ + agentName: task.agent, + model: task.model ?? params.model, + })); + } + if (params.agent) { + return [{ agentName: params.agent, model: params.model }]; + } + return []; +} +function validateSubagentInvocationAgainstInheritance( + params, + agents, + inheritanceEnvelope, +) { + for (const item of collectSubagentDispatchItems(params)) { + const { agent } = resolveAgentByName(agents, item.agentName); + if (!agent) continue; + const result = validateSubagentDispatch(inheritanceEnvelope, { + agentName: item.agentName, + model: item.model ?? agent.model, + tools: agent.tools ?? [], + }); + if (!result.ok) return result; + } + return { ok: true }; +} async function executeSubagentInvocation({ defaultCwd, agents, @@ -258,6 +298,7 @@ async function executeSubagentInvocation({ cmuxClient, cmuxSplitsEnabled, useIsolation, + inheritanceEnvelope, }) { const makeDetails = (mode) => (results) => ({ mode, @@ -306,6 +347,7 @@ async function executeSubagentInvocation({ chainUpdate, makeDetails("chain"), step.model ?? params.model, + inheritanceEnvelope, ); results.push(result); const isError = @@ -478,6 +520,7 @@ async function executeSubagentInvocation({ }, makeDetails("debate"), taskModelOverride, + inheritanceEnvelope, ) : await runSingleAgent( defaultCwd, @@ -497,6 +540,7 @@ async function executeSubagentInvocation({ }, makeDetails("debate"), taskModelOverride, + inheritanceEnvelope, ); result.task = t.task; result.step = round; @@ -646,6 +690,7 @@ async function executeSubagentInvocation({ }, makeDetails("parallel"), taskModelOverride, + inheritanceEnvelope, ) : runSingleAgent( defaultCwd, @@ -663,6 +708,7 @@ async function executeSubagentInvocation({ }, makeDetails("parallel"), taskModelOverride, + inheritanceEnvelope, ); let result = await runTask(); const isFailed = @@ -732,6 +778,7 @@ async function executeSubagentInvocation({ onUpdate, makeDetails("single"), params.model, + inheritanceEnvelope, ) : await runSingleAgent( defaultCwd, @@ -744,6 +791,7 @@ async function executeSubagentInvocation({ onUpdate, makeDetails("single"), params.model, + inheritanceEnvelope, ); if (isolation) { const patches = await isolation.captureDelta(); @@ -908,10 +956,11 @@ function getBundledExtensionCliArgs() { .filter(Boolean) .flatMap((p) => ["--extension", p]); } -function resolveSubagentLaunchSpec(args) { +function resolveSubagentLaunchSpec(args, inheritanceEnvelope) { const sfBinPath = process.env.SF_BIN_PATH || process.argv[1]; - const env = { ...process.env }; - const envPatch = {}; + const inheritanceEnvPatch = applyInheritanceToEnv(inheritanceEnvelope, {}); + const env = { ...process.env, ...inheritanceEnvPatch }; + const envPatch = { ...inheritanceEnvPatch }; const command = process.env.SF_NODE_BIN || process.execPath; if (sfBinPath && path.basename(sfBinPath) === "sf-from-source") { const sourceRoot = path.resolve(path.dirname(sfBinPath), ".."); @@ -1075,6 +1124,7 @@ async function runSingleAgent( onUpdate, makeDetails, modelOverride, + inheritanceEnvelope, ) { const { agent, effectiveName } = resolveAgentByName(agents, agentName); if (!agent) { @@ -1168,7 +1218,7 @@ async function runSingleAgent( tmpPromptPath, modelOverride, ); - const launchSpec = resolveSubagentLaunchSpec(args); + const launchSpec = resolveSubagentLaunchSpec(args, inheritanceEnvelope); let wasAborted = false; const exitCode = await new Promise((resolve) => { const proc = spawn(launchSpec.command, launchSpec.args, { @@ -1248,6 +1298,7 @@ async function runSingleAgentInCmuxSplit( onUpdate, makeDetails, modelOverride, + inheritanceEnvelope, ) { const { agent, effectiveName } = resolveAgentByName(agents, agentName); if (!agent) { @@ -1262,6 +1313,7 @@ async function runSingleAgentInCmuxSplit( onUpdate, makeDetails, modelOverride, + inheritanceEnvelope, ); } let tmpPromptDir = null; @@ -1330,10 +1382,12 @@ async function runSingleAgentInCmuxSplit( onUpdate, makeDetails, modelOverride, + inheritanceEnvelope, ); } const launchSpec = resolveSubagentLaunchSpec( buildSubagentProcessArgs(agent, task, tmpPromptPath, modelOverride), + inheritanceEnvelope, ); const launcherPath = writeNodeSubagentLauncher( launchSpec, @@ -1358,6 +1412,7 @@ async function runSingleAgentInCmuxSplit( onUpdate, makeDetails, modelOverride, + inheritanceEnvelope, ); } const finished = await waitForFile(exitPath, signal); @@ -1618,9 +1673,13 @@ export default function (pi) { const discovery = discoverAgents(ctx.cwd, agentScope); const agents = discovery.agents; const confirmProjectAgents = params.confirmProjectAgents ?? false; - const cmuxClient = CmuxClient.fromPreferences( - loadEffectiveSFPreferences()?.preferences, - ); + const effectivePreferences = + loadEffectiveSFPreferences()?.preferences ?? {}; + const inheritanceEnvelope = buildSubagentInheritanceEnvelope({ + preferences: effectivePreferences, + surface: ctx.hasUI ? "tui" : "headless", + }); + const cmuxClient = CmuxClient.fromPreferences(effectivePreferences); const cmuxSplitsEnabled = cmuxClient.getConfig().splits; // Resolve isolation mode const isolationMode = readIsolationMode(); @@ -1662,6 +1721,25 @@ export default function (pi) { isError: true, }; } + const inheritanceCheck = validateSubagentInvocationAgainstInheritance( + params, + agents, + inheritanceEnvelope, + ); + if (!inheritanceCheck.ok) { + return { + content: [ + { + type: "text", + text: `Subagent dispatch blocked by parent mode policy: ${inheritanceCheck.reason}`, + }, + ], + details: makeDetails( + hasChain ? "chain" : hasTasks ? "parallel" : "single", + )([]), + isError: true, + }; + } if ( (agentScope === "project" || agentScope === "both") && confirmProjectAgents && @@ -1717,6 +1795,7 @@ export default function (pi) { cmuxClient, cmuxSplitsEnabled, useIsolation, + inheritanceEnvelope, }), ); return { @@ -1743,6 +1822,7 @@ export default function (pi) { cmuxClient, cmuxSplitsEnabled, useIsolation, + inheritanceEnvelope, }); }, renderCall(args, theme) {