feat(sf): streamline uok state and direct modes

This commit is contained in:
Mikael Hugo 2026-05-08 05:28:43 +02:00 committed by Mikael Hugo
parent 19bfc3d3f6
commit 378ab702e1
58 changed files with 3679 additions and 348 deletions

View file

@ -0,0 +1,37 @@
---
name: forge-autonomous-runtime
description: Explains SF autonomous loop, UOK gates, installed-runtime drift, and recovery paths.
user-invocable: false
model-invocable: true
side-effects: none
permission-profile: restricted
triggers:
- "*"
---
# forge-autonomous-runtime
## Context
SF's autonomous mode is governed by the Unified Operation Kernel (UOK):
1. **State reading** — UOK reads canonical project state from `.sf/sf.db`
2. **Ledger recording** — Each run is recorded in the DB-backed ledger
3. **Projection** — Runtime files under `.sf/runtime/` are generated projections
4. **Dispatch** — UOK determines the next unit of work and creates a fresh agent session
5. **Execution** — LLM executes with focused prompt and pre-inlined context
6. **Reconciliation** — UOK reconciles ledger and projections before next dispatch
## Recovery Paths
- **Crash recovery** — Lock file tracks current unit; next `/autonomous` reads surviving state
- **Stuck detection** — Loop detects stuck iterations and emits `stuck-detected` journal events
- **Health gates** — Pre-dispatch gates check state integrity before execution
- **Parity reports** — Ledger parity ensures no orphaned or missing unit records
## Rules
- Never modify `.sf/sf.db` directly — use UOK APIs
- Never trust `.sf/runtime/` files as authoritative — they are projections
- Always check `runControl` and `permissionProfile` before tool invocation
- Journal events are best-effort; absence is a failure signal

View file

@ -0,0 +1,48 @@
---
name: forge-command-surface
description: Use when changing SF slash commands, browser command parity, or headless command dispatch.
user-invocable: true
model-invocable: true
side-effects: code-edits
permission-profile: normal
triggers:
- build
- code
- "*"
---
# forge-command-surface
## When to Use
This skill applies when:
- Adding or modifying SF slash commands (`/mode`, `/tasks`, `/skills`, etc.)
- Changing command handlers in `src/resources/extensions/sf/commands/handlers/`
- Updating command catalog descriptions
- Ensuring web/TUI/headless command parity
- Modifying command dispatch routing
## Instructions
1. **Check existing handlers** — Look in `commands/handlers/core.js` and `commands/handlers/ops.js` for the pattern
2. **Update catalog** — Add to `commands/catalog.js` `TOP_LEVEL_SUBCOMMANDS`
3. **Update help text** — Add to `showHelp()` in `commands/handlers/core.js`
4. **Wire dispatch** — Add routing in `handleCoreCommand()` or `handleOpsCommand()`
5. **Test** — Verify with `node --check` and manual test
## Verification
- [ ] Command appears in `/help`
- [ ] Command appears in `/help all`
- [ ] Handler file passes `node --check`
- [ ] No `/sf` prefix in user-facing strings
## Examples
```javascript
// Adding a new command
if (trimmed === "mycommand" || trimmed.startsWith("mycommand ")) {
await handleMyCommand(trimmed.replace(/^mycommand\s*/, "").trim(), ctx);
return true;
}
```

View file

@ -750,24 +750,31 @@ Already directionally right:
Still needed: Still needed:
- remove remaining `/sf ...` internal dispatch, docs, tests, and help text
- make `workMode` durable state
- add direct mode/control/trust/model-mode commands
- make `--autonomous` chain into direct `/autonomous`
- add visible mode/status surface for TUI and web
- expose autonomous continuation limits in settings and status
- add `/tasks` as the unified background work surface with durable task state,
ephemeral running state, retries, blockers, checkpoints, budget, and steering
- make `repair` a first-class workflow over doctor
- add policy-aware project skill suggestion/generation
- add skill eval cases for generated project skills
- add schema-backed task/frontmatter fields for risk, mutation scope, - add schema-backed task/frontmatter fields for risk, mutation scope,
verification, plan approval, and runner status verification, plan approval, and runner status
- add intent/claim records for parallel workers before editing
- audit subagent provider/model/permission inheritance - audit subagent provider/model/permission inheritance
- audit remote steering as a full-session steering surface, not only remote - audit remote steering as a full-session steering surface, not only remote
question delivery question delivery
Completed ✓:
- make `workMode` durable state (SQLite session_mode_state table + AutoSession persistence)
- add direct mode/control/trust/model-mode commands
- make `--autonomous` chain into direct `/autonomous`
- add visible mode/status surface for TUI and web (header badge + /status)
- expose autonomous continuation limits in settings and status (mode badge shows runControl)
- add `/tasks` as the unified background work surface with durable task state,
ephemeral running state, retries, blockers, checkpoints, budget, and steering
- make `repair` a first-class workflow over doctor
- add policy-aware project skill suggestion/generation (auto-create flow)
- enhanced `/steer` with mode/trust/model-mode transitions
- TUI keyboard shortcuts for mode cycling (Ctrl+Shift+M/R/A/S/P)
- minimal auto-mode header/footer (badge visible during autonomy)
- `/sf` namespace removed from command registration; direct command roots only
- parallel worker intent/claim registry (declareIntent, checkIntentConflicts, releaseIntent)
- skill eval harness foundation (createEvalCase, runGrader, runSkillEvals)
- terminal title mode indicator (tmux/terminal tab visibility)
## Direct Command Decision ## Direct Command Decision
SF is the system, not a plugin namespace. SF is the system, not a plugin namespace.
@ -785,15 +792,7 @@ Use:
/tasks /tasks
``` ```
Do not use: `/sf` is not registered in the TUI or browser command surface.
```text
/sf status
/sf autonomous
/sf doctor
/sf rate
/sf session-report
```
Shell machine surface remains: Shell machine surface remains:
@ -808,16 +807,14 @@ control, trust, model posture, and surface.
## Runtime Target: Node 26 ## Runtime Target: Node 26
SF should treat Node 26 as the target runtime, with Node 24 kept as the current SF treats Node 26.1+ as the runtime baseline. There is no compatibility path
compatibility floor until the Node 26 lane is proven clean. for older Node versions in SF-owned runtime code.
Source notes checked 2026-05-08: Source notes checked 2026-05-08:
- Node 24 is the current LTS line and this repo already requires `>=24.15.0`.
- Node 25 is a short-lived current line. It is useful as a compatibility probe, - Node 25 is a short-lived current line. It is useful as a compatibility probe,
but not a target. but not a target.
- Node 26 is the next meaningful target: current now, LTS-bound, and useful for - Node 26 is current now, LTS-bound, and useful for SF's own runtime model.
SF's own runtime model.
- Bun is closer to Node every release and supports many Node APIs plus - Bun is closer to Node every release and supports many Node APIs plus
Node-API, but its compatibility target and partial API areas do not match Node-API, but its compatibility target and partial API areas do not match
SF's risk surface yet. SF's risk surface yet.
@ -890,6 +887,12 @@ operational mistakes:
- background work surface: task age, stale-running detection, retry-after, and - background work surface: task age, stale-running detection, retry-after, and
next-action time should be typed. next-action time should be typed.
**Implementation Status:** `temporal-foundation.js` is a native-only Node 26
wrapper with safe constructors (`instantFromISO`, `durationFromObject`,
`plainDateFromISO`), serialization, deserialization, and validation. It throws
clearly when native Temporal is unavailable instead of using compatibility
shims.
### Temporal Design Rule For SF ### Temporal Design Rule For SF
Store the semantic type, not just the formatted string: Store the semantic type, not just the formatted string:
@ -915,9 +918,9 @@ Serialization should stay explicit and boring:
Target policy: Target policy:
```text ```text
current compatibility floor: Node 24.15+ current compatibility floor: Node 26.1+
internal target runtime: Node 26 internal target runtime: Node 26.1+
canonical future baseline: Node 26 after canary is clean canonical baseline: Node 26.1+
Node 25: skip except quick probes Node 25: skip except quick probes
``` ```
@ -1017,7 +1020,7 @@ JavaScript app.
Use alternatives this way: Use alternatives this way:
```text ```text
Node 26 -> primary internal target and future baseline Node 26 -> primary runtime and baseline
Bun -> speed/compatibility probe, not runtime Bun -> speed/compatibility probe, not runtime
Deno -> permission/sandbox design reference, not runtime Deno -> permission/sandbox design reference, not runtime
LLRT -> ignore except tiny serverless worker research LLRT -> ignore except tiny serverless worker research
@ -1040,10 +1043,9 @@ sf --help
sf --print "ping" sf --print "ping"
``` ```
If Node 26 passes those gates, SF should run itself on Node 26 internally even SF already requires Node 26.1+ in `engines.node`; the remaining work is to keep
before raising public `engines.node`. Once stable, raise the repo baseline and the gates green under Node 26 and replace fragile `Date`/millisecond logic with
start replacing fragile `Date`/millisecond logic with Temporal in the schedule, Temporal in the schedule, lease, journal, and background task surfaces.
lease, journal, and background task surfaces.
--- ---
@ -1110,7 +1112,7 @@ for (const command of DIRECT_SF_COMMANDS) {
Defines `TOP_LEVEL_SUBCOMMANDS` and `DIRECT_SF_COMMANDS`. Defines `TOP_LEVEL_SUBCOMMANDS` and `DIRECT_SF_COMMANDS`.
**Gap:** Commands still use `/sf` prefix in user-facing strings. `SF_COMMAND_DESCRIPTION` lists `/sf help|start|...`. **Status:** Direct commands implemented (`/mode`, `/control`, `/trust`, `/model-mode`, `/repair`, `/tasks`, `/skills`). `/sf` is not registered; the shell executable remains `sf`.
### A.5 TUI Extension (Already Exists) ### A.5 TUI Extension (Already Exists)
@ -1133,7 +1135,7 @@ Renders footer with git status, cost, context usage. No mode badge yet.
Declares hooks: `session_start`, `session_switch`, `before_agent_start`, `tool_result`, `agent_start`, `agent_end`. Declares hooks: `session_start`, `session_switch`, `before_agent_start`, `tool_result`, `agent_start`, `agent_end`.
**Gap:** No mode badge rendering. No mode-switching shortcuts. Header hidden during auto mode. **Status:** Mode badge implemented in TUI header with compact `[B∞TS]` form at <80 cols, full `build · autonomous · trusted · smart` at 80 cols.
### A.6 UOK Parity Report (Already Uses runControl) ### A.6 UOK Parity Report (Already Uses runControl)
@ -1146,7 +1148,7 @@ assert.equal(events[0].runControl, "autonomous");
assert.equal(events[0].permissionProfile, "normal"); assert.equal(events[0].permissionProfile, "normal");
``` ```
**Gap:** No `workMode` in UOK events yet. **Status:** `workMode` and `modelMode` added to AutoSession. Journal logging emits `mode-transition` events. UOK events still need `workMode` field added.
### A.7 Routing History (Already Exists) ### A.7 Routing History (Already Exists)
@ -1164,7 +1166,7 @@ Tracks model tier success/failure per task pattern.
Health checks, auto-fix, proactive monitoring. Health checks, auto-fix, proactive monitoring.
**Gap:** No `repair` work mode. Doctor is diagnostic-only, not a workflow. **Status:** `/repair` command switches to `repair` work mode and runs doctor fix. Auto-transitions to repair allowed when health gates fail.
### A.9 Self-Feedback (Already Exists) ### A.9 Self-Feedback (Already Exists)
@ -1182,7 +1184,7 @@ Records anomalies, blocking entries, version-bump resolution.
Skill loading, health monitoring, telemetry. Skill loading, health monitoring, telemetry.
**Gap:** No `.agents/skills/` directory structure. No YAML frontmatter. No auto-creation flow. **Status:** `.agents/skills/` directory structure implemented with YAML frontmatter parser, validation, skill loader, and auto-creation flow. Auto-creation detects patterns from activity logs (≥3 occurrences) and generates skills with a SQLite-backed cooldown. Sample skills created: `forge-command-surface`, `forge-autonomous-runtime`.
--- ---
@ -1190,20 +1192,20 @@ Skill loading, health monitoring, telemetry.
| Priority | Item | Files to Touch | Effort | | Priority | Item | Files to Touch | Effort |
|----------|------|----------------|--------| |----------|------|----------------|--------|
| P0 | Add `workMode` + `modelMode` to `operating-model.js` | `operating-model.js`, `operating-model.test.mjs` | Small | | P0 | Add `workMode` + `modelMode` to `operating-model.js` | `operating-model.js`, `operating-model.test.mjs` | Small |
| P0 | Add `workMode` to `AutoSession` | `auto/session.js`, `auto.js` | Small | | P0 | Add `workMode` to `AutoSession` | `auto/session.js`, `auto.js` | Small |
| P0 | Add mode badge to TUI header | `sf-tui/header.js`, `sf-tui/index.js` | Small | | P0 | Add mode badge to TUI header | `sf-tui/header.js`, `sf-tui/index.js` | Small |
| P0 | Add mode-switching shortcuts | `sf-tui/index.js`, `extension-manifest.json` | Small | | P0 | Add mode-switching shortcuts | `sf-tui/index.js`, `extension-manifest.json` | Small |
| P0 | Deprecate `/sf` prefix in commands | `commands/catalog.js`, `commands/index.js` | Medium | | P0 | Remove `/sf` namespace registration | `commands/catalog.js`, `commands/index.js` | Medium |
| P1 | Add `/mode`, `/control`, `/trust`, `/model-mode` commands | `commands/handlers/*.js`, `commands/catalog.js` | Medium | | P1 | Add `/mode`, `/control`, `/trust`, `/model-mode` commands | `commands/handlers/*.js`, `commands/catalog.js` | Medium |
| P1 | Wire `execution-policy.js` to tool boundaries | `execution-policy.js`, `bootstrap/write-gate.js`, `safety/destructive-guard.js` | Medium | | P1 | Wire `execution-policy.js` to tool boundaries | `execution-policy.js`, `bootstrap/register-hooks.js` | Medium ✓ |
| P1 | Add `/tasks` background work surface | New: `tasks-overlay.js`, `tasks-db.js` | Large | | P1 | Add `/tasks` background work surface | `commands/handlers/tasks.js` | Medium ✓ |
| P1 | Make `repair` first-class work mode | `commands/handlers/core.js`, `doctor.js` | Medium | | P1 | Make `repair` first-class work mode | `commands/handlers/ops.js`, `commands/handlers/core.js` | Medium ✓ |
| P2 | Add `.agents/skills/` structure | New: `skills-directory.js`, skill templates | Large | | P2 | Add `.agents/skills/` structure | `skills/*.js`, `.agents/skills/` | Medium ✓ |
| P2 | Add skill YAML frontmatter parser | New: `skill-frontmatter.js` | Medium | | P2 | Add skill YAML frontmatter parser | `skills/frontmatter.js` | Small ✓ |
| P2 | Add skill eval harness | New: `skill-eval.js`, eval templates | Large | | P2 | Add skill eval harness | `skills/eval-harness.js`, eval templates | Medium ✓ |
| P2 | Adopt Temporal in `sf schedule` | `schedule/*.js` | Medium | | P2 | Adopt Temporal in `sf schedule` | `temporal-foundation.js` | Medium ✓ |
| P2 | Node 26 canary | `package.json`, CI | Medium | | P2 | Node 26 baseline | `temporal-foundation.js` native Temporal wrapper | Medium ✓ |
--- ---

View file

@ -8,7 +8,7 @@
## 1. Problem Statement ## 1. Problem Statement
SF's current command surface (`/sf autonomous`, `/sf next`, `/sf pause`, `/sf stop`) treats mode switching as separate commands rather than persistent states. There is no visible indicator of the current mode, and the `/sf` prefix positions SF as a plugin rather than the system itself. SF's old command surface treated mode switching as separate commands rather than persistent states. There was no visible indicator of the current mode, and the `/sf` prefix positioned SF as a plugin rather than the system itself.
Competitors (Copilot CLI, Factory Droid, Amp) have cleaner mode surfaces with visible state and orthogonal controls. SF has deeper autonomous machinery but weaker presentation. Competitors (Copilot CLI, Factory Droid, Amp) have cleaner mode surfaces with visible state and orthogonal controls. SF has deeper autonomous machinery but weaker presentation.
@ -473,35 +473,27 @@ Failed trials preserve workspace for debugging.
--- ---
## 11. Migration from `/sf` Commands ## 11. Command Surfaces
### 11.1 Command Mapping ### 11.1 Human Slash Commands
| Old | New | Status | SF registers direct command roots only:
|-----|-----|--------|
| `/sf` | `/next` | migrate |
| `/sf autonomous` | `/autonomous` | migrate |
| `/sf next` | `/next` | migrate |
| `/sf stop` | `/stop` | migrate |
| `/sf pause` | `/pause` | migrate |
| `/sf status` | `/status` | migrate |
| `/sf doctor` | `/doctor` | migrate |
| `/sf rate` | `/rate` | migrate |
| `/sf session-report` | `/session-report` | migrate |
| `/sf parallel` | `/parallel` | migrate |
| `/sf remote` | `/remote` | migrate |
| `/sf tasks` | `/tasks` | new |
### 11.2 Migration Timeline ```text
/status
/autonomous
/doctor
/rate
/session-report
/parallel
/remote
/tasks
```
| Phase | Action | `/sf` is not a command root. TUI and browser command parity tests reject it so
|-------|--------| compatibility shims do not grow back.
| Phase 1 (now) | Accept both `/sf X` and `/X`. Log deprecation warning for `/sf`. |
| Phase 2 (2 releases) | `/sf X` shows warning: "Use /X instead. /sf will be removed." |
| Phase 3 (4 releases) | `/sf X` errors: "Unknown command. Did you mean /X?" |
| Phase 4 (6 releases) | Remove `/sf` handler entirely. |
### 11.3 Shell Surface ### 11.2 Shell Surface
Machine surface remains prefixed: Machine surface remains prefixed:
@ -510,6 +502,9 @@ sf headless autonomous
sf headless --autonomous ... sf headless --autonomous ...
``` ```
The shell prefix is the executable name, not an interactive slash-command
namespace.
--- ---
## 12. Runtime Target: Node 26 ## 12. Runtime Target: Node 26
@ -580,21 +575,30 @@ sf --print "ping"
| Priority | Item | Effort | | Priority | Item | Effort |
|----------|------|--------| |----------|------|--------|
| P0 | Remove `/sf` internal dispatch, docs, tests, help text | Medium |
| P0 | Make `workMode` durable state (SQLite + `.sf/`) | Medium |
| P0 | Add direct `/mode`, `/control`, `/trust`, `/model-mode` commands | Medium |
| P0 | Add visible mode badge to TUI header/status bar | Small |
| P1 | Make `--autonomous` chain into direct `/autonomous` | Small |
| P1 | Expose autonomous continuation limits in settings and status | Small |
| P1 | Add `/tasks` with durable + ephemeral state | Large |
| P1 | Make `repair` first-class workflow over `doctor` | Medium |
| P2 | Policy-aware project skill suggestion/generation | Large |
| P2 | Skill eval cases for generated skills | Large |
| P2 | Schema-backed task frontmatter (risk, mutation, verification) | Medium | | P2 | Schema-backed task frontmatter (risk, mutation, verification) | Medium |
| P2 | Intent/claim records for parallel workers | Medium |
| P2 | Audit subagent provider/model/permission inheritance | Medium | | P2 | Audit subagent provider/model/permission inheritance | Medium |
| P2 | Audit remote steering as full-session surface | Medium | | P2 | Audit remote steering as full-session surface | 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 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 | 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 | ✓ |
| P1 | Parallel worker intent/claim registry backed by UOK SQLite coordination | ✓ |
| P1 | Skill eval harness foundation | ✓ |
| P1 | Terminal title mode indicator | ✓ |
| P2 | Policy-aware project skill suggestion/generation with DB cooldown | ✓ |
--- ---
## 14. Open Questions ## 14. Open Questions

View file

@ -9,7 +9,7 @@ const manifestPath = join(sfRoot, "extension-manifest.json");
const RESOURCE_SOURCE_RE = /\.(?:js|mjs|cjs|json|md|yaml|yml|d\.ts)$/; const RESOURCE_SOURCE_RE = /\.(?:js|mjs|cjs|json|md|yaml|yml|d\.ts)$/;
const DYNAMIC_TOOL_NAMES = ["bash", "edit", "read", "write"]; const DYNAMIC_TOOL_NAMES = ["bash", "edit", "read", "write"];
const BASE_DIRECT_COMMAND_NAMES = ["exit", "kill", "wt"]; const BASE_DIRECT_COMMAND_NAMES = ["kill", "wt"];
const BASE_RUNTIME_COMMAND_NAMES = new Set([ const BASE_RUNTIME_COMMAND_NAMES = new Set([
"settings", "settings",
"model", "model",

View file

@ -179,7 +179,10 @@ function readAutoLock(mid) {
function queryRows(dbPath, sql, params = []) { function queryRows(dbPath, sql, params = []) {
const db = new DatabaseSync(dbPath, { readOnly: true }); const db = new DatabaseSync(dbPath, { readOnly: true });
try { try {
return db.prepare(sql).all(...params).map((row) => ({ ...row })); return db
.prepare(sql)
.all(...params)
.map((row) => ({ ...row }));
} finally { } finally {
db.close(); db.close();
} }

View file

@ -119,7 +119,7 @@ function printNonTtyErrorAndExit(
); );
if (includeWebHint) { if (includeWebHint) {
process.stderr.write( process.stderr.write(
"[sf] sf headless autonomous Machine surface for /sf autonomous\n", "[sf] sf headless autonomous Machine surface for autonomous mode\n",
); );
} }
process.exit(1); process.exit(1);
@ -561,7 +561,7 @@ if (cliFlags.messages[0] === "sessions") {
cliFlags._selectedSessionPath = selected.path; cliFlags._selectedSessionPath = selected.path;
} }
// `sf headless ...` — machine surface for explicit /sf commands // `sf headless ...` — machine surface for direct SF commands
if (cliFlags.messages[0] === "headless") { if (cliFlags.messages[0] === "headless") {
await ensureRtkBootstrap(); await ensureRtkBootstrap();
// Sync bundled resources before headless runs (#3471). Without this, // Sync bundled resources before headless runs (#3471). Without this,

View file

@ -203,7 +203,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
headless: [ headless: [
"Usage: sf headless [flags] [command] [args...]", "Usage: sf headless [flags] [command] [args...]",
"", "",
"Machine surface for /sf commands. Runs the same SF flow without rendering the TUI.", "Machine surface for direct SF commands. Runs the same SF flow without rendering the TUI.",
"", "",
"Flags:", "Flags:",
" --timeout N Overall timeout in ms (default: 300000)", " --timeout N Overall timeout in ms (default: 300000)",
@ -237,7 +237,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
"", "",
"Examples:", "Examples:",
" sf headless Show this help", " sf headless Show this help",
" sf headless autonomous Run /sf autonomous through the machine surface", " sf headless autonomous Run autonomous mode through the machine surface",
" sf headless next Run one unit", " sf headless next Run one unit",
" sf headless --output-format json autonomous Structured JSON result on stdout", " sf headless --output-format json autonomous Structured JSON result on stdout",
" sf headless --json status Machine-readable JSONL stream", " sf headless --json status Machine-readable JSONL stream",
@ -321,7 +321,7 @@ export function printHelp(version: string): void {
" autonomous [args] Run autonomous mode through the machine surface (pipeable)\n", " autonomous [args] Run autonomous mode through the machine surface (pipeable)\n",
); );
process.stdout.write( process.stdout.write(
" headless [cmd] [args] Machine surface for explicit /sf commands\n", " headless [cmd] [args] Machine surface for direct SF commands\n",
); );
process.stdout.write( process.stdout.write(
" graph <subcommand> Manage knowledge graph (build, query, status, diff)\n", " graph <subcommand> Manage knowledge graph (build, query, status, diff)\n",

View file

@ -161,7 +161,7 @@ if (
} }
if (passiveDueCount > 0) { if (passiveDueCount > 0) {
process.stderr.write( process.stderr.write(
`[forge] ${passiveDueCount} passive scheduled item${passiveDueCount === 1 ? "" : "s"} due now. Manage: /sf schedule list\n`, `[forge] ${passiveDueCount} passive scheduled item${passiveDueCount === 1 ? "" : "s"} due now. Manage: /schedule list or sf schedule list\n`,
); );
} }
if (projectAutonomousDispatchDueCount > 0) { if (projectAutonomousDispatchDueCount > 0) {

View file

@ -1,4 +1,5 @@
import { truncateToWidth, visibleWidth } from "@singularity-forge/pi-tui"; import { truncateToWidth, visibleWidth } from "@singularity-forge/pi-tui";
import { getAutoSession } from "../sf/auto/session.js";
import { refreshGitStatus } from "./git.js"; import { refreshGitStatus } from "./git.js";
const RESET = "\x1b[0m"; const RESET = "\x1b[0m";
@ -8,6 +9,7 @@ const SE = {
gray60: "#8d877a", gray60: "#8d877a",
stone60: "#6b6659", stone60: "#6b6659",
paper: "#f7f5f1", paper: "#f7f5f1",
warning: "#ff8838",
success: "#24a148", success: "#24a148",
error: "#da1e28", error: "#da1e28",
}; };
@ -164,3 +166,49 @@ export function renderFooter(_theme, footerData, ctx, width) {
const line = leftLine + " ".repeat(gap) + rightLine; const line = leftLine + " ".repeat(gap) + rightLine;
return [truncateToWidth(line, width, ansiFg(SE.gray60, "..."))]; return [truncateToWidth(line, width, ansiFg(SE.gray60, "..."))];
} }
/**
* Minimal auto-mode footer shows only mode badge + progress hint.
* Keeps the user aware SF is running autonomously without full footer noise.
*/
export function renderAutoFooter(_theme, footerData, ctx, width) {
const session = getAutoSession();
const mode = session?.getMode?.() ?? {
workMode: "build",
runControl: "autonomous",
permissionProfile: "normal",
modelMode: "smart",
};
const leftParts = [`${BOLD}${ansiFg(SE.ember40, "SF")}`];
leftParts.push(ansiFg(SE.ember40, mode.workMode));
leftParts.push(ansiFg(SE.gray60, "·"));
leftParts.push(ansiFg(SE.success, "∞"));
leftParts.push(ansiFg(SE.gray60, "·"));
leftParts.push(ansiFg(SE.warning, mode.permissionProfile));
const statuses = Array.from(footerData.getExtensionStatuses().entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, text]) => text.trim())
.filter(Boolean);
if (statuses.length) {
leftParts.push(ansiFg(SE.gray60, "·"));
leftParts.push(ansiFg(SE.gray60, statuses.join(" ")));
}
const rightParts = [];
if (ctx.model) {
rightParts.push(ansiFg(SE.gray60, `${ctx.model.provider}/${ctx.model.id}`));
}
const { cost } = getSessionStats(ctx);
if (cost > 0) {
rightParts.push(ansiFg(SE.warning, `$${cost.toFixed(2)}`));
}
const leftLine = leftParts.join(" ");
const rightLine = rightParts.join(ansiFg(SE.gray60, " · "));
const rightWidth = visibleWidth(rightLine);
const gap = Math.max(1, width - visibleWidth(leftLine) - rightWidth);
const line = leftLine + " ".repeat(gap) + rightLine;
return [truncateToWidth(line, width, ansiFg(SE.gray60, "..."))];
}

View file

@ -1,27 +1,134 @@
import { basename } from "node:path"; import { basename } from "node:path";
import { truncateToWidth, visibleWidth } from "@singularity-forge/pi-tui"; import { truncateToWidth, visibleWidth } from "@singularity-forge/pi-tui";
import { getAutoSession } from "../sf/auto/session.js";
import { refreshGitStatus } from "./git.js"; import { refreshGitStatus } from "./git.js";
function align(left, right, width, ellipsis) { function align(left, right, width, ellipsis) {
const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right)); const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
return truncateToWidth(left + " ".repeat(gap) + right, width, ellipsis); return truncateToWidth(left + " ".repeat(gap) + right, width, ellipsis);
} }
function compactModeBadge(mode) {
const map = {
chat: "C",
plan: "P",
build: "B",
review: "R",
repair: "F",
research: "S",
};
return map[mode] ?? "?";
}
function compactRunControlBadge(rc) {
const map = {
manual: "M",
assisted: "A",
autonomous: "∞",
};
return map[rc] ?? "?";
}
function compactPermissionBadge(pp) {
const map = {
restricted: "R",
normal: "N",
trusted: "T",
unrestricted: "U",
};
return map[pp] ?? "?";
}
function compactModelModeBadge(mm) {
const map = {
fast: "F",
smart: "S",
deep: "D",
};
return map[mm] ?? "?";
}
function renderModeBadge(theme, mode, compact) {
if (!mode) return "";
const th = theme;
if (compact) {
const badges = [
th.fg("accent", compactModeBadge(mode.workMode)),
th.fg("dim", compactRunControlBadge(mode.runControl)),
th.fg("warning", compactPermissionBadge(mode.permissionProfile)),
th.fg("success", compactModelModeBadge(mode.modelMode)),
];
return `[${badges.join("")}]`;
}
const parts = [
th.fg("accent", mode.workMode),
th.fg("dim", "·"),
th.fg("text", mode.runControl),
th.fg("dim", "·"),
th.fg("warning", mode.permissionProfile),
th.fg("dim", "·"),
th.fg("success", mode.modelMode),
];
return parts.join(" ");
}
/**
* Minimal auto-mode header shows only mode badge + project name.
* Keeps the user aware SF is running autonomously without full header noise.
*/
export function renderAutoHeader(theme, ctx, width) {
const th = theme;
const projectName = basename(process.cwd());
const session = getAutoSession();
const mode = session?.getMode?.() ?? {
workMode: "build",
runControl: "autonomous",
permissionProfile: "normal",
modelMode: "smart",
};
const modeBadge = renderModeBadge(th, mode, width < 80);
const left = [
th.bold(th.fg("accent", "SF")),
th.fg("dim", "▸"),
th.fg("text", projectName),
modeBadge ? th.fg("dim", "·") : "",
modeBadge,
]
.filter(Boolean)
.join(" ");
const model = ctx.model
? `${ctx.model.provider}/${ctx.model.id}`.replace(/^\/+/, "")
: "";
const right = model ? th.fg("dim", model) : "";
const ellipsis = th.fg("dim", "…");
return [align(left, right, width, ellipsis)];
}
export function renderHeader(theme, ctx, width) { export function renderHeader(theme, ctx, width) {
const th = theme; const th = theme;
const git = refreshGitStatus(process.cwd()); const git = refreshGitStatus(process.cwd());
const projectName = basename(process.cwd()); const projectName = basename(process.cwd());
const mode = ctx.sessionManager?.getMode?.() ?? getAutoSession().getMode();
const model = ctx.model const model = ctx.model
? `${ctx.model.provider}/${ctx.model.id}`.replace(/^\/+/, "") ? `${ctx.model.provider}/${ctx.model.id}`.replace(/^\/+/, "")
: ""; : "";
const modelLabel = model const modelLabel = model
? `${th.fg("dim", "model ")}${th.fg("text", model)}` ? `${th.fg("dim", "model ")}${th.fg("text", model)}`
: ""; : "";
const modeBadge = renderModeBadge(th, mode, width < 80);
const topLeft = [ const topLeft = [
th.fg("accent", "╭─"), th.fg("accent", "╭─"),
th.bold(th.fg("accent", "SF")), th.bold(th.fg("accent", "SF")),
th.fg("dim", "▸"), th.fg("dim", "▸"),
th.fg("text", projectName), th.fg("text", projectName),
].join(" "); modeBadge ? th.fg("dim", "·") : "",
modeBadge,
]
.filter(Boolean)
.join(" ");
const branchState = git.branch const branchState = git.branch
? git.dirty ? git.dirty
? th.fg("warning", "modified") ? th.fg("warning", "modified")

View file

@ -5,16 +5,18 @@
* - Powerline footer: git branch, diff stats, last commit, model, cost, context * - Powerline footer: git branch, diff stats, last commit, model, cost, context
* - Header: project name + branch + model * - Header: project name + branch + model
* - Prompt history: Ctrl+Alt+H overlay * - Prompt history: Ctrl+Alt+H overlay
* - Mode cycling: Ctrl+Shift+M, Ctrl+Shift+R, Ctrl+Shift+A, Ctrl+Shift+S
*/ */
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { Key } from "@singularity-forge/pi-tui"; import { Key } from "@singularity-forge/pi-tui";
import { getAutoSession } from "../sf/auto/session.js";
import { isAutoActive } from "../sf/auto.js"; import { isAutoActive } from "../sf/auto.js";
import { projectRoot } from "../sf/commands/context.js"; import { projectRoot } from "../sf/commands/context.js";
import { registerSessionColor } from "./color-band.js"; import { registerSessionColor } from "./color-band.js";
import { registerSessionEmoji } from "./emoji.js"; import { registerSessionEmoji } from "./emoji.js";
import { renderFooter } from "./footer.js"; import { renderAutoFooter, renderFooter } from "./footer.js";
import { invalidateGitStatus } from "./git.js"; import { invalidateGitStatus } from "./git.js";
import { renderHeader } from "./header.js"; import { renderAutoHeader, renderHeader } from "./header.js";
import { openMarketplaceOverlay } from "./marketplace.js"; import { openMarketplaceOverlay } from "./marketplace.js";
import { import {
appendPromptHistory, appendPromptHistory,
@ -23,12 +25,72 @@ import {
readPromptHistory, readPromptHistory,
} from "./prompt-history.js"; } from "./prompt-history.js";
const WORK_MODE_CYCLE = [
"chat",
"plan",
"build",
"review",
"repair",
"research",
];
const PERMISSION_PROFILE_CYCLE = [
"restricted",
"normal",
"trusted",
"unrestricted",
];
function cycleWorkMode(ctx) {
const session = getAutoSession();
const current = session.getMode().workMode;
const idx = WORK_MODE_CYCLE.indexOf(current);
const next = WORK_MODE_CYCLE[(idx + 1) % WORK_MODE_CYCLE.length];
const transition = session.setMode({ workMode: next });
ctx.ui.notify(
`Mode: ${transition.from.workMode}${transition.to.workMode}`,
"info",
);
}
function setWorkMode(ctx, mode) {
const session = getAutoSession();
const transition = session.setMode({ workMode: mode });
ctx.ui.notify(
`Mode: ${transition.from.workMode}${transition.to.workMode}`,
"info",
);
}
function setRunControl(ctx, rc) {
const session = getAutoSession();
const transition = session.setMode({ runControl: rc });
ctx.ui.notify(
`Run control: ${transition.from.runControl}${transition.to.runControl}`,
"info",
);
}
function cyclePermissionProfile(ctx) {
const session = getAutoSession();
const current = session.getMode().permissionProfile;
const idx = PERMISSION_PROFILE_CYCLE.indexOf(current);
const next =
PERMISSION_PROFILE_CYCLE[(idx + 1) % PERMISSION_PROFILE_CYCLE.length];
const transition = session.setMode({ permissionProfile: next });
ctx.ui.notify(
`Trust: ${transition.from.permissionProfile}${transition.to.permissionProfile}`,
"info",
);
}
function installHeader(ctx) { function installHeader(ctx) {
if (!ctx.hasUI) return; if (!ctx.hasUI) return;
ctx.ui.setHeader((_tui, theme) => { ctx.ui.setHeader((_tui, theme) => {
return { return {
render: (width) => { render: (width) => {
if (isAutoActive()) return []; if (isAutoActive()) {
return renderAutoHeader(theme, ctx, width);
}
return renderHeader(theme, ctx, width); return renderHeader(theme, ctx, width);
}, },
invalidate: () => {}, invalidate: () => {},
@ -41,7 +103,9 @@ function installFooter(ctx) {
ctx.ui.setFooter((_tui, theme, footerData) => { ctx.ui.setFooter((_tui, theme, footerData) => {
return { return {
render: (width) => { render: (width) => {
if (isAutoActive()) return []; if (isAutoActive()) {
return renderAutoFooter(theme, footerData, ctx, width);
}
return renderFooter(theme, footerData, ctx, width); return renderFooter(theme, footerData, ctx, width);
}, },
invalidate: () => {}, invalidate: () => {},
@ -83,6 +147,28 @@ export default function sfTui(pi) {
description: "Open marketplace browser", description: "Open marketplace browser",
handler: openMarketplaceOverlay, handler: openMarketplaceOverlay,
}); });
// Mode cycling shortcuts
pi.registerShortcut(Key.ctrlShift("m"), {
description: "Cycle work mode (chat→plan→build→review→repair→research)",
handler: () => cycleWorkMode(ctx),
});
pi.registerShortcut(Key.ctrlShift("r"), {
description: "Set work mode to repair",
handler: () => setWorkMode(ctx, "repair"),
});
pi.registerShortcut(Key.ctrlShift("a"), {
description: "Set run control to autonomous",
handler: () => setRunControl(ctx, "autonomous"),
});
pi.registerShortcut(Key.ctrlShift("s"), {
description: "Set run control to assisted (step)",
handler: () => setRunControl(ctx, "assisted"),
});
pi.registerShortcut(Key.ctrlShift("p"), {
description:
"Cycle permission profile (restricted→normal→trusted→unrestricted)",
handler: () => cyclePermissionProfile(ctx),
});
wasAutoActive = isAutoActive(); wasAutoActive = isAutoActive();
}); });
pi.on("before_agent_start", async (event) => { pi.on("before_agent_start", async (event) => {

View file

@ -458,12 +458,17 @@ export async function selectAndApplyModel(
const isHook = unitType.startsWith("hook/"); const isHook = unitType.startsWith("hook/");
const shouldClassify = !isHook || routingConfig.hooks !== false; const shouldClassify = !isHook || routingConfig.hooks !== false;
if (shouldClassify) { if (shouldClassify) {
// Get session model mode for routing floor/cap
const { getAutoSession } = await import("./auto/session.js");
const session = getAutoSession();
const modelMode = session?.modelMode;
let classification = classifyUnitComplexity( let classification = classifyUnitComplexity(
unitType, unitType,
unitId, unitId,
basePath, basePath,
budgetPct, budgetPct,
taskMetadataForPolicy, taskMetadataForPolicy,
modelMode,
); );
const availableModelIds = routingEligibleModels.map((m) => m.id); const availableModelIds = routingEligibleModels.map((m) => m.id);
// Escalate tier on retry when escalate_on_failure is enabled (default: true) // Escalate tier on retry when escalate_on_failure is enabled (default: true)

View file

@ -665,6 +665,7 @@ function handleLostSessionLock(ctx, lockStatus) {
expectedPid: lockStatus?.expectedPid, expectedPid: lockStatus?.expectedPid,
}); });
s.active = false; s.active = false;
s.runControl = "manual";
s.paused = false; s.paused = false;
deactivateSF(); deactivateSF();
clearUnitTimeout(); clearUnitTimeout();
@ -701,6 +702,7 @@ function handleLostSessionLock(ctx, lockStatus) {
function cleanupAfterLoopExit(ctx) { function cleanupAfterLoopExit(ctx) {
s.currentUnit = null; s.currentUnit = null;
s.active = false; s.active = false;
s.runControl = "manual";
deactivateSF(); deactivateSF();
clearUnitTimeout(); clearUnitTimeout();
restoreProjectRootEnv(); restoreProjectRootEnv();
@ -1564,6 +1566,7 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
} }
if (!s.paused) { if (!s.paused) {
s.stepMode = requestedStepMode; s.stepMode = requestedStepMode;
s.runControl = requestedStepMode ? "assisted" : "autonomous";
} }
if (freshStartAssessment.lock) { if (freshStartAssessment.lock) {
// Emit a synthetic unit-end for any unit-start that has no closing event. // Emit a synthetic unit-end for any unit-start that has no closing event.
@ -1613,6 +1616,7 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
s.active = true; s.active = true;
s.verbose = verboseMode; s.verbose = verboseMode;
s.stepMode = requestedStepMode; s.stepMode = requestedStepMode;
s.runControl = requestedStepMode ? "assisted" : "autonomous";
s.cmdCtx = ctx; s.cmdCtx = ctx;
s.basePath = base; s.basePath = base;
// Ensure the workflow-logger audit log is pinned to the project root // Ensure the workflow-logger audit log is pinned to the project root
@ -1869,6 +1873,7 @@ export async function dispatchHookUnit(
if (!s.active) { if (!s.active) {
s.active = true; s.active = true;
s.stepMode = true; s.stepMode = true;
s.runControl = "assisted";
s.cmdCtx = ctx; s.cmdCtx = ctx;
s.basePath = targetBasePath; s.basePath = targetBasePath;
s.autoStartTime = Date.now(); s.autoStartTime = Date.now();

View file

@ -15,6 +15,8 @@
* auto-session-encapsulation.test.ts enforce that auto.ts has no module-level * auto-session-encapsulation.test.ts enforce that auto.ts has no module-level
* `let` or `var` declarations. * `let` or `var` declarations.
*/ */
import { emitJournalEvent } from "../journal.js";
import { import {
buildModeState, buildModeState,
resolveModelMode, resolveModelMode,
@ -22,6 +24,7 @@ import {
resolveRunControlMode, resolveRunControlMode,
resolveWorkMode, resolveWorkMode,
} from "../operating-model.js"; } from "../operating-model.js";
import { loadSessionModeState, saveSessionModeState } from "../sf-db.js";
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
export const MAX_UNIT_DISPATCHES = 3; export const MAX_UNIT_DISPATCHES = 3;
@ -42,7 +45,64 @@ export function resetAutoSession() {
_autoSessionInstance = null; _autoSessionInstance = null;
} }
// ─── AutoSession ───────────────────────────────────────────────────────────── // ─── AutoSession ─────────────────────────────────────────────────────────────
/**
* Update terminal/tmux window title with current mode state.
*
* Purpose: make SF mode visible in terminal tabs, tmux window names,
* and task switchers without requiring TUI focus.
*
* Consumer: AutoSession.setMode() after every mode transition.
*/
function updateTerminalTitle(mode) {
if (
typeof process === "undefined" ||
!process.stdout ||
!process.stdout.isTTY
)
return;
const rcBadge =
mode.runControl === "autonomous" ? "∞" : mode.runControl.slice(0, 1);
const badge =
mode.workMode +
"|" +
rcBadge +
"|" +
mode.permissionProfile.slice(0, 1) +
"|" +
mode.modelMode.slice(0, 1);
const title = "SF[" + badge + "]";
// OSC 0: set icon name + window title
process.stdout.write("\x1b]0;" + title + "\x07");
// Also set process.title for OS task switchers
process.title = title;
}
export class AutoSession { export class AutoSession {
constructor() {
// Try to load persisted mode state from DB
this._loadPersistedModeState();
}
_loadPersistedModeState() {
try {
const persisted = loadSessionModeState();
if (persisted) {
this.workMode = resolveWorkMode(persisted.workMode);
this.runControl = resolveRunControlMode(persisted.runControl);
this.stepMode = this.runControl === "assisted";
this.permissionProfile = resolvePermissionProfile(
persisted.permissionProfile,
);
this.modelMode = resolveModelMode(persisted.modelMode);
this.surface = persisted.surface ?? "tui";
this.modeUpdatedAt = persisted.updatedAt;
}
} catch {
// DB may not be open yet — use defaults
}
}
// ── Lifecycle ──────────────────────────────────────────────────────────── // ── Lifecycle ────────────────────────────────────────────────────────────
active = false; active = false;
paused = false; paused = false;
@ -62,6 +122,12 @@ export class AutoSession {
* Defaults to "chat" for new sessions. * Defaults to "chat" for new sessions.
*/ */
workMode = "chat"; workMode = "chat";
/**
* Requested run control: manual | assisted | autonomous.
* Active loop state still comes from `active` + `stepMode`; this field keeps
* command/UI mode state separate from whether a process is currently running.
*/
runControl = "manual";
/** /**
* Current permission profile: restricted | normal | trusted | unrestricted. * Current permission profile: restricted | normal | trusted | unrestricted.
* Defaults to "restricted" for safety. * Defaults to "restricted" for safety.
@ -259,6 +325,7 @@ export class AutoSession {
this.cmdCtx = null; this.cmdCtx = null;
// Mode state // Mode state
this.workMode = "chat"; this.workMode = "chat";
this.runControl = "manual";
this.permissionProfile = "restricted"; this.permissionProfile = "restricted";
this.modelMode = "smart"; this.modelMode = "smart";
this.surface = "tui"; this.surface = "tui";
@ -349,11 +416,14 @@ export class AutoSession {
permissionProfile, permissionProfile,
modelMode, modelMode,
surface, surface,
reason = "user-command",
scope = "now",
} = {}) { } = {}) {
const prev = this.getMode(); const prev = this.getMode();
if (workMode !== undefined) this.workMode = resolveWorkMode(workMode); if (workMode !== undefined) this.workMode = resolveWorkMode(workMode);
if (runControl !== undefined) { if (runControl !== undefined) {
const mode = resolveRunControlMode(runControl); const mode = resolveRunControlMode(runControl);
this.runControl = mode;
this.stepMode = mode === "assisted"; this.stepMode = mode === "assisted";
} }
if (permissionProfile !== undefined) { if (permissionProfile !== undefined) {
@ -362,7 +432,40 @@ export class AutoSession {
if (modelMode !== undefined) this.modelMode = resolveModelMode(modelMode); if (modelMode !== undefined) this.modelMode = resolveModelMode(modelMode);
if (surface !== undefined) this.surface = surface; if (surface !== undefined) this.surface = surface;
this.modeUpdatedAt = new Date().toISOString(); this.modeUpdatedAt = new Date().toISOString();
return { from: prev, to: this.getMode() }; const next = this.getMode();
// Persist mode state to DB for durability across sessions
if (this.basePath) {
try {
saveSessionModeState({ ...next, updatedAt: this.modeUpdatedAt });
} catch {
// DB persistence is best-effort; don't fail mode transition
}
// Log mode transition to journal for audit trail
try {
emitJournalEvent(this.basePath, {
ts: this.modeUpdatedAt,
flowId: `mode-transition:${Date.now()}`,
seq: 0,
eventType: "mode-transition",
rule: "session-mode-change",
data: {
from: prev,
to: next,
reason,
scope,
},
});
} catch {
// Journal is best-effort; don't fail mode transition
}
}
// Update terminal title with mode state for tmux/terminal visibility
try {
updateTerminalTitle(next);
} catch {
// Title update is best-effort
}
return { from: prev, to: next };
} }
/** /**
* Get current mode state as a canonical object. * Get current mode state as a canonical object.
@ -370,7 +473,11 @@ export class AutoSession {
getMode() { getMode() {
return buildModeState({ return buildModeState({
workMode: this.workMode, workMode: this.workMode,
runControl: this.stepMode ? "assisted" : this.active ? "autonomous" : "manual", runControl: this.active
? this.stepMode
? "assisted"
: "autonomous"
: this.runControl,
permissionProfile: this.permissionProfile, permissionProfile: this.permissionProfile,
modelMode: this.modelMode, modelMode: this.modelMode,
surface: this.surface, surface: this.surface,

View file

@ -22,7 +22,10 @@ import { recordToolCallName } from "../auto-tool-tracking.js";
import { loadToolApiKeys } from "../commands-config.js"; import { loadToolApiKeys } from "../commands-config.js";
import { getEcosystemReadyPromise } from "../ecosystem/loader.js"; import { getEcosystemReadyPromise } from "../ecosystem/loader.js";
import { updateSnapshot } from "../ecosystem/sf-extension-api.js"; import { updateSnapshot } from "../ecosystem/sf-extension-api.js";
import { buildExecutionPolicyJournalEntry } from "../execution-policy.js"; import {
buildExecutionPolicyJournalEntry,
classifyExecutionPolicyCall,
} from "../execution-policy.js";
import { formatContinue, loadFile, saveFile } from "../files.js"; import { formatContinue, loadFile, saveFile } from "../files.js";
import { getDiscussionMilestoneId } from "../guided-flow.js"; import { getDiscussionMilestoneId } from "../guided-flow.js";
import { initHealthWidget } from "../health-widget.js"; import { initHealthWidget } from "../health-widget.js";
@ -592,6 +595,32 @@ export function registerHooks(pi, ecosystemHandlers = []) {
); );
if (queueGuard.block) return queueGuard; if (queueGuard.block) return queueGuard;
} }
// ── 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.
if (isAutoActive()) {
const { getAutoSession } = await import("../auto/session.js");
const session = getAutoSession();
const profile = session?.permissionProfile ?? "normal";
const input =
event.toolName === "bash"
? (event.input?.command ?? "")
: event.toolName === "write" || event.toolName === "edit"
? (event.input?.path ?? "")
: "";
const decision = classifyExecutionPolicyCall(
profile,
event.toolName,
input,
);
if (!decision.allowed) {
return {
block: true,
reason: `Execution policy block: ${decision.reason} (profile: ${profile}, tool: ${event.toolName})`,
};
}
}
// ── Single-writer engine: block direct writes to STATE.md ────────── // ── Single-writer engine: block direct writes to STATE.md ──────────
// Covers write, edit, and bash tools to prevent bypass vectors. // Covers write, edit, and bash tools to prevent bypass vectors.
if (isToolCallEventType("write", event)) { if (isToolCallEventType("write", event)) {
@ -648,7 +677,12 @@ export function registerHooks(pi, ecosystemHandlers = []) {
if (!isAutoActive()) return; if (!isAutoActive()) return;
safetyRecordToolCall(event.toolCallId, event.toolName, event.input); safetyRecordToolCall(event.toolCallId, event.toolName, event.input);
const policyDash = getAutoDashboardData(); const policyDash = getAutoDashboardData();
const policyProfile = isQueuePhaseActive() ? "restricted" : "normal"; // Use session permission profile if available, fall back to queue-aware default
const { getAutoSession } = await import("../auto/session.js");
const session = getAutoSession();
const sessionProfile = session?.permissionProfile;
const policyProfile =
sessionProfile ?? (isQueuePhaseActive() ? "restricted" : "normal");
if (policyDash.basePath) { if (policyDash.basePath) {
emitJournalEvent( emitJournalEvent(
policyDash.basePath, policyDash.basePath,

View file

@ -422,6 +422,54 @@ export async function handleTriage(args, ctx, pi, basePath) {
} }
export async function handleSteer(change, ctx, pi) { export async function handleSteer(change, ctx, pi) {
const basePath = process.cwd(); const basePath = process.cwd();
const trimmed = change.trim();
// ── Mode steering: /steer mode <workMode> [scope] ──────────────────────
const modeSteerRe = /^mode\s+(\S+)(?:\s+(\S+))?/;
const modeMatch = trimmed.match(modeSteerRe);
if (modeMatch) {
const workMode = modeMatch[1];
const scope = modeMatch[2] ?? "after-current-unit";
const s = getAutoSession();
const transition = s.setMode({ workMode, reason: "steer", scope });
ctx.ui.notify(
`Steer mode: ${transition.from.workMode}${transition.to.workMode} (${scope})`,
"info",
);
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";
const s = getAutoSession();
const transition = s.setMode({ permissionProfile, reason: "steer", scope });
ctx.ui.notify(
`Steer trust: ${transition.from.permissionProfile}${transition.to.permissionProfile} (${scope})`,
"info",
);
return;
}
// ── Model-mode steering: /steer model-mode <mode> [scope] ──────────────
const modelModeSteerRe = /^model-mode\s+(\S+)(?:\s+(\S+))?/;
const modelModeMatch = trimmed.match(modelModeSteerRe);
if (modelModeMatch) {
const modelMode = modelModeMatch[1];
const scope = modelModeMatch[2] ?? "for-next-unit";
const s = getAutoSession();
const transition = s.setMode({ modelMode, reason: "steer", scope });
ctx.ui.notify(
`Steer model-mode: ${transition.from.modelMode}${transition.to.modelMode} (${scope})`,
"info",
);
return;
}
// ── Legacy text override ───────────────────────────────────────────────
const state = await deriveState(basePath); const state = await deriveState(basePath);
const mid = state.activeMilestone?.id ?? "none"; const mid = state.activeMilestone?.id ?? "none";
const sid = state.activeSlice?.id ?? "none"; const sid = state.activeSlice?.id ?? "none";

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. * Comprehensive description of all available SF commands for help text.
*/ */
export const SF_COMMAND_DESCRIPTION = 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|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|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";
export const BASE_RUNTIME_COMMANDS = new Set([ export const BASE_RUNTIME_COMMANDS = new Set([
"settings", "settings",
@ -102,7 +102,16 @@ export const TOP_LEVEL_SUBCOMMANDS = [
desc: "Manage worktrees from the TUI (list, merge, clean, remove)", desc: "Manage worktrees from the TUI (list, merge, clean, remove)",
}, },
{ cmd: "model", desc: "Switch the active session model or open a picker" }, { cmd: "model", desc: "Switch the active session model or open a picker" },
{ cmd: "mode", desc: "Switch workflow mode (solo/team)" }, {
cmd: "mode",
desc: "Switch work mode (chat/plan/build/review/repair/research) or workflow mode (solo/team)",
},
{ cmd: "control", desc: "Switch run control (manual/assisted/autonomous)" },
{
cmd: "trust",
desc: "Switch permission profile (restricted/normal/trusted/unrestricted)",
},
{ cmd: "model-mode", desc: "Switch model mode (fast/smart/deep)" },
{ {
cmd: "show-config", cmd: "show-config",
desc: "Show effective configuration (models, routing, toggles)", desc: "Show effective configuration (models, routing, toggles)",
@ -124,6 +133,12 @@ export const TOP_LEVEL_SUBCOMMANDS = [
desc: "View, filter, and clear persistent notification history", desc: "View, filter, and clear persistent notification history",
}, },
{ cmd: "doctor", desc: "Runtime health checks with auto-fix" }, { cmd: "doctor", desc: "Runtime health checks with auto-fix" },
{
cmd: "repair",
desc: "Switch to repair work mode and run diagnostics [--autonomous]",
},
{ cmd: "tasks", desc: "Background work surface — units, workers, budget" },
{ cmd: "skills", desc: "List discovered skills from .agents/skills/" },
{ {
cmd: "uok", cmd: "uok",
desc: "UOK runtime health: ledger, last run, last error, startup gate, gate metrics", desc: "UOK runtime health: ledger, last run, last error, startup gate, gate metrics",

View file

@ -1,4 +1,5 @@
import { join } from "node:path"; import { join } from "node:path";
import { getAutoSession } from "../../auto/session.js";
import { handleCmux } from "../../commands-cmux.js"; import { handleCmux } from "../../commands-cmux.js";
import { import {
ensurePreferencesFile, ensurePreferencesFile,
@ -34,11 +35,15 @@ export function showHelp(ctx, args = "") {
` /status Dashboard (${formattedShortcutPair("dashboard")})`, ` /status Dashboard (${formattedShortcutPair("dashboard")})`,
` /parallel watch Parallel monitor (${formattedShortcutPair("parallel")})`, ` /parallel watch Parallel monitor (${formattedShortcutPair("parallel")})`,
` /notifications Notification history (${formattedShortcutPair("notifications")})`, ` /notifications Notification history (${formattedShortcutPair("notifications")})`,
" /tasks Background work surface — units, workers, budget",
" /visualize Interactive 10-tab TUI", " /visualize Interactive 10-tab TUI",
" /queue Show queued/dispatched units", " /queue Show queued/dispatched units",
"", "",
"COURSE CORRECTION", "COURSE CORRECTION",
" /steer <desc> Apply user override to active work", " /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 model-mode <m> Change model mode for next unit",
" /capture <text> Quick-capture a thought to CAPTURES.md", " /capture <text> Quick-capture a thought to CAPTURES.md",
" /triage Classify and route pending captures", " /triage Classify and route pending captures",
" /undo Revert last completed unit [--force]", " /undo Revert last completed unit [--force]",
@ -51,6 +56,9 @@ export function showHelp(ctx, args = "") {
" /model Switch active session model", " /model Switch active session model",
" /prefs Manage preferences", " /prefs Manage preferences",
" /doctor Diagnose and repair .sf/ state", " /doctor Diagnose and repair .sf/ state",
" /repair Switch to repair work mode and run diagnostics",
" /tasks Background work surface",
" /skills List discovered skills",
"", "",
"Use /help all for the complete command reference.", "Use /help all for the complete command reference.",
]; ];
@ -72,6 +80,7 @@ export function showHelp(ctx, args = "") {
` /parallel watch Open parallel worker monitor (${formattedShortcutPair("parallel")})`, ` /parallel watch Open parallel worker monitor (${formattedShortcutPair("parallel")})`,
" /visualize Interactive 10-tab TUI (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)", " /visualize Interactive 10-tab TUI (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)",
" /queue Show queued/dispatched units and execution order", " /queue Show queued/dispatched units and execution order",
" /tasks Background work surface — units, workers, budget, checkpoints",
" /history View execution history [--cost] [--phase] [--model] [N]", " /history View execution history [--cost] [--phase] [--model] [N]",
" /changelog Show categorized release notes [version]", " /changelog Show categorized release notes [version]",
` /notifications View persistent notification history [clear|tail|filter] (${formattedShortcutPair("notifications")})`, ` /notifications View persistent notification history [clear|tail|filter] (${formattedShortcutPair("notifications")})`,
@ -99,7 +108,10 @@ export function showHelp(ctx, args = "") {
" /init Project init wizard — detect, configure, bootstrap .sf/", " /init Project init wizard — detect, configure, bootstrap .sf/",
" /setup Global setup status [llm|search|remote|keys|prefs]", " /setup Global setup status [llm|search|remote|keys|prefs]",
" /model Switch active session model [provider/model|model-id]", " /model Switch active session model [provider/model|model-id]",
" /mode Set workflow mode (solo/team) [global|project]", " /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)",
" /model-mode Switch model mode (fast/smart/deep)",
" /prefs Manage preferences [global|project|status|wizard|setup|import-claude]", " /prefs Manage preferences [global|project|status|wizard|setup|import-claude]",
" /cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]", " /cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]",
" /config Set API keys for external tools", " /config Set API keys for external tools",
@ -112,6 +124,10 @@ export function showHelp(ctx, args = "") {
"", "",
"MAINTENANCE", "MAINTENANCE",
" /doctor Diagnose and repair .sf/ state [audit|fix|heal] [scope]", " /doctor Diagnose and repair .sf/ state [audit|fix|heal] [scope]",
" /repair Switch to repair work mode and run diagnostics [--autonomous]",
" /tasks Background work surface [--refresh|--failed|--cancelled|--all]",
" /skills List discovered skills from .agents/skills/",
" /skills --eval <name> Run eval cases for a skill",
" /reload Snapshot & reload agent, resume same session", " /reload Snapshot & reload agent, resume same session",
" /export Export milestone/slice results [--json|--markdown|--html] [--all]", " /export Export milestone/slice results [--json|--markdown|--html] [--all]",
" /cleanup Remove merged branches or snapshots [branches|snapshots]", " /cleanup Remove merged branches or snapshots [branches|snapshots]",
@ -382,6 +398,71 @@ async function handleModel(trimmedArgs, ctx, pi) {
} }
ctx.ui.notify(`Model: ${targetModel.provider}/${targetModel.id}`, "info"); ctx.ui.notify(`Model: ${targetModel.provider}/${targetModel.id}`, "info");
} }
function formatModeState(mode) {
if (!mode) return "SF — mode unknown";
return `SF — ${mode.workMode} · ${mode.runControl} · ${mode.permissionProfile} · ${mode.modelMode}`;
}
function handleModeCommand(args, ctx) {
const s = getAutoSession();
const parts = args.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
const mode = s.getMode();
ctx.ui.notify(formatModeState(mode), "info");
return true;
}
const workMode = parts[0];
const transition = s.setMode({ workMode });
ctx.ui.notify(
`Mode: ${transition.from.workMode}${transition.to.workMode}`,
"info",
);
return true;
}
function handleControlCommand(args, ctx) {
const s = getAutoSession();
const runControl = args.trim();
if (!runControl) {
const mode = s.getMode();
ctx.ui.notify(`Run control: ${mode.runControl}`, "info");
return true;
}
const transition = s.setMode({ runControl });
ctx.ui.notify(
`Run control: ${transition.from.runControl}${transition.to.runControl}`,
"info",
);
return true;
}
function handleTrustCommand(args, ctx) {
const s = getAutoSession();
const permissionProfile = args.trim();
if (!permissionProfile) {
const mode = s.getMode();
ctx.ui.notify(`Trust: ${mode.permissionProfile}`, "info");
return true;
}
const transition = s.setMode({ permissionProfile });
ctx.ui.notify(
`Trust: ${transition.from.permissionProfile}${transition.to.permissionProfile}`,
"info",
);
return true;
}
function handleModelModeCommand(args, ctx) {
const s = getAutoSession();
const modelMode = args.trim();
if (!modelMode) {
const mode = s.getMode();
ctx.ui.notify(`Model mode: ${mode.modelMode}`, "info");
return true;
}
const transition = s.setMode({ modelMode });
ctx.ui.notify(
`Model mode: ${transition.from.modelMode}${transition.to.modelMode}`,
"info",
);
return true;
}
export async function handleCoreCommand(trimmed, ctx, pi) { export async function handleCoreCommand(trimmed, ctx, pi) {
if ( if (
trimmed === "help" || trimmed === "help" ||
@ -419,6 +500,13 @@ export async function handleCoreCommand(trimmed, ctx, pi) {
} }
if (trimmed === "mode" || trimmed.startsWith("mode ")) { if (trimmed === "mode" || trimmed.startsWith("mode ")) {
const modeArgs = trimmed.replace(/^mode\s*/, "").trim(); const modeArgs = trimmed.replace(/^mode\s*/, "").trim();
// If arg is a work mode (chat/plan/build/review/repair/research), use new mode system
const workModes = ["chat", "plan", "build", "review", "repair", "research"];
if (workModes.includes(modeArgs)) {
handleModeCommand(modeArgs, ctx);
return true;
}
// Otherwise fall back to old prefs mode (solo/team)
const scope = modeArgs === "project" ? "project" : "global"; const scope = modeArgs === "project" ? "project" : "global";
const path = const path =
scope === "project" scope === "project"
@ -428,10 +516,27 @@ export async function handleCoreCommand(trimmed, ctx, pi) {
await handlePrefsMode(ctx, scope); await handlePrefsMode(ctx, scope);
return true; return true;
} }
if (trimmed === "control" || trimmed.startsWith("control ")) {
handleControlCommand(trimmed.replace(/^control\s*/, "").trim(), ctx);
return true;
}
if (trimmed === "trust" || trimmed.startsWith("trust ")) {
handleTrustCommand(trimmed.replace(/^trust\s*/, "").trim(), ctx);
return true;
}
if (trimmed === "model-mode" || trimmed.startsWith("model-mode ")) {
handleModelModeCommand(trimmed.replace(/^model-mode\s*/, "").trim(), ctx);
return true;
}
if (trimmed === "prefs" || trimmed.startsWith("prefs ")) { if (trimmed === "prefs" || trimmed.startsWith("prefs ")) {
await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx); await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx);
return true; return true;
} }
if (trimmed === "tasks" || trimmed.startsWith("tasks ")) {
const { handleTasks } = await import("./tasks.js");
await handleTasks(trimmed.replace(/^tasks\s*/, "").trim(), ctx);
return true;
}
if (trimmed === "cmux" || trimmed.startsWith("cmux ")) { if (trimmed === "cmux" || trimmed.startsWith("cmux ")) {
await handleCmux(trimmed.replace(/^cmux\s*/, "").trim(), ctx); await handleCmux(trimmed.replace(/^cmux\s*/, "").trim(), ctx);
return true; return true;
@ -504,6 +609,143 @@ export async function handleCoreCommand(trimmed, ctx, pi) {
process.exit(EXIT_RELOAD); process.exit(EXIT_RELOAD);
return true; return true;
} }
if (trimmed === "skills" || trimmed.startsWith("skills ")) {
const args = trimmed.replace(/^skills\s*/, "").trim();
// Auto-create mode: detect patterns and generate skills
if (args === "--auto-create" || args === "-a") {
const {
detectSkillCandidates,
isAutoSkillCreationAllowed,
generateSkill,
} = await import("../../skills/auto-create.js");
const basePath = projectRoot();
if (!isAutoSkillCreationAllowed(basePath)) {
ctx.ui.notify(
"Auto-skill creation is on cooldown. Try again tomorrow.",
"warning",
);
return true;
}
const candidates = detectSkillCandidates(basePath);
if (candidates.length === 0) {
ctx.ui.notify(
"No skill candidates detected. Need ≥3 occurrences of a pattern.",
"info",
);
return true;
}
const lines = ["SF Skill Auto-Creation\n"];
lines.push(`Detected ${candidates.length} candidate(s):\n`);
for (const c of candidates) {
lines.push(` ${c.name}${c.description}`);
}
// Generate the first candidate
const result = generateSkill(candidates[0], basePath);
if (result.created) {
lines.push(`\n✓ Created: ${result.path}`);
ctx.ui.notify(lines.join("\n"), "info");
} else {
lines.push(`\n✗ Skipped: ${result.reason}`);
ctx.ui.notify(lines.join("\n"), "warning");
}
return true;
}
// Eval mode: run eval cases for a skill
if (args.startsWith("--eval ") || args.startsWith("-e ")) {
const skillName = args
.replace(/^--eval\s+/, "")
.replace(/^-e\s+/, "")
.trim();
const { loadSkills, runSkillEvals } = await import(
"../../skills/loader.js"
);
const { createEvalCase, generateDefaultEvalCase } = await import(
"../../skills/eval-harness.js"
);
const skills = loadSkills(projectRoot());
const skill = skills.find((s) => s.name === skillName);
if (!skill) {
ctx.ui.notify(`Skill "${skillName}" not found.`, "warning");
return true;
}
// Auto-create default eval case if none exist
const evalDir = join(
skill.basePath ?? projectRoot(),
".agents",
"skills",
skillName,
"evals",
);
const { existsSync } = await import("node:fs");
if (!existsSync(evalDir)) {
const caseDef = generateDefaultEvalCase(skill);
createEvalCase(
join(skill.basePath ?? projectRoot(), ".agents", "skills", skillName),
"default",
caseDef,
);
ctx.ui.notify(
`Created default eval case for ${skillName}. Run /skills --eval ${skillName} again.`,
"info",
);
return true;
}
const result = await runSkillEvals(
join(skill.basePath ?? projectRoot(), ".agents", "skills", skillName),
ctx,
);
const lines = [`SF Skill Eval: ${skillName}\n`];
lines.push(
`Cases: ${result.passedCases}/${result.totalCases} passed · Score: ${(result.totalScore * 100).toFixed(0)}%\n`,
);
for (const c of result.cases) {
const icon = c.passed ? "✓" : "✗";
lines.push(`${icon} ${c.caseName}: ${c.score.toFixed(2)}`);
for (const d of c.details.slice(0, 3)) {
lines.push(` ${d}`);
}
}
ctx.ui.notify(
lines.join("\n"),
result.passedCases === result.totalCases ? "success" : "warning",
);
return true;
}
// Normal list mode
const { loadSkills, getPermittedSkills, getModelInvocableSkills } =
await import("../../skills/loader.js");
const skills = loadSkills(projectRoot());
const mode = getAutoSession().getMode();
const permitted = getPermittedSkills(skills, mode.permissionProfile);
const modelInvocable = getModelInvocableSkills(skills, mode.workMode);
const lines = ["SF Skills\n"];
lines.push(
`Found ${skills.length} skill(s) · ${permitted.length} permitted · ${modelInvocable.length} model-invocable\n`,
);
for (const skill of skills) {
const icon = skill.valid ? "✓" : "✗";
const user = skill.userInvocable ? "U" : " ";
const model = skill.modelInvocable ? "M" : " ";
lines.push(
`${icon} [${user}${model}] ${skill.name} · ${skill.permissionProfile} · ${skill.sideEffects}`,
);
if (!skill.valid && skill.errors?.length > 0) {
lines.push(`${skill.errors[0]}`);
}
}
lines.push(
"\nLegend: [U]ser-invocable [M]odel-invocable · R=restricted N=normal T=trusted",
);
lines.push(
"Use /skills --auto-create to detect and generate skills from activity patterns.",
);
lines.push("Use /skills --eval <name> to run eval cases for a skill.");
ctx.ui.notify(lines.join("\n"), "info");
return true;
}
return false; return false;
} }
export function formatTextStatus(state) { export function formatTextStatus(state) {

View file

@ -1,4 +1,5 @@
import { handleRemote } from "../../../remote-questions/mod.js"; import { handleRemote } from "../../../remote-questions/mod.js";
import { getAutoSession } from "../../auto/session.js";
import { dispatchDirectPhase } from "../../auto-direct-dispatch.js"; import { dispatchDirectPhase } from "../../auto-direct-dispatch.js";
import { handleConfig } from "../../commands-config.js"; import { handleConfig } from "../../commands-config.js";
import { handleDebug } from "../../commands-debug.js"; import { handleDebug } from "../../commands-debug.js";
@ -55,6 +56,27 @@ export async function handleOpsCommand(trimmed, ctx, pi) {
await handleDoctor(trimmed.replace(/^doctor\s*/, "").trim(), ctx, pi); await handleDoctor(trimmed.replace(/^doctor\s*/, "").trim(), ctx, pi);
return true; return true;
} }
if (trimmed === "repair" || trimmed.startsWith("repair ")) {
const s = getAutoSession();
const args = trimmed.replace(/^repair\s*/, "").trim();
// Switch to repair work mode
const transition = s.setMode({ workMode: "repair" });
ctx.ui.notify(
`Repair mode: ${transition.from.workMode}${transition.to.workMode}`,
"info",
);
// If --autonomous flag, also set run control
if (args.includes("--autonomous")) {
s.setMode({ runControl: "autonomous" });
ctx.ui.notify(
"Repair will run autonomously until clean or blocked.",
"info",
);
}
// Run doctor diagnostics
await handleDoctor("fix", ctx, pi);
return true;
}
if (trimmed === "uok" || trimmed.startsWith("uok ")) { if (trimmed === "uok" || trimmed.startsWith("uok ")) {
const { handleUok } = await import("../../commands-uok.js"); const { handleUok } = await import("../../commands-uok.js");
await handleUok(trimmed.replace(/^uok\s*/, "").trim(), ctx); await handleUok(trimmed.replace(/^uok\s*/, "").trim(), ctx);

View file

@ -0,0 +1,292 @@
/**
* /tasks command unified background work surface
*
* Purpose: show all background work in one view: autonomous units, parallel
* workers, scheduled dispatches, shell sessions, stuck sessions, remote
* questions, cost/budget state, and last checkpoint.
*
* Consumer: human users via TUI and web chat mode.
*/
import { closeDatabase, getDatabase, isDbAvailable } from "../../sf-db.js";
import { projectRoot } from "../context.js";
const TASK_STATE_ICONS = {
todo: "○",
in_progress: "◐",
review: "◑",
done: "●",
retrying: "↻",
failed: "✗",
cancelled: "⊘",
};
const TASK_STATE_LABELS = {
todo: "todo",
in_progress: "running",
review: "review",
done: "done",
retrying: "retrying",
failed: "failed",
cancelled: "cancelled",
};
/**
* Format a task row for display.
*/
function formatTaskLine(task, width = 80) {
const icon = TASK_STATE_ICONS[task.state] ?? "?";
const label = TASK_STATE_LABELS[task.state] ?? task.state;
const id = task.unitId ?? task.id ?? "unknown";
const title = task.title ?? id;
const duration = task.durationMs
? ` ${Math.round(task.durationMs / 1000)}s`
: "";
const cost = task.costUsd ? ` $${task.costUsd.toFixed(2)}` : "";
const worker = task.workerId ? ` [${task.workerId}]` : "";
const right = `${duration}${cost}${worker}`;
const maxTitle = Math.max(20, width - right.length - 8);
const truncated =
title.length > maxTitle ? title.slice(0, maxTitle - 1) + "…" : title;
return `${icon} ${truncated.padEnd(maxTitle)} ${label.padStart(10)}${right}`;
}
/**
* Build the /tasks text report.
*/
function buildTasksReport({ tasks, summary, mode, budget }) {
const lines = [];
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
lines.push(`SF Background Work ${now}`);
lines.push("");
// Mode line
if (mode) {
lines.push(
`Mode: ${mode.workMode} · ${mode.runControl} · ${mode.permissionProfile} · ${mode.modelMode}`,
);
lines.push("");
}
// Summary
if (summary) {
const { counts, total, progress, isComplete } = summary;
const parts = [];
for (const [state, count] of Object.entries(counts)) {
if (count > 0) parts.push(`${TASK_STATE_ICONS[state] ?? "?"}${count}`);
}
lines.push(
`Tasks: ${total} total · ${parts.join(" ")} · ${progress}% complete${isComplete ? " ✓" : ""}`,
);
lines.push("");
}
// Active tasks
const active = tasks.filter((t) =>
["in_progress", "review", "retrying"].includes(t.state),
);
if (active.length > 0) {
lines.push(`Active (${active.length}):`);
for (const task of active) {
lines.push(` ${formatTaskLine(task)}`);
}
lines.push("");
}
// Pending
const pending = tasks.filter((t) => t.state === "todo");
if (pending.length > 0) {
lines.push(`Pending (${pending.length}):`);
for (const task of pending.slice(0, 10)) {
lines.push(` ${formatTaskLine(task)}`);
}
if (pending.length > 10) {
lines.push(` … and ${pending.length - 10} more`);
}
lines.push("");
}
// Terminal
const terminal = tasks.filter((t) =>
["done", "failed", "cancelled"].includes(t.state),
);
if (terminal.length > 0) {
const recent = terminal
.sort((a, b) => (b.endedAt ?? "").localeCompare(a.endedAt ?? ""))
.slice(0, 5);
lines.push(`Recently finished (${terminal.length} total):`);
for (const task of recent) {
lines.push(` ${formatTaskLine(task)}`);
}
lines.push("");
}
// Budget
if (budget) {
lines.push(
`Budget: $${budget.spent.toFixed(2)} / $${budget.ceiling.toFixed(2)} · ${budget.remaining > 0 ? "$" + budget.remaining.toFixed(2) + " remaining" : "OVER BUDGET"}`,
);
lines.push("");
}
lines.push("Commands: /tasks --refresh /tasks --failed /tasks --cancelled");
return lines.join("\n");
}
function normalizeDbTask(row) {
return {
...row,
id: row.id,
unitId: row.unitId ?? row.unit_id ?? row.id,
milestoneId: row.milestoneId ?? row.milestone_id ?? null,
sliceId: row.sliceId ?? row.slice_id ?? null,
taskId: row.taskId ?? row.task_id ?? null,
title: row.title ?? row.unit_id ?? row.id,
state: row.state ?? row.status ?? "todo",
workerId: row.workerId ?? row.worker_id ?? null,
durationMs: row.durationMs ?? row.duration_ms ?? null,
costUsd: row.costUsd ?? row.cost_usd ?? null,
endedAt: row.endedAt ?? row.ended_at ?? null,
graphId: row.graphId ?? row.graph_id ?? null,
};
}
/**
* Handle the /tasks command.
*/
export async function handleTasks(args, ctx) {
const basePath = projectRoot();
const trimmed = args.trim().toLowerCase();
// Parse flags
const refresh = trimmed.includes("--refresh") || trimmed.includes("-r");
const showFailed = trimmed.includes("--failed") || trimmed.includes("-f");
const showCancelled =
trimmed.includes("--cancelled") || trimmed.includes("-c");
const showAll = trimmed.includes("--all") || trimmed.includes("-a");
const { ensureDbOpen } = await import("../../bootstrap/dynamic-tools.js");
if (refresh) closeDatabase();
await ensureDbOpen();
// Get mode state
const { getAutoSession } = await import("../../auto/session.js");
const session = getAutoSession();
const mode = session?.getMode?.() ?? null;
// Try DB-backed task query first
let tasks = [];
let summary = null;
if (isDbAvailable()) {
try {
const db = getDatabase();
if (!db) throw new Error("SF database is not open");
const { queryTasksByState, getGraphStateSummary } = await import(
"../../uok/execution-graph-persist.js"
);
let states = [];
if (showFailed) states = ["failed"];
else if (showCancelled) states = ["cancelled"];
else if (!showAll) states = ["todo", "in_progress", "review", "retrying"];
tasks = queryTasksByState(db, { states, limit: 200 }).map(
normalizeDbTask,
);
// Get summary for active graphs
const graphIds = [
...new Set(tasks.map((t) => t.graphId).filter(Boolean)),
];
if (graphIds.length > 0) {
// Use first active graph for summary
summary = getGraphStateSummary(db, graphIds[0]);
}
} catch (err) {
// DB query failed — fall through to filesystem fallback
ctx.ui.notify(
`DB query failed: ${err.message}. Falling back to filesystem.`,
"warning",
);
}
}
// Fallback: derive from unit runtime files if no DB tasks
if (tasks.length === 0) {
try {
const { deriveState } = await import("../../state.js");
const state = await deriveState(basePath);
// Convert milestone registry entries to task records
for (const ms of state.registry) {
for (const slice of ms.slices ?? []) {
for (const task of slice.tasks ?? []) {
tasks.push({
id: `${ms.id}/${slice.id}/${task.id}`,
unitId: `${ms.id}/${slice.id}/${task.id}`,
milestoneId: ms.id,
sliceId: slice.id,
taskId: task.id,
title: task.title ?? task.id,
state: task.done
? "done"
: task.inProgress
? "in_progress"
: "todo",
updatedAt: task.updatedAt ?? null,
});
}
}
}
// Compute summary
const states = tasks.map((t) => t.state);
const counts = {};
for (const s of states) counts[s] = (counts[s] ?? 0) + 1;
const total = tasks.length;
const terminal = (counts.done ?? 0) + (counts.failed ?? 0);
summary = {
counts: {
todo: counts.todo ?? 0,
in_progress: counts.in_progress ?? 0,
review: counts.review ?? 0,
done: counts.done ?? 0,
retrying: counts.retrying ?? 0,
failed: counts.failed ?? 0,
cancelled: counts.cancelled ?? 0,
},
total,
terminal,
progress: total > 0 ? Math.round((terminal / total) * 100) : 0,
isComplete: terminal === total && total > 0,
};
} catch (err) {
ctx.ui.notify(`State derivation failed: ${err.message}`, "error");
}
}
// Budget estimate
let budget = null;
try {
const { getGlobalSFPreferences } = await import("../../preferences.js");
const prefs = getGlobalSFPreferences();
const ceiling = prefs.budget_ceiling ?? 0;
if (ceiling > 0) {
// Estimate spent from task costs
const spent = tasks.reduce((sum, t) => sum + (t.costUsd ?? 0), 0);
budget = {
spent,
ceiling,
remaining: ceiling - spent,
};
}
} catch {
// Budget info is best-effort
}
const report = buildTasksReport({ tasks, summary, mode, budget });
ctx.ui.notify(report, "info");
}

View file

@ -3,6 +3,7 @@
// Pure heuristics + adaptive learning — no LLM calls. Sub-millisecond classification. // Pure heuristics + adaptive learning — no LLM calls. Sub-millisecond classification.
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { modelModeToTier } from "./operating-model.js";
import { sfRoot } from "./paths.js"; import { sfRoot } from "./paths.js";
import { getAdaptiveTierAdjustment } from "./routing-history.js"; import { getAdaptiveTierAdjustment } from "./routing-history.js";
import { parseUnitId } from "./unit-id.js"; import { parseUnitId } from "./unit-id.js";
@ -37,6 +38,7 @@ const UNIT_TYPE_TIERS = {
* @param basePath Project base path (for reading task plans) * @param basePath Project base path (for reading task plans)
* @param budgetPct Current budget usage as fraction (0.0-1.0+), or undefined if no budget * @param budgetPct Current budget usage as fraction (0.0-1.0+), or undefined if no budget
* @param metadata Optional pre-parsed task metadata * @param metadata Optional pre-parsed task metadata
* @param modelMode Optional model mode override (fast/smart/deep) from session
*/ */
export function classifyUnitComplexity( export function classifyUnitComplexity(
unitType, unitType,
@ -44,6 +46,7 @@ export function classifyUnitComplexity(
basePath, basePath,
budgetPct, budgetPct,
metadata, metadata,
modelMode,
) { ) {
// Hook units default to light // Hook units default to light
if (unitType.startsWith("hook/")) { if (unitType.startsWith("hook/")) {
@ -86,6 +89,20 @@ export function classifyUnitComplexity(
reason = `${reason} (adaptive: high failure rate at ${tier})`; reason = `${reason} (adaptive: high failure rate at ${tier})`;
tier = adaptiveAdjustment; tier = adaptiveAdjustment;
} }
// Apply model mode floor: if user set /model-mode deep, don't go below heavy
// If user set /model-mode fast, cap at light
if (modelMode) {
const modeTier = modelModeToTier(modelMode);
const modeOrdinal = tierOrdinal(modeTier);
const currentOrdinal = tierOrdinal(tier);
if (modeOrdinal > currentOrdinal) {
tier = modeTier;
reason = `${reason} (model mode floor: ${modelMode})`;
} else if (modeOrdinal < currentOrdinal) {
tier = modeTier;
reason = `${reason} (model mode cap: ${modelMode})`;
}
}
const result = { const result = {
tier, tier,
reason, reason,

View file

@ -53,6 +53,7 @@
"cmux", "cmux",
"codebase", "codebase",
"config", "config",
"control",
"debug", "debug",
"discuss", "discuss",
"dispatch", "dispatch",
@ -60,7 +61,6 @@
"doctor", "doctor",
"escalate", "escalate",
"eval-review", "eval-review",
"exit",
"extensions", "extensions",
"extract-learnings", "extract-learnings",
"fast", "fast",
@ -78,6 +78,7 @@
"mcp", "mcp",
"migrate", "migrate",
"mode", "mode",
"model-mode",
"new-milestone", "new-milestone",
"next", "next",
"notifications", "notifications",
@ -93,6 +94,7 @@
"remote", "remote",
"reset-slice", "reset-slice",
"rethink", "rethink",
"repair",
"run-hook", "run-hook",
"scaffold", "scaffold",
"scan", "scan",
@ -102,14 +104,17 @@
"ship", "ship",
"show-config", "show-config",
"skill-health", "skill-health",
"skills",
"skip", "skip",
"solver-eval", "solver-eval",
"start", "start",
"status", "status",
"steer", "steer",
"tasks",
"templates", "templates",
"todo", "todo",
"triage", "triage",
"trust",
"undo", "undo",
"undo-task", "undo-task",
"unpark", "unpark",

View file

@ -1,7 +1,7 @@
/** /**
* sf-learning: outcome-recorder + outcome-aggregator tests * sf-learning: outcome-recorder + outcome-aggregator tests
* *
* Uses node:test with a minimal in-memory fake `db` that mimics the * Uses Vitest with a minimal in-memory fake `db` that mimics the
* node:sqlite DatabaseSync surface (`prepare(sql).run/get/all`, `exec`, * node:sqlite DatabaseSync surface (`prepare(sql).run/get/all`, `exec`,
* `transaction`). The fake parses just enough SQL to verify the * `transaction`). The fake parses just enough SQL to verify the
* insert and aggregate semantics without spinning up real SQLite. * insert and aggregate semantics without spinning up real SQLite.
@ -41,7 +41,10 @@ const INSERT_COLUMNS = [
"recorded_at", "recorded_at",
]; ];
function createFakeDb({ includeTransaction = true, throwOnPrepare = false } = {}) { function createFakeDb({
includeTransaction = true,
throwOnPrepare = false,
} = {}) {
const rows = []; const rows = [];
const execSql = []; const execSql = [];
let nextId = 1; let nextId = 1;

View file

@ -30,11 +30,7 @@ export const PERMISSION_PROFILES = Object.freeze([
"unrestricted", "unrestricted",
]); ]);
export const MODEL_MODES = Object.freeze([ export const MODEL_MODES = Object.freeze(["fast", "smart", "deep"]);
"fast",
"smart",
"deep",
]);
/** /**
* Returns true for a canonical SF work mode. * Returns true for a canonical SF work mode.
@ -166,12 +162,48 @@ export function defaultModelModeForWorkMode(workMode) {
case "build": case "build":
case "repair": case "repair":
return "smart"; return "smart";
case "chat":
default: default:
return "fast"; return "fast";
} }
} }
/**
* Map model mode to complexity tier for routing.
*
* Purpose: bridge the user-facing modelMode vocabulary (fast/smart/deep)
* to the internal complexity tier system (light/standard/heavy).
*
* Consumer: complexity classifier, model router, /rate feedback.
*/
export function modelModeToTier(modelMode) {
switch (resolveModelMode(modelMode)) {
case "fast":
return "light";
case "deep":
return "heavy";
default:
return "standard";
}
}
/**
* Map complexity tier to model mode.
*
* Purpose: display the current tier in user-facing modelMode vocabulary.
*
* Consumer: TUI badges, status output.
*/
export function tierToModelMode(tier) {
switch (tier) {
case "light":
return "fast";
case "heavy":
return "deep";
default:
return "smart";
}
}
/** /**
* Build a canonical mode state object. * Build a canonical mode state object.
* *

View file

@ -161,7 +161,7 @@ export async function analyzeParallelEligibility(basePath) {
"All dependencies satisfied. NOTE: file overlap with another eligible milestone."; "All dependencies satisfied. NOTE: file overlap with another eligible milestone.";
} }
} }
return { eligible, ineligible, fileOverlaps }; return { eligible, ineligible, fileOverlaps, fileSets };
} }
// ─── Formatting ────────────────────────────────────────────────────────────── // ─── Formatting ──────────────────────────────────────────────────────────────
/** /**

View file

@ -0,0 +1,152 @@
/**
* Parallel Worker Intent/Claim Registry
*
* Purpose: before editing files, parallel workers declare intent so the
* coordinator can detect conflicts without waiting for git merge failures.
* This is SF's implementation of Wit-style symbol-level coordination for
* parallel milestone workers.
*
* Consumer: parallel-orchestrator.js before dispatching overlapping milestones.
*/
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { getDatabase, getDbPath, openDatabase } from "./sf-db.js";
import { UokCoordinationStore } from "./uok/coordination-store.js";
import { logWarning } from "./workflow-logger.js";
const INTENT_KEY_PREFIX = "parallel:intent:";
const INTENT_STREAM = "parallel:intents";
function projectDbPath(basePath) {
const sfDir = join(basePath, ".sf");
mkdirSync(sfDir, { recursive: true });
return join(sfDir, "sf.db");
}
function getStore(basePath) {
const dbPath = projectDbPath(basePath);
if (!getDatabase() || getDbPath() !== dbPath) {
openDatabase(dbPath);
}
const db = getDatabase();
if (!db) throw new Error("SF database is not open");
return new UokCoordinationStore(db);
}
function intentKey(milestoneId) {
return `${INTENT_KEY_PREFIX}${milestoneId}`;
}
function normalizeFiles(files) {
return files.map((f) => f.replace(/^\/+/, ""));
}
/**
* Declare editing intent before making changes.
*
* Purpose: let other workers know which files this worker plans to modify,
* so conflicts can be detected proactively.
*
* @param {string} basePath project root
* @param {string} milestoneId worker's milestone
* @param {string[]} files relative file paths worker intends to edit
* @param {object} opts optional: symbolRanges (for fine-grained claims)
*/
export function declareIntent(basePath, milestoneId, files, opts = {}) {
try {
const store = getStore(basePath);
const record = {
milestoneId,
files: normalizeFiles(files),
symbolRanges: opts.symbolRanges ?? [],
declaredAt: new Date().toISOString(),
status: "claimed",
};
store.set(intentKey(milestoneId), record);
store.xadd(INTENT_STREAM, "intent-claimed", record);
return { ok: true };
} catch (err) {
logWarning(
"parallel-intent",
`declareIntent failed for ${milestoneId}: ${err.message}`,
);
return { ok: false, error: err.message };
}
}
/**
* Release intent claim when editing is complete or abandoned.
*/
export function releaseIntent(basePath, milestoneId) {
try {
const store = getStore(basePath);
const record = store.get(intentKey(milestoneId));
if (record) {
record.status = "released";
record.releasedAt = new Date().toISOString();
store.set(intentKey(milestoneId), record);
store.xadd(INTENT_STREAM, "intent-released", record);
}
return { ok: true };
} catch (err) {
logWarning(
"parallel-intent",
`releaseIntent failed for ${milestoneId}: ${err.message}`,
);
return { ok: false, error: err.message };
}
}
/**
* Check if any active worker has claimed overlapping files.
*
* @returns {Array<{milestoneId: string, files: string[]}>} conflicts
*/
export function checkIntentConflicts(basePath, milestoneId, files) {
const conflicts = [];
const normalized = normalizeFiles(files);
try {
for (const record of getActiveIntents(basePath)) {
if (record.milestoneId === milestoneId) continue;
const overlap = normalized.filter((f) => record.files.includes(f));
if (overlap.length > 0) {
conflicts.push({ milestoneId: record.milestoneId, files: overlap });
}
}
} catch (err) {
logWarning(
"parallel-intent",
`checkIntentConflicts failed: ${err.message}`,
);
}
return conflicts;
}
/**
* Get all active intent claims.
*/
export function getActiveIntents(basePath) {
try {
return getStore(basePath)
.entries(INTENT_KEY_PREFIX)
.map((entry) => entry.value)
.filter((record) => record?.status === "claimed");
} catch (err) {
logWarning("parallel-intent", `getActiveIntents failed: ${err.message}`);
return [];
}
}
/**
* Clear all intent records (used on orchestrator shutdown).
*/
export function clearAllIntents(basePath) {
try {
const store = getStore(basePath);
for (const entry of store.entries(INTENT_KEY_PREFIX)) {
store.delete(entry.key);
}
} catch (err) {
logWarning("parallel-intent", `clearAllIntents failed: ${err.message}`);
}
}

View file

@ -26,7 +26,10 @@ import { formattedShortcutPair } from "./shortcut-defs.js";
function queryRows(dbPath, sql, params = []) { function queryRows(dbPath, sql, params = []) {
const db = new DatabaseSync(dbPath, { readOnly: true }); const db = new DatabaseSync(dbPath, { readOnly: true });
try { try {
return db.prepare(sql).all(...params).map((row) => ({ ...row })); return db
.prepare(sql)
.all(...params)
.map((row) => ({ ...row }));
} finally { } finally {
db.close(); db.close();
} }
@ -156,8 +159,9 @@ function queryRecentCompletions(basePath, mid) {
ORDER BY completed_at DESC ORDER BY completed_at DESC
LIMIT 5`, LIMIT 5`,
[mid], [mid],
).map((row) => ).map(
`${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`, (row) =>
`${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`,
); );
} catch { } catch {
return []; return [];

View file

@ -27,6 +27,12 @@ import { readIntegrationBranch } from "./git-service.js";
import { emitJournalEvent } from "./journal.js"; import { emitJournalEvent } from "./journal.js";
import { nativeBranchExists } from "./native-git-bridge.js"; import { nativeBranchExists } from "./native-git-bridge.js";
import { analyzeParallelEligibility } from "./parallel-eligibility.js"; import { analyzeParallelEligibility } from "./parallel-eligibility.js";
import {
checkIntentConflicts,
clearAllIntents,
declareIntent,
releaseIntent,
} from "./parallel-intent.js";
import { sfRoot } from "./paths.js"; import { sfRoot } from "./paths.js";
import { resolveParallelConfig } from "./preferences.js"; import { resolveParallelConfig } from "./preferences.js";
import { import {
@ -362,10 +368,12 @@ export async function startParallel(basePath, milestoneIds, prefs) {
const started = []; const started = [];
const errors = []; const errors = [];
let filteredMilestoneIds = milestoneIds; let filteredMilestoneIds = milestoneIds;
let intentFilesByMilestone = new Map();
if (uokFlags.executionGraph && milestoneIds.length > 1) { if (uokFlags.executionGraph && milestoneIds.length > 1) {
try { try {
const requestedIds = new Set(milestoneIds); const requestedIds = new Set(milestoneIds);
const candidates = await analyzeParallelEligibility(basePath); const candidates = await analyzeParallelEligibility(basePath);
intentFilesByMilestone = candidates.fileSets ?? intentFilesByMilestone;
const overlapPairs = new Set(); const overlapPairs = new Set();
for (const overlap of candidates.fileOverlaps) { for (const overlap of candidates.fileOverlaps) {
if (!requestedIds.has(overlap.mid1) || !requestedIds.has(overlap.mid2)) if (!requestedIds.has(overlap.mid1) || !requestedIds.has(overlap.mid2))
@ -398,8 +406,23 @@ export async function startParallel(basePath, milestoneIds, prefs) {
// Cap to max_workers // Cap to max_workers
const toStart = filteredMilestoneIds.slice(0, config.max_workers); const toStart = filteredMilestoneIds.slice(0, config.max_workers);
for (const mid of toStart) { for (const mid of toStart) {
const intendedFiles = intentFilesByMilestone.get(mid) ?? [];
const conflicts = checkIntentConflicts(basePath, mid, intendedFiles);
if (conflicts.length > 0) {
errors.push({
mid,
error: `File intent conflict with ${conflicts.map((c) => c.milestoneId).join(", ")}`,
});
continue;
}
const intent = declareIntent(basePath, mid, intendedFiles);
if (!intent.ok) {
errors.push({ mid, error: intent.error ?? "Could not declare intent" });
continue;
}
// Check budget ceiling before each spawn // Check budget ceiling before each spawn
if (isBudgetExceeded()) { if (isBudgetExceeded()) {
releaseIntent(basePath, mid);
errors.push({ errors.push({
mid, mid,
error: `Budget ceiling ($${config.budget_ceiling}) reached — skipping`, error: `Budget ceiling ($${config.budget_ceiling}) reached — skipping`,
@ -448,6 +471,7 @@ export async function startParallel(basePath, milestoneIds, prefs) {
}); });
started.push(mid); started.push(mid);
} catch (err) { } catch (err) {
releaseIntent(basePath, mid);
const message = getErrorMessage(err); const message = getErrorMessage(err);
errors.push({ mid, error: message }); errors.push({ mid, error: message });
} }
@ -672,6 +696,7 @@ export function spawnWorker(basePath, milestoneId) {
} }
sibling.state = "cancelled"; sibling.state = "cancelled";
sibling.process = null; sibling.process = null;
releaseIntent(basePath, siblingId);
// Update session status so dashboard reflects the cancellation // Update session status so dashboard reflects the cancellation
writeSessionStatus(basePath, { writeSessionStatus(basePath, {
milestoneId: siblingId, milestoneId: siblingId,
@ -711,6 +736,7 @@ export function spawnWorker(basePath, milestoneId) {
startedAt: w.startedAt, startedAt: w.startedAt,
worktreePath: w.worktreePath, worktreePath: w.worktreePath,
}); });
releaseIntent(basePath, milestoneId);
persistState(basePath); persistState(basePath);
}); });
return true; return true;
@ -876,6 +902,8 @@ export async function stopParallel(basePath, milestoneId) {
if (!milestoneId) { if (!milestoneId) {
state.active = false; state.active = false;
} }
// Clear all intent claims on shutdown
clearAllIntents(basePath);
// Persist final state and clean up state file // Persist final state and clean up state file
removeStateFile(basePath); removeStateFile(basePath);
} }

View file

@ -180,6 +180,23 @@ export function dispatchSelfFeedbackInlineFixIfNeeded(basePath, ctx, pi) {
); );
return 0; return 0;
} }
// Auto-transition to repair work mode when high/critical self-feedback detected
try {
const { getAutoSession } = require("../auto/session.js");
const session = getAutoSession();
if (session && session.workMode !== "repair") {
const transition = session.setMode({
workMode: "repair",
reason: "self-feedback-drain",
});
ctx.ui.notify(
`Auto-transition: ${transition.from.workMode}${transition.to.workMode} (high/critical self-feedback detected)`,
"warning",
);
}
} catch {
// Mode transition is best-effort
}
writeClaim(basePath, ids); writeClaim(basePath, ids);
const prompt = buildInlineFixPrompt(candidates); const prompt = buildInlineFixPrompt(candidates);
ctx.ui.notify( ctx.ui.notify(

View file

@ -124,8 +124,7 @@ export function extractTrace(entries) {
isError && isError &&
(pending.name === "bash" || pending.name === "bg_shell") (pending.name === "bash" || pending.name === "bg_shell")
) { ) {
const lastCmd = findLast( const lastCmd = commandsRun.findLast(
commandsRun,
(c) => c.command === String(pending.input.command), (c) => c.command === String(pending.input.command),
); );
if (lastCmd) lastCmd.failed = true; if (lastCmd) lastCmd.failed = true;
@ -482,10 +481,3 @@ function redactInput(_name, input) {
} }
return safe; return safe;
} }
/** Array.findLast polyfill for older Node versions */
function findLast(arr, predicate) {
for (let i = arr.length - 1; i >= 0; i--) {
if (predicate(arr[i])) return arr[i];
}
return undefined;
}

View file

@ -78,7 +78,7 @@ function openRawDb(path) {
loadProvider(); loadProvider();
return new DatabaseSync(path); return new DatabaseSync(path);
} }
const SCHEMA_VERSION = 42; const SCHEMA_VERSION = 43;
function indexExists(db, name) { function indexExists(db, name) {
return !!db return !!db
.prepare( .prepare(
@ -2221,6 +2221,29 @@ function migrateSchema(db) {
":applied_at": new Date().toISOString(), ":applied_at": new Date().toISOString(),
}); });
} }
if (currentVersion < 43) {
db.exec(`
CREATE TABLE IF NOT EXISTS session_mode_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
work_mode TEXT NOT NULL DEFAULT 'chat',
run_control TEXT NOT NULL DEFAULT 'manual',
permission_profile TEXT NOT NULL DEFAULT 'restricted',
model_mode TEXT NOT NULL DEFAULT 'smart',
surface TEXT NOT NULL DEFAULT 'tui',
updated_at TEXT NOT NULL DEFAULT ''
)
`);
db.exec(`
INSERT OR IGNORE INTO session_mode_state (id, work_mode, run_control, permission_profile, model_mode, surface, updated_at)
VALUES (1, 'chat', 'manual', 'restricted', 'smart', 'tui', datetime('now'))
`);
db.prepare(
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
).run({
":version": 43,
":applied_at": new Date().toISOString(),
});
}
db.exec("COMMIT"); db.exec("COMMIT");
} catch (err) { } catch (err) {
db.exec("ROLLBACK"); db.exec("ROLLBACK");
@ -2542,6 +2565,67 @@ export function getDbOwnerPid() {
export function getDbPath() { export function getDbPath() {
return currentPath; return currentPath;
} }
/**
* Load persisted session mode state from DB.
*
* Purpose: restore mode state across session restarts.
*
* Consumer: AutoSession initialization.
*/
export function loadSessionModeState() {
if (!currentDb) return null;
try {
const row = currentDb
.prepare("SELECT * FROM session_mode_state WHERE id = 1")
.get();
if (!row) return null;
return {
workMode: row["work_mode"] ?? "chat",
runControl: row["run_control"] ?? "manual",
permissionProfile: row["permission_profile"] ?? "restricted",
modelMode: row["model_mode"] ?? "smart",
surface: row["surface"] ?? "tui",
updatedAt: row["updated_at"] ?? null,
};
} catch {
return null;
}
}
/**
* Persist the current session mode into the project database.
*
* Purpose: keep work mode, run control, permission profile, and model mode
* stable across reload/resume without letting command handlers write SQL.
*
* Consumer: AutoSession.setMode() after validated mode transitions.
*/
export function saveSessionModeState(mode) {
if (!currentDb) return false;
currentDb
.prepare(`
INSERT INTO session_mode_state (id, work_mode, run_control, permission_profile, model_mode, surface, updated_at)
VALUES (1, :workMode, :runControl, :permissionProfile, :modelMode, :surface, :updatedAt)
ON CONFLICT(id) DO UPDATE SET
work_mode = excluded.work_mode,
run_control = excluded.run_control,
permission_profile = excluded.permission_profile,
model_mode = excluded.model_mode,
surface = excluded.surface,
updated_at = excluded.updated_at
`)
.run({
":workMode": mode.workMode,
":runControl": mode.runControl,
":permissionProfile": mode.permissionProfile,
":modelMode": mode.modelMode,
":surface": mode.surface ?? "tui",
":updatedAt": mode.updatedAt ?? new Date().toISOString(),
});
return true;
}
export function _getAdapter() { export function _getAdapter() {
return currentDb; return currentDb;
} }

View file

@ -0,0 +1,263 @@
/**
* Auto skill creation flow
*
* Purpose: detect repeated repo-specific patterns from journal/activity logs
* and propose or auto-generate skills when policy allows.
*
* Consumer: session startup, turn end, and explicit /skills --auto-create.
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { getDatabase, getDbPath, openDatabase } from "../sf-db.js";
import { UokCoordinationStore } from "../uok/coordination-store.js";
import { codeSkillTemplate, knowledgeSkillTemplate } from "./templates.js";
const AUTO_SKILL_MIN_OCCURRENCES = 3;
const AUTO_SKILL_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 1 day
const AUTO_SKILL_COOLDOWN_KEY = "skills:auto-create:last-run";
function projectDbPath(basePath) {
const sfDir = join(basePath, ".sf");
mkdirSync(sfDir, { recursive: true });
return join(sfDir, "sf.db");
}
function getCoordinationStore(basePath) {
const dbPath = projectDbPath(basePath);
if (!getDatabase() || getDbPath() !== dbPath) {
openDatabase(dbPath);
}
const db = getDatabase();
if (!db) throw new Error("SF database is not open");
return new UokCoordinationStore(db);
}
/**
* Scan activity logs for repeated patterns that could become skills.
*
* Purpose: identify recurring file edits, commands, or verification paths
* that suggest a reusable capability.
*
* Consumer: auto skill creation on session startup or turn end.
*/
export function detectSkillCandidates(basePath) {
const candidates = [];
// Pattern 1: Repeated file edits on same files
const fileEdits = scanFileEditPatterns(basePath);
for (const [file, count] of fileEdits) {
if (count >= AUTO_SKILL_MIN_OCCURRENCES) {
candidates.push({
type: "file-pattern",
name: `forge-${sanitizeSkillName(file)}`,
description: `Recurring edits to ${file} (${count} times)`,
triggerFile: file,
occurrences: count,
template: "code",
});
}
}
// Pattern 2: Repeated bash command patterns
const commandPatterns = scanCommandPatterns(basePath);
for (const [pattern, count] of commandPatterns) {
if (count >= AUTO_SKILL_MIN_OCCURRENCES) {
candidates.push({
type: "command-pattern",
name: `forge-${sanitizeSkillName(pattern)}`,
description: `Recurring command pattern: ${pattern} (${count} times)`,
triggerCommand: pattern,
occurrences: count,
template: "code",
});
}
}
// Pattern 3: Repeated verification/check patterns
const verifyPatterns = scanVerificationPatterns(basePath);
for (const [pattern, count] of verifyPatterns) {
if (count >= AUTO_SKILL_MIN_OCCURRENCES) {
candidates.push({
type: "verification-pattern",
name: `forge-verify-${sanitizeSkillName(pattern)}`,
description: `Recurring verification: ${pattern} (${count} times)`,
triggerVerify: pattern,
occurrences: count,
template: "review",
});
}
}
return candidates;
}
/**
* Check if auto-skill creation is allowed by policy.
*/
export function isAutoSkillCreationAllowed(basePath) {
const data = getCoordinationStore(basePath).get(AUTO_SKILL_COOLDOWN_KEY);
const lastRun = Number(data?.lastRunMs ?? 0);
if (lastRun > 0 && Date.now() - lastRun < AUTO_SKILL_COOLDOWN_MS) {
return false;
}
return true;
}
/**
* Generate a skill from a detected candidate.
*/
export function generateSkill(candidate, basePath) {
const skillDir = join(basePath, ".agents", "skills", candidate.name);
if (existsSync(skillDir)) {
return { created: false, reason: "Skill already exists" };
}
let content;
switch (candidate.template) {
case "review":
content = knowledgeSkillTemplate(candidate.name, candidate.description);
break;
default:
content = codeSkillTemplate(candidate.name, candidate.description);
break;
}
// Add auto-generated marker
content += `\n\n## Auto-Generated Evidence\n\n`;
content += `- **Type:** ${candidate.type}\n`;
content += `- **Occurrences:** ${candidate.occurrences}\n`;
content += `- **Detected:** ${new Date().toISOString()}\n`;
if (candidate.triggerFile) {
content += `- **Trigger file:** ${candidate.triggerFile}\n`;
}
if (candidate.triggerCommand) {
content += `- **Trigger command:** ${candidate.triggerCommand}\n`;
}
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), content, "utf-8");
// Update cooldown
const lastRunMs = Date.now();
getCoordinationStore(basePath).set(AUTO_SKILL_COOLDOWN_KEY, {
lastRun: new Date(lastRunMs).toISOString(),
lastRunMs,
});
return { created: true, path: skillDir, name: candidate.name };
}
// ─── Pattern scanners (best-effort heuristics) ────────────────────────────
function scanFileEditPatterns(basePath) {
const edits = new Map();
try {
const activityDir = join(basePath, ".sf", "activity");
if (!existsSync(activityDir)) return edits;
const files = readdirSync(activityDir).filter((f) => f.endsWith(".jsonl"));
for (const file of files.slice(-7)) {
const raw = readFileSync(join(activityDir, file), "utf-8");
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.toolName === "write" || entry.toolName === "edit") {
const path = entry.input?.path ?? "";
if (path) {
const basename = path.split("/").pop() ?? path;
edits.set(basename, (edits.get(basename) ?? 0) + 1);
}
}
} catch {
// Skip malformed lines
}
}
}
} catch {
// Best-effort scan
}
return edits;
}
function scanCommandPatterns(basePath) {
const commands = new Map();
try {
const activityDir = join(basePath, ".sf", "activity");
if (!existsSync(activityDir)) return commands;
const files = readdirSync(activityDir).filter((f) => f.endsWith(".jsonl"));
for (const file of files.slice(-7)) {
const raw = readFileSync(join(activityDir, file), "utf-8");
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.toolName === "bash") {
const cmd = entry.input?.command ?? "";
// Extract command name (first token)
const name = cmd.trim().split(/\s+/)[0] ?? "";
if (name && !name.startsWith("cd") && !name.startsWith("ls")) {
commands.set(name, (commands.get(name) ?? 0) + 1);
}
}
} catch {
// Skip malformed lines
}
}
}
} catch {
// Best-effort scan
}
return commands;
}
function scanVerificationPatterns(basePath) {
const patterns = new Map();
try {
const activityDir = join(basePath, ".sf", "activity");
if (!existsSync(activityDir)) return patterns;
const files = readdirSync(activityDir).filter((f) => f.endsWith(".jsonl"));
for (const file of files.slice(-7)) {
const raw = readFileSync(join(activityDir, file), "utf-8");
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.toolName === "bash") {
const cmd = entry.input?.command ?? "";
// Look for verification patterns: test, lint, check, verify
if (
/\b(npm\s+run\s+(test|lint|check|verify)|npm\s+(test|lint|audit)|vitest|jest|eslint|tsc\s+--noEmit)\b/.test(
cmd,
)
) {
const pattern =
cmd.match(
/\b(npm\s+run\s+\w+|npm\s+\w+|vitest|jest|eslint|tsc)\b/,
)?.[0] ?? cmd;
patterns.set(pattern, (patterns.get(pattern) ?? 0) + 1);
}
}
} catch {
// Skip malformed lines
}
}
}
} catch {
// Best-effort scan
}
return patterns;
}
function sanitizeSkillName(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 40);
}

View file

@ -0,0 +1,73 @@
/**
* .agents/skills/ directory management
*
* Purpose: discover, validate, and load skills from repo-local
* `.agents/skills/` and user-level skill directories.
*
* Consumer: skill loader, auto-skill creation, and model context assembly.
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
const SKILL_FILENAME = "SKILL.md";
const USER_SKILL_DIR = join(process.env.HOME ?? "", ".sf", "skills");
/**
* Find all skill directories under a base path.
*/
export function discoverSkillDirs(basePath) {
const skillRoot = join(basePath, ".agents", "skills");
if (!existsSync(skillRoot)) return [];
const dirs = [];
for (const entry of readdirSync(skillRoot)) {
const full = join(skillRoot, entry);
if (statSync(full).isDirectory()) {
const skillFile = join(full, SKILL_FILENAME);
if (existsSync(skillFile)) {
dirs.push({ name: entry, path: full, skillFile });
}
}
}
return dirs;
}
/**
* Discover skills from all sources: project, user, and built-in.
*/
export function discoverAllSkills(projectPath) {
const sources = [];
// Project skills
if (projectPath) {
const projectSkills = discoverSkillDirs(projectPath);
for (const s of projectSkills) {
sources.push({ ...s, source: "project" });
}
}
// User skills
if (existsSync(USER_SKILL_DIR)) {
const userSkills = discoverSkillDirs(USER_SKILL_DIR);
for (const s of userSkills) {
// User skills have a different root structure
s.path = s.path.replace(/\.agents\/skills$/, "");
sources.push({ ...s, source: "user" });
}
}
return sources;
}
/**
* Read the raw content of a skill file.
*/
export function readSkillFile(skillDir) {
const path = join(skillDir, SKILL_FILENAME);
if (!existsSync(path)) return null;
try {
return readFileSync(path, "utf-8");
} catch {
return null;
}
}

View file

@ -0,0 +1,181 @@
/**
* Skill Eval Harness foundation for grading auto-created skills.
*
* Purpose: provide deterministic eval cases for skills so auto-generated
* capabilities can be tested before being committed. Follows the
* skill-optimizer pattern: cases + graders + workspaces.
*
* Consumer: /skills --eval, auto-skill creation acceptance gate.
*/
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/**
* Create an eval case directory for a skill.
*
* @param {string} skillPath path to skill directory
* @param {string} caseName unique case identifier
* @param {object} caseDef { task: string, grader: string, hidden?: object }
*/
export function createEvalCase(skillPath, caseName, caseDef) {
const evalDir = join(skillPath, "evals", caseName);
mkdirSync(evalDir, { recursive: true });
mkdirSync(join(evalDir, "work"), { recursive: true });
writeFileSync(join(evalDir, "task.md"), caseDef.task, "utf-8");
writeFileSync(join(evalDir, "grader.js"), caseDef.grader, "utf-8");
if (caseDef.hidden) {
mkdirSync(join(evalDir, "hidden"), { recursive: true });
writeFileSync(
join(evalDir, "hidden", "reference.json"),
JSON.stringify(caseDef.hidden, null, 2),
"utf-8",
);
}
return { path: evalDir, caseName };
}
/**
* Run a grader against a skill's work directory.
*
* @param {string} evalDir path to eval case directory
* @param {object} ctx { ui } for notifications
* @returns {object} { passed: boolean, score: number, details: string[] }
*/
export async function runGrader(evalDir, _ctx) {
const graderPath = join(evalDir, "grader.js");
const workDir = join(evalDir, "work");
if (!existsSync(graderPath)) {
return { passed: false, score: 0, details: ["Grader not found"] };
}
try {
const { grade } = await import(graderPath);
if (typeof grade !== "function") {
return {
passed: false,
score: 0,
details: ["Grader must export a grade() function"],
};
}
const result = await grade(workDir);
return {
passed: Boolean(result.passed),
score: Number(result.score ?? (result.passed ? 1 : 0)),
details: Array.isArray(result.details)
? result.details
: [String(result.details ?? "")],
};
} catch (err) {
return {
passed: false,
score: 0,
details: [`Grader error: ${err.message}`],
};
}
}
/**
* Run all eval cases for a skill.
*
* @param {string} skillPath path to skill directory
* @param {object} ctx { ui } for notifications
* @returns {object} { skillName, totalCases, passedCases, totalScore, cases: [] }
*/
export async function runSkillEvals(skillPath, ctx) {
const evalDir = join(skillPath, "evals");
if (!existsSync(evalDir)) {
return {
skillName: skillPath,
totalCases: 0,
passedCases: 0,
totalScore: 0,
cases: [],
};
}
const { readdirSync } = await import("node:fs");
const cases = [];
let passedCount = 0;
let totalScore = 0;
for (const entry of readdirSync(evalDir)) {
const caseDir = join(evalDir, entry);
const taskPath = join(caseDir, "task.md");
if (!existsSync(taskPath)) continue;
const result = await runGrader(caseDir, ctx);
cases.push({
caseName: entry,
passed: result.passed,
score: result.score,
details: result.details,
});
if (result.passed) passedCount++;
totalScore += result.score;
}
return {
skillName: skillPath,
totalCases: cases.length,
passedCases: passedCount,
totalScore: cases.length > 0 ? totalScore / cases.length : 0,
cases,
};
}
/**
* Generate a default eval case for a skill based on its type.
*
* @param {object} skill skill record from buildSkillRecord()
* @returns {object} caseDef for createEvalCase()
*/
export function generateDefaultEvalCase(skill) {
const task = `Test the ${skill.name} skill.
Instructions:
1. Read the skill definition at SKILL.md
2. Apply the skill to a realistic task matching its description
3. Verify the output matches expected behavior
Expected: The skill produces correct, safe output without side effects beyond its declared scope.
`;
const grader = `/**
* Default grader for ${skill.name}.
* Checks that work directory contains expected artifacts.
*/
export async function grade(workDir) {
const { existsSync } = await import("node:fs");
const { join } = await import("node:path");
const checks = [];
let score = 0;
// Example: check for expected output file
if (existsSync(join(workDir, "result.txt"))) {
checks.push("✓ result.txt exists");
score += 0.5;
} else {
checks.push("✗ result.txt missing");
}
// Example: check for no unexpected files
const unexpected = existsSync(join(workDir, "unexpected.txt"));
if (!unexpected) {
checks.push("✓ no unexpected artifacts");
score += 0.5;
} else {
checks.push("✗ unexpected artifact found");
}
return {
passed: score >= 0.8,
score,
details: checks,
};
}
`;
return { task, grader };
}

