diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d048584 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run check diff --git a/extensions/agent-team/index.test.ts b/extensions/agent-team/index.test.ts index 9bc52a3..2d8b5da 100644 --- a/extensions/agent-team/index.test.ts +++ b/extensions/agent-team/index.test.ts @@ -3,14 +3,21 @@ import test from "node:test"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import agentTeamExtension from "./index.ts"; -test("agent-team propagates invalid start input as an execute error", async () => { +function captureTool(extra: Record = {}): any { let tool: any; agentTeamExtension({ on() {}, registerTool(value: unknown) { tool = value; }, + ...extra, } as unknown as ExtensionAPI); + assert.ok(tool); + return tool; +} + +test("agent-team propagates invalid start input as an execute error", async () => { + const tool = captureTool(); await assert.rejects( () => @@ -24,3 +31,41 @@ test("agent-team propagates invalid start input as an execute error", async () = /topic is required for start/, ); }); + +test("agent-team exposes its supported read-only tools in the tool schema", () => { + const tool = captureTool(); + const schema = JSON.stringify(tool.parameters); + for (const name of ["read", "grep", "find", "ls", "web_search", "web_fetch"]) { + assert.match(schema, new RegExp(`"${name}"`)); + } + assert.doesNotMatch(schema, /"bash"/); + assert.doesNotMatch(schema, /"astrolabe"/); +}); + +test("agent-team reports the supported tool set when direct callers bypass schema validation", async () => { + const tool = captureTool({ + getAllTools() { + return [{ name: "read" }, { name: "bash" }]; + }, + }); + + await assert.rejects( + () => + tool.execute( + "test-call", + { + action: "start", + topic: "Review this change", + members: [ + { name: "a", role: "reviewer" }, + { name: "b", role: "skeptic" }, + ], + tools: ["bash"], + }, + undefined, + undefined, + { ui: { setStatus() {} } }, + ), + /supported read-only tools \(read, grep, find, ls, web_search, web_fetch\); unsupported: bash/, + ); +}); diff --git a/extensions/agent-team/index.ts b/extensions/agent-team/index.ts index 78f7fe4..d9deb3f 100644 --- a/extensions/agent-team/index.ts +++ b/extensions/agent-team/index.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { access } from "node:fs/promises"; import { homedir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; -import { Type } from "@earendil-works/pi-ai"; +import { StringEnum, Type } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { agentTeamTaskRoot, createPiAgentFactory } from "./pi-runner.ts"; @@ -18,7 +18,8 @@ import { const TOOL_NAME = "agent_team"; const STATUS_KEY = "agent-team"; -const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls", "web_search", "web_fetch"]); +const READ_ONLY_TOOL_NAMES = ["read", "grep", "find", "ls", "web_search", "web_fetch"] as const; +const READ_ONLY_TOOLS = new Set(READ_ONLY_TOOL_NAMES); interface AgentTeamToolParams { action: "start" | "list" | "check" | "answer" | "stop"; @@ -43,10 +44,13 @@ interface AgentTeamToolParams { } function selectTools(requested: string[] | undefined, available: Set): string[] { - if (requested?.some((tool) => !READ_ONLY_TOOLS.has(tool))) { - throw new Error("agent-team only accepts known read-only tools"); + const unsupported = requested?.filter((tool) => !READ_ONLY_TOOLS.has(tool)) ?? []; + if (unsupported.length > 0) { + throw new Error( + `agent-team only accepts supported read-only tools (${READ_ONLY_TOOL_NAMES.join(", ")}); unsupported: ${unsupported.join(", ")}`, + ); } - return (requested ?? [...READ_ONLY_TOOLS]).filter((tool) => available.has(tool)); + return (requested ?? [...READ_ONLY_TOOL_NAMES]).filter((tool) => available.has(tool)); } async function resolveSkill(spec: string, cwd: string): Promise { @@ -198,8 +202,9 @@ export default function agentTeamExtension(pi: ExtensionAPI): void { answer: Type.Optional(Type.String({ description: "User direction for a waiting team" })), thinking: Type.Optional(Type.String({ description: "Thinking level for child Pi agents" })), tools: Type.Optional( - Type.Array(Type.String(), { - description: "Read-only tools available to team members", + Type.Array(StringEnum(READ_ONLY_TOOL_NAMES), { + description: + "Optional allowlist of supported read-only tools for team members. Omit to use every supported read-only tool that is currently available.", }), ), turnTimeoutMs: Type.Optional( diff --git a/extensions/astrolabe/README.md b/extensions/astrolabe/README.md index 3290a75..67f232f 100644 --- a/extensions/astrolabe/README.md +++ b/extensions/astrolabe/README.md @@ -11,7 +11,7 @@ Astrolabeがコードで強制するのは、continuationの有効性、対象 - `locate`は編集意図に含まれる`symbols`または`terms`から宣言ノードを順位付けします。Tree-sitterの構造・本文signalと、利用可能なLSPの`workspace/symbol`を独立したcandidate generatorとして並行して使い、同じconcrete syntax nodeを支持するevidenceは加算してconfidenceを上げます。LSPが利用できなければstructural/textual signalだけで同じ処理を続行します。完全一致symbolで明確に首位かつ本文が6,000 bytes以下なら`mode: "source"`として本文も返し、それ以外は`mode: "cards"`としてシグネチャ、親宣言、flow、範囲、continuationを返します。 - `search`は構文形状による補助探索です。関数・呼出し・importを検索し、`locate`で対象を特定できない調査に使います。 - `inspect`は`path`でoutlineを取得するか、continuationで選んだ構文ノードのsourceを取得します。 -- `inspect_many`は同一ファイルの複数continuationを一度にsourceまで取得し、`replace_many`用のテンプレートを返します。 +- `inspect_many`は複数continuationをファイルをまたいで並列にsourceまで取得します。対象がすべて同一ファイルなら`replace_many`用のテンプレートも返し、複数ファイルにまたがる場合は読み取り結果だけを返します。 - `replace`は有効なcontinuationと完全な`replacement`を受け、現在のsource hash、ノード型・範囲・親文脈を再検証してから保存します。 - `replace_many`は同一ファイル内の複数continuationを全件検証し、置換後の構文検査に成功した場合だけatomicに保存します。 - `rename`は宣言continuationと`newName`を受け、LSPの`textDocument/rename`に意味論的なWorkspaceEditを生成させます。AstrolabeはWorkspaceEditを即適用せず、対象ファイルのstaleness、範囲重複、対応言語、置換後の構文を検証してからcommitします。 @@ -74,13 +74,13 @@ WorkspaceEditは既存のAstrolabe対応ソースへのtext editだけを受理 - `locate(mode: "source")` → 通常はそのまま`replace`。同じnodeを再度`inspect`しません。 - `locate(mode: "cards")` → cardだけでreplacementが決まるなら直接`replace`。本文が必要なら選んだcardだけ`inspect`します。 -- 同一ファイルの複数cardで本文が必要 → `inspect_many` → `replace_many`。 +- 複数cardで本文が必要 → `inspect_many`。同一ファイルなら`replace_many`を次手として返し、複数ファイルなら読み取りだけをbatchします。 - シンボル自体のrename → `locate` → `rename`。referencesを手作業で`replace_many`しません。 -- `locate`で対象を絞れない → `search`またはoutline `inspect`へ広げます。 +- `locate`で対象を絞れない → 関数・呼出し・importなら`search`、より広い構造確認ならoutline `inspect`へ広げます。任意の文字列検索は通常のtext retrievalを使います。 ## ハンドルと位置 -continuationは短命かつセッション限定です。ハンドルはsource hash、ノード型、親宣言、祖先型、field、前後兄弟、周辺sourceを保持し、ファイル変更後も同一ノードを一意に再同定できる場合だけ通常のnode replacementを継続します。曖昧なら`stale_node`で拒否します。 +continuationはセッション限定です。内部のhandle cacheからLRU evictionされてもcontinuationが保持するsnapshotから再活性化するため、cache pressureだけでは失効しません。ハンドルはsource hash、ノード型、親宣言、祖先型、field、前後兄弟、周辺sourceを保持し、ファイル変更後も同一ノードを一意に再同定できる場合だけ通常のnode replacementを継続します。曖昧なら`stale_node`で拒否します。明示的に無効化されたcontinuationや終了済みセッションのcontinuationは`invalid_continuation`です。 `web-tree-sitter`の公開インデックスと`Point`はこのバインディングのUTF-16 JavaScript文字列位置として扱います。ハンドルには別途UTF-8バイト範囲も保存します。サロゲートペアやUTF-8コードポイントの途中は位置として受け付けません。 @@ -108,7 +108,7 @@ Astrolabeのスコープは編集です。LSPやTree-sitterによる探索は、 ## 状態コード - `stale_node`: continuationの対象を現在のファイルから一意に再同定できません。 -- `invalid_continuation`: continuationが失効したか変更されています。 +- `invalid_continuation`: continuationが明示的に無効化されたか、現在のセッションに存在しません。通常のhandle cache evictionだけでは発生しません。 - `lsp_unavailable`: 対応language serverを起動できません。 - `rename_unavailable`: language serverまたは対象位置がrenameを受け付けません。 - `stale_workspace_edit`: LSPがWorkspaceEditを生成した後に対象ファイルが変化しました。semantic operationを再実行します。 diff --git a/extensions/astrolabe/index.test.ts b/extensions/astrolabe/index.test.ts index 2037ecf..cda7e94 100644 --- a/extensions/astrolabe/index.test.ts +++ b/extensions/astrolabe/index.test.ts @@ -29,10 +29,16 @@ interface SyntaxResponse { flow: { calls: string[]; branches: number; returns: number; throws: number; awaits: number }; score: number; }>; + sources?: Array<{ + continuation: { token: string }; + path: string; + type: string; + source: string; + }>; }; handles?: Array<{ continuation: { token: string }; capabilities: string[] }>; next?: Array>; - error?: { code: string }; + error?: { code: string; message?: string }; } interface ToolResult { @@ -115,6 +121,39 @@ test("inspect path returns an executable next action and continuation source loo assert.equal(sourcedResponse.next?.[0]?.action, "replace"); }); +test("inspect_many reads selected continuations across files without proposing cross-file mutation", async () => { + const dir = await mkdtemp(join(tmpdir(), "astrolabe-index-")); + await writeFile(join(dir, "first.ts"), "function first() { return 1; }\n"); + await writeFile(join(dir, "second.ts"), "function second() { return 2; }\n"); + const tool = setup(dir); + + const firstOutline = responseOf( + await call(tool, dir, { action: "inspect", path: "first.ts", detail: "outline" }), + ); + const secondOutline = responseOf( + await call(tool, dir, { action: "inspect", path: "second.ts", detail: "outline" }), + ); + const first = firstOutline.next?.[0] as { continuation?: { token: string } } | undefined; + const second = secondOutline.next?.[0] as { continuation?: { token: string } } | undefined; + assert.ok(first?.continuation); + assert.ok(second?.continuation); + + const inspected = responseOf( + await call(tool, dir, { + action: "inspect_many", + targets: [ + { continuation: first.continuation }, + { continuation: second.continuation }, + ], + }), + ); + assert.equal(inspected.ok, true); + assert.equal(inspected.data?.sources?.length, 2); + assert.match(inspected.data?.sources?.[0]?.source ?? "", /function first/); + assert.match(inspected.data?.sources?.[1]?.source ?? "", /function second/); + assert.equal(inspected.next, undefined); +}); + test("locate returns a ranked source-inspected candidate usable for direct replacement", async () => { const dir = await mkdtemp(join(tmpdir(), "astrolabe-index-")); const path = join(dir, "sample.ts"); @@ -213,6 +252,7 @@ test("locate rejects missing hints and returns no-candidate failures", async () }); assert.equal(noCandidates.ok, false); assert.equal(noCandidates.error?.code, "no_candidates"); + assert.match(noCandidates.error?.message ?? "", /locate resolves declarations/); }); test("directory search returns continuations usable for direct replacement", async () => { diff --git a/extensions/astrolabe/index.ts b/extensions/astrolabe/index.ts index e2313a3..9ae3a85 100644 --- a/extensions/astrolabe/index.ts +++ b/extensions/astrolabe/index.ts @@ -34,7 +34,7 @@ const TOOL_SELECTION_GUIDANCE = `When modifying existing ${supportedLanguageDesc const GUIDANCE = [ `For existing ${supportedLanguageDescription} source, use astrolabe before read or edit. Do not use read/edit for a supported existing source file unless astrolabe reports unsupported, generated, or configuration content.`, - "For an edit intent or known symbol, use locate first. locate fuses Tree-sitter structural/textual evidence with LSP workspace-symbol evidence whenever a configured language server is available; corroborating signals for the same concrete node increase its rank, while unavailable LSP simply leaves structural resolution in place. If locate returns mode=source, use that source and continuation directly with replace; do not inspect the same candidate again. If it returns mode=cards, inspect only when the card does not provide enough context for the intended replacement. When several same-file cards need source context, inspect them together with inspect_many before replace_many. Use search or outline inspection only when locate cannot identify the target.", + "For an edit intent or known symbol, use locate first. locate fuses Tree-sitter structural/textual evidence with LSP workspace-symbol evidence whenever a configured language server is available; corroborating signals for the same concrete node increase its rank, while unavailable LSP simply leaves structural resolution in place. If locate returns mode=source, use that source and continuation directly with replace; do not inspect the same candidate again. If it returns mode=cards, inspect only when the card does not provide enough context for the intended replacement. When several cards need source context, inspect them together with inspect_many; it can batch reads across files and only proposes replace_many when all selected targets share one file. Use search for functions, calls, or imports, and use outline inspection when locate cannot identify the target.", "For a semantic symbol rename, pass the located declaration continuation to rename instead of emulating references with replace_many. The language server proposes the WorkspaceEdit; Astrolabe validates staleness and syntax before committing it.", "Use read or normal edits for unsupported languages, generated/configuration files, new files, or when astrolabe explicitly reports that the target is not applicable.", ]; @@ -42,10 +42,25 @@ const GUIDANCE = [ const actionSchema = Type.Union([ Type.Object({ action: Type.Literal("inspect"), - continuation: Type.Optional(Type.Object({ token: Type.String() })), - path: Type.Optional(Type.String()), + continuation: Type.Optional( + Type.Object({ + token: Type.String({ + description: "Continuation returned by locate, search, or outline inspection", + }), + }), + ), + path: Type.Optional( + Type.String({ + description: + "Existing supported source path for outline inspection. Source inspection requires a continuation instead of a bare path.", + }), + ), language: Type.Optional(StringEnum(supportedLanguageIds)), - detail: Type.Optional(StringEnum(["outline", "source"] as const)), + detail: Type.Optional( + StringEnum(["outline", "source"] as const, { + description: "outline for a path; source for a selected continuation", + }), + ), depth: Type.Optional(Type.Integer({ minimum: 0, maximum: 12 })), }), Type.Object({ @@ -53,6 +68,8 @@ const actionSchema = Type.Union([ targets: Type.Array(Type.Object({ continuation: Type.Object({ token: Type.String() }) }), { minItems: 1, maxItems: 10, + description: + "Selected continuations to inspect concurrently. Targets may span files; replace_many is suggested only when they share one file.", }), }), Type.Object({ @@ -60,7 +77,13 @@ const actionSchema = Type.Union([ scope: Type.String({ description: "Existing supported source file or directory scope" }), symbols: Type.Optional(Type.Array(Type.String(), { maxItems: 10 })), terms: Type.Optional(Type.Array(Type.String(), { maxItems: 10 })), - maxCandidates: Type.Optional(Type.Integer({ minimum: 1, maximum: 5 })), + maxCandidates: Type.Optional( + Type.Integer({ + minimum: 1, + maximum: 5, + description: "Maximum ranked declaration candidates to return (1-5)", + }), + ), }), Type.Object({ action: Type.Literal("search"), @@ -171,7 +194,7 @@ async function dispatch( return failure( "locate", "no_candidates", - "No declarations matched the available structural or semantic signals.", + "No declaration candidates matched. locate resolves declarations; use search for functions, calls, or imports, and use ordinary text retrieval for arbitrary literals.", ); } const includeTopSource = includeSource(matches); @@ -266,53 +289,49 @@ async function dispatch( "Every continuation must be valid and unexpired.", ); } - const firstPath = resolved[0]!.handle!.path; - if (resolved.some((target) => target.handle!.path !== firstPath)) { - return failure( - "inspect_many", - "mixed_paths", - "inspect_many requires all targets to belong to the same file.", - ); - } - const sources: Array<{ - continuation: { token: string }; - path: string; - type: string; - source: string; - }> = []; - for (const target of resolved) { - const handle = target.handle!; - const output = await inspect( - { path: handle.path, nodeId: handle.id, view: "source" }, - cwd, - handles, - ); - if (output.startsWith("stale_node:")) { - return failure("inspect_many", "stale_node", output, [ - { action: "inspect", continuation: target.continuation, detail: "source" }, - ]); - } - sources.push({ - continuation: target.continuation, - path: handle.path, - type: handle.type, - source: output, - }); + const inspected = await Promise.all( + resolved.map(async (target) => { + const handle = target.handle!; + const output = await inspect( + { path: handle.path, nodeId: handle.id, view: "source" }, + cwd, + handles, + ); + return { continuation: target.continuation, handle, output }; + }), + ); + const stale = inspected.find((target) => target.output.startsWith("stale_node:")); + if (stale) { + return failure("inspect_many", "stale_node", stale.output, [ + { action: "inspect", continuation: stale.continuation, detail: "source" }, + ]); } + const sources = inspected.map((target) => ({ + continuation: target.continuation, + path: target.handle.path, + type: target.handle.type, + source: target.output, + })); + const firstPath = sources[0]?.path; + const samePath = Boolean(firstPath && sources.every((source) => source.path === firstPath)); return { ok: true, action: "inspect_many", data: { sources }, - next: [ - { - action: "replace_many", - targets: sources.map((source) => ({ - continuation: source.continuation, - replacement: "", - })), - }, - ], + ...(samePath + ? { + next: [ + { + action: "replace_many" as const, + targets: sources.map((source) => ({ + continuation: source.continuation, + replacement: "", + })), + }, + ], + } + : {}), }; } @@ -346,7 +365,7 @@ async function dispatch( return failure( "inspect", "source_requires_target", - "Request outline first or pass a search continuation.", + "Source inspection requires a selected continuation; request outline first or pass a continuation returned by locate or search.", [outlineRequest(request.path as string, request.language)], ); } diff --git a/extensions/astrolabe/src/node-handles.test.ts b/extensions/astrolabe/src/node-handles.test.ts index ce59e60..f6d125c 100644 --- a/extensions/astrolabe/src/node-handles.test.ts +++ b/extensions/astrolabe/src/node-handles.test.ts @@ -26,7 +26,7 @@ test("bounds handles per file and retains recently used handles", async () => { assert.equal(handles.get(h3.id)?.id, h3.id); }); -test("continuations are opaque, resolve only live handles, and expire with them", async () => { +test("continuations are opaque and expire on explicit deletion", async () => { const file = await parseSource("/tmp/continuation.ts", "function answer() {}\n"); const node = file.tree.rootNode.namedChildren[0]; assert.ok(node); @@ -40,6 +40,44 @@ test("continuations are opaque, resolve only live handles, and expire with them" file.tree.delete(); }); +test("continuations survive LRU eviction and reactivate their handles", async () => { + const file = await parseSource( + "/tmp/continuation-lru.ts", + "function first() {}\nfunction second() {}\n", + ); + const first = file.tree.rootNode.namedChildren[0]; + const second = file.tree.rootNode.namedChildren[1]; + assert.ok(first); + assert.ok(second); + const handles = new HandleStore(1); + const firstHandle = handles.issue(file, first); + const token = handles.issueContinuation(firstHandle.id); + assert.ok(token); + const secondHandle = handles.issue(file, second); + assert.equal(handles.get(firstHandle.id), undefined); + assert.equal(handles.resolveContinuation(token)?.id, firstHandle.id); + assert.equal(handles.size(file.path), 1); + assert.equal(handles.get(secondHandle.id), undefined); +}); + +test("clearing a path invalidates continuations even after their handles were evicted", async () => { + const file = await parseSource( + "/tmp/continuation-clear.ts", + "function first() {}\nfunction second() {}\n", + ); + const first = file.tree.rootNode.namedChildren[0]; + const second = file.tree.rootNode.namedChildren[1]; + assert.ok(first); + assert.ok(second); + const handles = new HandleStore(1); + const firstHandle = handles.issue(file, first); + const token = handles.issueContinuation(firstHandle.id); + assert.ok(token); + handles.issue(file, second); + handles.clear(file.path); + assert.equal(handles.resolveContinuation(token), undefined); +}); + test("LRU eviction follows access order rather than issue order", async () => { const file = await parseSource( "/tmp/handles-lru.ts", diff --git a/extensions/astrolabe/src/node-handles.ts b/extensions/astrolabe/src/node-handles.ts index a6f28a6..76d3124 100644 --- a/extensions/astrolabe/src/node-handles.ts +++ b/extensions/astrolabe/src/node-handles.ts @@ -93,7 +93,7 @@ export class HandleStore { private next = 1; private readonly handles = new Map(); private readonly handlesByPath = new Map>(); - private readonly continuations = new Map(); + private readonly continuations = new Map(); private readonly continuationByHandle = new Map>(); private readonly maxHandlesPerFile: number; @@ -172,9 +172,10 @@ export class HandleStore { } issueContinuation(id: string): string | undefined { - if (!this.get(id)) return undefined; + const handle = this.get(id); + if (!handle) return undefined; const token = randomUUID(); - this.continuations.set(token, id); + this.continuations.set(token, handle); const tokens = this.continuationByHandle.get(id) ?? new Set(); tokens.add(token); this.continuationByHandle.set(id, tokens); @@ -182,8 +183,17 @@ export class HandleStore { } resolveContinuation(token: string): NodeHandle | undefined { - const id = this.continuations.get(token); - return id ? this.get(id) : undefined; + const snapshot = this.continuations.get(token); + if (!snapshot) return undefined; + const active = this.get(snapshot.id); + if (active) return active; + + this.handles.set(snapshot.id, snapshot); + const fileHandles = this.handlesByPath.get(snapshot.path) ?? new Set(); + fileHandles.add(snapshot.id); + this.handlesByPath.set(snapshot.path, fileHandles); + this.evictOldest(snapshot.path, fileHandles); + return snapshot; } private deleteContinuations(id: string): void { @@ -191,6 +201,16 @@ export class HandleStore { this.continuationByHandle.delete(id); } + private deleteContinuationsForPath(path: string): void { + for (const [token, handle] of this.continuations) { + if (handle.path !== path) continue; + this.continuations.delete(token); + const tokens = this.continuationByHandle.get(handle.id); + tokens?.delete(token); + if (tokens?.size === 0) this.continuationByHandle.delete(handle.id); + } + } + size(path?: string): number { if (path) return this.handlesByPath.get(path)?.size ?? 0; return this.handles.size; @@ -204,12 +224,17 @@ export class HandleStore { delete(id: string): boolean { const handle = this.handles.get(id); - if (!handle) return false; + const continuationToken = this.continuationByHandle.get(id)?.values().next().value; + const continuationHandle = continuationToken + ? this.continuations.get(continuationToken) + : undefined; + if (!handle && !continuationHandle) return false; this.handles.delete(id); this.deleteContinuations(id); - const fileHandles = this.handlesByPath.get(handle.path); + const path = handle?.path ?? continuationHandle!.path; + const fileHandles = this.handlesByPath.get(path); fileHandles?.delete(id); - if (fileHandles?.size === 0) this.handlesByPath.delete(handle.path); + if (fileHandles?.size === 0) this.handlesByPath.delete(path); return true; } @@ -220,6 +245,7 @@ export class HandleStore { this.deleteContinuations(id); } this.handlesByPath.delete(path); + this.deleteContinuationsForPath(path); return; } this.handles.clear(); @@ -254,7 +280,6 @@ export class HandleStore { if (!oldest) break; fileHandles.delete(oldest); this.handles.delete(oldest); - this.deleteContinuations(oldest); } if (fileHandles.size === 0) this.handlesByPath.delete(path); } diff --git a/extensions/background-process/package.json b/extensions/background-process/package.json index e22e546..e674452 100644 --- a/extensions/background-process/package.json +++ b/extensions/background-process/package.json @@ -6,7 +6,7 @@ ".": "./core.ts" }, "scripts": { - "check": "bun run typecheck && bun test", + "check": "bun run typecheck && bun run test", "typecheck": "tsc --noEmit", "test": "node --test", "dev": "pi -e ./index.ts", diff --git a/extensions/plan/tsconfig.json b/extensions/plan/tsconfig.json index 21f3880..ef8c693 100644 --- a/extensions/plan/tsconfig.json +++ b/extensions/plan/tsconfig.json @@ -1 +1,7 @@ -{ "extends": "../tsconfig.json", "include": ["*.ts"] } +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": [] + }, + "include": ["*.ts"] +} diff --git a/package.json b/package.json index 79c288c..5b28286 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ ], "scripts": { "hooks:install": "git config core.hooksPath .githooks", + "check": "bun run lint && bun run --sequential --workspaces --if-present check", "format": "oxfmt .", + "format:check": "oxfmt --check .", "lint": "oxlint ." }, "dependencies": { @@ -23,6 +25,7 @@ "bundledDependencies": [ "pi-intercom" ], + "packageManager": "bun@1.3.14", "catalog": { "@earendil-works/pi-agent-core": "^0.84.1", "@earendil-works/pi-ai": "^0.84.1", diff --git a/skills/delegate-task/SKILL.md b/skills/delegate-task/SKILL.md index 6396f20..d07978d 100644 --- a/skills/delegate-task/SKILL.md +++ b/skills/delegate-task/SKILL.md @@ -22,6 +22,8 @@ Keep the work in the parent when it is a trivial one-step edit, the contract or Before starting more than one mutating worker in a shared workspace, declare non-overlapping file ownership and verify it. Otherwise use one mutating worker at a time. Worktree automation is not assumed. +When concurrent writers share a workspace, an observed filesystem change is evidence that the shared workspace changed, not evidence of which worker caused it. Do not stop a worker merely because its session reports a changed file outside its ownership while another writer is active. Establish provenance from an isolated diff/commit or an attributable worker action; if the available tooling cannot attribute writes, serialize or isolate the writers before enforcing ownership. + ## Workflow 1. **Resolve the worker contract.** Write down the objective, relevant context, allowed paths, forbidden paths, acceptance criteria, verification commands, workspace/Git rules, and the expected report. Do not delegate an ambiguous design decision; keep that decision with the parent. diff --git a/skills/delegate-task/evals/evals.json b/skills/delegate-task/evals/evals.json index a53dc06..95b2adf 100644 --- a/skills/delegate-task/evals/evals.json +++ b/skills/delegate-task/evals/evals.json @@ -61,6 +61,17 @@ "The response considers delegation based on workstream independence, even though the prompt does not mention subagents.", "The response keeps authentication decisions and final verification with the parent." ] + }, + { + "id": 7, + "prompt": "親と子Piを同じworktreeで並列実行中。子は extensions/agent-team/ だけ担当している。親が extensions/background-process/index.ts を編集した直後、子のterminalに `Local resources changed: extensions/background-process/index.ts` と出た。担当外変更なので子を止めるべき?", + "expected_output": "The agent does not infer write provenance from a shared-workspace change notification. It keeps the worker running unless the write can actually be attributed to it, and serializes or isolates writers when attribution is required but unavailable.", + "assertions": [ + "The response distinguishes observation of a shared filesystem change from evidence that the child caused the change.", + "The response does not stop or blame the child solely because its session reported the changed file.", + "The response requires attributable evidence such as an isolated diff/commit or worker action before declaring an ownership violation.", + "The response recommends serialization or workspace isolation when concurrent write provenance cannot be established reliably." + ] } ] }