fix: make autonomous mode canonical
This commit is contained in:
parent
55e7dd0e02
commit
4c98cb8c33
166 changed files with 528 additions and 11914 deletions
43
README.md
43
README.md
|
|
@ -29,15 +29,10 @@ One command. Walk away. Come back to a built project with clean git history.
|
|||
|
||||
## What's New in v2.71
|
||||
|
||||
### MCP Secure Env Collect
|
||||
### External Tooling
|
||||
|
||||
- **Secure credential collection over MCP** — the new `secure_env_collect` tool uses MCP form elicitation to collect secrets (API keys, tokens) from external clients without exposing values in tool output. Masks input in interactive mode.
|
||||
- **Hardened elicitation schema** — MCP elicitation schema handling is stricter, with proper validation and fallback for providers that don't support forms.
|
||||
|
||||
### MCP Reliability
|
||||
|
||||
- **Stream ordering preserved** — MCP tool output now renders in the correct order, fixing interleaved output in Claude Code and other MCP clients.
|
||||
- **isError flag propagation** — workflow tool execution failures now correctly return `isError: true`, so MCP clients can distinguish success from failure.
|
||||
- **External MCP tool configs** — SF can connect to project-local MCP tool servers for third-party services and local integrations.
|
||||
- **Stream ordering preserved** — external tool output now renders in the correct order, including MCP tool calls surfaced by model/runtime adapters.
|
||||
- **Multi-round discuss questions** — new-project discuss phase supports multi-round questioning with structured question gates.
|
||||
|
||||
### Model Selection Hardening
|
||||
|
|
@ -49,7 +44,7 @@ One command. Walk away. Come back to a built project with clean git history.
|
|||
|
||||
### Auto-Mode Resilience
|
||||
|
||||
- **Credential cooldown recovery** — auto-mode survives transient 429 rate-limit responses with structured cooldown errors and a bounded retry budget.
|
||||
- **Credential cooldown recovery** — autonomous mode survives transient 429 rate-limit responses with structured cooldown errors and a bounded retry budget.
|
||||
- **Fire-and-forget auto start** — auto start is detached from active turns to prevent blocking.
|
||||
- **Scoped forensics** — stuck-loop forensics are now scoped to auto sessions only, preventing false positives in interactive use.
|
||||
|
||||
|
|
@ -85,10 +80,9 @@ See the full [Changelog](./CHANGELOG.md) for details on every release.
|
|||
<details>
|
||||
<summary>Previous highlights (v2.70 and earlier)</summary>
|
||||
|
||||
- **Full workflow over MCP (v2.68)** — slice replanning, milestone management, slice completion, task completion, and core planning tools exposed over MCP
|
||||
- **Transport-gated MCP (v2.68)** — workflow tool availability adapts to provider transport capabilities automatically
|
||||
- **External MCP integrations (v2.68)** — project-local MCP configs connect SF to external tools; SF workflow is no longer exposed as MCP
|
||||
- **Contextual tips system (v2.68)** — TUI and web terminal surface contextual tips based on workflow state
|
||||
- **Ask user questions over MCP (v2.70)** — interactive questions exposed via elicitation for external integrations
|
||||
- **Structured questions** — interactive prompts stay inside SF's direct runtime flow
|
||||
- **Tiered Context Injection (M005)** — relevance-scoped context with 65%+ token reduction
|
||||
- **Resilient transient error recovery** — defers to Core RetryHandler and fixes cmdCtx race conditions
|
||||
- **Anthropic subscription routing** — auto-routed through Claude Code CLI provider with proper display names
|
||||
|
|
@ -96,7 +90,7 @@ See the full [Changelog](./CHANGELOG.md) for details on every release.
|
|||
- **Discussion gate enforcement** — mechanical enforcement with fail-closed behavior
|
||||
- **Slice-level parallelism** — dependency-aware parallel dispatch within a milestone
|
||||
- **Persistent notification panel** — TUI overlay, widget, and web API for real-time notifications
|
||||
- **MCP server** — 6 read-only project state tools for external integrations, auto-wrapup guard, and question dedup
|
||||
- **MCP client integrations** — external tool servers can be discovered and used from SF sessions
|
||||
- **Ollama extension** — first-class local LLM support via Ollama, with dynamic routing enabled by default
|
||||
- **Discord bot & daemon** — dedicated daemon package, Discord bot, and headless text mode with tool calls
|
||||
- **Capability-aware model routing (ADR-004)** — capability scoring, `before_model_select` hook, and task metadata extraction
|
||||
|
|
@ -104,7 +98,7 @@ See the full [Changelog](./CHANGELOG.md) for details on every release.
|
|||
- **`/sf parallel watch`** — native TUI overlay for real-time worker monitoring
|
||||
- **Codebase map** — automatic codebase map injection for fresh agent contexts
|
||||
- **`--resume` flag** — resume previous sessions from the CLI
|
||||
- **Concurrent invocation guard** — prevents overlapping auto-mode runs
|
||||
- **Concurrent invocation guard** — prevents overlapping autonomous mode runs
|
||||
- **VS Code integration** — status bar, file decorations, bash terminal, session tree, conversation history, and code lens
|
||||
- **Skills overhaul** — 30+ skill packs covering major frameworks, databases, and cloud platforms
|
||||
- **Single-writer state engine** — disciplined state transitions with machine guards and TOCTOU hardening
|
||||
|
|
@ -237,7 +231,7 @@ This is what makes SF different. Run it, walk away, come back to built software.
|
|||
/sf autonomous
|
||||
```
|
||||
|
||||
Autonomous mode is a state machine driven by files on disk. It reads `.sf/STATE.md`, determines the next unit of work, creates a fresh agent session, injects a focused prompt with all relevant context pre-inlined, and lets the LLM execute. When the LLM finishes, autonomous mode reads disk state again and dispatches the next unit. `/sf auto` remains supported as a short alias.
|
||||
Autonomous mode is a state machine driven by files on disk. It reads `.sf/STATE.md`, determines the next unit of work, creates a fresh agent session, injects a focused prompt with all relevant context pre-inlined, and lets the LLM execute. When the LLM finishes, autonomous mode reads disk state again and dispatches the next unit. Legacy `/sf auto` remains accepted only for compatibility; new prompts and docs use `/sf autonomous`.
|
||||
|
||||
**What happens under the hood:**
|
||||
|
||||
|
|
@ -251,7 +245,7 @@ Autonomous mode is a state machine driven by files on disk. It reads `.sf/STATE.
|
|||
|
||||
5. **Provider error recovery** — Transient provider errors (rate limits, 500/503 server errors, overloaded) auto-resume after a delay. Permanent errors (auth, billing) pause for manual review. The model fallback chain retries transient network errors before switching models.
|
||||
|
||||
6. **Stuck detection** — A sliding-window detector identifies repeated dispatch patterns (including multi-unit cycles). On detection, it retries once with a deep diagnostic. If it fails again, auto mode stops with the exact file it expected.
|
||||
6. **Stuck detection** — A sliding-window detector identifies repeated dispatch patterns (including multi-unit cycles). On detection, it retries once with a deep diagnostic. If it fails again, autonomous mode stops with the exact file it expected.
|
||||
|
||||
7. **Timeout supervision** — Soft timeout warns the LLM to wrap up. Idle watchdog detects stalls. Hard timeout pauses autonomous mode. Recovery steering nudges the LLM to finish durable output before giving up.
|
||||
|
||||
|
|
@ -317,7 +311,7 @@ SF opens an interactive agent session. From there, you have two ways to work:
|
|||
|
||||
**`/sf` — step mode.** Type `/sf` and SF executes one unit of work at a time, pausing between each with a wizard showing what completed and what's next. Same state machine as autonomous mode, but you stay in the loop. No project yet? It starts the discussion flow. Roadmap exists? It plans or executes the next step.
|
||||
|
||||
**`/sf autonomous` — autonomous mode.** Type `/sf autonomous` and walk away. SF researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. Fresh context window per task. No babysitting. `/sf auto` is an alias.
|
||||
**`/sf autonomous` — autonomous mode.** Type `/sf autonomous` and walk away. SF researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. Fresh context window per task. No babysitting.
|
||||
|
||||
### Two terminals, one project
|
||||
|
||||
|
|
@ -377,20 +371,19 @@ On first run, SF launches a branded setup wizard that walks you through LLM prov
|
|||
| `/sf` | Step mode — executes one unit at a time, pauses between each |
|
||||
| `/sf next` | Explicit step mode (same as bare `/sf`) |
|
||||
| `/sf autonomous` | Autonomous mode — researches, plans, executes, commits, repeats |
|
||||
| `/sf auto` | Alias for `/sf autonomous` |
|
||||
| `/sf quick` | Execute a quick task with SF guarantees, skip planning overhead |
|
||||
| `/sf stop` | Stop autonomous mode gracefully |
|
||||
| `/sf steer` | Hard-steer plan documents during execution |
|
||||
| `/sf discuss` | Discuss architecture and decisions (works alongside autonomous mode) |
|
||||
| `/sf rethink` | Conversational project reorganization |
|
||||
| `/sf mcp` | MCP server status and connectivity |
|
||||
| `/sf mcp` | External MCP server status and connectivity |
|
||||
| `/sf status` | Progress dashboard |
|
||||
| `/sf queue` | Queue future milestones (safe during autonomous mode) |
|
||||
| `/sf prefs` | Model selection, timeouts, budget ceiling |
|
||||
| `/sf migrate` | Migrate a v1 `.planning` directory to `.sf` format |
|
||||
| `/sf help` | Categorized command reference for all SF subcommands |
|
||||
| `/sf mode` | Switch workflow mode (solo/team) with coordinated defaults |
|
||||
| `/sf forensics` | Full-access SF debugger for auto-mode failure investigation |
|
||||
| `/sf forensics` | Full-access SF debugger for autonomous mode failure investigation |
|
||||
| `/sf cleanup` | Archive phase directories from completed milestones |
|
||||
| `/sf doctor` | Runtime health checks — issues surface across widget, visualizer, and reports |
|
||||
| `/sf keys` | API key manager — list, add, remove, test, rotate, doctor |
|
||||
|
|
@ -536,7 +529,7 @@ auto_report: true
|
|||
| `verification_commands`| Array of shell commands to run after task execution (e.g., `["npm run lint", "npm run test"]`) |
|
||||
| `verification_auto_fix`| Auto-retry on verification failures (default: true) |
|
||||
| `verification_max_retries` | Max retries for verification failures (default: 2) |
|
||||
| `phases.require_slice_discussion` | Pause auto-mode before each slice for human discussion review |
|
||||
| `phases.require_slice_discussion` | Pause autonomous mode before each slice for human discussion review |
|
||||
| `auto_report` | Auto-generate HTML reports after milestone completion (default: true) |
|
||||
|
||||
### Agent Instructions
|
||||
|
|
@ -547,7 +540,7 @@ Place an `AGENTS.md` file in any directory to provide persistent behavioral guid
|
|||
|
||||
### Debug Mode
|
||||
|
||||
Start SF with `sf --debug` to enable structured JSONL diagnostic logging. Debug logs capture dispatch decisions, state transitions, and timing data for troubleshooting auto-mode issues.
|
||||
Start SF with `sf --debug` to enable structured JSONL diagnostic logging. Debug logs capture dispatch decisions, state transitions, and timing data for troubleshooting autonomous mode issues.
|
||||
|
||||
### Token Optimization
|
||||
|
||||
|
|
@ -622,9 +615,9 @@ The best practice for working in teams is to ensure unique milestone names acros
|
|||
|
||||
```bash
|
||||
# ── SF: Runtime / Ephemeral (per-developer, per-session) ──────────────────
|
||||
# Crash detection sentinel — PID lock, written per auto-mode session
|
||||
# Crash detection sentinel — PID lock, written per autonomous mode session
|
||||
.sf/auto.lock
|
||||
# Auto-mode dispatch tracker — prevents re-running completed units (includes archived per-milestone files)
|
||||
# Autonomous mode dispatch tracker — prevents re-running completed units (includes archived per-milestone files)
|
||||
.sf/completed-units*.json
|
||||
# State manifest — workflow state for recovery
|
||||
.sf/state-manifest.json
|
||||
|
|
@ -735,7 +728,7 @@ Anthropic, Anthropic (Vertex AI), OpenAI, Google (Gemini), OpenRouter, GitHub Co
|
|||
|
||||
### OAuth / Max Plans
|
||||
|
||||
If you have a **Claude Max**, **Codex**, or **GitHub Copilot** subscription, you can use those directly — Pi handles the OAuth flow. No API key needed.
|
||||
If you have a **Claude Max**, **Codex**, or **GitHub Copilot** subscription, SF can use the corresponding local authenticated runtime/provider adapter directly. Claude Code and Codex are not project MCP dependencies; they are model/runtime routes. Gemini can also route through the Gemini CLI core path where configured.
|
||||
|
||||
> **⚠️ Important:** Using OAuth tokens from subscription plans outside their native applications may violate the provider's Terms of Service. In particular:
|
||||
>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ docker sandbox create --template ./docker --name sf-sandbox
|
|||
docker sandbox exec -it sf-sandbox bash
|
||||
|
||||
# Inside the sandbox, run SF
|
||||
sf auto "implement the feature described in issue #42"
|
||||
sf autonomous "implement the feature described in issue #42"
|
||||
```
|
||||
|
||||
### Option B: Docker Compose
|
||||
|
|
@ -56,7 +56,7 @@ docker compose -f docker/docker-compose.yaml up -d
|
|||
docker exec -it sf-sandbox bash
|
||||
|
||||
# 4. Run SF inside the container
|
||||
sf auto "implement the feature described in issue #42"
|
||||
sf autonomous "implement the feature described in issue #42"
|
||||
```
|
||||
|
||||
## UID/GID Remapping
|
||||
|
|
@ -89,7 +89,7 @@ SF's recommended workflow uses two terminals — one for auto mode, one for inte
|
|||
```bash
|
||||
# Terminal 1: auto mode
|
||||
docker sandbox exec -it sf-sandbox bash
|
||||
sf auto "your task description"
|
||||
sf autonomous "your task description"
|
||||
|
||||
# Terminal 2: discuss / monitor
|
||||
docker sandbox exec -it sf-sandbox bash
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Simplified Chinese translation: [`zh-CN/`](./zh-CN/).
|
|||
| [Getting Started](./user-docs/getting-started.md) | Installation, first run, and basic usage |
|
||||
| [Autonomous Mode](./user-docs/auto-mode.md) | How autonomous execution works — the state machine, crash recovery, and steering |
|
||||
| [Commands Reference](./user-docs/commands.md) | All commands, keyboard shortcuts, and CLI flags |
|
||||
| [Remote Questions](./user-docs/remote-questions.md) | Discord and Slack integration for headless auto-mode |
|
||||
| [Remote Questions](./user-docs/remote-questions.md) | Discord and Slack integration for headless autonomous-mode |
|
||||
| [Configuration](./user-docs/configuration.md) | Preferences, model selection, git settings, and token profiles |
|
||||
| [Provider Setup](./user-docs/providers.md) | Step-by-step setup for OpenRouter, Ollama, LM Studio, vLLM, and all supported providers |
|
||||
| [Custom Models](./user-docs/custom-models.md) | Advanced model configuration — models.json schema, compat flags, overrides |
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
|
||||
## Restart Loop (headless daemon mode)
|
||||
|
||||
`sf headless auto --max-restarts 3` applies exponential backoff: 5 s → 10 s → 30 s (cap). After exhausting restarts the parent exits with code 1. Each restart resumes via crash recovery above.
|
||||
`sf headless autonomous --max-restarts 3` applies exponential backoff: 5 s → 10 s → 30 s (cap). After exhausting restarts the parent exits with code 1. Each restart resumes via crash recovery above.
|
||||
|
||||
## Observability
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Autonomous Mode
|
||||
|
||||
Autonomous mode is SF's product-development execution engine. Run `/sf autonomous`, walk away, come back to built software with clean git history. `/sf auto` remains supported as a short alias.
|
||||
Autonomous mode is SF's product-development execution engine. Run `/sf autonomous`, walk away, come back to built software with clean git history. `/sf autonomous` remains supported as a short alias.
|
||||
|
||||
## How It Works
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ When your project has independent milestones, you can run them simultaneously. E
|
|||
|
||||
A lock file tracks the current unit. If the session dies, the next `/sf autonomous` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context.
|
||||
|
||||
**Headless auto-restart (v2.26):** When running `sf headless auto`, crashes trigger automatic restart with exponential backoff (5s → 10s → 30s cap, default 3 attempts). Configure with `--max-restarts N`. SIGINT/SIGTERM bypasses restart. Combined with crash recovery, this enables true overnight "run until done" execution.
|
||||
**Headless auto-restart (v2.26):** When running `sf headless autonomous`, crashes trigger automatic restart with exponential backoff (5s → 10s → 30s cap, default 3 attempts). Configure with `--max-restarts N`. SIGINT/SIGTERM bypasses restart. Combined with crash recovery, this enables true overnight "run until done" execution.
|
||||
|
||||
### Provider Error Recovery
|
||||
|
||||
|
|
@ -89,13 +89,13 @@ Commits are generated from task summaries — not generic "complete task" messag
|
|||
|
||||
### Stuck Detection (v2.39)
|
||||
|
||||
SF uses a sliding-window analysis to detect stuck loops. Instead of a simple "same unit dispatched twice" counter, the detector examines recent dispatch history for repeated patterns — catching cycles like A→B→A→B as well as single-unit repeats. On detection, SF retries once with a deep diagnostic prompt. If it fails again, auto mode stops with the exact file it expected, so you can intervene.
|
||||
SF uses a sliding-window analysis to detect stuck loops. Instead of a simple "same unit dispatched twice" counter, the detector examines recent dispatch history for repeated patterns — catching cycles like A→B→A→B as well as single-unit repeats. On detection, SF retries once with a deep diagnostic prompt. If it fails again, autonomous mode stops with the exact file it expected, so you can intervene.
|
||||
|
||||
The sliding-window approach reduces false positives on legitimate retries (e.g., verification failures that self-correct) while catching genuine stuck loops faster.
|
||||
|
||||
### Post-Mortem Investigation (v2.40)
|
||||
|
||||
`/sf forensics` is a full-access SF debugger for post-mortem analysis of auto-mode failures. It provides:
|
||||
`/sf forensics` is a full-access SF debugger for post-mortem analysis of autonomous mode failures. It provides:
|
||||
|
||||
- **Anomaly detection** — structured identification of stuck loops, cost spikes, timeouts, missing artifacts, and crashes with severity levels
|
||||
- **Unit traces** — last 10 unit executions with error details and execution times
|
||||
|
|
@ -117,7 +117,7 @@ Three timeout tiers prevent runaway sessions:
|
|||
|---------|---------|----------|
|
||||
| Soft | 20 min | Warns the LLM to wrap up |
|
||||
| Idle | 10 min | Detects stalls, intervenes |
|
||||
| Hard | 30 min | Pauses auto mode |
|
||||
| Hard | 30 min | Pauses autonomous mode |
|
||||
|
||||
Recovery steering nudges the LLM to finish durable output before timing out. Configure in preferences:
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ auto_supervisor:
|
|||
|
||||
### Cost Tracking
|
||||
|
||||
Every unit's token usage and cost is captured, broken down by phase, slice, and model. The dashboard shows running totals and projections. Budget ceilings can pause auto mode before overspending.
|
||||
Every unit's token usage and cost is captured, broken down by phase, slice, and model. The dashboard shows running totals and projections. Budget ceilings can pause autonomous mode before overspending.
|
||||
|
||||
See [Cost Management](./cost-management.md).
|
||||
|
||||
|
|
@ -160,7 +160,7 @@ For projects where you want human review before each slice begins:
|
|||
require_slice_discussion: true
|
||||
```
|
||||
|
||||
Auto-mode pauses before each slice, presenting the slice context for discussion. After you confirm, execution continues. Useful for high-stakes projects where you want to review the plan before the agent builds.
|
||||
Autonomous mode pauses before each slice, presenting the slice context for discussion. After you confirm, execution continues. Useful for high-stakes projects where you want to review the plan before the agent builds.
|
||||
|
||||
### HTML Reports (v2.26)
|
||||
|
||||
|
|
@ -174,7 +174,7 @@ Generate manually anytime with `/sf export --html`, or generate reports for all
|
|||
|
||||
### Failure Recovery (v2.28)
|
||||
|
||||
v2.28 hardens auto-mode reliability with multiple safeguards: atomic file writes prevent corruption on crash, OAuth fetch timeouts (30s) prevent indefinite hangs, RPC subprocess exit is detected and reported, and blob garbage collection prevents unbounded disk growth. Combined with the existing crash recovery and headless auto-restart, auto-mode is designed for true "fire and forget" overnight execution.
|
||||
v2.28 hardens autonomous mode reliability with multiple safeguards: atomic file writes prevent corruption on crash, OAuth fetch timeouts (30s) prevent indefinite hangs, RPC subprocess exit is detected and reported, and blob garbage collection prevents unbounded disk growth. Combined with the existing crash recovery and headless autonomous-restart, autonomous mode is designed for true "fire and forget" overnight execution.
|
||||
|
||||
### Pipeline Architecture (v2.40)
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ Doctor issues (from `/sf doctor`) now surface in real time across three places:
|
|||
- **Workflow visualizer** — issues shown in the status panel
|
||||
- **HTML reports** — health section with all issues at report generation time
|
||||
|
||||
Issues are classified by severity: `error` (blocks auto-mode), `warning` (non-blocking), and `info` (advisory). Auto-mode checks health at dispatch time and can pause on critical issues.
|
||||
Issues are classified by severity: `error` (blocks autonomous mode), `warning` (non-blocking), and `info` (advisory). Autonomous mode checks health at dispatch time and can pause on critical issues.
|
||||
|
||||
### Skill Activation in Prompts (v2.39)
|
||||
|
||||
|
|
@ -216,8 +216,6 @@ See [Configuration](./configuration.md) for skill routing preferences.
|
|||
/sf autonomous
|
||||
```
|
||||
|
||||
`/sf auto` is equivalent to `/sf autonomous`.
|
||||
|
||||
### Pause
|
||||
|
||||
Press **Escape**. The conversation is preserved. You can interact with the agent, inspect state, or resume.
|
||||
|
|
@ -236,7 +234,7 @@ Autonomous mode reads disk state and picks up where it left off.
|
|||
/sf stop
|
||||
```
|
||||
|
||||
Stops auto mode gracefully. Can be run from a different terminal.
|
||||
Stops autonomous mode gracefully. Can be run from a different terminal.
|
||||
|
||||
### Steer
|
||||
|
||||
|
|
@ -288,7 +286,7 @@ See [Token Optimization](./token-optimization.md) for details.
|
|||
|
||||
## Dynamic Model Routing
|
||||
|
||||
When enabled, auto-mode automatically selects cheaper models for simple units (slice completion, UAT) and reserves expensive models for complex work (replanning, architectural tasks). See [Dynamic Model Routing](./dynamic-model-routing.md).
|
||||
When enabled, autonomous mode automatically selects cheaper models for simple units (slice completion, UAT) and reserves expensive models for complex work (replanning, architectural tasks). See [Dynamic Model Routing](./dynamic-model-routing.md).
|
||||
|
||||
## Reactive Task Execution (v2.38)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
*Introduced in v2.19.0*
|
||||
|
||||
Captures let you fire-and-forget thoughts during auto-mode execution. Instead of pausing auto-mode to steer, you can capture ideas, bugs, or scope changes and let SF triage them at natural seams between tasks.
|
||||
Captures let you fire-and-forget thoughts during autonomous mode execution. Instead of pausing autonomous mode to steer, you can capture ideas, bugs, or scope changes and let SF triage them at natural seams between tasks.
|
||||
|
||||
## Quick Start
|
||||
|
||||
While auto-mode is running (or any time):
|
||||
While autonomous mode is running (or any time):
|
||||
|
||||
```
|
||||
/sf capture "add rate limiting to the API endpoints"
|
||||
|
|
@ -27,7 +27,7 @@ capture → triage → confirm → resolve → resume
|
|||
2. **Triage** — at natural seams between tasks (in `handleAgentEnd`), SF detects pending captures and classifies them
|
||||
3. **Confirm** — the user is shown the proposed resolution and confirms or adjusts
|
||||
4. **Resolve** — the resolution is applied (task injection, replan trigger, deferral, etc.)
|
||||
5. **Resume** — auto-mode continues
|
||||
5. **Resume** — autonomous mode continues
|
||||
|
||||
### Classification Types
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ Each capture is classified into one of five types:
|
|||
|
||||
### Automatic Triage
|
||||
|
||||
Triage fires automatically between tasks during auto-mode. The triage prompt receives:
|
||||
Triage fires automatically between tasks during autonomous mode. The triage prompt receives:
|
||||
- All pending captures
|
||||
- The current slice plan
|
||||
- The active roadmap
|
||||
|
|
@ -62,7 +62,7 @@ This is useful when you've accumulated several captures and want to process them
|
|||
|
||||
## Dashboard Integration
|
||||
|
||||
The progress widget shows a pending capture count badge when captures are waiting for triage. This is visible in both the `Ctrl+Alt+G` dashboard and the auto-mode progress widget.
|
||||
The progress widget shows a pending capture count badge when captures are waiting for triage. This is visible in both the `Ctrl+Alt+G` dashboard and the autonomous mode progress widget.
|
||||
|
||||
## Context Injection
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ Capture context is automatically injected into:
|
|||
|
||||
## Worktree Awareness
|
||||
|
||||
Captures always resolve to the **original project root's** `.sf/CAPTURES.md`, not the worktree's local copy. This ensures captures from a steering terminal are visible to the auto-mode session running in a worktree.
|
||||
Captures always resolve to the **original project root's** `.sf/CAPTURES.md`, not the worktree's local copy. This ensures captures from a steering terminal are visible to the autonomous mode session running in a worktree.
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
|
|||
|
|
@ -7,20 +7,19 @@
|
|||
| `/sf` | Step mode — execute one unit at a time, pause between each |
|
||||
| `/sf next` | Explicit step mode (same as `/sf`) |
|
||||
| `/sf autonomous` | Autonomous product loop — research, plan, execute, commit, repeat |
|
||||
| `/sf auto` | Alias for `/sf autonomous` |
|
||||
| `/sf quick` | Execute a quick task with SF guarantees (atomic commits, state tracking) without full planning overhead |
|
||||
| `/sf stop` | Stop autonomous mode gracefully |
|
||||
| `/sf pause` | Pause autonomous mode (preserves state, `/sf autonomous` to resume) |
|
||||
| `/sf steer` | Hard-steer plan documents during execution |
|
||||
| `/sf discuss` | Discuss architecture and decisions (works alongside auto mode) |
|
||||
| `/sf discuss` | Discuss architecture and decisions (works alongside autonomous mode) |
|
||||
| `/sf status` | Progress dashboard |
|
||||
| `/sf widget` | Cycle dashboard widget: full / small / min / off |
|
||||
| `/sf queue` | Queue and reorder future milestones (safe during auto mode) |
|
||||
| `/sf capture` | Fire-and-forget thought capture (works during auto mode) |
|
||||
| `/sf queue` | Queue and reorder future milestones (safe during autonomous mode) |
|
||||
| `/sf capture` | Fire-and-forget thought capture (works during autonomous mode) |
|
||||
| `/sf triage` | Manually trigger triage of pending captures |
|
||||
| `/sf dispatch` | Dispatch a specific phase directly (research, plan, execute, complete, reassess, uat, replan) |
|
||||
| `/sf history` | View execution history (supports `--cost`, `--phase`, `--model` filters) |
|
||||
| `/sf forensics` | Full-access SF debugger — structured anomaly detection, unit traces, and LLM-guided root-cause analysis for auto-mode failures |
|
||||
| `/sf forensics` | Full-access SF debugger — structured anomaly detection, unit traces, and LLM-guided root-cause analysis for autonomous mode failures |
|
||||
| `/sf cleanup` | Clean up SF state files and stale worktrees |
|
||||
| `/sf visualize` | Open workflow visualizer (progress, deps, metrics, timeline) |
|
||||
| `/sf export --html` | Generate self-contained HTML report for current or completed milestone |
|
||||
|
|
@ -31,7 +30,7 @@
|
|||
| `/sf rate` | Rate last unit's model tier (over/ok/under) — improves adaptive routing |
|
||||
| `/sf changelog` | Show categorized release notes |
|
||||
| `/sf logs` | Browse activity logs, debug logs, and metrics |
|
||||
| `/sf remote` | Control remote auto-mode |
|
||||
| `/sf remote` | Control remote autonomous mode |
|
||||
| `/sf help` | Categorized command reference with descriptions for all SF subcommands |
|
||||
|
||||
## Configuration & Diagnostics
|
||||
|
|
@ -59,7 +58,7 @@
|
|||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/sf new-milestone` | Create a new milestone |
|
||||
| `/sf skip` | Prevent a unit from auto-mode dispatch |
|
||||
| `/sf skip` | Prevent a unit from autonomous mode dispatch |
|
||||
| `/sf undo` | Revert last completed unit |
|
||||
| `/sf undo-task` | Reset a specific task's completion state (DB + markdown) |
|
||||
| `/sf reset-slice` | Reset a slice and all its tasks (DB + markdown) |
|
||||
|
|
@ -94,13 +93,13 @@ See [Parallel Orchestration](./parallel-orchestration.md) for full documentation
|
|||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/sf workflow new` | Create a new workflow definition (via skill) |
|
||||
| `/sf workflow run <name>` | Create a run and start auto-mode |
|
||||
| `/sf workflow run <name>` | Create a run and start autonomous mode |
|
||||
| `/sf workflow list` | List workflow runs |
|
||||
| `/sf workflow validate <name>` | Validate a workflow definition YAML |
|
||||
| `/sf workflow pause` | Pause custom workflow auto-mode |
|
||||
| `/sf workflow resume` | Resume paused custom workflow auto-mode |
|
||||
| `/sf workflow pause` | Pause custom workflow autonomous mode |
|
||||
| `/sf workflow resume` | Resume paused custom workflow autonomous mode |
|
||||
|
||||
`/sf autonomous` is the product-development loop that chooses the next useful unit from project state. `/sf start` is guided workflow kickoff and may ask clarifying questions. `/sf workflow run` executes an explicit YAML workflow definition. `/sf auto` remains supported as shorthand for `/sf autonomous`.
|
||||
`/sf autonomous` is the product-development loop that chooses the next useful unit from project state. `/sf start` is guided workflow kickoff and may ask clarifying questions. `/sf workflow run` executes an explicit YAML workflow definition. `/sf autonomous` remains supported as shorthand for `/sf autonomous`.
|
||||
|
||||
## Extensions
|
||||
|
||||
|
|
@ -157,7 +156,7 @@ Enable with `github.enabled: true` in preferences. Requires `gh` CLI installed a
|
|||
| `Ctrl+Alt+V` | Toggle voice transcription |
|
||||
| `Ctrl+Alt+B` | Show background shell processes |
|
||||
| `Ctrl+V` / `Alt+V` | Paste image from clipboard (screenshot → vision input) |
|
||||
| `Escape` | Pause auto mode (preserves conversation) |
|
||||
| `Escape` | Pause autonomous mode (preserves conversation) |
|
||||
|
||||
> **Note:** In terminals without Kitty keyboard protocol support (macOS Terminal.app, JetBrains IDEs), slash-command fallbacks are shown instead of `Ctrl+Alt` shortcuts.
|
||||
>
|
||||
|
|
@ -192,7 +191,7 @@ Enable with `github.enabled: true` in preferences. Requires `gh` CLI installed a
|
|||
`sf headless` runs `/sf` commands without a TUI — designed for CI, cron jobs, and scripted automation. It spawns a child process in RPC mode, auto-responds to interactive prompts, detects completion, and exits with meaningful exit codes.
|
||||
|
||||
```bash
|
||||
# Run auto mode (default)
|
||||
# Run autonomous mode (default)
|
||||
sf headless
|
||||
|
||||
# Run a single unit
|
||||
|
|
@ -202,12 +201,12 @@ sf headless next
|
|||
sf headless query
|
||||
|
||||
# With timeout for CI
|
||||
sf headless --timeout 600000 auto
|
||||
sf headless --timeout 600000 autonomous
|
||||
|
||||
# Force a specific phase
|
||||
sf headless dispatch plan
|
||||
|
||||
# Create a new milestone from a context file and start auto mode
|
||||
# Create a new milestone from a context file and start autonomous mode
|
||||
sf headless new-milestone --context brief.md --auto
|
||||
|
||||
# Create a milestone from inline text
|
||||
|
|
@ -225,7 +224,7 @@ echo "Build a CLI tool" | sf headless new-milestone --context -
|
|||
| `--model ID` | Override the model for the headless session |
|
||||
| `--context <file>` | Context file for `new-milestone` (use `-` for stdin) |
|
||||
| `--context-text <text>` | Inline context text for `new-milestone` |
|
||||
| `--auto` | Chain into auto-mode after milestone creation |
|
||||
| `--auto` | Chain into autonomous mode after milestone creation |
|
||||
|
||||
**Exit codes:** `0` = complete, `1` = error or timeout, `2` = blocked.
|
||||
|
||||
|
|
@ -271,16 +270,10 @@ sf headless query | jq '.cost.total'
|
|||
}
|
||||
```
|
||||
|
||||
## MCP Server Mode
|
||||
## MCP Integrations
|
||||
|
||||
`sf --mode mcp` runs SF as a [Model Context Protocol](https://modelcontextprotocol.io) server over stdin/stdout. This exposes all SF tools (read, write, edit, bash, etc.) to external AI clients — Claude Desktop, VS Code Copilot, and any MCP-compatible host.
|
||||
|
||||
```bash
|
||||
# Start SF as an MCP server
|
||||
sf --mode mcp
|
||||
```
|
||||
|
||||
The server registers all tools from the agent session and maps MCP `tools/list` and `tools/call` requests to SF tool definitions. It runs until the transport closes.
|
||||
`/sf mcp` shows configured external MCP tool servers. SF does not expose its own
|
||||
workflow as an MCP server; run SF directly with `sf` or `/sf autonomous`.
|
||||
|
||||
## In-Session Update
|
||||
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ Recommended verification order:
|
|||
|
||||
- Use absolute paths for local executables and scripts when possible.
|
||||
- For `stdio` servers, prefer setting required environment variables directly in the MCP config instead of relying on an interactive shell profile.
|
||||
- SF and `sf-mcp-server` both hydrate supported model and tool keys saved in `~/.sf/agent/auth.json`, so MCP configs can safely reference them through `${ENV_VAR}` placeholders without committing raw credentials.
|
||||
- SF hydrates supported model and tool keys saved in `~/.sf/agent/auth.json`, so external MCP configs can safely reference them through `${ENV_VAR}` placeholders without committing raw credentials.
|
||||
- If a server is team-shared and safe to commit, `.mcp.json` is usually the better home.
|
||||
- If a server depends on machine-local paths, personal services, or local-only secrets, prefer `.sf/mcp.json`.
|
||||
|
||||
|
|
@ -259,7 +259,7 @@ Values: `budget`, `balanced` (default), `quality`
|
|||
|
||||
### `phases`
|
||||
|
||||
Fine-grained control over which phases run in auto mode:
|
||||
Fine-grained control over which phases run in autonomous mode:
|
||||
|
||||
```yaml
|
||||
phases:
|
||||
|
|
@ -267,7 +267,7 @@ phases:
|
|||
skip_reassess: false # skip roadmap reassessment after each slice
|
||||
skip_slice_research: true # skip per-slice research
|
||||
reassess_after_slice: true # enable roadmap reassessment after each slice (required for reassessment)
|
||||
require_slice_discussion: false # pause auto-mode before each slice for discussion
|
||||
require_slice_discussion: false # pause autonomous mode before each slice for discussion
|
||||
```
|
||||
|
||||
These are usually set automatically by `token_profile`, but can be overridden explicitly.
|
||||
|
|
@ -276,7 +276,7 @@ These are usually set automatically by `token_profile`, but can be overridden ex
|
|||
|
||||
### `skill_discovery`
|
||||
|
||||
Controls how SF finds and applies skills during auto mode.
|
||||
Controls how SF finds and applies skills during autonomous mode.
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
|
|
@ -286,20 +286,20 @@ Controls how SF finds and applies skills during auto mode.
|
|||
|
||||
### `auto_supervisor`
|
||||
|
||||
Timeout thresholds for auto mode supervision:
|
||||
Timeout thresholds for autonomous mode supervision:
|
||||
|
||||
```yaml
|
||||
auto_supervisor:
|
||||
model: claude-sonnet-4-6 # optional: model for supervisor (defaults to active model)
|
||||
soft_timeout_minutes: 20 # warn LLM to wrap up
|
||||
idle_timeout_minutes: 10 # detect stalls
|
||||
hard_timeout_minutes: 30 # pause auto mode
|
||||
hard_timeout_minutes: 30 # pause autonomous mode
|
||||
completion_nudge_after: 10 # complete-slice tool calls before nudging sf_slice_complete
|
||||
```
|
||||
|
||||
### `budget_ceiling`
|
||||
|
||||
Maximum USD to spend during auto mode. No `$` sign — just the number.
|
||||
Maximum USD to spend during autonomous mode. No `$` sign — just the number.
|
||||
|
||||
```yaml
|
||||
budget_ceiling: 50.00
|
||||
|
|
@ -312,12 +312,12 @@ How the budget ceiling is enforced:
|
|||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `warn` | Log a warning but continue |
|
||||
| `pause` | Pause auto mode (default when ceiling is set) |
|
||||
| `halt` | Stop auto mode entirely |
|
||||
| `pause` | Pause autonomous mode (default when ceiling is set) |
|
||||
| `halt` | Stop autonomous mode entirely |
|
||||
|
||||
### `context_pause_threshold`
|
||||
|
||||
Context window usage percentage (0-100) at which auto mode pauses for checkpointing. Set to `0` to disable.
|
||||
Context window usage percentage (0-100) at which autonomous mode pauses for checkpointing. Set to `0` to disable.
|
||||
|
||||
```yaml
|
||||
context_pause_threshold: 80 # pause at 80% context usage
|
||||
|
|
@ -439,7 +439,7 @@ git:
|
|||
| `commit_type` | string | (inferred) | Override conventional commit prefix (`feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`) |
|
||||
| `main_branch` | string | `"main"` | Primary branch name |
|
||||
| `merge_strategy` | string | `"squash"` | How worktree branches merge: `"squash"` (combine all commits) or `"merge"` (preserve individual commits) |
|
||||
| `isolation` | string | `"worktree"` | Auto-mode isolation: `"worktree"` (separate directory), `"branch"` (work in project root — useful for submodule-heavy repos), or `"none"` (no isolation — commits on current branch, no worktree or milestone branch) |
|
||||
| `isolation` | string | `"worktree"` | Autonomous mode isolation: `"worktree"` (separate directory), `"branch"` (work in project root — useful for submodule-heavy repos), or `"none"` (no isolation — commits on current branch, no worktree or milestone branch) |
|
||||
| `commit_docs` | boolean | `true` | Commit `.sf/` planning artifacts to git. Set `false` to keep local-only |
|
||||
| `manage_gitignore` | boolean | `true` | When `false`, SF will not modify `.gitignore` at all — no baseline patterns, no self-healing. Use if you manage your own `.gitignore` |
|
||||
| `worktree_post_create` | string | (none) | Script to run after worktree creation. Receives `SOURCE_DIR` and `WORKTREE_DIR` env vars |
|
||||
|
|
@ -448,7 +448,7 @@ git:
|
|||
|
||||
#### `git.worktree_post_create`
|
||||
|
||||
Script to run after a worktree is created (both auto-mode and manual `/worktree`). Useful for copying `.env` files, symlinking asset directories, or running setup commands that worktrees don't inherit from the main tree.
|
||||
Script to run after a worktree is created (both autonomous mode and manual `/worktree`). Useful for copying `.env` files, symlinking asset directories, or running setup commands that worktrees don't inherit from the main tree.
|
||||
|
||||
```yaml
|
||||
git:
|
||||
|
|
@ -524,7 +524,7 @@ github:
|
|||
|
||||
### `notifications`
|
||||
|
||||
Control what notifications SF sends during auto mode:
|
||||
Control what notifications SF sends during autonomous mode:
|
||||
|
||||
```yaml
|
||||
notifications:
|
||||
|
|
@ -546,7 +546,7 @@ Why: `osascript display notification` is attributed to your terminal app (Ghostt
|
|||
|
||||
### `remote_questions`
|
||||
|
||||
Route interactive questions to Slack or Discord for headless auto mode:
|
||||
Route interactive questions to Slack or Discord for headless autonomous mode:
|
||||
|
||||
```yaml
|
||||
remote_questions:
|
||||
|
|
@ -701,7 +701,7 @@ dynamic_routing:
|
|||
|
||||
### `context_management` (v2.59)
|
||||
|
||||
Controls observation masking and tool result truncation during auto-mode sessions. Reduces context bloat between compactions with zero LLM overhead.
|
||||
Controls observation masking and tool result truncation during autonomous mode sessions. Reduces context bloat between compactions with zero LLM overhead.
|
||||
|
||||
```yaml
|
||||
context_management:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Cost Management
|
||||
|
||||
SF tracks token usage and cost for every unit of work dispatched during auto mode. This data powers the dashboard, budget enforcement, and cost projections.
|
||||
SF tracks token usage and cost for every unit of work dispatched during autonomous mode. This data powers the dashboard, budget enforcement, and cost projections.
|
||||
|
||||
## Cost Tracking
|
||||
|
||||
|
|
@ -46,8 +46,8 @@ budget_enforcement: pause # default when ceiling is set
|
|||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `warn` | Log a warning, continue executing |
|
||||
| `pause` | Pause auto mode, wait for user action |
|
||||
| `halt` | Stop auto mode entirely |
|
||||
| `pause` | Pause autonomous mode, wait for user action |
|
||||
| `halt` | Stop autonomous mode entirely |
|
||||
|
||||
## Cost Projections
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Starting in v2.52.0, the router uses **capability-aware scoring** to select the
|
|||
|
||||
## How It Works
|
||||
|
||||
Each unit dispatched by auto-mode passes through a two-stage pipeline:
|
||||
Each unit dispatched by autonomous mode passes through a two-stage pipeline:
|
||||
|
||||
**Stage 1: Complexity classification** — classifies the work into a tier (light/standard/heavy).
|
||||
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ Step mode keeps you in the loop, reviewing output between each step.
|
|||
|
||||
### Autonomous Mode — `/sf autonomous`
|
||||
|
||||
Type `/sf autonomous` and walk away. SF researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. `/sf auto` remains available as a short alias.
|
||||
Type `/sf autonomous` and walk away. SF researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. `/sf autonomous` remains available as a short alias.
|
||||
|
||||
```
|
||||
/sf autonomous
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ SF-Task: M001/S01/T02
|
|||
|
||||
These features apply only in **worktree mode**.
|
||||
|
||||
### Automatic (Auto Mode)
|
||||
### Automatic (Autonomous Mode)
|
||||
|
||||
Auto mode creates and manages worktrees automatically:
|
||||
|
||||
|
|
@ -184,4 +184,4 @@ Run `/sf doctor` to check git health manually.
|
|||
|
||||
## Native Git Operations
|
||||
|
||||
Since v2.16, SF uses libgit2 via native bindings for read-heavy operations in the dispatch hot path. This eliminates ~70 process spawns per dispatch cycle, improving auto-mode throughput.
|
||||
Since v2.16, SF uses libgit2 via native bindings for read-heavy operations in the dispatch hot path. This eliminates ~70 process spawns per dispatch cycle, improving autonomous mode throughput.
|
||||
|
|
|
|||
|
|
@ -80,65 +80,11 @@ SF detects your local Claude Code installation and uses it as the authenticated
|
|||
|
||||
> **Note:** SF does not support browser-based OAuth sign-in for Anthropic. Use an API key or the Claude Code CLI instead.
|
||||
|
||||
**Option C — Use your Claude Pro/Max plan with SF inside Claude Code:**
|
||||
|
||||
If you already have a Claude Pro or Max subscription and want to use SF's planning, execution, and milestone orchestration directly from Claude Code — without switching to a separate terminal — you can connect SF as an MCP server. This gives Claude Code access to SF's full workflow toolset via the [Model Context Protocol](https://modelcontextprotocol.io), so you get SF's structured project management powered by your existing Claude plan.
|
||||
|
||||
**Automatic setup (recommended):**
|
||||
|
||||
When SF detects a Claude Code model during startup, it automatically writes a `.mcp.json` file in your project root with the SF workflow MCP server configured. No manual steps needed — just start SF once with Claude Code as the provider and the config is created for you.
|
||||
|
||||
You can also trigger this manually from inside a SF session:
|
||||
|
||||
```bash
|
||||
/sf mcp init
|
||||
```
|
||||
|
||||
This writes (or updates) the `sf-workflow` entry in your project's `.mcp.json`. Claude Code discovers this file automatically on its next session start.
|
||||
|
||||
**Manual setup:**
|
||||
|
||||
If you prefer to configure it yourself, add SF to your project's `.mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sf": {
|
||||
"command": "npx",
|
||||
"args": ["sf-mcp-server"],
|
||||
"env": {
|
||||
"SF_CLI_PATH": "/path/to/sf"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or if `sf-mcp-server` is installed globally:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sf": {
|
||||
"command": "sf-mcp-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also add this to `~/.claude/settings.json` under `mcpServers` to make SF available across all projects.
|
||||
|
||||
**What's exposed:**
|
||||
|
||||
The MCP server provides SF's full workflow tool surface — milestone planning, task completion, slice management, roadmap reassessment, journal queries, and more. Session management tools (`sf_execute`, `sf_status`, `sf_result`, `sf_cancel`) let Claude Code start and monitor SF auto-mode sessions. See [Commands → MCP Server Mode](./commands.md#mcp-server-mode) for the full tool list.
|
||||
|
||||
**Verify the connection:**
|
||||
|
||||
From inside a SF session, check that the MCP server is reachable:
|
||||
|
||||
```bash
|
||||
/sf mcp status
|
||||
```
|
||||
**Runtime boundary:** SF may use Claude Code, Codex, or Gemini CLI core as
|
||||
model/runtime adapters when configured. These adapters are not project MCP
|
||||
dependencies, and SF does not expose its own workflow as an MCP server. Run SF
|
||||
directly with `sf` or `/sf autonomous`; reserve MCP configuration for external
|
||||
tools that SF may call.
|
||||
|
||||
### OpenAI
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Remote Questions
|
||||
|
||||
Remote questions allow SF to ask for user input via Slack, Discord, or Telegram when running in headless auto-mode. When SF encounters a decision point that needs human input, it posts the question to your configured channel and polls for a response.
|
||||
Remote questions allow SF to ask for user input via Slack, Discord, or Telegram when running in headless autonomous-mode. When SF encounters a decision point that needs human input, it posts the question to your configured channel and polls for a response.
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ remote_questions:
|
|||
|
||||
## How It Works
|
||||
|
||||
1. SF encounters a decision point during auto-mode
|
||||
1. SF encounters a decision point during autonomous mode
|
||||
2. The question is posted to your configured channel as a rich embed (Discord) or Block Kit message (Slack)
|
||||
3. SF polls for a response at the configured interval
|
||||
4. You respond by:
|
||||
|
|
@ -99,7 +99,7 @@ remote_questions:
|
|||
|
||||
### Timeouts
|
||||
|
||||
If no response is received within `timeout_minutes`, the prompt times out and SF continues with a timeout result. The LLM handles timeouts according to the task context — typically by making a conservative default choice or pausing auto-mode.
|
||||
If no response is received within `timeout_minutes`, the prompt times out and SF continues with a timeout result. The LLM handles timeouts according to the task context — typically by making a conservative default choice or pausing autonomous mode.
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ The skill catalog lives in [`src/resources/extensions/sf/skill-catalog.ts`](../s
|
|||
|
||||
## Skill Discovery
|
||||
|
||||
The `skill_discovery` preference controls how SF finds skills during auto mode:
|
||||
The `skill_discovery` preference controls how SF finds skills during autonomous mode:
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
|
|
@ -147,11 +147,11 @@ Project-local skills can be committed to version control so team members share t
|
|||
|
||||
## Skill Lifecycle Management
|
||||
|
||||
SF tracks skill performance across auto-mode sessions and surfaces health data to help you maintain skill quality.
|
||||
SF tracks skill performance across autonomous mode sessions and surfaces health data to help you maintain skill quality.
|
||||
|
||||
### Skill Telemetry
|
||||
|
||||
Every auto-mode unit records which skills were available and actively loaded. This data is stored in `metrics.json` alongside existing token and cost tracking.
|
||||
Every autonomous mode unit records which skills were available and actively loaded. This data is stored in `metrics.json` alongside existing token and cost tracking.
|
||||
|
||||
### Skill Health Dashboard
|
||||
|
||||
|
|
|
|||
|
|
@ -281,9 +281,9 @@ The profile is resolved once and flows through the entire dispatch pipeline. Exp
|
|||
|
||||
*Introduced in v2.59.0*
|
||||
|
||||
During auto-mode sessions, tool results accumulate in the conversation history and consume context window space. Observation masking replaces tool result content older than N user turns with a lightweight placeholder before each LLM call. This reduces token usage with zero LLM overhead — no summarization calls, no latency.
|
||||
During autonomous mode sessions, tool results accumulate in the conversation history and consume context window space. Observation masking replaces tool result content older than N user turns with a lightweight placeholder before each LLM call. This reduces token usage with zero LLM overhead — no summarization calls, no latency.
|
||||
|
||||
Masking is enabled by default during auto-mode. Configure via preferences:
|
||||
Masking is enabled by default during autonomous mode. Configure via preferences:
|
||||
|
||||
```yaml
|
||||
context_management:
|
||||
|
|
@ -309,7 +309,7 @@ Individual tool results that exceed `tool_result_max_chars` (default: 800) are t
|
|||
|
||||
*Introduced in v2.59.0*
|
||||
|
||||
When auto-mode transitions between phases (research → planning → execution), structured JSON anchors are written to `.sf/milestones/<mid>/anchors/<phase>.json`. Downstream prompt builders inject these anchors so the next phase inherits intent, decisions, blockers, and next steps without re-inferring from artifact files.
|
||||
When autonomous mode transitions between phases (research → planning → execution), structured JSON anchors are written to `.sf/milestones/<mid>/anchors/<phase>.json`. Downstream prompt builders inject these anchors so the next phase inherits intent, decisions, blockers, and next steps without re-inferring from artifact files.
|
||||
|
||||
This reduces context drift — the 65% of enterprise agent failures caused by agents losing track of prior decisions across phase boundaries.
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ source ~/.zshrc
|
|||
- `postinstall` hangs on Linux (Playwright `--with-deps` triggering sudo) — fixed in v2.3.6+
|
||||
- Node.js version too old — requires ≥ 24.0.0
|
||||
|
||||
### Provider errors during auto mode
|
||||
### Provider errors during autonomous mode
|
||||
|
||||
**Symptoms:** Auto mode pauses with a provider error (rate limit, server error, auth failure).
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ models:
|
|||
- openrouter/minimax/minimax-m2.5
|
||||
```
|
||||
|
||||
**Headless mode:** `sf headless auto` auto-restarts the entire process on crash (default 3 attempts with exponential backoff). Combined with provider error auto-resume, this enables true overnight unattended execution.
|
||||
**Headless mode:** `sf headless autonomous` auto-restarts the entire process on crash (default 3 attempts with exponential backoff). Combined with provider error auto-resume, this enables true overnight unattended execution.
|
||||
|
||||
For common provider setup issues (role errors, streaming errors, model ID mismatches), see the [Provider Setup Guide — Common Pitfalls](./providers.md#common-pitfalls).
|
||||
|
||||
|
|
@ -129,7 +129,7 @@ rm -rf "$(dirname .sf)/.sf.lock"
|
|||
**What it means:** The milestone's `.sf/milestones/<MID>/<MID>-META.json` still points at the branch that was active when the milestone started, but that branch has since been renamed or deleted.
|
||||
|
||||
**Current behavior:**
|
||||
- If SF can deterministically recover to a safe branch, it no longer hard-stops auto mode.
|
||||
- If SF can deterministically recover to a safe branch, it no longer hard-stops autonomous mode.
|
||||
- Safe fallbacks are:
|
||||
- explicit `git.main_branch` when configured and present
|
||||
- the repo's detected default integration branch (for example `main` or `master`)
|
||||
|
|
@ -142,7 +142,7 @@ rm -rf "$(dirname .sf)/.sf.lock"
|
|||
|
||||
### Transient `EBUSY` / `EPERM` / `EACCES` while writing `.sf/` files
|
||||
|
||||
**Symptoms:** On Windows, auto mode or doctor occasionally fails while updating `.sf/` files with errors like `EBUSY`, `EPERM`, or `EACCES`.
|
||||
**Symptoms:** On Windows, autonomous mode or doctor occasionally fails while updating `.sf/` files with errors like `EBUSY`, `EPERM`, or `EACCES`.
|
||||
|
||||
**Cause:** Antivirus, indexers, editors, or filesystem watchers can briefly lock the destination or temp file just as SF performs the atomic rename.
|
||||
|
||||
|
|
@ -171,7 +171,7 @@ rm -rf "$(dirname .sf)/.sf.lock"
|
|||
|
||||
### Non-JS project blocked by worktree health check
|
||||
|
||||
**Symptoms:** Worktree health check fails or blocks auto-mode in projects that don't use Node.js (e.g., Rust, Go, Python).
|
||||
**Symptoms:** Worktree health check fails or blocks autonomous mode in projects that don't use Node.js (e.g., Rust, Go, Python).
|
||||
|
||||
**Cause:** The worktree health check only recognized JavaScript ecosystems prior to v2.42.0.
|
||||
|
||||
|
|
@ -260,13 +260,13 @@ rm -rf "$(dirname .sf)/.sf.lock"
|
|||
|
||||
### Session lock stolen by `/sf` in another terminal
|
||||
|
||||
**Symptoms:** Running `/sf` (step mode) in a second terminal causes a running auto-mode session to lose its lock.
|
||||
**Symptoms:** Running `/sf` (step mode) in a second terminal causes a running autonomous mode session to lose its lock.
|
||||
|
||||
**Fix:** Fixed in v2.36.0. Bare `/sf` no longer steals the session lock from a running auto-mode session. Upgrade to the latest version.
|
||||
**Fix:** Fixed in v2.36.0. Bare `/sf` no longer steals the session lock from a running autonomous mode session. Upgrade to the latest version.
|
||||
|
||||
### Worktree commits landing on main instead of milestone branch
|
||||
|
||||
**Symptoms:** Auto-mode commits in a worktree end up on `main` instead of the `milestone/<MID>` branch.
|
||||
**Symptoms:** Autonomous mode commits in a worktree end up on `main` instead of the `milestone/<MID>` branch.
|
||||
|
||||
**Fix:** Fixed in v2.37.1. CWD is now realigned before dispatch and stale merge state is cleaned on failure. Upgrade to the latest version.
|
||||
|
||||
|
|
@ -280,7 +280,7 @@ rm -rf "$(dirname .sf)/.sf.lock"
|
|||
|
||||
## Recovery Procedures
|
||||
|
||||
### Reset auto mode state
|
||||
### Reset autonomous mode state
|
||||
|
||||
```bash
|
||||
rm .sf/auto.lock
|
||||
|
|
@ -309,7 +309,7 @@ Doctor rebuilds `STATE.md` from plan and roadmap files on disk and fixes detecte
|
|||
|
||||
- **GitHub Issues:** [github.com/singularity-forge/sf-run/issues](https://github.com/singularity-forge/sf-run/issues)
|
||||
- **Dashboard:** `Ctrl+Alt+G` or `/sf status` for real-time diagnostics
|
||||
- **Forensics:** `/sf forensics` for structured post-mortem analysis of auto-mode failures
|
||||
- **Forensics:** `/sf forensics` for structured post-mortem analysis of autonomous mode failures
|
||||
- **Session logs:** `.sf/activity/` contains JSONL session dumps for crash forensics
|
||||
|
||||
## iTerm2-Specific Issues
|
||||
|
|
@ -346,7 +346,7 @@ Doctor rebuilds `STATE.md` from plan and roadmap files on disk and fixes detecte
|
|||
|
||||
**Symptoms:** `sf_decision_save`, `sf_requirement_update`, or `sf_summary_save` fail with this error.
|
||||
|
||||
**Cause:** The SQLite database wasn't initialized. This happens in manual `/sf` sessions (non-auto mode) on versions before v2.29.
|
||||
**Cause:** The SQLite database wasn't initialized. This happens in manual `/sf` sessions (non-autonomous mode) on versions before v2.29.
|
||||
|
||||
**Fix:** Updated in v2.29+ to auto-initialize the database on first tool call. Upgrade to the latest version.
|
||||
|
||||
|
|
@ -388,7 +388,7 @@ After installing, run `lsp reload` to restart detection without restarting SF.
|
|||
|
||||
### Notifications not appearing on macOS
|
||||
|
||||
**Symptoms:** `notifications.enabled: true` in preferences, but no desktop notifications appear during auto-mode (no milestone complete alerts, no budget warnings, no error notifications). No error messages logged.
|
||||
**Symptoms:** `notifications.enabled: true` in preferences, but no desktop notifications appear during autonomous mode (no milestone complete alerts, no budget warnings, no error notifications). No error messages logged.
|
||||
|
||||
**Cause:** SF uses `osascript display notification` as a fallback on macOS. This command is attributed to your terminal app (Ghostty, iTerm2, Alacritty, Kitty, Warp, etc.). If that app doesn't have notification permissions in System Settings → Notifications, macOS silently drops the notification — `osascript` exits 0 with no error.
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ Chronological execution history showing:
|
|||
- Model used
|
||||
- Token counts
|
||||
|
||||
Ordered by execution time, showing the full history of auto-mode dispatches.
|
||||
Ordered by execution time, showing the full history of autonomous mode dispatches.
|
||||
|
||||
## Controls
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ Ordered by execution time, showing the full history of auto-mode dispatches.
|
|||
|
||||
## Auto-Refresh
|
||||
|
||||
The visualizer refreshes data from disk every 2 seconds, so it stays current if opened alongside a running auto-mode session.
|
||||
The visualizer refreshes data from disk every 2 seconds, so it stays current if opened alongside a running autonomous mode session.
|
||||
|
||||
## HTML Export (v2.26)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ sf --web --host 0.0.0.0 --port 8080 --allowed-origins "https://example.com"
|
|||
## Features
|
||||
|
||||
- **Project management** — view milestones, slices, and tasks in a visual dashboard
|
||||
- **Real-time progress** — server-sent events push status updates as auto-mode executes
|
||||
- **Real-time progress** — server-sent events push status updates as autonomous mode executes
|
||||
- **Multi-project support** — manage multiple projects from a single browser tab via `?project=` URL parameter
|
||||
- **Change project root** — switch project directories from the web UI without restarting the server (v2.44)
|
||||
- **Onboarding flow** — API key setup and provider configuration through the browser
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ If you have an existing project with `.sf/` blanket-ignored:
|
|||
|
||||
## Parallel Development
|
||||
|
||||
Multiple developers can run auto mode simultaneously on different milestones. Each developer:
|
||||
Multiple developers can run autonomous mode simultaneously on different milestones. Each developer:
|
||||
|
||||
- Gets their own worktree (`.sf/worktrees/<MID>/`, gitignored)
|
||||
- Works on a unique `milestone/<MID>` branch
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 自动模式
|
||||
|
||||
自动模式是 SF 的自主执行引擎。运行 `/sf auto`,然后离开;回来时你会看到已经构建好的软件,以及干净的 git 历史。
|
||||
自动模式是 SF 的自主执行引擎。运行 `/sf autonomous`,然后离开;回来时你会看到已经构建好的软件,以及干净的 git 历史。
|
||||
|
||||
## 工作原理
|
||||
|
||||
|
|
@ -59,9 +59,9 @@ SF 支持三种 milestone 隔离模式(通过偏好设置中的 `git.isolation
|
|||
|
||||
### 崩溃恢复
|
||||
|
||||
自动模式会用锁文件跟踪当前工作单元。如果会话中途退出,下一次执行 `/sf auto` 时,会读取残留的会话文件,从所有已经落盘的工具调用中综合生成一份恢复简报,然后带着完整上下文继续执行。
|
||||
自动模式会用锁文件跟踪当前工作单元。如果会话中途退出,下一次执行 `/sf autonomous` 时,会读取残留的会话文件,从所有已经落盘的工具调用中综合生成一份恢复简报,然后带着完整上下文继续执行。
|
||||
|
||||
**Headless 自动重启(v2.26):** 当运行 `sf headless auto` 时,崩溃会触发带指数退避的自动重启(5s → 10s → 30s 上限,默认最多 3 次)。通过 `--max-restarts N` 配置。SIGINT/SIGTERM 不会触发重启。结合崩溃恢复机制,这让真正的“跑一夜直到完成”成为可能。
|
||||
**Headless 自动重启(v2.26):** 当运行 `sf headless autonomous` 时,崩溃会触发带指数退避的自动重启(5s → 10s → 30s 上限,默认最多 3 次)。通过 `--max-restarts N` 配置。SIGINT/SIGTERM 不会触发重启。结合崩溃恢复机制,这让真正的“跑一夜直到完成”成为可能。
|
||||
|
||||
### Provider 错误恢复
|
||||
|
||||
|
|
@ -213,7 +213,7 @@ v2.28 通过多项机制强化了自动模式的可靠性:原子文件写入
|
|||
### 启动
|
||||
|
||||
```
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
|
||||
### 暂停
|
||||
|
|
@ -223,7 +223,7 @@ v2.28 通过多项机制强化了自动模式的可靠性:原子文件写入
|
|||
### 恢复
|
||||
|
||||
```
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
|
||||
自动模式会读取磁盘状态,并从中断处继续。
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@
|
|||
|------|------|
|
||||
| `/sf` | Step mode:一次执行一个工作单元,并在每步之间暂停 |
|
||||
| `/sf next` | 显式 Step mode(与 `/sf` 相同) |
|
||||
| `/sf auto` | 自动模式:research、plan、execute、commit,然后重复 |
|
||||
| `/sf autonomous` | 自动模式:research、plan、execute、commit,然后重复 |
|
||||
| `/sf quick` | 在不经过完整 planning 开销的情况下,执行一个带 SF 保证的 quick task(原子提交、状态跟踪) |
|
||||
| `/sf stop` | 优雅地停止自动模式 |
|
||||
| `/sf pause` | 暂停自动模式(保留状态,可用 `/sf auto` 恢复) |
|
||||
| `/sf pause` | 暂停自动模式(保留状态,可用 `/sf autonomous` 恢复) |
|
||||
| `/sf steer` | 在执行过程中强制修改 plan 文档 |
|
||||
| `/sf discuss` | 讨论架构和决策(可与自动模式并行使用) |
|
||||
| `/sf status` | 进度仪表板 |
|
||||
|
|
@ -268,17 +268,9 @@ sf headless query | jq '.cost.total'
|
|||
}
|
||||
```
|
||||
|
||||
<a id="mcp-server-mode"></a>
|
||||
## MCP Server 模式
|
||||
## MCP 集成
|
||||
|
||||
`sf --mode mcp` 会通过 stdin/stdout 将 SF 作为一个 [Model Context Protocol](https://modelcontextprotocol.io) server 运行。这会把所有 SF 工具(read、write、edit、bash 等)暴露给外部 AI 客户端,例如 Claude Desktop、VS Code Copilot,以及任何兼容 MCP 的宿主。
|
||||
|
||||
```bash
|
||||
# 以 MCP server 模式启动 SF
|
||||
sf --mode mcp
|
||||
```
|
||||
|
||||
服务会注册 agent 会话中的全部工具,并把 MCP 的 `tools/list` 与 `tools/call` 请求映射到 SF 的工具定义上。连接会一直保持,直到底层 transport 关闭。
|
||||
`/sf mcp` 只显示外部 MCP 工具 server 的状态。SF 不会把自己的 workflow 暴露成 MCP server;请直接运行 `sf` 或 `/sf autonomous`。
|
||||
|
||||
## 会话内更新
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ mcp_call(server="my-server", tool="<tool_name>", args={...})
|
|||
|
||||
- 尽量为本地可执行文件和脚本使用绝对路径
|
||||
- 对于 `stdio` servers,优先在 MCP 配置里显式设置需要的环境变量,而不是依赖交互式 shell profile
|
||||
- SF 和 `sf-mcp-server` 都会自动加载保存在 `~/.sf/agent/auth.json` 中的 model / tool keys,因此 MCP 配置可以安全地通过 `${ENV_VAR}` 占位符引用这些值,而不必提交原始凭据
|
||||
- SF 会自动加载保存在 `~/.sf/agent/auth.json` 中的 model / tool keys,因此 MCP 配置可以安全地通过 `${ENV_VAR}` 占位符引用这些值,而不必提交原始凭据
|
||||
- 如果某个 server 是团队共享且适合提交到仓库,通常更适合放在 `.mcp.json`
|
||||
- 如果某个 server 依赖本机路径、个人服务或本地 secrets,更适合放在 `.sf/mcp.json`
|
||||
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ docker sandbox exec -it sf-sandbox bash
|
|||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
sf auto "implement the feature described in issue #42"
|
||||
sf autonomous "implement the feature described in issue #42"
|
||||
```
|
||||
|
||||
完整的配置、资源限制和 compose 文件请见 [Docker Sandbox 文档](../../../docker/README.md)。
|
||||
|
|
@ -328,12 +328,12 @@ sf auto "implement the feature described in issue #42"
|
|||
|
||||
步骤模式会让你始终留在回路中,在每一步之间查看和确认输出。
|
||||
|
||||
### 自动模式 — `/sf auto`
|
||||
### 自动模式 — `/sf autonomous`
|
||||
|
||||
输入 `/sf auto` 后就可以离开。SF 会自主完成 research、planning、execution、verification、commit,并持续推进每个 slice,直到 milestone 完成。
|
||||
输入 `/sf autonomous` 后就可以离开。SF 会自主完成 research、planning、execution、verification、commit,并持续推进每个 slice,直到 milestone 完成。
|
||||
|
||||
```
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
|
||||
完整细节请见 [自动模式](./auto-mode.md)。
|
||||
|
|
@ -348,7 +348,7 @@ sf auto "implement the feature described in issue #42"
|
|||
|
||||
```bash
|
||||
sf
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
|
||||
**终端 2:在它工作时进行引导**
|
||||
|
|
|
|||
|
|
@ -83,65 +83,7 @@ SF 会检测你本地的 Claude Code 安装,并把它作为已认证的 Anthro
|
|||
|
||||
> **注意:** SF 不支持 Anthropic 的浏览器 OAuth 登录。请改用 API key 或 Claude Code CLI。
|
||||
|
||||
**选项 C:在 Claude Code 里直接用 Claude Pro / Max 订阅跑 SF**
|
||||
|
||||
如果你已经有 Claude Pro / Max 订阅,并希望直接在 Claude Code 里使用 SF 的 planning、execution 和 milestone orchestration,而不是切到单独终端,那么可以把 SF 接成一个 MCP server。这样 Claude Code 就能通过 [Model Context Protocol](https://modelcontextprotocol.io) 使用 SF 的完整 workflow 工具集,在你现有 Claude plan 的驱动下获得 SF 的结构化项目管理能力。
|
||||
|
||||
**自动配置(推荐)**
|
||||
|
||||
当 SF 在启动时检测到 Claude Code model,它会自动在项目根目录写入一个带有 SF workflow MCP server 配置的 `.mcp.json` 文件。无需手动步骤,只要以 Claude Code 作为 provider 启动一次 SF,配置就会自动生成。
|
||||
|
||||
你也可以在 SF 会话中手动触发:
|
||||
|
||||
```bash
|
||||
/sf mcp init
|
||||
```
|
||||
|
||||
这会在项目的 `.mcp.json` 中写入(或更新)`sf-workflow` 条目。Claude Code 会在下一次启动会话时自动发现这个文件。
|
||||
|
||||
**手动配置**
|
||||
|
||||
如果你更希望自己配置,可以把 SF 加到项目的 `.mcp.json` 中:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sf": {
|
||||
"command": "npx",
|
||||
"args": ["sf-mcp-server"],
|
||||
"env": {
|
||||
"SF_CLI_PATH": "/path/to/sf"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果 `sf-mcp-server` 已经全局安装:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sf": {
|
||||
"command": "sf-mcp-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
你也可以把这段配置写到 `~/.claude/settings.json` 的 `mcpServers` 中,让 SF 在所有项目中都可用。
|
||||
|
||||
**暴露了什么**
|
||||
|
||||
MCP server 会暴露 SF 的完整 workflow 工具面:milestone planning、task completion、slice 管理、roadmap reassessment、journal 查询等。会话管理工具(`sf_execute`、`sf_status`、`sf_result`、`sf_cancel`)允许 Claude Code 启动并监控 SF 自动模式会话。完整工具列表见 [命令 → MCP Server 模式](./commands.md#mcp-server-mode)。
|
||||
|
||||
**验证连接**
|
||||
|
||||
在 SF 会话里检查 MCP server 是否可达:
|
||||
|
||||
```bash
|
||||
/sf mcp status
|
||||
```
|
||||
**Runtime 边界:** SF 可以把 Claude Code、Codex 或 Gemini CLI core 作为 model/runtime adapter 使用。这些 adapter 不是项目 MCP 依赖,SF 也不会把自己的 workflow 暴露成 MCP server。请直接运行 `sf` 或 `/sf autonomous`;MCP 配置只用于 SF 需要调用的外部工具。
|
||||
|
||||
<a id="openai"></a>
|
||||
### OpenAI
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@
|
|||
- 崩溃后的缓存过期:内存中的文件列表没有反映新产物
|
||||
- LLM 没有生成预期的 artifact 文件
|
||||
|
||||
**解决:** 先运行 `/sf doctor` 修复状态,然后执行 `/sf auto` 恢复。如果问题持续存在,检查预期 artifact 文件是否确实已经写到磁盘。
|
||||
**解决:** 先运行 `/sf doctor` 修复状态,然后执行 `/sf autonomous` 恢复。如果问题持续存在,检查预期 artifact 文件是否确实已经写到磁盘。
|
||||
|
||||
### 自动模式因 “Loop detected” 停止
|
||||
|
||||
**原因:** 同一个单元连续两次没有生成预期 artifact。
|
||||
|
||||
**解决:** 检查 task plan 是否足够清晰。如果 plan 存在歧义,先手动澄清,再执行 `/sf auto` 恢复。
|
||||
**解决:** 检查 task plan 是否足够清晰。如果 plan 存在歧义,先手动澄清,再执行 `/sf autonomous` 恢复。
|
||||
|
||||
### Worktree 中出现了错误文件
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ models:
|
|||
- openrouter/minimax/minimax-m2.5
|
||||
```
|
||||
|
||||
**Headless 模式:** `sf headless auto` 在进程崩溃时会自动重启整个进程(默认 3 次,带指数退避)。与 provider 错误自动恢复配合后,能支持真正的夜间无人值守运行。
|
||||
**Headless 模式:** `sf headless autonomous` 在进程崩溃时会自动重启整个进程(默认 3 次,带指数退避)。与 provider 错误自动恢复配合后,能支持真正的夜间无人值守运行。
|
||||
|
||||
常见的 provider 配置问题(role 错误、streaming 错误、model ID 不匹配)见 [Provider 设置指南:常见坑点](./providers.md#common-pitfalls)。
|
||||
|
||||
|
|
@ -107,13 +107,13 @@ models:
|
|||
|
||||
**症状:** 自动模式因 “Budget ceiling reached” 暂停。
|
||||
|
||||
**解决:** 提高偏好设置中的 `budget_ceiling`,或者切换到 `budget` token profile 降低每个工作单元成本,然后再执行 `/sf auto` 恢复。
|
||||
**解决:** 提高偏好设置中的 `budget_ceiling`,或者切换到 `budget` token profile 降低每个工作单元成本,然后再执行 `/sf autonomous` 恢复。
|
||||
|
||||
### 过期锁文件
|
||||
|
||||
**症状:** 自动模式无法启动,提示另一个会话正在运行。
|
||||
|
||||
**解决:** SF 会自动检测过期锁:如果持有锁的 PID 已死亡,则在下次 `/sf auto` 时清理并重新获取锁。它也会处理 `proper-lockfile` 崩溃后遗留的 `.sf.lock/` 目录。如果自动恢复失败,可手动删除 `.sf/auto.lock` 和 `.sf.lock/`:
|
||||
**解决:** SF 会自动检测过期锁:如果持有锁的 PID 已死亡,则在下次 `/sf autonomous` 时清理并重新获取锁。它也会处理 `proper-lockfile` 崩溃后遗留的 `.sf.lock/` 目录。如果自动恢复失败,可手动删除 `.sf/auto.lock` 和 `.sf.lock/`:
|
||||
|
||||
```bash
|
||||
rm -f .sf/auto.lock
|
||||
|
|
@ -304,7 +304,7 @@ rm .sf/auto.lock
|
|||
rm .sf/completed-units.json
|
||||
```
|
||||
|
||||
然后执行 `/sf auto`,从当前磁盘状态重新开始。
|
||||
然后执行 `/sf autonomous`,从当前磁盘状态重新开始。
|
||||
|
||||
### 重置路由历史
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ You can stay hands-on with **step mode** (reviewing each step) or let SF run aut
|
|||
|
||||
## Key Features
|
||||
|
||||
- **Autonomous execution** — `/sf auto` runs research, planning, coding, testing, and committing without intervention
|
||||
- **Autonomous execution** — `/sf autonomous` runs research, planning, coding, testing, and committing without intervention
|
||||
- **20+ LLM providers** — Anthropic, OpenAI, Google, OpenRouter, GitHub Copilot, Amazon Bedrock, local models, and more
|
||||
- **Git isolation** — Each milestone works in its own worktree branch, merged cleanly when done
|
||||
- **Cost tracking** — Real-time token usage, budget ceilings, and automatic model downgrading
|
||||
|
|
@ -44,7 +44,7 @@ npm install -g sf-run
|
|||
sf
|
||||
|
||||
# Start autonomous mode
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
|
||||
See [Installation](getting-started/installation.md) for detailed setup instructions.
|
||||
|
|
@ -54,7 +54,7 @@ See [Installation](getting-started/installation.md) for detailed setup instructi
|
|||
| Mode | Command | Best For |
|
||||
|------|---------|----------|
|
||||
| **Step** | `/sf` | Staying in the loop, reviewing each step |
|
||||
| **Auto** | `/sf auto` | Walking away, overnight builds, batch work |
|
||||
| **Auto** | `/sf autonomous` | Walking away, overnight builds, batch work |
|
||||
|
||||
The recommended workflow: run auto mode in one terminal, steer from another. See [Step Mode](core-concepts/step-mode.md) and [Auto Mode](core-concepts/auto-mode.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# Auto Mode
|
||||
|
||||
Auto mode is SF's autonomous execution engine. Run `/sf auto`, walk away, come back to built software with clean git history.
|
||||
Auto mode is SF's autonomous execution engine. Run `/sf autonomous`, walk away, come back to built software with clean git history.
|
||||
|
||||
## Starting Auto Mode
|
||||
|
||||
```
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
|
||||
SF reads `.sf/STATE.md`, determines the next unit of work, creates a fresh AI session with all relevant context, and lets the AI execute. When it finishes, SF reads disk state again and dispatches the next unit. This continues until the milestone is complete.
|
||||
|
|
@ -35,7 +35,7 @@ Press **Escape**. The conversation is preserved. You can interact with the agent
|
|||
### Resume
|
||||
|
||||
```
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
|
||||
Auto mode reads disk state and picks up where it left off.
|
||||
|
|
@ -82,9 +82,9 @@ In worktree mode, all commits are squash-merged to main as one clean commit when
|
|||
|
||||
## Crash Recovery
|
||||
|
||||
If a session dies, the next `/sf auto` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context.
|
||||
If a session dies, the next `/sf autonomous` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context.
|
||||
|
||||
In headless mode (`sf headless auto`), crashes trigger automatic restart with exponential backoff (5s → 10s → 30s, up to 3 attempts). Combined with crash recovery, this enables true overnight "fire and forget" execution.
|
||||
In headless mode (`sf headless autonomous`), crashes trigger automatic restart with exponential backoff (5s → 10s → 30s, up to 3 attempts). Combined with crash recovery, this enables true overnight "fire and forget" execution.
|
||||
|
||||
## Provider Error Recovery
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ Between steps, you can:
|
|||
- **Discuss** — `/sf discuss` to talk through architecture decisions
|
||||
- **Skip** — `/sf skip` to prevent a unit from being dispatched
|
||||
- **Undo** — `/sf undo` to revert the last completed unit
|
||||
- **Switch to auto** — `/sf auto` to let SF continue autonomously
|
||||
- **Switch to auto** — `/sf autonomous` to let SF continue autonomously
|
||||
|
||||
## When to Use Step Mode
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ Between steps, you can:
|
|||
Once you're comfortable with SF's approach, switch to auto mode:
|
||||
|
||||
```
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
|
||||
You can always press **Escape** to pause auto mode and return to step-by-step control.
|
||||
|
|
|
|||
|
|
@ -71,15 +71,9 @@ sf headless query | jq '.cost.total'
|
|||
|
||||
Any `/sf` subcommand works as a positional argument: `sf headless status`, `sf headless doctor`, etc.
|
||||
|
||||
## MCP Server Mode
|
||||
## MCP Integrations
|
||||
|
||||
`sf --mode mcp` runs SF as a Model Context Protocol server over stdin/stdout, exposing all SF tools to external AI clients:
|
||||
|
||||
```bash
|
||||
sf --mode mcp
|
||||
```
|
||||
|
||||
Compatible with Claude Desktop, VS Code Copilot, and any MCP host.
|
||||
`/sf mcp` reports configured external MCP tool servers. SF does not expose its own workflow as an MCP server; run SF directly with `sf` or `/sf autonomous`.
|
||||
|
||||
## Auto-Restart
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Remote Questions
|
||||
|
||||
Remote questions let SF ask for your input via Slack, Discord, or Telegram when running in headless auto mode. When SF needs a decision, it posts the question to your configured channel and polls for a response.
|
||||
Remote questions let SF ask for your input via Slack, Discord, or Telegram when running in headless autonomous mode. When SF needs a decision, it posts the question to your configured channel and polls for a response.
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ The key rule: **a task must fit in one AI context window.** If it can't, it beco
|
|||
Once you have a milestone and roadmap, let SF take the wheel:
|
||||
|
||||
```
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
|
||||
SF autonomously:
|
||||
|
|
@ -56,7 +56,7 @@ The recommended approach: auto mode in one terminal, steering from another.
|
|||
|
||||
```bash
|
||||
sf
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
|
||||
**Terminal 2 — steer while it works:**
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/sf` | Step mode — execute one unit at a time |
|
||||
| `/sf auto` | Autonomous mode — research, plan, execute, commit, repeat |
|
||||
| `/sf autonomous` | Autonomous mode — research, plan, execute, commit, repeat |
|
||||
| `/sf quick` | Quick task with SF guarantees but no full planning |
|
||||
| `/sf stop` | Stop auto mode gracefully |
|
||||
| `/sf pause` | Pause auto mode (preserves state) |
|
||||
| `/sf stop` | Stop autonomous mode gracefully |
|
||||
| `/sf pause` | Pause autonomous mode (preserves state) |
|
||||
| `/sf steer` | Modify plan documents during execution |
|
||||
| `/sf discuss` | Discuss architecture and decisions |
|
||||
| `/sf status` | Progress dashboard |
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
| `/sf triage` | Manually trigger capture triage |
|
||||
| `/sf dispatch` | Dispatch a specific phase directly |
|
||||
| `/sf history` | View execution history (supports `--cost`, `--phase`, `--model` filters) |
|
||||
| `/sf forensics` | Full debugger for auto-mode failures |
|
||||
| `/sf forensics` | Full debugger for autonomous mode failures |
|
||||
| `/sf cleanup` | Clean up state files and stale worktrees |
|
||||
| `/sf visualize` | Open workflow visualizer |
|
||||
| `/sf export --html` | Generate HTML report for current milestone |
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
| `/sf rate` | Rate last unit's model tier (over/ok/under) |
|
||||
| `/sf changelog` | Show release notes |
|
||||
| `/sf logs` | Browse activity and debug logs |
|
||||
| `/sf remote` | Control remote auto-mode |
|
||||
| `/sf remote` | Control remote autonomous mode |
|
||||
| `/sf help` | Show all available commands |
|
||||
|
||||
## Configuration & Diagnostics
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/sf new-milestone` | Create a new milestone |
|
||||
| `/sf skip` | Prevent a unit from auto-mode dispatch |
|
||||
| `/sf skip` | Prevent a unit from autonomous mode dispatch |
|
||||
| `/sf undo` | Revert last completed unit |
|
||||
| `/sf undo-task` | Reset a specific task's completion state |
|
||||
| `/sf reset-slice` | Reset a slice and all its tasks |
|
||||
|
|
@ -88,7 +88,7 @@
|
|||
| `/sf workflow run <name>` | Start a workflow run |
|
||||
| `/sf workflow list` | List workflow runs |
|
||||
| `/sf workflow validate <name>` | Validate a workflow YAML |
|
||||
| `/sf workflow pause` | Pause workflow auto-mode |
|
||||
| `/sf workflow pause` | Pause workflow autonomous mode |
|
||||
| `/sf workflow resume` | Resume paused workflow |
|
||||
|
||||
## Extensions
|
||||
|
|
|
|||
|
|
@ -16,13 +16,13 @@ It checks file structure, roadmap ↔ slice ↔ task consistency, completion sta
|
|||
|
||||
The same unit dispatches repeatedly.
|
||||
|
||||
**Fix:** Run `/sf doctor` to repair state, then `/sf auto`. If it persists, check that the expected artifact file exists on disk.
|
||||
**Fix:** Run `/sf doctor` to repair state, then `/sf autonomous`. If it persists, check that the expected artifact file exists on disk.
|
||||
|
||||
### Auto mode stops with "Loop detected"
|
||||
|
||||
A unit failed to produce its expected artifact twice.
|
||||
|
||||
**Fix:** Check the task plan for clarity. Refine it manually, then `/sf auto`.
|
||||
**Fix:** Check the task plan for clarity. Refine it manually, then `/sf autonomous`.
|
||||
|
||||
### `command not found: sf` after install
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ models:
|
|||
|
||||
Auto mode pauses with "Budget ceiling reached."
|
||||
|
||||
**Fix:** Increase `budget_ceiling` in preferences, or switch to `budget` token profile, then `/sf auto`.
|
||||
**Fix:** Increase `budget_ceiling` in preferences, or switch to `budget` token profile, then `/sf autonomous`.
|
||||
|
||||
### Stale lock file
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ rm .sf/auto.lock
|
|||
rm .sf/completed-units.json
|
||||
```
|
||||
|
||||
Then `/sf auto` to restart from current state.
|
||||
Then `/sf autonomous` to restart from current state.
|
||||
|
||||
### Reset routing history
|
||||
|
||||
|
|
|
|||
|
|
@ -68,10 +68,10 @@ Or configure per-phase models in [preferences](/guides/configuration).
|
|||
- **Mid-task** → resume where you left off
|
||||
</Tab>
|
||||
<Tab title="Auto mode">
|
||||
Type `/sf auto` and walk away. SF autonomously researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete.
|
||||
Type `/sf autonomous` and walk away. SF autonomously researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete.
|
||||
|
||||
```
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
|
||||
See [auto mode](/guides/auto-mode) for the full details.
|
||||
|
|
@ -86,7 +86,7 @@ The recommended workflow: auto mode in one terminal, steering from another.
|
|||
|
||||
```bash
|
||||
sf
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
|
||||
**Terminal 2 — steer while it works:**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: "Auto mode"
|
||||
description: "SF's autonomous execution engine — run /sf auto, walk away, come back to built software with clean git history."
|
||||
description: "SF's autonomous execution engine — run /sf autonomous, walk away, come back to built software with clean git history."
|
||||
---
|
||||
|
||||
Auto mode is a **state machine driven by files on disk**. It reads `.sf/STATE.md`, determines the next unit of work, creates a fresh agent session with pre-loaded context, and lets the LLM execute. When the LLM finishes, auto mode reads disk state again and dispatches the next unit.
|
||||
|
|
@ -50,9 +50,9 @@ See [git strategy](/guides/git-strategy) for details.
|
|||
|
||||
### Crash recovery
|
||||
|
||||
A lock file tracks the current unit. If the session dies, the next `/sf auto` synthesizes a recovery briefing from tool calls that made it to disk and resumes with full context.
|
||||
A lock file tracks the current unit. If the session dies, the next `/sf autonomous` synthesizes a recovery briefing from tool calls that made it to disk and resumes with full context.
|
||||
|
||||
**Headless auto-restart:** When running `sf headless auto`, crashes trigger automatic restart with exponential backoff (5s → 10s → 30s cap, default 3 attempts). Combined with crash recovery, this enables overnight "run until done" execution.
|
||||
**Headless auto-restart:** When running `sf headless autonomous`, crashes trigger automatic restart with exponential backoff (5s → 10s → 30s cap, default 3 attempts). Combined with crash recovery, this enables overnight "run until done" execution.
|
||||
|
||||
### Provider error recovery
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ reactive_execution: true # disabled by default
|
|||
<Steps>
|
||||
<Step title="Start">
|
||||
```
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
</Step>
|
||||
<Step title="Pause">
|
||||
|
|
@ -130,7 +130,7 @@ reactive_execution: true # disabled by default
|
|||
</Step>
|
||||
<Step title="Resume">
|
||||
```
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
Auto mode reads disk state and picks up where it left off.
|
||||
</Step>
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ For structural changes (adding tasks, removing tasks), the agent triggers a slic
|
|||
</Step>
|
||||
<Step title="Run auto-mode">
|
||||
```
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
Auto-mode dispatches the next active milestone in queue order.
|
||||
</Step>
|
||||
|
|
|
|||
|
|
@ -9,22 +9,22 @@ description: "Every SF command, keyboard shortcut, and CLI flag."
|
|||
|---------|-------------|
|
||||
| `/sf` | Step mode — execute one unit at a time, pause between each |
|
||||
| `/sf next` | Explicit step mode (same as `/sf`) |
|
||||
| `/sf auto` | Autonomous mode — research, plan, execute, commit, repeat |
|
||||
| `/sf autonomous` | Autonomous mode — research, plan, execute, commit, repeat |
|
||||
| `/sf quick` | Execute a quick task with SF guarantees without full planning overhead |
|
||||
| `/sf stop` | Stop auto mode gracefully |
|
||||
| `/sf pause` | Pause auto mode (preserves state, `/sf auto` to resume) |
|
||||
| `/sf stop` | Stop autonomous mode gracefully |
|
||||
| `/sf pause` | Pause autonomous mode (preserves state, `/sf autonomous` to resume) |
|
||||
| `/sf steer` | Hard-steer plan documents during execution |
|
||||
| `/sf discuss` | Discuss architecture and decisions (works alongside auto mode) |
|
||||
| `/sf discuss` | Discuss architecture and decisions (works alongside autonomous mode) |
|
||||
| `/sf rethink` | Conversational project reorganization |
|
||||
| `/sf mcp` | MCP server status and connectivity |
|
||||
| `/sf status` | Progress dashboard |
|
||||
| `/sf widget` | Cycle dashboard widget: full / small / min / off |
|
||||
| `/sf queue` | Queue and reorder future milestones (safe during auto mode) |
|
||||
| `/sf capture` | Fire-and-forget thought capture (works during auto mode) |
|
||||
| `/sf queue` | Queue and reorder future milestones (safe during autonomous mode) |
|
||||
| `/sf capture` | Fire-and-forget thought capture (works during autonomous mode) |
|
||||
| `/sf triage` | Manually trigger triage of pending captures |
|
||||
| `/sf dispatch` | Dispatch a specific phase directly |
|
||||
| `/sf history` | View execution history (supports `--cost`, `--phase`, `--model` filters) |
|
||||
| `/sf forensics` | Full-access debugger for auto-mode failures |
|
||||
| `/sf forensics` | Full-access debugger for autonomous mode failures |
|
||||
| `/sf cleanup` | Clean up SF state files and stale worktrees |
|
||||
| `/sf visualize` | Open workflow visualizer |
|
||||
| `/sf export --html` | Generate self-contained HTML report |
|
||||
|
|
@ -35,7 +35,7 @@ description: "Every SF command, keyboard shortcut, and CLI flag."
|
|||
| `/sf rate` | Rate last unit's model tier (over/ok/under) |
|
||||
| `/sf changelog` | Show categorized release notes |
|
||||
| `/sf logs` | Browse activity logs, debug logs, and metrics |
|
||||
| `/sf remote` | Control remote auto-mode |
|
||||
| `/sf remote` | Control remote autonomous mode |
|
||||
| `/sf help` | Categorized command reference |
|
||||
|
||||
## Configuration and diagnostics
|
||||
|
|
@ -60,7 +60,7 @@ description: "Every SF command, keyboard shortcut, and CLI flag."
|
|||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/sf new-milestone` | Create a new milestone |
|
||||
| `/sf skip` | Prevent a unit from auto-mode dispatch |
|
||||
| `/sf skip` | Prevent a unit from autonomous mode dispatch |
|
||||
| `/sf undo` | Revert last completed unit |
|
||||
| `/sf undo-task` | Reset a specific task's completion state |
|
||||
| `/sf reset-slice` | Reset a slice and all its tasks |
|
||||
|
|
@ -92,11 +92,11 @@ description: "Every SF command, keyboard shortcut, and CLI flag."
|
|||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/sf workflow new` | Create a new workflow definition |
|
||||
| `/sf workflow run <name>` | Create a run and start auto-mode |
|
||||
| `/sf workflow run <name>` | Create a run and start autonomous mode |
|
||||
| `/sf workflow list` | List workflow runs |
|
||||
| `/sf workflow validate <name>` | Validate a workflow definition |
|
||||
| `/sf workflow pause` | Pause custom workflow auto-mode |
|
||||
| `/sf workflow resume` | Resume paused custom workflow auto-mode |
|
||||
| `/sf workflow pause` | Pause custom workflow autonomous mode |
|
||||
| `/sf workflow resume` | Resume paused custom workflow autonomous mode |
|
||||
|
||||
## Extensions
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ description: "Every SF command, keyboard shortcut, and CLI flag."
|
|||
| `Ctrl+Alt+V` | Toggle voice transcription |
|
||||
| `Ctrl+Alt+B` | Show background shell processes |
|
||||
| `Ctrl+V` / `Alt+V` | Paste image from clipboard |
|
||||
| `Escape` | Pause auto mode |
|
||||
| `Escape` | Pause autonomous mode |
|
||||
|
||||
<Note>
|
||||
In terminals without Kitty keyboard protocol support (macOS Terminal.app, JetBrains IDEs), slash-command fallbacks are shown instead of `Ctrl+Alt` shortcuts.
|
||||
|
|
@ -145,7 +145,7 @@ In terminals without Kitty keyboard protocol support (macOS Terminal.app, JetBra
|
|||
`sf headless` runs commands without a TUI — designed for CI, cron jobs, and scripted automation.
|
||||
|
||||
```bash
|
||||
sf headless # run auto mode
|
||||
sf headless # run autonomous mode
|
||||
sf headless next # run a single unit
|
||||
sf headless query # instant JSON snapshot (~50ms, no LLM)
|
||||
sf headless --timeout 600000 auto # with timeout
|
||||
|
|
@ -159,7 +159,7 @@ sf headless new-milestone --context brief.md --auto
|
|||
| `--json` | Stream events as JSONL to stdout |
|
||||
| `--model ID` | Override the model |
|
||||
| `--context <file>` | Context file for `new-milestone` (use `-` for stdin) |
|
||||
| `--auto` | Chain into auto-mode after milestone creation |
|
||||
| `--auto` | Chain into autonomous mode after milestone creation |
|
||||
|
||||
**Exit codes:** `0` = complete, `1` = error/timeout, `2` = blocked.
|
||||
|
||||
|
|
@ -173,10 +173,6 @@ sf headless query | jq '.next' # next dispatch action
|
|||
sf headless query | jq '.cost.total' # total spend
|
||||
```
|
||||
|
||||
## MCP server mode
|
||||
## MCP integrations
|
||||
|
||||
```bash
|
||||
sf --mode mcp
|
||||
```
|
||||
|
||||
Runs SF as a Model Context Protocol server over stdin/stdout, exposing all tools to external AI clients (Claude Desktop, VS Code Copilot, etc.).
|
||||
`/sf mcp` shows configured external MCP tool servers. SF does not expose its own workflow as an MCP server; run SF directly with `sf` or `/sf autonomous`.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
---
|
||||
title: "Remote questions"
|
||||
description: "Discord, Slack, and Telegram integration for headless auto-mode."
|
||||
description: "Discord, Slack, and Telegram integration for headless autonomous-mode."
|
||||
---
|
||||
|
||||
Remote questions allow SF to ask for user input via Slack, Discord, or Telegram when running in headless auto-mode. When SF encounters a decision point, it posts the question to your configured channel and polls for a response.
|
||||
Remote questions allow SF to ask for user input via Slack, Discord, or Telegram when running in headless autonomous-mode. When SF encounters a decision point, it posts the question to your configured channel and polls for a response.
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ It checks file structure, referential integrity, completion state consistency, g
|
|||
<Accordion title="Auto mode loops on the same unit">
|
||||
**Cause:** Stale cache after a crash, or the LLM didn't produce the expected artifact.
|
||||
|
||||
**Fix:** Run `/sf doctor` to repair state, then `/sf auto`.
|
||||
**Fix:** Run `/sf doctor` to repair state, then `/sf autonomous`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Auto mode stops with 'Loop detected'">
|
||||
**Cause:** A unit failed to produce its expected artifact twice in a row.
|
||||
|
||||
**Fix:** Check the task plan for clarity. Refine it manually, then `/sf auto`.
|
||||
**Fix:** Check the task plan for clarity. Refine it manually, then `/sf autonomous`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="command not found: sf">
|
||||
|
|
@ -59,7 +59,7 @@ It checks file structure, referential integrity, completion state consistency, g
|
|||
</Accordion>
|
||||
|
||||
<Accordion title="Budget ceiling reached">
|
||||
Increase `budget_ceiling` in preferences, or switch to `budget` token profile. Resume with `/sf auto`.
|
||||
Increase `budget_ceiling` in preferences, or switch to `budget` token profile. Resume with `/sf autonomous`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Stale lock file">
|
||||
|
|
@ -134,7 +134,7 @@ rm .sf/auto.lock
|
|||
rm .sf/completed-units.json
|
||||
```
|
||||
|
||||
Then `/sf auto` to restart from current disk state.
|
||||
Then `/sf autonomous` to restart from current disk state.
|
||||
|
||||
### Reset routing history
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ title: "SF — Singularity Forge"
|
|||
description: "An autonomous coding agent that researches, plans, executes, and commits code while you focus on what matters."
|
||||
---
|
||||
|
||||
SF is an autonomous coding agent. Describe what you want built, run `/sf auto`, and walk away. Come back to working software with clean git history.
|
||||
SF is an autonomous coding agent. Describe what you want built, run `/sf autonomous`, and walk away. Come back to working software with clean git history.
|
||||
|
||||
## What SF does
|
||||
|
||||
|
|
@ -56,11 +56,11 @@ Every phase gets a fresh context window with pre-loaded context — no accumulat
|
|||
```
|
||||
</Tab>
|
||||
<Tab title="Auto mode">
|
||||
Type `/sf auto` and walk away. SF autonomously researches, plans, executes, verifies, and commits until the milestone is complete.
|
||||
Type `/sf autonomous` and walk away. SF autonomously researches, plans, executes, verifies, and commits until the milestone is complete.
|
||||
|
||||
```bash
|
||||
sf
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
|
@ -71,7 +71,7 @@ The recommended workflow: auto mode in one terminal, steering from another.
|
|||
|
||||
```bash
|
||||
sf
|
||||
/sf auto
|
||||
/sf autonomous
|
||||
```
|
||||
|
||||
**Terminal 2 — steer while it works:**
|
||||
|
|
|
|||
25
package-lock.json
generated
25
package-lock.json
generated
|
|
@ -6026,10 +6026,6 @@
|
|||
"node_modules/@singularity-forge/engine-win32-x64-msvc": {
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@singularity-forge/mcp-server": {
|
||||
"resolved": "packages/mcp-server",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@singularity-forge/native": {
|
||||
"resolved": "packages/native",
|
||||
"link": true
|
||||
|
|
@ -16349,27 +16345,6 @@
|
|||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"packages/mcp-server": {
|
||||
"name": "@singularity-forge/mcp-server",
|
||||
"version": "2.75.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@singularity-forge/pi-agent-core": "^2.75.0",
|
||||
"@singularity-forge/rpc-client": "^2.75.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"sf-mcp-server": "dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
}
|
||||
},
|
||||
"packages/native": {
|
||||
"name": "@singularity-forge/native",
|
||||
"version": "2.75.0",
|
||||
|
|
|
|||
|
|
@ -50,9 +50,8 @@
|
|||
"build:native-pkg": "npm --workspace @singularity-forge/native run build",
|
||||
"build:rpc-client": "npm --workspace @singularity-forge/rpc-client run build",
|
||||
"build:pi": "npm run build:native-pkg && npm run build:pi-tui && npm run build:pi-ai && npm run build:pi-agent-core && npm run build:pi-coding-agent",
|
||||
"build:mcp-server": "npm --workspace @singularity-forge/mcp-server run build",
|
||||
"build:daemon": "npm --workspace @singularity-forge/daemon run build",
|
||||
"build:core": "npm run build:pi && npm run build:rpc-client && npm run build:daemon && npm run build:mcp-server && npm run check:versioned-json && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html",
|
||||
"build:core": "npm run build:pi && npm run build:rpc-client && npm run build:daemon && npm run check:versioned-json && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html",
|
||||
"build": "npm run build:core && node scripts/build-web-if-stale.cjs",
|
||||
"stage:web-host": "node scripts/stage-web-standalone.cjs",
|
||||
"build:web-host": "npm --prefix web run build && npm run stage:web-host",
|
||||
|
|
|
|||
|
|
@ -6,11 +6,8 @@
|
|||
* detects blockers, tracks terminal state, and accumulates cost using
|
||||
* the cumulative-max pattern (K004).
|
||||
*
|
||||
* Adapted from packages/mcp-server/src/session-manager.ts with:
|
||||
* - Logger integration for structured logging
|
||||
* - EventEmitter for session lifecycle events
|
||||
* - getAllSessions() for cross-project status (R035)
|
||||
* - projectName field on ManagedSession
|
||||
* Purpose: provide daemon-owned session tracking without exposing SF workflows
|
||||
* through an MCP server.
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
|
|
@ -45,7 +42,9 @@ const FIRE_AND_FORGET_METHODS = new Set([
|
|||
]);
|
||||
|
||||
const TERMINAL_PREFIXES = [
|
||||
"autonomous mode stopped",
|
||||
"auto-mode stopped",
|
||||
"autonomous mode paused",
|
||||
"auto-mode paused",
|
||||
"step-mode stopped",
|
||||
];
|
||||
|
|
@ -62,7 +61,9 @@ function isBlockedNotification(event: Record<string, unknown>): boolean {
|
|||
if (event.type !== "extension_ui_request" || event.method !== "notify")
|
||||
return false;
|
||||
const message = String(event.message ?? "").toLowerCase();
|
||||
return message.includes("blocked:") || message.startsWith("auto-mode paused");
|
||||
return (
|
||||
message.includes("blocked:") || message.startsWith("autonomous mode paused")
|
||||
);
|
||||
}
|
||||
|
||||
function isBlockingUIRequest(event: Record<string, unknown>): boolean {
|
||||
|
|
@ -84,7 +85,7 @@ export class SessionManager extends EventEmitter {
|
|||
}
|
||||
|
||||
/**
|
||||
* Start a new SF auto-mode session for the given project directory.
|
||||
* Start a new SF autonomous mode session for the given project directory.
|
||||
*
|
||||
* Rejects if a session already exists for this projectDir.
|
||||
* Creates an RpcClient, starts the process, performs the v2 init handshake,
|
||||
|
|
@ -433,7 +434,7 @@ export class SessionManager extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
// Terminal detection — auto-mode/step-mode stopped
|
||||
// Terminal detection — autonomous mode/step-mode stopped
|
||||
if (isTerminalNotification(event as Record<string, unknown>)) {
|
||||
if (isBlockedNotification(event as Record<string, unknown>)) {
|
||||
session.status = "blocked";
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export interface FormattedEvent {
|
|||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum number of events kept in the ring buffer (larger than mcp-server's 50 — daemon forwards events to Discord) */
|
||||
/** Maximum number of events kept in the ring buffer for daemon-forwarded integrations. */
|
||||
export const MAX_EVENTS = 100;
|
||||
|
||||
/** Timeout for RpcClient initialization (ms) */
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
dist/*.test.*
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
# @singularity-forge/mcp-server
|
||||
|
||||
MCP server exposing SF orchestration tools for Claude Code, Cursor, and other MCP-compatible clients.
|
||||
|
||||
Start SF auto-mode sessions, poll progress, resolve blockers, and retrieve results — all through the [Model Context Protocol](https://modelcontextprotocol.io/).
|
||||
|
||||
This package now exposes two tool surfaces:
|
||||
|
||||
- session/read tools for starting and inspecting SF sessions
|
||||
- MCP-native interactive tools for structured user input
|
||||
- headless-safe workflow tools for planning, completion, validation, reassessment, metadata persistence, and journal reads
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @singularity-forge/mcp-server
|
||||
```
|
||||
|
||||
Or with the monorepo workspace:
|
||||
|
||||
```bash
|
||||
# Already available as a workspace package
|
||||
npx sf-mcp-server
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Claude Code
|
||||
|
||||
Add to your project's `.mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sf": {
|
||||
"command": "npx",
|
||||
"args": ["sf-mcp-server"],
|
||||
"env": {
|
||||
"SF_CLI_PATH": "/path/to/sf"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or if installed globally:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sf": {
|
||||
"command": "sf-mcp-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
Add to `.cursor/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sf": {
|
||||
"command": "npx",
|
||||
"args": ["sf-mcp-server"],
|
||||
"env": {
|
||||
"SF_CLI_PATH": "/path/to/sf"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### Workflow tools
|
||||
|
||||
The workflow MCP surface includes:
|
||||
|
||||
- `sf_decision_save`
|
||||
- `sf_requirement_update`
|
||||
- `sf_requirement_save`
|
||||
- `sf_milestone_generate_id`
|
||||
- `sf_plan_milestone`
|
||||
- `sf_plan_slice`
|
||||
- `sf_plan_task`
|
||||
- `sf_replan_slice`
|
||||
- `sf_task_complete`
|
||||
- `sf_slice_complete`
|
||||
- `sf_skip_slice`
|
||||
- `sf_validate_milestone`
|
||||
- `sf_complete_milestone`
|
||||
- `sf_reassess_roadmap`
|
||||
- `sf_save_gate_result`
|
||||
- `sf_summary_save`
|
||||
- `sf_milestone_status`
|
||||
- `sf_journal_query`
|
||||
|
||||
These tools use the same SF workflow handlers as the native in-process tool path wherever a shared handler exists.
|
||||
|
||||
### Interactive tools
|
||||
|
||||
The packaged server now exposes `ask_user_questions` through MCP form elicitation. This keeps the existing SF answer payload shape while allowing Claude Code CLI and other elicitation-capable clients to surface structured user choices.
|
||||
|
||||
`secure_env_collect` is still not exposed by this package. That path needs MCP URL elicitation or an equivalent secure bridge because secrets should not flow through form elicitation.
|
||||
|
||||
Current support boundary:
|
||||
|
||||
- when running inside the SF monorepo checkout, the MCP server auto-discovers the shared workflow executor module
|
||||
- outside the monorepo, set `SF_WORKFLOW_EXECUTORS_MODULE` to an importable `workflow-tool-executors` module path if you want the mutation tools enabled
|
||||
- `ask_user_questions` requires an MCP client that supports form elicitation
|
||||
- session/read tools do not depend on this bridge
|
||||
|
||||
If the executor bridge cannot be loaded, workflow mutation calls will fail with a precise configuration error instead of silently degrading.
|
||||
|
||||
### `sf_execute`
|
||||
|
||||
Start a SF auto-mode session for a project directory.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `projectDir` | `string` | ✅ | Absolute path to the project directory |
|
||||
| `command` | `string` | | Command to send (default: `"/sf autonomous"`) |
|
||||
| `model` | `string` | | Model ID override |
|
||||
| `bare` | `boolean` | | Run in bare mode (skip user config) |
|
||||
|
||||
**Returns:** `{ sessionId, status: "started" }`
|
||||
|
||||
### `sf_status`
|
||||
|
||||
Poll the current status of a running SF session.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `sessionId` | `string` | ✅ | Session ID from `sf_execute` |
|
||||
|
||||
**Returns:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "running",
|
||||
"progress": { "eventCount": 42, "toolCalls": 15 },
|
||||
"recentEvents": [ ... ],
|
||||
"pendingBlocker": null,
|
||||
"cost": { "totalCost": 0.12, "tokens": { "input": 5000, "output": 2000, "cacheRead": 1000, "cacheWrite": 500 } },
|
||||
"durationMs": 45000
|
||||
}
|
||||
```
|
||||
|
||||
### `sf_result`
|
||||
|
||||
Get the accumulated result of a session. Works for both running (partial) and completed sessions.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `sessionId` | `string` | ✅ | Session ID from `sf_execute` |
|
||||
|
||||
**Returns:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionId": "abc-123",
|
||||
"projectDir": "/path/to/project",
|
||||
"status": "completed",
|
||||
"durationMs": 120000,
|
||||
"cost": { ... },
|
||||
"recentEvents": [ ... ],
|
||||
"pendingBlocker": null,
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `sf_cancel`
|
||||
|
||||
Cancel a running session. Aborts the current operation and stops the agent process.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `sessionId` | `string` | ✅ | Session ID from `sf_execute` |
|
||||
|
||||
**Returns:** `{ cancelled: true }`
|
||||
|
||||
### `sf_query`
|
||||
|
||||
Query SF project state from the filesystem without an active session. Returns STATE.md, PROJECT.md, requirements, and milestone listing.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `projectDir` | `string` | ✅ | Absolute path to the project directory |
|
||||
| `query` | `string` | ✅ | What to query (e.g. `"status"`, `"milestones"`) |
|
||||
|
||||
**Returns:**
|
||||
|
||||
```json
|
||||
{
|
||||
"projectDir": "/path/to/project",
|
||||
"state": "...",
|
||||
"project": "...",
|
||||
"requirements": "...",
|
||||
"milestones": [
|
||||
{ "id": "M001", "hasRoadmap": true, "hasSummary": false }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `sf_resolve_blocker`
|
||||
|
||||
Resolve a pending blocker in a session by sending a response to the blocked UI request.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `sessionId` | `string` | ✅ | Session ID from `sf_execute` |
|
||||
| `response` | `string` | ✅ | Response to send for the pending blocker |
|
||||
|
||||
**Returns:** `{ resolved: true }`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SF_CLI_PATH` | Absolute path to the SF CLI binary. If not set, the server resolves `sf` via `which`. |
|
||||
| `SF_WORKFLOW_EXECUTORS_MODULE` | Optional absolute path or `file:` URL for the shared SF workflow executor module used by workflow mutation tools. |
|
||||
|
||||
The server also hydrates supported model-provider and tool credentials from `~/.sf/agent/auth.json` on startup. Keys saved through `/sf config` or `/sf keys` become available to the MCP server process automatically, and any explicitly-set environment variable still wins.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ stdio ┌──────────────────┐
|
||||
│ MCP Client │ ◄────────────► │ @singularity-forge/mcp-server │
|
||||
│ (Claude Code, │ JSON-RPC │ │
|
||||
│ Cursor, etc.) │ │ SessionManager │
|
||||
└─────────────────┘ │ │ │
|
||||
│ ▼ │
|
||||
│ @singularity-forge/rpc-client │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ SF CLI (child │
|
||||
│ process via RPC)│
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
- **@singularity-forge/mcp-server** — MCP protocol adapter. Translates MCP tool calls into SessionManager operations.
|
||||
- **SessionManager** — Manages RpcClient lifecycle. One session per project directory. Tracks events in a ring buffer (last 50), detects blockers, accumulates cost.
|
||||
- **@singularity-forge/rpc-client** — Low-level RPC client that spawns and communicates with the SF CLI process via JSON-RPC over stdio.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
{
|
||||
"name": "@singularity-forge/mcp-server",
|
||||
"version": "2.75.0",
|
||||
"description": "MCP server exposing sf-run orchestration tools for Claude Code, Cursor, and other MCP clients",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/singularity-forge/sf-run.git",
|
||||
"directory": "packages/mcp-server"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"sf-mcp-server": "./dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run packages/mcp-server/src --root ../.. --config vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@singularity-forge/pi-agent-core": "^2.75.0",
|
||||
"@singularity-forge/rpc-client": "^2.75.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.test.*"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
/**
|
||||
* @singularity-forge/mcp-server CLI — stdio transport entry point.
|
||||
*
|
||||
* Connects the MCP server to stdin/stdout for use by Claude Code,
|
||||
* Cursor, and other MCP-compatible clients.
|
||||
*/
|
||||
|
||||
import { createMcpServer } from "./server.js";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
import { loadStoredCredentialEnvKeys } from "./tool-credentials.js";
|
||||
|
||||
const MCP_PKG = "@modelcontextprotocol/sdk";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
loadStoredCredentialEnvKeys();
|
||||
|
||||
const sessionManager = new SessionManager();
|
||||
|
||||
// Create the configured MCP server with session, interactive, read-only,
|
||||
// and workflow tools.
|
||||
const { server } = await createMcpServer(sessionManager);
|
||||
|
||||
// Dynamic import for StdioServerTransport (same TS subpath workaround)
|
||||
const { StdioServerTransport } = await import(`${MCP_PKG}/server/stdio.js`);
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
// Cleanup handler — stop all sessions before exiting
|
||||
let cleaningUp = false;
|
||||
async function cleanup(): Promise<void> {
|
||||
if (cleaningUp) return;
|
||||
cleaningUp = true;
|
||||
process.stderr.write("[sf-mcp-server] Shutting down...\n");
|
||||
try {
|
||||
await sessionManager.cleanup();
|
||||
} catch {
|
||||
// swallow cleanup errors
|
||||
}
|
||||
try {
|
||||
await server.close();
|
||||
} catch {
|
||||
// swallow close errors
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => void cleanup());
|
||||
process.on("SIGINT", () => void cleanup());
|
||||
|
||||
// Handle stdin end — MCP client disconnected
|
||||
process.stdin.on("end", () => void cleanup());
|
||||
|
||||
// Connect and start serving
|
||||
try {
|
||||
await server.connect(transport);
|
||||
process.stderr.write("[sf-mcp-server] MCP server started on stdio\n");
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[sf-mcp-server] Fatal: failed to start — ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
await sessionManager.cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(
|
||||
`[sf-mcp-server] Fatal: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { validateToolArguments } from "../../pi-ai/src/utils/validation.ts";
|
||||
import { registerWorkflowTools } from "./workflow-tools.ts";
|
||||
|
||||
type RegisteredTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function makeMockServer() {
|
||||
const tools: RegisteredTool[] = [];
|
||||
return {
|
||||
tools,
|
||||
tool(
|
||||
name: string,
|
||||
description: string,
|
||||
params: Record<string, unknown>,
|
||||
_handler: (args: Record<string, unknown>) => Promise<unknown>,
|
||||
) {
|
||||
tools.push({ name, description, params });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function workflowToolSchema(toolName: string): Record<string, unknown> {
|
||||
const server = makeMockServer();
|
||||
registerWorkflowTools(server as any);
|
||||
const tool = server.tools.find((candidate) => candidate.name === toolName);
|
||||
assert.ok(tool, `${toolName} should be registered`);
|
||||
|
||||
const schema = z.toJSONSchema(
|
||||
z.object(tool.params as z.ZodRawShape),
|
||||
) as Record<string, unknown>;
|
||||
delete schema.$schema;
|
||||
return schema;
|
||||
}
|
||||
|
||||
function makeToolCall(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
type: "toolCall" as const,
|
||||
id: "call-1",
|
||||
name: "sf_task_complete",
|
||||
arguments: {
|
||||
projectDir: "/tmp/sf-project",
|
||||
taskId: "T01",
|
||||
sliceId: "S01",
|
||||
milestoneId: "M001",
|
||||
oneLiner: "Completed task",
|
||||
narrative: "Did the work.",
|
||||
verification: "npm test",
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("string-array schema coercion", () => {
|
||||
const sfCompleteTaskTool = {
|
||||
name: "sf_task_complete",
|
||||
description:
|
||||
"Record a completed task to the SF database and render its SUMMARY.md.",
|
||||
parameters: workflowToolSchema("sf_task_complete") as any,
|
||||
};
|
||||
|
||||
it("coerces a bare string keyDecisions value before validation", () => {
|
||||
const args = validateToolArguments(
|
||||
sfCompleteTaskTool,
|
||||
makeToolCall({ keyDecisions: "single string" }),
|
||||
);
|
||||
|
||||
assert.deepEqual(args.keyDecisions, ["single string"]);
|
||||
});
|
||||
|
||||
it("keeps an array keyDecisions value valid", () => {
|
||||
const args = validateToolArguments(
|
||||
sfCompleteTaskTool,
|
||||
makeToolCall({ keyDecisions: ["a", "b"] }),
|
||||
);
|
||||
|
||||
assert.deepEqual(args.keyDecisions, ["a", "b"]);
|
||||
});
|
||||
|
||||
it("rejects a non-string, non-array keyDecisions value", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
validateToolArguments(
|
||||
sfCompleteTaskTool,
|
||||
makeToolCall({ keyDecisions: 42 }),
|
||||
),
|
||||
/keyDecisions: must be array/,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows an undefined optional keyDecisions value", () => {
|
||||
const args = validateToolArguments(
|
||||
sfCompleteTaskTool,
|
||||
makeToolCall({ keyDecisions: undefined }),
|
||||
);
|
||||
|
||||
assert.equal(args.keyDecisions, undefined);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,375 +0,0 @@
|
|||
// @singularity-forge/mcp-server — Tests for env-writer utilities
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, it } from "vitest";
|
||||
|
||||
import {
|
||||
applySecrets,
|
||||
checkExistingEnvKeys,
|
||||
detectDestination,
|
||||
isSafeEnvVarKey,
|
||||
isSupportedDeploymentEnvironment,
|
||||
resolveProjectEnvFilePath,
|
||||
shellEscapeSingle,
|
||||
writeEnvKey,
|
||||
} from "./env-writer.js";
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
return mkdtempSync(join(tmpdir(), `${prefix}-`));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkExistingEnvKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkExistingEnvKeys", () => {
|
||||
it("finds key in .env file", async () => {
|
||||
const tmp = makeTempDir("env-check");
|
||||
try {
|
||||
const envPath = join(tmp, ".env");
|
||||
writeFileSync(envPath, "API_KEY=secret123\nOTHER=val\n");
|
||||
const result = await checkExistingEnvKeys(["API_KEY"], envPath);
|
||||
assert.deepStrictEqual(result, ["API_KEY"]);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("finds key in process.env", async () => {
|
||||
const tmp = makeTempDir("env-check");
|
||||
const saved = process.env.SF_MCP_TEST_KEY_1;
|
||||
try {
|
||||
process.env.SF_MCP_TEST_KEY_1 = "some-value";
|
||||
const envPath = join(tmp, ".env");
|
||||
const result = await checkExistingEnvKeys(["SF_MCP_TEST_KEY_1"], envPath);
|
||||
assert.deepStrictEqual(result, ["SF_MCP_TEST_KEY_1"]);
|
||||
} finally {
|
||||
delete process.env.SF_MCP_TEST_KEY_1;
|
||||
if (saved !== undefined) process.env.SF_MCP_TEST_KEY_1 = saved;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty for missing keys", async () => {
|
||||
const tmp = makeTempDir("env-check");
|
||||
try {
|
||||
const envPath = join(tmp, ".env");
|
||||
writeFileSync(envPath, "OTHER=val\n");
|
||||
delete process.env.DEFINITELY_NOT_SET_MCP_XYZ;
|
||||
const result = await checkExistingEnvKeys(
|
||||
["DEFINITELY_NOT_SET_MCP_XYZ"],
|
||||
envPath,
|
||||
);
|
||||
assert.deepStrictEqual(result, []);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("handles missing .env file gracefully", async () => {
|
||||
const tmp = makeTempDir("env-check");
|
||||
try {
|
||||
const envPath = join(tmp, "nonexistent.env");
|
||||
delete process.env.DEFINITELY_NOT_SET_MCP_XYZ;
|
||||
const result = await checkExistingEnvKeys(
|
||||
["DEFINITELY_NOT_SET_MCP_XYZ"],
|
||||
envPath,
|
||||
);
|
||||
assert.deepStrictEqual(result, []);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectDestination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("detectDestination", () => {
|
||||
it("returns vercel when vercel.json exists", () => {
|
||||
const tmp = makeTempDir("dest");
|
||||
try {
|
||||
writeFileSync(join(tmp, "vercel.json"), "{}");
|
||||
assert.equal(detectDestination(tmp), "vercel");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns convex when convex/ dir exists", () => {
|
||||
const tmp = makeTempDir("dest");
|
||||
try {
|
||||
mkdirSync(join(tmp, "convex"));
|
||||
assert.equal(detectDestination(tmp), "convex");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns dotenv when neither exists", () => {
|
||||
const tmp = makeTempDir("dest");
|
||||
try {
|
||||
assert.equal(detectDestination(tmp), "dotenv");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("vercel takes priority over convex", () => {
|
||||
const tmp = makeTempDir("dest");
|
||||
try {
|
||||
writeFileSync(join(tmp, "vercel.json"), "{}");
|
||||
mkdirSync(join(tmp, "convex"));
|
||||
assert.equal(detectDestination(tmp), "vercel");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// writeEnvKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("writeEnvKey", () => {
|
||||
it("creates .env file with new key", async () => {
|
||||
const tmp = makeTempDir("write");
|
||||
try {
|
||||
const envPath = join(tmp, ".env");
|
||||
await writeEnvKey(envPath, "NEW_KEY", "new-value");
|
||||
const content = readFileSync(envPath, "utf8");
|
||||
assert.ok(content.includes("NEW_KEY=new-value"));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("updates existing key in-place", async () => {
|
||||
const tmp = makeTempDir("write");
|
||||
try {
|
||||
const envPath = join(tmp, ".env");
|
||||
writeFileSync(envPath, "EXISTING=old\nOTHER=keep\n");
|
||||
await writeEnvKey(envPath, "EXISTING", "new");
|
||||
const content = readFileSync(envPath, "utf8");
|
||||
assert.ok(content.includes("EXISTING=new"));
|
||||
assert.ok(content.includes("OTHER=keep"));
|
||||
assert.ok(!content.includes("old"));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes newlines in values", async () => {
|
||||
const tmp = makeTempDir("write");
|
||||
try {
|
||||
const envPath = join(tmp, ".env");
|
||||
await writeEnvKey(envPath, "MULTI", "line1\nline2");
|
||||
const content = readFileSync(envPath, "utf8");
|
||||
assert.ok(content.includes("MULTI=line1\\nline2"));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-string values", async () => {
|
||||
const tmp = makeTempDir("write");
|
||||
try {
|
||||
const envPath = join(tmp, ".env");
|
||||
await assert.rejects(
|
||||
() => writeEnvKey(envPath, "KEY", undefined as unknown as string),
|
||||
/expects a string value/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not follow symlinked env files when writing", async () => {
|
||||
const tmp = makeTempDir("write");
|
||||
const outside = makeTempDir("write-outside");
|
||||
try {
|
||||
const outsideEnv = join(outside, ".env");
|
||||
writeFileSync(outsideEnv, "SECRET=outside\n");
|
||||
symlinkSync(outsideEnv, join(tmp, ".env"));
|
||||
|
||||
await assert.rejects(
|
||||
() => writeEnvKey(join(tmp, ".env"), "SECRET", "inside"),
|
||||
/ELOOP|symbolic link|symlink/i,
|
||||
);
|
||||
assert.equal(readFileSync(outsideEnv, "utf8"), "SECRET=outside\n");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveProjectEnvFilePath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveProjectEnvFilePath", () => {
|
||||
it("allows .env under the project root", () => {
|
||||
const tmp = makeTempDir("env-path");
|
||||
try {
|
||||
assert.equal(
|
||||
resolveProjectEnvFilePath(tmp, ".env"),
|
||||
join(realpathSync.native(tmp), ".env"),
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects envFilePath outside the project root", () => {
|
||||
const tmp = makeTempDir("env-path");
|
||||
try {
|
||||
assert.throws(
|
||||
() => resolveProjectEnvFilePath(tmp, "../outside.env"),
|
||||
/inside the project directory/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects symlinked parent directories that escape the project root", () => {
|
||||
const tmp = makeTempDir("env-path");
|
||||
const outside = makeTempDir("env-path-outside");
|
||||
try {
|
||||
symlinkSync(outside, join(tmp, "linked-outside"), "dir");
|
||||
assert.throws(
|
||||
() => resolveProjectEnvFilePath(tmp, "linked-outside/.env"),
|
||||
/inside the project directory/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects existing env files that are symlinks outside the project root", () => {
|
||||
const tmp = makeTempDir("env-path");
|
||||
const outside = makeTempDir("env-path-outside");
|
||||
try {
|
||||
writeFileSync(join(outside, ".env"), "SECRET=outside\n");
|
||||
symlinkSync(join(outside, ".env"), join(tmp, ".env"));
|
||||
assert.throws(
|
||||
() => resolveProjectEnvFilePath(tmp, ".env"),
|
||||
/inside the project directory/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applySecrets (dotenv)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("applySecrets", () => {
|
||||
const savedKeys: Record<string, string | undefined> = {};
|
||||
|
||||
afterEach(() => {
|
||||
for (const [k, v] of Object.entries(savedKeys)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
it("writes keys to .env and hydrates process.env", async () => {
|
||||
const tmp = makeTempDir("apply");
|
||||
const envPath = join(tmp, ".env");
|
||||
savedKeys.SF_APPLY_TEST_A = process.env.SF_APPLY_TEST_A;
|
||||
try {
|
||||
const { applied, errors } = await applySecrets(
|
||||
[{ key: "SF_APPLY_TEST_A", value: "val-a" }],
|
||||
"dotenv",
|
||||
{ envFilePath: envPath },
|
||||
);
|
||||
assert.deepStrictEqual(applied, ["SF_APPLY_TEST_A"]);
|
||||
assert.deepStrictEqual(errors, []);
|
||||
assert.equal(process.env.SF_APPLY_TEST_A, "val-a");
|
||||
const content = readFileSync(envPath, "utf8");
|
||||
assert.ok(content.includes("SF_APPLY_TEST_A=val-a"));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns errors for invalid vercel environment", async () => {
|
||||
const tmp = makeTempDir("apply");
|
||||
try {
|
||||
const { applied, errors } = await applySecrets(
|
||||
[{ key: "KEY", value: "val" }],
|
||||
"vercel",
|
||||
{
|
||||
envFilePath: join(tmp, ".env"),
|
||||
environment: "staging" as "development",
|
||||
execFn: async () => ({ code: 0, stderr: "" }),
|
||||
},
|
||||
);
|
||||
assert.deepStrictEqual(applied, []);
|
||||
assert.ok(errors[0]?.includes("unsupported"));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("isSafeEnvVarKey", () => {
|
||||
it("accepts valid keys", () => {
|
||||
assert.ok(isSafeEnvVarKey("API_KEY"));
|
||||
assert.ok(isSafeEnvVarKey("_PRIVATE"));
|
||||
assert.ok(isSafeEnvVarKey("key123"));
|
||||
});
|
||||
|
||||
it("rejects invalid keys", () => {
|
||||
assert.ok(!isSafeEnvVarKey("123BAD"));
|
||||
assert.ok(!isSafeEnvVarKey("has-dash"));
|
||||
assert.ok(!isSafeEnvVarKey("has space"));
|
||||
assert.ok(!isSafeEnvVarKey(""));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSupportedDeploymentEnvironment", () => {
|
||||
it("accepts valid environments", () => {
|
||||
assert.ok(isSupportedDeploymentEnvironment("development"));
|
||||
assert.ok(isSupportedDeploymentEnvironment("preview"));
|
||||
assert.ok(isSupportedDeploymentEnvironment("production"));
|
||||
});
|
||||
|
||||
it("rejects invalid environments", () => {
|
||||
assert.ok(!isSupportedDeploymentEnvironment("staging"));
|
||||
assert.ok(!isSupportedDeploymentEnvironment("test"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("shellEscapeSingle", () => {
|
||||
it("wraps in single quotes", () => {
|
||||
assert.equal(shellEscapeSingle("hello"), "'hello'");
|
||||
});
|
||||
|
||||
it("escapes embedded single quotes", () => {
|
||||
assert.equal(shellEscapeSingle("it's"), "'it'\\''s'");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
// @singularity-forge/mcp-server — Environment variable write utilities
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
//
|
||||
// Shared helpers for writing env vars to .env files, detecting project
|
||||
// destinations, and checking existing keys. Used by secure_env_collect
|
||||
// MCP tool. No TUI dependencies — pure filesystem + process.env operations.
|
||||
|
||||
import {
|
||||
constants,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { open, readFile, rename, rm } from "node:fs/promises";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
} from "node:path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkExistingEnvKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check which keys already exist in a .env file or process.env.
|
||||
* Returns the subset of `keys` that are already set.
|
||||
*/
|
||||
export async function checkExistingEnvKeys(
|
||||
keys: string[],
|
||||
envFilePath: string,
|
||||
): Promise<string[]> {
|
||||
let fileContent = "";
|
||||
try {
|
||||
fileContent = await readFile(envFilePath, "utf8");
|
||||
} catch {
|
||||
// ENOENT or other read error — proceed with empty content
|
||||
}
|
||||
|
||||
const existing: string[] = [];
|
||||
for (const key of keys) {
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`^${escaped}\\s*=`, "m");
|
||||
if (regex.test(fileContent) || key in process.env) {
|
||||
existing.push(key);
|
||||
}
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectDestination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect the write destination based on project files in basePath.
|
||||
* Priority: vercel.json → convex/ dir → fallback "dotenv".
|
||||
*/
|
||||
export function detectDestination(
|
||||
basePath: string,
|
||||
): "dotenv" | "vercel" | "convex" {
|
||||
if (existsSync(resolve(basePath, "vercel.json"))) {
|
||||
return "vercel";
|
||||
}
|
||||
const convexPath = resolve(basePath, "convex");
|
||||
try {
|
||||
if (existsSync(convexPath) && statSync(convexPath).isDirectory()) {
|
||||
return "convex";
|
||||
}
|
||||
} catch {
|
||||
// stat error — treat as not found
|
||||
}
|
||||
return "dotenv";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// writeEnvKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Write a single key=value pair to a .env file.
|
||||
* Updates existing keys in-place, appends new ones at the end.
|
||||
*/
|
||||
export async function writeEnvKey(
|
||||
filePath: string,
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(
|
||||
`writeEnvKey expects a string value for key "${key}", got ${typeof value}`,
|
||||
);
|
||||
}
|
||||
assertWritableEnvFileTarget(filePath);
|
||||
let content = "";
|
||||
try {
|
||||
const handle = await open(
|
||||
filePath,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
content = await handle.readFile("utf8");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
content = "";
|
||||
}
|
||||
const escaped = value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\r/g, "");
|
||||
const line = `${key}=${escaped}`;
|
||||
const regex = new RegExp(
|
||||
`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=.*$`,
|
||||
"m",
|
||||
);
|
||||
if (regex.test(content)) {
|
||||
content = content.replace(regex, line);
|
||||
} else {
|
||||
if (content.length > 0 && !content.endsWith("\n")) content += "\n";
|
||||
content += `${line}\n`;
|
||||
}
|
||||
const tempPath = join(
|
||||
dirname(filePath),
|
||||
`.${basename(filePath)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`,
|
||||
);
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(
|
||||
tempPath,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
||||
0o600,
|
||||
);
|
||||
await handle.writeFile(content, "utf8");
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
assertWritableEnvFileTarget(filePath);
|
||||
await rename(tempPath, filePath);
|
||||
} catch (err) {
|
||||
if (handle) {
|
||||
try {
|
||||
await handle.close();
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
await rm(tempPath, { force: true }).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function assertWritableEnvFileTarget(filePath: string): void {
|
||||
try {
|
||||
if (lstatSync(filePath).isSymbolicLink()) {
|
||||
throw new Error("Refusing to write symlinked env file");
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isSafeEnvVarKey(key: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
|
||||
}
|
||||
|
||||
export function isSupportedDeploymentEnvironment(env: string): boolean {
|
||||
return env === "development" || env === "preview" || env === "production";
|
||||
}
|
||||
|
||||
function isWithinProjectRoot(
|
||||
projectRoot: string,
|
||||
candidatePath: string,
|
||||
): boolean {
|
||||
const rel = relative(projectRoot, candidatePath);
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
export function resolveProjectEnvFilePath(
|
||||
projectDir: string,
|
||||
envFilePath = ".env",
|
||||
): string {
|
||||
const projectRoot = realpathSync.native(resolve(projectDir));
|
||||
const candidate = resolve(projectRoot, envFilePath);
|
||||
if (!isWithinProjectRoot(projectRoot, candidate)) {
|
||||
throw new Error("envFilePath must resolve inside the project directory");
|
||||
}
|
||||
if (existsSync(candidate)) {
|
||||
const targetRealPath = realpathSync.native(candidate);
|
||||
if (isWithinProjectRoot(projectRoot, targetRealPath)) {
|
||||
return candidate;
|
||||
}
|
||||
throw new Error("envFilePath must resolve inside the project directory");
|
||||
}
|
||||
const candidateParent = dirname(candidate);
|
||||
const parentRealPath = realpathSync.native(candidateParent);
|
||||
if (isWithinProjectRoot(projectRoot, parentRealPath)) {
|
||||
return candidate;
|
||||
}
|
||||
throw new Error("envFilePath must resolve inside the project directory");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell helpers (for vercel/convex CLI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function shellEscapeSingle(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applySecrets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ApplyResult {
|
||||
applied: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply collected secrets to the target destination.
|
||||
* Dotenv writes are handled directly; vercel/convex shell out via execFn.
|
||||
*/
|
||||
export async function applySecrets(
|
||||
provided: Array<{ key: string; value: string }>,
|
||||
destination: "dotenv" | "vercel" | "convex",
|
||||
opts: {
|
||||
envFilePath: string;
|
||||
environment?: string;
|
||||
execFn?: (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
) => Promise<{ code: number; stderr: string }>;
|
||||
},
|
||||
): Promise<ApplyResult> {
|
||||
const applied: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
if (destination === "dotenv") {
|
||||
for (const { key, value } of provided) {
|
||||
try {
|
||||
await writeEnvKey(opts.envFilePath, key, value);
|
||||
applied.push(key);
|
||||
// Hydrate process.env so the current session sees the new value
|
||||
process.env[key] = value;
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`${key}: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((destination === "vercel" || destination === "convex") && opts.execFn) {
|
||||
const env = opts.environment ?? "development";
|
||||
if (!isSupportedDeploymentEnvironment(env)) {
|
||||
errors.push(`environment: unsupported target environment "${env}"`);
|
||||
return { applied, errors };
|
||||
}
|
||||
for (const { key, value } of provided) {
|
||||
if (!isSafeEnvVarKey(key)) {
|
||||
errors.push(`${key}: invalid environment variable name`);
|
||||
continue;
|
||||
}
|
||||
const cmd =
|
||||
destination === "vercel"
|
||||
? `printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}`
|
||||
: "";
|
||||
try {
|
||||
const result =
|
||||
destination === "vercel"
|
||||
? await opts.execFn("sh", ["-c", cmd])
|
||||
: await opts.execFn("npx", ["convex", "env", "set", key, value]);
|
||||
if (result.code !== 0) {
|
||||
errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
|
||||
} else {
|
||||
applied.push(key);
|
||||
process.env[key] = value;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`${key}: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { applied, errors };
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
// SF — Regression tests for importLocalModule candidate resolution (#3954)
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { _buildImportCandidates } from "./workflow-tools.js";
|
||||
|
||||
describe("_buildImportCandidates", () => {
|
||||
it("includes dist/ fallback for src/ paths", () => {
|
||||
const candidates = _buildImportCandidates(
|
||||
"../../../src/resources/extensions/sf/db-writer.js",
|
||||
);
|
||||
assert.ok(
|
||||
candidates.some((c) =>
|
||||
c.includes("/dist/resources/extensions/sf/db-writer.js"),
|
||||
),
|
||||
"should include dist/ swapped candidate",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes src/ fallback for dist/ paths", () => {
|
||||
const candidates = _buildImportCandidates(
|
||||
"../../../dist/resources/extensions/sf/db-writer.js",
|
||||
);
|
||||
assert.ok(
|
||||
candidates.some((c) =>
|
||||
c.includes("/src/resources/extensions/sf/db-writer.js"),
|
||||
),
|
||||
"should include src/ swapped candidate",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes .ts variants for .js paths", () => {
|
||||
const candidates = _buildImportCandidates(
|
||||
"../../../src/resources/extensions/sf/db-writer.js",
|
||||
);
|
||||
assert.ok(
|
||||
candidates.some((c) => c.endsWith("db-writer.ts") && c.includes("/src/")),
|
||||
"should include .ts variant for original src/ path",
|
||||
);
|
||||
assert.ok(
|
||||
candidates.some(
|
||||
(c) => c.endsWith("db-writer.ts") && c.includes("/dist/"),
|
||||
),
|
||||
"should include .ts variant for swapped dist/ path",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns original path first", () => {
|
||||
const input = "../../../src/resources/extensions/sf/db-writer.js";
|
||||
const candidates = _buildImportCandidates(input);
|
||||
assert.equal(
|
||||
candidates[0],
|
||||
input,
|
||||
"first candidate should be the original path",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles paths without src/ or dist/ gracefully", () => {
|
||||
const candidates = _buildImportCandidates("./local-module.js");
|
||||
assert.equal(
|
||||
candidates.length,
|
||||
2,
|
||||
"should have original + .ts variant only",
|
||||
);
|
||||
assert.equal(candidates[0], "./local-module.js");
|
||||
assert.equal(candidates[1], "./local-module.ts");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/**
|
||||
* @singularity-forge/mcp-server — MCP server for SF orchestration and project state.
|
||||
*/
|
||||
|
||||
// Path resolution utilities
|
||||
export { resolveSFRoot } from "@singularity-forge/pi-agent-core";
|
||||
export type { CaptureEntry, CapturesResult } from "./readers/captures.js";
|
||||
export { readCaptures } from "./readers/captures.js";
|
||||
export type { DoctorIssue, DoctorResult } from "./readers/doctor-lite.js";
|
||||
export { runDoctorLite } from "./readers/doctor-lite.js";
|
||||
export type {
|
||||
ConfidenceTier,
|
||||
EdgeType,
|
||||
GraphDiffResult,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
GraphQueryResult,
|
||||
GraphStatusResult,
|
||||
KnowledgeGraph,
|
||||
NodeType,
|
||||
} from "./readers/graph.js";
|
||||
export {
|
||||
buildGraph,
|
||||
graphDiff,
|
||||
graphQuery,
|
||||
graphStatus,
|
||||
writeGraph,
|
||||
writeSnapshot,
|
||||
} from "./readers/graph.js";
|
||||
export type { KnowledgeEntry, KnowledgeResult } from "./readers/knowledge.js";
|
||||
export { readKnowledge } from "./readers/knowledge.js";
|
||||
export type { HistoryResult, MetricsUnit } from "./readers/metrics.js";
|
||||
export { readHistory } from "./readers/metrics.js";
|
||||
export type {
|
||||
MilestoneInfo,
|
||||
RoadmapResult,
|
||||
SliceInfo,
|
||||
TaskInfo,
|
||||
} from "./readers/roadmap.js";
|
||||
export { readRoadmap } from "./readers/roadmap.js";
|
||||
export type { ProgressResult } from "./readers/state.js";
|
||||
// Read-only state readers (usable without a running session)
|
||||
export { readProgress } from "./readers/state.js";
|
||||
export { createMcpServer } from "./server.js";
|
||||
export { SessionManager } from "./session-manager.js";
|
||||
export type {
|
||||
CostAccumulator,
|
||||
ExecuteOptions,
|
||||
ManagedSession,
|
||||
PendingBlocker,
|
||||
SessionStatus,
|
||||
} from "./types.js";
|
||||
export { INIT_TIMEOUT_MS, MAX_EVENTS } from "./types.js";
|
||||
|
|
@ -1,783 +0,0 @@
|
|||
/**
|
||||
* @singularity-forge/mcp-server — Integration and unit tests.
|
||||
*
|
||||
* Strategy: We cannot mock @singularity-forge/rpc-client at the module level without
|
||||
* --experimental-test-module-mocks. Instead we test by:
|
||||
*
|
||||
* 1. Subclassing SessionManager to inject a mock client factory
|
||||
* 2. Testing event handling, state transitions, and error paths
|
||||
* 3. Testing tool registration via createMcpServer
|
||||
* 4. Testing CLI path resolution via static method
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, it } from "vitest";
|
||||
import {
|
||||
buildAskUserQuestionsElicitRequest,
|
||||
createMcpServer,
|
||||
formatAskUserQuestionsElicitResult,
|
||||
} from "./server.js";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
import type { ManagedSession } from "./types.js";
|
||||
import { MAX_EVENTS } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock RpcClient (duck-typed to match RpcClient interface)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class MockRpcClient {
|
||||
started = false;
|
||||
stopped = false;
|
||||
aborted = false;
|
||||
prompted: string[] = [];
|
||||
private eventListeners: Array<(event: Record<string, unknown>) => void> = [];
|
||||
uiResponses: Array<{ requestId: string; response: Record<string, unknown> }> =
|
||||
[];
|
||||
|
||||
/** Control — set to make start() reject */
|
||||
startError: Error | null = null;
|
||||
/** Control — set to make init() reject */
|
||||
initError: Error | null = null;
|
||||
/** Control — override sessionId from init */
|
||||
initSessionId = "mock-session-001";
|
||||
|
||||
cwd: string;
|
||||
args: string[];
|
||||
|
||||
constructor(options?: Record<string, unknown>) {
|
||||
this.cwd = (options?.cwd as string) ?? "";
|
||||
this.args = (options?.args as string[]) ?? [];
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.startError) throw this.startError;
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
async init(): Promise<{ sessionId: string; version: string }> {
|
||||
if (this.initError) throw this.initError;
|
||||
return { sessionId: this.initSessionId, version: "2.51.0" };
|
||||
}
|
||||
|
||||
onEvent(listener: (event: Record<string, unknown>) => void): () => void {
|
||||
this.eventListeners.push(listener);
|
||||
return () => {
|
||||
const idx = this.eventListeners.indexOf(listener);
|
||||
if (idx >= 0) this.eventListeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
async prompt(message: string): Promise<void> {
|
||||
this.prompted.push(message);
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.aborted = true;
|
||||
}
|
||||
|
||||
sendUIResponse(requestId: string, response: Record<string, unknown>): void {
|
||||
this.uiResponses.push({ requestId, response });
|
||||
}
|
||||
|
||||
/** Test helper — emit an event to all listeners */
|
||||
emitEvent(event: Record<string, unknown>): void {
|
||||
for (const listener of this.eventListeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestableSessionManager — injects mock clients without module mocking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Subclass that overrides startSession to use MockRpcClient instead of the
|
||||
* real RpcClient. We directly construct the session object, mirroring the
|
||||
* parent's logic but with our mock.
|
||||
*/
|
||||
class TestableSessionManager extends SessionManager {
|
||||
/** The last mock client created */
|
||||
lastClient: MockRpcClient | null = null;
|
||||
/** All mock clients */
|
||||
allClients: MockRpcClient[] = [];
|
||||
/** Counter for unique session IDs across multiple sessions */
|
||||
private sessionCounter = 0;
|
||||
/** Control: set to make startSession fail during init */
|
||||
nextInitError: Error | null = null;
|
||||
/** Control: set to make startSession fail during start */
|
||||
nextStartError: Error | null = null;
|
||||
|
||||
override async startSession(
|
||||
projectDir: string,
|
||||
options: {
|
||||
cliPath?: string;
|
||||
command?: string;
|
||||
model?: string;
|
||||
bare?: boolean;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
if (!projectDir || projectDir.trim() === "") {
|
||||
throw new Error("projectDir is required and cannot be empty");
|
||||
}
|
||||
|
||||
const resolvedDir = resolve(projectDir);
|
||||
|
||||
// Check duplicate via getSessionByDir
|
||||
const existing = this.getSessionByDir(resolvedDir);
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
`Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const client = new MockRpcClient({ cwd: resolvedDir, args: [] });
|
||||
if (this.nextStartError) {
|
||||
client.startError = this.nextStartError;
|
||||
this.nextStartError = null;
|
||||
}
|
||||
if (this.nextInitError) {
|
||||
client.initError = this.nextInitError;
|
||||
this.nextInitError = null;
|
||||
}
|
||||
|
||||
this.sessionCounter++;
|
||||
client.initSessionId = `mock-session-${String(this.sessionCounter).padStart(3, "0")}`;
|
||||
this.lastClient = client;
|
||||
this.allClients.push(client);
|
||||
|
||||
// Create the session shell
|
||||
const session: ManagedSession = {
|
||||
sessionId: "",
|
||||
projectDir: resolvedDir,
|
||||
status: "starting",
|
||||
client: client as any, // duck-typed mock
|
||||
events: [],
|
||||
pendingBlocker: null,
|
||||
cost: {
|
||||
totalCost: 0,
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
// Insert into internal sessions map — access via protected method
|
||||
this._putSession(resolvedDir, session);
|
||||
|
||||
try {
|
||||
await client.start();
|
||||
|
||||
const initResult = await client.init();
|
||||
session.sessionId = initResult.sessionId;
|
||||
session.status = "running";
|
||||
|
||||
// Wire event tracking using the same handleEvent logic as parent
|
||||
session.unsubscribe = client.onEvent((event: Record<string, unknown>) => {
|
||||
this._handleEvent(session, event);
|
||||
});
|
||||
|
||||
// Kick off autonomous mode
|
||||
const command = options.command ?? "/sf autonomous";
|
||||
await client.prompt(command);
|
||||
|
||||
return session.sessionId;
|
||||
} catch (err) {
|
||||
session.status = "error";
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
try {
|
||||
await client.stop();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to start session for ${resolvedDir}: ${session.error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Expose internal session map insertion for testing */
|
||||
_putSession(key: string, session: ManagedSession): void {
|
||||
// Access the private sessions map via any cast
|
||||
(this as any).sessions.set(key, session);
|
||||
}
|
||||
|
||||
/** Expose handleEvent for testing */
|
||||
_handleEvent(session: ManagedSession, event: Record<string, unknown>): void {
|
||||
(this as any).handleEvent(session, event);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let allManagers: TestableSessionManager[] = [];
|
||||
|
||||
function createManager(): TestableSessionManager {
|
||||
const mgr = new TestableSessionManager();
|
||||
allManagers.push(mgr);
|
||||
return mgr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SessionManager unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SessionManager", () => {
|
||||
let sm: TestableSessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
sm = createManager();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const mgr of allManagers) {
|
||||
await mgr.cleanup();
|
||||
}
|
||||
allManagers = [];
|
||||
});
|
||||
|
||||
it("startSession creates session and returns sessionId", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/test-project", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
assert.equal(sessionId, "mock-session-001");
|
||||
|
||||
const session = sm.getSession(sessionId);
|
||||
assert.ok(session);
|
||||
assert.equal(session.status, "running");
|
||||
assert.equal(session.projectDir, resolve("/tmp/test-project"));
|
||||
});
|
||||
|
||||
it("startSession sends /sf autonomous by default", async () => {
|
||||
await sm.startSession("/tmp/test-prompt", { cliPath: "/usr/bin/sf" });
|
||||
assert.ok(sm.lastClient);
|
||||
assert.deepEqual(sm.lastClient.prompted, ["/sf autonomous"]);
|
||||
});
|
||||
|
||||
it("startSession sends custom command when provided", async () => {
|
||||
await sm.startSession("/tmp/test-cmd", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
command: "/sf auto --resume",
|
||||
});
|
||||
assert.ok(sm.lastClient);
|
||||
assert.deepEqual(sm.lastClient.prompted, ["/sf auto --resume"]);
|
||||
});
|
||||
|
||||
it("startSession rejects duplicate projectDir", async () => {
|
||||
await sm.startSession("/tmp/dup-test", { cliPath: "/usr/bin/sf" });
|
||||
await assert.rejects(
|
||||
() => sm.startSession("/tmp/dup-test", { cliPath: "/usr/bin/sf" }),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("Session already active"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("startSession rejects empty projectDir", async () => {
|
||||
await assert.rejects(
|
||||
() => sm.startSession("", { cliPath: "/usr/bin/sf" }),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("projectDir is required"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("startSession sets error status on start() failure", async () => {
|
||||
sm.nextStartError = new Error("spawn failed");
|
||||
|
||||
await assert.rejects(
|
||||
() => sm.startSession("/tmp/fail-start", { cliPath: "/usr/bin/sf" }),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("Failed to start session"));
|
||||
assert.ok(err.message.includes("spawn failed"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("startSession sets error status on init() failure", async () => {
|
||||
sm.nextInitError = new Error("handshake failed");
|
||||
|
||||
await assert.rejects(
|
||||
() => sm.startSession("/tmp/fail-init", { cliPath: "/usr/bin/sf" }),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("Failed to start session"));
|
||||
assert.ok(err.message.includes("handshake failed"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("getSession returns undefined for unknown sessionId", () => {
|
||||
const result = sm.getSession("nonexistent-id");
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
it("getSessionByDir returns session for known dir", async () => {
|
||||
await sm.startSession("/tmp/by-dir", { cliPath: "/usr/bin/sf" });
|
||||
const session = sm.getSessionByDir("/tmp/by-dir");
|
||||
assert.ok(session);
|
||||
assert.equal(session.sessionId, "mock-session-001");
|
||||
});
|
||||
|
||||
it("resolveBlocker errors when no pending blocker", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/no-blocker", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
await assert.rejects(
|
||||
() => sm.resolveBlocker(sessionId, "some response"),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("No pending blocker"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolveBlocker errors for unknown session", async () => {
|
||||
await assert.rejects(
|
||||
() => sm.resolveBlocker("unknown-session", "some response"),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("Session not found"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolveBlocker clears pendingBlocker and sends UI response", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/blocker-resolve", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
const client = sm.lastClient!;
|
||||
|
||||
// Simulate a blocking UI request event
|
||||
client.emitEvent({
|
||||
type: "extension_ui_request",
|
||||
id: "req-42",
|
||||
method: "select",
|
||||
title: "Pick an option",
|
||||
});
|
||||
|
||||
const session = sm.getSession(sessionId)!;
|
||||
assert.ok(session.pendingBlocker);
|
||||
assert.equal(session.status, "blocked");
|
||||
|
||||
// Resolve the blocker
|
||||
await sm.resolveBlocker(sessionId, "option-a");
|
||||
|
||||
assert.equal(session.pendingBlocker, null);
|
||||
assert.equal(session.status, "running");
|
||||
assert.equal(client.uiResponses.length, 1);
|
||||
assert.equal(client.uiResponses[0].requestId, "req-42");
|
||||
});
|
||||
|
||||
it("cancelSession calls abort + stop on client", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/cancel-test", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
const client = sm.lastClient!;
|
||||
|
||||
await sm.cancelSession(sessionId);
|
||||
|
||||
assert.ok(client.aborted);
|
||||
assert.ok(client.stopped);
|
||||
|
||||
const session = sm.getSession(sessionId)!;
|
||||
assert.equal(session.status, "cancelled");
|
||||
});
|
||||
|
||||
it("cancelSession errors for unknown session", async () => {
|
||||
await assert.rejects(
|
||||
() => sm.cancelSession("unknown"),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("Session not found"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("cleanup stops all active sessions", async () => {
|
||||
await sm.startSession("/tmp/cleanup-1", { cliPath: "/usr/bin/sf" });
|
||||
await sm.startSession("/tmp/cleanup-2", { cliPath: "/usr/bin/sf" });
|
||||
|
||||
assert.equal(sm.allClients.length, 2);
|
||||
|
||||
await sm.cleanup();
|
||||
|
||||
for (const client of sm.allClients) {
|
||||
assert.ok(client.stopped, "Client should be stopped after cleanup");
|
||||
}
|
||||
});
|
||||
|
||||
it("event ring buffer caps at MAX_EVENTS", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/ring-buffer", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
const client = sm.lastClient!;
|
||||
|
||||
for (let i = 0; i < MAX_EVENTS + 20; i++) {
|
||||
client.emitEvent({ type: "tool_use", index: i });
|
||||
}
|
||||
|
||||
const session = sm.getSession(sessionId)!;
|
||||
assert.equal(session.events.length, MAX_EVENTS);
|
||||
// Oldest events trimmed — first event index should be 20
|
||||
assert.equal((session.events[0] as Record<string, unknown>).index, 20);
|
||||
});
|
||||
|
||||
it("blocker detection: non-fire-and-forget extension_ui_request sets pendingBlocker", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/blocker-detect", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
const client = sm.lastClient!;
|
||||
|
||||
// 'select' is not in FIRE_AND_FORGET_METHODS
|
||||
client.emitEvent({
|
||||
type: "extension_ui_request",
|
||||
id: "req-99",
|
||||
method: "select",
|
||||
title: "Choose wisely",
|
||||
});
|
||||
|
||||
const session = sm.getSession(sessionId)!;
|
||||
assert.equal(session.status, "blocked");
|
||||
assert.ok(session.pendingBlocker);
|
||||
assert.equal(session.pendingBlocker.id, "req-99");
|
||||
assert.equal(session.pendingBlocker.method, "select");
|
||||
});
|
||||
|
||||
it("fire-and-forget methods do not set pendingBlocker", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/fire-forget", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
const client = sm.lastClient!;
|
||||
|
||||
// 'notify' is fire-and-forget — on its own (no terminal prefix) should not block
|
||||
client.emitEvent({
|
||||
type: "extension_ui_request",
|
||||
id: "req-100",
|
||||
method: "notify",
|
||||
message: "Just a notification",
|
||||
});
|
||||
|
||||
const session = sm.getSession(sessionId)!;
|
||||
assert.equal(session.status, "running");
|
||||
assert.equal(session.pendingBlocker, null);
|
||||
});
|
||||
|
||||
it("terminal detection: auto-mode stopped sets status to completed", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/terminal", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
const client = sm.lastClient!;
|
||||
|
||||
client.emitEvent({
|
||||
type: "extension_ui_request",
|
||||
method: "notify",
|
||||
message: "Auto-mode stopped — all tasks complete",
|
||||
id: "term-1",
|
||||
});
|
||||
|
||||
const session = sm.getSession(sessionId)!;
|
||||
assert.equal(session.status, "completed");
|
||||
});
|
||||
|
||||
it("terminal detection with blocked: message sets status to blocked", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/terminal-blocked", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
const client = sm.lastClient!;
|
||||
|
||||
client.emitEvent({
|
||||
type: "extension_ui_request",
|
||||
method: "notify",
|
||||
message: "Auto-mode stopped — blocked: needs user input",
|
||||
id: "block-1",
|
||||
});
|
||||
|
||||
const session = sm.getSession(sessionId)!;
|
||||
assert.equal(session.status, "blocked");
|
||||
assert.ok(session.pendingBlocker);
|
||||
});
|
||||
|
||||
it("cost tracking: cumulative-max from cost_update events", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/cost-track", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
const client = sm.lastClient!;
|
||||
|
||||
client.emitEvent({
|
||||
type: "cost_update",
|
||||
cumulativeCost: 0.05,
|
||||
tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100 },
|
||||
});
|
||||
|
||||
client.emitEvent({
|
||||
type: "cost_update",
|
||||
cumulativeCost: 0.12,
|
||||
tokens: { input: 2500, output: 800, cacheRead: 150, cacheWrite: 300 },
|
||||
});
|
||||
|
||||
const session = sm.getSession(sessionId)!;
|
||||
assert.equal(session.cost.totalCost, 0.12);
|
||||
assert.equal(session.cost.tokens.input, 2500);
|
||||
assert.equal(session.cost.tokens.output, 800);
|
||||
assert.equal(session.cost.tokens.cacheRead, 200); // First was higher
|
||||
assert.equal(session.cost.tokens.cacheWrite, 300); // Second was higher
|
||||
});
|
||||
|
||||
it("getResult returns HeadlessJsonResult-shaped object", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/result-shape", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
const result = sm.getResult(sessionId);
|
||||
|
||||
assert.equal(result.sessionId, sessionId);
|
||||
assert.equal(result.projectDir, resolve("/tmp/result-shape"));
|
||||
assert.equal(result.status, "running");
|
||||
assert.equal(typeof result.durationMs, "number");
|
||||
assert.ok(result.cost);
|
||||
assert.ok(Array.isArray(result.recentEvents));
|
||||
assert.equal(result.pendingBlocker, null);
|
||||
assert.equal(result.error, null);
|
||||
});
|
||||
|
||||
it("getResult errors for unknown session", () => {
|
||||
assert.throws(
|
||||
() => sm.getResult("unknown"),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("Session not found"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI path resolution tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SessionManager.resolveCLIPath", () => {
|
||||
const originalSfPath = process.env["SF_CLI_PATH"];
|
||||
const originalPath = process.env["PATH"];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalSfPath !== undefined) {
|
||||
process.env["SF_CLI_PATH"] = originalSfPath;
|
||||
} else {
|
||||
delete process.env["SF_CLI_PATH"];
|
||||
}
|
||||
if (originalPath !== undefined) {
|
||||
process.env["PATH"] = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("SF_CLI_PATH env var takes precedence", () => {
|
||||
process.env["SF_CLI_PATH"] = "/custom/path/to/sf";
|
||||
const result = SessionManager.resolveCLIPath();
|
||||
assert.equal(result, resolve("/custom/path/to/sf"));
|
||||
});
|
||||
|
||||
it("throws when SF_CLI_PATH not set and which fails", () => {
|
||||
delete process.env["SF_CLI_PATH"];
|
||||
process.env["PATH"] = "/nonexistent";
|
||||
assert.throws(
|
||||
() => SessionManager.resolveCLIPath(),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("Cannot find SF CLI"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool registration tests (via createMcpServer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createMcpServer tool registration", () => {
|
||||
let sm: TestableSessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
sm = createManager();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const mgr of allManagers) {
|
||||
await mgr.cleanup();
|
||||
}
|
||||
allManagers = [];
|
||||
});
|
||||
|
||||
it("creates server successfully with all required methods", async () => {
|
||||
const { server } = await createMcpServer(sm);
|
||||
assert.ok(server);
|
||||
assert.ok(server.server);
|
||||
assert.equal(typeof server.server.elicitInput, "function");
|
||||
assert.ok(typeof server.connect === "function");
|
||||
assert.ok(typeof server.close === "function");
|
||||
});
|
||||
|
||||
it("sf_execute flow returns sessionId on success", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/tool-exec", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
assert.equal(typeof sessionId, "string");
|
||||
assert.ok(sessionId.length > 0);
|
||||
});
|
||||
|
||||
it("sf_status flow returns correct shape", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/tool-status", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
const session = sm.getSession(sessionId)!;
|
||||
|
||||
assert.equal(typeof session.status, "string");
|
||||
assert.ok(Array.isArray(session.events));
|
||||
assert.ok(session.cost);
|
||||
assert.equal(typeof session.startTime, "number");
|
||||
});
|
||||
|
||||
it("sf_resolve_blocker flow returns error when no blocker", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/tool-resolve", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
await assert.rejects(
|
||||
() => sm.resolveBlocker(sessionId, "fix"),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("No pending blocker"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("sf_result flow returns HeadlessJsonResult shape", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/tool-result", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
const result = sm.getResult(sessionId);
|
||||
|
||||
assert.ok("sessionId" in result);
|
||||
assert.ok("projectDir" in result);
|
||||
assert.ok("status" in result);
|
||||
assert.ok("durationMs" in result);
|
||||
assert.ok("cost" in result);
|
||||
assert.ok("recentEvents" in result);
|
||||
assert.ok("pendingBlocker" in result);
|
||||
assert.ok("error" in result);
|
||||
});
|
||||
|
||||
it("sf_cancel flow marks session as cancelled", async () => {
|
||||
const sessionId = await sm.startSession("/tmp/tool-cancel", {
|
||||
cliPath: "/usr/bin/sf",
|
||||
});
|
||||
await sm.cancelSession(sessionId);
|
||||
const session = sm.getSession(sessionId)!;
|
||||
assert.equal(session.status, "cancelled");
|
||||
});
|
||||
|
||||
it("buildAskUserQuestionsElicitRequest adds None of the above note field for single-select questions", () => {
|
||||
const request = buildAskUserQuestionsElicitRequest([
|
||||
{
|
||||
id: "depth_verification_M001",
|
||||
header: "Depth Check",
|
||||
question: "Did I capture the depth right?",
|
||||
options: [
|
||||
{
|
||||
label: "Yes, you got it (Recommended)",
|
||||
description: "Continue with the current summary.",
|
||||
},
|
||||
{
|
||||
label: "Not quite",
|
||||
description: "I need to clarify the depth further.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "focus_areas",
|
||||
header: "Focus",
|
||||
question: "Which areas matter most?",
|
||||
allowMultiple: true,
|
||||
options: [
|
||||
{ label: "Frontend", description: "Prioritize the UI." },
|
||||
{ label: "Backend", description: "Prioritize server logic." },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(request.mode, "form");
|
||||
assert.deepEqual(request.requestedSchema.required, [
|
||||
"depth_verification_M001",
|
||||
"focus_areas",
|
||||
]);
|
||||
assert.ok(request.requestedSchema.properties["depth_verification_M001"]);
|
||||
assert.ok(
|
||||
request.requestedSchema.properties["depth_verification_M001__note"],
|
||||
);
|
||||
assert.ok(!request.requestedSchema.properties["focus_areas__note"]);
|
||||
});
|
||||
|
||||
it("formatAskUserQuestionsElicitResult preserves the existing answers JSON shape", () => {
|
||||
const result = formatAskUserQuestionsElicitResult(
|
||||
[
|
||||
{
|
||||
id: "depth_verification_M001",
|
||||
header: "Depth Check",
|
||||
question: "Did I capture the depth right?",
|
||||
options: [
|
||||
{
|
||||
label: "Yes, you got it (Recommended)",
|
||||
description: "Continue with the current summary.",
|
||||
},
|
||||
{
|
||||
label: "Not quite",
|
||||
description: "I need to clarify the depth further.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "focus_areas",
|
||||
header: "Focus",
|
||||
question: "Which areas matter most?",
|
||||
allowMultiple: true,
|
||||
options: [
|
||||
{ label: "Frontend", description: "Prioritize the UI." },
|
||||
{ label: "Backend", description: "Prioritize server logic." },
|
||||
],
|
||||
},
|
||||
],
|
||||
{
|
||||
action: "accept",
|
||||
content: {
|
||||
depth_verification_M001: "None of the above",
|
||||
depth_verification_M001__note: "Need more implementation detail.",
|
||||
focus_areas: ["Frontend", "Backend"],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
JSON.stringify({
|
||||
answers: {
|
||||
depth_verification_M001: {
|
||||
answers: [
|
||||
"None of the above",
|
||||
"user_note: Need more implementation detail.",
|
||||
],
|
||||
},
|
||||
focus_areas: {
|
||||
answers: ["Frontend", "Backend"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
// SF MCP Server — captures reader
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolveRootFile, resolveSFRoot } from "./paths.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CaptureStatus = "pending" | "triaged" | "resolved";
|
||||
export type CaptureClassification =
|
||||
| "quick-task"
|
||||
| "inject"
|
||||
| "defer"
|
||||
| "replan"
|
||||
| "note"
|
||||
| "stop"
|
||||
| "backtrack";
|
||||
|
||||
export interface CaptureEntry {
|
||||
id: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
status: CaptureStatus;
|
||||
classification: CaptureClassification | null;
|
||||
resolution: string | null;
|
||||
rationale: string | null;
|
||||
resolvedAt: string | null;
|
||||
milestone: string | null;
|
||||
executed: string | null;
|
||||
}
|
||||
|
||||
export interface CapturesResult {
|
||||
captures: CaptureEntry[];
|
||||
counts: {
|
||||
total: number;
|
||||
pending: number;
|
||||
resolved: number;
|
||||
actionable: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseCapturesMarkdown(content: string): CaptureEntry[] {
|
||||
const entries: CaptureEntry[] = [];
|
||||
|
||||
// Split on H3 headers: ### CAP-xxxxxxxx
|
||||
const sections = content.split(/(?=^### CAP-)/m);
|
||||
|
||||
for (const section of sections) {
|
||||
const idMatch = section.match(/^### (CAP-[\da-f]+)/);
|
||||
if (!idMatch) continue;
|
||||
|
||||
const id = idMatch[1];
|
||||
const field = (label: string): string | null => {
|
||||
const re = new RegExp(`\\*\\*${label}:\\*\\*\\s*(.+)`, "i");
|
||||
const m = section.match(re);
|
||||
return m ? m[1].trim() : null;
|
||||
};
|
||||
|
||||
const status = (
|
||||
field("Status") ?? "pending"
|
||||
).toLowerCase() as CaptureStatus;
|
||||
const classification = field(
|
||||
"Classification",
|
||||
) as CaptureClassification | null;
|
||||
|
||||
entries.push({
|
||||
id,
|
||||
text: field("Text") ?? "",
|
||||
timestamp: field("Captured") ?? "",
|
||||
status,
|
||||
classification,
|
||||
resolution: field("Resolution"),
|
||||
rationale: field("Rationale"),
|
||||
resolvedAt: field("Resolved"),
|
||||
milestone: field("Milestone"),
|
||||
executed: field("Executed"),
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACTIONABLE_CLASSIFICATIONS = new Set<string>([
|
||||
"quick-task",
|
||||
"inject",
|
||||
"replan",
|
||||
]);
|
||||
|
||||
export function readCaptures(
|
||||
projectDir: string,
|
||||
filter: "all" | "pending" | "actionable" = "all",
|
||||
): CapturesResult {
|
||||
const sf = resolveSFRoot(projectDir);
|
||||
const capturesPath = resolveRootFile(sf, "CAPTURES.md");
|
||||
|
||||
if (!existsSync(capturesPath)) {
|
||||
return {
|
||||
captures: [],
|
||||
counts: { total: 0, pending: 0, resolved: 0, actionable: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const content = readFileSync(capturesPath, "utf-8");
|
||||
let captures = parseCapturesMarkdown(content);
|
||||
|
||||
// Compute counts before filtering
|
||||
const counts = {
|
||||
total: captures.length,
|
||||
pending: captures.filter((c) => c.status === "pending").length,
|
||||
resolved: captures.filter((c) => c.status === "resolved").length,
|
||||
actionable: captures.filter(
|
||||
(c) =>
|
||||
c.classification !== null &&
|
||||
ACTIONABLE_CLASSIFICATIONS.has(c.classification),
|
||||
).length,
|
||||
};
|
||||
|
||||
// Apply filter
|
||||
if (filter === "pending") {
|
||||
captures = captures.filter((c) => c.status === "pending");
|
||||
} else if (filter === "actionable") {
|
||||
captures = captures.filter(
|
||||
(c) =>
|
||||
c.classification !== null &&
|
||||
ACTIONABLE_CLASSIFICATIONS.has(c.classification),
|
||||
);
|
||||
}
|
||||
|
||||
return { captures, counts };
|
||||
}
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
// SF MCP Server — lightweight structural health checks
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
findMilestoneIds,
|
||||
findSliceIds,
|
||||
findTaskFiles,
|
||||
resolveMilestoneDir,
|
||||
resolveMilestoneFile,
|
||||
resolveRootFile,
|
||||
resolveSFRoot,
|
||||
resolveSliceFile,
|
||||
} from "./paths.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Severity = "info" | "warning" | "error";
|
||||
|
||||
export interface DoctorIssue {
|
||||
severity: Severity;
|
||||
code: string;
|
||||
scope: "project" | "milestone" | "slice" | "task";
|
||||
unitId: string;
|
||||
message: string;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
export interface DoctorResult {
|
||||
ok: boolean;
|
||||
issues: DoctorIssue[];
|
||||
counts: { error: number; warning: number; info: number };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function checkProjectLevel(sfRoot: string, issues: DoctorIssue[]): void {
|
||||
// PROJECT.md should exist
|
||||
const projectPath = resolveRootFile(sfRoot, "PROJECT.md");
|
||||
if (!existsSync(projectPath)) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "missing_project_md",
|
||||
scope: "project",
|
||||
unitId: "",
|
||||
message: "PROJECT.md is missing — project lacks a description",
|
||||
file: projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
// STATE.md should exist if milestones exist
|
||||
const milestones = findMilestoneIds(sfRoot);
|
||||
if (milestones.length > 0) {
|
||||
const statePath = resolveRootFile(sfRoot, "STATE.md");
|
||||
if (!existsSync(statePath)) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "missing_state_md",
|
||||
scope: "project",
|
||||
unitId: "",
|
||||
message: "STATE.md is missing — run /sf status to regenerate",
|
||||
file: statePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkMilestoneLevel(
|
||||
sfRoot: string,
|
||||
mid: string,
|
||||
issues: DoctorIssue[],
|
||||
): void {
|
||||
const mDir = resolveMilestoneDir(sfRoot, mid);
|
||||
if (!mDir) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "missing_milestone_dir",
|
||||
scope: "milestone",
|
||||
unitId: mid,
|
||||
message: `Milestone directory for ${mid} not found`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// CONTEXT.md should exist
|
||||
const ctxPath = resolveMilestoneFile(sfRoot, mid, "CONTEXT");
|
||||
if (!ctxPath || !existsSync(ctxPath)) {
|
||||
// Check for draft
|
||||
const draftPath = resolveMilestoneFile(sfRoot, mid, "CONTEXT-DRAFT");
|
||||
if (!draftPath || !existsSync(draftPath)) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "missing_context",
|
||||
scope: "milestone",
|
||||
unitId: mid,
|
||||
message: `${mid} has no CONTEXT.md — milestone lacks defined scope`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ROADMAP.md should exist if slices exist
|
||||
const sliceIds = findSliceIds(sfRoot, mid);
|
||||
if (sliceIds.length > 0) {
|
||||
const roadmapPath = resolveMilestoneFile(sfRoot, mid, "ROADMAP");
|
||||
if (!roadmapPath || !existsSync(roadmapPath)) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "missing_roadmap",
|
||||
scope: "milestone",
|
||||
unitId: mid,
|
||||
message: `${mid} has ${sliceIds.length} slices but no ROADMAP.md`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all slices done but no SUMMARY
|
||||
if (sliceIds.length > 0) {
|
||||
const allDone = sliceIds.every((sid) => {
|
||||
const tasks = findTaskFiles(sfRoot, mid, sid);
|
||||
return tasks.length > 0 && tasks.every((t) => t.hasSummary);
|
||||
});
|
||||
const summaryPath = resolveMilestoneFile(sfRoot, mid, "SUMMARY");
|
||||
if (allDone && (!summaryPath || !existsSync(summaryPath))) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "all_slices_done_missing_summary",
|
||||
scope: "milestone",
|
||||
unitId: mid,
|
||||
message: `${mid} has all slices completed but no SUMMARY.md`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkSliceLevel(
|
||||
sfRoot: string,
|
||||
mid: string,
|
||||
sid: string,
|
||||
issues: DoctorIssue[],
|
||||
): void {
|
||||
const unitId = `${mid}/${sid}`;
|
||||
|
||||
// PLAN.md should exist
|
||||
const planPath = resolveSliceFile(sfRoot, mid, sid, "PLAN");
|
||||
if (!planPath || !existsSync(planPath)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
code: "missing_slice_plan",
|
||||
scope: "slice",
|
||||
unitId,
|
||||
message: `${unitId} has no PLAN.md`,
|
||||
});
|
||||
}
|
||||
|
||||
// Tasks should have plans
|
||||
const tasks = findTaskFiles(sfRoot, mid, sid);
|
||||
for (const task of tasks) {
|
||||
const taskUnitId = `${unitId}/${task.id}`;
|
||||
if (!task.hasPlan) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "missing_task_plan",
|
||||
scope: "task",
|
||||
unitId: taskUnitId,
|
||||
message: `${taskUnitId} has a summary but no plan file`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for empty slice (directory exists but no tasks or plan)
|
||||
if (tasks.length === 0 && (!planPath || !existsSync(planPath))) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
code: "empty_slice",
|
||||
scope: "slice",
|
||||
unitId,
|
||||
message: `${unitId} has no plan and no tasks — may be abandoned`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function runDoctorLite(
|
||||
projectDir: string,
|
||||
scope?: string,
|
||||
): DoctorResult {
|
||||
const sfRoot = resolveSFRoot(projectDir);
|
||||
const issues: DoctorIssue[] = [];
|
||||
|
||||
if (!existsSync(sfRoot)) {
|
||||
return {
|
||||
ok: true,
|
||||
issues: [
|
||||
{
|
||||
severity: "info",
|
||||
code: "no_sf_directory",
|
||||
scope: "project",
|
||||
unitId: "",
|
||||
message: "No .sf/ directory found — project not initialized",
|
||||
},
|
||||
],
|
||||
counts: { error: 0, warning: 0, info: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
// Project-level checks
|
||||
checkProjectLevel(sfRoot, issues);
|
||||
|
||||
// Milestone + slice checks
|
||||
const milestoneIds = scope
|
||||
? findMilestoneIds(sfRoot).filter((id) => id === scope)
|
||||
: findMilestoneIds(sfRoot);
|
||||
|
||||
for (const mid of milestoneIds) {
|
||||
checkMilestoneLevel(sfRoot, mid, issues);
|
||||
|
||||
const sliceIds = findSliceIds(sfRoot, mid);
|
||||
for (const sid of sliceIds) {
|
||||
checkSliceLevel(sfRoot, mid, sid, issues);
|
||||
}
|
||||
}
|
||||
|
||||
const counts = {
|
||||
error: issues.filter((i) => i.severity === "error").length,
|
||||
warning: issues.filter((i) => i.severity === "warning").length,
|
||||
info: issues.filter((i) => i.severity === "info").length,
|
||||
};
|
||||
|
||||
return { ok: counts.error === 0, issues, counts };
|
||||
}
|
||||
|
|
@ -1,752 +0,0 @@
|
|||
// SF project graph reader tests
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
it,
|
||||
} from "vitest";
|
||||
import type { KnowledgeGraph } from "./graph.js";
|
||||
import {
|
||||
buildGraph,
|
||||
graphDiff,
|
||||
graphQuery,
|
||||
graphStatus,
|
||||
writeGraph,
|
||||
writeSnapshot,
|
||||
} from "./graph.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tmpProject(): string {
|
||||
const dir = join(tmpdir(), `sf-graph-test-${randomBytes(4).toString("hex")}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeFixture(base: string, relPath: string, content: string): void {
|
||||
const full = join(base, relPath);
|
||||
mkdirSync(join(full, ".."), { recursive: true });
|
||||
writeFileSync(full, content, "utf-8");
|
||||
}
|
||||
|
||||
function makeProjectWithArtifacts(projectDir: string): void {
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/STATE.md",
|
||||
[
|
||||
"# SF State",
|
||||
"",
|
||||
"**Active Milestone:** M001: Auth System",
|
||||
"**Active Slice:** S01: Login flow",
|
||||
"**Phase:** execution",
|
||||
"",
|
||||
"## Milestone Registry",
|
||||
"",
|
||||
"- 🔄 **M001:** Auth System",
|
||||
"",
|
||||
"## Next Action",
|
||||
"",
|
||||
"Execute T01 in S01.",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/KNOWLEDGE.md",
|
||||
[
|
||||
"# Project Knowledge",
|
||||
"",
|
||||
"## Rules",
|
||||
"",
|
||||
"| # | Scope | Rule | Why | Added |",
|
||||
"|---|-------|------|-----|-------|",
|
||||
"| K001 | auth | Hash passwords with bcrypt | Security requirement | manual |",
|
||||
"| K002 | db | Use transactions for multi-table | Data consistency | auto |",
|
||||
"",
|
||||
"## Patterns",
|
||||
"",
|
||||
"| # | Pattern | Where | Notes |",
|
||||
"|---|---------|-------|-------|",
|
||||
"| P001 | Singleton services | services/ | Prevents duplication |",
|
||||
"",
|
||||
"## Lessons Learned",
|
||||
"",
|
||||
"| # | What Happened | Root Cause | Fix | Scope |",
|
||||
"|---|--------------|------------|-----|-------|",
|
||||
"| L001 | CI tests failed | Env diff | Added setup script | testing |",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/M001-ROADMAP.md",
|
||||
[
|
||||
"# M001: Auth System",
|
||||
"",
|
||||
"## Vision",
|
||||
"",
|
||||
"Build authentication for the platform.",
|
||||
"",
|
||||
"## Slice Overview",
|
||||
"",
|
||||
"| ID | Slice | Risk | Depends | Done | After this |",
|
||||
"|----|-------|------|---------|------|------------|",
|
||||
"| S01 | Login flow | low | — | 🔄 | Users can log in |",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/slices/S01/S01-PLAN.md",
|
||||
[
|
||||
"# S01: Login flow",
|
||||
"",
|
||||
"## Tasks",
|
||||
"",
|
||||
"- [ ] **T01: Implement login endpoint** — Core auth logic",
|
||||
"- [ ] **T02: Add session management** — Keep users logged in",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LEARNINGS.md fixture helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function writeLearningsFixture(
|
||||
projectDir: string,
|
||||
milestoneId: string,
|
||||
content: string,
|
||||
): void {
|
||||
writeFixture(
|
||||
projectDir,
|
||||
`.sf/milestones/${milestoneId}/${milestoneId}-LEARNINGS.md`,
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
const SAMPLE_LEARNINGS = `---
|
||||
phase: "M001"
|
||||
phase_name: "User Auth"
|
||||
project: "my-project"
|
||||
generated: "2026-04-15T10:00:00Z"
|
||||
counts:
|
||||
decisions: 2
|
||||
lessons: 1
|
||||
patterns: 1
|
||||
surprises: 1
|
||||
missing_artifacts: []
|
||||
---
|
||||
|
||||
# Learnings: User Auth
|
||||
|
||||
## Decisions
|
||||
- Use JWT for stateless auth across services.
|
||||
Source: M001-PLAN.md/Architecture
|
||||
|
||||
- Store refresh tokens in HTTP-only cookies only.
|
||||
Source: M001-PLAN.md/Security
|
||||
|
||||
## Lessons
|
||||
- Integration tests need a real DB — mocks missed migration bugs.
|
||||
Source: M001-SUMMARY.md/Testing
|
||||
|
||||
## Patterns
|
||||
- Repository pattern abstracts DB access and simplifies testing.
|
||||
Source: M001-PLAN.md/Design
|
||||
|
||||
## Surprises
|
||||
- Token expiry edge case caused silent auth failures in prod.
|
||||
Source: M001-SUMMARY.md/Issues
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildGraph tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildGraph", () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
projectDir = tmpProject();
|
||||
makeProjectWithArtifacts(projectDir);
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(projectDir, { recursive: true, force: true }));
|
||||
|
||||
it("returns nodeCount > 0 for a project with artifacts", async () => {
|
||||
const graph = await buildGraph(projectDir);
|
||||
assert.ok(
|
||||
graph.nodes.length > 0,
|
||||
`Expected nodes, got ${graph.nodes.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns edgeCount >= 0 (valid graph structure)", async () => {
|
||||
const graph = await buildGraph(projectDir);
|
||||
assert.ok(graph.edges.length >= 0);
|
||||
});
|
||||
|
||||
it("includes builtAt ISO timestamp", async () => {
|
||||
const graph = await buildGraph(projectDir);
|
||||
assert.ok(typeof graph.builtAt === "string");
|
||||
assert.ok(!Number.isNaN(Date.parse(graph.builtAt)));
|
||||
});
|
||||
|
||||
it("skips unparseable artifact and does not throw", async () => {
|
||||
const badProject = tmpProject();
|
||||
// Write a corrupt/minimal STATE.md that is technically valid but empty
|
||||
writeFixture(
|
||||
badProject,
|
||||
".sf/STATE.md",
|
||||
"not valid sf state at all \0\0\0",
|
||||
);
|
||||
// Should not throw
|
||||
const graph = await buildGraph(badProject);
|
||||
assert.ok(graph.nodes.length >= 0);
|
||||
rmSync(badProject, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns empty graph for project with no .sf/ directory", async () => {
|
||||
const emptyProject = tmpProject();
|
||||
const graph = await buildGraph(emptyProject);
|
||||
assert.ok(graph.nodes.length >= 0); // no throw
|
||||
assert.equal(typeof graph.builtAt, "string");
|
||||
rmSync(emptyProject, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("nodes have required fields: id, label, type, confidence", async () => {
|
||||
const graph = await buildGraph(projectDir);
|
||||
for (const node of graph.nodes) {
|
||||
assert.ok(typeof node.id === "string", "node.id must be string");
|
||||
assert.ok(typeof node.label === "string", "node.label must be string");
|
||||
assert.ok(typeof node.type === "string", "node.type must be string");
|
||||
assert.ok(
|
||||
node.confidence === "EXTRACTED" ||
|
||||
node.confidence === "INFERRED" ||
|
||||
node.confidence === "AMBIGUOUS",
|
||||
`Invalid confidence: ${node.confidence}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildGraph — LEARNINGS.md parsing tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildGraph — LEARNINGS.md parsing", () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = tmpProject();
|
||||
// Create minimal milestone directory so parseMilestoneFiles finds it
|
||||
mkdirSync(join(projectDir, ".sf", "milestones", "M001"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeLearningsFixture(projectDir, "M001", SAMPLE_LEARNINGS);
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(projectDir, { recursive: true, force: true }));
|
||||
|
||||
it("extracts decision nodes from ## Decisions section", async () => {
|
||||
const graph = await buildGraph(projectDir);
|
||||
// Decisions should be extracted with a 'decision' type (or similar existing type)
|
||||
const decisionNodes = graph.nodes.filter((n) =>
|
||||
n.id.includes("decision:M001"),
|
||||
);
|
||||
assert.ok(
|
||||
decisionNodes.length >= 2,
|
||||
`Expected >= 2 decision nodes, got ${decisionNodes.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts lesson nodes from ## Lessons section", async () => {
|
||||
const graph = await buildGraph(projectDir);
|
||||
const lessonNodes = graph.nodes.filter((n) => n.id.includes("lesson:M001"));
|
||||
assert.ok(
|
||||
lessonNodes.length >= 1,
|
||||
`Expected >= 1 lesson node, got ${lessonNodes.length}`,
|
||||
);
|
||||
assert.ok(
|
||||
lessonNodes.every((n) => n.type === "lesson"),
|
||||
'All lesson nodes must have type "lesson"',
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts pattern nodes from ## Patterns section", async () => {
|
||||
const graph = await buildGraph(projectDir);
|
||||
const patternNodes = graph.nodes.filter((n) =>
|
||||
n.id.includes("pattern:M001"),
|
||||
);
|
||||
assert.ok(
|
||||
patternNodes.length >= 1,
|
||||
`Expected >= 1 pattern node, got ${patternNodes.length}`,
|
||||
);
|
||||
assert.ok(
|
||||
patternNodes.every((n) => n.type === "pattern"),
|
||||
'All pattern nodes must have type "pattern"',
|
||||
);
|
||||
});
|
||||
|
||||
it("maps surprises to lesson nodes", async () => {
|
||||
const graph = await buildGraph(projectDir);
|
||||
// Surprises should be mapped to lesson type since no "surprise" NodeType exists
|
||||
const surpriseNodes = graph.nodes.filter((n) =>
|
||||
n.id.includes("surprise:M001"),
|
||||
);
|
||||
assert.ok(
|
||||
surpriseNodes.length >= 1,
|
||||
`Expected >= 1 surprise node, got ${surpriseNodes.length}`,
|
||||
);
|
||||
assert.ok(
|
||||
surpriseNodes.every((n) => n.type === "lesson"),
|
||||
'Surprises must be mapped to type "lesson"',
|
||||
);
|
||||
});
|
||||
|
||||
it("node labels contain the learning text", async () => {
|
||||
const graph = await buildGraph(projectDir);
|
||||
const hasJwtDecision = graph.nodes.some(
|
||||
(n) =>
|
||||
n.label.toLowerCase().includes("jwt") ||
|
||||
n.description?.toLowerCase().includes("jwt"),
|
||||
);
|
||||
assert.ok(hasJwtDecision, "Expected a node describing the JWT decision");
|
||||
});
|
||||
|
||||
it("node description includes source attribution", async () => {
|
||||
const graph = await buildGraph(projectDir);
|
||||
const learningNodes = graph.nodes.filter(
|
||||
(n) =>
|
||||
n.id.includes(":M001:") ||
|
||||
n.id.match(/:(decision|lesson|pattern|surprise):M001/),
|
||||
);
|
||||
const withSource = learningNodes.filter(
|
||||
(n) =>
|
||||
n.description?.includes("Source:") ||
|
||||
n.description?.includes("M001-PLAN"),
|
||||
);
|
||||
assert.ok(
|
||||
withSource.length > 0,
|
||||
"Expected at least one node with source attribution in description",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds relates_to edge from learning node to milestone node", async () => {
|
||||
const graph = await buildGraph(projectDir);
|
||||
const edgesToMilestone = graph.edges.filter(
|
||||
(e) => e.to === "milestone:M001" || e.from === "milestone:M001",
|
||||
);
|
||||
// At least one learning node should relate to the milestone
|
||||
const learningEdges = graph.edges.filter(
|
||||
(e) =>
|
||||
(e.from.includes("M001") &&
|
||||
(e.type === "relates_to" || e.type === "contains")) ||
|
||||
(e.to.includes("M001") && e.type === "relates_to"),
|
||||
);
|
||||
assert.ok(
|
||||
learningEdges.length > 0 || edgesToMilestone.length > 0,
|
||||
"Expected edges connecting learning nodes to milestone",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips LEARNINGS.md gracefully when file is malformed", async () => {
|
||||
const badProject = tmpProject();
|
||||
mkdirSync(join(badProject, ".sf", "milestones", "M002"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeLearningsFixture(
|
||||
badProject,
|
||||
"M002",
|
||||
"\0\0\0 not valid yaml or markdown \0\0\0",
|
||||
);
|
||||
// Must not throw
|
||||
const graph = await buildGraph(badProject);
|
||||
assert.ok(graph.nodes.length >= 0);
|
||||
assert.equal(typeof graph.builtAt, "string");
|
||||
rmSync(badProject, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("produces no learning nodes when all sections are empty", async () => {
|
||||
const emptyProject = tmpProject();
|
||||
mkdirSync(join(emptyProject, ".sf", "milestones", "M003"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeLearningsFixture(
|
||||
emptyProject,
|
||||
"M003",
|
||||
`---
|
||||
phase: "M003"
|
||||
phase_name: "Empty"
|
||||
project: "test"
|
||||
generated: "2026-04-15T10:00:00Z"
|
||||
counts:
|
||||
decisions: 0
|
||||
lessons: 0
|
||||
patterns: 0
|
||||
surprises: 0
|
||||
missing_artifacts: []
|
||||
---
|
||||
|
||||
# Learnings: Empty
|
||||
|
||||
## Decisions
|
||||
|
||||
## Lessons
|
||||
|
||||
## Patterns
|
||||
|
||||
## Surprises
|
||||
`,
|
||||
);
|
||||
const graph = await buildGraph(emptyProject);
|
||||
const learningNodes = graph.nodes.filter(
|
||||
(n) =>
|
||||
n.id.includes("decision:M003") ||
|
||||
n.id.includes("lesson:M003") ||
|
||||
n.id.includes("pattern:M003") ||
|
||||
n.id.includes("surprise:M003"),
|
||||
);
|
||||
assert.equal(
|
||||
learningNodes.length,
|
||||
0,
|
||||
"Empty sections should produce no nodes",
|
||||
);
|
||||
rmSync(emptyProject, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("does not crash when LEARNINGS.md is missing entirely", async () => {
|
||||
const noLearningsProject = tmpProject();
|
||||
mkdirSync(join(noLearningsProject, ".sf", "milestones", "M004"), {
|
||||
recursive: true,
|
||||
});
|
||||
// No LEARNINGS.md file written
|
||||
const graph = await buildGraph(noLearningsProject);
|
||||
assert.ok(graph.nodes.length >= 0);
|
||||
rmSync(noLearningsProject, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// writeGraph tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("writeGraph", () => {
|
||||
let projectDir: string;
|
||||
let graph: KnowledgeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
projectDir = tmpProject();
|
||||
makeProjectWithArtifacts(projectDir);
|
||||
graph = await buildGraph(projectDir);
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(projectDir, { recursive: true, force: true }));
|
||||
|
||||
it("creates graph.json in .sf/graphs/ after writeGraph()", async () => {
|
||||
const sfRoot = join(projectDir, ".sf");
|
||||
await writeGraph(sfRoot, graph);
|
||||
const graphPath = join(sfRoot, "graphs", "graph.json");
|
||||
assert.ok(existsSync(graphPath), `Expected ${graphPath} to exist`);
|
||||
});
|
||||
|
||||
it("write is atomic — no temp file remains after writeGraph()", async () => {
|
||||
const sfRoot = join(projectDir, ".sf");
|
||||
await writeGraph(sfRoot, graph);
|
||||
const tmpPath = join(sfRoot, "graphs", "graph.tmp.json");
|
||||
assert.ok(
|
||||
!existsSync(tmpPath),
|
||||
"Temp file should not exist after successful write",
|
||||
);
|
||||
});
|
||||
|
||||
it("written graph.json is valid JSON with nodes and edges", async () => {
|
||||
const sfRoot = join(projectDir, ".sf");
|
||||
await writeGraph(sfRoot, graph);
|
||||
const raw = readFileSync(join(sfRoot, "graphs", "graph.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as KnowledgeGraph;
|
||||
assert.ok(Array.isArray(parsed.nodes));
|
||||
assert.ok(Array.isArray(parsed.edges));
|
||||
assert.ok(typeof parsed.builtAt === "string");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// graphStatus tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("graphStatus", () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = tmpProject();
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(projectDir, { recursive: true, force: true }));
|
||||
|
||||
it("returns { exists: false } when no graph.json exists", async () => {
|
||||
const status = await graphStatus(projectDir);
|
||||
assert.equal(status.exists, false);
|
||||
});
|
||||
|
||||
it("returns { exists: true, nodeCount, edgeCount, ageHours } when graph exists", async () => {
|
||||
makeProjectWithArtifacts(projectDir);
|
||||
const sfRoot = join(projectDir, ".sf");
|
||||
const graph = await buildGraph(projectDir);
|
||||
await writeGraph(sfRoot, graph);
|
||||
|
||||
const status = await graphStatus(projectDir);
|
||||
assert.equal(status.exists, true);
|
||||
assert.ok(typeof status.nodeCount === "number");
|
||||
assert.ok(typeof status.edgeCount === "number");
|
||||
assert.ok(typeof status.ageHours === "number");
|
||||
assert.ok(status.ageHours >= 0);
|
||||
});
|
||||
|
||||
it("stale = false for a freshly built graph", async () => {
|
||||
makeProjectWithArtifacts(projectDir);
|
||||
const sfRoot = join(projectDir, ".sf");
|
||||
const graph = await buildGraph(projectDir);
|
||||
await writeGraph(sfRoot, graph);
|
||||
|
||||
const status = await graphStatus(projectDir);
|
||||
assert.equal(status.stale, false);
|
||||
});
|
||||
|
||||
it("stale = true for a graph older than 24h (builtAt backdated)", async () => {
|
||||
makeProjectWithArtifacts(projectDir);
|
||||
const sfRoot = join(projectDir, ".sf");
|
||||
mkdirSync(join(sfRoot, "graphs"), { recursive: true });
|
||||
|
||||
// Write a graph with a builtAt 25 hours ago
|
||||
const oldGraph: KnowledgeGraph = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
builtAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
writeFileSync(
|
||||
join(sfRoot, "graphs", "graph.json"),
|
||||
JSON.stringify(oldGraph),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const status = await graphStatus(projectDir);
|
||||
assert.equal(status.exists, true);
|
||||
assert.equal(status.stale, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// graphQuery tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("graphQuery", () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
projectDir = tmpProject();
|
||||
makeProjectWithArtifacts(projectDir);
|
||||
const sfRoot = join(projectDir, ".sf");
|
||||
const graph = await buildGraph(projectDir);
|
||||
await writeGraph(sfRoot, graph);
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(projectDir, { recursive: true, force: true }));
|
||||
|
||||
it("returns matching nodes for a known term", async () => {
|
||||
const result = await graphQuery(projectDir, "auth");
|
||||
assert.ok(Array.isArray(result.nodes));
|
||||
// Should match nodes with 'auth' in label or description
|
||||
assert.ok(
|
||||
result.nodes.length > 0,
|
||||
'Expected at least one match for "auth"',
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty array for a term that matches nothing", async () => {
|
||||
const result = await graphQuery(projectDir, "xxxxxxnotfound999zzz");
|
||||
assert.ok(Array.isArray(result.nodes));
|
||||
assert.equal(result.nodes.length, 0);
|
||||
});
|
||||
|
||||
it("search is case-insensitive", async () => {
|
||||
const lower = await graphQuery(projectDir, "auth");
|
||||
const upper = await graphQuery(projectDir, "AUTH");
|
||||
assert.deepEqual(
|
||||
lower.nodes.map((n) => n.id).sort(),
|
||||
upper.nodes.map((n) => n.id).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("budget trims AMBIGUOUS edges first", async () => {
|
||||
const sfRoot = join(projectDir, ".sf");
|
||||
// Write a graph with mixed confidence edges
|
||||
const mixedGraph: KnowledgeGraph = {
|
||||
builtAt: new Date().toISOString(),
|
||||
nodes: [
|
||||
{
|
||||
id: "n1",
|
||||
label: "seed node budget",
|
||||
type: "milestone",
|
||||
confidence: "EXTRACTED",
|
||||
},
|
||||
{
|
||||
id: "n2",
|
||||
label: "connected via AMBIGUOUS",
|
||||
type: "task",
|
||||
confidence: "AMBIGUOUS",
|
||||
},
|
||||
{
|
||||
id: "n3",
|
||||
label: "connected via INFERRED",
|
||||
type: "task",
|
||||
confidence: "INFERRED",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ from: "n1", to: "n2", type: "contains", confidence: "AMBIGUOUS" },
|
||||
{ from: "n1", to: "n3", type: "contains", confidence: "INFERRED" },
|
||||
],
|
||||
};
|
||||
await writeGraph(sfRoot, mixedGraph);
|
||||
|
||||
// With a very small budget, AMBIGUOUS edges should be trimmed first
|
||||
const result = await graphQuery(projectDir, "seed node budget", 10);
|
||||
// At minimum, the seed node itself should be present
|
||||
assert.ok(
|
||||
result.nodes.some((n) => n.id === "n1"),
|
||||
"Seed node should be in result",
|
||||
);
|
||||
|
||||
// Restore the original graph
|
||||
const originalGraph = await buildGraph(projectDir);
|
||||
await writeGraph(sfRoot, originalGraph);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// writeSnapshot + graphDiff tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("graphDiff", () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectDir = tmpProject();
|
||||
makeProjectWithArtifacts(projectDir);
|
||||
const sfRoot = join(projectDir, ".sf");
|
||||
const graph = await buildGraph(projectDir);
|
||||
await writeGraph(sfRoot, graph);
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(projectDir, { recursive: true, force: true }));
|
||||
|
||||
it("returns empty diff when comparing graph to itself (snapshot = current)", async () => {
|
||||
const sfRoot = join(projectDir, ".sf");
|
||||
await writeSnapshot(sfRoot);
|
||||
const diff = await graphDiff(projectDir);
|
||||
assert.ok(Array.isArray(diff.nodes.added));
|
||||
assert.ok(Array.isArray(diff.nodes.removed));
|
||||
assert.ok(Array.isArray(diff.nodes.changed));
|
||||
assert.equal(diff.nodes.added.length, 0);
|
||||
assert.equal(diff.nodes.removed.length, 0);
|
||||
});
|
||||
|
||||
it("returns added nodes when a new node appears after snapshot", async () => {
|
||||
const sfRoot = join(projectDir, ".sf");
|
||||
// Take snapshot of the original graph
|
||||
await writeSnapshot(sfRoot);
|
||||
|
||||
// Now write a graph with an extra node
|
||||
const extraGraph: KnowledgeGraph = {
|
||||
builtAt: new Date().toISOString(),
|
||||
nodes: [
|
||||
{
|
||||
id: "brand-new-node",
|
||||
label: "New Feature",
|
||||
type: "milestone",
|
||||
confidence: "EXTRACTED",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
await writeGraph(sfRoot, extraGraph);
|
||||
|
||||
const diff = await graphDiff(projectDir);
|
||||
assert.ok(
|
||||
diff.nodes.added.includes("brand-new-node"),
|
||||
"new node should be in added",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns removed nodes when a node disappears after snapshot", async () => {
|
||||
const sfRoot = join(projectDir, ".sf");
|
||||
// Create snapshot with a node that won't exist in current graph
|
||||
const snapshotGraph: KnowledgeGraph = {
|
||||
builtAt: new Date().toISOString(),
|
||||
nodes: [
|
||||
{
|
||||
id: "old-node-to-be-removed",
|
||||
label: "Old",
|
||||
type: "task",
|
||||
confidence: "EXTRACTED",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
writeFileSync(
|
||||
join(sfRoot, "graphs", ".last-build-snapshot.json"),
|
||||
JSON.stringify({
|
||||
...snapshotGraph,
|
||||
snapshotAt: new Date().toISOString(),
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Current graph.json has no such node
|
||||
const diff = await graphDiff(projectDir);
|
||||
assert.ok(
|
||||
diff.nodes.removed.includes("old-node-to-be-removed"),
|
||||
"old node should be in removed",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty diff structure when no snapshot exists", async () => {
|
||||
// No snapshot file — diff should be empty/meaningful
|
||||
const diff = await graphDiff(projectDir);
|
||||
assert.ok(Array.isArray(diff.nodes.added));
|
||||
assert.ok(Array.isArray(diff.nodes.removed));
|
||||
assert.ok(Array.isArray(diff.nodes.changed));
|
||||
assert.ok(Array.isArray(diff.edges.added));
|
||||
assert.ok(Array.isArray(diff.edges.removed));
|
||||
});
|
||||
|
||||
it("writeSnapshot creates .last-build-snapshot.json with snapshotAt", async () => {
|
||||
const sfRoot = join(projectDir, ".sf");
|
||||
await writeSnapshot(sfRoot);
|
||||
const snapshotPath = join(sfRoot, "graphs", ".last-build-snapshot.json");
|
||||
assert.ok(existsSync(snapshotPath));
|
||||
const raw = readFileSync(snapshotPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as KnowledgeGraph & { snapshotAt: string };
|
||||
assert.ok(typeof parsed.snapshotAt === "string");
|
||||
assert.ok(!Number.isNaN(Date.parse(parsed.snapshotAt)));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/**
|
||||
* MCP graph reader compatibility exports.
|
||||
*
|
||||
* Purpose: keep MCP as a transport wrapper over the SF project graph while the
|
||||
* core graph implementation lives in `@singularity-forge/pi-agent-core`.
|
||||
*
|
||||
* Consumer: MCP `sf_graph` tool and older imports from `readers/graph.js`.
|
||||
*/
|
||||
|
||||
export type {
|
||||
ConfidenceTier,
|
||||
EdgeType,
|
||||
GraphDiffResult,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
GraphQueryResult,
|
||||
GraphStatusResult,
|
||||
KnowledgeGraph,
|
||||
NodeType,
|
||||
} from "@singularity-forge/pi-agent-core";
|
||||
export {
|
||||
buildGraph,
|
||||
graphDiff,
|
||||
graphQuery,
|
||||
graphStatus,
|
||||
resolveSFRoot,
|
||||
writeGraph,
|
||||
writeSnapshot,
|
||||
} from "@singularity-forge/pi-agent-core";
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
// SF MCP Server — readers barrel export
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
export { resolveSFRoot } from "@singularity-forge/pi-agent-core";
|
||||
export type { CaptureEntry, CapturesResult } from "./captures.js";
|
||||
export { readCaptures } from "./captures.js";
|
||||
export type { DoctorIssue, DoctorResult } from "./doctor-lite.js";
|
||||
export { runDoctorLite } from "./doctor-lite.js";
|
||||
export type {
|
||||
ConfidenceTier,
|
||||
EdgeType,
|
||||
GraphDiffResult,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
GraphQueryResult,
|
||||
GraphStatusResult,
|
||||
KnowledgeGraph,
|
||||
NodeType,
|
||||
} from "./graph.js";
|
||||
export {
|
||||
buildGraph,
|
||||
graphDiff,
|
||||
graphQuery,
|
||||
graphStatus,
|
||||
writeGraph,
|
||||
writeSnapshot,
|
||||
} from "./graph.js";
|
||||
export type { KnowledgeEntry, KnowledgeResult } from "./knowledge.js";
|
||||
export { readKnowledge } from "./knowledge.js";
|
||||
export type { HistoryResult, MetricsUnit } from "./metrics.js";
|
||||
export { readHistory } from "./metrics.js";
|
||||
export { resolveRootFile } from "./paths.js";
|
||||
export type {
|
||||
MilestoneInfo,
|
||||
RoadmapResult,
|
||||
SliceInfo,
|
||||
TaskInfo,
|
||||
} from "./roadmap.js";
|
||||
export { readRoadmap } from "./roadmap.js";
|
||||
export type { ProgressResult } from "./state.js";
|
||||
export { readProgress } from "./state.js";
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
// SF MCP Server — knowledge base reader
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolveRootFile, resolveSFRoot } from "./paths.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type KnowledgeType = "rule" | "pattern" | "lesson";
|
||||
|
||||
export interface KnowledgeEntry {
|
||||
id: string;
|
||||
type: KnowledgeType;
|
||||
scope: string;
|
||||
content: string;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeResult {
|
||||
entries: KnowledgeEntry[];
|
||||
counts: { rules: number; patterns: number; lessons: number };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseTableRows(
|
||||
section: string,
|
||||
type: KnowledgeType,
|
||||
): KnowledgeEntry[] {
|
||||
const entries: KnowledgeEntry[] = [];
|
||||
const lines = section.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.includes("|")) continue;
|
||||
const cells = line
|
||||
.split("|")
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
if (cells.length < 3) continue;
|
||||
// Skip header/separator
|
||||
if (cells[0].startsWith("#") || cells[0].startsWith("-")) continue;
|
||||
|
||||
const id = cells[0];
|
||||
if (!/^[KPL]\d+$/i.test(id)) continue;
|
||||
|
||||
if (type === "rule" && cells.length >= 5) {
|
||||
entries.push({
|
||||
id,
|
||||
type,
|
||||
scope: cells[1],
|
||||
content: cells[2],
|
||||
addedAt: cells[4] ?? "",
|
||||
});
|
||||
} else if (type === "pattern" && cells.length >= 4) {
|
||||
entries.push({
|
||||
id,
|
||||
type,
|
||||
scope: cells[2] ?? "",
|
||||
content: cells[1],
|
||||
addedAt: cells[3] ?? "",
|
||||
});
|
||||
} else if (type === "lesson" && cells.length >= 5) {
|
||||
entries.push({
|
||||
id,
|
||||
type,
|
||||
scope: cells[4] ?? "",
|
||||
content: `${cells[1]} — Root cause: ${cells[2]} — Fix: ${cells[3]}`,
|
||||
addedAt: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseKnowledgeMarkdown(content: string): KnowledgeEntry[] {
|
||||
const entries: KnowledgeEntry[] = [];
|
||||
|
||||
// Find ## Rules section
|
||||
const rulesMatch = content.match(/## Rules\s*\n([\s\S]*?)(?=\n## |$)/i);
|
||||
if (rulesMatch) {
|
||||
entries.push(...parseTableRows(rulesMatch[1], "rule"));
|
||||
}
|
||||
|
||||
// Find ## Patterns section
|
||||
const patternsMatch = content.match(/## Patterns\s*\n([\s\S]*?)(?=\n## |$)/i);
|
||||
if (patternsMatch) {
|
||||
entries.push(...parseTableRows(patternsMatch[1], "pattern"));
|
||||
}
|
||||
|
||||
// Find ## Lessons Learned section
|
||||
const lessonsMatch = content.match(
|
||||
/## Lessons Learned\s*\n([\s\S]*?)(?=\n## |$)/i,
|
||||
);
|
||||
if (lessonsMatch) {
|
||||
entries.push(...parseTableRows(lessonsMatch[1], "lesson"));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function readKnowledge(projectDir: string): KnowledgeResult {
|
||||
const sf = resolveSFRoot(projectDir);
|
||||
const knowledgePath = resolveRootFile(sf, "KNOWLEDGE.md");
|
||||
|
||||
if (!existsSync(knowledgePath)) {
|
||||
return { entries: [], counts: { rules: 0, patterns: 0, lessons: 0 } };
|
||||
}
|
||||
|
||||
const content = readFileSync(knowledgePath, "utf-8");
|
||||
const entries = parseKnowledgeMarkdown(content);
|
||||
|
||||
return {
|
||||
entries,
|
||||
counts: {
|
||||
rules: entries.filter((e) => e.type === "rule").length,
|
||||
patterns: entries.filter((e) => e.type === "pattern").length,
|
||||
lessons: entries.filter((e) => e.type === "lesson").length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
// SF MCP Server — metrics/history reader
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolveRootFile, resolveSFRoot } from "./paths.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MetricsUnit {
|
||||
type: string;
|
||||
id: string;
|
||||
model: string;
|
||||
startedAt: number;
|
||||
finishedAt: number;
|
||||
tokens: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
total: number;
|
||||
};
|
||||
cost: number;
|
||||
toolCalls: number;
|
||||
apiRequests: number;
|
||||
}
|
||||
|
||||
export interface HistoryResult {
|
||||
entries: MetricsUnit[];
|
||||
totals: {
|
||||
cost: number;
|
||||
tokens: { input: number; output: number; total: number };
|
||||
units: number;
|
||||
durationMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseMetricsJson(content: string): MetricsUnit[] {
|
||||
try {
|
||||
const data = JSON.parse(content);
|
||||
if (!data.units || !Array.isArray(data.units)) return [];
|
||||
|
||||
return data.units.map((u: Record<string, unknown>) => ({
|
||||
type: String(u.type ?? "unknown"),
|
||||
id: String(u.id ?? ""),
|
||||
model: String(u.model ?? "unknown"),
|
||||
startedAt: Number(u.startedAt ?? 0),
|
||||
finishedAt: Number(u.finishedAt ?? 0),
|
||||
tokens: {
|
||||
input: Number((u.tokens as Record<string, unknown>)?.input ?? 0),
|
||||
output: Number((u.tokens as Record<string, unknown>)?.output ?? 0),
|
||||
cacheRead: Number(
|
||||
(u.tokens as Record<string, unknown>)?.cacheRead ?? 0,
|
||||
),
|
||||
cacheWrite: Number(
|
||||
(u.tokens as Record<string, unknown>)?.cacheWrite ?? 0,
|
||||
),
|
||||
total: Number((u.tokens as Record<string, unknown>)?.total ?? 0),
|
||||
},
|
||||
cost: Number(u.cost ?? 0),
|
||||
toolCalls: Number(u.toolCalls ?? 0),
|
||||
apiRequests: Number(u.apiRequests ?? 0),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function readHistory(projectDir: string, limit?: number): HistoryResult {
|
||||
const sf = resolveSFRoot(projectDir);
|
||||
|
||||
// metrics.json (primary)
|
||||
const metricsPath = resolveRootFile(sf, "metrics.json");
|
||||
let units: MetricsUnit[] = [];
|
||||
|
||||
if (existsSync(metricsPath)) {
|
||||
const content = readFileSync(metricsPath, "utf-8");
|
||||
units = parseMetricsJson(content);
|
||||
}
|
||||
|
||||
// Sort by startedAt descending (most recent first)
|
||||
units.sort((a, b) => b.startedAt - a.startedAt);
|
||||
|
||||
// Apply limit
|
||||
if (limit && limit > 0) {
|
||||
units = units.slice(0, limit);
|
||||
}
|
||||
|
||||
// Compute totals from ALL units (not just limited set)
|
||||
const allUnits = existsSync(metricsPath)
|
||||
? parseMetricsJson(readFileSync(metricsPath, "utf-8"))
|
||||
: [];
|
||||
|
||||
const totals = {
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, total: 0 },
|
||||
units: allUnits.length,
|
||||
durationMs: 0,
|
||||
};
|
||||
|
||||
for (const u of allUnits) {
|
||||
totals.cost += u.cost;
|
||||
totals.tokens.input += u.tokens.input;
|
||||
totals.tokens.output += u.tokens.output;
|
||||
totals.tokens.total += u.tokens.total;
|
||||
totals.durationMs += u.finishedAt - u.startedAt;
|
||||
}
|
||||
|
||||
// Round cost to 4 decimal places
|
||||
totals.cost = Math.round(totals.cost * 10000) / 10000;
|
||||
|
||||
return { entries: units, totals };
|
||||
}
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
// SF MCP Server — .sf/ directory resolution
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Resolve the .sf/ root directory for a project.
|
||||
*
|
||||
* Probes in order:
|
||||
* 1. projectDir/.sf (fast path)
|
||||
* 2. git repo root/.sf
|
||||
* 3. Walk up from projectDir
|
||||
* 4. Fallback: projectDir/.sf (even if missing — for init)
|
||||
*/
|
||||
export function resolveSFRoot(projectDir: string): string {
|
||||
const resolved = resolve(projectDir);
|
||||
|
||||
// Fast path: .sf/ in the given directory
|
||||
const direct = join(resolved, ".sf");
|
||||
if (existsSync(direct) && statSync(direct).isDirectory()) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
// Try git repo root
|
||||
try {
|
||||
const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: resolved,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
const gitSf = join(gitRoot, ".sf");
|
||||
if (existsSync(gitSf) && statSync(gitSf).isDirectory()) {
|
||||
return gitSf;
|
||||
}
|
||||
} catch {
|
||||
// Not a git repo or git not available
|
||||
}
|
||||
|
||||
// Walk up from projectDir
|
||||
let dir = resolved;
|
||||
while (dir !== dirname(dir)) {
|
||||
const candidate = join(dir, ".sf");
|
||||
if (existsSync(candidate) && statSync(candidate).isDirectory()) {
|
||||
return candidate;
|
||||
}
|
||||
dir = dirname(dir);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return direct;
|
||||
}
|
||||
|
||||
/** Resolve path to a .sf/ root file (STATE.md, KNOWLEDGE.md, etc.) */
|
||||
export function resolveRootFile(sfRoot: string, name: string): string {
|
||||
return join(sfRoot, name);
|
||||
}
|
||||
|
||||
/** Resolve path to milestones directory */
|
||||
export function milestonesDir(sfRoot: string): string {
|
||||
return join(sfRoot, "milestones");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all milestone directory IDs (M001, M002, etc.).
|
||||
* Handles both bare (M001/) and descriptor (M001-FLIGHT-SIM/) naming.
|
||||
*/
|
||||
export function findMilestoneIds(sfRoot: string): string[] {
|
||||
const dir = milestonesDir(sfRoot);
|
||||
if (!existsSync(dir)) return [];
|
||||
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
const ids: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const match = entry.name.match(/^(M\d+)/);
|
||||
if (match) ids.push(match[1]);
|
||||
}
|
||||
|
||||
return ids.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the actual directory name for a milestone ID.
|
||||
* M001 might live in M001/ or M001-SOME-DESCRIPTOR/.
|
||||
*/
|
||||
export function resolveMilestoneDir(
|
||||
sfRoot: string,
|
||||
milestoneId: string,
|
||||
): string | null {
|
||||
const dir = milestonesDir(sfRoot);
|
||||
if (!existsSync(dir)) return null;
|
||||
|
||||
// Fast path: exact match
|
||||
const exact = join(dir, milestoneId);
|
||||
if (existsSync(exact) && statSync(exact).isDirectory()) return exact;
|
||||
|
||||
// Prefix match
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith(milestoneId)) {
|
||||
return join(dir, entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a milestone-level file (M001-ROADMAP.md, M001-CONTEXT.md, etc.).
|
||||
* Handles various naming conventions.
|
||||
*/
|
||||
export function resolveMilestoneFile(
|
||||
sfRoot: string,
|
||||
milestoneId: string,
|
||||
suffix: string,
|
||||
): string | null {
|
||||
const mDir = resolveMilestoneDir(sfRoot, milestoneId);
|
||||
if (!mDir) return null;
|
||||
|
||||
const dirName = basename(mDir);
|
||||
|
||||
// Try: M001-ROADMAP.md, then DIRNAME-ROADMAP.md
|
||||
const candidates = [
|
||||
join(mDir, `${milestoneId}-${suffix}.md`),
|
||||
join(mDir, `${dirName}-${suffix}.md`),
|
||||
join(mDir, `${suffix}.md`),
|
||||
];
|
||||
|
||||
for (const c of candidates) {
|
||||
if (existsSync(c)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Find all slice IDs within a milestone (S01, S02, etc.) */
|
||||
export function findSliceIds(sfRoot: string, milestoneId: string): string[] {
|
||||
const mDir = resolveMilestoneDir(sfRoot, milestoneId);
|
||||
if (!mDir) return [];
|
||||
|
||||
const slicesDir = join(mDir, "slices");
|
||||
if (!existsSync(slicesDir)) return [];
|
||||
|
||||
const entries = readdirSync(slicesDir, { withFileTypes: true });
|
||||
const ids: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const match = entry.name.match(/^(S\d+)/);
|
||||
if (match) ids.push(match[1]);
|
||||
}
|
||||
|
||||
return ids.sort();
|
||||
}
|
||||
|
||||
/** Resolve the actual directory for a slice */
|
||||
export function resolveSliceDir(
|
||||
sfRoot: string,
|
||||
milestoneId: string,
|
||||
sliceId: string,
|
||||
): string | null {
|
||||
const mDir = resolveMilestoneDir(sfRoot, milestoneId);
|
||||
if (!mDir) return null;
|
||||
|
||||
const slicesDir = join(mDir, "slices");
|
||||
if (!existsSync(slicesDir)) return null;
|
||||
|
||||
const exact = join(slicesDir, sliceId);
|
||||
if (existsSync(exact) && statSync(exact).isDirectory()) return exact;
|
||||
|
||||
const entries = readdirSync(slicesDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith(sliceId)) {
|
||||
return join(slicesDir, entry.name);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve a slice-level file (S01-PLAN.md, etc.) */
|
||||
export function resolveSliceFile(
|
||||
sfRoot: string,
|
||||
milestoneId: string,
|
||||
sliceId: string,
|
||||
suffix: string,
|
||||
): string | null {
|
||||
const sDir = resolveSliceDir(sfRoot, milestoneId, sliceId);
|
||||
if (!sDir) return null;
|
||||
|
||||
const dirName = basename(sDir);
|
||||
const candidates = [
|
||||
join(sDir, `${sliceId}-${suffix}.md`),
|
||||
join(sDir, `${dirName}-${suffix}.md`),
|
||||
join(sDir, `${suffix}.md`),
|
||||
];
|
||||
|
||||
for (const c of candidates) {
|
||||
if (existsSync(c)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Find all task files in a slice's tasks/ directory */
|
||||
export function findTaskFiles(
|
||||
sfRoot: string,
|
||||
milestoneId: string,
|
||||
sliceId: string,
|
||||
): Array<{ id: string; hasPlan: boolean; hasSummary: boolean }> {
|
||||
const sDir = resolveSliceDir(sfRoot, milestoneId, sliceId);
|
||||
if (!sDir) return [];
|
||||
|
||||
const tasksDir = join(sDir, "tasks");
|
||||
if (!existsSync(tasksDir)) return [];
|
||||
|
||||
const files = readdirSync(tasksDir);
|
||||
const taskMap = new Map<string, { hasPlan: boolean; hasSummary: boolean }>();
|
||||
|
||||
for (const f of files) {
|
||||
const match = f.match(/^(T\d+).*-(PLAN|SUMMARY)\.md$/i);
|
||||
if (!match) continue;
|
||||
const [, id, type] = match;
|
||||
const existing = taskMap.get(id) ?? { hasPlan: false, hasSummary: false };
|
||||
if (type.toUpperCase() === "PLAN") existing.hasPlan = true;
|
||||
if (type.toUpperCase() === "SUMMARY") existing.hasSummary = true;
|
||||
taskMap.set(id, existing);
|
||||
}
|
||||
|
||||
return Array.from(taskMap.entries())
|
||||
.map(([id, info]) => ({ id, ...info }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
|
@ -1,617 +0,0 @@
|
|||
// SF MCP Server — reader tests
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, it } from "vitest";
|
||||
import { readCaptures } from "./captures.js";
|
||||
import { runDoctorLite } from "./doctor-lite.js";
|
||||
import { readKnowledge } from "./knowledge.js";
|
||||
import { readHistory } from "./metrics.js";
|
||||
import { readRoadmap } from "./roadmap.js";
|
||||
import { readProgress } from "./state.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixture helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tmpProject(): string {
|
||||
const dir = join(tmpdir(), `sf-mcp-test-${randomBytes(4).toString("hex")}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeFixture(base: string, relPath: string, content: string): void {
|
||||
const full = join(base, relPath);
|
||||
mkdirSync(join(full, ".."), { recursive: true });
|
||||
writeFileSync(full, content, "utf-8");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readProgress tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readProgress", () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
projectDir = tmpProject();
|
||||
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/STATE.md",
|
||||
`# SF State
|
||||
|
||||
**Active Milestone:** M002: Auth System
|
||||
**Active Slice:** S01: Login flow
|
||||
**Phase:** execution
|
||||
**Requirements Status:** 5 active · 2 validated · 1 deferred · 0 out of scope
|
||||
|
||||
## Milestone Registry
|
||||
|
||||
- ☑ **M001:** Core Setup
|
||||
- 🔄 **M002:** Auth System
|
||||
- ⬜ **M003:** Dashboard
|
||||
|
||||
## Blockers
|
||||
|
||||
- Waiting on OAuth provider approval
|
||||
|
||||
## Next Action
|
||||
|
||||
Execute T02 in S01 — implement token refresh.
|
||||
`,
|
||||
);
|
||||
|
||||
// Create filesystem structure
|
||||
const m1 = ".sf/milestones/M001/slices/S01/tasks";
|
||||
writeFixture(projectDir, `${m1}/T01-PLAN.md`, "# T01");
|
||||
writeFixture(projectDir, `${m1}/T01-SUMMARY.md`, "# T01 done");
|
||||
|
||||
const m2 = ".sf/milestones/M002/slices/S01/tasks";
|
||||
writeFixture(projectDir, `${m2}/T01-PLAN.md`, "# T01");
|
||||
writeFixture(projectDir, `${m2}/T01-SUMMARY.md`, "# T01 done");
|
||||
writeFixture(projectDir, `${m2}/T02-PLAN.md`, "# T02");
|
||||
|
||||
mkdirSync(join(projectDir, ".sf/milestones/M003"), { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(projectDir, { recursive: true, force: true }));
|
||||
|
||||
it("parses active milestone from STATE.md", () => {
|
||||
const result = readProgress(projectDir);
|
||||
assert.deepEqual(result.activeMilestone, {
|
||||
id: "M002",
|
||||
title: "Auth System",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses active slice", () => {
|
||||
const result = readProgress(projectDir);
|
||||
assert.deepEqual(result.activeSlice, { id: "S01", title: "Login flow" });
|
||||
});
|
||||
|
||||
it("parses phase", () => {
|
||||
const result = readProgress(projectDir);
|
||||
assert.equal(result.phase, "execute");
|
||||
});
|
||||
|
||||
it("parses milestone counts from registry", () => {
|
||||
const result = readProgress(projectDir);
|
||||
assert.equal(result.milestones.total, 3);
|
||||
assert.equal(result.milestones.done, 1);
|
||||
assert.equal(result.milestones.active, 1);
|
||||
assert.equal(result.milestones.pending, 1);
|
||||
});
|
||||
|
||||
it("counts tasks from filesystem", () => {
|
||||
const result = readProgress(projectDir);
|
||||
assert.equal(result.tasks.total, 3);
|
||||
assert.equal(result.tasks.done, 2);
|
||||
assert.equal(result.tasks.pending, 1);
|
||||
});
|
||||
|
||||
it("parses blockers", () => {
|
||||
const result = readProgress(projectDir);
|
||||
assert.equal(result.blockers.length, 1);
|
||||
assert.ok(result.blockers[0].includes("OAuth"));
|
||||
});
|
||||
|
||||
it("parses requirements", () => {
|
||||
const result = readProgress(projectDir);
|
||||
assert.equal(result.requirements?.active, 5);
|
||||
assert.equal(result.requirements?.validated, 2);
|
||||
assert.equal(result.requirements?.deferred, 1);
|
||||
});
|
||||
|
||||
it("parses next action", () => {
|
||||
const result = readProgress(projectDir);
|
||||
assert.ok(result.nextAction.includes("T02"));
|
||||
});
|
||||
|
||||
it("returns defaults for missing .sf/", () => {
|
||||
const empty = tmpProject();
|
||||
const result = readProgress(empty);
|
||||
assert.equal(result.phase, "unknown");
|
||||
assert.equal(result.milestones.total, 0);
|
||||
rmSync(empty, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readRoadmap tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readRoadmap", () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
projectDir = tmpProject();
|
||||
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/M001-CONTEXT.md",
|
||||
"# M001: Core Setup\n",
|
||||
);
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/M001-ROADMAP.md",
|
||||
`# M001: Core Setup
|
||||
|
||||
## Vision
|
||||
|
||||
Build the foundation for the project.
|
||||
|
||||
## Slice Overview
|
||||
|
||||
| ID | Slice | Risk | Depends | Done | After this |
|
||||
|----|-------|------|---------|------|------------|
|
||||
| S01 | Database schema | low | — | ☑ | DB ready |
|
||||
| S02 | API endpoints | medium | S01 | 🟫 | REST API live |
|
||||
`,
|
||||
);
|
||||
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/slices/S01/S01-PLAN.md",
|
||||
`# S01: Database schema
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] **T01: Create migrations** — Set up schema
|
||||
- [x] **T02: Seed data** — Initial seed
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/slices/S01/tasks/T01-PLAN.md",
|
||||
"# T01",
|
||||
);
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/slices/S01/tasks/T01-SUMMARY.md",
|
||||
"# T01 done",
|
||||
);
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/slices/S01/tasks/T02-PLAN.md",
|
||||
"# T02",
|
||||
);
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/slices/S01/tasks/T02-SUMMARY.md",
|
||||
"# T02 done",
|
||||
);
|
||||
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/slices/S02/S02-PLAN.md",
|
||||
`# S02: API endpoints
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] **T01: Auth routes** — Implement auth
|
||||
- [ ] **T02: User routes** — CRUD users
|
||||
`,
|
||||
);
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/slices/S02/tasks/T01-PLAN.md",
|
||||
"# T01",
|
||||
);
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/slices/S02/tasks/T02-PLAN.md",
|
||||
"# T02",
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(projectDir, { recursive: true, force: true }));
|
||||
|
||||
it("returns milestone structure", () => {
|
||||
const result = readRoadmap(projectDir);
|
||||
assert.equal(result.milestones.length, 1);
|
||||
assert.equal(result.milestones[0].id, "M001");
|
||||
assert.equal(result.milestones[0].title, "Core Setup");
|
||||
});
|
||||
|
||||
it("reads vision from roadmap", () => {
|
||||
const result = readRoadmap(projectDir);
|
||||
assert.ok(result.milestones[0].vision.includes("foundation"));
|
||||
});
|
||||
|
||||
it("parses slices from roadmap table", () => {
|
||||
const result = readRoadmap(projectDir);
|
||||
const slices = result.milestones[0].slices;
|
||||
assert.equal(slices.length, 2);
|
||||
assert.equal(slices[0].id, "S01");
|
||||
assert.equal(slices[0].title, "Database schema");
|
||||
assert.equal(slices[1].id, "S02");
|
||||
});
|
||||
|
||||
it("derives slice status from task summaries", () => {
|
||||
const result = readRoadmap(projectDir);
|
||||
const slices = result.milestones[0].slices;
|
||||
assert.equal(slices[0].status, "done");
|
||||
assert.equal(slices[1].status, "pending");
|
||||
});
|
||||
|
||||
it("includes tasks in slices", () => {
|
||||
const result = readRoadmap(projectDir);
|
||||
const s01Tasks = result.milestones[0].slices[0].tasks;
|
||||
assert.equal(s01Tasks.length, 2);
|
||||
assert.equal(s01Tasks[0].status, "done");
|
||||
});
|
||||
|
||||
it("filters by milestoneId", () => {
|
||||
const result = readRoadmap(projectDir, "M999");
|
||||
assert.equal(result.milestones.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readHistory tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readHistory", () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
projectDir = tmpProject();
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/metrics.json",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
projectStartedAt: 1700000000000,
|
||||
units: [
|
||||
{
|
||||
type: "execute-task",
|
||||
id: "M001/S01/T01",
|
||||
model: "claude-sonnet-4",
|
||||
startedAt: 1700001000000,
|
||||
finishedAt: 1700002000000,
|
||||
tokens: {
|
||||
input: 10000,
|
||||
output: 3000,
|
||||
cacheRead: 2000,
|
||||
cacheWrite: 1000,
|
||||
total: 16000,
|
||||
},
|
||||
cost: 0.05,
|
||||
toolCalls: 8,
|
||||
apiRequests: 3,
|
||||
},
|
||||
{
|
||||
type: "execute-task",
|
||||
id: "M001/S01/T02",
|
||||
model: "claude-sonnet-4",
|
||||
startedAt: 1700003000000,
|
||||
finishedAt: 1700004000000,
|
||||
tokens: {
|
||||
input: 15000,
|
||||
output: 5000,
|
||||
cacheRead: 3000,
|
||||
cacheWrite: 1500,
|
||||
total: 24500,
|
||||
},
|
||||
cost: 0.08,
|
||||
toolCalls: 12,
|
||||
apiRequests: 5,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(projectDir, { recursive: true, force: true }));
|
||||
|
||||
it("returns all entries sorted by most recent", () => {
|
||||
const result = readHistory(projectDir);
|
||||
assert.equal(result.entries.length, 2);
|
||||
assert.equal(result.entries[0].id, "M001/S01/T02"); // most recent first
|
||||
});
|
||||
|
||||
it("computes totals", () => {
|
||||
const result = readHistory(projectDir);
|
||||
assert.equal(result.totals.units, 2);
|
||||
assert.equal(result.totals.cost, 0.13);
|
||||
assert.equal(result.totals.tokens.total, 40500);
|
||||
});
|
||||
|
||||
it("respects limit", () => {
|
||||
const result = readHistory(projectDir, 1);
|
||||
assert.equal(result.entries.length, 1);
|
||||
assert.equal(result.totals.units, 2); // totals still reflect all
|
||||
});
|
||||
|
||||
it("returns empty for missing metrics", () => {
|
||||
const empty = tmpProject();
|
||||
mkdirSync(join(empty, ".sf"), { recursive: true });
|
||||
const result = readHistory(empty);
|
||||
assert.equal(result.entries.length, 0);
|
||||
assert.equal(result.totals.units, 0);
|
||||
rmSync(empty, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readCaptures tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readCaptures", () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
projectDir = tmpProject();
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/CAPTURES.md",
|
||||
`# Captures
|
||||
|
||||
### CAP-aaa11111
|
||||
|
||||
**Text:** Add rate limiting to API
|
||||
**Captured:** 2026-04-01T10:00:00Z
|
||||
**Status:** pending
|
||||
|
||||
### CAP-bbb22222
|
||||
|
||||
**Text:** Refactor auth module
|
||||
**Captured:** 2026-04-02T10:00:00Z
|
||||
**Status:** resolved
|
||||
**Classification:** inject
|
||||
**Resolution:** Added to M003 roadmap
|
||||
**Rationale:** Important for security
|
||||
**Resolved:** 2026-04-03T10:00:00Z
|
||||
**Milestone:** M003
|
||||
|
||||
### CAP-ccc33333
|
||||
|
||||
**Text:** Nice to have: dark mode
|
||||
**Captured:** 2026-04-02T11:00:00Z
|
||||
**Status:** resolved
|
||||
**Classification:** defer
|
||||
**Resolution:** Deferred to future
|
||||
**Rationale:** Not blocking
|
||||
**Resolved:** 2026-04-03T11:00:00Z
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(projectDir, { recursive: true, force: true }));
|
||||
|
||||
it("reads all captures", () => {
|
||||
const result = readCaptures(projectDir, "all");
|
||||
assert.equal(result.captures.length, 3);
|
||||
assert.equal(result.counts.total, 3);
|
||||
});
|
||||
|
||||
it("filters pending captures", () => {
|
||||
const result = readCaptures(projectDir, "pending");
|
||||
assert.equal(result.captures.length, 1);
|
||||
assert.equal(result.captures[0].id, "CAP-aaa11111");
|
||||
});
|
||||
|
||||
it("filters actionable captures (inject, replan, quick-task)", () => {
|
||||
const result = readCaptures(projectDir, "actionable");
|
||||
assert.equal(result.captures.length, 1);
|
||||
assert.equal(result.captures[0].id, "CAP-bbb22222");
|
||||
});
|
||||
|
||||
it("counts correctly regardless of filter", () => {
|
||||
const result = readCaptures(projectDir, "pending");
|
||||
assert.equal(result.counts.total, 3);
|
||||
assert.equal(result.counts.pending, 1);
|
||||
assert.equal(result.counts.actionable, 1);
|
||||
});
|
||||
|
||||
it("returns empty for missing CAPTURES.md", () => {
|
||||
const empty = tmpProject();
|
||||
mkdirSync(join(empty, ".sf"), { recursive: true });
|
||||
const result = readCaptures(empty);
|
||||
assert.equal(result.captures.length, 0);
|
||||
rmSync(empty, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readKnowledge tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readKnowledge", () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
projectDir = tmpProject();
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/KNOWLEDGE.md",
|
||||
`# Project Knowledge
|
||||
|
||||
## Rules
|
||||
|
||||
| # | Scope | Rule | Why | Added |
|
||||
|---|-------|------|-----|-------|
|
||||
| K001 | auth | Hash passwords with bcrypt | Security requirement | manual |
|
||||
| K002 | db | Use transactions for multi-table | Data consistency | auto |
|
||||
|
||||
## Patterns
|
||||
|
||||
| # | Pattern | Where | Notes |
|
||||
|---|---------|-------|-------|
|
||||
| P001 | Singleton services | services/ | Prevents duplication |
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
| # | What Happened | Root Cause | Fix | Scope |
|
||||
|---|--------------|------------|-----|-------|
|
||||
| L001 | CI tests failed | Env diff | Added setup script | testing |
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(projectDir, { recursive: true, force: true }));
|
||||
|
||||
it("reads all knowledge entries", () => {
|
||||
const result = readKnowledge(projectDir);
|
||||
assert.equal(result.entries.length, 4);
|
||||
});
|
||||
|
||||
it("counts by type", () => {
|
||||
const result = readKnowledge(projectDir);
|
||||
assert.equal(result.counts.rules, 2);
|
||||
assert.equal(result.counts.patterns, 1);
|
||||
assert.equal(result.counts.lessons, 1);
|
||||
});
|
||||
|
||||
it("parses rule fields correctly", () => {
|
||||
const result = readKnowledge(projectDir);
|
||||
const k001 = result.entries.find((e) => e.id === "K001");
|
||||
assert.ok(k001);
|
||||
assert.equal(k001.type, "rule");
|
||||
assert.equal(k001.scope, "auth");
|
||||
assert.ok(k001.content.includes("bcrypt"));
|
||||
});
|
||||
|
||||
it("returns empty for missing KNOWLEDGE.md", () => {
|
||||
const empty = tmpProject();
|
||||
mkdirSync(join(empty, ".sf"), { recursive: true });
|
||||
const result = readKnowledge(empty);
|
||||
assert.equal(result.entries.length, 0);
|
||||
rmSync(empty, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runDoctorLite tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("runDoctorLite", () => {
|
||||
let projectDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
projectDir = tmpProject();
|
||||
|
||||
// M001: complete milestone (has summary)
|
||||
writeFixture(projectDir, ".sf/PROJECT.md", "# Test Project");
|
||||
writeFixture(projectDir, ".sf/STATE.md", "# SF State");
|
||||
writeFixture(projectDir, ".sf/milestones/M001/M001-CONTEXT.md", "# M001");
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/M001-ROADMAP.md",
|
||||
"# Roadmap",
|
||||
);
|
||||
writeFixture(projectDir, ".sf/milestones/M001/M001-SUMMARY.md", "# Done");
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/slices/S01/S01-PLAN.md",
|
||||
"# Plan",
|
||||
);
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/slices/S01/tasks/T01-PLAN.md",
|
||||
"# T01",
|
||||
);
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M001/slices/S01/tasks/T01-SUMMARY.md",
|
||||
"# T01 done",
|
||||
);
|
||||
|
||||
// M002: incomplete — has all tasks done but no SUMMARY
|
||||
writeFixture(projectDir, ".sf/milestones/M002/M002-CONTEXT.md", "# M002");
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M002/M002-ROADMAP.md",
|
||||
"# Roadmap",
|
||||
);
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M002/slices/S01/S01-PLAN.md",
|
||||
"# Plan",
|
||||
);
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M002/slices/S01/tasks/T01-PLAN.md",
|
||||
"# T01",
|
||||
);
|
||||
writeFixture(
|
||||
projectDir,
|
||||
".sf/milestones/M002/slices/S01/tasks/T01-SUMMARY.md",
|
||||
"# T01 done",
|
||||
);
|
||||
|
||||
// M003: empty — no context, no slices
|
||||
mkdirSync(join(projectDir, ".sf/milestones/M003"), { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(projectDir, { recursive: true, force: true }));
|
||||
|
||||
it("detects all-slices-done-missing-summary", () => {
|
||||
const result = runDoctorLite(projectDir);
|
||||
const issue = result.issues.find(
|
||||
(i) => i.code === "all_slices_done_missing_summary",
|
||||
);
|
||||
assert.ok(issue, "Should detect M002 missing summary");
|
||||
assert.equal(issue.unitId, "M002");
|
||||
});
|
||||
|
||||
it("detects missing context", () => {
|
||||
const result = runDoctorLite(projectDir);
|
||||
const issue = result.issues.find(
|
||||
(i) => i.code === "missing_context" && i.unitId === "M003",
|
||||
);
|
||||
assert.ok(issue, "Should detect M003 missing context");
|
||||
});
|
||||
|
||||
it("scopes to a single milestone", () => {
|
||||
const result = runDoctorLite(projectDir, "M001");
|
||||
const m002Issues = result.issues.filter((i) => i.unitId.startsWith("M002"));
|
||||
assert.equal(
|
||||
m002Issues.length,
|
||||
0,
|
||||
"Should not include M002 when scoped to M001",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns ok:true for healthy project", () => {
|
||||
const healthy = tmpProject();
|
||||
writeFixture(healthy, ".sf/PROJECT.md", "# Project");
|
||||
writeFixture(healthy, ".sf/STATE.md", "# State");
|
||||
const result = runDoctorLite(healthy);
|
||||
assert.equal(result.ok, true);
|
||||
rmSync(healthy, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("handles missing .sf/ gracefully", () => {
|
||||
const empty = tmpProject();
|
||||
const result = runDoctorLite(empty);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.issues[0].code, "no_sf_directory");
|
||||
rmSync(empty, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
// SF MCP Server — roadmap structure reader
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
findMilestoneIds,
|
||||
findSliceIds,
|
||||
findTaskFiles,
|
||||
resolveMilestoneFile,
|
||||
resolveSFRoot,
|
||||
resolveSliceFile,
|
||||
} from "./paths.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TaskInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "done" | "pending";
|
||||
}
|
||||
|
||||
export interface SliceInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "done" | "active" | "pending";
|
||||
risk: string;
|
||||
depends: string[];
|
||||
demo: string;
|
||||
tasks: TaskInfo[];
|
||||
}
|
||||
|
||||
export interface MilestoneInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "done" | "active" | "pending" | "parked";
|
||||
vision: string;
|
||||
slices: SliceInfo[];
|
||||
}
|
||||
|
||||
export interface RoadmapResult {
|
||||
milestones: MilestoneInfo[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ROADMAP.md table parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseRoadmapTable(content: string): Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
risk: string;
|
||||
depends: string[];
|
||||
done: boolean;
|
||||
demo: string;
|
||||
}> {
|
||||
const results: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
risk: string;
|
||||
depends: string[];
|
||||
done: boolean;
|
||||
demo: string;
|
||||
}> = [];
|
||||
|
||||
// Try table format first: | S01 | Title | risk | depends | done-icon | demo |
|
||||
const tableSection = content.match(
|
||||
/## (?:Slice[s]?|Slice Overview|Slice Table)\s*\n([\s\S]*?)(?=\n##|\n$|$)/i,
|
||||
);
|
||||
if (tableSection) {
|
||||
const lines = tableSection[1].split("\n");
|
||||
for (const line of lines) {
|
||||
if (!line.includes("|")) continue;
|
||||
const cells = line
|
||||
.split("|")
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
if (cells.length < 4) continue;
|
||||
if (cells[0] === "ID" || cells[0].startsWith("--")) continue;
|
||||
|
||||
const id = cells[0].match(/S\d+/)?.[0];
|
||||
if (!id) continue;
|
||||
|
||||
const done = cells.some(
|
||||
(c) => c === "\u2611" || c === "\u2705" || c.toLowerCase() === "done",
|
||||
);
|
||||
const depends = (cells[3] ?? "")
|
||||
.replace(/\u2014/g, "")
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
results.push({
|
||||
id,
|
||||
title: cells[1] ?? "",
|
||||
risk: cells[2] ?? "medium",
|
||||
depends,
|
||||
done,
|
||||
demo: cells[5] ?? "",
|
||||
});
|
||||
}
|
||||
if (results.length > 0) return results;
|
||||
}
|
||||
|
||||
// Try checkbox format: - [x] **S01: Title** `risk:high` `depends:[S01]`
|
||||
const checkboxRe =
|
||||
/^-\s+\[([ xX])\]\s+\*\*(S\d+):\s*(.+?)\*\*(?:.*?`risk:(\w+)`)?(?:.*?`depends:\[([^\]]*)\]`)?/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = checkboxRe.exec(content)) !== null) {
|
||||
const [, checked, id, title, risk, deps] = match;
|
||||
results.push({
|
||||
id,
|
||||
title: title.trim(),
|
||||
risk: risk ?? "medium",
|
||||
depends: deps
|
||||
? deps
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
done: checked !== " ",
|
||||
demo: "",
|
||||
});
|
||||
}
|
||||
if (results.length > 0) return results;
|
||||
|
||||
// Try prose headers: ## S01: Title
|
||||
const headerRe = /^##\s+(S\d+):\s*(.+)/gm;
|
||||
while ((match = headerRe.exec(content)) !== null) {
|
||||
results.push({
|
||||
id: match[1],
|
||||
title: match[2].trim(),
|
||||
risk: "medium",
|
||||
depends: [],
|
||||
done: false,
|
||||
demo: "",
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PLAN.md task parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseSlicePlanTasks(
|
||||
content: string,
|
||||
): Array<{ id: string; title: string; done: boolean }> {
|
||||
const results: Array<{ id: string; title: string; done: boolean }> = [];
|
||||
|
||||
// Checkbox format: - [x] **T01: Title** — description
|
||||
const taskRe = /^-\s+\[([ xX])\]\s+\*\*(T\d+):\s*(.+?)\*\*/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = taskRe.exec(content)) !== null) {
|
||||
results.push({
|
||||
id: match[2],
|
||||
title: match[3].trim(),
|
||||
done: match[1] !== " ",
|
||||
});
|
||||
}
|
||||
if (results.length > 0) return results;
|
||||
|
||||
// H3 format: ### T01: Title
|
||||
const h3Re = /^###\s+(T\d+):\s*(.+)/gm;
|
||||
while ((match = h3Re.exec(content)) !== null) {
|
||||
results.push({
|
||||
id: match[1],
|
||||
title: match[2].trim(),
|
||||
done: false,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Milestone title from CONTEXT.md or ROADMAP.md H1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readMilestoneTitle(sfRoot: string, mid: string): string {
|
||||
const ctxPath = resolveMilestoneFile(sfRoot, mid, "CONTEXT");
|
||||
if (ctxPath && existsSync(ctxPath)) {
|
||||
const content = readFileSync(ctxPath, "utf-8");
|
||||
const h1 = content.match(/^#\s+(?:M\d+:?\s*)?(.+)/m);
|
||||
if (h1) return h1[1].trim();
|
||||
}
|
||||
|
||||
const roadmapPath = resolveMilestoneFile(sfRoot, mid, "ROADMAP");
|
||||
if (roadmapPath && existsSync(roadmapPath)) {
|
||||
const content = readFileSync(roadmapPath, "utf-8");
|
||||
const h1 = content.match(/^#\s+(?:M\d+:?\s*)?(.+)/m);
|
||||
if (h1) return h1[1].trim();
|
||||
}
|
||||
|
||||
return mid;
|
||||
}
|
||||
|
||||
function readVision(sfRoot: string, mid: string): string {
|
||||
const roadmapPath = resolveMilestoneFile(sfRoot, mid, "ROADMAP");
|
||||
if (!roadmapPath || !existsSync(roadmapPath)) return "";
|
||||
|
||||
const content = readFileSync(roadmapPath, "utf-8");
|
||||
const section = content.match(/## Vision\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
|
||||
return section ? section[1].trim() : "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function readRoadmap(
|
||||
projectDir: string,
|
||||
filterMilestoneId?: string,
|
||||
): RoadmapResult {
|
||||
const sf = resolveSFRoot(projectDir);
|
||||
let milestoneIds = findMilestoneIds(sf);
|
||||
|
||||
if (filterMilestoneId) {
|
||||
milestoneIds = milestoneIds.filter((id) => id === filterMilestoneId);
|
||||
}
|
||||
|
||||
const milestones: MilestoneInfo[] = [];
|
||||
|
||||
for (const mid of milestoneIds) {
|
||||
const title = readMilestoneTitle(sf, mid);
|
||||
const vision = readVision(sf, mid);
|
||||
|
||||
const summaryPath = resolveMilestoneFile(sf, mid, "SUMMARY");
|
||||
const hasSummary = summaryPath !== null && existsSync(summaryPath);
|
||||
|
||||
const roadmapPath = resolveMilestoneFile(sf, mid, "ROADMAP");
|
||||
let roadmapSlices: ReturnType<typeof parseRoadmapTable> = [];
|
||||
if (roadmapPath && existsSync(roadmapPath)) {
|
||||
roadmapSlices = parseRoadmapTable(readFileSync(roadmapPath, "utf-8"));
|
||||
}
|
||||
|
||||
const fsSliceIds = findSliceIds(sf, mid);
|
||||
const sliceIdSet = new Set([
|
||||
...roadmapSlices.map((s) => s.id),
|
||||
...fsSliceIds,
|
||||
]);
|
||||
|
||||
const slices: SliceInfo[] = [];
|
||||
for (const sid of Array.from(sliceIdSet).sort()) {
|
||||
const roadmapEntry = roadmapSlices.find((s) => s.id === sid);
|
||||
const taskFiles = findTaskFiles(sf, mid, sid);
|
||||
|
||||
const planPath = resolveSliceFile(sf, mid, sid, "PLAN");
|
||||
let planTasks: ReturnType<typeof parseSlicePlanTasks> = [];
|
||||
if (planPath && existsSync(planPath)) {
|
||||
planTasks = parseSlicePlanTasks(readFileSync(planPath, "utf-8"));
|
||||
}
|
||||
|
||||
const tasks: TaskInfo[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const pt of planTasks) {
|
||||
const fsTask = taskFiles.find((t) => t.id === pt.id);
|
||||
const done = fsTask?.hasSummary ?? pt.done;
|
||||
tasks.push({
|
||||
id: pt.id,
|
||||
title: pt.title,
|
||||
status: done ? "done" : "pending",
|
||||
});
|
||||
seenIds.add(pt.id);
|
||||
}
|
||||
for (const ft of taskFiles) {
|
||||
if (seenIds.has(ft.id)) continue;
|
||||
tasks.push({
|
||||
id: ft.id,
|
||||
title: ft.id,
|
||||
status: ft.hasSummary ? "done" : "pending",
|
||||
});
|
||||
}
|
||||
|
||||
const allDone =
|
||||
tasks.length > 0 && tasks.every((t) => t.status === "done");
|
||||
const anyDone = tasks.some((t) => t.status === "done");
|
||||
const sliceStatus: SliceInfo["status"] = allDone
|
||||
? "done"
|
||||
: anyDone
|
||||
? "active"
|
||||
: "pending";
|
||||
|
||||
slices.push({
|
||||
id: sid,
|
||||
title: roadmapEntry?.title ?? sid,
|
||||
status: sliceStatus,
|
||||
risk: roadmapEntry?.risk ?? "medium",
|
||||
depends: roadmapEntry?.depends ?? [],
|
||||
demo: roadmapEntry?.demo ?? "",
|
||||
tasks,
|
||||
});
|
||||
}
|
||||
|
||||
const allSlicesDone =
|
||||
slices.length > 0 && slices.every((s) => s.status === "done");
|
||||
const anySliceActive = slices.some(
|
||||
(s) => s.status === "active" || s.status === "done",
|
||||
);
|
||||
const milestoneStatus: MilestoneInfo["status"] = hasSummary
|
||||
? "done"
|
||||
: allSlicesDone
|
||||
? "done"
|
||||
: anySliceActive
|
||||
? "active"
|
||||
: "pending";
|
||||
|
||||
milestones.push({
|
||||
id: mid,
|
||||
title,
|
||||
status: milestoneStatus,
|
||||
vision,
|
||||
slices,
|
||||
});
|
||||
}
|
||||
|
||||
return { milestones };
|
||||
}
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
// SF MCP Server — project state reader
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
findMilestoneIds,
|
||||
findSliceIds,
|
||||
findTaskFiles,
|
||||
resolveRootFile,
|
||||
resolveSFRoot,
|
||||
} from "./paths.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProgressResult {
|
||||
activeMilestone: { id: string; title: string } | null;
|
||||
activeSlice: { id: string; title: string } | null;
|
||||
activeTask: { id: string; title: string } | null;
|
||||
phase: string;
|
||||
milestones: {
|
||||
total: number;
|
||||
done: number;
|
||||
active: number;
|
||||
pending: number;
|
||||
parked: number;
|
||||
};
|
||||
slices: { total: number; done: number; active: number; pending: number };
|
||||
tasks: { total: number; done: number; pending: number };
|
||||
requirements: {
|
||||
active: number;
|
||||
validated: number;
|
||||
deferred: number;
|
||||
outOfScope: number;
|
||||
} | null;
|
||||
blockers: string[];
|
||||
nextAction: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// STATE.md parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseBoldField(content: string, label: string): string | null {
|
||||
const re = new RegExp(`\\*\\*${label}:\\*\\*\\s*(.+)`, "i");
|
||||
const m = content.match(re);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
function parseActiveRef(
|
||||
value: string | null,
|
||||
): { id: string; title: string } | null {
|
||||
if (!value || value.toLowerCase() === "none" || value === "—") return null;
|
||||
// "M001: Flight Simulator" or "M001"
|
||||
const m = value.match(/^(M\d+|S\d+|T\d+):?\s*(.*)/);
|
||||
if (m) return { id: m[1], title: m[2] || m[1] };
|
||||
return { id: value, title: value };
|
||||
}
|
||||
|
||||
function parsePhase(value: string | null): string {
|
||||
if (!value) return "unknown";
|
||||
const lower = value.toLowerCase().trim();
|
||||
if (lower.includes("research") || lower.includes("discuss"))
|
||||
return "research";
|
||||
if (lower.includes("plan")) return "plan";
|
||||
if (lower.includes("execut")) return "execute";
|
||||
if (lower.includes("complete") || lower.includes("done")) return "complete";
|
||||
return lower;
|
||||
}
|
||||
|
||||
function parseRequirementsLine(
|
||||
value: string | null,
|
||||
): ProgressResult["requirements"] | null {
|
||||
if (!value) return null;
|
||||
const active = value.match(/(\d+)\s*active/i);
|
||||
const validated = value.match(/(\d+)\s*validated/i);
|
||||
const deferred = value.match(/(\d+)\s*deferred/i);
|
||||
const outOfScope = value.match(/(\d+)\s*out.of.scope/i);
|
||||
if (!active && !validated && !deferred && !outOfScope) return null;
|
||||
return {
|
||||
active: active ? parseInt(active[1], 10) : 0,
|
||||
validated: validated ? parseInt(validated[1], 10) : 0,
|
||||
deferred: deferred ? parseInt(deferred[1], 10) : 0,
|
||||
outOfScope: outOfScope ? parseInt(outOfScope[1], 10) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseBlockers(content: string): string[] {
|
||||
const section = content.match(/## Blockers\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
|
||||
if (!section) return [];
|
||||
return section[1]
|
||||
.split("\n")
|
||||
.map((l) => l.replace(/^[-*]\s*/, "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseNextAction(content: string): string {
|
||||
const section = content.match(/## Next Action\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
|
||||
if (!section) return "";
|
||||
return section[1].trim().split("\n")[0] || "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Milestone registry from STATE.md
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RegistryEntry {
|
||||
id: string;
|
||||
status: "done" | "active" | "pending" | "parked";
|
||||
}
|
||||
|
||||
function parseMilestoneRegistry(content: string): RegistryEntry[] {
|
||||
const section = content.match(
|
||||
/## Milestone Registry\s*\n([\s\S]*?)(?=\n##|\n$|$)/i,
|
||||
);
|
||||
if (!section) return [];
|
||||
const entries: RegistryEntry[] = [];
|
||||
for (const line of section[1].split("\n")) {
|
||||
const m = line.match(/[-*]\s*(☑|✅|🔄|⬜|⏸)\s*\*\*(M\d+):\*\*/);
|
||||
if (!m) continue;
|
||||
const [, icon, id] = m;
|
||||
let status: RegistryEntry["status"] = "pending";
|
||||
if (icon === "☑" || icon === "✅") status = "done";
|
||||
else if (icon === "🔄") status = "active";
|
||||
else if (icon === "⏸") status = "parked";
|
||||
entries.push({ id, status });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Count slices/tasks by walking filesystem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function countSlicesAndTasks(
|
||||
sfRoot: string,
|
||||
milestoneIds: string[],
|
||||
): {
|
||||
slices: ProgressResult["slices"];
|
||||
tasks: ProgressResult["tasks"];
|
||||
} {
|
||||
let sliceTotal = 0,
|
||||
sliceDone = 0,
|
||||
sliceActive = 0;
|
||||
let taskTotal = 0,
|
||||
taskDone = 0;
|
||||
|
||||
for (const mid of milestoneIds) {
|
||||
const sliceIds = findSliceIds(sfRoot, mid);
|
||||
sliceTotal += sliceIds.length;
|
||||
|
||||
for (const sid of sliceIds) {
|
||||
const tasks = findTaskFiles(sfRoot, mid, sid);
|
||||
taskTotal += tasks.length;
|
||||
|
||||
const allDone = tasks.length > 0 && tasks.every((t) => t.hasSummary);
|
||||
const anyDone = tasks.some((t) => t.hasSummary);
|
||||
|
||||
if (allDone) {
|
||||
sliceDone++;
|
||||
taskDone += tasks.length;
|
||||
} else {
|
||||
if (anyDone) sliceActive++;
|
||||
taskDone += tasks.filter((t) => t.hasSummary).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
slices: {
|
||||
total: sliceTotal,
|
||||
done: sliceDone,
|
||||
active: sliceActive,
|
||||
pending: sliceTotal - sliceDone - sliceActive,
|
||||
},
|
||||
tasks: { total: taskTotal, done: taskDone, pending: taskTotal - taskDone },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function readProgress(projectDir: string): ProgressResult {
|
||||
const sf = resolveSFRoot(projectDir);
|
||||
const statePath = resolveRootFile(sf, "STATE.md");
|
||||
|
||||
// Defaults
|
||||
const result: ProgressResult = {
|
||||
activeMilestone: null,
|
||||
activeSlice: null,
|
||||
activeTask: null,
|
||||
phase: "unknown",
|
||||
milestones: { total: 0, done: 0, active: 0, pending: 0, parked: 0 },
|
||||
slices: { total: 0, done: 0, active: 0, pending: 0 },
|
||||
tasks: { total: 0, done: 0, pending: 0 },
|
||||
requirements: null,
|
||||
blockers: [],
|
||||
nextAction: "",
|
||||
};
|
||||
|
||||
if (!existsSync(statePath)) {
|
||||
// No STATE.md — derive from filesystem only
|
||||
const milestoneIds = findMilestoneIds(sf);
|
||||
result.milestones.total = milestoneIds.length;
|
||||
result.milestones.pending = milestoneIds.length;
|
||||
const counts = countSlicesAndTasks(sf, milestoneIds);
|
||||
result.slices = counts.slices;
|
||||
result.tasks = counts.tasks;
|
||||
return result;
|
||||
}
|
||||
|
||||
const content = readFileSync(statePath, "utf-8");
|
||||
|
||||
// Parse STATE.md fields
|
||||
result.activeMilestone = parseActiveRef(
|
||||
parseBoldField(content, "Active Milestone"),
|
||||
);
|
||||
result.activeSlice = parseActiveRef(parseBoldField(content, "Active Slice"));
|
||||
result.activeTask = parseActiveRef(parseBoldField(content, "Active Task"));
|
||||
result.phase = parsePhase(parseBoldField(content, "Phase"));
|
||||
result.requirements = parseRequirementsLine(
|
||||
parseBoldField(content, "Requirements Status"),
|
||||
);
|
||||
result.blockers = parseBlockers(content);
|
||||
result.nextAction = parseNextAction(content);
|
||||
|
||||
// Milestone counts from registry
|
||||
const registry = parseMilestoneRegistry(content);
|
||||
if (registry.length > 0) {
|
||||
result.milestones.total = registry.length;
|
||||
result.milestones.done = registry.filter((e) => e.status === "done").length;
|
||||
result.milestones.active = registry.filter(
|
||||
(e) => e.status === "active",
|
||||
).length;
|
||||
result.milestones.parked = registry.filter(
|
||||
(e) => e.status === "parked",
|
||||
).length;
|
||||
result.milestones.pending =
|
||||
registry.length -
|
||||
result.milestones.done -
|
||||
result.milestones.active -
|
||||
result.milestones.parked;
|
||||
} else {
|
||||
// Fallback: count directories
|
||||
const milestoneIds = findMilestoneIds(sf);
|
||||
result.milestones.total = milestoneIds.length;
|
||||
result.milestones.pending = milestoneIds.length;
|
||||
}
|
||||
|
||||
// Slice/task counts from filesystem
|
||||
const milestoneIds = findMilestoneIds(sf);
|
||||
const counts = countSlicesAndTasks(sf, milestoneIds);
|
||||
result.slices = counts.slices;
|
||||
result.tasks = counts.tasks;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
// @singularity-forge/mcp-server — Tests for secure_env_collect MCP tool
|
||||
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
||||
//
|
||||
// Tests the secure_env_collect tool registered in createMcpServer.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { createMcpServer } from "./server.js";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Since createMcpServer uses dynamic import for McpServer, we can't easily
|
||||
* mock it. Instead, we test the env-writer utilities directly (in env-writer.test.ts)
|
||||
* and test the tool integration by verifying:
|
||||
* 1. The tool exists in the registered tools list
|
||||
* 2. The handler produces correct results with mock data
|
||||
*
|
||||
* For handler-level testing, we create a standalone test that replicates
|
||||
* the tool handler logic with a controllable mock.
|
||||
*/
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
return mkdtempSync(join(tmpdir(), `${prefix}-`));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration test — verify tool is registered
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("secure_env_collect tool registration", () => {
|
||||
it("createMcpServer registers secure_env_collect tool", async () => {
|
||||
// This test verifies the tool exists — createMcpServer internally calls
|
||||
// server.tool('secure_env_collect', ...) which we can't intercept without
|
||||
// module mocking, but we can verify the server creates successfully
|
||||
const sm = new SessionManager();
|
||||
try {
|
||||
const { server } = await createMcpServer(sm);
|
||||
assert.ok(server, "server should be created");
|
||||
// The McpServer internally tracks registered tools — we verify no error
|
||||
} finally {
|
||||
await sm.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler logic tests — using env-writer directly to test the flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("secure_env_collect handler logic", () => {
|
||||
it("skips keys that already exist in .env", async () => {
|
||||
const tmp = makeTempDir("sec-collect");
|
||||
try {
|
||||
const envPath = join(tmp, ".env");
|
||||
writeFileSync(envPath, "ALREADY_SET=existing-value\n");
|
||||
|
||||
// Import the utility directly to test the pre-check logic
|
||||
const { checkExistingEnvKeys } = await import("./env-writer.js");
|
||||
const existing = await checkExistingEnvKeys(
|
||||
["ALREADY_SET", "NEW_KEY"],
|
||||
envPath,
|
||||
);
|
||||
assert.deepStrictEqual(existing, ["ALREADY_SET"]);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("writes collected values to .env without returning secret values", async () => {
|
||||
const tmp = makeTempDir("sec-collect");
|
||||
try {
|
||||
const envPath = join(tmp, ".env");
|
||||
const savedKey = process.env.SEC_COLLECT_TEST_KEY;
|
||||
|
||||
const { applySecrets } = await import("./env-writer.js");
|
||||
const { applied, errors } = await applySecrets(
|
||||
[{ key: "SEC_COLLECT_TEST_KEY", value: "super-secret-value" }],
|
||||
"dotenv",
|
||||
{ envFilePath: envPath },
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(applied, ["SEC_COLLECT_TEST_KEY"]);
|
||||
assert.deepStrictEqual(errors, []);
|
||||
|
||||
// Verify the value was written
|
||||
const content = readFileSync(envPath, "utf8");
|
||||
assert.ok(content.includes("SEC_COLLECT_TEST_KEY=super-secret-value"));
|
||||
|
||||
// Verify process.env was hydrated
|
||||
assert.equal(process.env.SEC_COLLECT_TEST_KEY, "super-secret-value");
|
||||
|
||||
// Cleanup
|
||||
if (savedKey === undefined) delete process.env.SEC_COLLECT_TEST_KEY;
|
||||
else process.env.SEC_COLLECT_TEST_KEY = savedKey;
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-detects vercel destination from vercel.json", async () => {
|
||||
const tmp = makeTempDir("sec-collect");
|
||||
try {
|
||||
writeFileSync(join(tmp, "vercel.json"), "{}");
|
||||
const { detectDestination } = await import("./env-writer.js");
|
||||
assert.equal(detectDestination(tmp), "vercel");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("handles empty form values as skipped", async () => {
|
||||
// Simulate what happens when user leaves a field empty in the form
|
||||
const formContent: Record<string, string> = {
|
||||
API_KEY: "provided-value",
|
||||
OPTIONAL_KEY: "", // empty = skip
|
||||
};
|
||||
|
||||
const provided: Array<{ key: string; value: string }> = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const [key, raw] of Object.entries(formContent)) {
|
||||
const value = typeof raw === "string" ? raw.trim() : "";
|
||||
if (value.length > 0) {
|
||||
provided.push({ key, value });
|
||||
} else {
|
||||
skipped.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(provided, [
|
||||
{ key: "API_KEY", value: "provided-value" },
|
||||
]);
|
||||
assert.deepStrictEqual(skipped, ["OPTIONAL_KEY"]);
|
||||
});
|
||||
|
||||
it("result text never contains secret values", async () => {
|
||||
const tmp = makeTempDir("sec-collect");
|
||||
try {
|
||||
const envPath = join(tmp, ".env");
|
||||
const savedKey = process.env.RESULT_TEXT_TEST;
|
||||
|
||||
const { applySecrets } = await import("./env-writer.js");
|
||||
const { applied } = await applySecrets(
|
||||
[{ key: "RESULT_TEXT_TEST", value: "sk-super-secret-abc123" }],
|
||||
"dotenv",
|
||||
{ envFilePath: envPath },
|
||||
);
|
||||
|
||||
// Simulate building result text (same logic as the tool handler)
|
||||
const lines: string[] = [
|
||||
"destination: dotenv (auto-detected)",
|
||||
...applied.map((k) => `✓ ${k}: applied`),
|
||||
];
|
||||
const resultText = lines.join("\n");
|
||||
|
||||
// The result MUST NOT contain the secret value
|
||||
assert.ok(
|
||||
!resultText.includes("sk-super-secret-abc123"),
|
||||
"result text must not contain secret value",
|
||||
);
|
||||
assert.ok(
|
||||
resultText.includes("RESULT_TEXT_TEST"),
|
||||
"result text should contain key name",
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
if (savedKey === undefined) delete process.env.RESULT_TEXT_TEST;
|
||||
else process.env.RESULT_TEXT_TEST = savedKey;
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("handles multiple keys with mixed existing/new/skipped", async () => {
|
||||
const tmp = makeTempDir("sec-collect");
|
||||
try {
|
||||
const envPath = join(tmp, ".env");
|
||||
writeFileSync(envPath, "EXISTING_A=already-here\n");
|
||||
const savedB = process.env.NEW_B;
|
||||
const savedC = process.env.SKIP_C;
|
||||
|
||||
const { checkExistingEnvKeys, applySecrets } = await import(
|
||||
"./env-writer.js"
|
||||
);
|
||||
|
||||
const allKeys = ["EXISTING_A", "NEW_B", "SKIP_C"];
|
||||
const existing = await checkExistingEnvKeys(allKeys, envPath);
|
||||
assert.deepStrictEqual(existing, ["EXISTING_A"]);
|
||||
|
||||
// Simulate form response: NEW_B has value, SKIP_C is empty
|
||||
const formContent = { NEW_B: "new-value", SKIP_C: "" };
|
||||
const provided: Array<{ key: string; value: string }> = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const key of allKeys.filter((k) => !existing.includes(k))) {
|
||||
const raw = formContent[key as keyof typeof formContent] ?? "";
|
||||
if (raw.trim().length > 0) provided.push({ key, value: raw.trim() });
|
||||
else skipped.push(key);
|
||||
}
|
||||
|
||||
const { applied, errors } = await applySecrets(provided, "dotenv", {
|
||||
envFilePath: envPath,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(applied, ["NEW_B"]);
|
||||
assert.deepStrictEqual(skipped, ["SKIP_C"]);
|
||||
assert.deepStrictEqual(errors, []);
|
||||
assert.deepStrictEqual(existing, ["EXISTING_A"]);
|
||||
|
||||
// Cleanup
|
||||
if (savedB === undefined) delete process.env.NEW_B;
|
||||
else process.env.NEW_B = savedB;
|
||||
if (savedC === undefined) delete process.env.SKIP_C;
|
||||
else process.env.SKIP_C = savedC;
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,389 +0,0 @@
|
|||
/**
|
||||
* SessionManager — manages RpcClient lifecycle for background SF execution.
|
||||
*
|
||||
* One active session per projectDir. Tracks events in a ring buffer,
|
||||
* detects blockers, tracks terminal state, and accumulates cost using
|
||||
* the cumulative-max pattern (K004).
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import type {
|
||||
RpcCostUpdateEvent,
|
||||
RpcExtensionUIRequest,
|
||||
RpcInitResult,
|
||||
SdkAgentEvent,
|
||||
} from "@singularity-forge/rpc-client";
|
||||
import { RpcClient } from "@singularity-forge/rpc-client";
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ManagedSession,
|
||||
PendingBlocker,
|
||||
} from "./types.js";
|
||||
import { INIT_TIMEOUT_MS, MAX_EVENTS } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inlined detection logic (from headless-events.ts — no internal package imports)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FIRE_AND_FORGET_METHODS = new Set([
|
||||
"notify",
|
||||
"setStatus",
|
||||
"setWidget",
|
||||
"setTitle",
|
||||
"set_editor_text",
|
||||
]);
|
||||
|
||||
const TERMINAL_PREFIXES = ["auto-mode stopped", "step-mode stopped"];
|
||||
|
||||
function isTerminalNotification(event: Record<string, unknown>): boolean {
|
||||
if (event.type !== "extension_ui_request" || event.method !== "notify")
|
||||
return false;
|
||||
const message = String(event.message ?? "").toLowerCase();
|
||||
return TERMINAL_PREFIXES.some((prefix) => message.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isBlockedNotification(event: Record<string, unknown>): boolean {
|
||||
if (event.type !== "extension_ui_request" || event.method !== "notify")
|
||||
return false;
|
||||
const message = String(event.message ?? "").toLowerCase();
|
||||
return message.includes("blocked:");
|
||||
}
|
||||
|
||||
function isBlockingUIRequest(event: Record<string, unknown>): boolean {
|
||||
if (event.type !== "extension_ui_request") return false;
|
||||
const method = String(event.method ?? "");
|
||||
return !FIRE_AND_FORGET_METHODS.has(method);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SessionManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class SessionManager {
|
||||
/** Sessions keyed by projectDir for duplicate-start prevention */
|
||||
private sessions = new Map<string, ManagedSession>();
|
||||
|
||||
/**
|
||||
* Start a new SF auto-mode session for the given project directory.
|
||||
*
|
||||
* Rejects if a session already exists for this projectDir.
|
||||
* Creates an RpcClient, starts the process, performs the v2 init handshake,
|
||||
* wires event tracking, and sends '/sf autonomous' to begin execution.
|
||||
*/
|
||||
async startSession(
|
||||
projectDir: string,
|
||||
options: ExecuteOptions = {},
|
||||
): Promise<string> {
|
||||
if (!projectDir || projectDir.trim() === "") {
|
||||
throw new Error("projectDir is required and cannot be empty");
|
||||
}
|
||||
|
||||
const resolvedDir = resolve(projectDir);
|
||||
|
||||
const existing = this.sessions.get(resolvedDir);
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
`Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const cliPath = options.cliPath ?? SessionManager.resolveCLIPath();
|
||||
|
||||
const args: string[] = ["--mode", "rpc"];
|
||||
if (options.model) args.push("--model", options.model);
|
||||
if (options.bare) args.push("--bare");
|
||||
|
||||
const client = new RpcClient({
|
||||
cliPath,
|
||||
cwd: resolvedDir,
|
||||
args,
|
||||
});
|
||||
|
||||
// Build the session shell before async operations so we can track state
|
||||
const session: ManagedSession = {
|
||||
sessionId: "", // filled after init
|
||||
projectDir: resolvedDir,
|
||||
status: "starting",
|
||||
client,
|
||||
events: [],
|
||||
pendingBlocker: null,
|
||||
cost: {
|
||||
totalCost: 0,
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
// Insert into map early (keyed by dir) so concurrent starts are rejected
|
||||
this.sessions.set(resolvedDir, session);
|
||||
|
||||
try {
|
||||
// Start the process with timeout
|
||||
await Promise.race([
|
||||
client.start(),
|
||||
timeout(
|
||||
INIT_TIMEOUT_MS,
|
||||
`RpcClient.start() timed out after ${INIT_TIMEOUT_MS}ms`,
|
||||
),
|
||||
]);
|
||||
|
||||
// Perform v2 init handshake
|
||||
const initResult: RpcInitResult = (await Promise.race([
|
||||
client.init(),
|
||||
timeout(
|
||||
INIT_TIMEOUT_MS,
|
||||
`RpcClient.init() timed out after ${INIT_TIMEOUT_MS}ms`,
|
||||
),
|
||||
])) as RpcInitResult;
|
||||
|
||||
session.sessionId = initResult.sessionId;
|
||||
session.status = "running";
|
||||
|
||||
// Wire event tracking
|
||||
session.unsubscribe = client.onEvent((event: SdkAgentEvent) => {
|
||||
this.handleEvent(session, event);
|
||||
});
|
||||
|
||||
// Kick off autonomous mode
|
||||
const command = options.command ?? "/sf autonomous";
|
||||
await client.prompt(command);
|
||||
|
||||
return session.sessionId;
|
||||
} catch (err) {
|
||||
session.status = "error";
|
||||
session.error = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// Attempt cleanup
|
||||
try {
|
||||
await client.stop();
|
||||
} catch {
|
||||
/* swallow cleanup errors */
|
||||
}
|
||||
|
||||
// Keep session in map so callers can inspect the error
|
||||
throw new Error(
|
||||
`Failed to start session for ${resolvedDir}: ${session.error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a session by sessionId.
|
||||
* Linear scan is fine — we expect <10 concurrent sessions.
|
||||
*/
|
||||
getSession(sessionId: string): ManagedSession | undefined {
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.sessionId === sessionId) return session;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a session by project directory (direct map lookup).
|
||||
*/
|
||||
getSessionByDir(projectDir: string): ManagedSession | undefined {
|
||||
return this.sessions.get(resolve(projectDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending blocker by sending a UI response.
|
||||
*/
|
||||
async resolveBlocker(sessionId: string, response: string): Promise<void> {
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
||||
if (!session.pendingBlocker)
|
||||
throw new Error(`No pending blocker for session ${sessionId}`);
|
||||
|
||||
const blocker = session.pendingBlocker;
|
||||
session.client.sendUIResponse(blocker.id, { value: response });
|
||||
session.pendingBlocker = null;
|
||||
if (session.status === "blocked") {
|
||||
session.status = "running";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running session — abort current operation then stop the process.
|
||||
*/
|
||||
async cancelSession(sessionId: string): Promise<void> {
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
||||
|
||||
try {
|
||||
await session.client.abort();
|
||||
} catch {
|
||||
/* may already be stopped */
|
||||
}
|
||||
|
||||
try {
|
||||
await session.client.stop();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
|
||||
session.status = "cancelled";
|
||||
session.unsubscribe?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a HeadlessJsonResult-shaped object from accumulated session state.
|
||||
*/
|
||||
getResult(sessionId: string): Record<string, unknown> {
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
||||
|
||||
const durationMs = Date.now() - session.startTime;
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
projectDir: session.projectDir,
|
||||
status: session.status,
|
||||
durationMs,
|
||||
cost: session.cost,
|
||||
recentEvents: session.events.slice(-10),
|
||||
pendingBlocker: session.pendingBlocker
|
||||
? {
|
||||
id: session.pendingBlocker.id,
|
||||
method: session.pendingBlocker.method,
|
||||
message: session.pendingBlocker.message,
|
||||
}
|
||||
: null,
|
||||
error: session.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all active sessions and clean up resources.
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
const stopPromises: Promise<void>[] = [];
|
||||
|
||||
for (const session of this.sessions.values()) {
|
||||
session.unsubscribe?.();
|
||||
if (
|
||||
session.status === "running" ||
|
||||
session.status === "starting" ||
|
||||
session.status === "blocked"
|
||||
) {
|
||||
stopPromises.push(
|
||||
session.client.stop().catch(() => {
|
||||
/* swallow */
|
||||
}),
|
||||
);
|
||||
session.status = "cancelled";
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(stopPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the SF CLI path.
|
||||
*
|
||||
* 1. SF_CLI_PATH env var (highest priority)
|
||||
* 2. `which sf` → resolve to the actual dist/cli.js
|
||||
*/
|
||||
static resolveCLIPath(): string {
|
||||
// Check env var first
|
||||
const envPath = process.env["SF_CLI_PATH"];
|
||||
if (envPath) return resolve(envPath);
|
||||
|
||||
// Fallback: locate `sf` via which
|
||||
try {
|
||||
const sfBin = execSync("which sf", { encoding: "utf-8" }).trim();
|
||||
if (sfBin) {
|
||||
// sf bin is typically a symlink to dist/loader.js — return the resolved path
|
||||
return resolve(sfBin);
|
||||
}
|
||||
} catch {
|
||||
// which failed
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Cannot find SF CLI. Set SF_CLI_PATH environment variable or ensure `sf` is in PATH.",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private: Event Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private handleEvent(session: ManagedSession, event: SdkAgentEvent): void {
|
||||
// Ring buffer: push and trim
|
||||
session.events.push(event);
|
||||
if (session.events.length > MAX_EVENTS) {
|
||||
session.events.splice(0, session.events.length - MAX_EVENTS);
|
||||
}
|
||||
|
||||
// Cost tracking (K004 — cumulative-max)
|
||||
if (event.type === "cost_update") {
|
||||
const costEvent = event as unknown as RpcCostUpdateEvent;
|
||||
session.cost.totalCost = Math.max(
|
||||
session.cost.totalCost,
|
||||
costEvent.cumulativeCost ?? 0,
|
||||
);
|
||||
if (costEvent.tokens) {
|
||||
session.cost.tokens.input = Math.max(
|
||||
session.cost.tokens.input,
|
||||
costEvent.tokens.input ?? 0,
|
||||
);
|
||||
session.cost.tokens.output = Math.max(
|
||||
session.cost.tokens.output,
|
||||
costEvent.tokens.output ?? 0,
|
||||
);
|
||||
session.cost.tokens.cacheRead = Math.max(
|
||||
session.cost.tokens.cacheRead,
|
||||
costEvent.tokens.cacheRead ?? 0,
|
||||
);
|
||||
session.cost.tokens.cacheWrite = Math.max(
|
||||
session.cost.tokens.cacheWrite,
|
||||
costEvent.tokens.cacheWrite ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal detection — auto-mode/step-mode stopped
|
||||
if (isTerminalNotification(event as Record<string, unknown>)) {
|
||||
// Check if it's a blocked stop (not truly terminal — it's a blocker)
|
||||
if (isBlockedNotification(event as Record<string, unknown>)) {
|
||||
session.status = "blocked";
|
||||
session.pendingBlocker = extractBlocker(event);
|
||||
} else {
|
||||
session.status = "completed";
|
||||
session.unsubscribe?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Blocker detection — non-fire-and-forget extension_ui_request
|
||||
if (isBlockingUIRequest(event as Record<string, unknown>)) {
|
||||
session.status = "blocked";
|
||||
session.pendingBlocker = extractBlocker(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function timeout(ms: number, message: string): Promise<never> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(message)), ms);
|
||||
});
|
||||
}
|
||||
|
||||
function extractBlocker(event: SdkAgentEvent): PendingBlocker {
|
||||
const uiEvent = event as unknown as RpcExtensionUIRequest;
|
||||
return {
|
||||
id: String(uiEvent.id ?? ""),
|
||||
method: String(uiEvent.method ?? ""),
|
||||
message: String(
|
||||
(uiEvent as Record<string, unknown>).title ??
|
||||
(uiEvent as Record<string, unknown>).message ??
|
||||
"",
|
||||
),
|
||||
event: uiEvent,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import {
|
||||
loadStoredCredentialEnvKeys,
|
||||
resolveAuthPath,
|
||||
} from "./tool-credentials.js";
|
||||
|
||||
describe("tool credentials", () => {
|
||||
it("hydrates supported model and tool keys from auth.json", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "sf-mcp-auth-"));
|
||||
const authPath = join(tempRoot, "auth.json");
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
anthropic: { type: "api_key", key: "sk-ant-secret" },
|
||||
openai: { type: "api_key", key: "sk-openai-secret" },
|
||||
xiaomi: { type: "api_key", key: "xiaomi-secret" },
|
||||
tavily: { type: "api_key", key: "tvly-secret" },
|
||||
serper: { type: "api_key", key: "serper-secret" },
|
||||
exa: { type: "api_key", key: "exa-secret" },
|
||||
context7: [{ type: "api_key", key: "ctx7-secret" }],
|
||||
}),
|
||||
);
|
||||
|
||||
const loaded = loadStoredCredentialEnvKeys({ authPath, env });
|
||||
assert.deepEqual(loaded.sort(), [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CONTEXT7_API_KEY",
|
||||
"EXA_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"SERPER_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
"XIAOMI_API_KEY",
|
||||
]);
|
||||
assert.equal(env.ANTHROPIC_API_KEY, "sk-ant-secret");
|
||||
assert.equal(env.OPENAI_API_KEY, "sk-openai-secret");
|
||||
assert.equal(env.TAVILY_API_KEY, "tvly-secret");
|
||||
assert.equal(env.SERPER_API_KEY, "serper-secret");
|
||||
assert.equal(env.EXA_API_KEY, "exa-secret");
|
||||
assert.equal(env.CONTEXT7_API_KEY, "ctx7-secret");
|
||||
assert.equal(env.XIAOMI_API_KEY, "xiaomi-secret");
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not overwrite explicit environment variables", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "sf-mcp-auth-"));
|
||||
const authPath = join(tempRoot, "auth.json");
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
BRAVE_API_KEY: "already-set",
|
||||
};
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
brave: { type: "api_key", key: "from-auth-json" },
|
||||
anthropic: { type: "api_key", key: "sk-ant-from-auth-json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const loaded = loadStoredCredentialEnvKeys({ authPath, env });
|
||||
assert.deepEqual(loaded, ["ANTHROPIC_API_KEY"]);
|
||||
assert.equal(env.BRAVE_API_KEY, "already-set");
|
||||
assert.equal(env.ANTHROPIC_API_KEY, "sk-ant-from-auth-json");
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores oauth credentials because they are resolved through auth storage, not env hydration", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "sf-mcp-auth-"));
|
||||
const authPath = join(tempRoot, "auth.json");
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
openai: { type: "oauth", access: "oauth-access-token" },
|
||||
"google-gemini-cli": { type: "oauth", token: "ya29.oauth-token" },
|
||||
}),
|
||||
);
|
||||
|
||||
const loaded = loadStoredCredentialEnvKeys({ authPath, env });
|
||||
assert.deepEqual(loaded, []);
|
||||
assert.equal(env.OPENAI_API_KEY, undefined);
|
||||
assert.equal(env.GEMINI_API_KEY, undefined);
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves auth.json from SF_CODING_AGENT_DIR", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "sf-mcp-agent-dir-"));
|
||||
const agentDir = join(tempRoot, "agent");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
try {
|
||||
assert.equal(
|
||||
resolveAuthPath({ SF_CODING_AGENT_DIR: agentDir }),
|
||||
join(agentDir, "auth.json"),
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
type AuthCredential =
|
||||
| { type?: unknown; key?: unknown }
|
||||
| Array<{ type?: unknown; key?: unknown }>;
|
||||
|
||||
type AuthStorageData = Record<string, AuthCredential>;
|
||||
|
||||
const AUTH_ENV_KEYS = [
|
||||
["anthropic", "ANTHROPIC_API_KEY"],
|
||||
["openai", "OPENAI_API_KEY"],
|
||||
["github-copilot", "GITHUB_TOKEN"],
|
||||
["google", "GEMINI_API_KEY"],
|
||||
["groq", "GROQ_API_KEY"],
|
||||
["xai", "XAI_API_KEY"],
|
||||
["openrouter", "OPENROUTER_API_KEY"],
|
||||
["mistral", "MISTRAL_API_KEY"],
|
||||
["xiaomi", "XIAOMI_API_KEY"],
|
||||
["xiaomi-token-plan-ams", "XIAOMI_API_KEY"],
|
||||
["xiaomi-token-plan-sgp", "XIAOMI_API_KEY"],
|
||||
["xiaomi-token-plan-cn", "XIAOMI_API_KEY"],
|
||||
["ollama-cloud", "OLLAMA_API_KEY"],
|
||||
["custom-openai", "CUSTOM_OPENAI_API_KEY"],
|
||||
["cerebras", "CEREBRAS_API_KEY"],
|
||||
["azure-openai-responses", "AZURE_OPENAI_API_KEY"],
|
||||
["vercel-ai-gateway", "AI_GATEWAY_API_KEY"],
|
||||
["zai", "ZAI_API_KEY"],
|
||||
["minimax", "MINIMAX_API_KEY"],
|
||||
["minimax-cn", "MINIMAX_CN_API_KEY"],
|
||||
["huggingface", "HF_TOKEN"],
|
||||
["opencode", "OPENCODE_API_KEY"],
|
||||
["opencode-go", "OPENCODE_API_KEY"],
|
||||
["kimi-coding", "KIMI_API_KEY"],
|
||||
["alibaba-coding-plan", "ALIBABA_API_KEY"],
|
||||
["brave", "BRAVE_API_KEY"],
|
||||
["brave_answers", "BRAVE_ANSWERS_KEY"],
|
||||
["serper", "SERPER_API_KEY"],
|
||||
["exa", "EXA_API_KEY"],
|
||||
["context7", "CONTEXT7_API_KEY"],
|
||||
["jina", "JINA_API_KEY"],
|
||||
["tavily", "TAVILY_API_KEY"],
|
||||
["slack_bot", "SLACK_BOT_TOKEN"],
|
||||
["discord_bot", "DISCORD_BOT_TOKEN"],
|
||||
["telegram_bot", "TELEGRAM_BOT_TOKEN"],
|
||||
] as const;
|
||||
|
||||
function expandHome(pathValue: string): string {
|
||||
if (pathValue === "~") return homedir();
|
||||
if (pathValue.startsWith("~/")) return join(homedir(), pathValue.slice(2));
|
||||
return pathValue;
|
||||
}
|
||||
|
||||
function getStoredApiKey(
|
||||
data: AuthStorageData,
|
||||
providerId: string,
|
||||
): string | undefined {
|
||||
const raw = data[providerId];
|
||||
const credentials = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
|
||||
for (const credential of credentials) {
|
||||
if (credential?.type !== "api_key") continue;
|
||||
if (typeof credential.key !== "string") continue;
|
||||
if (credential.key.trim().length === 0) continue;
|
||||
return credential.key;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveAuthPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const agentDir = env.SF_CODING_AGENT_DIR?.trim();
|
||||
if (agentDir) return join(expandHome(agentDir), "auth.json");
|
||||
return join(homedir(), ".sf", "agent", "auth.json");
|
||||
}
|
||||
|
||||
export function loadStoredCredentialEnvKeys(
|
||||
options: { env?: NodeJS.ProcessEnv; authPath?: string } = {},
|
||||
): string[] {
|
||||
const env = options.env ?? process.env;
|
||||
const authPath = options.authPath ?? resolveAuthPath(env);
|
||||
if (!existsSync(authPath)) return [];
|
||||
|
||||
let parsed: AuthStorageData;
|
||||
try {
|
||||
const raw = readFileSync(authPath, "utf-8");
|
||||
const data = JSON.parse(raw) as unknown;
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
|
||||
parsed = data as AuthStorageData;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const loaded: string[] = [];
|
||||
for (const [providerId, envVar] of AUTH_ENV_KEYS) {
|
||||
if (env[envVar]) continue;
|
||||
const key = getStoredApiKey(parsed, providerId);
|
||||
if (!key) continue;
|
||||
env[envVar] = key;
|
||||
loaded.push(envVar);
|
||||
}
|
||||
|
||||
return loaded;
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
/**
|
||||
* MCP Server types — session lifecycle and orchestration.
|
||||
*/
|
||||
|
||||
import type {
|
||||
RpcClient,
|
||||
RpcExtensionUIRequest,
|
||||
SdkAgentEvent,
|
||||
} from "@singularity-forge/rpc-client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session Status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SessionStatus =
|
||||
| "starting"
|
||||
| "running"
|
||||
| "blocked"
|
||||
| "completed"
|
||||
| "error"
|
||||
| "cancelled";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Managed Session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ManagedSession {
|
||||
/** Unique session ID returned from RpcClient.init() */
|
||||
sessionId: string;
|
||||
|
||||
/** Absolute path to the project directory */
|
||||
projectDir: string;
|
||||
|
||||
/** Current lifecycle status */
|
||||
status: SessionStatus;
|
||||
|
||||
/** The RpcClient instance managing the agent process */
|
||||
client: RpcClient;
|
||||
|
||||
/** Ring buffer of recent events (capped at MAX_EVENTS) */
|
||||
events: SdkAgentEvent[];
|
||||
|
||||
/** Pending blocker requiring user response, if any */
|
||||
pendingBlocker: PendingBlocker | null;
|
||||
|
||||
/** Cumulative cost tracking (max pattern per K004) */
|
||||
cost: CostAccumulator;
|
||||
|
||||
/** Session start timestamp */
|
||||
startTime: number;
|
||||
|
||||
/** Error message if status is 'error' */
|
||||
error?: string;
|
||||
|
||||
/** Cleanup function to unsubscribe from events */
|
||||
unsubscribe?: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pending Blocker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PendingBlocker {
|
||||
/** The extension_ui_request id */
|
||||
id: string;
|
||||
|
||||
/** The request method (e.g. 'select', 'confirm', 'input') */
|
||||
method: string;
|
||||
|
||||
/** Human-readable message or title */
|
||||
message: string;
|
||||
|
||||
/** Full event payload for inspection */
|
||||
event: RpcExtensionUIRequest;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cost Accumulator (K004 — cumulative-max)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CostAccumulator {
|
||||
totalCost: number;
|
||||
tokens: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute Options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExecuteOptions {
|
||||
/** Command to send instead of the default '/sf autonomous' (default: none) */
|
||||
command?: string;
|
||||
|
||||
/** Model ID override */
|
||||
model?: string;
|
||||
|
||||
/** Run in bare mode (skip user config) */
|
||||
bare?: boolean;
|
||||
|
||||
/** Path to CLI binary (overrides SF_CLI_PATH and which resolution) */
|
||||
cliPath?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum number of events kept in the ring buffer */
|
||||
export const MAX_EVENTS = 50;
|
||||
|
||||
/** Timeout for RpcClient initialization (ms) */
|
||||
export const INIT_TIMEOUT_MS = 30_000;
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "Node16",
|
||||
"lib": ["ES2024"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"incremental": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"inlineSources": true,
|
||||
"inlineSourceMap": false,
|
||||
"moduleResolution": "Node16",
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"types": ["node"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.d.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -206,7 +206,7 @@ test("chat-controller renders serverToolUse before trailing text matching conten
|
|||
const serverToolUse = {
|
||||
type: "serverToolUse",
|
||||
id: toolId,
|
||||
name: "mcp__sf-workflow__secure_env_collect",
|
||||
name: "mcp__external-tools__secure_env_collect",
|
||||
input: {
|
||||
projectDir: "/tmp/project",
|
||||
keys: [{ key: "SECURE_PASSWORD" }],
|
||||
|
|
@ -399,7 +399,7 @@ test("chat-controller keeps pre-tool prose visible until post-tool prose arrives
|
|||
} as any);
|
||||
});
|
||||
|
||||
test("chat-controller keeps pre-tool thinking visible for claude-code MCP turns without post-tool prose", async () => {
|
||||
test("chat-controller keeps pre-tool thinking visible for adapter MCP turns without post-tool prose", async () => {
|
||||
(globalThis as any)[Symbol.for("@singularity-forge/pi-coding-agent:theme")] =
|
||||
{
|
||||
fg: (_key: string, text: string) => text,
|
||||
|
|
@ -482,7 +482,7 @@ test("chat-controller keeps pre-tool thinking visible for claude-code MCP turns
|
|||
} as any);
|
||||
});
|
||||
|
||||
test("chat-controller prunes orphaned provisional text after claude-code sub-turn shrink when MCP tools appear", async () => {
|
||||
test("chat-controller prunes orphaned provisional text after adapter sub-turn shrink when MCP tools appear", async () => {
|
||||
(globalThis as any)[Symbol.for("@singularity-forge/pi-coding-agent:theme")] =
|
||||
{
|
||||
fg: (_key: string, text: string) => text,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ function str(value: unknown): string | null {
|
|||
}
|
||||
|
||||
/**
|
||||
* Split a Claude Code MCP tool name (`mcp__<server>__<tool>`) into its parts.
|
||||
* Split an adapter-surfaced MCP tool name (`mcp__<server>__<tool>`) into its parts.
|
||||
* Returns null for non-prefixed names. Duplicated from the claude-code-cli
|
||||
* extension (parseMcpToolName) so this package doesn't have to import across
|
||||
* the resources/extensions boundary.
|
||||
|
|
@ -1319,7 +1319,7 @@ export class ToolExecutionComponent extends Container {
|
|||
}
|
||||
} else {
|
||||
// Generic tool / MCP tool without a registered renderer.
|
||||
// MCP tool names from Claude Code arrive as `mcp__<server>__<tool>`;
|
||||
// Adapter-surfaced MCP tool names arrive as `mcp__<server>__<tool>`;
|
||||
// render the server prefix in muted style so the tool name reads
|
||||
// cleanly. SF-registered MCP tools have already had their prefix
|
||||
// stripped upstream in partial-builder.ts and won't reach this branch.
|
||||
|
|
|
|||
|
|
@ -447,7 +447,7 @@ export async function handleAgentEvent(
|
|||
b.type === "text" || b.type === "thinking" ? b.type : undefined;
|
||||
const isTextLike = blockType === "text" || blockType === "thinking";
|
||||
const isTool = b.type === "toolCall" || b.type === "serverToolUse";
|
||||
// For Claude Code MCP turns, prune only pre-tool prose, never thinking.
|
||||
// For adapter-surfaced MCP tool turns, prune only pre-tool prose, never thinking.
|
||||
const shouldSkipProse =
|
||||
shouldDropPreToolProse &&
|
||||
firstToolIdx >= 0 &&
|
||||
|
|
@ -479,7 +479,7 @@ export async function handleAgentEvent(
|
|||
}
|
||||
closeRun();
|
||||
|
||||
// Claude Code MCP can emit provisional pre-tool prose that gets
|
||||
// Adapter-surfaced MCP tool turns can emit provisional pre-tool prose that gets
|
||||
// superseded by post-tool output. Prune stale text-run segments so
|
||||
// the final assistant output remains below tool output.
|
||||
if (shouldDropPreToolProse && firstToolIdx >= 0) {
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ export function setupEditorSubmitHandler(
|
|||
* Drag-and-drop inserts paths like "/Users/name/Desktop/file.png" which
|
||||
* should be treated as plain text input, not a /Users command.
|
||||
*
|
||||
* Heuristic: a slash command is a single token like "/help" or "/sf auto".
|
||||
* Heuristic: a slash command is a single token like "/help" or "/sf autonomous".
|
||||
* File paths have a second "/" within the first token (e.g., "/Users/...").
|
||||
*/
|
||||
function looksLikeFilePath(text: string): boolean {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ console.log(`[bump-version] package.json: ${oldVersion} → ${newVersion}`);
|
|||
// published and have their own lifecycle.
|
||||
const workspacePackages = [
|
||||
"daemon",
|
||||
"mcp-server",
|
||||
"native",
|
||||
"pi-agent-core",
|
||||
"pi-ai",
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ async function main() {
|
|||
|
||||
// Copy root dist/ into dist-test/dist/ — some tests compute projectRoot as
|
||||
// 3 levels up from dist-test/src/tests/ which lands at dist-test/, then
|
||||
// import from dist/mcp-server.js etc.
|
||||
// import from dist package entrypoints etc.
|
||||
const rootDistDir = join(ROOT, "dist");
|
||||
const distTestDistDir = join(ROOT, "dist-test", "dist");
|
||||
await copyAssets(rootDistDir, distTestDistDir);
|
||||
|
|
|
|||
|
|
@ -99,7 +99,6 @@ if (require.main === module) {
|
|||
"pi-coding-agent",
|
||||
"rpc-client",
|
||||
"daemon",
|
||||
"mcp-server",
|
||||
];
|
||||
|
||||
const stale = detectStalePackages(root, WORKSPACE_PACKAGES);
|
||||
|
|
|
|||
|
|
@ -6,13 +6,6 @@ const __dirname = import.meta.dirname;
|
|||
const repoRoot = resolve(__dirname, "..");
|
||||
|
||||
const featuresPath = join(repoRoot, "FEATURES.md");
|
||||
const workflowToolsPath = join(
|
||||
repoRoot,
|
||||
"packages",
|
||||
"mcp-server",
|
||||
"src",
|
||||
"workflow-tools.ts",
|
||||
);
|
||||
const providersPath = join(repoRoot, "packages", "pi-ai", "src", "types.ts");
|
||||
const extensionsRoot = join(repoRoot, "src", "resources", "extensions");
|
||||
const searchProviderPath = resolveExistingPath(
|
||||
|
|
@ -51,14 +44,6 @@ function resolveExistingPath(...paths) {
|
|||
return found;
|
||||
}
|
||||
|
||||
export function parseWorkflowToolNames() {
|
||||
const src = readFileSync(workflowToolsPath, "utf8");
|
||||
const matches = [...src.matchAll(/server\.tool\(\s*"([^"]+)"/g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
return uniqueSorted(matches);
|
||||
}
|
||||
|
||||
export function parseKnownProviders() {
|
||||
const src = readFileSync(providersPath, "utf8");
|
||||
const match = src.match(/export type KnownProvider =([\s\S]*?);/);
|
||||
|
|
@ -114,18 +99,11 @@ function formatBullets(values, formatter = (value) => `- \`${value}\``) {
|
|||
}
|
||||
|
||||
export function buildSection() {
|
||||
const workflowTools = parseWorkflowToolNames();
|
||||
const extensions = parseBundledExtensions();
|
||||
const searchProviders = parseSearchProviders();
|
||||
const knownProviders = parseKnownProviders();
|
||||
|
||||
return [
|
||||
"### Workflow Tools",
|
||||
"",
|
||||
"Generated from `packages/mcp-server/src/workflow-tools.ts`.",
|
||||
"",
|
||||
formatBullets(workflowTools),
|
||||
"",
|
||||
"### Bundled Extensions",
|
||||
"",
|
||||
"Generated from `src/resources/extensions/*/extension-manifest.json`.",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ const packageDirs = [
|
|||
"pi-tui",
|
||||
"rpc-client",
|
||||
"daemon",
|
||||
"mcp-server",
|
||||
];
|
||||
|
||||
if (!existsSync(scopeDir)) {
|
||||
|
|
|
|||
|
|
@ -122,7 +122,6 @@ try {
|
|||
"packages/pi-coding-agent/dist/index.js",
|
||||
"packages/rpc-client/dist/index.js",
|
||||
"packages/daemon/dist/cli.js",
|
||||
"packages/mcp-server/dist/cli.js",
|
||||
"scripts/link-workspace-packages.cjs",
|
||||
"dist/web/standalone/server.js",
|
||||
];
|
||||
|
|
@ -225,23 +224,11 @@ try {
|
|||
"dist",
|
||||
"cli.js",
|
||||
);
|
||||
const bundledWorkflowMcpCliPath = join(
|
||||
installedRoot,
|
||||
"packages",
|
||||
"mcp-server",
|
||||
"dist",
|
||||
"cli.js",
|
||||
);
|
||||
if (!existsSync(daemonCliPath)) {
|
||||
console.log("ERROR: Bundled daemon CLI missing after install.");
|
||||
console.log(` Expected: ${daemonCliPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!existsSync(bundledWorkflowMcpCliPath)) {
|
||||
console.log("ERROR: Bundled workflow MCP CLI missing after install.");
|
||||
console.log(` Expected: ${bundledWorkflowMcpCliPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const versionOutput = execFileSync(process.execPath, [loaderPath, "-v"], {
|
||||
cwd: installDir,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
} from "./web-mode.js";
|
||||
|
||||
export interface CliFlags {
|
||||
mode?: "text" | "json" | "rpc" | "mcp";
|
||||
mode?: "text" | "json" | "rpc";
|
||||
print?: boolean;
|
||||
continue?: boolean;
|
||||
noSession?: boolean;
|
||||
|
|
@ -71,12 +71,7 @@ export function parseCliArgs(argv: string[]): CliFlags {
|
|||
const arg = args[i];
|
||||
if (arg === "--mode" && i + 1 < args.length) {
|
||||
const mode = args[++i];
|
||||
if (
|
||||
mode === "text" ||
|
||||
mode === "json" ||
|
||||
mode === "rpc" ||
|
||||
mode === "mcp"
|
||||
)
|
||||
if (mode === "text" || mode === "json" || mode === "rpc")
|
||||
flags.mode = mode;
|
||||
} else if (arg === "--print" || arg === "-p") {
|
||||
flags.print = true;
|
||||
|
|
|
|||
65
src/cli.ts
65
src/cli.ts
|
|
@ -89,7 +89,7 @@ function printNonTtyErrorAndExit(
|
|||
);
|
||||
process.stderr.write("[sf] Non-interactive alternatives:\n");
|
||||
process.stderr.write(
|
||||
"[sf] sf auto Auto-mode (pipeable, no TUI)\n",
|
||||
"[sf] sf autonomous Autonomous mode (pipeable, no TUI)\n",
|
||||
);
|
||||
process.stderr.write(
|
||||
'[sf] sf --print "your message" Single-shot prompt\n',
|
||||
|
|
@ -102,15 +102,12 @@ function printNonTtyErrorAndExit(
|
|||
process.stderr.write(
|
||||
"[sf] sf --mode rpc JSON-RPC over stdin/stdout\n",
|
||||
);
|
||||
process.stderr.write(
|
||||
"[sf] sf --mode mcp MCP server over stdin/stdout\n",
|
||||
);
|
||||
process.stderr.write(
|
||||
'[sf] sf --mode text "message" Text output mode\n',
|
||||
);
|
||||
if (includeWebHint) {
|
||||
process.stderr.write(
|
||||
"[sf] sf headless Auto-mode without TUI\n",
|
||||
"[sf] sf headless Autonomous mode without TUI\n",
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
|
|
@ -552,7 +549,7 @@ if (cliFlags.messages[0] === "sessions") {
|
|||
cliFlags._selectedSessionPath = selected.path;
|
||||
}
|
||||
|
||||
// `sf headless` — run auto-mode without TUI
|
||||
// `sf headless` — run autonomous mode without TUI
|
||||
if (cliFlags.messages[0] === "headless") {
|
||||
await ensureRtkBootstrap();
|
||||
// Sync bundled resources before headless runs (#3471). Without this,
|
||||
|
|
@ -566,10 +563,12 @@ if (cliFlags.messages[0] === "headless") {
|
|||
|
||||
/**
|
||||
* Run a headless command by invoking the headless entrypoint with a synthetic
|
||||
* argv. Shared by the `auto` shorthand (#2732) and the auto-piped-stdout
|
||||
* argv. Shared by the `autonomous` shorthand (#2732) and the piped-stdout
|
||||
* redirect so they use the same bootstrap + dynamic-import dance.
|
||||
*/
|
||||
async function runHeadlessFromAuto(headlessArgs: string[]): Promise<never> {
|
||||
async function runHeadlessFromAutonomous(
|
||||
headlessArgs: string[],
|
||||
): Promise<never> {
|
||||
await ensureRtkBootstrap();
|
||||
const { runHeadless, parseHeadlessArgs } = await import("./headless.js");
|
||||
const argv = [process.argv[0], process.argv[1], "headless", ...headlessArgs];
|
||||
|
|
@ -577,15 +576,14 @@ async function runHeadlessFromAuto(headlessArgs: string[]): Promise<never> {
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
// `sf autonomous [args...]` / `sf auto [args...]` — shorthand for headless
|
||||
// autonomous mode (#2732). Without this, the command falls through to the TUI
|
||||
// when stdin/stdout are piped (non-TTY environments).
|
||||
// `sf autonomous [args...]` — shorthand for headless autonomous mode (#2732).
|
||||
// The legacy `sf auto` spelling is still accepted for compatibility, but all
|
||||
// generated prompts use `/sf autonomous`.
|
||||
if (cliFlags.messages[0] === "auto" || cliFlags.messages[0] === "autonomous") {
|
||||
const headlessArgs =
|
||||
cliFlags.messages[0] === "autonomous"
|
||||
? ["auto", ...cliFlags.messages.slice(1)]
|
||||
: cliFlags.messages;
|
||||
await runHeadlessFromAuto(headlessArgs);
|
||||
await runHeadlessFromAutonomous([
|
||||
"autonomous",
|
||||
...cliFlags.messages.slice(1),
|
||||
]);
|
||||
}
|
||||
|
||||
// Pi's tool bootstrap can mis-detect already-installed fd/rg on some systems
|
||||
|
|
@ -792,28 +790,6 @@ if (isPrintMode) {
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
if (mode === "mcp") {
|
||||
printStartupTimings();
|
||||
const { startMcpServer } = await import("./mcp-server.js");
|
||||
|
||||
// Activate every registered tool before starting the MCP transport.
|
||||
// `session.agent.state.tools` is the *active* subset, not the full
|
||||
// registry — if we expose only the active set, extension-registered
|
||||
// tools (sf workflow, browser-tools, mac-tools, search-the-web, …)
|
||||
// are invisible to MCP clients. Flipping the active set to every
|
||||
// known tool name makes `state.tools` mirror the full registry for
|
||||
// this MCP session, which is what an external client expects.
|
||||
const allToolNames = session.getAllTools().map((t) => t.name);
|
||||
session.setActiveToolsByName(allToolNames);
|
||||
|
||||
await startMcpServer({
|
||||
tools: session.agent.state.tools ?? [],
|
||||
version: process.env.SF_VERSION || "0.0.0",
|
||||
});
|
||||
// MCP server runs until the transport closes; keep alive
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
printStartupTimings();
|
||||
await runPrintMode(session, {
|
||||
mode: mode as "text" | "json",
|
||||
|
|
@ -884,8 +860,8 @@ if (!cliFlags.worktree && !isPrintMode) {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-redirect: autonomous mode with piped stdout → headless mode (#2732)
|
||||
// When stdout is not a TTY (e.g. `sf auto | cat`, `sf auto > file`),
|
||||
// Autonomous redirect: autonomous mode with piped stdout → headless mode (#2732)
|
||||
// When stdout is not a TTY (e.g. `sf autonomous | cat`),
|
||||
// the TUI cannot render and the process hangs. Redirect to headless mode
|
||||
// which handles non-interactive output gracefully.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -896,11 +872,10 @@ if (
|
|||
process.stderr.write(
|
||||
"[forge] stdout is not a terminal — running autonomous mode in headless mode.\n",
|
||||
);
|
||||
const headlessArgs =
|
||||
cliFlags.messages[0] === "autonomous"
|
||||
? ["auto", ...cliFlags.messages.slice(1)]
|
||||
: cliFlags.messages;
|
||||
await runHeadlessFromAuto(headlessArgs);
|
||||
await runHeadlessFromAutonomous([
|
||||
"autonomous",
|
||||
...cliFlags.messages.slice(1),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export function buildAutoBootstrapContext(basePath: string): string {
|
|||
const chunks: string[] = [
|
||||
"# Autonomous Repo Bootstrap",
|
||||
"",
|
||||
"SF headless auto found no milestones. Use the repository files below as the seed context.",
|
||||
"SF headless autonomous found no milestones. Use the repository files below as the seed context.",
|
||||
"Research every relevant markdown document and every source file path before creating the initial milestone plan.",
|
||||
"Use tool-based repository inspection for source contents; do not assume the seed excerpt is complete.",
|
||||
"Extract the project purpose, vision, architecture, constraints, current TODOs, risks, eval/gate ideas, and implementation backlog.",
|
||||
|
|
@ -194,7 +194,7 @@ export function buildAutoBootstrapContext(basePath: string): string {
|
|||
if (content.length > AUTO_BOOTSTRAP_MAX_FILE_BYTES) {
|
||||
content =
|
||||
content.slice(0, AUTO_BOOTSTRAP_MAX_FILE_BYTES) +
|
||||
"\n\n[truncated by SF headless auto bootstrap]\n";
|
||||
"\n\n[truncated by SF headless autonomous bootstrap]\n";
|
||||
}
|
||||
|
||||
const relPath = relative(basePath, filePath);
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export const NEW_MILESTONE_IDLE_TIMEOUT_MS = 120_000;
|
|||
* on legitimate slow LLM thinking or chained tool calls, but short enough
|
||||
* to recover from a real deadlock within a reasonable bound.
|
||||
*
|
||||
* Symptom from the old 15s timeout: sf headless auto would dispatch a task,
|
||||
* Symptom from the old 15s timeout: sf headless autonomous would dispatch a task,
|
||||
* the LLM would make 1-2 tool calls, pause to reason, exceed 15s of "no
|
||||
* events", and headless would declare "Status: complete" — exiting at ~35s
|
||||
* with the task barely started.
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export interface HeadlessOptions {
|
|||
commandArgs: string[];
|
||||
context?: string; // file path or '-' for stdin
|
||||
contextText?: string; // inline text
|
||||
auto?: boolean; // chain into auto-mode after milestone creation
|
||||
auto?: boolean; // chain into autonomous mode after milestone creation
|
||||
verbose?: boolean; // show tool calls in output
|
||||
maxRestarts?: number; // auto-restart on crash (default 3, 0 to disable)
|
||||
supervised?: boolean; // supervised mode: forward interactive requests to orchestrator
|
||||
|
|
@ -294,7 +294,7 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions {
|
|||
timeout: 300_000,
|
||||
json: false,
|
||||
outputFormat: "text",
|
||||
command: "auto",
|
||||
command: "autonomous",
|
||||
commandExplicit: false,
|
||||
commandArgs: [],
|
||||
};
|
||||
|
|
@ -382,8 +382,8 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions {
|
|||
options.bare = true;
|
||||
}
|
||||
} else if (!commandSeen) {
|
||||
if (arg === "autonomous") {
|
||||
options.command = "auto";
|
||||
if (arg === "autonomous" || arg === "auto") {
|
||||
options.command = "autonomous";
|
||||
options.auto = true; // autonomous subcommand implies --auto
|
||||
} else {
|
||||
options.command = arg;
|
||||
|
|
@ -488,7 +488,7 @@ async function runHeadlessOnce(
|
|||
): Promise<{ exitCode: number; interrupted: boolean }> {
|
||||
let interrupted = false;
|
||||
const startTime = Date.now();
|
||||
if (options.command === "auto" && !options.resumeSession) {
|
||||
if (options.command === "autonomous" && !options.resumeSession) {
|
||||
bootstrapProject(process.cwd());
|
||||
if (!hasMilestones(process.cwd())) {
|
||||
if (!options.json) {
|
||||
|
|
@ -512,11 +512,11 @@ async function runHeadlessOnce(
|
|||
// auto-mode sessions are long-running (minutes to hours) with their own internal
|
||||
// per-unit timeout via auto-supervisor. Disable the overall timeout unless the
|
||||
// user explicitly set --timeout.
|
||||
const isAutoMode = options.command === "auto";
|
||||
const isAutoMode = options.command === "autonomous";
|
||||
// discuss and plan are multi-turn: they involve multiple question rounds,
|
||||
// codebase scanning, and artifact writing before the workflow completes (#3547).
|
||||
const isMultiTurnCommand =
|
||||
options.command === "auto" ||
|
||||
options.command === "autonomous" ||
|
||||
options.command === "next" ||
|
||||
options.command === "discuss" ||
|
||||
options.command === "plan";
|
||||
|
|
@ -524,7 +524,7 @@ async function runHeadlessOnce(
|
|||
// Auto-mode defaults to supervised: wait for user input instead of exiting on questions
|
||||
// This is the desired behavior - auto should wait, not exit on blocked
|
||||
// Can be disabled via --no-supervised or preferences.auto_supervisor.supervised_mode: false
|
||||
if (options.command === "auto" && options.supervised === undefined) {
|
||||
if (options.command === "autonomous" && options.supervised === undefined) {
|
||||
// Check preferences for default
|
||||
try {
|
||||
const { loadEffectiveSFPreferences } = await import(
|
||||
|
|
@ -676,10 +676,10 @@ async function runHeadlessOnce(
|
|||
"[headless] Re-linked .sf to existing external project state\n",
|
||||
);
|
||||
}
|
||||
} else if (options.command === "auto" && options.commandExplicit) {
|
||||
} else if (options.command === "autonomous" && options.commandExplicit) {
|
||||
if (!options.json) {
|
||||
process.stderr.write(
|
||||
"[headless] No .sf/ project state found; initializing for auto mode...\n",
|
||||
"[headless] No .sf/ project state found; initializing for autonomous mode...\n",
|
||||
);
|
||||
}
|
||||
bootstrapProject(process.cwd());
|
||||
|
|
|
|||
|
|
@ -187,7 +187,6 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
"",
|
||||
"Commands:",
|
||||
" autonomous Run all queued product units continuously (default)",
|
||||
" auto Alias for autonomous",
|
||||
" next Run one unit",
|
||||
" status Show progress dashboard",
|
||||
" new-milestone Create a milestone from a specification document",
|
||||
|
|
@ -232,7 +231,7 @@ export function printHelp(version: string): void {
|
|||
process.stdout.write("Usage: sf [options] [message...]\n\n");
|
||||
process.stdout.write("Options:\n");
|
||||
process.stdout.write(
|
||||
" --mode <text|json|rpc|mcp> Output mode (default: interactive)\n",
|
||||
" --mode <text|json|rpc> Output mode (default: interactive)\n",
|
||||
);
|
||||
process.stdout.write(" --print, -p Single-shot print mode\n");
|
||||
process.stdout.write(
|
||||
|
|
@ -288,9 +287,8 @@ export function printHelp(version: string): void {
|
|||
process.stdout.write(
|
||||
" autonomous [args] Run autonomous mode without TUI (pipeable)\n",
|
||||
);
|
||||
process.stdout.write(" auto [args] Alias for autonomous\n");
|
||||
process.stdout.write(
|
||||
" headless [cmd] [args] Run /sf commands without TUI (default: auto)\n",
|
||||
" headless [cmd] [args] Run /sf commands without TUI (default: autonomous)\n",
|
||||
);
|
||||
process.stdout.write(
|
||||
" graph <subcommand> Manage knowledge graph (build, query, status, diff)\n",
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
/**
|
||||
* Minimal tool interface matching SF's AgentTool shape.
|
||||
* Avoids a direct dependency on @singularity-forge/pi-agent-core from this compiled module.
|
||||
*/
|
||||
export interface McpToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: unknown,
|
||||
): Promise<{
|
||||
content: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
// MCP SDK subpath imports use wildcard exports (./*) in @modelcontextprotocol/sdk's
|
||||
// package.json export map. The wildcard maps "./foo" → "./dist/cjs/foo" (no .js
|
||||
// suffix), so bare subpath specifiers like `${MCP_PKG}/server/stdio` resolve to
|
||||
// a non-existent file. Historically the workaround (#3603) used createRequire so
|
||||
// the CJS resolver could auto-append `.js`; that no longer works with current
|
||||
// Node + SDK releases (#3914) — `_require.resolve` also fails with
|
||||
// "Cannot find module .../dist/cjs/server/stdio".
|
||||
//
|
||||
// The reliable convention (matching packages/mcp-server/{server,cli}.ts) is to
|
||||
// write the `.js` suffix explicitly on every wildcard subpath. Specifiers are
|
||||
// built via a template string so TypeScript's NodeNext resolver treats them as
|
||||
// `any` and skips static checking.
|
||||
const MCP_PKG = "@modelcontextprotocol/sdk";
|
||||
|
||||
/**
|
||||
* Starts a native MCP (Model Context Protocol) server over stdin/stdout.
|
||||
*
|
||||
* This enables SF's tools (read, write, edit, bash, grep, glob, ls, etc.)
|
||||
* to be used by external AI clients such as Claude Desktop, VS Code Copilot,
|
||||
* and any MCP-compatible host.
|
||||
*
|
||||
* The server registers all tools from the agent session's tool registry and
|
||||
* maps MCP tools/list and tools/call requests to SF tool definitions and
|
||||
* execution, respectively.
|
||||
*
|
||||
* All MCP SDK imports are dynamic to avoid subpath export resolution issues
|
||||
* with TypeScript's NodeNext module resolution.
|
||||
*/
|
||||
export async function startMcpServer(options: {
|
||||
tools: McpToolDef[];
|
||||
version?: string;
|
||||
}): Promise<void> {
|
||||
const { tools, version = "0.0.0" } = options;
|
||||
|
||||
const serverMod = await import(`${MCP_PKG}/server/index.js`);
|
||||
const stdioMod = await import(`${MCP_PKG}/server/stdio.js`);
|
||||
const typesMod = await import(`${MCP_PKG}/types.js`);
|
||||
|
||||
const Server = serverMod.Server;
|
||||
const StdioServerTransport = stdioMod.StdioServerTransport;
|
||||
const { ListToolsRequestSchema, CallToolRequestSchema } = typesMod;
|
||||
|
||||
// Build a lookup map for fast tool resolution on calls
|
||||
const toolMap = new Map<string, McpToolDef>();
|
||||
for (const tool of tools) {
|
||||
toolMap.set(tool.name, tool);
|
||||
}
|
||||
|
||||
const server = new Server(
|
||||
{ name: "sf", version },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
// tools/list — return every registered SF tool with its JSON Schema parameters
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: tools.map((t: McpToolDef) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.parameters,
|
||||
})),
|
||||
}));
|
||||
|
||||
// tools/call — execute the requested tool and return content blocks.
|
||||
//
|
||||
// The MCP SDK passes an `extra` argument to request handlers that includes
|
||||
// an AbortSignal scoped to the RPC request (cancelled when the client
|
||||
// cancels the tool call or the transport closes). Threading it into
|
||||
// AgentTool.execute ensures long-running tools (Bash, WebFetch, grep on
|
||||
// huge trees) actually stop when the client gives up on the result.
|
||||
server.setRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
async (request: any, extra: any) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const tool = toolMap.get(name);
|
||||
if (!tool) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: `Unknown tool: ${name}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const signal: AbortSignal | undefined = extra?.signal;
|
||||
|
||||
try {
|
||||
const result = await tool.execute(
|
||||
`mcp-${Date.now()}`,
|
||||
args ?? {},
|
||||
signal,
|
||||
undefined, // onUpdate not yet wired — progress notifications require a progressToken round-trip
|
||||
);
|
||||
|
||||
// Convert AgentToolResult content blocks to MCP content format.
|
||||
// text and image pass through; any other shape is serialized as text
|
||||
// so the client sees the payload rather than an empty response.
|
||||
const content = result.content.map((block: any) => {
|
||||
if (block.type === "text")
|
||||
return { type: "text" as const, text: block.text ?? "" };
|
||||
if (block.type === "image") {
|
||||
return {
|
||||
type: "image" as const,
|
||||
data: block.data ?? "",
|
||||
mimeType: block.mimeType ?? "image/png",
|
||||
};
|
||||
}
|
||||
// Preserve unknown block types (resource, resource_link, audio, ...)
|
||||
// by stringifying into a text block so clients see the payload.
|
||||
return { type: "text" as const, text: JSON.stringify(block) };
|
||||
});
|
||||
return { content };
|
||||
} catch (err: unknown) {
|
||||
// AbortError from a cancelled tool surfaces as a normal error — MCP
|
||||
// clients interpret `isError: true` as a failed call, which is the
|
||||
// correct behaviour for a cancelled request.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Connect to stdin/stdout transport
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.stderr.write(`[forge] MCP server started (v${version})\n`);
|
||||
}
|
||||
|
|
@ -9,11 +9,11 @@ import { hasXmlParameterTags, repairToolJson } from "@singularity-forge/pi-ai";
|
|||
// MCP tool name parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Split a Claude Code MCP tool name (`mcp__<server>__<tool>`) into its parts.
|
||||
* Split an adapter-surfaced MCP tool name (`mcp__<server>__<tool>`) into its parts.
|
||||
* Returns null for non-prefixed names so callers can fall through unchanged.
|
||||
*
|
||||
* Server names may contain hyphens (`sf-workflow`); the SDK uses the literal
|
||||
* `__` delimiter between the server name and the tool name.
|
||||
* Server names may contain hyphens; the SDK uses the literal `__` delimiter
|
||||
* between the server name and the tool name.
|
||||
*/
|
||||
export function parseMcpToolName(name) {
|
||||
if (!name.startsWith("mcp__")) return null;
|
||||
|
|
@ -23,7 +23,7 @@ export function parseMcpToolName(name) {
|
|||
return { server: rest.slice(0, delim), tool: rest.slice(delim + 2) };
|
||||
}
|
||||
/**
|
||||
* Build a SF ToolCall block from a Claude Code SDK tool_use block, stripping
|
||||
* Build a SF ToolCall block from an adapter SDK tool_use block, stripping
|
||||
* the `mcp__<server>__` prefix from the name so registered extension renderers
|
||||
* (which use the unprefixed canonical names) can match. The original server
|
||||
* name is preserved on the block for diagnostics and rendering.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { existsSync, readFileSync } from "node:fs";
|
|||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { EventStream } from "@singularity-forge/pi-ai";
|
||||
import { buildWorkflowMcpServers } from "../sf/workflow-mcp.js";
|
||||
import { showInterviewRound } from "../shared/tui.js";
|
||||
import {
|
||||
mapUsage,
|
||||
|
|
@ -729,7 +728,7 @@ function formatToolInput(toolName, input) {
|
|||
* Follows the same pattern as {@link createClaudeCodeElicitationHandler}:
|
||||
* takes an optional UI context and returns the callback or undefined.
|
||||
*
|
||||
* When UI is unavailable (headless / auto-mode sub-agents), returns a handler
|
||||
* When UI is unavailable (headless / autonomous sub-agents), returns a handler
|
||||
* that always approves — replacing the old SF_AUTO_MODE → bypassPermissions
|
||||
* workaround.
|
||||
*/
|
||||
|
|
@ -965,8 +964,8 @@ export function makeAbortedMessage(model, lastTextContent) {
|
|||
* Resolve the Claude Code permission mode for the current run.
|
||||
*
|
||||
* SF subagents run underneath a host Claude Code session the user has
|
||||
* already consented to, and their work (edits, shell inspection, MCP calls)
|
||||
* spans the full workflow toolset. Defaulting the inner SDK to
|
||||
* already consented to, and their work spans the full direct runtime toolset.
|
||||
* Defaulting the inner SDK to
|
||||
* `bypassPermissions` avoids per-tool approval prompts that offer no
|
||||
* meaningful safety beyond what the host session and the subagent prompts
|
||||
* already enforce. `SF_CLAUDE_CODE_PERMISSION_MODE` lets security-conscious
|
||||
|
|
@ -1047,14 +1046,11 @@ export function buildSdkOptions(
|
|||
const reasoning =
|
||||
requestedReasoning === "auto" ? undefined : requestedReasoning;
|
||||
const autoReasoning = requestedReasoning === "auto";
|
||||
const mcpServers = buildWorkflowMcpServers();
|
||||
const permissionMode = overrides?.permissionMode ?? "bypassPermissions";
|
||||
const disallowedTools = ["AskUserQuestion"];
|
||||
// Pre-authorize the safe built-ins and every registered workflow MCP
|
||||
// server's tools. `acceptEdits` mode (the interactive default) only
|
||||
// auto-approves file edits — Read/Glob/Grep, basic shell inspection, and
|
||||
// every `mcp__sf-workflow__*` call still surface as "This command
|
||||
// requires approval" and block SF actions (#4099).
|
||||
// Pre-authorize the safe built-ins. `acceptEdits` mode (the interactive
|
||||
// default) only auto-approves file edits, so read-only inspection still
|
||||
// needs explicit allow-listing for autonomous SF actions (#4099).
|
||||
const allowedTools = [
|
||||
"Read",
|
||||
"Write",
|
||||
|
|
@ -1063,9 +1059,6 @@ export function buildSdkOptions(
|
|||
"Grep",
|
||||
"Bash(ls:*)",
|
||||
"Bash(pwd)",
|
||||
...(mcpServers
|
||||
? Object.keys(mcpServers).map((serverName) => `mcp__${serverName}__*`)
|
||||
: []),
|
||||
];
|
||||
const supportsAdaptive = modelSupportsAdaptiveThinking(modelId);
|
||||
const effort =
|
||||
|
|
@ -1093,7 +1086,6 @@ export function buildSdkOptions(
|
|||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
disallowedTools,
|
||||
...(allowedTools.length > 0 ? { allowedTools } : {}),
|
||||
...(mcpServers ? { mcpServers } : {}),
|
||||
betas:
|
||||
modelId.includes("sonnet") ||
|
||||
modelId.includes("opus-4-7") ||
|
||||
|
|
@ -1260,7 +1252,7 @@ async function pumpSdkMessages(model, context, options, stream) {
|
|||
const permissionMode = await resolveClaudePermissionMode();
|
||||
const uiContext = options?.extensionUIContext;
|
||||
const canUseToolHandler = createClaudeCodeCanUseToolHandler(uiContext);
|
||||
// When no UI is available (headless / auto-mode), auto-approve all
|
||||
// When no UI is available (headless / autonomous), auto-approve all
|
||||
// tool requests. This replaces the old bypassPermissions workaround.
|
||||
const canUseToolFallback =
|
||||
canUseToolHandler ??
|
||||
|
|
@ -1493,7 +1485,7 @@ async function pumpSdkMessages(model, context, options, stream) {
|
|||
}
|
||||
// Generator exhaustion without a terminal result is a stream interruption,
|
||||
// not a successful completion. Emitting an error lets SF classify it as a
|
||||
// transient provider failure instead of advancing auto-mode state.
|
||||
// transient provider failure instead of advancing autonomous state.
|
||||
const fallback = makeStreamExhaustedErrorMessage(modelId, lastTextContent);
|
||||
stream.push({ type: "error", reason: "error", error: fallback });
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function registerSessionEmoji(pi) {
|
|||
};
|
||||
registerCommands(pi, state);
|
||||
// Gate the session-lifecycle work on having a real TUI. Headless mode
|
||||
// (sf headless auto, --print, CI) has no footer to render into, and the
|
||||
// (sf headless autonomous, --print, CI) has no footer to render into, and the
|
||||
// AI auto-assign path would spend tokens choosing an emoji nothing sees.
|
||||
pi.on("session_start", (_, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export function buildAutoBootstrapContext(basePath) {
|
|||
const chunks = [
|
||||
"# Autonomous Repo Bootstrap",
|
||||
"",
|
||||
"SF headless auto found no milestones. Use the repository files below as the seed context.",
|
||||
"SF headless autonomous found no milestones. Use the repository files below as the seed context.",
|
||||
"Research every relevant markdown document and every source file path before creating the initial milestone plan.",
|
||||
"Use tool-based repository inspection for source contents; do not assume the seed excerpt is complete.",
|
||||
"Extract the project purpose, vision, architecture, constraints, current TODOs, risks, eval/gate ideas, and implementation backlog.",
|
||||
|
|
@ -119,7 +119,7 @@ export function buildAutoBootstrapContext(basePath) {
|
|||
if (content.length > AUTO_BOOTSTRAP_MAX_FILE_BYTES) {
|
||||
content =
|
||||
content.slice(0, AUTO_BOOTSTRAP_MAX_FILE_BYTES) +
|
||||
"\n\n[truncated by SF headless auto bootstrap]\n";
|
||||
"\n\n[truncated by SF headless autonomous bootstrap]\n";
|
||||
}
|
||||
const relPath = relative(basePath, filePath);
|
||||
const block = `\n\n## ${relPath}\n\n${content.trim()}\n`;
|
||||
|
|
@ -138,7 +138,7 @@ export function buildAutoBootstrapContext(basePath) {
|
|||
if (block.length > AUTO_BOOTSTRAP_MAX_INVENTORY_BYTES) {
|
||||
block =
|
||||
block.slice(0, AUTO_BOOTSTRAP_MAX_INVENTORY_BYTES) +
|
||||
"\n\n[truncated by SF headless auto bootstrap]\n";
|
||||
"\n\n[truncated by SF headless autonomous bootstrap]\n";
|
||||
}
|
||||
if (used + block.length <= AUTO_BOOTSTRAP_MAX_BYTES) {
|
||||
chunks.push(block);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Auto-mode Dispatch Table — declarative phase → unit mapping.
|
||||
* Autonomous mode Dispatch Table — declarative phase → unit mapping.
|
||||
*
|
||||
* Each rule maps a SF state to the unit type, unit ID, and prompt builder
|
||||
* that should be dispatched. Rules are evaluated in order; the first match wins.
|
||||
|
|
@ -105,7 +105,7 @@ function missingSliceStop(mid, phase) {
|
|||
function canonicalPlanStop(mid, plan) {
|
||||
return {
|
||||
action: "stop",
|
||||
reason: `${mid}: canonical milestone plan unavailable (${plan.source}): ${plan.reason} Run /sf doctor or regenerate structured roadmap state before dispatching auto-mode work.`,
|
||||
reason: `${mid}: canonical milestone plan unavailable (${plan.source}): ${plan.reason} Run /sf doctor or regenerate structured roadmap state before dispatching autonomous mode work.`,
|
||||
level: "error",
|
||||
};
|
||||
}
|
||||
|
|
@ -381,7 +381,7 @@ function buildValidationAttentionRemediationPrompt(
|
|||
const validationRel = `.sf/milestones/${mid}/${mid}-VALIDATION.md`;
|
||||
const escapedValidation = validationContent.replace(/```/g, "``\\`");
|
||||
const escapedPlan = attentionPlan.replace(/```/g, "``\\`");
|
||||
return `You are executing SF auto-mode.
|
||||
return `You are executing SF autonomous mode.
|
||||
|
||||
## UNIT: Resolve Validation Attention for ${mid} ("${midTitle}")
|
||||
|
||||
|
|
@ -447,7 +447,7 @@ export const DISPATCH_RULES = [
|
|||
},
|
||||
{
|
||||
// ADR-011 Phase 2 (gsd-2 ADR): mid-execution escalation handling.
|
||||
// Auto-mode is autonomous, so by default we accept the agent's
|
||||
// Autonomous mode is autonomous, so by default we accept the agent's
|
||||
// recommendation and continue — the user can review/override later via
|
||||
// `/sf escalate list --all`. Set `phases.escalation_auto_accept: false`
|
||||
// to keep gsd-2's pause-and-ask behavior.
|
||||
|
|
@ -469,8 +469,8 @@ export const DISPATCH_RULES = [
|
|||
state.activeSlice.id,
|
||||
state.activeTask.id,
|
||||
"accept",
|
||||
"auto-mode: accepted agent recommendation; user can override via /sf escalate",
|
||||
"auto-mode",
|
||||
"autonomous mode: accepted agent recommendation; user can override via /sf escalate",
|
||||
"autonomous mode",
|
||||
);
|
||||
if (result.status === "resolved") {
|
||||
// Flags cleared; let the next dispatch cycle re-read state and
|
||||
|
|
@ -536,7 +536,7 @@ export const DISPATCH_RULES = [
|
|||
"You are facilitating the **initial roadmap meeting** for milestone " +
|
||||
mid +
|
||||
".\n\n" +
|
||||
"You are running in SF auto-mode. Do not call `ask_user_questions`, " +
|
||||
"You are running in SF autonomous mode. Do not call `ask_user_questions`, " +
|
||||
"do not wait for a human reply, and do not end with open questions. " +
|
||||
"Use existing project artifacts as the user's durable input. If `" +
|
||||
mid +
|
||||
|
|
@ -891,7 +891,7 @@ export const DISPATCH_RULES = [
|
|||
if (!contradiction) return null;
|
||||
return {
|
||||
action: "stop",
|
||||
reason: `${mid}: ${contradiction}. Regenerate structured roadmap state before dispatching auto-mode work.`,
|
||||
reason: `${mid}: ${contradiction}. Regenerate structured roadmap state before dispatching autonomous mode work.`,
|
||||
level: "error",
|
||||
};
|
||||
},
|
||||
|
|
@ -1663,7 +1663,7 @@ export async function resolveDispatch(ctx) {
|
|||
if (ctx.pipelineVariant === undefined) {
|
||||
ctx.pipelineVariant = await getMilestonePipelineVariant(ctx.mid);
|
||||
}
|
||||
// Delegate to registry when available. Callers that run outside auto-mode
|
||||
// Delegate to registry when available. Callers that run outside autonomous mode
|
||||
// (e.g. `sf headless query`, `sf headless status`) never initialize the
|
||||
// registry — falling through to inline rules is the intended behavior,
|
||||
// not an error, so we silent-probe instead of warning on every call.
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export class ModelPolicyDispatchBlockedError extends Error {
|
|||
// LIFECYCLE: the baseline is tied to a single auto session, NOT to the
|
||||
// lifetime of the `pi` instance (which can outlive many auto runs and have
|
||||
// the user mutate tools between them). `clearToolBaseline` MUST be called
|
||||
// at auto start AND auto stop so that a second `/sf auto` run on the same
|
||||
// at auto start AND auto stop so that a second `/sf autonomous` run on the same
|
||||
// `pi` does not silently restore a stale snapshot from the prior run and
|
||||
// undo any tool changes the user made between sessions.
|
||||
const TOOL_BASELINE = new WeakMap();
|
||||
|
|
@ -612,7 +612,7 @@ export async function selectAndApplyModel(
|
|||
}
|
||||
// Skip models the provider has previously rejected for this account
|
||||
// (issue #4513). The block is persisted in .sf/runtime/blocked-models.json
|
||||
// so it survives /sf auto restarts — without this, the same dead model
|
||||
// so it survives /sf autonomous restarts — without this, the same dead model
|
||||
// gets reselected after every restart.
|
||||
if (isModelBlocked(basePath, model.provider, model.id)) {
|
||||
ctx.ui.notify(
|
||||
|
|
|
|||
|
|
@ -464,12 +464,6 @@ export async function bootstrapAutoSession(
|
|||
);
|
||||
}
|
||||
}
|
||||
{
|
||||
const { prepareWorkflowMcpForProject } = await import(
|
||||
"./workflow-mcp-auto-prep.js"
|
||||
);
|
||||
prepareWorkflowMcpForProject(ctx, base);
|
||||
}
|
||||
// Initialize GitServiceImpl
|
||||
s.gitService = new GitServiceImpl(
|
||||
s.basePath,
|
||||
|
|
|
|||
|
|
@ -596,7 +596,7 @@ export function stopAutoRemote(projectRoot) {
|
|||
* Check if a remote auto-mode session is running (from a different process).
|
||||
* Reads the crash lock, checks PID liveness, and returns session details.
|
||||
* Used by the guard in commands.ts to prevent bare /sf, /sf next, and
|
||||
* /sf auto from stealing the session lock.
|
||||
* /sf autonomous from stealing the session lock.
|
||||
*/
|
||||
export function checkRemoteAutoSession(projectRoot) {
|
||||
const lock = readCrashLock(projectRoot);
|
||||
|
|
@ -1111,7 +1111,7 @@ export async function stopAuto(ctx, pi, reason) {
|
|||
`auto-exit telemetry failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
// Drop the active-tool baseline so a subsequent /sf auto run on the
|
||||
// Drop the active-tool baseline so a subsequent /sf autonomous run on the
|
||||
// same `pi` instance recaptures from the live tool set rather than
|
||||
// restoring this session's snapshot and silently undoing any tool
|
||||
// changes the user made between sessions (#4959 / CodeRabbit).
|
||||
|
|
@ -1122,7 +1122,7 @@ export async function stopAuto(ctx, pi, reason) {
|
|||
}
|
||||
/**
|
||||
* Pause auto-mode without destroying state. Context is preserved.
|
||||
* The user can interact with the agent, then `/sf auto` resumes
|
||||
* The user can interact with the agent, then `/sf autonomous` resumes
|
||||
* from disk state. Called when the user presses Escape during auto-mode.
|
||||
*/
|
||||
export async function pauseAuto(ctx, _pi, _errorContext) {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue