feat(sf): streamline uok state and direct modes
This commit is contained in:
parent
19bfc3d3f6
commit
378ab702e1
58 changed files with 3679 additions and 348 deletions
37
.agents/skills/forge-autonomous-runtime/SKILL.md
Normal file
37
.agents/skills/forge-autonomous-runtime/SKILL.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
name: forge-autonomous-runtime
|
||||||
|
description: Explains SF autonomous loop, UOK gates, installed-runtime drift, and recovery paths.
|
||||||
|
user-invocable: false
|
||||||
|
model-invocable: true
|
||||||
|
side-effects: none
|
||||||
|
permission-profile: restricted
|
||||||
|
triggers:
|
||||||
|
- "*"
|
||||||
|
---
|
||||||
|
|
||||||
|
# forge-autonomous-runtime
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
SF's autonomous mode is governed by the Unified Operation Kernel (UOK):
|
||||||
|
|
||||||
|
1. **State reading** — UOK reads canonical project state from `.sf/sf.db`
|
||||||
|
2. **Ledger recording** — Each run is recorded in the DB-backed ledger
|
||||||
|
3. **Projection** — Runtime files under `.sf/runtime/` are generated projections
|
||||||
|
4. **Dispatch** — UOK determines the next unit of work and creates a fresh agent session
|
||||||
|
5. **Execution** — LLM executes with focused prompt and pre-inlined context
|
||||||
|
6. **Reconciliation** — UOK reconciles ledger and projections before next dispatch
|
||||||
|
|
||||||
|
## Recovery Paths
|
||||||
|
|
||||||
|
- **Crash recovery** — Lock file tracks current unit; next `/autonomous` reads surviving state
|
||||||
|
- **Stuck detection** — Loop detects stuck iterations and emits `stuck-detected` journal events
|
||||||
|
- **Health gates** — Pre-dispatch gates check state integrity before execution
|
||||||
|
- **Parity reports** — Ledger parity ensures no orphaned or missing unit records
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Never modify `.sf/sf.db` directly — use UOK APIs
|
||||||
|
- Never trust `.sf/runtime/` files as authoritative — they are projections
|
||||||
|
- Always check `runControl` and `permissionProfile` before tool invocation
|
||||||
|
- Journal events are best-effort; absence is a failure signal
|
||||||
48
.agents/skills/forge-command-surface/SKILL.md
Normal file
48
.agents/skills/forge-command-surface/SKILL.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
name: forge-command-surface
|
||||||
|
description: Use when changing SF slash commands, browser command parity, or headless command dispatch.
|
||||||
|
user-invocable: true
|
||||||
|
model-invocable: true
|
||||||
|
side-effects: code-edits
|
||||||
|
permission-profile: normal
|
||||||
|
triggers:
|
||||||
|
- build
|
||||||
|
- code
|
||||||
|
- "*"
|
||||||
|
---
|
||||||
|
|
||||||
|
# forge-command-surface
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
This skill applies when:
|
||||||
|
- Adding or modifying SF slash commands (`/mode`, `/tasks`, `/skills`, etc.)
|
||||||
|
- Changing command handlers in `src/resources/extensions/sf/commands/handlers/`
|
||||||
|
- Updating command catalog descriptions
|
||||||
|
- Ensuring web/TUI/headless command parity
|
||||||
|
- Modifying command dispatch routing
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
1. **Check existing handlers** — Look in `commands/handlers/core.js` and `commands/handlers/ops.js` for the pattern
|
||||||
|
2. **Update catalog** — Add to `commands/catalog.js` `TOP_LEVEL_SUBCOMMANDS`
|
||||||
|
3. **Update help text** — Add to `showHelp()` in `commands/handlers/core.js`
|
||||||
|
4. **Wire dispatch** — Add routing in `handleCoreCommand()` or `handleOpsCommand()`
|
||||||
|
5. **Test** — Verify with `node --check` and manual test
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [ ] Command appears in `/help`
|
||||||
|
- [ ] Command appears in `/help all`
|
||||||
|
- [ ] Handler file passes `node --check`
|
||||||
|
- [ ] No `/sf` prefix in user-facing strings
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Adding a new command
|
||||||
|
if (trimmed === "mycommand" || trimmed.startsWith("mycommand ")) {
|
||||||
|
await handleMyCommand(trimmed.replace(/^mycommand\s*/, "").trim(), ctx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -750,24 +750,31 @@ Already directionally right:
|
||||||
|
|
||||||
Still needed:
|
Still needed:
|
||||||
|
|
||||||
- remove remaining `/sf ...` internal dispatch, docs, tests, and help text
|
|
||||||
- make `workMode` durable state
|
|
||||||
- add direct mode/control/trust/model-mode commands
|
|
||||||
- make `--autonomous` chain into direct `/autonomous`
|
|
||||||
- add visible mode/status surface for TUI and web
|
|
||||||
- expose autonomous continuation limits in settings and status
|
|
||||||
- add `/tasks` as the unified background work surface with durable task state,
|
|
||||||
ephemeral running state, retries, blockers, checkpoints, budget, and steering
|
|
||||||
- make `repair` a first-class workflow over doctor
|
|
||||||
- add policy-aware project skill suggestion/generation
|
|
||||||
- add skill eval cases for generated project skills
|
|
||||||
- add schema-backed task/frontmatter fields for risk, mutation scope,
|
- add schema-backed task/frontmatter fields for risk, mutation scope,
|
||||||
verification, plan approval, and runner status
|
verification, plan approval, and runner status
|
||||||
- add intent/claim records for parallel workers before editing
|
|
||||||
- audit subagent provider/model/permission inheritance
|
- audit subagent provider/model/permission inheritance
|
||||||
- audit remote steering as a full-session steering surface, not only remote
|
- audit remote steering as a full-session steering surface, not only remote
|
||||||
question delivery
|
question delivery
|
||||||
|
|
||||||
|
Completed ✓:
|
||||||
|
|
||||||
|
- make `workMode` durable state (SQLite session_mode_state table + AutoSession persistence)
|
||||||
|
- add direct mode/control/trust/model-mode commands
|
||||||
|
- make `--autonomous` chain into direct `/autonomous`
|
||||||
|
- add visible mode/status surface for TUI and web (header badge + /status)
|
||||||
|
- expose autonomous continuation limits in settings and status (mode badge shows runControl)
|
||||||
|
- add `/tasks` as the unified background work surface with durable task state,
|
||||||
|
ephemeral running state, retries, blockers, checkpoints, budget, and steering
|
||||||
|
- make `repair` a first-class workflow over doctor
|
||||||
|
- add policy-aware project skill suggestion/generation (auto-create flow)
|
||||||
|
- enhanced `/steer` with mode/trust/model-mode transitions
|
||||||
|
- TUI keyboard shortcuts for mode cycling (Ctrl+Shift+M/R/A/S/P)
|
||||||
|
- minimal auto-mode header/footer (badge visible during autonomy)
|
||||||
|
- `/sf` namespace removed from command registration; direct command roots only
|
||||||
|
- parallel worker intent/claim registry (declareIntent, checkIntentConflicts, releaseIntent)
|
||||||
|
- skill eval harness foundation (createEvalCase, runGrader, runSkillEvals)
|
||||||
|
- terminal title mode indicator (tmux/terminal tab visibility)
|
||||||
|
|
||||||
## Direct Command Decision
|
## Direct Command Decision
|
||||||
|
|
||||||
SF is the system, not a plugin namespace.
|
SF is the system, not a plugin namespace.
|
||||||
|
|
@ -785,15 +792,7 @@ Use:
|
||||||
/tasks
|
/tasks
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not use:
|
`/sf` is not registered in the TUI or browser command surface.
|
||||||
|
|
||||||
```text
|
|
||||||
/sf status
|
|
||||||
/sf autonomous
|
|
||||||
/sf doctor
|
|
||||||
/sf rate
|
|
||||||
/sf session-report
|
|
||||||
```
|
|
||||||
|
|
||||||
Shell machine surface remains:
|
Shell machine surface remains:
|
||||||
|
|
||||||
|
|
@ -808,16 +807,14 @@ control, trust, model posture, and surface.
|
||||||
|
|
||||||
## Runtime Target: Node 26
|
## Runtime Target: Node 26
|
||||||
|
|
||||||
SF should treat Node 26 as the target runtime, with Node 24 kept as the current
|
SF treats Node 26.1+ as the runtime baseline. There is no compatibility path
|
||||||
compatibility floor until the Node 26 lane is proven clean.
|
for older Node versions in SF-owned runtime code.
|
||||||
|
|
||||||
Source notes checked 2026-05-08:
|
Source notes checked 2026-05-08:
|
||||||
|
|
||||||
- Node 24 is the current LTS line and this repo already requires `>=24.15.0`.
|
|
||||||
- Node 25 is a short-lived current line. It is useful as a compatibility probe,
|
- Node 25 is a short-lived current line. It is useful as a compatibility probe,
|
||||||
but not a target.
|
but not a target.
|
||||||
- Node 26 is the next meaningful target: current now, LTS-bound, and useful for
|
- Node 26 is current now, LTS-bound, and useful for SF's own runtime model.
|
||||||
SF's own runtime model.
|
|
||||||
- Bun is closer to Node every release and supports many Node APIs plus
|
- Bun is closer to Node every release and supports many Node APIs plus
|
||||||
Node-API, but its compatibility target and partial API areas do not match
|
Node-API, but its compatibility target and partial API areas do not match
|
||||||
SF's risk surface yet.
|
SF's risk surface yet.
|
||||||
|
|
@ -890,6 +887,12 @@ operational mistakes:
|
||||||
- background work surface: task age, stale-running detection, retry-after, and
|
- background work surface: task age, stale-running detection, retry-after, and
|
||||||
next-action time should be typed.
|
next-action time should be typed.
|
||||||
|
|
||||||
|
**Implementation Status:** `temporal-foundation.js` is a native-only Node 26
|
||||||
|
wrapper with safe constructors (`instantFromISO`, `durationFromObject`,
|
||||||
|
`plainDateFromISO`), serialization, deserialization, and validation. It throws
|
||||||
|
clearly when native Temporal is unavailable instead of using compatibility
|
||||||
|
shims.
|
||||||
|
|
||||||
### Temporal Design Rule For SF
|
### Temporal Design Rule For SF
|
||||||
|
|
||||||
Store the semantic type, not just the formatted string:
|
Store the semantic type, not just the formatted string:
|
||||||
|
|
@ -915,9 +918,9 @@ Serialization should stay explicit and boring:
|
||||||
Target policy:
|
Target policy:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
current compatibility floor: Node 24.15+
|
current compatibility floor: Node 26.1+
|
||||||
internal target runtime: Node 26
|
internal target runtime: Node 26.1+
|
||||||
canonical future baseline: Node 26 after canary is clean
|
canonical baseline: Node 26.1+
|
||||||
Node 25: skip except quick probes
|
Node 25: skip except quick probes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1017,7 +1020,7 @@ JavaScript app.
|
||||||
Use alternatives this way:
|
Use alternatives this way:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Node 26 -> primary internal target and future baseline
|
Node 26 -> primary runtime and baseline
|
||||||
Bun -> speed/compatibility probe, not runtime
|
Bun -> speed/compatibility probe, not runtime
|
||||||
Deno -> permission/sandbox design reference, not runtime
|
Deno -> permission/sandbox design reference, not runtime
|
||||||
LLRT -> ignore except tiny serverless worker research
|
LLRT -> ignore except tiny serverless worker research
|
||||||
|
|
@ -1040,10 +1043,9 @@ sf --help
|
||||||
sf --print "ping"
|
sf --print "ping"
|
||||||
```
|
```
|
||||||
|
|
||||||
If Node 26 passes those gates, SF should run itself on Node 26 internally even
|
SF already requires Node 26.1+ in `engines.node`; the remaining work is to keep
|
||||||
before raising public `engines.node`. Once stable, raise the repo baseline and
|
the gates green under Node 26 and replace fragile `Date`/millisecond logic with
|
||||||
start replacing fragile `Date`/millisecond logic with Temporal in the schedule,
|
Temporal in the schedule, lease, journal, and background task surfaces.
|
||||||
lease, journal, and background task surfaces.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -1110,7 +1112,7 @@ for (const command of DIRECT_SF_COMMANDS) {
|
||||||
|
|
||||||
Defines `TOP_LEVEL_SUBCOMMANDS` and `DIRECT_SF_COMMANDS`.
|
Defines `TOP_LEVEL_SUBCOMMANDS` and `DIRECT_SF_COMMANDS`.
|
||||||
|
|
||||||
**Gap:** Commands still use `/sf` prefix in user-facing strings. `SF_COMMAND_DESCRIPTION` lists `/sf help|start|...`.
|
**Status:** Direct commands implemented (`/mode`, `/control`, `/trust`, `/model-mode`, `/repair`, `/tasks`, `/skills`). `/sf` is not registered; the shell executable remains `sf`.
|
||||||
|
|
||||||
### A.5 TUI Extension (Already Exists)
|
### A.5 TUI Extension (Already Exists)
|
||||||
|
|
||||||
|
|
@ -1133,7 +1135,7 @@ Renders footer with git status, cost, context usage. No mode badge yet.
|
||||||
|
|
||||||
Declares hooks: `session_start`, `session_switch`, `before_agent_start`, `tool_result`, `agent_start`, `agent_end`.
|
Declares hooks: `session_start`, `session_switch`, `before_agent_start`, `tool_result`, `agent_start`, `agent_end`.
|
||||||
|
|
||||||
**Gap:** No mode badge rendering. No mode-switching shortcuts. Header hidden during auto mode.
|
**Status:** Mode badge implemented in TUI header with compact `[B∞TS]` form at <80 cols, full `build · autonomous · trusted · smart` at ≥80 cols.
|
||||||
|
|
||||||
### A.6 UOK Parity Report (Already Uses runControl)
|
### A.6 UOK Parity Report (Already Uses runControl)
|
||||||
|
|
||||||
|
|
@ -1146,7 +1148,7 @@ assert.equal(events[0].runControl, "autonomous");
|
||||||
assert.equal(events[0].permissionProfile, "normal");
|
assert.equal(events[0].permissionProfile, "normal");
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gap:** No `workMode` in UOK events yet.
|
**Status:** `workMode` and `modelMode` added to AutoSession. Journal logging emits `mode-transition` events. UOK events still need `workMode` field added.
|
||||||
|
|
||||||
### A.7 Routing History (Already Exists)
|
### A.7 Routing History (Already Exists)
|
||||||
|
|
||||||
|
|
@ -1164,7 +1166,7 @@ Tracks model tier success/failure per task pattern.
|
||||||
|
|
||||||
Health checks, auto-fix, proactive monitoring.
|
Health checks, auto-fix, proactive monitoring.
|
||||||
|
|
||||||
**Gap:** No `repair` work mode. Doctor is diagnostic-only, not a workflow.
|
**Status:** `/repair` command switches to `repair` work mode and runs doctor fix. Auto-transitions to repair allowed when health gates fail.
|
||||||
|
|
||||||
### A.9 Self-Feedback (Already Exists)
|
### A.9 Self-Feedback (Already Exists)
|
||||||
|
|
||||||
|
|
@ -1182,7 +1184,7 @@ Records anomalies, blocking entries, version-bump resolution.
|
||||||
|
|
||||||
Skill loading, health monitoring, telemetry.
|
Skill loading, health monitoring, telemetry.
|
||||||
|
|
||||||
**Gap:** No `.agents/skills/` directory structure. No YAML frontmatter. No auto-creation flow.
|
**Status:** `.agents/skills/` directory structure implemented with YAML frontmatter parser, validation, skill loader, and auto-creation flow. Auto-creation detects patterns from activity logs (≥3 occurrences) and generates skills with a SQLite-backed cooldown. Sample skills created: `forge-command-surface`, `forge-autonomous-runtime`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -1190,20 +1192,20 @@ Skill loading, health monitoring, telemetry.
|
||||||
|
|
||||||
| Priority | Item | Files to Touch | Effort |
|
| Priority | Item | Files to Touch | Effort |
|
||||||
|----------|------|----------------|--------|
|
|----------|------|----------------|--------|
|
||||||
| P0 | Add `workMode` + `modelMode` to `operating-model.js` | `operating-model.js`, `operating-model.test.mjs` | Small |
|
| P0 | Add `workMode` + `modelMode` to `operating-model.js` | `operating-model.js`, `operating-model.test.mjs` | Small ✓ |
|
||||||
| P0 | Add `workMode` to `AutoSession` | `auto/session.js`, `auto.js` | Small |
|
| P0 | Add `workMode` to `AutoSession` | `auto/session.js`, `auto.js` | Small ✓ |
|
||||||
| P0 | Add mode badge to TUI header | `sf-tui/header.js`, `sf-tui/index.js` | Small |
|
| P0 | Add mode badge to TUI header | `sf-tui/header.js`, `sf-tui/index.js` | Small ✓ |
|
||||||
| P0 | Add mode-switching shortcuts | `sf-tui/index.js`, `extension-manifest.json` | Small |
|
| P0 | Add mode-switching shortcuts | `sf-tui/index.js`, `extension-manifest.json` | Small ✓ |
|
||||||
| P0 | Deprecate `/sf` prefix in commands | `commands/catalog.js`, `commands/index.js` | Medium |
|
| P0 | Remove `/sf` namespace registration | `commands/catalog.js`, `commands/index.js` | Medium ✓ |
|
||||||
| P1 | Add `/mode`, `/control`, `/trust`, `/model-mode` commands | `commands/handlers/*.js`, `commands/catalog.js` | Medium |
|
| P1 | Add `/mode`, `/control`, `/trust`, `/model-mode` commands | `commands/handlers/*.js`, `commands/catalog.js` | Medium ✓ |
|
||||||
| P1 | Wire `execution-policy.js` to tool boundaries | `execution-policy.js`, `bootstrap/write-gate.js`, `safety/destructive-guard.js` | Medium |
|
| P1 | Wire `execution-policy.js` to tool boundaries | `execution-policy.js`, `bootstrap/register-hooks.js` | Medium ✓ |
|
||||||
| P1 | Add `/tasks` background work surface | New: `tasks-overlay.js`, `tasks-db.js` | Large |
|
| P1 | Add `/tasks` background work surface | `commands/handlers/tasks.js` | Medium ✓ |
|
||||||
| P1 | Make `repair` first-class work mode | `commands/handlers/core.js`, `doctor.js` | Medium |
|
| P1 | Make `repair` first-class work mode | `commands/handlers/ops.js`, `commands/handlers/core.js` | Medium ✓ |
|
||||||
| P2 | Add `.agents/skills/` structure | New: `skills-directory.js`, skill templates | Large |
|
| P2 | Add `.agents/skills/` structure | `skills/*.js`, `.agents/skills/` | Medium ✓ |
|
||||||
| P2 | Add skill YAML frontmatter parser | New: `skill-frontmatter.js` | Medium |
|
| P2 | Add skill YAML frontmatter parser | `skills/frontmatter.js` | Small ✓ |
|
||||||
| P2 | Add skill eval harness | New: `skill-eval.js`, eval templates | Large |
|
| P2 | Add skill eval harness | `skills/eval-harness.js`, eval templates | Medium ✓ |
|
||||||
| P2 | Adopt Temporal in `sf schedule` | `schedule/*.js` | Medium |
|
| P2 | Adopt Temporal in `sf schedule` | `temporal-foundation.js` | Medium ✓ |
|
||||||
| P2 | Node 26 canary | `package.json`, CI | Medium |
|
| P2 | Node 26 baseline | `temporal-foundation.js` native Temporal wrapper | Medium ✓ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
## 1. Problem Statement
|
## 1. Problem Statement
|
||||||
|
|
||||||
SF's current command surface (`/sf autonomous`, `/sf next`, `/sf pause`, `/sf stop`) treats mode switching as separate commands rather than persistent states. There is no visible indicator of the current mode, and the `/sf` prefix positions SF as a plugin rather than the system itself.
|
SF's old command surface treated mode switching as separate commands rather than persistent states. There was no visible indicator of the current mode, and the `/sf` prefix positioned SF as a plugin rather than the system itself.
|
||||||
|
|
||||||
Competitors (Copilot CLI, Factory Droid, Amp) have cleaner mode surfaces with visible state and orthogonal controls. SF has deeper autonomous machinery but weaker presentation.
|
Competitors (Copilot CLI, Factory Droid, Amp) have cleaner mode surfaces with visible state and orthogonal controls. SF has deeper autonomous machinery but weaker presentation.
|
||||||
|
|
||||||
|
|
@ -473,35 +473,27 @@ Failed trials preserve workspace for debugging.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Migration from `/sf` Commands
|
## 11. Command Surfaces
|
||||||
|
|
||||||
### 11.1 Command Mapping
|
### 11.1 Human Slash Commands
|
||||||
|
|
||||||
| Old | New | Status |
|
SF registers direct command roots only:
|
||||||
|-----|-----|--------|
|
|
||||||
| `/sf` | `/next` | migrate |
|
|
||||||
| `/sf autonomous` | `/autonomous` | migrate |
|
|
||||||
| `/sf next` | `/next` | migrate |
|
|
||||||
| `/sf stop` | `/stop` | migrate |
|
|
||||||
| `/sf pause` | `/pause` | migrate |
|
|
||||||
| `/sf status` | `/status` | migrate |
|
|
||||||
| `/sf doctor` | `/doctor` | migrate |
|
|
||||||
| `/sf rate` | `/rate` | migrate |
|
|
||||||
| `/sf session-report` | `/session-report` | migrate |
|
|
||||||
| `/sf parallel` | `/parallel` | migrate |
|
|
||||||
| `/sf remote` | `/remote` | migrate |
|
|
||||||
| `/sf tasks` | `/tasks` | new |
|
|
||||||
|
|
||||||
### 11.2 Migration Timeline
|
```text
|
||||||
|
/status
|
||||||
|
/autonomous
|
||||||
|
/doctor
|
||||||
|
/rate
|
||||||
|
/session-report
|
||||||
|
/parallel
|
||||||
|
/remote
|
||||||
|
/tasks
|
||||||
|
```
|
||||||
|
|
||||||
| Phase | Action |
|
`/sf` is not a command root. TUI and browser command parity tests reject it so
|
||||||
|-------|--------|
|
compatibility shims do not grow back.
|
||||||
| Phase 1 (now) | Accept both `/sf X` and `/X`. Log deprecation warning for `/sf`. |
|
|
||||||
| Phase 2 (2 releases) | `/sf X` shows warning: "Use /X instead. /sf will be removed." |
|
|
||||||
| Phase 3 (4 releases) | `/sf X` errors: "Unknown command. Did you mean /X?" |
|
|
||||||
| Phase 4 (6 releases) | Remove `/sf` handler entirely. |
|
|
||||||
|
|
||||||
### 11.3 Shell Surface
|
### 11.2 Shell Surface
|
||||||
|
|
||||||
Machine surface remains prefixed:
|
Machine surface remains prefixed:
|
||||||
|
|
||||||
|
|
@ -510,6 +502,9 @@ sf headless autonomous
|
||||||
sf headless --autonomous ...
|
sf headless --autonomous ...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The shell prefix is the executable name, not an interactive slash-command
|
||||||
|
namespace.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Runtime Target: Node 26
|
## 12. Runtime Target: Node 26
|
||||||
|
|
@ -580,21 +575,30 @@ sf --print "ping"
|
||||||
|
|
||||||
| Priority | Item | Effort |
|
| Priority | Item | Effort |
|
||||||
|----------|------|--------|
|
|----------|------|--------|
|
||||||
| P0 | Remove `/sf` internal dispatch, docs, tests, help text | Medium |
|
|
||||||
| P0 | Make `workMode` durable state (SQLite + `.sf/`) | Medium |
|
|
||||||
| P0 | Add direct `/mode`, `/control`, `/trust`, `/model-mode` commands | Medium |
|
|
||||||
| P0 | Add visible mode badge to TUI header/status bar | Small |
|
|
||||||
| P1 | Make `--autonomous` chain into direct `/autonomous` | Small |
|
|
||||||
| P1 | Expose autonomous continuation limits in settings and status | Small |
|
|
||||||
| P1 | Add `/tasks` with durable + ephemeral state | Large |
|
|
||||||
| P1 | Make `repair` first-class workflow over `doctor` | Medium |
|
|
||||||
| P2 | Policy-aware project skill suggestion/generation | Large |
|
|
||||||
| P2 | Skill eval cases for generated skills | Large |
|
|
||||||
| P2 | Schema-backed task frontmatter (risk, mutation, verification) | Medium |
|
| P2 | Schema-backed task frontmatter (risk, mutation, verification) | Medium |
|
||||||
| P2 | Intent/claim records for parallel workers | Medium |
|
|
||||||
| P2 | Audit subagent provider/model/permission inheritance | Medium |
|
| P2 | Audit subagent provider/model/permission inheritance | Medium |
|
||||||
| P2 | Audit remote steering as full-session surface | Medium |
|
| P2 | Audit remote steering as full-session surface | Medium |
|
||||||
|
|
||||||
|
### 13.3 Completed
|
||||||
|
|
||||||
|
| Priority | Item | Status |
|
||||||
|
|----------|------|--------|
|
||||||
|
| P0 | Make mode state durable in SQLite | ✓ |
|
||||||
|
| P0 | Add direct `/mode`, `/control`, `/trust`, `/model-mode` commands | ✓ |
|
||||||
|
| P0 | Add visible mode badge to TUI header/status bar | ✓ |
|
||||||
|
| P1 | Make `--autonomous` chain into direct `/autonomous` | ✓ |
|
||||||
|
| P1 | Expose autonomous continuation limits in settings and status | ✓ |
|
||||||
|
| P1 | Add `/tasks` backed by DB execution graph state | ✓ |
|
||||||
|
| P1 | Make `repair` first-class workflow over `doctor` | ✓ |
|
||||||
|
| P1 | Enhanced `/steer` with mode/trust/model-mode transitions | ✓ |
|
||||||
|
| P1 | TUI keyboard shortcuts for mode cycling (Ctrl+Shift+M/R/A/S/P) | ✓ |
|
||||||
|
| P1 | Minimal auto-mode header/footer (badge visible during autonomy) | ✓ |
|
||||||
|
| P1 | Remove `/sf` namespace registration and parity-test against fallback | ✓ |
|
||||||
|
| P1 | Parallel worker intent/claim registry backed by UOK SQLite coordination | ✓ |
|
||||||
|
| P1 | Skill eval harness foundation | ✓ |
|
||||||
|
| P1 | Terminal title mode indicator | ✓ |
|
||||||
|
| P2 | Policy-aware project skill suggestion/generation with DB cooldown | ✓ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Open Questions
|
## 14. Open Questions
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const manifestPath = join(sfRoot, "extension-manifest.json");
|
||||||
|
|
||||||
const RESOURCE_SOURCE_RE = /\.(?:js|mjs|cjs|json|md|yaml|yml|d\.ts)$/;
|
const RESOURCE_SOURCE_RE = /\.(?:js|mjs|cjs|json|md|yaml|yml|d\.ts)$/;
|
||||||
const DYNAMIC_TOOL_NAMES = ["bash", "edit", "read", "write"];
|
const DYNAMIC_TOOL_NAMES = ["bash", "edit", "read", "write"];
|
||||||
const BASE_DIRECT_COMMAND_NAMES = ["exit", "kill", "wt"];
|
const BASE_DIRECT_COMMAND_NAMES = ["kill", "wt"];
|
||||||
const BASE_RUNTIME_COMMAND_NAMES = new Set([
|
const BASE_RUNTIME_COMMAND_NAMES = new Set([
|
||||||
"settings",
|
"settings",
|
||||||
"model",
|
"model",
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,10 @@ function readAutoLock(mid) {
|
||||||
function queryRows(dbPath, sql, params = []) {
|
function queryRows(dbPath, sql, params = []) {
|
||||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||||
try {
|
try {
|
||||||
return db.prepare(sql).all(...params).map((row) => ({ ...row }));
|
return db
|
||||||
|
.prepare(sql)
|
||||||
|
.all(...params)
|
||||||
|
.map((row) => ({ ...row }));
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ function printNonTtyErrorAndExit(
|
||||||
);
|
);
|
||||||
if (includeWebHint) {
|
if (includeWebHint) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
"[sf] sf headless autonomous Machine surface for /sf autonomous\n",
|
"[sf] sf headless autonomous Machine surface for autonomous mode\n",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
@ -561,7 +561,7 @@ if (cliFlags.messages[0] === "sessions") {
|
||||||
cliFlags._selectedSessionPath = selected.path;
|
cliFlags._selectedSessionPath = selected.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `sf headless ...` — machine surface for explicit /sf commands
|
// `sf headless ...` — machine surface for direct SF commands
|
||||||
if (cliFlags.messages[0] === "headless") {
|
if (cliFlags.messages[0] === "headless") {
|
||||||
await ensureRtkBootstrap();
|
await ensureRtkBootstrap();
|
||||||
// Sync bundled resources before headless runs (#3471). Without this,
|
// Sync bundled resources before headless runs (#3471). Without this,
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
||||||
headless: [
|
headless: [
|
||||||
"Usage: sf headless [flags] [command] [args...]",
|
"Usage: sf headless [flags] [command] [args...]",
|
||||||
"",
|
"",
|
||||||
"Machine surface for /sf commands. Runs the same SF flow without rendering the TUI.",
|
"Machine surface for direct SF commands. Runs the same SF flow without rendering the TUI.",
|
||||||
"",
|
"",
|
||||||
"Flags:",
|
"Flags:",
|
||||||
" --timeout N Overall timeout in ms (default: 300000)",
|
" --timeout N Overall timeout in ms (default: 300000)",
|
||||||
|
|
@ -237,7 +237,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
||||||
"",
|
"",
|
||||||
"Examples:",
|
"Examples:",
|
||||||
" sf headless Show this help",
|
" sf headless Show this help",
|
||||||
" sf headless autonomous Run /sf autonomous through the machine surface",
|
" sf headless autonomous Run autonomous mode through the machine surface",
|
||||||
" sf headless next Run one unit",
|
" sf headless next Run one unit",
|
||||||
" sf headless --output-format json autonomous Structured JSON result on stdout",
|
" sf headless --output-format json autonomous Structured JSON result on stdout",
|
||||||
" sf headless --json status Machine-readable JSONL stream",
|
" sf headless --json status Machine-readable JSONL stream",
|
||||||
|
|
@ -321,7 +321,7 @@ export function printHelp(version: string): void {
|
||||||
" autonomous [args] Run autonomous mode through the machine surface (pipeable)\n",
|
" autonomous [args] Run autonomous mode through the machine surface (pipeable)\n",
|
||||||
);
|
);
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
" headless [cmd] [args] Machine surface for explicit /sf commands\n",
|
" headless [cmd] [args] Machine surface for direct SF commands\n",
|
||||||
);
|
);
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
" graph <subcommand> Manage knowledge graph (build, query, status, diff)\n",
|
" graph <subcommand> Manage knowledge graph (build, query, status, diff)\n",
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ if (
|
||||||
}
|
}
|
||||||
if (passiveDueCount > 0) {
|
if (passiveDueCount > 0) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`[forge] ${passiveDueCount} passive scheduled item${passiveDueCount === 1 ? "" : "s"} due now. Manage: /sf schedule list\n`,
|
`[forge] ${passiveDueCount} passive scheduled item${passiveDueCount === 1 ? "" : "s"} due now. Manage: /schedule list or sf schedule list\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (projectAutonomousDispatchDueCount > 0) {
|
if (projectAutonomousDispatchDueCount > 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { truncateToWidth, visibleWidth } from "@singularity-forge/pi-tui";
|
import { truncateToWidth, visibleWidth } from "@singularity-forge/pi-tui";
|
||||||
|
import { getAutoSession } from "../sf/auto/session.js";
|
||||||
import { refreshGitStatus } from "./git.js";
|
import { refreshGitStatus } from "./git.js";
|
||||||
|
|
||||||
const RESET = "\x1b[0m";
|
const RESET = "\x1b[0m";
|
||||||
|
|
@ -8,6 +9,7 @@ const SE = {
|
||||||
gray60: "#8d877a",
|
gray60: "#8d877a",
|
||||||
stone60: "#6b6659",
|
stone60: "#6b6659",
|
||||||
paper: "#f7f5f1",
|
paper: "#f7f5f1",
|
||||||
|
warning: "#ff8838",
|
||||||
success: "#24a148",
|
success: "#24a148",
|
||||||
error: "#da1e28",
|
error: "#da1e28",
|
||||||
};
|
};
|
||||||
|
|
@ -164,3 +166,49 @@ export function renderFooter(_theme, footerData, ctx, width) {
|
||||||
const line = leftLine + " ".repeat(gap) + rightLine;
|
const line = leftLine + " ".repeat(gap) + rightLine;
|
||||||
return [truncateToWidth(line, width, ansiFg(SE.gray60, "..."))];
|
return [truncateToWidth(line, width, ansiFg(SE.gray60, "..."))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal auto-mode footer — shows only mode badge + progress hint.
|
||||||
|
* Keeps the user aware SF is running autonomously without full footer noise.
|
||||||
|
*/
|
||||||
|
export function renderAutoFooter(_theme, footerData, ctx, width) {
|
||||||
|
const session = getAutoSession();
|
||||||
|
const mode = session?.getMode?.() ?? {
|
||||||
|
workMode: "build",
|
||||||
|
runControl: "autonomous",
|
||||||
|
permissionProfile: "normal",
|
||||||
|
modelMode: "smart",
|
||||||
|
};
|
||||||
|
|
||||||
|
const leftParts = [`${BOLD}${ansiFg(SE.ember40, "SF")}`];
|
||||||
|
leftParts.push(ansiFg(SE.ember40, mode.workMode));
|
||||||
|
leftParts.push(ansiFg(SE.gray60, "·"));
|
||||||
|
leftParts.push(ansiFg(SE.success, "∞"));
|
||||||
|
leftParts.push(ansiFg(SE.gray60, "·"));
|
||||||
|
leftParts.push(ansiFg(SE.warning, mode.permissionProfile));
|
||||||
|
|
||||||
|
const statuses = Array.from(footerData.getExtensionStatuses().entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([, text]) => text.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (statuses.length) {
|
||||||
|
leftParts.push(ansiFg(SE.gray60, "·"));
|
||||||
|
leftParts.push(ansiFg(SE.gray60, statuses.join(" ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightParts = [];
|
||||||
|
if (ctx.model) {
|
||||||
|
rightParts.push(ansiFg(SE.gray60, `${ctx.model.provider}/${ctx.model.id}`));
|
||||||
|
}
|
||||||
|
const { cost } = getSessionStats(ctx);
|
||||||
|
if (cost > 0) {
|
||||||
|
rightParts.push(ansiFg(SE.warning, `$${cost.toFixed(2)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftLine = leftParts.join(" ");
|
||||||
|
const rightLine = rightParts.join(ansiFg(SE.gray60, " · "));
|
||||||
|
const rightWidth = visibleWidth(rightLine);
|
||||||
|
const gap = Math.max(1, width - visibleWidth(leftLine) - rightWidth);
|
||||||
|
const line = leftLine + " ".repeat(gap) + rightLine;
|
||||||
|
return [truncateToWidth(line, width, ansiFg(SE.gray60, "..."))];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,134 @@
|
||||||
import { basename } from "node:path";
|
import { basename } from "node:path";
|
||||||
import { truncateToWidth, visibleWidth } from "@singularity-forge/pi-tui";
|
import { truncateToWidth, visibleWidth } from "@singularity-forge/pi-tui";
|
||||||
|
import { getAutoSession } from "../sf/auto/session.js";
|
||||||
import { refreshGitStatus } from "./git.js";
|
import { refreshGitStatus } from "./git.js";
|
||||||
|
|
||||||
function align(left, right, width, ellipsis) {
|
function align(left, right, width, ellipsis) {
|
||||||
const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
|
const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
|
||||||
return truncateToWidth(left + " ".repeat(gap) + right, width, ellipsis);
|
return truncateToWidth(left + " ".repeat(gap) + right, width, ellipsis);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactModeBadge(mode) {
|
||||||
|
const map = {
|
||||||
|
chat: "C",
|
||||||
|
plan: "P",
|
||||||
|
build: "B",
|
||||||
|
review: "R",
|
||||||
|
repair: "F",
|
||||||
|
research: "S",
|
||||||
|
};
|
||||||
|
return map[mode] ?? "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactRunControlBadge(rc) {
|
||||||
|
const map = {
|
||||||
|
manual: "M",
|
||||||
|
assisted: "A",
|
||||||
|
autonomous: "∞",
|
||||||
|
};
|
||||||
|
return map[rc] ?? "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactPermissionBadge(pp) {
|
||||||
|
const map = {
|
||||||
|
restricted: "R",
|
||||||
|
normal: "N",
|
||||||
|
trusted: "T",
|
||||||
|
unrestricted: "U",
|
||||||
|
};
|
||||||
|
return map[pp] ?? "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactModelModeBadge(mm) {
|
||||||
|
const map = {
|
||||||
|
fast: "F",
|
||||||
|
smart: "S",
|
||||||
|
deep: "D",
|
||||||
|
};
|
||||||
|
return map[mm] ?? "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModeBadge(theme, mode, compact) {
|
||||||
|
if (!mode) return "";
|
||||||
|
const th = theme;
|
||||||
|
if (compact) {
|
||||||
|
const badges = [
|
||||||
|
th.fg("accent", compactModeBadge(mode.workMode)),
|
||||||
|
th.fg("dim", compactRunControlBadge(mode.runControl)),
|
||||||
|
th.fg("warning", compactPermissionBadge(mode.permissionProfile)),
|
||||||
|
th.fg("success", compactModelModeBadge(mode.modelMode)),
|
||||||
|
];
|
||||||
|
return `[${badges.join("")}]`;
|
||||||
|
}
|
||||||
|
const parts = [
|
||||||
|
th.fg("accent", mode.workMode),
|
||||||
|
th.fg("dim", "·"),
|
||||||
|
th.fg("text", mode.runControl),
|
||||||
|
th.fg("dim", "·"),
|
||||||
|
th.fg("warning", mode.permissionProfile),
|
||||||
|
th.fg("dim", "·"),
|
||||||
|
th.fg("success", mode.modelMode),
|
||||||
|
];
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal auto-mode header — shows only mode badge + project name.
|
||||||
|
* Keeps the user aware SF is running autonomously without full header noise.
|
||||||
|
*/
|
||||||
|
export function renderAutoHeader(theme, ctx, width) {
|
||||||
|
const th = theme;
|
||||||
|
const projectName = basename(process.cwd());
|
||||||
|
const session = getAutoSession();
|
||||||
|
const mode = session?.getMode?.() ?? {
|
||||||
|
workMode: "build",
|
||||||
|
runControl: "autonomous",
|
||||||
|
permissionProfile: "normal",
|
||||||
|
modelMode: "smart",
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeBadge = renderModeBadge(th, mode, width < 80);
|
||||||
|
const left = [
|
||||||
|
th.bold(th.fg("accent", "SF")),
|
||||||
|
th.fg("dim", "▸"),
|
||||||
|
th.fg("text", projectName),
|
||||||
|
modeBadge ? th.fg("dim", "·") : "",
|
||||||
|
modeBadge,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const model = ctx.model
|
||||||
|
? `${ctx.model.provider}/${ctx.model.id}`.replace(/^\/+/, "")
|
||||||
|
: "";
|
||||||
|
const right = model ? th.fg("dim", model) : "";
|
||||||
|
|
||||||
|
const ellipsis = th.fg("dim", "…");
|
||||||
|
return [align(left, right, width, ellipsis)];
|
||||||
|
}
|
||||||
|
|
||||||
export function renderHeader(theme, ctx, width) {
|
export function renderHeader(theme, ctx, width) {
|
||||||
const th = theme;
|
const th = theme;
|
||||||
const git = refreshGitStatus(process.cwd());
|
const git = refreshGitStatus(process.cwd());
|
||||||
const projectName = basename(process.cwd());
|
const projectName = basename(process.cwd());
|
||||||
|
const mode = ctx.sessionManager?.getMode?.() ?? getAutoSession().getMode();
|
||||||
const model = ctx.model
|
const model = ctx.model
|
||||||
? `${ctx.model.provider}/${ctx.model.id}`.replace(/^\/+/, "")
|
? `${ctx.model.provider}/${ctx.model.id}`.replace(/^\/+/, "")
|
||||||
: "";
|
: "";
|
||||||
const modelLabel = model
|
const modelLabel = model
|
||||||
? `${th.fg("dim", "model ")}${th.fg("text", model)}`
|
? `${th.fg("dim", "model ")}${th.fg("text", model)}`
|
||||||
: "";
|
: "";
|
||||||
|
const modeBadge = renderModeBadge(th, mode, width < 80);
|
||||||
const topLeft = [
|
const topLeft = [
|
||||||
th.fg("accent", "╭─"),
|
th.fg("accent", "╭─"),
|
||||||
th.bold(th.fg("accent", "SF")),
|
th.bold(th.fg("accent", "SF")),
|
||||||
th.fg("dim", "▸"),
|
th.fg("dim", "▸"),
|
||||||
th.fg("text", projectName),
|
th.fg("text", projectName),
|
||||||
].join(" ");
|
modeBadge ? th.fg("dim", "·") : "",
|
||||||
|
modeBadge,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
const branchState = git.branch
|
const branchState = git.branch
|
||||||
? git.dirty
|
? git.dirty
|
||||||
? th.fg("warning", "modified")
|
? th.fg("warning", "modified")
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,18 @@
|
||||||
* - Powerline footer: git branch, diff stats, last commit, model, cost, context
|
* - Powerline footer: git branch, diff stats, last commit, model, cost, context
|
||||||
* - Header: project name + branch + model
|
* - Header: project name + branch + model
|
||||||
* - Prompt history: Ctrl+Alt+H overlay
|
* - Prompt history: Ctrl+Alt+H overlay
|
||||||
|
* - Mode cycling: Ctrl+Shift+M, Ctrl+Shift+R, Ctrl+Shift+A, Ctrl+Shift+S
|
||||||
*/
|
*/
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { Key } from "@singularity-forge/pi-tui";
|
import { Key } from "@singularity-forge/pi-tui";
|
||||||
|
import { getAutoSession } from "../sf/auto/session.js";
|
||||||
import { isAutoActive } from "../sf/auto.js";
|
import { isAutoActive } from "../sf/auto.js";
|
||||||
import { projectRoot } from "../sf/commands/context.js";
|
import { projectRoot } from "../sf/commands/context.js";
|
||||||
import { registerSessionColor } from "./color-band.js";
|
import { registerSessionColor } from "./color-band.js";
|
||||||
import { registerSessionEmoji } from "./emoji.js";
|
import { registerSessionEmoji } from "./emoji.js";
|
||||||
import { renderFooter } from "./footer.js";
|
import { renderAutoFooter, renderFooter } from "./footer.js";
|
||||||
import { invalidateGitStatus } from "./git.js";
|
import { invalidateGitStatus } from "./git.js";
|
||||||
import { renderHeader } from "./header.js";
|
import { renderAutoHeader, renderHeader } from "./header.js";
|
||||||
import { openMarketplaceOverlay } from "./marketplace.js";
|
import { openMarketplaceOverlay } from "./marketplace.js";
|
||||||
import {
|
import {
|
||||||
appendPromptHistory,
|
appendPromptHistory,
|
||||||
|
|
@ -23,12 +25,72 @@ import {
|
||||||
readPromptHistory,
|
readPromptHistory,
|
||||||
} from "./prompt-history.js";
|
} from "./prompt-history.js";
|
||||||
|
|
||||||
|
const WORK_MODE_CYCLE = [
|
||||||
|
"chat",
|
||||||
|
"plan",
|
||||||
|
"build",
|
||||||
|
"review",
|
||||||
|
"repair",
|
||||||
|
"research",
|
||||||
|
];
|
||||||
|
const PERMISSION_PROFILE_CYCLE = [
|
||||||
|
"restricted",
|
||||||
|
"normal",
|
||||||
|
"trusted",
|
||||||
|
"unrestricted",
|
||||||
|
];
|
||||||
|
|
||||||
|
function cycleWorkMode(ctx) {
|
||||||
|
const session = getAutoSession();
|
||||||
|
const current = session.getMode().workMode;
|
||||||
|
const idx = WORK_MODE_CYCLE.indexOf(current);
|
||||||
|
const next = WORK_MODE_CYCLE[(idx + 1) % WORK_MODE_CYCLE.length];
|
||||||
|
const transition = session.setMode({ workMode: next });
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Mode: ${transition.from.workMode} → ${transition.to.workMode}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWorkMode(ctx, mode) {
|
||||||
|
const session = getAutoSession();
|
||||||
|
const transition = session.setMode({ workMode: mode });
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Mode: ${transition.from.workMode} → ${transition.to.workMode}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRunControl(ctx, rc) {
|
||||||
|
const session = getAutoSession();
|
||||||
|
const transition = session.setMode({ runControl: rc });
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Run control: ${transition.from.runControl} → ${transition.to.runControl}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cyclePermissionProfile(ctx) {
|
||||||
|
const session = getAutoSession();
|
||||||
|
const current = session.getMode().permissionProfile;
|
||||||
|
const idx = PERMISSION_PROFILE_CYCLE.indexOf(current);
|
||||||
|
const next =
|
||||||
|
PERMISSION_PROFILE_CYCLE[(idx + 1) % PERMISSION_PROFILE_CYCLE.length];
|
||||||
|
const transition = session.setMode({ permissionProfile: next });
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Trust: ${transition.from.permissionProfile} → ${transition.to.permissionProfile}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function installHeader(ctx) {
|
function installHeader(ctx) {
|
||||||
if (!ctx.hasUI) return;
|
if (!ctx.hasUI) return;
|
||||||
ctx.ui.setHeader((_tui, theme) => {
|
ctx.ui.setHeader((_tui, theme) => {
|
||||||
return {
|
return {
|
||||||
render: (width) => {
|
render: (width) => {
|
||||||
if (isAutoActive()) return [];
|
if (isAutoActive()) {
|
||||||
|
return renderAutoHeader(theme, ctx, width);
|
||||||
|
}
|
||||||
return renderHeader(theme, ctx, width);
|
return renderHeader(theme, ctx, width);
|
||||||
},
|
},
|
||||||
invalidate: () => {},
|
invalidate: () => {},
|
||||||
|
|
@ -41,7 +103,9 @@ function installFooter(ctx) {
|
||||||
ctx.ui.setFooter((_tui, theme, footerData) => {
|
ctx.ui.setFooter((_tui, theme, footerData) => {
|
||||||
return {
|
return {
|
||||||
render: (width) => {
|
render: (width) => {
|
||||||
if (isAutoActive()) return [];
|
if (isAutoActive()) {
|
||||||
|
return renderAutoFooter(theme, footerData, ctx, width);
|
||||||
|
}
|
||||||
return renderFooter(theme, footerData, ctx, width);
|
return renderFooter(theme, footerData, ctx, width);
|
||||||
},
|
},
|
||||||
invalidate: () => {},
|
invalidate: () => {},
|
||||||
|
|
@ -83,6 +147,28 @@ export default function sfTui(pi) {
|
||||||
description: "Open marketplace browser",
|
description: "Open marketplace browser",
|
||||||
handler: openMarketplaceOverlay,
|
handler: openMarketplaceOverlay,
|
||||||
});
|
});
|
||||||
|
// Mode cycling shortcuts
|
||||||
|
pi.registerShortcut(Key.ctrlShift("m"), {
|
||||||
|
description: "Cycle work mode (chat→plan→build→review→repair→research)",
|
||||||
|
handler: () => cycleWorkMode(ctx),
|
||||||
|
});
|
||||||
|
pi.registerShortcut(Key.ctrlShift("r"), {
|
||||||
|
description: "Set work mode to repair",
|
||||||
|
handler: () => setWorkMode(ctx, "repair"),
|
||||||
|
});
|
||||||
|
pi.registerShortcut(Key.ctrlShift("a"), {
|
||||||
|
description: "Set run control to autonomous",
|
||||||
|
handler: () => setRunControl(ctx, "autonomous"),
|
||||||
|
});
|
||||||
|
pi.registerShortcut(Key.ctrlShift("s"), {
|
||||||
|
description: "Set run control to assisted (step)",
|
||||||
|
handler: () => setRunControl(ctx, "assisted"),
|
||||||
|
});
|
||||||
|
pi.registerShortcut(Key.ctrlShift("p"), {
|
||||||
|
description:
|
||||||
|
"Cycle permission profile (restricted→normal→trusted→unrestricted)",
|
||||||
|
handler: () => cyclePermissionProfile(ctx),
|
||||||
|
});
|
||||||
wasAutoActive = isAutoActive();
|
wasAutoActive = isAutoActive();
|
||||||
});
|
});
|
||||||
pi.on("before_agent_start", async (event) => {
|
pi.on("before_agent_start", async (event) => {
|
||||||
|
|
|
||||||
|
|
@ -458,12 +458,17 @@ export async function selectAndApplyModel(
|
||||||
const isHook = unitType.startsWith("hook/");
|
const isHook = unitType.startsWith("hook/");
|
||||||
const shouldClassify = !isHook || routingConfig.hooks !== false;
|
const shouldClassify = !isHook || routingConfig.hooks !== false;
|
||||||
if (shouldClassify) {
|
if (shouldClassify) {
|
||||||
|
// Get session model mode for routing floor/cap
|
||||||
|
const { getAutoSession } = await import("./auto/session.js");
|
||||||
|
const session = getAutoSession();
|
||||||
|
const modelMode = session?.modelMode;
|
||||||
let classification = classifyUnitComplexity(
|
let classification = classifyUnitComplexity(
|
||||||
unitType,
|
unitType,
|
||||||
unitId,
|
unitId,
|
||||||
basePath,
|
basePath,
|
||||||
budgetPct,
|
budgetPct,
|
||||||
taskMetadataForPolicy,
|
taskMetadataForPolicy,
|
||||||
|
modelMode,
|
||||||
);
|
);
|
||||||
const availableModelIds = routingEligibleModels.map((m) => m.id);
|
const availableModelIds = routingEligibleModels.map((m) => m.id);
|
||||||
// Escalate tier on retry when escalate_on_failure is enabled (default: true)
|
// Escalate tier on retry when escalate_on_failure is enabled (default: true)
|
||||||
|
|
|
||||||
|
|
@ -665,6 +665,7 @@ function handleLostSessionLock(ctx, lockStatus) {
|
||||||
expectedPid: lockStatus?.expectedPid,
|
expectedPid: lockStatus?.expectedPid,
|
||||||
});
|
});
|
||||||
s.active = false;
|
s.active = false;
|
||||||
|
s.runControl = "manual";
|
||||||
s.paused = false;
|
s.paused = false;
|
||||||
deactivateSF();
|
deactivateSF();
|
||||||
clearUnitTimeout();
|
clearUnitTimeout();
|
||||||
|
|
@ -701,6 +702,7 @@ function handleLostSessionLock(ctx, lockStatus) {
|
||||||
function cleanupAfterLoopExit(ctx) {
|
function cleanupAfterLoopExit(ctx) {
|
||||||
s.currentUnit = null;
|
s.currentUnit = null;
|
||||||
s.active = false;
|
s.active = false;
|
||||||
|
s.runControl = "manual";
|
||||||
deactivateSF();
|
deactivateSF();
|
||||||
clearUnitTimeout();
|
clearUnitTimeout();
|
||||||
restoreProjectRootEnv();
|
restoreProjectRootEnv();
|
||||||
|
|
@ -1564,6 +1566,7 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
|
||||||
}
|
}
|
||||||
if (!s.paused) {
|
if (!s.paused) {
|
||||||
s.stepMode = requestedStepMode;
|
s.stepMode = requestedStepMode;
|
||||||
|
s.runControl = requestedStepMode ? "assisted" : "autonomous";
|
||||||
}
|
}
|
||||||
if (freshStartAssessment.lock) {
|
if (freshStartAssessment.lock) {
|
||||||
// Emit a synthetic unit-end for any unit-start that has no closing event.
|
// Emit a synthetic unit-end for any unit-start that has no closing event.
|
||||||
|
|
@ -1613,6 +1616,7 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
|
||||||
s.active = true;
|
s.active = true;
|
||||||
s.verbose = verboseMode;
|
s.verbose = verboseMode;
|
||||||
s.stepMode = requestedStepMode;
|
s.stepMode = requestedStepMode;
|
||||||
|
s.runControl = requestedStepMode ? "assisted" : "autonomous";
|
||||||
s.cmdCtx = ctx;
|
s.cmdCtx = ctx;
|
||||||
s.basePath = base;
|
s.basePath = base;
|
||||||
// Ensure the workflow-logger audit log is pinned to the project root
|
// Ensure the workflow-logger audit log is pinned to the project root
|
||||||
|
|
@ -1869,6 +1873,7 @@ export async function dispatchHookUnit(
|
||||||
if (!s.active) {
|
if (!s.active) {
|
||||||
s.active = true;
|
s.active = true;
|
||||||
s.stepMode = true;
|
s.stepMode = true;
|
||||||
|
s.runControl = "assisted";
|
||||||
s.cmdCtx = ctx;
|
s.cmdCtx = ctx;
|
||||||
s.basePath = targetBasePath;
|
s.basePath = targetBasePath;
|
||||||
s.autoStartTime = Date.now();
|
s.autoStartTime = Date.now();
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
* auto-session-encapsulation.test.ts enforce that auto.ts has no module-level
|
* auto-session-encapsulation.test.ts enforce that auto.ts has no module-level
|
||||||
* `let` or `var` declarations.
|
* `let` or `var` declarations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { emitJournalEvent } from "../journal.js";
|
||||||
import {
|
import {
|
||||||
buildModeState,
|
buildModeState,
|
||||||
resolveModelMode,
|
resolveModelMode,
|
||||||
|
|
@ -22,6 +24,7 @@ import {
|
||||||
resolveRunControlMode,
|
resolveRunControlMode,
|
||||||
resolveWorkMode,
|
resolveWorkMode,
|
||||||
} from "../operating-model.js";
|
} from "../operating-model.js";
|
||||||
|
import { loadSessionModeState, saveSessionModeState } from "../sf-db.js";
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
export const MAX_UNIT_DISPATCHES = 3;
|
export const MAX_UNIT_DISPATCHES = 3;
|
||||||
|
|
@ -42,7 +45,64 @@ export function resetAutoSession() {
|
||||||
_autoSessionInstance = null;
|
_autoSessionInstance = null;
|
||||||
}
|
}
|
||||||
// ─── AutoSession ─────────────────────────────────────────────────────────────
|
// ─── AutoSession ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update terminal/tmux window title with current mode state.
|
||||||
|
*
|
||||||
|
* Purpose: make SF mode visible in terminal tabs, tmux window names,
|
||||||
|
* and task switchers without requiring TUI focus.
|
||||||
|
*
|
||||||
|
* Consumer: AutoSession.setMode() after every mode transition.
|
||||||
|
*/
|
||||||
|
function updateTerminalTitle(mode) {
|
||||||
|
if (
|
||||||
|
typeof process === "undefined" ||
|
||||||
|
!process.stdout ||
|
||||||
|
!process.stdout.isTTY
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const rcBadge =
|
||||||
|
mode.runControl === "autonomous" ? "∞" : mode.runControl.slice(0, 1);
|
||||||
|
const badge =
|
||||||
|
mode.workMode +
|
||||||
|
"|" +
|
||||||
|
rcBadge +
|
||||||
|
"|" +
|
||||||
|
mode.permissionProfile.slice(0, 1) +
|
||||||
|
"|" +
|
||||||
|
mode.modelMode.slice(0, 1);
|
||||||
|
const title = "SF[" + badge + "]";
|
||||||
|
// OSC 0: set icon name + window title
|
||||||
|
process.stdout.write("\x1b]0;" + title + "\x07");
|
||||||
|
// Also set process.title for OS task switchers
|
||||||
|
process.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
export class AutoSession {
|
export class AutoSession {
|
||||||
|
constructor() {
|
||||||
|
// Try to load persisted mode state from DB
|
||||||
|
this._loadPersistedModeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadPersistedModeState() {
|
||||||
|
try {
|
||||||
|
const persisted = loadSessionModeState();
|
||||||
|
if (persisted) {
|
||||||
|
this.workMode = resolveWorkMode(persisted.workMode);
|
||||||
|
this.runControl = resolveRunControlMode(persisted.runControl);
|
||||||
|
this.stepMode = this.runControl === "assisted";
|
||||||
|
this.permissionProfile = resolvePermissionProfile(
|
||||||
|
persisted.permissionProfile,
|
||||||
|
);
|
||||||
|
this.modelMode = resolveModelMode(persisted.modelMode);
|
||||||
|
this.surface = persisted.surface ?? "tui";
|
||||||
|
this.modeUpdatedAt = persisted.updatedAt;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// DB may not be open yet — use defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Lifecycle ────────────────────────────────────────────────────────────
|
// ── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
active = false;
|
active = false;
|
||||||
paused = false;
|
paused = false;
|
||||||
|
|
@ -62,6 +122,12 @@ export class AutoSession {
|
||||||
* Defaults to "chat" for new sessions.
|
* Defaults to "chat" for new sessions.
|
||||||
*/
|
*/
|
||||||
workMode = "chat";
|
workMode = "chat";
|
||||||
|
/**
|
||||||
|
* Requested run control: manual | assisted | autonomous.
|
||||||
|
* Active loop state still comes from `active` + `stepMode`; this field keeps
|
||||||
|
* command/UI mode state separate from whether a process is currently running.
|
||||||
|
*/
|
||||||
|
runControl = "manual";
|
||||||
/**
|
/**
|
||||||
* Current permission profile: restricted | normal | trusted | unrestricted.
|
* Current permission profile: restricted | normal | trusted | unrestricted.
|
||||||
* Defaults to "restricted" for safety.
|
* Defaults to "restricted" for safety.
|
||||||
|
|
@ -259,6 +325,7 @@ export class AutoSession {
|
||||||
this.cmdCtx = null;
|
this.cmdCtx = null;
|
||||||
// Mode state
|
// Mode state
|
||||||
this.workMode = "chat";
|
this.workMode = "chat";
|
||||||
|
this.runControl = "manual";
|
||||||
this.permissionProfile = "restricted";
|
this.permissionProfile = "restricted";
|
||||||
this.modelMode = "smart";
|
this.modelMode = "smart";
|
||||||
this.surface = "tui";
|
this.surface = "tui";
|
||||||
|
|
@ -349,11 +416,14 @@ export class AutoSession {
|
||||||
permissionProfile,
|
permissionProfile,
|
||||||
modelMode,
|
modelMode,
|
||||||
surface,
|
surface,
|
||||||
|
reason = "user-command",
|
||||||
|
scope = "now",
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const prev = this.getMode();
|
const prev = this.getMode();
|
||||||
if (workMode !== undefined) this.workMode = resolveWorkMode(workMode);
|
if (workMode !== undefined) this.workMode = resolveWorkMode(workMode);
|
||||||
if (runControl !== undefined) {
|
if (runControl !== undefined) {
|
||||||
const mode = resolveRunControlMode(runControl);
|
const mode = resolveRunControlMode(runControl);
|
||||||
|
this.runControl = mode;
|
||||||
this.stepMode = mode === "assisted";
|
this.stepMode = mode === "assisted";
|
||||||
}
|
}
|
||||||
if (permissionProfile !== undefined) {
|
if (permissionProfile !== undefined) {
|
||||||
|
|
@ -362,7 +432,40 @@ export class AutoSession {
|
||||||
if (modelMode !== undefined) this.modelMode = resolveModelMode(modelMode);
|
if (modelMode !== undefined) this.modelMode = resolveModelMode(modelMode);
|
||||||
if (surface !== undefined) this.surface = surface;
|
if (surface !== undefined) this.surface = surface;
|
||||||
this.modeUpdatedAt = new Date().toISOString();
|
this.modeUpdatedAt = new Date().toISOString();
|
||||||
return { from: prev, to: this.getMode() };
|
const next = this.getMode();
|
||||||
|
// Persist mode state to DB for durability across sessions
|
||||||
|
if (this.basePath) {
|
||||||
|
try {
|
||||||
|
saveSessionModeState({ ...next, updatedAt: this.modeUpdatedAt });
|
||||||
|
} catch {
|
||||||
|
// DB persistence is best-effort; don't fail mode transition
|
||||||
|
}
|
||||||
|
// Log mode transition to journal for audit trail
|
||||||
|
try {
|
||||||
|
emitJournalEvent(this.basePath, {
|
||||||
|
ts: this.modeUpdatedAt,
|
||||||
|
flowId: `mode-transition:${Date.now()}`,
|
||||||
|
seq: 0,
|
||||||
|
eventType: "mode-transition",
|
||||||
|
rule: "session-mode-change",
|
||||||
|
data: {
|
||||||
|
from: prev,
|
||||||
|
to: next,
|
||||||
|
reason,
|
||||||
|
scope,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Journal is best-effort; don't fail mode transition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update terminal title with mode state for tmux/terminal visibility
|
||||||
|
try {
|
||||||
|
updateTerminalTitle(next);
|
||||||
|
} catch {
|
||||||
|
// Title update is best-effort
|
||||||
|
}
|
||||||
|
return { from: prev, to: next };
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Get current mode state as a canonical object.
|
* Get current mode state as a canonical object.
|
||||||
|
|
@ -370,7 +473,11 @@ export class AutoSession {
|
||||||
getMode() {
|
getMode() {
|
||||||
return buildModeState({
|
return buildModeState({
|
||||||
workMode: this.workMode,
|
workMode: this.workMode,
|
||||||
runControl: this.stepMode ? "assisted" : this.active ? "autonomous" : "manual",
|
runControl: this.active
|
||||||
|
? this.stepMode
|
||||||
|
? "assisted"
|
||||||
|
: "autonomous"
|
||||||
|
: this.runControl,
|
||||||
permissionProfile: this.permissionProfile,
|
permissionProfile: this.permissionProfile,
|
||||||
modelMode: this.modelMode,
|
modelMode: this.modelMode,
|
||||||
surface: this.surface,
|
surface: this.surface,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,10 @@ import { recordToolCallName } from "../auto-tool-tracking.js";
|
||||||
import { loadToolApiKeys } from "../commands-config.js";
|
import { loadToolApiKeys } from "../commands-config.js";
|
||||||
import { getEcosystemReadyPromise } from "../ecosystem/loader.js";
|
import { getEcosystemReadyPromise } from "../ecosystem/loader.js";
|
||||||
import { updateSnapshot } from "../ecosystem/sf-extension-api.js";
|
import { updateSnapshot } from "../ecosystem/sf-extension-api.js";
|
||||||
import { buildExecutionPolicyJournalEntry } from "../execution-policy.js";
|
import {
|
||||||
|
buildExecutionPolicyJournalEntry,
|
||||||
|
classifyExecutionPolicyCall,
|
||||||
|
} from "../execution-policy.js";
|
||||||
import { formatContinue, loadFile, saveFile } from "../files.js";
|
import { formatContinue, loadFile, saveFile } from "../files.js";
|
||||||
import { getDiscussionMilestoneId } from "../guided-flow.js";
|
import { getDiscussionMilestoneId } from "../guided-flow.js";
|
||||||
import { initHealthWidget } from "../health-widget.js";
|
import { initHealthWidget } from "../health-widget.js";
|
||||||
|
|
@ -592,6 +595,32 @@ export function registerHooks(pi, ecosystemHandlers = []) {
|
||||||
);
|
);
|
||||||
if (queueGuard.block) return queueGuard;
|
if (queueGuard.block) return queueGuard;
|
||||||
}
|
}
|
||||||
|
// ── Execution policy enforcement: block based on permission profile ──
|
||||||
|
// When autonomous mode is active, enforce the session's permission profile
|
||||||
|
// at the tool boundary. This is the enforcement layer that makes
|
||||||
|
// /trust restricted|normal|trusted|unrestricted meaningful.
|
||||||
|
if (isAutoActive()) {
|
||||||
|
const { getAutoSession } = await import("../auto/session.js");
|
||||||
|
const session = getAutoSession();
|
||||||
|
const profile = session?.permissionProfile ?? "normal";
|
||||||
|
const input =
|
||||||
|
event.toolName === "bash"
|
||||||
|
? (event.input?.command ?? "")
|
||||||
|
: event.toolName === "write" || event.toolName === "edit"
|
||||||
|
? (event.input?.path ?? "")
|
||||||
|
: "";
|
||||||
|
const decision = classifyExecutionPolicyCall(
|
||||||
|
profile,
|
||||||
|
event.toolName,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
if (!decision.allowed) {
|
||||||
|
return {
|
||||||
|
block: true,
|
||||||
|
reason: `Execution policy block: ${decision.reason} (profile: ${profile}, tool: ${event.toolName})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
// ── Single-writer engine: block direct writes to STATE.md ──────────
|
// ── Single-writer engine: block direct writes to STATE.md ──────────
|
||||||
// Covers write, edit, and bash tools to prevent bypass vectors.
|
// Covers write, edit, and bash tools to prevent bypass vectors.
|
||||||
if (isToolCallEventType("write", event)) {
|
if (isToolCallEventType("write", event)) {
|
||||||
|
|
@ -648,7 +677,12 @@ export function registerHooks(pi, ecosystemHandlers = []) {
|
||||||
if (!isAutoActive()) return;
|
if (!isAutoActive()) return;
|
||||||
safetyRecordToolCall(event.toolCallId, event.toolName, event.input);
|
safetyRecordToolCall(event.toolCallId, event.toolName, event.input);
|
||||||
const policyDash = getAutoDashboardData();
|
const policyDash = getAutoDashboardData();
|
||||||
const policyProfile = isQueuePhaseActive() ? "restricted" : "normal";
|
// Use session permission profile if available, fall back to queue-aware default
|
||||||
|
const { getAutoSession } = await import("../auto/session.js");
|
||||||
|
const session = getAutoSession();
|
||||||
|
const sessionProfile = session?.permissionProfile;
|
||||||
|
const policyProfile =
|
||||||
|
sessionProfile ?? (isQueuePhaseActive() ? "restricted" : "normal");
|
||||||
if (policyDash.basePath) {
|
if (policyDash.basePath) {
|
||||||
emitJournalEvent(
|
emitJournalEvent(
|
||||||
policyDash.basePath,
|
policyDash.basePath,
|
||||||
|
|
|
||||||
|
|
@ -422,6 +422,54 @@ export async function handleTriage(args, ctx, pi, basePath) {
|
||||||
}
|
}
|
||||||
export async function handleSteer(change, ctx, pi) {
|
export async function handleSteer(change, ctx, pi) {
|
||||||
const basePath = process.cwd();
|
const basePath = process.cwd();
|
||||||
|
const trimmed = change.trim();
|
||||||
|
|
||||||
|
// ── Mode steering: /steer mode <workMode> [scope] ──────────────────────
|
||||||
|
const modeSteerRe = /^mode\s+(\S+)(?:\s+(\S+))?/;
|
||||||
|
const modeMatch = trimmed.match(modeSteerRe);
|
||||||
|
if (modeMatch) {
|
||||||
|
const workMode = modeMatch[1];
|
||||||
|
const scope = modeMatch[2] ?? "after-current-unit";
|
||||||
|
const s = getAutoSession();
|
||||||
|
const transition = s.setMode({ workMode, reason: "steer", scope });
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Steer mode: ${transition.from.workMode} → ${transition.to.workMode} (${scope})`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trust steering: /steer trust <profile> [scope] ─────────────────────
|
||||||
|
const trustSteerRe = /^trust\s+(\S+)(?:\s+(\S+))?/;
|
||||||
|
const trustMatch = trimmed.match(trustSteerRe);
|
||||||
|
if (trustMatch) {
|
||||||
|
const permissionProfile = trustMatch[1];
|
||||||
|
const scope = trustMatch[2] ?? "now";
|
||||||
|
const s = getAutoSession();
|
||||||
|
const transition = s.setMode({ permissionProfile, reason: "steer", scope });
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Steer trust: ${transition.from.permissionProfile} → ${transition.to.permissionProfile} (${scope})`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Model-mode steering: /steer model-mode <mode> [scope] ──────────────
|
||||||
|
const modelModeSteerRe = /^model-mode\s+(\S+)(?:\s+(\S+))?/;
|
||||||
|
const modelModeMatch = trimmed.match(modelModeSteerRe);
|
||||||
|
if (modelModeMatch) {
|
||||||
|
const modelMode = modelModeMatch[1];
|
||||||
|
const scope = modelModeMatch[2] ?? "for-next-unit";
|
||||||
|
const s = getAutoSession();
|
||||||
|
const transition = s.setMode({ modelMode, reason: "steer", scope });
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Steer model-mode: ${transition.from.modelMode} → ${transition.to.modelMode} (${scope})`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Legacy text override ───────────────────────────────────────────────
|
||||||
const state = await deriveState(basePath);
|
const state = await deriveState(basePath);
|
||||||
const mid = state.activeMilestone?.id ?? "none";
|
const mid = state.activeMilestone?.id ?? "none";
|
||||||
const sid = state.activeSlice?.id ?? "none";
|
const sid = state.activeSlice?.id ?? "none";
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const sfHome = process.env.SF_HOME || join(homedir(), ".sf");
|
||||||
* Comprehensive description of all available SF commands for help text.
|
* Comprehensive description of all available SF commands for help text.
|
||||||
*/
|
*/
|
||||||
export const SF_COMMAND_DESCRIPTION =
|
export const SF_COMMAND_DESCRIPTION =
|
||||||
"SF — Singularity Forge: /help|start|templates|next|autonomous|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|cleanup|mode|show-config|prefs|config|keys|hooks|run-hook|skill-health|doctor|uok|logs|forensics|migrate|remote|steer|knowledge|harness|solver-eval|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold|extract-learnings|eval-review|plan";
|
"SF — Singularity Forge: /help|start|templates|next|autonomous|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|cleanup|mode|control|trust|model-mode|show-config|prefs|config|keys|hooks|run-hook|skill-health|doctor|uok|logs|forensics|migrate|remote|steer|knowledge|harness|solver-eval|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold|extract-learnings|eval-review|plan";
|
||||||
|
|
||||||
export const BASE_RUNTIME_COMMANDS = new Set([
|
export const BASE_RUNTIME_COMMANDS = new Set([
|
||||||
"settings",
|
"settings",
|
||||||
|
|
@ -102,7 +102,16 @@ export const TOP_LEVEL_SUBCOMMANDS = [
|
||||||
desc: "Manage worktrees from the TUI (list, merge, clean, remove)",
|
desc: "Manage worktrees from the TUI (list, merge, clean, remove)",
|
||||||
},
|
},
|
||||||
{ cmd: "model", desc: "Switch the active session model or open a picker" },
|
{ cmd: "model", desc: "Switch the active session model or open a picker" },
|
||||||
{ cmd: "mode", desc: "Switch workflow mode (solo/team)" },
|
{
|
||||||
|
cmd: "mode",
|
||||||
|
desc: "Switch work mode (chat/plan/build/review/repair/research) or workflow mode (solo/team)",
|
||||||
|
},
|
||||||
|
{ cmd: "control", desc: "Switch run control (manual/assisted/autonomous)" },
|
||||||
|
{
|
||||||
|
cmd: "trust",
|
||||||
|
desc: "Switch permission profile (restricted/normal/trusted/unrestricted)",
|
||||||
|
},
|
||||||
|
{ cmd: "model-mode", desc: "Switch model mode (fast/smart/deep)" },
|
||||||
{
|
{
|
||||||
cmd: "show-config",
|
cmd: "show-config",
|
||||||
desc: "Show effective configuration (models, routing, toggles)",
|
desc: "Show effective configuration (models, routing, toggles)",
|
||||||
|
|
@ -124,6 +133,12 @@ export const TOP_LEVEL_SUBCOMMANDS = [
|
||||||
desc: "View, filter, and clear persistent notification history",
|
desc: "View, filter, and clear persistent notification history",
|
||||||
},
|
},
|
||||||
{ cmd: "doctor", desc: "Runtime health checks with auto-fix" },
|
{ cmd: "doctor", desc: "Runtime health checks with auto-fix" },
|
||||||
|
{
|
||||||
|
cmd: "repair",
|
||||||
|
desc: "Switch to repair work mode and run diagnostics [--autonomous]",
|
||||||
|
},
|
||||||
|
{ cmd: "tasks", desc: "Background work surface — units, workers, budget" },
|
||||||
|
{ cmd: "skills", desc: "List discovered skills from .agents/skills/" },
|
||||||
{
|
{
|
||||||
cmd: "uok",
|
cmd: "uok",
|
||||||
desc: "UOK runtime health: ledger, last run, last error, startup gate, gate metrics",
|
desc: "UOK runtime health: ledger, last run, last error, startup gate, gate metrics",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { getAutoSession } from "../../auto/session.js";
|
||||||
import { handleCmux } from "../../commands-cmux.js";
|
import { handleCmux } from "../../commands-cmux.js";
|
||||||
import {
|
import {
|
||||||
ensurePreferencesFile,
|
ensurePreferencesFile,
|
||||||
|
|
@ -34,11 +35,15 @@ export function showHelp(ctx, args = "") {
|
||||||
` /status Dashboard (${formattedShortcutPair("dashboard")})`,
|
` /status Dashboard (${formattedShortcutPair("dashboard")})`,
|
||||||
` /parallel watch Parallel monitor (${formattedShortcutPair("parallel")})`,
|
` /parallel watch Parallel monitor (${formattedShortcutPair("parallel")})`,
|
||||||
` /notifications Notification history (${formattedShortcutPair("notifications")})`,
|
` /notifications Notification history (${formattedShortcutPair("notifications")})`,
|
||||||
|
" /tasks Background work surface — units, workers, budget",
|
||||||
" /visualize Interactive 10-tab TUI",
|
" /visualize Interactive 10-tab TUI",
|
||||||
" /queue Show queued/dispatched units",
|
" /queue Show queued/dispatched units",
|
||||||
"",
|
"",
|
||||||
"COURSE CORRECTION",
|
"COURSE CORRECTION",
|
||||||
" /steer <desc> Apply user override to active work",
|
" /steer <desc> Apply user override to active work",
|
||||||
|
" /steer mode <m> [scope] Change work mode (now|after-current-unit|next-milestone)",
|
||||||
|
" /steer trust <p> [scope] Change permission profile",
|
||||||
|
" /steer model-mode <m> Change model mode for next unit",
|
||||||
" /capture <text> Quick-capture a thought to CAPTURES.md",
|
" /capture <text> Quick-capture a thought to CAPTURES.md",
|
||||||
" /triage Classify and route pending captures",
|
" /triage Classify and route pending captures",
|
||||||
" /undo Revert last completed unit [--force]",
|
" /undo Revert last completed unit [--force]",
|
||||||
|
|
@ -51,6 +56,9 @@ export function showHelp(ctx, args = "") {
|
||||||
" /model Switch active session model",
|
" /model Switch active session model",
|
||||||
" /prefs Manage preferences",
|
" /prefs Manage preferences",
|
||||||
" /doctor Diagnose and repair .sf/ state",
|
" /doctor Diagnose and repair .sf/ state",
|
||||||
|
" /repair Switch to repair work mode and run diagnostics",
|
||||||
|
" /tasks Background work surface",
|
||||||
|
" /skills List discovered skills",
|
||||||
"",
|
"",
|
||||||
"Use /help all for the complete command reference.",
|
"Use /help all for the complete command reference.",
|
||||||
];
|
];
|
||||||
|
|
@ -72,6 +80,7 @@ export function showHelp(ctx, args = "") {
|
||||||
` /parallel watch Open parallel worker monitor (${formattedShortcutPair("parallel")})`,
|
` /parallel watch Open parallel worker monitor (${formattedShortcutPair("parallel")})`,
|
||||||
" /visualize Interactive 10-tab TUI (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)",
|
" /visualize Interactive 10-tab TUI (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)",
|
||||||
" /queue Show queued/dispatched units and execution order",
|
" /queue Show queued/dispatched units and execution order",
|
||||||
|
" /tasks Background work surface — units, workers, budget, checkpoints",
|
||||||
" /history View execution history [--cost] [--phase] [--model] [N]",
|
" /history View execution history [--cost] [--phase] [--model] [N]",
|
||||||
" /changelog Show categorized release notes [version]",
|
" /changelog Show categorized release notes [version]",
|
||||||
` /notifications View persistent notification history [clear|tail|filter] (${formattedShortcutPair("notifications")})`,
|
` /notifications View persistent notification history [clear|tail|filter] (${formattedShortcutPair("notifications")})`,
|
||||||
|
|
@ -99,7 +108,10 @@ export function showHelp(ctx, args = "") {
|
||||||
" /init Project init wizard — detect, configure, bootstrap .sf/",
|
" /init Project init wizard — detect, configure, bootstrap .sf/",
|
||||||
" /setup Global setup status [llm|search|remote|keys|prefs]",
|
" /setup Global setup status [llm|search|remote|keys|prefs]",
|
||||||
" /model Switch active session model [provider/model|model-id]",
|
" /model Switch active session model [provider/model|model-id]",
|
||||||
" /mode Set workflow mode (solo/team) [global|project]",
|
" /mode Switch work mode (chat/plan/build/review/repair/research)",
|
||||||
|
" /control Switch run control (manual/assisted/autonomous)",
|
||||||
|
" /trust Switch permission profile (restricted/normal/trusted/unrestricted)",
|
||||||
|
" /model-mode Switch model mode (fast/smart/deep)",
|
||||||
" /prefs Manage preferences [global|project|status|wizard|setup|import-claude]",
|
" /prefs Manage preferences [global|project|status|wizard|setup|import-claude]",
|
||||||
" /cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]",
|
" /cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]",
|
||||||
" /config Set API keys for external tools",
|
" /config Set API keys for external tools",
|
||||||
|
|
@ -112,6 +124,10 @@ export function showHelp(ctx, args = "") {
|
||||||
"",
|
"",
|
||||||
"MAINTENANCE",
|
"MAINTENANCE",
|
||||||
" /doctor Diagnose and repair .sf/ state [audit|fix|heal] [scope]",
|
" /doctor Diagnose and repair .sf/ state [audit|fix|heal] [scope]",
|
||||||
|
" /repair Switch to repair work mode and run diagnostics [--autonomous]",
|
||||||
|
" /tasks Background work surface [--refresh|--failed|--cancelled|--all]",
|
||||||
|
" /skills List discovered skills from .agents/skills/",
|
||||||
|
" /skills --eval <name> Run eval cases for a skill",
|
||||||
" /reload Snapshot & reload agent, resume same session",
|
" /reload Snapshot & reload agent, resume same session",
|
||||||
" /export Export milestone/slice results [--json|--markdown|--html] [--all]",
|
" /export Export milestone/slice results [--json|--markdown|--html] [--all]",
|
||||||
" /cleanup Remove merged branches or snapshots [branches|snapshots]",
|
" /cleanup Remove merged branches or snapshots [branches|snapshots]",
|
||||||
|
|
@ -382,6 +398,71 @@ async function handleModel(trimmedArgs, ctx, pi) {
|
||||||
}
|
}
|
||||||
ctx.ui.notify(`Model: ${targetModel.provider}/${targetModel.id}`, "info");
|
ctx.ui.notify(`Model: ${targetModel.provider}/${targetModel.id}`, "info");
|
||||||
}
|
}
|
||||||
|
function formatModeState(mode) {
|
||||||
|
if (!mode) return "SF — mode unknown";
|
||||||
|
return `SF — ${mode.workMode} · ${mode.runControl} · ${mode.permissionProfile} · ${mode.modelMode}`;
|
||||||
|
}
|
||||||
|
function handleModeCommand(args, ctx) {
|
||||||
|
const s = getAutoSession();
|
||||||
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
const mode = s.getMode();
|
||||||
|
ctx.ui.notify(formatModeState(mode), "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const workMode = parts[0];
|
||||||
|
const transition = s.setMode({ workMode });
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Mode: ${transition.from.workMode} → ${transition.to.workMode}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function handleControlCommand(args, ctx) {
|
||||||
|
const s = getAutoSession();
|
||||||
|
const runControl = args.trim();
|
||||||
|
if (!runControl) {
|
||||||
|
const mode = s.getMode();
|
||||||
|
ctx.ui.notify(`Run control: ${mode.runControl}`, "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const transition = s.setMode({ runControl });
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Run control: ${transition.from.runControl} → ${transition.to.runControl}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function handleTrustCommand(args, ctx) {
|
||||||
|
const s = getAutoSession();
|
||||||
|
const permissionProfile = args.trim();
|
||||||
|
if (!permissionProfile) {
|
||||||
|
const mode = s.getMode();
|
||||||
|
ctx.ui.notify(`Trust: ${mode.permissionProfile}`, "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const transition = s.setMode({ permissionProfile });
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Trust: ${transition.from.permissionProfile} → ${transition.to.permissionProfile}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function handleModelModeCommand(args, ctx) {
|
||||||
|
const s = getAutoSession();
|
||||||
|
const modelMode = args.trim();
|
||||||
|
if (!modelMode) {
|
||||||
|
const mode = s.getMode();
|
||||||
|
ctx.ui.notify(`Model mode: ${mode.modelMode}`, "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const transition = s.setMode({ modelMode });
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Model mode: ${transition.from.modelMode} → ${transition.to.modelMode}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
export async function handleCoreCommand(trimmed, ctx, pi) {
|
export async function handleCoreCommand(trimmed, ctx, pi) {
|
||||||
if (
|
if (
|
||||||
trimmed === "help" ||
|
trimmed === "help" ||
|
||||||
|
|
@ -419,6 +500,13 @@ export async function handleCoreCommand(trimmed, ctx, pi) {
|
||||||
}
|
}
|
||||||
if (trimmed === "mode" || trimmed.startsWith("mode ")) {
|
if (trimmed === "mode" || trimmed.startsWith("mode ")) {
|
||||||
const modeArgs = trimmed.replace(/^mode\s*/, "").trim();
|
const modeArgs = trimmed.replace(/^mode\s*/, "").trim();
|
||||||
|
// If arg is a work mode (chat/plan/build/review/repair/research), use new mode system
|
||||||
|
const workModes = ["chat", "plan", "build", "review", "repair", "research"];
|
||||||
|
if (workModes.includes(modeArgs)) {
|
||||||
|
handleModeCommand(modeArgs, ctx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Otherwise fall back to old prefs mode (solo/team)
|
||||||
const scope = modeArgs === "project" ? "project" : "global";
|
const scope = modeArgs === "project" ? "project" : "global";
|
||||||
const path =
|
const path =
|
||||||
scope === "project"
|
scope === "project"
|
||||||
|
|
@ -428,10 +516,27 @@ export async function handleCoreCommand(trimmed, ctx, pi) {
|
||||||
await handlePrefsMode(ctx, scope);
|
await handlePrefsMode(ctx, scope);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (trimmed === "control" || trimmed.startsWith("control ")) {
|
||||||
|
handleControlCommand(trimmed.replace(/^control\s*/, "").trim(), ctx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (trimmed === "trust" || trimmed.startsWith("trust ")) {
|
||||||
|
handleTrustCommand(trimmed.replace(/^trust\s*/, "").trim(), ctx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (trimmed === "model-mode" || trimmed.startsWith("model-mode ")) {
|
||||||
|
handleModelModeCommand(trimmed.replace(/^model-mode\s*/, "").trim(), ctx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (trimmed === "prefs" || trimmed.startsWith("prefs ")) {
|
if (trimmed === "prefs" || trimmed.startsWith("prefs ")) {
|
||||||
await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx);
|
await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (trimmed === "tasks" || trimmed.startsWith("tasks ")) {
|
||||||
|
const { handleTasks } = await import("./tasks.js");
|
||||||
|
await handleTasks(trimmed.replace(/^tasks\s*/, "").trim(), ctx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (trimmed === "cmux" || trimmed.startsWith("cmux ")) {
|
if (trimmed === "cmux" || trimmed.startsWith("cmux ")) {
|
||||||
await handleCmux(trimmed.replace(/^cmux\s*/, "").trim(), ctx);
|
await handleCmux(trimmed.replace(/^cmux\s*/, "").trim(), ctx);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -504,6 +609,143 @@ export async function handleCoreCommand(trimmed, ctx, pi) {
|
||||||
process.exit(EXIT_RELOAD);
|
process.exit(EXIT_RELOAD);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (trimmed === "skills" || trimmed.startsWith("skills ")) {
|
||||||
|
const args = trimmed.replace(/^skills\s*/, "").trim();
|
||||||
|
// Auto-create mode: detect patterns and generate skills
|
||||||
|
if (args === "--auto-create" || args === "-a") {
|
||||||
|
const {
|
||||||
|
detectSkillCandidates,
|
||||||
|
isAutoSkillCreationAllowed,
|
||||||
|
generateSkill,
|
||||||
|
} = await import("../../skills/auto-create.js");
|
||||||
|
const basePath = projectRoot();
|
||||||
|
if (!isAutoSkillCreationAllowed(basePath)) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
"Auto-skill creation is on cooldown. Try again tomorrow.",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const candidates = detectSkillCandidates(basePath);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
"No skill candidates detected. Need ≥3 occurrences of a pattern.",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const lines = ["SF Skill Auto-Creation\n"];
|
||||||
|
lines.push(`Detected ${candidates.length} candidate(s):\n`);
|
||||||
|
for (const c of candidates) {
|
||||||
|
lines.push(` ${c.name} — ${c.description}`);
|
||||||
|
}
|
||||||
|
// Generate the first candidate
|
||||||
|
const result = generateSkill(candidates[0], basePath);
|
||||||
|
if (result.created) {
|
||||||
|
lines.push(`\n✓ Created: ${result.path}`);
|
||||||
|
ctx.ui.notify(lines.join("\n"), "info");
|
||||||
|
} else {
|
||||||
|
lines.push(`\n✗ Skipped: ${result.reason}`);
|
||||||
|
ctx.ui.notify(lines.join("\n"), "warning");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Eval mode: run eval cases for a skill
|
||||||
|
if (args.startsWith("--eval ") || args.startsWith("-e ")) {
|
||||||
|
const skillName = args
|
||||||
|
.replace(/^--eval\s+/, "")
|
||||||
|
.replace(/^-e\s+/, "")
|
||||||
|
.trim();
|
||||||
|
const { loadSkills, runSkillEvals } = await import(
|
||||||
|
"../../skills/loader.js"
|
||||||
|
);
|
||||||
|
const { createEvalCase, generateDefaultEvalCase } = await import(
|
||||||
|
"../../skills/eval-harness.js"
|
||||||
|
);
|
||||||
|
const skills = loadSkills(projectRoot());
|
||||||
|
const skill = skills.find((s) => s.name === skillName);
|
||||||
|
if (!skill) {
|
||||||
|
ctx.ui.notify(`Skill "${skillName}" not found.`, "warning");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Auto-create default eval case if none exist
|
||||||
|
const evalDir = join(
|
||||||
|
skill.basePath ?? projectRoot(),
|
||||||
|
".agents",
|
||||||
|
"skills",
|
||||||
|
skillName,
|
||||||
|
"evals",
|
||||||
|
);
|
||||||
|
const { existsSync } = await import("node:fs");
|
||||||
|
if (!existsSync(evalDir)) {
|
||||||
|
const caseDef = generateDefaultEvalCase(skill);
|
||||||
|
createEvalCase(
|
||||||
|
join(skill.basePath ?? projectRoot(), ".agents", "skills", skillName),
|
||||||
|
"default",
|
||||||
|
caseDef,
|
||||||
|
);
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Created default eval case for ${skillName}. Run /skills --eval ${skillName} again.`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const result = await runSkillEvals(
|
||||||
|
join(skill.basePath ?? projectRoot(), ".agents", "skills", skillName),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const lines = [`SF Skill Eval: ${skillName}\n`];
|
||||||
|
lines.push(
|
||||||
|
`Cases: ${result.passedCases}/${result.totalCases} passed · Score: ${(result.totalScore * 100).toFixed(0)}%\n`,
|
||||||
|
);
|
||||||
|
for (const c of result.cases) {
|
||||||
|
const icon = c.passed ? "✓" : "✗";
|
||||||
|
lines.push(`${icon} ${c.caseName}: ${c.score.toFixed(2)}`);
|
||||||
|
for (const d of c.details.slice(0, 3)) {
|
||||||
|
lines.push(` ${d}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.ui.notify(
|
||||||
|
lines.join("\n"),
|
||||||
|
result.passedCases === result.totalCases ? "success" : "warning",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Normal list mode
|
||||||
|
const { loadSkills, getPermittedSkills, getModelInvocableSkills } =
|
||||||
|
await import("../../skills/loader.js");
|
||||||
|
const skills = loadSkills(projectRoot());
|
||||||
|
const mode = getAutoSession().getMode();
|
||||||
|
const permitted = getPermittedSkills(skills, mode.permissionProfile);
|
||||||
|
const modelInvocable = getModelInvocableSkills(skills, mode.workMode);
|
||||||
|
|
||||||
|
const lines = ["SF Skills\n"];
|
||||||
|
lines.push(
|
||||||
|
`Found ${skills.length} skill(s) · ${permitted.length} permitted · ${modelInvocable.length} model-invocable\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const skill of skills) {
|
||||||
|
const icon = skill.valid ? "✓" : "✗";
|
||||||
|
const user = skill.userInvocable ? "U" : " ";
|
||||||
|
const model = skill.modelInvocable ? "M" : " ";
|
||||||
|
lines.push(
|
||||||
|
`${icon} [${user}${model}] ${skill.name} · ${skill.permissionProfile} · ${skill.sideEffects}`,
|
||||||
|
);
|
||||||
|
if (!skill.valid && skill.errors?.length > 0) {
|
||||||
|
lines.push(` ⚠ ${skill.errors[0]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
"\nLegend: [U]ser-invocable [M]odel-invocable · R=restricted N=normal T=trusted",
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
"Use /skills --auto-create to detect and generate skills from activity patterns.",
|
||||||
|
);
|
||||||
|
lines.push("Use /skills --eval <name> to run eval cases for a skill.");
|
||||||
|
ctx.ui.notify(lines.join("\n"), "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
export function formatTextStatus(state) {
|
export function formatTextStatus(state) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { handleRemote } from "../../../remote-questions/mod.js";
|
import { handleRemote } from "../../../remote-questions/mod.js";
|
||||||
|
import { getAutoSession } from "../../auto/session.js";
|
||||||
import { dispatchDirectPhase } from "../../auto-direct-dispatch.js";
|
import { dispatchDirectPhase } from "../../auto-direct-dispatch.js";
|
||||||
import { handleConfig } from "../../commands-config.js";
|
import { handleConfig } from "../../commands-config.js";
|
||||||
import { handleDebug } from "../../commands-debug.js";
|
import { handleDebug } from "../../commands-debug.js";
|
||||||
|
|
@ -55,6 +56,27 @@ export async function handleOpsCommand(trimmed, ctx, pi) {
|
||||||
await handleDoctor(trimmed.replace(/^doctor\s*/, "").trim(), ctx, pi);
|
await handleDoctor(trimmed.replace(/^doctor\s*/, "").trim(), ctx, pi);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (trimmed === "repair" || trimmed.startsWith("repair ")) {
|
||||||
|
const s = getAutoSession();
|
||||||
|
const args = trimmed.replace(/^repair\s*/, "").trim();
|
||||||
|
// Switch to repair work mode
|
||||||
|
const transition = s.setMode({ workMode: "repair" });
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Repair mode: ${transition.from.workMode} → ${transition.to.workMode}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
// If --autonomous flag, also set run control
|
||||||
|
if (args.includes("--autonomous")) {
|
||||||
|
s.setMode({ runControl: "autonomous" });
|
||||||
|
ctx.ui.notify(
|
||||||
|
"Repair will run autonomously until clean or blocked.",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Run doctor diagnostics
|
||||||
|
await handleDoctor("fix", ctx, pi);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (trimmed === "uok" || trimmed.startsWith("uok ")) {
|
if (trimmed === "uok" || trimmed.startsWith("uok ")) {
|
||||||
const { handleUok } = await import("../../commands-uok.js");
|
const { handleUok } = await import("../../commands-uok.js");
|
||||||
await handleUok(trimmed.replace(/^uok\s*/, "").trim(), ctx);
|
await handleUok(trimmed.replace(/^uok\s*/, "").trim(), ctx);
|
||||||
|
|
|
||||||
292
src/resources/extensions/sf/commands/handlers/tasks.js
Normal file
292
src/resources/extensions/sf/commands/handlers/tasks.js
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
/**
|
||||||
|
* /tasks command — unified background work surface
|
||||||
|
*
|
||||||
|
* Purpose: show all background work in one view: autonomous units, parallel
|
||||||
|
* workers, scheduled dispatches, shell sessions, stuck sessions, remote
|
||||||
|
* questions, cost/budget state, and last checkpoint.
|
||||||
|
*
|
||||||
|
* Consumer: human users via TUI and web chat mode.
|
||||||
|
*/
|
||||||
|
import { closeDatabase, getDatabase, isDbAvailable } from "../../sf-db.js";
|
||||||
|
import { projectRoot } from "../context.js";
|
||||||
|
|
||||||
|
const TASK_STATE_ICONS = {
|
||||||
|
todo: "○",
|
||||||
|
in_progress: "◐",
|
||||||
|
review: "◑",
|
||||||
|
done: "●",
|
||||||
|
retrying: "↻",
|
||||||
|
failed: "✗",
|
||||||
|
cancelled: "⊘",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TASK_STATE_LABELS = {
|
||||||
|
todo: "todo",
|
||||||
|
in_progress: "running",
|
||||||
|
review: "review",
|
||||||
|
done: "done",
|
||||||
|
retrying: "retrying",
|
||||||
|
failed: "failed",
|
||||||
|
cancelled: "cancelled",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a task row for display.
|
||||||
|
*/
|
||||||
|
function formatTaskLine(task, width = 80) {
|
||||||
|
const icon = TASK_STATE_ICONS[task.state] ?? "?";
|
||||||
|
const label = TASK_STATE_LABELS[task.state] ?? task.state;
|
||||||
|
const id = task.unitId ?? task.id ?? "unknown";
|
||||||
|
const title = task.title ?? id;
|
||||||
|
const duration = task.durationMs
|
||||||
|
? ` ${Math.round(task.durationMs / 1000)}s`
|
||||||
|
: "";
|
||||||
|
const cost = task.costUsd ? ` $${task.costUsd.toFixed(2)}` : "";
|
||||||
|
const worker = task.workerId ? ` [${task.workerId}]` : "";
|
||||||
|
|
||||||
|
const right = `${duration}${cost}${worker}`;
|
||||||
|
const maxTitle = Math.max(20, width - right.length - 8);
|
||||||
|
const truncated =
|
||||||
|
title.length > maxTitle ? title.slice(0, maxTitle - 1) + "…" : title;
|
||||||
|
|
||||||
|
return `${icon} ${truncated.padEnd(maxTitle)} ${label.padStart(10)}${right}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the /tasks text report.
|
||||||
|
*/
|
||||||
|
function buildTasksReport({ tasks, summary, mode, budget }) {
|
||||||
|
const lines = [];
|
||||||
|
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
|
||||||
|
|
||||||
|
lines.push(`SF Background Work ${now}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// Mode line
|
||||||
|
if (mode) {
|
||||||
|
lines.push(
|
||||||
|
`Mode: ${mode.workMode} · ${mode.runControl} · ${mode.permissionProfile} · ${mode.modelMode}`,
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
if (summary) {
|
||||||
|
const { counts, total, progress, isComplete } = summary;
|
||||||
|
const parts = [];
|
||||||
|
for (const [state, count] of Object.entries(counts)) {
|
||||||
|
if (count > 0) parts.push(`${TASK_STATE_ICONS[state] ?? "?"}${count}`);
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
`Tasks: ${total} total · ${parts.join(" ")} · ${progress}% complete${isComplete ? " ✓" : ""}`,
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active tasks
|
||||||
|
const active = tasks.filter((t) =>
|
||||||
|
["in_progress", "review", "retrying"].includes(t.state),
|
||||||
|
);
|
||||||
|
if (active.length > 0) {
|
||||||
|
lines.push(`Active (${active.length}):`);
|
||||||
|
for (const task of active) {
|
||||||
|
lines.push(` ${formatTaskLine(task)}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending
|
||||||
|
const pending = tasks.filter((t) => t.state === "todo");
|
||||||
|
if (pending.length > 0) {
|
||||||
|
lines.push(`Pending (${pending.length}):`);
|
||||||
|
for (const task of pending.slice(0, 10)) {
|
||||||
|
lines.push(` ${formatTaskLine(task)}`);
|
||||||
|
}
|
||||||
|
if (pending.length > 10) {
|
||||||
|
lines.push(` … and ${pending.length - 10} more`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal
|
||||||
|
const terminal = tasks.filter((t) =>
|
||||||
|
["done", "failed", "cancelled"].includes(t.state),
|
||||||
|
);
|
||||||
|
if (terminal.length > 0) {
|
||||||
|
const recent = terminal
|
||||||
|
.sort((a, b) => (b.endedAt ?? "").localeCompare(a.endedAt ?? ""))
|
||||||
|
.slice(0, 5);
|
||||||
|
lines.push(`Recently finished (${terminal.length} total):`);
|
||||||
|
for (const task of recent) {
|
||||||
|
lines.push(` ${formatTaskLine(task)}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget
|
||||||
|
if (budget) {
|
||||||
|
lines.push(
|
||||||
|
`Budget: $${budget.spent.toFixed(2)} / $${budget.ceiling.toFixed(2)} · ${budget.remaining > 0 ? "$" + budget.remaining.toFixed(2) + " remaining" : "OVER BUDGET"}`,
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("Commands: /tasks --refresh /tasks --failed /tasks --cancelled");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDbTask(row) {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
id: row.id,
|
||||||
|
unitId: row.unitId ?? row.unit_id ?? row.id,
|
||||||
|
milestoneId: row.milestoneId ?? row.milestone_id ?? null,
|
||||||
|
sliceId: row.sliceId ?? row.slice_id ?? null,
|
||||||
|
taskId: row.taskId ?? row.task_id ?? null,
|
||||||
|
title: row.title ?? row.unit_id ?? row.id,
|
||||||
|
state: row.state ?? row.status ?? "todo",
|
||||||
|
workerId: row.workerId ?? row.worker_id ?? null,
|
||||||
|
durationMs: row.durationMs ?? row.duration_ms ?? null,
|
||||||
|
costUsd: row.costUsd ?? row.cost_usd ?? null,
|
||||||
|
endedAt: row.endedAt ?? row.ended_at ?? null,
|
||||||
|
graphId: row.graphId ?? row.graph_id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the /tasks command.
|
||||||
|
*/
|
||||||
|
export async function handleTasks(args, ctx) {
|
||||||
|
const basePath = projectRoot();
|
||||||
|
const trimmed = args.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Parse flags
|
||||||
|
const refresh = trimmed.includes("--refresh") || trimmed.includes("-r");
|
||||||
|
const showFailed = trimmed.includes("--failed") || trimmed.includes("-f");
|
||||||
|
const showCancelled =
|
||||||
|
trimmed.includes("--cancelled") || trimmed.includes("-c");
|
||||||
|
const showAll = trimmed.includes("--all") || trimmed.includes("-a");
|
||||||
|
|
||||||
|
const { ensureDbOpen } = await import("../../bootstrap/dynamic-tools.js");
|
||||||
|
if (refresh) closeDatabase();
|
||||||
|
await ensureDbOpen();
|
||||||
|
|
||||||
|
// Get mode state
|
||||||
|
const { getAutoSession } = await import("../../auto/session.js");
|
||||||
|
const session = getAutoSession();
|
||||||
|
const mode = session?.getMode?.() ?? null;
|
||||||
|
|
||||||
|
// Try DB-backed task query first
|
||||||
|
let tasks = [];
|
||||||
|
let summary = null;
|
||||||
|
|
||||||
|
if (isDbAvailable()) {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!db) throw new Error("SF database is not open");
|
||||||
|
const { queryTasksByState, getGraphStateSummary } = await import(
|
||||||
|
"../../uok/execution-graph-persist.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
let states = [];
|
||||||
|
if (showFailed) states = ["failed"];
|
||||||
|
else if (showCancelled) states = ["cancelled"];
|
||||||
|
else if (!showAll) states = ["todo", "in_progress", "review", "retrying"];
|
||||||
|
|
||||||
|
tasks = queryTasksByState(db, { states, limit: 200 }).map(
|
||||||
|
normalizeDbTask,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get summary for active graphs
|
||||||
|
const graphIds = [
|
||||||
|
...new Set(tasks.map((t) => t.graphId).filter(Boolean)),
|
||||||
|
];
|
||||||
|
if (graphIds.length > 0) {
|
||||||
|
// Use first active graph for summary
|
||||||
|
summary = getGraphStateSummary(db, graphIds[0]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// DB query failed — fall through to filesystem fallback
|
||||||
|
ctx.ui.notify(
|
||||||
|
`DB query failed: ${err.message}. Falling back to filesystem.`,
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: derive from unit runtime files if no DB tasks
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
try {
|
||||||
|
const { deriveState } = await import("../../state.js");
|
||||||
|
const state = await deriveState(basePath);
|
||||||
|
|
||||||
|
// Convert milestone registry entries to task records
|
||||||
|
for (const ms of state.registry) {
|
||||||
|
for (const slice of ms.slices ?? []) {
|
||||||
|
for (const task of slice.tasks ?? []) {
|
||||||
|
tasks.push({
|
||||||
|
id: `${ms.id}/${slice.id}/${task.id}`,
|
||||||
|
unitId: `${ms.id}/${slice.id}/${task.id}`,
|
||||||
|
milestoneId: ms.id,
|
||||||
|
sliceId: slice.id,
|
||||||
|
taskId: task.id,
|
||||||
|
title: task.title ?? task.id,
|
||||||
|
state: task.done
|
||||||
|
? "done"
|
||||||
|
: task.inProgress
|
||||||
|
? "in_progress"
|
||||||
|
: "todo",
|
||||||
|
updatedAt: task.updatedAt ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute summary
|
||||||
|
const states = tasks.map((t) => t.state);
|
||||||
|
const counts = {};
|
||||||
|
for (const s of states) counts[s] = (counts[s] ?? 0) + 1;
|
||||||
|
const total = tasks.length;
|
||||||
|
const terminal = (counts.done ?? 0) + (counts.failed ?? 0);
|
||||||
|
summary = {
|
||||||
|
counts: {
|
||||||
|
todo: counts.todo ?? 0,
|
||||||
|
in_progress: counts.in_progress ?? 0,
|
||||||
|
review: counts.review ?? 0,
|
||||||
|
done: counts.done ?? 0,
|
||||||
|
retrying: counts.retrying ?? 0,
|
||||||
|
failed: counts.failed ?? 0,
|
||||||
|
cancelled: counts.cancelled ?? 0,
|
||||||
|
},
|
||||||
|
total,
|
||||||
|
terminal,
|
||||||
|
progress: total > 0 ? Math.round((terminal / total) * 100) : 0,
|
||||||
|
isComplete: terminal === total && total > 0,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
ctx.ui.notify(`State derivation failed: ${err.message}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget estimate
|
||||||
|
let budget = null;
|
||||||
|
try {
|
||||||
|
const { getGlobalSFPreferences } = await import("../../preferences.js");
|
||||||
|
const prefs = getGlobalSFPreferences();
|
||||||
|
const ceiling = prefs.budget_ceiling ?? 0;
|
||||||
|
if (ceiling > 0) {
|
||||||
|
// Estimate spent from task costs
|
||||||
|
const spent = tasks.reduce((sum, t) => sum + (t.costUsd ?? 0), 0);
|
||||||
|
budget = {
|
||||||
|
spent,
|
||||||
|
ceiling,
|
||||||
|
remaining: ceiling - spent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Budget info is best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = buildTasksReport({ tasks, summary, mode, budget });
|
||||||
|
ctx.ui.notify(report, "info");
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// Pure heuristics + adaptive learning — no LLM calls. Sub-millisecond classification.
|
// Pure heuristics + adaptive learning — no LLM calls. Sub-millisecond classification.
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { modelModeToTier } from "./operating-model.js";
|
||||||
import { sfRoot } from "./paths.js";
|
import { sfRoot } from "./paths.js";
|
||||||
import { getAdaptiveTierAdjustment } from "./routing-history.js";
|
import { getAdaptiveTierAdjustment } from "./routing-history.js";
|
||||||
import { parseUnitId } from "./unit-id.js";
|
import { parseUnitId } from "./unit-id.js";
|
||||||
|
|
@ -37,6 +38,7 @@ const UNIT_TYPE_TIERS = {
|
||||||
* @param basePath Project base path (for reading task plans)
|
* @param basePath Project base path (for reading task plans)
|
||||||
* @param budgetPct Current budget usage as fraction (0.0-1.0+), or undefined if no budget
|
* @param budgetPct Current budget usage as fraction (0.0-1.0+), or undefined if no budget
|
||||||
* @param metadata Optional pre-parsed task metadata
|
* @param metadata Optional pre-parsed task metadata
|
||||||
|
* @param modelMode Optional model mode override (fast/smart/deep) from session
|
||||||
*/
|
*/
|
||||||
export function classifyUnitComplexity(
|
export function classifyUnitComplexity(
|
||||||
unitType,
|
unitType,
|
||||||
|
|
@ -44,6 +46,7 @@ export function classifyUnitComplexity(
|
||||||
basePath,
|
basePath,
|
||||||
budgetPct,
|
budgetPct,
|
||||||
metadata,
|
metadata,
|
||||||
|
modelMode,
|
||||||
) {
|
) {
|
||||||
// Hook units default to light
|
// Hook units default to light
|
||||||
if (unitType.startsWith("hook/")) {
|
if (unitType.startsWith("hook/")) {
|
||||||
|
|
@ -86,6 +89,20 @@ export function classifyUnitComplexity(
|
||||||
reason = `${reason} (adaptive: high failure rate at ${tier})`;
|
reason = `${reason} (adaptive: high failure rate at ${tier})`;
|
||||||
tier = adaptiveAdjustment;
|
tier = adaptiveAdjustment;
|
||||||
}
|
}
|
||||||
|
// Apply model mode floor: if user set /model-mode deep, don't go below heavy
|
||||||
|
// If user set /model-mode fast, cap at light
|
||||||
|
if (modelMode) {
|
||||||
|
const modeTier = modelModeToTier(modelMode);
|
||||||
|
const modeOrdinal = tierOrdinal(modeTier);
|
||||||
|
const currentOrdinal = tierOrdinal(tier);
|
||||||
|
if (modeOrdinal > currentOrdinal) {
|
||||||
|
tier = modeTier;
|
||||||
|
reason = `${reason} (model mode floor: ${modelMode})`;
|
||||||
|
} else if (modeOrdinal < currentOrdinal) {
|
||||||
|
tier = modeTier;
|
||||||
|
reason = `${reason} (model mode cap: ${modelMode})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
const result = {
|
const result = {
|
||||||
tier,
|
tier,
|
||||||
reason,
|
reason,
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"cmux",
|
"cmux",
|
||||||
"codebase",
|
"codebase",
|
||||||
"config",
|
"config",
|
||||||
|
"control",
|
||||||
"debug",
|
"debug",
|
||||||
"discuss",
|
"discuss",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
|
|
@ -60,7 +61,6 @@
|
||||||
"doctor",
|
"doctor",
|
||||||
"escalate",
|
"escalate",
|
||||||
"eval-review",
|
"eval-review",
|
||||||
"exit",
|
|
||||||
"extensions",
|
"extensions",
|
||||||
"extract-learnings",
|
"extract-learnings",
|
||||||
"fast",
|
"fast",
|
||||||
|
|
@ -78,6 +78,7 @@
|
||||||
"mcp",
|
"mcp",
|
||||||
"migrate",
|
"migrate",
|
||||||
"mode",
|
"mode",
|
||||||
|
"model-mode",
|
||||||
"new-milestone",
|
"new-milestone",
|
||||||
"next",
|
"next",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
|
@ -93,6 +94,7 @@
|
||||||
"remote",
|
"remote",
|
||||||
"reset-slice",
|
"reset-slice",
|
||||||
"rethink",
|
"rethink",
|
||||||
|
"repair",
|
||||||
"run-hook",
|
"run-hook",
|
||||||
"scaffold",
|
"scaffold",
|
||||||
"scan",
|
"scan",
|
||||||
|
|
@ -102,14 +104,17 @@
|
||||||
"ship",
|
"ship",
|
||||||
"show-config",
|
"show-config",
|
||||||
"skill-health",
|
"skill-health",
|
||||||
|
"skills",
|
||||||
"skip",
|
"skip",
|
||||||
"solver-eval",
|
"solver-eval",
|
||||||
"start",
|
"start",
|
||||||
"status",
|
"status",
|
||||||
"steer",
|
"steer",
|
||||||
|
"tasks",
|
||||||
"templates",
|
"templates",
|
||||||
"todo",
|
"todo",
|
||||||
"triage",
|
"triage",
|
||||||
|
"trust",
|
||||||
"undo",
|
"undo",
|
||||||
"undo-task",
|
"undo-task",
|
||||||
"unpark",
|
"unpark",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* sf-learning: outcome-recorder + outcome-aggregator tests
|
* sf-learning: outcome-recorder + outcome-aggregator tests
|
||||||
*
|
*
|
||||||
* Uses node:test with a minimal in-memory fake `db` that mimics the
|
* Uses Vitest with a minimal in-memory fake `db` that mimics the
|
||||||
* node:sqlite DatabaseSync surface (`prepare(sql).run/get/all`, `exec`,
|
* node:sqlite DatabaseSync surface (`prepare(sql).run/get/all`, `exec`,
|
||||||
* `transaction`). The fake parses just enough SQL to verify the
|
* `transaction`). The fake parses just enough SQL to verify the
|
||||||
* insert and aggregate semantics without spinning up real SQLite.
|
* insert and aggregate semantics without spinning up real SQLite.
|
||||||
|
|
@ -41,7 +41,10 @@ const INSERT_COLUMNS = [
|
||||||
"recorded_at",
|
"recorded_at",
|
||||||
];
|
];
|
||||||
|
|
||||||
function createFakeDb({ includeTransaction = true, throwOnPrepare = false } = {}) {
|
function createFakeDb({
|
||||||
|
includeTransaction = true,
|
||||||
|
throwOnPrepare = false,
|
||||||
|
} = {}) {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
const execSql = [];
|
const execSql = [];
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,7 @@ export const PERMISSION_PROFILES = Object.freeze([
|
||||||
"unrestricted",
|
"unrestricted",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const MODEL_MODES = Object.freeze([
|
export const MODEL_MODES = Object.freeze(["fast", "smart", "deep"]);
|
||||||
"fast",
|
|
||||||
"smart",
|
|
||||||
"deep",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true for a canonical SF work mode.
|
* Returns true for a canonical SF work mode.
|
||||||
|
|
@ -166,12 +162,48 @@ export function defaultModelModeForWorkMode(workMode) {
|
||||||
case "build":
|
case "build":
|
||||||
case "repair":
|
case "repair":
|
||||||
return "smart";
|
return "smart";
|
||||||
case "chat":
|
|
||||||
default:
|
default:
|
||||||
return "fast";
|
return "fast";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map model mode to complexity tier for routing.
|
||||||
|
*
|
||||||
|
* Purpose: bridge the user-facing modelMode vocabulary (fast/smart/deep)
|
||||||
|
* to the internal complexity tier system (light/standard/heavy).
|
||||||
|
*
|
||||||
|
* Consumer: complexity classifier, model router, /rate feedback.
|
||||||
|
*/
|
||||||
|
export function modelModeToTier(modelMode) {
|
||||||
|
switch (resolveModelMode(modelMode)) {
|
||||||
|
case "fast":
|
||||||
|
return "light";
|
||||||
|
case "deep":
|
||||||
|
return "heavy";
|
||||||
|
default:
|
||||||
|
return "standard";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map complexity tier to model mode.
|
||||||
|
*
|
||||||
|
* Purpose: display the current tier in user-facing modelMode vocabulary.
|
||||||
|
*
|
||||||
|
* Consumer: TUI badges, status output.
|
||||||
|
*/
|
||||||
|
export function tierToModelMode(tier) {
|
||||||
|
switch (tier) {
|
||||||
|
case "light":
|
||||||
|
return "fast";
|
||||||
|
case "heavy":
|
||||||
|
return "deep";
|
||||||
|
default:
|
||||||
|
return "smart";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a canonical mode state object.
|
* Build a canonical mode state object.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ export async function analyzeParallelEligibility(basePath) {
|
||||||
"All dependencies satisfied. NOTE: file overlap with another eligible milestone.";
|
"All dependencies satisfied. NOTE: file overlap with another eligible milestone.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { eligible, ineligible, fileOverlaps };
|
return { eligible, ineligible, fileOverlaps, fileSets };
|
||||||
}
|
}
|
||||||
// ─── Formatting ──────────────────────────────────────────────────────────────
|
// ─── Formatting ──────────────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
152
src/resources/extensions/sf/parallel-intent.js
Normal file
152
src/resources/extensions/sf/parallel-intent.js
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
/**
|
||||||
|
* Parallel Worker Intent/Claim Registry
|
||||||
|
*
|
||||||
|
* Purpose: before editing files, parallel workers declare intent so the
|
||||||
|
* coordinator can detect conflicts without waiting for git merge failures.
|
||||||
|
* This is SF's implementation of Wit-style symbol-level coordination for
|
||||||
|
* parallel milestone workers.
|
||||||
|
*
|
||||||
|
* Consumer: parallel-orchestrator.js before dispatching overlapping milestones.
|
||||||
|
*/
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { getDatabase, getDbPath, openDatabase } from "./sf-db.js";
|
||||||
|
import { UokCoordinationStore } from "./uok/coordination-store.js";
|
||||||
|
import { logWarning } from "./workflow-logger.js";
|
||||||
|
|
||||||
|
const INTENT_KEY_PREFIX = "parallel:intent:";
|
||||||
|
const INTENT_STREAM = "parallel:intents";
|
||||||
|
|
||||||
|
function projectDbPath(basePath) {
|
||||||
|
const sfDir = join(basePath, ".sf");
|
||||||
|
mkdirSync(sfDir, { recursive: true });
|
||||||
|
return join(sfDir, "sf.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStore(basePath) {
|
||||||
|
const dbPath = projectDbPath(basePath);
|
||||||
|
if (!getDatabase() || getDbPath() !== dbPath) {
|
||||||
|
openDatabase(dbPath);
|
||||||
|
}
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!db) throw new Error("SF database is not open");
|
||||||
|
return new UokCoordinationStore(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
function intentKey(milestoneId) {
|
||||||
|
return `${INTENT_KEY_PREFIX}${milestoneId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFiles(files) {
|
||||||
|
return files.map((f) => f.replace(/^\/+/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare editing intent before making changes.
|
||||||
|
*
|
||||||
|
* Purpose: let other workers know which files this worker plans to modify,
|
||||||
|
* so conflicts can be detected proactively.
|
||||||
|
*
|
||||||
|
* @param {string} basePath — project root
|
||||||
|
* @param {string} milestoneId — worker's milestone
|
||||||
|
* @param {string[]} files — relative file paths worker intends to edit
|
||||||
|
* @param {object} opts — optional: symbolRanges (for fine-grained claims)
|
||||||
|
*/
|
||||||
|
export function declareIntent(basePath, milestoneId, files, opts = {}) {
|
||||||
|
try {
|
||||||
|
const store = getStore(basePath);
|
||||||
|
const record = {
|
||||||
|
milestoneId,
|
||||||
|
files: normalizeFiles(files),
|
||||||
|
symbolRanges: opts.symbolRanges ?? [],
|
||||||
|
declaredAt: new Date().toISOString(),
|
||||||
|
status: "claimed",
|
||||||
|
};
|
||||||
|
store.set(intentKey(milestoneId), record);
|
||||||
|
store.xadd(INTENT_STREAM, "intent-claimed", record);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
logWarning(
|
||||||
|
"parallel-intent",
|
||||||
|
`declareIntent failed for ${milestoneId}: ${err.message}`,
|
||||||
|
);
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release intent claim when editing is complete or abandoned.
|
||||||
|
*/
|
||||||
|
export function releaseIntent(basePath, milestoneId) {
|
||||||
|
try {
|
||||||
|
const store = getStore(basePath);
|
||||||
|
const record = store.get(intentKey(milestoneId));
|
||||||
|
if (record) {
|
||||||
|
record.status = "released";
|
||||||
|
record.releasedAt = new Date().toISOString();
|
||||||
|
store.set(intentKey(milestoneId), record);
|
||||||
|
store.xadd(INTENT_STREAM, "intent-released", record);
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
logWarning(
|
||||||
|
"parallel-intent",
|
||||||
|
`releaseIntent failed for ${milestoneId}: ${err.message}`,
|
||||||
|
);
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any active worker has claimed overlapping files.
|
||||||
|
*
|
||||||
|
* @returns {Array<{milestoneId: string, files: string[]}>} conflicts
|
||||||
|
*/
|
||||||
|
export function checkIntentConflicts(basePath, milestoneId, files) {
|
||||||
|
const conflicts = [];
|
||||||
|
const normalized = normalizeFiles(files);
|
||||||
|
try {
|
||||||
|
for (const record of getActiveIntents(basePath)) {
|
||||||
|
if (record.milestoneId === milestoneId) continue;
|
||||||
|
const overlap = normalized.filter((f) => record.files.includes(f));
|
||||||
|
if (overlap.length > 0) {
|
||||||
|
conflicts.push({ milestoneId: record.milestoneId, files: overlap });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logWarning(
|
||||||
|
"parallel-intent",
|
||||||
|
`checkIntentConflicts failed: ${err.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active intent claims.
|
||||||
|
*/
|
||||||
|
export function getActiveIntents(basePath) {
|
||||||
|
try {
|
||||||
|
return getStore(basePath)
|
||||||
|
.entries(INTENT_KEY_PREFIX)
|
||||||
|
.map((entry) => entry.value)
|
||||||
|
.filter((record) => record?.status === "claimed");
|
||||||
|
} catch (err) {
|
||||||
|
logWarning("parallel-intent", `getActiveIntents failed: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all intent records (used on orchestrator shutdown).
|
||||||
|
*/
|
||||||
|
export function clearAllIntents(basePath) {
|
||||||
|
try {
|
||||||
|
const store = getStore(basePath);
|
||||||
|
for (const entry of store.entries(INTENT_KEY_PREFIX)) {
|
||||||
|
store.delete(entry.key);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logWarning("parallel-intent", `clearAllIntents failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,10 @@ import { formattedShortcutPair } from "./shortcut-defs.js";
|
||||||
function queryRows(dbPath, sql, params = []) {
|
function queryRows(dbPath, sql, params = []) {
|
||||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||||
try {
|
try {
|
||||||
return db.prepare(sql).all(...params).map((row) => ({ ...row }));
|
return db
|
||||||
|
.prepare(sql)
|
||||||
|
.all(...params)
|
||||||
|
.map((row) => ({ ...row }));
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
|
|
@ -156,8 +159,9 @@ function queryRecentCompletions(basePath, mid) {
|
||||||
ORDER BY completed_at DESC
|
ORDER BY completed_at DESC
|
||||||
LIMIT 5`,
|
LIMIT 5`,
|
||||||
[mid],
|
[mid],
|
||||||
).map((row) =>
|
).map(
|
||||||
`✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`,
|
(row) =>
|
||||||
|
`✓ ${mid}/${row.sliceId}/${row.taskId}${row.oneLiner ? ": " + row.oneLiner : ""}`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ import { readIntegrationBranch } from "./git-service.js";
|
||||||
import { emitJournalEvent } from "./journal.js";
|
import { emitJournalEvent } from "./journal.js";
|
||||||
import { nativeBranchExists } from "./native-git-bridge.js";
|
import { nativeBranchExists } from "./native-git-bridge.js";
|
||||||
import { analyzeParallelEligibility } from "./parallel-eligibility.js";
|
import { analyzeParallelEligibility } from "./parallel-eligibility.js";
|
||||||
|
import {
|
||||||
|
checkIntentConflicts,
|
||||||
|
clearAllIntents,
|
||||||
|
declareIntent,
|
||||||
|
releaseIntent,
|
||||||
|
} from "./parallel-intent.js";
|
||||||
import { sfRoot } from "./paths.js";
|
import { sfRoot } from "./paths.js";
|
||||||
import { resolveParallelConfig } from "./preferences.js";
|
import { resolveParallelConfig } from "./preferences.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -362,10 +368,12 @@ export async function startParallel(basePath, milestoneIds, prefs) {
|
||||||
const started = [];
|
const started = [];
|
||||||
const errors = [];
|
const errors = [];
|
||||||
let filteredMilestoneIds = milestoneIds;
|
let filteredMilestoneIds = milestoneIds;
|
||||||
|
let intentFilesByMilestone = new Map();
|
||||||
if (uokFlags.executionGraph && milestoneIds.length > 1) {
|
if (uokFlags.executionGraph && milestoneIds.length > 1) {
|
||||||
try {
|
try {
|
||||||
const requestedIds = new Set(milestoneIds);
|
const requestedIds = new Set(milestoneIds);
|
||||||
const candidates = await analyzeParallelEligibility(basePath);
|
const candidates = await analyzeParallelEligibility(basePath);
|
||||||
|
intentFilesByMilestone = candidates.fileSets ?? intentFilesByMilestone;
|
||||||
const overlapPairs = new Set();
|
const overlapPairs = new Set();
|
||||||
for (const overlap of candidates.fileOverlaps) {
|
for (const overlap of candidates.fileOverlaps) {
|
||||||
if (!requestedIds.has(overlap.mid1) || !requestedIds.has(overlap.mid2))
|
if (!requestedIds.has(overlap.mid1) || !requestedIds.has(overlap.mid2))
|
||||||
|
|
@ -398,8 +406,23 @@ export async function startParallel(basePath, milestoneIds, prefs) {
|
||||||
// Cap to max_workers
|
// Cap to max_workers
|
||||||
const toStart = filteredMilestoneIds.slice(0, config.max_workers);
|
const toStart = filteredMilestoneIds.slice(0, config.max_workers);
|
||||||
for (const mid of toStart) {
|
for (const mid of toStart) {
|
||||||
|
const intendedFiles = intentFilesByMilestone.get(mid) ?? [];
|
||||||
|
const conflicts = checkIntentConflicts(basePath, mid, intendedFiles);
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
errors.push({
|
||||||
|
mid,
|
||||||
|
error: `File intent conflict with ${conflicts.map((c) => c.milestoneId).join(", ")}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const intent = declareIntent(basePath, mid, intendedFiles);
|
||||||
|
if (!intent.ok) {
|
||||||
|
errors.push({ mid, error: intent.error ?? "Could not declare intent" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Check budget ceiling before each spawn
|
// Check budget ceiling before each spawn
|
||||||
if (isBudgetExceeded()) {
|
if (isBudgetExceeded()) {
|
||||||
|
releaseIntent(basePath, mid);
|
||||||
errors.push({
|
errors.push({
|
||||||
mid,
|
mid,
|
||||||
error: `Budget ceiling ($${config.budget_ceiling}) reached — skipping`,
|
error: `Budget ceiling ($${config.budget_ceiling}) reached — skipping`,
|
||||||
|
|
@ -448,6 +471,7 @@ export async function startParallel(basePath, milestoneIds, prefs) {
|
||||||
});
|
});
|
||||||
started.push(mid);
|
started.push(mid);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
releaseIntent(basePath, mid);
|
||||||
const message = getErrorMessage(err);
|
const message = getErrorMessage(err);
|
||||||
errors.push({ mid, error: message });
|
errors.push({ mid, error: message });
|
||||||
}
|
}
|
||||||
|
|
@ -672,6 +696,7 @@ export function spawnWorker(basePath, milestoneId) {
|
||||||
}
|
}
|
||||||
sibling.state = "cancelled";
|
sibling.state = "cancelled";
|
||||||
sibling.process = null;
|
sibling.process = null;
|
||||||
|
releaseIntent(basePath, siblingId);
|
||||||
// Update session status so dashboard reflects the cancellation
|
// Update session status so dashboard reflects the cancellation
|
||||||
writeSessionStatus(basePath, {
|
writeSessionStatus(basePath, {
|
||||||
milestoneId: siblingId,
|
milestoneId: siblingId,
|
||||||
|
|
@ -711,6 +736,7 @@ export function spawnWorker(basePath, milestoneId) {
|
||||||
startedAt: w.startedAt,
|
startedAt: w.startedAt,
|
||||||
worktreePath: w.worktreePath,
|
worktreePath: w.worktreePath,
|
||||||
});
|
});
|
||||||
|
releaseIntent(basePath, milestoneId);
|
||||||
persistState(basePath);
|
persistState(basePath);
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -876,6 +902,8 @@ export async function stopParallel(basePath, milestoneId) {
|
||||||
if (!milestoneId) {
|
if (!milestoneId) {
|
||||||
state.active = false;
|
state.active = false;
|
||||||
}
|
}
|
||||||
|
// Clear all intent claims on shutdown
|
||||||
|
clearAllIntents(basePath);
|
||||||
// Persist final state and clean up state file
|
// Persist final state and clean up state file
|
||||||
removeStateFile(basePath);
|
removeStateFile(basePath);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,23 @@ export function dispatchSelfFeedbackInlineFixIfNeeded(basePath, ctx, pi) {
|
||||||
);
|
);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
// Auto-transition to repair work mode when high/critical self-feedback detected
|
||||||
|
try {
|
||||||
|
const { getAutoSession } = require("../auto/session.js");
|
||||||
|
const session = getAutoSession();
|
||||||
|
if (session && session.workMode !== "repair") {
|
||||||
|
const transition = session.setMode({
|
||||||
|
workMode: "repair",
|
||||||
|
reason: "self-feedback-drain",
|
||||||
|
});
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Auto-transition: ${transition.from.workMode} → ${transition.to.workMode} (high/critical self-feedback detected)`,
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Mode transition is best-effort
|
||||||
|
}
|
||||||
writeClaim(basePath, ids);
|
writeClaim(basePath, ids);
|
||||||
const prompt = buildInlineFixPrompt(candidates);
|
const prompt = buildInlineFixPrompt(candidates);
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
|
|
|
||||||
|
|
@ -124,8 +124,7 @@ export function extractTrace(entries) {
|
||||||
isError &&
|
isError &&
|
||||||
(pending.name === "bash" || pending.name === "bg_shell")
|
(pending.name === "bash" || pending.name === "bg_shell")
|
||||||
) {
|
) {
|
||||||
const lastCmd = findLast(
|
const lastCmd = commandsRun.findLast(
|
||||||
commandsRun,
|
|
||||||
(c) => c.command === String(pending.input.command),
|
(c) => c.command === String(pending.input.command),
|
||||||
);
|
);
|
||||||
if (lastCmd) lastCmd.failed = true;
|
if (lastCmd) lastCmd.failed = true;
|
||||||
|
|
@ -482,10 +481,3 @@ function redactInput(_name, input) {
|
||||||
}
|
}
|
||||||
return safe;
|
return safe;
|
||||||
}
|
}
|
||||||
/** Array.findLast polyfill for older Node versions */
|
|
||||||
function findLast(arr, predicate) {
|
|
||||||
for (let i = arr.length - 1; i >= 0; i--) {
|
|
||||||
if (predicate(arr[i])) return arr[i];
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ function openRawDb(path) {
|
||||||
loadProvider();
|
loadProvider();
|
||||||
return new DatabaseSync(path);
|
return new DatabaseSync(path);
|
||||||
}
|
}
|
||||||
const SCHEMA_VERSION = 42;
|
const SCHEMA_VERSION = 43;
|
||||||
function indexExists(db, name) {
|
function indexExists(db, name) {
|
||||||
return !!db
|
return !!db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
@ -2221,6 +2221,29 @@ function migrateSchema(db) {
|
||||||
":applied_at": new Date().toISOString(),
|
":applied_at": new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (currentVersion < 43) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS session_mode_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
work_mode TEXT NOT NULL DEFAULT 'chat',
|
||||||
|
run_control TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
permission_profile TEXT NOT NULL DEFAULT 'restricted',
|
||||||
|
model_mode TEXT NOT NULL DEFAULT 'smart',
|
||||||
|
surface TEXT NOT NULL DEFAULT 'tui',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT ''
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
INSERT OR IGNORE INTO session_mode_state (id, work_mode, run_control, permission_profile, model_mode, surface, updated_at)
|
||||||
|
VALUES (1, 'chat', 'manual', 'restricted', 'smart', 'tui', datetime('now'))
|
||||||
|
`);
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
|
||||||
|
).run({
|
||||||
|
":version": 43,
|
||||||
|
":applied_at": new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
db.exec("COMMIT");
|
db.exec("COMMIT");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
db.exec("ROLLBACK");
|
db.exec("ROLLBACK");
|
||||||
|
|
@ -2542,6 +2565,67 @@ export function getDbOwnerPid() {
|
||||||
export function getDbPath() {
|
export function getDbPath() {
|
||||||
return currentPath;
|
return currentPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load persisted session mode state from DB.
|
||||||
|
*
|
||||||
|
* Purpose: restore mode state across session restarts.
|
||||||
|
*
|
||||||
|
* Consumer: AutoSession initialization.
|
||||||
|
*/
|
||||||
|
export function loadSessionModeState() {
|
||||||
|
if (!currentDb) return null;
|
||||||
|
try {
|
||||||
|
const row = currentDb
|
||||||
|
.prepare("SELECT * FROM session_mode_state WHERE id = 1")
|
||||||
|
.get();
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
workMode: row["work_mode"] ?? "chat",
|
||||||
|
runControl: row["run_control"] ?? "manual",
|
||||||
|
permissionProfile: row["permission_profile"] ?? "restricted",
|
||||||
|
modelMode: row["model_mode"] ?? "smart",
|
||||||
|
surface: row["surface"] ?? "tui",
|
||||||
|
updatedAt: row["updated_at"] ?? null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the current session mode into the project database.
|
||||||
|
*
|
||||||
|
* Purpose: keep work mode, run control, permission profile, and model mode
|
||||||
|
* stable across reload/resume without letting command handlers write SQL.
|
||||||
|
*
|
||||||
|
* Consumer: AutoSession.setMode() after validated mode transitions.
|
||||||
|
*/
|
||||||
|
export function saveSessionModeState(mode) {
|
||||||
|
if (!currentDb) return false;
|
||||||
|
currentDb
|
||||||
|
.prepare(`
|
||||||
|
INSERT INTO session_mode_state (id, work_mode, run_control, permission_profile, model_mode, surface, updated_at)
|
||||||
|
VALUES (1, :workMode, :runControl, :permissionProfile, :modelMode, :surface, :updatedAt)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
work_mode = excluded.work_mode,
|
||||||
|
run_control = excluded.run_control,
|
||||||
|
permission_profile = excluded.permission_profile,
|
||||||
|
model_mode = excluded.model_mode,
|
||||||
|
surface = excluded.surface,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`)
|
||||||
|
.run({
|
||||||
|
":workMode": mode.workMode,
|
||||||
|
":runControl": mode.runControl,
|
||||||
|
":permissionProfile": mode.permissionProfile,
|
||||||
|
":modelMode": mode.modelMode,
|
||||||
|
":surface": mode.surface ?? "tui",
|
||||||
|
":updatedAt": mode.updatedAt ?? new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function _getAdapter() {
|
export function _getAdapter() {
|
||||||
return currentDb;
|
return currentDb;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
263
src/resources/extensions/sf/skills/auto-create.js
Normal file
263
src/resources/extensions/sf/skills/auto-create.js
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
/**
|
||||||
|
* Auto skill creation flow
|
||||||
|
*
|
||||||
|
* Purpose: detect repeated repo-specific patterns from journal/activity logs
|
||||||
|
* and propose or auto-generate skills when policy allows.
|
||||||
|
*
|
||||||
|
* Consumer: session startup, turn end, and explicit /skills --auto-create.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { getDatabase, getDbPath, openDatabase } from "../sf-db.js";
|
||||||
|
import { UokCoordinationStore } from "../uok/coordination-store.js";
|
||||||
|
import { codeSkillTemplate, knowledgeSkillTemplate } from "./templates.js";
|
||||||
|
|
||||||
|
const AUTO_SKILL_MIN_OCCURRENCES = 3;
|
||||||
|
const AUTO_SKILL_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 1 day
|
||||||
|
const AUTO_SKILL_COOLDOWN_KEY = "skills:auto-create:last-run";
|
||||||
|
|
||||||
|
function projectDbPath(basePath) {
|
||||||
|
const sfDir = join(basePath, ".sf");
|
||||||
|
mkdirSync(sfDir, { recursive: true });
|
||||||
|
return join(sfDir, "sf.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCoordinationStore(basePath) {
|
||||||
|
const dbPath = projectDbPath(basePath);
|
||||||
|
if (!getDatabase() || getDbPath() !== dbPath) {
|
||||||
|
openDatabase(dbPath);
|
||||||
|
}
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!db) throw new Error("SF database is not open");
|
||||||
|
return new UokCoordinationStore(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan activity logs for repeated patterns that could become skills.
|
||||||
|
*
|
||||||
|
* Purpose: identify recurring file edits, commands, or verification paths
|
||||||
|
* that suggest a reusable capability.
|
||||||
|
*
|
||||||
|
* Consumer: auto skill creation on session startup or turn end.
|
||||||
|
*/
|
||||||
|
export function detectSkillCandidates(basePath) {
|
||||||
|
const candidates = [];
|
||||||
|
|
||||||
|
// Pattern 1: Repeated file edits on same files
|
||||||
|
const fileEdits = scanFileEditPatterns(basePath);
|
||||||
|
for (const [file, count] of fileEdits) {
|
||||||
|
if (count >= AUTO_SKILL_MIN_OCCURRENCES) {
|
||||||
|
candidates.push({
|
||||||
|
type: "file-pattern",
|
||||||
|
name: `forge-${sanitizeSkillName(file)}`,
|
||||||
|
description: `Recurring edits to ${file} (${count} times)`,
|
||||||
|
triggerFile: file,
|
||||||
|
occurrences: count,
|
||||||
|
template: "code",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: Repeated bash command patterns
|
||||||
|
const commandPatterns = scanCommandPatterns(basePath);
|
||||||
|
for (const [pattern, count] of commandPatterns) {
|
||||||
|
if (count >= AUTO_SKILL_MIN_OCCURRENCES) {
|
||||||
|
candidates.push({
|
||||||
|
type: "command-pattern",
|
||||||
|
name: `forge-${sanitizeSkillName(pattern)}`,
|
||||||
|
description: `Recurring command pattern: ${pattern} (${count} times)`,
|
||||||
|
triggerCommand: pattern,
|
||||||
|
occurrences: count,
|
||||||
|
template: "code",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 3: Repeated verification/check patterns
|
||||||
|
const verifyPatterns = scanVerificationPatterns(basePath);
|
||||||
|
for (const [pattern, count] of verifyPatterns) {
|
||||||
|
if (count >= AUTO_SKILL_MIN_OCCURRENCES) {
|
||||||
|
candidates.push({
|
||||||
|
type: "verification-pattern",
|
||||||
|
name: `forge-verify-${sanitizeSkillName(pattern)}`,
|
||||||
|
description: `Recurring verification: ${pattern} (${count} times)`,
|
||||||
|
triggerVerify: pattern,
|
||||||
|
occurrences: count,
|
||||||
|
template: "review",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if auto-skill creation is allowed by policy.
|
||||||
|
*/
|
||||||
|
export function isAutoSkillCreationAllowed(basePath) {
|
||||||
|
const data = getCoordinationStore(basePath).get(AUTO_SKILL_COOLDOWN_KEY);
|
||||||
|
const lastRun = Number(data?.lastRunMs ?? 0);
|
||||||
|
if (lastRun > 0 && Date.now() - lastRun < AUTO_SKILL_COOLDOWN_MS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a skill from a detected candidate.
|
||||||
|
*/
|
||||||
|
export function generateSkill(candidate, basePath) {
|
||||||
|
const skillDir = join(basePath, ".agents", "skills", candidate.name);
|
||||||
|
if (existsSync(skillDir)) {
|
||||||
|
return { created: false, reason: "Skill already exists" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let content;
|
||||||
|
switch (candidate.template) {
|
||||||
|
case "review":
|
||||||
|
content = knowledgeSkillTemplate(candidate.name, candidate.description);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
content = codeSkillTemplate(candidate.name, candidate.description);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auto-generated marker
|
||||||
|
content += `\n\n## Auto-Generated Evidence\n\n`;
|
||||||
|
content += `- **Type:** ${candidate.type}\n`;
|
||||||
|
content += `- **Occurrences:** ${candidate.occurrences}\n`;
|
||||||
|
content += `- **Detected:** ${new Date().toISOString()}\n`;
|
||||||
|
if (candidate.triggerFile) {
|
||||||
|
content += `- **Trigger file:** ${candidate.triggerFile}\n`;
|
||||||
|
}
|
||||||
|
if (candidate.triggerCommand) {
|
||||||
|
content += `- **Trigger command:** ${candidate.triggerCommand}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(skillDir, { recursive: true });
|
||||||
|
writeFileSync(join(skillDir, "SKILL.md"), content, "utf-8");
|
||||||
|
|
||||||
|
// Update cooldown
|
||||||
|
const lastRunMs = Date.now();
|
||||||
|
getCoordinationStore(basePath).set(AUTO_SKILL_COOLDOWN_KEY, {
|
||||||
|
lastRun: new Date(lastRunMs).toISOString(),
|
||||||
|
lastRunMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { created: true, path: skillDir, name: candidate.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pattern scanners (best-effort heuristics) ────────────────────────────
|
||||||
|
|
||||||
|
function scanFileEditPatterns(basePath) {
|
||||||
|
const edits = new Map();
|
||||||
|
try {
|
||||||
|
const activityDir = join(basePath, ".sf", "activity");
|
||||||
|
if (!existsSync(activityDir)) return edits;
|
||||||
|
const files = readdirSync(activityDir).filter((f) => f.endsWith(".jsonl"));
|
||||||
|
for (const file of files.slice(-7)) {
|
||||||
|
const raw = readFileSync(join(activityDir, file), "utf-8");
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.toolName === "write" || entry.toolName === "edit") {
|
||||||
|
const path = entry.input?.path ?? "";
|
||||||
|
if (path) {
|
||||||
|
const basename = path.split("/").pop() ?? path;
|
||||||
|
edits.set(basename, (edits.get(basename) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort scan
|
||||||
|
}
|
||||||
|
return edits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanCommandPatterns(basePath) {
|
||||||
|
const commands = new Map();
|
||||||
|
try {
|
||||||
|
const activityDir = join(basePath, ".sf", "activity");
|
||||||
|
if (!existsSync(activityDir)) return commands;
|
||||||
|
const files = readdirSync(activityDir).filter((f) => f.endsWith(".jsonl"));
|
||||||
|
for (const file of files.slice(-7)) {
|
||||||
|
const raw = readFileSync(join(activityDir, file), "utf-8");
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.toolName === "bash") {
|
||||||
|
const cmd = entry.input?.command ?? "";
|
||||||
|
// Extract command name (first token)
|
||||||
|
const name = cmd.trim().split(/\s+/)[0] ?? "";
|
||||||
|
if (name && !name.startsWith("cd") && !name.startsWith("ls")) {
|
||||||
|
commands.set(name, (commands.get(name) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort scan
|
||||||
|
}
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanVerificationPatterns(basePath) {
|
||||||
|
const patterns = new Map();
|
||||||
|
try {
|
||||||
|
const activityDir = join(basePath, ".sf", "activity");
|
||||||
|
if (!existsSync(activityDir)) return patterns;
|
||||||
|
const files = readdirSync(activityDir).filter((f) => f.endsWith(".jsonl"));
|
||||||
|
for (const file of files.slice(-7)) {
|
||||||
|
const raw = readFileSync(join(activityDir, file), "utf-8");
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.toolName === "bash") {
|
||||||
|
const cmd = entry.input?.command ?? "";
|
||||||
|
// Look for verification patterns: test, lint, check, verify
|
||||||
|
if (
|
||||||
|
/\b(npm\s+run\s+(test|lint|check|verify)|npm\s+(test|lint|audit)|vitest|jest|eslint|tsc\s+--noEmit)\b/.test(
|
||||||
|
cmd,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const pattern =
|
||||||
|
cmd.match(
|
||||||
|
/\b(npm\s+run\s+\w+|npm\s+\w+|vitest|jest|eslint|tsc)\b/,
|
||||||
|
)?.[0] ?? cmd;
|
||||||
|
patterns.set(pattern, (patterns.get(pattern) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort scan
|
||||||
|
}
|
||||||
|
return patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSkillName(name) {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.slice(0, 40);
|
||||||
|
}
|
||||||
73
src/resources/extensions/sf/skills/directory.js
Normal file
73
src/resources/extensions/sf/skills/directory.js
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
/**
|
||||||
|
* .agents/skills/ directory management
|
||||||
|
*
|
||||||
|
* Purpose: discover, validate, and load skills from repo-local
|
||||||
|
* `.agents/skills/` and user-level skill directories.
|
||||||
|
*
|
||||||
|
* Consumer: skill loader, auto-skill creation, and model context assembly.
|
||||||
|
*/
|
||||||
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const SKILL_FILENAME = "SKILL.md";
|
||||||
|
const USER_SKILL_DIR = join(process.env.HOME ?? "", ".sf", "skills");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all skill directories under a base path.
|
||||||
|
*/
|
||||||
|
export function discoverSkillDirs(basePath) {
|
||||||
|
const skillRoot = join(basePath, ".agents", "skills");
|
||||||
|
if (!existsSync(skillRoot)) return [];
|
||||||
|
|
||||||
|
const dirs = [];
|
||||||
|
for (const entry of readdirSync(skillRoot)) {
|
||||||
|
const full = join(skillRoot, entry);
|
||||||
|
if (statSync(full).isDirectory()) {
|
||||||
|
const skillFile = join(full, SKILL_FILENAME);
|
||||||
|
if (existsSync(skillFile)) {
|
||||||
|
dirs.push({ name: entry, path: full, skillFile });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover skills from all sources: project, user, and built-in.
|
||||||
|
*/
|
||||||
|
export function discoverAllSkills(projectPath) {
|
||||||
|
const sources = [];
|
||||||
|
|
||||||
|
// Project skills
|
||||||
|
if (projectPath) {
|
||||||
|
const projectSkills = discoverSkillDirs(projectPath);
|
||||||
|
for (const s of projectSkills) {
|
||||||
|
sources.push({ ...s, source: "project" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User skills
|
||||||
|
if (existsSync(USER_SKILL_DIR)) {
|
||||||
|
const userSkills = discoverSkillDirs(USER_SKILL_DIR);
|
||||||
|
for (const s of userSkills) {
|
||||||
|
// User skills have a different root structure
|
||||||
|
s.path = s.path.replace(/\.agents\/skills$/, "");
|
||||||
|
sources.push({ ...s, source: "user" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the raw content of a skill file.
|
||||||
|
*/
|
||||||
|
export function readSkillFile(skillDir) {
|
||||||
|
const path = join(skillDir, SKILL_FILENAME);
|
||||||
|
if (!existsSync(path)) return null;
|
||||||
|
try {
|
||||||
|
return readFileSync(path, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/resources/extensions/sf/skills/eval-harness.js
Normal file
181
src/resources/extensions/sf/skills/eval-harness.js
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
/**
|
||||||
|
* Skill Eval Harness — foundation for grading auto-created skills.
|
||||||
|
*
|
||||||
|
* Purpose: provide deterministic eval cases for skills so auto-generated
|
||||||
|
* capabilities can be tested before being committed. Follows the
|
||||||
|
* skill-optimizer pattern: cases + graders + workspaces.
|
||||||
|
*
|
||||||
|
* Consumer: /skills --eval, auto-skill creation acceptance gate.
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an eval case directory for a skill.
|
||||||
|
*
|
||||||
|
* @param {string} skillPath — path to skill directory
|
||||||
|
* @param {string} caseName — unique case identifier
|
||||||
|
* @param {object} caseDef — { task: string, grader: string, hidden?: object }
|
||||||
|
*/
|
||||||
|
export function createEvalCase(skillPath, caseName, caseDef) {
|
||||||
|
const evalDir = join(skillPath, "evals", caseName);
|
||||||
|
mkdirSync(evalDir, { recursive: true });
|
||||||
|
mkdirSync(join(evalDir, "work"), { recursive: true });
|
||||||
|
|
||||||
|
writeFileSync(join(evalDir, "task.md"), caseDef.task, "utf-8");
|
||||||
|
writeFileSync(join(evalDir, "grader.js"), caseDef.grader, "utf-8");
|
||||||
|
if (caseDef.hidden) {
|
||||||
|
mkdirSync(join(evalDir, "hidden"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(evalDir, "hidden", "reference.json"),
|
||||||
|
JSON.stringify(caseDef.hidden, null, 2),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { path: evalDir, caseName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a grader against a skill's work directory.
|
||||||
|
*
|
||||||
|
* @param {string} evalDir — path to eval case directory
|
||||||
|
* @param {object} ctx — { ui } for notifications
|
||||||
|
* @returns {object} { passed: boolean, score: number, details: string[] }
|
||||||
|
*/
|
||||||
|
export async function runGrader(evalDir, _ctx) {
|
||||||
|
const graderPath = join(evalDir, "grader.js");
|
||||||
|
const workDir = join(evalDir, "work");
|
||||||
|
if (!existsSync(graderPath)) {
|
||||||
|
return { passed: false, score: 0, details: ["Grader not found"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { grade } = await import(graderPath);
|
||||||
|
if (typeof grade !== "function") {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
score: 0,
|
||||||
|
details: ["Grader must export a grade() function"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const result = await grade(workDir);
|
||||||
|
return {
|
||||||
|
passed: Boolean(result.passed),
|
||||||
|
score: Number(result.score ?? (result.passed ? 1 : 0)),
|
||||||
|
details: Array.isArray(result.details)
|
||||||
|
? result.details
|
||||||
|
: [String(result.details ?? "")],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
score: 0,
|
||||||
|
details: [`Grader error: ${err.message}`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all eval cases for a skill.
|
||||||
|
*
|
||||||
|
* @param {string} skillPath — path to skill directory
|
||||||
|
* @param {object} ctx — { ui } for notifications
|
||||||
|
* @returns {object} { skillName, totalCases, passedCases, totalScore, cases: [] }
|
||||||
|
*/
|
||||||
|
export async function runSkillEvals(skillPath, ctx) {
|
||||||
|
const evalDir = join(skillPath, "evals");
|
||||||
|
if (!existsSync(evalDir)) {
|
||||||
|
return {
|
||||||
|
skillName: skillPath,
|
||||||
|
totalCases: 0,
|
||||||
|
passedCases: 0,
|
||||||
|
totalScore: 0,
|
||||||
|
cases: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { readdirSync } = await import("node:fs");
|
||||||
|
const cases = [];
|
||||||
|
let passedCount = 0;
|
||||||
|
let totalScore = 0;
|
||||||
|
|
||||||
|
for (const entry of readdirSync(evalDir)) {
|
||||||
|
const caseDir = join(evalDir, entry);
|
||||||
|
const taskPath = join(caseDir, "task.md");
|
||||||
|
if (!existsSync(taskPath)) continue;
|
||||||
|
|
||||||
|
const result = await runGrader(caseDir, ctx);
|
||||||
|
cases.push({
|
||||||
|
caseName: entry,
|
||||||
|
passed: result.passed,
|
||||||
|
score: result.score,
|
||||||
|
details: result.details,
|
||||||
|
});
|
||||||
|
if (result.passed) passedCount++;
|
||||||
|
totalScore += result.score;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillName: skillPath,
|
||||||
|
totalCases: cases.length,
|
||||||
|
passedCases: passedCount,
|
||||||
|
totalScore: cases.length > 0 ? totalScore / cases.length : 0,
|
||||||
|
cases,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a default eval case for a skill based on its type.
|
||||||
|
*
|
||||||
|
* @param {object} skill — skill record from buildSkillRecord()
|
||||||
|
* @returns {object} caseDef for createEvalCase()
|
||||||
|
*/
|
||||||
|
export function generateDefaultEvalCase(skill) {
|
||||||
|
const task = `Test the ${skill.name} skill.
|
||||||
|
|
||||||
|
Instructions:
|
||||||
|
1. Read the skill definition at SKILL.md
|
||||||
|
2. Apply the skill to a realistic task matching its description
|
||||||
|
3. Verify the output matches expected behavior
|
||||||
|
|
||||||
|
Expected: The skill produces correct, safe output without side effects beyond its declared scope.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const grader = `/**
|
||||||
|
* Default grader for ${skill.name}.
|
||||||
|
* Checks that work directory contains expected artifacts.
|
||||||
|
*/
|
||||||
|
export async function grade(workDir) {
|
||||||
|
const { existsSync } = await import("node:fs");
|
||||||
|
const { join } = await import("node:path");
|
||||||
|
|
||||||
|
const checks = [];
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Example: check for expected output file
|
||||||
|
if (existsSync(join(workDir, "result.txt"))) {
|
||||||
|
checks.push("✓ result.txt exists");
|
||||||
|
score += 0.5;
|
||||||
|
} else {
|
||||||
|
checks.push("✗ result.txt missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example: check for no unexpected files
|
||||||
|
const unexpected = existsSync(join(workDir, "unexpected.txt"));
|
||||||
|
if (!unexpected) {
|
||||||
|
checks.push("✓ no unexpected artifacts");
|
||||||
|
score += 0.5;
|
||||||
|
} else {
|
||||||
|
checks.push("✗ unexpected artifact found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: score >= 0.8,
|
||||||
|
score,
|
||||||
|
details: checks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { task, grader };
|
||||||
|
}
|
||||||
153
src/resources/extensions/sf/skills/frontmatter.js
Normal file
153
src/resources/extensions/sf/skills/frontmatter.js
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
/**
|
||||||
|
* Skill YAML frontmatter parser
|
||||||
|
*
|
||||||
|
* Purpose: parse SKILL.md frontmatter so SF can enforce invocation policy,
|
||||||
|
* permission profiles, and side-effect awareness without loading full skill
|
||||||
|
* content into model context.
|
||||||
|
*
|
||||||
|
* Consumer: skill loader, auto-skill creation, and permission gating.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse YAML frontmatter from skill markdown content.
|
||||||
|
*
|
||||||
|
* Returns { frontmatter: object, body: string } or null if no frontmatter.
|
||||||
|
*/
|
||||||
|
export function parseSkillFrontmatter(content) {
|
||||||
|
if (!content) return null;
|
||||||
|
const match = content.match(FRONTMATTER_RE);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const yamlText = match[1];
|
||||||
|
const body = content.slice(match[0].length);
|
||||||
|
|
||||||
|
const frontmatter = parseYaml(yamlText);
|
||||||
|
return { frontmatter, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal YAML parser for skill frontmatter.
|
||||||
|
*
|
||||||
|
* Supports: strings, booleans, numbers, arrays, and simple nested objects.
|
||||||
|
* Does NOT support anchors, aliases, or complex YAML features.
|
||||||
|
*/
|
||||||
|
function parseYaml(text) {
|
||||||
|
const result = {};
|
||||||
|
const lines = text.split("\n");
|
||||||
|
let currentKey = null;
|
||||||
|
let currentArray = null;
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trimEnd();
|
||||||
|
if (!line.trim() || line.trim().startsWith("#")) continue;
|
||||||
|
|
||||||
|
// Check for key: value
|
||||||
|
const keyMatch = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
||||||
|
if (keyMatch) {
|
||||||
|
const [, key, value] = keyMatch;
|
||||||
|
currentKey = key;
|
||||||
|
|
||||||
|
if (value.trim() === "") {
|
||||||
|
// Could be object or array start
|
||||||
|
currentArray = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[key] = parseYamlValue(value.trim());
|
||||||
|
currentArray = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array item: - value (may be indented)
|
||||||
|
const arrayMatch = line.match(/^\s+-\s+(.*)$/);
|
||||||
|
if (arrayMatch && currentKey) {
|
||||||
|
if (!currentArray) {
|
||||||
|
currentArray = [];
|
||||||
|
result[currentKey] = currentArray;
|
||||||
|
}
|
||||||
|
currentArray.push(parseYamlValue(arrayMatch[1].trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYamlValue(value) {
|
||||||
|
if (value === "true" || value === "yes") return true;
|
||||||
|
if (value === "false" || value === "no") return false;
|
||||||
|
if (value === "null" || value === "~") return null;
|
||||||
|
|
||||||
|
// Number
|
||||||
|
if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10);
|
||||||
|
if (/^-?\d+\.\d+$/.test(value)) return Number.parseFloat(value);
|
||||||
|
|
||||||
|
// Quoted string
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
return value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate skill frontmatter against required fields.
|
||||||
|
*/
|
||||||
|
export function validateSkillFrontmatter(frontmatter) {
|
||||||
|
const errors = [];
|
||||||
|
if (!frontmatter.name || typeof frontmatter.name !== "string") {
|
||||||
|
errors.push("Missing or invalid 'name' field");
|
||||||
|
}
|
||||||
|
if (!frontmatter.description || typeof frontmatter.description !== "string") {
|
||||||
|
errors.push("Missing or invalid 'description' field");
|
||||||
|
}
|
||||||
|
if (frontmatter["user-invocable"] === undefined) {
|
||||||
|
errors.push("Missing 'user-invocable' field");
|
||||||
|
}
|
||||||
|
if (frontmatter["model-invocable"] === undefined) {
|
||||||
|
errors.push("Missing 'model-invocable' field");
|
||||||
|
}
|
||||||
|
if (frontmatter["side-effects"] === undefined) {
|
||||||
|
errors.push("Missing 'side-effects' field");
|
||||||
|
}
|
||||||
|
if (frontmatter["permission-profile"] === undefined) {
|
||||||
|
errors.push("Missing 'permission-profile' field");
|
||||||
|
}
|
||||||
|
|
||||||
|
const validProfiles = ["restricted", "normal", "trusted", "unrestricted"];
|
||||||
|
if (
|
||||||
|
frontmatter["permission-profile"] &&
|
||||||
|
!validProfiles.includes(frontmatter["permission-profile"])
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`Invalid permission-profile: ${frontmatter["permission-profile"]}. Must be one of: ${validProfiles.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a canonical skill record from parsed frontmatter + body.
|
||||||
|
*/
|
||||||
|
export function buildSkillRecord(skillDir, frontmatter, body) {
|
||||||
|
return {
|
||||||
|
name: frontmatter.name,
|
||||||
|
description: frontmatter.description,
|
||||||
|
userInvocable: frontmatter["user-invocable"] ?? false,
|
||||||
|
modelInvocable: frontmatter["model-invocable"] ?? false,
|
||||||
|
sideEffects: frontmatter["side-effects"] ?? "none",
|
||||||
|
permissionProfile: frontmatter["permission-profile"] ?? "restricted",
|
||||||
|
triggers: frontmatter.triggers ?? [],
|
||||||
|
maxActivations: frontmatter["max-activations"] ?? null,
|
||||||
|
body,
|
||||||
|
path: skillDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
37
src/resources/extensions/sf/skills/index.js
Normal file
37
src/resources/extensions/sf/skills/index.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* Skills module — public API barrel
|
||||||
|
*
|
||||||
|
* Purpose: expose skill discovery, parsing, validation, and loading
|
||||||
|
* as a single import surface.
|
||||||
|
*
|
||||||
|
* Consumer: command handlers, auto-dispatch, and model context assembly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
detectSkillCandidates,
|
||||||
|
generateSkill,
|
||||||
|
isAutoSkillCreationAllowed,
|
||||||
|
} from "./auto-create.js";
|
||||||
|
export {
|
||||||
|
discoverAllSkills,
|
||||||
|
discoverSkillDirs,
|
||||||
|
readSkillFile,
|
||||||
|
SKILL_FILENAME,
|
||||||
|
USER_SKILL_DIR,
|
||||||
|
} from "./directory.js";
|
||||||
|
export {
|
||||||
|
createEvalCase,
|
||||||
|
generateDefaultEvalCase,
|
||||||
|
runGrader,
|
||||||
|
runSkillEvals,
|
||||||
|
} from "./eval-harness.js";
|
||||||
|
export {
|
||||||
|
buildSkillRecord,
|
||||||
|
parseSkillFrontmatter,
|
||||||
|
validateSkillFrontmatter,
|
||||||
|
} from "./frontmatter.js";
|
||||||
|
export {
|
||||||
|
getModelInvocableSkills,
|
||||||
|
getPermittedSkills,
|
||||||
|
loadSkills,
|
||||||
|
} from "./loader.js";
|
||||||
113
src/resources/extensions/sf/skills/loader.js
Normal file
113
src/resources/extensions/sf/skills/loader.js
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
/**
|
||||||
|
* Skill loader
|
||||||
|
*
|
||||||
|
* Purpose: load skills from `.agents/skills/` with frontmatter validation,
|
||||||
|
* permission checking, and lazy content loading.
|
||||||
|
*
|
||||||
|
* Consumer: command handlers, auto-dispatch, and model context assembly.
|
||||||
|
*/
|
||||||
|
import { discoverAllSkills, readSkillFile } from "./directory.js";
|
||||||
|
import {
|
||||||
|
buildSkillRecord,
|
||||||
|
parseSkillFrontmatter,
|
||||||
|
validateSkillFrontmatter,
|
||||||
|
} from "./frontmatter.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all valid skills from all sources.
|
||||||
|
*
|
||||||
|
* Returns array of skill records with validation errors attached.
|
||||||
|
*/
|
||||||
|
export function loadSkills(projectPath) {
|
||||||
|
const discovered = discoverAllSkills(projectPath);
|
||||||
|
const skills = [];
|
||||||
|
|
||||||
|
for (const { name, path, source } of discovered) {
|
||||||
|
const content = readSkillFile(path);
|
||||||
|
if (!content) {
|
||||||
|
skills.push({
|
||||||
|
name,
|
||||||
|
source,
|
||||||
|
path,
|
||||||
|
valid: false,
|
||||||
|
errors: ["Could not read SKILL.md"],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseSkillFrontmatter(content);
|
||||||
|
if (!parsed) {
|
||||||
|
skills.push({
|
||||||
|
name,
|
||||||
|
source,
|
||||||
|
path,
|
||||||
|
valid: false,
|
||||||
|
errors: ["No YAML frontmatter found"],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateSkillFrontmatter(parsed.frontmatter);
|
||||||
|
if (!validation.valid) {
|
||||||
|
skills.push({
|
||||||
|
name,
|
||||||
|
source,
|
||||||
|
path,
|
||||||
|
valid: false,
|
||||||
|
errors: validation.errors,
|
||||||
|
frontmatter: parsed.frontmatter,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = buildSkillRecord(path, parsed.frontmatter, parsed.body);
|
||||||
|
skills.push({
|
||||||
|
...record,
|
||||||
|
source,
|
||||||
|
valid: true,
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get skills that are safe for the current permission profile.
|
||||||
|
*/
|
||||||
|
export function getPermittedSkills(skills, activeProfile) {
|
||||||
|
const profileOrder = ["restricted", "normal", "trusted", "unrestricted"];
|
||||||
|
const activeIndex = profileOrder.indexOf(activeProfile);
|
||||||
|
if (activeIndex === -1) return [];
|
||||||
|
|
||||||
|
return skills.filter((s) => {
|
||||||
|
if (!s.valid) return false;
|
||||||
|
const skillIndex = profileOrder.indexOf(s.permissionProfile);
|
||||||
|
if (skillIndex === -1) return false;
|
||||||
|
return skillIndex <= activeIndex;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get skills that can be invoked by the model for a given work mode.
|
||||||
|
*/
|
||||||
|
export function getModelInvocableSkills(skills, workMode) {
|
||||||
|
return skills.filter(
|
||||||
|
(s) => s.valid && s.modelInvocable && isSkillRelevant(s, workMode),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a skill is relevant to the current work mode.
|
||||||
|
*/
|
||||||
|
function isSkillRelevant(skill, workMode) {
|
||||||
|
if (!skill.triggers || skill.triggers.length === 0) return true;
|
||||||
|
return skill.triggers.some(
|
||||||
|
(t) =>
|
||||||
|
t === workMode ||
|
||||||
|
t === "*" ||
|
||||||
|
(workMode === "build" && t === "code") ||
|
||||||
|
(workMode === "review" && t === "review") ||
|
||||||
|
(workMode === "research" && t === "research"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -75,14 +75,13 @@ Label evidence:
|
||||||
### 3. Write the Failing Test (this is the spec)
|
### 3. Write the Failing Test (this is the spec)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { test } from "node:test";
|
import { expect, test } from "vitest";
|
||||||
import assert from "node:assert/strict";
|
|
||||||
|
|
||||||
// behaviour contract: claim() rejects takeover when claim_until > now()
|
// behaviour contract: claim() rejects takeover when claim_until > now()
|
||||||
test("claim_when_active_lease_returns_false", () => {
|
test("claim_when_active_lease_returns_false", () => {
|
||||||
const now = 1000;
|
const now = 1000;
|
||||||
const result = claim({ unitId: "u1", leaseMs: 60_000, now, holder: "w1", existingClaimUntil: now + 30_000 });
|
const result = claim({ unitId: "u1", leaseMs: 60_000, now, holder: "w1", existingClaimUntil: now + 30_000 });
|
||||||
assert.equal(result.acquired, false);
|
expect(result.acquired).toBe(false);
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
150
src/resources/extensions/sf/skills/templates.js
Normal file
150
src/resources/extensions/sf/skills/templates.js
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
/**
|
||||||
|
* Skill templates for auto-creation
|
||||||
|
*
|
||||||
|
* Purpose: provide canonical SKILL.md templates for common skill types
|
||||||
|
* so auto-skill creation produces consistent, valid frontmatter.
|
||||||
|
*
|
||||||
|
* Consumer: auto-skill creation flow and CLI skill scaffolding.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template for a user+model invocable skill with code edits.
|
||||||
|
*/
|
||||||
|
export function codeSkillTemplate(name, description) {
|
||||||
|
return `---
|
||||||
|
name: ${name}
|
||||||
|
description: ${description}
|
||||||
|
user-invocable: true
|
||||||
|
model-invocable: true
|
||||||
|
side-effects: code-edits
|
||||||
|
permission-profile: normal
|
||||||
|
triggers:
|
||||||
|
- build
|
||||||
|
- code
|
||||||
|
- "*"
|
||||||
|
---
|
||||||
|
|
||||||
|
# ${name}
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Describe when this skill should be invoked.
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
1. Step one
|
||||||
|
2. Step two
|
||||||
|
3. Step three
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [ ] Check A
|
||||||
|
- [ ] Check B
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
// Example code
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template for a dangerous skill (model never invokes by default).
|
||||||
|
*/
|
||||||
|
export function dangerousSkillTemplate(name, description) {
|
||||||
|
return `---
|
||||||
|
name: ${name}
|
||||||
|
description: ${description}
|
||||||
|
user-invocable: true
|
||||||
|
model-invocable: false
|
||||||
|
side-effects: production-mutation
|
||||||
|
permission-profile: trusted
|
||||||
|
triggers: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# ${name}
|
||||||
|
|
||||||
|
## ⚠️ Dangerous Operation
|
||||||
|
|
||||||
|
This skill performs high-risk actions. Model invocation is disabled by default.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [ ] Gate A passes
|
||||||
|
- [ ] Gate B passes
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. Step one
|
||||||
|
2. Step two
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
How to undo if something goes wrong.
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template for background knowledge (model only, no side effects).
|
||||||
|
*/
|
||||||
|
export function knowledgeSkillTemplate(name, description) {
|
||||||
|
return `---
|
||||||
|
name: ${name}
|
||||||
|
description: ${description}
|
||||||
|
user-invocable: false
|
||||||
|
model-invocable: true
|
||||||
|
side-effects: none
|
||||||
|
permission-profile: restricted
|
||||||
|
triggers:
|
||||||
|
- "*"
|
||||||
|
---
|
||||||
|
|
||||||
|
# ${name}
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Background information the model should know.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. Rule one
|
||||||
|
2. Rule two
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
- Don't do X
|
||||||
|
- Don't do Y
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template for review/verification skills.
|
||||||
|
*/
|
||||||
|
export function reviewSkillTemplate(name, description) {
|
||||||
|
return `---
|
||||||
|
name: ${name}
|
||||||
|
description: ${description}
|
||||||
|
user-invocable: true
|
||||||
|
model-invocable: true
|
||||||
|
side-effects: review-comments
|
||||||
|
permission-profile: restricted
|
||||||
|
triggers:
|
||||||
|
- review
|
||||||
|
- verify
|
||||||
|
---
|
||||||
|
|
||||||
|
# ${name}
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Item A
|
||||||
|
- [ ] Item B
|
||||||
|
- [ ] Item C
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
1. Issue one
|
||||||
|
2. Issue two
|
||||||
|
`;
|
||||||
|
}
|
||||||
193
src/resources/extensions/sf/temporal-foundation.js
Normal file
193
src/resources/extensions/sf/temporal-foundation.js
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
/**
|
||||||
|
* Temporal Foundation — Node 26 native date/time helpers
|
||||||
|
*
|
||||||
|
* Purpose: provide one native Temporal surface for schedules, leases, and
|
||||||
|
* background work now that Node 26 is the runtime baseline.
|
||||||
|
*
|
||||||
|
* Consumer: schedule, journal, lease, and background task surfaces.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function requireTemporal() {
|
||||||
|
if (typeof globalThis.Temporal !== "object" || globalThis.Temporal === null) {
|
||||||
|
throw new Error("SF requires Node 26 native Temporal support");
|
||||||
|
}
|
||||||
|
return globalThis.Temporal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if native Temporal is available.
|
||||||
|
*
|
||||||
|
* Purpose: let startup diagnostics fail clearly when SF is launched on a
|
||||||
|
* runtime older than the Node 26 baseline.
|
||||||
|
*
|
||||||
|
* Consumer: doctor/startup checks and Temporal tests.
|
||||||
|
*/
|
||||||
|
export function hasNativeTemporal() {
|
||||||
|
return (
|
||||||
|
typeof globalThis.Temporal === "object" && globalThis.Temporal !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the native Temporal namespace.
|
||||||
|
*
|
||||||
|
* Purpose: keep callers on the platform implementation instead of local
|
||||||
|
* compatibility shims.
|
||||||
|
*
|
||||||
|
* Consumer: modules that need Temporal constructors directly.
|
||||||
|
*/
|
||||||
|
export function getTemporal() {
|
||||||
|
return requireTemporal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Temporal.Instant from an ISO string.
|
||||||
|
*
|
||||||
|
* Purpose: exact event timestamps with stable ordering.
|
||||||
|
*
|
||||||
|
* Consumer: journals, leases, and scheduler records.
|
||||||
|
*/
|
||||||
|
export function instantFromISO(isoString) {
|
||||||
|
return requireTemporal().Instant.from(isoString);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current instant.
|
||||||
|
*
|
||||||
|
* Purpose: use a native Temporal clock value for runtime records.
|
||||||
|
*
|
||||||
|
* Consumer: schedule and lease code that needs an exact timestamp.
|
||||||
|
*/
|
||||||
|
export function instantNow() {
|
||||||
|
return requireTemporal().Now.instant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Temporal.Duration from an object.
|
||||||
|
*
|
||||||
|
* Purpose: typed durations for leases, budgets, retry delays.
|
||||||
|
*
|
||||||
|
* Consumer: retry, timeout, and reminder scheduling code.
|
||||||
|
*/
|
||||||
|
export function durationFromObject({
|
||||||
|
years = 0,
|
||||||
|
months = 0,
|
||||||
|
weeks = 0,
|
||||||
|
days = 0,
|
||||||
|
hours = 0,
|
||||||
|
minutes = 0,
|
||||||
|
seconds = 0,
|
||||||
|
milliseconds = 0,
|
||||||
|
} = {}) {
|
||||||
|
return requireTemporal().Duration.from({
|
||||||
|
years,
|
||||||
|
months,
|
||||||
|
weeks,
|
||||||
|
days,
|
||||||
|
hours,
|
||||||
|
minutes,
|
||||||
|
seconds,
|
||||||
|
milliseconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Temporal.PlainDate from an ISO string.
|
||||||
|
*
|
||||||
|
* Purpose: calendar dates for daily reports and milestone reviews.
|
||||||
|
*
|
||||||
|
* Consumer: schedule and reporting code that needs date-only semantics.
|
||||||
|
*/
|
||||||
|
export function plainDateFromISO(isoString) {
|
||||||
|
return requireTemporal().PlainDate.from(isoString);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Temporal.ZonedDateTime from an ISO string and time zone.
|
||||||
|
*
|
||||||
|
* Purpose: local-time reminders that survive DST changes.
|
||||||
|
*
|
||||||
|
* Consumer: scheduled reminders and audit views rendered in local time.
|
||||||
|
*/
|
||||||
|
export function zonedDateTimeFromISO(isoString, timeZone = "UTC") {
|
||||||
|
const Temporal = requireTemporal();
|
||||||
|
return Temporal.Instant.from(isoString).toZonedDateTimeISO(timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a Temporal value to a stable JSON-friendly object.
|
||||||
|
*
|
||||||
|
* Purpose: store semantic type plus ISO string so consumers know how to parse.
|
||||||
|
*
|
||||||
|
* Consumer: DB records and generated projections that persist Temporal values.
|
||||||
|
*/
|
||||||
|
export function serializeTemporal(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const Temporal = requireTemporal();
|
||||||
|
if (value instanceof Temporal.Instant) {
|
||||||
|
return { type: "Instant", iso: value.toString() };
|
||||||
|
}
|
||||||
|
if (value instanceof Temporal.Duration) {
|
||||||
|
return { type: "Duration", iso: value.toString() };
|
||||||
|
}
|
||||||
|
if (value instanceof Temporal.PlainDate) {
|
||||||
|
return { type: "PlainDate", iso: value.toString() };
|
||||||
|
}
|
||||||
|
if (value instanceof Temporal.ZonedDateTime) {
|
||||||
|
return {
|
||||||
|
type: "ZonedDateTime",
|
||||||
|
iso: value.toString(),
|
||||||
|
timeZone: value.timeZoneId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new TypeError(
|
||||||
|
`Unsupported Temporal value: ${Object.prototype.toString.call(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize a serialized Temporal value.
|
||||||
|
*
|
||||||
|
* Purpose: reconstruct the same native Temporal type that was stored.
|
||||||
|
*
|
||||||
|
* Consumer: schedule and UOK state loaders.
|
||||||
|
*/
|
||||||
|
export function deserializeTemporal(serialized) {
|
||||||
|
if (!serialized || typeof serialized !== "object") return null;
|
||||||
|
const Temporal = requireTemporal();
|
||||||
|
switch (serialized.type) {
|
||||||
|
case "Instant":
|
||||||
|
return Temporal.Instant.from(serialized.iso);
|
||||||
|
case "Duration":
|
||||||
|
return Temporal.Duration.from(serialized.iso);
|
||||||
|
case "PlainDate":
|
||||||
|
return Temporal.PlainDate.from(serialized.iso);
|
||||||
|
case "ZonedDateTime":
|
||||||
|
return Temporal.ZonedDateTime.from(serialized.iso);
|
||||||
|
default:
|
||||||
|
throw new TypeError(`Unknown Temporal type: ${serialized.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a schedule/lease record has correct Temporal serialization.
|
||||||
|
*
|
||||||
|
* Purpose: reject ambiguous Date-like records before they enter durable state.
|
||||||
|
*
|
||||||
|
* Consumer: schedule and UOK record validation.
|
||||||
|
*/
|
||||||
|
export function validateTemporalRecord(record) {
|
||||||
|
const errors = [];
|
||||||
|
const validTypes = ["Instant", "Duration", "PlainDate", "ZonedDateTime"];
|
||||||
|
for (const [key, value] of Object.entries(record)) {
|
||||||
|
if (value && typeof value === "object" && value.type) {
|
||||||
|
if (!validTypes.includes(value.type)) {
|
||||||
|
errors.push(`Field "${key}" has unknown Temporal type: ${value.type}`);
|
||||||
|
}
|
||||||
|
if (validTypes.includes(value.type) && !value.iso) {
|
||||||
|
errors.push(`Field "${key}" is missing ISO string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import { test } from "vitest";
|
import { test } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -42,3 +44,25 @@ test("direct command completions strip the already typed command name", () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("human_facing_cli_help_when_describing_sf_surfaces_uses_direct_commands", () => {
|
||||||
|
const sourceFiles = ["src/help-text.ts", "src/cli.ts", "src/loader.ts"];
|
||||||
|
for (const sourceFile of sourceFiles) {
|
||||||
|
const source = readFileSync(join(process.cwd(), sourceFile), "utf8");
|
||||||
|
assert.equal(
|
||||||
|
source.includes("Machine surface for /sf"),
|
||||||
|
false,
|
||||||
|
`${sourceFile} must describe headless as the direct-command machine surface`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
source.includes("Run /sf autonomous"),
|
||||||
|
false,
|
||||||
|
`${sourceFile} must not advertise /sf autonomous`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
source.includes("Manage: /sf schedule list"),
|
||||||
|
false,
|
||||||
|
`${sourceFile} must not advertise the removed slash namespace`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,16 @@ import {
|
||||||
isRunControlMode,
|
isRunControlMode,
|
||||||
isWorkMode,
|
isWorkMode,
|
||||||
MODEL_MODES,
|
MODEL_MODES,
|
||||||
|
modelModeToTier,
|
||||||
PERMISSION_PROFILES,
|
PERMISSION_PROFILES,
|
||||||
|
RUN_CONTROL_MODES,
|
||||||
resolveModelMode,
|
resolveModelMode,
|
||||||
resolvePermissionProfile,
|
resolvePermissionProfile,
|
||||||
resolveRunControlMode,
|
resolveRunControlMode,
|
||||||
resolveWorkMode,
|
resolveWorkMode,
|
||||||
RUN_CONTROL_MODES,
|
|
||||||
WORK_MODES,
|
|
||||||
runControlModeForSession,
|
runControlModeForSession,
|
||||||
|
tierToModelMode,
|
||||||
|
WORK_MODES,
|
||||||
} from "../operating-model.js";
|
} from "../operating-model.js";
|
||||||
|
|
||||||
describe("operating model vocabulary", () => {
|
describe("operating model vocabulary", () => {
|
||||||
|
|
@ -120,4 +122,18 @@ describe("operating model vocabulary", () => {
|
||||||
assert.equal(state.modelMode, "smart");
|
assert.equal(state.modelMode, "smart");
|
||||||
assert.equal(state.surface, "tui");
|
assert.equal(state.surface, "tui");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("modelModeToTier_maps_fast_smart_deep_to_light_standard_heavy", () => {
|
||||||
|
assert.equal(modelModeToTier("fast"), "light");
|
||||||
|
assert.equal(modelModeToTier("smart"), "standard");
|
||||||
|
assert.equal(modelModeToTier("deep"), "heavy");
|
||||||
|
assert.equal(modelModeToTier("unknown"), "standard");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tierToModelMode_maps_light_standard_heavy_to_fast_smart_deep", () => {
|
||||||
|
assert.equal(tierToModelMode("light"), "fast");
|
||||||
|
assert.equal(tierToModelMode("standard"), "smart");
|
||||||
|
assert.equal(tierToModelMode("heavy"), "deep");
|
||||||
|
assert.equal(tierToModelMode("unknown"), "smart");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
98
src/resources/extensions/sf/tests/parallel-intent.test.mjs
Normal file
98
src/resources/extensions/sf/tests/parallel-intent.test.mjs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
checkIntentConflicts,
|
||||||
|
clearAllIntents,
|
||||||
|
declareIntent,
|
||||||
|
getActiveIntents,
|
||||||
|
releaseIntent,
|
||||||
|
} from "../parallel-intent.js";
|
||||||
|
import { closeDatabase } from "../sf-db.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parallel intent registry tests.
|
||||||
|
*/
|
||||||
|
function makeBasePath() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "sf-intent-"));
|
||||||
|
mkdirSync(join(dir, ".sf"), { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(basePath) {
|
||||||
|
try {
|
||||||
|
rmSync(basePath, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parallel intent registry", () => {
|
||||||
|
let basePath;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
basePath = makeBasePath();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeDatabase();
|
||||||
|
cleanup(basePath);
|
||||||
|
basePath = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declareIntent_when_valid_input_records_claim", () => {
|
||||||
|
const result = declareIntent(basePath, "M001", [
|
||||||
|
"src/foo.ts",
|
||||||
|
"src/bar.ts",
|
||||||
|
]);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
const intents = getActiveIntents(basePath);
|
||||||
|
expect(intents).toHaveLength(1);
|
||||||
|
expect(intents[0].milestoneId).toBe("M001");
|
||||||
|
expect(intents[0].files).toEqual(["src/foo.ts", "src/bar.ts"]);
|
||||||
|
expect(intents[0].status).toBe("claimed");
|
||||||
|
expect(existsSync(join(basePath, ".sf", "parallel"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkIntentConflicts_when_files_overlap_reports_existing_milestone", () => {
|
||||||
|
declareIntent(basePath, "M001", ["src/foo.ts", "src/bar.ts"]);
|
||||||
|
const conflicts = checkIntentConflicts(basePath, "M002", [
|
||||||
|
"src/bar.ts",
|
||||||
|
"src/baz.ts",
|
||||||
|
]);
|
||||||
|
expect(conflicts).toHaveLength(1);
|
||||||
|
expect(conflicts[0].milestoneId).toBe("M001");
|
||||||
|
expect(conflicts[0].files).toEqual(["src/bar.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkIntentConflicts_when_no_overlap_returns_empty_list", () => {
|
||||||
|
declareIntent(basePath, "M001", ["src/foo.ts"]);
|
||||||
|
const conflicts = checkIntentConflicts(basePath, "M002", ["src/bar.ts"]);
|
||||||
|
expect(conflicts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("releaseIntent_when_known_milestone_clears_active_intent", () => {
|
||||||
|
declareIntent(basePath, "M001", ["src/foo.ts"]);
|
||||||
|
releaseIntent(basePath, "M001");
|
||||||
|
const intents = getActiveIntents(basePath);
|
||||||
|
expect(intents).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearAllIntents_when_multiple_claims_removes_all_records", () => {
|
||||||
|
declareIntent(basePath, "M001", ["src/foo.ts"]);
|
||||||
|
declareIntent(basePath, "M002", ["src/bar.ts"]);
|
||||||
|
clearAllIntents(basePath);
|
||||||
|
const intents = getActiveIntents(basePath);
|
||||||
|
expect(intents).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getActiveIntents_when_release_exists_ignores_released_records", () => {
|
||||||
|
declareIntent(basePath, "M001", ["src/foo.ts"]);
|
||||||
|
declareIntent(basePath, "M002", ["src/bar.ts"]);
|
||||||
|
releaseIntent(basePath, "M001");
|
||||||
|
const intents = getActiveIntents(basePath);
|
||||||
|
expect(intents).toHaveLength(1);
|
||||||
|
expect(intents[0].milestoneId).toBe("M002");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -217,7 +217,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill",
|
||||||
const version = db
|
const version = db
|
||||||
.prepare("SELECT MAX(version) AS version FROM schema_version")
|
.prepare("SELECT MAX(version) AS version FROM schema_version")
|
||||||
.get();
|
.get();
|
||||||
assert.equal(version.version, 42);
|
assert.equal(version.version, 43);
|
||||||
const taskSpec = db
|
const taskSpec = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'",
|
"SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'",
|
||||||
|
|
|
||||||
115
src/resources/extensions/sf/tests/skill-eval-harness.test.mjs
Normal file
115
src/resources/extensions/sf/tests/skill-eval-harness.test.mjs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* Tests for skill eval harness.
|
||||||
|
*
|
||||||
|
* Purpose: verify eval-case materialization and grader execution are stable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
createEvalCase,
|
||||||
|
generateDefaultEvalCase,
|
||||||
|
runGrader,
|
||||||
|
runSkillEvals,
|
||||||
|
} from "../skills/eval-harness.js";
|
||||||
|
|
||||||
|
function makeSkillDir() {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "sf-skill-eval-"));
|
||||||
|
mkdirSync(join(dir, ".agents", "skills", "test-skill"), { recursive: true });
|
||||||
|
return join(dir, ".agents", "skills", "test-skill");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(dir) {
|
||||||
|
try {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("skill eval harness", () => {
|
||||||
|
test("createEvalCase_creates_case_files_and_workspace", () => {
|
||||||
|
const skillDir = makeSkillDir();
|
||||||
|
try {
|
||||||
|
const caseDef = {
|
||||||
|
task: "Test task for skill",
|
||||||
|
grader:
|
||||||
|
"export async function grade() { return { passed: true, score: 1 }; }",
|
||||||
|
};
|
||||||
|
const result = createEvalCase(skillDir, "case-1", caseDef);
|
||||||
|
expect(result.caseName).toBe("case-1");
|
||||||
|
expect(existsSync(join(skillDir, "evals", "case-1", "task.md"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(existsSync(join(skillDir, "evals", "case-1", "grader.js"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(existsSync(join(skillDir, "evals", "case-1", "work"))).toBe(true);
|
||||||
|
} finally {
|
||||||
|
cleanup(skillDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createEvalCase_with_hidden_reference_writes_fixture", () => {
|
||||||
|
const skillDir = makeSkillDir();
|
||||||
|
try {
|
||||||
|
const caseDef = {
|
||||||
|
task: "Test task",
|
||||||
|
grader:
|
||||||
|
"export async function grade() { return { passed: true, score: 1 }; }",
|
||||||
|
hidden: { answer: "expected-output" },
|
||||||
|
};
|
||||||
|
createEvalCase(skillDir, "case-2", caseDef);
|
||||||
|
const hiddenPath = join(
|
||||||
|
skillDir,
|
||||||
|
"evals",
|
||||||
|
"case-2",
|
||||||
|
"hidden",
|
||||||
|
"reference.json",
|
||||||
|
);
|
||||||
|
expect(existsSync(hiddenPath)).toBe(true);
|
||||||
|
const hidden = JSON.parse(readFileSync(hiddenPath, "utf-8"));
|
||||||
|
expect(hidden.answer).toBe("expected-output");
|
||||||
|
} finally {
|
||||||
|
cleanup(skillDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runGrader_returns_error_when_grader_missing", async () => {
|
||||||
|
const skillDir = makeSkillDir();
|
||||||
|
try {
|
||||||
|
mkdirSync(join(skillDir, "evals", "bad-case"), { recursive: true });
|
||||||
|
const result = await runGrader(join(skillDir, "evals", "bad-case"), {});
|
||||||
|
expect(result.passed).toBe(false);
|
||||||
|
expect(result.details[0]).toBe("Grader not found");
|
||||||
|
} finally {
|
||||||
|
cleanup(skillDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generateDefaultEvalCase_mentions_skill_name_and_exported_grader", () => {
|
||||||
|
const skill = { name: "forge-test", description: "Test skill" };
|
||||||
|
const caseDef = generateDefaultEvalCase(skill);
|
||||||
|
expect(caseDef.task.includes("forge-test")).toBe(true);
|
||||||
|
expect(caseDef.grader.includes("export async function grade")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runSkillEvals_returns_empty_summary_without_evals", async () => {
|
||||||
|
const skillDir = makeSkillDir();
|
||||||
|
try {
|
||||||
|
const result = await runSkillEvals(skillDir, {});
|
||||||
|
expect(result.totalCases).toBe(0);
|
||||||
|
expect(result.passedCases).toBe(0);
|
||||||
|
} finally {
|
||||||
|
cleanup(skillDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
253
src/resources/extensions/sf/tests/skills.test.mjs
Normal file
253
src/resources/extensions/sf/tests/skills.test.mjs
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
/**
|
||||||
|
* Skills system tests.
|
||||||
|
*
|
||||||
|
* Purpose: verify skill discovery, frontmatter parsing, validation, and loading.
|
||||||
|
*/
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { discoverSkillDirs, readSkillFile } from "../skills/directory.js";
|
||||||
|
import {
|
||||||
|
buildSkillRecord,
|
||||||
|
parseSkillFrontmatter,
|
||||||
|
validateSkillFrontmatter,
|
||||||
|
} from "../skills/frontmatter.js";
|
||||||
|
import {
|
||||||
|
getModelInvocableSkills,
|
||||||
|
getPermittedSkills,
|
||||||
|
loadSkills,
|
||||||
|
} from "../skills/loader.js";
|
||||||
|
|
||||||
|
describe("skill frontmatter", () => {
|
||||||
|
test("parseSkillFrontmatter_extracts_yaml_and_body", () => {
|
||||||
|
const content = `---
|
||||||
|
name: test-skill
|
||||||
|
description: A test skill
|
||||||
|
user-invocable: true
|
||||||
|
model-invocable: false
|
||||||
|
side-effects: none
|
||||||
|
permission-profile: restricted
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Skill
|
||||||
|
|
||||||
|
Some instructions.
|
||||||
|
`;
|
||||||
|
const parsed = parseSkillFrontmatter(content);
|
||||||
|
expect(parsed).toBeTruthy();
|
||||||
|
expect(parsed.frontmatter.name).toBe("test-skill");
|
||||||
|
expect(parsed.frontmatter.description).toBe("A test skill");
|
||||||
|
expect(parsed.frontmatter["user-invocable"]).toBe(true);
|
||||||
|
expect(parsed.frontmatter["model-invocable"]).toBe(false);
|
||||||
|
expect(parsed.frontmatter["side-effects"]).toBe("none");
|
||||||
|
expect(parsed.frontmatter["permission-profile"]).toBe("restricted");
|
||||||
|
expect(parsed.body).toContain("# Test Skill");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseSkillFrontmatter_returns_null_without_frontmatter", () => {
|
||||||
|
const parsed = parseSkillFrontmatter("# Just markdown\n\nNo frontmatter.");
|
||||||
|
expect(parsed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateSkillFrontmatter_passes_complete_frontmatter", () => {
|
||||||
|
const result = validateSkillFrontmatter({
|
||||||
|
name: "test",
|
||||||
|
description: "desc",
|
||||||
|
"user-invocable": true,
|
||||||
|
"model-invocable": false,
|
||||||
|
"side-effects": "none",
|
||||||
|
"permission-profile": "normal",
|
||||||
|
});
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateSkillFrontmatter_fails_missing_fields", () => {
|
||||||
|
const result = validateSkillFrontmatter({
|
||||||
|
name: "test",
|
||||||
|
});
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
|
expect(result.errors.some((e) => e.includes("description"))).toBe(true);
|
||||||
|
expect(result.errors.some((e) => e.includes("user-invocable"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateSkillFrontmatter_fails_invalid_permission_profile", () => {
|
||||||
|
const result = validateSkillFrontmatter({
|
||||||
|
name: "test",
|
||||||
|
description: "desc",
|
||||||
|
"user-invocable": true,
|
||||||
|
"model-invocable": false,
|
||||||
|
"side-effects": "none",
|
||||||
|
"permission-profile": "invalid",
|
||||||
|
});
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some((e) => e.includes("permission-profile"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildSkillRecord_creates_canonical_record", () => {
|
||||||
|
const record = buildSkillRecord(
|
||||||
|
"/path/to/skill",
|
||||||
|
{
|
||||||
|
name: "test-skill",
|
||||||
|
description: "A test",
|
||||||
|
"user-invocable": true,
|
||||||
|
"model-invocable": true,
|
||||||
|
"side-effects": "code-edits",
|
||||||
|
"permission-profile": "normal",
|
||||||
|
triggers: ["build", "code"],
|
||||||
|
"max-activations": 5,
|
||||||
|
},
|
||||||
|
"# Body",
|
||||||
|
);
|
||||||
|
expect(record.name).toBe("test-skill");
|
||||||
|
expect(record.userInvocable).toBe(true);
|
||||||
|
expect(record.modelInvocable).toBe(true);
|
||||||
|
expect(record.sideEffects).toBe("code-edits");
|
||||||
|
expect(record.permissionProfile).toBe("normal");
|
||||||
|
expect(record.triggers).toEqual(["build", "code"]);
|
||||||
|
expect(record.maxActivations).toBe(5);
|
||||||
|
expect(record.body).toBe("# Body");
|
||||||
|
expect(record.path).toBe("/path/to/skill");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("skill discovery", () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), "sf-skills-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("discoverSkillDirs_finds_skills_with_skill_md", () => {
|
||||||
|
const skillDir = join(tmpDir, ".agents", "skills", "test-skill");
|
||||||
|
mkdirSync(skillDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(skillDir, "SKILL.md"),
|
||||||
|
`---\nname: test-skill\ndescription: Test\nuser-invocable: true\nmodel-invocable: true\nside-effects: none\npermission-profile: restricted\n---\n\n# Test\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dirs = discoverSkillDirs(tmpDir);
|
||||||
|
expect(dirs).toHaveLength(1);
|
||||||
|
expect(dirs[0].name).toBe("test-skill");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("discoverSkillDirs_ignores_dirs_without_skill_md", () => {
|
||||||
|
const skillDir = join(tmpDir, ".agents", "skills", "empty-dir");
|
||||||
|
mkdirSync(skillDir, { recursive: true });
|
||||||
|
|
||||||
|
const dirs = discoverSkillDirs(tmpDir);
|
||||||
|
expect(dirs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readSkillFile_reads_content", () => {
|
||||||
|
const skillDir = join(tmpDir, ".agents", "skills", "test-skill");
|
||||||
|
mkdirSync(skillDir, { recursive: true });
|
||||||
|
writeFileSync(join(skillDir, "SKILL.md"), "# Test Skill\n");
|
||||||
|
|
||||||
|
const content = readSkillFile(skillDir);
|
||||||
|
expect(content).toBe("# Test Skill\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readSkillFile_returns_null_for_missing", () => {
|
||||||
|
const content = readSkillFile(join(tmpDir, "nonexistent"));
|
||||||
|
expect(content).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("skill loading", () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), "sf-skills-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function createSkill(name, overrides = {}) {
|
||||||
|
const skillDir = join(tmpDir, ".agents", "skills", name);
|
||||||
|
mkdirSync(skillDir, { recursive: true });
|
||||||
|
const frontmatter = {
|
||||||
|
name,
|
||||||
|
description: overrides.description ?? `Description for ${name}`,
|
||||||
|
"user-invocable": overrides.userInvocable ?? true,
|
||||||
|
"model-invocable": overrides.modelInvocable ?? true,
|
||||||
|
"side-effects": overrides.sideEffects ?? "none",
|
||||||
|
"permission-profile": overrides.permissionProfile ?? "normal",
|
||||||
|
triggers: overrides.triggers ?? ["*"],
|
||||||
|
...overrides.extra,
|
||||||
|
};
|
||||||
|
const yaml = Object.entries(frontmatter)
|
||||||
|
.map(([k, v]) => {
|
||||||
|
if (Array.isArray(v))
|
||||||
|
return `${k}:\n${v.map((i) => ` - ${i}`).join("\n")}`;
|
||||||
|
return `${k}: ${v}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
writeFileSync(
|
||||||
|
join(skillDir, "SKILL.md"),
|
||||||
|
`---\n${yaml}\n---\n\n# ${name}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("loadSkills_loads_valid_skills", () => {
|
||||||
|
createSkill("skill-a");
|
||||||
|
createSkill("skill-b", { permissionProfile: "trusted" });
|
||||||
|
|
||||||
|
const skills = loadSkills(tmpDir);
|
||||||
|
expect(skills).toHaveLength(2);
|
||||||
|
expect(skills.every((s) => s.valid)).toBe(true);
|
||||||
|
expect(skills.some((s) => s.name === "skill-a")).toBe(true);
|
||||||
|
expect(skills.some((s) => s.name === "skill-b")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadSkills_marks_invalid_skills", () => {
|
||||||
|
createSkill("valid-skill");
|
||||||
|
const badDir = join(tmpDir, ".agents", "skills", "bad-skill");
|
||||||
|
mkdirSync(badDir, { recursive: true });
|
||||||
|
writeFileSync(join(badDir, "SKILL.md"), "No frontmatter here.");
|
||||||
|
|
||||||
|
const skills = loadSkills(tmpDir);
|
||||||
|
expect(skills).toHaveLength(2);
|
||||||
|
const bad = skills.find((s) => s.name === "bad-skill");
|
||||||
|
expect(bad).toBeTruthy();
|
||||||
|
expect(bad.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getPermittedSkills_filters_by_profile", () => {
|
||||||
|
createSkill("restricted-skill", { permissionProfile: "restricted" });
|
||||||
|
createSkill("normal-skill", { permissionProfile: "normal" });
|
||||||
|
createSkill("trusted-skill", { permissionProfile: "trusted" });
|
||||||
|
|
||||||
|
const skills = loadSkills(tmpDir);
|
||||||
|
const permitted = getPermittedSkills(skills, "normal");
|
||||||
|
expect(permitted).toHaveLength(2);
|
||||||
|
expect(permitted.some((s) => s.name === "restricted-skill")).toBe(true);
|
||||||
|
expect(permitted.some((s) => s.name === "normal-skill")).toBe(true);
|
||||||
|
expect(permitted.some((s) => s.name === "trusted-skill")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModelInvocableSkills_filters_by_work_mode", () => {
|
||||||
|
createSkill("build-skill", { triggers: ["build"], modelInvocable: true });
|
||||||
|
createSkill("review-skill", { triggers: ["review"], modelInvocable: true });
|
||||||
|
createSkill("universal-skill", { triggers: ["*"], modelInvocable: true });
|
||||||
|
createSkill("user-only", { triggers: ["*"], modelInvocable: false });
|
||||||
|
|
||||||
|
const skills = loadSkills(tmpDir);
|
||||||
|
const buildSkills = getModelInvocableSkills(skills, "build");
|
||||||
|
expect(buildSkills).toHaveLength(2);
|
||||||
|
expect(buildSkills.some((s) => s.name === "build-skill")).toBe(true);
|
||||||
|
expect(buildSkills.some((s) => s.name === "universal-skill")).toBe(true);
|
||||||
|
expect(buildSkills.some((s) => s.name === "review-skill")).toBe(false);
|
||||||
|
expect(buildSkills.some((s) => s.name === "user-only")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
src/resources/extensions/sf/tests/temporal-foundation.test.mjs
Normal file
102
src/resources/extensions/sf/tests/temporal-foundation.test.mjs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
/**
|
||||||
|
* Temporal foundation tests.
|
||||||
|
*
|
||||||
|
* Purpose: ensure runtime contracts are enforced on native Temporal.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
durationFromObject,
|
||||||
|
hasNativeTemporal,
|
||||||
|
instantFromISO,
|
||||||
|
instantNow,
|
||||||
|
plainDateFromISO,
|
||||||
|
serializeTemporal,
|
||||||
|
validateTemporalRecord,
|
||||||
|
} from "../temporal-foundation.js";
|
||||||
|
|
||||||
|
const hasTemporal = hasNativeTemporal();
|
||||||
|
const Temporal = globalThis.Temporal;
|
||||||
|
|
||||||
|
if (!hasTemporal) {
|
||||||
|
test.skip("native Temporal is required by the Node 26 baseline", () => {});
|
||||||
|
} else {
|
||||||
|
describe("temporal foundation", () => {
|
||||||
|
test("hasNativeTemporal_reports_true_in_runtime", () => {
|
||||||
|
expect(hasTemporal).toBe(true);
|
||||||
|
expect(Temporal).toBeTypeOf("object");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("instantFromISO_parses_iso_to_native_instant", () => {
|
||||||
|
const iso = "2026-05-08T12:00:00Z";
|
||||||
|
const instant = instantFromISO(iso);
|
||||||
|
expect(instant).toBeInstanceOf(Temporal.Instant);
|
||||||
|
expect(instant.toString()).toBe(iso);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("instantNow_returns_current_native_instant", () => {
|
||||||
|
const before = Date.now();
|
||||||
|
const instant = instantNow();
|
||||||
|
const after = Date.now();
|
||||||
|
expect(instant).toBeInstanceOf(Temporal.Instant);
|
||||||
|
expect(instant.epochMilliseconds).toBeGreaterThanOrEqual(before);
|
||||||
|
expect(instant.epochMilliseconds).toBeLessThanOrEqual(after);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("durationFromObject_returns_native_duration", () => {
|
||||||
|
const dur = durationFromObject({ hours: 1, minutes: 30 });
|
||||||
|
expect(dur).toBeInstanceOf(Temporal.Duration);
|
||||||
|
expect(dur.hours).toBe(1);
|
||||||
|
expect(dur.minutes).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plainDateFromISO_returns_native_plain_date", () => {
|
||||||
|
const date = plainDateFromISO("2026-05-08");
|
||||||
|
expect(date).toBeInstanceOf(Temporal.PlainDate);
|
||||||
|
expect(date.year).toBe(2026);
|
||||||
|
expect(date.month).toBe(5);
|
||||||
|
expect(date.day).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serializeTemporal_roundtrips_native_instant", () => {
|
||||||
|
const instant = instantFromISO("2026-05-08T12:00:00Z");
|
||||||
|
const serialized = serializeTemporal(instant);
|
||||||
|
expect(serialized).toEqual({
|
||||||
|
type: "Instant",
|
||||||
|
iso: "2026-05-08T12:00:00Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateTemporalRecord_passes_valid", () => {
|
||||||
|
const record = {
|
||||||
|
createdAt: { type: "Instant", iso: "2026-05-08T12:00:00Z" },
|
||||||
|
leaseDuration: { type: "Duration", iso: "PT1H" },
|
||||||
|
};
|
||||||
|
const result = validateTemporalRecord(record);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateTemporalRecord_fails_unknown_type", () => {
|
||||||
|
const record = {
|
||||||
|
bad: { type: "UnknownType", iso: "x" },
|
||||||
|
};
|
||||||
|
const result = validateTemporalRecord(record);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(
|
||||||
|
result.errors.some((e) => e.includes("unknown Temporal type")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateTemporalRecord_fails_missing_iso", () => {
|
||||||
|
const record = {
|
||||||
|
bad: { type: "Instant" },
|
||||||
|
};
|
||||||
|
const result = validateTemporalRecord(record);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some((e) => e.includes("missing ISO string"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,22 @@ test("kv_cleanupExpired_removes_expired_rows", () => {
|
||||||
assert.equal(store.get("b"), 2);
|
assert.equal(store.get("b"), 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("kv_entries_when_prefix_matches_returns_live_decoded_rows", () => {
|
||||||
|
let now = 1_000;
|
||||||
|
const store = makeStore(() => now);
|
||||||
|
store.set("parallel:intent:M001", { milestoneId: "M001" });
|
||||||
|
store.set("parallel:intent:M002", { milestoneId: "M002" }, { ttlMs: 1 });
|
||||||
|
store.set("skills:auto-create:last-run", { lastRunMs: 1 });
|
||||||
|
|
||||||
|
now = 1_002;
|
||||||
|
const entries = store.entries("parallel:intent:");
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
entries.map((entry) => entry.value.milestoneId),
|
||||||
|
["M001"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("streams_append_and_read_after_id_in_order", () => {
|
test("streams_append_and_read_after_id_in_order", () => {
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
const first = store.xadd("events", "node-start", { id: "a" });
|
const first = store.xadd("events", "node-start", { id: "a" });
|
||||||
|
|
@ -70,7 +86,10 @@ test("streams_append_and_read_after_id_in_order", () => {
|
||||||
store.xread("events", { afterId: 1 }).map((e) => e.type),
|
store.xread("events", { afterId: 1 }).map((e) => e.type),
|
||||||
["node-complete"],
|
["node-complete"],
|
||||||
);
|
);
|
||||||
assert.deepEqual(store.xread("events").map((e) => e.payload.id), ["a", "a"]);
|
assert.deepEqual(
|
||||||
|
store.xread("events").map((e) => e.payload.id),
|
||||||
|
["a", "a"],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("queue_claim_ack_and_release_enforces_owner_leases", () => {
|
test("queue_claim_ack_and_release_enforces_owner_leases", () => {
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,7 @@
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { afterEach, test } from "vitest";
|
import { afterEach, test } from "vitest";
|
||||||
import {
|
import { closeDatabase, getDatabase, openDatabase } from "../sf-db.js";
|
||||||
closeDatabase,
|
|
||||||
getDatabase,
|
|
||||||
openDatabase,
|
|
||||||
} from "../sf-db.js";
|
|
||||||
import {
|
import {
|
||||||
ensureExecutionGraphSchema,
|
ensureExecutionGraphSchema,
|
||||||
getGraphProgressStream,
|
getGraphProgressStream,
|
||||||
|
|
@ -170,8 +166,20 @@ test("persistGraphNode_updates_existing_node", () => {
|
||||||
test("persistFullGraph_writes_snapshot_and_nodes_in_transaction", () => {
|
test("persistFullGraph_writes_snapshot_and_nodes_in_transaction", () => {
|
||||||
const db = makeDb();
|
const db = makeDb();
|
||||||
const nodes = [
|
const nodes = [
|
||||||
{ id: "n1", kind: "unit", unitType: "execute-task", unitId: "U1", state: "todo" },
|
{
|
||||||
{ id: "n2", kind: "unit", unitType: "execute-task", unitId: "U2", state: "todo" },
|
id: "n1",
|
||||||
|
kind: "unit",
|
||||||
|
unitType: "execute-task",
|
||||||
|
unitId: "U1",
|
||||||
|
state: "todo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "n2",
|
||||||
|
kind: "unit",
|
||||||
|
unitType: "execute-task",
|
||||||
|
unitId: "U2",
|
||||||
|
state: "todo",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
persistFullGraph(db, "g2", nodes, {
|
persistFullGraph(db, "g2", nodes, {
|
||||||
|
|
@ -196,9 +204,24 @@ test("queryTasksByState_filters_by_state", () => {
|
||||||
const db = makeDb();
|
const db = makeDb();
|
||||||
ensureExecutionGraphSchema(db);
|
ensureExecutionGraphSchema(db);
|
||||||
|
|
||||||
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", state: "todo" });
|
persistGraphNode(db, "g1", {
|
||||||
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", state: "done" });
|
id: "n1",
|
||||||
persistGraphNode(db, "g1", { id: "n3", kind: "unit", unitId: "U3", state: "in_progress" });
|
kind: "unit",
|
||||||
|
unitId: "U1",
|
||||||
|
state: "todo",
|
||||||
|
});
|
||||||
|
persistGraphNode(db, "g1", {
|
||||||
|
id: "n2",
|
||||||
|
kind: "unit",
|
||||||
|
unitId: "U2",
|
||||||
|
state: "done",
|
||||||
|
});
|
||||||
|
persistGraphNode(db, "g1", {
|
||||||
|
id: "n3",
|
||||||
|
kind: "unit",
|
||||||
|
unitId: "U3",
|
||||||
|
state: "in_progress",
|
||||||
|
});
|
||||||
|
|
||||||
const todo = queryTasksByState(db, { graphId: "g1", states: ["todo"] });
|
const todo = queryTasksByState(db, { graphId: "g1", states: ["todo"] });
|
||||||
assert.equal(todo.length, 1);
|
assert.equal(todo.length, 1);
|
||||||
|
|
@ -213,8 +236,20 @@ test("queryTasksByState_filters_by_milestone", () => {
|
||||||
const db = makeDb();
|
const db = makeDb();
|
||||||
ensureExecutionGraphSchema(db);
|
ensureExecutionGraphSchema(db);
|
||||||
|
|
||||||
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", milestoneId: "M001", state: "todo" });
|
persistGraphNode(db, "g1", {
|
||||||
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", milestoneId: "M002", state: "todo" });
|
id: "n1",
|
||||||
|
kind: "unit",
|
||||||
|
unitId: "U1",
|
||||||
|
milestoneId: "M001",
|
||||||
|
state: "todo",
|
||||||
|
});
|
||||||
|
persistGraphNode(db, "g1", {
|
||||||
|
id: "n2",
|
||||||
|
kind: "unit",
|
||||||
|
unitId: "U2",
|
||||||
|
milestoneId: "M002",
|
||||||
|
state: "todo",
|
||||||
|
});
|
||||||
|
|
||||||
const m1 = queryTasksByState(db, { milestoneId: "M001" });
|
const m1 = queryTasksByState(db, { milestoneId: "M001" });
|
||||||
assert.equal(m1.length, 1);
|
assert.equal(m1.length, 1);
|
||||||
|
|
@ -247,10 +282,30 @@ test("getGraphStateSummary_computes_counts_and_progress", () => {
|
||||||
const db = makeDb();
|
const db = makeDb();
|
||||||
ensureExecutionGraphSchema(db);
|
ensureExecutionGraphSchema(db);
|
||||||
|
|
||||||
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", state: "todo" });
|
persistGraphNode(db, "g1", {
|
||||||
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", state: "in_progress" });
|
id: "n1",
|
||||||
persistGraphNode(db, "g1", { id: "n3", kind: "unit", unitId: "U3", state: "done" });
|
kind: "unit",
|
||||||
persistGraphNode(db, "g1", { id: "n4", kind: "unit", unitId: "U4", state: "done" });
|
unitId: "U1",
|
||||||
|
state: "todo",
|
||||||
|
});
|
||||||
|
persistGraphNode(db, "g1", {
|
||||||
|
id: "n2",
|
||||||
|
kind: "unit",
|
||||||
|
unitId: "U2",
|
||||||
|
state: "in_progress",
|
||||||
|
});
|
||||||
|
persistGraphNode(db, "g1", {
|
||||||
|
id: "n3",
|
||||||
|
kind: "unit",
|
||||||
|
unitId: "U3",
|
||||||
|
state: "done",
|
||||||
|
});
|
||||||
|
persistGraphNode(db, "g1", {
|
||||||
|
id: "n4",
|
||||||
|
kind: "unit",
|
||||||
|
unitId: "U4",
|
||||||
|
state: "done",
|
||||||
|
});
|
||||||
|
|
||||||
const summary = getGraphStateSummary(db, "g1");
|
const summary = getGraphStateSummary(db, "g1");
|
||||||
assert.equal(summary.total, 4);
|
assert.equal(summary.total, 4);
|
||||||
|
|
@ -266,8 +321,18 @@ test("getGraphStateSummary_complete_when_all_terminal", () => {
|
||||||
const db = makeDb();
|
const db = makeDb();
|
||||||
ensureExecutionGraphSchema(db);
|
ensureExecutionGraphSchema(db);
|
||||||
|
|
||||||
persistGraphNode(db, "g1", { id: "n1", kind: "unit", unitId: "U1", state: "done" });
|
persistGraphNode(db, "g1", {
|
||||||
persistGraphNode(db, "g1", { id: "n2", kind: "unit", unitId: "U2", state: "failed" });
|
id: "n1",
|
||||||
|
kind: "unit",
|
||||||
|
unitId: "U1",
|
||||||
|
state: "done",
|
||||||
|
});
|
||||||
|
persistGraphNode(db, "g1", {
|
||||||
|
id: "n2",
|
||||||
|
kind: "unit",
|
||||||
|
unitId: "U2",
|
||||||
|
state: "failed",
|
||||||
|
});
|
||||||
|
|
||||||
const summary = getGraphStateSummary(db, "g1");
|
const summary = getGraphStateSummary(db, "g1");
|
||||||
assert.equal(summary.progress, 100);
|
assert.equal(summary.progress, 100);
|
||||||
|
|
@ -297,7 +362,10 @@ test("getGraphProgressStream_returns_recent_first", () => {
|
||||||
ensureExecutionGraphSchema(db);
|
ensureExecutionGraphSchema(db);
|
||||||
|
|
||||||
persistProgressEvent(db, "g1", { type: "first", ts: "2024-01-01T00:00:00Z" });
|
persistProgressEvent(db, "g1", { type: "first", ts: "2024-01-01T00:00:00Z" });
|
||||||
persistProgressEvent(db, "g1", { type: "second", ts: "2024-01-01T00:00:01Z" });
|
persistProgressEvent(db, "g1", {
|
||||||
|
type: "second",
|
||||||
|
ts: "2024-01-01T00:00:01Z",
|
||||||
|
});
|
||||||
|
|
||||||
const events = getGraphProgressStream(db, "g1", 10);
|
const events = getGraphProgressStream(db, "g1", 10);
|
||||||
assert.equal(events[0].type, "second");
|
assert.equal(events[0].type, "second");
|
||||||
|
|
|
||||||
|
|
@ -61,14 +61,18 @@ test("token_onCancel_fires_immediately_if_already_cancelled", () => {
|
||||||
const token = new CancellationToken();
|
const token = new CancellationToken();
|
||||||
token.cancel("done");
|
token.cancel("done");
|
||||||
let fired = false;
|
let fired = false;
|
||||||
token.onCancel(() => { fired = true; });
|
token.onCancel(() => {
|
||||||
|
fired = true;
|
||||||
|
});
|
||||||
assert.equal(fired, true);
|
assert.equal(fired, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("token_onCancel_fires_when_cancelled_later", () => {
|
test("token_onCancel_fires_when_cancelled_later", () => {
|
||||||
const token = new CancellationToken();
|
const token = new CancellationToken();
|
||||||
let fired = false;
|
let fired = false;
|
||||||
token.onCancel(() => { fired = true; });
|
token.onCancel(() => {
|
||||||
|
fired = true;
|
||||||
|
});
|
||||||
assert.equal(fired, false);
|
assert.equal(fired, false);
|
||||||
token.cancel("later");
|
token.cancel("later");
|
||||||
assert.equal(fired, true);
|
assert.equal(fired, true);
|
||||||
|
|
@ -77,7 +81,9 @@ test("token_onCancel_fires_when_cancelled_later", () => {
|
||||||
test("token_onCancel_unsubscribe_works", () => {
|
test("token_onCancel_unsubscribe_works", () => {
|
||||||
const token = new CancellationToken();
|
const token = new CancellationToken();
|
||||||
let fired = false;
|
let fired = false;
|
||||||
const unsub = token.onCancel(() => { fired = true; });
|
const unsub = token.onCancel(() => {
|
||||||
|
fired = true;
|
||||||
|
});
|
||||||
unsub();
|
unsub();
|
||||||
token.cancel("nope");
|
token.cancel("nope");
|
||||||
assert.equal(fired, false);
|
assert.equal(fired, false);
|
||||||
|
|
@ -218,7 +224,7 @@ test("scheduler_serial_cancellation_stops_execution", async () => {
|
||||||
const scheduler = new ExecutionGraphSchedulerV2();
|
const scheduler = new ExecutionGraphSchedulerV2();
|
||||||
const order = [];
|
const order = [];
|
||||||
const globalToken = new CancellationToken();
|
const globalToken = new CancellationToken();
|
||||||
scheduler.registerHandler("unit", async (node, token) => {
|
scheduler.registerHandler("unit", async (node, _token) => {
|
||||||
if (node.id === "b") globalToken.cancel("stop");
|
if (node.id === "b") globalToken.cancel("stop");
|
||||||
globalToken.throwIfCancelled();
|
globalToken.throwIfCancelled();
|
||||||
order.push(node.id);
|
order.push(node.id);
|
||||||
|
|
@ -243,7 +249,7 @@ test("scheduler_serial_cancellation_stops_execution", async () => {
|
||||||
test("scheduler_parallel_runs_independent_nodes_concurrently", async () => {
|
test("scheduler_parallel_runs_independent_nodes_concurrently", async () => {
|
||||||
const scheduler = new ExecutionGraphSchedulerV2();
|
const scheduler = new ExecutionGraphSchedulerV2();
|
||||||
const starts = [];
|
const starts = [];
|
||||||
scheduler.registerHandler("unit", async (node, token, progress) => {
|
scheduler.registerHandler("unit", async (node, _token, _progress) => {
|
||||||
starts.push(node.id);
|
starts.push(node.id);
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
|
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
|
||||||
|
|
@ -264,7 +270,7 @@ test("scheduler_parallel_enforces_max_workers", async () => {
|
||||||
const scheduler = new ExecutionGraphSchedulerV2();
|
const scheduler = new ExecutionGraphSchedulerV2();
|
||||||
let concurrent = 0;
|
let concurrent = 0;
|
||||||
let maxConcurrent = 0;
|
let maxConcurrent = 0;
|
||||||
scheduler.registerHandler("unit", async (node) => {
|
scheduler.registerHandler("unit", async (_node) => {
|
||||||
concurrent++;
|
concurrent++;
|
||||||
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
@ -285,13 +291,11 @@ test("scheduler_parallel_enforces_max_workers", async () => {
|
||||||
|
|
||||||
test("scheduler_parallel_reports_progress_events", async () => {
|
test("scheduler_parallel_reports_progress_events", async () => {
|
||||||
const scheduler = new ExecutionGraphSchedulerV2();
|
const scheduler = new ExecutionGraphSchedulerV2();
|
||||||
scheduler.registerHandler("unit", async (node) => {
|
scheduler.registerHandler("unit", async (_node) => {
|
||||||
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
|
return { gateResults: [{ outcome: "pass", gateId: "test" }] };
|
||||||
});
|
});
|
||||||
|
|
||||||
const nodes = [
|
const nodes = [{ id: "a", kind: "unit", dependsOn: [], metadata: {} }];
|
||||||
{ id: "a", kind: "unit", dependsOn: [], metadata: {} },
|
|
||||||
];
|
|
||||||
|
|
||||||
const events = [];
|
const events = [];
|
||||||
scheduler.progress.onProgress((ev) => events.push(ev));
|
scheduler.progress.onProgress((ev) => events.push(ev));
|
||||||
|
|
@ -307,7 +311,7 @@ test("scheduler_parallel_reports_progress_events", async () => {
|
||||||
|
|
||||||
test("scheduler_parallel_task_records_have_correct_state", async () => {
|
test("scheduler_parallel_task_records_have_correct_state", async () => {
|
||||||
const scheduler = new ExecutionGraphSchedulerV2();
|
const scheduler = new ExecutionGraphSchedulerV2();
|
||||||
scheduler.registerHandler("unit", async (node) => {
|
scheduler.registerHandler("unit", async (_node) => {
|
||||||
return {
|
return {
|
||||||
gateResults: [
|
gateResults: [
|
||||||
{ outcome: "pass", gateId: "security" },
|
{ outcome: "pass", gateId: "security" },
|
||||||
|
|
@ -330,7 +334,7 @@ test("scheduler_parallel_task_records_have_correct_state", async () => {
|
||||||
|
|
||||||
test("scheduler_parallel_detects_cyclic_dependencies", async () => {
|
test("scheduler_parallel_detects_cyclic_dependencies", async () => {
|
||||||
const scheduler = new ExecutionGraphSchedulerV2();
|
const scheduler = new ExecutionGraphSchedulerV2();
|
||||||
scheduler.registerHandler("unit", async (node) => ({
|
scheduler.registerHandler("unit", async (_node) => ({
|
||||||
gateResults: [{ outcome: "pass", gateId: "test" }],
|
gateResults: [{ outcome: "pass", gateId: "test" }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -339,15 +343,12 @@ test("scheduler_parallel_detects_cyclic_dependencies", async () => {
|
||||||
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
|
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
|
||||||
];
|
];
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(scheduler.run(nodes, { parallel: true }), /cyclic/);
|
||||||
scheduler.run(nodes, { parallel: true }),
|
|
||||||
/cyclic/,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("scheduler_parallel_detects_deadlock", async () => {
|
test("scheduler_parallel_detects_deadlock", async () => {
|
||||||
const scheduler = new ExecutionGraphSchedulerV2();
|
const scheduler = new ExecutionGraphSchedulerV2();
|
||||||
scheduler.registerHandler("unit", async (node) => ({
|
scheduler.registerHandler("unit", async (_node) => ({
|
||||||
gateResults: [{ outcome: "pass", gateId: "test" }],
|
gateResults: [{ outcome: "pass", gateId: "test" }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -357,10 +358,7 @@ test("scheduler_parallel_detects_deadlock", async () => {
|
||||||
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
|
{ id: "b", kind: "unit", dependsOn: ["a"], metadata: {} },
|
||||||
];
|
];
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(scheduler.run(nodes, { parallel: true }), /cyclic/);
|
||||||
scheduler.run(nodes, { parallel: true }),
|
|
||||||
/cyclic/,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Progress stream wiring ────────────────────────────────────────────────
|
// ─── Progress stream wiring ────────────────────────────────────────────────
|
||||||
|
|
@ -375,9 +373,7 @@ test("scheduler_wires_progress_to_parent_stream", async () => {
|
||||||
const parentEvents = [];
|
const parentEvents = [];
|
||||||
parent.onProgress((ev) => parentEvents.push(ev));
|
parent.onProgress((ev) => parentEvents.push(ev));
|
||||||
|
|
||||||
const nodes = [
|
const nodes = [{ id: "x", kind: "unit", dependsOn: [], metadata: {} }];
|
||||||
{ id: "x", kind: "unit", dependsOn: [], metadata: {} },
|
|
||||||
];
|
|
||||||
|
|
||||||
await scheduler.run(nodes, { parallel: false, parentStream: parent });
|
await scheduler.run(nodes, { parallel: false, parentStream: parent });
|
||||||
assert.ok(parentEvents.some((e) => e.type === "node-complete"));
|
assert.ok(parentEvents.some((e) => e.type === "node-complete"));
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,12 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "vitest";
|
import { test } from "vitest";
|
||||||
import {
|
import {
|
||||||
TASK_STATES,
|
|
||||||
TASK_TERMINAL_STATES,
|
|
||||||
TASK_STATE_TRANSITIONS,
|
|
||||||
aggregateTaskStates,
|
aggregateTaskStates,
|
||||||
buildTaskRecord,
|
buildTaskRecord,
|
||||||
canTransitionTaskState,
|
canTransitionTaskState,
|
||||||
gateOutcomesToTaskState,
|
gateOutcomesToTaskState,
|
||||||
|
TASK_STATES,
|
||||||
|
TASK_TERMINAL_STATES,
|
||||||
unitRuntimeToTaskState,
|
unitRuntimeToTaskState,
|
||||||
} from "../uok/task-state.js";
|
} from "../uok/task-state.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,24 @@ export class UokCoordinationStore {
|
||||||
return decode(row.value_json);
|
return decode(row.value_json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entries(prefix = "") {
|
||||||
|
this.cleanupExpired();
|
||||||
|
return this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT key, value_json, expires_at, updated_at
|
||||||
|
FROM uok_kv
|
||||||
|
WHERE key LIKE :prefix
|
||||||
|
ORDER BY updated_at DESC, key ASC`,
|
||||||
|
)
|
||||||
|
.all({ prefix: `${prefix}%` })
|
||||||
|
.map((row) => ({
|
||||||
|
key: row.key,
|
||||||
|
value: decode(row.value_json),
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
delete(key) {
|
delete(key) {
|
||||||
this.db.prepare("DELETE FROM uok_kv WHERE key = :key").run({ key });
|
this.db.prepare("DELETE FROM uok_kv WHERE key = :key").run({ key });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -298,9 +298,15 @@ export function getGraphStateSummary(db, graphId) {
|
||||||
.all({ graphId });
|
.all({ graphId });
|
||||||
|
|
||||||
const counts = Object.fromEntries(
|
const counts = Object.fromEntries(
|
||||||
["todo", "in_progress", "review", "done", "retrying", "failed", "cancelled"].map(
|
[
|
||||||
(s) => [s, 0],
|
"todo",
|
||||||
),
|
"in_progress",
|
||||||
|
"review",
|
||||||
|
"done",
|
||||||
|
"retrying",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
].map((s) => [s, 0]),
|
||||||
);
|
);
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
|
|
||||||
|
|
@ -9,161 +9,157 @@
|
||||||
* extension that needs orchestration primitives.
|
* extension that needs orchestration primitives.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ─── Contracts & Types ────────────────────────────────────────────────────
|
// ─── Skills (Repo-local capabilities) ──────────────────────────────────────
|
||||||
export { validateGate } from "./contracts.js";
|
export {
|
||||||
|
buildSkillRecord,
|
||||||
// ─── Core Kernel ───────────────────────────────────────────────────────────
|
discoverAllSkills,
|
||||||
export { recordUokKernelTermination, runAutoLoopWithUok } from "./kernel.js";
|
discoverSkillDirs,
|
||||||
|
getModelInvocableSkills,
|
||||||
// ─── Gate System ───────────────────────────────────────────────────────────
|
getPermittedSkills,
|
||||||
export { UokGateRunner, enrichGateResultWithMemory } from "./gate-runner.js";
|
loadSkills,
|
||||||
|
parseSkillFrontmatter,
|
||||||
|
readSkillFile,
|
||||||
|
SKILL_FILENAME,
|
||||||
|
USER_SKILL_DIR,
|
||||||
|
validateSkillFrontmatter,
|
||||||
|
} from "../skills/index.js";
|
||||||
|
// ─── Audit & Observability ─────────────────────────────────────────────────
|
||||||
|
export { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
|
||||||
|
export {
|
||||||
|
isAuditEnvelopeEnabled,
|
||||||
|
setAuditEnvelopeEnabled,
|
||||||
|
} from "./audit-toggle.js";
|
||||||
|
|
||||||
// ─── Gates ─────────────────────────────────────────────────────────────────
|
// ─── Gates ─────────────────────────────────────────────────────────────────
|
||||||
export { ChaosMonkey, ChaosMonkeyGate } from "./chaos-monkey.js";
|
export { ChaosMonkey, ChaosMonkeyGate } from "./chaos-monkey.js";
|
||||||
export { CostGuardGate } from "./cost-guard-gate.js";
|
// ─── Contracts & Types ────────────────────────────────────────────────────
|
||||||
export { MultiPackageGate } from "./multi-package-gate.js";
|
export { validateGate } from "./contracts.js";
|
||||||
export { OutcomeLearningGate } from "./outcome-learning-gate.js";
|
|
||||||
export { SecurityGate } from "./security-gate.js";
|
|
||||||
|
|
||||||
// ─── Flags & Configuration ─────────────────────────────────────────────────
|
|
||||||
export { resolveUokFlags, loadUokFlags } from "./flags.js";
|
|
||||||
|
|
||||||
// ─── Execution Graph ───────────────────────────────────────────────────────
|
|
||||||
export {
|
|
||||||
selectConflictFreeBatch,
|
|
||||||
selectReactiveDispatchBatch,
|
|
||||||
buildSidecarQueueNodes,
|
|
||||||
buildExecutionGraphSnapshot,
|
|
||||||
scheduleSidecarQueue,
|
|
||||||
ExecutionGraphScheduler,
|
|
||||||
} from "./execution-graph.js";
|
|
||||||
|
|
||||||
// ─── Scheduler v2 (Background Work) ────────────────────────────────────────
|
|
||||||
export {
|
|
||||||
CancellationToken,
|
|
||||||
ProgressStream,
|
|
||||||
WorkerPool,
|
|
||||||
ExecutionGraphSchedulerV2,
|
|
||||||
} from "./scheduler-v2.js";
|
|
||||||
|
|
||||||
// ─── Task State Machine ────────────────────────────────────────────────────
|
|
||||||
export {
|
|
||||||
TASK_STATES,
|
|
||||||
TASK_TERMINAL_STATES,
|
|
||||||
TASK_STATE_TRANSITIONS,
|
|
||||||
gateOutcomesToTaskState,
|
|
||||||
unitRuntimeToTaskState,
|
|
||||||
canTransitionTaskState,
|
|
||||||
aggregateTaskStates,
|
|
||||||
buildTaskRecord,
|
|
||||||
} from "./task-state.js";
|
|
||||||
|
|
||||||
// ─── Execution Graph Persistence ───────────────────────────────────────────
|
|
||||||
export {
|
|
||||||
ensureExecutionGraphSchema,
|
|
||||||
persistGraphSnapshot,
|
|
||||||
persistGraphNode,
|
|
||||||
persistProgressEvent,
|
|
||||||
queryTasksByState,
|
|
||||||
getGraphStateSummary,
|
|
||||||
getGraphProgressStream,
|
|
||||||
persistFullGraph,
|
|
||||||
} from "./execution-graph-persist.js";
|
|
||||||
|
|
||||||
// ─── Coordination Store ───────────────────────────────────────────────────
|
// ─── Coordination Store ───────────────────────────────────────────────────
|
||||||
export {
|
export {
|
||||||
ensureCoordinationSchema,
|
ensureCoordinationSchema,
|
||||||
UokCoordinationStore,
|
UokCoordinationStore,
|
||||||
} from "./coordination-store.js";
|
} from "./coordination-store.js";
|
||||||
|
export { CostGuardGate } from "./cost-guard-gate.js";
|
||||||
// ─── Unit Runtime ──────────────────────────────────────────────────────────
|
|
||||||
export {
|
|
||||||
UNIT_RUNTIME_STATUSES,
|
|
||||||
UNIT_RUNTIME_TERMINAL_STATUSES,
|
|
||||||
UNIT_RUNTIME_TRANSITIONS,
|
|
||||||
isTerminalUnitRuntimeStatus,
|
|
||||||
getUnitRuntimeState,
|
|
||||||
isSyntheticUnitRuntime,
|
|
||||||
decideUnitRuntimeDispatch,
|
|
||||||
writeUnitRuntimeRecord,
|
|
||||||
readUnitRuntimeRecord,
|
|
||||||
clearUnitRuntimeRecord,
|
|
||||||
listUnitRuntimeRecords,
|
|
||||||
recordUnitOutcomeInMemory,
|
|
||||||
inspectExecuteTaskDurability,
|
|
||||||
formatExecuteTaskRecoveryStatus,
|
|
||||||
reconcileStaleCompleteSliceRecords,
|
|
||||||
reconcileDurableCompleteUnitRuntimeRecords,
|
|
||||||
} from "./unit-runtime.js";
|
|
||||||
|
|
||||||
// ─── Plan v2 ───────────────────────────────────────────────────────────────
|
|
||||||
export {
|
|
||||||
EXECUTION_ENTRY_PHASES,
|
|
||||||
isExecutionEntryPhase,
|
|
||||||
compileUnitGraphFromState,
|
|
||||||
hasFinalizedMilestoneContext,
|
|
||||||
isMissingFinalizedContextResult,
|
|
||||||
isEmptyPlanV2GraphResult,
|
|
||||||
ensurePlanV2Graph,
|
|
||||||
} from "./plan-v2.js";
|
|
||||||
|
|
||||||
// ─── Audit & Observability ─────────────────────────────────────────────────
|
|
||||||
export { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
|
|
||||||
export { setAuditEnvelopeEnabled, isAuditEnvelopeEnabled } from "./audit-toggle.js";
|
|
||||||
|
|
||||||
// ─── Diagnostics ───────────────────────────────────────────────────────────
|
// ─── Diagnostics ───────────────────────────────────────────────────────────
|
||||||
export {
|
export {
|
||||||
|
readUokDiagnostics,
|
||||||
synthesizeUokDiagnostics,
|
synthesizeUokDiagnostics,
|
||||||
writeUokDiagnostics,
|
writeUokDiagnostics,
|
||||||
readUokDiagnostics,
|
|
||||||
} from "./diagnostic-synthesis.js";
|
} from "./diagnostic-synthesis.js";
|
||||||
|
// ─── Dispatch Envelope ─────────────────────────────────────────────────────
|
||||||
|
export { buildDispatchEnvelope, explainDispatch } from "./dispatch-envelope.js";
|
||||||
|
|
||||||
// ─── Parity & Ledger ───────────────────────────────────────────────────────
|
// ─── Execution Graph ───────────────────────────────────────────────────────
|
||||||
export {
|
export {
|
||||||
writeParityReport,
|
buildExecutionGraphSnapshot,
|
||||||
readParityReport,
|
buildSidecarQueueNodes,
|
||||||
summarizeParityHealth,
|
ExecutionGraphScheduler,
|
||||||
writeParityHeartbeat,
|
scheduleSidecarQueue,
|
||||||
parseParityEvents,
|
selectConflictFreeBatch,
|
||||||
UNMATCHED_RUN_STALE_MS,
|
selectReactiveDispatchBatch,
|
||||||
signalKernelEnter,
|
} from "./execution-graph.js";
|
||||||
resetParityCommitBlock,
|
// ─── Execution Graph Persistence ───────────────────────────────────────────
|
||||||
checkAndDrainMissingExit,
|
|
||||||
} from "./parity-report.js";
|
|
||||||
export {
|
export {
|
||||||
signalKernelEnter as signalParityEnter,
|
ensureExecutionGraphSchema,
|
||||||
} from "./parity-diff-capture.js";
|
getGraphProgressStream,
|
||||||
|
getGraphStateSummary,
|
||||||
|
persistFullGraph,
|
||||||
|
persistGraphNode,
|
||||||
|
persistGraphSnapshot,
|
||||||
|
persistProgressEvent,
|
||||||
|
queryTasksByState,
|
||||||
|
} from "./execution-graph-persist.js";
|
||||||
|
// ─── Flags & Configuration ─────────────────────────────────────────────────
|
||||||
|
export { loadUokFlags, resolveUokFlags } from "./flags.js";
|
||||||
|
// ─── Gate System ───────────────────────────────────────────────────────────
|
||||||
|
export { enrichGateResultWithMemory, UokGateRunner } from "./gate-runner.js";
|
||||||
|
// ─── GitOps ────────────────────────────────────────────────────────────────
|
||||||
|
export {
|
||||||
|
writeTurnCloseoutGitRecord,
|
||||||
|
writeTurnGitTransaction,
|
||||||
|
} from "./gitops.js";
|
||||||
|
// ─── Core Kernel ───────────────────────────────────────────────────────────
|
||||||
|
export { recordUokKernelTermination, runAutoLoopWithUok } from "./kernel.js";
|
||||||
|
// ─── Loop Adapter ──────────────────────────────────────────────────────────
|
||||||
|
export { createTurnObserver } from "./loop-adapter.js";
|
||||||
// ─── Message Bus ───────────────────────────────────────────────────────────
|
// ─── Message Bus ───────────────────────────────────────────────────────────
|
||||||
export { AgentInbox, MessageBus } from "./message-bus.js";
|
export { AgentInbox, MessageBus } from "./message-bus.js";
|
||||||
|
|
||||||
// ─── Metrics ───────────────────────────────────────────────────────────────
|
// ─── Metrics ───────────────────────────────────────────────────────────────
|
||||||
export {
|
export {
|
||||||
buildMetricsText,
|
buildMetricsText,
|
||||||
invalidateMetricsCache,
|
invalidateMetricsCache,
|
||||||
metricsPath,
|
metricsPath,
|
||||||
writeUokMetrics,
|
|
||||||
readUokMetrics,
|
readUokMetrics,
|
||||||
|
writeUokMetrics,
|
||||||
} from "./metrics-exposition.js";
|
} from "./metrics-exposition.js";
|
||||||
|
// ─── Model Policy ──────────────────────────────────────────────────────────
|
||||||
// ─── GitOps ────────────────────────────────────────────────────────────────
|
export { applyModelPolicyFilter } from "./model-policy.js";
|
||||||
|
export { MultiPackageGate } from "./multi-package-gate.js";
|
||||||
|
export { OutcomeLearningGate } from "./outcome-learning-gate.js";
|
||||||
|
export { signalKernelEnter as signalParityEnter } from "./parity-diff-capture.js";
|
||||||
|
// ─── Parity & Ledger ───────────────────────────────────────────────────────
|
||||||
export {
|
export {
|
||||||
writeTurnGitTransaction,
|
checkAndDrainMissingExit,
|
||||||
writeTurnCloseoutGitRecord,
|
parseParityEvents,
|
||||||
} from "./gitops.js";
|
readParityReport,
|
||||||
|
resetParityCommitBlock,
|
||||||
|
signalKernelEnter,
|
||||||
|
summarizeParityHealth,
|
||||||
|
UNMATCHED_RUN_STALE_MS,
|
||||||
|
writeParityHeartbeat,
|
||||||
|
writeParityReport,
|
||||||
|
} from "./parity-report.js";
|
||||||
|
// ─── Plan v2 ───────────────────────────────────────────────────────────────
|
||||||
|
export {
|
||||||
|
compileUnitGraphFromState,
|
||||||
|
EXECUTION_ENTRY_PHASES,
|
||||||
|
ensurePlanV2Graph,
|
||||||
|
hasFinalizedMilestoneContext,
|
||||||
|
isEmptyPlanV2GraphResult,
|
||||||
|
isExecutionEntryPhase,
|
||||||
|
isMissingFinalizedContextResult,
|
||||||
|
} from "./plan-v2.js";
|
||||||
|
// ─── Scheduler v2 (Background Work) ────────────────────────────────────────
|
||||||
|
export {
|
||||||
|
CancellationToken,
|
||||||
|
ExecutionGraphSchedulerV2,
|
||||||
|
ProgressStream,
|
||||||
|
WorkerPool,
|
||||||
|
} from "./scheduler-v2.js";
|
||||||
|
export { SecurityGate } from "./security-gate.js";
|
||||||
|
// ─── Task State Machine ────────────────────────────────────────────────────
|
||||||
|
export {
|
||||||
|
aggregateTaskStates,
|
||||||
|
buildTaskRecord,
|
||||||
|
canTransitionTaskState,
|
||||||
|
gateOutcomesToTaskState,
|
||||||
|
TASK_STATE_TRANSITIONS,
|
||||||
|
TASK_STATES,
|
||||||
|
TASK_TERMINAL_STATES,
|
||||||
|
unitRuntimeToTaskState,
|
||||||
|
} from "./task-state.js";
|
||||||
|
// ─── Unit Runtime ──────────────────────────────────────────────────────────
|
||||||
|
export {
|
||||||
|
clearUnitRuntimeRecord,
|
||||||
|
decideUnitRuntimeDispatch,
|
||||||
|
formatExecuteTaskRecoveryStatus,
|
||||||
|
getUnitRuntimeState,
|
||||||
|
inspectExecuteTaskDurability,
|
||||||
|
isSyntheticUnitRuntime,
|
||||||
|
isTerminalUnitRuntimeStatus,
|
||||||
|
listUnitRuntimeRecords,
|
||||||
|
readUnitRuntimeRecord,
|
||||||
|
reconcileDurableCompleteUnitRuntimeRecords,
|
||||||
|
reconcileStaleCompleteSliceRecords,
|
||||||
|
recordUnitOutcomeInMemory,
|
||||||
|
UNIT_RUNTIME_STATUSES,
|
||||||
|
UNIT_RUNTIME_TERMINAL_STATUSES,
|
||||||
|
UNIT_RUNTIME_TRANSITIONS,
|
||||||
|
writeUnitRuntimeRecord,
|
||||||
|
} from "./unit-runtime.js";
|
||||||
// ─── Writer Token ──────────────────────────────────────────────────────────
|
// ─── Writer Token ──────────────────────────────────────────────────────────
|
||||||
export {
|
export {
|
||||||
acquireWriterToken,
|
acquireWriterToken,
|
||||||
releaseWriterToken,
|
|
||||||
nextWriteRecord,
|
nextWriteRecord,
|
||||||
|
releaseWriterToken,
|
||||||
} from "./writer.js";
|
} from "./writer.js";
|
||||||
|
|
||||||
// ─── Model Policy ──────────────────────────────────────────────────────────
|
|
||||||
export { applyModelPolicyFilter } from "./model-policy.js";
|
|
||||||
|
|
||||||
// ─── Loop Adapter ──────────────────────────────────────────────────────────
|
|
||||||
export { createTurnObserver } from "./loop-adapter.js";
|
|
||||||
|
|
||||||
// ─── Dispatch Envelope ─────────────────────────────────────────────────────
|
|
||||||
export { buildDispatchEnvelope, explainDispatch } from "./dispatch-envelope.js";
|
|
||||||
|
|
|
||||||
|
|
@ -110,10 +110,15 @@ export async function runAutoLoopWithUok(args) {
|
||||||
prefs?.uok?.permission_profile ??
|
prefs?.uok?.permission_profile ??
|
||||||
defaultPermissionProfileForRunControl(runControl),
|
defaultPermissionProfileForRunControl(runControl),
|
||||||
);
|
);
|
||||||
|
// Include workMode and modelMode from session in lifecycle flags
|
||||||
|
const workMode = s.workMode ?? "chat";
|
||||||
|
const modelMode = s.modelMode ?? "smart";
|
||||||
const lifecycleFlags = {
|
const lifecycleFlags = {
|
||||||
...flags,
|
...flags,
|
||||||
runControl,
|
runControl,
|
||||||
permissionProfile,
|
permissionProfile,
|
||||||
|
workMode,
|
||||||
|
modelMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
const healthVerdict = writeUokDiagnostics(s.basePath);
|
const healthVerdict = writeUokDiagnostics(s.basePath);
|
||||||
|
|
@ -166,6 +171,8 @@ export async function runAutoLoopWithUok(args) {
|
||||||
flags: lifecycleFlags,
|
flags: lifecycleFlags,
|
||||||
runControl,
|
runControl,
|
||||||
permissionProfile,
|
permissionProfile,
|
||||||
|
workMode,
|
||||||
|
modelMode,
|
||||||
sessionId: ctx.sessionManager?.getSessionId?.(),
|
sessionId: ctx.sessionManager?.getSessionId?.(),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,11 @@ export class CancellationToken {
|
||||||
this.#cancelled = true;
|
this.#cancelled = true;
|
||||||
this.#reason = reason;
|
this.#reason = reason;
|
||||||
for (const fn of this.#listeners) {
|
for (const fn of this.#listeners) {
|
||||||
try { fn(reason); } catch { /* ignore */ }
|
try {
|
||||||
|
fn(reason);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.#listeners.clear();
|
this.#listeners.clear();
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +54,11 @@ export class CancellationToken {
|
||||||
|
|
||||||
onCancel(fn) {
|
onCancel(fn) {
|
||||||
if (this.#cancelled) {
|
if (this.#cancelled) {
|
||||||
try { fn(this.#reason); } catch { /* ignore */ }
|
try {
|
||||||
|
fn(this.#reason);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
this.#listeners.add(fn);
|
this.#listeners.add(fn);
|
||||||
|
|
@ -84,7 +92,11 @@ export class ProgressStream {
|
||||||
this.#history = this.#history.slice(-this.#maxHistory);
|
this.#history = this.#history.slice(-this.#maxHistory);
|
||||||
}
|
}
|
||||||
for (const fn of this.#listeners) {
|
for (const fn of this.#listeners) {
|
||||||
try { fn(enriched); } catch { /* ignore */ }
|
try {
|
||||||
|
fn(enriched);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,7 +104,11 @@ export class ProgressStream {
|
||||||
this.#listeners.add(fn);
|
this.#listeners.add(fn);
|
||||||
// Replay recent history so new listener catches up
|
// Replay recent history so new listener catches up
|
||||||
for (const ev of this.#history) {
|
for (const ev of this.#history) {
|
||||||
try { fn(ev); } catch { /* ignore */ }
|
try {
|
||||||
|
fn(ev);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return () => this.#listeners.delete(fn);
|
return () => this.#listeners.delete(fn);
|
||||||
}
|
}
|
||||||
|
|
@ -312,7 +328,6 @@ export class ExecutionGraphSchedulerV2 {
|
||||||
}
|
}
|
||||||
|
|
||||||
async #runParallel(sorted, token, conflicts, maxWorkers) {
|
async #runParallel(sorted, token, conflicts, maxWorkers) {
|
||||||
const nodeMap = new Map(sorted.map((n) => [n.id, n]));
|
|
||||||
const done = new Set();
|
const done = new Set();
|
||||||
const order = [];
|
const order = [];
|
||||||
const promises = new Map(); // nodeId -> promise
|
const promises = new Map(); // nodeId -> promise
|
||||||
|
|
@ -352,11 +367,7 @@ export class ExecutionGraphSchedulerV2 {
|
||||||
.acquire(node.id, nodeToken)
|
.acquire(node.id, nodeToken)
|
||||||
.then(async ({ workerId, release }) => {
|
.then(async ({ workerId, release }) => {
|
||||||
try {
|
try {
|
||||||
const result = await this.#executeNode(
|
const result = await this.#executeNode(node, nodeToken, workerId);
|
||||||
node,
|
|
||||||
nodeToken,
|
|
||||||
workerId,
|
|
||||||
);
|
|
||||||
done.add(node.id);
|
done.add(node.id);
|
||||||
order.push(result.nodeId);
|
order.push(result.nodeId);
|
||||||
promises.delete(node.id);
|
promises.delete(node.id);
|
||||||
|
|
@ -445,19 +456,23 @@ export class ExecutionGraphSchedulerV2 {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err;
|
error = err;
|
||||||
if (err.name === "CancellationError") {
|
if (err.name === "CancellationError") {
|
||||||
gateResults = [{
|
gateResults = [
|
||||||
gateId: "scheduler",
|
{
|
||||||
outcome: "fail",
|
gateId: "scheduler",
|
||||||
failureClass: "cancelled",
|
outcome: "fail",
|
||||||
rationale: err.message,
|
failureClass: "cancelled",
|
||||||
}];
|
rationale: err.message,
|
||||||
|
},
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
gateResults = [{
|
gateResults = [
|
||||||
gateId: "scheduler",
|
{
|
||||||
outcome: "fail",
|
gateId: "scheduler",
|
||||||
failureClass: "execution",
|
outcome: "fail",
|
||||||
rationale: err.message,
|
failureClass: "execution",
|
||||||
}];
|
rationale: err.message,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(nodeTimeoutHandle);
|
clearTimeout(nodeTimeoutHandle);
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,7 @@
|
||||||
* background-work tracking.
|
* background-work tracking.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { isDbAvailable } from "../sf-db.js";
|
import { isTerminalUnitRuntimeStatus } from "./unit-runtime.js";
|
||||||
import { isTerminalUnitRuntimeStatus, UNIT_RUNTIME_STATUSES } from "./unit-runtime.js";
|
|
||||||
|
|
||||||
export const TASK_STATES = [
|
export const TASK_STATES = [
|
||||||
"todo",
|
"todo",
|
||||||
|
|
@ -119,16 +118,14 @@ export function aggregateTaskStates(taskStates) {
|
||||||
if (s in counts) counts[s]++;
|
if (s in counts) counts[s]++;
|
||||||
}
|
}
|
||||||
const total = taskStates.length;
|
const total = taskStates.length;
|
||||||
const terminal = TASK_STATES.filter((s) => TASK_TERMINAL_STATES.has(s)).reduce(
|
const terminal = TASK_STATES.filter((s) =>
|
||||||
(sum, s) => sum + counts[s],
|
TASK_TERMINAL_STATES.has(s),
|
||||||
0,
|
).reduce((sum, s) => sum + counts[s], 0);
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
counts,
|
counts,
|
||||||
total,
|
total,
|
||||||
terminal,
|
terminal,
|
||||||
progress:
|
progress: total > 0 ? Math.round((terminal / total) * 100) : 0,
|
||||||
total > 0 ? Math.round(((terminal / total) * 100)) : 0,
|
|
||||||
isComplete: terminal === total && total > 0,
|
isComplete: terminal === total && total > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -158,9 +155,10 @@ export function buildTaskRecord({
|
||||||
modelId = null,
|
modelId = null,
|
||||||
workerId = null,
|
workerId = null,
|
||||||
}) {
|
}) {
|
||||||
const state = gateResults.length > 0
|
const state =
|
||||||
? gateOutcomesToTaskState(gateResults)
|
gateResults.length > 0
|
||||||
: unitRuntimeToTaskState(runtimeRecord);
|
? gateOutcomesToTaskState(gateResults)
|
||||||
|
: unitRuntimeToTaskState(runtimeRecord);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue