feat(sf): align uok task state and steering

This commit is contained in:
Mikael Hugo 2026-05-08 06:43:53 +02:00 committed by Mikael Hugo
parent 378ab702e1
commit 10694440e3
31 changed files with 2583 additions and 254 deletions

View file

@ -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 | ✓ |
---

View file

@ -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

View file

@ -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

View file

@ -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" },
),

View file

@ -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)

View file

@ -439,16 +439,16 @@ export async function handleSteer(change, ctx, pi) {
return;
}
// ── Trust steering: /steer trust <profile> [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 <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;

View file

@ -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)" },

View file

@ -42,7 +42,7 @@ export function showHelp(ctx, args = "") {
"COURSE CORRECTION",
" /steer <desc> Apply user override to active work",
" /steer mode <m> [scope] Change work mode (now|after-current-unit|next-milestone)",
" /steer trust <p> [scope] Change permission profile",
" /steer permission-profile <p> [scope] Change permission profile",
" /steer model-mode <m> Change model mode for next unit",
" /capture <text> 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 ")) {

View file

@ -114,7 +114,7 @@
"templates",
"todo",
"triage",
"trust",
"permission-profile",
"undo",
"undo-task",
"unpark",

View file

@ -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,

View file

@ -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) {

View file

@ -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 [];
}
}

View file

@ -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");
}

View file

@ -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);

View file

@ -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",
};
}

View file

@ -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);
}

View file

@ -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) {

View file

@ -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",
});
});

View file

@ -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);
});

View file

@ -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,
);

View file

@ -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", () => {

View file

@ -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();
}
});

View file

@ -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);
});

View file

@ -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,
);

View file

@ -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));
});

View file

@ -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);
});

View file

@ -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", () => {

View file

@ -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

View file

@ -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,

View file

@ -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) =>

View file

@ -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) {