diff --git a/package-lock.json b/package-lock.json index cf665d022..823062272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16346,6 +16346,7 @@ "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" }, diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 117c05361..eeb484765 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,46 +1,47 @@ { - "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": "node --test dist/mcp-server.test.js" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", - "@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.*" - ] + "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": "node --test dist/mcp-server.test.js" + }, + "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.*" + ] } diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index eefa07963..f0dc0b46e 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -2,42 +2,52 @@ * @singularity-forge/mcp-server — MCP server for SF orchestration and project state. */ -export { SessionManager } from './session-manager.js'; -export { createMcpServer } from './server.js'; -export type { - SessionStatus, - ManagedSession, - ExecuteOptions, - PendingBlocker, - CostAccumulator, -} from './types.js'; -export { MAX_EVENTS, INIT_TIMEOUT_MS } from './types.js'; - // Path resolution utilities -export { resolveSFRoot } from './readers/paths.js'; - -// Read-only state readers (usable without a running session) -export { readProgress } from './readers/state.js'; -export type { ProgressResult } from './readers/state.js'; -export { readRoadmap } from './readers/roadmap.js'; -export type { RoadmapResult, MilestoneInfo, SliceInfo, TaskInfo } from './readers/roadmap.js'; -export { readHistory } from './readers/metrics.js'; -export type { HistoryResult, MetricsUnit } from './readers/metrics.js'; -export { readCaptures } from './readers/captures.js'; -export type { CapturesResult, CaptureEntry } from './readers/captures.js'; -export { readKnowledge } from './readers/knowledge.js'; -export type { KnowledgeResult, KnowledgeEntry } from './readers/knowledge.js'; -export { runDoctorLite } from './readers/doctor-lite.js'; -export type { DoctorResult, DoctorIssue } from './readers/doctor-lite.js'; -export { buildGraph, writeGraph, writeSnapshot, graphStatus, graphQuery, graphDiff } from './readers/graph.js'; +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 { - NodeType, - EdgeType, - ConfidenceTier, - GraphNode, - GraphEdge, - KnowledgeGraph, - GraphStatusResult, - GraphQueryResult, - GraphDiffResult, -} from './readers/graph.js'; + 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"; diff --git a/packages/mcp-server/src/readers/graph.test.ts b/packages/mcp-server/src/readers/graph.test.ts index f97bd6069..e288b7424 100644 --- a/packages/mcp-server/src/readers/graph.test.ts +++ b/packages/mcp-server/src/readers/graph.test.ts @@ -1,109 +1,145 @@ -// SF MCP Server — knowledge graph reader tests +// SF project graph reader tests // Copyright (c) 2026 Jeremy McSpadden -import { describe, it, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; -import assert from 'node:assert/strict'; -import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { randomBytes } from 'node:crypto'; - +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; import { - buildGraph, - writeGraph, - writeSnapshot, - graphStatus, - graphQuery, - graphDiff, -} from './graph.js'; -import type { KnowledgeGraph } from './graph.js'; + 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; + 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'); + 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/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/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/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')); + 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); +function writeLearningsFixture( + projectDir: string, + milestoneId: string, + content: string, +): void { + writeFixture( + projectDir, + `.sf/milestones/${milestoneId}/${milestoneId}-LEARNINGS.md`, + content, + ); } const SAMPLE_LEARNINGS = `--- @@ -145,158 +181,219 @@ missing_artifacts: [] // buildGraph tests // --------------------------------------------------------------------------- -describe('buildGraph', () => { - let projectDir: string; +describe("buildGraph", () => { + let projectDir: string; - beforeAll(() => { - projectDir = tmpProject(); - makeProjectWithArtifacts(projectDir); - }); + beforeAll(() => { + projectDir = tmpProject(); + makeProjectWithArtifacts(projectDir); + }); - afterAll(() => rmSync(projectDir, { recursive: true, force: true })); + 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 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("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(!isNaN(Date.parse(graph.builtAt))); - }); + 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("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("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}`, - ); - } - }); + 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; +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); - }); + 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 })); + afterEach(() => rmSync(projectDir, { recursive: true, force: true })); - it('extracts decision nodes from ## Decisions section', async () => { - const graph = await buildGraph(projectDir); - const decisions = graph.nodes.filter((n) => n.type === 'decision' || (n.type === 'rule' && n.id.startsWith('decision:'))); - // 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 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 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("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("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 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("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("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("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', `--- + 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" @@ -318,287 +415,338 @@ missing_artifacts: [] ## 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 }); - }); +`, + ); + 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 }); - }); + 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; +describe("writeGraph", () => { + let projectDir: string; + let graph: KnowledgeGraph; - beforeAll(async () => { - projectDir = tmpProject(); - makeProjectWithArtifacts(projectDir); - graph = await buildGraph(projectDir); - }); + beforeAll(async () => { + projectDir = tmpProject(); + makeProjectWithArtifacts(projectDir); + graph = await buildGraph(projectDir); + }); - afterAll(() => rmSync(projectDir, { recursive: true, force: true })); + 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("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("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'); - }); + 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; +describe("graphStatus", () => { + let projectDir: string; - beforeEach(() => { - projectDir = tmpProject(); - }); + beforeEach(() => { + projectDir = tmpProject(); + }); - afterEach(() => rmSync(projectDir, { recursive: true, force: true })); + 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: 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); + 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); - }); + 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); + 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); - }); + 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 }); + 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', - ); + // 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); - }); + const status = await graphStatus(projectDir); + assert.equal(status.exists, true); + assert.equal(status.stale, true); + }); }); // --------------------------------------------------------------------------- // graphQuery tests // --------------------------------------------------------------------------- -describe('graphQuery', () => { - let projectDir: string; +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); - }); + 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 })); + 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 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("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("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); + 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'); + // 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); - }); + // Restore the original graph + const originalGraph = await buildGraph(projectDir); + await writeGraph(sfRoot, originalGraph); + }); }); // --------------------------------------------------------------------------- // writeSnapshot + graphDiff tests // --------------------------------------------------------------------------- -describe('graphDiff', () => { - let projectDir: string; +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); - }); + 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 })); + 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 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); + 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); + // 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'); - }); + 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', - ); + 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'); - }); + // 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("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(!isNaN(Date.parse(parsed.snapshotAt))); - }); + 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))); + }); }); diff --git a/packages/mcp-server/src/readers/graph.ts b/packages/mcp-server/src/readers/graph.ts index dc4a6751d..524c20a7b 100644 --- a/packages/mcp-server/src/readers/graph.ts +++ b/packages/mcp-server/src/readers/graph.ts @@ -1,855 +1,29 @@ -// SF MCP Server — knowledge graph reader -// Copyright (c) 2026 Jeremy McSpadden - /** - * Knowledge Graph for SF projects. + * MCP graph reader compatibility exports. * - * Parses .sf/ artifacts (STATE.md, milestone ROADMAPs, slice PLANs, - * KNOWLEDGE.md) into a graph of nodes and edges. Parse errors in any - * single artifact are caught and never propagate — the artifact is skipped - * and the rest of the graph is returned. + * Purpose: keep MCP as a transport wrapper over the SF project graph while the + * core graph implementation lives in `@singularity-forge/pi-agent-core`. * - * writeGraph() is atomic: writes to graph.tmp.json then renames to graph.json. + * Consumer: MCP `sf_graph` tool and older imports from `readers/graph.js`. */ -import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { resolveSFRoot, findMilestoneIds, resolveMilestoneDir, findSliceIds, resolveSliceDir } from './paths.js'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type NodeType = - | 'milestone' - | 'slice' - | 'task' - | 'rule' - | 'pattern' - | 'lesson' - | 'concept' - | 'decision'; - -export type EdgeType = - | 'contains' - | 'depends_on' - | 'relates_to' - | 'implements'; - -export type ConfidenceTier = 'EXTRACTED' | 'INFERRED' | 'AMBIGUOUS'; - -export interface GraphNode { - id: string; - label: string; - type: NodeType; - description?: string; - confidence: ConfidenceTier; - sourceFile?: string; -} - -export interface GraphEdge { - from: string; - to: string; - type: EdgeType; - confidence: ConfidenceTier; -} - -export interface KnowledgeGraph { - nodes: GraphNode[]; - edges: GraphEdge[]; - builtAt: string; -} - -export interface GraphStatusResult { - exists: boolean; - lastBuild?: string; - nodeCount?: number; - edgeCount?: number; - stale?: boolean; - ageHours?: number; -} - -export interface GraphQueryResult { - nodes: GraphNode[]; - edges: GraphEdge[]; - term: string; - budget: number; -} - -export interface GraphDiffResult { - nodes: { - added: string[]; - removed: string[]; - changed: string[]; - }; - edges: { - added: string[]; - removed: string[]; - }; -} - -// --------------------------------------------------------------------------- -// Graph file paths -// --------------------------------------------------------------------------- - -function graphsDir(sfRoot: string): string { - return join(sfRoot, 'graphs'); -} - -function graphJsonPath(sfRoot: string): string { - return join(graphsDir(sfRoot), 'graph.json'); -} - -function graphTmpPath(sfRoot: string): string { - return join(graphsDir(sfRoot), 'graph.tmp.json'); -} - -function snapshotPath(sfRoot: string): string { - return join(graphsDir(sfRoot), '.last-build-snapshot.json'); -} - -// --------------------------------------------------------------------------- -// Parsers — each returns nodes/edges and never throws -// --------------------------------------------------------------------------- - -/** - * Parse STATE.md for active milestone and phase concepts. - */ -function parseStateFile(sfRoot: string, nodes: GraphNode[], _edges: GraphEdge[]): void { - const statePath = join(sfRoot, 'STATE.md'); - if (!existsSync(statePath)) return; - - let content: string; - try { - content = readFileSync(statePath, 'utf-8'); - } catch { - return; - } - - // Extract active milestone - const activeMilestoneMatch = content.match(/\*\*Active Milestone:\*\*\s+([A-Z]\d+):\s+(.+)/i); - if (activeMilestoneMatch) { - const [, milestoneId, title] = activeMilestoneMatch; - const id = `milestone:${milestoneId}`; - if (!nodes.some((n) => n.id === id)) { - nodes.push({ - id, - label: `${milestoneId}: ${title.trim()}`, - type: 'milestone', - description: `Active milestone: ${milestoneId}`, - confidence: 'EXTRACTED', - sourceFile: 'STATE.md', - }); - } - } - - // Extract phase as concept - const phaseMatch = content.match(/\*\*Phase:\*\*\s+(\S+)/i); - if (phaseMatch) { - const phase = phaseMatch[1].trim(); - nodes.push({ - id: `concept:phase:${phase}`, - label: `Phase: ${phase}`, - type: 'concept', - confidence: 'EXTRACTED', - sourceFile: 'STATE.md', - }); - } -} - -/** - * Parse KNOWLEDGE.md for rules, patterns, and lessons. - */ -function parseKnowledgeFile(sfRoot: string, nodes: GraphNode[], _edges: GraphEdge[]): void { - const knowledgePath = join(sfRoot, 'KNOWLEDGE.md'); - if (!existsSync(knowledgePath)) return; - - let content: string; - try { - content = readFileSync(knowledgePath, 'utf-8'); - } catch { - return; - } - - // Parse Rules table - const rulesMatch = content.match(/## Rules\s*\n([\s\S]*?)(?=\n## |$)/i); - if (rulesMatch) { - for (const line of rulesMatch[1].split('\n')) { - if (!line.includes('|')) continue; - const cells = line.split('|').map((c) => c.trim()).filter(Boolean); - if (cells.length < 3) continue; - if (cells[0].startsWith('#') || cells[0].startsWith('-')) continue; - const id = cells[0]; - if (!/^K\d+$/i.test(id)) continue; - nodes.push({ - id: `rule:${id}`, - label: id, - type: 'rule', - description: cells[2] ?? '', - confidence: 'EXTRACTED', - sourceFile: 'KNOWLEDGE.md', - }); - } - } - - // Parse Patterns table - const patternsMatch = content.match(/## Patterns\s*\n([\s\S]*?)(?=\n## |$)/i); - if (patternsMatch) { - for (const line of patternsMatch[1].split('\n')) { - if (!line.includes('|')) continue; - const cells = line.split('|').map((c) => c.trim()).filter(Boolean); - if (cells.length < 2) continue; - if (cells[0].startsWith('#') || cells[0].startsWith('-')) continue; - const id = cells[0]; - if (!/^P\d+$/i.test(id)) continue; - nodes.push({ - id: `pattern:${id}`, - label: id, - type: 'pattern', - description: cells[1] ?? '', - confidence: 'EXTRACTED', - sourceFile: 'KNOWLEDGE.md', - }); - } - } - - // Parse Lessons Learned table - const lessonsMatch = content.match(/## Lessons Learned\s*\n([\s\S]*?)(?=\n## |$)/i); - if (lessonsMatch) { - for (const line of lessonsMatch[1].split('\n')) { - if (!line.includes('|')) continue; - const cells = line.split('|').map((c) => c.trim()).filter(Boolean); - if (cells.length < 2) continue; - if (cells[0].startsWith('#') || cells[0].startsWith('-')) continue; - const id = cells[0]; - if (!/^L\d+$/i.test(id)) continue; - nodes.push({ - id: `lesson:${id}`, - label: id, - type: 'lesson', - description: cells[1] ?? '', - confidence: 'EXTRACTED', - sourceFile: 'KNOWLEDGE.md', - }); - } - } -} - -/** - * Parse milestone ROADMAP.md files for milestones and slices. - */ -function parseMilestoneFiles( - sfRoot: string, - nodes: GraphNode[], - edges: GraphEdge[], -): void { - const milestoneIds = findMilestoneIds(sfRoot); - - for (const milestoneId of milestoneIds) { - try { - parseSingleMilestone(sfRoot, milestoneId, nodes, edges); - } catch { - // Skip this milestone on any error - } - } -} - -function parseSingleMilestone( - sfRoot: string, - milestoneId: string, - nodes: GraphNode[], - edges: GraphEdge[], -): void { - const mDir = resolveMilestoneDir(sfRoot, milestoneId); - if (!mDir) return; - - const milestoneNodeId = `milestone:${milestoneId}`; - - // Try to read the roadmap file - const roadmapPath = join(mDir, `${milestoneId}-ROADMAP.md`); - let roadmapContent: string | null = null; - if (existsSync(roadmapPath)) { - try { - roadmapContent = readFileSync(roadmapPath, 'utf-8'); - } catch { - // Skip - } - } - - // Extract milestone title from roadmap - let milestoneTitle = milestoneId; - if (roadmapContent) { - const titleMatch = roadmapContent.match(/^#\s+[A-Z]\d+:\s+(.+)/m); - if (titleMatch) milestoneTitle = `${milestoneId}: ${titleMatch[1].trim()}`; - } - - // Ensure milestone node exists - if (!nodes.some((n) => n.id === milestoneNodeId)) { - nodes.push({ - id: milestoneNodeId, - label: milestoneTitle, - type: 'milestone', - confidence: 'EXTRACTED', - sourceFile: roadmapContent ? `milestones/${milestoneId}/${milestoneId}-ROADMAP.md` : undefined, - }); - } - - // Parse slices from roadmap table or filesystem - const sliceIds = findSliceIds(sfRoot, milestoneId); - for (const sliceId of sliceIds) { - try { - parseSingleSlice(sfRoot, milestoneId, sliceId, milestoneNodeId, nodes, edges); - } catch { - // Skip this slice on any error - } - } -} - -function parseSingleSlice( - sfRoot: string, - milestoneId: string, - sliceId: string, - milestoneNodeId: string, - nodes: GraphNode[], - edges: GraphEdge[], -): void { - const sDir = resolveSliceDir(sfRoot, milestoneId, sliceId); - if (!sDir) return; - - const sliceNodeId = `slice:${milestoneId}:${sliceId}`; - - // Try to read the slice plan - const planPath = join(sDir, `${sliceId}-PLAN.md`); - let sliceTitle = `${milestoneId}/${sliceId}`; - let planContent: string | null = null; - - if (existsSync(planPath)) { - try { - planContent = readFileSync(planPath, 'utf-8'); - const titleMatch = planContent.match(/^#\s+[A-Z]\d+:\s+(.+)/m); - if (titleMatch) sliceTitle = `${sliceId}: ${titleMatch[1].trim()}`; - } catch { - // Use default title - } - } - - nodes.push({ - id: sliceNodeId, - label: sliceTitle, - type: 'slice', - confidence: 'EXTRACTED', - sourceFile: planContent ? `milestones/${milestoneId}/slices/${sliceId}/${sliceId}-PLAN.md` : undefined, - }); - - // Edge: milestone contains slice - edges.push({ - from: milestoneNodeId, - to: sliceNodeId, - type: 'contains', - confidence: 'EXTRACTED', - }); - - // Parse tasks from the slice plan - if (planContent) { - parseTasksFromPlan(planContent, milestoneId, sliceId, sliceNodeId, nodes, edges); - } -} - -function parseTasksFromPlan( - content: string, - milestoneId: string, - sliceId: string, - sliceNodeId: string, - nodes: GraphNode[], - edges: GraphEdge[], -): void { - // Match lines like: - [ ] **T01: Title** — description - const taskPattern = /[-*]\s+\[[ x]\]\s+\*\*(T\d+):\s*([^*]+)\*\*/g; - let match: RegExpExecArray | null; - - while ((match = taskPattern.exec(content)) !== null) { - const [, taskId, taskTitle] = match; - const taskNodeId = `task:${milestoneId}:${sliceId}:${taskId}`; - - nodes.push({ - id: taskNodeId, - label: `${taskId}: ${taskTitle.trim()}`, - type: 'task', - confidence: 'EXTRACTED', - }); - - edges.push({ - from: sliceNodeId, - to: taskNodeId, - type: 'contains', - confidence: 'EXTRACTED', - }); - } -} - -// --------------------------------------------------------------------------- -// LEARNINGS.md parser -// --------------------------------------------------------------------------- - -/** - * Parse all *-LEARNINGS.md files found in milestone directories. - * Extracts Decisions, Lessons, Patterns, and Surprises as typed graph nodes. - * Surprises are mapped to the 'lesson' NodeType (no distinct type exists). - * Parse errors per file are caught — the file is skipped, never rethrows. - */ -function parseLearningsFiles(sfRoot: string, nodes: GraphNode[], edges: GraphEdge[]): void { - const milestoneIds = findMilestoneIds(sfRoot); - - for (const milestoneId of milestoneIds) { - try { - parseSingleLearningsFile(sfRoot, milestoneId, nodes, edges); - } catch { - // Skip this milestone's LEARNINGS.md on any error - } - } -} - -function parseSingleLearningsFile( - sfRoot: string, - milestoneId: string, - nodes: GraphNode[], - edges: GraphEdge[], -): void { - const mDir = resolveMilestoneDir(sfRoot, milestoneId); - if (!mDir) return; - - const learningsPath = join(mDir, `${milestoneId}-LEARNINGS.md`); - if (!existsSync(learningsPath)) return; - - let content: string; - try { - content = readFileSync(learningsPath, 'utf-8'); - } catch { - return; - } - - // Strip YAML frontmatter if present - const withoutFrontmatter = content.replace(/^---[\s\S]*?---\n?/, ''); - - const milestoneNodeId = `milestone:${milestoneId}`; - const sourceFile = `milestones/${milestoneId}/${milestoneId}-LEARNINGS.md`; - - // Parse each section: [sectionName, nodeType, idPrefix] - const sections: Array<[string, NodeType, string]> = [ - ['Decisions', 'decision', 'decision'], - ['Lessons', 'lesson', 'lesson'], - ['Patterns', 'pattern', 'pattern'], - ['Surprises', 'lesson', 'surprise'], - ]; - - for (const [sectionName, nodeType, idPrefix] of sections) { - const sectionMatch = withoutFrontmatter.match( - new RegExp(`##\\s+${sectionName}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i'), - ); - if (!sectionMatch) continue; - - const sectionContent = sectionMatch[1]; - parseLearningsSection( - sectionContent, - milestoneId, - idPrefix, - nodeType, - milestoneNodeId, - sourceFile, - nodes, - edges, - ); - } -} - -function parseLearningsSection( - sectionContent: string, - milestoneId: string, - idPrefix: string, - nodeType: NodeType, - milestoneNodeId: string, - sourceFile: string, - nodes: GraphNode[], - edges: GraphEdge[], -): void { - // Each item is a bullet line starting with "- " followed by optional - // indented "Source: ..." line. - // We collect bullet items and their associated source attribution. - const lines = sectionContent.split('\n'); - let itemIndex = 0; - let currentText: string | null = null; - let currentSource: string | null = null; - - const flushItem = (): void => { - if (!currentText) return; - itemIndex += 1; - const nodeId = `${idPrefix}:${milestoneId}:${itemIndex}`; - const description = currentSource ? `${currentSource}` : undefined; - - nodes.push({ - id: nodeId, - label: currentText, - type: nodeType, - description, - confidence: 'EXTRACTED', - sourceFile, - }); - - // Edge: milestone relates_to this learning node - edges.push({ - from: milestoneNodeId, - to: nodeId, - type: 'relates_to', - confidence: 'EXTRACTED', - }); - - currentText = null; - currentSource = null; - }; - - for (const line of lines) { - const bulletMatch = line.match(/^[-*]\s+(.+)/); - if (bulletMatch) { - flushItem(); - currentText = bulletMatch[1].trim(); - continue; - } - - // Indented source attribution: " Source: ..." - const sourceMatch = line.match(/^\s+Source:\s+(.+)/i); - if (sourceMatch && currentText !== null) { - currentSource = `Source: ${sourceMatch[1].trim()}`; - continue; - } - - // Continuation of current item text (indented non-source line) - const continuationMatch = line.match(/^\s{2,}(.+)/); - if (continuationMatch && currentText !== null && currentSource === null) { - currentText += ' ' + continuationMatch[1].trim(); - } - } - - flushItem(); -} - -// --------------------------------------------------------------------------- -// buildGraph -// --------------------------------------------------------------------------- - -/** - * Build a KnowledgeGraph by parsing all .sf/ artifacts. - * - * Parse errors in any single artifact are caught — the artifact is skipped - * and never causes buildGraph() to throw. - */ -export async function buildGraph(projectDir: string): Promise { - const sfRoot = resolveSFRoot(resolve(projectDir)); - - const nodes: GraphNode[] = []; - const edges: GraphEdge[] = []; - - // Each parser is wrapped so a crash in one never stops others - const parsers: Array<(g: string, n: GraphNode[], e: GraphEdge[]) => void> = [ - parseStateFile, - parseKnowledgeFile, - parseMilestoneFiles, - parseLearningsFiles, - ]; - - for (const parser of parsers) { - try { - parser(sfRoot, nodes, edges); - } catch { - // Parsing error — skip this artifact, mark as ambiguous - nodes.push({ - id: `error:${parser.name}:${Date.now()}`, - label: `Parse error in ${parser.name}`, - type: 'concept', - confidence: 'AMBIGUOUS', - }); - } - } - - // Deduplicate nodes by id (keep first occurrence) - const seen = new Set(); - const dedupedNodes = nodes.filter((n) => { - if (seen.has(n.id)) return false; - seen.add(n.id); - return true; - }); - - return { - nodes: dedupedNodes, - edges, - builtAt: new Date().toISOString(), - }; -} - -// --------------------------------------------------------------------------- -// writeGraph — atomic write via tmp + rename -// --------------------------------------------------------------------------- - -/** - * Write the graph to .sf/graphs/graph.json atomically. - * - * Writes to graph.tmp.json first, then renames to graph.json. - * Creates the graphs/ directory if it does not exist. - */ -export async function writeGraph(sfRoot: string, graph: KnowledgeGraph): Promise { - const dir = graphsDir(sfRoot); - mkdirSync(dir, { recursive: true }); - - const tmp = graphTmpPath(sfRoot); - const final = graphJsonPath(sfRoot); - - writeFileSync(tmp, JSON.stringify(graph, null, 2), 'utf-8'); - renameSync(tmp, final); -} - -// --------------------------------------------------------------------------- -// writeSnapshot -// --------------------------------------------------------------------------- - -/** - * Copy the current graph.json to .last-build-snapshot.json. - * Adds a snapshotAt timestamp to the copy. - */ -export async function writeSnapshot(sfRoot: string): Promise { - const src = graphJsonPath(sfRoot); - if (!existsSync(src)) return; - - const dir = graphsDir(sfRoot); - mkdirSync(dir, { recursive: true }); - - const raw = readFileSync(src, 'utf-8'); - let graph: KnowledgeGraph; - try { - graph = JSON.parse(raw) as KnowledgeGraph; - } catch { - return; - } - const snapshot = { ...graph, snapshotAt: new Date().toISOString() }; - - writeFileSync(snapshotPath(sfRoot), JSON.stringify(snapshot, null, 2), 'utf-8'); -} - -// --------------------------------------------------------------------------- -// graphStatus -// --------------------------------------------------------------------------- - -/** - * Return status of the graph: whether it exists, its age, and whether it is stale. - * Stale means builtAt is older than 24 hours. - */ -export async function graphStatus(projectDir: string): Promise { - const sfRoot = resolveSFRoot(resolve(projectDir)); - const graphPath = graphJsonPath(sfRoot); - - if (!existsSync(graphPath)) { - return { exists: false }; - } - - try { - const raw = readFileSync(graphPath, 'utf-8'); - const graph = JSON.parse(raw) as KnowledgeGraph; - - const builtAt = graph.builtAt; - const ageMs = Date.now() - new Date(builtAt).getTime(); - const ageHours = ageMs / (1000 * 60 * 60); - const stale = ageHours > 24; - - return { - exists: true, - lastBuild: builtAt, - nodeCount: graph.nodes.length, - edgeCount: graph.edges.length, - stale, - ageHours, - }; - } catch { - return { exists: false }; - } -} - -// --------------------------------------------------------------------------- -// applyBudget — trim edges to stay within token budget -// --------------------------------------------------------------------------- - -/** - * Given a set of seed node IDs and the full graph, apply BFS to collect - * reachable nodes and edges. Trims AMBIGUOUS edges first, then INFERRED, - * stopping when the estimated token count drops within budget. - * - * Budget is a rough token estimate: 1 node ≈ 20 tokens, 1 edge ≈ 10 tokens. - */ -function applyBudget( - graph: KnowledgeGraph, - seedIds: Set, - budget: number, -): { nodes: GraphNode[]; edges: GraphEdge[] } { - // BFS to collect reachable nodes (start from seeds) - const reachable = new Set(seedIds); - const queue = [...seedIds]; - - while (queue.length > 0) { - const current = queue.shift()!; - for (const edge of graph.edges) { - if (edge.from === current && !reachable.has(edge.to)) { - reachable.add(edge.to); - queue.push(edge.to); - } - } - } - - let resultNodes = graph.nodes.filter((n) => reachable.has(n.id)); - let resultEdges = graph.edges.filter( - (e) => reachable.has(e.from) && reachable.has(e.to), - ); - - // Estimate tokens and trim if over budget - // Trim AMBIGUOUS edges first, then INFERRED - const estimate = (): number => - resultNodes.length * 20 + resultEdges.length * 10; - - if (estimate() > budget) { - resultEdges = resultEdges.filter((e) => e.confidence !== 'AMBIGUOUS'); - } - if (estimate() > budget) { - resultEdges = resultEdges.filter((e) => e.confidence !== 'INFERRED'); - } - if (estimate() > budget) { - // Hard trim — keep only seed nodes and their EXTRACTED edges - const seedNodes = resultNodes.filter((n) => seedIds.has(n.id)); - const seedEdges = resultEdges.filter( - (e) => seedIds.has(e.from) && e.confidence === 'EXTRACTED', - ); - return { nodes: seedNodes, edges: seedEdges }; - } - - return { nodes: resultNodes, edges: resultEdges }; -} - -// --------------------------------------------------------------------------- -// graphQuery -// --------------------------------------------------------------------------- - -/** - * Query the graph for nodes matching a term (case-insensitive on label + description). - * BFS from seed nodes, applying budget trimming. - * - * Reads from the pre-built graph.json. Falls back to an empty result if no - * graph exists. - */ -export async function graphQuery( - projectDir: string, - term: string, - budget = 4000, -): Promise { - const sfRoot = resolveSFRoot(resolve(projectDir)); - const graphPath = graphJsonPath(sfRoot); - - if (!existsSync(graphPath)) { - return { nodes: [], edges: [], term, budget }; - } - - let graph: KnowledgeGraph; - try { - const raw = readFileSync(graphPath, 'utf-8'); - graph = JSON.parse(raw) as KnowledgeGraph; - } catch { - return { nodes: [], edges: [], term, budget }; - } - - if (!term || term.trim() === '') { - // Empty term — return empty result - return { nodes: [], edges: [], term, budget }; - } - - const lower = term.toLowerCase(); - - // Find seed nodes that match the term - const seedIds = new Set( - graph.nodes - .filter((n) => { - const labelMatch = n.label.toLowerCase().includes(lower); - const descMatch = n.description?.toLowerCase().includes(lower) ?? false; - return labelMatch || descMatch; - }) - .map((n) => n.id), - ); - - if (seedIds.size === 0) { - return { nodes: [], edges: [], term, budget }; - } - - const result = applyBudget(graph, seedIds, budget); - return { ...result, term, budget }; -} - -// --------------------------------------------------------------------------- -// graphDiff -// --------------------------------------------------------------------------- - -/** - * Compare the current graph.json with .last-build-snapshot.json. - * Returns added/removed/changed nodes and added/removed edges. - * - * If no snapshot exists, returns empty diff arrays. - */ -export async function graphDiff(projectDir: string): Promise { - const sfRoot = resolveSFRoot(resolve(projectDir)); - const empty: GraphDiffResult = { - nodes: { added: [], removed: [], changed: [] }, - edges: { added: [], removed: [] }, - }; - - const graphPath = graphJsonPath(sfRoot); - const snap = snapshotPath(sfRoot); - - if (!existsSync(graphPath)) return empty; - if (!existsSync(snap)) return empty; - - let current: KnowledgeGraph; - let snapshot: KnowledgeGraph; - - try { - current = JSON.parse(readFileSync(graphPath, 'utf-8')) as KnowledgeGraph; - } catch { - return empty; - } - - try { - snapshot = JSON.parse(readFileSync(snap, 'utf-8')) as KnowledgeGraph; - } catch { - return empty; - } - - const currentNodeIds = new Set(current.nodes.map((n) => n.id)); - const snapshotNodeIds = new Set(snapshot.nodes.map((n) => n.id)); - - const added = current.nodes.filter((n) => !snapshotNodeIds.has(n.id)).map((n) => n.id); - const removed = snapshot.nodes.filter((n) => !currentNodeIds.has(n.id)).map((n) => n.id); - - // Changed: same id but different label or description - const snapshotNodeMap = new Map(snapshot.nodes.map((n) => [n.id, n])); - const changed = current.nodes - .filter((n) => { - const snap = snapshotNodeMap.get(n.id); - if (!snap) return false; - return n.label !== snap.label || n.description !== snap.description; - }) - .map((n) => n.id); - - // Edges — compare by string key "from->to:type" - const edgeKey = (e: GraphEdge): string => `${e.from}->${e.to}:${e.type}`; - const currentEdgeKeys = new Set(current.edges.map(edgeKey)); - const snapshotEdgeKeys = new Set(snapshot.edges.map(edgeKey)); - - const edgesAdded = current.edges.filter((e) => !snapshotEdgeKeys.has(edgeKey(e))).map(edgeKey); - const edgesRemoved = snapshot.edges.filter((e) => !currentEdgeKeys.has(edgeKey(e))).map(edgeKey); - - return { - nodes: { added, removed, changed }, - edges: { added: edgesAdded, removed: edgesRemoved }, - }; -} +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"; diff --git a/packages/mcp-server/src/readers/index.ts b/packages/mcp-server/src/readers/index.ts index d159fff41..a122c7136 100644 --- a/packages/mcp-server/src/readers/index.ts +++ b/packages/mcp-server/src/readers/index.ts @@ -1,28 +1,41 @@ // SF MCP Server — readers barrel export // Copyright (c) 2026 Jeremy McSpadden -export { resolveSFRoot, resolveRootFile } from './paths.js'; -export { readProgress } from './state.js'; -export type { ProgressResult } from './state.js'; -export { readRoadmap } from './roadmap.js'; -export type { RoadmapResult, MilestoneInfo, SliceInfo, TaskInfo } from './roadmap.js'; -export { readHistory } from './metrics.js'; -export type { HistoryResult, MetricsUnit } from './metrics.js'; -export { readCaptures } from './captures.js'; -export type { CapturesResult, CaptureEntry } from './captures.js'; -export { readKnowledge } from './knowledge.js'; -export type { KnowledgeResult, KnowledgeEntry } from './knowledge.js'; -export { runDoctorLite } from './doctor-lite.js'; -export type { DoctorResult, DoctorIssue } from './doctor-lite.js'; -export { buildGraph, writeGraph, writeSnapshot, graphStatus, graphQuery, graphDiff } from './graph.js'; +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 { - NodeType, - EdgeType, - ConfidenceTier, - GraphNode, - GraphEdge, - KnowledgeGraph, - GraphStatusResult, - GraphQueryResult, - GraphDiffResult, -} from './graph.js'; + 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"; diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index ed7bb4eca..2b2ece820 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -11,46 +11,99 @@ * src/mcp-server.ts in the main package). */ -import { readFile, readdir, stat } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; -import { z } from 'zod'; -import type { SessionManager } from './session-manager.js'; -import { readProgress } from './readers/state.js'; -import { readRoadmap } from './readers/roadmap.js'; -import { readHistory } from './readers/metrics.js'; -import { readCaptures } from './readers/captures.js'; -import { readKnowledge } from './readers/knowledge.js'; -import { buildGraph, writeGraph, writeSnapshot, graphStatus, graphQuery, graphDiff } from './readers/graph.js'; -import { resolveSFRoot } from './readers/paths.js'; -import { runDoctorLite } from './readers/doctor-lite.js'; -import { registerWorkflowTools } from './workflow-tools.js'; -import { applySecrets, checkExistingEnvKeys, detectDestination, resolveProjectEnvFilePath } from './env-writer.js'; +import { readdir, readFile, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { + buildQuestionStructuredContent, + formatRoundResultForTool, + type Question, + type RoundResult, + roundResultFromElicitationContent, +} from "@singularity-forge/pi-agent-core"; +import { z } from "zod"; +import { + applySecrets, + checkExistingEnvKeys, + detectDestination, + resolveProjectEnvFilePath, +} from "./env-writer.js"; +import { readCaptures } from "./readers/captures.js"; +import { runDoctorLite } from "./readers/doctor-lite.js"; +import { + buildGraph, + graphDiff, + graphQuery, + graphStatus, + writeGraph, + writeSnapshot, +} from "./readers/graph.js"; +import { readKnowledge } from "./readers/knowledge.js"; +import { readHistory } from "./readers/metrics.js"; +import { resolveSFRoot } from "./readers/paths.js"; +import { readRoadmap } from "./readers/roadmap.js"; +import { readProgress } from "./readers/state.js"; +import type { SessionManager } from "./session-manager.js"; +import { registerWorkflowTools } from "./workflow-tools.js"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const MCP_PKG = '@modelcontextprotocol/sdk'; -const SERVER_NAME = 'sf'; -const SERVER_VERSION = '2.53.0'; +const MCP_PKG = "@modelcontextprotocol/sdk"; +const SERVER_NAME = "sf"; +const SERVER_VERSION = "2.53.0"; // --------------------------------------------------------------------------- // Tool result helpers // --------------------------------------------------------------------------- /** Wrap a JSON-serializable value as MCP tool content. */ -function jsonContent(data: unknown): { content: Array<{ type: 'text'; text: string }> } { - return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; +function jsonContent(data: unknown): { + content: Array<{ type: "text"; text: string }>; +} { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + }; } /** Return an MCP error response. */ -function errorContent(message: string): { isError: true; content: Array<{ type: 'text'; text: string }> } { - return { isError: true, content: [{ type: 'text' as const, text: message }] }; +function errorContent(message: string): { + isError: true; + content: Array<{ type: "text"; text: string }>; +} { + return { isError: true, content: [{ type: "text" as const, text: message }] }; } /** Return raw text content without JSON wrapping. */ -function textContent(text: string): { content: Array<{ type: 'text'; text: string }> } { - return { content: [{ type: 'text' as const, text }] }; +function textContent(text: string): { + content: Array<{ type: "text"; text: string }>; +} { + return { content: [{ type: "text" as const, text }] }; +} + +function askUserQuestionsContent( + questions: AskUserQuestion[], + response: RoundResult | null, + cancelled: boolean, +): { + content: Array<{ type: "text"; text: string }>; + structuredContent: Record; +} { + return { + content: [ + { + type: "text" as const, + text: response + ? formatRoundResultForTool(response) + : "ask_user_questions was cancelled before receiving a response", + }, + ], + structuredContent: buildQuestionStructuredContent( + questions as Question[], + response, + cancelled, + ) as unknown as Record, + }; } // --------------------------------------------------------------------------- @@ -64,104 +117,113 @@ function textContent(text: string): { content: Array<{ type: 'text'; text: strin * Accepts common synonyms so the MCP client can pass intuitive values. */ const QUERY_FIELDS = { - all: ['state', 'project', 'requirements', 'milestones'] as const, - state: ['state'] as const, - status: ['state'] as const, - project: ['project'] as const, - requirements: ['requirements'] as const, - milestones: ['milestones'] as const, + all: ["state", "project", "requirements", "milestones"] as const, + state: ["state"] as const, + status: ["state"] as const, + project: ["project"] as const, + requirements: ["requirements"] as const, + milestones: ["milestones"] as const, } as const; type QueryCategory = keyof typeof QUERY_FIELDS; type ProjectStateField = (typeof QUERY_FIELDS)[QueryCategory][number]; function normalizeQuery(query: string | undefined): QueryCategory { - const key = (query ?? 'all').trim().toLowerCase(); - if (key in QUERY_FIELDS) return key as QueryCategory; - return 'all'; + const key = (query ?? "all").trim().toLowerCase(); + if (key in QUERY_FIELDS) return key as QueryCategory; + return "all"; } -async function readProjectState(projectDir: string, query: string | undefined): Promise> { - const sfDir = join(resolve(projectDir), '.sf'); - const category = normalizeQuery(query); - const wanted = new Set(QUERY_FIELDS[category]); +async function readProjectState( + projectDir: string, + query: string | undefined, +): Promise> { + const sfDir = join(resolve(projectDir), ".sf"); + const category = normalizeQuery(query); + const wanted = new Set(QUERY_FIELDS[category]); - const result: Record = { - projectDir: resolve(projectDir), - query: category, - }; + const result: Record = { + projectDir: resolve(projectDir), + query: category, + }; - if (wanted.has('state')) { - try { - result.state = await readFile(join(sfDir, 'STATE.md'), 'utf-8'); - } catch { - result.state = null; - } - } + if (wanted.has("state")) { + try { + result.state = await readFile(join(sfDir, "STATE.md"), "utf-8"); + } catch { + result.state = null; + } + } - if (wanted.has('project')) { - try { - result.project = await readFile(join(sfDir, 'PROJECT.md'), 'utf-8'); - } catch { - result.project = null; - } - } + if (wanted.has("project")) { + try { + result.project = await readFile(join(sfDir, "PROJECT.md"), "utf-8"); + } catch { + result.project = null; + } + } - if (wanted.has('requirements')) { - try { - result.requirements = await readFile(join(sfDir, 'REQUIREMENTS.md'), 'utf-8'); - } catch { - result.requirements = null; - } - } + if (wanted.has("requirements")) { + try { + result.requirements = await readFile( + join(sfDir, "REQUIREMENTS.md"), + "utf-8", + ); + } catch { + result.requirements = null; + } + } - if (wanted.has('milestones')) { - const milestonesDir = join(sfDir, 'milestones'); - try { - const entries = await readdir(milestonesDir, { withFileTypes: true }); - const milestones: Array<{ id: string; hasRoadmap: boolean; hasSummary: boolean }> = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const mDir = join(milestonesDir, entry.name); - const hasRoadmap = await fileExists(join(mDir, `${entry.name}-ROADMAP.md`)); - const hasSummary = await fileExists(join(mDir, `${entry.name}-SUMMARY.md`)); - milestones.push({ id: entry.name, hasRoadmap, hasSummary }); - } - result.milestones = milestones; - } catch { - result.milestones = []; - } - } + if (wanted.has("milestones")) { + const milestonesDir = join(sfDir, "milestones"); + try { + const entries = await readdir(milestonesDir, { withFileTypes: true }); + const milestones: Array<{ + id: string; + hasRoadmap: boolean; + hasSummary: boolean; + }> = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const mDir = join(milestonesDir, entry.name); + const hasRoadmap = await fileExists( + join(mDir, `${entry.name}-ROADMAP.md`), + ); + const hasSummary = await fileExists( + join(mDir, `${entry.name}-SUMMARY.md`), + ); + milestones.push({ id: entry.name, hasRoadmap, hasSummary }); + } + result.milestones = milestones; + } catch { + result.milestones = []; + } + } - return result; + return result; } async function fileExists(path: string): Promise { - try { - await stat(path); - return true; - } catch { - return false; - } + try { + await stat(path); + return true; + } catch { + return false; + } } // --------------------------------------------------------------------------- // MCP Server type — minimal interface for the dynamically-imported McpServer // --------------------------------------------------------------------------- -interface ElicitResult { - action: 'accept' | 'decline' | 'cancel'; - content?: Record; -} - interface ElicitRequestFormParams { - mode?: 'form'; - message: string; - requestedSchema: { - type: 'object'; - properties: Record>; - required?: string[]; - }; + mode?: "form"; + message: string; + requestedSchema: { + type: "object"; + properties: Record>; + required?: string[]; + }; } /** @@ -171,165 +233,161 @@ interface ElicitRequestFormParams { * Tools that can actually be stopped mid-flight should honour `signal`. */ export interface McpToolExtra { - signal?: AbortSignal; - requestId?: string | number; - sendNotification?: (notification: unknown) => void | Promise; + signal?: AbortSignal; + requestId?: string | number; + sendNotification?: (notification: unknown) => void | Promise; } interface McpServerInstance { - tool( - name: string, - description: string, - params: Record, - handler: (args: Record, extra?: McpToolExtra) => Promise, - ): unknown; - server: { - elicitInput( - params: AskUserQuestionsElicitRequest | ElicitRequestFormParams, - options?: unknown, - ): Promise; - }; - connect(transport: unknown): Promise; - close(): Promise; + tool( + name: string, + description: string, + params: Record, + handler: ( + args: Record, + extra?: McpToolExtra, + ) => Promise, + ): unknown; + server: { + elicitInput( + params: AskUserQuestionsElicitRequest | ElicitRequestFormParams, + options?: unknown, + ): Promise; + }; + connect(transport: unknown): Promise; + close(): Promise; } interface AskUserQuestionOption { - label: string; - description: string; + label: string; + description: string; } interface AskUserQuestion { - id: string; - header: string; - question: string; - options: AskUserQuestionOption[]; - allowMultiple?: boolean; + id: string; + header: string; + question: string; + options: AskUserQuestionOption[]; + allowMultiple?: boolean; } interface AskUserQuestionsParams { - questions: AskUserQuestion[]; + questions: AskUserQuestion[]; } type AskUserQuestionsContentValue = string | number | boolean | string[]; interface AskUserQuestionsElicitResult { - action: 'accept' | 'decline' | 'cancel'; - content?: Record; + action: "accept" | "decline" | "cancel"; + content?: Record; } interface AskUserQuestionsElicitRequest { - mode: 'form'; - message: string; - requestedSchema: { - type: 'object'; - properties: Record>; - required?: string[]; - }; + mode: "form"; + message: string; + requestedSchema: { + type: "object"; + properties: Record>; + required?: string[]; + }; } -const OTHER_OPTION_LABEL = 'None of the above'; +const OTHER_OPTION_LABEL = "None of the above"; -function normalizeAskUserQuestionsNote(value: AskUserQuestionsContentValue | undefined): string { - return typeof value === 'string' ? value.trim() : ''; +function validateAskUserQuestionsPayload( + questions: AskUserQuestion[], +): string | null { + if (questions.length === 0 || questions.length > 3) { + return "Error: questions must contain 1-3 items"; + } + + for (const question of questions) { + if (!question.options || question.options.length === 0) { + return `Error: ask_user_questions requires non-empty options for every question (question "${question.id}" has none)`; + } + } + + return null; } -function normalizeAskUserQuestionsAnswers( - value: AskUserQuestionsContentValue | undefined, - allowMultiple: boolean, -): string[] { - if (allowMultiple) { - return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; - } +export function buildAskUserQuestionsElicitRequest( + questions: AskUserQuestion[], +): AskUserQuestionsElicitRequest { + const properties: Record> = {}; + const required = questions.map((question) => question.id); - return typeof value === 'string' && value.length > 0 ? [value] : []; -} + for (const question of questions) { + if (question.allowMultiple) { + properties[question.id] = { + type: "array", + title: question.header, + description: question.question, + minItems: 1, + maxItems: question.options.length, + items: { + anyOf: question.options.map((option) => ({ + const: option.label, + title: option.label, + })), + }, + }; + continue; + } -function validateAskUserQuestionsPayload(questions: AskUserQuestion[]): string | null { - if (questions.length === 0 || questions.length > 3) { - return 'Error: questions must contain 1-3 items'; - } + properties[question.id] = { + type: "string", + title: question.header, + description: question.question, + oneOf: [ + ...question.options, + { + label: OTHER_OPTION_LABEL, + description: "Choose this when the listed options do not fit.", + }, + ].map((option) => ({ + const: option.label, + title: option.label, + })), + }; - for (const question of questions) { - if (!question.options || question.options.length === 0) { - return `Error: ask_user_questions requires non-empty options for every question (question "${question.id}" has none)`; - } - } + properties[`${question.id}__note`] = { + type: "string", + title: `${question.header} Note`, + description: `Optional note for "${OTHER_OPTION_LABEL}".`, + maxLength: 500, + }; + } - return null; -} - -export function buildAskUserQuestionsElicitRequest(questions: AskUserQuestion[]): AskUserQuestionsElicitRequest { - const properties: Record> = {}; - const required = questions.map((question) => question.id); - - for (const question of questions) { - if (question.allowMultiple) { - properties[question.id] = { - type: 'array', - title: question.header, - description: question.question, - minItems: 1, - maxItems: question.options.length, - items: { - anyOf: question.options.map((option) => ({ - const: option.label, - title: option.label, - })), - }, - }; - continue; - } - - properties[question.id] = { - type: 'string', - title: question.header, - description: question.question, - oneOf: [...question.options, { label: OTHER_OPTION_LABEL, description: 'Choose this when the listed options do not fit.' }].map((option) => ({ - const: option.label, - title: option.label, - })), - }; - - properties[`${question.id}__note`] = { - type: 'string', - title: `${question.header} Note`, - description: `Optional note for "${OTHER_OPTION_LABEL}".`, - maxLength: 500, - }; - } - - return { - mode: 'form', - message: 'Please answer the following question(s). For single-select questions, choose "None of the above" and add a note if the provided options do not fit.', - requestedSchema: { - type: 'object', - properties, - required, - }, - }; + return { + mode: "form", + message: + 'Please answer the following question(s). For single-select questions, choose "None of the above" and add a note if the provided options do not fit.', + requestedSchema: { + type: "object", + properties, + required, + }, + }; } export function formatAskUserQuestionsElicitResult( - questions: AskUserQuestion[], - result: AskUserQuestionsElicitResult, + questions: AskUserQuestion[], + result: AskUserQuestionsElicitResult, ): string { - const answers: Record = {}; - const content = result.content ?? {}; + return formatRoundResultForTool( + buildAskUserQuestionsRoundResult(questions, result), + ); +} - for (const question of questions) { - const answerList = normalizeAskUserQuestionsAnswers(content[question.id], !!question.allowMultiple); - - if (!question.allowMultiple && answerList[0] === OTHER_OPTION_LABEL) { - const note = normalizeAskUserQuestionsNote(content[`${question.id}__note`]); - if (note) { - answerList.push(`user_note: ${note}`); - } - } - - answers[question.id] = { answers: answerList }; - } - - return JSON.stringify({ answers }); +export function buildAskUserQuestionsRoundResult( + questions: AskUserQuestion[], + result: AskUserQuestionsElicitResult, +): RoundResult { + return roundResultFromElicitationContent( + questions as Question[], + result, + OTHER_OPTION_LABEL, + ); } // --------------------------------------------------------------------------- @@ -343,547 +401,684 @@ export function formatAskUserQuestionsElicitResult( * Uses dynamic imports for the MCP SDK to avoid TS subpath resolution issues. */ export async function createMcpServer(sessionManager: SessionManager): Promise<{ - server: McpServerInstance; + server: McpServerInstance; }> { - // Dynamic import — same workaround as src/mcp-server.ts - const mcpMod = await import(`${MCP_PKG}/server/mcp.js`); - const McpServer = mcpMod.McpServer; + // Dynamic import — same workaround as src/mcp-server.ts + const mcpMod = await import(`${MCP_PKG}/server/mcp.js`); + const McpServer = mcpMod.McpServer; - const server: McpServerInstance = new McpServer( - { name: SERVER_NAME, version: SERVER_VERSION }, - { capabilities: { tools: {}, elicitation: {} } }, - ); + const server: McpServerInstance = new McpServer( + { name: SERVER_NAME, version: SERVER_VERSION }, + { capabilities: { tools: {}, elicitation: {} } }, + ); - // ----------------------------------------------------------------------- - // sf_execute — start a new SF auto-mode session. - // - // If the JSON-RPC request is aborted while the session is starting (or - // immediately after), we cancel the session so we don't leak a background - // RpcClient process. Once the session is running the caller should use - // `sf_cancel` to stop it via sessionId. - // ----------------------------------------------------------------------- - server.tool( - 'sf_execute', - 'Start a SF auto-mode session for a project directory. Returns a sessionId for tracking.', - { - projectDir: z.string().describe('Absolute path to the project directory'), - command: z.string().optional().describe('Command to send (default: "/sf autonomous")'), - model: z.string().optional().describe('Model ID override'), - bare: z.boolean().optional().describe('Run in bare mode (skip user config)'), - }, - async (args: Record, extra?: McpToolExtra) => { - const { projectDir, command, model, bare } = args as { - projectDir: string; command?: string; model?: string; bare?: boolean; - }; - try { - const sessionId = await sessionManager.startSession(projectDir, { command, model, bare }); + // ----------------------------------------------------------------------- + // sf_execute — start a new SF auto-mode session. + // + // If the JSON-RPC request is aborted while the session is starting (or + // immediately after), we cancel the session so we don't leak a background + // RpcClient process. Once the session is running the caller should use + // `sf_cancel` to stop it via sessionId. + // ----------------------------------------------------------------------- + server.tool( + "sf_execute", + "Start a SF auto-mode session for a project directory. Returns a sessionId for tracking.", + { + projectDir: z.string().describe("Absolute path to the project directory"), + command: z + .string() + .optional() + .describe('Command to send (default: "/sf autonomous")'), + model: z.string().optional().describe("Model ID override"), + bare: z + .boolean() + .optional() + .describe("Run in bare mode (skip user config)"), + }, + async (args: Record, extra?: McpToolExtra) => { + const { projectDir, command, model, bare } = args as { + projectDir: string; + command?: string; + model?: string; + bare?: boolean; + }; + try { + const sessionId = await sessionManager.startSession(projectDir, { + command, + model, + bare, + }); - // If the client aborted while startSession was running, cancel the - // newly-created session rather than leaving an orphaned process. - if (extra?.signal?.aborted) { - await sessionManager.cancelSession(sessionId).catch(() => { /* swallow */ }); - return errorContent('sf_execute aborted by client before returning'); - } + // If the client aborted while startSession was running, cancel the + // newly-created session rather than leaving an orphaned process. + if (extra?.signal?.aborted) { + await sessionManager.cancelSession(sessionId).catch(() => { + /* swallow */ + }); + return errorContent("sf_execute aborted by client before returning"); + } - return jsonContent({ sessionId, status: 'started' }); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + return jsonContent({ sessionId, status: "started" }); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ----------------------------------------------------------------------- - // sf_status — poll session status - // ----------------------------------------------------------------------- - server.tool( - 'sf_status', - 'Get the current status of a SF session including progress, recent events, and pending blockers.', - { - sessionId: z.string().describe('Session ID returned from sf_execute'), - }, - async (args: Record) => { - const { sessionId } = args as { sessionId: string }; - try { - const session = sessionManager.getSession(sessionId); - if (!session) return errorContent(`Session not found: ${sessionId}`); + // ----------------------------------------------------------------------- + // sf_status — poll session status + // ----------------------------------------------------------------------- + server.tool( + "sf_status", + "Get the current status of a SF session including progress, recent events, and pending blockers.", + { + sessionId: z.string().describe("Session ID returned from sf_execute"), + }, + async (args: Record) => { + const { sessionId } = args as { sessionId: string }; + try { + const session = sessionManager.getSession(sessionId); + if (!session) return errorContent(`Session not found: ${sessionId}`); - const durationMs = Date.now() - session.startTime; - const toolCallCount = session.events.filter( - (e) => (e as Record).type === 'tool_use' || - (e as Record).type === 'tool_execution_start' - ).length; + const durationMs = Date.now() - session.startTime; + const toolCallCount = session.events.filter( + (e) => + (e as Record).type === "tool_use" || + (e as Record).type === "tool_execution_start", + ).length; - return jsonContent({ - status: session.status, - progress: { - eventCount: session.events.length, - toolCalls: toolCallCount, - }, - recentEvents: session.events.slice(-10), - pendingBlocker: session.pendingBlocker - ? { - id: session.pendingBlocker.id, - method: session.pendingBlocker.method, - message: session.pendingBlocker.message, - } - : null, - cost: session.cost, - durationMs, - }); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + return jsonContent({ + status: session.status, + progress: { + eventCount: session.events.length, + toolCalls: toolCallCount, + }, + recentEvents: session.events.slice(-10), + pendingBlocker: session.pendingBlocker + ? { + id: session.pendingBlocker.id, + method: session.pendingBlocker.method, + message: session.pendingBlocker.message, + } + : null, + cost: session.cost, + durationMs, + }); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ----------------------------------------------------------------------- - // sf_result — get accumulated session result - // ----------------------------------------------------------------------- - server.tool( - 'sf_result', - 'Get the result of a SF session. Returns partial results if the session is still running.', - { - sessionId: z.string().describe('Session ID returned from sf_execute'), - }, - async (args: Record) => { - const { sessionId } = args as { sessionId: string }; - try { - const result = sessionManager.getResult(sessionId); - return jsonContent(result); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + // ----------------------------------------------------------------------- + // sf_result — get accumulated session result + // ----------------------------------------------------------------------- + server.tool( + "sf_result", + "Get the result of a SF session. Returns partial results if the session is still running.", + { + sessionId: z.string().describe("Session ID returned from sf_execute"), + }, + async (args: Record) => { + const { sessionId } = args as { sessionId: string }; + try { + const result = sessionManager.getResult(sessionId); + return jsonContent(result); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ----------------------------------------------------------------------- - // sf_cancel — cancel a running session - // ----------------------------------------------------------------------- - server.tool( - 'sf_cancel', - 'Cancel a running SF session. Aborts the current operation and stops the process.', - { - sessionId: z.string().describe('Session ID returned from sf_execute'), - }, - async (args: Record) => { - const { sessionId } = args as { sessionId: string }; - try { - await sessionManager.cancelSession(sessionId); - return jsonContent({ cancelled: true }); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + // ----------------------------------------------------------------------- + // sf_cancel — cancel a running session + // ----------------------------------------------------------------------- + server.tool( + "sf_cancel", + "Cancel a running SF session. Aborts the current operation and stops the process.", + { + sessionId: z.string().describe("Session ID returned from sf_execute"), + }, + async (args: Record) => { + const { sessionId } = args as { sessionId: string }; + try { + await sessionManager.cancelSession(sessionId); + return jsonContent({ cancelled: true }); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ----------------------------------------------------------------------- - // sf_query — read project state from filesystem (no session needed). - // - // `query` is optional: when omitted the tool returns all fields (STATE.md, - // PROJECT.md, requirements, milestone listing). Accepted narrow values: - // "state" / "status", "project", "requirements", "milestones", "all". - // Unknown values fall back to "all" for forward-compatibility. - // ----------------------------------------------------------------------- - server.tool( - 'sf_query', - 'Query SF project state from the filesystem. By default returns STATE.md, PROJECT.md, requirements, and milestone listing. Pass `query` to narrow the response (accepted: "state"/"status", "project", "requirements", "milestones", "all"). Does not require an active session.', - { - projectDir: z.string().describe('Absolute path to the project directory'), - query: z - .enum(['all', 'state', 'status', 'project', 'requirements', 'milestones']) - .optional() - .describe('Narrow the response to a single field (default: "all")'), - }, - async (args: Record) => { - const { projectDir, query } = args as { projectDir: string; query?: string }; - try { - const state = await readProjectState(projectDir, query); - return jsonContent(state); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + // ----------------------------------------------------------------------- + // sf_query — read project state from filesystem (no session needed). + // + // `query` is optional: when omitted the tool returns all fields (STATE.md, + // PROJECT.md, requirements, milestone listing). Accepted narrow values: + // "state" / "status", "project", "requirements", "milestones", "all". + // Unknown values fall back to "all" for forward-compatibility. + // ----------------------------------------------------------------------- + server.tool( + "sf_query", + 'Query SF project state from the filesystem. By default returns STATE.md, PROJECT.md, requirements, and milestone listing. Pass `query` to narrow the response (accepted: "state"/"status", "project", "requirements", "milestones", "all"). Does not require an active session.', + { + projectDir: z.string().describe("Absolute path to the project directory"), + query: z + .enum([ + "all", + "state", + "status", + "project", + "requirements", + "milestones", + ]) + .optional() + .describe('Narrow the response to a single field (default: "all")'), + }, + async (args: Record) => { + const { projectDir, query } = args as { + projectDir: string; + query?: string; + }; + try { + const state = await readProjectState(projectDir, query); + return jsonContent(state); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ----------------------------------------------------------------------- - // sf_resolve_blocker — resolve a pending blocker - // ----------------------------------------------------------------------- - server.tool( - 'sf_resolve_blocker', - 'Resolve a pending blocker in a SF session by sending a response to the UI request.', - { - sessionId: z.string().describe('Session ID returned from sf_execute'), - response: z.string().describe('Response to send for the pending blocker'), - }, - async (args: Record) => { - const { sessionId, response } = args as { sessionId: string; response: string }; - try { - await sessionManager.resolveBlocker(sessionId, response); - return jsonContent({ resolved: true }); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + // ----------------------------------------------------------------------- + // sf_resolve_blocker — resolve a pending blocker + // ----------------------------------------------------------------------- + server.tool( + "sf_resolve_blocker", + "Resolve a pending blocker in a SF session by sending a response to the UI request.", + { + sessionId: z.string().describe("Session ID returned from sf_execute"), + response: z.string().describe("Response to send for the pending blocker"), + }, + async (args: Record) => { + const { sessionId, response } = args as { + sessionId: string; + response: string; + }; + try { + await sessionManager.resolveBlocker(sessionId, response); + return jsonContent({ resolved: true }); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ----------------------------------------------------------------------- - // ask_user_questions — structured user input via MCP form elicitation - // ----------------------------------------------------------------------- - server.tool( - 'ask_user_questions', - 'Request user input for one to three short questions and wait for the response. Single-select questions include a free-form "None of the above" path. Multi-select questions allow multiple choices.', - { - questions: z.array(z.object({ - id: z.string().describe('Stable identifier for mapping answers (snake_case)'), - header: z.string().describe('Short header label shown in the UI (12 or fewer chars)'), - question: z.string().describe('Single-sentence prompt shown to the user'), - options: z.array(z.object({ - label: z.string().describe('User-facing label (1-5 words)'), - description: z.string().describe('One short sentence explaining impact/tradeoff if selected'), - })).describe('Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with "(Recommended)". Do not include an "Other" option for single-select questions.'), - allowMultiple: z.boolean().optional().describe('If true, the user can select multiple options. No "None of the above" option is added.'), - })).describe('Questions to show the user. Prefer 1 and do not exceed 3.'), - }, - async (args: Record) => { - const { questions } = args as unknown as AskUserQuestionsParams; - try { - const validationError = validateAskUserQuestionsPayload(questions); - if (validationError) return errorContent(validationError); + // ----------------------------------------------------------------------- + // ask_user_questions — structured user input via MCP form elicitation + // ----------------------------------------------------------------------- + server.tool( + "ask_user_questions", + 'Request user input for one to three short questions and wait for the response. Single-select questions include a free-form "None of the above" path. Multi-select questions allow multiple choices.', + { + questions: z + .array( + z.object({ + id: z + .string() + .describe("Stable identifier for mapping answers (snake_case)"), + header: z + .string() + .describe( + "Short header label shown in the UI (12 or fewer chars)", + ), + question: z + .string() + .describe("Single-sentence prompt shown to the user"), + options: z + .array( + z.object({ + label: z.string().describe("User-facing label (1-5 words)"), + description: z + .string() + .describe( + "One short sentence explaining impact/tradeoff if selected", + ), + }), + ) + .describe( + 'Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with "(Recommended)". Do not include an "Other" option for single-select questions.', + ), + allowMultiple: z + .boolean() + .optional() + .describe( + 'If true, the user can select multiple options. No "None of the above" option is added.', + ), + }), + ) + .describe("Questions to show the user. Prefer 1 and do not exceed 3."), + }, + async (args: Record) => { + const { questions } = args as unknown as AskUserQuestionsParams; + try { + const validationError = validateAskUserQuestionsPayload(questions); + if (validationError) return errorContent(validationError); - const elicitation = await server.server.elicitInput(buildAskUserQuestionsElicitRequest(questions)); - if (elicitation.action !== 'accept' || !elicitation.content) { - return textContent('ask_user_questions was cancelled before receiving a response'); - } + const elicitation = await server.server.elicitInput( + buildAskUserQuestionsElicitRequest(questions), + ); + if (elicitation.action !== "accept" || !elicitation.content) { + return askUserQuestionsContent(questions, null, true); + } - return textContent(formatAskUserQuestionsElicitResult(questions, elicitation)); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + return askUserQuestionsContent( + questions, + buildAskUserQuestionsRoundResult(questions, elicitation), + false, + ); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ----------------------------------------------------------------------- - // secure_env_collect — collect secrets via MCP form elicitation - // ----------------------------------------------------------------------- - server.tool( - 'secure_env_collect', - 'Collect environment variables securely via form input. Values are written directly to .env (or Vercel/Convex) and NEVER appear in tool output — only key names and applied/skipped status are returned. Use this instead of asking users to manually edit .env files or paste secrets into chat.', - { - projectDir: z.string().describe('Absolute path to the project directory'), - keys: z.array(z.object({ - key: z.string().describe('Env var name, e.g. OPENAI_API_KEY'), - hint: z.string().optional().describe('Format hint shown to user, e.g. "starts with sk-"'), - guidance: z.array(z.string()).optional().describe('Step-by-step instructions for obtaining this key'), - })).min(1).describe('Environment variables to collect'), - destination: z.enum(['dotenv', 'vercel', 'convex']).optional().describe('Where to write secrets. Auto-detected from project files if omitted.'), - envFilePath: z.string().optional().describe('Path to .env file (dotenv only). Defaults to .env in projectDir.'), - environment: z.enum(['development', 'preview', 'production']).optional().describe('Target environment (vercel/convex only)'), - }, - async (args: Record) => { - const { projectDir, keys, destination, envFilePath, environment } = args as { - projectDir: string; - keys: Array<{ key: string; hint?: string; guidance?: string[] }>; - destination?: 'dotenv' | 'vercel' | 'convex'; - envFilePath?: string; - environment?: 'development' | 'preview' | 'production'; - }; + // ----------------------------------------------------------------------- + // secure_env_collect — collect secrets via MCP form elicitation + // ----------------------------------------------------------------------- + server.tool( + "secure_env_collect", + "Collect environment variables securely via form input. Values are written directly to .env (or Vercel/Convex) and NEVER appear in tool output — only key names and applied/skipped status are returned. Use this instead of asking users to manually edit .env files or paste secrets into chat.", + { + projectDir: z.string().describe("Absolute path to the project directory"), + keys: z + .array( + z.object({ + key: z.string().describe("Env var name, e.g. OPENAI_API_KEY"), + hint: z + .string() + .optional() + .describe('Format hint shown to user, e.g. "starts with sk-"'), + guidance: z + .array(z.string()) + .optional() + .describe("Step-by-step instructions for obtaining this key"), + }), + ) + .min(1) + .describe("Environment variables to collect"), + destination: z + .enum(["dotenv", "vercel", "convex"]) + .optional() + .describe( + "Where to write secrets. Auto-detected from project files if omitted.", + ), + envFilePath: z + .string() + .optional() + .describe( + "Path to .env file (dotenv only). Defaults to .env in projectDir.", + ), + environment: z + .enum(["development", "preview", "production"]) + .optional() + .describe("Target environment (vercel/convex only)"), + }, + async (args: Record) => { + const { projectDir, keys, destination, envFilePath, environment } = + args as { + projectDir: string; + keys: Array<{ key: string; hint?: string; guidance?: string[] }>; + destination?: "dotenv" | "vercel" | "convex"; + envFilePath?: string; + environment?: "development" | "preview" | "production"; + }; - try { - const resolvedProjectDir = resolveProjectEnvFilePath(projectDir); - const resolvedEnvPath = resolve(resolvedProjectDir, envFilePath ?? '.env'); + try { + const resolvedProjectDir = resolveProjectEnvFilePath(projectDir); + const resolvedEnvPath = resolve( + resolvedProjectDir, + envFilePath ?? ".env", + ); - // (1) Check which keys already exist - const allKeyNames = keys.map((k) => k.key); - const existingKeys = await checkExistingEnvKeys(allKeyNames, resolvedEnvPath); - const existingSet = new Set(existingKeys); - const pendingKeys = keys.filter((k) => !existingSet.has(k.key)); + // (1) Check which keys already exist + const allKeyNames = keys.map((k) => k.key); + const existingKeys = await checkExistingEnvKeys( + allKeyNames, + resolvedEnvPath, + ); + const existingSet = new Set(existingKeys); + const pendingKeys = keys.filter((k) => !existingSet.has(k.key)); - // If all keys already exist, return immediately - if (pendingKeys.length === 0) { - const lines = existingKeys.map((k) => `• ${k}: already set`); - return textContent(`All ${existingKeys.length} key(s) already set.\n${lines.join('\n')}`); - } + // If all keys already exist, return immediately + if (pendingKeys.length === 0) { + const lines = existingKeys.map((k) => `• ${k}: already set`); + return textContent( + `All ${existingKeys.length} key(s) already set.\n${lines.join("\n")}`, + ); + } - // (2) Build elicitation form — one string field per pending key - const properties: Record> = {}; - const required: string[] = []; + // (2) Build elicitation form — one string field per pending key + const properties: Record> = {}; + const required: string[] = []; - for (const item of pendingKeys) { - const descParts: string[] = []; - if (item.hint) descParts.push(`Format: ${item.hint}`); - if (item.guidance && item.guidance.length > 0) { - descParts.push('How to get this:'); - item.guidance.forEach((step, i) => descParts.push(`${i + 1}. ${step}`)); - } - descParts.push('Leave empty to skip.'); + for (const item of pendingKeys) { + const descParts: string[] = []; + if (item.hint) descParts.push(`Format: ${item.hint}`); + if (item.guidance && item.guidance.length > 0) { + descParts.push("How to get this:"); + item.guidance.forEach((step, i) => + descParts.push(`${i + 1}. ${step}`), + ); + } + descParts.push("Leave empty to skip."); - properties[item.key] = { - type: 'string', - title: item.key, - description: descParts.join('\n'), - }; - // Don't mark as required — empty string = skip - } + properties[item.key] = { + type: "string", + title: item.key, + description: descParts.join("\n"), + }; + // Don't mark as required — empty string = skip + } - // (3) Elicit input from the MCP client - const elicitation = await server.server.elicitInput({ - message: `Enter values for ${pendingKeys.length} environment variable(s). Values are written directly to the project and never shown to the AI.`, - requestedSchema: { - type: 'object', - properties, - required, - }, - }); + // (3) Elicit input from the MCP client + const elicitation = await server.server.elicitInput({ + message: `Enter values for ${pendingKeys.length} environment variable(s). Values are written directly to the project and never shown to the AI.`, + requestedSchema: { + type: "object", + properties, + required, + }, + }); - if (elicitation.action !== 'accept' || !elicitation.content) { - return textContent('secure_env_collect was cancelled by user.'); - } + if (elicitation.action !== "accept" || !elicitation.content) { + return textContent("secure_env_collect was cancelled by user."); + } - // (4) Separate provided vs skipped from form response - const provided: Array<{ key: string; value: string }> = []; - const skipped: string[] = []; + // (4) Separate provided vs skipped from form response + const provided: Array<{ key: string; value: string }> = []; + const skipped: string[] = []; - for (const item of pendingKeys) { - const raw = elicitation.content[item.key]; - const value = typeof raw === 'string' ? raw.trim() : ''; - if (value.length > 0) { - provided.push({ key: item.key, value }); - } else { - skipped.push(item.key); - } - } + for (const item of pendingKeys) { + const raw = elicitation.content[item.key]; + const value = typeof raw === "string" ? raw.trim() : ""; + if (value.length > 0) { + provided.push({ key: item.key, value }); + } else { + skipped.push(item.key); + } + } - // (5) Auto-detect destination if not specified - const resolvedDestination = destination ?? detectDestination(resolvedProjectDir); + // (5) Auto-detect destination if not specified + const resolvedDestination = + destination ?? detectDestination(resolvedProjectDir); - // (6) Write secrets to destination - const { applied, errors } = await applySecrets(provided, resolvedDestination, { - envFilePath: resolvedEnvPath, - environment, - }); + // (6) Write secrets to destination + const { applied, errors } = await applySecrets( + provided, + resolvedDestination, + { + envFilePath: resolvedEnvPath, + environment, + }, + ); - // (7) Build result — NEVER include secret values - const lines: string[] = [ - `destination: ${resolvedDestination}${!destination ? ' (auto-detected)' : ''}${environment ? ` (${environment})` : ''}`, - ]; - for (const k of applied) lines.push(`✓ ${k}: applied`); - for (const k of skipped) lines.push(`• ${k}: skipped`); - for (const k of existingKeys) lines.push(`• ${k}: already set`); - for (const e of errors) lines.push(`✗ ${e}`); + // (7) Build result — NEVER include secret values + const lines: string[] = [ + `destination: ${resolvedDestination}${!destination ? " (auto-detected)" : ""}${environment ? ` (${environment})` : ""}`, + ]; + for (const k of applied) lines.push(`✓ ${k}: applied`); + for (const k of skipped) lines.push(`• ${k}: skipped`); + for (const k of existingKeys) lines.push(`• ${k}: already set`); + for (const e of errors) lines.push(`✗ ${e}`); - return errors.length > 0 && applied.length === 0 - ? errorContent(lines.join('\n')) - : textContent(lines.join('\n')); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + return errors.length > 0 && applied.length === 0 + ? errorContent(lines.join("\n")) + : textContent(lines.join("\n")); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ======================================================================= - // READ-ONLY TOOLS — no session required, pure filesystem reads - // ======================================================================= + // ======================================================================= + // READ-ONLY TOOLS — no session required, pure filesystem reads + // ======================================================================= - // ----------------------------------------------------------------------- - // sf_progress — structured project progress metrics - // ----------------------------------------------------------------------- - server.tool( - 'sf_progress', - 'Get structured project progress: active milestone/slice/task, phase, completion counts, blockers, and next action. No session required — reads directly from .sf/ on disk.', - { - projectDir: z.string().describe('Absolute path to the project directory'), - }, - async (args: Record) => { - const { projectDir } = args as { projectDir: string }; - try { - return jsonContent(readProgress(projectDir)); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + // ----------------------------------------------------------------------- + // sf_progress — structured project progress metrics + // ----------------------------------------------------------------------- + server.tool( + "sf_progress", + "Get structured project progress: active milestone/slice/task, phase, completion counts, blockers, and next action. No session required — reads directly from .sf/ on disk.", + { + projectDir: z.string().describe("Absolute path to the project directory"), + }, + async (args: Record) => { + const { projectDir } = args as { projectDir: string }; + try { + return jsonContent(readProgress(projectDir)); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ----------------------------------------------------------------------- - // sf_roadmap — milestone/slice/task structure with status - // ----------------------------------------------------------------------- - server.tool( - 'sf_roadmap', - 'Get the full project roadmap structure: milestones with their slices, tasks, status, risk, and dependencies. Optionally filter to a single milestone. No session required.', - { - projectDir: z.string().describe('Absolute path to the project directory'), - milestoneId: z.string().optional().describe('Filter to a specific milestone (e.g. "M001")'), - }, - async (args: Record) => { - const { projectDir, milestoneId } = args as { projectDir: string; milestoneId?: string }; - try { - return jsonContent(readRoadmap(projectDir, milestoneId)); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + // ----------------------------------------------------------------------- + // sf_roadmap — milestone/slice/task structure with status + // ----------------------------------------------------------------------- + server.tool( + "sf_roadmap", + "Get the full project roadmap structure: milestones with their slices, tasks, status, risk, and dependencies. Optionally filter to a single milestone. No session required.", + { + projectDir: z.string().describe("Absolute path to the project directory"), + milestoneId: z + .string() + .optional() + .describe('Filter to a specific milestone (e.g. "M001")'), + }, + async (args: Record) => { + const { projectDir, milestoneId } = args as { + projectDir: string; + milestoneId?: string; + }; + try { + return jsonContent(readRoadmap(projectDir, milestoneId)); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ----------------------------------------------------------------------- - // sf_history — execution history with cost/token metrics - // ----------------------------------------------------------------------- - server.tool( - 'sf_history', - 'Get execution history with cost, token usage, model, and duration per unit. Returns totals across all units. No session required.', - { - projectDir: z.string().describe('Absolute path to the project directory'), - limit: z.number().optional().describe('Max entries to return (most recent first). Default: all.'), - }, - async (args: Record) => { - const { projectDir, limit } = args as { projectDir: string; limit?: number }; - try { - return jsonContent(readHistory(projectDir, limit)); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + // ----------------------------------------------------------------------- + // sf_history — execution history with cost/token metrics + // ----------------------------------------------------------------------- + server.tool( + "sf_history", + "Get execution history with cost, token usage, model, and duration per unit. Returns totals across all units. No session required.", + { + projectDir: z.string().describe("Absolute path to the project directory"), + limit: z + .number() + .optional() + .describe("Max entries to return (most recent first). Default: all."), + }, + async (args: Record) => { + const { projectDir, limit } = args as { + projectDir: string; + limit?: number; + }; + try { + return jsonContent(readHistory(projectDir, limit)); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ----------------------------------------------------------------------- - // sf_doctor — lightweight structural health check - // ----------------------------------------------------------------------- - server.tool( - 'sf_doctor', - 'Run a lightweight structural health check on the .sf/ directory. Checks for missing files, status inconsistencies, and orphaned state. No session required.', - { - projectDir: z.string().describe('Absolute path to the project directory'), - scope: z.string().optional().describe('Limit checks to a specific milestone (e.g. "M001")'), - }, - async (args: Record) => { - const { projectDir, scope } = args as { projectDir: string; scope?: string }; - try { - return jsonContent(runDoctorLite(projectDir, scope)); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + // ----------------------------------------------------------------------- + // sf_doctor — lightweight structural health check + // ----------------------------------------------------------------------- + server.tool( + "sf_doctor", + "Run a lightweight structural health check on the .sf/ directory. Checks for missing files, status inconsistencies, and orphaned state. No session required.", + { + projectDir: z.string().describe("Absolute path to the project directory"), + scope: z + .string() + .optional() + .describe('Limit checks to a specific milestone (e.g. "M001")'), + }, + async (args: Record) => { + const { projectDir, scope } = args as { + projectDir: string; + scope?: string; + }; + try { + return jsonContent(runDoctorLite(projectDir, scope)); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ----------------------------------------------------------------------- - // sf_captures — pending captures and ideas - // ----------------------------------------------------------------------- - server.tool( - 'sf_captures', - 'Get captured ideas and thoughts from CAPTURES.md with triage status. Filter by pending, actionable, or all. No session required.', - { - projectDir: z.string().describe('Absolute path to the project directory'), - filter: z.enum(['all', 'pending', 'actionable']).optional().describe('Filter captures (default: "all")'), - }, - async (args: Record) => { - const { projectDir, filter } = args as { projectDir: string; filter?: 'all' | 'pending' | 'actionable' }; - try { - return jsonContent(readCaptures(projectDir, filter ?? 'all')); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + // ----------------------------------------------------------------------- + // sf_captures — pending captures and ideas + // ----------------------------------------------------------------------- + server.tool( + "sf_captures", + "Get captured ideas and thoughts from CAPTURES.md with triage status. Filter by pending, actionable, or all. No session required.", + { + projectDir: z.string().describe("Absolute path to the project directory"), + filter: z + .enum(["all", "pending", "actionable"]) + .optional() + .describe('Filter captures (default: "all")'), + }, + async (args: Record) => { + const { projectDir, filter } = args as { + projectDir: string; + filter?: "all" | "pending" | "actionable"; + }; + try { + return jsonContent(readCaptures(projectDir, filter ?? "all")); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ----------------------------------------------------------------------- - // sf_knowledge — project knowledge base - // ----------------------------------------------------------------------- - server.tool( - 'sf_knowledge', - 'Get the project knowledge base: rules, patterns, and lessons learned accumulated during development. No session required.', - { - projectDir: z.string().describe('Absolute path to the project directory'), - }, - async (args: Record) => { - const { projectDir } = args as { projectDir: string }; - try { - return jsonContent(readKnowledge(projectDir)); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + // ----------------------------------------------------------------------- + // sf_knowledge — project knowledge base + // ----------------------------------------------------------------------- + server.tool( + "sf_knowledge", + "Get the project knowledge base: rules, patterns, and lessons learned accumulated during development. No session required.", + { + projectDir: z.string().describe("Absolute path to the project directory"), + }, + async (args: Record) => { + const { projectDir } = args as { projectDir: string }; + try { + return jsonContent(readKnowledge(projectDir)); + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - // ----------------------------------------------------------------------- - // sf_graph — knowledge graph for SF projects - // - // Modes: - // build Parse .sf/ artifacts and write graph.json atomically. - // query Search the graph for nodes matching a term (BFS, budget-trimmed). - // status Check whether graph.json exists and whether it is stale (>24h). - // diff Compare graph.json with the last build snapshot. - // ----------------------------------------------------------------------- - server.tool( - 'sf_graph', - [ - 'Manage the SF project knowledge graph. No session required.', - '', - 'Modes:', - ' build Parse .sf/ artifacts (STATE.md, milestone ROADMAPs, slice PLANs,', - ' KNOWLEDGE.md) and write .sf/graphs/graph.json atomically.', - ' query Search graph nodes by term (BFS from seed matches, budget-trimmed).', - ' Returns matching nodes and reachable edges within the token budget.', - ' status Show whether graph.json exists, its age, node/edge counts, and', - ' whether it is stale (built more than 24 hours ago).', - ' diff Compare current graph.json with .last-build-snapshot.json.', - ' Returns added, removed, and changed nodes and edges.', - ].join('\n'), - { - projectDir: z.string().describe('Absolute path to the project directory'), - mode: z.enum(['build', 'query', 'status', 'diff']).describe( - 'Operation: build | query | status | diff', - ), - term: z.string().optional().describe('Search term for query mode (case-insensitive)'), - budget: z.number().optional().describe('Token budget for query mode (default: 4000)'), - snapshot: z.boolean().optional().describe('Write snapshot before build (for future diff)'), - }, - async (args: Record) => { - const { projectDir, mode, term, budget, snapshot } = args as { - projectDir: string; - mode: 'build' | 'query' | 'status' | 'diff'; - term?: string; - budget?: number; - snapshot?: boolean; - }; + // ----------------------------------------------------------------------- + // sf_graph — knowledge graph for SF projects + // + // Modes: + // build Parse .sf/ artifacts and write graph.json atomically. + // query Search the graph for nodes matching a term (BFS, budget-trimmed). + // status Check whether graph.json exists and whether it is stale (>24h). + // diff Compare graph.json with the last build snapshot. + // ----------------------------------------------------------------------- + server.tool( + "sf_graph", + [ + "Manage the SF project knowledge graph. No session required.", + "", + "Modes:", + " build Parse .sf/ artifacts (STATE.md, milestone ROADMAPs, slice PLANs,", + " KNOWLEDGE.md) and write .sf/graphs/graph.json atomically.", + " query Search graph nodes by term (BFS from seed matches, budget-trimmed).", + " Returns matching nodes and reachable edges within the token budget.", + " status Show whether graph.json exists, its age, node/edge counts, and", + " whether it is stale (built more than 24 hours ago).", + " diff Compare current graph.json with .last-build-snapshot.json.", + " Returns added, removed, and changed nodes and edges.", + ].join("\n"), + { + projectDir: z.string().describe("Absolute path to the project directory"), + mode: z + .enum(["build", "query", "status", "diff"]) + .describe("Operation: build | query | status | diff"), + term: z + .string() + .optional() + .describe("Search term for query mode (case-insensitive)"), + budget: z + .number() + .optional() + .describe("Token budget for query mode (default: 4000)"), + snapshot: z + .boolean() + .optional() + .describe("Write snapshot before build (for future diff)"), + }, + async (args: Record) => { + const { projectDir, mode, term, budget, snapshot } = args as { + projectDir: string; + mode: "build" | "query" | "status" | "diff"; + term?: string; + budget?: number; + snapshot?: boolean; + }; - try { - const sfRoot = resolveSFRoot(projectDir); + try { + const sfRoot = resolveSFRoot(projectDir); - switch (mode) { - case 'build': { - if (snapshot) { - await writeSnapshot(sfRoot).catch(() => { /* best-effort */ }); - } - const graph = await buildGraph(projectDir); - await writeGraph(sfRoot, graph); - return jsonContent({ - built: true, - nodeCount: graph.nodes.length, - edgeCount: graph.edges.length, - builtAt: graph.builtAt, - }); - } + switch (mode) { + case "build": { + if (snapshot) { + await writeSnapshot(sfRoot).catch(() => { + /* best-effort */ + }); + } + const graph = await buildGraph(projectDir); + await writeGraph(sfRoot, graph); + return jsonContent({ + built: true, + nodeCount: graph.nodes.length, + edgeCount: graph.edges.length, + builtAt: graph.builtAt, + }); + } - case 'query': { - const result = await graphQuery(projectDir, term ?? '', budget); - return jsonContent(result); - } + case "query": { + const result = await graphQuery(projectDir, term ?? "", budget); + return jsonContent(result); + } - case 'status': { - const result = await graphStatus(projectDir); - return jsonContent(result); - } + case "status": { + const result = await graphStatus(projectDir); + return jsonContent(result); + } - case 'diff': { - const result = await graphDiff(projectDir); - return jsonContent(result); - } - } - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); + case "diff": { + const result = await graphDiff(projectDir); + return jsonContent(result); + } + } + } catch (err) { + return errorContent(err instanceof Error ? err.message : String(err)); + } + }, + ); - registerWorkflowTools(server); + registerWorkflowTools(server); - return { server }; + return { server }; } diff --git a/packages/native/package.json b/packages/native/package.json index 10c834afb..3eb09bb0d 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -9,7 +9,7 @@ "build": "tsc -p tsconfig.json", "build:native": "node ../../rust-engine/scripts/build.js", "build:native:dev": "node ../../rust-engine/scripts/build.js --dev", - "test": "npm run build:native:dev && node --test src/__tests__/grep.test.mjs src/__tests__/ps.test.mjs src/__tests__/glob.test.mjs src/__tests__/clipboard.test.mjs src/__tests__/highlight.test.mjs src/__tests__/html.test.mjs src/__tests__/text.test.mjs src/__tests__/fd.test.mjs src/__tests__/image.test.mjs" + "test": "npm run build:native:dev && node --test src/__tests__/grep.test.mjs src/__tests__/ps.test.mjs src/__tests__/glob.test.mjs src/__tests__/clipboard.test.mjs src/__tests__/highlight.test.mjs src/__tests__/html.test.mjs src/__tests__/text.test.mjs src/__tests__/fd.test.mjs src/__tests__/image.test.mjs src/__tests__/edit.test.mjs src/__tests__/symbol.test.mjs src/__tests__/watch.test.mjs" }, "exports": { ".": { @@ -60,6 +60,10 @@ "types": "./dist/diff/index.d.ts", "default": "./dist/diff/index.js" }, + "./edit": { + "types": "./dist/edit/index.d.ts", + "default": "./dist/edit/index.js" + }, "./forge-parser": { "types": "./dist/forge-parser/index.d.ts", "default": "./dist/forge-parser/index.js" diff --git a/packages/native/src/__tests__/edit.test.mjs b/packages/native/src/__tests__/edit.test.mjs new file mode 100644 index 000000000..654c577f8 --- /dev/null +++ b/packages/native/src/__tests__/edit.test.mjs @@ -0,0 +1,95 @@ +import { describe, test } from "vitest"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as fs from "node:fs"; +import * as os from "node:os"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "rust-engine", "addon"); +const platformTag = `${process.platform}-${process.arch}`; +const candidates = [ + path.join(addonDir, "forge_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), +]; + +let native; +for (const candidate of candidates) { + try { + native = require(candidate); + break; + } catch { + // try next + } +} + +if (!native) { + console.error("Native addon not found. Run `npm run build:native -w @singularity-forge/native` first."); + process.exit(1); +} + +function tempFile(contents) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sf-edit-test-")); + const file = path.join(dir, "sample.txt"); + fs.writeFileSync(file, contents, "utf8"); + return { dir, file }; +} + +const range = (startLine, startCharacter, endLine, endCharacter) => ({ + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, +}); + +describe("native edit: applyEdits()", () => { + test("applies LSP-style edits atomically", ({ onTestFinished }) => { + const { dir, file } = tempFile("hello world\n"); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + const result = native.applyEdits( + file, + [{ range: range(0, 6, 0, 11), newText: "forge" }], + { fsync: false }, + ); + + assert.deepEqual(result, { + editsApplied: 1, + bytesWritten: Buffer.byteLength("hello forge\n"), + }); + assert.equal(fs.readFileSync(file, "utf8"), "hello forge\n"); + }); + + test("uses UTF-16 character offsets", ({ onTestFinished }) => { + const { dir, file } = tempFile("a😀b\n"); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + native.applyEdits( + file, + [{ range: range(0, 1, 0, 3), newText: "X" }], + { fsync: false }, + ); + + assert.equal(fs.readFileSync(file, "utf8"), "aXb\n"); + }); + + test("rejects overlapping edits without changing the file", ({ onTestFinished }) => { + const { dir, file } = tempFile("abcdef\n"); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + assert.throws( + () => + native.applyEdits( + file, + [ + { range: range(0, 0, 0, 4), newText: "x" }, + { range: range(0, 2, 0, 6), newText: "y" }, + ], + { fsync: false }, + ), + /overlapping edits/, + ); + assert.equal(fs.readFileSync(file, "utf8"), "abcdef\n"); + }); +}); diff --git a/packages/native/src/__tests__/symbol.test.mjs b/packages/native/src/__tests__/symbol.test.mjs new file mode 100644 index 000000000..ed7a1f26b --- /dev/null +++ b/packages/native/src/__tests__/symbol.test.mjs @@ -0,0 +1,232 @@ +/** + * Tests for replaceSymbol and insertAroundSymbol. + * + * These tests require the native addon to be compiled. They are designed but + * will fail at runtime until: + * 1. The integration owner adds `mod symbol;` to `lib.rs`. + * 2. The native addon is rebuilt (`npm run build:native -w @singularity-forge/native`). + * + * Run with: + * npx vitest run packages/native/src/__tests__/symbol.test.mjs --config vitest.config.ts + */ + +import { describe, test } from "vitest"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as fs from "node:fs"; +import * as os from "node:os"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "rust-engine", "addon"); +const platformTag = `${process.platform}-${process.arch}`; +const candidates = [ + path.join(addonDir, "forge_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), +]; + +let native; +for (const candidate of candidates) { + try { + native = require(candidate); + break; + } catch { + // try next + } +} + +if (!native) { + console.error( + "Native addon not found. Run `npm run build:native -w @singularity-forge/native` first.", + ); + process.exit(1); +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function tempTsFile(contents) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sf-symbol-test-")); + const file = path.join(dir, "sample.ts"); + fs.writeFileSync(file, contents, "utf8"); + return { dir, file }; +} + +// ── replaceSymbol ───────────────────────────────────────────────────────────── + +describe("native symbol: replaceSymbol()", () => { + test("replaces a top-level TypeScript function declaration", ({ onTestFinished }) => { + const original = `function foo() { return 1; }\n`; + const { dir, file } = tempTsFile(original); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + const result = native.replaceSymbol( + file, + "foo", + "function foo() { return 42; }", + { fsync: false }, + ); + + assert.equal(result.matched, true); + assert.ok(typeof result.byteStart === "number", "byteStart should be set"); + assert.ok(typeof result.startLine === "number", "startLine should be set"); + assert.equal(result.startLine, 1, "startLine should be 1-based"); + + const written = fs.readFileSync(file, "utf8"); + assert.ok( + written.includes("return 42"), + `Expected new body in file, got: ${written}`, + ); + assert.ok( + !written.includes("return 1;"), + `Old body should be gone, got: ${written}`, + ); + }); + + test("returns matched:false when the symbol does not exist", ({ onTestFinished }) => { + const { dir, file } = tempTsFile("function bar() { return 0; }\n"); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + const result = native.replaceSymbol( + file, + "nonExistentFunction", + "function nonExistentFunction() {}", + { fsync: false }, + ); + + assert.equal(result.matched, false); + assert.equal(result.byteStart, undefined); + // File must be unchanged. + assert.ok(fs.readFileSync(file, "utf8").includes("function bar()")); + }); + + test("throws on ambiguous symbol (multiple matches)", ({ onTestFinished }) => { + // Two functions with different names but both match a loose pattern is not + // the ambiguity scenario; the ambiguity arises from an overloaded symbol. + // Simulate it by writing a file with the same function name appearing twice + // (which can happen if tree-sitter picks up both the function and an arrow + // alias with the same logical name via multiple patterns — or in practice + // when the user uses duplicate declarations). + const source = [ + "function executeCommand() { return 1; }", + "const executeCommand = () => { return 2; }", + ].join("\n") + "\n"; + const { dir, file } = tempTsFile(source); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + assert.throws( + () => + native.replaceSymbol( + file, + "executeCommand", + "function executeCommand() { return 99; }", + { fsync: false }, + ), + /[Aa]mbiguous/, + ); + // File must be unchanged. + assert.ok(fs.readFileSync(file, "utf8").includes("return 1;")); + }); + + test("replaces an arrow function declaration", ({ onTestFinished }) => { + const { dir, file } = tempTsFile( + "const greet = (name: string) => { return `Hello ${name}`; }\n", + ); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + const result = native.replaceSymbol( + file, + "greet", + "const greet = (name: string) => { return `Hi ${name}!`; }", + { fsync: false }, + ); + + assert.equal(result.matched, true); + const written = fs.readFileSync(file, "utf8"); + assert.ok(written.includes("Hi"), `Expected new body, got: ${written}`); + }); +}); + +// ── insertAroundSymbol ──────────────────────────────────────────────────────── + +describe("native symbol: insertAroundSymbol()", () => { + test("BeforeDecl inserts code on the line immediately before the declaration", ({ + onTestFinished, + }) => { + const original = "function hello() { return 'world'; }\n"; + const { dir, file } = tempTsFile(original); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + const result = native.insertAroundSymbol( + file, + "hello", + "BeforeDecl", + "// AUTO-GENERATED\n", + { fsync: false }, + ); + + assert.equal(result.inserted, true); + assert.ok(typeof result.byteOffset === "number", "byteOffset should be set"); + assert.equal(result.byteOffset, 0, "should insert at start of file for top-level decl"); + + const written = fs.readFileSync(file, "utf8"); + assert.ok( + written.startsWith("// AUTO-GENERATED\n"), + `Expected comment at top, got: ${written}`, + ); + assert.ok(written.includes("function hello()"), "Original declaration must still exist"); + }); + + test("AfterDecl inserts code immediately after the declaration", ({ onTestFinished }) => { + const original = "function bye() { return 'cya'; }\n"; + const { dir, file } = tempTsFile(original); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + const result = native.insertAroundSymbol( + file, + "bye", + "AfterDecl", + "\n// end of bye\n", + { fsync: false }, + ); + + assert.equal(result.inserted, true); + const written = fs.readFileSync(file, "utf8"); + assert.ok( + written.includes("// end of bye"), + `Expected trailing comment, got: ${written}`, + ); + }); + + test("returns inserted:false when symbol does not exist", ({ onTestFinished }) => { + const { dir, file } = tempTsFile("function noop() {}\n"); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + const result = native.insertAroundSymbol( + file, + "phantom", + "BeforeDecl", + "// phantom\n", + { fsync: false }, + ); + + assert.equal(result.inserted, false); + assert.equal(result.byteOffset, undefined); + assert.equal(fs.readFileSync(file, "utf8"), "function noop() {}\n"); + }); + + test("AtBodyStart and AtBodyEnd throw 'not yet implemented'", ({ onTestFinished }) => { + const { dir, file } = tempTsFile("function x() { return 1; }\n"); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + for (const pos of ["AtBodyStart", "AtBodyEnd"]) { + assert.throws( + () => native.insertAroundSymbol(file, "x", pos, "/* code */", { fsync: false }), + /not yet implemented/i, + `Expected 'not yet implemented' error for position ${pos}`, + ); + } + }); +}); diff --git a/packages/native/src/__tests__/watch.test.mjs b/packages/native/src/__tests__/watch.test.mjs new file mode 100644 index 000000000..231a7832c --- /dev/null +++ b/packages/native/src/__tests__/watch.test.mjs @@ -0,0 +1,219 @@ +import { describe, test } from "vitest"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as fs from "node:fs"; +import * as os from "node:os"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +// ─── Load native addon ──────────────────────────────────────────────────────── + +const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "rust-engine", "addon"); +const platformTag = `${process.platform}-${process.arch}`; +const candidates = [ + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), +]; + +let native; +for (const candidate of candidates) { + try { + native = require(candidate); + break; + } catch { + // try next + } +} + +if (!native) { + console.error( + "Native addon not found. Run `npm run build:native -w @singularity-forge/native` first.", + ); + process.exit(1); +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Create a unique temporary directory for one test and return its path. + * The caller is responsible for cleanup via `onTestFinished`. + */ +function makeTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), "sf-watch-test-")); +} + +/** + * Collect the first batch of events from `watchTree` that satisfy `predicate`, + * then stop the watcher and resolve with the matching event array. + * + * Rejects after `timeoutMs` if no satisfying batch arrives. + */ +function waitForEvents(root, options, predicate, timeoutMs = 3000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + native.stopWatch(handle); + reject(new Error(`Timed out after ${timeoutMs}ms waiting for events in ${root}`)); + }, timeoutMs); + + const handle = native.watchTree(root, options ?? null, (events) => { + if (predicate(events)) { + clearTimeout(timer); + native.stopWatch(handle); + resolve(events); + } + }); + }); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("native watch: watchTree / stopWatch", () => { + // ── creation ─────────────────────────────────────────────────────────────── + + test("detects file creation", async ({ onTestFinished }) => { + const dir = makeTmpDir(); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + const pending = waitForEvents( + dir, + { debounceMs: 50 }, + (events) => events.some((e) => e.kind === "create"), + ); + + // Give the watcher a moment to register before writing. + await new Promise((r) => setTimeout(r, 100)); + fs.writeFileSync(path.join(dir, "hello.txt"), "hi"); + + const events = await pending; + const created = events.filter((e) => e.kind === "create"); + assert.ok(created.length >= 1, `expected >=1 create event, got: ${JSON.stringify(events)}`); + assert.ok( + created.some((e) => e.path.endsWith("hello.txt")), + `expected hello.txt in create events, got: ${JSON.stringify(created)}`, + ); + }); + + // ── modification ─────────────────────────────────────────────────────────── + + test("detects file modification", async ({ onTestFinished }) => { + const dir = makeTmpDir(); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + const file = path.join(dir, "data.txt"); + fs.writeFileSync(file, "initial"); + + // Wait for any initial create events to drain before starting the real watch. + await new Promise((r) => setTimeout(r, 200)); + + const pending = waitForEvents( + dir, + { debounceMs: 50 }, + (events) => events.some((e) => e.kind === "modify" && e.path.endsWith("data.txt")), + ); + + await new Promise((r) => setTimeout(r, 100)); + fs.writeFileSync(file, "updated"); + + const events = await pending; + const modified = events.filter((e) => e.kind === "modify"); + assert.ok(modified.length >= 1, `expected >=1 modify event, got: ${JSON.stringify(events)}`); + }); + + // ── removal ──────────────────────────────────────────────────────────────── + + test("detects file removal", async ({ onTestFinished }) => { + const dir = makeTmpDir(); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + const file = path.join(dir, "todelete.txt"); + fs.writeFileSync(file, "bye"); + + await new Promise((r) => setTimeout(r, 200)); + + const pending = waitForEvents( + dir, + { debounceMs: 50 }, + (events) => events.some((e) => e.kind === "remove" && e.path.endsWith("todelete.txt")), + ); + + await new Promise((r) => setTimeout(r, 100)); + fs.unlinkSync(file); + + const events = await pending; + const removed = events.filter((e) => e.kind === "remove"); + assert.ok(removed.length >= 1, `expected >=1 remove event, got: ${JSON.stringify(events)}`); + }); + + // ── ignore patterns ──────────────────────────────────────────────────────── + + test("respects ignore pattern (*.log ignored, .txt not)", async ({ onTestFinished }) => { + const dir = makeTmpDir(); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + // Collect ALL events for 600ms after writing both files, then inspect. + const collected = []; + let handle; + const settled = new Promise((resolve) => { + handle = native.watchTree( + dir, + { ignore: ["*.log"], debounceMs: 50 }, + (events) => { + collected.push(...events); + }, + ); + setTimeout(resolve, 600); + }); + + await new Promise((r) => setTimeout(r, 100)); + fs.writeFileSync(path.join(dir, "ignored.log"), "log data"); + fs.writeFileSync(path.join(dir, "kept.txt"), "text data"); + + await settled; + native.stopWatch(handle); + + const logEvents = collected.filter((e) => e.path.endsWith("ignored.log")); + const txtEvents = collected.filter((e) => e.path.endsWith("kept.txt")); + + assert.equal(logEvents.length, 0, `*.log file should be ignored, got: ${JSON.stringify(logEvents)}`); + assert.ok(txtEvents.length >= 1, `*.txt file should produce events, got: ${JSON.stringify(collected)}`); + }); + + // ── stop ─────────────────────────────────────────────────────────────────── + + test("stop() ends the watch — no further events delivered", async ({ onTestFinished }) => { + const dir = makeTmpDir(); + onTestFinished(() => fs.rmSync(dir, { recursive: true, force: true })); + + const received = []; + const handle = native.watchTree(dir, { debounceMs: 50 }, (events) => { + received.push(...events); + }); + + // Write a file, wait for the debounce to fire, then stop. + await new Promise((r) => setTimeout(r, 100)); + fs.writeFileSync(path.join(dir, "before.txt"), "a"); + await new Promise((r) => setTimeout(r, 300)); + + const stopped = native.stopWatch(handle); + assert.equal(stopped, true, "stopWatch should return true for a live handle"); + + const countAfterStop = received.length; + + // Write another file after stopping — should NOT trigger any new events. + fs.writeFileSync(path.join(dir, "after.txt"), "b"); + await new Promise((r) => setTimeout(r, 300)); + + assert.equal( + received.length, + countAfterStop, + `No new events should arrive after stop. Got extra: ${JSON.stringify(received.slice(countAfterStop))}`, + ); + + // Stopping an already-stopped handle should return false, not throw. + const stoppedAgain = native.stopWatch(handle); + assert.equal(stoppedAgain, false, "second stopWatch on same handle should return false"); + }); +}); diff --git a/packages/native/src/edit/index.ts b/packages/native/src/edit/index.ts new file mode 100644 index 000000000..f8bb21e89 --- /dev/null +++ b/packages/native/src/edit/index.ts @@ -0,0 +1,174 @@ +/** + * Atomic LSP-style text edits backed by the Rust native engine. + * + * Purpose: let agents apply LSP WorkspaceEdit/TextEdit output without a + * read-splice-write loop in JavaScript. + * + * Consumer: SF agent editing flows that receive semantic edits from LSP and + * need to commit them to disk as one native operation. + */ + +import { native } from "../native.js"; +import { EventEmitter } from "node:events"; +import type { + ApplyEditsOptions, + ApplyEditsResult, + ApplyWorkspaceEditResult, + InsertAroundSymbolOptions, + InsertAroundSymbolResult, + InsertPosition, + ReplaceSymbolOptions, + ReplaceSymbolResult, + TextDocumentEdit, + TextEdit, + WatchEvent, + WatchHandle, + WatchOptions, + WorkspaceEditLike, +} from "./types.js"; + +export type { + ApplyEditsOptions, + ApplyEditsResult, + ApplyWorkspaceEditResult, + InsertAroundSymbolOptions, + InsertAroundSymbolResult, + InsertPosition, + Position, + Range, + ReplaceSymbolOptions, + ReplaceSymbolResult, + TextDocumentEdit, + TextEdit, + WatchEvent, + WatchEventKind, + WatchHandle, + WatchOptions, + WorkspaceEditLike, +} from "./types.js"; + +/** + * Apply LSP-style TextEdit entries to one file atomically. + * + * Purpose: preserve LSP range semantics while avoiding JavaScript string + * slicing and partial writes for agent-driven edits. + * + * Consumer: agent edit tools that hand LSP rename/code-action results to the + * native module for durable file updates. + */ +export function applyEdits( + filePath: string, + edits: readonly TextEdit[], + options?: ApplyEditsOptions, +): ApplyEditsResult { + return native.applyEdits(filePath, [...edits], options) as ApplyEditsResult; +} + +/** + * Apply LSP-style text edits across multiple files with native two-phase staging. + * + * Purpose: commit semantic edits from LSP rename/code-action results with one + * native call and no per-file JavaScript splice loop. + * + * Consumer: agent refactor flows that receive a WorkspaceEdit-shaped object + * from the LSP client. + */ +export function applyWorkspaceEdit( + workspaceEdit: readonly TextDocumentEdit[] | WorkspaceEditLike, + options?: ApplyEditsOptions, +): ApplyWorkspaceEditResult { + return native.applyWorkspaceEdit(normalizeWorkspaceEdit(workspaceEdit), options) as ApplyWorkspaceEditResult; +} + +/** + * Replace the declaration matched by a symbol path. + * + * Purpose: let agent code target stable semantic names instead of stale line + * numbers or fragile string matches. + * + * Consumer: symbol-aware edit flows for TypeScript/JavaScript/TSX files. + */ +export function replaceSymbol( + filePath: string, + symbolPath: string, + newBody: string, + options?: ReplaceSymbolOptions, +): ReplaceSymbolResult { + return native.replaceSymbol(filePath, symbolPath, newBody, options) as ReplaceSymbolResult; +} + +/** + * Insert code around a declaration matched by a symbol path. + * + * Purpose: support common agent edits such as adding adjacent declarations or + * declaration comments without regex-based placement. + * + * Consumer: symbol-aware edit flows for TypeScript/JavaScript/TSX files. + */ +export function insertAroundSymbol( + filePath: string, + symbolPath: string, + position: InsertPosition, + code: string, + options?: InsertAroundSymbolOptions, +): InsertAroundSymbolResult { + return native.insertAroundSymbol( + filePath, + symbolPath, + position, + code, + options, + ) as InsertAroundSymbolResult; +} + +/** + * Watch a directory tree with native notify/globset filtering. + * + * Purpose: keep file-change pressure out of the JavaScript event loop while + * still presenting a Node-friendly EventEmitter surface. + * + * Consumer: long-running agent sessions and UI flows that need debounced file + * change batches. + */ +export function watchTree(root: string, options?: WatchOptions): WatchHandle { + const emitter = new EventEmitter(); + const handle = native.watchTree(root, options ?? null, (events: unknown[]) => { + emitter.emit("events", events as WatchEvent[]); + }); + let stopped = false; + + return { + stop() { + if (!stopped) { + stopped = true; + native.stopWatch(handle); + emitter.removeAllListeners(); + } + }, + on(event: "events", listener: (events: WatchEvent[]) => void) { + emitter.on(event, listener); + return this; + }, + }; +} + +function normalizeWorkspaceEdit( + workspaceEdit: readonly TextDocumentEdit[] | WorkspaceEditLike, +): TextDocumentEdit[] { + if (Array.isArray(workspaceEdit)) { + return workspaceEdit.map((entry) => ({ + filePath: entry.filePath, + edits: [...entry.edits], + })); + } + if (Array.isArray(workspaceEdit.documentChanges)) { + return workspaceEdit.documentChanges.map((entry) => ({ + filePath: entry.filePath, + edits: [...entry.edits], + })); + } + return Object.entries(workspaceEdit.changes ?? {}).map(([filePath, edits]) => ({ + filePath, + edits: [...edits], + })); +} diff --git a/packages/native/src/edit/types.ts b/packages/native/src/edit/types.ts new file mode 100644 index 000000000..2bbfadbed --- /dev/null +++ b/packages/native/src/edit/types.ts @@ -0,0 +1,97 @@ +export interface Position { + /** 0-based line number. */ + line: number; + /** 0-based UTF-16 code-unit offset from the line start, matching LSP. */ + character: number; +} + +export interface Range { + start: Position; + end: Position; +} + +export interface TextEdit { + range: Range; + newText: string; +} + +export interface ApplyEditsOptions { + /** fsync the temp file and parent directory before/after rename. Defaults to true. */ + fsync?: boolean; +} + +export interface ApplyEditsResult { + editsApplied: number; + bytesWritten: number; +} + +export interface TextDocumentEdit { + filePath: string; + edits: TextEdit[]; +} + +export interface WorkspaceEditLike { + documentChanges?: TextDocumentEdit[]; + changes?: Record; +} + +export interface WorkspaceEditFileResult { + filePath: string; + editsApplied: number; + bytesWritten: number; +} + +export interface ApplyWorkspaceEditResult { + filesChanged: number; + totalEditsApplied: number; + files: WorkspaceEditFileResult[]; +} + +export interface ReplaceSymbolOptions { + /** Force a specific language. If absent, inferred from the file extension. */ + lang?: string; + /** fsync the write. Defaults to true. */ + fsync?: boolean; +} + +export interface ReplaceSymbolResult { + matched: boolean; + byteStart?: number; + byteEnd?: number; + startLine?: number; +} + +export type InsertPosition = "BeforeDecl" | "AfterDecl" | "AtBodyStart" | "AtBodyEnd"; + +export interface InsertAroundSymbolOptions { + /** Force a specific language. If absent, inferred from the file extension. */ + lang?: string; + /** fsync the write. Defaults to true. */ + fsync?: boolean; +} + +export interface InsertAroundSymbolResult { + inserted: boolean; + byteOffset?: number; +} + +export type WatchEventKind = "create" | "modify" | "remove" | "rename"; + +export interface WatchEvent { + kind: WatchEventKind; + path: string; +} + +export interface WatchOptions { + /** Glob patterns to ignore. Bare patterns match anywhere below the root. */ + ignore?: string[]; + /** Coalesce events that fire within this window in milliseconds. Defaults to 50. */ + debounceMs?: number; + /** Watch recursively. Defaults to true. */ + recursive?: boolean; +} + +export interface WatchHandle { + stop(): void; + on(event: "events", listener: (events: WatchEvent[]) => void): this; +} diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts index 4279ab7f0..409635b7a 100644 --- a/packages/native/src/index.ts +++ b/packages/native/src/index.ts @@ -81,6 +81,33 @@ export { } from "./diff/index.js"; export type { FuzzyMatchResult, DiffResult } from "./diff/index.js"; +export { + applyEdits, + applyWorkspaceEdit, + insertAroundSymbol, + replaceSymbol, + watchTree, +} from "./edit/index.js"; +export type { + ApplyEditsOptions, + ApplyEditsResult, + ApplyWorkspaceEditResult, + InsertAroundSymbolOptions, + InsertAroundSymbolResult, + InsertPosition, + Position, + Range, + ReplaceSymbolOptions, + ReplaceSymbolResult, + TextDocumentEdit, + TextEdit, + WatchEvent, + WatchEventKind, + WatchHandle, + WatchOptions, + WorkspaceEditLike, +} from "./edit/index.js"; + export { fuzzyFind } from "./fd/index.js"; export type { FuzzyFindMatch, diff --git a/packages/native/src/native.ts b/packages/native/src/native.ts index 378ca6407..5426146a3 100644 --- a/packages/native/src/native.ts +++ b/packages/native/src/native.ts @@ -132,6 +132,27 @@ export const native = loadNative() as { normalizeForFuzzyMatch: (text: string) => string; fuzzyFindText: (content: string, oldText: string) => unknown; generateDiff: (oldContent: string, newContent: string, contextLines?: number) => unknown; + applyEdits: (filePath: string, edits: unknown[], options?: unknown) => unknown; + applyWorkspaceEdit: (documentEdits: unknown[], options?: unknown) => unknown; + replaceSymbol: ( + filePath: string, + symbolName: string, + newBody: string, + options?: unknown, + ) => unknown; + insertAroundSymbol: ( + filePath: string, + symbolName: string, + position: string, + code: string, + options?: unknown, + ) => unknown; + watchTree: ( + root: string, + options: unknown, + onEvents: (events: unknown[]) => void, + ) => number; + stopWatch: (handle: number) => boolean; NativeImage: unknown; ttsrCompileRules: (rules: unknown[]) => number; ttsrCheckBuffer: (handle: number, buffer: string) => string[]; diff --git a/packages/pi-agent-core/src/index.ts b/packages/pi-agent-core/src/index.ts index d8ed5b8eb..ded337b37 100644 --- a/packages/pi-agent-core/src/index.ts +++ b/packages/pi-agent-core/src/index.ts @@ -2,7 +2,11 @@ export * from "./agent.js"; // Loop functions export * from "./agent-loop.js"; +// Interactive question contract +export * from "./interactive-questions.js"; // Proxy utilities export * from "./proxy.js"; +// SF project graph +export * from "./sf-graph.js"; // Types export * from "./types.js"; diff --git a/packages/pi-agent-core/src/interactive-questions.test.ts b/packages/pi-agent-core/src/interactive-questions.test.ts new file mode 100644 index 000000000..74e4fcacb --- /dev/null +++ b/packages/pi-agent-core/src/interactive-questions.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { + formatRoundResultForTool, + type Question, + roundResultFromElicitationContent, + roundResultFromRemoteAnswer, +} from "./interactive-questions.js"; + +const questions: Question[] = [ + { + id: "choice", + header: "Choice", + question: "Pick one", + options: [ + { label: "Alpha", description: "A" }, + { label: "None of the above", description: "Other" }, + ], + }, + { + id: "multi", + header: "Multi", + question: "Pick many", + allowMultiple: true, + options: [ + { label: "Frontend", description: "UI" }, + { label: "Backend", description: "API" }, + ], + }, +]; + +test("roundResultFromElicitationContent preserves notes and multi-select arrays", () => { + const result = roundResultFromElicitationContent(questions, { + action: "accept", + content: { + choice: "None of the above", + choice__note: "Hybrid", + multi: ["Frontend"], + }, + }); + + assert.deepEqual(result, { + endInterview: false, + answers: { + choice: { selected: "None of the above", notes: "Hybrid" }, + multi: { selected: ["Frontend"], notes: "" }, + }, + }); +}); + +test("roundResultFromRemoteAnswer uses question metadata to keep one multi-select as array", () => { + const result = roundResultFromRemoteAnswer( + { + answers: { + choice: { answers: ["Alpha"] }, + multi: { answers: ["Backend"] }, + }, + }, + questions, + ); + + assert.deepEqual(result.answers.choice.selected, "Alpha"); + assert.deepEqual(result.answers.multi.selected, ["Backend"]); + assert.equal( + formatRoundResultForTool(result), + JSON.stringify({ + answers: { + choice: { answers: ["Alpha"] }, + multi: { answers: ["Backend"] }, + }, + }), + ); +}); diff --git a/packages/pi-agent-core/src/interactive-questions.ts b/packages/pi-agent-core/src/interactive-questions.ts new file mode 100644 index 000000000..a7163e138 --- /dev/null +++ b/packages/pi-agent-core/src/interactive-questions.ts @@ -0,0 +1,171 @@ +/** + * Shared structured-question contract for local UI, remote channels, and MCP. + * + * Purpose: keep every ask_user_questions transport on the same answer shape so + * gate hooks and LLM-facing JSON do not drift between local TUI, remote + * Slack/Discord/Telegram, and MCP elicitation paths. + * + * Consumer: SF ask_user_questions extension, remote question manager, and the + * packaged MCP server. + */ + +export interface QuestionOption { + label: string; + description: string; +} + +export interface Question { + id: string; + header: string; + question: string; + options: QuestionOption[]; + allowMultiple?: boolean; +} + +export interface RoundAnswer { + selected: string | string[]; + notes: string; +} + +export interface RoundResult { + /** Always false; wrap-up/exit is handled outside a single question round. */ + endInterview: false; + answers: Record; +} + +export interface RemoteAnswerLike { + answers: Record; +} + +export type ElicitationContentValue = string | number | boolean | string[]; + +export interface ElicitationResultLike { + action?: "accept" | "decline" | "cancel" | string; + content?: Record; +} + +export const DEFAULT_OTHER_OPTION_LABEL = "None of the above"; + +function normalizeNote(value: ElicitationContentValue | undefined): string { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeSelectedList( + value: ElicitationContentValue | undefined, + allowMultiple: boolean, +): string[] { + if (allowMultiple) { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; + } + return typeof value === "string" && value.length > 0 ? [value] : []; +} + +/** + * Convert local/MCP elicitation form content into the canonical RoundResult. + * + * Purpose: preserve the multi-select array contract and "None of the above" + * notes consistently across transports. + * + * Consumer: MCP ask_user_questions handler and any form-based local bridge. + */ +export function roundResultFromElicitationContent( + questions: readonly Question[], + result: ElicitationResultLike, + otherOptionLabel = DEFAULT_OTHER_OPTION_LABEL, +): RoundResult { + const content = result.content ?? {}; + const answers: Record = {}; + + for (const question of questions) { + if (question.allowMultiple) { + answers[question.id] = { + selected: normalizeSelectedList(content[question.id], true), + notes: "", + }; + continue; + } + + const list = normalizeSelectedList(content[question.id], false); + const selected = list[0] ?? ""; + const notes = + selected === otherOptionLabel + ? normalizeNote(content[`${question.id}__note`]) + : ""; + answers[question.id] = { selected, notes }; + } + + return { endInterview: false, answers }; +} + +/** + * Convert a remote-channel answer into the canonical RoundResult. + * + * Purpose: remote adapters store answers as `{ answers: string[] }`; consumers + * need the same `selected` shape as local TUI, especially array preservation for + * multi-select questions with a single selected item. + * + * Consumer: SF remote question manager. + */ +export function roundResultFromRemoteAnswer( + answer: RemoteAnswerLike, + questions: readonly Question[], +): RoundResult { + const allowMultipleById = new Map(); + for (const question of questions) { + allowMultipleById.set(question.id, question.allowMultiple ?? false); + } + + const answers: Record = {}; + for (const [id, data] of Object.entries(answer.answers)) { + const list = data.answers ?? []; + const allowMultiple = allowMultipleById.get(id) ?? false; + answers[id] = { + selected: allowMultiple ? [...list] : (list[0] ?? ""), + notes: data.user_note ?? "", + }; + } + + return { endInterview: false, answers }; +} + +/** + * Render the canonical RoundResult as the historical LLM/tool JSON payload. + * + * Purpose: keep the text response backward-compatible while structured callers + * consume RoundResult directly. + * + * Consumer: ask_user_questions local/remote/MCP handlers. + */ +export function formatRoundResultForTool(result: RoundResult): string { + const answers: Record = {}; + for (const [id, answer] of Object.entries(result.answers)) { + const list = Array.isArray(answer.selected) + ? [...answer.selected] + : [answer.selected]; + if (answer.notes) list.push(`user_note: ${answer.notes}`); + answers[id] = { answers: list }; + } + return JSON.stringify({ answers }); +} + +/** + * Build the structured content payload shared by MCP and extension details. + * + * Purpose: provide the same cancellation and response contract to gate hooks + * regardless of transport. + * + * Consumer: MCP ask_user_questions handler. + */ +export function buildQuestionStructuredContent( + questions: readonly Question[], + response: RoundResult | null, + cancelled: boolean, +): { + questions: readonly Question[]; + response: RoundResult | null; + cancelled: boolean; +} { + return { questions, response, cancelled }; +} diff --git a/packages/pi-agent-core/src/sf-graph.ts b/packages/pi-agent-core/src/sf-graph.ts new file mode 100644 index 000000000..0721c66cc --- /dev/null +++ b/packages/pi-agent-core/src/sf-graph.ts @@ -0,0 +1,1035 @@ +/** + * SF project graph reader. + * + * Purpose: derive local SF planning knowledge from `.sf/` artifacts without + * making MCP the owner of core graph behavior. The graph remains generated + * state under `.sf/graphs/`; durable repo artifacts still go through explicit + * promotion. + * + * Consumer: `sf graph`, MCP `sf_graph`, and prompt context injection. + */ + +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type NodeType = + | "milestone" + | "slice" + | "task" + | "rule" + | "pattern" + | "lesson" + | "concept" + | "decision"; + +export type EdgeType = "contains" | "depends_on" | "relates_to" | "implements"; + +export type ConfidenceTier = "EXTRACTED" | "INFERRED" | "AMBIGUOUS"; + +export interface GraphNode { + id: string; + label: string; + type: NodeType; + description?: string; + confidence: ConfidenceTier; + sourceFile?: string; +} + +export interface GraphEdge { + from: string; + to: string; + type: EdgeType; + confidence: ConfidenceTier; +} + +export interface KnowledgeGraph { + nodes: GraphNode[]; + edges: GraphEdge[]; + builtAt: string; +} + +export interface GraphStatusResult { + exists: boolean; + lastBuild?: string; + nodeCount?: number; + edgeCount?: number; + stale?: boolean; + ageHours?: number; +} + +export interface GraphQueryResult { + nodes: GraphNode[]; + edges: GraphEdge[]; + term: string; + budget: number; +} + +export interface GraphDiffResult { + nodes: { + added: string[]; + removed: string[]; + changed: string[]; + }; + edges: { + added: string[]; + removed: string[]; + }; +} + +// --------------------------------------------------------------------------- +// Graph file paths +// --------------------------------------------------------------------------- + +/** + * Resolve the `.sf/` root for a project checkout. + * + * Purpose: keep derived graph state attached to SF's local project state, + * including external-state symlinks, without requiring callers to know where + * the active `.sf` directory physically lives. + * + * Consumer: SF graph builders and readers. + */ +export function resolveSFRoot(projectDir: string): string { + const resolved = resolve(projectDir); + const direct = join(resolved, ".sf"); + if (existsSync(direct) && statSync(direct).isDirectory()) { + return direct; + } + + 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 is unavailable. + } + + let dir = resolved; + while (dir !== dirname(dir)) { + const candidate = join(dir, ".sf"); + if (existsSync(candidate) && statSync(candidate).isDirectory()) { + return candidate; + } + dir = dirname(dir); + } + + return direct; +} + +function milestonesDir(sfRoot: string): string { + return join(sfRoot, "milestones"); +} + +function findMilestoneIds(sfRoot: string): string[] { + const dir = milestonesDir(sfRoot); + if (!existsSync(dir)) return []; + + const ids: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const match = entry.name.match(/^(M\d+)/); + if (match) ids.push(match[1]); + } + + return ids.sort(); +} + +function resolveMilestoneDir( + sfRoot: string, + milestoneId: string, +): string | null { + const dir = milestonesDir(sfRoot); + if (!existsSync(dir)) return null; + + const exact = join(dir, milestoneId); + if (existsSync(exact) && statSync(exact).isDirectory()) return exact; + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.startsWith(milestoneId)) { + return join(dir, entry.name); + } + } + + return null; +} + +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 ids: string[] = []; + for (const entry of readdirSync(slicesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const match = entry.name.match(/^(S\d+)/); + if (match) ids.push(match[1]); + } + + return ids.sort(); +} + +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; + + for (const entry of readdirSync(slicesDir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.startsWith(sliceId)) { + return join(slicesDir, entry.name); + } + } + + return null; +} + +function graphsDir(sfRoot: string): string { + return join(sfRoot, "graphs"); +} + +function graphJsonPath(sfRoot: string): string { + return join(graphsDir(sfRoot), "graph.json"); +} + +function graphTmpPath(sfRoot: string): string { + return join(graphsDir(sfRoot), "graph.tmp.json"); +} + +function snapshotPath(sfRoot: string): string { + return join(graphsDir(sfRoot), ".last-build-snapshot.json"); +} + +// --------------------------------------------------------------------------- +// Parsers — each returns nodes/edges and never throws +// --------------------------------------------------------------------------- + +/** + * Parse STATE.md for active milestone and phase concepts. + */ +function parseStateFile( + sfRoot: string, + nodes: GraphNode[], + _edges: GraphEdge[], +): void { + const statePath = join(sfRoot, "STATE.md"); + if (!existsSync(statePath)) return; + + let content: string; + try { + content = readFileSync(statePath, "utf-8"); + } catch { + return; + } + + // Extract active milestone + const activeMilestoneMatch = content.match( + /\*\*Active Milestone:\*\*\s+([A-Z]\d+):\s+(.+)/i, + ); + if (activeMilestoneMatch) { + const [, milestoneId, title] = activeMilestoneMatch; + const id = `milestone:${milestoneId}`; + if (!nodes.some((n) => n.id === id)) { + nodes.push({ + id, + label: `${milestoneId}: ${title.trim()}`, + type: "milestone", + description: `Active milestone: ${milestoneId}`, + confidence: "EXTRACTED", + sourceFile: "STATE.md", + }); + } + } + + // Extract phase as concept + const phaseMatch = content.match(/\*\*Phase:\*\*\s+(\S+)/i); + if (phaseMatch) { + const phase = phaseMatch[1].trim(); + nodes.push({ + id: `concept:phase:${phase}`, + label: `Phase: ${phase}`, + type: "concept", + confidence: "EXTRACTED", + sourceFile: "STATE.md", + }); + } +} + +/** + * Parse KNOWLEDGE.md for rules, patterns, and lessons. + */ +function parseKnowledgeFile( + sfRoot: string, + nodes: GraphNode[], + _edges: GraphEdge[], +): void { + const knowledgePath = join(sfRoot, "KNOWLEDGE.md"); + if (!existsSync(knowledgePath)) return; + + let content: string; + try { + content = readFileSync(knowledgePath, "utf-8"); + } catch { + return; + } + + // Parse Rules table + const rulesMatch = content.match(/## Rules\s*\n([\s\S]*?)(?=\n## |$)/i); + if (rulesMatch) { + for (const line of rulesMatch[1].split("\n")) { + if (!line.includes("|")) continue; + const cells = line + .split("|") + .map((c) => c.trim()) + .filter(Boolean); + if (cells.length < 3) continue; + if (cells[0].startsWith("#") || cells[0].startsWith("-")) continue; + const id = cells[0]; + if (!/^K\d+$/i.test(id)) continue; + nodes.push({ + id: `rule:${id}`, + label: id, + type: "rule", + description: cells[2] ?? "", + confidence: "EXTRACTED", + sourceFile: "KNOWLEDGE.md", + }); + } + } + + // Parse Patterns table + const patternsMatch = content.match(/## Patterns\s*\n([\s\S]*?)(?=\n## |$)/i); + if (patternsMatch) { + for (const line of patternsMatch[1].split("\n")) { + if (!line.includes("|")) continue; + const cells = line + .split("|") + .map((c) => c.trim()) + .filter(Boolean); + if (cells.length < 2) continue; + if (cells[0].startsWith("#") || cells[0].startsWith("-")) continue; + const id = cells[0]; + if (!/^P\d+$/i.test(id)) continue; + nodes.push({ + id: `pattern:${id}`, + label: id, + type: "pattern", + description: cells[1] ?? "", + confidence: "EXTRACTED", + sourceFile: "KNOWLEDGE.md", + }); + } + } + + // Parse Lessons Learned table + const lessonsMatch = content.match( + /## Lessons Learned\s*\n([\s\S]*?)(?=\n## |$)/i, + ); + if (lessonsMatch) { + for (const line of lessonsMatch[1].split("\n")) { + if (!line.includes("|")) continue; + const cells = line + .split("|") + .map((c) => c.trim()) + .filter(Boolean); + if (cells.length < 2) continue; + if (cells[0].startsWith("#") || cells[0].startsWith("-")) continue; + const id = cells[0]; + if (!/^L\d+$/i.test(id)) continue; + nodes.push({ + id: `lesson:${id}`, + label: id, + type: "lesson", + description: cells[1] ?? "", + confidence: "EXTRACTED", + sourceFile: "KNOWLEDGE.md", + }); + } + } +} + +/** + * Parse milestone ROADMAP.md files for milestones and slices. + */ +function parseMilestoneFiles( + sfRoot: string, + nodes: GraphNode[], + edges: GraphEdge[], +): void { + const milestoneIds = findMilestoneIds(sfRoot); + + for (const milestoneId of milestoneIds) { + try { + parseSingleMilestone(sfRoot, milestoneId, nodes, edges); + } catch { + // Skip this milestone on any error + } + } +} + +function parseSingleMilestone( + sfRoot: string, + milestoneId: string, + nodes: GraphNode[], + edges: GraphEdge[], +): void { + const mDir = resolveMilestoneDir(sfRoot, milestoneId); + if (!mDir) return; + + const milestoneNodeId = `milestone:${milestoneId}`; + + // Try to read the roadmap file + const roadmapPath = join(mDir, `${milestoneId}-ROADMAP.md`); + let roadmapContent: string | null = null; + if (existsSync(roadmapPath)) { + try { + roadmapContent = readFileSync(roadmapPath, "utf-8"); + } catch { + // Skip + } + } + + // Extract milestone title from roadmap + let milestoneTitle = milestoneId; + if (roadmapContent) { + const titleMatch = roadmapContent.match(/^#\s+[A-Z]\d+:\s+(.+)/m); + if (titleMatch) milestoneTitle = `${milestoneId}: ${titleMatch[1].trim()}`; + } + + // Ensure milestone node exists + if (!nodes.some((n) => n.id === milestoneNodeId)) { + nodes.push({ + id: milestoneNodeId, + label: milestoneTitle, + type: "milestone", + confidence: "EXTRACTED", + sourceFile: roadmapContent + ? `milestones/${milestoneId}/${milestoneId}-ROADMAP.md` + : undefined, + }); + } + + // Parse slices from roadmap table or filesystem + const sliceIds = findSliceIds(sfRoot, milestoneId); + for (const sliceId of sliceIds) { + try { + parseSingleSlice( + sfRoot, + milestoneId, + sliceId, + milestoneNodeId, + nodes, + edges, + ); + } catch { + // Skip this slice on any error + } + } +} + +function parseSingleSlice( + sfRoot: string, + milestoneId: string, + sliceId: string, + milestoneNodeId: string, + nodes: GraphNode[], + edges: GraphEdge[], +): void { + const sDir = resolveSliceDir(sfRoot, milestoneId, sliceId); + if (!sDir) return; + + const sliceNodeId = `slice:${milestoneId}:${sliceId}`; + + // Try to read the slice plan + const planPath = join(sDir, `${sliceId}-PLAN.md`); + let sliceTitle = `${milestoneId}/${sliceId}`; + let planContent: string | null = null; + + if (existsSync(planPath)) { + try { + planContent = readFileSync(planPath, "utf-8"); + const titleMatch = planContent.match(/^#\s+[A-Z]\d+:\s+(.+)/m); + if (titleMatch) sliceTitle = `${sliceId}: ${titleMatch[1].trim()}`; + } catch { + // Use default title + } + } + + nodes.push({ + id: sliceNodeId, + label: sliceTitle, + type: "slice", + confidence: "EXTRACTED", + sourceFile: planContent + ? `milestones/${milestoneId}/slices/${sliceId}/${sliceId}-PLAN.md` + : undefined, + }); + + // Edge: milestone contains slice + edges.push({ + from: milestoneNodeId, + to: sliceNodeId, + type: "contains", + confidence: "EXTRACTED", + }); + + // Parse tasks from the slice plan + if (planContent) { + parseTasksFromPlan( + planContent, + milestoneId, + sliceId, + sliceNodeId, + nodes, + edges, + ); + } +} + +function parseTasksFromPlan( + content: string, + milestoneId: string, + sliceId: string, + sliceNodeId: string, + nodes: GraphNode[], + edges: GraphEdge[], +): void { + // Match lines like: - [ ] **T01: Title** — description + const taskPattern = /[-*]\s+\[[ x]\]\s+\*\*(T\d+):\s*([^*]+)\*\*/g; + let match: RegExpExecArray | null; + + while ((match = taskPattern.exec(content)) !== null) { + const [, taskId, taskTitle] = match; + const taskNodeId = `task:${milestoneId}:${sliceId}:${taskId}`; + + nodes.push({ + id: taskNodeId, + label: `${taskId}: ${taskTitle.trim()}`, + type: "task", + confidence: "EXTRACTED", + }); + + edges.push({ + from: sliceNodeId, + to: taskNodeId, + type: "contains", + confidence: "EXTRACTED", + }); + } +} + +// --------------------------------------------------------------------------- +// LEARNINGS.md parser +// --------------------------------------------------------------------------- + +/** + * Parse all *-LEARNINGS.md files found in milestone directories. + * Extracts Decisions, Lessons, Patterns, and Surprises as typed graph nodes. + * Surprises are mapped to the 'lesson' NodeType (no distinct type exists). + * Parse errors per file are caught — the file is skipped, never rethrows. + */ +function parseLearningsFiles( + sfRoot: string, + nodes: GraphNode[], + edges: GraphEdge[], +): void { + const milestoneIds = findMilestoneIds(sfRoot); + + for (const milestoneId of milestoneIds) { + try { + parseSingleLearningsFile(sfRoot, milestoneId, nodes, edges); + } catch { + // Skip this milestone's LEARNINGS.md on any error + } + } +} + +function parseSingleLearningsFile( + sfRoot: string, + milestoneId: string, + nodes: GraphNode[], + edges: GraphEdge[], +): void { + const mDir = resolveMilestoneDir(sfRoot, milestoneId); + if (!mDir) return; + + const learningsPath = join(mDir, `${milestoneId}-LEARNINGS.md`); + if (!existsSync(learningsPath)) return; + + let content: string; + try { + content = readFileSync(learningsPath, "utf-8"); + } catch { + return; + } + + // Strip YAML frontmatter if present + const withoutFrontmatter = content.replace(/^---[\s\S]*?---\n?/, ""); + + const milestoneNodeId = `milestone:${milestoneId}`; + const sourceFile = `milestones/${milestoneId}/${milestoneId}-LEARNINGS.md`; + + // Parse each section: [sectionName, nodeType, idPrefix] + const sections: Array<[string, NodeType, string]> = [ + ["Decisions", "decision", "decision"], + ["Lessons", "lesson", "lesson"], + ["Patterns", "pattern", "pattern"], + ["Surprises", "lesson", "surprise"], + ]; + + for (const [sectionName, nodeType, idPrefix] of sections) { + const sectionMatch = withoutFrontmatter.match( + new RegExp(`##\\s+${sectionName}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, "i"), + ); + if (!sectionMatch) continue; + + const sectionContent = sectionMatch[1]; + parseLearningsSection( + sectionContent, + milestoneId, + idPrefix, + nodeType, + milestoneNodeId, + sourceFile, + nodes, + edges, + ); + } +} + +function parseLearningsSection( + sectionContent: string, + milestoneId: string, + idPrefix: string, + nodeType: NodeType, + milestoneNodeId: string, + sourceFile: string, + nodes: GraphNode[], + edges: GraphEdge[], +): void { + // Each item is a bullet line starting with "- " followed by optional + // indented "Source: ..." line. + // We collect bullet items and their associated source attribution. + const lines = sectionContent.split("\n"); + let itemIndex = 0; + let currentText: string | null = null; + let currentSource: string | null = null; + + const flushItem = (): void => { + if (!currentText) return; + itemIndex += 1; + const nodeId = `${idPrefix}:${milestoneId}:${itemIndex}`; + const description = currentSource ? `${currentSource}` : undefined; + + nodes.push({ + id: nodeId, + label: currentText, + type: nodeType, + description, + confidence: "EXTRACTED", + sourceFile, + }); + + // Edge: milestone relates_to this learning node + edges.push({ + from: milestoneNodeId, + to: nodeId, + type: "relates_to", + confidence: "EXTRACTED", + }); + + currentText = null; + currentSource = null; + }; + + for (const line of lines) { + const bulletMatch = line.match(/^[-*]\s+(.+)/); + if (bulletMatch) { + flushItem(); + currentText = bulletMatch[1].trim(); + continue; + } + + // Indented source attribution: " Source: ..." + const sourceMatch = line.match(/^\s+Source:\s+(.+)/i); + if (sourceMatch && currentText !== null) { + currentSource = `Source: ${sourceMatch[1].trim()}`; + continue; + } + + // Continuation of current item text (indented non-source line) + const continuationMatch = line.match(/^\s{2,}(.+)/); + if (continuationMatch && currentText !== null && currentSource === null) { + currentText += " " + continuationMatch[1].trim(); + } + } + + flushItem(); +} + +// --------------------------------------------------------------------------- +// buildGraph +// --------------------------------------------------------------------------- + +/** + * Build a KnowledgeGraph by parsing all .sf/ artifacts. + * + * Parse errors in any single artifact are caught — the artifact is skipped + * and never causes buildGraph() to throw. + */ +export async function buildGraph(projectDir: string): Promise { + const sfRoot = resolveSFRoot(resolve(projectDir)); + + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + + // Each parser is wrapped so a crash in one never stops others + const parsers: Array<(g: string, n: GraphNode[], e: GraphEdge[]) => void> = [ + parseStateFile, + parseKnowledgeFile, + parseMilestoneFiles, + parseLearningsFiles, + ]; + + for (const parser of parsers) { + try { + parser(sfRoot, nodes, edges); + } catch { + // Parsing error — skip this artifact, mark as ambiguous + nodes.push({ + id: `error:${parser.name}:${Date.now()}`, + label: `Parse error in ${parser.name}`, + type: "concept", + confidence: "AMBIGUOUS", + }); + } + } + + // Deduplicate nodes by id (keep first occurrence) + const seen = new Set(); + const dedupedNodes = nodes.filter((n) => { + if (seen.has(n.id)) return false; + seen.add(n.id); + return true; + }); + + return { + nodes: dedupedNodes, + edges, + builtAt: new Date().toISOString(), + }; +} + +// --------------------------------------------------------------------------- +// writeGraph — atomic write via tmp + rename +// --------------------------------------------------------------------------- + +/** + * Write the graph to .sf/graphs/graph.json atomically. + * + * Writes to graph.tmp.json first, then renames to graph.json. + * Creates the graphs/ directory if it does not exist. + */ +export async function writeGraph( + sfRoot: string, + graph: KnowledgeGraph, +): Promise { + const dir = graphsDir(sfRoot); + mkdirSync(dir, { recursive: true }); + + const tmp = graphTmpPath(sfRoot); + const final = graphJsonPath(sfRoot); + + writeFileSync(tmp, JSON.stringify(graph, null, 2), "utf-8"); + renameSync(tmp, final); +} + +// --------------------------------------------------------------------------- +// writeSnapshot +// --------------------------------------------------------------------------- + +/** + * Copy the current graph.json to .last-build-snapshot.json. + * Adds a snapshotAt timestamp to the copy. + */ +export async function writeSnapshot(sfRoot: string): Promise { + const src = graphJsonPath(sfRoot); + if (!existsSync(src)) return; + + const dir = graphsDir(sfRoot); + mkdirSync(dir, { recursive: true }); + + const raw = readFileSync(src, "utf-8"); + let graph: KnowledgeGraph; + try { + graph = JSON.parse(raw) as KnowledgeGraph; + } catch { + return; + } + const snapshot = { ...graph, snapshotAt: new Date().toISOString() }; + + writeFileSync( + snapshotPath(sfRoot), + JSON.stringify(snapshot, null, 2), + "utf-8", + ); +} + +// --------------------------------------------------------------------------- +// graphStatus +// --------------------------------------------------------------------------- + +/** + * Return status of the graph: whether it exists, its age, and whether it is stale. + * Stale means builtAt is older than 24 hours. + */ +export async function graphStatus( + projectDir: string, +): Promise { + const sfRoot = resolveSFRoot(resolve(projectDir)); + const graphPath = graphJsonPath(sfRoot); + + if (!existsSync(graphPath)) { + return { exists: false }; + } + + try { + const raw = readFileSync(graphPath, "utf-8"); + const graph = JSON.parse(raw) as KnowledgeGraph; + + const builtAt = graph.builtAt; + const ageMs = Date.now() - new Date(builtAt).getTime(); + const ageHours = ageMs / (1000 * 60 * 60); + const stale = ageHours > 24; + + return { + exists: true, + lastBuild: builtAt, + nodeCount: graph.nodes.length, + edgeCount: graph.edges.length, + stale, + ageHours, + }; + } catch { + return { exists: false }; + } +} + +// --------------------------------------------------------------------------- +// applyBudget — trim edges to stay within token budget +// --------------------------------------------------------------------------- + +/** + * Given a set of seed node IDs and the full graph, apply BFS to collect + * reachable nodes and edges. Trims AMBIGUOUS edges first, then INFERRED, + * stopping when the estimated token count drops within budget. + * + * Budget is a rough token estimate: 1 node ≈ 20 tokens, 1 edge ≈ 10 tokens. + */ +function applyBudget( + graph: KnowledgeGraph, + seedIds: Set, + budget: number, +): { nodes: GraphNode[]; edges: GraphEdge[] } { + // BFS to collect reachable nodes (start from seeds) + const reachable = new Set(seedIds); + const queue = [...seedIds]; + + while (queue.length > 0) { + const current = queue.shift()!; + for (const edge of graph.edges) { + if (edge.from === current && !reachable.has(edge.to)) { + reachable.add(edge.to); + queue.push(edge.to); + } + } + } + + const resultNodes = graph.nodes.filter((n) => reachable.has(n.id)); + let resultEdges = graph.edges.filter( + (e) => reachable.has(e.from) && reachable.has(e.to), + ); + + // Estimate tokens and trim if over budget + // Trim AMBIGUOUS edges first, then INFERRED + const estimate = (): number => + resultNodes.length * 20 + resultEdges.length * 10; + + if (estimate() > budget) { + resultEdges = resultEdges.filter((e) => e.confidence !== "AMBIGUOUS"); + } + if (estimate() > budget) { + resultEdges = resultEdges.filter((e) => e.confidence !== "INFERRED"); + } + if (estimate() > budget) { + // Hard trim — keep only seed nodes and their EXTRACTED edges + const seedNodes = resultNodes.filter((n) => seedIds.has(n.id)); + const seedEdges = resultEdges.filter( + (e) => seedIds.has(e.from) && e.confidence === "EXTRACTED", + ); + return { nodes: seedNodes, edges: seedEdges }; + } + + return { nodes: resultNodes, edges: resultEdges }; +} + +// --------------------------------------------------------------------------- +// graphQuery +// --------------------------------------------------------------------------- + +/** + * Query the graph for nodes matching a term (case-insensitive on label + description). + * BFS from seed nodes, applying budget trimming. + * + * Reads from the pre-built graph.json. Falls back to an empty result if no + * graph exists. + */ +export async function graphQuery( + projectDir: string, + term: string, + budget = 4000, +): Promise { + const sfRoot = resolveSFRoot(resolve(projectDir)); + const graphPath = graphJsonPath(sfRoot); + + if (!existsSync(graphPath)) { + return { nodes: [], edges: [], term, budget }; + } + + let graph: KnowledgeGraph; + try { + const raw = readFileSync(graphPath, "utf-8"); + graph = JSON.parse(raw) as KnowledgeGraph; + } catch { + return { nodes: [], edges: [], term, budget }; + } + + if (!term || term.trim() === "") { + // Empty term — return empty result + return { nodes: [], edges: [], term, budget }; + } + + const lower = term.toLowerCase(); + + // Find seed nodes that match the term + const seedIds = new Set( + graph.nodes + .filter((n) => { + const labelMatch = n.label.toLowerCase().includes(lower); + const descMatch = n.description?.toLowerCase().includes(lower) ?? false; + return labelMatch || descMatch; + }) + .map((n) => n.id), + ); + + if (seedIds.size === 0) { + return { nodes: [], edges: [], term, budget }; + } + + const result = applyBudget(graph, seedIds, budget); + return { ...result, term, budget }; +} + +// --------------------------------------------------------------------------- +// graphDiff +// --------------------------------------------------------------------------- + +/** + * Compare the current graph.json with .last-build-snapshot.json. + * Returns added/removed/changed nodes and added/removed edges. + * + * If no snapshot exists, returns empty diff arrays. + */ +export async function graphDiff(projectDir: string): Promise { + const sfRoot = resolveSFRoot(resolve(projectDir)); + const empty: GraphDiffResult = { + nodes: { added: [], removed: [], changed: [] }, + edges: { added: [], removed: [] }, + }; + + const graphPath = graphJsonPath(sfRoot); + const snap = snapshotPath(sfRoot); + + if (!existsSync(graphPath)) return empty; + if (!existsSync(snap)) return empty; + + let current: KnowledgeGraph; + let snapshot: KnowledgeGraph; + + try { + current = JSON.parse(readFileSync(graphPath, "utf-8")) as KnowledgeGraph; + } catch { + return empty; + } + + try { + snapshot = JSON.parse(readFileSync(snap, "utf-8")) as KnowledgeGraph; + } catch { + return empty; + } + + const currentNodeIds = new Set(current.nodes.map((n) => n.id)); + const snapshotNodeIds = new Set(snapshot.nodes.map((n) => n.id)); + + const added = current.nodes + .filter((n) => !snapshotNodeIds.has(n.id)) + .map((n) => n.id); + const removed = snapshot.nodes + .filter((n) => !currentNodeIds.has(n.id)) + .map((n) => n.id); + + // Changed: same id but different label or description + const snapshotNodeMap = new Map(snapshot.nodes.map((n) => [n.id, n])); + const changed = current.nodes + .filter((n) => { + const snap = snapshotNodeMap.get(n.id); + if (!snap) return false; + return n.label !== snap.label || n.description !== snap.description; + }) + .map((n) => n.id); + + // Edges — compare by string key "from->to:type" + const edgeKey = (e: GraphEdge): string => `${e.from}->${e.to}:${e.type}`; + const currentEdgeKeys = new Set(current.edges.map(edgeKey)); + const snapshotEdgeKeys = new Set(snapshot.edges.map(edgeKey)); + + const edgesAdded = current.edges + .filter((e) => !snapshotEdgeKeys.has(edgeKey(e))) + .map(edgeKey); + const edgesRemoved = snapshot.edges + .filter((e) => !currentEdgeKeys.has(edgeKey(e))) + .map(edgeKey); + + return { + nodes: { added, removed, changed }, + edges: { added: edgesAdded, removed: edgesRemoved }, + }; +} diff --git a/packages/pi-ai/src/utils/tests/json-parse.test.ts b/packages/pi-ai/src/utils/tests/json-parse.test.ts index 298d7eb70..deae944da 100644 --- a/packages/pi-ai/src/utils/tests/json-parse.test.ts +++ b/packages/pi-ai/src/utils/tests/json-parse.test.ts @@ -1,5 +1,5 @@ -import { describe } from 'vitest'; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { parseStreamingJson } from "../json-parse.js"; describe("parseStreamingJson — XML parameter recovery (#3751)", () => { diff --git a/packages/pi-ai/src/utils/tests/overflow.test.ts b/packages/pi-ai/src/utils/tests/overflow.test.ts index 3dee7fe87..49d2897b2 100644 --- a/packages/pi-ai/src/utils/tests/overflow.test.ts +++ b/packages/pi-ai/src/utils/tests/overflow.test.ts @@ -1,10 +1,11 @@ -import { describe } from 'vitest'; import assert from "node:assert/strict"; - -import { isContextOverflow } from "../overflow.js"; +import { describe, test } from "vitest"; import type { AssistantMessage } from "../../types.js"; +import { isContextOverflow } from "../overflow.js"; -function makeAssistantMessage(overrides: Partial = {}): AssistantMessage { +function makeAssistantMessage( + overrides: Partial = {}, +): AssistantMessage { return { role: "assistant", content: [], diff --git a/packages/pi-ai/src/utils/tests/repair-tool-json.test.ts b/packages/pi-ai/src/utils/tests/repair-tool-json.test.ts index 6f1917017..7967f5ee7 100644 --- a/packages/pi-ai/src/utils/tests/repair-tool-json.test.ts +++ b/packages/pi-ai/src/utils/tests/repair-tool-json.test.ts @@ -1,11 +1,11 @@ -import { describe } from 'vitest'; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { + hasTruncatedNumbers, + hasXmlParameterTags, + hasYamlBulletLists, repairToolJson, repairToolJsonWithReport, - hasYamlBulletLists, - hasXmlParameterTags, - hasTruncatedNumbers, TOOL_JSON_REPAIR_PIPELINE_VERSION, } from "../repair-tool-json.js"; @@ -83,11 +83,17 @@ describe("repairToolJson — YAML bullet list repair (#2660)", () => { assert.equal(parsed.milestoneId, "M005"); assert.equal(parsed.title, "Native Desktop Polish"); - assert.ok(Array.isArray(parsed.keyDecisions), "keyDecisions should be an array"); + assert.ok( + Array.isArray(parsed.keyDecisions), + "keyDecisions should be an array", + ); assert.ok(parsed.keyDecisions[0].includes("Web Notification API")); assert.ok(Array.isArray(parsed.keyFiles), "keyFiles should be an array"); assert.ok(parsed.keyFiles[0].includes("src-tauri/src/lib.rs")); - assert.ok(Array.isArray(parsed.lessonsLearned), "lessonsLearned should be an array"); + assert.ok( + Array.isArray(parsed.lessonsLearned), + "lessonsLearned should be an array", + ); assert.equal(parsed.verificationPassed, true); }); @@ -155,10 +161,7 @@ describe("repairToolJson — full YAML object fallback", () => { assert.ok(report.repairs.includes("yaml")); assert.deepEqual(parsed, { title: "Done", - keyDecisions: [ - "Keep semantic model aliases", - "Prefer strict validation", - ], + keyDecisions: ["Keep semantic model aliases", "Prefer strict validation"], verificationPassed: true, }); }); @@ -177,25 +180,33 @@ describe("repairToolJson — XML parameter tag stripping (#3403)", () => { }); test("hasXmlParameterTags returns false for clean JSON", () => { - assert.equal( - hasXmlParameterTags('{"narrative": "some text"}'), - false, - ); + assert.equal(hasXmlParameterTags('{"narrative": "some text"}'), false); }); test("strips XML parameter tags from JSON values", () => { - const malformed = '{"sliceId": "S03", "narrative": The slice work}'; + const malformed = + '{"sliceId": "S03", "narrative": The slice work}'; const repaired = repairToolJson(malformed); // After stripping tags, the content should be parseable or at least tag-free - assert.ok(!repaired.includes(""), "should not contain tags"); + assert.ok( + !repaired.includes(""), + "should not contain tags", + ); }); test("handles mixed XML and JSON content", () => { - const malformed = '{"oneLiner": "done", "verification": all tests pass}'; + const malformed = + '{"oneLiner": "done", "verification": all tests pass}'; const repaired = repairToolJson(malformed); assert.ok(!repaired.includes(" { @@ -208,7 +219,10 @@ describe("repairToolJson — XML parameter tag stripping (#3403)", () => { assert.equal(parsed.verification, "all tests pass"); assert.deepEqual(parsed.verificationEvidence, ["npm test"]); assert.equal(parsed.oneLiner, "done"); - assert.ok(!parsed.narrative.includes(" { }); test("hasTruncatedNumbers returns false for valid numbers", () => { - assert.equal(hasTruncatedNumbers('"exitCode": 0, "durationMs": 1234'), false); + assert.equal( + hasTruncatedNumbers('"exitCode": 0, "durationMs": 1234'), + false, + ); }); test("hasTruncatedNumbers returns false for negative numbers", () => { @@ -238,7 +255,8 @@ describe("repairToolJson — truncated number repair (#3464)", () => { }); test("repairs truncated exitCode with bare comma", () => { - const malformed = '{"command": "npm test", "exitCode": , "verdict": "pass", "durationMs": 500}'; + const malformed = + '{"command": "npm test", "exitCode": , "verdict": "pass", "durationMs": 500}'; const repaired = repairToolJson(malformed); const parsed = JSON.parse(repaired); assert.equal(parsed.exitCode, 0); @@ -246,7 +264,8 @@ describe("repairToolJson — truncated number repair (#3464)", () => { }); test("repairs truncated exitCode with bare minus", () => { - const malformed = '{"command": "npm test", "exitCode": -, "verdict": "pass", "durationMs": 1234}'; + const malformed = + '{"command": "npm test", "exitCode": -, "verdict": "pass", "durationMs": 1234}'; const repaired = repairToolJson(malformed); const parsed = JSON.parse(repaired); assert.equal(parsed.exitCode, 0); @@ -254,7 +273,8 @@ describe("repairToolJson — truncated number repair (#3464)", () => { }); test("repairs truncated durationMs at end of object", () => { - const malformed = '{"command": "npm test", "exitCode": 0, "verdict": "pass", "durationMs": -}'; + const malformed = + '{"command": "npm test", "exitCode": 0, "verdict": "pass", "durationMs": -}'; const repaired = repairToolJson(malformed); const parsed = JSON.parse(repaired); assert.equal(parsed.durationMs, 0); diff --git a/packages/pi-coding-agent/src/modes/interactive/components/__tests__/login-dialog.test.ts b/packages/pi-coding-agent/src/modes/interactive/components/__tests__/login-dialog.test.ts index 49c18fac9..be086667c 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/__tests__/login-dialog.test.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/__tests__/login-dialog.test.ts @@ -1,5 +1,5 @@ -import { describe } from 'vitest'; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { buildAuthUrlPresentation } from "../login-dialog.js"; describe("LoginDialogComponent", () => { @@ -14,8 +14,14 @@ describe("LoginDialogComponent", () => { "https://auth.example.com/device?code=ABCD-1234&callback=oauth&state=needs-full-visibility", "narrow terminals should still truncate the hyperlink label", ); - assert.ok(presentation.fullUrlLines.length > 1, "truncated URLs should expose wrapped full-url lines"); - assert.match(presentation.fullUrlLines[0] ?? "", /https:\/\/auth\.example\.com\/device\?code=ABCD-1234&/); + assert.ok( + presentation.fullUrlLines.length > 1, + "truncated URLs should expose wrapped full-url lines", + ); + assert.match( + presentation.fullUrlLines[0] ?? "", + /https:\/\/auth\.example\.com\/device\?code=ABCD-1234&/, + ); assert.match( presentation.fullUrlLines[presentation.fullUrlLines.length - 1] ?? "", /state=needs-full-visibility/, diff --git a/packages/pi-coding-agent/src/modes/interactive/components/__tests__/provider-display-name.test.ts b/packages/pi-coding-agent/src/modes/interactive/components/__tests__/provider-display-name.test.ts index b066615f9..093de46e5 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/__tests__/provider-display-name.test.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/__tests__/provider-display-name.test.ts @@ -1,6 +1,7 @@ // SF — Provider display name mapping tests -import { describe } from 'vitest'; + import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { providerDisplayName } from "../model-selector.js"; describe("providerDisplayName", () => { diff --git a/packages/pi-coding-agent/src/modes/interactive/components/__tests__/timestamp.test.ts b/packages/pi-coding-agent/src/modes/interactive/components/__tests__/timestamp.test.ts index f92a1c762..350c03a30 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/__tests__/timestamp.test.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/__tests__/timestamp.test.ts @@ -1,5 +1,5 @@ -import { describe } from 'vitest'; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { formatTimestamp } from "../timestamp.js"; describe("formatTimestamp", () => { @@ -28,7 +28,10 @@ describe("formatTimestamp", () => { test("US format handles midnight as 12 AM", () => { const midnight = new Date(2026, 2, 24, 0, 0, 0).getTime(); - assert.equal(formatTimestamp(midnight, "date-time-us"), "03-24-2026 12:00 AM"); + assert.equal( + formatTimestamp(midnight, "date-time-us"), + "03-24-2026 12:00 AM", + ); }); test("ISO format pads single digit months and days", () => { diff --git a/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-execution.test.ts b/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-execution.test.ts index c2de93ce0..add7adf6a 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-execution.test.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-execution.test.ts @@ -1,8 +1,8 @@ -import { describe } from 'vitest'; import assert from "node:assert/strict"; import stripAnsi from "strip-ansi"; -import { ToolExecutionComponent } from "../tool-execution.js"; +import { describe, test } from "vitest"; import { initTheme } from "../../theme/theme.js"; +import { ToolExecutionComponent } from "../tool-execution.js"; initTheme("dark", false); @@ -37,13 +37,9 @@ function renderToolCollapsed( details?: Record; }, ): string { - const component = new ToolExecutionComponent( - toolName, - args, - {}, - undefined, - { requestRender() {} } as any, - ); + const component = new ToolExecutionComponent(toolName, args, {}, undefined, { + requestRender() {}, + } as any); if (result) component.updateResult(result); return stripAnsi(component.render(120).join("\n")); } @@ -87,10 +83,11 @@ describe("ToolExecutionComponent", () => { }); test("generic fallback renders compact key=value args for primitive args", () => { - const rendered = renderTool( - "some_unknown_tool", - { count: 3, enabled: true, label: "hello" }, - ); + const rendered = renderTool("some_unknown_tool", { + count: 3, + enabled: true, + label: "hello", + }); assert.match(rendered, /Some Unknown Tool/); assert.doesNotMatch(rendered, /some_unknown_tool/); @@ -101,7 +98,10 @@ describe("ToolExecutionComponent", () => { }); test("generic fallback truncates long output when collapsed", () => { - const longOutput = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join("\n"); + const longOutput = Array.from( + { length: 25 }, + (_, i) => `line ${i + 1}`, + ).join("\n"); const rendered = renderToolCollapsed( "mcp__demo__do_thing", { ok: true }, @@ -115,10 +115,10 @@ describe("ToolExecutionComponent", () => { }); test("generic fallback falls back to truncated JSON for complex args", () => { - const rendered = renderTool( - "mcp__demo__nested", - { payload: { nested: { deeply: ["a", "b", "c"] } }, name: "x" }, - ); + const rendered = renderTool("mcp__demo__nested", { + payload: { nested: { deeply: ["a", "b", "c"] } }, + name: "x", + }); assert.match(rendered, /demo\u00b7nested/); // Multi-line JSON dump for the complex payload diff --git a/rust-engine/Cargo.lock b/rust-engine/Cargo.lock index acc72001b..c977be2ba 100644 --- a/rust-engine/Cargo.lock +++ b/rust-engine/Cargo.lock @@ -113,6 +113,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -274,7 +280,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.11.0", "objc2", ] @@ -464,6 +470,7 @@ name = "forge-engine" version = "0.1.0" dependencies = [ "arboard", + "ast-grep-core", "dashmap", "forge-ast", "forge-grep", @@ -477,6 +484,8 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "notify", + "notify-debouncer-mini", "regex", "serde_json", "similar", @@ -507,6 +516,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -556,7 +574,7 @@ version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", "libgit2-sys", "log", @@ -834,6 +852,26 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "itoa" version = "1.0.17" @@ -850,6 +888,26 @@ dependencies = [ "libc", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "libc" version = "0.2.183" @@ -962,6 +1020,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -978,7 +1048,7 @@ version = "2.16.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" dependencies = [ - "bitflags", + "bitflags 2.11.0", "ctor", "napi-derive", "napi-sys", @@ -1035,6 +1105,45 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17849edfaabd9a5fef1c606d99cfc615a8e99f7ac4366406d86c7942a3184cf2" +dependencies = [ + "log", + "notify", + "notify-types", + "tempfile", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1059,7 +1168,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags", + "bitflags 2.11.0", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -1071,7 +1180,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.11.0", "dispatch2", "objc2", ] @@ -1082,7 +1191,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags", + "bitflags 2.11.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -1101,7 +1210,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", ] @@ -1112,7 +1221,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", ] @@ -1217,7 +1326,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crc32fast", "fdeflate", "flate2", @@ -1301,7 +1410,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1339,7 +1448,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -1516,6 +1625,19 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.0" diff --git a/rust-engine/crates/engine/Cargo.toml b/rust-engine/crates/engine/Cargo.toml index 6b831cb54..10f123683 100644 --- a/rust-engine/crates/engine/Cargo.toml +++ b/rust-engine/crates/engine/Cargo.toml @@ -16,6 +16,7 @@ doctest = false forge-ast = { path = "../ast" } forge-grep = { path = "../grep" } arboard = "3" +ast-grep-core = { version = "0.39", default-features = false, features = ["tree-sitter"] } dashmap = "6" globset = "0.4" html-to-markdown-rs = { version = "2", default-features = false } @@ -29,6 +30,8 @@ image = { version = "0.25", default-features = false, features = [ ] } napi = { version = "2", features = ["napi8"] } napi-derive = "2" +notify = "8" +notify-debouncer-mini = "0.7" regex = "1" serde_json = "1" similar = "2" diff --git a/rust-engine/crates/engine/src/edit.rs b/rust-engine/crates/engine/src/edit.rs new file mode 100644 index 000000000..931dcff5a --- /dev/null +++ b/rust-engine/crates/engine/src/edit.rs @@ -0,0 +1,796 @@ +//! Fast atomic file edits with LSP-compatible position semantics. +//! +//! Applies an array of `TextEdit { range, newText }` to a file and writes the +//! result atomically (write to temp file in same directory, fsync, rename). +//! Edits are sorted in descending order by start position so positions remain +//! valid during application - there is no rope; one allocation, one splice +//! pass, one write. +//! +//! Position semantics match LSP: `line` is 0-based, `character` is the count +//! of UTF-16 code units from the line start. + +use napi::{Error, Result, Status}; +use napi_derive::napi; +use std::collections::HashSet; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +#[napi(object)] +pub struct Position { + /// 0-based line number. + pub line: u32, + /// 0-based offset in UTF-16 code units from line start (LSP convention). + pub character: u32, +} + +#[napi(object)] +pub struct Range { + pub start: Position, + pub end: Position, +} + +#[napi(object)] +pub struct TextEdit { + pub range: Range, + #[napi(js_name = "newText")] + pub new_text: String, +} + +#[napi(object)] +pub struct ApplyEditsOptions { + /// fsync the temp file and parent dir before/after rename. Default true. + pub fsync: Option, +} + +#[napi(object)] +#[derive(Debug)] +pub struct ApplyEditsResult { + /// Number of edits applied. + #[napi(js_name = "editsApplied")] + pub edits_applied: u32, + /// Final file size in bytes after the write. + #[napi(js_name = "bytesWritten")] + pub bytes_written: u32, +} + +#[napi(object)] +pub struct TextDocumentEdit { + #[napi(js_name = "filePath")] + pub file_path: String, + pub edits: Vec, +} + +#[napi(object)] +#[derive(Debug)] +pub struct WorkspaceEditFileResult { + #[napi(js_name = "filePath")] + pub file_path: String, + #[napi(js_name = "editsApplied")] + pub edits_applied: u32, + #[napi(js_name = "bytesWritten")] + pub bytes_written: u32, +} + +#[napi(object)] +#[derive(Debug)] +pub struct ApplyWorkspaceEditResult { + #[napi(js_name = "filesChanged")] + pub files_changed: u32, + #[napi(js_name = "totalEditsApplied")] + pub total_edits_applied: u32, + pub files: Vec, +} + +// ─── In-memory edit pipeline ────────────────────────────────────────────── + +/// Apply `edits` to `original` bytes (which must be valid UTF-8) and return +/// the resulting bytes. `context` is a human-readable label (e.g. file path) +/// used only in error messages. +fn compute_new_bytes(original: &[u8], edits: &[TextEdit], context: &str) -> Result> { + let content = std::str::from_utf8(original).map_err(|e| { + Error::new( + Status::InvalidArg, + format!( + "{context}: file is not valid UTF-8 at byte {}: {e}", + e.valid_up_to() + ), + ) + })?; + + if edits.is_empty() { + return Ok(original.to_vec()); + } + + let line_starts = compute_line_starts(content); + + // Resolve each edit to (start_byte, end_byte, new_text). + let mut resolved: Vec<(usize, usize, String)> = Vec::with_capacity(edits.len()); + for (idx, e) in edits.iter().enumerate() { + let start = position_to_byte(content, &line_starts, &e.range.start).ok_or_else(|| { + Error::new( + Status::InvalidArg, + format!( + "{context}: edit[{idx}]: start position line {} character {} is out of range", + e.range.start.line, e.range.start.character + ), + ) + })?; + let end = position_to_byte(content, &line_starts, &e.range.end).ok_or_else(|| { + Error::new( + Status::InvalidArg, + format!( + "{context}: edit[{idx}]: end position line {} character {} is out of range", + e.range.end.line, e.range.end.character + ), + ) + })?; + if start > end { + return Err(Error::new( + Status::InvalidArg, + format!("{context}: edit[{idx}]: start ({start}) > end ({end}) in byte offsets"), + )); + } + resolved.push((start, end, e.new_text.clone())); + } + + // Sort ascending by start to detect overlaps deterministically. + resolved.sort_by_key(|(s, _, _)| *s); + for w in resolved.windows(2) { + let (_, prev_end, _) = &w[0]; + let (next_start, _, _) = &w[1]; + if next_start < prev_end { + return Err(Error::new( + Status::InvalidArg, + format!( + "{context}: overlapping edits: prev ends at {prev_end}, next starts at {next_start}" + ), + )); + } + } + + // Sort descending by start so we can splice from the back without + // invalidating earlier offsets. + resolved.sort_by(|a, b| b.0.cmp(&a.0)); + + let mut out = original.to_vec(); + for (start, end, new_text) in &resolved { + out.splice(*start..*end, new_text.bytes()); + } + + Ok(out) +} + +// ─── Atomic-write helpers ───────────────────────────────────────────────── + +/// Return a sibling `.{name}.applyEdits..` path for `final_path`. +fn make_tmp_path(final_path: &Path) -> std::io::Result { + let parent = final_path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("path has no parent: {}", final_path.display()), + ) + })?; + let file_name = final_path + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("path has no filename: {}", final_path.display()), + ) + })?; + let tmp_name = format!( + ".{file_name}.applyEdits.{}.{}", + std::process::id(), + thread_id() + ); + Ok(parent.join(tmp_name)) +} + +/// Write `content` to `tmp_path`, optionally fsyncing before returning. +/// Does NOT rename; the caller is responsible for cleanup on error. +fn write_tmp(tmp_path: &Path, content: &[u8], do_fsync: bool) -> std::io::Result<()> { + let mut f = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(tmp_path)?; + f.write_all(content)?; + if do_fsync { + f.sync_all()?; + } + Ok(()) +} + +/// Atomic write: temp file in same dir → fsync → rename → fsync parent. +fn atomic_write(path: &Path, content: &[u8], do_fsync: bool) -> std::io::Result<()> { + let parent = path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("path has no parent: {}", path.display()), + ) + })?; + + let tmp_path = make_tmp_path(path)?; + write_tmp(&tmp_path, content, do_fsync)?; + + // Rename is atomic on POSIX; on Windows it's atomic for files on the same + // volume, which the same-directory placement guarantees. + let rename_result = fs::rename(&tmp_path, path); + if rename_result.is_err() { + // Best-effort cleanup of the temp file before propagating. + let _ = fs::remove_file(&tmp_path); + return rename_result; + } + + if do_fsync { + // fsync the directory so the rename hits the disk's filesystem journal, + // not just the page cache. Best-effort: not all platforms support + // directory fsync (Windows ignores it). + if let Ok(dir_fd) = File::open(parent) { + let _ = dir_fd.sync_all(); + } + } + + Ok(()) +} + +// ─── Public API ─────────────────────────────────────────────────────────── + +/// Apply LSP-style TextEdits to a file atomically. +/// +/// Steps: +/// 1. Read file as UTF-8. +/// 2. Resolve every (line, character-utf16) pair to a byte offset. +/// 3. Validate that no two edits overlap. +/// 4. Sort edits in descending order by start byte. +/// 5. Splice into a single output Vec. +/// 6. Write to `.tmp.`, fsync, atomic rename, fsync parent dir. +/// +/// Errors: +/// - File missing or unreadable +/// - File is not valid UTF-8 +/// - Any range references a line/character position that does not exist +/// - Any two edits overlap +/// - Write or rename fails +#[napi(js_name = "applyEdits")] +pub fn apply_edits( + file_path: String, + edits: Vec, + options: Option, +) -> Result { + let opts = options.unwrap_or(ApplyEditsOptions { fsync: None }); + let do_fsync = opts.fsync.unwrap_or(true); + + let path = PathBuf::from(&file_path); + let bytes = fs::read(&path) + .map_err(|e| Error::new(Status::GenericFailure, format!("read {file_path}: {e}")))?; + + if edits.is_empty() { + return Ok(ApplyEditsResult { + edits_applied: 0, + bytes_written: bytes.len() as u32, + }); + } + + let out = compute_new_bytes(&bytes, &edits, &file_path)?; + + atomic_write(&path, &out, do_fsync) + .map_err(|e| Error::new(Status::GenericFailure, format!("write {file_path}: {e}")))?; + + Ok(ApplyEditsResult { + edits_applied: edits.len() as u32, + bytes_written: out.len() as u32, + }) +} + +/// Apply LSP-style WorkspaceEdit (multiple files) atomically using two-phase commit. +/// +/// Phase 1 (validate + stage): for each file, read it, compute the new bytes, +/// write them to a sibling `.tmp` file with fsync. If ANY file fails (I/O, +/// UTF-8, overlap), all staged `.tmp` files are cleaned up and the originals +/// are left untouched. +/// +/// Phase 2 (commit): rename every staged `.tmp` over its original. If a rename +/// fails partway, remaining `.tmp` files are cleaned up and an error is returned +/// that includes how many files were successfully renamed. +/// +/// After all renames: fsync each unique parent directory once. +#[napi(js_name = "applyWorkspaceEdit")] +pub fn apply_workspace_edit( + document_edits: Vec, + options: Option, +) -> Result { + if document_edits.is_empty() { + return Ok(ApplyWorkspaceEditResult { + files_changed: 0, + total_edits_applied: 0, + files: vec![], + }); + } + + let opts = options.unwrap_or(ApplyEditsOptions { fsync: None }); + let do_fsync = opts.fsync.unwrap_or(true); + + // ── Phase 1: validate + stage ──────────────────────────────────────── + // staged[i] = (final_path, tmp_path, new_bytes, edits_applied) + let mut staged: Vec<(PathBuf, PathBuf, Vec, u32)> = + Vec::with_capacity(document_edits.len()); + + for doc_edit in &document_edits { + let path = PathBuf::from(&doc_edit.file_path); + + let bytes = match fs::read(&path) { + Ok(b) => b, + Err(e) => { + // Cleanup already-staged tmps before returning. + for (_, tmp, _, _) in &staged { + let _ = fs::remove_file(tmp); + } + return Err(Error::new( + Status::GenericFailure, + format!("read {}: {e}", doc_edit.file_path), + )); + } + }; + + let new_bytes = match compute_new_bytes(&bytes, &doc_edit.edits, &doc_edit.file_path) { + Ok(b) => b, + Err(e) => { + for (_, tmp, _, _) in &staged { + let _ = fs::remove_file(tmp); + } + return Err(e); + } + }; + + let tmp_path = match make_tmp_path(&path) { + Ok(p) => p, + Err(e) => { + for (_, tmp, _, _) in &staged { + let _ = fs::remove_file(tmp); + } + return Err(Error::new( + Status::GenericFailure, + format!("make_tmp_path {}: {e}", doc_edit.file_path), + )); + } + }; + + if let Err(e) = write_tmp(&tmp_path, &new_bytes, do_fsync) { + let _ = fs::remove_file(&tmp_path); + for (_, tmp, _, _) in &staged { + let _ = fs::remove_file(tmp); + } + return Err(Error::new( + Status::GenericFailure, + format!("write_tmp {}: {e}", doc_edit.file_path), + )); + } + + let edits_applied = if doc_edit.edits.is_empty() { + 0 + } else { + doc_edit.edits.len() as u32 + }; + staged.push((path, tmp_path, new_bytes, edits_applied)); + } + + // ── Phase 2: commit ────────────────────────────────────────────────── + let mut file_results: Vec = Vec::with_capacity(staged.len()); + let mut succeeded = 0usize; + + for (final_path, tmp_path, new_bytes, edits_applied) in &staged { + if let Err(e) = fs::rename(tmp_path, final_path) { + // Cleanup remaining staged tmps (including this one if rename failed + // before touching the original). + let _ = fs::remove_file(tmp_path); + for (_, remaining_tmp, _, _) in staged.iter().skip(succeeded + 1) { + let _ = fs::remove_file(remaining_tmp); + } + return Err(Error::new( + Status::GenericFailure, + format!( + "rename failed for {} after {succeeded} successful renames: {e}", + final_path.display() + ), + )); + } + file_results.push(WorkspaceEditFileResult { + file_path: final_path.to_string_lossy().into_owned(), + edits_applied: *edits_applied, + bytes_written: new_bytes.len() as u32, + }); + succeeded += 1; + } + + // ── fsync parent directories (deduplicated) ────────────────────────── + if do_fsync { + let mut parents: HashSet = HashSet::new(); + for (final_path, _, _, _) in &staged { + if let Some(parent) = final_path.parent() { + parents.insert(parent.to_path_buf()); + } + } + for parent in &parents { + if let Ok(dir_fd) = File::open(parent) { + let _ = dir_fd.sync_all(); + } + } + } + + let total_edits_applied: u32 = file_results.iter().map(|r| r.edits_applied).sum(); + + Ok(ApplyWorkspaceEditResult { + files_changed: file_results.len() as u32, + total_edits_applied, + files: file_results, + }) +} + +// ─── Private helpers ────────────────────────────────────────────────────── + +/// Pre-compute byte offsets where each line begins (line 0 = byte 0; line N +/// begins at the byte after the (N-1)th '\n'). +fn compute_line_starts(content: &str) -> Vec { + let mut starts = Vec::with_capacity(content.len() / 40 + 1); + starts.push(0); + for (i, c) in content.char_indices() { + if c == '\n' { + starts.push(i + 1); + } + } + starts +} + +/// Convert an LSP `Position { line, character (UTF-16) }` to a byte offset +/// into `content`. Returns `None` if the line index is past EOF or the UTF-16 +/// character offset is past the line's text. +fn position_to_byte(content: &str, line_starts: &[usize], pos: &Position) -> Option { + let line_idx = pos.line as usize; + if line_idx >= line_starts.len() { + return None; + } + let line_start = line_starts[line_idx]; + let line_end = line_starts + .get(line_idx + 1) + .copied() + .unwrap_or(content.len()); + let line_text = &content[line_start..line_end]; + + let mut utf16_units: u32 = 0; + for (offset_in_line, c) in line_text.char_indices() { + if c == '\n' || c == '\r' { + return if utf16_units == pos.character { + Some(line_start + offset_in_line) + } else { + None + }; + } + if utf16_units >= pos.character { + return Some(line_start + offset_in_line); + } + utf16_units += c.len_utf16() as u32; + } + if utf16_units == pos.character { + Some(line_end) + } else { + None + } +} + +fn thread_id() -> u64 { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut h = DefaultHasher::new(); + std::thread::current().id().hash(&mut h); + h.finish() +} + +// ─── Tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + use std::path::PathBuf; + + fn tmp_file(content: &str) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "forge-edit-test-{}-{}-{}", + std::process::id(), + thread_id(), + COUNTER.fetch_add(1, Ordering::Relaxed), + )); + fs::write(&path, content).unwrap(); + path + } + + fn read(path: &PathBuf) -> String { + let mut s = String::new(); + File::open(path).unwrap().read_to_string(&mut s).unwrap(); + s + } + + fn pos(line: u32, character: u32) -> Position { + Position { line, character } + } + + fn range(s_line: u32, s_char: u32, e_line: u32, e_char: u32) -> Range { + Range { + start: pos(s_line, s_char), + end: pos(e_line, e_char), + } + } + + #[test] + fn replaces_single_word() { + let path = tmp_file("hello world\n"); + let edits = vec![TextEdit { + range: range(0, 6, 0, 11), + new_text: "Earth".into(), + }]; + let r = apply_edits(path.to_string_lossy().into(), edits, None).unwrap(); + assert_eq!(r.edits_applied, 1); + assert_eq!(read(&path), "hello Earth\n"); + fs::remove_file(&path).ok(); + } + + #[test] + fn applies_multiple_non_overlapping_edits() { + let path = tmp_file("aaa bbb ccc\n"); + let edits = vec![ + TextEdit { + range: range(0, 0, 0, 3), + new_text: "XXX".into(), + }, + TextEdit { + range: range(0, 8, 0, 11), + new_text: "YYY".into(), + }, + ]; + let r = apply_edits(path.to_string_lossy().into(), edits, None).unwrap(); + assert_eq!(r.edits_applied, 2); + assert_eq!(read(&path), "XXX bbb YYY\n"); + fs::remove_file(&path).ok(); + } + + #[test] + fn applies_multiline_edit() { + let path = tmp_file("line one\nline two\nline three\n"); + let edits = vec![TextEdit { + range: range(0, 5, 2, 4), + new_text: "ONE\n_REPLACED_\nfour".into(), + }]; + apply_edits(path.to_string_lossy().into(), edits, None).unwrap(); + assert_eq!(read(&path), "line ONE\n_REPLACED_\nfour three\n"); + fs::remove_file(&path).ok(); + } + + #[test] + fn rejects_overlapping_edits() { + let path = tmp_file("aaaaa\n"); + let edits = vec![ + TextEdit { + range: range(0, 0, 0, 3), + new_text: "X".into(), + }, + TextEdit { + range: range(0, 2, 0, 5), + new_text: "Y".into(), + }, + ]; + let err = apply_edits(path.to_string_lossy().into(), edits, None).unwrap_err(); + assert!(err.reason.contains("overlapping"), "got: {}", err.reason); + // Original file untouched + assert_eq!(read(&path), "aaaaa\n"); + fs::remove_file(&path).ok(); + } + + #[test] + fn rejects_out_of_range_line() { + let path = tmp_file("only one line\n"); + let edits = vec![TextEdit { + range: range(99, 0, 99, 1), + new_text: "x".into(), + }]; + let err = apply_edits(path.to_string_lossy().into(), edits, None).unwrap_err(); + assert!(err.reason.contains("out of range"), "got: {}", err.reason); + fs::remove_file(&path).ok(); + } + + #[test] + fn handles_utf16_code_units_for_emoji() { + // 😀 is one Unicode codepoint but two UTF-16 code units (surrogate pair). + // LSP measures `character` in UTF-16 units, so character=2 should land + // just after the emoji. + let path = tmp_file("a😀b\n"); + let edits = vec![TextEdit { + // Replace just the emoji: chars 1..3 (UTF-16 units, since emoji is 2) + range: range(0, 1, 0, 3), + new_text: "X".into(), + }]; + apply_edits(path.to_string_lossy().into(), edits, None).unwrap(); + assert_eq!(read(&path), "aXb\n"); + fs::remove_file(&path).ok(); + } + + #[test] + fn empty_edits_no_op() { + let path = tmp_file("unchanged\n"); + let r = apply_edits(path.to_string_lossy().into(), vec![], None).unwrap(); + assert_eq!(r.edits_applied, 0); + assert_eq!(read(&path), "unchanged\n"); + fs::remove_file(&path).ok(); + } + + #[test] + fn insertion_at_position() { + // Pure insertion: range start == range end + let path = tmp_file("ab\n"); + let edits = vec![TextEdit { + range: range(0, 1, 0, 1), + new_text: "X".into(), + }]; + apply_edits(path.to_string_lossy().into(), edits, None).unwrap(); + assert_eq!(read(&path), "aXb\n"); + fs::remove_file(&path).ok(); + } + + #[test] + fn append_after_eof() { + let path = tmp_file("first line\nsecond line"); + let edits = vec![TextEdit { + range: range(1, 11, 1, 11), + new_text: "\nthird line".into(), + }]; + apply_edits(path.to_string_lossy().into(), edits, None).unwrap(); + assert_eq!(read(&path), "first line\nsecond line\nthird line"); + fs::remove_file(&path).ok(); + } + + #[test] + fn allows_disabling_fsync_for_speed() { + let path = tmp_file("hi\n"); + let edits = vec![TextEdit { + range: range(0, 0, 0, 2), + new_text: "yo".into(), + }]; + apply_edits( + path.to_string_lossy().into(), + edits, + Some(ApplyEditsOptions { fsync: Some(false) }), + ) + .unwrap(); + assert_eq!(read(&path), "yo\n"); + fs::remove_file(&path).ok(); + } + + // ── workspace_edit tests ────────────────────────────────────────────── + + #[test] + fn workspace_edit_applies_to_two_files() { + let path_a = tmp_file("alpha beta\n"); + let path_b = tmp_file("foo bar\n"); + + let doc_edits = vec![ + TextDocumentEdit { + file_path: path_a.to_string_lossy().into_owned(), + edits: vec![TextEdit { + range: range(0, 6, 0, 10), + new_text: "gamma".into(), + }], + }, + TextDocumentEdit { + file_path: path_b.to_string_lossy().into_owned(), + edits: vec![TextEdit { + range: range(0, 4, 0, 7), + new_text: "baz".into(), + }], + }, + ]; + + let r = apply_workspace_edit(doc_edits, None).unwrap(); + + assert_eq!(r.files_changed, 2); + assert_eq!(r.total_edits_applied, 2); + assert_eq!(read(&path_a), "alpha gamma\n"); + assert_eq!(read(&path_b), "foo baz\n"); + + fs::remove_file(&path_a).ok(); + fs::remove_file(&path_b).ok(); + } + + #[test] + fn workspace_edit_rolls_back_on_phase1_error() { + let path_a = tmp_file("good file\n"); + let path_b = tmp_file("aaaaa\n"); + let path_c = tmp_file("another good file\n"); + + // path_b has overlapping edits — should cause Phase 1 failure. + let doc_edits = vec![ + TextDocumentEdit { + file_path: path_a.to_string_lossy().into_owned(), + edits: vec![TextEdit { + range: range(0, 0, 0, 4), + new_text: "nice".into(), + }], + }, + TextDocumentEdit { + file_path: path_b.to_string_lossy().into_owned(), + edits: vec![ + TextEdit { + range: range(0, 0, 0, 3), + new_text: "X".into(), + }, + TextEdit { + range: range(0, 2, 0, 5), + new_text: "Y".into(), + }, + ], + }, + TextDocumentEdit { + file_path: path_c.to_string_lossy().into_owned(), + edits: vec![TextEdit { + range: range(0, 8, 0, 12), + new_text: "great".into(), + }], + }, + ]; + + let err = apply_workspace_edit(doc_edits, None).unwrap_err(); + assert!(err.reason.contains("overlapping"), "got: {}", err.reason); + + // All originals must be untouched. + assert_eq!(read(&path_a), "good file\n"); + assert_eq!(read(&path_b), "aaaaa\n"); + assert_eq!(read(&path_c), "another good file\n"); + + // No leftover .tmp files. + let tmp_a = make_tmp_path(&path_a).unwrap(); + let tmp_b = make_tmp_path(&path_b).unwrap(); + let tmp_c = make_tmp_path(&path_c).unwrap(); + assert!(!tmp_a.exists(), "stale tmp left behind for path_a"); + assert!(!tmp_b.exists(), "stale tmp left behind for path_b"); + assert!(!tmp_c.exists(), "stale tmp left behind for path_c"); + + fs::remove_file(&path_a).ok(); + fs::remove_file(&path_b).ok(); + fs::remove_file(&path_c).ok(); + } + + #[test] + fn workspace_edit_empty_input() { + let r = apply_workspace_edit(vec![], None).unwrap(); + assert_eq!(r.files_changed, 0); + assert_eq!(r.total_edits_applied, 0); + assert!(r.files.is_empty()); + } + + #[test] + fn workspace_edit_handles_missing_file() { + let missing = std::env::temp_dir().join("forge-edit-test-nonexistent-99999999.txt"); + // Ensure it really doesn't exist. + let _ = fs::remove_file(&missing); + + let doc_edits = vec![TextDocumentEdit { + file_path: missing.to_string_lossy().into_owned(), + edits: vec![TextEdit { + range: range(0, 0, 0, 1), + new_text: "x".into(), + }], + }]; + + let err = apply_workspace_edit(doc_edits, None).unwrap_err(); + // Error must mention the path. + assert!( + err.reason.contains("forge-edit-test-nonexistent-99999999"), + "error does not mention path: {}", + err.reason + ); + } +} diff --git a/rust-engine/crates/engine/src/lib.rs b/rust-engine/crates/engine/src/lib.rs index 8f5e88055..c57010cc7 100644 --- a/rust-engine/crates/engine/src/lib.rs +++ b/rust-engine/crates/engine/src/lib.rs @@ -12,20 +12,22 @@ mod ast; mod clipboard; mod diff; mod fd; +mod forge_parser; mod fs_cache; +mod git; mod glob; mod glob_util; mod grep; mod highlight; mod html; +mod image; +mod json_parse; mod ps; +mod stream_process; +mod symbol; mod task; mod text; -mod ttsr; -mod forge_parser; -mod image; mod truncate; -mod json_parse; -mod stream_process; +mod ttsr; +mod watch; mod xxhash; -mod git; diff --git a/rust-engine/crates/engine/src/symbol.rs b/rust-engine/crates/engine/src/symbol.rs new file mode 100644 index 000000000..99cfb8123 --- /dev/null +++ b/rust-engine/crates/engine/src/symbol.rs @@ -0,0 +1,451 @@ +//! Symbol-level structural replace and insert. +//! +//! Exposes [`replace_symbol`] and [`insert_around_symbol`] to JavaScript via +//! napi-rs. Both functions use the same ast-grep infrastructure as +//! `forge_ast::ast` (tree-sitter pattern matching) but add a higher-level +//! "find me the declaration named X" abstraction on top. +//! +//! ## Language support (v1) +//! +//! Only **TypeScript / JavaScript / TSX** are fully supported. For all other +//! languages the functions return an `Err` asking the caller to fall back to +//! `astEdit` with a custom pattern. +//! +//! ## Replacement scope (v1) +//! +//! For simplicity `replaceSymbol` replaces the **entire matched declaration** +//! (function / arrow / method node), not just the body. `new_body` is +//! therefore expected to be the full declaration text, e.g.: +//! +//! ```text +//! function foo(x: number): number { return x + 1; } +//! ``` +//! +//! `insertAroundSymbol` supports only `BeforeDecl` and `AfterDecl` in v1. +//! `AtBodyStart` / `AtBodyEnd` return `Err("not yet implemented")`. + +use ast_grep_core::{matcher::Pattern, tree_sitter::LanguageExt, Language}; +use forge_ast::language::SupportLang; +use napi::{Error, Result}; +use napi_derive::napi; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +// ─── napi types ────────────────────────────────────────────────────────────── + +#[napi(object)] +pub struct ReplaceSymbolOptions { + /// Force a specific language ("typescript", "rust", "python", …). + /// If absent, inferred from the file extension. + pub lang: Option, + /// fsync the write. Defaults to true. + pub fsync: Option, +} + +#[napi(object)] +pub struct ReplaceSymbolResult { + pub matched: bool, + /// Byte offset of the start of the replaced range (set only when matched). + #[napi(js_name = "byteStart")] + pub byte_start: Option, + /// Byte offset of the end of the replaced range (set only when matched). + #[napi(js_name = "byteEnd")] + pub byte_end: Option, + /// 1-based line number of the replacement start (set only when matched). + #[napi(js_name = "startLine")] + pub start_line: Option, +} + +#[napi(string_enum)] +pub enum InsertPosition { + BeforeDecl, + AfterDecl, + AtBodyStart, + AtBodyEnd, +} + +#[napi(object)] +pub struct InsertAroundSymbolOptions { + pub lang: Option, + pub fsync: Option, +} + +#[napi(object)] +pub struct InsertAroundSymbolResult { + pub inserted: bool, + /// Byte offset at which the code was inserted (set only when inserted). + #[napi(js_name = "byteOffset")] + pub byte_offset: Option, +} + +// ─── language detection (self-contained, no phf dependency) ────────────────── + +/// Resolve a user-supplied language name string to a `SupportLang`. +/// Covers the same aliases as `forge_ast::ast::LANG_ALIASES` but implemented +/// as a simple match to avoid a direct `phf` dependency in this crate. +fn resolve_lang_from_str(value: &str) -> Result { + let l = value.to_ascii_lowercase(); + let lang = match l.as_str() { + "bash" | "sh" => SupportLang::Bash, + "c" => SupportLang::C, + "cpp" | "c++" | "cc" | "cxx" => SupportLang::Cpp, + "csharp" | "c#" | "cs" => SupportLang::CSharp, + "css" => SupportLang::Css, + "diff" | "patch" => SupportLang::Diff, + "elixir" | "ex" => SupportLang::Elixir, + "go" | "golang" => SupportLang::Go, + "haskell" | "hs" => SupportLang::Haskell, + "hcl" | "tf" | "tfvars" | "terraform" => SupportLang::Hcl, + "html" | "htm" => SupportLang::Html, + "java" => SupportLang::Java, + "javascript" | "js" | "jsx" | "mjs" | "cjs" => SupportLang::JavaScript, + "json" => SupportLang::Json, + "julia" | "jl" => SupportLang::Julia, + "kotlin" | "kt" => SupportLang::Kotlin, + "lua" => SupportLang::Lua, + "make" | "makefile" => SupportLang::Make, + "markdown" | "md" | "mdx" => SupportLang::Markdown, + "nix" => SupportLang::Nix, + "objc" | "objective-c" => SupportLang::ObjC, + "odin" => SupportLang::Odin, + "php" => SupportLang::Php, + "python" | "py" => SupportLang::Python, + "regex" => SupportLang::Regex, + "ruby" | "rb" => SupportLang::Ruby, + "rust" | "rs" => SupportLang::Rust, + "scala" => SupportLang::Scala, + "solidity" | "sol" => SupportLang::Solidity, + "starlark" | "star" => SupportLang::Starlark, + "swift" => SupportLang::Swift, + "toml" => SupportLang::Toml, + "tsx" => SupportLang::Tsx, + "typescript" | "ts" | "mts" | "cts" => SupportLang::TypeScript, + "verilog" | "systemverilog" | "sv" => SupportLang::Verilog, + "xml" | "xsl" | "svg" => SupportLang::Xml, + "yaml" | "yml" => SupportLang::Yaml, + "zig" => SupportLang::Zig, + _ => { + return Err(Error::from_reason(format!( + "Unsupported language '{value}'" + ))) + } + }; + Ok(lang) +} + +fn resolve_lang(lang_opt: Option<&str>, file_path: &Path) -> Result { + if let Some(lang) = lang_opt.map(str::trim).filter(|l| !l.is_empty()) { + return resolve_lang_from_str(lang); + } + // Use the SupportLang trait impl which calls from_extension internally. + ::from_path(file_path).ok_or_else(|| { + Error::from_reason(format!( + "Cannot infer language from '{}'. Specify `lang` explicitly.", + file_path.display() + )) + }) +} + +// ─── language family check ──────────────────────────────────────────────────── + +/// Returns `true` for the TypeScript/JavaScript/TSX family. +fn is_ts_js(lang: SupportLang) -> bool { + matches!( + lang, + SupportLang::TypeScript | SupportLang::JavaScript | SupportLang::Tsx + ) +} + +// ─── pattern building ───────────────────────────────────────────────────────── + +/// Build ast-grep patterns to try for a given symbol name in a TS/JS/TSX file. +/// +/// Plain name → function declaration + arrow patterns. +/// Dotted name like `"Class.method"` → class method pattern. +fn ts_patterns_for_symbol(symbol: &str) -> Result> { + if symbol.contains('.') { + let parts: Vec<&str> = symbol.splitn(2, '.').collect(); + let class_name = parts[0].trim(); + let method_name = parts[1].trim(); + if class_name.is_empty() || method_name.is_empty() { + return Err(Error::from_reason(format!( + "Invalid symbol name '{symbol}': expected 'ClassName.methodName'" + ))); + } + Ok(vec![ + // Method inside a named class + format!("class {class_name} {{ $$$ {method_name}($$$ARGS) {{ $$$BODY }} $$$ }}"), + ]) + } else { + Ok(vec![ + // function declaration + format!("function {symbol}($$$ARGS) {{ $$$BODY }}"), + // arrow with parens + format!("const {symbol} = ($$$ARGS) => {{ $$$BODY }}"), + // arrow without parens (single param) + format!("const {symbol} = $ARG => {{ $$$BODY }}"), + ]) + } +} + +// ─── matching helpers ───────────────────────────────────────────────────────── + +struct SymbolMatch { + byte_start: usize, + byte_end: usize, + start_line: usize, // 0-based (ast-grep convention) +} + +/// Run all patterns against `source` and collect distinct top-level matches, +/// deduped by start byte. Returns an error if more than one distinct +/// declaration was found (ambiguity). +fn find_symbol_matches( + source: &str, + patterns: &[String], + lang: SupportLang, +) -> Result> { + let mut compiled: Vec = Vec::new(); + for pat_str in patterns { + match Pattern::try_new(pat_str, lang) { + Ok(p) => compiled.push(p), + Err(_) => {} // skip patterns that don't compile for this lang variant + } + } + if compiled.is_empty() { + return Err(Error::from_reason( + "No patterns compiled successfully for this symbol/language combination".to_string(), + )); + } + + let ast = lang.ast_grep(source); + // BTreeMap keyed on start byte → deduplicates when multiple patterns hit + // the same node. + let mut by_start: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + + for pattern in compiled { + for m in ast.root().find_all(pattern) { + let range = m.range(); + by_start.entry(range.start).or_insert(SymbolMatch { + byte_start: range.start, + byte_end: range.end, + start_line: m.start_pos().line(), + }); + } + } + + Ok(by_start.into_values().collect()) +} + +// ─── atomic write ──────────────────────────────────────────────────────────── + +fn atomic_write_bytes(path: &Path, content: &[u8], do_fsync: bool) -> std::io::Result<()> { + use std::io::Write; + + let parent = path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("path has no parent: {}", path.display()), + ) + })?; + let file_name = path.file_name().and_then(|s| s.to_str()).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("path has no filename: {}", path.display()), + ) + })?; + + let tmp_name = format!(".{file_name}.symbol.{}", std::process::id()); + let tmp_path = parent.join(tmp_name); + + { + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&tmp_path)?; + f.write_all(content)?; + if do_fsync { + f.sync_all()?; + } + } + + let rename_result = fs::rename(&tmp_path, path); + if rename_result.is_err() { + let _ = fs::remove_file(&tmp_path); + return rename_result; + } + + if do_fsync { + if let Ok(dir_fd) = std::fs::File::open(parent) { + let _ = dir_fd.sync_all(); + } + } + + Ok(()) +} + +// ─── public napi functions ─────────────────────────────────────────────────── + +/// Replace the entire declaration of the symbol identified by `symbol_name` +/// with `new_body`. +/// +/// `symbol_name` is either a plain identifier (e.g. `"executeCommand"`) or a +/// dotted path (e.g. `"MyClass.myMethod"`). +/// +/// **v1 scope**: only TypeScript / JavaScript / TSX are supported. For other +/// languages use `astEdit` with a custom pattern. +/// +/// **v1 replacement**: the *entire* matched declaration node is replaced, not +/// just its body. `new_body` should be the complete declaration text. +/// +/// Returns `matched: false` when no declaration matches. Returns an error +/// when multiple distinct declarations match (ambiguity). +#[napi(js_name = "replaceSymbol")] +pub fn replace_symbol( + file_path: String, + symbol_name: String, + new_body: String, + options: Option, +) -> Result { + let opts = options.unwrap_or(ReplaceSymbolOptions { + lang: None, + fsync: None, + }); + let do_fsync = opts.fsync.unwrap_or(true); + + let path = PathBuf::from(&file_path); + let lang = resolve_lang(opts.lang.as_deref(), &path)?; + + if !is_ts_js(lang) { + return Err(Error::from_reason(format!( + "Language '{}' is not yet supported for symbol resolution. \ + Use astEdit with a custom pattern instead.", + lang.canonical_name() + ))); + } + + let source = fs::read_to_string(&path) + .map_err(|e| Error::from_reason(format!("read {file_path}: {e}")))?; + + let patterns = ts_patterns_for_symbol(&symbol_name)?; + let matches = find_symbol_matches(&source, &patterns, lang)?; + + match matches.len() { + 0 => Ok(ReplaceSymbolResult { + matched: false, + byte_start: None, + byte_end: None, + start_line: None, + }), + 1 => { + let m = &matches[0]; + let before = &source.as_bytes()[..m.byte_start]; + let after = &source.as_bytes()[m.byte_end..]; + let mut out = Vec::with_capacity(before.len() + new_body.len() + after.len()); + out.extend_from_slice(before); + out.extend_from_slice(new_body.as_bytes()); + out.extend_from_slice(after); + + atomic_write_bytes(&path, &out, do_fsync) + .map_err(|e| Error::from_reason(format!("write {file_path}: {e}")))?; + + Ok(ReplaceSymbolResult { + matched: true, + byte_start: Some(m.byte_start as u32), + byte_end: Some(m.byte_end as u32), + start_line: Some((m.start_line + 1) as u32), + }) + } + n => Err(Error::from_reason(format!( + "Ambiguous symbol '{symbol_name}': found {n} matching declarations in '{file_path}'. \ + Qualify the name (e.g. 'ClassName.methodName') or use astEdit with a narrower pattern." + ))), + } +} + +/// Insert `code` before or after the declaration of the symbol identified by +/// `symbol_name`. +/// +/// **v1 scope**: only TypeScript / JavaScript / TSX are supported. +/// +/// **v1 positions**: only `BeforeDecl` and `AfterDecl` are implemented. +/// `AtBodyStart` / `AtBodyEnd` return `Err("not yet implemented")`. +#[napi(js_name = "insertAroundSymbol")] +pub fn insert_around_symbol( + file_path: String, + symbol_name: String, + position: InsertPosition, + code: String, + options: Option, +) -> Result { + match position { + InsertPosition::AtBodyStart | InsertPosition::AtBodyEnd => { + return Err(Error::from_reason( + "AtBodyStart / AtBodyEnd are not yet implemented in v1. \ + Use BeforeDecl or AfterDecl, or use astEdit with a custom pattern." + .to_string(), + )); + } + _ => {} + } + + let opts = options.unwrap_or(InsertAroundSymbolOptions { + lang: None, + fsync: None, + }); + let do_fsync = opts.fsync.unwrap_or(true); + + let path = PathBuf::from(&file_path); + let lang = resolve_lang(opts.lang.as_deref(), &path)?; + + if !is_ts_js(lang) { + return Err(Error::from_reason(format!( + "Language '{}' is not yet supported for symbol resolution. \ + Use astEdit with a custom pattern instead.", + lang.canonical_name() + ))); + } + + let source = fs::read_to_string(&path) + .map_err(|e| Error::from_reason(format!("read {file_path}: {e}")))?; + + let patterns = ts_patterns_for_symbol(&symbol_name)?; + let matches = find_symbol_matches(&source, &patterns, lang)?; + + match matches.len() { + 0 => Ok(InsertAroundSymbolResult { + inserted: false, + byte_offset: None, + }), + 1 => { + let m = &matches[0]; + let insert_at = match position { + InsertPosition::BeforeDecl => m.byte_start, + InsertPosition::AfterDecl => m.byte_end, + _ => unreachable!(), + }; + + let before = &source.as_bytes()[..insert_at]; + let after = &source.as_bytes()[insert_at..]; + let mut out = Vec::with_capacity(before.len() + code.len() + after.len()); + out.extend_from_slice(before); + out.extend_from_slice(code.as_bytes()); + out.extend_from_slice(after); + + atomic_write_bytes(&path, &out, do_fsync) + .map_err(|e| Error::from_reason(format!("write {file_path}: {e}")))?; + + Ok(InsertAroundSymbolResult { + inserted: true, + byte_offset: Some(insert_at as u32), + }) + } + n => Err(Error::from_reason(format!( + "Ambiguous symbol '{symbol_name}': found {n} matching declarations in '{file_path}'. \ + Qualify the name (e.g. 'ClassName.methodName') or use astEdit with a narrower pattern." + ))), + } +} diff --git a/rust-engine/crates/engine/src/watch.rs b/rust-engine/crates/engine/src/watch.rs new file mode 100644 index 000000000..db827e85a --- /dev/null +++ b/rust-engine/crates/engine/src/watch.rs @@ -0,0 +1,255 @@ +//! Recursive filesystem watcher exposed to JavaScript via napi-rs. +//! +//! Purpose: keep high-volume filesystem notifications out of the JavaScript +//! event loop while preserving a small, debounced batch API for agent sessions. + +use dashmap::DashMap; +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use napi::bindgen_prelude::*; +use napi::threadsafe_function::{ + ErrorStrategy, ThreadSafeCallContext, ThreadsafeFunction, ThreadsafeFunctionCallMode, +}; +use napi_derive::napi; +use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, mpsc}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +static NEXT_HANDLE: AtomicU32 = AtomicU32::new(1); +static WATCHERS: std::sync::OnceLock> = std::sync::OnceLock::new(); + +fn watchers() -> &'static DashMap { + WATCHERS.get_or_init(DashMap::new) +} + +struct WatcherHandle { + _watcher: RecommendedWatcher, + stop: Arc, + thread: Option>, +} + +impl Drop for WatcherHandle { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +#[napi(object)] +pub struct WatchOptions { + /// Glob patterns to ignore. Bare patterns match anywhere below the root. + pub ignore: Option>, + /// Coalesce events that fire within this window in milliseconds. Default 50. + #[napi(js_name = "debounceMs")] + pub debounce_ms: Option, + /// Watch recursively. Default true. + pub recursive: Option, +} + +#[napi(object)] +#[derive(Clone)] +pub struct WatchEvent { + /// "create" | "modify" | "remove" | "rename" + pub kind: String, + /// Absolute path of the affected entry. + pub path: String, +} + +fn build_ignore_set(patterns: &[String]) -> std::result::Result { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + let normalized = pattern.replace('\\', "/"); + let full = if !normalized.contains('/') && !normalized.starts_with("**") { + format!("**/{normalized}") + } else { + normalized + }; + builder.add( + GlobBuilder::new(&full) + .literal_separator(true) + .build() + .map_err(|e| format!("invalid ignore pattern '{pattern}': {e}"))?, + ); + } + builder + .build() + .map_err(|e| format!("failed to build ignore set: {e}")) +} + +fn event_kind(kind: &EventKind) -> Option<&'static str> { + use notify::EventKind::*; + use notify::event::ModifyKind; + + match kind { + Create(_) => Some("create"), + Remove(_) => Some("remove"), + Modify(ModifyKind::Name(_)) => Some("rename"), + Modify(_) => Some("modify"), + _ => None, + } +} + +fn path_is_ignored(path: &Path, root: &Path, ignore_set: &GlobSet, has_ignores: bool) -> bool { + if !has_ignores { + return false; + } + path.strip_prefix(root) + .ok() + .is_some_and(|relative| ignore_set.is_match(relative)) +} + +fn drain_batch( + receiver: &mpsc::Receiver>, + first: notify::Result, + debounce: Duration, +) -> Vec> { + let deadline = Instant::now() + debounce; + let mut events = vec![first]; + loop { + let now = Instant::now(); + if now >= deadline { + break; + } + match receiver.recv_timeout(deadline - now) { + Ok(event) => events.push(event), + Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + events +} + +fn convert_batch( + root: &Path, + ignore_set: &GlobSet, + has_ignores: bool, + raw_events: Vec>, +) -> Vec { + let mut batch = Vec::new(); + for raw in raw_events { + let event = match raw { + Ok(event) => event, + Err(_) => continue, + }; + let Some(kind) = event_kind(&event.kind) else { + continue; + }; + for path in event.paths { + if path_is_ignored(&path, root, ignore_set, has_ignores) { + continue; + } + batch.push(WatchEvent { + kind: kind.to_string(), + path: path.to_string_lossy().into_owned(), + }); + } + } + batch +} + +/// Start watching a directory tree and return a numeric handle for `stopWatch`. +#[napi( + js_name = "watchTree", + ts_args_type = "root: string, options: WatchOptions | undefined | null, onEvents: (events: WatchEvent[]) => void" +)] +pub fn watch_tree( + _env: Env, + root: String, + options: Option, + on_events: JsFunction, +) -> Result { + let opts = options.unwrap_or(WatchOptions { + ignore: None, + debounce_ms: None, + recursive: None, + }); + let debounce = Duration::from_millis(u64::from(opts.debounce_ms.unwrap_or(50))); + let recursive = opts.recursive.unwrap_or(true); + let root_path = PathBuf::from(&root); + if !root_path.exists() { + return Err(Error::new( + Status::InvalidArg, + format!("watch root does not exist: {root}"), + )); + } + + let ignore_patterns = opts.ignore.unwrap_or_default(); + let ignore_set = + build_ignore_set(&ignore_patterns).map_err(|e| Error::new(Status::InvalidArg, e))?; + let has_ignores = !ignore_patterns.is_empty(); + + let tsfn: ThreadsafeFunction, ErrorStrategy::CalleeHandled> = on_events + .create_threadsafe_function(0, |ctx: ThreadSafeCallContext>| { + let events: Vec = ctx.value; + let env = ctx.env; + let mut arr = env.create_array_with_length(events.len())?; + for (i, event) in events.into_iter().enumerate() { + let mut obj = env.create_object()?; + obj.set_named_property("kind", env.create_string(&event.kind)?)?; + obj.set_named_property("path", env.create_string(&event.path)?)?; + arr.set_element(i as u32, obj)?; + } + Ok(vec![arr]) + })?; + + let (sender, receiver) = mpsc::channel(); + let mut watcher = RecommendedWatcher::new( + move |result| { + let _ = sender.send(result); + }, + Config::default(), + ) + .map_err(|e| Error::new(Status::GenericFailure, format!("failed to create watcher: {e}")))?; + + let mode = if recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }; + watcher.watch(&root_path, mode).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("failed to watch '{root}': {e}"), + ) + })?; + + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = Arc::clone(&stop); + let root_thread = root_path.clone(); + let tsfn_thread = tsfn.clone(); + let thread = thread::spawn(move || { + while !stop_thread.load(Ordering::Relaxed) { + let first = match receiver.recv_timeout(Duration::from_millis(100)) { + Ok(event) => event, + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + }; + let raw = drain_batch(&receiver, first, debounce); + let batch = convert_batch(&root_thread, &ignore_set, has_ignores, raw); + if !batch.is_empty() { + tsfn_thread.call(Ok(batch), ThreadsafeFunctionCallMode::NonBlocking); + } + } + }); + + let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed); + watchers().insert( + handle, + WatcherHandle { + _watcher: watcher, + stop, + thread: Some(thread), + }, + ); + Ok(handle) +} + +/// Stop a watcher returned by `watchTree`. +#[napi(js_name = "stopWatch")] +pub fn stop_watch(handle: u32) -> Result { + Ok(watchers().remove(&handle).is_some()) +} diff --git a/src/cli.ts b/src/cli.ts index c923ebd89..0e6857362 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; +import type { Api, Model } from "@singularity-forge/pi-ai"; import { AuthStorage, createAgentSession, @@ -13,7 +14,6 @@ import { SessionManager, SettingsManager, } from "@singularity-forge/pi-coding-agent"; -import type { Api, Model } from "@singularity-forge/pi-ai"; import chalk from "chalk"; import { agentDir, authFilePath, sessionsDir } from "./app-paths.js"; import { @@ -222,7 +222,7 @@ function ensureRtkBootstrap(): Promise { } // `sf update` — update to the latest version via npm -if (cliFlags.messages[0] === 'update') { +if (cliFlags.messages[0] === "update") { const { runUpdate } = await import("./update-cmd.js"); await runUpdate(); process.exit(0); @@ -235,12 +235,12 @@ if (cliFlags.messages[0] === "graph") { const sub = cliFlags.messages[1]; const { buildGraph, - writeGraph, graphStatus, graphQuery, graphDiff, resolveSFRoot, - } = await import("@singularity-forge/mcp-server"); + writeGraph, + } = await import("@singularity-forge/pi-agent-core"); const projectDir = process.cwd(); const sfRoot = resolveSFRoot(projectDir); @@ -467,7 +467,9 @@ if (cliFlags.messages[0] === "sessions") { let sessions; if (cliFlags.allSessions) { - process.stderr.write(chalk.dim("Loading all sessions across all projects...\n")); + process.stderr.write( + chalk.dim("Loading all sessions across all projects...\n"), + ); sessions = await SessionManager.listAll(); } else { const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; @@ -477,14 +479,14 @@ if (cliFlags.messages[0] === "sessions") { } if (sessions.length === 0) { - process.stderr.write( - chalk.yellow("No sessions found.\n"), - ); + process.stderr.write(chalk.yellow("No sessions found.\n")); process.exit(0); } const label = cliFlags.allSessions ? "all projects" : cwd; - process.stderr.write(chalk.bold(`\n Sessions (${sessions.length}) for ${label}:\n\n`)); + process.stderr.write( + chalk.bold(`\n Sessions (${sessions.length}) for ${label}:\n\n`), + ); const maxShow = 20; const toShow = sessions.slice(0, maxShow); @@ -497,9 +499,8 @@ if (cliFlags.messages[0] === "sessions") { ? s.firstMessage.replace(/\n/g, " ").substring(0, 80) : chalk.dim("(empty)"); const num = String(i + 1).padStart(3); - const projectLabel = cliFlags.allSessions && s.cwd - ? ` ${chalk.yellow(`[${s.cwd}]`)}` - : ""; + const projectLabel = + cliFlags.allSessions && s.cwd ? ` ${chalk.yellow(`[${s.cwd}]`)}` : ""; process.stderr.write( ` ${chalk.bold(num)}. ${chalk.green(date)} ${chalk.dim(`(${msgs} msgs)`)}${name}${projectLabel}\n`, ); @@ -873,8 +874,7 @@ if (!cliFlags.worktree && !isPrintMode) { // which handles non-interactive output gracefully. // --------------------------------------------------------------------------- if ( - (cliFlags.messages[0] === "auto" || - cliFlags.messages[0] === "autonomous") && + (cliFlags.messages[0] === "auto" || cliFlags.messages[0] === "autonomous") && !process.stdout.isTTY ) { process.stderr.write( diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts index 653ea5577..74e4d2e7c 100644 --- a/src/resources/extensions/ask-user-questions.ts +++ b/src/resources/extensions/ask-user-questions.ts @@ -10,6 +10,10 @@ */ import { Type } from "@sinclair/typebox"; +import { + formatRoundResultForTool, + type RoundResult, +} from "@singularity-forge/pi-agent-core"; import type { ExtensionAPI, ExtensionCommandContext, @@ -19,7 +23,6 @@ import { sanitizeError } from "./shared/sanitize.js"; import { type Question, type QuestionOption, - type RoundResult, showInterviewRound, } from "./shared/tui.js"; @@ -42,7 +45,7 @@ interface RemoteResultDetails { autoResolved?: boolean; autoResolveStrategy?: string; questions?: Question[]; - response?: import("./remote-questions/types.js").RemoteAnswer; + response?: RoundResult; error?: boolean; } @@ -227,8 +230,10 @@ async function askLocalQuestionRound( if (result !== undefined) return result; if (signal?.aborted) return null; - const answers: Record = - {}; + const answers: Record< + string, + { selected: string | string[]; notes: string } + > = {}; for (const q of questions) { const options = q.options.map((o) => o.label); if (!q.allowMultiple) { @@ -294,11 +299,11 @@ function logHeadlessLocalAutoResolve(result: RaceableResult): void { ) return; const questions = details.questions as Question[]; - const response = - details.response as import("./remote-questions/types.js").RemoteAnswer; + const response = details.response as RoundResult; const firstQuestion = questions[0]; if (!firstQuestion) return; - const firstAnswer = response.answers[firstQuestion.id]?.answers?.[0]; + const selected = response.answers[firstQuestion.id]?.selected; + const firstAnswer = Array.isArray(selected) ? selected[0] : selected; if (!firstAnswer) return; process.stderr.write( `[gate] auto-resolved ${gateLogId(firstQuestion.id)} → "${cleanRecommendedLabel(firstAnswer)}" (timeout, headless, no telegram)\n`, @@ -306,22 +311,7 @@ function logHeadlessLocalAutoResolve(result: RaceableResult): void { } /** Convert the shared RoundResult into the JSON the LLM expects. */ -function formatForLLM(result: RoundResult): string { - const answers: Record = {}; - for (const [id, answer] of Object.entries(result.answers)) { - const list: string[] = []; - if (Array.isArray(answer.selected)) { - list.push(...answer.selected); - } else { - list.push(answer.selected); - } - if (answer.notes) { - list.push(`user_note: ${answer.notes}`); - } - answers[id] = { answers: list }; - } - return JSON.stringify({ answers }); -} +const formatForLLM = formatRoundResultForTool; // ─── Extension ──────────────────────────────────────────────────────────────── @@ -393,12 +383,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) { const raceResult = await raceRemoteAndLocal( () => tryRemoteQuestions(params.questions, raceSignal), - () => - askLocalQuestionRound( - params.questions, - raceSignal, - ctx as any, - ), + () => askLocalQuestionRound(params.questions, raceSignal, ctx as any), raceController, params.questions, ); @@ -500,7 +485,10 @@ export default function AskUserQuestions(pi: ExtensionAPI) { ctx as any, ); if (!result) { - return errorResult("ask_user_questions was cancelled", params.questions); + return errorResult( + "ask_user_questions was cancelled", + params.questions, + ); } // Check if cancelled (empty answers = user exited) @@ -600,13 +588,13 @@ export default function AskUserQuestions(pi: ExtensionAPI) { ); continue; } - const answerText = - answer.answers.length > 0 - ? answer.answers.join(", ") - : "(custom)"; + const selected = answer.selected; + const answerText = Array.isArray(selected) + ? selected.join(", ") + : selected || "(custom)"; let line = `${theme.fg("success", "✓ ")}${theme.fg("accent", q.header)}: ${answerText}`; - if (answer.user_note) { - line += ` ${theme.fg("muted", `[note: ${answer.user_note}]`)}`; + if (answer.notes) { + line += ` ${theme.fg("muted", `[note: ${answer.notes}]`)}`; } lines.push(line); } diff --git a/src/resources/extensions/remote-questions/manager.ts b/src/resources/extensions/remote-questions/manager.ts index 8d775386d..3a80e2415 100644 --- a/src/resources/extensions/remote-questions/manager.ts +++ b/src/resources/extensions/remote-questions/manager.ts @@ -3,6 +3,11 @@ */ import { randomUUID } from "node:crypto"; +import { + formatRoundResultForTool, + type RoundResult, + roundResultFromRemoteAnswer, +} from "@singularity-forge/pi-agent-core"; import { sanitizeError } from "../shared/sanitize.js"; import { type ResolvedConfig, @@ -101,14 +106,10 @@ export async function tryHeadlessLocalAutoResolveQuestions( policy.autoResolveStrategy, ); if (!autoResolved) return null; + const resolved = resultFromRemoteAnswer(autoResolved, questions); return { - content: [ - { - type: "text", - text: JSON.stringify({ answers: formatForTool(autoResolved) }), - }, - ], + content: resolved.content, details: { remote: true, channel: policy.channel, @@ -119,7 +120,7 @@ export async function tryHeadlessLocalAutoResolveQuestions( localFallback: true, unavailableReason: options.unavailableReason, questions, - response: autoResolved, + response: resolved.response, }, }; } @@ -190,13 +191,9 @@ export async function tryRemoteQuestions( : null; if (autoResolved) { markPromptAnswered(prompt.id, autoResolved); + const resolved = resultFromRemoteAnswer(autoResolved, questions); return { - content: [ - { - type: "text", - text: JSON.stringify({ answers: formatForTool(autoResolved) }), - }, - ], + content: resolved.content, details: { remote: true, channel: config.channel, @@ -207,7 +204,7 @@ export async function tryRemoteQuestions( autoResolved: true, autoResolveStrategy: config.autoResolveStrategy, questions, - response: autoResolved, + response: resolved.response, }, }; } @@ -246,14 +243,10 @@ export async function tryRemoteQuestions( } catch { /* best-effort */ } + const resolved = resultFromRemoteAnswer(pollResult.answer, questions); return { - content: [ - { - type: "text", - text: JSON.stringify({ answers: formatForTool(pollResult.answer) }), - }, - ], + content: resolved.content, details: { remote: true, channel: config.channel, @@ -261,7 +254,7 @@ export async function tryRemoteQuestions( promptId: prompt.id, threadUrl: dispatch.ref.threadUrl ?? null, questions, - response: pollResult.answer, + response: resolved.response, status: "answered", }, }; @@ -291,6 +284,20 @@ function createPrompt( }; } +function resultFromRemoteAnswer( + answer: RemoteAnswer, + questions: QuestionInput[], +): { + content: Array<{ type: "text"; text: string }>; + response: RoundResult; +} { + const response = roundResultFromRemoteAnswer(answer, questions); + return { + content: [{ type: "text", text: formatRoundResultForTool(response) }], + response, + }; +} + function createAdapter(config: ResolvedConfig): ChannelAdapter { if (config.channel === "slack") return new SlackAdapter(config.token, config.channelId); @@ -350,18 +357,6 @@ function sleep(ms: number, signal?: AbortSignal): Promise { }); } -function formatForTool( - answer: RemoteAnswer, -): Record { - const out: Record = {}; - for (const [id, data] of Object.entries(answer.answers)) { - const list = [...data.answers]; - if (data.user_note) list.push(`user_note: ${data.user_note}`); - out[id] = { answers: list }; - } - return out; -} - function errorResult(message: string, channel: string): ToolResult { return { content: [{ type: "text", text: sanitizeError(message) }], diff --git a/src/resources/extensions/sf/auto-prompts.ts b/src/resources/extensions/sf/auto-prompts.ts index 3fa015fb5..d2d457e14 100644 --- a/src/resources/extensions/sf/auto-prompts.ts +++ b/src/resources/extensions/sf/auto-prompts.ts @@ -1638,6 +1638,9 @@ export async function buildResearchMilestonePrompt( base, extractKeywords(midTitle), ); + const graphBlockRM = await inlineGraphSubgraph(base, `${mid} ${midTitle}`, { + budget: 3000, + }); const parts: string[] = []; if (knowledgeInlineRM && composed) { // Insert knowledge before the template block so the overall order is: @@ -1654,6 +1657,7 @@ export async function buildResearchMilestonePrompt( parts.push(composed); if (knowledgeInlineRM) parts.push(knowledgeInlineRM); } + if (graphBlockRM) parts.push(graphBlockRM); const inlinedContext = capPreamble( `## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`, @@ -1741,6 +1745,10 @@ export async function buildPlanMilestonePrompt( extractKeywords(midTitle), ); if (knowledgeInlinePM) inlined.push(knowledgeInlinePM); + const graphBlockPM = await inlineGraphSubgraph(base, `${mid} ${midTitle}`, { + budget: 3000, + }); + if (graphBlockPM) inlined.push(graphBlockPM); inlined.push(inlineTemplate("roadmap", "Roadmap")); if (inlineLevel === "full") { inlined.push(inlineTemplate("decisions", "Decisions")); @@ -2537,9 +2545,15 @@ export async function buildCompleteSlicePrompt( ...extractKeywords(midTitle), ...extractKeywords(sTitle), ]); + const graphBlockCS = await inlineGraphSubgraph(base, `${sid} ${sTitle}`, { + budget: 3000, + }); let body = composed; - if (knowledgeInlineCS && body) { + const graphAwareKnowledgeInline = [knowledgeInlineCS, graphBlockCS] + .filter((block): block is string => Boolean(block)) + .join("\n\n---\n\n"); + if (graphAwareKnowledgeInline && body) { // Splice knowledge right before the first "### Task Summary:" block // to preserve pre-migration ordering. If no task summaries exist, // append after requirements (before templates). @@ -2549,9 +2563,9 @@ export async function buildCompleteSlicePrompt( if (spliceIdx > 0) { const before = body.slice(0, spliceIdx).replace(/\n\n---\n\n$/, ""); const after = body.slice(spliceIdx); - body = [before, knowledgeInlineCS, after].join("\n\n---\n\n"); + body = [before, graphAwareKnowledgeInline, after].join("\n\n---\n\n"); } else { - body = `${body}\n\n---\n\n${knowledgeInlineCS}`; + body = `${body}\n\n---\n\n${graphAwareKnowledgeInline}`; } } @@ -2691,6 +2705,10 @@ export async function buildCompleteMilestonePrompt( extractKeywords(midTitle), ); if (knowledgeInlineCM) inlined.push(knowledgeInlineCM); + const graphBlockCM = await inlineGraphSubgraph(base, `${mid} ${midTitle}`, { + budget: 3000, + }); + if (graphBlockCM) inlined.push(graphBlockCM); // Inline milestone context file (milestone-level, not SF root) const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); const contextRel = relMilestoneFile(base, mid, "CONTEXT"); @@ -2908,6 +2926,10 @@ export async function buildValidateMilestonePrompt( extractKeywords(midTitle), ); if (knowledgeInline) inlined.push(knowledgeInline); + const graphBlockVM = await inlineGraphSubgraph(base, `${mid} ${midTitle}`, { + budget: 3000, + }); + if (graphBlockVM) inlined.push(graphBlockVM); // Inline milestone context file const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); const contextRel = relMilestoneFile(base, mid, "CONTEXT"); @@ -3191,6 +3213,10 @@ export async function buildReassessRoadmapPrompt( extractKeywords(midTitle), ); if (knowledgeInlineRA) parts.push(knowledgeInlineRA); + const graphBlockRA = await inlineGraphSubgraph(base, `${mid} ${midTitle}`, { + budget: 3000, + }); + if (graphBlockRA) parts.push(graphBlockRA); const inlinedContext = capPreamble( `## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`, @@ -3436,31 +3462,31 @@ export async function buildParallelResearchSlicesPrompt( ); const subagentSections = entries.map(({ slice, guardedPrompt }) => { - return [ - `### ${slice.id}: ${slice.title}`, - "", - "Task payload:", - "", - "```", - guardedPrompt, - "```", - ].join("\n"); - }); + return [ + `### ${slice.id}: ${slice.title}`, + "", + "Task payload:", + "", + "```", + guardedPrompt, + "```", + ].join("\n"); + }); const tasks = entries.map(({ guardedPrompt }) => { - const task: { - agent: string; - task: string; - cwd: string; - model?: string; - } = { - agent: "worker", - cwd: basePath, - task: guardedPrompt, - }; - if (subagentModel) task.model = subagentModel; - return task; - }); + const task: { + agent: string; + task: string; + cwd: string; + model?: string; + } = { + agent: "worker", + cwd: basePath, + task: guardedPrompt, + }; + if (subagentModel) task.model = subagentModel; + return task; + }); const subagentCall = JSON.stringify({ tasks }, null, 2); return loadPrompt("parallel-research-slices", { diff --git a/src/resources/extensions/sf/graph-context.ts b/src/resources/extensions/sf/graph-context.ts index 43924a17a..a66364c1b 100644 --- a/src/resources/extensions/sf/graph-context.ts +++ b/src/resources/extensions/sf/graph-context.ts @@ -159,14 +159,14 @@ async function resolveGraphApi(): Promise { resolvedGraphApi = true; try { - const imported = await import("@singularity-forge/mcp-server"); + const imported = await import("@singularity-forge/pi-agent-core"); if (isGraphApi(imported)) { cachedGraphApi = imported; return cachedGraphApi; } logWarning( "prompt", - "@singularity-forge/mcp-server graph exports unavailable; using local graph fallback", + "@singularity-forge/pi-agent-core graph exports unavailable; using local graph fallback", ); } catch { // Fall back to local reader implementation. @@ -184,7 +184,7 @@ async function resolveGraphApi(): Promise { * the result as an inlined context block. * * Returns null when: - * - @singularity-forge/mcp-server fails to import + * - @singularity-forge/pi-agent-core fails to import * - graph.json does not exist (graphQuery already handles this gracefully) * - query returns zero nodes * diff --git a/src/resources/extensions/sf/native-git-bridge.ts b/src/resources/extensions/sf/native-git-bridge.ts index 08db8a88b..1f8efa215 100644 --- a/src/resources/extensions/sf/native-git-bridge.ts +++ b/src/resources/extensions/sf/native-git-bridge.ts @@ -6,10 +6,37 @@ // execSync calls because git2 credential handling is too complex. import { execFileSync } from "node:child_process"; +import { lstatSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; import { getErrorMessage } from "./error-utils.js"; import { SF_GIT_ERROR, SFError } from "./errors.js"; import { GIT_NO_PROMPT_ENV } from "./git-constants.js"; +/** + * Return true when any directory component of `relPath` (relative to `basePath`) + * is itself a symlink. Git refuses to add paths that traverse a symlink, so + * callers should drop these silently rather than surface a "beyond a symbolic + * link" stage failure for state directories that the user intentionally pointed + * at out-of-tree storage (e.g. `.sf` symlinked to `~/.sf/projects//`). + */ +function isPathBeyondSymlink(basePath: string, relPath: string): boolean { + if (isAbsolute(relPath)) return false; + const segments = relPath.split(/[\\/]/).filter(Boolean); + let acc = basePath; + // Stop one short of the final segment — git only complains when the + // traversal is through a symlink, not when the leaf itself is one. + for (let i = 0; i < segments.length - 1; i++) { + acc = join(acc, segments[i]); + try { + if (lstatSync(acc).isSymbolicLink()) return true; + } catch { + // Missing intermediate; let git report whatever it would have. + return false; + } + } + return false; +} + // Issue #453: keep auto-mode bookkeeping on the stable git CLI path unless a // caller explicitly opts into the native helper. const NATIVE_SF_GIT_ENABLED = process.env.SF_ENABLE_NATIVE_SF_GIT === "1"; @@ -915,12 +942,18 @@ export function nativeAddAllWithExclusions( */ export function nativeAddPaths(basePath: string, paths: string[]): void { if (paths.length === 0) return; + // Drop paths that traverse a symlink out of the worktree (e.g. `.sf/...` + // when `.sf` points at `~/.sf/projects//`). Both libgit2 and the git + // CLI reject these with "beyond a symbolic link", and the user has + // intentionally placed that state outside version control. + const safe = paths.filter((p) => !isPathBeyondSymlink(basePath, p)); + if (safe.length === 0) return; const native = loadNative(); if (native) { - native.gitAddPaths(basePath, paths); + native.gitAddPaths(basePath, safe); return; } - gitFileExec(basePath, ["add", "--", ...paths]); + gitFileExec(basePath, ["add", "--", ...safe]); } /** diff --git a/src/resources/extensions/sf/prompts/guided-research-slice.md b/src/resources/extensions/sf/prompts/guided-research-slice.md index 8b2d0a985..373908083 100644 --- a/src/resources/extensions/sf/prompts/guided-research-slice.md +++ b/src/resources/extensions/sf/prompts/guided-research-slice.md @@ -1,4 +1,4 @@ -Research slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Read `.sf/DECISIONS.md` if it exists — respect existing decisions, don't contradict them. Read `.sf/REQUIREMENTS.md` if it exists — identify which Active requirements this slice owns or supports and target research toward risks, unknowns, and constraints that could affect delivery of those requirements. {{skillActivation}} If a repo-intelligence MCP (e.g. Serena) is configured, prefer it for symbol lookup, references, and cross-file architecture mapping. For direct text inspection use `rg`/`find` for targeted reads, or `scout` if the area is broad or unfamiliar. If there are 2-3 independent unknowns, use a research swarm with parallel `scout`/`researcher` subagents and synthesize their findings here; do not swarm narrow sequence-dependent research. Check libraries DeepWiki-first: `ask_question` / `read_wiki_structure` / `read_wiki_contents` for any GitHub-hosted library; fall back to `resolve_library` / `get_library_docs` (Context7, capped at 1000 req/month free) for npm/pypi/crates packages DeepWiki doesn't have. Skip both for libraries already used in this codebase. Use the **Research** output template below. Call `sf_summary_save` with `milestone_id: {{milestoneId}}`, `slice_id: {{sliceId}}`, `artifact_type: "RESEARCH"`, and the research content — the tool writes the file to disk and persists to DB. +Research slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Read `.sf/DECISIONS.md` if it exists — respect existing decisions, don't contradict them. Read `.sf/REQUIREMENTS.md` if it exists — identify which Active requirements this slice owns or supports and target research toward risks, unknowns, and constraints that could affect delivery of those requirements. {{skillActivation}} Use native `lsp` first for symbol lookup, references, and cross-file navigation. For direct text inspection use `rg`/`find` for targeted reads, or `scout` if the area is broad or unfamiliar. If there are 2-3 independent unknowns, use a research swarm with parallel `scout`/`researcher` subagents and synthesize their findings here; do not swarm narrow sequence-dependent research. Check libraries DeepWiki-first: `ask_question` / `read_wiki_structure` / `read_wiki_contents` for any GitHub-hosted library; fall back to `resolve_library` / `get_library_docs` (Context7, capped at 1000 req/month free) for npm/pypi/crates packages DeepWiki doesn't have. Skip both for libraries already used in this codebase. Use the **Research** output template below. Call `sf_summary_save` with `milestone_id: {{milestoneId}}`, `slice_id: {{sliceId}}`, `artifact_type: "RESEARCH"`, and the research content — the tool writes the file to disk and persists to DB. **You are the scout.** A planner agent reads your output in a fresh context to decompose this slice into tasks. Write for the planner — surface key files, where the work divides naturally, what to build first, and how to verify. If the research doc is vague, the planner re-explores code you already read. If it's precise, the planner decomposes immediately. diff --git a/src/resources/extensions/sf/prompts/plan-milestone.md b/src/resources/extensions/sf/prompts/plan-milestone.md index 72f01ce52..9550d832b 100644 --- a/src/resources/extensions/sf/prompts/plan-milestone.md +++ b/src/resources/extensions/sf/prompts/plan-milestone.md @@ -20,7 +20,7 @@ After you finish, each slice goes through its own plan → execute cycle. Slice Before decomposing, build your understanding: -1. **Codebase exploration.** If a repo-intelligence MCP (e.g. Serena) is configured, prefer it for symbol lookup, references, and cross-file architecture mapping. For small/familiar codebases, use `rg`, `find`, and targeted reads. For large or unfamiliar codebases, use `scout` to build a broad map efficiently before diving in. +1. **Codebase exploration.** Use native `lsp` first for symbol lookup, references, and cross-file navigation. For small/familiar codebases, use `rg`, `find`, and targeted reads. For large or unfamiliar codebases, use `scout` to build a broad map efficiently before diving in. 2. **Library docs — DeepWiki first.** Use `ask_question` / `read_wiki_structure` / `read_wiki_contents` (DeepWiki) as the default for any GitHub-hosted library. Fall back to `resolve_library` / `get_library_docs` (Context7) for npm/pypi/crates packages DeepWiki doesn't have. Context7 free tier is capped at 1000 req/month — spend those on cases DeepWiki can't cover. Skip both for libraries already used in this codebase. 3. **Skill Discovery ({{skillDiscoveryMode}}):**{{skillDiscoveryInstructions}} 4. **Requirements analysis.** If `.sf/REQUIREMENTS.md` exists, research against it. Identify which Active requirements are table stakes, likely omissions, overbuilt risks, or domain-standard behaviors. diff --git a/src/resources/extensions/sf/prompts/plan-slice.md b/src/resources/extensions/sf/prompts/plan-slice.md index a81ee2aae..8c34e18ef 100644 --- a/src/resources/extensions/sf/prompts/plan-slice.md +++ b/src/resources/extensions/sf/prompts/plan-slice.md @@ -26,7 +26,7 @@ Check prior slice summaries (inlined above as dependency summaries, if present). ### Explore Slice Scope -Read the code files relevant to this slice. Confirm the roadmap's description of what exists, what needs to change, and what boundaries apply. If a repo-intelligence MCP (e.g. Serena) is configured, prefer it for symbol lookup, references, and cross-file architecture mapping. Use `rg`, `find`, and targeted reads for direct text inspection. +Read the code files relevant to this slice. Confirm the roadmap's description of what exists, what needs to change, and what boundaries apply. Use native `lsp` first for symbol lookup, references, and cross-file navigation. Use `rg`, `find`, and targeted reads for direct text inspection. ### Source Files diff --git a/src/resources/extensions/sf/prompts/product-audit.md b/src/resources/extensions/sf/prompts/product-audit.md index 4a6785804..2d826fdc3 100644 --- a/src/resources/extensions/sf/prompts/product-audit.md +++ b/src/resources/extensions/sf/prompts/product-audit.md @@ -18,10 +18,10 @@ Use repo docs as the product contract. Do not hardcode assumptions from another You must inspect the codebase enough to confirm whether declared product capabilities exist. Prefer semantic or structured search when available: -- Start with MCP discovery: call `mcp_servers`, then `mcp_discover` for configured repo-intelligence servers. In our repositories, use Serena first when present for symbol search, references, and cross-file codebase mapping. If Serena is not configured or a call fails, state that and continue with AST/text search. +- Start with native code intelligence: use `lsp` for symbol search, definitions, references, and call hierarchy; use `rg` for exact text evidence. If LSP is unavailable for a language, state that and continue with AST/text search. - Use AST-aware search (`ast-grep`, repository semantic tools, or equivalent) for implementation patterns, exported APIs, route handlers, CLI commands, service definitions, config keys, and test coverage. - Use text search (`rg`) for docs, deployment scripts, runbooks, CI workflows, build targets, and evidence strings. -- Use available repo-intelligence MCP tools such as Serena, DeepWiki, Context7, package-intelligence, or project-specific servers when configured, especially for cross-file symbol tracing and architecture questions. +- Use external MCP tools only for external knowledge or explicitly configured project-specific services. Native `lsp` is the first-party symbol tracing path. - Do not rely only on preloaded docs. If a required capability is declared, look for concrete implementation, tests, config, deploy, and operational evidence. - Do not edit source files in this audit. The only write path is the `sf_product_audit` tool. diff --git a/src/resources/extensions/sf/prompts/research-milestone.md b/src/resources/extensions/sf/prompts/research-milestone.md index 002b0c0bf..325ca6567 100644 --- a/src/resources/extensions/sf/prompts/research-milestone.md +++ b/src/resources/extensions/sf/prompts/research-milestone.md @@ -31,7 +31,7 @@ A milestone adding a small feature to an established codebase needs targeted res Then research the codebase and relevant technologies. Narrate key findings and surprises as you go — what exists, what's missing, what constrains the approach. 1. {{skillActivation}} 2. **Skill Discovery ({{skillDiscoveryMode}}):**{{skillDiscoveryInstructions}} -3. Explore relevant code. If a repo-intelligence MCP (e.g. Serena) is configured, prefer it for symbol lookup, references, and cross-file architecture mapping. For small/familiar codebases, use `rg`, `find`, and targeted reads. For large or unfamiliar codebases, use `scout` to build a broad map efficiently before diving in. +3. Explore relevant code. Use native `lsp` first for symbol lookup, references, and cross-file navigation. For small/familiar codebases, use `rg`, `find`, and targeted reads. For large or unfamiliar codebases, use `scout` to build a broad map efficiently before diving in. 3a. Use research swarms when the questions fan out cleanly. If the milestone spans 2-3 independent subsystems, dispatch parallel `scout`/`researcher` subagents with separate lenses, then synthesize their findings into one research artifact. Do not swarm one tightly-coupled question; do it inline. 4. **Documentation lookup — prefer DeepWiki first.** Use `ask_question` / `read_wiki_structure` / `read_wiki_contents` (DeepWiki) as the default for any GitHub-hosted library or framework — AI-indexed, no free-tier cap. Fall back to `resolve_library` → `get_library_docs` (Context7) for npm/pypi/crates packages DeepWiki doesn't have. **Context7 free tier is capped at 1000 requests/month — spend those on cases DeepWiki can't cover.** Skip both for libraries already used in this codebase. 5. **Web search budget:** You have a limited budget of web searches (max ~15 per session). Use them strategically — try DeepWiki → Context7 → web search in that order. Do NOT repeat the same or similar queries. If a search didn't find what you need, rephrase once or move on. Target 3-5 total web searches for a typical research unit. diff --git a/src/resources/extensions/sf/prompts/research-slice.md b/src/resources/extensions/sf/prompts/research-slice.md index 797fa56ce..cd6aa0892 100644 --- a/src/resources/extensions/sf/prompts/research-slice.md +++ b/src/resources/extensions/sf/prompts/research-slice.md @@ -44,7 +44,7 @@ Research what this slice needs. Narrate key findings and surprises as you go — 0. If `REQUIREMENTS.md` was preloaded above, identify which Active requirements this slice owns or supports. Research should target these requirements — surfacing risks, unknowns, and implementation constraints that could affect whether the slice actually delivers them. 1. {{skillActivation}} Reference specific rules from loaded skills in your findings where they inform the implementation approach. 2. **Skill Discovery ({{skillDiscoveryMode}}):**{{skillDiscoveryInstructions}} -3. Explore relevant code for this slice's scope. If a repo-intelligence MCP (e.g. Serena) is configured, prefer it for symbol lookup, references, and cross-file architecture mapping. For direct text inspection, use `rg`, `find`, and reads. For broad or unfamiliar subsystems, use `scout` to map the relevant area first. +3. Explore relevant code for this slice's scope. Use native `lsp` first for symbol lookup, references, and cross-file navigation. For direct text inspection, use `rg`, `find`, and reads. For broad or unfamiliar subsystems, use `scout` to map the relevant area first. 3a. Use a research swarm when the slice has 2-3 independent unknowns or subsystems. Dispatch parallel `scout`/`researcher` subagents with distinct lenses, then synthesize what each found into this single RESEARCH artifact. Do not swarm a narrow, sequence-dependent investigation. 4. **Documentation lookup — prefer DeepWiki first.** Use `ask_question` / `read_wiki_structure` / `read_wiki_contents` (DeepWiki) as the default for any GitHub-hosted library or framework — AI-indexed, no free-tier cap. Fall back to `resolve_library` → `get_library_docs` (Context7) for npm/pypi/crates packages DeepWiki doesn't have. **Context7 free tier is capped at 1000 requests/month — spend those on cases DeepWiki can't cover.** Skip both for libraries already used in this codebase. 5. **Web search budget:** You have a limited budget of web searches (max ~15 per session). Use them strategically — try DeepWiki → Context7 → web search in that order. Do NOT repeat the same or similar queries. If a search didn't find what you need, rephrase once or move on. Target 3-5 total web searches for a typical research unit. diff --git a/src/resources/extensions/sf/skills/researcher/SKILL.md b/src/resources/extensions/sf/skills/researcher/SKILL.md index 69a5c52c7..543cb8875 100644 --- a/src/resources/extensions/sf/skills/researcher/SKILL.md +++ b/src/resources/extensions/sf/skills/researcher/SKILL.md @@ -5,8 +5,8 @@ description: Researches codebase, project state, and external knowledge using lo Research a topic using four complementary information sources, in priority order: -1. **Serena MCP** (46 LSP-backed tools: symbol search, file read, find references, pattern search) — use FIRST for code exploration -2. **sift** (hybrid BM25+vector local search) — use when Serena symbol search isn't enough +1. **Native LSP tool + local search** (`lsp`, `rg`, `find`, `ls`) — use FIRST for code exploration +2. **sift** (hybrid BM25+vector local search) — use when LSP/rg is not enough 3. **SF project database** (sqlite3) — use for project state (milestones, requirements, decisions) 4. **Web search** — use for external documentation and current information @@ -34,28 +34,24 @@ If a research question genuinely requires a write to answer (e.g., "does X actua -**Serena MCP (code intelligence — USE FIRST for code exploration):** +**Native code intelligence — USE FIRST for code exploration:** ```bash -# Discover Serena tools (she has 46 tools available) -mcp_servers +# Workspace symbol search +lsp action=symbols query=resolveSubagentLaunchSpec -# Get Serena's full tool list -mcp_discover server=serena +# Document symbols +lsp action=symbols file=src/resources/extensions/subagent/index.ts -# Symbol search — find where a function/type is defined -mcp_call server=serena tool=find_symbol arguments={contextLines=5,matchPattern="resolveSubagentLaunchSpec"} +# Go to definition at a line/symbol +lsp action=definition file=src/resources/extensions/subagent/index.ts line=42 symbol=resolveSubagentLaunchSpec -# Find all references to a symbol (callers, usages) -mcp_call server=serena tool=find_referencing_symbols arguments={contextLines=3,matchPattern="resolveSubagentLaunchSpec"} +# Find references/callers +lsp action=references file=src/resources/extensions/subagent/index.ts line=42 symbol=resolveSubagentLaunchSpec +lsp action=incoming_calls file=src/resources/extensions/subagent/index.ts line=42 symbol=resolveSubagentLaunchSpec -# Read a file (Serena's LSP-backed read is faster than bash cat) -mcp_call server=serena tool=read_file arguments={file_path="src/resources/extensions/subagent/index.ts"} - -# Search for pattern in files -mcp_call server=serena tool=search_for_pattern arguments={pattern="codebase_search",filePattern="*.ts",contextLines=3} - -# List directory -mcp_call server=serena tool=list_dir arguments={path="src/resources/extensions/sf/skills/"} +# Fast text/file search +rg -n "codebase_search|resolveSubagentLaunchSpec" src packages +rg --files src/resources/extensions/sf/skills ``` **Local code search — sift (hybrid BM25+vector search):** @@ -87,30 +83,32 @@ sqlite3 .sf/sf.db "SELECT id, title, status FROM tasks WHERE milestone_id='M001' ## Step 1: Clarify the research goal Before searching, identify what you need to know: -- **Code exploration** (finding functions, types, references) → use Serena MCP first +- **Code exploration** (finding functions, types, references) → use native `lsp` first, then `rg` - **Project state** (milestones, slices, tasks, requirements) → query the SF DB - **Current external information** → use web search - **All of the above** → combine all four sources -## Step 2: Explore code with Serena MCP (priority) +## Step 2: Explore code with native LSP (priority) - Serena is an LSP-backed code intelligence layer. Use `mcp_call` to invoke her tools: +Use the built-in `lsp` tool for symbol-aware navigation: ```bash # Find where a function or type is defined -mcp_call server=serena tool=find_symbol arguments={matchPattern="MyFunction",contextLines=5} +lsp action=symbols query=MyFunction +lsp action=definition file=src/my-file.ts line=42 symbol=MyFunction # Find all callers/references to a symbol -mcp_call server=serena tool=find_referencing_symbols arguments={matchPattern="MyFunction",contextLines=3} +lsp action=references file=src/my-file.ts line=42 symbol=MyFunction +lsp action=incoming_calls file=src/my-file.ts line=42 symbol=MyFunction # Read a specific file -mcp_call server=serena tool=read_file arguments={file_path="src/my-file.ts"} +read file=src/my-file.ts # Grep-like search across the codebase -mcp_call server=serena tool=search_for_pattern arguments={pattern="TODO.*auth",filePattern="*.ts"} +rg -n "TODO.*auth" src packages ``` -## Step 3: Supplement with sift (when Serena isn't enough) +## Step 3: Supplement with sift (when LSP/rg is not enough) Use sift when you need semantic/hybrid search across unstructured content: diff --git a/src/resources/extensions/sf/tests/commands-eval-review.test.ts b/src/resources/extensions/sf/tests/commands-eval-review.test.ts new file mode 100644 index 000000000..7f0d1e8ca --- /dev/null +++ b/src/resources/extensions/sf/tests/commands-eval-review.test.ts @@ -0,0 +1,379 @@ +import assert from "node:assert/strict"; +import { mkdirSync } from "node:fs"; +import { test } from "vitest"; +import { + buildSliceFileName, + resolveMilestonePath, + resolveSliceFile, + resolveSlicePath, +} from "../paths.js"; +import { + computeOverallScore, + deriveCounts, + EVAL_REVIEW_SCHEMA_VERSION, + EvalReviewFrontmatter, + extractFrontmatterRaw, + parseEvalReviewFrontmatter, + verdictForScore, +} from "../eval-review-schema.js"; +import { + buildEvalReviewContext, + detectEvalReviewState, + EvalReviewArgError, + evalReviewWritePath, + findEvalReviewFile, + parseEvalReviewArgs, + planEvalReviewAction, + SLICE_ID_PATTERN, +} from "../commands-eval-review.js"; +import { TOP_LEVEL_SUBCOMMANDS } from "../commands/catalog.js"; +import { makeTempDir, cleanup, createFile } from "./test-utils.js"; + +// ─── Argument parsing ─────────────────────────────────────────────────────── + +test("parseEvalReviewArgs: valid slice ID only", () => { + const args = parseEvalReviewArgs("S07"); + assert.equal(args.sliceId, "S07"); + assert.equal(args.force, false); + assert.equal(args.show, false); +}); + +test("parseEvalReviewArgs: with --force", () => { + const args = parseEvalReviewArgs("S07 --force"); + assert.equal(args.sliceId, "S07"); + assert.equal(args.force, true); + assert.equal(args.show, false); +}); + +test("parseEvalReviewArgs: with --show", () => { + const args = parseEvalReviewArgs("S07 --show"); + assert.equal(args.sliceId, "S07"); + assert.equal(args.force, false); + assert.equal(args.show, true); +}); + +test("parseEvalReviewArgs: rejects missing slice ID", () => { + assert.throws(() => parseEvalReviewArgs(""), EvalReviewArgError); + assert.throws(() => parseEvalReviewArgs("--force"), EvalReviewArgError); +}); + +test("parseEvalReviewArgs: rejects invalid slice ID", () => { + assert.throws(() => parseEvalReviewArgs("../etc/passwd"), EvalReviewArgError); + assert.throws(() => parseEvalReviewArgs("foo"), EvalReviewArgError); + assert.throws(() => parseEvalReviewArgs("S"), EvalReviewArgError); +}); + +test("parseEvalReviewArgs: rejects unknown flags", () => { + assert.throws(() => parseEvalReviewArgs("S07 --wipe"), EvalReviewArgError); +}); + +test("parseEvalReviewArgs: rejects multiple slice IDs", () => { + assert.throws(() => parseEvalReviewArgs("S07 S08"), EvalReviewArgError); +}); + +// ─── Slice ID pattern ─────────────────────────────────────────────────────── + +test("SLICE_ID_PATTERN: accepts canonical IDs", () => { + assert.ok(SLICE_ID_PATTERN.test("S01")); + assert.ok(SLICE_ID_PATTERN.test("S99")); + assert.ok(SLICE_ID_PATTERN.test("S123")); +}); + +test("SLICE_ID_PATTERN: rejects path traversal", () => { + assert.ok(!SLICE_ID_PATTERN.test("../foo")); + assert.ok(!SLICE_ID_PATTERN.test("S01/../../etc")); + assert.ok(!SLICE_ID_PATTERN.test("foo/bar")); +}); + +test("SLICE_ID_PATTERN: rejects malformed IDs", () => { + assert.ok(!SLICE_ID_PATTERN.test("S")); + assert.ok(!SLICE_ID_PATTERN.test("s01")); + assert.ok(!SLICE_ID_PATTERN.test("01")); + assert.ok(!SLICE_ID_PATTERN.test("S01a")); + assert.ok(!SLICE_ID_PATTERN.test("")); +}); + +// ─── State detection ──────────────────────────────────────────────────────── + +test("detectEvalReviewState: no-slice-dir when slice missing", () => { + const dir = makeTempDir(); + try { + mkdirSync(`${dir}/.sf/milestones/M001/slices`, { recursive: true }); + const args = parseEvalReviewArgs("S99"); + const state = detectEvalReviewState(args, dir, "M001"); + assert.equal(state.kind, "no-slice-dir"); + if (state.kind === "no-slice-dir") { + assert.ok(state.expectedDir.includes("S99")); + } + } finally { + cleanup(dir); + } +}); + +test("detectEvalReviewState: no-summary when SUMMARY.md missing", () => { + const dir = makeTempDir(); + try { + mkdirSync(`${dir}/.sf/milestones/M001/slices/S01`, { recursive: true }); + const args = parseEvalReviewArgs("S01"); + const state = detectEvalReviewState(args, dir, "M001"); + assert.equal(state.kind, "no-summary"); + } finally { + cleanup(dir); + } +}); + +test("detectEvalReviewState: ready when SUMMARY.md present", () => { + const dir = makeTempDir(); + try { + createFile(dir, ".sf/milestones/M001/slices/S01/S01-SUMMARY.md", "# Summary\n"); + const args = parseEvalReviewArgs("S01"); + const state = detectEvalReviewState(args, dir, "M001"); + assert.equal(state.kind, "ready"); + if (state.kind === "ready") { + assert.ok(state.summaryPath.includes("S01-SUMMARY.md")); + } + } finally { + cleanup(dir); + } +}); + +// ─── Path helpers ─────────────────────────────────────────────────────────── + +test("evalReviewWritePath: canonical naming", () => { + const path = evalReviewWritePath("/tmp/milestone/slices/S01", "S01"); + assert.ok(path.endsWith("S01-EVAL-REVIEW.md")); + assert.ok(path.includes("/tmp/milestone/slices/S01")); +}); + +test("findEvalReviewFile: returns null when absent", () => { + const dir = makeTempDir(); + try { + mkdirSync(`${dir}/.sf/milestones/M001/slices/S01`, { recursive: true }); + const result = findEvalReviewFile(dir, "M001", "S01"); + assert.equal(result, null); + } finally { + cleanup(dir); + } +}); + +test("findEvalReviewFile: returns path when present", () => { + const dir = makeTempDir(); + try { + createFile(dir, ".sf/milestones/M001/slices/S01/S01-EVAL-REVIEW.md", "---\n---\n"); + const result = findEvalReviewFile(dir, "M001", "S01"); + assert.ok(result !== null); + assert.ok(result?.includes("S01-EVAL-REVIEW.md")); + } finally { + cleanup(dir); + } +}); + +// ─── Action planner ───────────────────────────────────────────────────────── + +test("planEvalReviewAction: no-slice-dir → no-slice-dir", () => { + const args = parseEvalReviewArgs("S01"); + const state = { kind: "no-slice-dir", sliceId: "S01", expectedDir: "/tmp" } as const; + const action = planEvalReviewAction(args, state, null); + assert.equal(action.kind, "no-slice-dir"); +}); + +test("planEvalReviewAction: --show tolerates missing summary", () => { + const args = parseEvalReviewArgs("S01 --show"); + const state = { kind: "no-summary", sliceId: "S01", sliceDir: "/tmp", specPath: null } as const; + const action = planEvalReviewAction(args, state, null); + assert.equal(action.kind, "show"); + assert.equal(action.path, null); +}); + +test("planEvalReviewAction: no-summary without --show → no-summary", () => { + const args = parseEvalReviewArgs("S01"); + const state = { kind: "no-summary", sliceId: "S01", sliceDir: "/tmp", specPath: null } as const; + const action = planEvalReviewAction(args, state, null); + assert.equal(action.kind, "no-summary"); +}); + +test("planEvalReviewAction: existing file without --force → exists-no-force", () => { + const args = parseEvalReviewArgs("S01"); + const state = { kind: "ready", sliceId: "S01", sliceDir: "/tmp", summaryPath: "/tmp/SUMMARY.md", specPath: null } as const; + const action = planEvalReviewAction(args, state, "/tmp/S01-EVAL-REVIEW.md"); + assert.equal(action.kind, "exists-no-force"); +}); + +test("planEvalReviewAction: existing file with --force → dispatch", () => { + const args = parseEvalReviewArgs("S01 --force"); + const state = { kind: "ready", sliceId: "S01", sliceDir: "/tmp", summaryPath: "/tmp/SUMMARY.md", specPath: null } as const; + const action = planEvalReviewAction(args, state, "/tmp/S01-EVAL-REVIEW.md"); + assert.equal(action.kind, "dispatch"); +}); + +test("planEvalReviewAction: no existing file → dispatch", () => { + const args = parseEvalReviewArgs("S01"); + const state = { kind: "ready", sliceId: "S01", sliceDir: "/tmp", summaryPath: "/tmp/SUMMARY.md", specPath: null } as const; + const action = planEvalReviewAction(args, state, null); + assert.equal(action.kind, "dispatch"); +}); + +// ─── Context builder ──────────────────────────────────────────────────────── + +test("buildEvalReviewContext: reads SUMMARY.md and builds context", async () => { + const dir = makeTempDir(); + try { + createFile(dir, ".sf/milestones/M001/slices/S01/S01-SUMMARY.md", "# Summary\nDone.\n"); + const state = { + kind: "ready" as const, + sliceId: "S01", + sliceDir: `${dir}/.sf/milestones/M001/slices/S01`, + summaryPath: `${dir}/.sf/milestones/M001/slices/S01/S01-SUMMARY.md`, + specPath: null, + }; + const ctx = await buildEvalReviewContext(state, "M001", () => new Date("2024-01-01T00:00:00Z")); + assert.equal(ctx.milestoneId, "M001"); + assert.equal(ctx.sliceId, "S01"); + assert.ok(ctx.summary.includes("Done.")); + assert.equal(ctx.spec, null); + assert.equal(ctx.truncated, false); + assert.ok(ctx.outputPath.includes("S01-EVAL-REVIEW.md")); + } finally { + cleanup(dir); + } +}); + +test("buildEvalReviewContext: reads optional AI-SPEC.md", async () => { + const dir = makeTempDir(); + try { + createFile(dir, ".sf/milestones/M001/slices/S01/S01-SUMMARY.md", "# Summary\n"); + createFile(dir, ".sf/milestones/M001/slices/S01/S01-AI-SPEC.md", "# Spec\n"); + const state = { + kind: "ready" as const, + sliceId: "S01", + sliceDir: `${dir}/.sf/milestones/M001/slices/S01`, + summaryPath: `${dir}/.sf/milestones/M001/slices/S01/S01-SUMMARY.md`, + specPath: `${dir}/.sf/milestones/M001/slices/S01/S01-AI-SPEC.md`, + }; + const ctx = await buildEvalReviewContext(state, "M001"); + assert.ok(ctx.spec?.includes("# Spec")); + } finally { + cleanup(dir); + } +}); + +// ─── Schema / frontmatter ─────────────────────────────────────────────────── + +test("extractFrontmatterRaw: finds YAML block", () => { + const raw = "---\nschema: eval-review/v1\nverdict: PRODUCTION_READY\n---\n# Body\n"; + const result = extractFrontmatterRaw(raw); + assert.ok("yaml" in result); + if ("yaml" in result) { + assert.ok(result.yaml.includes("schema: eval-review/v1")); + } +}); + +test("extractFrontmatterRaw: rejects missing opening delimiter", () => { + const result = extractFrontmatterRaw("no frontmatter"); + assert.ok("error" in result); +}); + +test("extractFrontmatterRaw: rejects missing closing delimiter", () => { + const result = extractFrontmatterRaw("---\nschema: eval-review/v1\n"); + assert.ok("error" in result); +}); + +test("parseEvalReviewFrontmatter: valid frontmatter parses", () => { + const raw = `--- +schema: eval-review/v1 +verdict: PRODUCTION_READY +coverage_score: 80 +infrastructure_score: 75 +overall_score: 78 +generated: 2024-01-01T00:00:00Z +slice: S01 +milestone: M001 +gaps: [] +counts: + blocker: 0 + major: 0 + minor: 0 +--- +# Body +`; + const result = parseEvalReviewFrontmatter(raw); + assert.ok(result.ok); + if (result.ok) { + assert.equal(result.data.schema, EVAL_REVIEW_SCHEMA_VERSION); + assert.equal(result.data.verdict, "PRODUCTION_READY"); + assert.equal(result.data.coverage_score, 80); + assert.equal(result.data.gaps.length, 0); + } +}); + +test("parseEvalReviewFrontmatter: invalid schema version fails", () => { + const raw = `--- +schema: eval-review/v2 +verdict: PRODUCTION_READY +coverage_score: 80 +infrastructure_score: 75 +overall_score: 78 +generated: 2024-01-01T00:00:00Z +slice: S01 +milestone: M001 +gaps: [] +counts: + blocker: 0 + major: 0 + minor: 0 +--- +`; + const result = parseEvalReviewFrontmatter(raw); + assert.ok(!result.ok); +}); + +test("parseEvalReviewFrontmatter: missing frontmatter fails", () => { + const result = parseEvalReviewFrontmatter("# Just a markdown file\n"); + assert.ok(!result.ok); +}); + +// ─── Derived fields ───────────────────────────────────────────────────────── + +test("computeOverallScore: 60/40 weighting", () => { + assert.equal(computeOverallScore(80, 75), Math.round(80 * 0.6 + 75 * 0.4)); + assert.equal(computeOverallScore(100, 0), 60); + assert.equal(computeOverallScore(0, 100), 40); + assert.equal(computeOverallScore(50, 50), 50); +}); + +test("computeOverallScore: clamps to bounds", () => { + assert.equal(computeOverallScore(-10, -10), 0); + assert.equal(computeOverallScore(200, 200), 100); +}); + +test("deriveCounts: tallies severities correctly", () => { + const gaps = [ + { id: "G01", dimension: "tests", severity: "blocker", description: "d", evidence: "e", suggested_fix: "f" }, + { id: "G02", dimension: "tests", severity: "major", description: "d", evidence: "e", suggested_fix: "f" }, + { id: "G03", dimension: "tests", severity: "major", description: "d", evidence: "e", suggested_fix: "f" }, + { id: "G04", dimension: "tests", severity: "minor", description: "d", evidence: "e", suggested_fix: "f" }, + ] as const; + const counts = deriveCounts(gaps as any); + assert.equal(counts.blocker, 1); + assert.equal(counts.major, 2); + assert.equal(counts.minor, 1); +}); + +test("verdictForScore: maps to correct bands", () => { + assert.equal(verdictForScore(100), "PRODUCTION_READY"); + assert.equal(verdictForScore(80), "PRODUCTION_READY"); + assert.equal(verdictForScore(79), "NEEDS_WORK"); + assert.equal(verdictForScore(60), "NEEDS_WORK"); + assert.equal(verdictForScore(59), "SIGNIFICANT_GAPS"); + assert.equal(verdictForScore(40), "SIGNIFICANT_GAPS"); + assert.equal(verdictForScore(39), "NOT_IMPLEMENTED"); + assert.equal(verdictForScore(0), "NOT_IMPLEMENTED"); +}); + +// ─── Catalog wiring ───────────────────────────────────────────────────────── + +test("eval-review command is in catalog", () => { + const entry = TOP_LEVEL_SUBCOMMANDS.find((c) => c.cmd === "eval-review"); + assert.ok(entry, "eval-review should be registered in TOP_LEVEL_SUBCOMMANDS"); + assert.ok(entry!.desc.includes("EVAL-REVIEW")); +}); diff --git a/src/resources/extensions/sf/tests/graph-context.test.ts b/src/resources/extensions/sf/tests/graph-context.test.ts index 50753d65d..8fc6aca3d 100644 --- a/src/resources/extensions/sf/tests/graph-context.test.ts +++ b/src/resources/extensions/sf/tests/graph-context.test.ts @@ -7,7 +7,7 @@ * Group 3: Node formatting (description, confidence, no-description) * * Testing strategy: - * @singularity-forge/mcp-server is dynamically imported inside inlineGraphSubgraph(). + * @singularity-forge/pi-agent-core is dynamically imported inside inlineGraphSubgraph(). * Because node:test (v22) does not support mock.module() without the * --experimental-test-module-mocks flag (not enabled in test:unit), we * exercise the real graphQuery/graphStatus functions by controlling the @@ -24,7 +24,7 @@ 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 { describe, it } from "vitest"; import { inlineGraphSubgraph } from "../graph-context.ts"; diff --git a/src/resources/extensions/sf/tests/remote-questions-manager.test.ts b/src/resources/extensions/sf/tests/remote-questions-manager.test.ts index ef99ae129..d2884379c 100644 --- a/src/resources/extensions/sf/tests/remote-questions-manager.test.ts +++ b/src/resources/extensions/sf/tests/remote-questions-manager.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { test } from 'vitest'; +import { test } from "vitest"; import { isUsableRemoteQuestionResult } from "../../ask-user-questions.ts"; import { tryAutoResolveQuestions, @@ -119,8 +119,12 @@ test("headless local auto-resolution fires after timeout when telegram is unavai assert.equal(result?.details?.autoResolved, true); assert.equal(result?.details?.localFallback, true); assert.deepEqual(result?.details?.response, { + endInterview: false, answers: { - depth_verification_M001_confirm: { answers: ["Sufficient"] }, + depth_verification_M001_confirm: { + selected: "Sufficient", + notes: "", + }, }, }); }); diff --git a/src/resources/extensions/sf/tools/complete-slice.ts b/src/resources/extensions/sf/tools/complete-slice.ts index fe84aaa86..4760a2fb2 100644 --- a/src/resources/extensions/sf/tools/complete-slice.ts +++ b/src/resources/extensions/sf/tools/complete-slice.ts @@ -13,6 +13,7 @@ import { clearParseCache } from "../files.js"; import { getGatesForTurn } from "../gate-registry.js"; import { renderRoadmapCheckboxes } from "../markdown-renderer.js"; import { clearPathCache, resolveSlicePath } from "../paths.js"; +import { checkSafeIds } from "../safety/safe-id.js"; import { getMilestone, getPendingGatesForTurn, @@ -25,7 +26,6 @@ import { transaction, updateSliceStatus, } from "../sf-db.js"; -import { checkSafeIds } from "../safety/safe-id.js"; import { invalidateStateCache } from "../state.js"; import { isClosedStatus } from "../status-guards.js"; import type { CompleteSliceParams } from "../types.js"; @@ -418,7 +418,9 @@ ${params.uatContent} export async function handleCompleteSlice( paramsInput: CompleteSliceParams, basePath: string, -): Promise { +): Promise< + CompleteSliceResult | { error: string; field?: string; reason?: string } +> { // ── Path-traversal guard (validation-safe-id-path-segments) ─────────── // Checked on raw input before normalizeCompleteSliceParams so the // structured error shape is preserved. Rejects any ID that could escape @@ -698,25 +700,25 @@ export async function handleCompleteSlice( (async () => { try { const graphMod = (await import( - "@singularity-forge/mcp-server" + "@singularity-forge/pi-agent-core" )) as unknown as Partial<{ buildGraph: ( dir: string, ) => Promise<{ nodes: unknown[]; edges: unknown[]; builtAt: string }>; writeGraph: (sfRoot: string, graph: unknown) => Promise; - resolveGsdRoot: (basePath: string) => string; + resolveSFRoot: (basePath: string) => string; }>; if ( typeof graphMod.buildGraph !== "function" || typeof graphMod.writeGraph !== "function" || - typeof graphMod.resolveGsdRoot !== "function" + typeof graphMod.resolveSFRoot !== "function" ) { throw new Error( - "graph helpers unavailable from @singularity-forge/mcp-server", + "graph helpers unavailable from @singularity-forge/pi-agent-core", ); } const g = await graphMod.buildGraph(basePath); - await graphMod.writeGraph(graphMod.resolveGsdRoot(basePath), g); + await graphMod.writeGraph(graphMod.resolveSFRoot(basePath), g); } catch (graphErr) { // Graph rebuild is best-effort — log at warning level but never propagate logWarning( diff --git a/src/resources/extensions/sf/workflow-templates/product-audit.md b/src/resources/extensions/sf/workflow-templates/product-audit.md index 67fd7c231..d85bae634 100644 --- a/src/resources/extensions/sf/workflow-templates/product-audit.md +++ b/src/resources/extensions/sf/workflow-templates/product-audit.md @@ -27,7 +27,7 @@ hard-block milestone completion — actionable gaps become follow-up slices. 1. Load the audit prompt at `prompts/product-audit.md`. 2. Treat repo docs (VISION.md, README.md, docs/RUNBOOKS.md, milestone artifacts) as the product contract. Do not invent expectations. -3. Inspect the codebase using semantic search (Serena/MCP), AST tools, and `rg` +3. Inspect the codebase using native `lsp`, AST tools, and `rg` to confirm declared capabilities have concrete evidence (code, tests, deployment artifacts, runbooks). 4. Score each gap with severity, confidence, and a suggested follow-up slice. diff --git a/src/resources/extensions/shared/interview-ui.ts b/src/resources/extensions/shared/interview-ui.ts index ba21e8dfa..a8a9aef8e 100644 --- a/src/resources/extensions/shared/interview-ui.ts +++ b/src/resources/extensions/shared/interview-ui.ts @@ -40,25 +40,13 @@ import { INDENT, makeUI } from "./ui.js"; // ─── Exported types ─────────────────────────────────────────────────────────── -export interface QuestionOption { - label: string; - description: string; -} +export type { + Question, + QuestionOption, + RoundResult, +} from "@singularity-forge/pi-agent-core"; -export interface Question { - id: string; - header: string; - question: string; - options: QuestionOption[]; - /** If true, user can toggle multiple options with SPACE, confirm with ENTER */ - allowMultiple?: boolean; -} - -export interface RoundResult { - /** Always false — end is handled by showWrapUpScreen, not per-question */ - endInterview: false; - answers: Record; -} +import type { Question, RoundResult } from "@singularity-forge/pi-agent-core"; export interface WrapUpResult { /** true = wrap up and write file, false = keep going */ diff --git a/src/tests/package-mcp-server-elicitation.test.ts b/src/tests/package-mcp-server-elicitation.test.ts index 7c21be70a..4ef60c5b2 100644 --- a/src/tests/package-mcp-server-elicitation.test.ts +++ b/src/tests/package-mcp-server-elicitation.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; -import { test } from 'vitest'; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { test } from "vitest"; import { buildAskUserQuestionsElicitRequest, + buildAskUserQuestionsRoundResult, createMcpServer, formatAskUserQuestionsElicitResult, } from "../../packages/mcp-server/src/server.js"; @@ -130,6 +131,32 @@ test("ask_user_questions returns the packaged answers JSON shape for form elicit const text = result.content.find((item) => item.type === "text"); assert.ok(text && "text" in text); + assert.deepEqual(result.structuredContent, { + questions: [ + { + id: "deployment", + header: "Deploy", + question: "Where will this run?", + options: [ + { label: "Cloud", description: "Managed hosting." }, + { + label: "On-prem", + description: "Runs in customer infrastructure.", + }, + ], + }, + ], + response: { + endInterview: false, + answers: { + deployment: { + selected: "None of the above", + notes: "Need hybrid deployment.", + }, + }, + }, + cancelled: false, + }); assert.equal( text.text, JSON.stringify({ @@ -203,6 +230,21 @@ test("ask_user_questions returns the cancellation message when elicitation is de const text = result.content.find((item) => item.type === "text"); assert.ok(text && "text" in text); + assert.deepEqual(result.structuredContent, { + questions: [ + { + id: "continue", + header: "Continue", + question: "Continue?", + options: [ + { label: "Yes", description: "Proceed." }, + { label: "No", description: "Stop here." }, + ], + }, + ], + response: null, + cancelled: true, + }); assert.equal( text.text, "ask_user_questions was cancelled before receiving a response", @@ -234,7 +276,13 @@ test("helper formatting stays aligned with the tool contract", () => { const formatted = formatAskUserQuestionsElicitResult(questions, { action: "accept", content: { - focus_areas: ["Frontend", "Backend"], + focus_areas: ["Frontend"], + }, + }); + const round = buildAskUserQuestionsRoundResult(questions, { + action: "accept", + content: { + focus_areas: ["Frontend"], }, }); @@ -243,9 +291,10 @@ test("helper formatting stays aligned with the tool contract", () => { JSON.stringify({ answers: { focus_areas: { - answers: ["Frontend", "Backend"], + answers: ["Frontend"], }, }, }), ); + assert.deepEqual(round.answers.focus_areas.selected, ["Frontend"]); }); diff --git a/vitest.config.ts b/vitest.config.ts index 911bb74eb..88ecb02e0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,113 +11,189 @@ * npx vitest # watch mode * npx vitest run --changed # only tests affected by recent changes */ -import { defineConfig } from "vitest/config"; + import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; const __dirname = import.meta.dirname; export default defineConfig({ - test: { - // ── File patterns ───────────────────────────────────────────────────────── - // Files without vitest imports (standalone test scripts that run assertions - // directly at module load time — these are skipped by the old node --test - // runner and must be excluded here too to avoid "No test suite found" errors. - include: [ - "src/tests/**/*.test.ts", - "src/tests/**/*.test.mjs", - "src/resources/extensions/sf/tests/**/*.test.ts", - "src/resources/extensions/sf/tests/**/*.test.mjs", - "src/resources/extensions/shared/tests/**/*.test.ts", - "src/resources/extensions/claude-code-cli/tests/**/*.test.ts", - "src/resources/extensions/github-sync/tests/**/*.test.ts", - "src/resources/extensions/universal-config/tests/**/*.test.ts", - "src/resources/extensions/voice/tests/**/*.test.ts", - "src/resources/extensions/vectordrive/tests/**/*.test.ts", - "src/resources/extensions/mcp-client/tests/**/*.test.ts", - "src/resources/extensions/async-jobs/*.test.ts", - "src/resources/extensions/browser-tools/tests/*.test.mjs", - "packages/pi-coding-agent/src/**/*.test.ts", - "packages/pi-ai/src/**/*.test.ts", - "packages/pi-agent-core/src/**/*.test.ts", - "packages/pi-tui/src/**/*.test.ts", - "packages/daemon/src/**/*.test.ts", - "packages/mcp-server/src/**/*.test.ts", - "packages/rpc-client/src/**/*.test.ts", - "packages/native/src/**/*.test.mjs", - "web/lib/**/*.test.ts", - "studio/test/**/*.test.mjs", - "scripts/*.test.mjs", - ], + // ── TypeScript / module resolution ───────────────────────────────────────── + // Vitest uses esbuild for TS transform (fast, bundled). We still set up + // NodeNext module resolution and path aliases to match the project's tsconfig. + resolve: { + alias: { + "@singularity-forge/pi-coding-agent": resolve( + __dirname, + "packages/pi-coding-agent/src/index.ts", + ), + "@singularity-forge/pi-ai/oauth": resolve( + __dirname, + "packages/pi-ai/src/utils/oauth/index.ts", + ), + "@singularity-forge/pi-ai/bedrock-provider": resolve( + __dirname, + "packages/pi-ai/src/bedrock-provider.ts", + ), + "@singularity-forge/pi-ai": resolve( + __dirname, + "packages/pi-ai/src/index.ts", + ), + "@singularity-forge/pi-agent-core": resolve( + __dirname, + "packages/pi-agent-core/src/index.ts", + ), + "@singularity-forge/pi-tui": resolve( + __dirname, + "packages/pi-tui/src/index.ts", + ), + "@singularity-forge/native/ast": resolve( + __dirname, + "packages/native/src/ast/index.ts", + ), + "@singularity-forge/native/clipboard": resolve( + __dirname, + "packages/native/src/clipboard/index.ts", + ), + "@singularity-forge/native/diff": resolve( + __dirname, + "packages/native/src/diff/index.ts", + ), + "@singularity-forge/native/edit": resolve( + __dirname, + "packages/native/src/edit/index.ts", + ), + "@singularity-forge/native/fd": resolve( + __dirname, + "packages/native/src/fd/index.ts", + ), + "@singularity-forge/native/forge-parser": resolve( + __dirname, + "packages/native/src/forge-parser/index.ts", + ), + "@singularity-forge/native/glob": resolve( + __dirname, + "packages/native/src/glob/index.ts", + ), + "@singularity-forge/native/grep": resolve( + __dirname, + "packages/native/src/grep/index.ts", + ), + "@singularity-forge/native/highlight": resolve( + __dirname, + "packages/native/src/highlight/index.ts", + ), + "@singularity-forge/native/html": resolve( + __dirname, + "packages/native/src/html/index.ts", + ), + "@singularity-forge/native/image": resolve( + __dirname, + "packages/native/src/image/index.ts", + ), + "@singularity-forge/native/json-parse": resolve( + __dirname, + "packages/native/src/json-parse/index.ts", + ), + "@singularity-forge/native/ps": resolve( + __dirname, + "packages/native/src/ps/index.ts", + ), + "@singularity-forge/native/stream-process": resolve( + __dirname, + "packages/native/src/stream-process/index.ts", + ), + "@singularity-forge/native/text": resolve( + __dirname, + "packages/native/src/text/index.ts", + ), + "@singularity-forge/native/truncate": resolve( + __dirname, + "packages/native/src/truncate/index.ts", + ), + "@singularity-forge/native/ttsr": resolve( + __dirname, + "packages/native/src/ttsr/index.ts", + ), + "@singularity-forge/native/xxhash": resolve( + __dirname, + "packages/native/src/xxhash/index.ts", + ), + "@singularity-forge/native": resolve( + __dirname, + "packages/native/src/index.ts", + ), + "@singularity-forge/mcp-server": resolve( + __dirname, + "packages/mcp-server/src/index.ts", + ), + "@singularity-forge/rpc-client": resolve( + __dirname, + "packages/rpc-client/src/index.ts", + ), + }, + }, + test: { + // ── File patterns ───────────────────────────────────────────────────────── + // Files without vitest imports (standalone test scripts that run assertions + // directly at module load time — these are skipped by the old node --test + // runner and must be excluded here too to avoid "No test suite found" errors. + include: [ + "src/tests/**/*.test.ts", + "src/tests/**/*.test.mjs", + "src/resources/extensions/sf/tests/**/*.test.ts", + "src/resources/extensions/sf/tests/**/*.test.mjs", + "src/resources/extensions/shared/tests/**/*.test.ts", + "src/resources/extensions/claude-code-cli/tests/**/*.test.ts", + "src/resources/extensions/github-sync/tests/**/*.test.ts", + "src/resources/extensions/universal-config/tests/**/*.test.ts", + "src/resources/extensions/voice/tests/**/*.test.ts", + "src/resources/extensions/vectordrive/tests/**/*.test.ts", + "src/resources/extensions/mcp-client/tests/**/*.test.ts", + "src/resources/extensions/async-jobs/*.test.ts", + "src/resources/extensions/browser-tools/tests/*.test.mjs", + "packages/pi-coding-agent/src/**/*.test.ts", + "packages/pi-ai/src/**/*.test.ts", + "packages/pi-agent-core/src/**/*.test.ts", + "packages/pi-tui/src/**/*.test.ts", + "packages/daemon/src/**/*.test.ts", + "packages/mcp-server/src/**/*.test.ts", + "packages/rpc-client/src/**/*.test.ts", + "packages/native/src/**/*.test.mjs", + "web/lib/**/*.test.ts", + "studio/test/**/*.test.mjs", + "scripts/*.test.mjs", + ], - // ── Timeouts ────────────────────────────────────────────────────────────── - testTimeout: 30_000, - hookTimeout: 30_000, + // ── Timeouts ────────────────────────────────────────────────────────────── + testTimeout: 30_000, + hookTimeout: 30_000, - // ── Pool: forks = one Node process per test file (best for Node.js tests) ─ - pool: "forks", - // Single worker in CI; parallel in dev for speed - singleFork: process.env.CI === "true", + // ── Pool: forks = one Node process per test file (best for Node.js tests) ─ + pool: "forks", + // Single worker in CI; parallel in dev for speed + singleFork: process.env.CI === "true", - // ── Coverage ────────────────────────────────────────────────────────────── - coverage: { - provider: "v8", - reporter: ["text", "lcov"], - exclude: [ - "src/resources/extensions/sf/tests/**", - "src/tests/**", - "scripts/**", - "rust-engine/**", - "node_modules/**", - "dist/**", - "dist-test/**", - "web/**", - ], - thresholds: { - statements: 40, - lines: 40, - branches: 20, - functions: 20, - }, - }, - - // ── TypeScript / module resolution ───────────────────────────────────────── - // Vitest uses esbuild for TS transform (fast, bundled). We still set up - // NodeNext module resolution and path aliases to match the project's tsconfig. - resolve: { - alias: { - "@singularity-forge/pi-coding-agent": resolve( - __dirname, - "packages/pi-coding-agent/src/index.ts", - ), - "@singularity-forge/pi-ai": resolve( - __dirname, - "packages/pi-ai/src/index.ts", - ), - "@singularity-forge/pi-ai/oauth": resolve( - __dirname, - "packages/pi-ai/src/utils/oauth/index.ts", - ), - "@singularity-forge/pi-agent-core": resolve( - __dirname, - "packages/pi-agent-core/src/index.ts", - ), - "@singularity-forge/pi-tui": resolve( - __dirname, - "packages/pi-tui/src/index.ts", - ), - "@singularity-forge/native": resolve( - __dirname, - "packages/native/src/index.ts", - ), - "@singularity-forge/mcp-server": resolve( - __dirname, - "packages/mcp-server/src/index.ts", - ), - "@singularity-forge/rpc-client": resolve( - __dirname, - "packages/rpc-client/src/index.ts", - ), - }, - }, - }, + // ── Coverage ────────────────────────────────────────────────────────────── + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + exclude: [ + "src/resources/extensions/sf/tests/**", + "src/tests/**", + "scripts/**", + "rust-engine/**", + "node_modules/**", + "dist/**", + "dist-test/**", + "web/**", + ], + thresholds: { + statements: 40, + lines: 40, + branches: 20, + functions: 20, + }, + }, + }, });