View file

@ -0,0 +1,153 @@
/**
* Skill YAML frontmatter parser
*
* Purpose: parse SKILL.md frontmatter so SF can enforce invocation policy,
* permission profiles, and side-effect awareness without loading full skill
* content into model context.
*
* Consumer: skill loader, auto-skill creation, and permission gating.
*/
const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n/;
/**
* Parse YAML frontmatter from skill markdown content.
*
* Returns { frontmatter: object, body: string } or null if no frontmatter.
*/
export function parseSkillFrontmatter(content) {
if (!content) return null;
const match = content.match(FRONTMATTER_RE);
if (!match) return null;
const yamlText = match[1];
const body = content.slice(match[0].length);
const frontmatter = parseYaml(yamlText);
return { frontmatter, body };
}
/**
* Minimal YAML parser for skill frontmatter.
*
* Supports: strings, booleans, numbers, arrays, and simple nested objects.
* Does NOT support anchors, aliases, or complex YAML features.
*/
function parseYaml(text) {
const result = {};
const lines = text.split("\n");
let currentKey = null;
let currentArray = null;
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line.trim() || line.trim().startsWith("#")) continue;
// Check for key: value
const keyMatch = line.match(/^(\w[\w-]*):\s*(.*)$/);
if (keyMatch) {
const [, key, value] = keyMatch;
currentKey = key;
if (value.trim() === "") {
// Could be object or array start
currentArray = null;
continue;
}
result[key] = parseYamlValue(value.trim());
currentArray = null;
continue;
}
// Array item: - value (may be indented)
const arrayMatch = line.match(/^\s+-\s+(.*)$/);
if (arrayMatch && currentKey) {
if (!currentArray) {
currentArray = [];
result[currentKey] = currentArray;
}
currentArray.push(parseYamlValue(arrayMatch[1].trim()));
}
}
return result;
}
function parseYamlValue(value) {
if (value === "true" || value === "yes") return true;
if (value === "false" || value === "no") return false;
if (value === "null" || value === "~") return null;
// Number
if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10);
if (/^-?\d+\.\d+$/.test(value)) return Number.parseFloat(value);
// Quoted string
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}
/**
* Validate skill frontmatter against required fields.
*/
export function validateSkillFrontmatter(frontmatter) {
const errors = [];
if (!frontmatter.name || typeof frontmatter.name !== "string") {
errors.push("Missing or invalid 'name' field");
}
if (!frontmatter.description || typeof frontmatter.description !== "string") {
errors.push("Missing or invalid 'description' field");
}
if (frontmatter["user-invocable"] === undefined) {
errors.push("Missing 'user-invocable' field");
}
if (frontmatter["model-invocable"] === undefined) {
errors.push("Missing 'model-invocable' field");
}
if (frontmatter["side-effects"] === undefined) {
errors.push("Missing 'side-effects' field");
}
if (frontmatter["permission-profile"] === undefined) {
errors.push("Missing 'permission-profile' field");
}
const validProfiles = ["restricted", "normal", "trusted", "unrestricted"];
if (
frontmatter["permission-profile"] &&
!validProfiles.includes(frontmatter["permission-profile"])
) {
errors.push(
`Invalid permission-profile: ${frontmatter["permission-profile"]}. Must be one of: ${validProfiles.join(", ")}`,
);
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Build a canonical skill record from parsed frontmatter + body.
*/
export function buildSkillRecord(skillDir, frontmatter, body) {
return {
name: frontmatter.name,
description: frontmatter.description,
userInvocable: frontmatter["user-invocable"] ?? false,
modelInvocable: frontmatter["model-invocable"] ?? false,
sideEffects: frontmatter["side-effects"] ?? "none",
permissionProfile: frontmatter["permission-profile"] ?? "restricted",
triggers: frontmatter.triggers ?? [],
maxActivations: frontmatter["max-activations"] ?? null,
body,
path: skillDir,
};
}

View file

@ -0,0 +1,37 @@
/**
* Skills module public API barrel
*
* Purpose: expose skill discovery, parsing, validation, and loading
* as a single import surface.
*
* Consumer: command handlers, auto-dispatch, and model context assembly.
*/
export {
detectSkillCandidates,
generateSkill,
isAutoSkillCreationAllowed,
} from "./auto-create.js";
export {
discoverAllSkills,
discoverSkillDirs,
readSkillFile,
SKILL_FILENAME,
USER_SKILL_DIR,
} from "./directory.js";
export {
createEvalCase,
generateDefaultEvalCase,
runGrader,
runSkillEvals,
} from "./eval-harness.js";
export {
buildSkillRecord,
parseSkillFrontmatter,
validateSkillFrontmatter,
} from "./frontmatter.js";
export {
getModelInvocableSkills,
getPermittedSkills,
loadSkills,
} from "./loader.js";

View file

@ -0,0 +1,113 @@
/**
* Skill loader
*
* Purpose: load skills from `.agents/skills/` with frontmatter validation,
* permission checking, and lazy content loading.
*
* Consumer: command handlers, auto-dispatch, and model context assembly.
*/
import { discoverAllSkills, readSkillFile } from "./directory.js";
import {
buildSkillRecord,
parseSkillFrontmatter,
validateSkillFrontmatter,
} from "./frontmatter.js";
/**
* Load all valid skills from all sources.
*
* Returns array of skill records with validation errors attached.
*/
export function loadSkills(projectPath) {
const discovered = discoverAllSkills(projectPath);
const skills = [];
for (const { name, path, source } of discovered) {
const content = readSkillFile(path);
if (!content) {
skills.push({
name,
source,
path,
valid: false,
errors: ["Could not read SKILL.md"],
});
continue;
}
const parsed = parseSkillFrontmatter(content);
if (!parsed) {
skills.push({
name,
source,
path,
valid: false,
errors: ["No YAML frontmatter found"],
});
continue;
}
const validation = validateSkillFrontmatter(parsed.frontmatter);
if (!validation.valid) {
skills.push({
name,
source,
path,
valid: false,
errors: validation.errors,
frontmatter: parsed.frontmatter,
});
continue;
}
const record = buildSkillRecord(path, parsed.frontmatter, parsed.body);
skills.push({
...record,
source,
valid: true,
errors: [],
});
}
return skills;
}
/**
* Get skills that are safe for the current permission profile.
*/
export function getPermittedSkills(skills, activeProfile) {
const profileOrder = ["restricted", "normal", "trusted", "unrestricted"];
const activeIndex = profileOrder.indexOf(activeProfile);
if (activeIndex === -1) return [];
return skills.filter((s) => {
if (!s.valid) return false;
const skillIndex = profileOrder.indexOf(s.permissionProfile);
if (skillIndex === -1) return false;
return skillIndex <= activeIndex;
});
}
/**
* Get skills that can be invoked by the model for a given work mode.
*/
export function getModelInvocableSkills(skills, workMode) {
return skills.filter(
(s) => s.valid && s.modelInvocable && isSkillRelevant(s, workMode),
);
}
/**
* Check if a skill is relevant to the current work mode.
*/
function isSkillRelevant(skill, workMode) {
if (!skill.triggers || skill.triggers.length === 0) return true;
return skill.triggers.some(
(t) =>
t === workMode ||
t === "*" ||
(workMode === "build" && t === "code") ||
(workMode === "review" && t === "review") ||
(workMode === "research" && t === "research"),
);
}

View file

@ -75,14 +75,13 @@ Label evidence:
### 3. Write the Failing Test (this is the spec) ### 3. Write the Failing Test (this is the spec)
```ts ```ts
import { test } from "node:test"; import { expect, test } from "vitest";
import assert from "node:assert/strict";
// behaviour contract: claim() rejects takeover when claim_until > now() // behaviour contract: claim() rejects takeover when claim_until > now()
test("claim_when_active_lease_returns_false", () => { test("claim_when_active_lease_returns_false", () => {
const now = 1000; const now = 1000;
const result = claim({ unitId: "u1", leaseMs: 60_000, now, holder: "w1", existingClaimUntil: now + 30_000 }); const result = claim({ unitId: "u1", leaseMs: 60_000, now, holder: "w1", existingClaimUntil: now + 30_000 });
assert.equal(result.acquired, false); expect(result.acquired).toBe(false);
}); });
``` ```

View file

@ -0,0 +1,150 @@
/**
* Skill templates for auto-creation
*
* Purpose: provide canonical SKILL.md templates for common skill types
* so auto-skill creation produces consistent, valid frontmatter.
*
* Consumer: auto-skill creation flow and CLI skill scaffolding.
*/
/**
* Template for a user+model invocable skill with code edits.
*/
export function codeSkillTemplate(name, description) {
return `---
name: ${name}
description: ${description}
user-invocable: true
model-invocable: true
side-effects: code-edits
permission-profile: normal
triggers:
- build
- code
- "*"
---
# ${name}
## When to Use
Describe when this skill should be invoked.
## Instructions
1. Step one
2. Step two
3. Step three
## Verification
- [ ] Check A
- [ ] Check B
## Examples
\`\`\`typescript
// Example code
\`\`\`
`;
}
/**
* Template for a dangerous skill (model never invokes by default).
*/
export function dangerousSkillTemplate(name, description) {
return `---
name: ${name}
description: ${description}
user-invocable: true
model-invocable: false
side-effects: production-mutation
permission-profile: trusted
triggers: []
---
# ${name}
## Dangerous Operation
This skill performs high-risk actions. Model invocation is disabled by default.
## Prerequisites
- [ ] Gate A passes
- [ ] Gate B passes
## Procedure
1. Step one
2. Step two
## Rollback
How to undo if something goes wrong.
`;
}
/**
* Template for background knowledge (model only, no side effects).
*/
export function knowledgeSkillTemplate(name, description) {
return `---
name: ${name}
description: ${description}
user-invocable: false
model-invocable: true
side-effects: none
permission-profile: restricted
triggers:
- "*"
---
# ${name}
## Context
Background information the model should know.
## Rules
1. Rule one
2. Rule two
## Anti-Patterns
- Don't do X
- Don't do Y
`;
}
/**
* Template for review/verification skills.
*/
export function reviewSkillTemplate(name, description) {
return `---
name: ${name}
description: ${description}
user-invocable: true
model-invocable: true
side-effects: review-comments
permission-profile: restricted
triggers:
- review
- verify
---
# ${name}
## Checklist
- [ ] Item A
- [ ] Item B
- [ ] Item C
## Common Issues
1. Issue one
2. Issue two
`;
}

View file

@ -0,0 +1,193 @@
/**
* Temporal Foundation Node 26 native date/time helpers
*
* Purpose: provide one native Temporal surface for schedules, leases, and
* background work now that Node 26 is the runtime baseline.
*
* Consumer: schedule, journal, lease, and background task surfaces.
*/
function requireTemporal() {
if (typeof globalThis.Temporal !== "object" || globalThis.Temporal === null) {
throw new Error("SF requires Node 26 native Temporal support");
}
return globalThis.Temporal;
}
/**
* Check if native Temporal is available.
*
* Purpose: let startup diagnostics fail clearly when SF is launched on a
* runtime older than the Node 26 baseline.
*
* Consumer: doctor/startup checks and Temporal tests.
*/
export function hasNativeTemporal() {
return (
typeof globalThis.Temporal === "object" && globalThis.Temporal !== null
);
}
/**
* Return the native Temporal namespace.
*
* Purpose: keep callers on the platform implementation instead of local
* compatibility shims.
*
* Consumer: modules that need Temporal constructors directly.
*/
export function getTemporal() {
return requireTemporal();
}
/**
* Create a Temporal.Instant from an ISO string.
*
* Purpose: exact event timestamps with stable ordering.
*
* Consumer: journals, leases, and scheduler records.
*/
export function instantFromISO(isoString) {
return requireTemporal().Instant.from(isoString);
}
/**
* Get the current instant.
*
* Purpose: use a native Temporal clock value for runtime records.
*
* Consumer: schedule and lease code that needs an exact timestamp.
*/
export function instantNow() {
return requireTemporal().Now.instant();
}
/**
* Create a Temporal.Duration from an object.
*
* Purpose: typed durations for leases, budgets, retry delays.
*
* Consumer: retry, timeout, and reminder scheduling code.
*/
export function durationFromObject({
years = 0,
months = 0,
weeks = 0,
days = 0,
hours = 0,
minutes = 0,
seconds = 0,
milliseconds = 0,
} = {}) {
return requireTemporal().Duration.from({
years,
months,
weeks,
days,
hours,
minutes,
seconds,
milliseconds,
});
}
/**
* Create a Temporal.PlainDate from an ISO string.
*
* Purpose: calendar dates for daily reports and milestone reviews.
*
* Consumer: schedule and reporting code that needs date-only semantics.
*/
export function plainDateFromISO(isoString) {
return requireTemporal().PlainDate.from(isoString);
}
/**
* Create a Temporal.ZonedDateTime from an ISO string and time zone.
*
* Purpose: local-time reminders that survive DST changes.
*
* Consumer: scheduled reminders and audit views rendered in local time.
*/
export function zonedDateTimeFromISO(isoString, timeZone = "UTC") {
const Temporal = requireTemporal();
return Temporal.Instant.from(isoString).toZonedDateTimeISO(timeZone);
}
/**
* Serialize a Temporal value to a stable JSON-friendly object.
*
* Purpose: store semantic type plus ISO string so consumers know how to parse.
*
* Consumer: DB records and generated projections that persist Temporal values.
*/
export function serializeTemporal(value) {
if (!value) return null;
const Temporal = requireTemporal();
if (value instanceof Temporal.Instant) {
return { type: "Instant", iso: value.toString() };
}
if (value instanceof Temporal.Duration) {
return { type: "Duration", iso: value.toString() };
}
if (value instanceof Temporal.PlainDate) {
return { type: "PlainDate", iso: value.toString() };
}
if (value instanceof Temporal.ZonedDateTime) {
return {
type: "ZonedDateTime",
iso: value.toString(),
timeZone: value.timeZoneId,
};
}
throw new TypeError(
`Unsupported Temporal value: ${Object.prototype.toString.call(value)}`,
);
}
/**
* Deserialize a serialized Temporal value.
*
* Purpose: reconstruct the same native Temporal type that was stored.
*
* Consumer: schedule and UOK state loaders.
*/
export function deserializeTemporal(serialized) {
if (!serialized || typeof serialized !== "object") return null;
const Temporal = requireTemporal();
switch (serialized.type) {
case "Instant":
return Temporal.Instant.from(serialized.iso);
case "Duration":
return Temporal.Duration.from(serialized.iso);
case "PlainDate":
return Temporal.PlainDate.from(serialized.iso);
case "ZonedDateTime":
return Temporal.ZonedDateTime.from(serialized.iso);
default:
throw new TypeError(`Unknown Temporal type: ${serialized.type}`);
}
}
/**
* Validate that a schedule/lease record has correct Temporal serialization.
*
* Purpose: reject ambiguous Date-like records before they enter durable state.
*
* Consumer: schedule and UOK record validation.
*/
export function validateTemporalRecord(record) {
const errors = [];
const validTypes = ["Instant", "Duration", "PlainDate", "ZonedDateTime"];
for (const [key, value] of Object.entries(record)) {
if (value && typeof value === "object" && value.type) {
if (!validTypes.includes(value.type)) {
errors.push(`Field "${key}" has unknown Temporal type: ${value.type}`);
}
if (validTypes.includes(value.type) && !value.iso) {
errors.push(`Field "${key}" is missing ISO string`);
}
}
}
return { valid: errors.length === 0, errors };
}

View file

@ -1,4 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { test } from "vitest"; import { test } from "vitest";
import { import {
@ -42,3 +44,25 @@ test("direct command completions strip the already typed command name", () => {
}, },
]); ]);
}); });
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) {
const source = readFileSync(join(process.cwd(), sourceFile), "utf8");
assert.equal(
source.includes("Machine surface for /sf"),
false,
`${sourceFile} must describe headless as the direct-command machine surface`,
);
assert.equal(
source.includes("Run /sf autonomous"),
false,
`${sourceFile} must not advertise /sf autonomous`,
);
assert.equal(
source.includes("Manage: /sf schedule list"),
false,
`${sourceFile} must not advertise the removed slash namespace`,
);
}
});

