diff --git a/.agents/skills/forge-autonomous-runtime/SKILL.md b/.agents/skills/forge-autonomous-runtime/SKILL.md new file mode 100644 index 000000000..40c0f0055 --- /dev/null +++ b/.agents/skills/forge-autonomous-runtime/SKILL.md @@ -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 diff --git a/.agents/skills/forge-command-surface/SKILL.md b/.agents/skills/forge-command-surface/SKILL.md new file mode 100644 index 000000000..0d7654a88 --- /dev/null +++ b/.agents/skills/forge-command-surface/SKILL.md @@ -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; +} +``` diff --git a/copilot-thoughts.md b/copilot-thoughts.md index c64ccf4e1..08a068833 100644 --- a/copilot-thoughts.md +++ b/copilot-thoughts.md @@ -750,24 +750,31 @@ Already directionally right: 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, verification, plan approval, and runner status -- add intent/claim records for parallel workers before editing - audit subagent provider/model/permission inheritance - audit remote steering as a full-session steering surface, not only remote 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 SF is the system, not a plugin namespace. @@ -785,15 +792,7 @@ Use: /tasks ``` -Do not use: - -```text -/sf status -/sf autonomous -/sf doctor -/sf rate -/sf session-report -``` +`/sf` is not registered in the TUI or browser command surface. Shell machine surface remains: @@ -808,16 +807,14 @@ control, trust, model posture, and surface. ## Runtime Target: Node 26 -SF should treat Node 26 as the target runtime, with Node 24 kept as the current -compatibility floor until the Node 26 lane is proven clean. +SF treats Node 26.1+ as the runtime baseline. There is no compatibility path +for older Node versions in SF-owned runtime code. 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, but not a target. -- Node 26 is the next meaningful target: current now, LTS-bound, and useful for - SF's own runtime model. +- Node 26 is current now, LTS-bound, and useful for SF's own runtime model. - 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 SF's risk surface yet. @@ -890,6 +887,12 @@ operational mistakes: - background work surface: task age, stale-running detection, retry-after, and 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 Store the semantic type, not just the formatted string: @@ -915,9 +918,9 @@ Serialization should stay explicit and boring: Target policy: ```text -current compatibility floor: Node 24.15+ -internal target runtime: Node 26 -canonical future baseline: Node 26 after canary is clean +current compatibility floor: Node 26.1+ +internal target runtime: Node 26.1+ +canonical baseline: Node 26.1+ Node 25: skip except quick probes ``` @@ -1017,7 +1020,7 @@ JavaScript app. Use alternatives this way: ```text -Node 26 -> primary internal target and future baseline +Node 26 -> primary runtime and baseline Bun -> speed/compatibility probe, not runtime Deno -> permission/sandbox design reference, not runtime LLRT -> ignore except tiny serverless worker research @@ -1040,10 +1043,9 @@ sf --help sf --print "ping" ``` -If Node 26 passes those gates, SF should run itself on Node 26 internally even -before raising public `engines.node`. Once stable, raise the repo baseline and -start replacing fragile `Date`/millisecond logic with Temporal in the schedule, -lease, journal, and background task surfaces. +SF already requires Node 26.1+ in `engines.node`; the remaining work is to keep +the gates green under Node 26 and replace fragile `Date`/millisecond logic with +Temporal in the schedule, 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`. -**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) @@ -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`. -**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) @@ -1146,7 +1148,7 @@ assert.equal(events[0].runControl, "autonomous"); 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) @@ -1164,7 +1166,7 @@ Tracks model tier success/failure per task pattern. 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) @@ -1182,7 +1184,7 @@ Records anomalies, blocking entries, version-bump resolution. 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 | |----------|------|----------------|--------| -| 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 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 | Deprecate `/sf` prefix in commands | `commands/catalog.js`, `commands/index.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 | Add `/tasks` background work surface | New: `tasks-overlay.js`, `tasks-db.js` | Large | -| P1 | Make `repair` first-class work mode | `commands/handlers/core.js`, `doctor.js` | Medium | -| P2 | Add `.agents/skills/` structure | New: `skills-directory.js`, skill templates | Large | -| P2 | Add skill YAML frontmatter parser | New: `skill-frontmatter.js` | Medium | -| P2 | Add skill eval harness | New: `skill-eval.js`, eval templates | Large | -| P2 | Adopt Temporal in `sf schedule` | `schedule/*.js` | Medium | -| P2 | Node 26 canary | `package.json`, CI | Medium | +| 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 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 | 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 | Wire `execution-policy.js` to tool boundaries | `execution-policy.js`, `bootstrap/register-hooks.js` | Medium ✓ | +| P1 | Add `/tasks` background work surface | `commands/handlers/tasks.js` | Medium ✓ | +| P1 | Make `repair` first-class work mode | `commands/handlers/ops.js`, `commands/handlers/core.js` | Medium ✓ | +| P2 | Add `.agents/skills/` structure | `skills/*.js`, `.agents/skills/` | Medium ✓ | +| P2 | Add skill YAML frontmatter parser | `skills/frontmatter.js` | Small ✓ | +| P2 | Add skill eval harness | `skills/eval-harness.js`, eval templates | Medium ✓ | +| P2 | Adopt Temporal in `sf schedule` | `temporal-foundation.js` | Medium ✓ | +| P2 | Node 26 baseline | `temporal-foundation.js` native Temporal wrapper | Medium ✓ | --- diff --git a/docs/specs/agent-mode-system.md b/docs/specs/agent-mode-system.md index 45aa28601..395ffba52 100644 --- a/docs/specs/agent-mode-system.md +++ b/docs/specs/agent-mode-system.md @@ -8,7 +8,7 @@ ## 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. @@ -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` | `/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 | +SF registers direct command roots only: -### 11.2 Migration Timeline +```text +/status +/autonomous +/doctor +/rate +/session-report +/parallel +/remote +/tasks +``` -| Phase | Action | -|-------|--------| -| 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. | +`/sf` is not a command root. TUI and browser command parity tests reject it so +compatibility shims do not grow back. -### 11.3 Shell Surface +### 11.2 Shell Surface Machine surface remains prefixed: @@ -510,6 +502,9 @@ sf headless autonomous sf headless --autonomous ... ``` +The shell prefix is the executable name, not an interactive slash-command +namespace. + --- ## 12. Runtime Target: Node 26 @@ -580,21 +575,30 @@ sf --print "ping" | 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 | Intent/claim records for parallel workers | Medium | | P2 | Audit subagent provider/model/permission inheritance | 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 diff --git a/scripts/check-sf-extension-inventory.mjs b/scripts/check-sf-extension-inventory.mjs index ec98a2cfb..8604f4129 100644 --- a/scripts/check-sf-extension-inventory.mjs +++ b/scripts/check-sf-extension-inventory.mjs @@ -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 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([ "settings", "model", diff --git a/scripts/parallel-monitor.mjs b/scripts/parallel-monitor.mjs index 177f1641b..8172c826c 100755 --- a/scripts/parallel-monitor.mjs +++ b/scripts/parallel-monitor.mjs @@ -179,7 +179,10 @@ function readAutoLock(mid) { function queryRows(dbPath, sql, params = []) { const db = new DatabaseSync(dbPath, { readOnly: true }); try { - return db.prepare(sql).all(...params).map((row) => ({ ...row })); + return db + .prepare(sql) + .all(...params) + .map((row) => ({ ...row })); } finally { db.close(); } diff --git a/src/cli.ts b/src/cli.ts index 5127e104b..27ec9b77e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -119,7 +119,7 @@ function printNonTtyErrorAndExit( ); if (includeWebHint) { 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); @@ -561,7 +561,7 @@ if (cliFlags.messages[0] === "sessions") { 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") { await ensureRtkBootstrap(); // Sync bundled resources before headless runs (#3471). Without this, diff --git a/src/help-text.ts b/src/help-text.ts index f19af3227..2444912f4 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -203,7 +203,7 @@ const SUBCOMMAND_HELP: Record = { headless: [ "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:", " --timeout N Overall timeout in ms (default: 300000)", @@ -237,7 +237,7 @@ const SUBCOMMAND_HELP: Record = { "", "Examples:", " 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 --output-format json autonomous Structured JSON result on stdout", " 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", ); 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( " graph Manage knowledge graph (build, query, status, diff)\n", diff --git a/src/loader.ts b/src/loader.ts index c6c271279..6d580b3a2 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -161,7 +161,7 @@ if ( } if (passiveDueCount > 0) { 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) { diff --git a/src/resources/extensions/sf-tui/footer.js b/src/resources/extensions/sf-tui/footer.js index 466a8b3ae..62f6dcbc8 100644 --- a/src/resources/extensions/sf-tui/footer.js +++ b/src/resources/extensions/sf-tui/footer.js @@ -1,4 +1,5 @@ import { truncateToWidth, visibleWidth } from "@singularity-forge/pi-tui"; +import { getAutoSession } from "../sf/auto/session.js"; import { refreshGitStatus } from "./git.js"; const RESET = "\x1b[0m"; @@ -8,6 +9,7 @@ const SE = { gray60: "#8d877a", stone60: "#6b6659", paper: "#f7f5f1", + warning: "#ff8838", success: "#24a148", error: "#da1e28", }; @@ -164,3 +166,49 @@ export function renderFooter(_theme, footerData, ctx, width) { const line = leftLine + " ".repeat(gap) + rightLine; 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, "..."))]; +} diff --git a/src/resources/extensions/sf-tui/header.js b/src/resources/extensions/sf-tui/header.js index 54160648e..13b5dac30 100644 --- a/src/resources/extensions/sf-tui/header.js +++ b/src/resources/extensions/sf-tui/header.js @@ -1,27 +1,134 @@ import { basename } from "node:path"; import { truncateToWidth, visibleWidth } from "@singularity-forge/pi-tui"; +import { getAutoSession } from "../sf/auto/session.js"; import { refreshGitStatus } from "./git.js"; function align(left, right, width, ellipsis) { const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right)); 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) { const th = theme; const git = refreshGitStatus(process.cwd()); const projectName = basename(process.cwd()); + const mode = ctx.sessionManager?.getMode?.() ?? getAutoSession().getMode(); const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}`.replace(/^\/+/, "") : ""; const modelLabel = model ? `${th.fg("dim", "model ")}${th.fg("text", model)}` : ""; + const modeBadge = renderModeBadge(th, mode, width < 80); const topLeft = [ th.fg("accent", "╭─"), th.bold(th.fg("accent", "SF")), th.fg("dim", "▸"), th.fg("text", projectName), - ].join(" "); + modeBadge ? th.fg("dim", "·") : "", + modeBadge, + ] + .filter(Boolean) + .join(" "); const branchState = git.branch ? git.dirty ? th.fg("warning", "modified") diff --git a/src/resources/extensions/sf-tui/index.js b/src/resources/extensions/sf-tui/index.js index 64594e923..7214b4b19 100644 --- a/src/resources/extensions/sf-tui/index.js +++ b/src/resources/extensions/sf-tui/index.js @@ -5,16 +5,18 @@ * - Powerline footer: git branch, diff stats, last commit, model, cost, context * - Header: project name + branch + model * - 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 { Key } from "@singularity-forge/pi-tui"; +import { getAutoSession } from "../sf/auto/session.js"; import { isAutoActive } from "../sf/auto.js"; import { projectRoot } from "../sf/commands/context.js"; import { registerSessionColor } from "./color-band.js"; import { registerSessionEmoji } from "./emoji.js"; -import { renderFooter } from "./footer.js"; +import { renderAutoFooter, renderFooter } from "./footer.js"; import { invalidateGitStatus } from "./git.js"; -import { renderHeader } from "./header.js"; +import { renderAutoHeader, renderHeader } from "./header.js"; import { openMarketplaceOverlay } from "./marketplace.js"; import { appendPromptHistory, @@ -23,12 +25,72 @@ import { readPromptHistory, } 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) { if (!ctx.hasUI) return; ctx.ui.setHeader((_tui, theme) => { return { render: (width) => { - if (isAutoActive()) return []; + if (isAutoActive()) { + return renderAutoHeader(theme, ctx, width); + } return renderHeader(theme, ctx, width); }, invalidate: () => {}, @@ -41,7 +103,9 @@ function installFooter(ctx) { ctx.ui.setFooter((_tui, theme, footerData) => { return { render: (width) => { - if (isAutoActive()) return []; + if (isAutoActive()) { + return renderAutoFooter(theme, footerData, ctx, width); + } return renderFooter(theme, footerData, ctx, width); }, invalidate: () => {}, @@ -83,6 +147,28 @@ export default function sfTui(pi) { description: "Open marketplace browser", 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(); }); pi.on("before_agent_start", async (event) => { diff --git a/src/resources/extensions/sf/auto-model-selection.js b/src/resources/extensions/sf/auto-model-selection.js index 67c687dae..31a36a047 100644 --- a/src/resources/extensions/sf/auto-model-selection.js +++ b/src/resources/extensions/sf/auto-model-selection.js @@ -458,12 +458,17 @@ export async function selectAndApplyModel( const isHook = unitType.startsWith("hook/"); const shouldClassify = !isHook || routingConfig.hooks !== false; 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( unitType, unitId, basePath, budgetPct, taskMetadataForPolicy, + modelMode, ); const availableModelIds = routingEligibleModels.map((m) => m.id); // Escalate tier on retry when escalate_on_failure is enabled (default: true) diff --git a/src/resources/extensions/sf/auto.js b/src/resources/extensions/sf/auto.js index 0c58af409..b36700c9f 100644 --- a/src/resources/extensions/sf/auto.js +++ b/src/resources/extensions/sf/auto.js @@ -665,6 +665,7 @@ function handleLostSessionLock(ctx, lockStatus) { expectedPid: lockStatus?.expectedPid, }); s.active = false; + s.runControl = "manual"; s.paused = false; deactivateSF(); clearUnitTimeout(); @@ -701,6 +702,7 @@ function handleLostSessionLock(ctx, lockStatus) { function cleanupAfterLoopExit(ctx) { s.currentUnit = null; s.active = false; + s.runControl = "manual"; deactivateSF(); clearUnitTimeout(); restoreProjectRootEnv(); @@ -1564,6 +1566,7 @@ export async function startAuto(ctx, pi, base, verboseMode, options) { } if (!s.paused) { s.stepMode = requestedStepMode; + s.runControl = requestedStepMode ? "assisted" : "autonomous"; } if (freshStartAssessment.lock) { // 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.verbose = verboseMode; s.stepMode = requestedStepMode; + s.runControl = requestedStepMode ? "assisted" : "autonomous"; s.cmdCtx = ctx; s.basePath = base; // Ensure the workflow-logger audit log is pinned to the project root @@ -1869,6 +1873,7 @@ export async function dispatchHookUnit( if (!s.active) { s.active = true; s.stepMode = true; + s.runControl = "assisted"; s.cmdCtx = ctx; s.basePath = targetBasePath; s.autoStartTime = Date.now(); diff --git a/src/resources/extensions/sf/auto/session.js b/src/resources/extensions/sf/auto/session.js index d07c13022..ed0cd9b67 100644 --- a/src/resources/extensions/sf/auto/session.js +++ b/src/resources/extensions/sf/auto/session.js @@ -15,6 +15,8 @@ * auto-session-encapsulation.test.ts enforce that auto.ts has no module-level * `let` or `var` declarations. */ + +import { emitJournalEvent } from "../journal.js"; import { buildModeState, resolveModelMode, @@ -22,6 +24,7 @@ import { resolveRunControlMode, resolveWorkMode, } from "../operating-model.js"; +import { loadSessionModeState, saveSessionModeState } from "../sf-db.js"; // ─── Constants ─────────────────────────────────────────────────────────────── export const MAX_UNIT_DISPATCHES = 3; @@ -42,7 +45,64 @@ export function resetAutoSession() { _autoSessionInstance = null; } // ─── 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 { + 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 ──────────────────────────────────────────────────────────── active = false; paused = false; @@ -62,6 +122,12 @@ export class AutoSession { * Defaults to "chat" for new sessions. */ 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. * Defaults to "restricted" for safety. @@ -259,6 +325,7 @@ export class AutoSession { this.cmdCtx = null; // Mode state this.workMode = "chat"; + this.runControl = "manual"; this.permissionProfile = "restricted"; this.modelMode = "smart"; this.surface = "tui"; @@ -349,11 +416,14 @@ export class AutoSession { permissionProfile, modelMode, surface, + reason = "user-command", + scope = "now", } = {}) { const prev = this.getMode(); if (workMode !== undefined) this.workMode = resolveWorkMode(workMode); if (runControl !== undefined) { const mode = resolveRunControlMode(runControl); + this.runControl = mode; this.stepMode = mode === "assisted"; } if (permissionProfile !== undefined) { @@ -362,7 +432,40 @@ export class AutoSession { if (modelMode !== undefined) this.modelMode = resolveModelMode(modelMode); if (surface !== undefined) this.surface = surface; 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. @@ -370,7 +473,11 @@ export class AutoSession { getMode() { return buildModeState({ workMode: this.workMode, - runControl: this.stepMode ? "assisted" : this.active ? "autonomous" : "manual", + runControl: this.active + ? this.stepMode + ? "assisted" + : "autonomous" + : this.runControl, permissionProfile: this.permissionProfile, modelMode: this.modelMode, surface: this.surface, diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.js b/src/resources/extensions/sf/bootstrap/register-hooks.js index 4cc492131..c4a014c2f 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.js +++ b/src/resources/extensions/sf/bootstrap/register-hooks.js @@ -22,7 +22,10 @@ import { recordToolCallName } from "../auto-tool-tracking.js"; import { loadToolApiKeys } from "../commands-config.js"; import { getEcosystemReadyPromise } from "../ecosystem/loader.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 { getDiscussionMilestoneId } from "../guided-flow.js"; import { initHealthWidget } from "../health-widget.js"; @@ -592,6 +595,32 @@ export function registerHooks(pi, ecosystemHandlers = []) { ); 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 ────────── // Covers write, edit, and bash tools to prevent bypass vectors. if (isToolCallEventType("write", event)) { @@ -648,7 +677,12 @@ export function registerHooks(pi, ecosystemHandlers = []) { if (!isAutoActive()) return; safetyRecordToolCall(event.toolCallId, event.toolName, event.input); 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) { emitJournalEvent( policyDash.basePath, diff --git a/src/resources/extensions/sf/commands-handlers.js b/src/resources/extensions/sf/commands-handlers.js index 8c54713dd..c66e52321 100644 --- a/src/resources/extensions/sf/commands-handlers.js +++ b/src/resources/extensions/sf/commands-handlers.js @@ -422,6 +422,54 @@ export async function handleTriage(args, ctx, pi, basePath) { } export async function handleSteer(change, ctx, pi) { const basePath = process.cwd(); + const trimmed = change.trim(); + + // ── Mode steering: /steer mode [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 [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 [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 mid = state.activeMilestone?.id ?? "none"; const sid = state.activeSlice?.id ?? "none"; diff --git a/src/resources/extensions/sf/commands/catalog.js b/src/resources/extensions/sf/commands/catalog.js index 960478bea..195a2c19a 100644 --- a/src/resources/extensions/sf/commands/catalog.js +++ b/src/resources/extensions/sf/commands/catalog.js @@ -12,7 +12,7 @@ const sfHome = process.env.SF_HOME || join(homedir(), ".sf"); * Comprehensive description of all available SF commands for help text. */ export const SF_COMMAND_DESCRIPTION = - "SF — Singularity Forge: /help|start|templates|next|autonomous|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|cleanup|mode|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([ "settings", @@ -102,7 +102,16 @@ export const TOP_LEVEL_SUBCOMMANDS = [ desc: "Manage worktrees from the TUI (list, merge, clean, remove)", }, { 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", desc: "Show effective configuration (models, routing, toggles)", @@ -124,6 +133,12 @@ export const TOP_LEVEL_SUBCOMMANDS = [ desc: "View, filter, and clear persistent notification history", }, { 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", desc: "UOK runtime health: ledger, last run, last error, startup gate, gate metrics", diff --git a/src/resources/extensions/sf/commands/handlers/core.js b/src/resources/extensions/sf/commands/handlers/core.js index 6b0bb7953..f392b5253 100644 --- a/src/resources/extensions/sf/commands/handlers/core.js +++ b/src/resources/extensions/sf/commands/handlers/core.js @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { getAutoSession } from "../../auto/session.js"; import { handleCmux } from "../../commands-cmux.js"; import { ensurePreferencesFile, @@ -34,11 +35,15 @@ export function showHelp(ctx, args = "") { ` /status Dashboard (${formattedShortcutPair("dashboard")})`, ` /parallel watch Parallel monitor (${formattedShortcutPair("parallel")})`, ` /notifications Notification history (${formattedShortcutPair("notifications")})`, + " /tasks Background work surface — units, workers, budget", " /visualize Interactive 10-tab TUI", " /queue Show queued/dispatched units", "", "COURSE CORRECTION", " /steer Apply user override to active work", + " /steer mode [scope] Change work mode (now|after-current-unit|next-milestone)", + " /steer trust

[scope] Change permission profile", + " /steer model-mode Change model mode for next unit", " /capture Quick-capture a thought to CAPTURES.md", " /triage Classify and route pending captures", " /undo Revert last completed unit [--force]", @@ -51,6 +56,9 @@ export function showHelp(ctx, args = "") { " /model Switch active session model", " /prefs Manage preferences", " /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.", ]; @@ -72,6 +80,7 @@ export function showHelp(ctx, args = "") { ` /parallel watch Open parallel worker monitor (${formattedShortcutPair("parallel")})`, " /visualize Interactive 10-tab TUI (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)", " /queue Show queued/dispatched units and execution order", + " /tasks Background work surface — units, workers, budget, checkpoints", " /history View execution history [--cost] [--phase] [--model] [N]", " /changelog Show categorized release notes [version]", ` /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/", " /setup Global setup status [llm|search|remote|keys|prefs]", " /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]", " /cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]", " /config Set API keys for external tools", @@ -112,6 +124,10 @@ export function showHelp(ctx, args = "") { "", "MAINTENANCE", " /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 Run eval cases for a skill", " /reload Snapshot & reload agent, resume same session", " /export Export milestone/slice results [--json|--markdown|--html] [--all]", " /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"); } +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) { if ( trimmed === "help" || @@ -419,6 +500,13 @@ export async function handleCoreCommand(trimmed, ctx, pi) { } if (trimmed === "mode" || trimmed.startsWith("mode ")) { 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 path = scope === "project" @@ -428,10 +516,27 @@ export async function handleCoreCommand(trimmed, ctx, pi) { await handlePrefsMode(ctx, scope); 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 ")) { await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx); 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 ")) { await handleCmux(trimmed.replace(/^cmux\s*/, "").trim(), ctx); return true; @@ -504,6 +609,143 @@ export async function handleCoreCommand(trimmed, ctx, pi) { process.exit(EXIT_RELOAD); 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 to run eval cases for a skill."); + ctx.ui.notify(lines.join("\n"), "info"); + return true; + } return false; } export function formatTextStatus(state) { diff --git a/src/resources/extensions/sf/commands/handlers/ops.js b/src/resources/extensions/sf/commands/handlers/ops.js index 03c7f9713..2f36ab336 100644 --- a/src/resources/extensions/sf/commands/handlers/ops.js +++ b/src/resources/extensions/sf/commands/handlers/ops.js @@ -1,4 +1,5 @@ import { handleRemote } from "../../../remote-questions/mod.js"; +import { getAutoSession } from "../../auto/session.js"; import { dispatchDirectPhase } from "../../auto-direct-dispatch.js"; import { handleConfig } from "../../commands-config.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); 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 ")) { const { handleUok } = await import("../../commands-uok.js"); await handleUok(trimmed.replace(/^uok\s*/, "").trim(), ctx); diff --git a/src/resources/extensions/sf/commands/handlers/tasks.js b/src/resources/extensions/sf/commands/handlers/tasks.js new file mode 100644 index 000000000..204a705ee --- /dev/null +++ b/src/resources/extensions/sf/commands/handlers/tasks.js @@ -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"); +} diff --git a/src/resources/extensions/sf/complexity-classifier.js b/src/resources/extensions/sf/complexity-classifier.js index 0869cfba1..dbb911c8a 100644 --- a/src/resources/extensions/sf/complexity-classifier.js +++ b/src/resources/extensions/sf/complexity-classifier.js @@ -3,6 +3,7 @@ // Pure heuristics + adaptive learning — no LLM calls. Sub-millisecond classification. import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { modelModeToTier } from "./operating-model.js"; import { sfRoot } from "./paths.js"; import { getAdaptiveTierAdjustment } from "./routing-history.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 budgetPct Current budget usage as fraction (0.0-1.0+), or undefined if no budget * @param metadata Optional pre-parsed task metadata + * @param modelMode Optional model mode override (fast/smart/deep) from session */ export function classifyUnitComplexity( unitType, @@ -44,6 +46,7 @@ export function classifyUnitComplexity( basePath, budgetPct, metadata, + modelMode, ) { // Hook units default to light if (unitType.startsWith("hook/")) { @@ -86,6 +89,20 @@ export function classifyUnitComplexity( reason = `${reason} (adaptive: high failure rate at ${tier})`; 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 = { tier, reason, diff --git a/src/resources/extensions/sf/extension-manifest.json b/src/resources/extensions/sf/extension-manifest.json index 2de411cc8..6854fda0c 100644 --- a/src/resources/extensions/sf/extension-manifest.json +++ b/src/resources/extensions/sf/extension-manifest.json @@ -53,6 +53,7 @@ "cmux", "codebase", "config", + "control", "debug", "discuss", "dispatch", @@ -60,7 +61,6 @@ "doctor", "escalate", "eval-review", - "exit", "extensions", "extract-learnings", "fast", @@ -78,6 +78,7 @@ "mcp", "migrate", "mode", + "model-mode", "new-milestone", "next", "notifications", @@ -93,6 +94,7 @@ "remote", "reset-slice", "rethink", + "repair", "run-hook", "scaffold", "scan", @@ -102,14 +104,17 @@ "ship", "show-config", "skill-health", + "skills", "skip", "solver-eval", "start", "status", "steer", + "tasks", "templates", "todo", "triage", + "trust", "undo", "undo-task", "unpark", diff --git a/src/resources/extensions/sf/learning/outcome-recorder.test.mjs b/src/resources/extensions/sf/learning/outcome-recorder.test.mjs index fa61514e9..ffffc650d 100644 --- a/src/resources/extensions/sf/learning/outcome-recorder.test.mjs +++ b/src/resources/extensions/sf/learning/outcome-recorder.test.mjs @@ -1,7 +1,7 @@ /** * 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`, * `transaction`). The fake parses just enough SQL to verify the * insert and aggregate semantics without spinning up real SQLite. @@ -41,7 +41,10 @@ const INSERT_COLUMNS = [ "recorded_at", ]; -function createFakeDb({ includeTransaction = true, throwOnPrepare = false } = {}) { +function createFakeDb({ + includeTransaction = true, + throwOnPrepare = false, +} = {}) { const rows = []; const execSql = []; let nextId = 1; diff --git a/src/resources/extensions/sf/operating-model.js b/src/resources/extensions/sf/operating-model.js index 8d9d152cb..c52dcea6c 100644 --- a/src/resources/extensions/sf/operating-model.js +++ b/src/resources/extensions/sf/operating-model.js @@ -30,11 +30,7 @@ export const PERMISSION_PROFILES = Object.freeze([ "unrestricted", ]); -export const MODEL_MODES = Object.freeze([ - "fast", - "smart", - "deep", -]); +export const MODEL_MODES = Object.freeze(["fast", "smart", "deep"]); /** * Returns true for a canonical SF work mode. @@ -166,12 +162,48 @@ export function defaultModelModeForWorkMode(workMode) { case "build": case "repair": return "smart"; - case "chat": default: 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. * diff --git a/src/resources/extensions/sf/parallel-eligibility.js b/src/resources/extensions/sf/parallel-eligibility.js index 60abe2e06..57e61bd17 100644 --- a/src/resources/extensions/sf/parallel-eligibility.js +++ b/src/resources/extensions/sf/parallel-eligibility.js @@ -161,7 +161,7 @@ export async function analyzeParallelEligibility(basePath) { "All dependencies satisfied. NOTE: file overlap with another eligible milestone."; } } - return { eligible, ineligible, fileOverlaps }; + return { eligible, ineligible, fileOverlaps, fileSets }; } // ─── Formatting ────────────────────────────────────────────────────────────── /** diff --git a/src/resources/extensions/sf/parallel-intent.js b/src/resources/extensions/sf/parallel-intent.js new file mode 100644 index 000000000..8782affea --- /dev/null +++ b/src/resources/extensions/sf/parallel-intent.js @@ -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}`); + } +} diff --git a/src/resources/extensions/sf/parallel-monitor-overlay.js b/src/resources/extensions/sf/parallel-monitor-overlay.js index fa5f78fdc..61ad54cd2 100644 --- a/src/resources/extensions/sf/parallel-monitor-overlay.js +++ b/src/resources/extensions/sf/parallel-monitor-overlay.js @@ -26,7 +26,10 @@ import { formattedShortcutPair } from "./shortcut-defs.js"; function queryRows(dbPath, sql, params = []) { const db = new DatabaseSync(dbPath, { readOnly: true }); try { - return db.prepare(sql).all(...params).map((row) => ({ ...row })); + return db + .prepare(sql) + .all(...params) + .map((row) => ({ ...row })); } finally { db.close(); } @@ -156,8 +159,9 @@ function queryRecentCompletions(basePath, mid) { ORDER BY completed_at DESC LIMIT 5`, [mid], - ).map((row) => - `✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`, + ).map( + (row) => + `✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`, ); } catch { return []; diff --git a/src/resources/extensions/sf/parallel-orchestrator.js b/src/resources/extensions/sf/parallel-orchestrator.js index 105238cf0..439dba3f4 100644 --- a/src/resources/extensions/sf/parallel-orchestrator.js +++ b/src/resources/extensions/sf/parallel-orchestrator.js @@ -27,6 +27,12 @@ import { readIntegrationBranch } from "./git-service.js"; import { emitJournalEvent } from "./journal.js"; import { nativeBranchExists } from "./native-git-bridge.js"; import { analyzeParallelEligibility } from "./parallel-eligibility.js"; +import { + checkIntentConflicts, + clearAllIntents, + declareIntent, + releaseIntent, +} from "./parallel-intent.js"; import { sfRoot } from "./paths.js"; import { resolveParallelConfig } from "./preferences.js"; import { @@ -362,10 +368,12 @@ export async function startParallel(basePath, milestoneIds, prefs) { const started = []; const errors = []; let filteredMilestoneIds = milestoneIds; + let intentFilesByMilestone = new Map(); if (uokFlags.executionGraph && milestoneIds.length > 1) { try { const requestedIds = new Set(milestoneIds); const candidates = await analyzeParallelEligibility(basePath); + intentFilesByMilestone = candidates.fileSets ?? intentFilesByMilestone; const overlapPairs = new Set(); for (const overlap of candidates.fileOverlaps) { if (!requestedIds.has(overlap.mid1) || !requestedIds.has(overlap.mid2)) @@ -398,8 +406,23 @@ export async function startParallel(basePath, milestoneIds, prefs) { // Cap to max_workers const toStart = filteredMilestoneIds.slice(0, config.max_workers); 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 if (isBudgetExceeded()) { + releaseIntent(basePath, mid); errors.push({ mid, error: `Budget ceiling ($${config.budget_ceiling}) reached — skipping`, @@ -448,6 +471,7 @@ export async function startParallel(basePath, milestoneIds, prefs) { }); started.push(mid); } catch (err) { + releaseIntent(basePath, mid); const message = getErrorMessage(err); errors.push({ mid, error: message }); } @@ -672,6 +696,7 @@ export function spawnWorker(basePath, milestoneId) { } sibling.state = "cancelled"; sibling.process = null; + releaseIntent(basePath, siblingId); // Update session status so dashboard reflects the cancellation writeSessionStatus(basePath, { milestoneId: siblingId, @@ -711,6 +736,7 @@ export function spawnWorker(basePath, milestoneId) { startedAt: w.startedAt, worktreePath: w.worktreePath, }); + releaseIntent(basePath, milestoneId); persistState(basePath); }); return true; @@ -876,6 +902,8 @@ export async function stopParallel(basePath, milestoneId) { if (!milestoneId) { state.active = false; } + // Clear all intent claims on shutdown + clearAllIntents(basePath); // Persist final state and clean up state file removeStateFile(basePath); } diff --git a/src/resources/extensions/sf/self-feedback-drain.js b/src/resources/extensions/sf/self-feedback-drain.js index 7a436a128..642e7cd74 100644 --- a/src/resources/extensions/sf/self-feedback-drain.js +++ b/src/resources/extensions/sf/self-feedback-drain.js @@ -180,6 +180,23 @@ export function dispatchSelfFeedbackInlineFixIfNeeded(basePath, ctx, pi) { ); 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); const prompt = buildInlineFixPrompt(candidates); ctx.ui.notify( diff --git a/src/resources/extensions/sf/session-forensics.js b/src/resources/extensions/sf/session-forensics.js index 8e4233ccb..3a4701f0b 100644 --- a/src/resources/extensions/sf/session-forensics.js +++ b/src/resources/extensions/sf/session-forensics.js @@ -124,8 +124,7 @@ export function extractTrace(entries) { isError && (pending.name === "bash" || pending.name === "bg_shell") ) { - const lastCmd = findLast( - commandsRun, + const lastCmd = commandsRun.findLast( (c) => c.command === String(pending.input.command), ); if (lastCmd) lastCmd.failed = true; @@ -482,10 +481,3 @@ function redactInput(_name, input) { } 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; -} diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index 81fdd6675..6ef676b7f 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -78,7 +78,7 @@ function openRawDb(path) { loadProvider(); return new DatabaseSync(path); } -const SCHEMA_VERSION = 42; +const SCHEMA_VERSION = 43; function indexExists(db, name) { return !!db .prepare( @@ -2221,6 +2221,29 @@ function migrateSchema(db) { ":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"); } catch (err) { db.exec("ROLLBACK"); @@ -2542,6 +2565,67 @@ export function getDbOwnerPid() { export function getDbPath() { 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() { return currentDb; } diff --git a/src/resources/extensions/sf/skills/auto-create.js b/src/resources/extensions/sf/skills/auto-create.js new file mode 100644 index 000000000..8b43467d6 --- /dev/null +++ b/src/resources/extensions/sf/skills/auto-create.js @@ -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); +} diff --git a/src/resources/extensions/sf/skills/directory.js b/src/resources/extensions/sf/skills/directory.js new file mode 100644 index 000000000..a20c474fc --- /dev/null +++ b/src/resources/extensions/sf/skills/directory.js @@ -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; + } +} diff --git a/src/resources/extensions/sf/skills/eval-harness.js b/src/resources/extensions/sf/skills/eval-harness.js new file mode 100644 index 000000000..955518aed --- /dev/null +++ b/src/resources/extensions/sf/skills/eval-harness.js @@ -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 }; +} diff --git a/src/resources/extensions/sf/skills/frontmatter.js b/src/resources/extensions/sf/skills/frontmatter.js new file mode 100644 index 000000000..5d251b5d8 --- /dev/null +++ b/src/resources/extensions/sf/skills/frontmatter.js @@ -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, + }; +} diff --git a/src/resources/extensions/sf/skills/index.js b/src/resources/extensions/sf/skills/index.js new file mode 100644 index 000000000..a05e6da6c --- /dev/null +++ b/src/resources/extensions/sf/skills/index.js @@ -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"; diff --git a/src/resources/extensions/sf/skills/loader.js b/src/resources/extensions/sf/skills/loader.js new file mode 100644 index 000000000..83ba7c7cf --- /dev/null +++ b/src/resources/extensions/sf/skills/loader.js @@ -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"), + ); +} diff --git a/src/resources/extensions/sf/skills/spec-first-tdd/SKILL.md b/src/resources/extensions/sf/skills/spec-first-tdd/SKILL.md index 2d709327f..1cd946ec4 100644 --- a/src/resources/extensions/sf/skills/spec-first-tdd/SKILL.md +++ b/src/resources/extensions/sf/skills/spec-first-tdd/SKILL.md @@ -75,14 +75,13 @@ Label evidence: ### 3. Write the Failing Test (this is the spec) ```ts -import { test } from "node:test"; -import assert from "node:assert/strict"; +import { expect, test } from "vitest"; // behaviour contract: claim() rejects takeover when claim_until > now() test("claim_when_active_lease_returns_false", () => { const now = 1000; 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); }); ``` diff --git a/src/resources/extensions/sf/skills/templates.js b/src/resources/extensions/sf/skills/templates.js new file mode 100644 index 000000000..9dad96827 --- /dev/null +++ b/src/resources/extensions/sf/skills/templates.js @@ -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 +`; +} diff --git a/src/resources/extensions/sf/temporal-foundation.js b/src/resources/extensions/sf/temporal-foundation.js new file mode 100644 index 000000000..fa0405a5b --- /dev/null +++ b/src/resources/extensions/sf/temporal-foundation.js @@ -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 }; +} diff --git a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs index c4d8522a0..9a8935849 100644 --- a/src/resources/extensions/sf/tests/direct-command-surface.test.mjs +++ b/src/resources/extensions/sf/tests/direct-command-surface.test.mjs @@ -1,4 +1,6 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { test } from "vitest"; 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`, + ); + } +}); diff --git a/src/resources/extensions/sf/tests/operating-model.test.mjs b/src/resources/extensions/sf/tests/operating-model.test.mjs index cee0399cc..aeaa7f6e5 100644 --- a/src/resources/extensions/sf/tests/operating-model.test.mjs +++ b/src/resources/extensions/sf/tests/operating-model.test.mjs @@ -9,14 +9,16 @@ import { isRunControlMode, isWorkMode, MODEL_MODES, + modelModeToTier, PERMISSION_PROFILES, + RUN_CONTROL_MODES, resolveModelMode, resolvePermissionProfile, resolveRunControlMode, resolveWorkMode, - RUN_CONTROL_MODES, - WORK_MODES, runControlModeForSession, + tierToModelMode, + WORK_MODES, } from "../operating-model.js"; describe("operating model vocabulary", () => { @@ -120,4 +122,18 @@ describe("operating model vocabulary", () => { assert.equal(state.modelMode, "smart"); 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"); + }); }); diff --git a/src/resources/extensions/sf/tests/parallel-intent.test.mjs b/src/resources/extensions/sf/tests/parallel-intent.test.mjs new file mode 100644 index 000000000..9f7d297db --- /dev/null +++ b/src/resources/extensions/sf/tests/parallel-intent.test.mjs @@ -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"); + }); +}); diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs index 086dcb2fc..f31fb3a07 100644 --- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -217,7 +217,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", const version = db .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); - assert.equal(version.version, 42); + assert.equal(version.version, 43); const taskSpec = db .prepare( "SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'", diff --git a/src/resources/extensions/sf/tests/skill-eval-harness.test.mjs b/src/resources/extensions/sf/tests/skill-eval-harness.test.mjs new file mode 100644 index 000000000..19f7addf8 --- /dev/null +++ b/src/resources/extensions/sf/tests/skill-eval-harness.test.mjs @@ -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); + } + }); +}); diff --git a/src/resources/extensions/sf/tests/skills.test.mjs b/src/resources/extensions/sf/tests/skills.test.mjs new file mode 100644 index 000000000..2ff71313e --- /dev/null +++ b/src/resources/extensions/sf/tests/skills.test.mjs @@ -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); + }); +}); diff --git a/src/resources/extensions/sf/tests/temporal-foundation.test.mjs b/src/resources/extensions/sf/tests/temporal-foundation.test.mjs new file mode 100644 index 000000000..c523fec75 --- /dev/null +++ b/src/resources/extensions/sf/tests/temporal-foundation.test.mjs @@ -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, + ); + }); + }); +} diff --git a/src/resources/extensions/sf/tests/uok-coordination-store.test.mjs b/src/resources/extensions/sf/tests/uok-coordination-store.test.mjs index d6e0f4d8d..9c802fce0 100644 --- a/src/resources/extensions/sf/tests/uok-coordination-store.test.mjs +++ b/src/resources/extensions/sf/tests/uok-coordination-store.test.mjs @@ -59,6 +59,22 @@ test("kv_cleanupExpired_removes_expired_rows", () => { 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", () => { const store = makeStore(); 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), ["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", () => { diff --git a/src/resources/extensions/sf/tests/uok-execution-graph-persist.test.mjs b/src/resources/extensions/sf/tests/uok-execution-graph-persist.test.mjs index 3892ce02e..47b1fd2f8 100644 --- a/src/resources/extensions/sf/tests/uok-execution-graph-persist.test.mjs +++ b/src/resources/extensions/sf/tests/uok-execution-graph-persist.test.mjs @@ -7,11 +7,7 @@ import assert from "node:assert/strict"; import { afterEach, test } from "vitest"; -import { - closeDatabase, - getDatabase, - openDatabase, -} from "../sf-db.js"; +import { closeDatabase, getDatabase, openDatabase } from "../sf-db.js"; import { ensureExecutionGraphSchema, getGraphProgressStream, @@ -170,8 +166,20 @@ test("persistGraphNode_updates_existing_node", () => { test("persistFullGraph_writes_snapshot_and_nodes_in_transaction", () => { const db = makeDb(); 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, { @@ -196,9 +204,24 @@ test("queryTasksByState_filters_by_state", () => { const db = makeDb(); ensureExecutionGraphSchema(db); - persistGraphNode(db, "g1", { id: "n1", 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" }); + persistGraphNode(db, "g1", { + id: "n1", + 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"] }); assert.equal(todo.length, 1); @@ -213,8 +236,20 @@ test("queryTasksByState_filters_by_milestone", () => { const db = makeDb(); ensureExecutionGraphSchema(db); - persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", milestoneId: "M001", state: "todo" }); - persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", milestoneId: "M002", state: "todo" }); + persistGraphNode(db, "g1", { + 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" }); assert.equal(m1.length, 1); @@ -247,10 +282,30 @@ test("getGraphStateSummary_computes_counts_and_progress", () => { const db = makeDb(); ensureExecutionGraphSchema(db); - persistGraphNode(db, "g1", { id: "n1", kind: "unit", 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" }); + persistGraphNode(db, "g1", { + id: "n1", + kind: "unit", + 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"); assert.equal(summary.total, 4); @@ -266,8 +321,18 @@ test("getGraphStateSummary_complete_when_all_terminal", () => { const db = makeDb(); ensureExecutionGraphSchema(db); - persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", state: "done" }); - persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", state: "failed" }); + persistGraphNode(db, "g1", { + id: "n1", + kind: "unit", + unitId: "U1", + state: "done", + }); + persistGraphNode(db, "g1", { + id: "n2", + kind: "unit", + unitId: "U2", + state: "failed", + }); const summary = getGraphStateSummary(db, "g1"); assert.equal(summary.progress, 100); @@ -297,7 +362,10 @@ test("getGraphProgressStream_returns_recent_first", () => { ensureExecutionGraphSchema(db); 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); assert.equal(events[0].type, "second"); diff --git a/src/resources/extensions/sf/tests/uok-scheduler-v2.test.mjs b/src/resources/extensions/sf/tests/uok-scheduler-v2.test.mjs index 098eb594e..5324932c5 100644 --- a/src/resources/extensions/sf/tests/uok-scheduler-v2.test.mjs +++ b/src/resources/extensions/sf/tests/uok-scheduler-v2.test.mjs @@ -61,14 +61,18 @@ test("token_onCancel_fires_immediately_if_already_cancelled", () => { const token = new CancellationToken(); token.cancel("done"); let fired = false; - token.onCancel(() => { fired = true; }); + token.onCancel(() => { + fired = true; + }); assert.equal(fired, true); }); test("token_onCancel_fires_when_cancelled_later", () => { const token = new CancellationToken(); let fired = false; - token.onCancel(() => { fired = true; }); + token.onCancel(() => { + fired = true; + }); assert.equal(fired, false); token.cancel("later"); assert.equal(fired, true); @@ -77,7 +81,9 @@ test("token_onCancel_fires_when_cancelled_later", () => { test("token_onCancel_unsubscribe_works", () => { const token = new CancellationToken(); let fired = false; - const unsub = token.onCancel(() => { fired = true; }); + const unsub = token.onCancel(() => { + fired = true; + }); unsub(); token.cancel("nope"); assert.equal(fired, false); @@ -218,7 +224,7 @@ test("scheduler_serial_cancellation_stops_execution", async () => { const scheduler = new ExecutionGraphSchedulerV2(); const order = []; const globalToken = new CancellationToken(); - scheduler.registerHandler("unit", async (node, token) => { + scheduler.registerHandler("unit", async (node, _token) => { if (node.id === "b") globalToken.cancel("stop"); globalToken.throwIfCancelled(); order.push(node.id); @@ -243,7 +249,7 @@ test("scheduler_serial_cancellation_stops_execution", async () => { test("scheduler_parallel_runs_independent_nodes_concurrently", async () => { const scheduler = new ExecutionGraphSchedulerV2(); const starts = []; - scheduler.registerHandler("unit", async (node, token, progress) => { + scheduler.registerHandler("unit", async (node, _token, _progress) => { starts.push(node.id); await new Promise((r) => setTimeout(r, 50)); return { gateResults: [{ outcome: "pass", gateId: "test" }] }; @@ -264,7 +270,7 @@ test("scheduler_parallel_enforces_max_workers", async () => { const scheduler = new ExecutionGraphSchedulerV2(); let concurrent = 0; let maxConcurrent = 0; - scheduler.registerHandler("unit", async (node) => { + scheduler.registerHandler("unit", async (_node) => { concurrent++; maxConcurrent = Math.max(maxConcurrent, concurrent); 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 () => { const scheduler = new ExecutionGraphSchedulerV2(); - scheduler.registerHandler("unit", async (node) => { + scheduler.registerHandler("unit", async (_node) => { return { gateResults: [{ outcome: "pass", gateId: "test" }] }; }); - const nodes = [ - { id: "a", kind: "unit", dependsOn: [], metadata: {} }, - ]; + const nodes = [{ id: "a", kind: "unit", dependsOn: [], metadata: {} }]; const events = []; 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 () => { const scheduler = new ExecutionGraphSchedulerV2(); - scheduler.registerHandler("unit", async (node) => { + scheduler.registerHandler("unit", async (_node) => { return { gateResults: [ { outcome: "pass", gateId: "security" }, @@ -330,7 +334,7 @@ test("scheduler_parallel_task_records_have_correct_state", async () => { test("scheduler_parallel_detects_cyclic_dependencies", async () => { const scheduler = new ExecutionGraphSchedulerV2(); - scheduler.registerHandler("unit", async (node) => ({ + scheduler.registerHandler("unit", async (_node) => ({ gateResults: [{ outcome: "pass", gateId: "test" }], })); @@ -339,15 +343,12 @@ test("scheduler_parallel_detects_cyclic_dependencies", async () => { { id: "b", kind: "unit", dependsOn: ["a"], metadata: {} }, ]; - await assert.rejects( - scheduler.run(nodes, { parallel: true }), - /cyclic/, - ); + await assert.rejects(scheduler.run(nodes, { parallel: true }), /cyclic/); }); test("scheduler_parallel_detects_deadlock", async () => { const scheduler = new ExecutionGraphSchedulerV2(); - scheduler.registerHandler("unit", async (node) => ({ + scheduler.registerHandler("unit", async (_node) => ({ gateResults: [{ outcome: "pass", gateId: "test" }], })); @@ -357,10 +358,7 @@ test("scheduler_parallel_detects_deadlock", async () => { { id: "b", kind: "unit", dependsOn: ["a"], metadata: {} }, ]; - await assert.rejects( - scheduler.run(nodes, { parallel: true }), - /cyclic/, - ); + await assert.rejects(scheduler.run(nodes, { parallel: true }), /cyclic/); }); // ─── Progress stream wiring ──────────────────────────────────────────────── @@ -375,9 +373,7 @@ test("scheduler_wires_progress_to_parent_stream", async () => { const parentEvents = []; parent.onProgress((ev) => parentEvents.push(ev)); - const nodes = [ - { id: "x", kind: "unit", dependsOn: [], metadata: {} }, - ]; + const nodes = [{ id: "x", kind: "unit", dependsOn: [], metadata: {} }]; await scheduler.run(nodes, { parallel: false, parentStream: parent }); assert.ok(parentEvents.some((e) => e.type === "node-complete")); diff --git a/src/resources/extensions/sf/tests/uok-task-state.test.mjs b/src/resources/extensions/sf/tests/uok-task-state.test.mjs index 94b448a4b..c3eb0c3f3 100644 --- a/src/resources/extensions/sf/tests/uok-task-state.test.mjs +++ b/src/resources/extensions/sf/tests/uok-task-state.test.mjs @@ -8,13 +8,12 @@ import assert from "node:assert/strict"; import { test } from "vitest"; import { - TASK_STATES, - TASK_TERMINAL_STATES, - TASK_STATE_TRANSITIONS, aggregateTaskStates, buildTaskRecord, canTransitionTaskState, gateOutcomesToTaskState, + TASK_STATES, + TASK_TERMINAL_STATES, unitRuntimeToTaskState, } from "../uok/task-state.js"; diff --git a/src/resources/extensions/sf/uok/coordination-store.js b/src/resources/extensions/sf/uok/coordination-store.js index 87b53a5e3..a2ccd843e 100644 --- a/src/resources/extensions/sf/uok/coordination-store.js +++ b/src/resources/extensions/sf/uok/coordination-store.js @@ -125,6 +125,24 @@ export class UokCoordinationStore { 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) { this.db.prepare("DELETE FROM uok_kv WHERE key = :key").run({ key }); } diff --git a/src/resources/extensions/sf/uok/execution-graph-persist.js b/src/resources/extensions/sf/uok/execution-graph-persist.js index 69bcbb00f..71d3613d1 100644 --- a/src/resources/extensions/sf/uok/execution-graph-persist.js +++ b/src/resources/extensions/sf/uok/execution-graph-persist.js @@ -298,9 +298,15 @@ export function getGraphStateSummary(db, graphId) { .all({ graphId }); 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; for (const r of rows) { diff --git a/src/resources/extensions/sf/uok/index.js b/src/resources/extensions/sf/uok/index.js index 5a56dc165..8b8e42875 100644 --- a/src/resources/extensions/sf/uok/index.js +++ b/src/resources/extensions/sf/uok/index.js @@ -9,161 +9,157 @@ * extension that needs orchestration primitives. */ -// ─── Contracts & Types ──────────────────────────────────────────────────── -export { validateGate } from "./contracts.js"; - -// ─── Core Kernel ─────────────────────────────────────────────────────────── -export { recordUokKernelTermination, runAutoLoopWithUok } from "./kernel.js"; - -// ─── Gate System ─────────────────────────────────────────────────────────── -export { UokGateRunner, enrichGateResultWithMemory } from "./gate-runner.js"; +// ─── Skills (Repo-local capabilities) ────────────────────────────────────── +export { + buildSkillRecord, + discoverAllSkills, + discoverSkillDirs, + getModelInvocableSkills, + getPermittedSkills, + 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 ───────────────────────────────────────────────────────────────── export { ChaosMonkey, ChaosMonkeyGate } from "./chaos-monkey.js"; -export { CostGuardGate } from "./cost-guard-gate.js"; -export { MultiPackageGate } from "./multi-package-gate.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"; - +// ─── Contracts & Types ──────────────────────────────────────────────────── +export { validateGate } from "./contracts.js"; // ─── Coordination Store ─────────────────────────────────────────────────── export { ensureCoordinationSchema, UokCoordinationStore, } from "./coordination-store.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"; - +export { CostGuardGate } from "./cost-guard-gate.js"; // ─── Diagnostics ─────────────────────────────────────────────────────────── export { + readUokDiagnostics, synthesizeUokDiagnostics, writeUokDiagnostics, - readUokDiagnostics, } from "./diagnostic-synthesis.js"; +// ─── Dispatch Envelope ───────────────────────────────────────────────────── +export { buildDispatchEnvelope, explainDispatch } from "./dispatch-envelope.js"; -// ─── Parity & Ledger ─────────────────────────────────────────────────────── +// ─── Execution Graph ─────────────────────────────────────────────────────── export { - writeParityReport, - readParityReport, - summarizeParityHealth, - writeParityHeartbeat, - parseParityEvents, - UNMATCHED_RUN_STALE_MS, - signalKernelEnter, - resetParityCommitBlock, - checkAndDrainMissingExit, -} from "./parity-report.js"; + buildExecutionGraphSnapshot, + buildSidecarQueueNodes, + ExecutionGraphScheduler, + scheduleSidecarQueue, + selectConflictFreeBatch, + selectReactiveDispatchBatch, +} from "./execution-graph.js"; +// ─── Execution Graph Persistence ─────────────────────────────────────────── export { - signalKernelEnter as signalParityEnter, -} from "./parity-diff-capture.js"; - + ensureExecutionGraphSchema, + 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 ─────────────────────────────────────────────────────────── export { AgentInbox, MessageBus } from "./message-bus.js"; - // ─── Metrics ─────────────────────────────────────────────────────────────── export { buildMetricsText, invalidateMetricsCache, metricsPath, - writeUokMetrics, readUokMetrics, + writeUokMetrics, } from "./metrics-exposition.js"; - -// ─── GitOps ──────────────────────────────────────────────────────────────── +// ─── Model Policy ────────────────────────────────────────────────────────── +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 { - writeTurnGitTransaction, - writeTurnCloseoutGitRecord, -} from "./gitops.js"; - + checkAndDrainMissingExit, + parseParityEvents, + 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 ────────────────────────────────────────────────────────── export { acquireWriterToken, - releaseWriterToken, nextWriteRecord, + releaseWriterToken, } 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"; diff --git a/src/resources/extensions/sf/uok/kernel.js b/src/resources/extensions/sf/uok/kernel.js index 39f2d9544..db83c420d 100644 --- a/src/resources/extensions/sf/uok/kernel.js +++ b/src/resources/extensions/sf/uok/kernel.js @@ -110,10 +110,15 @@ export async function runAutoLoopWithUok(args) { prefs?.uok?.permission_profile ?? defaultPermissionProfileForRunControl(runControl), ); + // Include workMode and modelMode from session in lifecycle flags + const workMode = s.workMode ?? "chat"; + const modelMode = s.modelMode ?? "smart"; const lifecycleFlags = { ...flags, runControl, permissionProfile, + workMode, + modelMode, }; const healthVerdict = writeUokDiagnostics(s.basePath); @@ -166,6 +171,8 @@ export async function runAutoLoopWithUok(args) { flags: lifecycleFlags, runControl, permissionProfile, + workMode, + modelMode, sessionId: ctx.sessionManager?.getSessionId?.(), }, }), diff --git a/src/resources/extensions/sf/uok/scheduler-v2.js b/src/resources/extensions/sf/uok/scheduler-v2.js index f26172644..67149626d 100644 --- a/src/resources/extensions/sf/uok/scheduler-v2.js +++ b/src/resources/extensions/sf/uok/scheduler-v2.js @@ -35,7 +35,11 @@ export class CancellationToken { this.#cancelled = true; this.#reason = reason; for (const fn of this.#listeners) { - try { fn(reason); } catch { /* ignore */ } + try { + fn(reason); + } catch { + /* ignore */ + } } this.#listeners.clear(); } @@ -50,7 +54,11 @@ export class CancellationToken { onCancel(fn) { if (this.#cancelled) { - try { fn(this.#reason); } catch { /* ignore */ } + try { + fn(this.#reason); + } catch { + /* ignore */ + } return () => {}; } this.#listeners.add(fn); @@ -84,7 +92,11 @@ export class ProgressStream { this.#history = this.#history.slice(-this.#maxHistory); } 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); // Replay recent history so new listener catches up for (const ev of this.#history) { - try { fn(ev); } catch { /* ignore */ } + try { + fn(ev); + } catch { + /* ignore */ + } } return () => this.#listeners.delete(fn); } @@ -312,7 +328,6 @@ export class ExecutionGraphSchedulerV2 { } async #runParallel(sorted, token, conflicts, maxWorkers) { - const nodeMap = new Map(sorted.map((n) => [n.id, n])); const done = new Set(); const order = []; const promises = new Map(); // nodeId -> promise @@ -352,11 +367,7 @@ export class ExecutionGraphSchedulerV2 { .acquire(node.id, nodeToken) .then(async ({ workerId, release }) => { try { - const result = await this.#executeNode( - node, - nodeToken, - workerId, - ); + const result = await this.#executeNode(node, nodeToken, workerId); done.add(node.id); order.push(result.nodeId); promises.delete(node.id); @@ -445,19 +456,23 @@ export class ExecutionGraphSchedulerV2 { } catch (err) { error = err; if (err.name === "CancellationError") { - gateResults = [{ - gateId: "scheduler", - outcome: "fail", - failureClass: "cancelled", - rationale: err.message, - }]; + gateResults = [ + { + gateId: "scheduler", + outcome: "fail", + failureClass: "cancelled", + rationale: err.message, + }, + ]; } else { - gateResults = [{ - gateId: "scheduler", - outcome: "fail", - failureClass: "execution", - rationale: err.message, - }]; + gateResults = [ + { + gateId: "scheduler", + outcome: "fail", + failureClass: "execution", + rationale: err.message, + }, + ]; } } finally { clearTimeout(nodeTimeoutHandle); diff --git a/src/resources/extensions/sf/uok/task-state.js b/src/resources/extensions/sf/uok/task-state.js index b40ec394f..e0599aa8d 100644 --- a/src/resources/extensions/sf/uok/task-state.js +++ b/src/resources/extensions/sf/uok/task-state.js @@ -10,8 +10,7 @@ * background-work tracking. */ -import { isDbAvailable } from "../sf-db.js"; -import { isTerminalUnitRuntimeStatus, UNIT_RUNTIME_STATUSES } from "./unit-runtime.js"; +import { isTerminalUnitRuntimeStatus } from "./unit-runtime.js"; export const TASK_STATES = [ "todo", @@ -119,16 +118,14 @@ export function aggregateTaskStates(taskStates) { if (s in counts) counts[s]++; } const total = taskStates.length; - const terminal = TASK_STATES.filter((s) => TASK_TERMINAL_STATES.has(s)).reduce( - (sum, s) => sum + counts[s], - 0, - ); + const terminal = TASK_STATES.filter((s) => + TASK_TERMINAL_STATES.has(s), + ).reduce((sum, s) => sum + counts[s], 0); return { counts, total, terminal, - progress: - total > 0 ? Math.round(((terminal / total) * 100)) : 0, + progress: total > 0 ? Math.round((terminal / total) * 100) : 0, isComplete: terminal === total && total > 0, }; } @@ -158,9 +155,10 @@ export function buildTaskRecord({ modelId = null, workerId = null, }) { - const state = gateResults.length > 0 - ? gateOutcomesToTaskState(gateResults) - : unitRuntimeToTaskState(runtimeRecord); + const state = + gateResults.length > 0 + ? gateOutcomesToTaskState(gateResults) + : unitRuntimeToTaskState(runtimeRecord); return { id: nodeId,