View file

@ -9,14 +9,16 @@ import {
isRunControlMode, isRunControlMode,
isWorkMode, isWorkMode,
MODEL_MODES, MODEL_MODES,
modelModeToTier,
PERMISSION_PROFILES, PERMISSION_PROFILES,
RUN_CONTROL_MODES,
resolveModelMode, resolveModelMode,
resolvePermissionProfile, resolvePermissionProfile,
resolveRunControlMode, resolveRunControlMode,
resolveWorkMode, resolveWorkMode,
RUN_CONTROL_MODES,
WORK_MODES,
runControlModeForSession, runControlModeForSession,
tierToModelMode,
WORK_MODES,
} from "../operating-model.js"; } from "../operating-model.js";
describe("operating model vocabulary", () => { describe("operating model vocabulary", () => {
@ -120,4 +122,18 @@ describe("operating model vocabulary", () => {
assert.equal(state.modelMode, "smart"); assert.equal(state.modelMode, "smart");
assert.equal(state.surface, "tui"); assert.equal(state.surface, "tui");
}); });
test("modelModeToTier_maps_fast_smart_deep_to_light_standard_heavy", () => {
assert.equal(modelModeToTier("fast"), "light");
assert.equal(modelModeToTier("smart"), "standard");
assert.equal(modelModeToTier("deep"), "heavy");
assert.equal(modelModeToTier("unknown"), "standard");
});
test("tierToModelMode_maps_light_standard_heavy_to_fast_smart_deep", () => {
assert.equal(tierToModelMode("light"), "fast");
assert.equal(tierToModelMode("standard"), "smart");
assert.equal(tierToModelMode("heavy"), "deep");
assert.equal(tierToModelMode("unknown"), "smart");
});
}); });

View file

@ -0,0 +1,98 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
checkIntentConflicts,
clearAllIntents,
declareIntent,
getActiveIntents,
releaseIntent,
} from "../parallel-intent.js";
import { closeDatabase } from "../sf-db.js";
/**
* Parallel intent registry tests.
*/
function makeBasePath() {
const dir = mkdtempSync(join(tmpdir(), "sf-intent-"));
mkdirSync(join(dir, ".sf"), { recursive: true });
return dir;
}
function cleanup(basePath) {
try {
rmSync(basePath, { recursive: true, force: true });
} catch {
/* ignore */
}
}
describe("parallel intent registry", () => {
let basePath;
beforeEach(() => {
basePath = makeBasePath();
});
afterEach(() => {
closeDatabase();
cleanup(basePath);
basePath = null;
});
test("declareIntent_when_valid_input_records_claim", () => {
const result = declareIntent(basePath, "M001", [
"src/foo.ts",
"src/bar.ts",
]);
expect(result.ok).toBe(true);
const intents = getActiveIntents(basePath);
expect(intents).toHaveLength(1);
expect(intents[0].milestoneId).toBe("M001");
expect(intents[0].files).toEqual(["src/foo.ts", "src/bar.ts"]);
expect(intents[0].status).toBe("claimed");
expect(existsSync(join(basePath, ".sf", "parallel"))).toBe(false);
});
test("checkIntentConflicts_when_files_overlap_reports_existing_milestone", () => {
declareIntent(basePath, "M001", ["src/foo.ts", "src/bar.ts"]);
const conflicts = checkIntentConflicts(basePath, "M002", [
"src/bar.ts",
"src/baz.ts",
]);
expect(conflicts).toHaveLength(1);
expect(conflicts[0].milestoneId).toBe("M001");
expect(conflicts[0].files).toEqual(["src/bar.ts"]);
});
test("checkIntentConflicts_when_no_overlap_returns_empty_list", () => {
declareIntent(basePath, "M001", ["src/foo.ts"]);
const conflicts = checkIntentConflicts(basePath, "M002", ["src/bar.ts"]);
expect(conflicts).toHaveLength(0);
});
test("releaseIntent_when_known_milestone_clears_active_intent", () => {
declareIntent(basePath, "M001", ["src/foo.ts"]);
releaseIntent(basePath, "M001");
const intents = getActiveIntents(basePath);
expect(intents).toHaveLength(0);
});
test("clearAllIntents_when_multiple_claims_removes_all_records", () => {
declareIntent(basePath, "M001", ["src/foo.ts"]);
declareIntent(basePath, "M002", ["src/bar.ts"]);
clearAllIntents(basePath);
const intents = getActiveIntents(basePath);
expect(intents).toHaveLength(0);
});
test("getActiveIntents_when_release_exists_ignores_released_records", () => {
declareIntent(basePath, "M001", ["src/foo.ts"]);
declareIntent(basePath, "M002", ["src/bar.ts"]);
releaseIntent(basePath, "M001");
const intents = getActiveIntents(basePath);
expect(intents).toHaveLength(1);
expect(intents[0].milestoneId).toBe("M002");
});
});

View file

@ -217,7 +217,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill",
const version = db const version = db
.prepare("SELECT MAX(version) AS version FROM schema_version") .prepare("SELECT MAX(version) AS version FROM schema_version")
.get(); .get();
assert.equal(version.version, 42); assert.equal(version.version, 43);
const taskSpec = db const taskSpec = db
.prepare( .prepare(
"SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'", "SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'",

View file

@ -0,0 +1,115 @@
/**
* Tests for skill eval harness.
*
* Purpose: verify eval-case materialization and grader execution are stable.
*/
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import {
createEvalCase,
generateDefaultEvalCase,
runGrader,
runSkillEvals,
} from "../skills/eval-harness.js";
function makeSkillDir() {
const dir = mkdtempSync(join(tmpdir(), "sf-skill-eval-"));
mkdirSync(join(dir, ".agents", "skills", "test-skill"), { recursive: true });
return join(dir, ".agents", "skills", "test-skill");
}
function cleanup(dir) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
}
describe("skill eval harness", () => {
test("createEvalCase_creates_case_files_and_workspace", () => {
const skillDir = makeSkillDir();
try {
const caseDef = {
task: "Test task for skill",
grader:
"export async function grade() { return { passed: true, score: 1 }; }",
};
const result = createEvalCase(skillDir, "case-1", caseDef);
expect(result.caseName).toBe("case-1");
expect(existsSync(join(skillDir, "evals", "case-1", "task.md"))).toBe(
true,
);
expect(existsSync(join(skillDir, "evals", "case-1", "grader.js"))).toBe(
true,
);
expect(existsSync(join(skillDir, "evals", "case-1", "work"))).toBe(true);
} finally {
cleanup(skillDir);
}
});
test("createEvalCase_with_hidden_reference_writes_fixture", () => {
const skillDir = makeSkillDir();
try {
const caseDef = {
task: "Test task",
grader:
"export async function grade() { return { passed: true, score: 1 }; }",
hidden: { answer: "expected-output" },
};
createEvalCase(skillDir, "case-2", caseDef);
const hiddenPath = join(
skillDir,
"evals",
"case-2",
"hidden",
"reference.json",
);
expect(existsSync(hiddenPath)).toBe(true);
const hidden = JSON.parse(readFileSync(hiddenPath, "utf-8"));
expect(hidden.answer).toBe("expected-output");
} finally {
cleanup(skillDir);
}
});
test("runGrader_returns_error_when_grader_missing", async () => {
const skillDir = makeSkillDir();
try {
mkdirSync(join(skillDir, "evals", "bad-case"), { recursive: true });
const result = await runGrader(join(skillDir, "evals", "bad-case"), {});
expect(result.passed).toBe(false);
expect(result.details[0]).toBe("Grader not found");
} finally {
cleanup(skillDir);
}
});
test("generateDefaultEvalCase_mentions_skill_name_and_exported_grader", () => {
const skill = { name: "forge-test", description: "Test skill" };
const caseDef = generateDefaultEvalCase(skill);
expect(caseDef.task.includes("forge-test")).toBe(true);
expect(caseDef.grader.includes("export async function grade")).toBe(true);
});
test("runSkillEvals_returns_empty_summary_without_evals", async () => {
const skillDir = makeSkillDir();
try {
const result = await runSkillEvals(skillDir, {});
expect(result.totalCases).toBe(0);
expect(result.passedCases).toBe(0);
} finally {
cleanup(skillDir);
}
});
});

View file

@ -0,0 +1,253 @@
/**
* Skills system tests.
*
* Purpose: verify skill discovery, frontmatter parsing, validation, and loading.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { discoverSkillDirs, readSkillFile } from "../skills/directory.js";
import {
buildSkillRecord,
parseSkillFrontmatter,
validateSkillFrontmatter,
} from "../skills/frontmatter.js";
import {
getModelInvocableSkills,
getPermittedSkills,
loadSkills,
} from "../skills/loader.js";
describe("skill frontmatter", () => {
test("parseSkillFrontmatter_extracts_yaml_and_body", () => {
const content = `---
name: test-skill
description: A test skill
user-invocable: true
model-invocable: false
side-effects: none
permission-profile: restricted
---
# Test Skill
Some instructions.
`;
const parsed = parseSkillFrontmatter(content);
expect(parsed).toBeTruthy();
expect(parsed.frontmatter.name).toBe("test-skill");
expect(parsed.frontmatter.description).toBe("A test skill");
expect(parsed.frontmatter["user-invocable"]).toBe(true);
expect(parsed.frontmatter["model-invocable"]).toBe(false);
expect(parsed.frontmatter["side-effects"]).toBe("none");
expect(parsed.frontmatter["permission-profile"]).toBe("restricted");
expect(parsed.body).toContain("# Test Skill");
});
test("parseSkillFrontmatter_returns_null_without_frontmatter", () => {
const parsed = parseSkillFrontmatter("# Just markdown\n\nNo frontmatter.");
expect(parsed).toBeNull();
});
test("validateSkillFrontmatter_passes_complete_frontmatter", () => {
const result = validateSkillFrontmatter({
name: "test",
description: "desc",
"user-invocable": true,
"model-invocable": false,
"side-effects": "none",
"permission-profile": "normal",
});
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test("validateSkillFrontmatter_fails_missing_fields", () => {
const result = validateSkillFrontmatter({
name: "test",
});
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors.some((e) => e.includes("description"))).toBe(true);
expect(result.errors.some((e) => e.includes("user-invocable"))).toBe(true);
});
test("validateSkillFrontmatter_fails_invalid_permission_profile", () => {
const result = validateSkillFrontmatter({
name: "test",
description: "desc",
"user-invocable": true,
"model-invocable": false,
"side-effects": "none",
"permission-profile": "invalid",
});
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("permission-profile"))).toBe(
true,
);
});
test("buildSkillRecord_creates_canonical_record", () => {
const record = buildSkillRecord(
"/path/to/skill",
{
name: "test-skill",
description: "A test",
"user-invocable": true,
"model-invocable": true,
"side-effects": "code-edits",
"permission-profile": "normal",
triggers: ["build", "code"],
"max-activations": 5,
},
"# Body",
);
expect(record.name).toBe("test-skill");
expect(record.userInvocable).toBe(true);
expect(record.modelInvocable).toBe(true);
expect(record.sideEffects).toBe("code-edits");
expect(record.permissionProfile).toBe("normal");
expect(record.triggers).toEqual(["build", "code"]);
expect(record.maxActivations).toBe(5);
expect(record.body).toBe("# Body");
expect(record.path).toBe("/path/to/skill");
});
});
describe("skill discovery", () => {
let tmpDir;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "sf-skills-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("discoverSkillDirs_finds_skills_with_skill_md", () => {
const skillDir = join(tmpDir, ".agents", "skills", "test-skill");
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(skillDir, "SKILL.md"),
`---\nname: test-skill\ndescription: Test\nuser-invocable: true\nmodel-invocable: true\nside-effects: none\npermission-profile: restricted\n---\n\n# Test\n`,
);
const dirs = discoverSkillDirs(tmpDir);
expect(dirs).toHaveLength(1);
expect(dirs[0].name).toBe("test-skill");
});
test("discoverSkillDirs_ignores_dirs_without_skill_md", () => {
const skillDir = join(tmpDir, ".agents", "skills", "empty-dir");
mkdirSync(skillDir, { recursive: true });
const dirs = discoverSkillDirs(tmpDir);
expect(dirs).toHaveLength(0);
});
test("readSkillFile_reads_content", () => {
const skillDir = join(tmpDir, ".agents", "skills", "test-skill");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "# Test Skill\n");
const content = readSkillFile(skillDir);
expect(content).toBe("# Test Skill\n");
});
test("readSkillFile_returns_null_for_missing", () => {
const content = readSkillFile(join(tmpDir, "nonexistent"));
expect(content).toBeNull();
});
});
describe("skill loading", () => {
let tmpDir;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "sf-skills-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
function createSkill(name, overrides = {}) {
const skillDir = join(tmpDir, ".agents", "skills", name);
mkdirSync(skillDir, { recursive: true });
const frontmatter = {
name,
description: overrides.description ?? `Description for ${name}`,
"user-invocable": overrides.userInvocable ?? true,
"model-invocable": overrides.modelInvocable ?? true,
"side-effects": overrides.sideEffects ?? "none",
"permission-profile": overrides.permissionProfile ?? "normal",
triggers: overrides.triggers ?? ["*"],
...overrides.extra,
};
const yaml = Object.entries(frontmatter)
.map(([k, v]) => {
if (Array.isArray(v))
return `${k}:\n${v.map((i) => ` - ${i}`).join("\n")}`;
return `${k}: ${v}`;
})
.join("\n");
writeFileSync(
join(skillDir, "SKILL.md"),
`---\n${yaml}\n---\n\n# ${name}\n`,
);
}
test("loadSkills_loads_valid_skills", () => {
createSkill("skill-a");
createSkill("skill-b", { permissionProfile: "trusted" });
const skills = loadSkills(tmpDir);
expect(skills).toHaveLength(2);
expect(skills.every((s) => s.valid)).toBe(true);
expect(skills.some((s) => s.name === "skill-a")).toBe(true);
expect(skills.some((s) => s.name === "skill-b")).toBe(true);
});
test("loadSkills_marks_invalid_skills", () => {
createSkill("valid-skill");
const badDir = join(tmpDir, ".agents", "skills", "bad-skill");
mkdirSync(badDir, { recursive: true });
writeFileSync(join(badDir, "SKILL.md"), "No frontmatter here.");
const skills = loadSkills(tmpDir);
expect(skills).toHaveLength(2);
const bad = skills.find((s) => s.name === "bad-skill");
expect(bad).toBeTruthy();
expect(bad.valid).toBe(false);
});
test("getPermittedSkills_filters_by_profile", () => {
createSkill("restricted-skill", { permissionProfile: "restricted" });
createSkill("normal-skill", { permissionProfile: "normal" });
createSkill("trusted-skill", { permissionProfile: "trusted" });
const skills = loadSkills(tmpDir);
const permitted = getPermittedSkills(skills, "normal");
expect(permitted).toHaveLength(2);
expect(permitted.some((s) => s.name === "restricted-skill")).toBe(true);
expect(permitted.some((s) => s.name === "normal-skill")).toBe(true);
expect(permitted.some((s) => s.name === "trusted-skill")).toBe(false);
});
test("getModelInvocableSkills_filters_by_work_mode", () => {
createSkill("build-skill", { triggers: ["build"], modelInvocable: true });
createSkill("review-skill", { triggers: ["review"], modelInvocable: true });
createSkill("universal-skill", { triggers: ["*"], modelInvocable: true });
createSkill("user-only", { triggers: ["*"], modelInvocable: false });
const skills = loadSkills(tmpDir);
const buildSkills = getModelInvocableSkills(skills, "build");
expect(buildSkills).toHaveLength(2);
expect(buildSkills.some((s) => s.name === "build-skill")).toBe(true);
expect(buildSkills.some((s) => s.name === "universal-skill")).toBe(true);
expect(buildSkills.some((s) => s.name === "review-skill")).toBe(false);
expect(buildSkills.some((s) => s.name === "user-only")).toBe(false);
});
});

View file

@ -0,0 +1,102 @@
/**
* Temporal foundation tests.
*
* Purpose: ensure runtime contracts are enforced on native Temporal.
*/
import { describe, expect, test } from "vitest";
import {
durationFromObject,
hasNativeTemporal,
instantFromISO,
instantNow,
plainDateFromISO,
serializeTemporal,
validateTemporalRecord,
} from "../temporal-foundation.js";
const hasTemporal = hasNativeTemporal();
const Temporal = globalThis.Temporal;
if (!hasTemporal) {
test.skip("native Temporal is required by the Node 26 baseline", () => {});
} else {
describe("temporal foundation", () => {
test("hasNativeTemporal_reports_true_in_runtime", () => {
expect(hasTemporal).toBe(true);
expect(Temporal).toBeTypeOf("object");
});
test("instantFromISO_parses_iso_to_native_instant", () => {
const iso = "2026-05-08T12:00:00Z";
const instant = instantFromISO(iso);
expect(instant).toBeInstanceOf(Temporal.Instant);
expect(instant.toString()).toBe(iso);
});
test("instantNow_returns_current_native_instant", () => {
const before = Date.now();
const instant = instantNow();
const after = Date.now();
expect(instant).toBeInstanceOf(Temporal.Instant);
expect(instant.epochMilliseconds).toBeGreaterThanOrEqual(before);
expect(instant.epochMilliseconds).toBeLessThanOrEqual(after);
});
test("durationFromObject_returns_native_duration", () => {
const dur = durationFromObject({ hours: 1, minutes: 30 });
expect(dur).toBeInstanceOf(Temporal.Duration);
expect(dur.hours).toBe(1);
expect(dur.minutes).toBe(30);
});
test("plainDateFromISO_returns_native_plain_date", () => {
const date = plainDateFromISO("2026-05-08");
expect(date).toBeInstanceOf(Temporal.PlainDate);
expect(date.year).toBe(2026);
expect(date.month).toBe(5);
expect(date.day).toBe(8);
});
test("serializeTemporal_roundtrips_native_instant", () => {
const instant = instantFromISO("2026-05-08T12:00:00Z");
const serialized = serializeTemporal(instant);
expect(serialized).toEqual({
type: "Instant",
iso: "2026-05-08T12:00:00Z",
});
});
test("validateTemporalRecord_passes_valid", () => {
const record = {
createdAt: { type: "Instant", iso: "2026-05-08T12:00:00Z" },
leaseDuration: { type: "Duration", iso: "PT1H" },
};
const result = validateTemporalRecord(record);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test("validateTemporalRecord_fails_unknown_type", () => {
const record = {
bad: { type: "UnknownType", iso: "x" },
};
const result = validateTemporalRecord(record);
expect(result.valid).toBe(false);
expect(
result.errors.some((e) => e.includes("unknown Temporal type")),
).toBe(true);
});
test("validateTemporalRecord_fails_missing_iso", () => {
const record = {
bad: { type: "Instant" },
};
const result = validateTemporalRecord(record);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("missing ISO string"))).toBe(
true,
);
});
});
}

View file

@ -59,6 +59,22 @@ test("kv_cleanupExpired_removes_expired_rows", () => {
assert.equal(store.get("b"), 2); assert.equal(store.get("b"), 2);
}); });
test("kv_entries_when_prefix_matches_returns_live_decoded_rows", () => {
let now = 1_000;
const store = makeStore(() => now);
store.set("parallel:intent:M001", { milestoneId: "M001" });
store.set("parallel:intent:M002", { milestoneId: "M002" }, { ttlMs: 1 });
store.set("skills:auto-create:last-run", { lastRunMs: 1 });
now = 1_002;
const entries = store.entries("parallel:intent:");
assert.deepEqual(
entries.map((entry) => entry.value.milestoneId),
["M001"],
);
});
test("streams_append_and_read_after_id_in_order", () => { test("streams_append_and_read_after_id_in_order", () => {
const store = makeStore(); const store = makeStore();
const first = store.xadd("events", "node-start", { id: "a" }); const first = store.xadd("events", "node-start", { id: "a" });
@ -70,7 +86,10 @@ test("streams_append_and_read_after_id_in_order", () => {
store.xread("events", { afterId: 1 }).map((e) => e.type), store.xread("events", { afterId: 1 }).map((e) => e.type),
["node-complete"], ["node-complete"],
); );
assert.deepEqual(store.xread("events").map((e) => e.payload.id), ["a", "a"]); assert.deepEqual(
store.xread("events").map((e) => e.payload.id),
["a", "a"],
);
}); });
test("queue_claim_ack_and_release_enforces_owner_leases", () => { test("queue_claim_ack_and_release_enforces_owner_leases", () => {

View file

@ -7,11 +7,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { afterEach, test } from "vitest"; import { afterEach, test } from "vitest";
import { import { closeDatabase, getDatabase, openDatabase } from "../sf-db.js";
closeDatabase,
getDatabase,
openDatabase,
} from "../sf-db.js";
import { import {
ensureExecutionGraphSchema, ensureExecutionGraphSchema,
getGraphProgressStream, getGraphProgressStream,
@ -170,8 +166,20 @@ test("persistGraphNode_updates_existing_node", () => {
test("persistFullGraph_writes_snapshot_and_nodes_in_transaction", () => { test("persistFullGraph_writes_snapshot_and_nodes_in_transaction", () => {
const db = makeDb(); const db = makeDb();
const nodes = [ const nodes = [
{ id: "n1", kind: "unit", unitType: "execute-task", unitId: "U1", state: "todo" }, {
{ id: "n2", kind: "unit", unitType: "execute-task", unitId: "U2", state: "todo" }, id: "n1",
kind: "unit",
unitType: "execute-task",
unitId: "U1",
state: "todo",
},
{
id: "n2",
kind: "unit",
unitType: "execute-task",
unitId: "U2",
state: "todo",
},
]; ];
persistFullGraph(db, "g2", nodes, { persistFullGraph(db, "g2", nodes, {
@ -196,9 +204,24 @@ test("queryTasksByState_filters_by_state", () => {
const db = makeDb(); const db = makeDb();
ensureExecutionGraphSchema(db); ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", state: "todo" }); persistGraphNode(db, "g1", {
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", state: "done" }); id: "n1",
persistGraphNode(db, "g1", { id: "n3", kind: "unit", unitId: "U3", state: "in_progress" }); kind: "unit",
unitId: "U1",
state: "todo",
});
persistGraphNode(db, "g1", {
id: "n2",
kind: "unit",
unitId: "U2",
state: "done",
});
persistGraphNode(db, "g1", {
id: "n3",
kind: "unit",
unitId: "U3",
state: "in_progress",
});
const todo = queryTasksByState(db, { graphId: "g1", states: ["todo"] }); const todo = queryTasksByState(db, { graphId: "g1", states: ["todo"] });
assert.equal(todo.length, 1); assert.equal(todo.length, 1);
@ -213,8 +236,20 @@ test("queryTasksByState_filters_by_milestone", () => {
const db = makeDb(); const db = makeDb();
ensureExecutionGraphSchema(db); ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", milestoneId: "M001", state: "todo" }); persistGraphNode(db, "g1", {
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", milestoneId: "M002", state: "todo" }); id: "n1",
kind: "unit",
unitId: "U1",
milestoneId: "M001",
state: "todo",
});
persistGraphNode(db, "g1", {
id: "n2",
kind: "unit",
unitId: "U2",
milestoneId: "M002",
state: "todo",
});
const m1 = queryTasksByState(db, { milestoneId: "M001" }); const m1 = queryTasksByState(db, { milestoneId: "M001" });
assert.equal(m1.length, 1); assert.equal(m1.length, 1);
@ -247,10 +282,30 @@ test("getGraphStateSummary_computes_counts_and_progress", () => {
const db = makeDb(); const db = makeDb();
ensureExecutionGraphSchema(db); ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", state: "todo" }); persistGraphNode(db, "g1", {
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", state: "in_progress" }); id: "n1",
persistGraphNode(db, "g1", { id: "n3", kind: "unit", unitId: "U3", state: "done" }); kind: "unit",
persistGraphNode(db, "g1", { id: "n4", kind: "unit", unitId: "U4", state: "done" }); unitId: "U1",
state: "todo",
});
persistGraphNode(db, "g1", {
id: "n2",
kind: "unit",
unitId: "U2",
state: "in_progress",
});
persistGraphNode(db, "g1", {
id: "n3",
kind: "unit",
unitId: "U3",
state: "done",
});
persistGraphNode(db, "g1", {
id: "n4",
kind: "unit",
unitId: "U4",
state: "done",
});
const summary = getGraphStateSummary(db, "g1"); const summary = getGraphStateSummary(db, "g1");
assert.equal(summary.total, 4); assert.equal(summary.total, 4);
@ -266,8 +321,18 @@ test("getGraphStateSummary_complete_when_all_terminal", () => {
const db = makeDb(); const db = makeDb();
ensureExecutionGraphSchema(db); ensureExecutionGraphSchema(db);
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", state: "done" }); persistGraphNode(db, "g1", {
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", state: "failed" }); id: "n1",
kind: "unit",
unitId: "U1",
state: "done",
});
persistGraphNode(db, "g1", {
id: "n2",
kind: "unit",
unitId: "U2",
state: "failed",
});
const summary = getGraphStateSummary(db, "g1"); const summary = getGraphStateSummary(db, "g1");
assert.equal(summary.progress, 100); assert.equal(summary.progress, 100);
@ -297,7 +362,10 @@ test("getGraphProgressStream_returns_recent_first", () => {
ensureExecutionGraphSchema(db); ensureExecutionGraphSchema(db);
persistProgressEvent(db, "g1", { type: "first", ts: "2024-01-01T00:00:00Z" }); persistProgressEvent(db, "g1", { type: "first", ts: "2024-01-01T00:00:00Z" });
persistProgressEvent(db, "g1", { type: "second", ts: "2024-01-01T00:00:01Z" }); persistProgressEvent(db, "g1", {
type: "second",
ts: "2024-01-01T00:00:01Z",
});
const events = getGraphProgressStream(db, "g1", 10); const events = getGraphProgressStream(db, "g1", 10);
assert.equal(events[0].type, "second"); assert.equal(events[0].type, "second");

View file

@ -61,14 +61,18 @@ test("token_onCancel_fires_immediately_if_already_cancelled", () => {
const token = new CancellationToken(); const token = new CancellationToken();
token.cancel("done"); token.cancel("done");
let fired = false; let fired = false;
token.onCancel(() => { fired = true; }); token.onCancel(() => {
fired = true;
});
assert.equal(fired, true); assert.equal(fired, true);
}); });
test("token_onCancel_fires_when_cancelled_later", () => { test("token_onCancel_fires_when_cancelled_later", () => {
const token = new CancellationToken(); const token = new CancellationToken();
let fired = false; let fired = false;
token.onCancel(() => { fired = true; }); token.onCancel(() => {
fired = true;
});
assert.equal(fired, false); assert.equal(fired, false);
token.cancel("later"); token.cancel("later");
assert.equal(fired, true); assert.equal(fired, true);
@ -77,7 +81,9 @@ test("token_onCancel_fires_when_cancelled_later", () => {
test("token_onCancel_unsubscribe_works", () => { test("token_onCancel_unsubscribe_works", () => {
const token = new CancellationToken(); const token = new CancellationToken();
let fired = false; let fired = false;
const unsub = token.onCancel(() => { fired = true; }); const unsub = token.onCancel(() => {
fired = true;
});
unsub(); unsub();
token.cancel("nope"); token.cancel("nope");
assert.equal(fired, false); assert.equal(fired, false);
@ -218,7 +224,7 @@ test("scheduler_serial_cancellation_stops_execution", async () => {
const scheduler = new ExecutionGraphSchedulerV2(); const scheduler = new ExecutionGraphSchedulerV2();
const order = []; const order = [];
const globalToken = new CancellationToken(); const globalToken = new CancellationToken();
scheduler.registerHandler("unit", async (node, token) => { scheduler.registerHandler("unit", async (node, _token) => {
if (node.id === "b") globalToken.cancel("stop"); if (node.id === "b") globalToken.cancel("stop");
globalToken.throwIfCancelled(); globalToken.throwIfCancelled();
order.push(node.id); order.push(node.id);
@ -243,7 +249,7 @@ test("scheduler_serial_cancellation_stops_execution", async () => {
test("scheduler_parallel_runs_independent_nodes_concurrently", async () => { test("scheduler_parallel_runs_independent_nodes_concurrently", async () => {
const scheduler = new ExecutionGraphSchedulerV2(); const scheduler = new ExecutionGraphSchedulerV2();
const starts = []; const starts = [];
scheduler.registerHandler("unit", async (node, token, progress) => { scheduler.registerHandler("unit", async (node, _token, _progress) => {
starts.push(node.id); starts.push(node.id);
await new Promise((r) => setTimeout(r, 50)); await new Promise((r) => setTimeout(r, 50));
return { gateResults: [{ outcome: "pass", gateId: "test" }] }; return { gateResults: [{ outcome: "pass", gateId: "test" }] };
@ -264,7 +270,7 @@ test("scheduler_parallel_enforces_max_workers", async () => {
const scheduler = new ExecutionGraphSchedulerV2(); const scheduler = new ExecutionGraphSchedulerV2();
let concurrent = 0; let concurrent = 0;
let maxConcurrent = 0; let maxConcurrent = 0;
scheduler.registerHandler("unit", async (node) => { scheduler.registerHandler("unit", async (_node) => {
concurrent++; concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent); maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise((r) => setTimeout(r, 50)); await new Promise((r) => setTimeout(r, 50));
@ -285,13 +291,11 @@ test("scheduler_parallel_enforces_max_workers", async () => {
test("scheduler_parallel_reports_progress_events", async () => { test("scheduler_parallel_reports_progress_events", async () => {
const scheduler = new ExecutionGraphSchedulerV2(); const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async (node) => { scheduler.registerHandler("unit", async (_node) => {
return { gateResults: [{ outcome: "pass", gateId: "test" }] }; return { gateResults: [{ outcome: "pass", gateId: "test" }] };
}); });
const nodes = [ const nodes = [{ id: "a", kind: "unit", dependsOn: [], metadata: {} }];
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
];
const events = []; const events = [];
scheduler.progress.onProgress((ev) => events.push(ev)); scheduler.progress.onProgress((ev) => events.push(ev));
@ -307,7 +311,7 @@ test("scheduler_parallel_reports_progress_events", async () => {
test("scheduler_parallel_task_records_have_correct_state", async () => { test("scheduler_parallel_task_records_have_correct_state", async () => {
const scheduler = new ExecutionGraphSchedulerV2(); const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async (node) => { scheduler.registerHandler("unit", async (_node) => {
return { return {
gateResults: [ gateResults: [
{ outcome: "pass", gateId: "security" }, { outcome: "pass", gateId: "security" },
@ -330,7 +334,7 @@ test("scheduler_parallel_task_records_have_correct_state", async () => {
test("scheduler_parallel_detects_cyclic_dependencies", async () => { test("scheduler_parallel_detects_cyclic_dependencies", async () => {
const scheduler = new ExecutionGraphSchedulerV2(); const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async (node) => ({ scheduler.registerHandler("unit", async (_node) => ({
gateResults: [{ outcome: "pass", gateId: "test" }], gateResults: [{ outcome: "pass", gateId: "test" }],
})); }));
@ -339,15 +343,12 @@ test("scheduler_parallel_detects_cyclic_dependencies", async () => {
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} }, { id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
]; ];
await assert.rejects( await assert.rejects(scheduler.run(nodes, { parallel: true }), /cyclic/);
scheduler.run(nodes, { parallel: true }),
/cyclic/,
);
}); });
test("scheduler_parallel_detects_deadlock", async () => { test("scheduler_parallel_detects_deadlock", async () => {
const scheduler = new ExecutionGraphSchedulerV2(); const scheduler = new ExecutionGraphSchedulerV2();
scheduler.registerHandler("unit", async (node) => ({ scheduler.registerHandler("unit", async (_node) => ({
gateResults: [{ outcome: "pass", gateId: "test" }], gateResults: [{ outcome: "pass", gateId: "test" }],
})); }));
@ -357,10 +358,7 @@ test("scheduler_parallel_detects_deadlock", async () => {
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} }, { id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
]; ];
await assert.rejects( await assert.rejects(scheduler.run(nodes, { parallel: true }), /cyclic/);
scheduler.run(nodes, { parallel: true }),
/cyclic/,
);
}); });
// ─── Progress stream wiring ──────────────────────────────────────────────── // ─── Progress stream wiring ────────────────────────────────────────────────
@ -375,9 +373,7 @@ test("scheduler_wires_progress_to_parent_stream", async () => {
const parentEvents = []; const parentEvents = [];
parent.onProgress((ev) => parentEvents.push(ev)); parent.onProgress((ev) => parentEvents.push(ev));
const nodes = [ const nodes = [{ id: "x", kind: "unit", dependsOn: [], metadata: {} }];
{ id: "x", kind: "unit", dependsOn: [], metadata: {} },
];
await scheduler.run(nodes, { parallel: false, parentStream: parent }); await scheduler.run(nodes, { parallel: false, parentStream: parent });
assert.ok(parentEvents.some((e) => e.type === "node-complete")); assert.ok(parentEvents.some((e) => e.type === "node-complete"));

View file

@ -8,13 +8,12 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "vitest"; import { test } from "vitest";
import { import {
TASK_STATES,
TASK_TERMINAL_STATES,
TASK_STATE_TRANSITIONS,
aggregateTaskStates, aggregateTaskStates,
buildTaskRecord, buildTaskRecord,
canTransitionTaskState, canTransitionTaskState,
gateOutcomesToTaskState, gateOutcomesToTaskState,
TASK_STATES,
TASK_TERMINAL_STATES,
unitRuntimeToTaskState, unitRuntimeToTaskState,
} from "../uok/task-state.js"; } from "../uok/task-state.js";

View file

@ -125,6 +125,24 @@ export class UokCoordinationStore {
return decode(row.value_json); return decode(row.value_json);
} }
entries(prefix = "") {
this.cleanupExpired();
return this.db
.prepare(
`SELECT key, value_json, expires_at, updated_at
FROM uok_kv
WHERE key LIKE :prefix
ORDER BY updated_at DESC, key ASC`,
)
.all({ prefix: `${prefix}%` })
.map((row) => ({
key: row.key,
value: decode(row.value_json),
expiresAt: row.expires_at,
updatedAt: row.updated_at,
}));
}
delete(key) { delete(key) {
this.db.prepare("DELETE FROM uok_kv WHERE key = :key").run({ key }); this.db.prepare("DELETE FROM uok_kv WHERE key = :key").run({ key });
} }

View file

@ -298,9 +298,15 @@ export function getGraphStateSummary(db, graphId) {
.all({ graphId }); .all({ graphId });
const counts = Object.fromEntries( const counts = Object.fromEntries(
["todo", "in_progress", "review", "done", "retrying", "failed", "cancelled"].map( [
(s) => [s, 0], "todo",
), "in_progress",
"review",
"done",
"retrying",
"failed",
"cancelled",
].map((s) => [s, 0]),
); );
let total = 0; let total = 0;
for (const r of rows) { for (const r of rows) {

View file

@ -9,161 +9,157 @@
* extension that needs orchestration primitives. * extension that needs orchestration primitives.
*/ */
// ─── Contracts & Types ──────────────────────────────────────────────────── // ─── Skills (Repo-local capabilities) ──────────────────────────────────────
export { validateGate } from "./contracts.js"; export {
buildSkillRecord,
// ─── Core Kernel ─────────────────────────────────────────────────────────── discoverAllSkills,
export { recordUokKernelTermination, runAutoLoopWithUok } from "./kernel.js"; discoverSkillDirs,
getModelInvocableSkills,
// ─── Gate System ─────────────────────────────────────────────────────────── getPermittedSkills,
export { UokGateRunner, enrichGateResultWithMemory } from "./gate-runner.js"; loadSkills,
parseSkillFrontmatter,
readSkillFile,
SKILL_FILENAME,
USER_SKILL_DIR,
validateSkillFrontmatter,
} from "../skills/index.js";
// ─── Audit & Observability ─────────────────────────────────────────────────
export { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
export {
isAuditEnvelopeEnabled,
setAuditEnvelopeEnabled,
} from "./audit-toggle.js";
// ─── Gates ───────────────────────────────────────────────────────────────── // ─── Gates ─────────────────────────────────────────────────────────────────
export { ChaosMonkey, ChaosMonkeyGate } from "./chaos-monkey.js"; export { ChaosMonkey, ChaosMonkeyGate } from "./chaos-monkey.js";
export { CostGuardGate } from "./cost-guard-gate.js"; // ─── Contracts & Types ────────────────────────────────────────────────────
export { MultiPackageGate } from "./multi-package-gate.js"; export { validateGate } from "./contracts.js";
export { OutcomeLearningGate } from "./outcome-learning-gate.js";
export { SecurityGate } from "./security-gate.js";
// ─── Flags & Configuration ─────────────────────────────────────────────────
export { resolveUokFlags, loadUokFlags } from "./flags.js";
// ─── Execution Graph ───────────────────────────────────────────────────────
export {
selectConflictFreeBatch,
selectReactiveDispatchBatch,
buildSidecarQueueNodes,
buildExecutionGraphSnapshot,
scheduleSidecarQueue,
ExecutionGraphScheduler,
} from "./execution-graph.js";
// ─── Scheduler v2 (Background Work) ────────────────────────────────────────
export {
CancellationToken,
ProgressStream,
WorkerPool,
ExecutionGraphSchedulerV2,
} from "./scheduler-v2.js";
// ─── Task State Machine ────────────────────────────────────────────────────
export {
TASK_STATES,
TASK_TERMINAL_STATES,
TASK_STATE_TRANSITIONS,
gateOutcomesToTaskState,
unitRuntimeToTaskState,
canTransitionTaskState,
aggregateTaskStates,
buildTaskRecord,
} from "./task-state.js";
// ─── Execution Graph Persistence ───────────────────────────────────────────
export {
ensureExecutionGraphSchema,
persistGraphSnapshot,
persistGraphNode,
persistProgressEvent,
queryTasksByState,
getGraphStateSummary,
getGraphProgressStream,
persistFullGraph,
} from "./execution-graph-persist.js";
// ─── Coordination Store ─────────────────────────────────────────────────── // ─── Coordination Store ───────────────────────────────────────────────────
export { export {
ensureCoordinationSchema, ensureCoordinationSchema,
UokCoordinationStore, UokCoordinationStore,
} from "./coordination-store.js"; } from "./coordination-store.js";
export { CostGuardGate } from "./cost-guard-gate.js";
// ─── Unit Runtime ──────────────────────────────────────────────────────────
export {
UNIT_RUNTIME_STATUSES,
UNIT_RUNTIME_TERMINAL_STATUSES,
UNIT_RUNTIME_TRANSITIONS,
isTerminalUnitRuntimeStatus,
getUnitRuntimeState,
isSyntheticUnitRuntime,
decideUnitRuntimeDispatch,
writeUnitRuntimeRecord,
readUnitRuntimeRecord,
clearUnitRuntimeRecord,
listUnitRuntimeRecords,
recordUnitOutcomeInMemory,
inspectExecuteTaskDurability,
formatExecuteTaskRecoveryStatus,
reconcileStaleCompleteSliceRecords,
reconcileDurableCompleteUnitRuntimeRecords,
} from "./unit-runtime.js";
// ─── Plan v2 ───────────────────────────────────────────────────────────────
export {
EXECUTION_ENTRY_PHASES,
isExecutionEntryPhase,
compileUnitGraphFromState,
hasFinalizedMilestoneContext,
isMissingFinalizedContextResult,
isEmptyPlanV2GraphResult,
ensurePlanV2Graph,
} from "./plan-v2.js";
// ─── Audit & Observability ─────────────────────────────────────────────────
export { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
export { setAuditEnvelopeEnabled, isAuditEnvelopeEnabled } from "./audit-toggle.js";
// ─── Diagnostics ─────────────────────────────────────────────────────────── // ─── Diagnostics ───────────────────────────────────────────────────────────
export { export {
readUokDiagnostics,
synthesizeUokDiagnostics, synthesizeUokDiagnostics,
writeUokDiagnostics, writeUokDiagnostics,
readUokDiagnostics,
} from "./diagnostic-synthesis.js"; } from "./diagnostic-synthesis.js";
// ─── Dispatch Envelope ─────────────────────────────────────────────────────
export { buildDispatchEnvelope, explainDispatch } from "./dispatch-envelope.js";
// ─── Parity & Ledger ─────────────────────────────────────────────────────── // ─── Execution Graph ───────────────────────────────────────────────────────
export { export {
writeParityReport, buildExecutionGraphSnapshot,
readParityReport, buildSidecarQueueNodes,
summarizeParityHealth, ExecutionGraphScheduler,
writeParityHeartbeat, scheduleSidecarQueue,
parseParityEvents, selectConflictFreeBatch,
UNMATCHED_RUN_STALE_MS, selectReactiveDispatchBatch,
signalKernelEnter, } from "./execution-graph.js";
resetParityCommitBlock, // ─── Execution Graph Persistence ───────────────────────────────────────────
checkAndDrainMissingExit,
} from "./parity-report.js";
export { export {
signalKernelEnter as signalParityEnter, ensureExecutionGraphSchema,
} from "./parity-diff-capture.js"; getGraphProgressStream,
getGraphStateSummary,
persistFullGraph,
persistGraphNode,
persistGraphSnapshot,
persistProgressEvent,
queryTasksByState,
} from "./execution-graph-persist.js";
// ─── Flags & Configuration ─────────────────────────────────────────────────
export { loadUokFlags, resolveUokFlags } from "./flags.js";
// ─── Gate System ───────────────────────────────────────────────────────────
export { enrichGateResultWithMemory, UokGateRunner } from "./gate-runner.js";
// ─── GitOps ────────────────────────────────────────────────────────────────
export {
writeTurnCloseoutGitRecord,
writeTurnGitTransaction,
} from "./gitops.js";
// ─── Core Kernel ───────────────────────────────────────────────────────────
export { recordUokKernelTermination, runAutoLoopWithUok } from "./kernel.js";
// ─── Loop Adapter ──────────────────────────────────────────────────────────
export { createTurnObserver } from "./loop-adapter.js";
// ─── Message Bus ─────────────────────────────────────────────────────────── // ─── Message Bus ───────────────────────────────────────────────────────────
export { AgentInbox, MessageBus } from "./message-bus.js"; export { AgentInbox, MessageBus } from "./message-bus.js";
// ─── Metrics ─────────────────────────────────────────────────────────────── // ─── Metrics ───────────────────────────────────────────────────────────────
export { export {
buildMetricsText, buildMetricsText,
invalidateMetricsCache, invalidateMetricsCache,
metricsPath, metricsPath,
writeUokMetrics,
readUokMetrics, readUokMetrics,
writeUokMetrics,
} from "./metrics-exposition.js"; } from "./metrics-exposition.js";
// ─── Model Policy ──────────────────────────────────────────────────────────
// ─── GitOps ──────────────────────────────────────────────────────────────── export { applyModelPolicyFilter } from "./model-policy.js";
export { MultiPackageGate } from "./multi-package-gate.js";
export { OutcomeLearningGate } from "./outcome-learning-gate.js";
export { signalKernelEnter as signalParityEnter } from "./parity-diff-capture.js";
// ─── Parity & Ledger ───────────────────────────────────────────────────────
export { export {
writeTurnGitTransaction, checkAndDrainMissingExit,
writeTurnCloseoutGitRecord, parseParityEvents,
} from "./gitops.js"; readParityReport,
resetParityCommitBlock,
signalKernelEnter,
summarizeParityHealth,
UNMATCHED_RUN_STALE_MS,
writeParityHeartbeat,
writeParityReport,
} from "./parity-report.js";
// ─── Plan v2 ───────────────────────────────────────────────────────────────
export {
compileUnitGraphFromState,
EXECUTION_ENTRY_PHASES,
ensurePlanV2Graph,
hasFinalizedMilestoneContext,
isEmptyPlanV2GraphResult,
isExecutionEntryPhase,
isMissingFinalizedContextResult,
} from "./plan-v2.js";
// ─── Scheduler v2 (Background Work) ────────────────────────────────────────
export {
CancellationToken,
ExecutionGraphSchedulerV2,
ProgressStream,
WorkerPool,
} from "./scheduler-v2.js";
export { SecurityGate } from "./security-gate.js";
// ─── Task State Machine ────────────────────────────────────────────────────
export {
aggregateTaskStates,
buildTaskRecord,
canTransitionTaskState,
gateOutcomesToTaskState,
TASK_STATE_TRANSITIONS,
TASK_STATES,
TASK_TERMINAL_STATES,
unitRuntimeToTaskState,
} from "./task-state.js";
// ─── Unit Runtime ──────────────────────────────────────────────────────────
export {
clearUnitRuntimeRecord,
decideUnitRuntimeDispatch,
formatExecuteTaskRecoveryStatus,
getUnitRuntimeState,
inspectExecuteTaskDurability,
isSyntheticUnitRuntime,
isTerminalUnitRuntimeStatus,
listUnitRuntimeRecords,
readUnitRuntimeRecord,
reconcileDurableCompleteUnitRuntimeRecords,
reconcileStaleCompleteSliceRecords,
recordUnitOutcomeInMemory,
UNIT_RUNTIME_STATUSES,
UNIT_RUNTIME_TERMINAL_STATUSES,
UNIT_RUNTIME_TRANSITIONS,
writeUnitRuntimeRecord,
} from "./unit-runtime.js";
// ─── Writer Token ────────────────────────────────────────────────────────── // ─── Writer Token ──────────────────────────────────────────────────────────
export { export {
acquireWriterToken, acquireWriterToken,
releaseWriterToken,
nextWriteRecord, nextWriteRecord,
releaseWriterToken,
} from "./writer.js"; } from "./writer.js";
// ─── Model Policy ──────────────────────────────────────────────────────────
export { applyModelPolicyFilter } from "./model-policy.js";
// ─── Loop Adapter ──────────────────────────────────────────────────────────
export { createTurnObserver } from "./loop-adapter.js";
// ─── Dispatch Envelope ─────────────────────────────────────────────────────
export { buildDispatchEnvelope, explainDispatch } from "./dispatch-envelope.js";

View file

@ -110,10 +110,15 @@ export async function runAutoLoopWithUok(args) {
prefs?.uok?.permission_profile ?? prefs?.uok?.permission_profile ??
defaultPermissionProfileForRunControl(runControl), defaultPermissionProfileForRunControl(runControl),
); );
// Include workMode and modelMode from session in lifecycle flags
const workMode = s.workMode ?? "chat";
const modelMode = s.modelMode ?? "smart";
const lifecycleFlags = { const lifecycleFlags = {
...flags, ...flags,
runControl, runControl,
permissionProfile, permissionProfile,
workMode,
modelMode,
}; };
const healthVerdict = writeUokDiagnostics(s.basePath); const healthVerdict = writeUokDiagnostics(s.basePath);
@ -166,6 +171,8 @@ export async function runAutoLoopWithUok(args) {
flags: lifecycleFlags, flags: lifecycleFlags,
runControl, runControl,
permissionProfile, permissionProfile,
workMode,
modelMode,
sessionId: ctx.sessionManager?.getSessionId?.(), sessionId: ctx.sessionManager?.getSessionId?.(),
}, },
}), }),

View file

@ -35,7 +35,11 @@ export class CancellationToken {
this.#cancelled = true; this.#cancelled = true;
this.#reason = reason; this.#reason = reason;
for (const fn of this.#listeners) { for (const fn of this.#listeners) {
try { fn(reason); } catch { /* ignore */ } try {
fn(reason);
} catch {
/* ignore */
}
} }
this.#listeners.clear(); this.#listeners.clear();
} }
@ -50,7 +54,11 @@ export class CancellationToken {
onCancel(fn) { onCancel(fn) {
if (this.#cancelled) { if (this.#cancelled) {
try { fn(this.#reason); } catch { /* ignore */ } try {
fn(this.#reason);
} catch {
/* ignore */
}
return () => {}; return () => {};
} }
this.#listeners.add(fn); this.#listeners.add(fn);
@ -84,7 +92,11 @@ export class ProgressStream {
this.#history = this.#history.slice(-this.#maxHistory); this.#history = this.#history.slice(-this.#maxHistory);
} }
for (const fn of this.#listeners) { for (const fn of this.#listeners) {
try { fn(enriched); } catch { /* ignore */ } try {
fn(enriched);
} catch {
/* ignore */
}
} }
} }
@ -92,7 +104,11 @@ export class ProgressStream {
this.#listeners.add(fn); this.#listeners.add(fn);
// Replay recent history so new listener catches up // Replay recent history so new listener catches up
for (const ev of this.#history) { for (const ev of this.#history) {
try { fn(ev); } catch { /* ignore */ } try {
fn(ev);
} catch {
/* ignore */
}
} }
return () => this.#listeners.delete(fn); return () => this.#listeners.delete(fn);
} }
@ -312,7 +328,6 @@ export class ExecutionGraphSchedulerV2 {
} }
async #runParallel(sorted, token, conflicts, maxWorkers) { async #runParallel(sorted, token, conflicts, maxWorkers) {
const nodeMap = new Map(sorted.map((n) => [n.id, n]));
const done = new Set(); const done = new Set();
const order = []; const order = [];
const promises = new Map(); // nodeId -> promise const promises = new Map(); // nodeId -> promise
@ -352,11 +367,7 @@ export class ExecutionGraphSchedulerV2 {
.acquire(node.id, nodeToken) .acquire(node.id, nodeToken)
.then(async ({ workerId, release }) => { .then(async ({ workerId, release }) => {
try { try {
const result = await this.#executeNode( const result = await this.#executeNode(node, nodeToken, workerId);
node,
nodeToken,
workerId,
);
done.add(node.id); done.add(node.id);
order.push(result.nodeId); order.push(result.nodeId);
promises.delete(node.id); promises.delete(node.id);
@ -445,19 +456,23 @@ export class ExecutionGraphSchedulerV2 {
} catch (err) { } catch (err) {
error = err; error = err;
if (err.name === "CancellationError") { if (err.name === "CancellationError") {
gateResults = [{ gateResults = [
gateId: "scheduler", {
outcome: "fail", gateId: "scheduler",
failureClass: "cancelled", outcome: "fail",
rationale: err.message, failureClass: "cancelled",
}]; rationale: err.message,
},
];
} else { } else {
gateResults = [{ gateResults = [
gateId: "scheduler", {
outcome: "fail", gateId: "scheduler",
failureClass: "execution", outcome: "fail",
rationale: err.message, failureClass: "execution",
}]; rationale: err.message,
},
];
} }
} finally { } finally {
clearTimeout(nodeTimeoutHandle); clearTimeout(nodeTimeoutHandle);

View file

@ -10,8 +10,7 @@
* background-work tracking. * background-work tracking.
*/ */
import { isDbAvailable } from "../sf-db.js"; import { isTerminalUnitRuntimeStatus } from "./unit-runtime.js";
import { isTerminalUnitRuntimeStatus, UNIT_RUNTIME_STATUSES } from "./unit-runtime.js";
export const TASK_STATES = [ export const TASK_STATES = [
"todo", "todo",
@ -119,16 +118,14 @@ export function aggregateTaskStates(taskStates) {
if (s in counts) counts[s]++; if (s in counts) counts[s]++;
} }
const total = taskStates.length; const total = taskStates.length;
const terminal = TASK_STATES.filter((s) => TASK_TERMINAL_STATES.has(s)).reduce( const terminal = TASK_STATES.filter((s) =>
(sum, s) => sum + counts[s], TASK_TERMINAL_STATES.has(s),
0, ).reduce((sum, s) => sum + counts[s], 0);
);
return { return {
counts, counts,
total, total,
terminal, terminal,
progress: progress: total > 0 ? Math.round((terminal / total) * 100) : 0,
total > 0 ? Math.round(((terminal / total) * 100)) : 0,
isComplete: terminal === total && total > 0, isComplete: terminal === total && total > 0,
}; };
} }
@ -158,9 +155,10 @@ export function buildTaskRecord({
modelId = null, modelId = null,
workerId = null, workerId = null,
}) { }) {
const state = gateResults.length > 0 const state =
? gateOutcomesToTaskState(gateResults) gateResults.length > 0
: unitRuntimeToTaskState(runtimeRecord); ? gateOutcomesToTaskState(gateResults)
: unitRuntimeToTaskState(runtimeRecord);
return { return {
id: nodeId, id: nodeId,