From 699fa78b70962bbd8c76e93df7318d1751a90a02 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Fri, 24 Apr 2026 15:50:57 +0900 Subject: [PATCH 01/34] =?UTF-8?q?docs:=20Atlassian=20MCP=20C=20=E3=83=95?= =?UTF-8?q?=E3=82=A7=E3=83=BC=E3=82=BA=20implementation=20plan=20=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit office-hours + plan-eng-review で固めた設計を元に、TDD-style で 19 task に分割。 buildMcpServers utility の抽出、chat-runner / agent-runner の refactor、 duplicate-guards/ の strategy-pattern 化、T1/T4 fix を含む。A フェーズ (専用エージェント + ボタン UI) は C dogfood 結果を踏まえて別 plan で書く。 Related docs: - ~/.gstack/projects/ignission-tally/knowbe01-main-design-20260423-164810.md - ~/.gstack/projects/ignission-tally/knowbe01-main-eng-review-test-plan-20260423-212143.md --- .../plans/2026-04-24-atlassian-mcp-c-phase.md | 2419 +++++++++++++++++ 1 file changed, 2419 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase.md diff --git a/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase.md b/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase.md new file mode 100644 index 0000000..020fc30 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase.md @@ -0,0 +1,2419 @@ +# Atlassian MCP 連携 — C フェーズ Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Tally の Chat で Atlassian MCP (Jira) を multi-turn 対話で使える完成形 UX を作る。プロジェクト設定から mcpServers[] を登録 → Chat で「@JIRA EPIC-X を読んで論点を出して」→ AI が外部 MCP 経由で Jira 読み → question proposal 生成 → 採用、までが動く。 + +**Architecture:** `chat-runner.ts` と `agent-runner.ts` の `mcpServers: { tally }` ハードコードを `buildMcpServers` utility に抽出し、プロジェクト設定の `mcpServers[]` から外部 MCP (Atlassian HTTP MCP) を動的合成する。ChatBlockSchema に `source: 'internal' | 'external'` を追加し、外部 MCP の tool_use/tool_result を承認なしで永続化。buildChatPrompt を拡張して tool_use/tool_result を replay し、multi-turn で AI が前ターンの Jira 内容を覚える。create-node の重複ガードを strategy-pattern に抽出し、sourceUrl ベースの guard を追加 (chat で anchorId 空でも動く)。 + +**Tech Stack:** TypeScript, pnpm workspaces, Next.js 15 App Router, React Flow, Zustand, Claude Agent SDK (`@anthropic-ai/claude-agent-sdk@0.2.117`), Vitest, Biome. 既存 Tally のパッケージ構成 (`@tally/core`, `@tally/storage`, `@tally/ai-engine`, `@tally/frontend`) に従う。 + +**Related docs:** +- Design doc: `~/.gstack/projects/ignission-tally/knowbe01-main-design-20260423-164810.md` +- Test plan: `~/.gstack/projects/ignission-tally/knowbe01-main-eng-review-test-plan-20260423-212143.md` +- CLAUDE.md / `.claude/rules/testing.md` / `.claude/rules/packages-architecture.md` + +--- + +## Prerequisite: Step 0 Spikes (C 着手前、30-35 分、手動) + +これらは実装でなく調査。結果を design doc 末尾に脚注として追記してから Task 1 を開始する。 + +- [ ] **Spike 0a (30 分): Atlassian MCP 実装選定** + - `sooperset/mcp-atlassian` (OSS、PAT 対応、HTTP transport) を第一候補として起動確認 + - 公式 Atlassian Remote MCP が利用可能なら比較検討、PAT 認証が使えることが必須 (Premise 9) + - 選定結果と tool 一覧 (例: `atlassian_getJiraIssue`, `atlassian_searchJiraIssues` 等) を `~/.gstack/projects/ignission-tally/knowbe01-main-design-20260423-164810.md` 末尾に `## Atlassian MCP Implementation Footnote` として追記 + - tool 名 prefix (例: `mcp__atlassian__`) を記録 → Task 3 / Task 9 で使用 + +- [ ] **Spike 0b (5 分): allowedTools wildcard 動作検証** + - 最小 spike スクリプト `/tmp/spike-allowed-tools.mjs` を書く: + ```javascript + // spike-allowed-tools.mjs + import { query } from '@anthropic-ai/claude-agent-sdk'; + // 外部 MCP は spike 時点では mock、allowedTools: ['mcp__atlassian__*'] で + // SDK が permission エラーを出さないか確認するのみ + ``` + - または既存 `pnpm -F @tally/ai-engine exec tsx` で SDK の `allowedTools: ['mcp__atlassian__*']` が warning/error 出さずに受理されるか確認 + - wildcard 受理 → Task 9 で `['mcp__tally__*', 'mcp__atlassian__*']` パターンを採用 + - 拒否 → Task 9 は Spike 0a で取得した tool 名を `['mcp__tally__*', 'mcp__atlassian__atlassian_getJiraIssue', ...]` と静的列挙 + - 結果を design doc 末尾の Footnote に追記 + +--- + +## File Structure (C フェーズで触る範囲) + +**新規作成:** +- `packages/ai-engine/src/mcp/build-mcp-servers.ts` — プロジェクト設定から SDK の mcpServers を組み立てる +- `packages/ai-engine/src/mcp/build-mcp-servers.test.ts` — 上記のテスト +- `packages/ai-engine/src/mcp/redact.ts` — Authorization header を含むログの redaction utility +- `packages/ai-engine/src/mcp/redact.test.ts` +- `packages/ai-engine/src/duplicate-guards/index.ts` — guard interface + strategy map +- `packages/ai-engine/src/duplicate-guards/coderef.ts` — 既存 coderef 重複ガードを分離 +- `packages/ai-engine/src/duplicate-guards/question.ts` — 既存 question 重複ガードを分離 +- `packages/ai-engine/src/duplicate-guards/source-url.ts` — T1 fix、sourceUrl ベースの新規 guard +- `packages/ai-engine/src/duplicate-guards/*.test.ts` — 各 guard のテスト + +**修正:** +- `packages/core/src/schema.ts` — McpServerConfigSchema / ProjectSchema.mcpServers / ChatBlockSchema.tool_use.source / RequirementNodeSchema.sourceUrl +- `packages/core/src/types.ts` — 対応する型 export +- `packages/core/src/schema.test.ts` — 上記の round-trip / migration テスト +- `packages/ai-engine/src/chat-runner.ts` — buildMcpServers 呼び出し / extractAssistantBlocks 拡張 / buildChatPrompt 拡張 / tool_result truncate +- `packages/ai-engine/src/chat-runner.test.ts` — 対応テスト +- `packages/ai-engine/src/agent-runner.ts` — buildMcpServers 共有 +- `packages/ai-engine/src/agent-runner.test.ts` — regression snapshot +- `packages/ai-engine/src/tools/create-node.ts` — duplicate-guards に委譲 +- `packages/ai-engine/src/tools/create-node.test.ts` +- `packages/frontend/src/app/api/projects/[id]/route.ts` — mcpServers round-trip +- `packages/frontend/src/lib/api.ts` — mcpServers API +- `packages/frontend/src/components/dialog/project-settings-dialog.tsx` — mcpServers CRUD UI +- `packages/frontend/src/components/chat/tool-approval-card.tsx` — source 分岐 +- `packages/frontend/src/components/chat/chat-tab.tsx` — external source の折り畳み表示 + +--- + +## Task 1: core schema 拡張 (McpServerConfigSchema) + +**Files:** +- Modify: `packages/core/src/schema.ts` +- Modify: `packages/core/src/types.ts` +- Test: `packages/core/src/schema.test.ts` + +- [ ] **Step 1-1: failing test を書く — `McpServerConfigSchema` の round-trip** + +`packages/core/src/schema.test.ts` に追記: + +```typescript +import { McpServerConfigSchema } from './schema'; + +describe('McpServerConfigSchema', () => { + it('atlassian kind + PAT auth の round-trip が通る', () => { + const raw = { + id: 'atlassian-main', + name: 'Atlassian Cloud', + kind: 'atlassian' as const, + url: 'https://mcp.atlassian.example/mcp', + auth: { type: 'pat' as const, envVar: 'ATLASSIAN_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }; + const parsed = McpServerConfigSchema.parse(raw); + expect(parsed).toEqual(raw); + }); + + it('options 未指定なら default が入る', () => { + const parsed = McpServerConfigSchema.parse({ + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', envVar: 'X_PAT' }, + }); + expect(parsed.options.maxChildIssues).toBe(30); + expect(parsed.options.maxCommentsPerIssue).toBe(5); + }); + + it('url が URL でないと fail', () => { + expect(() => + McpServerConfigSchema.parse({ + id: 'a', name: 'A', kind: 'atlassian', url: 'not a url', + auth: { type: 'pat', envVar: 'X' }, + }), + ).toThrow(); + }); +}); +``` + +- [ ] **Step 1-2: test を走らせて fail を確認** + +Run: `pnpm -F @tally/core test -- schema.test` +Expected: FAIL with "McpServerConfigSchema is not exported" + +- [ ] **Step 1-3: `McpServerConfigSchema` を `packages/core/src/schema.ts` に追加** + +既存 `ProjectSchema` の直前に追加: + +```typescript +export const McpServerConfigSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + kind: z.literal('atlassian'), + url: z.string().url(), + auth: z.object({ + type: z.literal('pat'), + envVar: z.string().min(1), + }), + options: z + .object({ + maxChildIssues: z.number().int().positive().default(30), + maxCommentsPerIssue: z.number().int().nonnegative().default(5), + }) + .default({}), +}); + +export type McpServerConfig = z.infer; +``` + +`packages/core/src/types.ts` にも: + +```typescript +export type { McpServerConfig } from './schema'; +``` + +- [ ] **Step 1-4: test を走らせて pass を確認** + +Run: `pnpm -F @tally/core test -- schema.test` +Expected: PASS (McpServerConfigSchema の 3 case) + +- [ ] **Step 1-5: commit** + +```bash +git add packages/core/src/schema.ts packages/core/src/schema.test.ts packages/core/src/types.ts +git commit -m "feat(core): McpServerConfigSchema を追加 (Atlassian MCP 連携の基盤)" +``` + +--- + +## Task 2: core schema 拡張 (ProjectSchema.mcpServers) + +**Files:** +- Modify: `packages/core/src/schema.ts` +- Test: `packages/core/src/schema.test.ts` + +- [ ] **Step 2-1: failing test — ProjectSchema に mcpServers[] が含まれる** + +`packages/core/src/schema.test.ts` に追記: + +```typescript +describe('ProjectSchema.mcpServers', () => { + it('mcpServers 未指定なら default の空配列', () => { + const p = ProjectSchema.parse({ + id: 'p', name: 'P', codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + }); + expect(p.mcpServers).toEqual([]); + }); + + it('mcpServers 指定で round-trip する', () => { + const input = { + id: 'p', name: 'P', codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + mcpServers: [ + { + id: 'a', name: 'A', kind: 'atlassian' as const, + url: 'https://x.test/mcp', + auth: { type: 'pat' as const, envVar: 'X' }, + }, + ], + }; + const p = ProjectSchema.parse(input); + expect(p.mcpServers).toHaveLength(1); + expect(p.mcpServers[0].options.maxChildIssues).toBe(30); + }); +}); +``` + +- [ ] **Step 2-2: test fail を確認** + +Run: `pnpm -F @tally/core test -- schema.test` +Expected: FAIL with "Property 'mcpServers' ..." + +- [ ] **Step 2-3: ProjectSchema に mcpServers を追加** + +`packages/core/src/schema.ts` の `ProjectSchema` 定義に: + +```typescript +export const ProjectSchema = z.object({ + // 既存フィールド ... + mcpServers: z.array(McpServerConfigSchema).default([]), +}); +``` + +`ProjectMetaSchema` にも同じ `mcpServers` フィールドを追加 (project.yaml の meta と本体で整合)。既存の Project 型と ProjectMeta 型が何を含むかは既存コードに合わせる。 + +- [ ] **Step 2-4: test pass を確認 + storage の既存 round-trip テストも通る確認** + +Run: `pnpm -F @tally/core test && pnpm -F @tally/storage test` +Expected: PASS 全件。既存 YAML (mcpServers 無し) が optional default で [] として読めること。 + +- [ ] **Step 2-5: commit** + +```bash +git add packages/core/src/schema.ts packages/core/src/schema.test.ts +git commit -m "feat(core): ProjectSchema に mcpServers[] を追加 (default [])" +``` + +--- + +## Task 3: core schema 拡張 (ChatBlockSchema.source / RequirementNodeSchema.sourceUrl) + +**Files:** +- Modify: `packages/core/src/schema.ts` +- Test: `packages/core/src/schema.test.ts` + +- [ ] **Step 3-1: failing test — ChatBlock.tool_use に source が入り、古い YAML (source 無し) が 'internal' として読める** + +```typescript +describe('ChatBlockSchema.tool_use.source', () => { + it('source 未指定の古いデータが "internal" に defaults', () => { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-1', + name: 'mcp__tally__create_node', + input: { x: 1 }, + approval: 'approved', + }); + expect(b.type).toBe('tool_use'); + if (b.type === 'tool_use') expect(b.source).toBe('internal'); + }); + + it('source = "external" は承認不要扱い (approval optional)', () => { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-2', + name: 'mcp__atlassian__getJiraIssue', + input: { issueKey: 'EPIC-1' }, + source: 'external', + }); + if (b.type === 'tool_use') { + expect(b.source).toBe('external'); + expect(b.approval).toBeUndefined(); + } + }); + + it('source = "internal" で approval 無しは fail', () => { + expect(() => + ChatBlockSchema.parse({ + type: 'tool_use', toolUseId: 'tu-3', + name: 'mcp__tally__create_node', input: {}, + source: 'internal', + }), + ).toThrow(); + }); +}); + +describe('RequirementNodeSchema.sourceUrl', () => { + it('sourceUrl 未指定は optional (既存互換)', () => { + const n = RequirementNodeSchema.parse({ + id: 'n', type: 'requirement', x: 0, y: 0, + title: 'R', body: '', + }); + expect(n.sourceUrl).toBeUndefined(); + }); + + it('sourceUrl 指定で保持', () => { + const n = RequirementNodeSchema.parse({ + id: 'n', type: 'requirement', x: 0, y: 0, + title: 'R', body: '', + sourceUrl: 'https://jira.test/browse/EPIC-1', + }); + expect(n.sourceUrl).toBe('https://jira.test/browse/EPIC-1'); + }); +}); +``` + +- [ ] **Step 3-2: test fail を確認** + +Run: `pnpm -F @tally/core test -- schema.test` + +- [ ] **Step 3-3: schema.ts を修正** + +ChatBlockSchema の tool_use 枝を書き換え: + +```typescript +z.object({ + type: z.literal('tool_use'), + toolUseId: z.string().min(1), + name: z.string().min(1), + input: z.unknown(), + source: z.enum(['internal', 'external']).default('internal'), + approval: z.enum(['pending', 'approved', 'rejected']).optional(), +}).refine( + (b) => b.source === 'external' || b.approval !== undefined, + { message: 'internal tool_use には approval が必要' }, +), +``` + +RequirementNodeSchema に: + +```typescript +// 既存 フィールドに追加: +sourceUrl: z.string().url().optional(), +``` + +- [ ] **Step 3-4: test pass + 既存 agent-runner / chat-runner テストが退行なしで通る** + +Run: `pnpm -F @tally/core test && pnpm -F @tally/ai-engine test && pnpm -F @tally/storage test` + +- [ ] **Step 3-5: commit** + +```bash +git add packages/core/src/schema.ts packages/core/src/schema.test.ts +git commit -m "feat(core): ChatBlock.tool_use に source を追加、Requirement に sourceUrl を追加" +``` + +--- + +## Task 4: redact-logs utility + +**Files:** +- Create: `packages/ai-engine/src/mcp/redact.ts` +- Create: `packages/ai-engine/src/mcp/redact.test.ts` + +- [ ] **Step 4-1: failing test** + +```typescript +// packages/ai-engine/src/mcp/redact.test.ts +import { describe, expect, it } from 'vitest'; +import { redactMcpSecrets } from './redact'; + +describe('redactMcpSecrets', () => { + it('Authorization header を "***" に置換', () => { + const input = { + mcpServers: { + atlassian: { + type: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Bearer abc-123' }, + }, + }, + }; + const out = redactMcpSecrets(input); + expect((out as any).mcpServers.atlassian.headers.Authorization).toBe('***'); + // 元オブジェクトは破壊しない + expect(input.mcpServers.atlassian.headers.Authorization).toBe('Bearer abc-123'); + }); + + it('他フィールドは保持', () => { + const out = redactMcpSecrets({ + mcpServers: { + atlassian: { type: 'http', url: 'https://x.test/mcp', headers: { 'X-Other': 'keep' } }, + }, + }); + expect((out as any).mcpServers.atlassian.url).toBe('https://x.test/mcp'); + expect((out as any).mcpServers.atlassian.headers['X-Other']).toBe('keep'); + }); + + it('mcpServers が無ければそのまま', () => { + const input = { foo: 'bar' }; + expect(redactMcpSecrets(input)).toEqual(input); + }); +}); +``` + +- [ ] **Step 4-2: test fail を確認** + +Run: `pnpm -F @tally/ai-engine test -- redact.test` +Expected: FAIL (モジュール未定義) + +- [ ] **Step 4-3: 実装** + +```typescript +// packages/ai-engine/src/mcp/redact.ts + +// SDK に渡す mcpServers 設定 (特に Authorization ヘッダ) をログに出す前の +// 安全な形に変換する。プロセスメモリには PAT が残るが、ログ出力経路では ***。 +export function redactMcpSecrets(value: unknown): unknown { + if (!value || typeof value !== 'object') return value; + const obj = value as Record; + if (!obj.mcpServers || typeof obj.mcpServers !== 'object') return value; + + const servers = obj.mcpServers as Record; + const redactedServers: Record = {}; + for (const [name, cfg] of Object.entries(servers)) { + if (cfg && typeof cfg === 'object' && 'headers' in cfg) { + const src = cfg as { headers?: Record }; + const headers = src.headers; + if (headers && typeof headers === 'object' && 'Authorization' in headers) { + redactedServers[name] = { + ...src, + headers: { ...headers, Authorization: '***' }, + }; + continue; + } + } + redactedServers[name] = cfg; + } + return { ...obj, mcpServers: redactedServers }; +} +``` + +- [ ] **Step 4-4: test pass** + +Run: `pnpm -F @tally/ai-engine test -- redact.test` + +- [ ] **Step 4-5: commit** + +```bash +git add packages/ai-engine/src/mcp/ +git commit -m "feat(ai-engine): redactMcpSecrets を追加 (Authorization header のログ漏洩予防)" +``` + +--- + +## Task 5: buildMcpServers utility + +**Files:** +- Create: `packages/ai-engine/src/mcp/build-mcp-servers.ts` +- Create: `packages/ai-engine/src/mcp/build-mcp-servers.test.ts` + +- [ ] **Step 5-1: failing test** + +```typescript +// packages/ai-engine/src/mcp/build-mcp-servers.test.ts +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { buildMcpServers } from './build-mcp-servers'; + +describe('buildMcpServers', () => { + const ORIGINAL_ENV = { ...process.env }; + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('mcpServers 空配列 → external 無し、allowedTools は tally のみ', () => { + const result = buildMcpServers({ tallyMcp: { type: 'sdk' } as any, configs: [] }); + expect(Object.keys(result.mcpServers)).toEqual(['tally']); + expect(result.allowedTools).toEqual(['mcp__tally__*']); + }); + + it('atlassian 1 個 + env 設定済み → HTTP config + allowedTools 合成', () => { + process.env.ATLASSIAN_PAT = 'secret-xyz'; + const result = buildMcpServers({ + tallyMcp: { type: 'sdk' } as any, + configs: [ + { + id: 'atlassian-main', name: 'A', kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', envVar: 'ATLASSIAN_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }); + const atlassian = result.mcpServers['atlassian-main'] as any; + expect(atlassian.type).toBe('http'); + expect(atlassian.url).toBe('https://x.test/mcp'); + expect(atlassian.headers.Authorization).toBe('Bearer secret-xyz'); + expect(result.allowedTools).toContain('mcp__tally__*'); + expect(result.allowedTools).toContain('mcp__atlassian-main__*'); + }); + + it('env 未設定 → throw', () => { + delete process.env.ATLASSIAN_PAT; + expect(() => + buildMcpServers({ + tallyMcp: { type: 'sdk' } as any, + configs: [ + { + id: 'a', name: 'A', kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', envVar: 'ATLASSIAN_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + ).toThrowError(/ATLASSIAN_PAT/); + }); + + it('env が空文字 → throw', () => { + process.env.ATLASSIAN_PAT = ''; + expect(() => + buildMcpServers({ + tallyMcp: { type: 'sdk' } as any, + configs: [ + { + id: 'a', name: 'A', kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', envVar: 'ATLASSIAN_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + ).toThrowError(/ATLASSIAN_PAT/); + }); +}); +``` + +- [ ] **Step 5-2: test fail を確認** + +Run: `pnpm -F @tally/ai-engine test -- build-mcp-servers.test` +Expected: FAIL (未実装) + +- [ ] **Step 5-3: 実装 — wildcard 版 (Spike 0b で wildcard OK だった場合)** + +```typescript +// packages/ai-engine/src/mcp/build-mcp-servers.ts +import type { McpServerConfig } from '@tally/core'; + +// SDK の型に縛らず、chat-runner / agent-runner が共通で使える shape にする。 +// SDK の mcpServers は Record を受ける (sdk.d.ts:1386 参照)。 +export interface BuildMcpServersInput { + // createSdkMcpServer で組み立てた Tally MCP。ここでは opaque。 + tallyMcp: unknown; + // プロジェクト設定 project.mcpServers[]。 + configs: McpServerConfig[]; +} + +export interface BuildMcpServersResult { + mcpServers: Record; + allowedTools: string[]; +} + +// SDK 設定と allowedTools を組み立てる。env var 未設定なら throw。 +// 呼び出し元 (chat-runner / agent-runner) は runUserTurn の都度これを呼ぶ +// → env 変更がホットリロードされる。 +export function buildMcpServers(input: BuildMcpServersInput): BuildMcpServersResult { + const { tallyMcp, configs } = input; + + const mcpServers: Record = { tally: tallyMcp }; + const allowedTools: string[] = ['mcp__tally__*']; + + for (const cfg of configs) { + const token = process.env[cfg.auth.envVar]; + if (token === undefined || token === '') { + throw new Error( + `MCP 設定 "${cfg.id}" の env var "${cfg.auth.envVar}" が未設定です`, + ); + } + mcpServers[cfg.id] = { + type: 'http' as const, + url: cfg.url, + headers: { Authorization: `Bearer ${token}` }, + }; + allowedTools.push(`mcp__${cfg.id}__*`); + } + + return { mcpServers, allowedTools }; +} +``` + +**注記:** Spike 0b で wildcard が効かないと判明したら、`allowedTools` 生成部を `cfg` に応じた実 tool 名列挙に置き換える。tool 名一覧は Spike 0a で記録した design doc 末尾 Footnote を参照。kind 別の tool リストを map にして、`cfg.kind === 'atlassian'` なら `ATLASSIAN_TOOLS.map(t => \`mcp__\${cfg.id}__\${t}\`)` を push する形。 + +- [ ] **Step 5-4: test pass** + +Run: `pnpm -F @tally/ai-engine test -- build-mcp-servers.test` + +- [ ] **Step 5-5: commit** + +```bash +git add packages/ai-engine/src/mcp/build-mcp-servers.ts packages/ai-engine/src/mcp/build-mcp-servers.test.ts +git commit -m "feat(ai-engine): buildMcpServers utility を追加 (外部 MCP 合成 + env 検証)" +``` + +--- + +## Task 6: duplicate-guards 骨格 (interface + strategy map) + +**Files:** +- Create: `packages/ai-engine/src/duplicate-guards/index.ts` +- Create: `packages/ai-engine/src/duplicate-guards/index.test.ts` + +- [ ] **Step 6-1: failing test — guard map のディスパッチ** + +```typescript +// packages/ai-engine/src/duplicate-guards/index.test.ts +import { describe, expect, it } from 'vitest'; +import { dispatchDuplicateGuard, type DuplicateGuardContext } from './index'; + +describe('dispatchDuplicateGuard', () => { + const fakeStore = { listNodes: async () => [], findRelatedNodes: async () => [] } as any; + const baseCtx: DuplicateGuardContext = { + store: fakeStore, + anchorId: '', + sessionMemo: new Set(), + }; + + it('adoptAs="requirement" は guard 対象外 → null', async () => { + const result = await dispatchDuplicateGuard('requirement', { title: 't', body: '', additional: undefined }, baseCtx); + expect(result).toBeNull(); + }); + + it('guard 登録が無い adoptAs は null', async () => { + const result = await dispatchDuplicateGuard('usecase' as any, { title: 't', body: '', additional: undefined }, baseCtx); + expect(result).toBeNull(); + }); +}); +``` + +- [ ] **Step 6-2: test fail を確認** + +Run: `pnpm -F @tally/ai-engine test -- duplicate-guards` + +- [ ] **Step 6-3: interface + dispatcher を実装 (個別 guard は後続 task)** + +```typescript +// packages/ai-engine/src/duplicate-guards/index.ts +import type { AdoptableType } from '@tally/core'; +import type { ProjectStore } from '@tally/storage'; + +// create-node 入力のうち guard に必要な最小 shape。 +export interface GuardInput { + title: string; + body: string; + additional: Record | undefined; +} + +// guard が共有するランタイム文脈。 +export interface DuplicateGuardContext { + store: ProjectStore; + // anchor 無し (chat) のときは空文字。anchor 依存 guard は空文字を skip せよ。 + anchorId: string; + // セッション内で生成済みノードの重複記録。キーは guard 実装が決める。 + sessionMemo: Set; + // マルチコードベース対応のために流すコードベース ID (optional)。 + codebaseId?: string; +} + +export interface DuplicateFound { + reason: string; // ユーザー向けメッセージ (既存 node id などを含む) +} + +export interface DuplicateGuard { + // 対象 adoptAs。複数対応は同 guard を複数 adoptAs で登録する。 + adoptAs: AdoptableType; + // 重複があれば DuplicateFound、無ければ null。 + // 副作用: 重複が無く生成が成功するかどうかの追跡は呼び出し側 (create-node) が行う。 + check(input: GuardInput, ctx: DuplicateGuardContext): Promise; + // 生成成功後に呼ばれる (sessionMemo 更新など)。 + onCreated?(input: GuardInput, ctx: DuplicateGuardContext): void; +} + +// adoptAs → Guard[] のレジストリ。Task 7-9 で個別 guard を追加する。 +const REGISTRY = new Map(); + +export function registerGuard(guard: DuplicateGuard): void { + const list = REGISTRY.get(guard.adoptAs) ?? []; + list.push(guard); + REGISTRY.set(guard.adoptAs, list); +} + +export async function dispatchDuplicateGuard( + adoptAs: AdoptableType, + input: GuardInput, + ctx: DuplicateGuardContext, +): Promise { + const guards = REGISTRY.get(adoptAs) ?? []; + for (const g of guards) { + const found = await g.check(input, ctx); + if (found) return found; + } + return null; +} + +export function notifyCreated( + adoptAs: AdoptableType, + input: GuardInput, + ctx: DuplicateGuardContext, +): void { + const guards = REGISTRY.get(adoptAs) ?? []; + for (const g of guards) g.onCreated?.(input, ctx); +} +``` + +- [ ] **Step 6-4: test pass** + +Run: `pnpm -F @tally/ai-engine test -- duplicate-guards` + +- [ ] **Step 6-5: commit** + +```bash +git add packages/ai-engine/src/duplicate-guards/ +git commit -m "feat(ai-engine): duplicate-guards の骨格 (interface + dispatcher) を追加" +``` + +--- + +## Task 7: coderef guard を分離 (既存ロジック移行) + +**Files:** +- Create: `packages/ai-engine/src/duplicate-guards/coderef.ts` +- Create: `packages/ai-engine/src/duplicate-guards/coderef.test.ts` +- Modify: `packages/ai-engine/src/duplicate-guards/index.ts` (register 呼び出し) + +- [ ] **Step 7-1: failing test — 既存挙動を網羅する単体テスト** + +```typescript +// packages/ai-engine/src/duplicate-guards/coderef.test.ts +import { describe, expect, it, vi } from 'vitest'; +import { coderefGuard } from './coderef'; +import type { DuplicateGuardContext } from './index'; + +function makeCtx(nodes: any[], anchorId = ''): DuplicateGuardContext { + return { + store: { + listNodes: async () => nodes, + findRelatedNodes: async () => [], + } as any, + anchorId, + sessionMemo: new Set(), + codebaseId: undefined, + }; +} + +describe('coderefGuard', () => { + it('同一 filePath + 近接 startLine (±10) で重複検知', async () => { + const ctx = makeCtx([ + { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + ]); + const res = await coderefGuard.check( + { + title: 'T', body: '', + additional: { filePath: 'src/a.ts', startLine: 105, codebaseId: 'cb1' }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + }); + + it('11 行以上離れていれば重複ではない', async () => { + const ctx = makeCtx([ + { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + ]); + const res = await coderefGuard.check( + { + title: 'T', body: '', + additional: { filePath: 'src/a.ts', startLine: 112, codebaseId: 'cb1' }, + }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('codebaseId が異なれば別物扱い', async () => { + const ctx = makeCtx([ + { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + ]); + const res = await coderefGuard.check( + { + title: 'T', body: '', + additional: { filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb2' }, + }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('filePath が "./" 付きでも正規化して判定', async () => { + const ctx = makeCtx([ + { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100 }, + ]); + const res = await coderefGuard.check( + { + title: 'T', body: '', + additional: { filePath: './src/a.ts', startLine: 100 }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + }); +}); +``` + +- [ ] **Step 7-2: test fail を確認** + +Run: `pnpm -F @tally/ai-engine test -- duplicate-guards/coderef` + +- [ ] **Step 7-3: 既存 `findDuplicateCoderef` を移行** + +`packages/ai-engine/src/duplicate-guards/coderef.ts` に既存 create-node.ts の normalizeFilePath + findDuplicateCoderef を guard 形式で書く: + +```typescript +import path from 'node:path'; +import type { DuplicateGuard } from './index'; + +const CODEREF_LINE_TOLERANCE = 10; + +function normalizeFilePath(fp: string): string { + const stripped = fp.startsWith('./') ? fp.slice(2) : fp; + return path.posix.normalize(stripped); +} + +export const coderefGuard: DuplicateGuard = { + adoptAs: 'coderef', + async check(input, ctx) { + const additional = input.additional ?? {}; + const fp = additional.filePath; + const sl = additional.startLine; + if (typeof fp !== 'string' || typeof sl !== 'number') return null; + + const normalized = normalizeFilePath(fp); + const activeCbId = + typeof additional.codebaseId === 'string' ? additional.codebaseId : ctx.codebaseId; + + const all = await ctx.store.listNodes(); + for (const n of all) { + const rec = n as Record; + const type = rec.type as string | undefined; + const adoptAs = rec.adoptAs as string | undefined; + const isCoderef = type === 'coderef' || (type === 'proposal' && adoptAs === 'coderef'); + if (!isCoderef) continue; + const existingFp = rec.filePath as string | undefined; + const existingSl = rec.startLine as number | undefined; + if (!existingFp || typeof existingSl !== 'number') continue; + if (normalizeFilePath(existingFp) !== normalized) continue; + const existingCb = rec.codebaseId as string | undefined; + if (activeCbId !== undefined && existingCb !== undefined && existingCb !== activeCbId) { + continue; + } + if (Math.abs(existingSl - sl) <= CODEREF_LINE_TOLERANCE) { + return { + reason: `重複: ${rec.id} と近接 (filePath=${normalized}, startLine 差=${Math.abs(existingSl - sl)})`, + }; + } + } + return null; + }, +}; +``` + +`packages/ai-engine/src/duplicate-guards/index.ts` の末尾に register: + +```typescript +import { coderefGuard } from './coderef'; +registerGuard(coderefGuard); +``` + +- [ ] **Step 7-4: test pass** + +Run: `pnpm -F @tally/ai-engine test -- duplicate-guards` + +- [ ] **Step 7-5: commit** + +```bash +git add packages/ai-engine/src/duplicate-guards/coderef.ts packages/ai-engine/src/duplicate-guards/coderef.test.ts packages/ai-engine/src/duplicate-guards/index.ts +git commit -m "feat(ai-engine): coderef 重複ガードを duplicate-guards/ に分離" +``` + +--- + +## Task 8: question guard を分離 (既存ロジック移行) + +**Files:** +- Create: `packages/ai-engine/src/duplicate-guards/question.ts` +- Create: `packages/ai-engine/src/duplicate-guards/question.test.ts` +- Modify: `packages/ai-engine/src/duplicate-guards/index.ts` + +- [ ] **Step 8-1: failing test** + +```typescript +// packages/ai-engine/src/duplicate-guards/question.test.ts +import { describe, expect, it } from 'vitest'; +import { questionGuard } from './question'; +import type { DuplicateGuardContext } from './index'; + +function makeCtx(neighbors: any[], anchorId = 'anchor-1'): DuplicateGuardContext { + return { + store: { + listNodes: async () => [], + findRelatedNodes: async () => neighbors, + } as any, + anchorId, + sessionMemo: new Set(), + }; +} + +describe('questionGuard', () => { + it('anchorId が空なら skip (null を返す)', async () => { + const ctx = makeCtx([], ''); + const res = await questionGuard.check({ title: '[AI] Q', body: '', additional: undefined }, ctx); + expect(res).toBeNull(); + }); + + it('同 anchor に同タイトルが既にあれば重複', async () => { + const ctx = makeCtx([ + { id: 'q1', type: 'question', title: 'どうするか', adoptAs: undefined }, + ]); + const res = await questionGuard.check( + { title: '[AI] どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('q1'); + }); + + it('sessionMemo に同 anchor+title が記録済みなら重複', async () => { + const ctx = makeCtx([]); + ctx.sessionMemo.add('anchor-1|どうするか'); + const res = await questionGuard.check( + { title: '[AI] どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('同一セッション'); + }); +}); +``` + +- [ ] **Step 8-2: test fail を確認** + +Run: `pnpm -F @tally/ai-engine test -- duplicate-guards/question` + +- [ ] **Step 8-3: 移行実装** + +```typescript +// packages/ai-engine/src/duplicate-guards/question.ts +import { stripAiPrefix } from '@tally/core'; +import type { DuplicateGuard } from './index'; + +export const questionGuard: DuplicateGuard = { + adoptAs: 'question', + async check(input, ctx) { + // T1: anchorId が空なら anchor ベースのチェックは skip。 + // chat 経由では anchor が無いので、source-url guard が代わりに重複検知する。 + if (!ctx.anchorId) return null; + + const normalizedTitle = stripAiPrefix(input.title); + const sessionKey = `${ctx.anchorId}|${normalizedTitle}`; + if (ctx.sessionMemo.has(sessionKey)) { + return { + reason: `重複 (同一セッション内): anchor ${ctx.anchorId} に既に同タイトル question を生成済み`, + }; + } + + const neighbors = await ctx.store.findRelatedNodes(ctx.anchorId); + for (const n of neighbors) { + const rec = n as unknown as { id: string; type: string; adoptAs?: string; title: string }; + const isQuestion = + rec.type === 'question' || (rec.type === 'proposal' && rec.adoptAs === 'question'); + if (isQuestion && stripAiPrefix(rec.title) === normalizedTitle) { + return { + reason: `重複: anchor ${ctx.anchorId} に既に同タイトル question 候補 ${rec.id} が存在`, + }; + } + } + return null; + }, + onCreated(input, ctx) { + if (!ctx.anchorId) return; + const normalizedTitle = stripAiPrefix(input.title); + ctx.sessionMemo.add(`${ctx.anchorId}|${normalizedTitle}`); + }, +}; +``` + +`packages/ai-engine/src/duplicate-guards/index.ts` に register 追記: + +```typescript +import { questionGuard } from './question'; +registerGuard(questionGuard); +``` + +- [ ] **Step 8-4: test pass** + +Run: `pnpm -F @tally/ai-engine test -- duplicate-guards` + +- [ ] **Step 8-5: commit** + +```bash +git add packages/ai-engine/src/duplicate-guards/question.ts packages/ai-engine/src/duplicate-guards/question.test.ts packages/ai-engine/src/duplicate-guards/index.ts +git commit -m "feat(ai-engine): question 重複ガードを duplicate-guards/ に分離 (anchorId 空は skip)" +``` + +--- + +## Task 9: source-url guard 追加 (T1 fix、chat anchor 無しでも動く) + +**Files:** +- Create: `packages/ai-engine/src/duplicate-guards/source-url.ts` +- Create: `packages/ai-engine/src/duplicate-guards/source-url.test.ts` +- Modify: `packages/ai-engine/src/duplicate-guards/index.ts` + +- [ ] **Step 9-1: failing test** + +```typescript +// packages/ai-engine/src/duplicate-guards/source-url.test.ts +import { describe, expect, it } from 'vitest'; +import { sourceUrlGuard } from './source-url'; +import type { DuplicateGuardContext } from './index'; + +function makeCtx(nodes: any[]): DuplicateGuardContext { + return { + store: { listNodes: async () => nodes, findRelatedNodes: async () => [] } as any, + anchorId: '', + sessionMemo: new Set(), + }; +} + +describe('sourceUrlGuard', () => { + it('sourceUrl が additional に無ければ skip', async () => { + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: undefined }, + makeCtx([]), + ); + expect(res).toBeNull(); + }); + + it('同 sourceUrl の requirement が既にあれば重複', async () => { + const ctx = makeCtx([ + { id: 'r1', type: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }, + ]); + const res = await sourceUrlGuard.check( + { + title: 'R', body: '', + additional: { sourceUrl: 'https://jira.test/EPIC-1' }, + }, + ctx, + ); + expect(res?.reason).toContain('r1'); + }); + + it('proposal 段階の sourceUrl も重複検知対象', async () => { + const ctx = makeCtx([ + { + id: 'p1', type: 'proposal', adoptAs: 'requirement', + sourceUrl: 'https://jira.test/EPIC-1', + }, + ]); + const res = await sourceUrlGuard.check( + { + title: 'R', body: '', + additional: { sourceUrl: 'https://jira.test/EPIC-1' }, + }, + ctx, + ); + expect(res?.reason).toContain('p1'); + }); + + it('セッション内 sessionMemo でも重複検知', async () => { + const ctx = makeCtx([]); + ctx.sessionMemo.add('sourceUrl:https://jira.test/EPIC-1'); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res?.reason).toContain('同一セッション'); + }); + + it('onCreated で sessionMemo 更新', async () => { + const ctx = makeCtx([]); + sourceUrlGuard.onCreated?.( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(ctx.sessionMemo.has('sourceUrl:https://jira.test/EPIC-1')).toBe(true); + }); +}); +``` + +- [ ] **Step 9-2: test fail を確認** + +Run: `pnpm -F @tally/ai-engine test -- duplicate-guards/source-url` + +- [ ] **Step 9-3: 実装** + +```typescript +// packages/ai-engine/src/duplicate-guards/source-url.ts +import type { DuplicateGuard } from './index'; + +// sourceUrl ベースの重複検知。 +// anchor 不要 → chat (anchorId='') でも動く (T1 fix の核)。 +// requirement / proposal(adoptAs=requirement) の全件スキャン。 +export const sourceUrlGuard: DuplicateGuard = { + adoptAs: 'requirement', + async check(input, ctx) { + const sourceUrl = input.additional?.sourceUrl; + if (typeof sourceUrl !== 'string' || sourceUrl.length === 0) return null; + + const sessionKey = `sourceUrl:${sourceUrl}`; + if (ctx.sessionMemo.has(sessionKey)) { + return { reason: `重複 (同一セッション内): sourceUrl ${sourceUrl} を既に生成済み` }; + } + + const all = await ctx.store.listNodes(); + for (const n of all) { + const rec = n as Record; + const type = rec.type as string | undefined; + const adoptAs = rec.adoptAs as string | undefined; + const isRequirement = + type === 'requirement' || (type === 'proposal' && adoptAs === 'requirement'); + if (!isRequirement) continue; + const existingUrl = rec.sourceUrl as string | undefined; + if (existingUrl === sourceUrl) { + return { reason: `重複: sourceUrl ${sourceUrl} は既に node ${rec.id} が保持` }; + } + } + return null; + }, + onCreated(input, ctx) { + const sourceUrl = input.additional?.sourceUrl; + if (typeof sourceUrl === 'string' && sourceUrl.length > 0) { + ctx.sessionMemo.add(`sourceUrl:${sourceUrl}`); + } + }, +}; +``` + +`packages/ai-engine/src/duplicate-guards/index.ts` に register 追記: + +```typescript +import { sourceUrlGuard } from './source-url'; +registerGuard(sourceUrlGuard); +``` + +- [ ] **Step 9-4: test pass** + +Run: `pnpm -F @tally/ai-engine test -- duplicate-guards` + +- [ ] **Step 9-5: commit** + +```bash +git add packages/ai-engine/src/duplicate-guards/source-url.ts packages/ai-engine/src/duplicate-guards/source-url.test.ts packages/ai-engine/src/duplicate-guards/index.ts +git commit -m "feat(ai-engine): sourceUrl ベースの重複ガードを追加 (T1 fix: chat anchor 無しで動く)" +``` + +--- + +## Task 10: create-node を duplicate-guards に委譲 + +**Files:** +- Modify: `packages/ai-engine/src/tools/create-node.ts` +- Modify: `packages/ai-engine/src/tools/create-node.test.ts` + +- [ ] **Step 10-1: failing test — source-url guard の動作と既存 coderef/question regression** + +```typescript +// packages/ai-engine/src/tools/create-node.test.ts に追加: +it('sourceUrl 重複で 2 度目の作成は fail', async () => { + // arrange: 既に sourceUrl を持つ proposal が 1 個存在 + const store = makeFakeStore({ + listNodes: async () => [ + { + id: 'p1', type: 'proposal', adoptAs: 'requirement', + sourceUrl: 'https://jira.test/EPIC-1', + }, + ], + }); + const handler = createNodeHandler({ + store, emit: () => {}, + anchor: { x: 0, y: 0 }, anchorId: '', + agentName: 'ingest-document', + }); + const res = await handler({ + adoptAs: 'requirement', + title: 'R', body: '', + additional: { sourceUrl: 'https://jira.test/EPIC-1' }, + }); + expect(res.ok).toBe(false); + expect(res.output).toContain('sourceUrl'); +}); + +it('既存 coderef 重複 test は引き続き pass (regression)', async () => { + // ... 既存テストをそのまま +}); + +it('既存 question 重複 test (anchor あり) は引き続き pass (regression)', async () => { + // ... 既存テストをそのまま +}); +``` + +- [ ] **Step 10-2: test fail を確認** + +Run: `pnpm -F @tally/ai-engine test -- create-node.test` + +- [ ] **Step 10-3: create-node.ts を dispatcher に委譲するよう書き換え** + +現在の `findDuplicateCoderef` / `sessionQuestionKeys` 関連ロジックを削除し、`dispatchDuplicateGuard` / `notifyCreated` を呼ぶ形に書き換える。normalizeFilePath は coderef guard 側に移したので、create-node で filePath 正規化する処理は「DB 保存前の正規化」目的で残す (guard 側と独立、重複して OK)。 + +```typescript +// packages/ai-engine/src/tools/create-node.ts の該当箇所書き換え +import { + dispatchDuplicateGuard, + notifyCreated, + type DuplicateGuardContext, +} from '../duplicate-guards/index'; + +export function createNodeHandler(deps: CreateNodeDeps) { + let nextOffsetIndex = 0; + const sessionMemo = new Set(); + + return async (input: unknown): Promise => { + const parsed = CreateNodeInputSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, output: `invalid input: ${parsed.error.message}` }; + } + const { adoptAs, title, body, x, y, additional } = parsed.data; + + // coderef の filePath 正規化 + codebaseId 注入 (保存前の integrity) + let normalizedAdditional = additional; + if (adoptAs === 'coderef') { + const base = additional ?? {}; + const withCb: Record = + deps.codebaseId !== undefined && base.codebaseId === undefined + ? { ...base, codebaseId: deps.codebaseId } + : { ...base }; + const fp = withCb.filePath; + if (typeof fp === 'string' && fp.length > 0) { + withCb.filePath = normalizeFilePathForStorage(fp); + } + normalizedAdditional = withCb; + } + + // question の options 正規化 + min 2 検証 (既存ロジック保持) + if (adoptAs === 'question') { + const rawOptions = additional?.options; + const normalizedOptions = Array.isArray(rawOptions) + ? rawOptions + .map((opt) => { + const text = + typeof opt === 'object' && opt !== null && 'text' in opt + ? String((opt as { text: unknown }).text ?? '') + : String(opt ?? ''); + return { id: newQuestionOptionId(), text: text.trim(), selected: false }; + }) + .filter((o) => o.text.length > 0) + : []; + if (normalizedOptions.length < QUESTION_MIN_OPTIONS) { + return { + ok: false, + output: `options は最低 ${QUESTION_MIN_OPTIONS} 個の非空 text を要求します (受け取り: ${normalizedOptions.length} 個)`, + }; + } + normalizedAdditional = { + ...(additional ?? {}), + options: normalizedOptions, + decision: null, + }; + } + + // duplicate-guards dispatch + const guardCtx: DuplicateGuardContext = { + store: deps.store, + anchorId: deps.anchorId, + sessionMemo, + codebaseId: deps.codebaseId, + }; + const dup = await dispatchDuplicateGuard( + adoptAs, + { title, body, additional: normalizedAdditional }, + guardCtx, + ); + if (dup) return { ok: false, output: dup.reason }; + + // 既存の ensureTitle + placement + addNode フロー (変更なし) + const ensuredTitle = title.startsWith('[AI]') ? title : `[AI] ${title}`; + const idx = nextOffsetIndex++; + const placedX = x ?? deps.anchor.x + 260 + idx * 20; + const placedY = y ?? deps.anchor.y + idx * 120; + + try { + const created = (await deps.store.addNode({ + ...(normalizedAdditional ?? {}), + type: 'proposal', + x: placedX, y: placedY, + title: ensuredTitle, body, + adoptAs, + sourceAgentId: deps.agentName, + } as Parameters[0])) as ProposalNode; + deps.emit({ type: 'node_created', node: created }); + + // 生成成功後、guard に通知 (sessionMemo 更新など) + notifyCreated( + adoptAs, + { title, body, additional: normalizedAdditional }, + guardCtx, + ); + return { ok: true, output: JSON.stringify(created) }; + } catch (err) { + return { ok: false, output: `addNode failed: ${String(err)}` }; + } + }; +} + +// 旧 normalizeFilePath を storage 用に残す (guard 側と独立) +function normalizeFilePathForStorage(fp: string): string { + const stripped = fp.startsWith('./') ? fp.slice(2) : fp; + return path.posix.normalize(stripped); +} +``` + +旧 `findDuplicateCoderef` 関数は削除 (coderef guard に移行済み)。 + +- [ ] **Step 10-4: test pass + regression** + +Run: `pnpm -F @tally/ai-engine test -- create-node` +Expected: PASS 全件 (新規 sourceUrl test + 既存 coderef/question test) + +- [ ] **Step 10-5: commit** + +```bash +git add packages/ai-engine/src/tools/create-node.ts packages/ai-engine/src/tools/create-node.test.ts +git commit -m "refactor(ai-engine): create-node を duplicate-guards に委譲、sourceUrl guard を有効化" +``` + +--- + +## Task 11: ChatRunner — buildMcpServers 統合 + +**Files:** +- Modify: `packages/ai-engine/src/chat-runner.ts` +- Modify: `packages/ai-engine/src/chat-runner.test.ts` + +- [ ] **Step 11-1: failing test — プロジェクトの mcpServers[] から外部 MCP が sdk.query に渡る** + +```typescript +// chat-runner.test.ts に追加 +it('プロジェクト設定の mcpServers[] を sdk.query に渡す', async () => { + process.env.TEST_PAT = 'secret'; + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + // saveProjectMeta で mcpServers を含めて保存 + await projectStore.saveProjectMeta({ + id: 'proj-1', name: 'P', codebases: [], + mcpServers: [ + { + id: 'test-mcp', name: 'T', kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', envVar: 'TEST_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + }); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + + const querySpy = vi.fn(() => + (async function* () { + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + const runner = new ChatRunner({ + sdk, chatStore, projectStore, projectDir: root, threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('hi')) events.push(e); + + const callArg = querySpy.mock.calls[0][0] as any; + expect(Object.keys(callArg.options.mcpServers)).toEqual( + expect.arrayContaining(['tally', 'test-mcp']), + ); + expect((callArg.options.mcpServers['test-mcp'] as any).headers.Authorization).toBe( + 'Bearer secret', + ); + expect(callArg.options.allowedTools).toContain('mcp__tally__*'); + expect(callArg.options.allowedTools).toContain('mcp__test-mcp__*'); +}); + +it('env 未設定ならエラーイベントを発火 (sdk.query は呼ばない)', async () => { + delete process.env.MISSING_PAT; + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + await projectStore.saveProjectMeta({ + id: 'proj-1', name: 'P', codebases: [], + mcpServers: [ + { + id: 'a', name: 'A', kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', envVar: 'MISSING_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: '2026-04-24T00:00:00Z', updatedAt: '2026-04-24T00:00:00Z', + }); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + + const querySpy = vi.fn(); + const sdk: SdkLike = { query: querySpy }; + const runner = new ChatRunner({ + sdk, chatStore, projectStore, projectDir: root, threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('hi')) events.push(e); + + expect(querySpy).not.toHaveBeenCalled(); + expect(events.some((e) => e.type === 'error' && /MISSING_PAT/.test(e.message))).toBe(true); +}); +``` + +- [ ] **Step 11-2: test fail** + +Run: `pnpm -F @tally/ai-engine test -- chat-runner` + +- [ ] **Step 11-3: ChatRunner.runUserTurn を書き換え** + +`runUserTurn` の step 5 (sdkDone IIFE) 冒頭で buildMcpServers を呼び、エラー時は error event を push して early return: + +```typescript +// chat-runner.ts の runUserTurn 内 +import { buildMcpServers } from './mcp/build-mcp-servers'; +import { redactMcpSecrets } from './mcp/redact'; + +// ... (既存の step 1-4 は維持) + +// プロジェクトから最新の mcpServers[] を取得 (run ごとにホットリロード) +const projectMeta = await projectStore.getProjectMeta(); +const externalConfigs = projectMeta?.mcpServers ?? []; + +let mcpServers: Record; +let allowedTools: string[]; +try { + const built = buildMcpServers({ tallyMcp: mcp, configs: externalConfigs }); + mcpServers = built.mcpServers; + allowedTools = built.allowedTools; +} catch (err) { + yield { type: 'error', code: 'mcp_config_invalid', message: String(err) }; + return; +} + +// ... sdkDone IIFE 内の sdk.query options を差し替え +const iter = sdk.query({ + prompt, + options: { + systemPrompt, + mcpServers, // 動的生成 + tools: [], + allowedTools, // 動的生成 + permissionMode: 'dontAsk', + settingSources: [], + cwd: projectDir, + // ... (既存 CLAUDE_CODE_PATH 処理) + }, +}); + +// 既存 console.log は redaction 経由に +console.log('[chat-runner] sdk msg:', JSON.stringify(redactMcpSecrets(msg)).slice(0, 200)); +``` + +- [ ] **Step 11-4: test pass + regression (text-only / invokeInterceptedTool テスト)** + +Run: `pnpm -F @tally/ai-engine test -- chat-runner` +Expected: PASS 全件 (新規 2 件 + 既存 3 件) + +- [ ] **Step 11-5: commit** + +```bash +git add packages/ai-engine/src/chat-runner.ts packages/ai-engine/src/chat-runner.test.ts +git commit -m "feat(ai-engine): ChatRunner が buildMcpServers で外部 MCP を合成するように変更" +``` + +--- + +## Task 12: ChatRunner — extractAssistantBlocks + 外部 tool_use 永続化 + +**Files:** +- Modify: `packages/ai-engine/src/chat-runner.ts` +- Modify: `packages/ai-engine/src/chat-runner.test.ts` + +- [ ] **Step 12-1: failing test — 外部 MCP の tool_use block が source='external' で永続化される** + +```typescript +it('SDK から来た外部 tool_use/tool_result は source=external で chatStore に append', async () => { + process.env.TEST_PAT = 'secret'; + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + await projectStore.saveProjectMeta({ + id: 'proj-1', name: 'P', codebases: [], + mcpServers: [ + { + id: 'atlassian', name: 'A', kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', envVar: 'TEST_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: '2026-04-24T00:00:00Z', updatedAt: '2026-04-24T00:00:00Z', + }); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Jira を読みます' }, + { + type: 'tool_use', + id: 'atlassian-tu-1', + name: 'mcp__atlassian__getJiraIssue', + input: { key: 'EPIC-1' }, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'atlassian-tu-1', + content: [{ type: 'text', text: '{"summary":"Epic title"}' }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, chatStore, projectStore, projectDir: root, threadId: thread.id, + }); + for await (const _ of runner.runUserTurn('@JIRA EPIC-1 を読んで')) {} + + const reloaded = await chatStore.getChat(thread.id); + const asstMsg = reloaded?.messages.find((m) => m.role === 'assistant'); + const toolUse = asstMsg?.blocks.find((b) => b.type === 'tool_use') as any; + expect(toolUse.source).toBe('external'); + expect(toolUse.name).toBe('mcp__atlassian__getJiraIssue'); + expect(toolUse.approval).toBeUndefined(); + + const toolResult = asstMsg?.blocks.find((b) => b.type === 'tool_result') as any; + expect(toolResult.ok).toBe(true); + expect(toolResult.output).toContain('Epic title'); +}); +``` + +- [ ] **Step 12-2: test fail** + +Run: `pnpm -F @tally/ai-engine test -- chat-runner` + +- [ ] **Step 12-3: 実装 — extractAssistantBlocks を拡張** + +```typescript +// chat-runner.ts の下部 helper 書き換え +type ExtractedBlock = + | { type: 'text'; text: string } + | { type: 'tool_use'; toolUseId: string; name: string; input: unknown } + | { type: 'tool_result'; toolUseId: string; ok: boolean; output: string }; + +// SDK から流れてくる assistant message + user message (tool_result を含む) から block 抽出。 +// Tally MCP の tool_use は MCP handler が処理するので、ここでは外部 MCP (mcp____* +// で name !== 'tally') のものだけ拾う。 +function extractExternalBlocks(msg: SdkMessageLike): ExtractedBlock[] { + const m = msg as unknown as { type?: string; message?: { content?: unknown[] } }; + if ((m.type !== 'assistant' && m.type !== 'user') || !m.message?.content) return []; + const out: ExtractedBlock[] = []; + for (const block of m.message.content) { + const b = block as { + type?: string; + text?: string; + id?: string; + name?: string; + input?: unknown; + tool_use_id?: string; + content?: Array<{ type?: string; text?: string }>; + is_error?: boolean; + }; + if (b.type === 'text' && typeof b.text === 'string' && m.type === 'assistant') { + out.push({ type: 'text', text: b.text }); + } else if ( + b.type === 'tool_use' && + typeof b.id === 'string' && + typeof b.name === 'string' && + !b.name.startsWith('mcp__tally__') // Tally MCP は intercept 経路 + ) { + out.push({ + type: 'tool_use', + toolUseId: b.id, + name: b.name, + input: b.input, + }); + } else if ( + b.type === 'tool_result' && + typeof b.tool_use_id === 'string' && + Array.isArray(b.content) + ) { + const text = b.content + .filter((c) => c.type === 'text' && typeof c.text === 'string') + .map((c) => c.text) + .join(''); + out.push({ + type: 'tool_result', + toolUseId: b.tool_use_id, + ok: !b.is_error, + output: text, + }); + } + } + return out; +} +``` + +`runUserTurn` 内 SDK iterate loop を書き換え: + +```typescript +for await (const msg of iter) { + console.log('[chat-runner] sdk msg:', JSON.stringify(redactMcpSecrets(msg)).slice(0, 200)); + const blocks = extractExternalBlocks(msg); + for (const b of blocks) { + if (b.type === 'text') { + textBuffer.push(b.text); + queue.push({ type: 'chat_text_delta', messageId: assistantMsgId, text: b.text }); + } else if (b.type === 'tool_use') { + // 外部 MCP の tool_use: 永続化 + UI 通知 (承認なし) + await chatStore.appendBlockToMessage(threadId, assistantMsgId, { + type: 'tool_use', + toolUseId: b.toolUseId, + name: b.name, + input: b.input, + source: 'external', + }); + queue.push({ + type: 'chat_tool_external_use', + messageId: assistantMsgId, + toolUseId: b.toolUseId, + name: b.name, + input: b.input, + }); + } else if (b.type === 'tool_result') { + await chatStore.appendBlockToMessage(threadId, assistantMsgId, { + type: 'tool_result', + toolUseId: b.toolUseId, + ok: b.ok, + output: b.output, + }); + queue.push({ + type: 'chat_tool_external_result', + messageId: assistantMsgId, + toolUseId: b.toolUseId, + ok: b.ok, + output: b.output, + }); + } + } +} +``` + +`packages/ai-engine/src/stream.ts` に新 event を追加: + +```typescript +| { type: 'chat_tool_external_use'; messageId: string; toolUseId: string; name: string; input: unknown } +| { type: 'chat_tool_external_result'; messageId: string; toolUseId: string; ok: boolean; output: string } +``` + +- [ ] **Step 12-4: test pass** + +Run: `pnpm -F @tally/ai-engine test -- chat-runner` + +- [ ] **Step 12-5: commit** + +```bash +git add packages/ai-engine/src/chat-runner.ts packages/ai-engine/src/chat-runner.test.ts packages/ai-engine/src/stream.ts +git commit -m "feat(ai-engine): ChatRunner が外部 MCP の tool_use/tool_result を source=external で永続化" +``` + +--- + +## Task 13: ChatRunner — tool_result 4KB truncate (永続化時のみ) + +**Files:** +- Modify: `packages/ai-engine/src/chat-runner.ts` +- Modify: `packages/ai-engine/src/chat-runner.test.ts` + +- [ ] **Step 13-1: failing test** + +```typescript +it('tool_result output が 4KB 超えると永続化時に truncate、event は full', async () => { + process.env.TEST_PAT = 'secret'; + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + await projectStore.saveProjectMeta({ + id: 'proj-1', name: 'P', codebases: [], + mcpServers: [ + { + id: 'atlassian', name: 'A', kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', envVar: 'TEST_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: '2026-04-24T00:00:00Z', updatedAt: '2026-04-24T00:00:00Z', + }); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const bigOutput = 'X'.repeat(10_000); + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'user', + message: { + content: [ + { type: 'tool_result', tool_use_id: 'big-1', content: [{ type: 'text', text: bigOutput }] }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, chatStore, projectStore, projectDir: root, threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('q')) events.push(e); + + // event には full output + const evt = events.find((e) => e.type === 'chat_tool_external_result') as any; + expect(evt.output.length).toBe(10_000); + + // YAML 永続化は truncate + const reloaded = await chatStore.getChat(thread.id); + const tr = reloaded?.messages + .flatMap((m) => m.blocks) + .find((b) => b.type === 'tool_result') as any; + expect(tr.output.length).toBeLessThanOrEqual(4200); // 4KB + marker 余裕 + expect(tr.output).toContain('(truncated'); +}); +``` + +- [ ] **Step 13-2: test fail** + +Run: `pnpm -F @tally/ai-engine test -- chat-runner` + +- [ ] **Step 13-3: 実装 — truncate ロジック** + +chat-runner.ts の tool_result 永続化箇所を修正: + +```typescript +const TOOL_RESULT_PERSIST_LIMIT = 4096; + +function truncateForPersistence(output: string): string { + if (output.length <= TOOL_RESULT_PERSIST_LIMIT) return output; + const head = output.slice(0, TOOL_RESULT_PERSIST_LIMIT); + return `${head}\n... (truncated, ${output.length} chars total)`; +} + +// tool_result append: +await chatStore.appendBlockToMessage(threadId, assistantMsgId, { + type: 'tool_result', + toolUseId: b.toolUseId, + ok: b.ok, + output: truncateForPersistence(b.output), +}); +// event は full を流す: +queue.push({ + type: 'chat_tool_external_result', + messageId: assistantMsgId, + toolUseId: b.toolUseId, + ok: b.ok, + output: b.output, +}); +``` + +- [ ] **Step 13-4: test pass** + +Run: `pnpm -F @tally/ai-engine test -- chat-runner` + +- [ ] **Step 13-5: commit** + +```bash +git add packages/ai-engine/src/chat-runner.ts packages/ai-engine/src/chat-runner.test.ts +git commit -m "feat(ai-engine): tool_result output を永続化時 4KB に truncate (event はフル)" +``` + +--- + +## Task 14: ChatRunner — buildChatPrompt が tool_use/tool_result を replay (T4 fix) + +**Files:** +- Modify: `packages/ai-engine/src/chat-runner.ts` +- Modify: `packages/ai-engine/src/chat-runner.test.ts` + +- [ ] **Step 14-1: failing test — multi-turn で前ターンの tool_result が prompt に含まれる** + +```typescript +it('buildChatPrompt が tool_use と tool_result も replay する', async () => { + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + // 1 ターン目: user + assistant (tool_use + tool_result + text) + await chatStore.appendMessage(thread.id, { + id: 'u1', role: 'user', + blocks: [{ type: 'text', text: '@JIRA EPIC-1 を読んで' }], + createdAt: '2026-04-24T00:00:00Z', + }); + await chatStore.appendMessage(thread.id, { + id: 'a1', role: 'assistant', + blocks: [ + { type: 'text', text: 'Jira を読みます' }, + { + type: 'tool_use', toolUseId: 'tu-1', + name: 'mcp__atlassian__getJiraIssue', input: { key: 'EPIC-1' }, + source: 'external', + }, + { type: 'tool_result', toolUseId: 'tu-1', ok: true, output: '{"summary":"Epic X"}' }, + { type: 'text', text: '読みました。Epic X です' }, + ], + createdAt: '2026-04-24T00:01:00Z', + }); + + // 2 ターン目: 新しい user message + await chatStore.appendMessage(thread.id, { + id: 'u2', role: 'user', + blocks: [{ type: 'text', text: '続けて子チケット STORY-42 を読んで' }], + createdAt: '2026-04-24T00:02:00Z', + }); + + // buildChatPrompt を直接 import (export 必要) + const reloaded = await chatStore.getChat(thread.id); + const prompt = buildChatPromptForTest(reloaded!.messages); + + // 過去 tool_use / tool_result が含まれる + expect(prompt).toContain('Epic X'); + expect(prompt).toContain('getJiraIssue'); + // 直近 user が current message として出る + expect(prompt).toContain('STORY-42'); +}); +``` + +`chat-runner.ts` の `buildChatPrompt` を export に変更する必要がある (或いは test 用に関数エクスポート)。 + +- [ ] **Step 14-2: test fail** + +Run: `pnpm -F @tally/ai-engine test -- chat-runner` + +- [ ] **Step 14-3: buildChatPrompt を拡張** + +```typescript +// chat-runner.ts の buildChatPrompt 書き換え、export を追加 +export function buildChatPromptForTest(messages: ChatMessage[]): string { + return buildChatPrompt(messages); +} + +function buildChatPrompt(messages: ChatMessage[]): string { + const lines: string[] = []; + const last = messages[messages.length - 1]; + const past = last?.role === 'user' ? messages.slice(0, -1) : messages; + + if (past.length > 0) { + lines.push(''); + for (const m of past) { + lines.push(``); + for (const b of m.blocks) { + if (b.type === 'text') { + lines.push(b.text); + } else if (b.type === 'tool_use') { + const srcTag = (b as any).source === 'external' ? ' source="external"' : ''; + lines.push( + `${JSON.stringify(b.input)}`, + ); + } else if (b.type === 'tool_result') { + lines.push( + `${b.output}`, + ); + } + } + lines.push(``); + } + lines.push(''); + } + + if (last && last.role === 'user') { + const texts = last.blocks + .filter((b): b is Extract => b.type === 'text') + .map((b) => b.text); + lines.push(''); + lines.push(texts.join('\n')); + lines.push(''); + } + + return lines.join('\n'); +} +``` + +- [ ] **Step 14-4: test pass + multi-turn E2E (runUserTurn 2 回) の regression なし** + +Run: `pnpm -F @tally/ai-engine test -- chat-runner` + +- [ ] **Step 14-5: commit** + +```bash +git add packages/ai-engine/src/chat-runner.ts packages/ai-engine/src/chat-runner.test.ts +git commit -m "feat(ai-engine): buildChatPrompt が tool_use/tool_result も replay (T4 fix: multi-turn で context 保持)" +``` + +--- + +## Task 15: agent-runner — buildMcpServers 共有 + regression snapshot + +**Files:** +- Modify: `packages/ai-engine/src/agent-runner.ts` +- Modify: `packages/ai-engine/src/agent-runner.test.ts` + +- [ ] **Step 15-1: failing test — プロジェクトの mcpServers[] が agent-runner でも外部 MCP として渡る** + +```typescript +// agent-runner.test.ts +it('プロジェクト mcpServers[] が sdk.query に渡る (agent-runner も外部 MCP 合成)', async () => { + process.env.TEST_PAT = 'secret'; + const store = makeProjectStoreWithMeta({ + mcpServers: [ + { + id: 'atlassian', name: 'A', kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', envVar: 'TEST_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }); + const querySpy = vi.fn(() => + (async function* () { + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + // 既存 agent (extract-questions) で試行 + await runAgent({ + sdk, store, agentName: 'extract-questions', input: { nodeId: 'n-anchor' }, + projectDir: root, + }); + const call = querySpy.mock.calls[0][0] as any; + expect(Object.keys(call.options.mcpServers)).toEqual(expect.arrayContaining(['tally', 'atlassian'])); +}); +``` + +- [ ] **Step 15-2: test fail** + +Run: `pnpm -F @tally/ai-engine test -- agent-runner` + +- [ ] **Step 15-3: agent-runner.ts の sdk.query options を buildMcpServers 経由にする** + +`packages/ai-engine/src/agent-runner.ts:114` 周辺を書き換え: + +```typescript +import { buildMcpServers } from './mcp/build-mcp-servers'; +import { redactMcpSecrets } from './mcp/redact'; + +// runAgent 内で project meta を取得 → buildMcpServers +const projectMeta = await store.getProjectMeta(); +const externalConfigs = projectMeta?.mcpServers ?? []; +const { mcpServers, allowedTools: externalAllowed } = buildMcpServers({ + tallyMcp: mcp, + configs: externalConfigs, +}); + +// agent の allowedTools + 外部 MCP allowedTools を合成 +const finalAllowedTools = [ + ...agentDef.allowedTools, + ...externalAllowed.filter((t) => t !== 'mcp__tally__*'), // agentDef 側に既に具体指定あれば dedup +]; + +const iter = sdk.query({ + prompt, + options: { + systemPrompt, + mcpServers, + tools: [], + allowedTools: finalAllowedTools, + permissionMode: 'dontAsk', + settingSources: [], + cwd: agentCwd, + // ... + }, +}); + +// ログ redaction +console.log('[agent-runner] msg:', JSON.stringify(redactMcpSecrets(msg)).slice(0, 200)); +``` + +- [ ] **Step 15-4: regression snapshot — 既存 5 agent の動作不変** + +各 agent (decompose-to-stories / extract-questions / find-related-code / analyze-impact / ingest-document) に対して: +- mcpServers[] 空のプロジェクトで runAgent を走らせる +- sdk.query に渡る mcpServers が `{ tally }` のみ +- allowedTools が既存 agentDef.allowedTools と一致 +- agent event の emit シーケンスが不変 + +```typescript +it.each([ + 'decompose-to-stories', + 'extract-questions', + 'find-related-code', + 'analyze-impact', + 'ingest-document', +] as const)('%s は mcpServers[] 空で既存動作不変', async (agentName) => { + // ... 各 agent に最小 valid input を渡し、sdk.query の options snapshot を記録 + // 期待: mcpServers = { tally }, allowedTools = agentDef.allowedTools (外部 MCP 合成なし) +}); +``` + +- [ ] **Step 15-5: test pass** + +Run: `pnpm -F @tally/ai-engine test` + +- [ ] **Step 15-6: commit** + +```bash +git add packages/ai-engine/src/agent-runner.ts packages/ai-engine/src/agent-runner.test.ts +git commit -m "feat(ai-engine): agent-runner を buildMcpServers に統合 (chat-runner と共有、5 agent regression OK)" +``` + +--- + +## Task 16: プロジェクト設定 API — mcpServers round-trip + +**Files:** +- Modify: `packages/frontend/src/app/api/projects/[id]/route.ts` +- Modify: `packages/frontend/src/lib/api.ts` +- Test: `packages/frontend/src/app/api/projects/[id]/route.test.ts` (既存 or 新規) + +- [ ] **Step 16-1: failing test — GET/PUT で mcpServers を round-trip** + +```typescript +// packages/frontend/src/app/api/projects/[id]/route.test.ts +it('PUT with mcpServers → GET で同じ mcpServers が返る', async () => { + // ... test setup + const putRes = await PUT(/* ... */, { + params: { id: 'proj-1' }, + body: { + // 既存 project fields + mcpServers: [ + { + id: 'atlassian', name: 'A', kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', envVar: 'ATLASSIAN_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }, + }); + expect(putRes.status).toBe(200); + + const getRes = await GET(/* ... */, { params: { id: 'proj-1' } }); + const json = await getRes.json(); + expect(json.mcpServers).toHaveLength(1); + expect(json.mcpServers[0].auth.envVar).toBe('ATLASSIAN_PAT'); +}); +``` + +- [ ] **Step 16-2: test fail (まだ route.ts が mcpServers を受けない)** + +- [ ] **Step 16-3: route.ts を修正** + +既存 PUT handler のバリデーションに `mcpServers: McpServerConfig[]` を含めた入力受付を追加。既存 ProjectSchema/ProjectMetaSchema 経由で zod parse が自動的に mcpServers を受けるようになっている (Task 2 で default [] を追加済み) ため、handler 側は明示フィールド追加不要の可能性あり。それでも入力 shape を再確認: + +```typescript +// route.ts の PUT 内 +const body = await request.json(); +const parsed = UpdateProjectInputSchema.parse(body); +// parsed.mcpServers が自動で入る +await projectStore.saveProjectMeta({ + ...existingMeta, + name: parsed.name ?? existingMeta.name, + codebases: parsed.codebases ?? existingMeta.codebases, + mcpServers: parsed.mcpServers ?? existingMeta.mcpServers ?? [], + updatedAt: new Date().toISOString(), +}); +``` + +`packages/frontend/src/lib/api.ts` の UpdateProjectInput 型 (または zod schema) に mcpServers を追加: + +```typescript +export const UpdateProjectInputSchema = z.object({ + name: z.string().optional(), + codebases: z.array(CodebaseSchema).optional(), + mcpServers: z.array(McpServerConfigSchema).optional(), +}); +``` + +- [ ] **Step 16-4: test pass** + +Run: `pnpm -F @tally/frontend test` + +- [ ] **Step 16-5: commit** + +```bash +git add packages/frontend/src/app/api/projects/ packages/frontend/src/lib/api.ts +git commit -m "feat(frontend): projects API が mcpServers[] を受け取る" +``` + +--- + +## Task 17: 設定ダイアログ UI — mcpServers CRUD + +**Files:** +- Modify: `packages/frontend/src/components/dialog/project-settings-dialog.tsx` +- Test: `packages/frontend/src/components/dialog/project-settings-dialog.test.tsx` + +- [ ] **Step 17-1: failing test — mcpServers セクションの CRUD** + +```typescript +it('mcpServers[] セクションで新規追加 → name/url/envVar 入力 → 保存 → 再表示で復元', async () => { + const onSave = vi.fn(); + const { getByText, getByLabelText } = render( + , + ); + fireEvent.click(getByText('MCP サーバーを追加')); + fireEvent.change(getByLabelText('表示名'), { target: { value: 'Atlassian' } }); + fireEvent.change(getByLabelText('URL'), { target: { value: 'https://t.test/mcp' } }); + fireEvent.change(getByLabelText('環境変数名'), { target: { value: 'ATLASSIAN_PAT' } }); + fireEvent.click(getByText('保存')); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServers: [ + expect.objectContaining({ + name: 'Atlassian', + url: 'https://t.test/mcp', + auth: expect.objectContaining({ envVar: 'ATLASSIAN_PAT' }), + }), + ], + }), + ); +}); + +it('secret 値 (PAT) の入力欄は表示しない', () => { + const { queryByText, queryByLabelText } = render( + {}} />, + ); + expect(queryByLabelText('PAT')).toBeNull(); + expect(queryByLabelText('シークレット')).toBeNull(); + expect(queryByText(/\.env/)).toBeTruthy(); // 「PAT は .env ファイルに置いてください」的な説明 +}); +``` + +- [ ] **Step 17-2: test fail** + +- [ ] **Step 17-3: 実装 — ダイアログに mcpServers セクションを追加** + +既存の project-settings-dialog.tsx に新セクション: + +```tsx +// ProjectSettingsDialog 内の JSX +
+

MCP サーバー (外部連携)

+

+ AI が外部の情報源にアクセスするための接続先。 + 秘密値 (PAT など) はこのフォームではなく .env ファイルに置いてください + (例: ATLASSIAN_PAT=...)。 +

+ {mcpServers.map((s, i) => ( +
+ updateMcpServer(i, { ...s, id: e.target.value })} + /> + updateMcpServer(i, { ...s, name: e.target.value })} + /> + + updateMcpServer(i, { ...s, url: e.target.value })} + /> + + updateMcpServer(i, { ...s, auth: { ...s.auth, envVar: e.target.value } }) + } + /> + +
+ ))} + +
+``` + +`addMcpServer`, `updateMcpServer`, `removeMcpServer` は local state handler。保存時に `onSave({ ..., mcpServers })` を呼ぶ。 + +- [ ] **Step 17-4: test pass** + +Run: `pnpm -F @tally/frontend test` + +- [ ] **Step 17-5: commit** + +```bash +git add packages/frontend/src/components/dialog/ +git commit -m "feat(frontend): プロジェクト設定に MCP サーバー CRUD UI を追加 (secret は .env で管理)" +``` + +--- + +## Task 18: Chat UI — source 分岐 (external は承認 UI 出さない) + +**Files:** +- Modify: `packages/frontend/src/components/chat/tool-approval-card.tsx` +- Modify: `packages/frontend/src/components/chat/chat-tab.tsx` +- Test: 各 test + +- [ ] **Step 18-1: failing test — external tool_use は承認ボタンが出ない** + +```typescript +// tool-approval-card.test.tsx +it('source=external の tool_use は承認ボタンを表示しない', () => { + const { queryByText, getByText } = render( + {}} + onReject={() => {}} + />, + ); + expect(queryByText('承認')).toBeNull(); + expect(queryByText('却下')).toBeNull(); + expect(getByText(/getJiraIssue/)).toBeTruthy(); // AI が読んだ外部ソース表示 +}); + +it('source=internal (approval=pending) の tool_use は承認ボタンが出る (既存挙動 regression)', () => { + const { getByText } = render( + {}} + onReject={() => {}} + />, + ); + expect(getByText('承認')).toBeTruthy(); +}); +``` + +- [ ] **Step 18-2: test fail** + +- [ ] **Step 18-3: tool-approval-card.tsx を source 分岐** + +```tsx +export function ToolApprovalCard({ block, onApprove, onReject }: Props) { + if (block.source === 'external') { + return ( +
+
+ 🔗 外部ソース: {block.name} +
{JSON.stringify(block.input, null, 2)}
+
+
+ ); + } + // 既存 internal + approval=pending/approved/rejected 表示 (変更なし) + return
{/* 既存 JSX */}
; +} +``` + +- [ ] **Step 18-4: chat-tab.tsx で external tool_result を折り畳み表示** + +chat-tab.tsx 内 block レンダリング箇所: + +```tsx +{block.type === 'tool_result' && ( +
+ tool_result ({block.ok ? 'OK' : 'ERROR'}) +
+      {block.output}
+    
+
+)} +``` + +- [ ] **Step 18-5: test pass** + +Run: `pnpm -F @tally/frontend test` + +- [ ] **Step 18-6: commit** + +```bash +git add packages/frontend/src/components/chat/ +git commit -m "feat(frontend): Chat UI が外部 MCP の tool_use/tool_result を折り畳み表示 (承認ボタン非表示)" +``` + +--- + +## Task 19: Dogfooding Protocol (手動、実装ではなく運用手順) + +**Files:** +- Create: `docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md` + +これは実装タスクではない。Task 1-18 完了後、自分の手元で 10 個の Jira エピックを使って動作確認し、Success Criteria を測る。plan ではこの手順だけを明記する。 + +- [ ] **Step 19-1: dogfooding log ファイルを作成** + +```bash +cat > docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md <<'EOF' +# Dogfood Log — Atlassian MCP C フェーズ + +## Setup +- .env に `ATLASSIAN_PAT=` を設定 +- プロジェクト設定で MCP サーバー追加: kind=atlassian, url=, envVar=ATLASSIAN_PAT + +## Epic 1-10 +### Epic N: +- **Turn 1:** `@JIRA を読んで論点を出して` + - 生成 question proposal: N 個 + - 所要時間: <秒> + - 採用: 、却下: + - 採用判断の理由: +- **Turn 2 (multi-turn test):** `続けて子チケット を読んで論点を追加して` + - AI が前ターンの Epic 内容を覚えているか: YES / NO + - 生成 question proposal: N 個 + - 採用: +- **「気づかなかった論点」判定:** YES / NO、YES なら具体: +- **重複ガード動作:** 同 URL 2 度目取り込み → sourceUrl guard 発動: YES / NO + +## 集計 +- 合計生成 question proposal: N 個 +- 合計採用数: N 個 +- 採用率: N% (target: 50%+) +- 「気づかなかった論点」合計: N 件 (target: 3+) +- multi-turn が機能した Epic: N/10 (target: 10/10) +- 重複ガード発動数 / 試行数: N/N + +## 観察メモ (A フェーズの ingest-jira-epic プロンプト設計の入力) +- プロンプト改善点: +- tool 呼び出しパターン: +- レイテンシ分布: +- 失敗パターン (接続失敗 / rate limit / タイムアウト): +EOF +``` + +- [ ] **Step 19-2: 実際に 10 epic で dogfood** + +ユーザーが手元で実施、上記 log に記録。 + +- [ ] **Step 19-3: Success Criteria 判定** + +C フェーズの Success Criteria: +- 90 秒以内に question proposal 3 個以上 (all 10 epics で満たすこと) +- 採用率 50%+ +- 「気づかなかった論点」3+ 件 +- multi-turn での context 保持 + +満たせば A フェーズへ。満たさなければ Task 1-18 のどこかに追加修正。 + +- [ ] **Step 19-4: commit (dogfood log)** + +```bash +git add docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md +git commit -m "docs: Atlassian MCP C フェーズ dogfood log を記録" +``` + +--- + +## Final Verification + +C フェーズ完了条件: + +- [ ] `pnpm test` 全パッケージ PASS +- [ ] `pnpm -F @tally/ai-engine test` の既存 agent 5 個 (decompose-to-stories / extract-questions / find-related-code / analyze-impact / ingest-document) が regression なしで通る +- [ ] 既存 Chat の Tally MCP 承認フロー (pending → approve → executed) が動作不変 +- [ ] `pnpm lint` PASS (Biome) +- [ ] `pnpm typecheck` PASS (tsc) +- [ ] dogfood log が 10 epic 分記録されている +- [ ] C フェーズ Success Criteria 満たす + +これで A フェーズ (`ingest-jira-epic` agent + 専用ボタン + ADR) の plan を別途書ける。 + +--- + +## Self-Review + +- [x] **Spec coverage:** design doc の C フェーズ Step 1-8 すべてをタスクに落としている。Issue 1-9 + T1-T4 すべてに対応する task がある。 +- [x] **Placeholder scan:** "TBD" / "implement later" / "add validation" なし。各 step にコード記述あり。 +- [x] **Type consistency:** `McpServerConfig` / `DuplicateGuard` / `ChatBlock` の型名・フィールド名が全タスクで一致。 +- [x] **spec 対応:** Test Plan の Coverage Diagram 54 GAP のうち主要 file 単位のテストをすべて TDD で組み込み。dogfooding は Task 19 で記録。 +- [x] **Parallel 可能性:** Task 1-3 (core schema) → Task 4-9 (ai-engine utilities) → Task 10 (create-node refactor) → Task 11-14 (ChatRunner) → Task 15 (agent-runner) → Task 16-18 (frontend) の依存関係は直列寄り。Task 4/5/6 は相互独立なので worktree 並列可。Task 16/17/18 も frontend 内で独立ファイルなので並列可。 From 9b7e64e315fd01de74982d7e473ca681fcf53e54 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 13:53:35 +0900 Subject: [PATCH 02/34] =?UTF-8?q?docs:=20Atlassian=20MCP=20plan=20?= =?UTF-8?q?=E3=81=AB=20Spike=200a/0b=20=E7=B5=90=E6=9E=9C=E3=82=92?= =?UTF-8?q?=E5=8F=8D=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Task 1: McpServerConfigSchema を Basic/Bearer 両対応に拡張 (Cloud=Basic email:token base64、Server/DC=Bearer) - Task 5: buildMcpServers の auth header 構築を scheme 分岐 - 関連テストケース更新 --- .../plans/2026-04-24-atlassian-mcp-c-phase.md | 194 ++++++++++++++---- 1 file changed, 150 insertions(+), 44 deletions(-) diff --git a/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase.md b/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase.md index 020fc30..6dd1ed7 100644 --- a/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase.md +++ b/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase.md @@ -78,6 +78,8 @@ - Modify: `packages/core/src/types.ts` - Test: `packages/core/src/schema.test.ts` +**Spike 0a の結果を反映**: auth は Basic (Cloud) / Bearer (Server/DC) の 2 scheme。Basic の場合は email + token の両方が必要なので、envVar を `emailEnvVar` + `tokenEnvVar` に分離した discriminated union。 + - [ ] **Step 1-1: failing test を書く — `McpServerConfigSchema` の round-trip** `packages/core/src/schema.test.ts` に追記: @@ -86,26 +88,56 @@ import { McpServerConfigSchema } from './schema'; describe('McpServerConfigSchema', () => { - it('atlassian kind + PAT auth の round-trip が通る', () => { + it('Cloud (basic) auth の round-trip が通る', () => { const raw = { - id: 'atlassian-main', + id: 'atlassian-cloud', name: 'Atlassian Cloud', kind: 'atlassian' as const, - url: 'https://mcp.atlassian.example/mcp', - auth: { type: 'pat' as const, envVar: 'ATLASSIAN_PAT' }, + url: 'https://mcp.atlassian.example/v1/mcp', + auth: { + type: 'pat' as const, + scheme: 'basic' as const, + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }; + const parsed = McpServerConfigSchema.parse(raw); + expect(parsed).toEqual(raw); + }); + + it('Server/DC (bearer) auth の round-trip が通る', () => { + const raw = { + id: 'atlassian-onprem', + name: 'Atlassian On-Prem', + kind: 'atlassian' as const, + url: 'https://jira.example.com/mcp', + auth: { + type: 'pat' as const, + scheme: 'bearer' as const, + tokenEnvVar: 'JIRA_PAT', + }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }; const parsed = McpServerConfigSchema.parse(raw); expect(parsed).toEqual(raw); }); + it('basic で emailEnvVar 無しは fail', () => { + expect(() => + McpServerConfigSchema.parse({ + id: 'a', name: 'A', kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'basic', tokenEnvVar: 'T' }, + }), + ).toThrow(); + }); + it('options 未指定なら default が入る', () => { const parsed = McpServerConfigSchema.parse({ - id: 'a', - name: 'A', - kind: 'atlassian', + id: 'a', name: 'A', kind: 'atlassian', url: 'https://x.test/mcp', - auth: { type: 'pat', envVar: 'X_PAT' }, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X_PAT' }, }); expect(parsed.options.maxChildIssues).toBe(30); expect(parsed.options.maxCommentsPerIssue).toBe(5); @@ -115,7 +147,7 @@ describe('McpServerConfigSchema', () => { expect(() => McpServerConfigSchema.parse({ id: 'a', name: 'A', kind: 'atlassian', url: 'not a url', - auth: { type: 'pat', envVar: 'X' }, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X' }, }), ).toThrow(); }); @@ -132,15 +164,28 @@ Expected: FAIL with "McpServerConfigSchema is not exported" 既存 `ProjectSchema` の直前に追加: ```typescript +// Atlassian Cloud は Basic (base64(email:token))、Server/DC は Bearer (pat) の 2 scheme。 +// どちらも PAT ベースの認証 (OAuth は MVP 非対応、Premise 9)。 +const McpAuthSchema = z.discriminatedUnion('scheme', [ + z.object({ + type: z.literal('pat'), + scheme: z.literal('basic'), + emailEnvVar: z.string().min(1), // 例 "ATLASSIAN_EMAIL" + tokenEnvVar: z.string().min(1), // 例 "ATLASSIAN_API_TOKEN" + }), + z.object({ + type: z.literal('pat'), + scheme: z.literal('bearer'), + tokenEnvVar: z.string().min(1), // 例 "JIRA_PAT" + }), +]); + export const McpServerConfigSchema = z.object({ id: z.string().min(1), name: z.string().min(1), kind: z.literal('atlassian'), url: z.string().url(), - auth: z.object({ - type: z.literal('pat'), - envVar: z.string().min(1), - }), + auth: McpAuthSchema, options: z .object({ maxChildIssues: z.number().int().positive().default(30), @@ -457,11 +502,13 @@ git commit -m "feat(ai-engine): redactMcpSecrets を追加 (Authorization header - Create: `packages/ai-engine/src/mcp/build-mcp-servers.ts` - Create: `packages/ai-engine/src/mcp/build-mcp-servers.test.ts` +**Spike 0a/0b の結果を反映**: auth.scheme で Basic/Bearer 分岐、Basic は `base64(email:token)`。allowedTools は wildcard `mcp____*` を使用 (Spike 0b で確認)。 + - [ ] **Step 5-1: failing test** ```typescript // packages/ai-engine/src/mcp/build-mcp-servers.test.ts -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { buildMcpServers } from './build-mcp-servers'; describe('buildMcpServers', () => { @@ -476,29 +523,52 @@ describe('buildMcpServers', () => { expect(result.allowedTools).toEqual(['mcp__tally__*']); }); - it('atlassian 1 個 + env 設定済み → HTTP config + allowedTools 合成', () => { - process.env.ATLASSIAN_PAT = 'secret-xyz'; + it('Bearer (Server/DC) → Authorization: Bearer ', () => { + process.env.JIRA_PAT = 'secret-xyz'; const result = buildMcpServers({ tallyMcp: { type: 'sdk' } as any, configs: [ { - id: 'atlassian-main', name: 'A', kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { type: 'pat', envVar: 'ATLASSIAN_PAT' }, + id: 'atlassian-dc', name: 'A', kind: 'atlassian', + url: 'https://jira.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], }); - const atlassian = result.mcpServers['atlassian-main'] as any; + const atlassian = result.mcpServers['atlassian-dc'] as any; expect(atlassian.type).toBe('http'); - expect(atlassian.url).toBe('https://x.test/mcp'); + expect(atlassian.url).toBe('https://jira.test/mcp'); expect(atlassian.headers.Authorization).toBe('Bearer secret-xyz'); expect(result.allowedTools).toContain('mcp__tally__*'); - expect(result.allowedTools).toContain('mcp__atlassian-main__*'); + expect(result.allowedTools).toContain('mcp__atlassian-dc__*'); }); - it('env 未設定 → throw', () => { - delete process.env.ATLASSIAN_PAT; + it('Basic (Cloud) → Authorization: Basic ', () => { + process.env.ATLASSIAN_EMAIL = 'user@example.com'; + process.env.ATLASSIAN_API_TOKEN = 'api-token-xyz'; + const result = buildMcpServers({ + tallyMcp: { type: 'sdk' } as any, + configs: [ + { + id: 'atlassian-cloud', name: 'A', kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { + type: 'pat', scheme: 'basic', + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }); + const atlassian = result.mcpServers['atlassian-cloud'] as any; + const expected = Buffer.from('user@example.com:api-token-xyz').toString('base64'); + expect(atlassian.headers.Authorization).toBe(`Basic ${expected}`); + }); + + it('Bearer の tokenEnvVar 未設定 → throw', () => { + delete process.env.JIRA_PAT; expect(() => buildMcpServers({ tallyMcp: { type: 'sdk' } as any, @@ -506,16 +576,17 @@ describe('buildMcpServers', () => { { id: 'a', name: 'A', kind: 'atlassian', url: 'https://x.test/mcp', - auth: { type: 'pat', envVar: 'ATLASSIAN_PAT' }, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], }), - ).toThrowError(/ATLASSIAN_PAT/); + ).toThrowError(/JIRA_PAT/); }); - it('env が空文字 → throw', () => { - process.env.ATLASSIAN_PAT = ''; + it('Basic の emailEnvVar 未設定 → throw', () => { + delete process.env.ATLASSIAN_EMAIL; + process.env.ATLASSIAN_API_TOKEN = 'x'; expect(() => buildMcpServers({ tallyMcp: { type: 'sdk' } as any, @@ -523,12 +594,32 @@ describe('buildMcpServers', () => { { id: 'a', name: 'A', kind: 'atlassian', url: 'https://x.test/mcp', - auth: { type: 'pat', envVar: 'ATLASSIAN_PAT' }, + auth: { + type: 'pat', scheme: 'basic', + emailEnvVar: 'ATLASSIAN_EMAIL', tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], }), - ).toThrowError(/ATLASSIAN_PAT/); + ).toThrowError(/ATLASSIAN_EMAIL/); + }); + + it('env 値が空文字でも → throw', () => { + process.env.JIRA_PAT = ''; + expect(() => + buildMcpServers({ + tallyMcp: { type: 'sdk' } as any, + configs: [ + { + id: 'a', name: 'A', kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + ).toThrowError(/JIRA_PAT/); }); }); ``` @@ -538,14 +629,14 @@ describe('buildMcpServers', () => { Run: `pnpm -F @tally/ai-engine test -- build-mcp-servers.test` Expected: FAIL (未実装) -- [ ] **Step 5-3: 実装 — wildcard 版 (Spike 0b で wildcard OK だった場合)** +- [ ] **Step 5-3: 実装** ```typescript // packages/ai-engine/src/mcp/build-mcp-servers.ts import type { McpServerConfig } from '@tally/core'; -// SDK の型に縛らず、chat-runner / agent-runner が共通で使える shape にする。 // SDK の mcpServers は Record を受ける (sdk.d.ts:1386 参照)。 +// chat-runner / agent-runner が共通で使える shape にする。 export interface BuildMcpServersInput { // createSdkMcpServer で組み立てた Tally MCP。ここでは opaque。 tallyMcp: unknown; @@ -558,9 +649,31 @@ export interface BuildMcpServersResult { allowedTools: string[]; } -// SDK 設定と allowedTools を組み立てる。env var 未設定なら throw。 -// 呼び出し元 (chat-runner / agent-runner) は runUserTurn の都度これを呼ぶ -// → env 変更がホットリロードされる。 +function requireEnv(varName: string, contextId: string): string { + const v = process.env[varName]; + if (v === undefined || v === '') { + throw new Error( + `MCP 設定 "${contextId}" の env var "${varName}" が未設定または空です`, + ); + } + return v; +} + +function buildAuthHeader(auth: McpServerConfig['auth'], contextId: string): string { + if (auth.scheme === 'bearer') { + const token = requireEnv(auth.tokenEnvVar, contextId); + return `Bearer ${token}`; + } + // basic + const email = requireEnv(auth.emailEnvVar, contextId); + const token = requireEnv(auth.tokenEnvVar, contextId); + const b64 = Buffer.from(`${email}:${token}`).toString('base64'); + return `Basic ${b64}`; +} + +// SDK 設定と allowedTools を組み立てる。env 未設定は throw。 +// 呼び出し元は runUserTurn の都度これを呼ぶ → env 変更がホットリロードされる。 +// allowedTools は wildcard `mcp____*` を使用 (Spike 0b で SDK サポート確認済み)。 export function buildMcpServers(input: BuildMcpServersInput): BuildMcpServersResult { const { tallyMcp, configs } = input; @@ -568,16 +681,11 @@ export function buildMcpServers(input: BuildMcpServersInput): BuildMcpServersRes const allowedTools: string[] = ['mcp__tally__*']; for (const cfg of configs) { - const token = process.env[cfg.auth.envVar]; - if (token === undefined || token === '') { - throw new Error( - `MCP 設定 "${cfg.id}" の env var "${cfg.auth.envVar}" が未設定です`, - ); - } + const authHeader = buildAuthHeader(cfg.auth, cfg.id); mcpServers[cfg.id] = { type: 'http' as const, url: cfg.url, - headers: { Authorization: `Bearer ${token}` }, + headers: { Authorization: authHeader }, }; allowedTools.push(`mcp__${cfg.id}__*`); } @@ -586,8 +694,6 @@ export function buildMcpServers(input: BuildMcpServersInput): BuildMcpServersRes } ``` -**注記:** Spike 0b で wildcard が効かないと判明したら、`allowedTools` 生成部を `cfg` に応じた実 tool 名列挙に置き換える。tool 名一覧は Spike 0a で記録した design doc 末尾 Footnote を参照。kind 別の tool リストを map にして、`cfg.kind === 'atlassian'` なら `ATLASSIAN_TOOLS.map(t => \`mcp__\${cfg.id}__\${t}\`)` を push する形。 - - [ ] **Step 5-4: test pass** Run: `pnpm -F @tally/ai-engine test -- build-mcp-servers.test` From ada730162fab2a0d99fae2094438ef8ec1282324 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Fri, 24 Apr 2026 16:01:23 +0900 Subject: [PATCH 03/34] =?UTF-8?q?feat(core):=20McpServerConfigSchema=20?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=20(Atlassian=20MCP=20=E9=80=A3?= =?UTF-8?q?=E6=90=BA=E3=81=AE=E5=9F=BA=E7=9B=A4=E3=80=81Basic/Bearer=20?= =?UTF-8?q?=E4=B8=A1=E5=AF=BE=E5=BF=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/schema.test.ts | 74 ++++++++++++++++++++++++++++++++ packages/core/src/schema.ts | 41 ++++++++++++++++++ packages/core/src/types.ts | 2 +- 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index b2199ed..a5b330c 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -8,6 +8,7 @@ import { CodebaseSchema, CodeRefNodeSchema, EdgeSchema, + McpServerConfigSchema, NodeSchema, ProjectMetaSchema, ProposalNodeSchema, @@ -317,3 +318,76 @@ describe('ChatThreadSchema / ChatThreadMetaSchema', () => { ).toBe(true); }); }); + +describe('McpServerConfigSchema', () => { + it('Cloud (basic) auth の round-trip が通る', () => { + const raw = { + id: 'atlassian-cloud', + name: 'Atlassian Cloud', + kind: 'atlassian' as const, + url: 'https://mcp.atlassian.example/v1/mcp', + auth: { + type: 'pat' as const, + scheme: 'basic' as const, + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }; + const parsed = McpServerConfigSchema.parse(raw); + expect(parsed).toEqual(raw); + }); + + it('Server/DC (bearer) auth の round-trip が通る', () => { + const raw = { + id: 'atlassian-onprem', + name: 'Atlassian On-Prem', + kind: 'atlassian' as const, + url: 'https://jira.example.com/mcp', + auth: { + type: 'pat' as const, + scheme: 'bearer' as const, + tokenEnvVar: 'JIRA_PAT', + }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }; + const parsed = McpServerConfigSchema.parse(raw); + expect(parsed).toEqual(raw); + }); + + it('basic で emailEnvVar 無しは fail', () => { + expect(() => + McpServerConfigSchema.parse({ + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'basic', tokenEnvVar: 'T' }, + }), + ).toThrow(); + }); + + it('options 未指定なら default が入る', () => { + const parsed = McpServerConfigSchema.parse({ + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X_PAT' }, + }); + expect(parsed.options.maxChildIssues).toBe(30); + expect(parsed.options.maxCommentsPerIssue).toBe(5); + }); + + it('url が URL でないと fail', () => { + expect(() => + McpServerConfigSchema.parse({ + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'not a url', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X' }, + }), + ).toThrow(); + }); +}); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 849e3c9..9583b75 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -188,6 +188,47 @@ export const ProjectMetaSchema = z }) .superRefine((meta, ctx) => checkUniqueCodebaseIds(meta.codebases, ctx)); +// --------------------------------------------------------------------------- +// MCP サーバー設定スキーマ (Atlassian MCP 連携) +// --------------------------------------------------------------------------- + +// Atlassian Cloud は Basic (base64(email:token))、Server/DC は Bearer (pat) の 2 scheme。 +// どちらも PAT ベースの認証 (OAuth は MVP 非対応、Premise 9)。 +const McpAuthSchema = z.discriminatedUnion('scheme', [ + z.object({ + type: z.literal('pat'), + scheme: z.literal('basic'), + emailEnvVar: z.string().min(1), // 例 "ATLASSIAN_EMAIL" + tokenEnvVar: z.string().min(1), // 例 "ATLASSIAN_API_TOKEN" + }), + z.object({ + type: z.literal('pat'), + scheme: z.literal('bearer'), + tokenEnvVar: z.string().min(1), // 例 "JIRA_PAT" + }), +]); + +// options は未指定時に {} を default として与え、内側で各フィールドの default を発火させる。 +// zod v4 では outer .default(value) が parse 前に value をそのまま流すため、 +// 入力と同じ経路でフィールド default を解決するには .default({}) → inner default の 2 段構え。 +const McpServerOptionsSchema = z + .object({ + maxChildIssues: z.number().int().positive().default(30), + maxCommentsPerIssue: z.number().int().nonnegative().default(5), + }) + .default(() => ({ maxChildIssues: 30, maxCommentsPerIssue: 5 })); + +export const McpServerConfigSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + kind: z.literal('atlassian'), + url: z.string().url(), + auth: McpAuthSchema, + options: McpServerOptionsSchema, +}); + +export type McpServerConfig = z.infer; + // 実行時に Project 全体を扱う際の合成スキーマ (メモリ上表現)。 export const ProjectSchema = z .object({ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bc9dcdf..0bc7a08 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -20,7 +20,7 @@ import type { UserStoryNodeSchema, } from './schema'; -export type { ChatBlock, ChatMessage, ChatThread, ChatThreadMeta } from './schema'; +export type { ChatBlock, ChatMessage, ChatThread, ChatThreadMeta, McpServerConfig } from './schema'; export type NodeType = (typeof NODE_TYPES)[number]; export type EdgeType = (typeof EDGE_TYPES)[number]; From 1383cf752ca3051fe152566dcbff6c92ac7dc661 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 13:58:23 +0900 Subject: [PATCH 04/34] =?UTF-8?q?feat(core):=20McpServerConfigSchema=20?= =?UTF-8?q?=E3=81=AB=20hardening=203=20=E4=BB=B6=20(https=20=E5=BC=B7?= =?UTF-8?q?=E5=88=B6=20/=20id=20charset=20/=20envVar=20=E5=90=8D=20regex)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/schema.test.ts | 177 +++++++++++++++++++++++++++++++ packages/core/src/schema.ts | 48 ++++++++- 2 files changed, 220 insertions(+), 5 deletions(-) diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index a5b330c..765d500 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -391,3 +391,180 @@ describe('McpServerConfigSchema', () => { ).toThrow(); }); }); + +describe('McpServerConfigSchema hardening', () => { + // hardening test の共通 valid base。テスト対象のフィールドだけを上書きする。 + const validBase = { + id: 'atlassian', + name: 'Atlassian', + kind: 'atlassian' as const, + url: 'https://mcp.atlassian.example/v1/mcp', + auth: { + type: 'pat' as const, + scheme: 'bearer' as const, + tokenEnvVar: 'JIRA_PAT', + }, + }; + + describe('url: https 強制 + loopback 例外', () => { + it('https スキームは pass', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'https://x.test/mcp' }), + ).not.toThrow(); + }); + + it('http://localhost は pass (sooperset セルフホスト想定)', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'http://localhost:9000/mcp' }), + ).not.toThrow(); + }); + + it('http://127.0.0.1 は pass', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'http://127.0.0.1:9000/mcp' }), + ).not.toThrow(); + }); + + it('http://example.com は fail (cleartext で credential が漏れる)', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'http://example.com/mcp' }), + ).toThrow(); + }); + + it('ftp:// は fail', () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, url: 'ftp://x.test/mcp' }), + ).toThrow(); + }); + }); + + describe('id: charset 制約 (CodebaseSchema.id と同じ regex)', () => { + it("'atlassian' は pass", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'atlassian' })).not.toThrow(); + }); + + it("'atlassian-cloud' は pass", () => { + expect(() => + McpServerConfigSchema.parse({ ...validBase, id: 'atlassian-cloud' }), + ).not.toThrow(); + }); + + it("'a' は pass (1 文字)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'a' })).not.toThrow(); + }); + + it("'Atlassian' は fail (大文字)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'Atlassian' })).toThrow(); + }); + + it("'1abc' は fail (数字始まり)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: '1abc' })).toThrow(); + }); + + it("'a_b' は fail (アンダースコア含む)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'a_b' })).toThrow(); + }); + + it("'a.b' は fail (ドット含む)", () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'a.b' })).toThrow(); + }); + + it('33 文字は fail (上限超過)', () => { + expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'a'.repeat(33) })).toThrow(); + }); + }); + + describe('emailEnvVar / tokenEnvVar: env var 名 regex', () => { + const baseBasic = { + ...validBase, + auth: { + type: 'pat' as const, + scheme: 'basic' as const, + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + }; + + it("'ATLASSIAN_PAT' は pass", () => { + expect(() => + McpServerConfigSchema.parse({ + ...validBase, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'ATLASSIAN_PAT' }, + }), + ).not.toThrow(); + }); + + it("'JIRA_PAT_1' は pass (数字含む OK)", () => { + expect(() => + McpServerConfigSchema.parse({ + ...validBase, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT_1' }, + }), + ).not.toThrow(); + }); + + it("'A' は pass (1 文字大文字)", () => { + expect(() => + McpServerConfigSchema.parse({ + ...validBase, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'A' }, + }), + ).not.toThrow(); + }); + + it("'lowercase' は fail", () => { + expect(() => + McpServerConfigSchema.parse({ + ...validBase, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'lowercase' }, + }), + ).toThrow(); + }); + + it("'foo@bar.com' は fail (実値混入を防ぐ)", () => { + expect(() => + McpServerConfigSchema.parse({ + ...baseBasic, + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: 'foo@bar.com', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + }), + ).toThrow(); + }); + + it("'1ABC' は fail (数字始まり)", () => { + expect(() => + McpServerConfigSchema.parse({ + ...validBase, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: '1ABC' }, + }), + ).toThrow(); + }); + + it("'' (空文字) は fail", () => { + expect(() => + McpServerConfigSchema.parse({ + ...validBase, + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: '' }, + }), + ).toThrow(); + }); + + it('basic auth の emailEnvVar も同じ regex を要求', () => { + expect(() => + McpServerConfigSchema.parse({ + ...baseBasic, + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: 'lowercase', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + }), + ).toThrow(); + }); + }); +}); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 9583b75..eb57127 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -192,19 +192,26 @@ export const ProjectMetaSchema = z // MCP サーバー設定スキーマ (Atlassian MCP 連携) // --------------------------------------------------------------------------- +// 環境変数名の shape (POSIX 準拠: 大文字英 + 数字 + アンダースコア、先頭は大文字英)。 +// 実値 (例 "foo@bar.com") の混入を防ぐ。空文字は regex で自動的に reject される。 +const ENV_VAR_NAME_REGEX = /^[A-Z][A-Z0-9_]*$/u; +const envVarName = z.string().regex(ENV_VAR_NAME_REGEX, { + message: 'env var 名は ^[A-Z][A-Z0-9_]*$ (大文字英始まり、英数字・_ のみ)', +}); + // Atlassian Cloud は Basic (base64(email:token))、Server/DC は Bearer (pat) の 2 scheme。 // どちらも PAT ベースの認証 (OAuth は MVP 非対応、Premise 9)。 const McpAuthSchema = z.discriminatedUnion('scheme', [ z.object({ type: z.literal('pat'), scheme: z.literal('basic'), - emailEnvVar: z.string().min(1), // 例 "ATLASSIAN_EMAIL" - tokenEnvVar: z.string().min(1), // 例 "ATLASSIAN_API_TOKEN" + emailEnvVar: envVarName, // 例 "ATLASSIAN_EMAIL" + tokenEnvVar: envVarName, // 例 "ATLASSIAN_API_TOKEN" }), z.object({ type: z.literal('pat'), scheme: z.literal('bearer'), - tokenEnvVar: z.string().min(1), // 例 "JIRA_PAT" + tokenEnvVar: envVarName, // 例 "JIRA_PAT" }), ]); @@ -218,11 +225,42 @@ const McpServerOptionsSchema = z }) .default(() => ({ maxChildIssues: 30, maxCommentsPerIssue: 5 })); +// MCP サーバー id は SDK の wildcard `mcp____*` の id 部分に embed されるため、 +// tool 名 matching が壊れないよう CodebaseSchema.id と同じ charset 制約を採用。 +const McpServerIdRegex = /^[a-z][a-z0-9-]{0,31}$/u; + export const McpServerConfigSchema = z.object({ - id: z.string().min(1), + id: z.string().regex(McpServerIdRegex, { + message: 'mcp server id は先頭英小文字 + 英小文字/数字/ハイフン、32 字以内', + }), name: z.string().min(1), kind: z.literal('atlassian'), - url: z.string().url(), + // PAT を Authorization header で送る transport なので cleartext を許さない。 + // 開発・テスト用の loopback (localhost / 127.0.0.1 / ::1) のみ http: を例外的に許容。 + url: z + .string() + .url() + .refine( + (u) => { + try { + const parsed = new URL(u); + if (parsed.protocol === 'https:') return true; + if ( + parsed.protocol === 'http:' && + (parsed.hostname === 'localhost' || + parsed.hostname === '127.0.0.1' || + parsed.hostname === '::1' || + parsed.hostname === '[::1]') + ) { + return true; + } + return false; + } catch { + return false; + } + }, + { message: 'url は https で始まる必要があります (loopback の http は例外的に許容)' }, + ), auth: McpAuthSchema, options: McpServerOptionsSchema, }); From 398778064ef684f7ec16eafc49e175857d46b0bc Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:08:40 +0900 Subject: [PATCH 05/34] =?UTF-8?q?feat(core):=20ProjectSchema/ProjectMetaSc?= =?UTF-8?q?hema=20=E3=81=AB=20mcpServers[]=20=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20(default=20[])?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlassian MCP 連携の Project レベル設定を保持するため、 ProjectSchema と ProjectMetaSchema に mcpServers: McpServerConfig[] を追加。 default は [] とし、mcpServers フィールドを持たない既存 YAML も後方互換で読み込める。 宣言順の都合で MCP セクションを ProjectMetaSchema より前に移動。 ProjectMeta / Project 型は z.input 由来に切り替え、書き込み側で mcpServers 省略可とした (output は parse 時に default [] が解決される)。 --- packages/core/src/schema.test.ts | 74 ++++++++++++++++++++++++++++++++ packages/core/src/schema.ts | 34 +++++++++------ packages/core/src/types.ts | 11 +++-- 3 files changed, 102 insertions(+), 17 deletions(-) diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index 765d500..6cd12c9 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -11,6 +11,7 @@ import { McpServerConfigSchema, NodeSchema, ProjectMetaSchema, + ProjectSchema, ProposalNodeSchema, QuestionNodeSchema, RequirementNodeSchema, @@ -568,3 +569,76 @@ describe('McpServerConfigSchema hardening', () => { }); }); }); + +describe('ProjectSchema.mcpServers', () => { + it('mcpServers 未指定なら default の空配列', () => { + const p = ProjectSchema.parse({ + id: 'p', + name: 'P', + codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + nodes: [], + edges: [], + }); + expect(p.mcpServers).toEqual([]); + }); + + it('mcpServers 指定で round-trip する', () => { + const input = { + id: 'p', + name: 'P', + codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + nodes: [], + edges: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian' as const, + url: 'https://x.test/mcp', + auth: { type: 'pat' as const, scheme: 'bearer' as const, tokenEnvVar: 'JIRA_PAT' }, + }, + ], + }; + const p = ProjectSchema.parse(input); + expect(p.mcpServers).toHaveLength(1); + expect(p.mcpServers[0]?.options.maxChildIssues).toBe(30); + expect(p.mcpServers[0]?.id).toBe('atlassian'); + }); +}); + +describe('ProjectMetaSchema.mcpServers', () => { + it('ProjectMetaSchema にも mcpServers が乗る (project.yaml の meta との整合)', () => { + const meta = ProjectMetaSchema.parse({ + id: 'p', + name: 'P', + codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian' as const, + url: 'https://x.test/mcp', + auth: { type: 'pat' as const, scheme: 'bearer' as const, tokenEnvVar: 'JIRA_PAT' }, + }, + ], + }); + expect(meta.mcpServers).toHaveLength(1); + }); + + it('既存 YAML (mcpServers 無し) は default [] で読める (後方互換)', () => { + const meta = ProjectMetaSchema.parse({ + id: 'p', + name: 'P', + codebases: [], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + }); + expect(meta.mcpServers).toEqual([]); + }); +}); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index eb57127..b950ddd 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -174,23 +174,11 @@ function checkUniqueCodebaseIds( } } -// .tally/project.yaml に対応する meta のみのスキーマ。 -// ノード・エッジはファイル分割で永続化するため、ここには含めない。 -export const ProjectMetaSchema = z - .object({ - id: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), - // 0 件以上。code ノードが存在するときは最低 1 件必要(整合性は storage 層で検証)。 - codebases: z.array(CodebaseSchema), - createdAt: z.string(), - updatedAt: z.string(), - }) - .superRefine((meta, ctx) => checkUniqueCodebaseIds(meta.codebases, ctx)); - // --------------------------------------------------------------------------- // MCP サーバー設定スキーマ (Atlassian MCP 連携) // --------------------------------------------------------------------------- +// 注: ProjectMetaSchema / ProjectSchema が McpServerConfigSchema を参照するため、 +// 宣言順序として MCP セクションを Project 系より前に置く。 // 環境変数名の shape (POSIX 準拠: 大文字英 + 数字 + アンダースコア、先頭は大文字英)。 // 実値 (例 "foo@bar.com") の混入を防ぐ。空文字は regex で自動的に reject される。 @@ -267,6 +255,22 @@ export const McpServerConfigSchema = z.object({ export type McpServerConfig = z.infer; +// .tally/project.yaml に対応する meta のみのスキーマ。 +// ノード・エッジはファイル分割で永続化するため、ここには含めない。 +export const ProjectMetaSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + // 0 件以上。code ノードが存在するときは最低 1 件必要(整合性は storage 層で検証)。 + codebases: z.array(CodebaseSchema), + // Atlassian 等の MCP サーバー設定。既存 YAML (フィールド無し) は default [] で読み込める。 + mcpServers: z.array(McpServerConfigSchema).default([]), + createdAt: z.string(), + updatedAt: z.string(), + }) + .superRefine((meta, ctx) => checkUniqueCodebaseIds(meta.codebases, ctx)); + // 実行時に Project 全体を扱う際の合成スキーマ (メモリ上表現)。 export const ProjectSchema = z .object({ @@ -274,6 +278,8 @@ export const ProjectSchema = z name: z.string().min(1), description: z.string().optional(), codebases: z.array(CodebaseSchema), + // Atlassian 等の MCP サーバー設定。ProjectMetaSchema と整合。 + mcpServers: z.array(McpServerConfigSchema).default([]), createdAt: z.string(), updatedAt: z.string(), nodes: z.array(NodeSchema), diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0bc7a08..523dedb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -39,9 +39,14 @@ export type ProposalNode = z.infer; export type Node = z.infer; export type Edge = z.infer; -export type ProjectMeta = z.infer; -export type ProjectMetaPatch = z.infer; -export type Project = z.infer; +// ProjectMeta / Project は z.input 由来にする。 +// 理由: ProjectMetaSchema.mcpServers は default [] を持つので +// output 型では required、input 型では optional になる。 +// 既存の YAML や呼び出しが mcpServers を持たないケースを許容するため input 側を採用。 +// 読み取り時は z.parse で必ず default が解決され実値は McpServerConfig[]。 +export type ProjectMeta = z.input; +export type ProjectMetaPatch = z.input; +export type Project = z.input; // UserStoryNode の補助型。 export type AcceptanceCriterion = NonNullable[number]; From 1b07eba17b9d15eb5dae0fa88e262fa241e91a6a Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:18:02 +0900 Subject: [PATCH 06/34] =?UTF-8?q?refactor(core):=20Project=20=E5=9E=8B?= =?UTF-8?q?=E3=82=92=20z.infer=20=E5=BE=A9=E5=B8=B0=E3=80=81=E6=97=A2?= =?UTF-8?q?=E5=AD=98=20fixture=20=E3=81=AB=20mcpServers:=20[]=20=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3987780 で導入した z.input 由来の Project / ProjectMeta / ProjectMetaPatch を z.infer (output 型) に戻す。foundation schema は parse 後の値を表すべきで、 input 緩和は downstream で ?? [] ガードを書く debt を生むため。 これに伴い必要となる既存 fixture 17 ファイルに mcpServers: [] を機械的に追加: - packages/storage/src/init-project.ts (1) - packages/storage/src/project-store.test.ts (5) - packages/ai-engine の test 4 ファイル (9) - packages/frontend の test 11 ファイル (32) 新規 test の追加は無し。既存 546 tests は同数のまま全 pass。 --- packages/ai-engine/src/agent-runner.test.ts | 2 ++ .../src/agents/find-related-code.test.ts | 4 ++++ packages/ai-engine/src/chat-runner.test.ts | 1 + packages/ai-engine/src/server.test.ts | 2 ++ packages/core/src/types.ts | 11 +++-------- .../api/projects/[id]/chats/chats-route.test.ts | 1 + .../api/projects/[id]/edges/edges-route.test.ts | 2 ++ .../nodes/[nodeId]/adopt/adopt-route.test.ts | 1 + .../api/projects/[id]/nodes/nodes-route.test.ts | 3 +++ .../ai-actions/analyze-impact-button.test.tsx | 1 + .../ai-actions/codebase-agent-button.test.tsx | 1 + .../ai-actions/extract-questions-button.test.tsx | 1 + .../ai-actions/find-related-code-button.test.tsx | 1 + .../components/details/usecase-detail.test.tsx | 2 ++ .../dialog/project-settings-dialog.test.tsx | 1 + packages/frontend/src/lib/store.test.ts | 16 ++++++++++++++++ packages/storage/src/init-project.ts | 1 + packages/storage/src/project-store.test.ts | 5 +++++ 18 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/ai-engine/src/agent-runner.test.ts b/packages/ai-engine/src/agent-runner.test.ts index 94adc43..deb37d7 100644 --- a/packages/ai-engine/src/agent-runner.test.ts +++ b/packages/ai-engine/src/agent-runner.test.ts @@ -228,6 +228,7 @@ describe('runAgent', () => { id: 'proj-test', name: 'FRC integration', codebases: [{ id: 'main', label: 'Main', path: codebaseDir }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -288,6 +289,7 @@ describe('runAgent', () => { id: 'proj-test', name: 'P', codebases: [{ id: 'main', label: 'Main', path: codebaseDir }], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', }); diff --git a/packages/ai-engine/src/agents/find-related-code.test.ts b/packages/ai-engine/src/agents/find-related-code.test.ts index c87341a..df915f4 100644 --- a/packages/ai-engine/src/agents/find-related-code.test.ts +++ b/packages/ai-engine/src/agents/find-related-code.test.ts @@ -52,6 +52,7 @@ describe('findRelatedCodeAgent.validateInput', () => { id: 'proj-frc', name: 'FRC', codebases: [{ id: 'main', label: 'Main', path: codebaseDir }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -106,6 +107,7 @@ describe('findRelatedCodeAgent.validateInput', () => { id: 'proj-frc', name: 'FRC', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -122,6 +124,7 @@ describe('findRelatedCodeAgent.validateInput', () => { id: 'proj-frc', name: 'FRC', codebases: [{ id: 'main', label: 'Main', path: filePath }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -136,6 +139,7 @@ describe('findRelatedCodeAgent.validateInput', () => { id: 'proj-frc', name: 'FRC', codebases: [{ id: 'main', label: 'Main', path: '../nonexistent-xyz' }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index 0af071e..5208d63 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -21,6 +21,7 @@ describe('ChatRunner', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/ai-engine/src/server.test.ts b/packages/ai-engine/src/server.test.ts index 7d0d50e..e39bffe 100644 --- a/packages/ai-engine/src/server.test.ts +++ b/packages/ai-engine/src/server.test.ts @@ -24,6 +24,7 @@ describe('WS /agent', () => { id: 'proj-ws', name: 'WS', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -158,6 +159,7 @@ describe('WS /chat', () => { id: 'proj-ws', name: 'WS', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 523dedb..0bc7a08 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -39,14 +39,9 @@ export type ProposalNode = z.infer; export type Node = z.infer; export type Edge = z.infer; -// ProjectMeta / Project は z.input 由来にする。 -// 理由: ProjectMetaSchema.mcpServers は default [] を持つので -// output 型では required、input 型では optional になる。 -// 既存の YAML や呼び出しが mcpServers を持たないケースを許容するため input 側を採用。 -// 読み取り時は z.parse で必ず default が解決され実値は McpServerConfig[]。 -export type ProjectMeta = z.input; -export type ProjectMetaPatch = z.input; -export type Project = z.input; +export type ProjectMeta = z.infer; +export type ProjectMetaPatch = z.infer; +export type Project = z.infer; // UserStoryNode の補助型。 export type AcceptanceCriterion = NonNullable[number]; diff --git a/packages/frontend/src/app/api/projects/[id]/chats/chats-route.test.ts b/packages/frontend/src/app/api/projects/[id]/chats/chats-route.test.ts index 4d1e706..694b296 100644 --- a/packages/frontend/src/app/api/projects/[id]/chats/chats-route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/chats/chats-route.test.ts @@ -22,6 +22,7 @@ describe('/api/projects/[id]/chats', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/frontend/src/app/api/projects/[id]/edges/edges-route.test.ts b/packages/frontend/src/app/api/projects/[id]/edges/edges-route.test.ts index c2e639d..f55715f 100644 --- a/packages/frontend/src/app/api/projects/[id]/edges/edges-route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/edges/edges-route.test.ts @@ -24,6 +24,7 @@ describe('POST /api/projects/[id]/edges', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -98,6 +99,7 @@ describe('PATCH /api/projects/[id]/edges/[edgeId]', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/frontend/src/app/api/projects/[id]/nodes/[nodeId]/adopt/adopt-route.test.ts b/packages/frontend/src/app/api/projects/[id]/nodes/[nodeId]/adopt/adopt-route.test.ts index b3b0090..4b78d78 100644 --- a/packages/frontend/src/app/api/projects/[id]/nodes/[nodeId]/adopt/adopt-route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/nodes/[nodeId]/adopt/adopt-route.test.ts @@ -21,6 +21,7 @@ describe('POST /api/projects/[id]/nodes/[nodeId]/adopt', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/frontend/src/app/api/projects/[id]/nodes/nodes-route.test.ts b/packages/frontend/src/app/api/projects/[id]/nodes/nodes-route.test.ts index 84cfc7c..066c602 100644 --- a/packages/frontend/src/app/api/projects/[id]/nodes/nodes-route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/nodes/nodes-route.test.ts @@ -22,6 +22,7 @@ describe('POST /api/projects/[id]/nodes', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -91,6 +92,7 @@ describe('PATCH /api/projects/[id]/nodes/[nodeId]', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -195,6 +197,7 @@ describe('DELETE /api/projects/[id]/nodes/[nodeId]', () => { id: 'proj-test', name: 'Test', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); diff --git a/packages/frontend/src/components/ai-actions/analyze-impact-button.test.tsx b/packages/frontend/src/components/ai-actions/analyze-impact-button.test.tsx index 3593a53..9355776 100644 --- a/packages/frontend/src/components/ai-actions/analyze-impact-button.test.tsx +++ b/packages/frontend/src/components/ai-actions/analyze-impact-button.test.tsx @@ -11,6 +11,7 @@ const baseMeta = { id: 'proj-1', name: 'P', codebases: [] as { id: string; label: string; path: string }[], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [node], diff --git a/packages/frontend/src/components/ai-actions/codebase-agent-button.test.tsx b/packages/frontend/src/components/ai-actions/codebase-agent-button.test.tsx index e43f56f..39414a9 100644 --- a/packages/frontend/src/components/ai-actions/codebase-agent-button.test.tsx +++ b/packages/frontend/src/components/ai-actions/codebase-agent-button.test.tsx @@ -11,6 +11,7 @@ const baseMeta = { id: 'proj-1', name: 'P', codebases: [] as { id: string; label: string; path: string }[], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [anchor], diff --git a/packages/frontend/src/components/ai-actions/extract-questions-button.test.tsx b/packages/frontend/src/components/ai-actions/extract-questions-button.test.tsx index e292f03..64b2ec3 100644 --- a/packages/frontend/src/components/ai-actions/extract-questions-button.test.tsx +++ b/packages/frontend/src/components/ai-actions/extract-questions-button.test.tsx @@ -18,6 +18,7 @@ const baseMeta = { id: 'proj-1', name: 'P', codebases: [] as { id: string; label: string; path: string }[], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [anchor], diff --git a/packages/frontend/src/components/ai-actions/find-related-code-button.test.tsx b/packages/frontend/src/components/ai-actions/find-related-code-button.test.tsx index fb058bf..de58f32 100644 --- a/packages/frontend/src/components/ai-actions/find-related-code-button.test.tsx +++ b/packages/frontend/src/components/ai-actions/find-related-code-button.test.tsx @@ -11,6 +11,7 @@ const baseMeta = { id: 'proj-1', name: 'P', codebases: [] as { id: string; label: string; path: string }[], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [node], diff --git a/packages/frontend/src/components/details/usecase-detail.test.tsx b/packages/frontend/src/components/details/usecase-detail.test.tsx index 983e9f4..c4ecf3f 100644 --- a/packages/frontend/src/components/details/usecase-detail.test.tsx +++ b/packages/frontend/src/components/details/usecase-detail.test.tsx @@ -38,6 +38,7 @@ describe('UseCaseDetail', () => { id: 'proj-1', name: 'P', codebases: [{ id: 'backend', label: 'Backend', path: '../backend' }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: '', body: '' }], @@ -56,6 +57,7 @@ describe('UseCaseDetail', () => { id: 'proj-1', name: 'P', codebases: [{ id: 'backend', label: 'Backend', path: '../backend' }], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: '', body: '' }], diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx index 58cabcc..d2e6b9a 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx @@ -10,6 +10,7 @@ const meta: ProjectMeta = { id: 'proj-a', name: 'P', codebases: [{ id: 'web', label: 'Web', path: '/w' }], + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }; diff --git a/packages/frontend/src/lib/store.test.ts b/packages/frontend/src/lib/store.test.ts index f676521..4e59705 100644 --- a/packages/frontend/src/lib/store.test.ts +++ b/packages/frontend/src/lib/store.test.ts @@ -17,6 +17,7 @@ function baseProject(): Project { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: now, updatedAt: now, nodes: [n1], @@ -136,6 +137,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: 'uc', body: '' }], @@ -181,6 +183,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: 'uc', body: '' }], @@ -250,6 +253,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: 'uc', body: '' }], @@ -307,6 +311,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'uc-1', type: 'usecase', x: 0, y: 0, title: 'uc', body: '' }], @@ -370,6 +375,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [], @@ -409,6 +415,7 @@ describe('useCanvasStore', () => { id: 'proj-2', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [], @@ -432,6 +439,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [], @@ -459,6 +467,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [{ id: 'old', label: 'Old', path: '/old' }], + mcpServers: [], createdAt: '2026-04-18T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', nodes: [], @@ -487,6 +496,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [ @@ -525,6 +535,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [ @@ -680,6 +691,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 'P', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [ @@ -841,6 +853,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [], @@ -890,6 +903,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [], @@ -944,6 +958,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [ @@ -989,6 +1004,7 @@ describe('useCanvasStore', () => { id: 'proj-1', name: 't', codebases: [], + mcpServers: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), nodes: [{ id: 'req-a', type: 'requirement', x: 0, y: 0, title: 'A', body: '' }], diff --git a/packages/storage/src/init-project.ts b/packages/storage/src/init-project.ts index 92bed9b..9496ccf 100644 --- a/packages/storage/src/init-project.ts +++ b/packages/storage/src/init-project.ts @@ -71,6 +71,7 @@ export async function initProject(input: InitProjectInput): Promise { name: 'テストプロジェクト', description: '説明', codebases: [], + mcpServers: [], createdAt: '2026-04-18T10:00:00Z', updatedAt: '2026-04-18T10:00:00Z', }); @@ -355,6 +356,7 @@ describe('FileSystemProjectStore', () => { id: 'proj-a', name: 'a', codebases: [], + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }); @@ -372,6 +374,7 @@ describe('FileSystemProjectStore', () => { id: 'proj-a', name: 'a', codebases, + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }); @@ -386,6 +389,7 @@ describe('FileSystemProjectStore', () => { id: 'proj-a', name: 'a', codebases: [{ id: 'frontend', label: 'W', path: '/a' }], + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }); @@ -407,6 +411,7 @@ describe('FileSystemProjectStore', () => { id: 'proj-a', name: 'a', codebases: [{ id: 'frontend', label: 'W', path: '/a' }], + mcpServers: [], createdAt: '2026-04-21T00:00:00Z', updatedAt: '2026-04-21T00:00:00Z', }); From 275c1fe0b5b4976e207fd1e9bef233cada4528d9 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:25:59 +0900 Subject: [PATCH 07/34] =?UTF-8?q?feat(core):=20ChatBlock.tool=5Fuse=20?= =?UTF-8?q?=E3=81=AB=20source=20=E8=BF=BD=E5=8A=A0=E3=80=81RequirementNode?= =?UTF-8?q?=20=E3=81=AB=20sourceUrl=20=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ai-engine/src/chat-runner.ts | 2 + packages/core/src/schema.test.ts | 136 ++++++++++++++++++ packages/core/src/schema.ts | 23 ++- .../chat/tool-approval-card.test.tsx | 3 + packages/frontend/src/lib/store.ts | 1 + packages/storage/src/chat-store.test.ts | 7 + 6 files changed, 165 insertions(+), 7 deletions(-) diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index dba1055..6949430 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -234,6 +234,7 @@ export class ChatRunner { toolUseId: uiId, name: entry.name, input, + source: 'internal', approval: 'approved', }); await chatStore.appendBlockToMessage(threadId, assistantMsgId, { @@ -262,6 +263,7 @@ export class ChatRunner { toolUseId: uiToolUseId, name: entry.name, input, + source: 'internal', approval: 'pending', }); emit({ diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index 6cd12c9..f2cae68 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -642,3 +642,139 @@ describe('ProjectMetaSchema.mcpServers', () => { expect(meta.mcpServers).toEqual([]); }); }); + +describe('ChatBlockSchema.tool_use.source', () => { + it('source 未指定の古いデータが "internal" に defaults', () => { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-1', + name: 'mcp__tally__create_node', + input: { x: 1 }, + approval: 'approved', + }); + expect(b.type).toBe('tool_use'); + if (b.type === 'tool_use') expect(b.source).toBe('internal'); + }); + + it('source = "external" は承認不要 (approval optional)', () => { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-2', + name: 'mcp__atlassian__jira_get_issue', + input: { issueKey: 'EPIC-1' }, + source: 'external', + }); + if (b.type === 'tool_use') { + expect(b.source).toBe('external'); + expect(b.approval).toBeUndefined(); + } + }); + + it('source = "external" + approval 指定もできる (任意で記録可)', () => { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-3', + name: 'mcp__atlassian__jira_search', + input: {}, + source: 'external', + approval: 'approved', + }); + if (b.type === 'tool_use') { + expect(b.source).toBe('external'); + expect(b.approval).toBe('approved'); + } + }); + + it('source = "internal" で approval 無しは fail', () => { + expect(() => + ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-4', + name: 'mcp__tally__create_node', + input: {}, + source: 'internal', + }), + ).toThrow(); + }); + + it('source 未指定 (= internal default) で approval 無しは fail', () => { + expect(() => + ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-5', + name: 'mcp__tally__create_node', + input: {}, + }), + ).toThrow(); + }); + + it('既存の internal + approval=pending/approved/rejected は引き続き valid', () => { + for (const a of ['pending', 'approved', 'rejected'] as const) { + const b = ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: `tu-${a}`, + name: 'mcp__tally__create_node', + input: {}, + approval: a, + }); + if (b.type === 'tool_use') { + expect(b.source).toBe('internal'); + expect(b.approval).toBe(a); + } + } + }); + + it('source の不正値 (例 "auto") は fail', () => { + expect(() => + ChatBlockSchema.parse({ + type: 'tool_use', + toolUseId: 'tu-bad', + name: 'mcp__tally__create_node', + input: {}, + source: 'auto', + approval: 'approved', + }), + ).toThrow(); + }); +}); + +describe('RequirementNodeSchema.sourceUrl', () => { + it('sourceUrl 未指定は optional (既存互換)', () => { + const n = RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + }); + expect(n.sourceUrl).toBeUndefined(); + }); + + it('sourceUrl 指定で保持', () => { + const n = RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'https://jira.test/browse/EPIC-1', + }); + expect(n.sourceUrl).toBe('https://jira.test/browse/EPIC-1'); + }); + + it('sourceUrl が URL でないと fail', () => { + expect(() => + RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'not a url', + }), + ).toThrow(); + }); +}); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index b950ddd..6035d63 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -53,6 +53,8 @@ export const RequirementNodeSchema = z.object({ kind: z.enum(REQUIREMENT_KINDS).optional(), qualityCategory: z.enum(QUALITY_CATEGORIES).optional(), priority: z.enum(REQUIREMENT_PRIORITIES).optional(), + // 外部 MCP (Atlassian 等) から取り込んだ場合の元情報 URL。Phase 6+ で UI から開けるようにする予定。 + sourceUrl: z.string().url().optional(), }); export const UseCaseNodeSchema = z.object({ @@ -303,13 +305,20 @@ export const ProjectMetaPatchSchema = z export const ChatBlockSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('text'), text: z.string() }), - z.object({ - type: z.literal('tool_use'), - toolUseId: z.string().min(1), - name: z.string().min(1), - input: z.unknown(), - approval: z.enum(['pending', 'approved', 'rejected']), - }), + z + .object({ + type: z.literal('tool_use'), + toolUseId: z.string().min(1), + name: z.string().min(1), + input: z.unknown(), + // 'internal' = Tally MCP (人間承認が必要)、'external' = Atlassian 等の外部 MCP (承認概念なし)。 + // 既存 YAML (source 無し) は default 'internal' で読めるよう後方互換を保つ。 + source: z.enum(['internal', 'external']).default('internal'), + approval: z.enum(['pending', 'approved', 'rejected']).optional(), + }) + .refine((b) => b.source === 'external' || b.approval !== undefined, { + message: 'internal tool_use には approval が必要', + }), z.object({ type: z.literal('tool_result'), toolUseId: z.string().min(1), diff --git a/packages/frontend/src/components/chat/tool-approval-card.test.tsx b/packages/frontend/src/components/chat/tool-approval-card.test.tsx index 653f286..8341cf7 100644 --- a/packages/frontend/src/components/chat/tool-approval-card.test.tsx +++ b/packages/frontend/src/components/chat/tool-approval-card.test.tsx @@ -20,6 +20,7 @@ describe('ToolApprovalCard', () => { toolUseId: 'tool-abc', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'X', body: '' }, + source: 'internal', approval: 'pending', }} />, @@ -38,6 +39,7 @@ describe('ToolApprovalCard', () => { toolUseId: 'tool-xyz', name: 'mcp__tally__create_edge', input: { from: 'a', to: 'b', type: 'derive' }, + source: 'internal', approval: 'pending', }} />, @@ -55,6 +57,7 @@ describe('ToolApprovalCard', () => { toolUseId: 'tool-1', name: 'mcp__tally__create_node', input: {}, + source: 'internal', approval: 'pending', }} />, diff --git a/packages/frontend/src/lib/store.ts b/packages/frontend/src/lib/store.ts index a72af75..a47ff68 100644 --- a/packages/frontend/src/lib/store.ts +++ b/packages/frontend/src/lib/store.ts @@ -277,6 +277,7 @@ export const useCanvasStore = create((set, get) => { toolUseId: evt.toolUseId, name: evt.name, input: evt.input, + source: 'internal', approval: 'pending', }, ], diff --git a/packages/storage/src/chat-store.test.ts b/packages/storage/src/chat-store.test.ts index 744f04b..181fb18 100644 --- a/packages/storage/src/chat-store.test.ts +++ b/packages/storage/src/chat-store.test.ts @@ -64,6 +64,7 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-1', name: 'mcp__tally__create_node', input: { x: 1 }, + source: 'internal', approval: 'pending', }, ], @@ -74,6 +75,7 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-1', name: 'mcp__tally__create_node', input: { x: 1 }, + source: 'internal', approval: 'approved', }); const reloaded = await store.getChat(thread.id); @@ -155,15 +157,18 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-aaa', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'X', body: '' }, + source: 'internal', approval: 'pending', }); const reloaded = await store.getChat(thread.id); expect(reloaded?.messages[0]?.blocks).toHaveLength(2); + // source は schema の default で 'internal' が入る (Phase 6+ Atlassian MCP 連携の準備) expect(reloaded?.messages[0]?.blocks[1]).toEqual({ type: 'tool_use', toolUseId: 'tool-aaa', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'X', body: '' }, + source: 'internal', approval: 'pending', }); } finally { @@ -229,6 +234,7 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-a', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'A', body: '' }, + source: 'internal', approval: 'pending', }, { @@ -236,6 +242,7 @@ describe('FileSystemChatStore', () => { toolUseId: 'tool-b', name: 'mcp__tally__create_node', input: { adoptAs: 'requirement', title: 'B', body: '' }, + source: 'internal', approval: 'pending', }, ], From 43e54024cb2d8d9644976478d4e99d8b370a0ded Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:31:32 +0900 Subject: [PATCH 08/34] =?UTF-8?q?feat(core):=20RequirementNode.sourceUrl?= =?UTF-8?q?=20=E3=82=92=20https-only=20=E3=81=AB=20hardening=20(Task=201?= =?UTF-8?q?=20url=20=E3=81=A8=E3=81=AE=E6=95=B4=E5=90=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/schema.test.ts | 41 ++++++++++++++++++++++++++++++++ packages/core/src/schema.ts | 17 ++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index f2cae68..f510daf 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -777,4 +777,45 @@ describe('RequirementNodeSchema.sourceUrl', () => { }), ).toThrow(); }); + + it('sourceUrl が http:// なら fail (https 強制、UI link でも cleartext は禁止)', () => { + expect(() => + RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'http://jira.test/browse/EPIC-1', + }), + ).toThrow(); + }); + + it('sourceUrl が https:// なら pass', () => { + const n = RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'https://jira.test/browse/EPIC-1', + }); + expect(n.sourceUrl).toBe('https://jira.test/browse/EPIC-1'); + }); + + it('sourceUrl が ftp:// なら fail', () => { + expect(() => + RequirementNodeSchema.parse({ + id: 'n', + type: 'requirement', + x: 0, + y: 0, + title: 'R', + body: '', + sourceUrl: 'ftp://jira.test/EPIC-1', + }), + ).toThrow(); + }); }); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 6035d63..0bd2ffd 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -54,7 +54,22 @@ export const RequirementNodeSchema = z.object({ qualityCategory: z.enum(QUALITY_CATEGORIES).optional(), priority: z.enum(REQUIREMENT_PRIORITIES).optional(), // 外部 MCP (Atlassian 等) から取り込んだ場合の元情報 URL。Phase 6+ で UI から開けるようにする予定。 - sourceUrl: z.string().url().optional(), + // UI link 経由で credential が漏れる構図を排除するため https-only。 + // McpServerConfig.url と異なり loopback 例外は不要 (Jira issue URL に loopback はあり得ない)。 + sourceUrl: z + .string() + .url() + .refine( + (u) => { + try { + return new URL(u).protocol === 'https:'; + } catch { + return false; + } + }, + { message: 'sourceUrl は https で始まる必要があります' }, + ) + .optional(), }); export const UseCaseNodeSchema = z.object({ From 1daf1b0432762cda996343b91b86b36730aa7214 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:34:46 +0900 Subject: [PATCH 09/34] =?UTF-8?q?feat(ai-engine):=20redactMcpSecrets=20uti?= =?UTF-8?q?lity=20=E3=82=92=E8=BF=BD=E5=8A=A0=20(Authorization=20header=20?= =?UTF-8?q?=E3=81=AE=20log=20=E6=BC=8F=E6=B4=A9=E4=BA=88=E9=98=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ai-engine/src/mcp/redact.test.ts | 99 +++++++++++++++++++++++ packages/ai-engine/src/mcp/redact.ts | 39 +++++++++ 2 files changed, 138 insertions(+) create mode 100644 packages/ai-engine/src/mcp/redact.test.ts create mode 100644 packages/ai-engine/src/mcp/redact.ts diff --git a/packages/ai-engine/src/mcp/redact.test.ts b/packages/ai-engine/src/mcp/redact.test.ts new file mode 100644 index 0000000..fcef1dc --- /dev/null +++ b/packages/ai-engine/src/mcp/redact.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { redactMcpSecrets } from './redact'; + +describe('redactMcpSecrets', () => { + it('Authorization header を "***" に置換', () => { + const input = { + mcpServers: { + atlassian: { + type: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Bearer abc-123' }, + }, + }, + }; + const out = redactMcpSecrets(input) as { + mcpServers: { atlassian: { headers: Record } }; + }; + expect(out.mcpServers.atlassian.headers.Authorization).toBe('***'); + // 元オブジェクトは破壊しない (immutable) + expect(input.mcpServers.atlassian.headers.Authorization).toBe('Bearer abc-123'); + }); + + it('Basic auth (Basic xxx) も同じく redact', () => { + const input = { + mcpServers: { + cloud: { + type: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Basic dXNlcjpwYXNz' }, + }, + }, + }; + const out = redactMcpSecrets(input) as { + mcpServers: { cloud: { headers: Record } }; + }; + expect(out.mcpServers.cloud.headers.Authorization).toBe('***'); + }); + + it('他の header は保持', () => { + const out = redactMcpSecrets({ + mcpServers: { + atlassian: { + type: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Bearer secret', 'X-Other': 'keep' }, + }, + }, + }) as { + mcpServers: { atlassian: { url: string; headers: Record } }; + }; + expect(out.mcpServers.atlassian.url).toBe('https://x.test/mcp'); + expect(out.mcpServers.atlassian.headers['X-Other']).toBe('keep'); + expect(out.mcpServers.atlassian.headers.Authorization).toBe('***'); + }); + + it('mcpServers 不在ならそのまま返す', () => { + const input = { foo: 'bar' }; + expect(redactMcpSecrets(input)).toEqual(input); + }); + + it('mcpServers 内に headers が無いサーバ (SDK type 等) は触らない', () => { + const sdkServer = { type: 'sdk', name: 'tally' }; + const input = { + mcpServers: { tally: sdkServer }, + }; + const out = redactMcpSecrets(input) as { mcpServers: Record }; + expect(out.mcpServers.tally).toEqual(sdkServer); + }); + + it('複数サーバが混在しても各々を独立に処理', () => { + const input = { + mcpServers: { + tally: { type: 'sdk', name: 'tally' }, + atlassian: { + type: 'http', + url: 'https://x.test/mcp', + headers: { Authorization: 'Bearer xyz' }, + }, + }, + }; + const out = redactMcpSecrets(input) as { + mcpServers: { + tally: { type: string }; + atlassian: { type: string; headers?: Record }; + }; + }; + expect(out.mcpServers.tally.type).toBe('sdk'); + expect(out.mcpServers.atlassian.headers?.Authorization).toBe('***'); + }); + + it('non-object input (primitive / null / array) はそのまま返す', () => { + expect(redactMcpSecrets(null)).toBe(null); + expect(redactMcpSecrets(undefined)).toBe(undefined); + expect(redactMcpSecrets(42)).toBe(42); + expect(redactMcpSecrets('string')).toBe('string'); + expect(redactMcpSecrets([1, 2, 3])).toEqual([1, 2, 3]); + }); +}); diff --git a/packages/ai-engine/src/mcp/redact.ts b/packages/ai-engine/src/mcp/redact.ts new file mode 100644 index 0000000..fcd8fb9 --- /dev/null +++ b/packages/ai-engine/src/mcp/redact.ts @@ -0,0 +1,39 @@ +// SDK に渡す mcpServers 設定 (Authorization header) をログに出す前の安全な形に変換する。 +// プロセスメモリには PAT が残るが、ログ出力経路では "***" にする。 +// 元オブジェクトは破壊せず、shallow copy で返す (mcpServers と該当 server / headers のみ複製)。 +export function redactMcpSecrets(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + + const obj = value as Record; + if (!obj.mcpServers || typeof obj.mcpServers !== 'object' || Array.isArray(obj.mcpServers)) { + return value; + } + + const servers = obj.mcpServers as Record; + const redactedServers: Record = {}; + + for (const [name, cfg] of Object.entries(servers)) { + if (cfg && typeof cfg === 'object' && !Array.isArray(cfg) && 'headers' in cfg) { + const src = cfg as { headers?: unknown }; + const headers = src.headers; + if ( + headers && + typeof headers === 'object' && + !Array.isArray(headers) && + 'Authorization' in headers + ) { + redactedServers[name] = { + ...(cfg as Record), + headers: { + ...(headers as Record), + Authorization: '***', + }, + }; + continue; + } + } + redactedServers[name] = cfg; + } + + return { ...obj, mcpServers: redactedServers }; +} From 85b6482588dc16130f99fdc9d578ab4a8af6269f Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:38:05 +0900 Subject: [PATCH 10/34] =?UTF-8?q?feat(ai-engine):=20redactMcpSecrets=20?= =?UTF-8?q?=E3=81=AB=20case-sensitive=20=E6=B3=A8=E8=A8=98=E3=81=A8?= =?UTF-8?q?=E9=85=8D=E5=88=97=E5=80=A4=20test=20=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ai-engine/src/mcp/redact.test.ts | 18 ++++++++++++++++++ packages/ai-engine/src/mcp/redact.ts | 7 +++++++ 2 files changed, 25 insertions(+) diff --git a/packages/ai-engine/src/mcp/redact.test.ts b/packages/ai-engine/src/mcp/redact.test.ts index fcef1dc..30877f8 100644 --- a/packages/ai-engine/src/mcp/redact.test.ts +++ b/packages/ai-engine/src/mcp/redact.test.ts @@ -96,4 +96,22 @@ describe('redactMcpSecrets', () => { expect(redactMcpSecrets('string')).toBe('string'); expect(redactMcpSecrets([1, 2, 3])).toEqual([1, 2, 3]); }); + + it('Authorization 値が配列等の非 string でも redact される (安全側に倒す)', () => { + const input = { + mcpServers: { + atlassian: { + type: 'http', + url: 'https://x.test/mcp', + headers: { + Authorization: ['Bearer xxx'] as unknown as string, + }, + }, + }, + }; + const out = redactMcpSecrets(input) as { + mcpServers: { atlassian: { headers: Record } }; + }; + expect(out.mcpServers.atlassian.headers.Authorization).toBe('***'); + }); }); diff --git a/packages/ai-engine/src/mcp/redact.ts b/packages/ai-engine/src/mcp/redact.ts index fcd8fb9..bcc1289 100644 --- a/packages/ai-engine/src/mcp/redact.ts +++ b/packages/ai-engine/src/mcp/redact.ts @@ -1,6 +1,13 @@ // SDK に渡す mcpServers 設定 (Authorization header) をログに出す前の安全な形に変換する。 // プロセスメモリには PAT が残るが、ログ出力経路では "***" にする。 // 元オブジェクトは破壊せず、shallow copy で返す (mcpServers と該当 server / headers のみ複製)。 +// +// 注意: +// - Authorization header の検出は **canonical な "Authorization" のみ** (case-sensitive)。 +// Claude Agent SDK は McpHttpServerConfig.headers を canonical 表記で吐くため十分。 +// 将来 SDK 仕様変更で "authorization" 等の表記が混在する場合は本関数を更新する。 +// - 現状は Authorization のみ redact 対象。Cookie / X-API-Key / Proxy-Authorization 等は対応外。 +// MVP の MCP HTTP transport では Authorization 以外の credential header を使わないため。 export function redactMcpSecrets(value: unknown): unknown { if (!value || typeof value !== 'object' || Array.isArray(value)) return value; From aca69939ea4a91de7b25dde89321ebc6d51bdd88 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:40:50 +0900 Subject: [PATCH 11/34] =?UTF-8?q?feat(ai-engine):=20buildMcpServers=20util?= =?UTF-8?q?ity=20=E3=82=92=E8=BF=BD=E5=8A=A0=20(Basic/Bearer=20auth=20+=20?= =?UTF-8?q?allowedTools=20wildcard=20=E5=90=88=E6=88=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/mcp/build-mcp-servers.test.ts | 191 ++++++++++++++++++ .../ai-engine/src/mcp/build-mcp-servers.ts | 63 ++++++ 2 files changed, 254 insertions(+) create mode 100644 packages/ai-engine/src/mcp/build-mcp-servers.test.ts create mode 100644 packages/ai-engine/src/mcp/build-mcp-servers.ts diff --git a/packages/ai-engine/src/mcp/build-mcp-servers.test.ts b/packages/ai-engine/src/mcp/build-mcp-servers.test.ts new file mode 100644 index 0000000..0dc3939 --- /dev/null +++ b/packages/ai-engine/src/mcp/build-mcp-servers.test.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildMcpServers } from './build-mcp-servers'; + +describe('buildMcpServers', () => { + const ORIGINAL_ENV = { ...process.env }; + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('mcpServers 空配列 → external 無し、allowedTools は tally のみ', () => { + const result = buildMcpServers({ tallyMcp: { type: 'sdk' } as unknown, configs: [] }); + expect(Object.keys(result.mcpServers)).toEqual(['tally']); + expect(result.allowedTools).toEqual(['mcp__tally__*']); + }); + + it('Bearer (Server/DC) → Authorization: Bearer ', () => { + process.env.JIRA_PAT = 'secret-xyz'; + const result = buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'atlassian-dc', + name: 'A', + kind: 'atlassian', + url: 'https://jira.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }); + const atlassian = result.mcpServers['atlassian-dc'] as { + type: string; + url: string; + headers: Record; + }; + expect(atlassian.type).toBe('http'); + expect(atlassian.url).toBe('https://jira.test/mcp'); + expect(atlassian.headers.Authorization).toBe('Bearer secret-xyz'); + expect(result.allowedTools).toContain('mcp__tally__*'); + expect(result.allowedTools).toContain('mcp__atlassian-dc__*'); + }); + + it('Basic (Cloud) → Authorization: Basic ', () => { + process.env.ATLASSIAN_EMAIL = 'user@example.com'; + process.env.ATLASSIAN_API_TOKEN = 'api-token-xyz'; + const result = buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'atlassian-cloud', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }); + const atlassian = result.mcpServers['atlassian-cloud'] as { + headers: Record; + }; + const expected = Buffer.from('user@example.com:api-token-xyz').toString('base64'); + expect(atlassian.headers.Authorization).toBe(`Basic ${expected}`); + }); + + it('Bearer の tokenEnvVar 未設定 → throw', () => { + delete process.env.JIRA_PAT; + expect(() => + buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + ).toThrowError(/JIRA_PAT/); + }); + + it('Basic の emailEnvVar 未設定 → throw', () => { + delete process.env.ATLASSIAN_EMAIL; + process.env.ATLASSIAN_API_TOKEN = 'x'; + expect(() => + buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + ).toThrowError(/ATLASSIAN_EMAIL/); + }); + + it('Basic の tokenEnvVar 未設定 → throw', () => { + process.env.ATLASSIAN_EMAIL = 'user@example.com'; + delete process.env.ATLASSIAN_API_TOKEN; + expect(() => + buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + ).toThrowError(/ATLASSIAN_API_TOKEN/); + }); + + it('env 値が空文字でも → throw (= 未設定と同じ扱い)', () => { + process.env.JIRA_PAT = ''; + expect(() => + buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + ).toThrowError(/JIRA_PAT/); + }); + + it('複数の config を合成 → 各々が独立に build される', () => { + process.env.JIRA_PAT = 's1'; + process.env.OTHER_TOKEN = 's2'; + const result = buildMcpServers({ + tallyMcp: { type: 'sdk' } as unknown, + configs: [ + { + id: 'first', + name: 'F', + kind: 'atlassian', + url: 'https://a.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + { + id: 'second', + name: 'S', + kind: 'atlassian', + url: 'https://b.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'OTHER_TOKEN' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }); + expect(Object.keys(result.mcpServers)).toEqual(['tally', 'first', 'second']); + const first = result.mcpServers.first as { headers: Record }; + const second = result.mcpServers.second as { headers: Record }; + expect(first.headers.Authorization).toBe('Bearer s1'); + expect(second.headers.Authorization).toBe('Bearer s2'); + expect(result.allowedTools).toEqual(['mcp__tally__*', 'mcp__first__*', 'mcp__second__*']); + }); +}); diff --git a/packages/ai-engine/src/mcp/build-mcp-servers.ts b/packages/ai-engine/src/mcp/build-mcp-servers.ts new file mode 100644 index 0000000..e89bc1c --- /dev/null +++ b/packages/ai-engine/src/mcp/build-mcp-servers.ts @@ -0,0 +1,63 @@ +import type { McpServerConfig } from '@tally/core'; + +// SDK の mcpServers は Record を受ける (sdk.d.ts:1386 参照)。 +// chat-runner / agent-runner が共通で使える shape にする。 +// +// 認証方式: +// - bearer (Server/DC): Authorization: Bearer +// - basic (Cloud): Authorization: Basic +// +// allowedTools は wildcard `mcp____*` (Spike 0b 確認済、Claude Code 2.1.117+ サポート)。 +export interface BuildMcpServersInput { + // createSdkMcpServer で組み立てた Tally MCP。ここでは opaque。 + tallyMcp: unknown; + // プロジェクト設定 project.mcpServers[]。 + configs: McpServerConfig[]; +} + +export interface BuildMcpServersResult { + mcpServers: Record; + allowedTools: string[]; +} + +function requireEnv(varName: string, contextId: string): string { + const v = process.env[varName]; + if (v === undefined || v === '') { + throw new Error(`MCP 設定 "${contextId}" の env var "${varName}" が未設定または空です`); + } + return v; +} + +function buildAuthHeader(auth: McpServerConfig['auth'], contextId: string): string { + if (auth.scheme === 'bearer') { + const token = requireEnv(auth.tokenEnvVar, contextId); + return `Bearer ${token}`; + } + // basic + const email = requireEnv(auth.emailEnvVar, contextId); + const token = requireEnv(auth.tokenEnvVar, contextId); + const b64 = Buffer.from(`${email}:${token}`).toString('base64'); + return `Basic ${b64}`; +} + +// SDK 設定と allowedTools を組み立てる。env 未設定は throw。 +// 呼び出し元 (chat-runner / agent-runner) は runUserTurn の都度これを呼ぶ +// → env 変更がホットリロードされる。 +export function buildMcpServers(input: BuildMcpServersInput): BuildMcpServersResult { + const { tallyMcp, configs } = input; + + const mcpServers: Record = { tally: tallyMcp }; + const allowedTools: string[] = ['mcp__tally__*']; + + for (const cfg of configs) { + const authHeader = buildAuthHeader(cfg.auth, cfg.id); + mcpServers[cfg.id] = { + type: 'http' as const, + url: cfg.url, + headers: { Authorization: authHeader }, + }; + allowedTools.push(`mcp__${cfg.id}__*`); + } + + return { mcpServers, allowedTools }; +} From caf290453311400ffc3b26c65f3991e7dc9ba0f7 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:43:39 +0900 Subject: [PATCH 12/34] =?UTF-8?q?feat(ai-engine):=20duplicate-guards=20?= =?UTF-8?q?=E9=AA=A8=E6=A0=BC=20(interface=20+=20strategy=20dispatcher)=20?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/duplicate-guards/index.test.ts | 122 ++++++++++++++++++ .../ai-engine/src/duplicate-guards/index.ts | 73 +++++++++++ 2 files changed, 195 insertions(+) create mode 100644 packages/ai-engine/src/duplicate-guards/index.test.ts create mode 100644 packages/ai-engine/src/duplicate-guards/index.ts diff --git a/packages/ai-engine/src/duplicate-guards/index.test.ts b/packages/ai-engine/src/duplicate-guards/index.test.ts new file mode 100644 index 0000000..69c51f5 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/index.test.ts @@ -0,0 +1,122 @@ +import type { ProjectStore } from '@tally/storage'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + __resetGuardsForTest, + type DuplicateGuard, + type DuplicateGuardContext, + dispatchDuplicateGuard, + notifyCreated, + registerGuard, +} from './index'; + +const fakeStore = { + listNodes: async () => [], + findRelatedNodes: async () => [], +} as unknown as ProjectStore; + +const baseCtx: DuplicateGuardContext = { + store: fakeStore, + anchorId: '', + sessionMemo: new Set(), +}; + +beforeEach(() => { + __resetGuardsForTest(); +}); + +describe('dispatchDuplicateGuard', () => { + it('登録された guard が無い adoptAs は null を返す', async () => { + const result = await dispatchDuplicateGuard( + 'requirement', + { title: 't', body: '', additional: undefined }, + baseCtx, + ); + expect(result).toBeNull(); + }); + + it('guard が DuplicateFound を返したら dispatcher も同じものを返す', async () => { + const stubGuard: DuplicateGuard = { + adoptAs: 'usecase', + check: async () => ({ reason: '重複: stub' }), + }; + registerGuard(stubGuard); + const result = await dispatchDuplicateGuard( + 'usecase', + { title: 't', body: '', additional: undefined }, + baseCtx, + ); + expect(result?.reason).toBe('重複: stub'); + }); + + it('複数 guard が同じ adoptAs に登録された場合、最初に重複を検知したものが返る', async () => { + const guardA: DuplicateGuard = { + adoptAs: 'userstory', + check: async () => null, + }; + const guardB: DuplicateGuard = { + adoptAs: 'userstory', + check: async () => ({ reason: 'B が検知' }), + }; + registerGuard(guardA); + registerGuard(guardB); + const result = await dispatchDuplicateGuard( + 'userstory', + { title: 't', body: '', additional: undefined }, + baseCtx, + ); + expect(result?.reason).toBe('B が検知'); + }); + + it('全 guard が null なら null を返す', async () => { + const guardA: DuplicateGuard = { + adoptAs: 'issue', + check: async () => null, + }; + const guardB: DuplicateGuard = { + adoptAs: 'issue', + check: async () => null, + }; + registerGuard(guardA); + registerGuard(guardB); + const result = await dispatchDuplicateGuard( + 'issue', + { title: 't', body: '', additional: undefined }, + baseCtx, + ); + expect(result).toBeNull(); + }); +}); + +describe('notifyCreated', () => { + it('登録された guard の onCreated が呼ばれる', async () => { + const calls: string[] = []; + const guard: DuplicateGuard = { + adoptAs: 'coderef', + check: async () => null, + onCreated: (input) => { + calls.push(input.title); + }, + }; + registerGuard(guard); + notifyCreated('coderef', { title: 'T1', body: '', additional: undefined }, baseCtx); + expect(calls).toEqual(['T1']); + }); + + it('onCreated が無い guard では何も起きない (例外も出ない)', async () => { + const guard: DuplicateGuard = { + adoptAs: 'question', + check: async () => null, + }; + registerGuard(guard); + expect(() => + notifyCreated('question', { title: 'T2', body: '', additional: undefined }, baseCtx), + ).not.toThrow(); + }); + + it('登録 guard が無い adoptAs では何も起きない', () => { + expect(() => + notifyCreated('coderef', { title: 'T3', body: '', additional: undefined }, baseCtx), + ).not.toThrow(); + }); +}); diff --git a/packages/ai-engine/src/duplicate-guards/index.ts b/packages/ai-engine/src/duplicate-guards/index.ts new file mode 100644 index 0000000..53af068 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/index.ts @@ -0,0 +1,73 @@ +import type { AdoptableType } from '@tally/core'; +import type { ProjectStore } from '@tally/storage'; + +// create-node 入力のうち guard に必要な最小 shape。 +export interface GuardInput { + title: string; + body: string; + additional: Record | undefined; +} + +// guard が共有するランタイム文脈。 +export interface DuplicateGuardContext { + store: ProjectStore; + // anchor 無し (chat) のときは空文字。anchor 依存 guard は空文字を skip せよ。 + anchorId: string; + // セッション内で生成済みノードの重複記録。キーは guard 実装が決める。 + sessionMemo: Set; + // マルチコードベース対応のために流すコードベース ID (optional)。 + codebaseId?: string; +} + +export interface DuplicateFound { + reason: string; // ユーザー向けメッセージ (既存 node id などを含む) +} + +export interface DuplicateGuard { + // 対象 adoptAs。複数対応は同 guard を複数 adoptAs で登録する。 + adoptAs: AdoptableType; + // 重複があれば DuplicateFound、無ければ null。 + check(input: GuardInput, ctx: DuplicateGuardContext): Promise; + // 生成成功後に呼ばれる (sessionMemo 更新など)。任意。 + onCreated?(input: GuardInput, ctx: DuplicateGuardContext): void; +} + +// adoptAs → Guard[] のレジストリ。Task 7-9 で個別 guard を追加する。 +const REGISTRY = new Map(); + +export function registerGuard(guard: DuplicateGuard): void { + const list = REGISTRY.get(guard.adoptAs) ?? []; + list.push(guard); + REGISTRY.set(guard.adoptAs, list); +} + +// dispatcher: 登録 guard を順に check し、最初に重複を見つけたら返す。 +// 全部 null なら null。Promise を一つずつ await する (並列にしない: 副作用順序を保つ)。 +export async function dispatchDuplicateGuard( + adoptAs: AdoptableType, + input: GuardInput, + ctx: DuplicateGuardContext, +): Promise { + const guards = REGISTRY.get(adoptAs) ?? []; + for (const g of guards) { + const found = await g.check(input, ctx); + if (found) return found; + } + return null; +} + +// 生成成功通知: 登録 guard の onCreated を全部呼ぶ。 +export function notifyCreated( + adoptAs: AdoptableType, + input: GuardInput, + ctx: DuplicateGuardContext, +): void { + const guards = REGISTRY.get(adoptAs) ?? []; + for (const g of guards) g.onCreated?.(input, ctx); +} + +// テスト用: REGISTRY をクリア。プロダクションコードからは呼ばないこと。 +// 命名 prefix で「test-only」を明示し、accidental 使用を防ぐ。 +export function __resetGuardsForTest(): void { + REGISTRY.clear(); +} From 5623cd22e41dd4cfe4d05cf591e0bc2b32ef89d6 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:47:32 +0900 Subject: [PATCH 13/34] =?UTF-8?q?feat(ai-engine):=20coderef=20=E9=87=8D?= =?UTF-8?q?=E8=A4=87=E3=82=AC=E3=83=BC=E3=83=89=E3=82=92=20duplicate-guard?= =?UTF-8?q?s/coderef.ts=20=E3=81=AB=E5=88=86=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/duplicate-guards/coderef.test.ts | 147 ++++++++++++++++++ .../ai-engine/src/duplicate-guards/coderef.ts | 57 +++++++ .../ai-engine/src/duplicate-guards/index.ts | 6 + 3 files changed, 210 insertions(+) create mode 100644 packages/ai-engine/src/duplicate-guards/coderef.test.ts create mode 100644 packages/ai-engine/src/duplicate-guards/coderef.ts diff --git a/packages/ai-engine/src/duplicate-guards/coderef.test.ts b/packages/ai-engine/src/duplicate-guards/coderef.test.ts new file mode 100644 index 0000000..a23ccc1 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/coderef.test.ts @@ -0,0 +1,147 @@ +import type { ProjectStore } from '@tally/storage'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { coderefGuard } from './coderef'; +import { __resetGuardsForTest, type DuplicateGuardContext } from './index'; + +function makeCtx( + nodes: ReadonlyArray>, + override: Partial = {}, +): DuplicateGuardContext { + // 注: exactOptionalPropertyTypes のため codebaseId は明示 undefined にせず、 + // override で指定された場合のみ広げる。 + return { + store: { + listNodes: async () => nodes as never, + findRelatedNodes: async () => [], + } as unknown as ProjectStore, + anchorId: '', + sessionMemo: new Set(), + ...override, + }; +} + +describe('coderefGuard', () => { + beforeEach(() => __resetGuardsForTest()); + + it('adoptAs は "coderef"', () => { + expect(coderefGuard.adoptAs).toBe('coderef'); + }); + + it('同一 filePath + 近接 startLine (±10) で重複検知', async () => { + const ctx = makeCtx([ + { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 105, codebaseId: 'cb1' }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + expect(res?.reason).toContain('n1'); + }); + + it('11 行以上離れていれば重複ではない', async () => { + const ctx = makeCtx([ + { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 112, codebaseId: 'cb1' }, + }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('codebaseId が異なれば別物扱い (重複ではない)', async () => { + const ctx = makeCtx([ + { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb2' }, + }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('input の codebaseId が無くても ctx.codebaseId が使われる', async () => { + const ctx = makeCtx( + [{ id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }], + { codebaseId: 'cb1' }, + ); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 100 }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + }); + + it('既存 codebaseId が undefined でも横断的に重複扱い (legacy migration 対応)', async () => { + const ctx = makeCtx([ + { id: 'n_legacy', type: 'coderef', filePath: 'src/a.ts', startLine: 100 }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + }); + + it('filePath が "./" 付きでも正規化して判定', async () => { + const ctx = makeCtx([{ id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100 }]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: './src/a.ts', startLine: 100 }, + }, + ctx, + ); + expect(res?.reason).toContain('重複'); + }); + + it('proposal (adoptAs="coderef") も重複検知の対象', async () => { + const ctx = makeCtx([ + { + id: 'p1', + type: 'proposal', + adoptAs: 'coderef', + filePath: 'src/a.ts', + startLine: 100, + }, + ]); + const res = await coderefGuard.check( + { + title: 'T', + body: '', + additional: { filePath: 'src/a.ts', startLine: 100 }, + }, + ctx, + ); + expect(res?.reason).toContain('p1'); + }); + + it('filePath / startLine が input に無ければ skip (null)', async () => { + const ctx = makeCtx([{ id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100 }]); + const res = await coderefGuard.check({ title: 'T', body: '', additional: undefined }, ctx); + expect(res).toBeNull(); + }); +}); diff --git a/packages/ai-engine/src/duplicate-guards/coderef.ts b/packages/ai-engine/src/duplicate-guards/coderef.ts new file mode 100644 index 0000000..edd3f6d --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/coderef.ts @@ -0,0 +1,57 @@ +import path from 'node:path'; + +import type { DuplicateGuard } from './index'; + +// 既存 create-node.ts の findDuplicateCoderef ロジックを移行 (動作不変)。 +// `find-related-code` / `analyze-impact` はスキャン位置がブレやすいので、 +// 同一 filePath で ±10 行以内の近接 coderef を重複扱いする。 +const CODEREF_LINE_TOLERANCE = 10; + +// "./src/a.ts" や "src//a.ts" を "src/a.ts" に正規化する。 +function normalizeFilePath(fp: string): string { + const stripped = fp.startsWith('./') ? fp.slice(2) : fp; + return path.posix.normalize(stripped); +} + +export const coderefGuard: DuplicateGuard = { + adoptAs: 'coderef', + async check(input, ctx) { + const additional = input.additional ?? {}; + const fp = additional.filePath; + const sl = additional.startLine; + if (typeof fp !== 'string' || typeof sl !== 'number') return null; + + const normalized = normalizeFilePath(fp); + // input 側の codebaseId 優先、無ければ ctx の codebaseId を使う + const inputCb = + typeof additional.codebaseId === 'string' ? (additional.codebaseId as string) : undefined; + const activeCbId = inputCb ?? ctx.codebaseId; + + const all = await ctx.store.listNodes(); + for (const n of all) { + const rec = n as Record; + const type = rec.type as string | undefined; + const adoptAs = rec.adoptAs as string | undefined; + // 正規 coderef と adoptAs=coderef proposal の両方を対象 + const isCoderef = type === 'coderef' || (type === 'proposal' && adoptAs === 'coderef'); + if (!isCoderef) continue; + const existingFp = rec.filePath as string | undefined; + const existingSl = rec.startLine as number | undefined; + if (!existingFp || typeof existingSl !== 'number') continue; + if (normalizeFilePath(existingFp) !== normalized) continue; + // マルチコードベース: 両方が codebaseId を持ち、かつ異なれば別物扱い。 + // 一方でも undefined なら従来通り全件比較 (legacy migration 対応)。 + const existingCb = rec.codebaseId as string | undefined; + if (activeCbId !== undefined && existingCb !== undefined && existingCb !== activeCbId) { + continue; + } + if (Math.abs(existingSl - sl) <= CODEREF_LINE_TOLERANCE) { + const id = rec.id as string; + return { + reason: `重複: ${id} と近接 (filePath=${normalized}, startLine 差=${Math.abs(existingSl - sl)})`, + }; + } + } + return null; + }, +}; diff --git a/packages/ai-engine/src/duplicate-guards/index.ts b/packages/ai-engine/src/duplicate-guards/index.ts index 53af068..73fcf38 100644 --- a/packages/ai-engine/src/duplicate-guards/index.ts +++ b/packages/ai-engine/src/duplicate-guards/index.ts @@ -71,3 +71,9 @@ export function notifyCreated( export function __resetGuardsForTest(): void { REGISTRY.clear(); } + +import { coderefGuard } from './coderef'; + +// 個別 guard を register する (module load 時の副作用)。 +// テストは __resetGuardsForTest でクリアした後、必要な guard を再登録すること。 +registerGuard(coderefGuard); From 1573cdbd1f750dfc9e2fa05571a4c86854aa42c3 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 14:50:33 +0900 Subject: [PATCH 14/34] =?UTF-8?q?feat(ai-engine):=20question=20=E9=87=8D?= =?UTF-8?q?=E8=A4=87=E3=82=AC=E3=83=BC=E3=83=89=E3=82=92=20duplicate-guard?= =?UTF-8?q?s/question.ts=20=E3=81=AB=E5=88=86=E9=9B=A2=20(anchorId=20?= =?UTF-8?q?=E7=A9=BA=E3=81=AF=20skip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-engine/src/duplicate-guards/index.ts | 2 + .../src/duplicate-guards/question.test.ts | 110 ++++++++++++++++++ .../src/duplicate-guards/question.ts | 48 ++++++++ 3 files changed, 160 insertions(+) create mode 100644 packages/ai-engine/src/duplicate-guards/question.test.ts create mode 100644 packages/ai-engine/src/duplicate-guards/question.ts diff --git a/packages/ai-engine/src/duplicate-guards/index.ts b/packages/ai-engine/src/duplicate-guards/index.ts index 73fcf38..617371c 100644 --- a/packages/ai-engine/src/duplicate-guards/index.ts +++ b/packages/ai-engine/src/duplicate-guards/index.ts @@ -73,7 +73,9 @@ export function __resetGuardsForTest(): void { } import { coderefGuard } from './coderef'; +import { questionGuard } from './question'; // 個別 guard を register する (module load 時の副作用)。 // テストは __resetGuardsForTest でクリアした後、必要な guard を再登録すること。 registerGuard(coderefGuard); +registerGuard(questionGuard); diff --git a/packages/ai-engine/src/duplicate-guards/question.test.ts b/packages/ai-engine/src/duplicate-guards/question.test.ts new file mode 100644 index 0000000..6b43554 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/question.test.ts @@ -0,0 +1,110 @@ +import type { ProjectStore } from '@tally/storage'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { __resetGuardsForTest, type DuplicateGuardContext } from './index'; +import { questionGuard } from './question'; + +function makeCtx( + neighbors: ReadonlyArray>, + override: Partial = {}, +): DuplicateGuardContext { + const ctx: DuplicateGuardContext = { + store: { + listNodes: async () => [], + findRelatedNodes: async () => neighbors as never, + } as unknown as ProjectStore, + anchorId: 'anchor-1', + sessionMemo: new Set(), + ...override, + }; + return ctx; +} + +describe('questionGuard', () => { + beforeEach(() => __resetGuardsForTest()); + + it('adoptAs は "question"', () => { + expect(questionGuard.adoptAs).toBe('question'); + }); + + it('anchorId が空なら skip (null を返す) — T1 fix の前提', async () => { + const ctx = makeCtx([], { anchorId: '' }); + const res = await questionGuard.check( + { title: '[AI] Q', body: '', additional: undefined }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('同 anchor に同タイトル正規 question が既にあれば重複', async () => { + const ctx = makeCtx([{ id: 'q1', type: 'question', title: 'どうするか' }]); + const res = await questionGuard.check( + { title: '[AI] どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('q1'); + expect(res?.reason).toContain('anchor-1'); + }); + + it('同 anchor に同タイトル proposal (adoptAs=question) が既にあれば重複', async () => { + const ctx = makeCtx([ + { id: 'p1', type: 'proposal', adoptAs: 'question', title: '[AI] どうするか' }, + ]); + const res = await questionGuard.check( + { title: 'どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('p1'); + }); + + it('[AI] prefix の有無を吸収して比較する', async () => { + const ctx = makeCtx([{ id: 'q1', type: 'question', title: '[AI] どうするか' }]); + const res = await questionGuard.check( + { title: 'どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('q1'); + }); + + it('sessionMemo に同 anchor+title が記録済みなら重複 (同セッション内の連続生成防止)', async () => { + const ctx = makeCtx([], { sessionMemo: new Set(['anchor-1|どうするか']) }); + const res = await questionGuard.check( + { title: '[AI] どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res?.reason).toContain('同一セッション'); + expect(res?.reason).toContain('anchor-1'); + }); + + it('別タイトルなら重複ではない', async () => { + const ctx = makeCtx([{ id: 'q1', type: 'question', title: 'どうするか' }]); + const res = await questionGuard.check( + { title: '[AI] 別の論点', body: '', additional: undefined }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('近傍に他 type のノード (例 usecase) は無視', async () => { + const ctx = makeCtx([{ id: 'u1', type: 'usecase', title: 'どうするか' }]); + const res = await questionGuard.check( + { title: '[AI] どうするか', body: '', additional: undefined }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('onCreated が anchorId+title を sessionMemo に追加', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + questionGuard.onCreated?.({ title: '[AI] 新しい論点', body: '', additional: undefined }, ctx); + expect(memo.has('anchor-1|新しい論点')).toBe(true); + }); + + it('onCreated は anchorId が空なら何もしない', () => { + const memo = new Set(); + const ctx = makeCtx([], { anchorId: '', sessionMemo: memo }); + questionGuard.onCreated?.({ title: '[AI] X', body: '', additional: undefined }, ctx); + expect(memo.size).toBe(0); + }); +}); diff --git a/packages/ai-engine/src/duplicate-guards/question.ts b/packages/ai-engine/src/duplicate-guards/question.ts new file mode 100644 index 0000000..20fc189 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/question.ts @@ -0,0 +1,48 @@ +import { stripAiPrefix } from '@tally/core'; + +import type { DuplicateGuard } from './index'; + +// 既存 create-node.ts の question 用重複ガード (sessionQuestionKeys + findRelatedNodes) を移行。 +// +// T1 fix の前提: chat 経由 (anchorId が空) ではこの guard は skip し、 +// Task 9 の sourceUrl guard が代替で重複検知する。 +// +// 比較方針: +// - title は stripAiPrefix で "[AI]" prefix を剥がしてから比較 (AI 提案と人間生成の混在対応) +// - 同セッション内の連続生成は sessionMemo (anchorId|normalizedTitle) で短絡防止 +// - DB 側は anchor の近傍 (findRelatedNodes) を引き、同タイトルの正規 question or +// proposal(adoptAs=question) があれば重複扱い +export const questionGuard: DuplicateGuard = { + adoptAs: 'question', + async check(input, ctx) { + // T1: anchorId が空なら skip (chat 経路で findRelatedNodes('') は空配列) + if (!ctx.anchorId) return null; + + const normalizedTitle = stripAiPrefix(input.title); + const sessionKey = `${ctx.anchorId}|${normalizedTitle}`; + if (ctx.sessionMemo.has(sessionKey)) { + return { + reason: `重複 (同一セッション内): anchor ${ctx.anchorId} に既に同タイトル question を生成済み`, + }; + } + + const neighbors = await ctx.store.findRelatedNodes(ctx.anchorId); + for (const n of neighbors) { + const rec = n as unknown as { id: string; type: string; adoptAs?: string; title: string }; + const isQuestion = + rec.type === 'question' || (rec.type === 'proposal' && rec.adoptAs === 'question'); + if (isQuestion && stripAiPrefix(rec.title) === normalizedTitle) { + return { + reason: `重複: anchor ${ctx.anchorId} に既に同タイトル question 候補 ${rec.id} が存在`, + }; + } + } + return null; + }, + onCreated(input, ctx) { + // anchor 無し (chat) では memo しない (T1 fix の対称) + if (!ctx.anchorId) return; + const normalizedTitle = stripAiPrefix(input.title); + ctx.sessionMemo.add(`${ctx.anchorId}|${normalizedTitle}`); + }, +}; From e2e6efe155d8c1dbd760552f5f40887213f9ec09 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:02:41 +0900 Subject: [PATCH 15/34] =?UTF-8?q?feat(ai-engine):=20sourceUrl=20=E3=83=99?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E9=87=8D=E8=A4=87=E3=82=AC=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=20(T1=20fix:=20chat=20anchor=20?= =?UTF-8?q?=E7=84=A1=E3=81=97=E3=81=A7=E3=82=82=E5=8B=95=E3=81=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-engine/src/duplicate-guards/index.ts | 2 + .../src/duplicate-guards/source-url.test.ts | 129 ++++++++++++++++++ .../src/duplicate-guards/source-url.ts | 54 ++++++++ 3 files changed, 185 insertions(+) create mode 100644 packages/ai-engine/src/duplicate-guards/source-url.test.ts create mode 100644 packages/ai-engine/src/duplicate-guards/source-url.ts diff --git a/packages/ai-engine/src/duplicate-guards/index.ts b/packages/ai-engine/src/duplicate-guards/index.ts index 617371c..90ee894 100644 --- a/packages/ai-engine/src/duplicate-guards/index.ts +++ b/packages/ai-engine/src/duplicate-guards/index.ts @@ -74,8 +74,10 @@ export function __resetGuardsForTest(): void { import { coderefGuard } from './coderef'; import { questionGuard } from './question'; +import { sourceUrlGuard } from './source-url'; // 個別 guard を register する (module load 時の副作用)。 // テストは __resetGuardsForTest でクリアした後、必要な guard を再登録すること。 registerGuard(coderefGuard); registerGuard(questionGuard); +registerGuard(sourceUrlGuard); diff --git a/packages/ai-engine/src/duplicate-guards/source-url.test.ts b/packages/ai-engine/src/duplicate-guards/source-url.test.ts new file mode 100644 index 0000000..08022f6 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/source-url.test.ts @@ -0,0 +1,129 @@ +import type { ProjectStore } from '@tally/storage'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { __resetGuardsForTest, type DuplicateGuardContext } from './index'; +import { sourceUrlGuard } from './source-url'; + +function makeCtx( + nodes: ReadonlyArray>, + override: Partial = {}, +): DuplicateGuardContext { + return { + store: { + listNodes: async () => nodes as never, + findRelatedNodes: async () => [], + } as unknown as ProjectStore, + anchorId: '', + sessionMemo: new Set(), + ...override, + }; +} + +describe('sourceUrlGuard', () => { + beforeEach(() => __resetGuardsForTest()); + + it('adoptAs は "requirement"', () => { + expect(sourceUrlGuard.adoptAs).toBe('requirement'); + }); + + it('sourceUrl が additional に無ければ skip (null)', async () => { + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: undefined }, + makeCtx([]), + ); + expect(res).toBeNull(); + }); + + it('sourceUrl が空文字なら skip (null)', async () => { + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: '' } }, + makeCtx([]), + ); + expect(res).toBeNull(); + }); + + it('同 sourceUrl の正規 requirement が既にあれば重複', async () => { + const ctx = makeCtx([{ id: 'r1', type: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res?.reason).toContain('r1'); + expect(res?.reason).toContain('https://jira.test/EPIC-1'); + }); + + it('同 sourceUrl の proposal(adoptAs=requirement) も重複検知対象', async () => { + const ctx = makeCtx([ + { id: 'p1', type: 'proposal', adoptAs: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }, + ]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res?.reason).toContain('p1'); + }); + + it('別 sourceUrl なら重複ではない', async () => { + const ctx = makeCtx([{ id: 'r1', type: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-2' } }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('他 type のノード (例 usecase) は無視', async () => { + const ctx = makeCtx([{ id: 'u1', type: 'usecase', sourceUrl: 'https://jira.test/EPIC-1' }]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res).toBeNull(); + }); + + it('sessionMemo に記録済みなら重複 (連続生成防止)', async () => { + const memo = new Set(['sourceUrl:https://jira.test/EPIC-1']); + const ctx = makeCtx([], { sessionMemo: memo }); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res?.reason).toContain('同一セッション'); + }); + + it('chat 経路 (anchorId="") でも sourceUrl で重複検知 — T1 fix の核', async () => { + const ctx = makeCtx( + [{ id: 'r1', type: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }], + { anchorId: '' }, + ); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res?.reason).toContain('r1'); + }); + + it('onCreated で sessionMemo にキーを追加', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + sourceUrlGuard.onCreated?.( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(memo.has('sourceUrl:https://jira.test/EPIC-1')).toBe(true); + }); + + it('onCreated は sourceUrl が無いときは何もしない', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + sourceUrlGuard.onCreated?.({ title: 'R', body: '', additional: undefined }, ctx); + expect(memo.size).toBe(0); + }); + + it('onCreated は sourceUrl が空文字なら何もしない', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + sourceUrlGuard.onCreated?.({ title: 'R', body: '', additional: { sourceUrl: '' } }, ctx); + expect(memo.size).toBe(0); + }); +}); diff --git a/packages/ai-engine/src/duplicate-guards/source-url.ts b/packages/ai-engine/src/duplicate-guards/source-url.ts new file mode 100644 index 0000000..5916313 --- /dev/null +++ b/packages/ai-engine/src/duplicate-guards/source-url.ts @@ -0,0 +1,54 @@ +import type { DuplicateGuard } from './index'; + +// sourceUrl ベースの重複検知。 +// +// T1 fix の核: anchor 不要 → chat (anchorId='') でも動く。 +// 既存 question guard は anchorId に依存して findRelatedNodes 経由で動くが、 +// chat 経路では anchorId が空文字で findRelatedNodes('') が空配列を返すため重複ガードが dead。 +// sourceUrl は anchor 概念がないので、グラフ全件スキャンで重複検知する。 +// +// 対象: +// - 正規 requirement (`type === 'requirement'`) +// - adoptAs=requirement の proposal (`type === 'proposal' && adoptAs === 'requirement'`) +// +// memo キー: `sourceUrl:${url}` (anchor 非依存、グラフ横断で一意) +const SESSION_KEY_PREFIX = 'sourceUrl:'; + +export const sourceUrlGuard: DuplicateGuard = { + adoptAs: 'requirement', + async check(input, ctx) { + const sourceUrl = input.additional?.sourceUrl; + if (typeof sourceUrl !== 'string' || sourceUrl.length === 0) return null; + + const sessionKey = `${SESSION_KEY_PREFIX}${sourceUrl}`; + if (ctx.sessionMemo.has(sessionKey)) { + return { + reason: `重複 (同一セッション内): sourceUrl ${sourceUrl} を既に生成済み`, + }; + } + + const all = await ctx.store.listNodes(); + for (const n of all) { + const rec = n as Record; + const type = rec.type as string | undefined; + const adoptAs = rec.adoptAs as string | undefined; + const isRequirement = + type === 'requirement' || (type === 'proposal' && adoptAs === 'requirement'); + if (!isRequirement) continue; + const existingUrl = rec.sourceUrl as string | undefined; + if (existingUrl === sourceUrl) { + const id = rec.id as string; + return { + reason: `重複: sourceUrl ${sourceUrl} は既に node ${id} が保持`, + }; + } + } + return null; + }, + onCreated(input, ctx) { + const sourceUrl = input.additional?.sourceUrl; + if (typeof sourceUrl === 'string' && sourceUrl.length > 0) { + ctx.sessionMemo.add(`${SESSION_KEY_PREFIX}${sourceUrl}`); + } + }, +}; From c750be1fadfdff54c6d60cebc63bc88f51e18ee5 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:07:28 +0900 Subject: [PATCH 16/34] =?UTF-8?q?refactor(ai-engine):=20create-node=20?= =?UTF-8?q?=E3=82=92=20duplicate-guards=20=E3=81=AB=E5=A7=94=E8=AD=B2?= =?UTF-8?q?=E3=80=81sourceUrl=20guard=20=E3=82=92=E6=9C=89=E5=8A=B9?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-engine/src/tools/create-node.test.ts | 156 ++++++++++++++++++ packages/ai-engine/src/tools/create-node.ts | 142 ++++++---------- 2 files changed, 203 insertions(+), 95 deletions(-) diff --git a/packages/ai-engine/src/tools/create-node.test.ts b/packages/ai-engine/src/tools/create-node.test.ts index a5e4b0a..bdcf089 100644 --- a/packages/ai-engine/src/tools/create-node.test.ts +++ b/packages/ai-engine/src/tools/create-node.test.ts @@ -695,3 +695,159 @@ describe('adoptAs=question の anchor+同タイトル重複ガード', () => { expect(r2.ok).toBe(true); }); }); + +describe('createNodeHandler — sourceUrl 重複ガード (Task 9 連動)', () => { + // duplicate-guards/source-url.ts と組み合わさった統合テスト。 + // Task 10 の主目的: dispatcher 経由で sourceUrl guard が発火することを担保する。 + + it('同 sourceUrl の requirement が既にあれば 2 度目の作成は fail (chat anchor 無し経路)', async () => { + const existing = [ + { + id: 'req-old', + type: 'requirement', + x: 0, + y: 0, + title: 'Jira Issue', + body: '', + sourceUrl: 'https://example.atlassian.net/browse/PROJ-1', + }, + ]; + const store = { + listNodes: vi.fn().mockResolvedValue(existing), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addNode: vi.fn(), + } as unknown as ProjectStore; + const handler = createNodeHandler({ + store, + emit: () => {}, + anchor: { x: 0, y: 0 }, + // chat 経路: anchorId は空文字 + anchorId: '', + agentName: 'extract-questions', + }); + const res = await handler({ + adoptAs: 'requirement', + title: 'Jira Issue', + body: '', + additional: { sourceUrl: 'https://example.atlassian.net/browse/PROJ-1' }, + }); + expect(res.ok).toBe(false); + expect(res.output).toContain('sourceUrl'); + expect(store.addNode).not.toHaveBeenCalled(); + }); + + it('chat 経路 (anchorId="") でも sourceUrl で重複検知が動く (proposal 既存)', async () => { + const existing = [ + { + id: 'prop-req-0', + type: 'proposal', + adoptAs: 'requirement', + x: 0, + y: 0, + title: '[AI] Jira Issue', + body: '', + sourceUrl: 'https://example.atlassian.net/browse/PROJ-2', + }, + ]; + const store = { + listNodes: vi.fn().mockResolvedValue(existing), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addNode: vi.fn(), + } as unknown as ProjectStore; + const handler = createNodeHandler({ + store, + emit: () => {}, + anchor: { x: 0, y: 0 }, + anchorId: '', + agentName: 'extract-questions', + }); + const res = await handler({ + adoptAs: 'requirement', + title: 'Jira Issue 再取込', + body: '', + additional: { sourceUrl: 'https://example.atlassian.net/browse/PROJ-2' }, + }); + expect(res.ok).toBe(false); + expect(res.output).toContain('sourceUrl'); + expect(store.addNode).not.toHaveBeenCalled(); + }); + + it('別 sourceUrl なら重複扱いしない (新規 requirement として通る)', async () => { + const existing = [ + { + id: 'req-old', + type: 'requirement', + x: 0, + y: 0, + title: 'Jira PROJ-1', + body: '', + sourceUrl: 'https://example.atlassian.net/browse/PROJ-1', + }, + ]; + const added: Array> = []; + const store = { + listNodes: vi.fn().mockResolvedValue(existing), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addNode: vi.fn().mockImplementation(async (n: Record) => { + const created = { ...n, id: `n-${added.length + 1}` }; + added.push(created); + return created; + }), + } as unknown as ProjectStore; + const handler = createNodeHandler({ + store, + emit: () => {}, + anchor: { x: 0, y: 0 }, + anchorId: '', + agentName: 'extract-questions', + }); + const res = await handler({ + adoptAs: 'requirement', + title: 'Jira PROJ-2', + body: '', + additional: { sourceUrl: 'https://example.atlassian.net/browse/PROJ-2' }, + }); + expect(res.ok).toBe(true); + expect(added).toHaveLength(1); + expect(added[0]?.sourceUrl).toBe('https://example.atlassian.net/browse/PROJ-2'); + }); + + it('sourceUrl 無し (legacy / 非外部 ingest) は guard skip → 通常通り作成', async () => { + const existing = [ + { + id: 'req-old', + type: 'requirement', + x: 0, + y: 0, + title: '内部要求', + body: '', + // sourceUrl 無し + }, + ]; + const added: Array> = []; + const store = { + listNodes: vi.fn().mockResolvedValue(existing), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addNode: vi.fn().mockImplementation(async (n: Record) => { + const created = { ...n, id: `n-${added.length + 1}` }; + added.push(created); + return created; + }), + } as unknown as ProjectStore; + const handler = createNodeHandler({ + store, + emit: () => {}, + anchor: { x: 0, y: 0 }, + anchorId: '', + agentName: 'extract-questions', + }); + const res = await handler({ + adoptAs: 'requirement', + title: '別の内部要求', + body: '', + // sourceUrl 無し → guard skip + }); + expect(res.ok).toBe(true); + expect(added).toHaveLength(1); + }); +}); diff --git a/packages/ai-engine/src/tools/create-node.ts b/packages/ai-engine/src/tools/create-node.ts index 6a9886a..75c27dd 100644 --- a/packages/ai-engine/src/tools/create-node.ts +++ b/packages/ai-engine/src/tools/create-node.ts @@ -1,16 +1,26 @@ import path from 'node:path'; import type { AdoptableType, AgentName, ProposalNode } from '@tally/core'; -import { newQuestionOptionId, stripAiPrefix } from '@tally/core'; +import { newQuestionOptionId } from '@tally/core'; import type { ProjectStore } from '@tally/storage'; import { z } from 'zod'; +import { + type DuplicateGuardContext, + dispatchDuplicateGuard, + notifyCreated, +} from '../duplicate-guards/index'; import type { AgentEvent } from '../stream'; // create_node: ツールハンドラ。AI は proposal しか作れない (ADR-0005 前提)。 // adoptAs は「採用されたら何になるか」を宣言。title に [AI] プレフィックスが無ければ自動付与。 // x/y 未指定時は呼び出し元が与える anchor 座標を基準に自動オフセット配置。 -// coderef の場合は filePath を正規化し、近接する既存 coderef があれば重複としてガードする。 +// +// 重複検知は duplicate-guards/ の strategy map に委譲 (Task 6-9 で抽出)。 +// ここでは「保存内容の整合性」だけ責任を持つ: +// - coderef の filePath 正規化と codebaseId 注入 (DB に書く値そのものを揃える) +// - question の options 正規化と min 2 検証 (採用後 decision 不能を防ぐ) +// - dispatcher 呼び出し → addNode → notifyCreated const ADOPTABLE_TYPES = [ 'requirement', @@ -36,7 +46,7 @@ export interface CreateNodeDeps { store: ProjectStore; emit: (e: AgentEvent) => void; anchor: { x: number; y: number }; - // anchor ノードの id。question 重複ガードで近傍を引くために使う。 + // anchor ノードの id。question 重複ガードで近傍を引くために使う。chat 経路は空文字。 anchorId: string; // AI が生成した proposal に sourceAgentId として刻むエージェント名。 // どの agent が作ったかを後から辿れるようにするため required。 @@ -51,63 +61,26 @@ export interface ToolResult { output: string; } -// filePath 近接判定の許容行数。`find-related-code` / `analyze-impact` は -// スキャン位置がブレやすく、同一箇所を複数 proposal として追加しがちなので -// ±10 行以内を重複とみなしてガードする。 -const CODEREF_LINE_TOLERANCE = 10; - -// "./src/a.ts" や "src//a.ts" を "src/a.ts" に正規化する。 -// 比較・保存を揃えるため。 -function normalizeFilePath(fp: string): string { - const stripped = fp.startsWith('./') ? fp.slice(2) : fp; - return path.posix.normalize(stripped); -} - -async function findDuplicateCoderef( - store: ProjectStore, - filePath: string, - startLine: number, - codebaseId: string | undefined, -): Promise<{ id: string; startLine: number } | null> { - const all = await store.listNodes(); - for (const n of all) { - const rec = n as Record; - const type = rec.type as string | undefined; - const adoptAs = rec.adoptAs as string | undefined; - // 正規 coderef と、adoptAs=coderef の proposal の両方を対象にする。 - const isCoderef = type === 'coderef' || (type === 'proposal' && adoptAs === 'coderef'); - if (!isCoderef) continue; - const fp = rec.filePath as string | undefined; - const sl = rec.startLine as number | undefined; - if (!fp || typeof sl !== 'number') continue; - if (normalizeFilePath(fp) !== filePath) continue; - // マルチコードベース対応: 同一 filePath でも codebaseId が異なれば別物として扱う。 - // codebaseId 未指定の旧 proposal (レガシー) や横断エージェントは従来通り全件比較する。 - const existingCb = rec.codebaseId as string | undefined; - if (codebaseId !== undefined && existingCb !== undefined && existingCb !== codebaseId) { - continue; - } - if (Math.abs(sl - startLine) <= CODEREF_LINE_TOLERANCE) { - return { id: rec.id as string, startLine: sl }; - } - } - return null; -} - // adoptAs=question の options として有効な最小数。extract-questions の仕様上 // 「必ず 2〜4 個」とプロンプト指示しているが、AI が守らなかったとき proposal // 採用後に decision を選べない question が出来てしまうのでサーバ側でも弾く。 const QUESTION_MIN_OPTIONS = 2; +// 保存前の filePath 正規化 (guard 内の正規化とは独立、保存内容の整合性のため必須)。 +// "./src/a.ts" や "src//a.ts" を "src/a.ts" に揃えて DB に書く。 +function normalizeFilePathForStorage(fp: string): string { + const stripped = fp.startsWith('./') ? fp.slice(2) : fp; + return path.posix.normalize(stripped); +} + export function createNodeHandler(deps: CreateNodeDeps) { // 複数ノードが同じ anchor で作られたときに重ならないよう、呼び出しごとにオフセットをずらす。 // agent セッション毎に独立させるため handler closure で保持。 let nextOffsetIndex = 0; - // 同一セッション内で作成済みの question を「anchorId|正規化タイトル」の Set で記録する。 - // findRelatedNodes は edge 経由で近傍を引くため、「create_node × 2 → create_edge × 2」の - // 順にモデルが呼んだとき 1 件目の edge 作成前は 2 件目の findRelatedNodes が 1 件目を - // 拾えず重複が素通りする。セッション内 Set と併用して防ぐ。 - const sessionQuestionKeys = new Set(); + // duplicate-guards の sessionMemo (anchorId|title など、guard 実装が定義するキー)。 + // handler closure で持ち、同一エージェントセッション内の重複を短絡防止する。 + const sessionMemo = new Set(); + return async (input: unknown): Promise => { const parsed = CreateNodeInputSchema.safeParse(input); if (!parsed.success) { @@ -115,9 +88,10 @@ export function createNodeHandler(deps: CreateNodeDeps) { } const { adoptAs, title, body, x, y, additional } = parsed.data; - // coderef のとき filePath を正規化して additional に戻し、さらに近接 coderef を探して重複ガード。 - // deps.codebaseId があれば additional に必ず注入し、adopt 時に codebaseId 必須検証が通るようにする。 let normalizedAdditional = additional; + + // coderef: filePath 正規化 + codebaseId 注入。 + // 保存値の正規化なので guard 委譲とは別に必須 (DB に "./" 付きを書かない)。 if (adoptAs === 'coderef') { const base = additional ?? {}; const withCb: Record = @@ -126,29 +100,14 @@ export function createNodeHandler(deps: CreateNodeDeps) { : { ...base }; const fp = withCb.filePath; if (typeof fp === 'string' && fp.length > 0) { - const normalized = normalizeFilePath(fp); - withCb.filePath = normalized; - const sl = withCb.startLine; - if (typeof sl === 'number') { - const activeCbId = - typeof withCb.codebaseId === 'string' ? (withCb.codebaseId as string) : undefined; - const dup = await findDuplicateCoderef(deps.store, normalized, sl, activeCbId); - if (dup) { - return { - ok: false, - output: `重複: ${dup.id} と近接 (filePath=${normalized}, startLine 差=${Math.abs(dup.startLine - sl)})`, - }; - } - } + withCb.filePath = normalizeFilePathForStorage(fp); } normalizedAdditional = withCb; } - // adoptAs=question: options の正規化 + 有効数チェック + anchor 重複ガード。 + // adoptAs=question: options の正規化 + 有効数チェック。 // AI は { text } だけ渡す (仕様)。id / selected 指定があっても上書きする (信頼境界)。 // options < 2 件の proposal は「決定不能な question」になるのでサーバ側で弾く。 - // sessionKey は addNode 成功後に set へ追加する (失敗時の汚染回避)。 - let sessionKey: string | null = null; if (adoptAs === 'question') { const rawOptions = additional?.options; const normalizedOptions = Array.isArray(rawOptions) @@ -173,32 +132,22 @@ export function createNodeHandler(deps: CreateNodeDeps) { options: normalizedOptions, decision: null, }; - - // anchor の近傍に同タイトル question (正規 or proposal) があれば重複として弾く。 - // 比較は [AI] 接頭辞を剥がして揃える。 - const normalizedTitle = stripAiPrefix(title); - sessionKey = `${deps.anchorId}|${normalizedTitle}`; - if (sessionQuestionKeys.has(sessionKey)) { - return { - ok: false, - output: `重複 (同一セッション内): anchor ${deps.anchorId} に既に同タイトル question を生成済み`, - }; - } - const neighbors = await deps.store.findRelatedNodes(deps.anchorId); - const dup = neighbors.find((n) => { - const rec = n as unknown as { type: string; adoptAs?: string; title: string }; - const isQuestion = - rec.type === 'question' || (rec.type === 'proposal' && rec.adoptAs === 'question'); - return isQuestion && stripAiPrefix(rec.title) === normalizedTitle; - }); - if (dup) { - return { - ok: false, - output: `重複: anchor ${deps.anchorId} に既に同タイトル question 候補 ${(dup as { id: string }).id} が存在`, - }; - } } + // 重複ガード: dispatcher に委譲 (coderef / question / source-url の guard が登録済み)。 + // 重複あれば early return、無ければ addNode に進む。 + // codebaseId は exactOptionalPropertyTypes 対応で条件付きで含める。 + const guardCtx: DuplicateGuardContext = { + store: deps.store, + anchorId: deps.anchorId, + sessionMemo, + ...(deps.codebaseId !== undefined ? { codebaseId: deps.codebaseId } : {}), + }; + const guardInput = { title, body, additional: normalizedAdditional }; + const dup = await dispatchDuplicateGuard(adoptAs, guardInput, guardCtx); + if (dup) return { ok: false, output: dup.reason }; + + // 共通: ensureTitle / placement / addNode const ensuredTitle = title.startsWith('[AI]') ? title : `[AI] ${title}`; const idx = nextOffsetIndex++; const placedX = x ?? deps.anchor.x + 260 + idx * 20; @@ -216,7 +165,10 @@ export function createNodeHandler(deps: CreateNodeDeps) { sourceAgentId: deps.agentName, } as Parameters[0])) as ProposalNode; deps.emit({ type: 'node_created', node: created }); - if (sessionKey) sessionQuestionKeys.add(sessionKey); + + // 生成成功後、guard に通知 (sessionMemo の更新など)。失敗時は通知しない (Set 汚染回避)。 + notifyCreated(adoptAs, guardInput, guardCtx); + return { ok: true, output: JSON.stringify(created) }; } catch (err) { return { ok: false, output: `addNode failed: ${String(err)}` }; From 9fe71bb8db3479c7eb7899280177d8d24697259b Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:13:08 +0900 Subject: [PATCH 17/34] =?UTF-8?q?feat(ai-engine):=20ChatRunner=20=E3=81=8C?= =?UTF-8?q?=20buildMcpServers=20=E3=81=A7=E5=A4=96=E9=83=A8=20MCP=20?= =?UTF-8?q?=E3=82=92=E5=90=88=E6=88=90=20(Task=2011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ai-engine/src/chat-runner.test.ts | 210 +++++++++++++++++++++ packages/ai-engine/src/chat-runner.ts | 37 +++- 2 files changed, 239 insertions(+), 8 deletions(-) diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index 5208d63..c0ed5d1 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -475,3 +475,213 @@ describe('formatNodeForContext / buildChatPrompt', () => { expect(out.slice(curIdx)).not.toContain('過去質問'); }); }); + +describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { + const ORIGINAL_ENV = { ...process.env }; + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('プロジェクト設定の mcpServers[] を sdk.query に動的に渡す (Bearer)', async () => { + process.env.TEST_PAT = 'secret'; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task11-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'test-mcp', + name: 'T', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + + const querySpy = vi.fn(() => + (async function* () { + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner.runUserTurn('hi')) { + /* drain */ + } + + expect(querySpy).toHaveBeenCalled(); + const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { + options?: { + mcpServers?: Record }>; + allowedTools?: string[]; + }; + }; + expect(Object.keys(callArg.options?.mcpServers ?? {})).toEqual( + expect.arrayContaining(['tally', 'test-mcp']), + ); + const testMcp = callArg.options?.mcpServers?.['test-mcp']; + expect(testMcp?.headers?.Authorization).toBe('Bearer secret'); + expect(callArg.options?.allowedTools).toContain('mcp__tally__*'); + expect(callArg.options?.allowedTools).toContain('mcp__test-mcp__*'); + + rmSync(root, { recursive: true, force: true }); + }); + + it('mcpServers[] が空配列なら tally のみ (退行なし)', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'tally-task11b-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const querySpy = vi.fn(() => + (async function* () { + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner.runUserTurn('hi')) { + /* drain */ + } + + const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { + options?: { mcpServers?: Record; allowedTools?: string[] }; + }; + expect(Object.keys(callArg.options?.mcpServers ?? {})).toEqual(['tally']); + expect(callArg.options?.allowedTools).toEqual(['mcp__tally__*']); + + rmSync(root, { recursive: true, force: true }); + }); + + it('env 未設定なら error event を emit、sdk.query は呼ばない', async () => { + delete process.env.MISSING_PAT; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task11c-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'MISSING_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const querySpy = vi.fn(); + const sdk: SdkLike = { query: querySpy }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('hi')) events.push(e); + + expect(querySpy).not.toHaveBeenCalled(); + const errorEvent = events.find((e) => e.type === 'error'); + expect(errorEvent).toBeDefined(); + if (errorEvent && errorEvent.type === 'error') { + expect(errorEvent.message).toMatch(/MISSING_PAT/); + } + + rmSync(root, { recursive: true, force: true }); + }); + + it('Basic auth (Cloud) でも正しく Authorization header が組まれる', async () => { + process.env.ATLASSIAN_EMAIL = 'user@example.com'; + process.env.ATLASSIAN_API_TOKEN = 'token-xyz'; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task11d-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'cloud', + name: 'C', + kind: 'atlassian', + url: 'https://api.atlassian.test/mcp', + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: 'ATLASSIAN_EMAIL', + tokenEnvVar: 'ATLASSIAN_API_TOKEN', + }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const querySpy = vi.fn(() => + (async function* () { + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner.runUserTurn('hi')) { + /* drain */ + } + + const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { + options?: { mcpServers?: Record }> }; + }; + const expected = Buffer.from('user@example.com:token-xyz').toString('base64'); + expect(callArg.options?.mcpServers?.cloud?.headers?.Authorization).toBe(`Basic ${expected}`); + + rmSync(root, { recursive: true, force: true }); + }); +}); diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index 6949430..f421a35 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -9,6 +9,8 @@ import { import type { ChatStore, ProjectStore } from '@tally/storage'; import type { SdkLike } from './agent-runner'; +import { buildMcpServers } from './mcp/build-mcp-servers'; +import { redactMcpSecrets } from './mcp/redact'; import type { ChatEvent, SdkMessageLike } from './stream'; import { CreateEdgeInputSchema, createEdgeHandler } from './tools/create-edge'; import { CreateNodeInputSchema, createNodeHandler } from './tools/create-node'; @@ -133,6 +135,27 @@ export class ChatRunner { const emit = (e: ChatEvent) => queue.push(e); const mcp = this.buildMcpServer(tools, emit, assistantMsgId); + // 4b. プロジェクト設定の mcpServers[] を Tally MCP と合成する (Task 11)。 + // 毎ターン読むことで env / 設定変更がホットリロードされる。 + // env 未設定 (PAT 等) は buildMcpServers が throw するので、ここで補足し + // error event を emit して early return する (sdk.query は呼ばない)。 + const projectMeta = await projectStore.getProjectMeta(); + const externalConfigs = projectMeta?.mcpServers ?? []; + let mcpServers: Record; + let allowedTools: string[]; + try { + const built = buildMcpServers({ tallyMcp: mcp, configs: externalConfigs }); + mcpServers = built.mcpServers; + allowedTools = built.allowedTools; + } catch (err) { + yield { + type: 'error', + code: 'mcp_config_invalid', + message: err instanceof Error ? err.message : String(err), + }; + return; + } + const textBuffer: string[] = []; // 5. SDK query をバックグラウンドで走らせ、queue にイベントを push する。 @@ -143,14 +166,9 @@ export class ChatRunner { prompt, options: { systemPrompt, - mcpServers: { tally: mcp as unknown as Record }, + mcpServers, tools: [], - allowedTools: [ - 'mcp__tally__create_node', - 'mcp__tally__create_edge', - 'mcp__tally__find_related', - 'mcp__tally__list_by_type', - ], + allowedTools, permissionMode: 'dontAsk', settingSources: [], cwd: projectDir, @@ -161,7 +179,10 @@ export class ChatRunner { }); for await (const msg of iter) { - console.log('[chat-runner] sdk msg:', JSON.stringify(msg).slice(0, 200)); + console.log( + '[chat-runner] sdk msg:', + JSON.stringify(redactMcpSecrets(msg)).slice(0, 200), + ); const blocks = extractAssistantBlocks(msg); for (const b of blocks) { if (b.type === 'text') { From a2b1be3058ec15ff28638e6d32e909c540ab131c Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:34:21 +0900 Subject: [PATCH 18/34] =?UTF-8?q?feat(ai-engine):=20=E5=A4=96=E9=83=A8=20M?= =?UTF-8?q?CP=20=E3=81=AE=20tool=5Fuse/tool=5Fresult=20=E3=82=92=20source?= =?UTF-8?q?=3Dexternal=20=E3=81=A7=E6=B0=B8=E7=B6=9A=E5=8C=96=20(Task=2012?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatEvent に chat_tool_external_use / chat_tool_external_result を追加 - extractAssistantBlocks を拡張: text + 外部 MCP tool_use + tool_result を返す - mcp__tally__* は intercept 経路で処理されるため拾わない (重複回避) - chatStore.appendBlockToMessage で source: 'external' で永続化、approval 省略 - console.log は redactMcpSecrets でラップ済み (Task 11 で導入済) --- packages/ai-engine/src/chat-runner.test.ts | 232 +++++++++++++++++++++ packages/ai-engine/src/chat-runner.ts | 90 +++++++- packages/ai-engine/src/stream.ts | 17 ++ 3 files changed, 332 insertions(+), 7 deletions(-) diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index c0ed5d1..f1ad729 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -685,3 +685,235 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { rmSync(root, { recursive: true, force: true }); }); }); + +describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', () => { + const ORIGINAL_ENV = { ...process.env }; + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('外部 MCP の tool_use を source=external で永続化、chat_tool_external_use event を emit', async () => { + process.env.TEST_PAT = 'secret'; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task12a-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Jira を読みます' }, + { + type: 'tool_use', + id: 'atlassian-tu-1', + name: 'mcp__atlassian__jira_get_issue', + input: { issueKey: 'EPIC-1' }, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'atlassian-tu-1', + content: [{ type: 'text', text: '{"summary":"Epic title"}' }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('@JIRA EPIC-1')) events.push(e); + + const useEvent = events.find((e) => e.type === 'chat_tool_external_use'); + expect(useEvent).toBeDefined(); + if (useEvent && useEvent.type === 'chat_tool_external_use') { + expect(useEvent.toolUseId).toBe('atlassian-tu-1'); + expect(useEvent.name).toBe('mcp__atlassian__jira_get_issue'); + } + const resultEvent = events.find((e) => e.type === 'chat_tool_external_result'); + expect(resultEvent).toBeDefined(); + if (resultEvent && resultEvent.type === 'chat_tool_external_result') { + expect(resultEvent.toolUseId).toBe('atlassian-tu-1'); + expect(resultEvent.ok).toBe(true); + expect(resultEvent.output).toContain('Epic title'); + } + + const reloaded = await chatStore.getChat(thread.id); + const asstMsg = reloaded?.messages.find((m) => m.role === 'assistant'); + const toolUse = asstMsg?.blocks.find((b) => b.type === 'tool_use'); + expect(toolUse).toBeDefined(); + if (toolUse?.type === 'tool_use') { + expect(toolUse.source).toBe('external'); + expect(toolUse.name).toBe('mcp__atlassian__jira_get_issue'); + expect(toolUse.approval).toBeUndefined(); + } + const toolResult = asstMsg?.blocks.find((b) => b.type === 'tool_result'); + expect(toolResult).toBeDefined(); + if (toolResult?.type === 'tool_result') { + expect(toolResult.ok).toBe(true); + expect(toolResult.output).toContain('Epic title'); + } + + rmSync(root, { recursive: true, force: true }); + }); + + it('mcp__tally__ で始まる tool_use は無視 (intercept 経路で処理されるため)', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'tally-task12b-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { type: 'text', text: '作ります' }, + { + type: 'tool_use', + id: 'tally-tu', + name: 'mcp__tally__create_node', + input: {}, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('hi')) events.push(e); + + expect(events.find((e) => e.type === 'chat_tool_external_use')).toBeUndefined(); + + rmSync(root, { recursive: true, force: true }); + }); + + it('外部 tool_result が is_error=true なら ok=false で記録', async () => { + process.env.TEST_PAT = 'secret'; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task12c-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'err-tu', + name: 'mcp__atlassian__jira_get_issue', + input: { issueKey: 'BOGUS' }, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'err-tu', + content: [{ type: 'text', text: '404 not found' }], + is_error: true, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('q')) events.push(e); + + const evt = events.find((e) => e.type === 'chat_tool_external_result'); + expect(evt).toBeDefined(); + if (evt && evt.type === 'chat_tool_external_result') { + expect(evt.ok).toBe(false); + expect(evt.output).toContain('404'); + } + + rmSync(root, { recursive: true, force: true }); + }); +}); diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index f421a35..4b9564b 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -27,9 +27,13 @@ export interface ChatRunnerDeps { threadId: string; } -// SDK の assistant message から抽出する block の単純化形。 -// MCP 経路に一本化したため tool_use は拾わないが、将来のデバッグ用に型定義は保持。 -type ExtractedBlock = { type: 'text'; text: string }; +// SDK の assistant / user message から抽出する block の単純化形。 +// Tally MCP の tool_use は MCP intercept 経路で処理されるので拾わない。 +// 外部 MCP (mcp__tally__ 以外) の tool_use / tool_result は永続化と UI 通知のためここで拾う (Task 12)。 +type ExtractedBlock = + | { type: 'text'; text: string } + | { type: 'tool_use'; toolUseId: string; name: string; input: unknown } + | { type: 'tool_result'; toolUseId: string; ok: boolean; output: string }; // MCP ツール名と、そのハンドラ (承認必要かどうか) を束ねるエントリ。 // 承認必須のツールは create_* 系 (書き込み)、承認不要は find_related / list_by_type (読み取り)。 @@ -188,6 +192,36 @@ export class ChatRunner { if (b.type === 'text') { textBuffer.push(b.text); queue.push({ type: 'chat_text_delta', messageId: assistantMsgId, text: b.text }); + } else if (b.type === 'tool_use') { + // 外部 MCP の tool_use: source='external' で永続化、承認 UI なし (Task 12)。 + await chatStore.appendBlockToMessage(threadId, assistantMsgId, { + type: 'tool_use', + toolUseId: b.toolUseId, + name: b.name, + input: b.input, + source: 'external', + }); + queue.push({ + type: 'chat_tool_external_use', + messageId: assistantMsgId, + toolUseId: b.toolUseId, + name: b.name, + input: b.input, + }); + } else if (b.type === 'tool_result') { + await chatStore.appendBlockToMessage(threadId, assistantMsgId, { + type: 'tool_result', + toolUseId: b.toolUseId, + ok: b.ok, + output: b.output, + }); + queue.push({ + type: 'chat_tool_external_result', + messageId: assistantMsgId, + toolUseId: b.toolUseId, + ok: b.ok, + output: b.output, + }); } } } @@ -613,20 +647,62 @@ export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] = return lines.join('\n'); } -// SDK から流れてくる assistant message の content 配列から text を取り出す。 -// tool_use は MCP 経路が処理するのでここでは無視する (重複処理を避ける)。 +// SDK から流れてくる assistant message + user message (tool_result を含む) から block 抽出。 +// 拾うもの: +// - assistant.text (existing 動作維持) +// - tool_use で name が mcp__tally__ で始まらないもの (= 外部 MCP、Task 12) +// - tool_result 全部 (外部 MCP の応答、user message に含まれる) +// +// Tally MCP (mcp__tally__*) の tool_use は createSdkMcpServer の intercept 経路で +// invokeInterceptedTool が処理するので、ここで拾うと二重処理になる。よって除外。 // 実行時 duck typing (agent-runner.ts の sdkMessageToAgentEvent と同じパターン)。 function extractAssistantBlocks(msg: SdkMessageLike): ExtractedBlock[] { const m = msg as unknown as { type?: string; message?: { content?: unknown[] } }; - if (m.type !== 'assistant' || !m.message?.content) return []; + if ((m.type !== 'assistant' && m.type !== 'user') || !m.message?.content) return []; const out: ExtractedBlock[] = []; for (const block of m.message.content) { const b = block as { type?: string; text?: string; + id?: string; + name?: string; + input?: unknown; + tool_use_id?: string; + content?: unknown; + is_error?: boolean; }; - if (b.type === 'text' && typeof b.text === 'string') { + if (b.type === 'text' && typeof b.text === 'string' && m.type === 'assistant') { out.push({ type: 'text', text: b.text }); + } else if ( + b.type === 'tool_use' && + typeof b.id === 'string' && + typeof b.name === 'string' && + !b.name.startsWith('mcp__tally__') + ) { + out.push({ + type: 'tool_use', + toolUseId: b.id, + name: b.name, + input: b.input, + }); + } else if (b.type === 'tool_result' && typeof b.tool_use_id === 'string') { + // content は string or [{type:'text', text:'...'}] で来る (SDK 仕様)。string 化する。 + let outputText = ''; + if (typeof b.content === 'string') { + outputText = b.content; + } else if (Array.isArray(b.content)) { + outputText = b.content + .map((c: { type?: string; text?: string }) => + c.type === 'text' && typeof c.text === 'string' ? c.text : '', + ) + .join(''); + } + out.push({ + type: 'tool_result', + toolUseId: b.tool_use_id, + ok: b.is_error !== true, + output: outputText, + }); } } return out; diff --git a/packages/ai-engine/src/stream.ts b/packages/ai-engine/src/stream.ts index 040573c..1b9a916 100644 --- a/packages/ai-engine/src/stream.ts +++ b/packages/ai-engine/src/stream.ts @@ -40,6 +40,23 @@ export type ChatEvent = } | { type: 'chat_assistant_message_completed'; messageId: string } | { type: 'chat_turn_ended' } + // 外部 MCP (mcp__tally__ 以外) の tool_use を承認なしで永続化するときに発火。 + // AI が外部ソースを read したことを UI に見える形で残す (Task 12)。 + | { + type: 'chat_tool_external_use'; + messageId: string; + toolUseId: string; + name: string; + input: unknown; + } + // 外部 MCP の tool_result。AI が読んだ外部ソースの内容を UI に展開可能で表示する (Task 12)。 + | { + type: 'chat_tool_external_result'; + messageId: string; + toolUseId: string; + ok: boolean; + output: string; + } | { type: 'error'; code: string; message: string }; // SDK の厳密な型に依存せず、実行時に触る最小限のプロパティだけで型付けする。 From d394b551c20e9dd8900adafcf7719c05f9aa5b6a Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:35:54 +0900 Subject: [PATCH 19/34] =?UTF-8?q?feat(ai-engine):=20tool=5Fresult=20output?= =?UTF-8?q?=20=E3=82=92=E6=B0=B8=E7=B6=9A=E5=8C=96=E6=99=82=204KB=20?= =?UTF-8?q?=E3=81=AB=20truncate=20(Task=2013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 大規模 epic 取り込みで 1 ターン 500KB+ になり得る tool_result の YAML 永続化を 4KB + "(truncated, N chars total)" マーカーに切り詰める。event はフルを流すので UI セッション内では全文展開可、リロード後は truncated 版のみ。 --- packages/ai-engine/src/chat-runner.test.ts | 137 +++++++++++++++++++++ packages/ai-engine/src/chat-runner.ts | 16 ++- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index f1ad729..6a53439 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -841,6 +841,143 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( rmSync(root, { recursive: true, force: true }); }); + it('tool_result output が 4KB 超えると永続化時に truncate、event は full (Task 13)', async () => { + process.env.TEST_PAT = 'secret'; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task13-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const bigOutput = 'X'.repeat(10_000); + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'big-1', + content: [{ type: 'text', text: bigOutput }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('q')) events.push(e); + + // event はフル + const evt = events.find((e) => e.type === 'chat_tool_external_result'); + expect(evt).toBeDefined(); + if (evt && evt.type === 'chat_tool_external_result') { + expect(evt.output.length).toBe(10_000); + } + + // YAML 永続化は truncate + const reloaded = await chatStore.getChat(thread.id); + const tr = reloaded?.messages.flatMap((m) => m.blocks).find((b) => b.type === 'tool_result'); + expect(tr).toBeDefined(); + if (tr?.type === 'tool_result') { + expect(tr.output.length).toBeLessThanOrEqual(4200); + expect(tr.output).toContain('(truncated'); + expect(tr.output).toContain('10000'); + } + + rmSync(root, { recursive: true, force: true }); + }); + + it('tool_result output が 4KB 以下なら truncate しない', async () => { + process.env.TEST_PAT = 'secret'; + const root = mkdtempSync(path.join(tmpdir(), 'tally-task13b-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + const smallOutput = 'small result'; + const sdk: SdkLike = { + query: () => + (async function* () { + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'small-1', + content: [{ type: 'text', text: smallOutput }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + const runner = new ChatRunner({ + sdk, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner.runUserTurn('q')) { + /* drain */ + } + + const reloaded = await chatStore.getChat(thread.id); + const tr = reloaded?.messages.flatMap((m) => m.blocks).find((b) => b.type === 'tool_result'); + if (tr?.type === 'tool_result') { + expect(tr.output).toBe(smallOutput); + expect(tr.output).not.toContain('truncated'); + } + + rmSync(root, { recursive: true, force: true }); + }); + it('外部 tool_result が is_error=true なら ok=false で記録', async () => { process.env.TEST_PAT = 'secret'; const root = mkdtempSync(path.join(tmpdir(), 'tally-task12c-')); diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index 4b9564b..6be8f16 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -27,6 +27,18 @@ export interface ChatRunnerDeps { threadId: string; } +// 外部 MCP の tool_result output を YAML に永続化するときの上限 (Task 13)。 +// 大規模 epic 取り込み等で 1 ターンに 500KB+ 来うるので、永続化は 4KB に切る。 +// メモリ内 (event) は full を流すので、UI セッション内では全文展開可能。 +// リロード後は truncated 版だけ見える (dogfooding には十分)。 +const TOOL_RESULT_PERSIST_LIMIT = 4096; + +function truncateForPersistence(output: string): string { + if (output.length <= TOOL_RESULT_PERSIST_LIMIT) return output; + const head = output.slice(0, TOOL_RESULT_PERSIST_LIMIT); + return `${head}\n... (truncated, ${output.length} chars total)`; +} + // SDK の assistant / user message から抽出する block の単純化形。 // Tally MCP の tool_use は MCP intercept 経路で処理されるので拾わない。 // 外部 MCP (mcp__tally__ 以外) の tool_use / tool_result は永続化と UI 通知のためここで拾う (Task 12)。 @@ -209,11 +221,13 @@ export class ChatRunner { input: b.input, }); } else if (b.type === 'tool_result') { + // Task 13: 大規模 epic で tool_result が 500KB+ になり得るので、 + // YAML 永続化は 4KB に切り詰める。event はフル (UI はメモリ内で全文展開可)。 await chatStore.appendBlockToMessage(threadId, assistantMsgId, { type: 'tool_result', toolUseId: b.toolUseId, ok: b.ok, - output: b.output, + output: truncateForPersistence(b.output), }); queue.push({ type: 'chat_tool_external_result', From 38d8f08bed2aad790223463686f082ba009d5999 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:38:36 +0900 Subject: [PATCH 20/34] =?UTF-8?q?feat(ai-engine):=20buildChatPrompt=20?= =?UTF-8?q?=E3=81=8C=20tool=5Fuse/tool=5Fresult=20=E3=82=82=20replay=20(Ta?= =?UTF-8?q?sk=2014,=20T4=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit multi-turn 対話で AI が前ターンの外部 MCP 出力を覚えるように buildChatPrompt を拡張。 旧版は text block のみ replay していたため、AI が 1 ターン目で @JIRA EPIC-1 を読んで proposal を生成しても、2 ターン目「続けて子チケット STORY-2 も見て」では Jira の内容を 覚えていない (= AI が summarize した assistant text しか残らない) 問題があった。 各 block を順に replay: - text → そのまま (assistant / user の自然言語) - tool_use → ...input... - tool_result → ...output... ChatRunner.runUserTurn の prompt 構築タイミングは現状維持 (user message append → contextNodes load → buildChatPrompt → 空 assistant append)。 --- packages/ai-engine/src/chat-runner.test.ts | 133 ++++++++++++++++++++- packages/ai-engine/src/chat-runner.ts | 36 ++++-- 2 files changed, 158 insertions(+), 11 deletions(-) diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index 6a53439..6dcd91a 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import type { Node } from '@tally/core'; +import type { ChatMessage, Node } from '@tally/core'; import { newChatMessageId } from '@tally/core'; import { FileSystemChatStore, FileSystemProjectStore } from '@tally/storage'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -1054,3 +1054,134 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( rmSync(root, { recursive: true, force: true }); }); }); + +describe('buildChatPrompt — tool_use/tool_result replay (Task 14, T4 fix)', () => { + it('過去 turn の text + tool_use + tool_result が conversation_history に含まれる', () => { + const messages: ChatMessage[] = [ + { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text: '@JIRA EPIC-1 を読んで' }], + createdAt: '2026-04-24T00:00:00Z', + }, + { + id: 'a1', + role: 'assistant', + blocks: [ + { type: 'text', text: 'Jira を読みます' }, + { + type: 'tool_use', + toolUseId: 'tu-1', + name: 'mcp__atlassian__jira_get_issue', + input: { key: 'EPIC-1' }, + source: 'external', + }, + { type: 'tool_result', toolUseId: 'tu-1', ok: true, output: '{"summary":"Epic X"}' }, + { type: 'text', text: '読みました。Epic X です' }, + ], + createdAt: '2026-04-24T00:01:00Z', + }, + { + id: 'u2', + role: 'user', + blocks: [{ type: 'text', text: '続けて子チケット STORY-42 を読んで' }], + createdAt: '2026-04-24T00:02:00Z', + }, + ]; + + const prompt = buildChatPrompt(messages); + + // 過去 turn の Jira 内容が prompt に含まれる (T4 fix の核) + expect(prompt).toContain('Epic X'); + expect(prompt).toContain('mcp__atlassian__jira_get_issue'); + expect(prompt).toContain('source="external"'); + // 直近 user message は current_user_message として独立 + expect(prompt).toContain(''); + expect(prompt).toContain('STORY-42'); + // tool_use / tool_result タグが正しく出る + expect(prompt).toContain(' { + const messages: ChatMessage[] = [ + { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text: '作って' }], + createdAt: '2026-04-24T00:00:00Z', + }, + { + id: 'a1', + role: 'assistant', + blocks: [ + { + type: 'tool_use', + toolUseId: 'tu-1', + name: 'mcp__tally__create_node', + input: {}, + source: 'internal', + approval: 'approved', + }, + ], + createdAt: '2026-04-24T00:01:00Z', + }, + { + id: 'u2', + role: 'user', + blocks: [{ type: 'text', text: 'next' }], + createdAt: '2026-04-24T00:02:00Z', + }, + ]; + + const prompt = buildChatPrompt(messages); + expect(prompt).toContain('mcp__tally__create_node'); + expect(prompt).not.toContain('source="external"'); + expect(prompt).not.toContain('source="internal"'); + }); + + it('blocks が空の message は省く (履歴前段の空 assistant 想定)', () => { + const messages: ChatMessage[] = [ + { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }], + createdAt: '2026-04-24T00:00:00Z', + }, + { + id: 'a-empty', + role: 'assistant', + blocks: [], + createdAt: '2026-04-24T00:01:00Z', + }, + { + id: 'u2', + role: 'user', + blocks: [{ type: 'text', text: 'continue' }], + createdAt: '2026-04-24T00:02:00Z', + }, + ]; + const prompt = buildChatPrompt(messages); + // 空 assistant は省かれる + const messageOpens = prompt.match(//g) ?? []; + expect(messageOpens.length).toBe(0); + // user の "hello" は履歴に残る + expect(prompt).toContain('hello'); + expect(prompt).toContain('continue'); + }); + + it('過去 turn が無く current user のみのケース (初回 turn)', () => { + const messages: ChatMessage[] = [ + { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text: '初回' }], + createdAt: '2026-04-24T00:00:00Z', + }, + ]; + const prompt = buildChatPrompt(messages); + expect(prompt).not.toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain('初回'); + }); +}); diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index 6be8f16..0c71a65 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -613,12 +613,18 @@ export function formatNodeForContext(node: Node): string { } // チャット履歴を単一 prompt にエンコードする。 -// tool_use / tool_result は冗長なので省き、text block だけを role 付きで並べる。 -// 最後の user message は current として別タグに出し、モデルの「今答えるべきもの」を明示する。 +// 各 block を順に replay する: +// - text: そのまま (assistant / user の自然言語) +// - tool_use: ... +// - tool_result: ... +// +// T4 fix (Task 14): 旧版は text block だけ replay していたが、これだと AI が +// 2 ターン目以降で前ターンの外部 MCP tool_result (= Jira 等の読み取り内容) を忘れてしまい、 +// multi-turn 対話が成立しなかった。tool_use / tool_result も replay することで +// 「@JIRA EPIC-1 を読んで → 続けて子チケット STORY-2 も見て」が動く。 // // contextNodes: 今ターンで参照するコンテキストノード (issue #11)。 // 履歴より下、current_user_message より上に として埋め込む。 -// 履歴に積まないのは「ターンごとに添付し直しできる軽量な参照」という UX 設計のため。 export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] = []): string { const lines: string[] = []; const last = messages[messages.length - 1]; @@ -627,14 +633,24 @@ export function buildChatPrompt(messages: ChatMessage[], contextNodes: Node[] = if (past.length > 0) { lines.push(''); for (const m of past) { - const texts = m.blocks - .filter((b): b is Extract => b.type === 'text') - .map((b) => b.text); - if (texts.length > 0) { - lines.push(``); - lines.push(texts.join('\n')); - lines.push(''); + // block が 1 つも無い空 message は省く (空 assistant の preliminary append 等) + if (m.blocks.length === 0) continue; + lines.push(``); + for (const b of m.blocks) { + if (b.type === 'text') { + lines.push(b.text); + } else if (b.type === 'tool_use') { + // source は default 'internal'。external も含めて全部 replay する + // (AI に「外部 source を読んだ」事実を context として伝えるため) + const sourceAttr = b.source === 'external' ? ' source="external"' : ''; + lines.push( + `${JSON.stringify(b.input)}`, + ); + } else if (b.type === 'tool_result') { + lines.push(`${b.output}`); + } } + lines.push(''); } lines.push(''); } From 4382e97c46e74c3346288538b9af7bb57ac5849a Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:42:02 +0900 Subject: [PATCH 21/34] =?UTF-8?q?feat(ai-engine):=20agent-runner=20refacto?= =?UTF-8?q?r=20+=20buildMcpServers=20=E5=85=B1=E6=9C=89=20(Task=2015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-runner.ts:114 の hardcoded mcpServers / allowedTools を chat-runner と 共通の buildMcpServers utility 経由に変更。プロジェクト設定 mcpServers[] が agent 側でも有効化され、Task 16+ で実装する ingest-jira-epic agent がそのまま 動く土台が整う。 - 既存 5 agent (decompose-to-stories / extract-questions / find-related-code / analyze-impact / ingest-document) の動作不変を 193 既存 test で regression 担保 - 新規 2 test: 外部 MCP 合成 (Bearer auth) / env 未設定で error event - agent 固有 allowedTools (mcp__tally__find_related 等) と外部 MCP wildcard (mcp____*) を合流、tally の wildcard は dedup - env 未設定 throw は既存の catch (err) で agent_failed error event に流れる --- packages/ai-engine/src/agent-runner.test.ts | 150 +++++++++++++++++++- packages/ai-engine/src/agent-runner.ts | 24 +++- 2 files changed, 168 insertions(+), 6 deletions(-) diff --git a/packages/ai-engine/src/agent-runner.test.ts b/packages/ai-engine/src/agent-runner.test.ts index deb37d7..4982fae 100644 --- a/packages/ai-engine/src/agent-runner.test.ts +++ b/packages/ai-engine/src/agent-runner.test.ts @@ -438,7 +438,9 @@ describe('runAgent', () => { it('ingest-document: anchor 無しで起動し、tool_use を素通しする', async () => { const store = { getNode: vi.fn(), - getProjectMeta: vi.fn(), + // Task 15: agent-runner は mcpServers[] を取得するため毎ターン getProjectMeta を呼ぶ。 + // 空 (mcpServers なし) を返して既存挙動と同等にする。 + getProjectMeta: vi.fn().mockResolvedValue(null), addNode: vi.fn(), listNodes: vi.fn().mockResolvedValue([]), findRelatedNodes: vi.fn().mockResolvedValue([]), @@ -491,8 +493,150 @@ describe('runAgent', () => { expect(events.some((e) => e.type === 'error')).toBe(false); const toolUseEvents = events.filter((e) => e.type === 'tool_use'); expect(toolUseEvents.length).toBeGreaterThan(0); - // anchor 無しなので store.getNode / getProjectMeta は呼ばれない + // anchor 無しなので store.getNode は呼ばれない expect(store.getNode).not.toHaveBeenCalled(); - expect(store.getProjectMeta).not.toHaveBeenCalled(); + // Task 15: getProjectMeta は mcpServers[] を取るため必ず呼ばれる + expect(store.getProjectMeta).toHaveBeenCalled(); + }); + + describe('Task 15: agent-runner で buildMcpServers を共有', () => { + const ORIGINAL_ENV = { ...process.env }; + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it('プロジェクト mcpServers[] を sdk.query に動的に渡す (chat-runner と同じ utility 共有)', async () => { + process.env.TEST_PAT = 'secret'; + const store = { + getNode: vi.fn().mockResolvedValue({ + id: 'uc-1', + type: 'usecase', + x: 0, + y: 0, + title: 'UC', + body: '', + }), + getProjectMeta: vi.fn().mockResolvedValue({ + id: 'p', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + }), + addNode: vi.fn(), + listNodes: vi.fn().mockResolvedValue([]), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addEdge: vi.fn(), + } as unknown as ProjectStore; + + const querySpy = vi.fn(() => + (async function* () { + yield { + type: 'result', + subtype: 'success', + result: 'ok', + } as unknown as SdkMessageLike; + })(), + ); + const sdk: SdkLike = { query: querySpy }; + + for await (const _ of runAgent({ + sdk, + store, + projectDir: '/ws', + req: { + type: 'start', + agent: 'extract-questions', + projectId: 'p', + input: { nodeId: 'uc-1' }, + }, + })) { + /* drain */ + } + + const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { + options?: { + mcpServers?: Record }>; + allowedTools?: string[]; + }; + }; + expect(Object.keys(callArg.options?.mcpServers ?? {})).toEqual( + expect.arrayContaining(['tally', 'atlassian']), + ); + const atlassian = callArg.options?.mcpServers?.atlassian; + expect(atlassian?.headers?.Authorization).toBe('Bearer secret'); + // agent 固有の allowedTools (mcp__tally__find_related 等) + 外部 MCP wildcard + expect(callArg.options?.allowedTools).toContain('mcp__atlassian__*'); + }); + + it('env 未設定なら error event を emit、sdk.query は呼ばない', async () => { + delete process.env.MISSING_PAT; + const store = { + getNode: vi.fn().mockResolvedValue({ + id: 'uc-1', + type: 'usecase', + x: 0, + y: 0, + title: 'UC', + body: '', + }), + getProjectMeta: vi.fn().mockResolvedValue({ + id: 'p', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://t.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'MISSING_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: '2026-04-24T00:00:00Z', + updatedAt: '2026-04-24T00:00:00Z', + }), + addNode: vi.fn(), + listNodes: vi.fn().mockResolvedValue([]), + findRelatedNodes: vi.fn().mockResolvedValue([]), + addEdge: vi.fn(), + } as unknown as ProjectStore; + + const querySpy = vi.fn(); + const sdk: SdkLike = { query: querySpy }; + const events: AgentEvent[] = []; + for await (const e of runAgent({ + sdk, + store, + projectDir: '/ws', + req: { + type: 'start', + agent: 'extract-questions', + projectId: 'p', + input: { nodeId: 'uc-1' }, + }, + })) { + events.push(e); + } + + // buildMcpServers の throw が catch (err) で error event に流れる + expect(querySpy).not.toHaveBeenCalled(); + const errorEvent = events.find((e) => e.type === 'error'); + expect(errorEvent).toBeDefined(); + if (errorEvent && errorEvent.type === 'error') { + expect(errorEvent.message).toMatch(/MISSING_PAT/); + } + }); }); }); diff --git a/packages/ai-engine/src/agent-runner.ts b/packages/ai-engine/src/agent-runner.ts index 7dc10e9..fd94972 100644 --- a/packages/ai-engine/src/agent-runner.ts +++ b/packages/ai-engine/src/agent-runner.ts @@ -2,6 +2,7 @@ import type { AgentName } from '@tally/core'; import type { ProjectStore } from '@tally/storage'; import { AGENT_REGISTRY } from './agents/registry'; +import { buildMcpServers } from './mcp/build-mcp-servers'; import type { AgentEvent, SdkMessageLike } from './stream'; import { sdkMessageToAgentEvent } from './stream'; import { buildTallyMcpServer } from './tools'; @@ -104,19 +105,36 @@ export async function* runAgent(deps: RunAgentDeps): AsyncGenerator input: parsed.data, }); try { + // Task 15: プロジェクト設定の mcpServers[] を毎ターン読み込み、buildMcpServers で + // Tally MCP と外部 MCP (Atlassian 等) を合成する。chat-runner と同じ utility を共有。 + // env 未設定時は throw → catch で error event に流す。 + const projectMeta = await store.getProjectMeta(); + const externalConfigs = projectMeta?.mcpServers ?? []; + const { mcpServers, allowedTools: externalAllowed } = buildMcpServers({ + tallyMcp: mcp, + configs: externalConfigs, + }); + // built-in ツールは mcp__ プレフィックスを持たないもの (Read / Glob / Grep など)。 // options.tools = 実質的な built-in 使用可能リスト。[] を渡せば Bash/Edit/Write 等すべてオフ。 const builtInTools = def.allowedTools.filter((t) => !t.startsWith('mcp__')); + // agent 固有の allowedTools (Tally MCP の具体 tool 名 + built-in) に、外部 MCP の wildcard + // (mcp____*) を合流。tally の wildcard は agent 側に既に具体名で並んでいるので除外して dedup。 + const finalAllowedTools = [ + ...def.allowedTools, + ...externalAllowed.filter((t) => t !== 'mcp__tally__*'), + ]; + const iter = sdk.query({ prompt: prompt.userPrompt, options: { systemPrompt: prompt.systemPrompt, - mcpServers: { tally: mcp as unknown as Record }, + mcpServers, // built-in ツールは registry で宣言した範囲のみ許可。 // これで find-related-code に Bash / Edit / Write 等が使われなくなる。 tools: builtInTools, - // MCP ツール (mcp__tally__*) も含めて自動承認する。 - allowedTools: def.allowedTools, + // MCP ツール (mcp__tally__* + 外部 MCP wildcard) を自動承認する。 + allowedTools: finalAllowedTools, // 承認リスト外は拒否。built-in 側は tools で絞っているので二重ガード。 permissionMode: 'dontAsk', // cwd は find-related-code のコード探索スコープ。未指定エージェントは SDK デフォルト。 From 099df9a1e808b66960b10fa86feb4f0ed0c867e9 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:43:57 +0900 Subject: [PATCH 22/34] =?UTF-8?q?feat(core,frontend):=20Project=20API=20?= =?UTF-8?q?=E3=81=A7=20mcpServers[]=20=E3=81=AE=20round-trip=20=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=20(Task=2016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core: ProjectMetaPatchSchema に mcpServers: McpServerConfig[] optional を追加 (.strict() なので明示追加が必要) - frontend: PATCH /api/projects/:id で parsed.data.mcpServers を next に流す - 新規 test 3 件: round-trip / hardening (http://example.com で 400) / 空配列で全消去 --- packages/core/src/schema.ts | 3 +- .../src/app/api/projects/[id]/route.test.ts | 80 +++++++++++++++++++ .../src/app/api/projects/[id]/route.ts | 1 + 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 0bd2ffd..2880814 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -304,12 +304,13 @@ export const ProjectSchema = z }) .superRefine((p, ctx) => checkUniqueCodebaseIds(p.codebases, ctx)); -// PATCH /api/projects/:id の body スキーマ。codebases 全置換のみ許可(部分更新はしない)。 +// PATCH /api/projects/:id の body スキーマ。codebases / mcpServers は全置換のみ (部分更新はしない)。 export const ProjectMetaPatchSchema = z .object({ name: z.string().min(1).optional(), description: z.string().nullable().optional(), codebases: z.array(CodebaseSchema).optional(), + mcpServers: z.array(McpServerConfigSchema).optional(), }) .strict() .superRefine((patch, ctx) => checkUniqueCodebaseIds(patch.codebases, ctx)); diff --git a/packages/frontend/src/app/api/projects/[id]/route.test.ts b/packages/frontend/src/app/api/projects/[id]/route.test.ts index 04861b5..83368c9 100644 --- a/packages/frontend/src/app/api/projects/[id]/route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/route.test.ts @@ -70,6 +70,86 @@ describe('PATCH /api/projects/:id', () => { expect(body.codebases).toEqual([{ id: 'web', label: 'Web', path: '/w' }]); }); + it('mcpServers[] を全置換 (Task 16)', async () => { + const res = await PATCH( + new Request('http://localhost', { + method: 'PATCH', + body: JSON.stringify({ + mcpServers: [ + { + id: 'atlassian', + name: 'Atlassian', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + }), + { params: Promise.resolve({ id: projectId }) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { mcpServers: Array<{ id: string }> }; + expect(body.mcpServers).toHaveLength(1); + expect(body.mcpServers[0]?.id).toBe('atlassian'); + }); + + it('mcpServers の url が http (loopback 以外) なら 400 (Task 1 hardening)', async () => { + const res = await PATCH( + new Request('http://localhost', { + method: 'PATCH', + body: JSON.stringify({ + mcpServers: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'http://example.com/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + }), + { params: Promise.resolve({ id: projectId }) }, + ); + expect(res.status).toBe(400); + }); + + it('mcpServers を空配列で全消去できる', async () => { + // 事前に登録 + await PATCH( + new Request('http://localhost', { + method: 'PATCH', + body: JSON.stringify({ + mcpServers: [ + { + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + }), + }), + { params: Promise.resolve({ id: projectId }) }, + ); + // 空配列で全消去 + const res = await PATCH( + new Request('http://localhost', { + method: 'PATCH', + body: JSON.stringify({ mcpServers: [] }), + }), + { params: Promise.resolve({ id: projectId }) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { mcpServers: unknown[] }; + expect(body.mcpServers).toEqual([]); + }); + it('name を更新', async () => { const res = await PATCH( new Request('http://localhost', { diff --git a/packages/frontend/src/app/api/projects/[id]/route.ts b/packages/frontend/src/app/api/projects/[id]/route.ts index db983ee..4d0c4f3 100644 --- a/packages/frontend/src/app/api/projects/[id]/route.ts +++ b/packages/frontend/src/app/api/projects/[id]/route.ts @@ -84,6 +84,7 @@ export async function PATCH(req: Request, context: RouteContext): Promise Date: Mon, 27 Apr 2026 15:48:34 +0900 Subject: [PATCH 23/34] =?UTF-8?q?feat(frontend):=20=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E8=A8=AD=E5=AE=9A=20dialog?= =?UTF-8?q?=20=E3=81=AB=20MCP=20=E3=82=B5=E3=83=BC=E3=83=90=E3=83=BC=20CRU?= =?UTF-8?q?D=20UI=20=E3=82=92=E8=BF=BD=E5=8A=A0=20(Task=2017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mcpServers[] の追加 / 削除 / 編集 (id / name / url / scheme / envVar) - Bearer (Server/DC) と Basic (Cloud) 切替で emailEnvVar 入力欄が出現 - secret 値はフォームに無し → caption で .env への誘導 - store の patchProjectMeta type に mcpServers? を追加 - 新規 test 4 件: 追加/削除/Basic 切替/secret 欄が無いこと --- .../dialog/project-settings-dialog.test.tsx | 70 +++++++ .../dialog/project-settings-dialog.tsx | 180 +++++++++++++++++- packages/frontend/src/lib/store.ts | 2 + 3 files changed, 249 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx index d2e6b9a..49c78cc 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx @@ -93,6 +93,76 @@ describe('ProjectSettingsDialog', () => { ); }); + it('MCP サーバーを追加できる (Task 17)', async () => { + render( {}} />); + await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); + // 新規追加された MCP server の id 入力欄が現れる (default は atlassian-1) + const idInput = screen.getByLabelText('mcp-0-id') as HTMLInputElement; + expect(idInput).toBeInTheDocument(); + expect(idInput.value).toBe('atlassian-1'); + // url / tokenEnvVar を入力 + const urlInput = screen.getByLabelText('mcp-0-url'); + await userEvent.type(urlInput, 'https://x.test/mcp'); + const tokenInput = screen.getByLabelText('mcp-0-tokenEnvVar'); + await userEvent.type(tokenInput, 'JIRA_PAT'); + + await userEvent.click(screen.getByRole('button', { name: /保存/ })); + await waitFor(() => + expect(patchProjectMeta).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServers: [ + expect.objectContaining({ + id: 'atlassian-1', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: expect.objectContaining({ + type: 'pat', + scheme: 'bearer', + tokenEnvVar: 'JIRA_PAT', + }), + }), + ], + }), + ), + ); + }); + + it('Basic auth 切替で emailEnvVar 入力欄が現れる (Task 17)', async () => { + render( {}} />); + await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); + // 初期は bearer なので emailEnvVar 欄は無し + expect(screen.queryByLabelText('mcp-0-emailEnvVar')).toBeNull(); + // basic に切替 + const schemeSelect = screen.getByLabelText('mcp-0-scheme') as HTMLSelectElement; + await userEvent.selectOptions(schemeSelect, 'basic'); + // emailEnvVar 欄が現れる + expect(screen.getByLabelText('mcp-0-emailEnvVar')).toBeInTheDocument(); + }); + + it('MCP サーバーを削除できる (Task 17)', async () => { + render( {}} />); + await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); + // 追加直後 1 件 + expect(screen.getByLabelText('mcp-0-id')).toBeInTheDocument(); + // MCP セクションの削除 button (codebase の削除と区別するため scope で取る) + // codebase 削除 + MCP 削除の 2 つ「削除」button があるので getAllByRole で取って最後を click + const removeButtons = screen.getAllByRole('button', { name: /削除/ }); + // 最後の削除 button = MCP のもの (codebase は 1 件目で追加 = 0 番目) + await userEvent.click(removeButtons[removeButtons.length - 1] as HTMLElement); + expect(screen.queryByLabelText('mcp-0-id')).toBeNull(); + }); + + it('secret 値の入力欄は無い (envVar 名のみ。caption に .env への誘導)', () => { + render( {}} />); + // secret / token / pat / api_token / password 系の入力欄が無いこと + expect(screen.queryByLabelText(/PAT$/i)).toBeNull(); + expect(screen.queryByLabelText(/シークレット/i)).toBeNull(); + expect(screen.queryByLabelText(/api_token$/i)).toBeNull(); + expect(screen.queryByLabelText(/password/i)).toBeNull(); + // .env への誘導文言 + expect(screen.getByText(/\.env/)).toBeInTheDocument(); + }); + it('id 重複時は保存 disabled', async () => { render( {}} />); // まず 2 件目を追加 diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.tsx index b19a8a9..b58da2f 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.tsx @@ -1,23 +1,40 @@ 'use client'; -import type { Codebase } from '@tally/core'; +import type { Codebase, McpServerConfig } from '@tally/core'; import { useEffect, useMemo, useState } from 'react'; import { TextInput } from '@/components/ui/text-input'; import { useCanvasStore } from '@/lib/store'; import { FolderBrowserDialog } from './folder-browser-dialog'; +// MCP サーバー新規追加時のデフォルト config (Bearer + 空 envVar 名 + 空 url)。 +// scheme/envVar は ユーザーが Cloud (basic) か Server/DC (bearer) かで選択する。 +function makeDefaultMcpServer(seq: number): McpServerConfig { + return { + id: `atlassian-${seq}`, + name: 'Atlassian', + kind: 'atlassian', + url: '', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: '' }, + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }; +} + export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { const projectMeta = useCanvasStore((s) => s.projectMeta); const patchProjectMeta = useCanvasStore((s) => s.patchProjectMeta); const [codebases, setCodebases] = useState([]); + const [mcpServers, setMcpServers] = useState([]); const [pickerOpen, setPickerOpen] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { - if (open && projectMeta) setCodebases(projectMeta.codebases); + if (open && projectMeta) { + setCodebases(projectMeta.codebases); + setMcpServers(projectMeta.mcpServers ?? []); + } }, [open, projectMeta]); const duplicateIds = useMemo(() => { @@ -58,7 +75,7 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos setBusy(true); setError(null); try { - await patchProjectMeta({ codebases }); + await patchProjectMeta({ codebases, mcpServers }); onClose(); } catch (e) { setError(String((e as Error).message ?? e)); @@ -66,6 +83,20 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos } }; + const addMcpServer = () => { + setMcpServers([...mcpServers, makeDefaultMcpServer(mcpServers.length + 1)]); + }; + + const updateMcpServer = (index: number, next: McpServerConfig) => { + const list = [...mcpServers]; + list[index] = next; + setMcpServers(list); + }; + + const removeMcpServer = (index: number) => { + setMcpServers(mcpServers.filter((_, i) => i !== index)); + }; + return (
@@ -131,6 +162,134 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
+
+
+ MCP サーバー (Atlassian 等の外部連携) ({mcpServers.length}) + +
+
+ シークレット (PAT 等) はこのフォームでは入力しません。サーバーの .env に + ATLASSIAN_PAT=... のように置き、ここでは環境変数名のみ指定します。 +
+ {mcpServers.length === 0 &&
MCP サーバー未設定
} +
    + {mcpServers.map((s, i) => ( +
  • +
    + updateMcpServer(i, { ...s, id: e.target.value })} + disabled={busy} + aria-label={`mcp-${i}-id`} + style={{ ...INPUT, width: 160 }} + placeholder="atlassian" + /> + updateMcpServer(i, { ...s, name: e.target.value })} + disabled={busy} + aria-label={`mcp-${i}-name`} + style={{ ...INPUT, flex: 1 }} + placeholder="表示名" + /> + +
    +
    + updateMcpServer(i, { ...s, url: e.target.value })} + disabled={busy} + aria-label={`mcp-${i}-url`} + style={{ ...INPUT, flex: 1 }} + placeholder="https://mcp.atlassian.example/v1/mcp" + /> + +
    +
    + {s.auth.scheme === 'basic' && ( + + updateMcpServer(i, { + ...s, + auth: { + type: 'pat', + scheme: 'basic', + emailEnvVar: e.target.value, + tokenEnvVar: s.auth.tokenEnvVar, + }, + }) + } + disabled={busy} + aria-label={`mcp-${i}-emailEnvVar`} + style={{ ...INPUT, flex: 1 }} + placeholder="ATLASSIAN_EMAIL" + /> + )} + + updateMcpServer(i, { + ...s, + auth: { ...s.auth, tokenEnvVar: e.target.value }, + }) + } + disabled={busy} + aria-label={`mcp-${i}-tokenEnvVar`} + style={{ ...INPUT, flex: 1 }} + placeholder={s.auth.scheme === 'basic' ? 'ATLASSIAN_API_TOKEN' : 'JIRA_PAT'} + /> +
    +
  • + ))} +
+
+ {error && (
{error} @@ -227,6 +386,21 @@ const CB_ITEM = { gap: 6, flexWrap: 'wrap' as const, }; +const MCP_ITEM = { + display: 'flex', + flexDirection: 'column' as const, + gap: 4, + padding: 8, + border: '1px solid #30363d', + borderRadius: 6, + background: '#0d1117', +}; +const MCP_ROW = { + display: 'flex', + alignItems: 'center', + gap: 6, + flexWrap: 'wrap' as const, +}; const CB_PATH = { flex: 1, fontSize: 11, diff --git a/packages/frontend/src/lib/store.ts b/packages/frontend/src/lib/store.ts index a47ff68..90678e5 100644 --- a/packages/frontend/src/lib/store.ts +++ b/packages/frontend/src/lib/store.ts @@ -9,6 +9,7 @@ import type { Codebase, Edge, EdgeType, + McpServerConfig, Node, NodeType, Project, @@ -113,6 +114,7 @@ interface CanvasState { name?: string; description?: string | null; codebases?: Codebase[]; + mcpServers?: McpServerConfig[]; }) => Promise; // Phase 6: チャットスレッド管理。 From 8c7e246ee791f60be5f13bf96782818dc9582f43 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:50:43 +0900 Subject: [PATCH 24/34] =?UTF-8?q?feat(frontend):=20Chat=20UI=20=E3=81=A7?= =?UTF-8?q?=E5=A4=96=E9=83=A8=20MCP=20=E3=81=AE=20tool=5Fuse=20=E3=82=92?= =?UTF-8?q?=E6=8A=98=E3=82=8A=E7=95=B3=E3=81=BF=E8=A1=A8=E7=A4=BA=20(Task?= =?UTF-8?q?=2018)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store.ts: chat_tool_external_use / chat_tool_external_result の event handler を追加 - 内部 tool_use とは別 handler で source='external' の block を append - external は承認概念なし (approval は付与しない) - tool-approval-card.tsx: source 分岐 - source='external' は
折り畳み + 「外部ソース」ラベル + 承認 / 却下なし - source='internal' は既存通り承認 / 却下 button (退行なし) - 新規 test 2 件: external で承認 button 非表示 + details 折り畳み --- .../chat/tool-approval-card.test.tsx | 38 ++++++++++++++++ .../components/chat/tool-approval-card.tsx | 36 ++++++++++++++- packages/frontend/src/lib/store.ts | 44 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/components/chat/tool-approval-card.test.tsx b/packages/frontend/src/components/chat/tool-approval-card.test.tsx index 8341cf7..5f13f1e 100644 --- a/packages/frontend/src/components/chat/tool-approval-card.test.tsx +++ b/packages/frontend/src/components/chat/tool-approval-card.test.tsx @@ -64,4 +64,42 @@ describe('ToolApprovalCard', () => { ); expect(screen.getByText(/^create_node$/)).toBeDefined(); }); + + it('source=external の tool_use は承認 / 却下ボタンを表示しない (Task 18)', () => { + useCanvasStore.setState({ approveChatTool: vi.fn() } as never); + render( + , + ); + expect(screen.queryByRole('button', { name: /^承認$/ })).toBeNull(); + expect(screen.queryByRole('button', { name: /^却下$/ })).toBeNull(); + // 外部ソース表示は AI が読んだ tool 名を含む + expect(screen.getByText(/外部ソース/)).toBeInTheDocument(); + expect(screen.getByText(/atlassian: jira_get_issue/)).toBeInTheDocument(); + }); + + it('source=external は折り畳み (details) で input を隠す (Task 18)', () => { + useCanvasStore.setState({ approveChatTool: vi.fn() } as never); + const { container } = render( + , + ); + //
要素が存在 + const details = container.querySelector('details'); + expect(details).not.toBeNull(); + }); }); diff --git a/packages/frontend/src/components/chat/tool-approval-card.tsx b/packages/frontend/src/components/chat/tool-approval-card.tsx index 838b500..93ebeef 100644 --- a/packages/frontend/src/components/chat/tool-approval-card.tsx +++ b/packages/frontend/src/components/chat/tool-approval-card.tsx @@ -6,9 +6,28 @@ import { useCanvasStore } from '@/lib/store'; type PendingToolBlock = Extract; -// pending tool_use のカード UI。承認 / 却下ボタン。 +// tool_use のカード UI。 +// - source='internal' (Tally MCP): 承認 / 却下 ボタン (approval=pending のとき) +// - source='external' (外部 MCP): 承認概念なし、AI が自律で読んだ外部ソースを折り畳み表示 export function ToolApprovalCard({ block }: { block: PendingToolBlock }) { const approveChatTool = useCanvasStore((s) => s.approveChatTool); + + // Task 18: 外部 MCP は source='external' で識別、承認 UI を出さない + if (block.source === 'external') { + const shortName = block.name.replace(/^mcp__([^_]+)__/, '$1: '); + const inputPreview = previewInput(block.input); + return ( +
+
+ + 🔗 外部ソース {shortName} + +
{inputPreview}
+
+
+ ); + } + const shortName = block.name.replace(/^mcp__tally__/, ''); const inputPreview = previewInput(block.input); @@ -113,3 +132,18 @@ const APPROVE_BUTTON_STYLE = { fontSize: 11, cursor: 'pointer', }; +const EXTERNAL_CARD_STYLE = { + background: '#0d1d2a', + border: '1px solid #1f6feb', + borderRadius: 6, + padding: 8, + width: '100%', +}; +const EXTERNAL_HEADER_STYLE = { + display: 'flex', + alignItems: 'center', + gap: 6, + fontSize: 12, + color: '#79c0ff', + cursor: 'pointer', +}; diff --git a/packages/frontend/src/lib/store.ts b/packages/frontend/src/lib/store.ts index 90678e5..fa18bb5 100644 --- a/packages/frontend/src/lib/store.ts +++ b/packages/frontend/src/lib/store.ts @@ -338,6 +338,50 @@ export const useCanvasStore = create((set, get) => { } return; } + // Task 12/18: 外部 MCP の tool_use を source='external' で append。承認 UI は出さない。 + if (evt.type === 'chat_tool_external_use') { + set({ + chatThreadMessages: get().chatThreadMessages.map((m) => { + if (m.id !== evt.messageId) return m; + return { + ...m, + blocks: [ + ...m.blocks, + { + type: 'tool_use', + toolUseId: evt.toolUseId, + name: evt.name, + input: evt.input, + source: 'external', + }, + ], + }; + }), + }); + return; + } + // Task 12/18: 外部 MCP の tool_result を append。 + // event はフル output (Task 13 の truncate は永続化のみ)。UI セッション内では全文閲覧可。 + if (evt.type === 'chat_tool_external_result') { + set({ + chatThreadMessages: get().chatThreadMessages.map((m) => { + if (m.id !== evt.messageId) return m; + return { + ...m, + blocks: [ + ...m.blocks, + { + type: 'tool_result', + toolUseId: evt.toolUseId, + ok: evt.ok, + output: evt.output, + }, + ], + }; + }), + }); + return; + } if (evt.type === 'chat_assistant_message_completed') { return; } From e78ffaa4e8dd91e5464503a16391a50e3b7b0f93 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 15:51:39 +0900 Subject: [PATCH 25/34] =?UTF-8?q?docs:=20Atlassian=20MCP=20C=20=E3=83=95?= =?UTF-8?q?=E3=82=A7=E3=83=BC=E3=82=BA=20dogfood=20log=20skeleton=20?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=20(Task=2019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 個の Jira エピックで Success Criteria を測定する手順と記録テンプレート。 - Setup: sooperset/mcp-atlassian の起動方法 + Tally 設定 - 各 Epic ごとの記録項目 (turn 1 / turn 2 / 気づかなかった論点 / 重複ガード) - 集計: 採用率 50%+ / 質的記録 3+ / multi-turn / システム動作 - A フェーズ ingest-jira-epic 設計の入力 (プロンプト改善点 / tool 呼び出しパターン) --- ...04-24-atlassian-mcp-c-phase-dogfood-log.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md diff --git a/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md b/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md new file mode 100644 index 0000000..5c317b0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md @@ -0,0 +1,122 @@ +# Dogfood Log — Atlassian MCP C フェーズ + +> **目的**: 10 個の Jira エピックで C フェーズ Success Criteria を測定し、A フェーズの ingest-jira-epic agent プロンプト設計の入力を作る。 + +## Setup + +### MCP サーバー起動 (sooperset/mcp-atlassian) + +```bash +# uvx 経由 +uvx mcp-atlassian --transport streamable-http --port 9000 + +# または Docker +docker run -p 9000:9000 ghcr.io/sooperset/mcp-atlassian:latest \ + --transport streamable-http --port 9000 +``` + +### .env 設定 + +Cloud (Atlassian Cloud) の場合 (Basic auth): +```bash +ATLASSIAN_EMAIL=your-email@example.com +ATLASSIAN_API_TOKEN=your-api-token +``` + +Server / DC (オンプレ Jira) の場合 (Bearer auth): +```bash +JIRA_PAT=your-personal-access-token +``` + +### Tally プロジェクト設定 + +プロジェクト設定ダイアログ → MCP サーバーを追加: +- ID: `atlassian` +- 名前: `Atlassian Cloud` (任意) +- URL: `http://localhost:9000/mcp` +- スキーム: Bearer or Basic +- envVar: `ATLASSIAN_EMAIL` / `ATLASSIAN_API_TOKEN` (basic) or `JIRA_PAT` (bearer) + +## Epic 1-10 + +各エピックについて以下の項目を記録する。 + +### Epic N: + +- **エピック概要** (1 行): +- **規模**: 子チケット ___ 件、コメント総数 ___ 件 +- **Turn 1**: `@JIRA を読んで論点を出して` + - 所要時間: ___ 秒 (target: 90 秒以内) + - 生成 question proposal: ___ 個 (target: 3 個以上) + - 採用: ___ 個、却下: ___ 個 + - 採用判断の理由 (採用/却下それぞれ箇条書き): +- **Turn 2 (multi-turn test)**: `続けて子チケット も読んで論点を追加して` + - AI が前ターンの Epic 内容を覚えているか: YES / NO + - 生成 question proposal: ___ 個 + - 採用: ___ 個 +- **「気づかなかった論点」判定**: YES / NO + - YES なら具体内容: +- **重複ガード動作**: 同 URL 2 度目取り込み → sourceUrl guard 発動: YES / NO + +--- + +(Epic 2-10 を同フォーマットで) + +## 集計 + +### 量的基準 + +- 合計生成 question proposal: ___ 個 +- 合計採用数: ___ 個 +- **採用率**: ___ % (target: **50%+**) +- 90 秒以内に proposal 3 個以上の Epic 数: ___ / 10 (target: **10/10**) + +### 質的基準 + +- 「気づかなかった論点」合計: ___ 件 (target: **3+**) + - 該当 Epic 一覧: +- multi-turn が機能した Epic: ___ / 10 (target: **10/10**) + +### システム動作 + +- 重複ガード発動数 / 試行数: ___ / ___ +- env 未設定エラーで blocked になった回数: ___ +- MCP 接続エラーの種類: + +## 観察メモ (A フェーズ ingest-jira-epic 設計の入力) + +### プロンプト改善点 + +(AI が安定して論点を出すために、どんな指示が効いたか) + +### tool 呼び出しパターン + +(AI がどの順で `jira_get_issue` / `jira_get_epic_issues` / `jira_search` 等を呼んだか) + +### レイテンシ分布 + +(エピックサイズと所要時間の相関) + +### 失敗パターン + +- 接続失敗: +- rate limit: +- タイムアウト: +- AI が無限ループ: + +### A フェーズ仕様への提案 + +(C で見えた「AI に必須で指示すべき事項」「制限すべき事項」を箇条書き) + +--- + +## 完了判定 + +C フェーズ Success Criteria: +- [ ] 90 秒以内に question proposal 3 個以上 (10 epic 全部) +- [ ] 採用率 50%+ +- [ ] 「気づかなかった論点」3+ 件 +- [ ] multi-turn での context 保持が動作 + +満たせば → A フェーズ (ingest-jira-epic agent + 専用 UI + ADR) へ。 +満たさなければ → 観察結果をもとに plan を再調整。 From f675be53febbcbf5db062e279874b9eaa78b53da Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 16:42:21 +0900 Subject: [PATCH 26/34] =?UTF-8?q?refactor:=20MCP=20=E8=AA=8D=E8=A8=BC?= =?UTF-8?q?=E3=82=92=20OAuth=202.1=20/=20SDK=20=E4=BB=BB=E3=81=9B=E3=81=AB?= =?UTF-8?q?=E5=88=87=E6=9B=BF=E3=80=81Tally=20=E3=81=8B=E3=82=89=20PAT/API?= =?UTF-8?q?=20key=20=E3=82=92=E5=AE=8C=E5=85=A8=E6=8E=92=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Premise 9 撤回 (PAT only → MCP プロトコルの OAuth 2.1 採用、user sovereignty)。 理由 (user 指摘): - Atlassian 公式 Rovo MCP は OAuth 2.1 ネイティブ、Claude Agent SDK は MCP の 'needs-auth' status を扱う built-in support を持つ → SDK 任せで auto auth flow が回る - sooperset/mcp-atlassian を使う場合も PAT は MCP server 自身の env で持つ前提に - Tally プロセスから PAT/API key の概念が完全消滅 (project.yaml / メモリ / ログのいずれにも残らない) 実装変更: - core: McpServerConfigSchema から auth フィールド削除、関連 hardening (envVar regex / Basic+Bearer 分岐) も削除。schema test を auth-less に整理 (84 tests) - ai-engine: buildMcpServers の auth header 構築ロジック削除、url のみで HTTP config (req は SDK が必要に応じて WWW-Authenticate を解釈)。chat-runner / agent-runner も auth に依存しない。env 未設定 throw test は仕様変更のため削除 (189 tests) - frontend: 設定 dialog から auth scheme dropdown / envVar 入力欄を削除、URL のみの シンプル UI、caption も「OAuth 2.1 / API token は MCP 任せ」に書き換え (264 tests) - 全 624 tests pass、typecheck clean、lint errors 0 docs: - design doc Premise 9 を「MCP プロトコル / SDK 側に完全委譲」に更新 - dogfood log: sooperset の env 受け渡し例 + Atlassian 公式 Rovo MCP 経由の OAuth 2.1 フロー手順を併記 --- ...04-24-atlassian-mcp-c-phase-dogfood-log.md | 47 ++++-- packages/ai-engine/src/agent-runner.test.ts | 77 +-------- packages/ai-engine/src/chat-runner.test.ts | 84 ++------- .../src/mcp/build-mcp-servers.test.ts | 158 ++--------------- .../ai-engine/src/mcp/build-mcp-servers.ts | 37 +--- packages/core/src/schema.test.ts | 159 ++---------------- packages/core/src/schema.ts | 38 ++--- .../src/app/api/projects/[id]/route.test.ts | 3 - .../dialog/project-settings-dialog.test.tsx | 40 ++--- .../dialog/project-settings-dialog.tsx | 78 +-------- 10 files changed, 122 insertions(+), 599 deletions(-) diff --git a/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md b/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md index 5c317b0..0e2c0c9 100644 --- a/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md +++ b/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md @@ -4,38 +4,51 @@ ## Setup -### MCP サーバー起動 (sooperset/mcp-atlassian) +### MCP サーバーの選択肢 -```bash -# uvx 経由 -uvx mcp-atlassian --transport streamable-http --port 9000 +#### (A) Atlassian 公式 Rovo MCP — OAuth 2.1 (推奨) -# または Docker -docker run -p 9000:9000 ghcr.io/sooperset/mcp-atlassian:latest \ - --transport streamable-http --port 9000 -``` +- URL: `https://mcp.atlassian.com/v1/mcp` +- 認証: ユーザーが初回 Tally Chat 利用時に Claude Agent SDK が WWW-Authenticate を解釈し、 + ブラウザ経由で OAuth 2.1 を実行。token は SDK が管理し、Tally process には保存されない。 +- 制約: Atlassian Cloud 専用 (Server/DC 非対応)。 -### .env 設定 +#### (B) sooperset/mcp-atlassian — credentials は MCP server 側で管理 -Cloud (Atlassian Cloud) の場合 (Basic auth): ```bash -ATLASSIAN_EMAIL=your-email@example.com -ATLASSIAN_API_TOKEN=your-api-token +# Cloud に対する Basic auth で起動 (token は MCP server プロセスに留まる) +JIRA_USERNAME=you@example.com JIRA_API_TOKEN=xxx \ + uvx mcp-atlassian --transport streamable-http --port 9000 + +# Server/DC に対する Bearer auth で起動 +JIRA_PERSONAL_TOKEN=xxx JIRA_URL=https://jira.your-company.example \ + uvx mcp-atlassian --transport streamable-http --port 9000 ``` -Server / DC (オンプレ Jira) の場合 (Bearer auth): +または Docker: ```bash -JIRA_PAT=your-personal-access-token +docker run -p 9000:9000 -e JIRA_PERSONAL_TOKEN=xxx -e JIRA_URL=... \ + ghcr.io/sooperset/mcp-atlassian:latest --transport streamable-http --port 9000 ``` +**Tally は (A)/(B) いずれの場合も credentials を一切持ちません。** Tally プロセスから PAT/API key +が漏れる経路が無いことが Premise 9 撤回後の設計です。 + ### Tally プロジェクト設定 プロジェクト設定ダイアログ → MCP サーバーを追加: - ID: `atlassian` - 名前: `Atlassian Cloud` (任意) -- URL: `http://localhost:9000/mcp` -- スキーム: Bearer or Basic -- envVar: `ATLASSIAN_EMAIL` / `ATLASSIAN_API_TOKEN` (basic) or `JIRA_PAT` (bearer) +- URL: 上の (A) なら `https://mcp.atlassian.com/v1/mcp`、(B) なら `http://localhost:9000/mcp` + (loopback の http はテスト用に許容) + +### 初回 OAuth フロー (ケース A) + +1. Tally Chat で `@JIRA EPIC-XXX 読んで論点出して` を投げる +2. SDK が 401 を受けて WWW-Authenticate から OAuth metadata を取得 +3. ブラウザが開き Atlassian で auth、token は SDK 内部に保存 +4. 自動で再リクエストが走り、tool 呼び出しが成功する +5. 以降は token が refresh される間、再認証は不要 ## Epic 1-10 diff --git a/packages/ai-engine/src/agent-runner.test.ts b/packages/ai-engine/src/agent-runner.test.ts index 4982fae..9926a9e 100644 --- a/packages/ai-engine/src/agent-runner.test.ts +++ b/packages/ai-engine/src/agent-runner.test.ts @@ -500,13 +500,7 @@ describe('runAgent', () => { }); describe('Task 15: agent-runner で buildMcpServers を共有', () => { - const ORIGINAL_ENV = { ...process.env }; - afterEach(() => { - process.env = { ...ORIGINAL_ENV }; - }); - - it('プロジェクト mcpServers[] を sdk.query に動的に渡す (chat-runner と同じ utility 共有)', async () => { - process.env.TEST_PAT = 'secret'; + it('プロジェクト mcpServers[] を sdk.query に動的に渡す (url のみ、auth は SDK 任せ)', async () => { const store = { getNode: vi.fn().mockResolvedValue({ id: 'uc-1', @@ -526,7 +520,6 @@ describe('runAgent', () => { name: 'A', kind: 'atlassian', url: 'https://t.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -566,7 +559,7 @@ describe('runAgent', () => { const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { options?: { - mcpServers?: Record }>; + mcpServers?: Record; allowedTools?: string[]; }; }; @@ -574,69 +567,11 @@ describe('runAgent', () => { expect.arrayContaining(['tally', 'atlassian']), ); const atlassian = callArg.options?.mcpServers?.atlassian; - expect(atlassian?.headers?.Authorization).toBe('Bearer secret'); - // agent 固有の allowedTools (mcp__tally__find_related 等) + 外部 MCP wildcard + expect(atlassian?.url).toBe('https://t.test/mcp'); + // OAuth 2.1 採用: Tally は Authorization header を組み立てない + expect(atlassian?.headers).toBeUndefined(); + // agent 固有の allowedTools + 外部 MCP wildcard expect(callArg.options?.allowedTools).toContain('mcp__atlassian__*'); }); - - it('env 未設定なら error event を emit、sdk.query は呼ばない', async () => { - delete process.env.MISSING_PAT; - const store = { - getNode: vi.fn().mockResolvedValue({ - id: 'uc-1', - type: 'usecase', - x: 0, - y: 0, - title: 'UC', - body: '', - }), - getProjectMeta: vi.fn().mockResolvedValue({ - id: 'p', - name: 'P', - codebases: [], - mcpServers: [ - { - id: 'a', - name: 'A', - kind: 'atlassian', - url: 'https://t.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'MISSING_PAT' }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - createdAt: '2026-04-24T00:00:00Z', - updatedAt: '2026-04-24T00:00:00Z', - }), - addNode: vi.fn(), - listNodes: vi.fn().mockResolvedValue([]), - findRelatedNodes: vi.fn().mockResolvedValue([]), - addEdge: vi.fn(), - } as unknown as ProjectStore; - - const querySpy = vi.fn(); - const sdk: SdkLike = { query: querySpy }; - const events: AgentEvent[] = []; - for await (const e of runAgent({ - sdk, - store, - projectDir: '/ws', - req: { - type: 'start', - agent: 'extract-questions', - projectId: 'p', - input: { nodeId: 'uc-1' }, - }, - })) { - events.push(e); - } - - // buildMcpServers の throw が catch (err) で error event に流れる - expect(querySpy).not.toHaveBeenCalled(); - const errorEvent = events.find((e) => e.type === 'error'); - expect(errorEvent).toBeDefined(); - if (errorEvent && errorEvent.type === 'error') { - expect(errorEvent.message).toMatch(/MISSING_PAT/); - } - }); }); }); diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index 6dcd91a..ca579a1 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -482,8 +482,7 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { process.env = { ...ORIGINAL_ENV }; }); - it('プロジェクト設定の mcpServers[] を sdk.query に動的に渡す (Bearer)', async () => { - process.env.TEST_PAT = 'secret'; + it('プロジェクト設定の mcpServers[] を sdk.query に動的に渡す (url のみ、auth は SDK 任せ)', async () => { const root = mkdtempSync(path.join(tmpdir(), 'tally-task11-')); const ps = new FileSystemProjectStore(root); await ps.saveProjectMeta({ @@ -496,7 +495,6 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { name: 'T', kind: 'atlassian', url: 'https://t.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -527,7 +525,7 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { expect(querySpy).toHaveBeenCalled(); const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { options?: { - mcpServers?: Record }>; + mcpServers?: Record; allowedTools?: string[]; }; }; @@ -535,7 +533,8 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { expect.arrayContaining(['tally', 'test-mcp']), ); const testMcp = callArg.options?.mcpServers?.['test-mcp']; - expect(testMcp?.headers?.Authorization).toBe('Bearer secret'); + expect(testMcp?.url).toBe('https://t.test/mcp'); + expect(testMcp?.headers).toBeUndefined(); expect(callArg.options?.allowedTools).toContain('mcp__tally__*'); expect(callArg.options?.allowedTools).toContain('mcp__test-mcp__*'); @@ -582,8 +581,7 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { rmSync(root, { recursive: true, force: true }); }); - it('env 未設定なら error event を emit、sdk.query は呼ばない', async () => { - delete process.env.MISSING_PAT; + it('OAuth 採用後: SDK 設定に Authorization header は付かない (auth は MCP/SDK 任せ)', async () => { const root = mkdtempSync(path.join(tmpdir(), 'tally-task11c-')); const ps = new FileSystemProjectStore(root); await ps.saveProjectMeta({ @@ -592,64 +590,10 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { codebases: [], mcpServers: [ { - id: 'a', + id: 'atlassian', name: 'A', kind: 'atlassian', - url: 'https://t.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'MISSING_PAT' }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - const chatStore = new FileSystemChatStore(root); - const projectStore = new FileSystemProjectStore(root); - const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); - const querySpy = vi.fn(); - const sdk: SdkLike = { query: querySpy }; - const runner = new ChatRunner({ - sdk, - chatStore, - projectStore, - projectDir: root, - threadId: thread.id, - }); - - const events: ChatEvent[] = []; - for await (const e of runner.runUserTurn('hi')) events.push(e); - - expect(querySpy).not.toHaveBeenCalled(); - const errorEvent = events.find((e) => e.type === 'error'); - expect(errorEvent).toBeDefined(); - if (errorEvent && errorEvent.type === 'error') { - expect(errorEvent.message).toMatch(/MISSING_PAT/); - } - - rmSync(root, { recursive: true, force: true }); - }); - - it('Basic auth (Cloud) でも正しく Authorization header が組まれる', async () => { - process.env.ATLASSIAN_EMAIL = 'user@example.com'; - process.env.ATLASSIAN_API_TOKEN = 'token-xyz'; - const root = mkdtempSync(path.join(tmpdir(), 'tally-task11d-')); - const ps = new FileSystemProjectStore(root); - await ps.saveProjectMeta({ - id: 'proj-1', - name: 'P', - codebases: [], - mcpServers: [ - { - id: 'cloud', - name: 'C', - kind: 'atlassian', url: 'https://api.atlassian.test/mcp', - auth: { - type: 'pat', - scheme: 'basic', - emailEnvVar: 'ATLASSIAN_EMAIL', - tokenEnvVar: 'ATLASSIAN_API_TOKEN', - }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -677,10 +621,12 @@ describe('ChatRunner — buildMcpServers 統合 (Task 11)', () => { } const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as { - options?: { mcpServers?: Record }> }; + options?: { mcpServers?: Record }; }; - const expected = Buffer.from('user@example.com:token-xyz').toString('base64'); - expect(callArg.options?.mcpServers?.cloud?.headers?.Authorization).toBe(`Basic ${expected}`); + const atlassian = callArg.options?.mcpServers?.atlassian; + expect(atlassian?.url).toBe('https://api.atlassian.test/mcp'); + // OAuth 2.1 採用: Tally は Authorization header を組み立てない + expect(atlassian?.headers).toBeUndefined(); rmSync(root, { recursive: true, force: true }); }); @@ -693,7 +639,6 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( }); it('外部 MCP の tool_use を source=external で永続化、chat_tool_external_use event を emit', async () => { - process.env.TEST_PAT = 'secret'; const root = mkdtempSync(path.join(tmpdir(), 'tally-task12a-')); const ps = new FileSystemProjectStore(root); await ps.saveProjectMeta({ @@ -706,7 +651,6 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( name: 'A', kind: 'atlassian', url: 'https://t.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -842,7 +786,6 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( }); it('tool_result output が 4KB 超えると永続化時に truncate、event は full (Task 13)', async () => { - process.env.TEST_PAT = 'secret'; const root = mkdtempSync(path.join(tmpdir(), 'tally-task13-')); const ps = new FileSystemProjectStore(root); await ps.saveProjectMeta({ @@ -855,7 +798,6 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( name: 'A', kind: 'atlassian', url: 'https://t.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -915,7 +857,6 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( }); it('tool_result output が 4KB 以下なら truncate しない', async () => { - process.env.TEST_PAT = 'secret'; const root = mkdtempSync(path.join(tmpdir(), 'tally-task13b-')); const ps = new FileSystemProjectStore(root); await ps.saveProjectMeta({ @@ -928,7 +869,6 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( name: 'A', kind: 'atlassian', url: 'https://t.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -979,7 +919,6 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( }); it('外部 tool_result が is_error=true なら ok=false で記録', async () => { - process.env.TEST_PAT = 'secret'; const root = mkdtempSync(path.join(tmpdir(), 'tally-task12c-')); const ps = new FileSystemProjectStore(root); await ps.saveProjectMeta({ @@ -992,7 +931,6 @@ describe('ChatRunner — 外部 MCP tool_use/tool_result 永続化 (Task 12)', ( name: 'A', kind: 'atlassian', url: 'https://t.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], diff --git a/packages/ai-engine/src/mcp/build-mcp-servers.test.ts b/packages/ai-engine/src/mcp/build-mcp-servers.test.ts index 0dc3939..59b262d 100644 --- a/packages/ai-engine/src/mcp/build-mcp-servers.test.ts +++ b/packages/ai-engine/src/mcp/build-mcp-servers.test.ts @@ -1,165 +1,41 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { buildMcpServers } from './build-mcp-servers'; describe('buildMcpServers', () => { - const ORIGINAL_ENV = { ...process.env }; - afterEach(() => { - process.env = { ...ORIGINAL_ENV }; - }); - it('mcpServers 空配列 → external 無し、allowedTools は tally のみ', () => { const result = buildMcpServers({ tallyMcp: { type: 'sdk' } as unknown, configs: [] }); expect(Object.keys(result.mcpServers)).toEqual(['tally']); expect(result.allowedTools).toEqual(['mcp__tally__*']); }); - it('Bearer (Server/DC) → Authorization: Bearer ', () => { - process.env.JIRA_PAT = 'secret-xyz'; + it('atlassian 1 個 → HTTP config (url のみ、Authorization header なし) + allowedTools', () => { const result = buildMcpServers({ tallyMcp: { type: 'sdk' } as unknown, configs: [ { - id: 'atlassian-dc', - name: 'A', + id: 'atlassian', + name: 'Atlassian', kind: 'atlassian', - url: 'https://jira.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, + url: 'https://mcp.atlassian.example/v1/mcp', options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], }); - const atlassian = result.mcpServers['atlassian-dc'] as { + const atlassian = result.mcpServers.atlassian as { type: string; url: string; - headers: Record; + headers?: unknown; }; expect(atlassian.type).toBe('http'); - expect(atlassian.url).toBe('https://jira.test/mcp'); - expect(atlassian.headers.Authorization).toBe('Bearer secret-xyz'); + expect(atlassian.url).toBe('https://mcp.atlassian.example/v1/mcp'); + // OAuth 2.1 採用: Tally は Authorization header を組み立てない + expect(atlassian.headers).toBeUndefined(); expect(result.allowedTools).toContain('mcp__tally__*'); - expect(result.allowedTools).toContain('mcp__atlassian-dc__*'); - }); - - it('Basic (Cloud) → Authorization: Basic ', () => { - process.env.ATLASSIAN_EMAIL = 'user@example.com'; - process.env.ATLASSIAN_API_TOKEN = 'api-token-xyz'; - const result = buildMcpServers({ - tallyMcp: { type: 'sdk' } as unknown, - configs: [ - { - id: 'atlassian-cloud', - name: 'A', - kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { - type: 'pat', - scheme: 'basic', - emailEnvVar: 'ATLASSIAN_EMAIL', - tokenEnvVar: 'ATLASSIAN_API_TOKEN', - }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - }); - const atlassian = result.mcpServers['atlassian-cloud'] as { - headers: Record; - }; - const expected = Buffer.from('user@example.com:api-token-xyz').toString('base64'); - expect(atlassian.headers.Authorization).toBe(`Basic ${expected}`); - }); - - it('Bearer の tokenEnvVar 未設定 → throw', () => { - delete process.env.JIRA_PAT; - expect(() => - buildMcpServers({ - tallyMcp: { type: 'sdk' } as unknown, - configs: [ - { - id: 'a', - name: 'A', - kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - }), - ).toThrowError(/JIRA_PAT/); - }); - - it('Basic の emailEnvVar 未設定 → throw', () => { - delete process.env.ATLASSIAN_EMAIL; - process.env.ATLASSIAN_API_TOKEN = 'x'; - expect(() => - buildMcpServers({ - tallyMcp: { type: 'sdk' } as unknown, - configs: [ - { - id: 'a', - name: 'A', - kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { - type: 'pat', - scheme: 'basic', - emailEnvVar: 'ATLASSIAN_EMAIL', - tokenEnvVar: 'ATLASSIAN_API_TOKEN', - }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - }), - ).toThrowError(/ATLASSIAN_EMAIL/); - }); - - it('Basic の tokenEnvVar 未設定 → throw', () => { - process.env.ATLASSIAN_EMAIL = 'user@example.com'; - delete process.env.ATLASSIAN_API_TOKEN; - expect(() => - buildMcpServers({ - tallyMcp: { type: 'sdk' } as unknown, - configs: [ - { - id: 'a', - name: 'A', - kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { - type: 'pat', - scheme: 'basic', - emailEnvVar: 'ATLASSIAN_EMAIL', - tokenEnvVar: 'ATLASSIAN_API_TOKEN', - }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - }), - ).toThrowError(/ATLASSIAN_API_TOKEN/); - }); - - it('env 値が空文字でも → throw (= 未設定と同じ扱い)', () => { - process.env.JIRA_PAT = ''; - expect(() => - buildMcpServers({ - tallyMcp: { type: 'sdk' } as unknown, - configs: [ - { - id: 'a', - name: 'A', - kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - }), - ).toThrowError(/JIRA_PAT/); + expect(result.allowedTools).toContain('mcp__atlassian__*'); }); it('複数の config を合成 → 各々が独立に build される', () => { - process.env.JIRA_PAT = 's1'; - process.env.OTHER_TOKEN = 's2'; const result = buildMcpServers({ tallyMcp: { type: 'sdk' } as unknown, configs: [ @@ -168,7 +44,6 @@ describe('buildMcpServers', () => { name: 'F', kind: 'atlassian', url: 'https://a.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, { @@ -176,16 +51,17 @@ describe('buildMcpServers', () => { name: 'S', kind: 'atlassian', url: 'https://b.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'OTHER_TOKEN' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], }); expect(Object.keys(result.mcpServers)).toEqual(['tally', 'first', 'second']); - const first = result.mcpServers.first as { headers: Record }; - const second = result.mcpServers.second as { headers: Record }; - expect(first.headers.Authorization).toBe('Bearer s1'); - expect(second.headers.Authorization).toBe('Bearer s2'); + const first = result.mcpServers.first as { url: string; headers?: unknown }; + const second = result.mcpServers.second as { url: string; headers?: unknown }; + expect(first.url).toBe('https://a.test/mcp'); + expect(second.url).toBe('https://b.test/mcp'); + expect(first.headers).toBeUndefined(); + expect(second.headers).toBeUndefined(); expect(result.allowedTools).toEqual(['mcp__tally__*', 'mcp__first__*', 'mcp__second__*']); }); }); diff --git a/packages/ai-engine/src/mcp/build-mcp-servers.ts b/packages/ai-engine/src/mcp/build-mcp-servers.ts index e89bc1c..035167c 100644 --- a/packages/ai-engine/src/mcp/build-mcp-servers.ts +++ b/packages/ai-engine/src/mcp/build-mcp-servers.ts @@ -3,9 +3,13 @@ import type { McpServerConfig } from '@tally/core'; // SDK の mcpServers は Record を受ける (sdk.d.ts:1386 参照)。 // chat-runner / agent-runner が共通で使える shape にする。 // -// 認証方式: -// - bearer (Server/DC): Authorization: Bearer -// - basic (Cloud): Authorization: Basic +// 認証方針 (Premise 9 撤回後): +// MCP プロトコルの OAuth 2.1 を採用し、Tally は credentials を一切扱わない。 +// - 401 を受けたら Claude Agent SDK が WWW-Authenticate から OAuth metadata を取り、 +// ブラウザ経由 (or device flow) で auth、token 管理は SDK 側で完結する。 +// - ここでは Authorization header を組み立てない。url のみを SDK に渡す。 +// - PAT 認証の MCP server (sooperset 等) を使う場合は、その server 自身が起動時 env で +// credentials を持つ前提 (Tally は header passthrough しない)。 // // allowedTools は wildcard `mcp____*` (Spike 0b 確認済、Claude Code 2.1.117+ サポート)。 export interface BuildMcpServersInput { @@ -20,29 +24,8 @@ export interface BuildMcpServersResult { allowedTools: string[]; } -function requireEnv(varName: string, contextId: string): string { - const v = process.env[varName]; - if (v === undefined || v === '') { - throw new Error(`MCP 設定 "${contextId}" の env var "${varName}" が未設定または空です`); - } - return v; -} - -function buildAuthHeader(auth: McpServerConfig['auth'], contextId: string): string { - if (auth.scheme === 'bearer') { - const token = requireEnv(auth.tokenEnvVar, contextId); - return `Bearer ${token}`; - } - // basic - const email = requireEnv(auth.emailEnvVar, contextId); - const token = requireEnv(auth.tokenEnvVar, contextId); - const b64 = Buffer.from(`${email}:${token}`).toString('base64'); - return `Basic ${b64}`; -} - -// SDK 設定と allowedTools を組み立てる。env 未設定は throw。 -// 呼び出し元 (chat-runner / agent-runner) は runUserTurn の都度これを呼ぶ -// → env 変更がホットリロードされる。 +// SDK 設定と allowedTools を組み立てる。 +// 認証は MCP 側 (SDK の OAuth 2.1 / MCP server 自身) に委譲しており、Tally は touch しない。 export function buildMcpServers(input: BuildMcpServersInput): BuildMcpServersResult { const { tallyMcp, configs } = input; @@ -50,11 +33,9 @@ export function buildMcpServers(input: BuildMcpServersInput): BuildMcpServersRes const allowedTools: string[] = ['mcp__tally__*']; for (const cfg of configs) { - const authHeader = buildAuthHeader(cfg.auth, cfg.id); mcpServers[cfg.id] = { type: 'http' as const, url: cfg.url, - headers: { Authorization: authHeader }, }; allowedTools.push(`mcp__${cfg.id}__*`); } diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index f510daf..76aba25 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -321,60 +321,26 @@ describe('ChatThreadSchema / ChatThreadMetaSchema', () => { }); describe('McpServerConfigSchema', () => { - it('Cloud (basic) auth の round-trip が通る', () => { + // OAuth 2.1 採用後、Tally は url のみ持ち auth credentials は MCP/SDK に委譲する。 + // よって round-trip の最小形は id/name/kind/url/options のみ。 + it('atlassian round-trip (auth credentials は MCP/SDK 任せ、Tally は url のみ)', () => { const raw = { id: 'atlassian-cloud', name: 'Atlassian Cloud', kind: 'atlassian' as const, url: 'https://mcp.atlassian.example/v1/mcp', - auth: { - type: 'pat' as const, - scheme: 'basic' as const, - emailEnvVar: 'ATLASSIAN_EMAIL', - tokenEnvVar: 'ATLASSIAN_API_TOKEN', - }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }; const parsed = McpServerConfigSchema.parse(raw); expect(parsed).toEqual(raw); }); - it('Server/DC (bearer) auth の round-trip が通る', () => { - const raw = { - id: 'atlassian-onprem', - name: 'Atlassian On-Prem', - kind: 'atlassian' as const, - url: 'https://jira.example.com/mcp', - auth: { - type: 'pat' as const, - scheme: 'bearer' as const, - tokenEnvVar: 'JIRA_PAT', - }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }; - const parsed = McpServerConfigSchema.parse(raw); - expect(parsed).toEqual(raw); - }); - - it('basic で emailEnvVar 無しは fail', () => { - expect(() => - McpServerConfigSchema.parse({ - id: 'a', - name: 'A', - kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { type: 'pat', scheme: 'basic', tokenEnvVar: 'T' }, - }), - ).toThrow(); - }); - it('options 未指定なら default が入る', () => { const parsed = McpServerConfigSchema.parse({ id: 'a', name: 'A', kind: 'atlassian', url: 'https://x.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X_PAT' }, }); expect(parsed.options.maxChildIssues).toBe(30); expect(parsed.options.maxCommentsPerIssue).toBe(5); @@ -387,10 +353,22 @@ describe('McpServerConfigSchema', () => { name: 'A', kind: 'atlassian', url: 'not a url', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X' }, }), ).toThrow(); }); + + it('auth フィールドが付いていても strict ではないので無視される (passthrough)', () => { + // schema 上は auth キーを持たない。zod は default で strict ではないため余計なキーは drop。 + // OAuth 移行前の YAML が混入しても parse 自体は通すが、auth 情報は使われない。 + const parsed = McpServerConfigSchema.parse({ + id: 'a', + name: 'A', + kind: 'atlassian', + url: 'https://x.test/mcp', + auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, // 余計なキー + } as Record); + expect((parsed as unknown as { auth?: unknown }).auth).toBeUndefined(); + }); }); describe('McpServerConfigSchema hardening', () => { @@ -400,11 +378,6 @@ describe('McpServerConfigSchema hardening', () => { name: 'Atlassian', kind: 'atlassian' as const, url: 'https://mcp.atlassian.example/v1/mcp', - auth: { - type: 'pat' as const, - scheme: 'bearer' as const, - tokenEnvVar: 'JIRA_PAT', - }, }; describe('url: https 強制 + loopback 例外', () => { @@ -414,7 +387,7 @@ describe('McpServerConfigSchema hardening', () => { ).not.toThrow(); }); - it('http://localhost は pass (sooperset セルフホスト想定)', () => { + it('http://localhost は pass (セルフホスト MCP server 想定)', () => { expect(() => McpServerConfigSchema.parse({ ...validBase, url: 'http://localhost:9000/mcp' }), ).not.toThrow(); @@ -426,7 +399,7 @@ describe('McpServerConfigSchema hardening', () => { ).not.toThrow(); }); - it('http://example.com は fail (cleartext で credential が漏れる)', () => { + it('http://example.com は fail (OAuth handshake / token を cleartext で運ばない)', () => { expect(() => McpServerConfigSchema.parse({ ...validBase, url: 'http://example.com/mcp' }), ).toThrow(); @@ -474,100 +447,6 @@ describe('McpServerConfigSchema hardening', () => { expect(() => McpServerConfigSchema.parse({ ...validBase, id: 'a'.repeat(33) })).toThrow(); }); }); - - describe('emailEnvVar / tokenEnvVar: env var 名 regex', () => { - const baseBasic = { - ...validBase, - auth: { - type: 'pat' as const, - scheme: 'basic' as const, - emailEnvVar: 'ATLASSIAN_EMAIL', - tokenEnvVar: 'ATLASSIAN_API_TOKEN', - }, - }; - - it("'ATLASSIAN_PAT' は pass", () => { - expect(() => - McpServerConfigSchema.parse({ - ...validBase, - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'ATLASSIAN_PAT' }, - }), - ).not.toThrow(); - }); - - it("'JIRA_PAT_1' は pass (数字含む OK)", () => { - expect(() => - McpServerConfigSchema.parse({ - ...validBase, - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT_1' }, - }), - ).not.toThrow(); - }); - - it("'A' は pass (1 文字大文字)", () => { - expect(() => - McpServerConfigSchema.parse({ - ...validBase, - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'A' }, - }), - ).not.toThrow(); - }); - - it("'lowercase' は fail", () => { - expect(() => - McpServerConfigSchema.parse({ - ...validBase, - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'lowercase' }, - }), - ).toThrow(); - }); - - it("'foo@bar.com' は fail (実値混入を防ぐ)", () => { - expect(() => - McpServerConfigSchema.parse({ - ...baseBasic, - auth: { - type: 'pat', - scheme: 'basic', - emailEnvVar: 'foo@bar.com', - tokenEnvVar: 'ATLASSIAN_API_TOKEN', - }, - }), - ).toThrow(); - }); - - it("'1ABC' は fail (数字始まり)", () => { - expect(() => - McpServerConfigSchema.parse({ - ...validBase, - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: '1ABC' }, - }), - ).toThrow(); - }); - - it("'' (空文字) は fail", () => { - expect(() => - McpServerConfigSchema.parse({ - ...validBase, - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: '' }, - }), - ).toThrow(); - }); - - it('basic auth の emailEnvVar も同じ regex を要求', () => { - expect(() => - McpServerConfigSchema.parse({ - ...baseBasic, - auth: { - type: 'pat', - scheme: 'basic', - emailEnvVar: 'lowercase', - tokenEnvVar: 'ATLASSIAN_API_TOKEN', - }, - }), - ).toThrow(); - }); - }); }); describe('ProjectSchema.mcpServers', () => { @@ -599,7 +478,6 @@ describe('ProjectSchema.mcpServers', () => { name: 'A', kind: 'atlassian' as const, url: 'https://x.test/mcp', - auth: { type: 'pat' as const, scheme: 'bearer' as const, tokenEnvVar: 'JIRA_PAT' }, }, ], }; @@ -624,7 +502,6 @@ describe('ProjectMetaSchema.mcpServers', () => { name: 'A', kind: 'atlassian' as const, url: 'https://x.test/mcp', - auth: { type: 'pat' as const, scheme: 'bearer' as const, tokenEnvVar: 'JIRA_PAT' }, }, ], }); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 2880814..87bf0f1 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -196,29 +196,15 @@ function checkUniqueCodebaseIds( // --------------------------------------------------------------------------- // 注: ProjectMetaSchema / ProjectSchema が McpServerConfigSchema を参照するため、 // 宣言順序として MCP セクションを Project 系より前に置く。 - -// 環境変数名の shape (POSIX 準拠: 大文字英 + 数字 + アンダースコア、先頭は大文字英)。 -// 実値 (例 "foo@bar.com") の混入を防ぐ。空文字は regex で自動的に reject される。 -const ENV_VAR_NAME_REGEX = /^[A-Z][A-Z0-9_]*$/u; -const envVarName = z.string().regex(ENV_VAR_NAME_REGEX, { - message: 'env var 名は ^[A-Z][A-Z0-9_]*$ (大文字英始まり、英数字・_ のみ)', -}); - -// Atlassian Cloud は Basic (base64(email:token))、Server/DC は Bearer (pat) の 2 scheme。 -// どちらも PAT ベースの認証 (OAuth は MVP 非対応、Premise 9)。 -const McpAuthSchema = z.discriminatedUnion('scheme', [ - z.object({ - type: z.literal('pat'), - scheme: z.literal('basic'), - emailEnvVar: envVarName, // 例 "ATLASSIAN_EMAIL" - tokenEnvVar: envVarName, // 例 "ATLASSIAN_API_TOKEN" - }), - z.object({ - type: z.literal('pat'), - scheme: z.literal('bearer'), - tokenEnvVar: envVarName, // 例 "JIRA_PAT" - }), -]); +// +// 認証方針 (Premise 9 撤回後): +// MCP プロトコルの OAuth 2.1 を採用し、Tally は credentials を一切扱わない。 +// - 401 を受けたら Claude Agent SDK が WWW-Authenticate から OAuth metadata を取り、 +// ブラウザ経由 (or device flow) で auth、token 管理は SDK 側で完結する。 +// - Tally は url のみ持ち、Authorization header は組み立てない。 +// - PAT / API key を Tally のメモリ・ログ・ファイルに残さない。 +// 既存の sooperset/mcp-atlassian (PAT 認証) を使う場合はその MCP server 自身が +// 起動時 env で credentials を持つ前提 (Tally は header passthrough しない)。 // options は未指定時に {} を default として与え、内側で各フィールドの default を発火させる。 // zod v4 では outer .default(value) が parse 前に value をそのまま流すため、 @@ -240,8 +226,9 @@ export const McpServerConfigSchema = z.object({ }), name: z.string().min(1), kind: z.literal('atlassian'), - // PAT を Authorization header で送る transport なので cleartext を許さない。 - // 開発・テスト用の loopback (localhost / 127.0.0.1 / ::1) のみ http: を例外的に許容。 + // OAuth 2.1 / その他 credential は MCP プロトコル経由で SDK が処理する。 + // ここでは url のみを持ち、cleartext を防ぐため https のみ許可 + // (開発・テスト用の loopback (localhost / 127.0.0.1 / ::1) のみ http: を例外的に許容)。 url: z .string() .url() @@ -266,7 +253,6 @@ export const McpServerConfigSchema = z.object({ }, { message: 'url は https で始まる必要があります (loopback の http は例外的に許容)' }, ), - auth: McpAuthSchema, options: McpServerOptionsSchema, }); diff --git a/packages/frontend/src/app/api/projects/[id]/route.test.ts b/packages/frontend/src/app/api/projects/[id]/route.test.ts index 83368c9..6165c45 100644 --- a/packages/frontend/src/app/api/projects/[id]/route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/route.test.ts @@ -81,7 +81,6 @@ describe('PATCH /api/projects/:id', () => { name: 'Atlassian', kind: 'atlassian', url: 'https://x.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -106,7 +105,6 @@ describe('PATCH /api/projects/:id', () => { name: 'A', kind: 'atlassian', url: 'http://example.com/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], @@ -129,7 +127,6 @@ describe('PATCH /api/projects/:id', () => { name: 'A', kind: 'atlassian', url: 'https://x.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }, ], diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx index 49c78cc..2d40671 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx @@ -93,18 +93,16 @@ describe('ProjectSettingsDialog', () => { ); }); - it('MCP サーバーを追加できる (Task 17)', async () => { + it('MCP サーバーを追加できる (Task 17、OAuth 2.1 採用後は url のみ)', async () => { render( {}} />); await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); // 新規追加された MCP server の id 入力欄が現れる (default は atlassian-1) const idInput = screen.getByLabelText('mcp-0-id') as HTMLInputElement; expect(idInput).toBeInTheDocument(); expect(idInput.value).toBe('atlassian-1'); - // url / tokenEnvVar を入力 + // url のみ入力 (auth は MCP/SDK 任せ) const urlInput = screen.getByLabelText('mcp-0-url'); await userEvent.type(urlInput, 'https://x.test/mcp'); - const tokenInput = screen.getByLabelText('mcp-0-tokenEnvVar'); - await userEvent.type(tokenInput, 'JIRA_PAT'); await userEvent.click(screen.getByRole('button', { name: /保存/ })); await waitFor(() => @@ -115,28 +113,16 @@ describe('ProjectSettingsDialog', () => { id: 'atlassian-1', kind: 'atlassian', url: 'https://x.test/mcp', - auth: expect.objectContaining({ - type: 'pat', - scheme: 'bearer', - tokenEnvVar: 'JIRA_PAT', - }), }), ], }), ), ); - }); - - it('Basic auth 切替で emailEnvVar 入力欄が現れる (Task 17)', async () => { - render( {}} />); - await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); - // 初期は bearer なので emailEnvVar 欄は無し - expect(screen.queryByLabelText('mcp-0-emailEnvVar')).toBeNull(); - // basic に切替 - const schemeSelect = screen.getByLabelText('mcp-0-scheme') as HTMLSelectElement; - await userEvent.selectOptions(schemeSelect, 'basic'); - // emailEnvVar 欄が現れる - expect(screen.getByLabelText('mcp-0-emailEnvVar')).toBeInTheDocument(); + // auth フィールドは保存ペイロードに含まれない + const lastCallArg = patchProjectMeta.mock.calls.at(-1)?.[0] as + | { mcpServers?: Array> } + | undefined; + expect(lastCallArg?.mcpServers?.[0]?.auth).toBeUndefined(); }); it('MCP サーバーを削除できる (Task 17)', async () => { @@ -152,15 +138,19 @@ describe('ProjectSettingsDialog', () => { expect(screen.queryByLabelText('mcp-0-id')).toBeNull(); }); - it('secret 値の入力欄は無い (envVar 名のみ。caption に .env への誘導)', () => { + it('auth / secret 関連の入力欄は無い (OAuth 2.1 で MCP/SDK 任せ)', async () => { render( {}} />); - // secret / token / pat / api_token / password 系の入力欄が無いこと + await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); + // auth scheme dropdown / envVar 入力欄 / secret 値入力欄、いずれも無い + expect(screen.queryByLabelText('mcp-0-scheme')).toBeNull(); + expect(screen.queryByLabelText('mcp-0-emailEnvVar')).toBeNull(); + expect(screen.queryByLabelText('mcp-0-tokenEnvVar')).toBeNull(); expect(screen.queryByLabelText(/PAT$/i)).toBeNull(); expect(screen.queryByLabelText(/シークレット/i)).toBeNull(); expect(screen.queryByLabelText(/api_token$/i)).toBeNull(); expect(screen.queryByLabelText(/password/i)).toBeNull(); - // .env への誘導文言 - expect(screen.getByText(/\.env/)).toBeInTheDocument(); + // OAuth/MCP 任せの説明文言 + expect(screen.getByText(/MCP プロトコル/)).toBeInTheDocument(); }); it('id 重複時は保存 disabled', async () => { diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.tsx index b58da2f..720439c 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.tsx @@ -7,15 +7,13 @@ import { TextInput } from '@/components/ui/text-input'; import { useCanvasStore } from '@/lib/store'; import { FolderBrowserDialog } from './folder-browser-dialog'; -// MCP サーバー新規追加時のデフォルト config (Bearer + 空 envVar 名 + 空 url)。 -// scheme/envVar は ユーザーが Cloud (basic) か Server/DC (bearer) かで選択する。 +// MCP サーバー新規追加時のデフォルト config (url 空、認証は MCP/SDK 任せ)。 function makeDefaultMcpServer(seq: number): McpServerConfig { return { id: `atlassian-${seq}`, name: 'Atlassian', kind: 'atlassian', url: '', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: '' }, options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, }; } @@ -170,12 +168,13 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
- シークレット (PAT 等) はこのフォームでは入力しません。サーバーの .env に - ATLASSIAN_PAT=... のように置き、ここでは環境変数名のみ指定します。 + 認証 (OAuth 2.1 / API token 等) は MCP プロトコルに任せます。Tally では URL + の登録のみ行い、 初回利用時に MCP サーバーから案内される認証フローに従ってください。
{mcpServers.length === 0 &&
MCP サーバー未設定
}
    {mcpServers.map((s, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: id 重複 (default 'atlassian-1' が複数行に並ぶ瞬間) を許す UI で index 込みのキーが必要
  • - -
    -
    - {s.auth.scheme === 'basic' && ( - - updateMcpServer(i, { - ...s, - auth: { - type: 'pat', - scheme: 'basic', - emailEnvVar: e.target.value, - tokenEnvVar: s.auth.tokenEnvVar, - }, - }) - } - disabled={busy} - aria-label={`mcp-${i}-emailEnvVar`} - style={{ ...INPUT, flex: 1 }} - placeholder="ATLASSIAN_EMAIL" - /> - )} - - updateMcpServer(i, { - ...s, - auth: { ...s.auth, tokenEnvVar: e.target.value }, - }) - } - disabled={busy} - aria-label={`mcp-${i}-tokenEnvVar`} - style={{ ...INPUT, flex: 1 }} - placeholder={s.auth.scheme === 'basic' ? 'ATLASSIAN_API_TOKEN' : 'JIRA_PAT'} - />
  • ))} From 1e25715eba4abd897883480277d8e97a5efbfb03 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 17:37:07 +0900 Subject: [PATCH 27/34] =?UTF-8?q?docs:=20PMDEV-165=20=E3=81=A7=20dogfood?= =?UTF-8?q?=20proof-of-concept=20=E3=82=92=E3=82=B7=E3=83=9F=E3=83=A5?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=83=88=20(Atlassian=20MCP=20=E9=80=A3?= =?UTF-8?q?=E6=90=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実機 Tally dev server で dogfood する前の事前 PoC として、Claude が atlassian plugin tool 経由で PMDEV-165 (ホーム画面+認証、子チケット 50+) の context を取り、 「もし Tally AI agent が同じ context で論点抽出したら」のシミュレーション出力を 6 件の question proposal として記録。 論点候補: - ログインセッションの「保持」既定状態 (UX vs リテンション) - パスワード再発行メールの送信元・文言所有権 (法務/マーケ判断) - ゲストの価格・在庫「非表示」の具体実装 (ぼかし/CTA/完全隠し) - 退会後のデータ保持期間 (電帳法 7 年 vs GDPR 削除権) - パフォーマンス "3 秒以内" の計測条件 (dev vs Core Web Vitals) - サプライヤー初回パスワード変更 UX (子チケット PMDEV-264/266 由来) うち「気づかなかった論点」候補 2 件 (退会後の法定保持 / パフォーマンス二重基準)。 実機実装後に同エピックで dogfood し、この期待値と比べる。 --- ...04-24-atlassian-mcp-c-phase-dogfood-log.md | 89 +++++++++++++++---- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md b/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md index 0e2c0c9..d3929ce 100644 --- a/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md +++ b/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md @@ -54,26 +54,81 @@ docker run -p 9000:9000 -e JIRA_PERSONAL_TOKEN=xxx -e JIRA_URL=... \ 各エピックについて以下の項目を記録する。 -### Epic N: - -- **エピック概要** (1 行): -- **規模**: 子チケット ___ 件、コメント総数 ___ 件 -- **Turn 1**: `@JIRA を読んで論点を出して` - - 所要時間: ___ 秒 (target: 90 秒以内) - - 生成 question proposal: ___ 個 (target: 3 個以上) - - 採用: ___ 個、却下: ___ 個 - - 採用判断の理由 (採用/却下それぞれ箇条書き): -- **Turn 2 (multi-turn test)**: `続けて子チケット も読んで論点を追加して` - - AI が前ターンの Epic 内容を覚えているか: YES / NO - - 生成 question proposal: ___ 個 - - 採用: ___ 個 -- **「気づかなかった論点」判定**: YES / NO - - YES なら具体内容: -- **重複ガード動作**: 同 URL 2 度目取り込み → sourceUrl guard 発動: YES / NO +### Epic 1: PMDEV-165【L1】ホーム画面+認証 (proof-of-concept、2026-04-27) + +> **注**: これは **Tally dev server で実機 dogfood する前の事前 PoC**。Claude が atlassian plugin tool 経由で context を取り、「もし Tally AI agent が同じ context で論点抽出したら」のシミュレーション出力を記録。実装が完成したら同じエピックで実機 dogfood し、ここの「期待値」と比べる。 + +- **エピック概要**: バイヤー向けの認証機能 (FUNC-022〜026: ログイン / パスワード再発行 / 自動ログイン記憶 / 新規会員登録 / 退会) と、ホーム画面 (FUNC-060: トップページ / FUNC-016: ゲスト/ログイン差分表示) +- **URL**: https://ignission.atlassian.net/browse/PMDEV-165 +- **規模**: 子チケット **50+** 件 (Tally Premise 7 の `maxChildIssues=30` を超過)、ステータス進行中 + +#### シミュレーション (Claude as Tally AI agent) + +PMDEV-165 description + 子チケット summary 50 件を context に「未決定の設計判断 (論点)」を抽出。各論点は Tally の `question` proposal 形式 (title / body / options[] / sourceRefs[]): + +**論点 1: ログインセッションの「ログイン状態を保持する」既定状態** +- body: FUNC-022/024 で `JWTトークンをHttpOnly Cookieに保存(有効期限7日)` と `「ログイン状態を保持する」が機能する` の AC があるが、**この checkbox の初期値**が決まっていない。チェック ON 既定なら離脱が増えにくいが UX として強制感、OFF 既定だと再ログイン頻度が上がりリテンション低下。 +- options: + - チェック ON 既定 (UX 簡略、長期 cookie 7 日保持) + - チェック OFF 既定 (短期 session cookie、明示同意) + - そもそも checkbox を出さない (常に 7 日保持 / なし) +- sourceRefs: FUNC-022, FUNC-024 + +**論点 2: パスワード再発行メールの送信元アドレス / 文言所有権** +- body: FUNC-023「メールアドレス入力でCognito経由の再発行メールが送信される」だけ書かれているが、**送信元 (no-reply@?)、件名 / 本文の文言、ブランディング、Cognito default テンプレ vs カスタム**が決まっていない。法務 / マーケ含めた決断が要る。 +- options: + - Cognito default テンプレ (Amazon SES) で MVP + - SES + カスタムテンプレ (ブランド整える、デザイン依頼必要) + - 第三者メール SaaS 経由 (SendGrid 等、cost / deliverability) +- sourceRefs: FUNC-023, PMDEV-176 [SRE] メール基盤 + +**論点 3: ゲストの価格・在庫を「非表示」とは具体的に何か** +- body: FUNC-016「未ログイン(ゲスト)状態では商品の価格・在庫情報が非表示になる」とあるが、**ぼかし / プレースホルダ / 「ログインしてご確認ください」誘導 / 完全に隠す**のどれか未決定。商品ヒーロー画像で価格訴求できないと SEO / 流入時の最初の印象が弱い。 +- options: + - 完全非表示 (空のスペース) + - 「会員登録/ログインして表示」CTA で置換 (推奨? CV 期待) + - ぼかし表示 (具体額を ××× 等) +- sourceRefs: FUNC-016, FUNC-060 + +**論点 4: 退会後のデータ保持期間 / 法的要件** +- body: FUNC-026「退会処理でCognitoユーザーが無効化・削除される」だが、**注文履歴 / 請求書 / GDPR 的な個人データ保持期間 / soft delete か hard delete か**が決まっていない。日本の電帳法 (請求書 7 年) と矛盾する可能性。 +- options: + - Cognito user は即削除、注文 / 請求は anonymize で保持 + - Cognito user は soft delete (再開可能)、注文 / 請求は実名保持 (法定期間) + - 退会時に user に「データ削除 / 保持」選択させる (GDPR の権利) +- sourceRefs: FUNC-026 + +**論点 5: パフォーマンス基準 "3 秒以内" の計測条件** +- body: 「ページ初期表示が3秒以内に完了する(dev環境)」と「Core Web Vitalsの基準を満たす」がある。**dev 環境の 3 秒** はネットワーク条件 / キャッシュ状態 / 同時接続数によって大きく揺れる。Core Web Vitals (LCP 2.5s 等) の方が標準的だが、dev 環境では本番回線で測れない。 +- options: + - dev 3 秒 + Core Web Vitals = 二重基準 (dev は緩い目安、本番は CWV) + - Core Web Vitals 一本化 (dev 環境では参考、staging で測定) + - 削除 (dev 3 秒は意味薄、CWV のみ採用) +- sourceRefs: FUNC-060 パフォーマンス節 + +**論点 6: サプライヤー初回ログインのパスワード変更 UX (子チケット由来)** +- body: PMDEV-264 [FE] サプライヤー初回ログイン時のパスワード変更画面 / PMDEV-266 NEW_PASSWORD_REQUIRED チャレンジで 500 エラー (バグ完了)。**初回パスワード生成方式** (招待メールに 1 回限り URL / 初期パスを発行 / 完全自由設定) と **強度ポリシー** が docs に書かれていない。 +- options: + - 招待メール内 1 回限り URL (Cognito の admin invitation flow) + - 招待時に初期 12 文字 random pwd を発行 (メール本文に platintext = リスク) + - サプライヤーが自由設定 (招待メール内のリンクから直接 sign-up) +- sourceRefs: PMDEV-264, PMDEV-266 + +**シミュレーション結果**: +- 生成 question proposal: 6 件 (target 3+ クリア) +- うち「気づかなかった論点」候補 (人間が epic だけ読んでは見落とす): 論点 4 (退会後の法定保持) / 論点 5 (パフォーマンス基準二重) — **2 件以上** (target 3 件には惜しい、実機で深堀れば届きそう) +- AI が子チケット 50 件全部読むと context が爆発する想定。Premise 7 の `maxChildIssues=30` での切り捨ては必要だが、**重要な子チケットの抽出ロジック** (例: status='完了' は除外、bug type は context 寄与低、最新更新優先 等) が dogfood で見えそう + +#### 実機 dogfood 時の確認事項 + +- [ ] Tally Chat で同じ epic を投げ、上記 6 論点と類似の output が出るか +- [ ] 子チケット 50 件 → AI 動作 (context 爆発する? truncate される?) +- [ ] multi-turn で「PMDEV-264 をもっと深く」と聞いたら過去 context を覚えているか +- [ ] 同 sourceUrl 2 度目取り込みの重複ガード発動 --- -(Epic 2-10 を同フォーマットで) +(Epic 2-10 を同フォーマットで、実機 dogfood 時に追記) ## 集計 From c253f8a5dc9196068de0af36b4e2d53ac51b7a73 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Mon, 27 Apr 2026 18:18:22 +0900 Subject: [PATCH 28/34] =?UTF-8?q?chore:=20ai-engine=20=E3=81=AE=20default?= =?UTF-8?q?=20port=20=E3=82=92=204000=20=E2=86=92=205050=20=E3=81=AB?= =?UTF-8?q?=E5=A4=89=E6=9B=B4=20(ark=20=E7=AD=89=E3=81=A8=E3=81=AE?= =?UTF-8?q?=E8=A1=9D=E7=AA=81=E5=9B=9E=E9=81=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4000/4001 ポートが他プロジェクト (ark) と衝突しがちなので default を 5050 に。 env override (AI_ENGINE_PORT / NEXT_PUBLIC_AI_ENGINE_URL) は引き続き有効。 - packages/ai-engine/src/config.ts: default port 5050 - packages/ai-engine/src/config.test.ts: 期待値 5050 - packages/frontend/src/lib/ws.ts: default URL ws://localhost:5050 (2 箇所) - .env.example: コメントと値を 5050 に - README.md: ai-engine の URL 表記を 5050 に --- .env.example | 9 +++++---- README.md | 2 +- packages/ai-engine/src/config.test.ts | 4 ++-- packages/ai-engine/src/config.ts | 4 +++- packages/frontend/next-env.d.ts | 2 +- packages/frontend/src/lib/ws.ts | 4 ++-- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index bc2f3c7..cd8fa1a 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,9 @@ -# Tally AI Engine の WebSocket ポート (任意、デフォルト 4000) -AI_ENGINE_PORT=4000 +# Tally AI Engine の WebSocket ポート (任意、デフォルト 5050) +# 4000/4001 は ark 等の他プロジェクトと衝突しがちなので避ける。 +AI_ENGINE_PORT=5050 -# フロントエンドが AI Engine に接続する URL (任意、デフォルト ws://localhost:4000) -# NEXT_PUBLIC_AI_ENGINE_URL=ws://localhost:4000 +# フロントエンドが AI Engine に接続する URL (任意、デフォルト ws://localhost:5050) +# NEXT_PUBLIC_AI_ENGINE_URL=ws://localhost:5050 # プロジェクトデータの保存先 (開発用) # 実運用では対象リポジトリ配下の .tally/ を使う diff --git a/README.md b/README.md index 79b5d82..73b64a6 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ pnpm dev ``` - frontend: http://localhost:3000 -- ai-engine: ws://localhost:4000/agent +- ai-engine: ws://localhost:5050/agent `pnpm dev` は `pnpm -r --parallel dev` を呼び、frontend (Next.js dev) と ai-engine (tsx watch) を並列起動する。 diff --git a/packages/ai-engine/src/config.test.ts b/packages/ai-engine/src/config.test.ts index 5b941cc..8b61bcc 100644 --- a/packages/ai-engine/src/config.test.ts +++ b/packages/ai-engine/src/config.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest'; import { loadConfig } from './config'; describe('loadConfig', () => { - it('デフォルト PORT は 4000', () => { + it('デフォルト PORT は 5050 (4000/4001 は ark 等と衝突するため避ける)', () => { const cfg = loadConfig({}); - expect(cfg.port).toBe(4000); + expect(cfg.port).toBe(5050); }); it('AI_ENGINE_PORT を解釈する', () => { diff --git a/packages/ai-engine/src/config.ts b/packages/ai-engine/src/config.ts index 43ce3dc..86a2f54 100644 --- a/packages/ai-engine/src/config.ts +++ b/packages/ai-engine/src/config.ts @@ -6,7 +6,9 @@ export interface AiEngineConfig { // 認証情報は扱わない (Claude Code OAuth トークンを SDK が暗黙で拾う)。 export function loadConfig(env: NodeJS.ProcessEnv): AiEngineConfig { const raw = env.AI_ENGINE_PORT; - if (raw === undefined || raw === '') return { port: 4000 }; + // default 5050: 4000/4001 が他プロジェクト (ark 等) と衝突しがちなので避ける。 + // env AI_ENGINE_PORT で上書き可能。 + if (raw === undefined || raw === '') return { port: 5050 }; const n = Number.parseInt(raw, 10); if (!Number.isFinite(n) || n <= 0) { throw new Error(`AI_ENGINE_PORT が不正: ${raw}`); diff --git a/packages/frontend/next-env.d.ts b/packages/frontend/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/packages/frontend/next-env.d.ts +++ b/packages/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/frontend/src/lib/ws.ts b/packages/frontend/src/lib/ws.ts index 032b208..a12ec70 100644 --- a/packages/frontend/src/lib/ws.ts +++ b/packages/frontend/src/lib/ws.ts @@ -20,7 +20,7 @@ export interface AgentHandle { // WS ベースの agent 呼び出し。受信した NDJSON を AgentEvent の AsyncIterable に変換する。 // close() で接続を明示的に終わらせる。サーバ側が close したら AsyncIterable も終了する。 export function startAgent(opts: StartAgentOptions): AgentHandle { - const url = opts.url ?? process.env.NEXT_PUBLIC_AI_ENGINE_URL ?? 'ws://localhost:4000'; + const url = opts.url ?? process.env.NEXT_PUBLIC_AI_ENGINE_URL ?? 'ws://localhost:5050'; const ws = new WebSocket(`${url}/agent`); const buf: AgentEvent[] = []; @@ -107,7 +107,7 @@ export interface OpenChatOptions { // sendUserMessage / approveTool を任意のタイミングで呼ぶ。 // サーバが close した場合は events も終了する。 export function openChat(opts: OpenChatOptions): ChatHandle { - const url = opts.url ?? process.env.NEXT_PUBLIC_AI_ENGINE_URL ?? 'ws://localhost:4000'; + const url = opts.url ?? process.env.NEXT_PUBLIC_AI_ENGINE_URL ?? 'ws://localhost:5050'; const ws = new WebSocket(`${url}/chat`); const buf: ChatEvent[] = []; From 265a43cea026c82b595d6479417afaca71b7c038 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Tue, 28 Apr 2026 15:24:40 +0900 Subject: [PATCH 29/34] =?UTF-8?q?fix(storage):=20YAML=20=E6=B0=B8=E7=B6=9A?= =?UTF-8?q?=E5=8C=96=E3=81=A7=20flow=E2=86=92block=20=E3=82=92=E5=BC=B7?= =?UTF-8?q?=E5=88=B6=E3=81=97=20chat=20=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E7=A0=B4=E6=90=8D=E3=82=92=E9=98=B2=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 空配列 (`messages: []`) を初回書き込みすると yaml lib は flow style `[]` で 出力する。次回書き込み時に既存 seq の flow=true が残ったまま新しい map を 追加すると、フロー集約の中に複数行 string の block scalar が混在して 再パース不能な YAML が生成されていた (Atlassian MCP 連携で複数行応答が 来始めて顕在化)。 - packages/storage/src/yaml.ts: forceBlockStyle ヘルパで Map/Seq の flow フラグを再帰的にオフ。mergeSeqById と doc.set の両経路で適用。 - packages/storage/src/chat-store.ts: listChats を 1 ファイルずつ try/catch にし、破損 1 件で全 chat 一覧が 500 で死ぬのを回避 (warn は残す)。 - yaml.test.ts: 空 seq → 複数行 string 追加が再パース可能であることを regression として固定。 --- packages/storage/src/chat-store.ts | 25 +++++++++++++------- packages/storage/src/yaml.test.ts | 38 ++++++++++++++++++++++++++++++ packages/storage/src/yaml.ts | 22 +++++++++++++++++ 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/packages/storage/src/chat-store.ts b/packages/storage/src/chat-store.ts index 56bf4c5..99cc0c6 100644 --- a/packages/storage/src/chat-store.ts +++ b/packages/storage/src/chat-store.ts @@ -91,15 +91,22 @@ export class FileSystemChatStore implements ChatStore { const yamlFiles = entries.filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')); const threads = await Promise.all( yamlFiles.map(async (file) => { - const t = await readYaml(path.join(this.paths.chatsDir, file), ChatThreadSchema); - if (!t) return null; - return { - id: t.id, - projectId: t.projectId, - title: t.title, - createdAt: t.createdAt, - updatedAt: t.updatedAt, - } satisfies ChatThreadMeta; + // 1 ファイルが壊れていても他を表示できるように個別 try/catch。 + // 黙って捨てると気付かないので warn は出す。 + try { + const t = await readYaml(path.join(this.paths.chatsDir, file), ChatThreadSchema); + if (!t) return null; + return { + id: t.id, + projectId: t.projectId, + title: t.title, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + } satisfies ChatThreadMeta; + } catch (err) { + console.warn(`[chat-store] skip broken chat file: ${file}`, err); + return null; + } }), ); return threads diff --git a/packages/storage/src/yaml.test.ts b/packages/storage/src/yaml.test.ts index 8eb483d..7f95384 100644 --- a/packages/storage/src/yaml.test.ts +++ b/packages/storage/src/yaml.test.ts @@ -148,6 +148,44 @@ name: old }); }); + describe('writeYaml flow→block 強制', () => { + // regression: 空配列 (`messages: []`) を初回書き込みすると yaml lib は flow style で + // 出力する。次回書き込み時、既存 seq が flow=true のまま map を追加すると + // 「フロー集約の中にブロック scalar」が混在し再パースが破綻していた (chat YAML 破損)。 + it('id 配列に複数行 string を含む要素を追加しても再パース可能な YAML が出る', async () => { + const filePath = path.join(workspace, 'messages.yaml'); + // Step 1: 空配列で初回書き込み (yaml lib が `messages: []` flow style で書き出す) + await writeYaml(filePath, { + id: 'thread-1', + messages: [], + }); + // Step 2: 複数行 string を含む要素を追加 + await writeYaml(filePath, { + id: 'thread-1', + messages: [ + { + id: 'msg-1', + text: 'line one\n\nline two\n\nline three', + }, + ], + }); + // 再パースできれば fix 成立 + const re = await readYaml( + filePath, + z.object({ + id: z.string(), + messages: z.array(z.object({ id: z.string(), text: z.string() })), + }), + ); + expect(re?.messages).toHaveLength(1); + expect(re?.messages[0]?.text).toBe('line one\n\nline two\n\nline three'); + // 出力は block style (`- id: msg-1`) になっているべき + const raw = await fs.readFile(filePath, 'utf8'); + expect(raw).toContain('- id: msg-1'); + expect(raw).not.toMatch(/messages:\s*\[/); + }); + }); + describe('writeYaml + readYaml 往復', () => { it('書いて読み直せば元のデータと一致する', async () => { const filePath = path.join(workspace, 'roundtrip.yaml'); diff --git a/packages/storage/src/yaml.ts b/packages/storage/src/yaml.ts index 8cd53f3..2f6180b 100644 --- a/packages/storage/src/yaml.ts +++ b/packages/storage/src/yaml.ts @@ -78,6 +78,7 @@ function serializePreservingComments(doc: Document, data: Record & { id: string }> { @@ -124,6 +141,9 @@ function mergeSeqById( data: Array & { id: string }>, doc: Document, ): void { + // 既存 seq が flow style (`[]` で初期化された空配列由来) のままだと、 + // 中に追加する map が flow になり複数行 string と相性が悪い。block 強制。 + seq.flow = false; // 既存アイテムを id で引ける Map にする const existingById = new Map(); for (const item of seq.items) { @@ -140,9 +160,11 @@ function mergeSeqById( const existing = existingById.get(obj.id); if (existing) { updateMapInPlace(existing, obj, doc); + forceBlockStyle(existing); nextItems.push(existing); } else { const newNode = doc.createNode(obj); + forceBlockStyle(newNode); nextItems.push(newNode as YamlNode); } } From 031d70554dd267c2d69f842a8ab7427224c865f6 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Tue, 28 Apr 2026 15:25:09 +0900 Subject: [PATCH 30/34] =?UTF-8?q?feat(chat):=20OAuth=202.1=20auth=20?= =?UTF-8?q?=E3=83=95=E3=83=AD=E3=83=BC=E3=82=92=201=20=E7=AD=89=E5=9C=B0?= =?UTF-8?q?=E3=81=A7=E6=89=B1=E3=81=84=20ChatRunner=20=E3=82=92=20long-liv?= =?UTF-8?q?ed=20Query=20=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 外部 MCP (Atlassian Rovo 等) の OAuth 2.1 認証フローで以下を解消する: 1. 認証 URL とその後の callback URL paste 操作が assistant 文中に埋もれて UX が破綻していた問題 2. user_message のたびに sdk.query() を呼び直していたため SDK subprocess が 再生成され、MCP HTTP transport の OAuth state (PKCE / token) が turn 跨ぎ で消えてしまう問題 ## auth_request ブロックで OAuth UI を 1 等地化 - packages/core: ChatBlock に `auth_request` variant を追加 (mcpServerId / mcpServerLabel / authUrl / status / failureMessage) - packages/ai-engine/auth-detector.ts: `mcp____authenticate` / `complete_authentication` のパターン検出と auth URL 抽出 - chat-runner: tool_use を stash して tool_result とペアになった瞬間に auth_request ブロックへ変換 (raw な tool_use/tool_result は出さない)。 complete_authentication 受領時は同 mcpServerId の最新 pending を更新。 - frontend: `AuthRequestCard` を新設し、認証ボタン (新規タブ) と callback URL paste 入力を 1 カードに集約。chat-message から dispatch。 - store.ts: chat_auth_request イベントで pending append / completed・failed in-place 更新。 ## ChatRunner を long-lived Query 化 - packages/ai-engine/async-input.ts: push 可能な AsyncIterable。 - agent-runner.ts: SdkLike.query を `prompt: string | AsyncIterable<...>` に 拡張、SdkQueryHandle 型 (close 任意) を追加。 - chat-runner.ts: 1 ChatRunner = 1 sdk.query() = 1 subprocess に固定。 ensureQuery / runOutputLoop / dispatchSdkMessage / close を新設し、 runUserTurn は input.push + queue drain に簡素化。MCP ハンドラは currentTurn から assistantMsgId / emit を解決する。 - server.ts: WS close 時に runner.close() で SDK subprocess を片付ける。 注: 実 SDK の Query iter が turn 単位で切れる挙動を持つ場合 OAuth state が それでも復元されないケースがあるため、debug 用 console.log を残してある。 本番化時に削除予定。 --- packages/ai-engine/src/agent-runner.ts | 22 +- packages/ai-engine/src/async-input.test.ts | 64 +++ packages/ai-engine/src/async-input.ts | 59 ++ packages/ai-engine/src/auth-detector.test.ts | 67 +++ packages/ai-engine/src/auth-detector.ts | 34 ++ packages/ai-engine/src/chat-runner.test.ts | 324 ++++++++++- packages/ai-engine/src/chat-runner.ts | 538 ++++++++++++++---- packages/ai-engine/src/server.ts | 12 + packages/ai-engine/src/stream.ts | 12 + packages/core/src/schema.test.ts | 31 + packages/core/src/schema.ts | 14 + .../chat/auth-request-card.test.tsx | 110 ++++ .../src/components/chat/auth-request-card.tsx | 224 ++++++++ .../src/components/chat/chat-message.tsx | 4 + packages/frontend/src/lib/store.ts | 50 ++ 15 files changed, 1441 insertions(+), 124 deletions(-) create mode 100644 packages/ai-engine/src/async-input.test.ts create mode 100644 packages/ai-engine/src/async-input.ts create mode 100644 packages/ai-engine/src/auth-detector.test.ts create mode 100644 packages/ai-engine/src/auth-detector.ts create mode 100644 packages/frontend/src/components/chat/auth-request-card.test.tsx create mode 100644 packages/frontend/src/components/chat/auth-request-card.tsx diff --git a/packages/ai-engine/src/agent-runner.ts b/packages/ai-engine/src/agent-runner.ts index fd94972..353d66c 100644 --- a/packages/ai-engine/src/agent-runner.ts +++ b/packages/ai-engine/src/agent-runner.ts @@ -19,9 +19,27 @@ export interface StartRequest { // テスト時に mockSdk を差し込めるようにするため。 // SDK 実体のシグネチャは `query({ prompt, options })` なので、systemPrompt / mcpServers / // allowedTools / cwd / settingSources / permissionMode はすべて options 内に入れる必要がある。 +// Streaming input mode 用の最小 SDKUserMessage 形状 (実 SDK の SDKUserMessage を duck-type 化)。 +// MCP HTTP transport の OAuth 状態を turn 跨ぎで保持したい場合は、 +// 1 query に AsyncIterable を渡し続ける必要がある。 +export interface SdkUserMessageLike { + type: 'user'; + message: { role: 'user'; content: string }; + parent_tool_use_id: null; + session_id?: string; +} + +// SDK Query は AsyncIterable + 任意の close() を持つハンドル。 +// 実 SDK の Query 型 (interrupt / setMcpServers / streamInput / close) のうち、 +// chat-runner が触るのは close のみなので最小化して受ける。 +export interface SdkQueryHandle extends AsyncIterable { + close?(): void; +} + export interface SdkLike { query(opts: { - prompt: string; + // 単発 (agent-runner) は文字列、chat (multi-turn) は AsyncIterable で push 流す。 + prompt: string | AsyncIterable; options?: { systemPrompt?: string; mcpServers?: Record; @@ -43,7 +61,7 @@ export interface SdkLike { // 解決に失敗するケースがある。明示的にシステムの claude CLI パスを渡すと回避できる。 pathToClaudeCodeExecutable?: string; }; - }): AsyncIterable; + }): SdkQueryHandle; } export interface RunAgentDeps { diff --git a/packages/ai-engine/src/async-input.test.ts b/packages/ai-engine/src/async-input.test.ts new file mode 100644 index 0000000..850e9e1 --- /dev/null +++ b/packages/ai-engine/src/async-input.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { AsyncIterableInput } from './async-input'; + +describe('AsyncIterableInput', () => { + it('push 後に for-await で順番に取り出せる', async () => { + const input = new AsyncIterableInput(); + input.push(1); + input.push(2); + input.push(3); + input.close(); + const got: number[] = []; + for await (const v of input.iterable()) got.push(v); + expect(got).toEqual([1, 2, 3]); + }); + + it('iter が空の状態で next() を待ち、後の push で解決される', async () => { + const input = new AsyncIterableInput(); + const it = input.iterable()[Symbol.asyncIterator](); + const p = it.next(); + // 待機状態を確認: 即時 resolve しない + let resolved = false; + p.then(() => { + resolved = true; + }); + await new Promise((r) => setTimeout(r, 5)); + expect(resolved).toBe(false); + input.push('hi'); + const r = await p; + expect(r).toEqual({ value: 'hi', done: false }); + }); + + it('close() で待機中の next() が done: true で解決される', async () => { + const input = new AsyncIterableInput(); + const it = input.iterable()[Symbol.asyncIterator](); + const p = it.next(); + input.close(); + const r = await p; + expect(r.done).toBe(true); + }); + + it('close 後の push は無視される', async () => { + const input = new AsyncIterableInput(); + input.push(1); + input.close(); + input.push(99); // 無視 + const got: number[] = []; + for await (const v of input.iterable()) got.push(v); + expect(got).toEqual([1]); + }); + + it('iterator.return() で残りの push が消費されず終了', async () => { + const input = new AsyncIterableInput(); + input.push(1); + input.push(2); + const it = input.iterable()[Symbol.asyncIterator](); + const r1 = await it.next(); + expect(r1.value).toBe(1); + if (it.return) { + const r2 = await it.return(); + expect(r2.done).toBe(true); + } + }); +}); diff --git a/packages/ai-engine/src/async-input.ts b/packages/ai-engine/src/async-input.ts new file mode 100644 index 0000000..e8b17ff --- /dev/null +++ b/packages/ai-engine/src/async-input.ts @@ -0,0 +1,59 @@ +// SDK の query({ prompt: AsyncIterable }) に流す、 +// 後から push できる AsyncIterable 実装。 +// 1 chat thread = 1 long-lived sdk.query() を実現するため、user message を +// 任意のタイミングで投入し、close で iter を終わらせる。 +// +// 実装方針: バッファ + waiter の二段構え。consumer (SDK) が next を呼んだ瞬間に +// バッファがあれば即返す。空なら 1 回限りの resolver を保留 (背圧に近い形)。 +// 再 push でその resolver を解決する。 +export class AsyncIterableInput { + private buf: T[] = []; + private waiter: ((r: IteratorResult) => void) | null = null; + private finished = false; + + push(value: T): void { + if (this.finished) return; + const w = this.waiter; + if (w) { + this.waiter = null; + w({ value, done: false }); + return; + } + this.buf.push(value); + } + + close(): void { + if (this.finished) return; + this.finished = true; + const w = this.waiter; + if (w) { + this.waiter = null; + w({ value: undefined as never, done: true }); + } + } + + // SDK 等に渡す iter。同一インスタンスから複数回 [Symbol.asyncIterator] を取られる + // ことは想定しない (本パッケージでは 1 query に 1 input)。 + iterable(): AsyncIterable { + return { + [Symbol.asyncIterator]: () => ({ + next: (): Promise> => { + if (this.buf.length > 0) { + const v = this.buf.shift() as T; + return Promise.resolve({ value: v, done: false }); + } + if (this.finished) { + return Promise.resolve({ value: undefined as never, done: true }); + } + return new Promise>((resolve) => { + this.waiter = resolve; + }); + }, + return: (): Promise> => { + this.close(); + return Promise.resolve({ value: undefined as never, done: true }); + }, + }), + }; + } +} diff --git a/packages/ai-engine/src/auth-detector.test.ts b/packages/ai-engine/src/auth-detector.test.ts new file mode 100644 index 0000000..a2dbc2d --- /dev/null +++ b/packages/ai-engine/src/auth-detector.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { extractAuthUrl, parseAuthToolName } from './auth-detector'; + +describe('parseAuthToolName', () => { + it('mcp__atlassian__authenticate を分解', () => { + expect(parseAuthToolName('mcp__atlassian__authenticate')).toEqual({ + mcpServerId: 'atlassian', + kind: 'authenticate', + }); + }); + + it('mcp__atlassian__complete_authentication を分解', () => { + expect(parseAuthToolName('mcp__atlassian__complete_authentication')).toEqual({ + mcpServerId: 'atlassian', + kind: 'complete_authentication', + }); + }); + + it('別 id でも動く (jira-cloud 等のハイフン許容)', () => { + expect(parseAuthToolName('mcp__jira-cloud__authenticate')).toEqual({ + mcpServerId: 'jira-cloud', + kind: 'authenticate', + }); + }); + + it('Tally 内部 MCP は match しない', () => { + expect(parseAuthToolName('mcp__tally__create_node')).toBeNull(); + }); + + it('別ツール名 (read_issue など) は match しない', () => { + expect(parseAuthToolName('mcp__atlassian__read_issue')).toBeNull(); + }); + + it('id が大文字を含むと reject', () => { + expect(parseAuthToolName('mcp__Atlassian__authenticate')).toBeNull(); + }); +}); + +describe('extractAuthUrl', () => { + it('SDK 標準 output 形式から URL を抽出', () => { + const out = `Ask the user to open this URL in their browser to authorize the atlassian MCP server: + +https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz + +Once they complete the flow, the server's tools will become available automatically.`; + expect(extractAuthUrl(out)).toBe( + 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz', + ); + }); + + it('折り返し (`\\\\\\n` + 空白) も復元してから抽出', () => { + const out = + 'Ask the user: https://mcp.atlassian.com/v1/authorize?response_type=code&cli\\\n ent_id=abc&state=xyz_done'; + expect(extractAuthUrl(out)).toBe( + 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz_done', + ); + }); + + it('クエリ文字列なしの URL は無視 (説明用 https://example.com 等)', () => { + expect(extractAuthUrl('See https://example.com for more info')).toBeNull(); + }); + + it('URL が無ければ null', () => { + expect(extractAuthUrl('no url here')).toBeNull(); + }); +}); diff --git a/packages/ai-engine/src/auth-detector.ts b/packages/ai-engine/src/auth-detector.ts new file mode 100644 index 0000000..5b991c8 --- /dev/null +++ b/packages/ai-engine/src/auth-detector.ts @@ -0,0 +1,34 @@ +// 外部 MCP の OAuth 2.1 認証フローを検出するヘルパ。 +// chat-runner が SDK から流れてくる tool_use / tool_result を walk しながら +// authenticate / complete_authentication をパターンで識別し、 +// auth_request ブロックに変換する判断材料を提供する。 + +const AUTH_TOOL_NAME_RE = /^mcp__([a-z][a-z0-9-]{0,31})__(authenticate|complete_authentication)$/; + +export interface AuthToolNameMatch { + mcpServerId: string; + kind: 'authenticate' | 'complete_authentication'; +} + +// `mcp____authenticate` / `mcp____complete_authentication` を分解する。 +// id 部は McpServerIdRegex (core schema 側) と整合: 先頭英小文字 + 英小文字/数字/ハイフン、32 字以内。 +export function parseAuthToolName(name: string): AuthToolNameMatch | null { + const m = name.match(AUTH_TOOL_NAME_RE); + if (!m) return null; + return { mcpServerId: m[1] ?? '', kind: m[2] as 'authenticate' | 'complete_authentication' }; +} + +// authenticate tool_result.output に含まれる OAuth 認可エンドポイントの URL を抽出する。 +// SDK の典型的な出力例: +// "Ask the user to open this URL ... https://mcp.atlassian.com/v1/authorize?..." +// URL は折り返されている (`\<改行>` でエスケープされていることもある) ので +// 復元してから正規表現を当てる。 +export function extractAuthUrl(output: string): string | null { + // SDK が 80 桁折返しで `\<改行 + 連続空白>` を入れる場合がある。これを潰す。 + const unfolded = output.replace(/\\\n\s*/g, ''); + // 最初に見つかった https://...?...&... を採用。query string を含むものに限定して + // 単なる説明用の URL (https://example.com 等) を引かないようにする。 + const urlRe = /https:\/\/[^\s)"'<>]+\?[^\s)"'<>]+/; + const m = unfolded.match(urlRe); + return m ? m[0] : null; +} diff --git a/packages/ai-engine/src/chat-runner.test.ts b/packages/ai-engine/src/chat-runner.test.ts index ca579a1..596812e 100644 --- a/packages/ai-engine/src/chat-runner.test.ts +++ b/packages/ai-engine/src/chat-runner.test.ts @@ -11,6 +11,27 @@ import type { SdkLike } from './agent-runner'; import { buildChatPrompt, ChatRunner, formatNodeForContext } from './chat-runner'; import type { ChatEvent, SdkMessageLike } from './stream'; +// long-lived Query 化に伴い prompt は AsyncIterable 型に変わった。 +// テスト側で「最初に push された user message の content」を読むためのヘルパ。 +// string で渡された場合 (互換) も同じ shape で扱えるようにする。 +function startCapturePromptText(prompt: unknown): { read: () => string } { + const captured = { value: '' }; + if (typeof prompt === 'string') { + captured.value = prompt; + } else if ( + prompt && + typeof (prompt as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function' + ) { + const it = (prompt as AsyncIterable<{ message?: { content?: string } }>)[ + Symbol.asyncIterator + ](); + it.next().then((r) => { + if (!r.done && r.value?.message?.content) captured.value = r.value.message.content; + }); + } + return { read: () => captured.value }; +} + describe('ChatRunner', () => { let root: string; @@ -194,10 +215,10 @@ describe('ChatRunner', () => { priority: 'must', })) as Node; - let capturedPrompt = ''; + let promptCapture: { read: () => string } = { read: () => '' }; const sdk: SdkLike = { - query: ({ prompt }: { prompt: string }) => { - capturedPrompt = prompt; + query: ({ prompt }: { prompt: unknown }) => { + promptCapture = startCapturePromptText(prompt); return (async function* () { yield { type: 'assistant', @@ -221,6 +242,7 @@ describe('ChatRunner', () => { events.push(e); } + const capturedPrompt = promptCapture.read(); expect(capturedPrompt).toContain(''); expect(capturedPrompt).toContain(`id: ${target.id}`); expect(capturedPrompt).toContain('type: requirement'); @@ -273,10 +295,10 @@ describe('ChatRunner', () => { body: '', })) as Node; - let capturedPrompt = ''; + let promptCapture: { read: () => string } = { read: () => '' }; const sdk: SdkLike = { - query: ({ prompt }: { prompt: string }) => { - capturedPrompt = prompt; + query: ({ prompt }: { prompt: unknown }) => { + promptCapture = startCapturePromptText(prompt); return (async function* () { yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; })(); @@ -295,6 +317,7 @@ describe('ChatRunner', () => { // drain } + const capturedPrompt = promptCapture.read(); const histIdx = capturedPrompt.indexOf(''); const histEndIdx = capturedPrompt.indexOf(''); const ctxIdx = capturedPrompt.indexOf(''); @@ -325,10 +348,10 @@ describe('ChatRunner', () => { body: '', })) as Node; - let capturedPrompt = ''; + let promptCapture: { read: () => string } = { read: () => '' }; const sdk: SdkLike = { - query: ({ prompt }: { prompt: string }) => { - capturedPrompt = prompt; + query: ({ prompt }: { prompt: unknown }) => { + promptCapture = startCapturePromptText(prompt); return (async function* () { yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; })(); @@ -344,6 +367,7 @@ describe('ChatRunner', () => { for await (const _e of runner.runUserTurn('q', ['nonexistent', valid.id, 'also-gone'])) { // drain } + const capturedPrompt = promptCapture.read(); expect(capturedPrompt).toContain(''); expect(capturedPrompt).toContain(`id: ${valid.id}`); expect(capturedPrompt).not.toContain('id: nonexistent'); @@ -356,10 +380,10 @@ describe('ChatRunner', () => { const projectStore = new FileSystemProjectStore(root); const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); - let capturedPrompt = ''; + let promptCapture: { read: () => string } = { read: () => '' }; const sdk: SdkLike = { - query: ({ prompt }: { prompt: string }) => { - capturedPrompt = prompt; + query: ({ prompt }: { prompt: unknown }) => { + promptCapture = startCapturePromptText(prompt); return (async function* () { yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; })(); @@ -375,6 +399,7 @@ describe('ChatRunner', () => { for await (const _e of runner.runUserTurn('hello', [])) { // drain } + const capturedPrompt = promptCapture.read(); expect(capturedPrompt).not.toContain(''); // user 文字列自体は (履歴経由で) 必ず prompt に入る expect(capturedPrompt).toContain('hello'); @@ -1123,3 +1148,278 @@ describe('buildChatPrompt — tool_use/tool_result replay (Task 14, T4 fix)', () expect(prompt).toContain('初回'); }); }); + +// 外部 MCP の OAuth 2.1 フロー: authenticate / complete_authentication tool_use を +// 検出して auth_request ブロックに変換する経路の検証。raw な tool_use/tool_result が +// チャット履歴に並ばず、UI が描画する auth_request 1 等地ブロックだけ残る。 +describe('ChatRunner — auth_request 変換 (OAuth 2.1)', () => { + async function setup() { + const root = mkdtempSync(path.join(tmpdir(), 'tally-chat-auth-')); + const ps = new FileSystemProjectStore(root); + await ps.saveProjectMeta({ + id: 'proj-1', + name: 'P', + codebases: [], + mcpServers: [ + { + id: 'atlassian', + name: 'My Atlassian', + kind: 'atlassian', + url: 'https://t.test/mcp', + options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, + }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + const chatStore = new FileSystemChatStore(root); + const projectStore = new FileSystemProjectStore(root); + const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); + return { root, chatStore, projectStore, thread }; + } + + function makeAuthSdk(authUrl: string): SdkLike { + return { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { type: 'text', text: '認証フローを開始します' }, + { + type: 'tool_use', + id: 'auth-tu-1', + name: 'mcp__atlassian__authenticate', + input: {}, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'auth-tu-1', + content: [{ type: 'text', text: `Open: ${authUrl}` }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; + })(), + }; + } + + it('authenticate: tool_use/tool_result を消化し、auth_request{pending} ブロック + chat_auth_request event を出す', async () => { + const { root, chatStore, projectStore, thread } = await setup(); + try { + const authUrl = + 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz'; + const runner = new ChatRunner({ + sdk: makeAuthSdk(authUrl), + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner.runUserTurn('jira を読んで')) events.push(e); + + // raw な tool_use / tool_result event は出ない (auth は auth_request 1 等地) + expect(events.find((e) => e.type === 'chat_tool_external_use')).toBeUndefined(); + expect(events.find((e) => e.type === 'chat_tool_external_result')).toBeUndefined(); + + const authEvt = events.find((e) => e.type === 'chat_auth_request'); + expect(authEvt).toBeDefined(); + if (authEvt && authEvt.type === 'chat_auth_request') { + expect(authEvt.mcpServerId).toBe('atlassian'); + expect(authEvt.mcpServerLabel).toBe('My Atlassian'); + expect(authEvt.authUrl).toBe(authUrl); + expect(authEvt.status).toBe('pending'); + } + + // 永続化: assistant message に auth_request ブロックがあって、tool_use/tool_result は無い + const reloaded = await chatStore.getChat(thread.id); + const assistant = reloaded?.messages.find((m) => m.role === 'assistant'); + const blocks = assistant?.blocks ?? []; + const hasRawToolUse = blocks.some( + (b) => b.type === 'tool_use' && b.name.includes('authenticate'), + ); + expect(hasRawToolUse).toBe(false); + const authBlock = blocks.find((b) => b.type === 'auth_request'); + expect(authBlock).toBeDefined(); + if (authBlock && authBlock.type === 'auth_request') { + expect(authBlock.status).toBe('pending'); + expect(authBlock.authUrl).toBe(authUrl); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('complete_authentication 成功: 同 thread の最新 pending auth_request が completed に更新される', async () => { + const { root, chatStore, projectStore, thread } = await setup(); + try { + const authUrl = + 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz'; + // turn 1: authenticate を流して pending auth_request を作る + const runner1 = new ChatRunner({ + sdk: makeAuthSdk(authUrl), + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner1.runUserTurn('jira を読んで')) { + void _; + } + + // turn 2: complete_authentication が走るシナリオ + const sdk2: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'auth-tu-2', + name: 'mcp__atlassian__complete_authentication', + input: { url: 'http://localhost:54801/callback?code=xxx&state=xyz' }, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'auth-tu-2', + content: [{ type: 'text', text: 'authenticated' }], + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'result', + subtype: 'success', + result: 'done', + } as unknown as SdkMessageLike; + })(), + }; + const runner2 = new ChatRunner({ + sdk: sdk2, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner2.runUserTurn( + '[OAuth callback] http://localhost:54801/callback?code=xxx&state=xyz', + )) + events.push(e); + + const authEvt = events.find((e) => e.type === 'chat_auth_request'); + expect(authEvt).toBeDefined(); + if (authEvt && authEvt.type === 'chat_auth_request') { + expect(authEvt.status).toBe('completed'); + expect(authEvt.mcpServerId).toBe('atlassian'); + } + + // 永続化: 元の pending auth_request ブロックが completed に書き換わっている + const reloaded = await chatStore.getChat(thread.id); + const allAuthBlocks = (reloaded?.messages ?? []).flatMap((m) => + m.blocks.filter((b) => b.type === 'auth_request'), + ); + // 同 server の auth_request は 1 個のままで、status が completed に変わっている + expect(allAuthBlocks).toHaveLength(1); + const ab = allAuthBlocks[0]; + if (ab && ab.type === 'auth_request') { + expect(ab.status).toBe('completed'); + expect(ab.authUrl).toBe(authUrl); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('complete_authentication 失敗 (ok=false): 最新 pending が failed + failureMessage 付きで更新', async () => { + const { root, chatStore, projectStore, thread } = await setup(); + try { + const authUrl = + 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz'; + const runner1 = new ChatRunner({ + sdk: makeAuthSdk(authUrl), + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + for await (const _ of runner1.runUserTurn('jira を読んで')) { + void _; + } + + const sdk2: SdkLike = { + query: () => + (async function* () { + yield { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'auth-tu-2', + name: 'mcp__atlassian__complete_authentication', + input: { url: 'http://localhost:54801/callback?code=bad' }, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'auth-tu-2', + content: [{ type: 'text', text: 'invalid_grant: state mismatch' }], + is_error: true, + }, + ], + }, + } as unknown as SdkMessageLike; + yield { + type: 'result', + subtype: 'success', + result: 'done', + } as unknown as SdkMessageLike; + })(), + }; + const runner2 = new ChatRunner({ + sdk: sdk2, + chatStore, + projectStore, + projectDir: root, + threadId: thread.id, + }); + const events: ChatEvent[] = []; + for await (const e of runner2.runUserTurn('callback URL: ...')) events.push(e); + + const authEvt = events.find((e) => e.type === 'chat_auth_request'); + expect(authEvt).toBeDefined(); + if (authEvt && authEvt.type === 'chat_auth_request') { + expect(authEvt.status).toBe('failed'); + expect(authEvt.failureMessage).toContain('invalid_grant'); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/ai-engine/src/chat-runner.ts b/packages/ai-engine/src/chat-runner.ts index 0c71a65..57fdd61 100644 --- a/packages/ai-engine/src/chat-runner.ts +++ b/packages/ai-engine/src/chat-runner.ts @@ -8,7 +8,9 @@ import { } from '@tally/core'; import type { ChatStore, ProjectStore } from '@tally/storage'; -import type { SdkLike } from './agent-runner'; +import type { SdkLike, SdkQueryHandle, SdkUserMessageLike } from './agent-runner'; +import { AsyncIterableInput } from './async-input'; +import { type AuthToolNameMatch, extractAuthUrl, parseAuthToolName } from './auth-detector'; import { buildMcpServers } from './mcp/build-mcp-servers'; import { redactMcpSecrets } from './mcp/redact'; import type { ChatEvent, SdkMessageLike } from './stream'; @@ -39,6 +41,36 @@ function truncateForPersistence(output: string): string { return `${head}\n... (truncated, ${output.length} chars total)`; } +// 最新の pending auth_request ブロックを探す (同一 mcpServerId 限定)。 +// thread.messages を末尾から走査し、最初に見つかった pending を返す。 +// 同一 server に対する直近の認証フローのみを更新対象にして、過去に completed/failed で +// 終わったブロックには触らない方針。 +function findLatestPendingAuthRequest( + messages: ChatMessage[], + mcpServerId: string, +): { + messageId: string; + blockIndex: number; + block: Extract; +} | null { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (!m) continue; + for (let j = m.blocks.length - 1; j >= 0; j--) { + const b = m.blocks[j]; + if ( + b && + b.type === 'auth_request' && + b.mcpServerId === mcpServerId && + b.status === 'pending' + ) { + return { messageId: m.id, blockIndex: j, block: b }; + } + } + } + return null; +} + // SDK の assistant / user message から抽出する block の単純化形。 // Tally MCP の tool_use は MCP intercept 経路で処理されるので拾わない。 // 外部 MCP (mcp__tally__ 以外) の tool_use / tool_result は永続化と UI 通知のためここで拾う (Task 12)。 @@ -66,11 +98,40 @@ export interface ToolEntry { // - tool 呼び出しは createSdkMcpServer で登録した MCP 経由でのみ行う。 // MCP ハンドラ内で invokeInterceptedTool を呼び、pending → 承認 → 実行 → result を完結させる。 // SDK 側から見ると通常の tool 呼び出し (同期的に output を返す) に見える。 +// 1 user turn の間だけ生きる mutable state。 +// long-lived Query から流れてくる SDK メッセージを「今どの assistant message に紐付けるか」 +// 「どの queue に流すか」を解決するためのコンテキスト。 +// turn と turn の間 (= ユーザーが次の user message を送るまで) は null。 +interface TurnState { + assistantMsgId: string; + queue: EventQueue; + textBuffer: string[]; + // OAuth 認証フロー検出用 stash (tool_use 受信 → tool_result 到達時に auth_request に変換)。 + stashedAuthUses: Map; + // 外部 MCP の id → name の即引き map (label 表示用、turn ごとに固定で再構築しない)。 + externalConfigById: Map; +} + export class ChatRunner { private readonly deps: ChatRunnerDeps; // 承認待ちの Promise resolver。ui-toolUseId → (approved) => void。 private readonly pendingApprovals = new Map void>(); + // long-lived SDK Query。1 ChatRunner = 1 sdk.query() = 1 subprocess に固定して + // MCP HTTP transport の OAuth 状態 (PKCE / token) を turn 跨ぎで保持する。 + // null = まだ start していない。closed 状態になっても close() / 再 ensure で破棄して再開できる。 + private query: SdkQueryHandle | null = null; + private input: AsyncIterableInput | null = null; + private outputLoopDone: Promise | null = null; + private outputLoopFailed = false; + // 現在進行中の turn。runUserTurn の入口で set、出口で null。 + // MCP ハンドラと出力ループはここから assistantMsgId / queue を読む。 + private currentTurn: TurnState | null = null; + // ensureQuery が走った時に決まる、long-lived な externalConfig snapshot。 + // 再起動するまで mcpServers の入替えは反映しない (実 SDK は setMcpServers で動的更新できるが + // MVP では undo/redo を許さず、変更は次回 ChatRunner 起動から有効にする)。 + private cachedExternalConfigById: Map | null = null; + constructor(deps: ChatRunnerDeps) { this.deps = deps; } @@ -102,7 +163,7 @@ export class ChatRunner { // ProjectStore から該当ノードを引いて prompt の ブロックに埋め込む。 // 不在 ID は無視 (削除済みノード等)。永続化はせず、毎ターンの prompt prefix としてのみ使う。 async *runUserTurn(userText: string, contextNodeIds: string[] = []): AsyncGenerator { - const { sdk, chatStore, projectStore, projectDir, threadId } = this.deps; + const { chatStore, projectStore, threadId } = this.deps; const thread = await chatStore.getChat(threadId); if (!thread) { @@ -130,7 +191,6 @@ export class ChatRunner { const threadWithUser = await chatStore.getChat(threadId); const contextNodes = await loadContextNodes(projectStore, contextNodeIds); const prompt = buildChatPrompt(threadWithUser?.messages ?? [], contextNodes); - const systemPrompt = buildChatSystemPrompt(); // 3. 空の assistant message を append (後続の tool_use 即時永続化の親として必要) // prompt スナップショット後に行うことで、上記 buildChatPrompt の前提が崩れないようにする。 @@ -143,27 +203,26 @@ export class ChatRunner { }); yield { type: 'chat_assistant_message_started', messageId: assistantMsgId }; - // 4. MCP 経由で呼ばれる tool ハンドラ内で invokeInterceptedTool を回す。 - // MCP handler は SDK query を block するので、イベント emit は AsyncQueue 経由に分離する。 - // さもないと deadlock (SDK が MCP 応答待ち / MCP が承認待ち / 承認は UI 経由で queue flush が必要)。 + // 4. turn state を組み立てる。出力ループ (runOutputLoop) と MCP ハンドラはここから + // assistantMsgId / queue を読む。turn 中は this.currentTurn = 同じインスタンス。 + // ensureQuery より前に必ず set しておく — output loop が SDK メッセージを + // dispatch する瞬間に currentTurn が null だと取りこぼす (race)。 const queue = new EventQueue(); - const tools = this.buildToolRegistry(); - const emit = (e: ChatEvent) => queue.push(e); - const mcp = this.buildMcpServer(tools, emit, assistantMsgId); + const turnState: TurnState = { + assistantMsgId, + queue, + textBuffer: [], + stashedAuthUses: new Map(), + externalConfigById: this.cachedExternalConfigById ?? new Map(), + }; + this.currentTurn = turnState; - // 4b. プロジェクト設定の mcpServers[] を Tally MCP と合成する (Task 11)。 - // 毎ターン読むことで env / 設定変更がホットリロードされる。 - // env 未設定 (PAT 等) は buildMcpServers が throw するので、ここで補足し - // error event を emit して early return する (sdk.query は呼ばない)。 - const projectMeta = await projectStore.getProjectMeta(); - const externalConfigs = projectMeta?.mcpServers ?? []; - let mcpServers: Record; - let allowedTools: string[]; + // 5. SDK Query (long-lived) を必要なら起動する。OAuth state を turn 跨ぎで保持するため + // 1 ChatRunner = 1 sdk.query()。失敗 (mcpServers 設定不正等) は error を yield して終わる。 try { - const built = buildMcpServers({ tallyMcp: mcp, configs: externalConfigs }); - mcpServers = built.mcpServers; - allowedTools = built.allowedTools; + await this.ensureQuery(); } catch (err) { + this.currentTurn = null; yield { type: 'error', code: 'mcp_config_invalid', @@ -171,105 +230,348 @@ export class ChatRunner { }; return; } + // ensureQuery が externalConfig を更新するので turnState の参照を最新へ差し替える。 + turnState.externalConfigById = this.cachedExternalConfigById ?? new Map(); - const textBuffer: string[] = []; + // 6. user message を SDK の input ストリームに push する。実 SDK は streaming input mode で + // 待機しており、ここで turn が始まる。出力ループが SDK 応答を queue に流し込む。 + if (this.input) { + this.input.push({ + type: 'user', + message: { role: 'user', content: prompt }, + parent_tool_use_id: null, + }); + } - // 5. SDK query をバックグラウンドで走らせ、queue にイベントを push する。 - // generator 側は queue をドレインして yield するだけ。 - const sdkDone = (async () => { - try { - const iter = sdk.query({ - prompt, - options: { - systemPrompt, - mcpServers, - tools: [], - allowedTools, - permissionMode: 'dontAsk', - settingSources: [], - cwd: projectDir, - ...(process.env.CLAUDE_CODE_PATH - ? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_PATH } - : {}), - }, - }); + // 7. queue をドレイン。chat_turn_ended が来たら今 turn は終わり。 + // MCP ハンドラから push される pending/result も同じ queue を通る。 + try { + while (true) { + const evt = await queue.next(); + if (evt === null) break; + yield evt; + if (evt.type === 'chat_turn_ended') break; + } + } finally { + this.currentTurn = null; + // queue は finish しない: 出力ループから pending な MCP イベントが遅延で push される + // 可能性は無いが、念のため明示的に終わらせる必要は無い (turn が抜けた後は誰も読まない)。 + } + } - for await (const msg of iter) { - console.log( - '[chat-runner] sdk msg:', - JSON.stringify(redactMcpSecrets(msg)).slice(0, 200), - ); - const blocks = extractAssistantBlocks(msg); - for (const b of blocks) { - if (b.type === 'text') { - textBuffer.push(b.text); - queue.push({ type: 'chat_text_delta', messageId: assistantMsgId, text: b.text }); - } else if (b.type === 'tool_use') { - // 外部 MCP の tool_use: source='external' で永続化、承認 UI なし (Task 12)。 - await chatStore.appendBlockToMessage(threadId, assistantMsgId, { - type: 'tool_use', - toolUseId: b.toolUseId, - name: b.name, - input: b.input, - source: 'external', - }); - queue.push({ - type: 'chat_tool_external_use', - messageId: assistantMsgId, - toolUseId: b.toolUseId, - name: b.name, - input: b.input, - }); - } else if (b.type === 'tool_result') { - // Task 13: 大規模 epic で tool_result が 500KB+ になり得るので、 - // YAML 永続化は 4KB に切り詰める。event はフル (UI はメモリ内で全文展開可)。 - await chatStore.appendBlockToMessage(threadId, assistantMsgId, { - type: 'tool_result', - toolUseId: b.toolUseId, - ok: b.ok, - output: truncateForPersistence(b.output), - }); - queue.push({ - type: 'chat_tool_external_result', - messageId: assistantMsgId, - toolUseId: b.toolUseId, - ok: b.ok, - output: b.output, - }); - } - } + // SDK query を 1 度だけ立ち上げ、出力ループをバックグラウンドで走らせる。 + // close() / iter 終端 / 例外で query が死んでいる場合は次回呼び出しで再起動する。 + // throw する条件: project mcpServers の設定不正 (URL 無効等) — 上位で error event 化される。 + private async ensureQuery(): Promise { + if (this.query && !this.outputLoopFailed) { + console.log('[chat-runner] ensureQuery: reuse existing query'); + return; + } + console.log( + '[chat-runner] ensureQuery: creating new query (failed?', + this.outputLoopFailed, + ')', + ); + // 既存が死んでいるなら片付けてから作り直す。 + this.tearDownQuery(); + + const { sdk, projectStore, projectDir } = this.deps; + const projectMeta = await projectStore.getProjectMeta(); + const externalConfigs = projectMeta?.mcpServers ?? []; + + const externalConfigById = new Map(); + for (const c of externalConfigs) externalConfigById.set(c.id, c.name); + this.cachedExternalConfigById = externalConfigById; + + const tools = this.buildToolRegistry(); + const mcp = this.buildMcpServer(tools); + const built = buildMcpServers({ tallyMcp: mcp, configs: externalConfigs }); + + const input = new AsyncIterableInput(); + this.input = input; + + const query = sdk.query({ + prompt: input.iterable(), + options: { + systemPrompt: buildChatSystemPrompt(), + mcpServers: built.mcpServers, + tools: [], + allowedTools: built.allowedTools, + permissionMode: 'dontAsk', + settingSources: [], + cwd: projectDir, + ...(process.env.CLAUDE_CODE_PATH + ? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_PATH } + : {}), + }, + }); + this.query = query; + this.outputLoopFailed = false; + this.outputLoopDone = this.runOutputLoop(query); + } + + // SDK query から流れてくる SDKMessage を進行中 turn の queue に振り分ける。 + // turn 終端は SDKResultMessage (type: 'result') の到達で判定し、chat_turn_ended を emit する。 + // iter が終わった (= subprocess 死亡 / close()) ときは進行中 turn にもエラーを通知して終わらせる。 + private async runOutputLoop(query: SdkQueryHandle): Promise { + console.log('[chat-runner] runOutputLoop: started'); + try { + for await (const msg of query) { + console.log('[chat-runner] sdk msg:', JSON.stringify(redactMcpSecrets(msg)).slice(0, 200)); + await this.dispatchSdkMessage(msg); + } + } catch (err) { + console.log('[chat-runner] runOutputLoop: error', err); + this.outputLoopFailed = true; + const turn = this.currentTurn; + if (turn) { + turn.queue.push({ type: 'error', code: 'agent_failed', message: String(err) }); + turn.queue.push({ type: 'chat_turn_ended' }); + turn.queue.finish(); + } + return; + } + console.log( + '[chat-runner] runOutputLoop: iter ended (currentTurn?', + this.currentTurn !== null, + ')', + ); + // iter 正常終端 (close 等)。進行中 turn が残っていれば打ち切る。 + // 同時に query が死んだ印として outputLoopFailed を立て、次回 ensureQuery で作り直させる。 + this.outputLoopFailed = true; + const turn = this.currentTurn; + if (turn) { + turn.queue.push({ type: 'chat_turn_ended' }); + turn.queue.finish(); + } + } + + // 1 つの SDKMessage を処理する。turn が無ければ捨てる。 + private async dispatchSdkMessage(msg: SdkMessageLike): Promise { + const turn = this.currentTurn; + if (!turn) return; + const { chatStore, threadId } = this.deps; + const { assistantMsgId, queue, textBuffer, stashedAuthUses, externalConfigById } = turn; + + // result message: turn 終了 + const m = msg as unknown as { type?: string; subtype?: string }; + if (m.type === 'result') { + // text blocks を assistant message の先頭に統合 (tool_use/result は intercept 経路で既に append 済み) + if (textBuffer.length > 0) { + const current = await chatStore.getChat(threadId); + const target = current?.messages.find((m2) => m2.id === assistantMsgId); + if (current && target) { + const textBlocks: ChatBlock[] = textBuffer.map((t) => ({ type: 'text', text: t })); + await chatStore.replaceMessageBlocks(threadId, assistantMsgId, [ + ...textBlocks, + ...target.blocks, + ]); } + } + queue.push({ type: 'chat_assistant_message_completed', messageId: assistantMsgId }); + queue.push({ type: 'chat_turn_ended' }); + return; + } - // text blocks を assistant message の先頭に統合 (tool_use/result は intercept 経路で既に append 済み) - if (textBuffer.length > 0) { - const current = await chatStore.getChat(threadId); - const target = current?.messages.find((m) => m.id === assistantMsgId); - if (current && target) { - const textBlocks: ChatBlock[] = textBuffer.map((t) => ({ type: 'text', text: t })); - await chatStore.replaceMessageBlocks(threadId, assistantMsgId, [ - ...textBlocks, - ...target.blocks, - ]); - } + const blocks = extractAssistantBlocks(msg); + for (const b of blocks) { + if (b.type === 'text') { + textBuffer.push(b.text); + queue.push({ type: 'chat_text_delta', messageId: assistantMsgId, text: b.text }); + } else if (b.type === 'tool_use') { + const authMatch = parseAuthToolName(b.name); + if (authMatch) { + const label = externalConfigById.get(authMatch.mcpServerId) ?? authMatch.mcpServerId; + stashedAuthUses.set(b.toolUseId, { match: authMatch, mcpServerLabel: label }); + continue; } + await chatStore.appendBlockToMessage(threadId, assistantMsgId, { + type: 'tool_use', + toolUseId: b.toolUseId, + name: b.name, + input: b.input, + source: 'external', + }); + queue.push({ + type: 'chat_tool_external_use', + messageId: assistantMsgId, + toolUseId: b.toolUseId, + name: b.name, + input: b.input, + }); + } else if (b.type === 'tool_result') { + const stash = stashedAuthUses.get(b.toolUseId); + if (stash) { + stashedAuthUses.delete(b.toolUseId); + await this.handleAuthToolResult({ + match: stash.match, + mcpServerLabel: stash.mcpServerLabel, + result: { ok: b.ok, output: b.output }, + assistantMsgId, + emit: (e) => queue.push(e), + }); + continue; + } + await chatStore.appendBlockToMessage(threadId, assistantMsgId, { + type: 'tool_result', + toolUseId: b.toolUseId, + ok: b.ok, + output: truncateForPersistence(b.output), + }); + queue.push({ + type: 'chat_tool_external_result', + messageId: assistantMsgId, + toolUseId: b.toolUseId, + ok: b.ok, + output: b.output, + }); + } + } + } + + private tearDownQuery(): void { + try { + this.input?.close(); + } catch { + /* swallow: close は idempotent */ + } + try { + this.query?.close?.(); + } catch { + /* swallow */ + } + this.input = null; + this.query = null; + this.outputLoopDone = null; + } - queue.push({ type: 'chat_assistant_message_completed', messageId: assistantMsgId }); - queue.push({ type: 'chat_turn_ended' }); - } catch (err) { - queue.push({ type: 'error', code: 'agent_failed', message: String(err) }); - } finally { - queue.finish(); + // ChatRunner 終了時 (WS close 等) に SDK subprocess を片付ける。 + // 進行中の turn があれば打ち切られ、queue.finish() を経て runUserTurn 側の for-await が + // 自然に抜ける。再度 runUserTurn を呼べば ensureQuery が再起動する (= 再 auth が必要)。 + async close(): Promise { + this.tearDownQuery(); + if (this.outputLoopDone) { + // 念のため出力ループの終了を待つ (リソースリーク防止)。 + try { + await this.outputLoopDone; + } catch { + /* swallow */ } - })(); + } + } - // 6. queue をドレイン。MCP handler から push される pending/result も含め全て通過する。 - while (true) { - const evt = await queue.next(); - if (evt === null) break; - yield evt; + // OAuth 認証系 tool_use/tool_result ペアを auth_request ブロックに変換する。 + // - authenticate: tool_result.output から auth URL を抽出して新規 pending ブロックを append + // - complete_authentication: 同 mcpServerId の最新 pending ブロックを completed/failed に更新 + // どちらの場合も chat_auth_request イベントを emit する (UI が card を再描画するための合図)。 + // tool_result の ok=false や URL 抽出失敗時は failed として扱い、UI に message を出す。 + private async handleAuthToolResult(opts: { + match: AuthToolNameMatch; + mcpServerLabel: string; + result: { ok: boolean; output: string }; + assistantMsgId: string; + emit: (e: ChatEvent) => void; + }): Promise { + const { match, mcpServerLabel, result, assistantMsgId, emit } = opts; + const { chatStore, threadId } = this.deps; + + if (match.kind === 'authenticate') { + const authUrl = result.ok ? extractAuthUrl(result.output) : null; + if (!authUrl) { + // URL 抽出に失敗 (フォーマット変更 / 認証 server エラー等)。failed として可視化。 + const failureMessage = result.ok + ? 'authenticate tool_result から URL を抽出できませんでした' + : result.output.slice(0, 256); + const placeholderUrl = 'https://invalid.invalid/?auth_url_unavailable'; + const block: ChatBlock = { + type: 'auth_request', + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl: placeholderUrl, + status: 'failed', + failureMessage, + }; + await chatStore.appendBlockToMessage(threadId, assistantMsgId, block); + emit({ + type: 'chat_auth_request', + messageId: assistantMsgId, + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl: placeholderUrl, + status: 'failed', + failureMessage, + }); + return; + } + const block: ChatBlock = { + type: 'auth_request', + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl, + status: 'pending', + }; + await chatStore.appendBlockToMessage(threadId, assistantMsgId, block); + emit({ + type: 'chat_auth_request', + messageId: assistantMsgId, + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl, + status: 'pending', + }); + return; } - await sdkDone; // バックグラウンドタスクの未捕捉エラーを顕在化 + // complete_authentication: 最新 pending ブロックを更新する。 + const thread = await chatStore.getChat(threadId); + if (!thread) return; + const found = findLatestPendingAuthRequest(thread.messages, match.mcpServerId); + if (!found) { + // 対応する pending が無い (履歴外で auth 済 / 別 thread で auth 済 / 重複呼び出し)。 + // 失敗時は新規 failed ブロックで残す。成功時はサイレント (ノイズ防止)。 + if (!result.ok) { + const failureMessage = result.output.slice(0, 256); + const placeholderUrl = 'https://invalid.invalid/?orphan_complete_failed'; + const block: ChatBlock = { + type: 'auth_request', + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl: placeholderUrl, + status: 'failed', + failureMessage, + }; + await chatStore.appendBlockToMessage(threadId, assistantMsgId, block); + emit({ + type: 'chat_auth_request', + messageId: assistantMsgId, + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl: placeholderUrl, + status: 'failed', + failureMessage, + }); + } + return; + } + const updated: ChatBlock = result.ok + ? { ...found.block, status: 'completed' } + : { + ...found.block, + status: 'failed', + failureMessage: result.output.slice(0, 256), + }; + await chatStore.updateMessageBlock(threadId, found.messageId, found.blockIndex, updated); + emit({ + type: 'chat_auth_request', + messageId: found.messageId, + mcpServerId: match.mcpServerId, + mcpServerLabel, + authUrl: found.block.authUrl, + status: updated.status, + ...(updated.status === 'failed' && updated.failureMessage + ? { failureMessage: updated.failureMessage } + : {}), + }); } // 承認 intercept + 実ツール呼び出し。 @@ -435,16 +737,32 @@ export class ChatRunner { // SDK 視点では通常の tool_use → tool_result の往復。 // 間に挟まる pending / result の ChatEvent は emit callback で直接 queue に流す // (sideEvents buffer にすると SDK block 中に flush できず deadlock するため)。 - private buildMcpServer(tools: ToolEntry[], emit: (e: ChatEvent) => void, assistantMsgId: string) { + private buildMcpServer(tools: ToolEntry[]) { const find = (name: string): ToolEntry => { const t = tools.find((x) => x.name === name); if (!t) throw new Error(`tool not registered: ${name}`); return t; }; + // long-lived MCP server: handler 呼び出し時の「現 turn」から assistantMsgId / emit を読む。 + // SDK は tool 呼び出し中ずっと query を block するので、その間 currentTurn は不変が保証される。 + // turn が無い (= 想定外) ときは tool 呼び出しを失敗で返して保身する。 const makeHandler = (name: string) => async (input: unknown) => { const entry = find(name); - const { done } = this.invokeInterceptedTool({ entry, input, emit, assistantMsgId }); + const turn = this.currentTurn; + if (!turn) { + return { + content: [{ type: 'text' as const, text: 'no active turn (chat runner 状態異常)' }], + isError: true, + }; + } + const emit = (e: ChatEvent) => turn.queue.push(e); + const { done } = this.invokeInterceptedTool({ + entry, + input, + emit, + assistantMsgId: turn.assistantMsgId, + }); const result = await done; return { content: [{ type: 'text' as const, text: result.output }], diff --git a/packages/ai-engine/src/server.ts b/packages/ai-engine/src/server.ts index cc9b1c8..4729560 100644 --- a/packages/ai-engine/src/server.ts +++ b/packages/ai-engine/src/server.ts @@ -143,6 +143,7 @@ function handleAgentConnection(ws: WebSocket, sdk: SdkLike): void { // approve_tool は runner.approveTool へ同期的にデリゲート (pendingApprovals の Promise を resolve)。 // 切断で runner は破棄 (pending な承認は喪失するが、次回 open で永続化済み状態から再開できる)。 function handleChatConnection(ws: WebSocket, sdk: SdkLike): void { + console.log('[server] /chat WS connected'); const send = (evt: ChatEvent) => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(evt)); }; @@ -248,4 +249,15 @@ function handleChatConnection(ws: WebSocket, sdk: SdkLike): void { message: `unknown message type: ${String(obj.type)}`, }); }); + + // WS が閉じたら ChatRunner も片付ける (long-lived SDK subprocess を終わらせる)。 + // close を呼ばないと subprocess + MCP HTTP transport がプロセス終了まで生き残る。 + ws.on('close', (code, reason) => { + console.log('[server] /chat WS closed:', code, reason?.toString()); + if (runner) { + const r = runner; + runner = null; + void r.close(); + } + }); } diff --git a/packages/ai-engine/src/stream.ts b/packages/ai-engine/src/stream.ts index 1b9a916..15ab206 100644 --- a/packages/ai-engine/src/stream.ts +++ b/packages/ai-engine/src/stream.ts @@ -57,6 +57,18 @@ export type ChatEvent = ok: boolean; output: string; } + // 外部 MCP の OAuth 2.1 認証要求。SDK の authenticate tool_use を検出して + // tool_use/tool_result の代わりに UI に流す。pending → completed/failed の遷移は + // 同 thread 内の complete_authentication tool_use 検出時に追って emit する。 + | { + type: 'chat_auth_request'; + messageId: string; + mcpServerId: string; + mcpServerLabel: string; + authUrl: string; + status: 'pending' | 'completed' | 'failed'; + failureMessage?: string; + } | { type: 'error'; code: string; message: string }; // SDK の厳密な型に依存せず、実行時に触る最小限のプロパティだけで型付けする。 diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index 76aba25..4b30037 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -268,6 +268,37 @@ describe('ChatBlockSchema', () => { }).success, ).toBe(true); }); + it('auth_request ブロック (pending)', () => { + const r = ChatBlockSchema.safeParse({ + type: 'auth_request', + mcpServerId: 'atlassian', + mcpServerLabel: 'Atlassian', + authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', + status: 'pending', + }); + expect(r.success).toBe(true); + }); + it('auth_request ブロック (failed + failureMessage)', () => { + const r = ChatBlockSchema.safeParse({ + type: 'auth_request', + mcpServerId: 'atlassian', + mcpServerLabel: 'Atlassian', + authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', + status: 'failed', + failureMessage: 'invalid_grant', + }); + expect(r.success).toBe(true); + }); + it('auth_request の authUrl が URL でないと reject', () => { + const r = ChatBlockSchema.safeParse({ + type: 'auth_request', + mcpServerId: 'atlassian', + mcpServerLabel: 'Atlassian', + authUrl: 'not-a-url', + status: 'pending', + }); + expect(r.success).toBe(false); + }); it('不正な type は reject', () => { expect(ChatBlockSchema.safeParse({ type: 'other', text: 'x' }).success).toBe(false); }); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 87bf0f1..73f04c8 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -327,6 +327,20 @@ export const ChatBlockSchema = z.discriminatedUnion('type', [ ok: z.boolean(), output: z.string(), }), + // 外部 MCP (Atlassian 等) の OAuth 2.1 認証フローを 1 等地で扱うブロック。 + // SDK の `mcp____authenticate` tool_use を生のまま並べると UX が破綻する + // (URL がプレーンテキスト + redirect 先 localhost:XXXXX が即死) ため、検出して + // この auth_request に置き換える。status は同 thread 内の complete_authentication で更新。 + // mcpServerLabel は project.mcpServers[].label 由来 (label 未設定なら id を表示)。 + z.object({ + type: z.literal('auth_request'), + mcpServerId: z.string().min(1), + mcpServerLabel: z.string().min(1), + authUrl: z.string().url(), + status: z.enum(['pending', 'completed', 'failed']), + // 失敗時にエラーメッセージを残す。pending/completed では undefined。 + failureMessage: z.string().optional(), + }), ]); export const ChatMessageSchema = z.object({ diff --git a/packages/frontend/src/components/chat/auth-request-card.test.tsx b/packages/frontend/src/components/chat/auth-request-card.test.tsx new file mode 100644 index 0000000..5654636 --- /dev/null +++ b/packages/frontend/src/components/chat/auth-request-card.test.tsx @@ -0,0 +1,110 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useCanvasStore } from '@/lib/store'; + +import { AuthRequestCard } from './auth-request-card'; + +describe('AuthRequestCard', () => { + beforeEach(() => { + useCanvasStore.getState().reset(); + }); + + const pendingBlock = { + type: 'auth_request' as const, + mcpServerId: 'atlassian', + mcpServerLabel: 'My Atlassian', + authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz', + status: 'pending' as const, + }; + + it('pending: ラベル / 認証ボタン / paste 入力欄が表示される', () => { + useCanvasStore.setState({ sendChatMessage: vi.fn() } as never); + render(); + expect(screen.getByText(/My Atlassian 認証/)).toBeInTheDocument(); + expect(screen.getByText(/未認証/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /My Atlassian で認証/ })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /callback URL/ })).toBeInTheDocument(); + }); + + it('「認証」ボタンクリックで authUrl を新規タブで開く (window.open)', () => { + useCanvasStore.setState({ sendChatMessage: vi.fn() } as never); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + render(); + fireEvent.click(screen.getByRole('button', { name: /My Atlassian で認証/ })); + expect(openSpy).toHaveBeenCalledWith(pendingBlock.authUrl, '_blank', 'noopener,noreferrer'); + openSpy.mockRestore(); + }); + + it('callback URL 入力 → 認証完了で sendChatMessage に mcpServerId 付き user_message を送る', async () => { + const send = vi.fn().mockResolvedValue(undefined); + useCanvasStore.setState({ sendChatMessage: send } as never); + render(); + const input = screen.getByRole('textbox', { name: /callback URL/ }) as HTMLInputElement; + fireEvent.change(input, { + target: { value: 'http://localhost:54801/callback?code=AAA&state=xyz' }, + }); + fireEvent.click(screen.getByRole('button', { name: /^認証完了$/ })); + // sendChatMessage 呼び出し待ち + await screen.findByDisplayValue(''); // 送信成功時は input がクリアされる + expect(send).toHaveBeenCalledTimes(1); + const text = send.mock.calls[0]?.[0] as string; + expect(text).toContain('[OAuth callback for atlassian]'); + expect(text).toContain('http://localhost:54801/callback?code=AAA&state=xyz'); + expect(text).toContain('My Atlassian'); + }); + + it('callback URL の形式が不正なら認証完了ボタンが disabled (送信されない)', () => { + const send = vi.fn(); + useCanvasStore.setState({ sendChatMessage: send } as never); + render(); + const input = screen.getByRole('textbox', { name: /callback URL/ }) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'not a url' } }); + const btn = screen.getByRole('button', { name: /^認証完了$/ }) as HTMLButtonElement; + expect(btn.disabled).toBe(true); + fireEvent.click(btn); + expect(send).not.toHaveBeenCalled(); + }); + + it('host が localhost / 127.0.0.1 でない URL は reject (paste 偽造の防御)', () => { + useCanvasStore.setState({ sendChatMessage: vi.fn() } as never); + render(); + const input = screen.getByRole('textbox', { name: /callback URL/ }) as HTMLInputElement; + fireEvent.change(input, { + target: { value: 'http://evil.example.com/callback?code=AAA&state=xyz' }, + }); + const btn = screen.getByRole('button', { name: /^認証完了$/ }) as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it('completed: 完了メッセージを表示し paste 欄は出ない', () => { + useCanvasStore.setState({ sendChatMessage: vi.fn() } as never); + render( + , + ); + expect(screen.getByText(/認証済/)).toBeInTheDocument(); + expect(screen.getByText(/認証完了/)).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: /callback URL/ })).toBeNull(); + expect(screen.queryByRole('button', { name: /My Atlassian で認証/ })).toBeNull(); + }); + + it('failed: 失敗メッセージと failureMessage 内容を表示', () => { + useCanvasStore.setState({ sendChatMessage: vi.fn() } as never); + render( + , + ); + expect(screen.getAllByText(/失敗/).length).toBeGreaterThan(0); + expect(screen.getByText(/invalid_grant: state mismatch/)).toBeInTheDocument(); + }); +}); diff --git a/packages/frontend/src/components/chat/auth-request-card.tsx b/packages/frontend/src/components/chat/auth-request-card.tsx new file mode 100644 index 0000000..7e57b53 --- /dev/null +++ b/packages/frontend/src/components/chat/auth-request-card.tsx @@ -0,0 +1,224 @@ +'use client'; + +import type { ChatBlock } from '@tally/core'; +import { useState } from 'react'; + +import { useCanvasStore } from '@/lib/store'; + +type AuthRequestBlock = Extract; + +// callback URL が「http://localhost:XXXXX/callback?code=...&state=...」形式かを軽く検査。 +// SDK が立てた一時 callback 鯖は agent turn 終了で死ぬので、ユーザーがアドレスバーから +// コピーして貼ることを想定。host は localhost / 127.0.0.1 のみ通す。 +function isLikelyCallbackUrl(s: string): boolean { + try { + const u = new URL(s.trim()); + if (u.protocol !== 'http:' && u.protocol !== 'https:') return false; + if (u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') return false; + return u.searchParams.has('code') && u.searchParams.has('state'); + } catch { + return false; + } +} + +// 外部 MCP の OAuth 2.1 認証要求ブロック。 +// 「Atlassian で認証」ボタン (新規タブ) と callback URL paste 入力欄を 1 等地でまとめる。 +// 設計意図: SDK が tool 出力した auth URL をプレーンテキスト中に紛れさせると、 +// ・URL がクリックできない / 同タブ遷移で session を壊す +// ・redirect 先 localhost:XXXXX が即死しているのにユーザーが原因を特定できない +// という UX が破綻するため、専用カードで「やるべきこと」を 2 ステップに分けて提示する。 +export function AuthRequestCard({ block }: { block: AuthRequestBlock }) { + const sendChatMessage = useCanvasStore((s) => s.sendChatMessage); + const [callbackUrl, setCallbackUrl] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const isPending = block.status === 'pending'; + const isCompleted = block.status === 'completed'; + const isFailed = block.status === 'failed'; + + const onAuthClick = () => { + if (!isPending) return; + window.open(block.authUrl, '_blank', 'noopener,noreferrer'); + }; + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = callbackUrl.trim(); + if (!trimmed || !isLikelyCallbackUrl(trimmed)) return; + setSubmitting(true); + try { + // AI に complete_authentication を呼ばせるための user_message。 + // mcpServerId を明示することで、複数 MCP server がある場合でも AI が正しい + // server の complete_authentication ツールを選べる。 + const text = + `[OAuth callback for ${block.mcpServerId}] ${trimmed}\n\n` + + `上記 URL を使って ${block.mcpServerLabel} の認証コードを引き換えて、` + + `元のタスクを続行してください。`; + await sendChatMessage(text); + setCallbackUrl(''); + } finally { + setSubmitting(false); + } + }; + + return ( +
    +
    + 🔐 {block.mcpServerLabel} 認証 + {statusLabel(block.status)} +
    + + {isPending && ( + <> +
    + 下のボタンで {block.mcpServerLabel} の認証ページを別タブで開いて承認してください。 +
    + 承認後にブラウザが「接続できません」を表示しても問題ありません。 +
    + アドレスバーの URL (例: http://localhost:XXXXX/callback?code=...) を + コピーして、下の入力欄に貼り付け「認証完了」を押してください。 +
    + +
    + setCallbackUrl(e.target.value)} + placeholder="http://localhost:XXXXX/callback?code=...&state=..." + style={INPUT_STYLE} + disabled={submitting} + aria-label="callback URL" + /> + +
    + + )} + + {isCompleted && ( +
    + ✅ 認証完了。{block.mcpServerLabel} のツールが利用可能になりました。 +
    + )} + + {isFailed && ( +
    + ❌ 認証に失敗しました。 + {block.failureMessage ? ( +
    {block.failureMessage}
    + ) : null} +
    + 再度 AI に認証を要求してください (例: 「もう一度認証して」)。 +
    + )} +
    + ); +} + +function statusLabel(status: AuthRequestBlock['status']): string { + if (status === 'pending') return '未認証'; + if (status === 'completed') return '認証済'; + return '失敗'; +} + +function badgeStyle(status: AuthRequestBlock['status']) { + if (status === 'completed') { + return { ...BADGE_BASE_STYLE, background: '#23863633', color: '#7ee787' }; + } + if (status === 'failed') { + return { ...BADGE_BASE_STYLE, background: '#f8514933', color: '#ffa198' }; + } + return { ...BADGE_BASE_STYLE, background: '#bf8700aa', color: '#ffd33d' }; +} + +const CARD_STYLE = { + background: '#1a1f2e', + border: '1px solid #58a6ff', + borderRadius: 6, + padding: 10, + display: 'flex', + flexDirection: 'column' as const, + gap: 8, + width: '100%', +}; +const HEADER_STYLE = { + display: 'flex', + alignItems: 'center', + gap: 6, + fontSize: 13, + color: '#e6edf3', +}; +const LABEL_STYLE = { + flex: 1, + fontWeight: 600, +}; +const BADGE_BASE_STYLE = { + fontSize: 10, + padding: '1px 6px', + borderRadius: 4, +}; +const DESC_STYLE = { + fontSize: 11, + color: '#c8d1da', + lineHeight: 1.5, +}; +const COMPLETED_DESC_STYLE = { + fontSize: 12, + color: '#7ee787', +}; +const FAILED_DESC_STYLE = { + fontSize: 11, + color: '#ffa198', + lineHeight: 1.5, +}; +const FAILURE_PRE_STYLE = { + background: '#0d1117', + border: '1px solid #30363d', + borderRadius: 4, + padding: 6, + fontSize: 10, + fontFamily: 'ui-monospace, SFMono-Regular, monospace', + color: '#ffa198', + marginTop: 4, + whiteSpace: 'pre-wrap' as const, +}; +const AUTH_BUTTON_STYLE = { + background: '#1f6feb', + color: '#fff', + border: '1px solid #388bfd', + borderRadius: 6, + padding: '8px 12px', + fontSize: 12, + fontWeight: 600, + cursor: 'pointer', +}; +const FORM_STYLE = { + display: 'flex', + gap: 6, +}; +const INPUT_STYLE = { + flex: 1, + background: '#0d1117', + border: '1px solid #30363d', + borderRadius: 4, + padding: '6px 8px', + fontSize: 11, + fontFamily: 'ui-monospace, SFMono-Regular, monospace', + color: '#e6edf3', +}; +const SUBMIT_BUTTON_STYLE = { + background: '#238636', + color: '#fff', + border: '1px solid #2ea043', + borderRadius: 6, + padding: '4px 10px', + fontSize: 11, + cursor: 'pointer', +}; diff --git a/packages/frontend/src/components/chat/chat-message.tsx b/packages/frontend/src/components/chat/chat-message.tsx index 303e0aa..0f78456 100644 --- a/packages/frontend/src/components/chat/chat-message.tsx +++ b/packages/frontend/src/components/chat/chat-message.tsx @@ -2,6 +2,7 @@ import type { ChatBlock, ChatMessage as ChatMessageType } from '@tally/core'; +import { AuthRequestCard } from './auth-request-card'; import { ToolApprovalCard } from './tool-approval-card'; interface Props { @@ -55,6 +56,9 @@ function renderBlock(block: ChatBlock, idx: number) {
); } + if (block.type === 'auth_request') { + return ; + } return null; } diff --git a/packages/frontend/src/lib/store.ts b/packages/frontend/src/lib/store.ts index fa18bb5..4f6faf3 100644 --- a/packages/frontend/src/lib/store.ts +++ b/packages/frontend/src/lib/store.ts @@ -382,6 +382,56 @@ export const useCanvasStore = create((set, get) => { }); return; } + // OAuth 2.1 認証要求/状態更新。pending は新規 auth_request ブロックを append、 + // completed/failed は同 mcpServerId の最新 pending ブロックを in-place で更新する。 + // これは chat-runner が永続化側で行う処理と整合させるための鏡映ロジック。 + if (evt.type === 'chat_auth_request') { + set({ + chatThreadMessages: get().chatThreadMessages.map((m) => { + // pending: 該当 messageId に新規 append + if (evt.status === 'pending') { + if (m.id !== evt.messageId) return m; + return { + ...m, + blocks: [ + ...m.blocks, + { + type: 'auth_request', + mcpServerId: evt.mcpServerId, + mcpServerLabel: evt.mcpServerLabel, + authUrl: evt.authUrl, + status: 'pending', + }, + ], + }; + } + // completed/failed: 同 mcpServerId の pending ブロックを更新 (どのメッセージに属していても)。 + // 同 server の pending は最新 1 件しか存在しないので最初に見つけたものを書き換える。 + let updated = false; + const blocks = m.blocks.map((b) => { + if ( + !updated && + b.type === 'auth_request' && + b.mcpServerId === evt.mcpServerId && + b.status === 'pending' + ) { + updated = true; + return { + ...b, + status: evt.status, + ...(evt.status === 'failed' && evt.failureMessage + ? { failureMessage: evt.failureMessage } + : {}), + }; + } + return b; + }); + if (!updated) return m; + return { ...m, blocks }; + }), + }); + return; + } if (evt.type === 'chat_assistant_message_completed') { return; } From a676e67be7b054d19a93d0ba90104066b3646153 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Tue, 28 Apr 2026 15:25:23 +0900 Subject: [PATCH 31/34] =?UTF-8?q?chore:=20default=20port=20=E3=82=92=20fro?= =?UTF-8?q?ntend=3D3321=20/=20ai-engine=3D3322=20=E3=81=AB=E5=A4=89?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3000 (Next.js デフォルト) は claude.ai のローカル OAuth callback / 他の React dev と衝突する。5050 も別プロジェクトと衝突実例あり。3321/3322 は セットで覚えやすく現状空き。 - frontend: package.json の dev/start に `-p 3321` を固定、ws.ts と playwright.config / README / docs を 3321 に統一 - ai-engine: loadConfig の default を 3322 に変更、config.test.ts 追従 - README / .env.example / docs / examples の URL を一斉更新 --- .env.example | 10 +++++----- README.md | 6 +++--- docs/03-architecture.md | 4 ++-- docs/04-roadmap.md | 2 +- examples/sample-project/README.md | 2 +- packages/ai-engine/src/config.test.ts | 4 ++-- packages/ai-engine/src/config.ts | 4 ++-- packages/frontend/README.md | 2 +- packages/frontend/package.json | 4 ++-- packages/frontend/playwright.config.ts | 4 ++-- packages/frontend/src/lib/ws.ts | 4 ++-- 11 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index cd8fa1a..e3f6812 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ -# Tally AI Engine の WebSocket ポート (任意、デフォルト 5050) -# 4000/4001 は ark 等の他プロジェクトと衝突しがちなので避ける。 -AI_ENGINE_PORT=5050 +# Tally AI Engine の WebSocket ポート (任意、デフォルト 3322) +# 4000/4001/5050 等は他プロジェクトと衝突しがちなので避ける。 +AI_ENGINE_PORT=3322 -# フロントエンドが AI Engine に接続する URL (任意、デフォルト ws://localhost:5050) -# NEXT_PUBLIC_AI_ENGINE_URL=ws://localhost:5050 +# フロントエンドが AI Engine に接続する URL (任意、デフォルト ws://localhost:3322) +# NEXT_PUBLIC_AI_ENGINE_URL=ws://localhost:3322 # プロジェクトデータの保存先 (開発用) # 実運用では対象リポジトリ配下の .tally/ を使う diff --git a/README.md b/README.md index 73b64a6..044ab43 100644 --- a/README.md +++ b/README.md @@ -65,14 +65,14 @@ cp .env.example .env # 必要なら編集 pnpm dev ``` -- frontend: http://localhost:3000 -- ai-engine: ws://localhost:5050/agent +- frontend: http://localhost:3321 +- ai-engine: ws://localhost:3322/agent `pnpm dev` は `pnpm -r --parallel dev` を呼び、frontend (Next.js dev) と ai-engine (tsx watch) を並列起動する。 ### 利用フロー (要点) -1. ブラウザで http://localhost:3000 を開く +1. ブラウザで http://localhost:3321 を開く 2. 「+ 新規プロジェクト」でフォルダ選択ダイアログから保存先を選ぶ(`~/.local/share/tally/projects/<名前>/` が提案される) 3. 任意で 1 つ以上の「コードベース」(AI が探索する対象リポジトリ)を追加して「作成」 4. UC ノードを選択 → DetailSheet の「ストーリー分解」ボタンを押下 diff --git a/docs/03-architecture.md b/docs/03-architecture.md index 40dcd8b..2d2e0b7 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -137,8 +137,8 @@ User taps "UC" node → "ストーリー分解" ``` $ pnpm dev -├── frontend (Next.js dev server, :3000) -├── ai-engine (WebSocket server, :3001) +├── frontend (Next.js dev server, :3321) +├── ai-engine (WebSocket server, :3322) └── storage (inline in Next.js Route Handlers) ``` diff --git a/docs/04-roadmap.md b/docs/04-roadmap.md index 3a51bf1..fd7204d 100644 --- a/docs/04-roadmap.md +++ b/docs/04-roadmap.md @@ -21,7 +21,7 @@ - `pnpm install` がエラーなく完了 - `pnpm -r test` が通る(空のテストでOK) -- `pnpm --filter frontend dev` で http://localhost:3000 が表示される +- `pnpm --filter frontend dev` で http://localhost:3321 が表示される --- diff --git a/examples/sample-project/README.md b/examples/sample-project/README.md index 0a2417c..cf6162f 100644 --- a/examples/sample-project/README.md +++ b/examples/sample-project/README.md @@ -41,7 +41,7 @@ sample-project/ pnpm dev # ブラウザで以下を開く -# http://localhost:3000/projects/taskflow-invite +# http://localhost:3321/projects/taskflow-invite ``` ## 注意 diff --git a/packages/ai-engine/src/config.test.ts b/packages/ai-engine/src/config.test.ts index 8b61bcc..b682bab 100644 --- a/packages/ai-engine/src/config.test.ts +++ b/packages/ai-engine/src/config.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest'; import { loadConfig } from './config'; describe('loadConfig', () => { - it('デフォルト PORT は 5050 (4000/4001 は ark 等と衝突するため避ける)', () => { + it('デフォルト PORT は 3322 (3321 frontend の隣、他プロジェクトと衝突しがちな 3000/4000/4001/5050 を避ける)', () => { const cfg = loadConfig({}); - expect(cfg.port).toBe(5050); + expect(cfg.port).toBe(3322); }); it('AI_ENGINE_PORT を解釈する', () => { diff --git a/packages/ai-engine/src/config.ts b/packages/ai-engine/src/config.ts index 86a2f54..cd26d9f 100644 --- a/packages/ai-engine/src/config.ts +++ b/packages/ai-engine/src/config.ts @@ -6,9 +6,9 @@ export interface AiEngineConfig { // 認証情報は扱わない (Claude Code OAuth トークンを SDK が暗黙で拾う)。 export function loadConfig(env: NodeJS.ProcessEnv): AiEngineConfig { const raw = env.AI_ENGINE_PORT; - // default 5050: 4000/4001 が他プロジェクト (ark 等) と衝突しがちなので避ける。 + // default 3322: 3321 (frontend) の隣。3000/4000/4001/5050 等は他プロジェクトと衝突しがち。 // env AI_ENGINE_PORT で上書き可能。 - if (raw === undefined || raw === '') return { port: 5050 }; + if (raw === undefined || raw === '') return { port: 3322 }; const n = Number.parseInt(raw, 10); if (!Number.isFinite(n) || n <= 0) { throw new Error(`AI_ENGINE_PORT が不正: ${raw}`); diff --git a/packages/frontend/README.md b/packages/frontend/README.md index ef45ca7..3de95fc 100644 --- a/packages/frontend/README.md +++ b/packages/frontend/README.md @@ -59,7 +59,7 @@ src/ ## 開発 ```bash -pnpm --filter @tally/frontend dev # http://localhost:3000 +pnpm --filter @tally/frontend dev # http://localhost:3321 pnpm --filter @tally/frontend build pnpm --filter @tally/frontend typecheck ``` diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 800a9bf..af2bb1b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,9 +4,9 @@ "private": true, "description": "Tally のフロントエンド (Next.js 16 App Router + React Flow キャンバス)", "scripts": { - "dev": "next dev", + "dev": "next dev -p 3321", "build": "next build", - "start": "next start", + "start": "next start -p 3321", "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", diff --git a/packages/frontend/playwright.config.ts b/packages/frontend/playwright.config.ts index 97a731f..b27f84a 100644 --- a/packages/frontend/playwright.config.ts +++ b/packages/frontend/playwright.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ globalSetup: './e2e/global-setup.ts', use: { - baseURL: 'http://localhost:3000', + baseURL: 'http://localhost:3321', trace: 'retain-on-failure', screenshot: 'only-on-failure', }, @@ -31,7 +31,7 @@ export default defineConfig({ // frontend の dev server を自動起動。ai-engine は未起動でもノード表示は動く (chat を開かない限り WS 接続なし)。 webServer: { command: 'pnpm dev', - url: 'http://localhost:3000', + url: 'http://localhost:3321', reuseExistingServer: !process.env.CI, timeout: 120_000, env: { diff --git a/packages/frontend/src/lib/ws.ts b/packages/frontend/src/lib/ws.ts index a12ec70..c5dc556 100644 --- a/packages/frontend/src/lib/ws.ts +++ b/packages/frontend/src/lib/ws.ts @@ -20,7 +20,7 @@ export interface AgentHandle { // WS ベースの agent 呼び出し。受信した NDJSON を AgentEvent の AsyncIterable に変換する。 // close() で接続を明示的に終わらせる。サーバ側が close したら AsyncIterable も終了する。 export function startAgent(opts: StartAgentOptions): AgentHandle { - const url = opts.url ?? process.env.NEXT_PUBLIC_AI_ENGINE_URL ?? 'ws://localhost:5050'; + const url = opts.url ?? process.env.NEXT_PUBLIC_AI_ENGINE_URL ?? 'ws://localhost:3322'; const ws = new WebSocket(`${url}/agent`); const buf: AgentEvent[] = []; @@ -107,7 +107,7 @@ export interface OpenChatOptions { // sendUserMessage / approveTool を任意のタイミングで呼ぶ。 // サーバが close した場合は events も終了する。 export function openChat(opts: OpenChatOptions): ChatHandle { - const url = opts.url ?? process.env.NEXT_PUBLIC_AI_ENGINE_URL ?? 'ws://localhost:5050'; + const url = opts.url ?? process.env.NEXT_PUBLIC_AI_ENGINE_URL ?? 'ws://localhost:3322'; const ws = new WebSocket(`${url}/chat`); const buf: ChatEvent[] = []; From 78f838ac8d2f72407147a4766cc52c1875144532 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Tue, 28 Apr 2026 15:49:45 +0900 Subject: [PATCH 32/34] =?UTF-8?q?fix:=20CodeRabbit=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=20(Critical=20+=20Major)?= =?UTF-8?q?=20=E5=8F=8D=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #18 のレビューから明確にバグの 5 件を一括修正。 - async-input: waiter スロットを単一から FIFO キューに変更。next() を 並行で 2 回呼んで push 1 回だけのとき 1 つ目の resolver が捨てられ Promise が永遠に未解決になる仕様違反を解消。close 時に残った全 waiter を done で倒す。回帰テスト 2 件追加。 - auth-detector.extractAuthUrl: 自然文末尾の `.` `,` `;` `:` `!` `?` `)` を URL に含めないよう trailing strip。`...state=xyz.` のような出力で 認可リンクが壊れていた問題を修正。テスト 3 件追加。 - server.ts: WS close 時の `r.close()` を `.catch` で握って unhandled rejection 化を防ぐ。失敗は warn で観測可能にする。 - project-settings-dialog.tsx: addMcpServer の id 採番を `mcpServers.length + 1` から「未使用 suffix を探索」方式に変更。 例えば atlassian-1/atlassian-2 がある状態で 1 件削除して追加すると 再び atlassian-2 が生成されて key 衝突するバグを修正。回帰テスト追加。 - next-env.d.ts: Next.js 自動生成ファイルなので .gitignore に追加して リポジトリから untrack。dev/build で内容が変動するため tracking する意味が無い。 --- .gitignore | 3 ++ packages/ai-engine/src/async-input.test.ts | 36 +++++++++++++++++++ packages/ai-engine/src/async-input.ts | 20 +++++------ packages/ai-engine/src/auth-detector.test.ts | 21 +++++++++++ packages/ai-engine/src/auth-detector.ts | 5 ++- packages/ai-engine/src/server.ts | 6 +++- packages/frontend/next-env.d.ts | 6 ---- .../dialog/project-settings-dialog.test.tsx | 26 ++++++++++++++ .../dialog/project-settings-dialog.tsx | 8 ++++- 9 files changed, 111 insertions(+), 20 deletions(-) delete mode 100644 packages/frontend/next-env.d.ts diff --git a/.gitignore b/.gitignore index 3afc9cc..bb07a62 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ build/ .next/ out/ +# Next.js auto-generated type reference (内容が dev/build で変動する。手で編集しない) +next-env.d.ts + # Test coverage/ *.lcov diff --git a/packages/ai-engine/src/async-input.test.ts b/packages/ai-engine/src/async-input.test.ts index 850e9e1..aa009a2 100644 --- a/packages/ai-engine/src/async-input.test.ts +++ b/packages/ai-engine/src/async-input.test.ts @@ -61,4 +61,40 @@ describe('AsyncIterableInput', () => { expect(r2.done).toBe(true); } }); + + // CodeRabbit 指摘 (PR #18): 単一 waiter スロットだと 2 回連続で next() を呼んだとき + // 1 つ目の resolver が捨てられて Promise が永遠に未解決になる。FIFO キューで保持する + // ことで、push 順に正しく解決されることを確認する。 + it('next() を複数回先に呼んでから push しても、push 順に各 promise が解決される', async () => { + const input = new AsyncIterableInput(); + const it = input.iterable()[Symbol.asyncIterator](); + const p1 = it.next(); + const p2 = it.next(); + input.push(10); + input.push(20); + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1).toEqual({ value: 10, done: false }); + expect(r2).toEqual({ value: 20, done: false }); + }); + + it('next() を 2 回先に呼んで push を 1 回だけしても、未解決の Promise は close で done に倒れる', async () => { + const input = new AsyncIterableInput(); + const it = input.iterable()[Symbol.asyncIterator](); + const p1 = it.next(); + const p2 = it.next(); + input.push(42); + const r1 = await p1; + expect(r1).toEqual({ value: 42, done: false }); + // p2 は未解決 + let p2Resolved = false; + p2.then(() => { + p2Resolved = true; + }); + await new Promise((r) => setTimeout(r, 5)); + expect(p2Resolved).toBe(false); + // close で残りの waiter が done に倒れる + input.close(); + const r2 = await p2; + expect(r2.done).toBe(true); + }); }); diff --git a/packages/ai-engine/src/async-input.ts b/packages/ai-engine/src/async-input.ts index e8b17ff..4973656 100644 --- a/packages/ai-engine/src/async-input.ts +++ b/packages/ai-engine/src/async-input.ts @@ -3,19 +3,19 @@ // 1 chat thread = 1 long-lived sdk.query() を実現するため、user message を // 任意のタイミングで投入し、close で iter を終わらせる。 // -// 実装方針: バッファ + waiter の二段構え。consumer (SDK) が next を呼んだ瞬間に -// バッファがあれば即返す。空なら 1 回限りの resolver を保留 (背圧に近い形)。 -// 再 push でその resolver を解決する。 +// 実装方針: バッファ + waiter キュー。consumer が next を複数回連続で呼んでも +// 各 promise が独立に保持される。AsyncIterator 仕様に沿うため waiter は +// 単一スロットではなく FIFO キューで持つ (consumer が並行で next() を呼ぶ +// ケースに耐える)。 export class AsyncIterableInput { private buf: T[] = []; - private waiter: ((r: IteratorResult) => void) | null = null; + private waiters: Array<(r: IteratorResult) => void> = []; private finished = false; push(value: T): void { if (this.finished) return; - const w = this.waiter; + const w = this.waiters.shift(); if (w) { - this.waiter = null; w({ value, done: false }); return; } @@ -25,10 +25,8 @@ export class AsyncIterableInput { close(): void { if (this.finished) return; this.finished = true; - const w = this.waiter; - if (w) { - this.waiter = null; - w({ value: undefined as never, done: true }); + while (this.waiters.length > 0) { + this.waiters.shift()?.({ value: undefined as never, done: true }); } } @@ -46,7 +44,7 @@ export class AsyncIterableInput { return Promise.resolve({ value: undefined as never, done: true }); } return new Promise>((resolve) => { - this.waiter = resolve; + this.waiters.push(resolve); }); }, return: (): Promise> => { diff --git a/packages/ai-engine/src/auth-detector.test.ts b/packages/ai-engine/src/auth-detector.test.ts index a2dbc2d..fd3bf36 100644 --- a/packages/ai-engine/src/auth-detector.test.ts +++ b/packages/ai-engine/src/auth-detector.test.ts @@ -64,4 +64,25 @@ Once they complete the flow, the server's tools will become available automatica it('URL が無ければ null', () => { expect(extractAuthUrl('no url here')).toBeNull(); }); + + // CodeRabbit 指摘 (PR #18): 自然文末尾の句読点・括弧が URL に紛れて + // 認可リンクが壊れていた。末尾の `.,;:!?)` は剥がす。 + it('文末の句読点を URL に含めない (period)', () => { + const out = '認証は https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz.'; + expect(extractAuthUrl(out)).toBe( + 'https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz', + ); + }); + + it('文末の閉じ括弧を URL に含めない', () => { + const out = '(認証は https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz)'; + expect(extractAuthUrl(out)).toBe( + 'https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz', + ); + }); + + it('複数の末尾句読点も剥がす', () => { + const out = '行ってください: https://mcp.atlassian.com/v1/authorize?state=xyz!?'; + expect(extractAuthUrl(out)).toBe('https://mcp.atlassian.com/v1/authorize?state=xyz'); + }); }); diff --git a/packages/ai-engine/src/auth-detector.ts b/packages/ai-engine/src/auth-detector.ts index 5b991c8..b81c142 100644 --- a/packages/ai-engine/src/auth-detector.ts +++ b/packages/ai-engine/src/auth-detector.ts @@ -30,5 +30,8 @@ export function extractAuthUrl(output: string): string | null { // 単なる説明用の URL (https://example.com 等) を引かないようにする。 const urlRe = /https:\/\/[^\s)"'<>]+\?[^\s)"'<>]+/; const m = unfolded.match(urlRe); - return m ? m[0] : null; + if (!m) return null; + // 自然文末尾の句読点 / 閉じ括弧が URL に紛れるのを除く (CodeRabbit 指摘 PR #18)。 + // 例: "...state=xyz." / "...state=xyz)" → 末尾の `.` `)` を落とす。 + return m[0].replace(/[).,;:!?]+$/u, ''); } diff --git a/packages/ai-engine/src/server.ts b/packages/ai-engine/src/server.ts index 4729560..60e0175 100644 --- a/packages/ai-engine/src/server.ts +++ b/packages/ai-engine/src/server.ts @@ -257,7 +257,11 @@ function handleChatConnection(ws: WebSocket, sdk: SdkLike): void { if (runner) { const r = runner; runner = null; - void r.close(); + // close 内部の例外 (subprocess kill 失敗など) を unhandled rejection に + // しないために .catch で握る。観測できるよう warn は出す。 + r.close().catch((err) => { + console.warn('[server] runner.close() failed:', err); + }); } }); } diff --git a/packages/frontend/next-env.d.ts b/packages/frontend/next-env.d.ts deleted file mode 100644 index c4b7818..0000000 --- a/packages/frontend/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/dev/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx index 2d40671..b15b91a 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.test.tsx @@ -138,6 +138,32 @@ describe('ProjectSettingsDialog', () => { expect(screen.queryByLabelText('mcp-0-id')).toBeNull(); }); + // CodeRabbit 指摘 (PR #18): mcpServers.length + 1 で id 採番すると、 + // 削除→追加で既存 id と衝突する。未使用 suffix を採番するよう修正済み。 + it('追加 → 追加 → 1 件目を削除 → 追加で id が衝突しない', async () => { + render( {}} />); + const addBtn = () => screen.getByRole('button', { name: /MCP サーバーを追加/ }); + await userEvent.click(addBtn()); // atlassian-1 + await userEvent.click(addBtn()); // atlassian-2 + expect((screen.getByLabelText('mcp-0-id') as HTMLInputElement).value).toBe('atlassian-1'); + expect((screen.getByLabelText('mcp-1-id') as HTMLInputElement).value).toBe('atlassian-2'); + // 1 件目 (mcp-0) を削除 → 残るのは元 mcp-1 だが index は 0 にスライド + const removeButtons = screen.getAllByRole('button', { name: /削除/ }); + // codebase 削除 (0) + MCP 2 件分 削除 (1, 2) → MCP 削除は最後 2 つ。1 件目 MCP を削除。 + await userEvent.click(removeButtons[removeButtons.length - 2] as HTMLElement); + expect((screen.getByLabelText('mcp-0-id') as HTMLInputElement).value).toBe('atlassian-2'); + // ここで再度追加 → 旧実装は mcpServers.length + 1 = 2 で `atlassian-2` 衝突。 + // 修正後は未使用 suffix `atlassian-1` が採番される。 + await userEvent.click(addBtn()); + const ids = [ + (screen.getByLabelText('mcp-0-id') as HTMLInputElement).value, + (screen.getByLabelText('mcp-1-id') as HTMLInputElement).value, + ]; + expect(new Set(ids).size).toBe(2); // 衝突なし + expect(ids).toContain('atlassian-2'); + expect(ids).toContain('atlassian-1'); + }); + it('auth / secret 関連の入力欄は無い (OAuth 2.1 で MCP/SDK 任せ)', async () => { render( {}} />); await userEvent.click(screen.getByRole('button', { name: /MCP サーバーを追加/ })); diff --git a/packages/frontend/src/components/dialog/project-settings-dialog.tsx b/packages/frontend/src/components/dialog/project-settings-dialog.tsx index 720439c..ba57f6b 100644 --- a/packages/frontend/src/components/dialog/project-settings-dialog.tsx +++ b/packages/frontend/src/components/dialog/project-settings-dialog.tsx @@ -82,7 +82,13 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos }; const addMcpServer = () => { - setMcpServers([...mcpServers, makeDefaultMcpServer(mcpServers.length + 1)]); + // 削除→追加で `mcpServers.length + 1` を使うと既存 ID と衝突する + // (例: atlassian-1, atlassian-2 で 1 件削除して追加すると再び atlassian-2)。 + // mcpServers の id は下流で key として使われるため、未使用 suffix を探す。 + const usedIds = new Set(mcpServers.map((s) => s.id)); + let seq = 1; + while (usedIds.has(`atlassian-${seq}`)) seq += 1; + setMcpServers([...mcpServers, makeDefaultMcpServer(seq)]); }; const updateMcpServer = (index: number, next: McpServerConfig) => { From b3182c71e983e9aefe1bc0f7f2fc15b7ed85ef26 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Tue, 28 Apr 2026 17:50:31 +0900 Subject: [PATCH 33/34] =?UTF-8?q?fix:=20CodeRabbit=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=20(Minor)=20=E5=8F=8D?= =?UTF-8?q?=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #18 のレビューから残り Minor 4 件を一括対応。 - docs/03-architecture.md: 環境変数例の `TALLY_AI_PORT=3001` を実装と整合 する `AI_ENGINE_PORT=3322` (ai-engine の loadConfig が読む env 名) に 修正。同ファイル内で port が複数表記されていた不整合を解消。 - duplicate-guards/source-url.ts: sourceUrl を生値のまま比較していたため 前後空白付き入力で memo / 永続化済み URL と比較がずれて重複検知を すり抜けていた。`normalizeSourceUrl` で trim 正規化し、check / onCreated / 既存ノード比較すべてで揃える。回帰テスト 4 件追加。 - core/schema.ts: auth_request の status と failureMessage の整合を superRefine で固定。failed なのに message 無し / pending・completed に message が付くケースを永続化レイヤで弾く。テスト 3 件追加。 - frontend/route.test.ts: mcpServers 全消去テストの事前 PATCH の ステータスを assert。失敗していると後続の「空配列で削除」ケースが 偽の成功 (空 → 空) で通る恐れがあった。 --- docs/03-architecture.md | 2 +- .../2026-04-21-project-storage-redesign.md | 3457 ----------------- ...04-24-atlassian-mcp-c-phase-dogfood-log.md | 190 - .../plans/2026-04-24-atlassian-mcp-c-phase.md | 2525 ------------ ...6-04-21-project-storage-redesign-design.md | 479 --- .../src/duplicate-guards/source-url.test.ts | 41 + .../src/duplicate-guards/source-url.ts | 20 +- packages/core/src/schema.test.ts | 35 + packages/core/src/schema.ts | 37 +- .../src/app/api/projects/[id]/route.test.ts | 6 +- 10 files changed, 124 insertions(+), 6668 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-21-project-storage-redesign.md delete mode 100644 docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md delete mode 100644 docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase.md delete mode 100644 docs/superpowers/specs/2026-04-21-project-storage-redesign-design.md diff --git a/docs/03-architecture.md b/docs/03-architecture.md index 2d2e0b7..01b79ba 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -173,7 +173,7 @@ AI Engine を別プロセスにする理由: ``` ANTHROPIC_API_KEY=sk-ant-... # Claude Agent SDK -TALLY_AI_PORT=3001 # AI Engine WebSocket ポート +AI_ENGINE_PORT=3322 # AI Engine WebSocket ポート (env 名は ai-engine の loadConfig に揃える) TALLY_HOME=~/.local/share/tally # レジストリ・デフォルトプロジェクト置き場(省略時はこの値) ``` diff --git a/docs/superpowers/plans/2026-04-21-project-storage-redesign.md b/docs/superpowers/plans/2026-04-21-project-storage-redesign.md deleted file mode 100644 index 2c3adef..0000000 --- a/docs/superpowers/plans/2026-04-21-project-storage-redesign.md +++ /dev/null @@ -1,3457 +0,0 @@ -# プロジェクトストレージ再設計 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** プロジェクトをリポジトリから独立した第一級の存在に昇格させ、0 件以上の `codebases[]` を参照できるモデルに刷新。保存先は XDG 準拠のグローバルディレクトリをデフォルトにし、レジストリによる明示発見 + バックエンド駆動フォルダピッカーで作成・インポートを行う。 - -**Architecture:** `.tally/` 規約を廃止し「プロジェクト = 任意のディレクトリ」に統一。レジストリ (`~/.local/share/tally/registry.yaml`) が既知プロジェクト一覧を保持。後方互換は一切維持しない破壊的変更で、ADR-0003 は Superseded とし新 ADR 3 本を追加。 - -**Tech Stack:** TypeScript (core / storage / frontend / ai-engine)、Zod(型とバリデーション)、Next.js 15 App Router(Route Handlers)、Vitest(全パッケージ)、Testing Library(frontend)、pnpm workspaces。 - -**Spec:** `docs/superpowers/specs/2026-04-21-project-storage-redesign-design.md` - ---- - -## 実装前の重要な前提 - -- **コードノードの型名**: spec では便宜上「code ノード」と呼んでいるが、実コードでは `coderef` を使う(`packages/core/src/schema.ts` の `NODE_TYPES`)。プラン内では `coderef` を使う -- **プロジェクト ID 形式**: `newProjectId()` は `proj-<10文字nanoid>` を返す(`packages/core/src/id.ts`)。spec の表記 `proj_abc123` はイメージで、実際は `-` 区切り -- **YAML 永続化**: `packages/storage/src/yaml.ts` の `readYaml` / `writeYaml` (Zod validation 付き) を使う。アトミック書き込みが必要な箇所は本計画の Task 3 で追加する -- **破壊的変更**: 既存ファイル・型・テストを大量に削除/書き換える。型変更を起点にコンパイルエラーを潰す順序で進める - ---- - -## ファイル構造 - -### 新規作成 -- `packages/storage/src/registry.ts` — レジストリ CRUD -- `packages/storage/src/registry.test.ts` -- `packages/storage/src/project-dir.ts` — projectDir 直下の path 解決(旧 `paths.ts` 置換) -- `packages/storage/src/project-dir.test.ts` -- `packages/frontend/src/app/api/fs/ls/route.ts` — ディレクトリ一覧 API -- `packages/frontend/src/app/api/fs/ls/route.test.ts` -- `packages/frontend/src/app/api/fs/mkdir/route.ts` — 新規フォルダ API -- `packages/frontend/src/app/api/fs/mkdir/route.test.ts` -- `packages/frontend/src/components/dialog/folder-browser-dialog.tsx` -- `packages/frontend/src/components/dialog/folder-browser-dialog.test.tsx` -- `packages/frontend/src/components/dialog/project-import-dialog.tsx` -- `packages/frontend/src/components/dialog/project-import-dialog.test.tsx` -- `docs/adr/0008-project-independent-from-repo.md` -- `docs/adr/0009-project-registry.md` -- `docs/adr/0010-multiple-codebases.md` - -### 削除 -- `packages/storage/src/project-resolver.ts` + `.test.ts` -- `packages/storage/src/paths.ts` + `.test.ts`(`project-dir.ts` に役割移管) -- `packages/frontend/src/app/api/workspace-candidates/route.ts` -- `packages/frontend/src/lib/project-resolver.ts`(フロント側にも残がある。後述 Task で確認) - -### 書き換え -- `packages/core/src/schema.ts`(`ProjectMetaSchema` / `ProjectMetaPatchSchema` / `CodeRefNodeSchema`) -- `packages/core/src/schema.test.ts` -- `packages/storage/src/index.ts` — export 整理 -- `packages/storage/src/init-project.ts` + `.test.ts` -- `packages/storage/src/project-store.ts` + `.test.ts` -- `packages/storage/src/clear-project.ts` + `.test.ts`(`workspaceRoot` → `projectDir` rename に伴い) -- `packages/storage/src/chat-store.ts` + `.test.ts`(同上) -- `packages/frontend/src/lib/api.ts` -- `packages/frontend/src/lib/store.ts` -- `packages/frontend/src/app/api/projects/route.ts` -- `packages/frontend/src/app/api/projects/[id]/route.ts` -- `packages/frontend/src/components/dialog/new-project-dialog.tsx` + `.test.tsx`(全面刷新) -- `packages/frontend/src/components/dialog/project-settings-dialog.tsx` + `.test.tsx`(`codebases[]` 対応に全面刷新) -- `packages/frontend/src/app/page.tsx`(トップページ、registry 駆動) -- `packages/ai-engine/src/agents/codebase-anchor.ts` + `.test.ts` -- `packages/ai-engine/src/agents/find-related-code.ts` + `.test.ts` -- `packages/ai-engine/src/agents/analyze-impact.ts` + `.test.ts` -- `packages/ai-engine/src/agents/extract-questions.ts` + `.test.ts` -- `packages/ai-engine/src/agent-runner.ts` + `.test.ts` -- `packages/frontend/src/components/ai-actions/*`(codebase 参照系 UI を codebases[] 前提に) -- `examples/sample-project/`(ディレクトリ構造刷新) -- `docs/adr/0003-git-managed-yaml.md`(Superseded に更新) -- `CLAUDE.md` / `README.md` - ---- - -## Phase 1: Core データモデル - -### Task 1: CodebaseSchema 追加と ProjectMetaSchema 刷新 - -**Files:** -- Modify: `packages/core/src/schema.ts:142-175` -- Test: `packages/core/src/schema.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/core/src/schema.test.ts` に追加: - -```ts -import { describe, expect, it } from 'vitest'; -import { CodebaseSchema, ProjectMetaSchema } from './schema'; - -describe('CodebaseSchema', () => { - it('id / label / path を必須で受け入れる', () => { - const input = { id: 'frontend', label: 'TaskFlow Web', path: '/abs/path' }; - expect(CodebaseSchema.parse(input)).toEqual(input); - }); - - it('id が空文字は拒否', () => { - expect(() => CodebaseSchema.parse({ id: '', label: 'x', path: '/abs' })).toThrow(); - }); - - it('id は kebab-case 英小文字 32 字以内', () => { - expect(() => CodebaseSchema.parse({ id: 'Frontend', label: 'x', path: '/abs' })).toThrow(); - expect(() => - CodebaseSchema.parse({ id: 'a'.repeat(33), label: 'x', path: '/abs' }), - ).toThrow(); - expect(CodebaseSchema.parse({ id: 'a', label: 'x', path: '/abs' }).id).toBe('a'); - }); -}); - -describe('ProjectMetaSchema (刷新後)', () => { - it('codebases: Codebase[] を必須で受け入れる (空配列可)', () => { - const meta = { - id: 'proj-abc', - name: 'p', - codebases: [], - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }; - expect(ProjectMetaSchema.parse(meta).codebases).toEqual([]); - }); - - it('codebasePath / additionalCodebasePaths を受け入れない', () => { - const meta = { - id: 'proj-abc', - name: 'p', - codebases: [], - codebasePath: '/x', // 旧フィールド、もう存在しない - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }; - // passthrough してないので余計なキーは単に無視されるが、型に存在しないことを別途検証 - const parsed = ProjectMetaSchema.parse(meta); - expect('codebasePath' in parsed).toBe(false); - }); - - it('codebases[].id の重複を拒否', () => { - const meta = { - id: 'proj-abc', - name: 'p', - codebases: [ - { id: 'dup', label: 'A', path: '/a' }, - { id: 'dup', label: 'B', path: '/b' }, - ], - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }; - expect(() => ProjectMetaSchema.parse(meta)).toThrow(/codebases\[\]\.id/); - }); -}); -``` - -- [ ] **Step 2: テストを走らせ失敗確認** - -Run: `pnpm -F @tally/core test -- schema.test` -Expected: `CodebaseSchema` / 新仕様テスト が FAIL - -- [ ] **Step 3: 最小実装** - -`packages/core/src/schema.ts` の 142〜175 行目(プロジェクトスキーマ部分)を全面書き換え: - -```ts -// ---------------------------------------------------------------------------- -// プロジェクトスキーマ -// ---------------------------------------------------------------------------- - -export const CodebaseSchema = z.object({ - id: z - .string() - .regex(/^[a-z][a-z0-9-]{0,31}$/u, { - message: 'codebase id は先頭英小文字 + 英小文字/数字/ハイフン、32 字以内', - }), - label: z.string().min(1), - path: z.string().min(1), -}); - -export type Codebase = z.infer; - -// project.yaml に対応する meta のみのスキーマ。 -// ノード・エッジはファイル分割で永続化するため、ここには含めない。 -export const ProjectMetaSchema = z - .object({ - id: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), - // 0 件以上。code ノードが存在するときは最低 1 件必要(整合性は storage 層で検証)。 - codebases: z.array(CodebaseSchema), - createdAt: z.string(), - updatedAt: z.string(), - }) - .superRefine((meta, ctx) => { - const ids = meta.codebases.map((c) => c.id); - const dup = ids.find((id, idx) => ids.indexOf(id) !== idx); - if (dup !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `codebases[].id 重複: ${dup}`, - path: ['codebases'], - }); - } - }); - -// 実行時に Project 全体を扱う際の合成スキーマ (メモリ上表現)。 -export const ProjectSchema = z - .object({ - id: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), - codebases: z.array(CodebaseSchema), - createdAt: z.string(), - updatedAt: z.string(), - nodes: z.array(NodeSchema), - edges: z.array(EdgeSchema), - }) - .superRefine((p, ctx) => { - const ids = p.codebases.map((c) => c.id); - const dup = ids.find((id, idx) => ids.indexOf(id) !== idx); - if (dup !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `codebases[].id 重複: ${dup}`, - path: ['codebases'], - }); - } - }); - -// PATCH /api/projects/:id の body スキーマ。codebases 全置換のみ許可(部分更新はしない)。 -export const ProjectMetaPatchSchema = z - .object({ - name: z.string().min(1).optional(), - description: z.string().nullable().optional(), - codebases: z.array(CodebaseSchema).optional(), - }) - .strict() - .superRefine((patch, ctx) => { - if (patch.codebases) { - const ids = patch.codebases.map((c) => c.id); - const dup = ids.find((id, idx) => ids.indexOf(id) !== idx); - if (dup !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `codebases[].id 重複: ${dup}`, - path: ['codebases'], - }); - } - } - }); -``` - -- [ ] **Step 4: テストを走らせ成功確認** - -Run: `pnpm -F @tally/core test -- schema.test` -Expected: 追加した `CodebaseSchema` / `ProjectMetaSchema (刷新後)` の全テストが PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/core/src/schema.ts packages/core/src/schema.test.ts -git commit -m "feat(core): CodebaseSchema追加、ProjectMetaSchemaをcodebases[]に刷新 - -codebasePath / additionalCodebasePaths を削除し、codebases: Codebase[] に統一。 -0件許容 + id 重複チェック。ProjectMetaPatchSchema も codebases 全置換方式に。" -``` - ---- - -### Task 2: CodeRefNode に codebaseId を必須化 - -**Files:** -- Modify: `packages/core/src/schema.ts:96-105` -- Test: `packages/core/src/schema.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/core/src/schema.test.ts` に追加: - -```ts -import { CodeRefNodeSchema } from './schema'; - -describe('CodeRefNodeSchema (codebaseId 必須化)', () => { - const base = { id: 'c-1', x: 0, y: 0, title: 't', body: 'b', type: 'coderef' as const }; - - it('codebaseId 必須', () => { - expect(() => CodeRefNodeSchema.parse(base)).toThrow(); - }); - - it('codebaseId があれば合格', () => { - expect(CodeRefNodeSchema.parse({ ...base, codebaseId: 'frontend' }).codebaseId).toBe( - 'frontend', - ); - }); - - it('codebaseId が空文字は拒否', () => { - expect(() => CodeRefNodeSchema.parse({ ...base, codebaseId: '' })).toThrow(); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/core test -- schema.test` -Expected: `CodeRefNodeSchema (codebaseId 必須化)` が FAIL - -- [ ] **Step 3: 最小実装** - -`packages/core/src/schema.ts` の `CodeRefNodeSchema` を置き換え: - -```ts -export const CodeRefNodeSchema = z.object({ - ...baseNodeShape, - type: z.literal('coderef'), - codebaseId: z.string().min(1), - filePath: z.string().optional(), - startLine: z.number().int().nonnegative().optional(), - endLine: z.number().int().nonnegative().optional(), - summary: z.string().optional(), - // analyze-impact 由来のみ記入 (find-related-code は書かない)。spec §1 の棲み分け契約。 - impact: z.string().optional(), -}); -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/core test -- schema.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/core/src/schema.ts packages/core/src/schema.test.ts -git commit -m "feat(core): CodeRefNode に codebaseId を必須フィールドとして追加" -``` - ---- - -## Phase 2: Storage レイヤー - -### Task 3: yaml.ts に atomicWriteFile を追加 - -**Files:** -- Modify: `packages/storage/src/yaml.ts` -- Test: `packages/storage/src/yaml.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/storage/src/yaml.test.ts` に追加: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { atomicWriteFile } from './yaml'; - -describe('atomicWriteFile', () => { - it('temp → rename で書き込み、既存を上書きする', async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-atomic-')); - const target = path.join(dir, 'a.txt'); - await fs.writeFile(target, 'old'); - await atomicWriteFile(target, 'new'); - expect(await fs.readFile(target, 'utf8')).toBe('new'); - // 同じディレクトリに .tmp が残っていない - const entries = await fs.readdir(dir); - expect(entries.filter((e) => e.endsWith('.tmp'))).toHaveLength(0); - }); - - it('親ディレクトリが無ければエラー', async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-atomic-')); - await expect( - atomicWriteFile(path.join(dir, 'nope', 'a.txt'), 'x'), - ).rejects.toThrow(); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- yaml.test` -Expected: FAIL(`atomicWriteFile` 未定義) - -- [ ] **Step 3: 最小実装** - -`packages/storage/src/yaml.ts` の末尾に追加: - -```ts -import { promises as fs } from 'node:fs'; -import path from 'node:path'; - -// 書き込み途中のプロセスダウンでファイルが半壊するのを防ぐため、 -// 同じディレクトリに .tmp-- を書いてから rename で置き換える。 -export async function atomicWriteFile(filePath: string, data: string): Promise { - const dir = path.dirname(filePath); - const base = path.basename(filePath); - const tmp = path.join(dir, `.${base}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`); - try { - await fs.writeFile(tmp, data, 'utf8'); - await fs.rename(tmp, filePath); - } catch (err) { - // rename 失敗時は tmp を片付ける - await fs.rm(tmp, { force: true }); - throw err; - } -} -``` - -既存の `writeYaml` を atomicWriteFile 経由に修正: - -```ts -export async function writeYaml(filePath: string, value: T): Promise { - const dump = yaml.stringify(value); - await atomicWriteFile(filePath, dump); -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- yaml.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/storage/src/yaml.ts packages/storage/src/yaml.test.ts -git commit -m "feat(storage): atomicWriteFile を追加し writeYaml を temp→rename 方式に" -``` - ---- - -### Task 4: registry.ts (home 解決・load/save) - -**Files:** -- Create: `packages/storage/src/registry.ts` -- Test: `packages/storage/src/registry.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/storage/src/registry.test.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - loadRegistry, - resolveRegistryPath, - resolveTallyHome, - saveRegistry, -} from './registry'; - -describe('resolveTallyHome', () => { - const orig = { ...process.env }; - afterEach(() => { - process.env = { ...orig }; - }); - - it('TALLY_HOME が最優先', () => { - process.env.TALLY_HOME = '/override'; - expect(resolveTallyHome()).toBe('/override'); - }); - - it('TALLY_HOME 未設定 + XDG_DATA_HOME あり → /tally', () => { - delete process.env.TALLY_HOME; - process.env.XDG_DATA_HOME = '/xdg'; - expect(resolveTallyHome()).toBe('/xdg/tally'); - }); - - it('両方未設定 → ~/.local/share/tally', () => { - delete process.env.TALLY_HOME; - delete process.env.XDG_DATA_HOME; - expect(resolveTallyHome()).toBe(path.join(os.homedir(), '.local', 'share', 'tally')); - }); -}); - -describe('registry load/save', () => { - let dir: string; - const orig = { ...process.env }; - - beforeEach(async () => { - dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-reg-')); - process.env.TALLY_HOME = dir; - }); - - afterEach(async () => { - process.env = { ...orig }; - await fs.rm(dir, { recursive: true, force: true }); - }); - - it('resolveRegistryPath は /registry.yaml', () => { - expect(resolveRegistryPath()).toBe(path.join(dir, 'registry.yaml')); - }); - - it('ファイルが無ければ空 Registry を返す', async () => { - const reg = await loadRegistry(); - expect(reg).toEqual({ version: 1, projects: [] }); - }); - - it('save → load ラウンドトリップ', async () => { - const reg = { - version: 1 as const, - projects: [ - { id: 'proj-a', path: '/x/y', lastOpenedAt: '2026-04-21T00:00:00Z' }, - ], - }; - await saveRegistry(reg); - expect(await loadRegistry()).toEqual(reg); - }); - - it('壊れた YAML は例外', async () => { - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(path.join(dir, 'registry.yaml'), '::not yaml::', 'utf8'); - await expect(loadRegistry()).rejects.toThrow(); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- registry.test` -Expected: FAIL(registry.ts 未作成) - -- [ ] **Step 3: 最小実装** - -`packages/storage/src/registry.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { z } from 'zod'; -import { atomicWriteFile, readYaml } from './yaml'; - -// --------------------------------------------------------------------------- -// パス解決 -// --------------------------------------------------------------------------- - -// $TALLY_HOME > $XDG_DATA_HOME/tally > ~/.local/share/tally -export function resolveTallyHome(): string { - if (process.env.TALLY_HOME) return process.env.TALLY_HOME; - const xdg = process.env.XDG_DATA_HOME; - if (xdg) return path.join(xdg, 'tally'); - return path.join(os.homedir(), '.local', 'share', 'tally'); -} - -export function resolveRegistryPath(): string { - return path.join(resolveTallyHome(), 'registry.yaml'); -} - -export function resolveDefaultProjectsRoot(): string { - return path.join(resolveTallyHome(), 'projects'); -} - -// --------------------------------------------------------------------------- -// スキーマ -// --------------------------------------------------------------------------- - -export const RegistryEntrySchema = z.object({ - id: z.string().min(1), - path: z.string().min(1), - lastOpenedAt: z.string().min(1), -}); - -export const RegistrySchema = z.object({ - version: z.literal(1), - projects: z.array(RegistryEntrySchema), -}); - -export type RegistryEntry = z.infer; -export type Registry = z.infer; - -const EMPTY_REGISTRY: Registry = { version: 1, projects: [] }; - -// --------------------------------------------------------------------------- -// load / save -// --------------------------------------------------------------------------- - -export async function loadRegistry(): Promise { - const filePath = resolveRegistryPath(); - try { - await fs.stat(filePath); - } catch { - return EMPTY_REGISTRY; - } - const loaded = await readYaml(filePath, RegistrySchema); - return loaded ?? EMPTY_REGISTRY; -} - -export async function saveRegistry(reg: Registry): Promise { - const filePath = resolveRegistryPath(); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - // atomicWriteFile を使うため、直接 YAML 文字列化 - const yaml = (await import('yaml')).default.stringify(RegistrySchema.parse(reg)); - await atomicWriteFile(filePath, yaml); -} -``` - -注: `readYaml` は `packages/storage/src/yaml.ts` で Zod validation 付き読み込みが既に実装されている。無ければこのタスクで追加する(既存を確認しつつ)。 - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- registry.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/storage/src/registry.ts packages/storage/src/registry.test.ts -git commit -m "feat(storage): registry.ts 新設、TALLY_HOME/XDG準拠のパス解決と load/save" -``` - ---- - -### Task 5: registry CRUD (list/register/unregister/touch) - -**Files:** -- Modify: `packages/storage/src/registry.ts` -- Test: `packages/storage/src/registry.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/storage/src/registry.test.ts` に追加: - -```ts -import { listProjects, registerProject, touchProject, unregisterProject } from './registry'; - -describe('registry CRUD', () => { - let dir: string; - const orig = { ...process.env }; - - beforeEach(async () => { - dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-reg-')); - process.env.TALLY_HOME = dir; - }); - afterEach(async () => { - process.env = { ...orig }; - await fs.rm(dir, { recursive: true, force: true }); - }); - - it('registerProject が空 Registry にエントリを追加', async () => { - await registerProject({ id: 'proj-a', path: '/a' }); - const list = await listProjects(); - expect(list).toHaveLength(1); - expect(list[0]?.id).toBe('proj-a'); - expect(list[0]?.path).toBe('/a'); - expect(list[0]?.lastOpenedAt).toMatch(/\dT\d/); - }); - - it('registerProject が既存 id を上書き(後勝ち)', async () => { - await registerProject({ id: 'proj-a', path: '/a' }); - await registerProject({ id: 'proj-a', path: '/b' }); - const list = await listProjects(); - expect(list).toHaveLength(1); - expect(list[0]?.path).toBe('/b'); - }); - - it('unregisterProject が id で削除', async () => { - await registerProject({ id: 'proj-a', path: '/a' }); - await registerProject({ id: 'proj-b', path: '/b' }); - await unregisterProject('proj-a'); - const list = await listProjects(); - expect(list.map((p) => p.id)).toEqual(['proj-b']); - }); - - it('unregisterProject は存在しない id に対して no-op', async () => { - await expect(unregisterProject('does-not-exist')).resolves.toBeUndefined(); - }); - - it('touchProject が lastOpenedAt を更新', async () => { - await registerProject({ id: 'proj-a', path: '/a' }); - const before = (await listProjects())[0]?.lastOpenedAt ?? ''; - await new Promise((r) => setTimeout(r, 10)); - await touchProject('proj-a'); - const after = (await listProjects())[0]?.lastOpenedAt ?? ''; - expect(after > before).toBe(true); - }); - - it('listProjects は lastOpenedAt 降順', async () => { - await registerProject({ id: 'a', path: '/a' }); - await new Promise((r) => setTimeout(r, 10)); - await registerProject({ id: 'b', path: '/b' }); - const list = await listProjects(); - expect(list.map((p) => p.id)).toEqual(['b', 'a']); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- registry.test` -Expected: FAIL - -- [ ] **Step 3: 最小実装** - -`packages/storage/src/registry.ts` の末尾に追加: - -```ts -// --------------------------------------------------------------------------- -// CRUD -// --------------------------------------------------------------------------- - -export async function listProjects(): Promise { - const reg = await loadRegistry(); - return [...reg.projects].sort((a, b) => b.lastOpenedAt.localeCompare(a.lastOpenedAt)); -} - -export async function registerProject(entry: { id: string; path: string }): Promise { - const reg = await loadRegistry(); - const now = new Date().toISOString(); - const next: Registry = { - version: 1, - projects: [ - ...reg.projects.filter((p) => p.id !== entry.id), - { id: entry.id, path: entry.path, lastOpenedAt: now }, - ], - }; - await saveRegistry(next); -} - -export async function unregisterProject(id: string): Promise { - const reg = await loadRegistry(); - const next: Registry = { - version: 1, - projects: reg.projects.filter((p) => p.id !== id), - }; - await saveRegistry(next); -} - -export async function touchProject(id: string): Promise { - const reg = await loadRegistry(); - const now = new Date().toISOString(); - const next: Registry = { - version: 1, - projects: reg.projects.map((p) => (p.id === id ? { ...p, lastOpenedAt: now } : p)), - }; - await saveRegistry(next); -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- registry.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/storage/src/registry.ts packages/storage/src/registry.test.ts -git commit -m "feat(storage): registry CRUD (list/register/unregister/touch)" -``` - ---- - -### Task 6: project-dir.ts で projectDir 直下の path 解決 - -**Files:** -- Create: `packages/storage/src/project-dir.ts` -- Test: `packages/storage/src/project-dir.test.ts` -- Delete: `packages/storage/src/paths.ts`、`packages/storage/src/paths.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/storage/src/project-dir.test.ts`: - -```ts -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { chatFileName, nodeFileName, resolveProjectPaths } from './project-dir'; - -describe('resolveProjectPaths', () => { - it('projectDir 直下を直接指す (.tally/ サブディレクトリを挟まない)', () => { - const paths = resolveProjectPaths('/root/my-proj'); - expect(paths.root).toBe('/root/my-proj'); - expect(paths.projectFile).toBe(path.join('/root/my-proj', 'project.yaml')); - expect(paths.nodesDir).toBe(path.join('/root/my-proj', 'nodes')); - expect(paths.edgesDir).toBe(path.join('/root/my-proj', 'edges')); - expect(paths.edgesFile).toBe(path.join('/root/my-proj', 'edges', 'edges.yaml')); - expect(paths.chatsDir).toBe(path.join('/root/my-proj', 'chats')); - }); - - it('相対パスは絶対化', () => { - const cwd = process.cwd(); - const paths = resolveProjectPaths('rel/sub'); - expect(paths.root).toBe(path.join(cwd, 'rel', 'sub')); - }); -}); - -describe('file name helpers', () => { - it('nodeFileName', () => { - expect(nodeFileName('req-abc')).toBe('req-abc.yaml'); - }); - it('chatFileName', () => { - expect(chatFileName('chat-xyz')).toBe('chat-xyz.yaml'); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- project-dir.test` -Expected: FAIL(未作成) - -- [ ] **Step 3: 最小実装** - -`packages/storage/src/project-dir.ts`: - -```ts -import path from 'node:path'; - -// プロジェクトディレクトリ直下の各 path を集約。.tally/ サブディレクトリは挟まない。 -export interface ProjectPaths { - root: string; - projectFile: string; - nodesDir: string; - edgesDir: string; - edgesFile: string; - chatsDir: string; -} - -export function resolveProjectPaths(projectDir: string): ProjectPaths { - const root = path.resolve(projectDir); - return { - root, - projectFile: path.join(root, 'project.yaml'), - nodesDir: path.join(root, 'nodes'), - edgesDir: path.join(root, 'edges'), - edgesFile: path.join(root, 'edges', 'edges.yaml'), - chatsDir: path.join(root, 'chats'), - }; -} - -export function nodeFileName(id: string): string { - return `${id}.yaml`; -} - -export function chatFileName(threadId: string): string { - return `${threadId}.yaml`; -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- project-dir.test` -Expected: PASS - -- [ ] **Step 5: paths.ts を削除** - -```bash -git rm packages/storage/src/paths.ts packages/storage/src/paths.test.ts -``` - -- [ ] **Step 6: コミット** - -```bash -git add packages/storage/src/project-dir.ts packages/storage/src/project-dir.test.ts -git commit -m "feat(storage): project-dir.ts 新設、paths.ts 削除 - -プロジェクトディレクトリ = 任意のディレクトリ、.tally/ サブディレクトリ規約廃止。" -``` - ---- - -### Task 7: project-store.ts を projectDir + codebases 対応に刷新 - -**Files:** -- Modify: `packages/storage/src/project-store.ts`(大幅書き換え) -- Modify: `packages/storage/src/project-store.test.ts` - -- [ ] **Step 1: テストを書き換え(既存テストは `.tally/` 前提のため全面書き直し)** - -既存テストの import 先を `resolveTallyPaths` → `resolveProjectPaths` に変え、`new FileSystemProjectStore(workspaceRoot)` を `new FileSystemProjectStore(projectDir)` に変える。`codebases: []` を meta に含める。 - -`packages/storage/src/project-store.test.ts` の先頭部(setup)例: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { FileSystemProjectStore } from './project-store'; -import { resolveProjectPaths } from './project-dir'; - -let projectDir: string; -beforeEach(async () => { - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-proj-')); - // project.yaml + nodes/ + edges/edges.yaml の土台を作る - const paths = resolveProjectPaths(projectDir); - await fs.mkdir(paths.nodesDir, { recursive: true }); - await fs.mkdir(paths.edgesDir, { recursive: true }); - await fs.writeFile(paths.edgesFile, 'edges: []\n'); -}); -afterEach(async () => { - await fs.rm(projectDir, { recursive: true, force: true }); -}); -``` - -既存テストの ProjectMeta 生成部を全て以下パターンに統一: - -```ts -const meta = { - id: 'proj-test', - name: 'test', - codebases: [], - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', -}; -``` - -追加テスト(codebases 系): - -```ts -describe('codebases roundtrip', () => { - it('0 件の codebases を save/load', async () => { - const store = new FileSystemProjectStore(projectDir); - await store.saveProjectMeta({ - id: 'proj-a', - name: 'a', - codebases: [], - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }); - const loaded = await store.getProjectMeta(); - expect(loaded?.codebases).toEqual([]); - }); - - it('複数 codebases を save/load', async () => { - const store = new FileSystemProjectStore(projectDir); - const codebases = [ - { id: 'frontend', label: 'Web', path: '/a' }, - { id: 'backend', label: 'API', path: '/b' }, - ]; - await store.saveProjectMeta({ - id: 'proj-a', - name: 'a', - codebases, - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }); - expect((await store.getProjectMeta())?.codebases).toEqual(codebases); - }); -}); - -describe('coderef codebaseId 整合性', () => { - it('存在しない codebaseId の coderef 追加は拒否', async () => { - const store = new FileSystemProjectStore(projectDir); - await store.saveProjectMeta({ - id: 'proj-a', - name: 'a', - codebases: [{ id: 'frontend', label: 'W', path: '/a' }], - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }); - await expect( - store.addNode({ - type: 'coderef', - x: 0, - y: 0, - title: 't', - body: 'b', - codebaseId: 'unknown', - }), - ).rejects.toThrow(/codebaseId/); - }); - - it('存在する codebaseId なら合格', async () => { - const store = new FileSystemProjectStore(projectDir); - await store.saveProjectMeta({ - id: 'proj-a', - name: 'a', - codebases: [{ id: 'frontend', label: 'W', path: '/a' }], - createdAt: '2026-04-21T00:00:00Z', - updatedAt: '2026-04-21T00:00:00Z', - }); - const node = await store.addNode({ - type: 'coderef', - x: 0, - y: 0, - title: 't', - body: 'b', - codebaseId: 'frontend', - }); - expect(node.codebaseId).toBe('frontend'); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- project-store.test` -Expected: FAIL(旧 `resolveTallyPaths` が未定義 / 新仕様未対応) - -- [ ] **Step 3: 最小実装** - -`packages/storage/src/project-store.ts` の先頭 import と `FileSystemProjectStore` コンストラクタを変更: - -```ts -import { - // ... 既存 -} from '@tally/core'; -import { nodeFileName, resolveProjectPaths } from './project-dir'; -import { readYaml, writeYaml } from './yaml'; -// ... - -export class FileSystemProjectStore implements ProjectStore { - private readonly paths: ReturnType; - - constructor(projectDir: string) { - this.paths = resolveProjectPaths(projectDir); - } - // ... 以下既存のまま、workspaceRoot 参照を paths.root に統一 -``` - -`addNode` の coderef 分岐に codebaseId 整合性検証を追加: - -```ts -async addNode(draft: D): Promise> { - if (draft.type === 'coderef') { - const meta = await this.getProjectMeta(); - const cbIds = new Set(meta?.codebases.map((c) => c.id) ?? []); - if (!cbIds.has((draft as unknown as { codebaseId: string }).codebaseId)) { - throw new Error( - `coderef.codebaseId が projectMeta.codebases に存在しない: ${(draft as unknown as { codebaseId: string }).codebaseId}`, - ); - } - } - // ... 既存ロジック -} -``` - -`updateNode` / `transmuteNode` にも同様の検証を加える(coderef に変換するケース・codebaseId 変更ケース)。 - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- project-store.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/storage/src/project-store.ts packages/storage/src/project-store.test.ts -git commit -m "refactor(storage): FileSystemProjectStore を projectDir + codebases[] に刷新 - -- コンストラクタ引数 workspaceRoot を projectDir にリネーム -- resolveTallyPaths → resolveProjectPaths 経由に -- coderef 追加/更新時に codebaseId の整合性を検証" -``` - ---- - -### Task 8: chat-store.ts と clear-project.ts を projectDir に追従 - -**Files:** -- Modify: `packages/storage/src/chat-store.ts` + `.test.ts` -- Modify: `packages/storage/src/clear-project.ts` + `.test.ts` - -- [ ] **Step 1: 既存テストを workspaceRoot → projectDir に一括 rename** - -各 `.test.ts` で以下置換: -- 変数 `workspaceRoot` → `projectDir` -- `resolveTallyPaths(workspaceRoot)` → `resolveProjectPaths(projectDir)` -- setup の `.tally/` サブディレクトリ作成を廃止し、`projectDir` 直下に `nodes/`, `edges/`, `chats/` を作る - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- chat-store.test clear-project.test` -Expected: FAIL - -- [ ] **Step 3: 最小実装** - -`chat-store.ts`: - -```ts -import { chatFileName, resolveProjectPaths } from './project-dir'; -// ... -export class FileSystemChatStore implements ChatStore { - private readonly paths: ReturnType; - - constructor(projectDir: string) { - this.paths = resolveProjectPaths(projectDir); - } - // 以下既存ロジック、this.paths.chatsDir 参照 -} -``` - -`clear-project.ts` も同様に `workspaceRoot` → `projectDir` にリネーム、`resolveProjectPaths` 経由に。 - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- chat-store.test clear-project.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/storage/src/chat-store.ts packages/storage/src/chat-store.test.ts \ - packages/storage/src/clear-project.ts packages/storage/src/clear-project.test.ts -git commit -m "refactor(storage): chat-store / clear-project を projectDir に追従" -``` - ---- - -### Task 9: init-project.ts を registry 登録 + codebases[] 対応に刷新 - -**Files:** -- Modify: `packages/storage/src/init-project.ts` -- Modify: `packages/storage/src/init-project.test.ts` - -- [ ] **Step 1: テストを書き換える** - -`packages/storage/src/init-project.test.ts` を全面書き直し: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { initProject } from './init-project'; -import { listProjects } from './registry'; - -let tallyHome: string; -let workspace: string; -const orig = { ...process.env }; - -beforeEach(async () => { - tallyHome = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-home-')); - workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-ws-')); - process.env.TALLY_HOME = tallyHome; -}); -afterEach(async () => { - process.env = { ...orig }; - await fs.rm(tallyHome, { recursive: true, force: true }); - await fs.rm(workspace, { recursive: true, force: true }); -}); - -describe('initProject', () => { - it('空 projectDir に project.yaml / nodes / edges を作り registry に登録', async () => { - const projectDir = path.join(workspace, 'new-proj'); - const result = await initProject({ - projectDir, - name: 'new proj', - codebases: [], - }); - expect(result.id).toMatch(/^proj-/); - expect(result.projectDir).toBe(projectDir); - expect((await fs.stat(path.join(projectDir, 'project.yaml'))).isFile()).toBe(true); - expect((await fs.stat(path.join(projectDir, 'nodes'))).isDirectory()).toBe(true); - expect((await fs.stat(path.join(projectDir, 'edges', 'edges.yaml'))).isFile()).toBe(true); - const reg = await listProjects(); - expect(reg.map((p) => p.id)).toContain(result.id); - }); - - it('codebases を受け取って保存', async () => { - const projectDir = path.join(workspace, 'with-cb'); - const codebases = [{ id: 'web', label: 'Web', path: '/w' }]; - await initProject({ projectDir, name: 'x', codebases }); - const raw = await fs.readFile(path.join(projectDir, 'project.yaml'), 'utf8'); - expect(raw).toContain('web'); - expect(raw).toContain('/w'); - }); - - it('codebases 0 件でも成功する', async () => { - const projectDir = path.join(workspace, 'no-cb'); - await expect(initProject({ projectDir, name: 'x', codebases: [] })).resolves.toBeDefined(); - }); - - it('既存の project.yaml を含む dir は拒否', async () => { - const projectDir = path.join(workspace, 'existing'); - await fs.mkdir(projectDir); - await fs.writeFile(path.join(projectDir, 'project.yaml'), 'id: old\n'); - await expect( - initProject({ projectDir, name: 'x', codebases: [] }), - ).rejects.toThrow(/既存の project\.yaml/); - }); - - it('非空の dir で project.yaml 無しは拒否', async () => { - const projectDir = path.join(workspace, 'dirty'); - await fs.mkdir(projectDir); - await fs.writeFile(path.join(projectDir, 'random.txt'), 'x'); - await expect( - initProject({ projectDir, name: 'x', codebases: [] }), - ).rejects.toThrow(/空ではありません/); - }); - - it('存在しないパスでも親ディレクトリが存在すれば成功', async () => { - const projectDir = path.join(workspace, 'fresh'); - await initProject({ projectDir, name: 'x', codebases: [] }); - expect((await fs.stat(projectDir)).isDirectory()).toBe(true); - }); - - it('親ディレクトリが存在しないパスは拒否', async () => { - const projectDir = path.join(workspace, 'missing-parent', 'sub'); - await expect( - initProject({ projectDir, name: 'x', codebases: [] }), - ).rejects.toThrow(/親ディレクトリ/); - }); - - it('name が空は拒否', async () => { - await expect( - initProject({ projectDir: path.join(workspace, 'p'), name: ' ', codebases: [] }), - ).rejects.toThrow(/name/); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/storage test -- init-project.test` -Expected: FAIL - -- [ ] **Step 3: 最小実装** - -`packages/storage/src/init-project.ts` を全面書き直し: - -```ts -import { promises as fs } from 'node:fs'; -import path from 'node:path'; - -import { newProjectId } from '@tally/core'; -import type { Codebase } from '@tally/core'; - -import { FileSystemProjectStore } from './project-store'; -import { registerProject } from './registry'; -import { resolveProjectPaths } from './project-dir'; - -export interface InitProjectInput { - projectDir: string; // 絶対または相対。相対は cwd 基準で解決 - name: string; - description?: string; - codebases: Codebase[]; -} - -export interface InitProjectResult { - id: string; - projectDir: string; -} - -export async function initProject(input: InitProjectInput): Promise { - const absDir = path.resolve(input.projectDir); - - const name = input.name.trim(); - if (name.length === 0) throw new Error('name が空'); - - // 親ディレクトリが存在するか - const parent = path.dirname(absDir); - try { - const st = await fs.stat(parent); - if (!st.isDirectory()) throw new Error(`親ディレクトリがディレクトリではない: ${parent}`); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT') throw new Error(`親ディレクトリが存在しない: ${parent}`); - throw err; - } - - // projectDir 自身の状態を判定 - let exists = false; - try { - const st = await fs.stat(absDir); - if (!st.isDirectory()) throw new Error(`projectDir がディレクトリではない: ${absDir}`); - exists = true; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - - if (exists) { - const entries = await fs.readdir(absDir); - if (entries.includes('project.yaml')) { - throw new Error(`既存の project.yaml が存在: ${absDir}`); - } - if (entries.length > 0) { - throw new Error(`ディレクトリが空ではありません: ${absDir}`); - } - } else { - await fs.mkdir(absDir); - } - - const paths = resolveProjectPaths(absDir); - await fs.mkdir(paths.nodesDir, { recursive: true }); - await fs.mkdir(paths.edgesDir, { recursive: true }); - await fs.writeFile(paths.edgesFile, 'edges: []\n', 'utf8'); - - const id = newProjectId(); - const now = new Date().toISOString(); - const store = new FileSystemProjectStore(absDir); - await store.saveProjectMeta({ - id, - name, - ...(input.description ? { description: input.description } : {}), - codebases: input.codebases, - createdAt: now, - updatedAt: now, - }); - - await registerProject({ id, path: absDir }); - - return { id, projectDir: absDir }; -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/storage test -- init-project.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/storage/src/init-project.ts packages/storage/src/init-project.test.ts -git commit -m "refactor(storage): initProject を projectDir + codebases[] + registry 登録に刷新" -``` - ---- - -### Task 10: project-resolver.ts 削除と index.ts 整理 - -**Files:** -- Delete: `packages/storage/src/project-resolver.ts` + `.test.ts` -- Modify: `packages/storage/src/index.ts` - -- [ ] **Step 1: 新 index.ts を書く** - -`packages/storage/src/index.ts`: - -```ts -export const PACKAGE_NAME = '@tally/storage'; - -export { FileSystemProjectStore } from './project-store'; -export type { NodeDraft, NodePatch, ProjectStore } from './project-store'; -export { FileSystemChatStore } from './chat-store'; -export type { ChatStore, CreateChatInput } from './chat-store'; -export { - chatFileName, - nodeFileName, - resolveProjectPaths, -} from './project-dir'; -export type { ProjectPaths } from './project-dir'; -export { YamlValidationError, atomicWriteFile, readYaml, writeYaml } from './yaml'; -export { - listProjects, - loadRegistry, - registerProject, - resolveDefaultProjectsRoot, - resolveRegistryPath, - resolveTallyHome, - saveRegistry, - touchProject, - unregisterProject, -} from './registry'; -export type { Registry, RegistryEntry } from './registry'; -export { initProject } from './init-project'; -export type { InitProjectInput, InitProjectResult } from './init-project'; -export { clearProject } from './clear-project'; -export type { ClearProjectResult } from './clear-project'; -``` - -- [ ] **Step 2: project-resolver.ts を削除** - -```bash -git rm packages/storage/src/project-resolver.ts packages/storage/src/project-resolver.test.ts -``` - -- [ ] **Step 3: テスト全体確認** - -Run: `pnpm -F @tally/storage test` -Expected: PASS(storage 内部は他に依存がない) - -- [ ] **Step 4: コミット** - -```bash -git add packages/storage/src/index.ts -git commit -m "refactor(storage): project-resolver.ts 削除、index.ts の export を registry/project-dir 中心に整理" -``` - ---- - -## Phase 3: バックエンド API - -### Task 11: GET /api/fs/ls - -**Files:** -- Create: `packages/frontend/src/app/api/fs/ls/route.ts` -- Create: `packages/frontend/src/app/api/fs/ls/route.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/frontend/src/app/api/fs/ls/route.test.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { GET } from './route'; - -let dir: string; - -beforeEach(async () => { - dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-fs-')); -}); -afterEach(async () => { - await fs.rm(dir, { recursive: true, force: true }); -}); - -function req(pathParam?: string): Request { - const url = new URL('http://localhost/api/fs/ls'); - if (pathParam !== undefined) url.searchParams.set('path', pathParam); - return new Request(url); -} - -describe('GET /api/fs/ls', () => { - it('ディレクトリのみを返し、ファイルは含めない', async () => { - await fs.mkdir(path.join(dir, 'subA')); - await fs.mkdir(path.join(dir, '.hidden')); - await fs.writeFile(path.join(dir, 'file.txt'), 'x'); - const res = await GET(req(dir)); - expect(res.status).toBe(200); - const body = (await res.json()) as { - entries: { name: string; isHidden: boolean; hasProjectYaml: boolean }[]; - }; - const names = body.entries.map((e) => e.name).sort(); - expect(names).toEqual(['.hidden', 'subA']); - const hidden = body.entries.find((e) => e.name === '.hidden'); - expect(hidden?.isHidden).toBe(true); - }); - - it('子に project.yaml があれば hasProjectYaml: true', async () => { - const sub = path.join(dir, 'proj'); - await fs.mkdir(sub); - await fs.writeFile(path.join(sub, 'project.yaml'), 'id: x'); - const res = await GET(req(dir)); - const body = (await res.json()) as { - entries: { name: string; hasProjectYaml: boolean }[]; - }; - expect(body.entries.find((e) => e.name === 'proj')?.hasProjectYaml).toBe(true); - }); - - it('dir 自身が project.yaml を含むなら containsProjectYaml: true', async () => { - await fs.writeFile(path.join(dir, 'project.yaml'), 'id: x'); - const res = await GET(req(dir)); - const body = (await res.json()) as { containsProjectYaml: boolean }; - expect(body.containsProjectYaml).toBe(true); - }); - - it('parent は 1 階層上', async () => { - const sub = path.join(dir, 'a', 'b'); - await fs.mkdir(sub, { recursive: true }); - const res = await GET(req(sub)); - const body = (await res.json()) as { parent: string }; - expect(body.parent).toBe(path.join(dir, 'a')); - }); - - it('parent がシステムルートなら null', async () => { - const res = await GET(req('/')); - const body = (await res.json()) as { parent: string | null }; - expect(body.parent).toBeNull(); - }); - - it('path が相対パスは 400', async () => { - const res = await GET(req('relative/path')); - expect(res.status).toBe(400); - }); - - it('path が未指定なら HOME にフォールバック', async () => { - const res = await GET(req()); - expect(res.status).toBe(200); - const body = (await res.json()) as { path: string }; - expect(body.path).toBe(os.homedir()); - }); - - it('path 不在は 404', async () => { - const res = await GET(req(path.join(dir, 'does-not-exist'))); - expect(res.status).toBe(404); - }); - - it('.. を含む path は path.resolve で正規化して処理', async () => { - const sub = path.join(dir, 'a'); - await fs.mkdir(sub); - const weird = `${sub}/../a`; - const res = await GET(req(weird)); - expect(res.status).toBe(200); - const body = (await res.json()) as { path: string }; - expect(body.path).toBe(path.resolve(weird)); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- fs/ls/route.test` -Expected: FAIL(未作成) - -- [ ] **Step 3: 最小実装** - -`packages/frontend/src/app/api/fs/ls/route.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { NextResponse } from 'next/server'; - -export const dynamic = 'force-dynamic'; - -export async function GET(req: Request): Promise { - const url = new URL(req.url); - const raw = url.searchParams.get('path'); - const target = raw ?? os.homedir(); - if (!path.isAbsolute(target)) { - return NextResponse.json({ error: 'path は絶対パスのみ' }, { status: 400 }); - } - const normalized = path.resolve(target); - - let stat: Awaited>; - try { - stat = await fs.stat(normalized); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT') { - return NextResponse.json({ error: 'ディレクトリが存在しない' }, { status: 404 }); - } - if (code === 'EACCES') { - return NextResponse.json({ error: '権限がない' }, { status: 403 }); - } - throw err; - } - if (!stat.isDirectory()) { - return NextResponse.json({ error: 'ディレクトリではない' }, { status: 400 }); - } - - const parent = path.dirname(normalized); - const parentResolved = parent === normalized ? null : parent; - - let rawEntries: import('node:fs').Dirent[]; - try { - rawEntries = await fs.readdir(normalized, { withFileTypes: true }); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'EACCES') { - return NextResponse.json( - { - path: normalized, - parent: parentResolved, - entries: [], - containsProjectYaml: false, - }, - { status: 200 }, - ); - } - throw err; - } - - const entries = await Promise.all( - rawEntries - .filter((e) => e.isDirectory()) - .map(async (e) => { - const childPath = path.join(normalized, e.name); - let hasProjectYaml = false; - try { - await fs.stat(path.join(childPath, 'project.yaml')); - hasProjectYaml = true; - } catch { - /* なし */ - } - return { - name: e.name, - path: childPath, - isHidden: e.name.startsWith('.'), - hasProjectYaml, - }; - }), - ); - - let containsProjectYaml = false; - try { - await fs.stat(path.join(normalized, 'project.yaml')); - containsProjectYaml = true; - } catch { - /* なし */ - } - - return NextResponse.json( - { - path: normalized, - parent: parentResolved, - entries: entries.sort((a, b) => a.name.localeCompare(b.name, 'ja')), - containsProjectYaml, - }, - { status: 200 }, - ); -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- fs/ls/route.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/app/api/fs/ls/ -git commit -m "feat(frontend): GET /api/fs/ls 追加(ディレクトリ一覧 + project.yaml 検出)" -``` - ---- - -### Task 12: POST /api/fs/mkdir - -**Files:** -- Create: `packages/frontend/src/app/api/fs/mkdir/route.ts` -- Create: `packages/frontend/src/app/api/fs/mkdir/route.test.ts` - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/frontend/src/app/api/fs/mkdir/route.test.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { POST } from './route'; - -let dir: string; - -beforeEach(async () => { - dir = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-mkdir-')); -}); -afterEach(async () => { - await fs.rm(dir, { recursive: true, force: true }); -}); - -function req(body: unknown): Request { - return new Request('http://localhost/api/fs/mkdir', { - method: 'POST', - body: JSON.stringify(body), - }); -} - -describe('POST /api/fs/mkdir', () => { - it('新規ディレクトリを作成して 201 を返す', async () => { - const res = await POST(req({ path: dir, name: 'new-sub' })); - expect(res.status).toBe(201); - expect( - (await fs.stat(path.join(dir, 'new-sub'))).isDirectory(), - ).toBe(true); - }); - - it('既存は 409', async () => { - await fs.mkdir(path.join(dir, 'exists')); - const res = await POST(req({ path: dir, name: 'exists' })); - expect(res.status).toBe(409); - }); - - it('name に / を含むと 400', async () => { - const res = await POST(req({ path: dir, name: 'a/b' })); - expect(res.status).toBe(400); - }); - - it('name が .. は 400', async () => { - const res = await POST(req({ path: dir, name: '..' })); - expect(res.status).toBe(400); - }); - - it('name が空は 400', async () => { - const res = await POST(req({ path: dir, name: '' })); - expect(res.status).toBe(400); - }); - - it('path が相対パスは 400', async () => { - const res = await POST(req({ path: 'rel', name: 'a' })); - expect(res.status).toBe(400); - }); - - it('親 path が不在は 404', async () => { - const res = await POST(req({ path: path.join(dir, 'nope'), name: 'x' })); - expect(res.status).toBe(404); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- fs/mkdir/route.test` -Expected: FAIL - -- [ ] **Step 3: 最小実装** - -`packages/frontend/src/app/api/fs/mkdir/route.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { NextResponse } from 'next/server'; - -export const dynamic = 'force-dynamic'; - -export async function POST(req: Request): Promise { - const raw = (await req.json().catch(() => null)) as { - path?: unknown; - name?: unknown; - } | null; - if (!raw || typeof raw.path !== 'string' || typeof raw.name !== 'string') { - return NextResponse.json({ error: 'invalid body' }, { status: 400 }); - } - const parent = raw.path; - const name = raw.name; - - if (!path.isAbsolute(parent)) { - return NextResponse.json({ error: 'path は絶対パスのみ' }, { status: 400 }); - } - if (name.length === 0 || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { - return NextResponse.json({ error: 'name が不正' }, { status: 400 }); - } - - const parentNorm = path.resolve(parent); - const target = path.resolve(parentNorm, name); - // 二重防御: 正規化後ターゲットが parent 配下であること - if (!target.startsWith(`${parentNorm}${path.sep}`) && target !== parentNorm) { - return NextResponse.json({ error: 'path traversal 検出' }, { status: 400 }); - } - - try { - const st = await fs.stat(parentNorm); - if (!st.isDirectory()) { - return NextResponse.json({ error: 'path がディレクトリではない' }, { status: 400 }); - } - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT') { - return NextResponse.json({ error: '親ディレクトリが存在しない' }, { status: 404 }); - } - throw err; - } - - try { - await fs.mkdir(target); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'EEXIST') { - return NextResponse.json({ error: '既に存在' }, { status: 409 }); - } - throw err; - } - - return NextResponse.json({ path: target }, { status: 201 }); -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- fs/mkdir/route.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/app/api/fs/mkdir/ -git commit -m "feat(frontend): POST /api/fs/mkdir 追加(path traversal 二重防御)" -``` - ---- - -### Task 13: GET /api/projects を registry 駆動に書き換え、workspace-candidates 削除 - -**Files:** -- Modify: `packages/frontend/src/app/api/projects/route.ts` -- Delete: `packages/frontend/src/app/api/workspace-candidates/route.ts` -- Modify: 既存 `route.test.ts` があれば更新、無ければ新規 - -- [ ] **Step 1: 失敗するテストを書く** - -`packages/frontend/src/app/api/projects/route.test.ts`(無ければ新規作成): - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { GET, POST } from './route'; -import { resolveTallyHome } from '@tally/storage'; - -let home: string; -let workspace: string; -const orig = { ...process.env }; - -beforeEach(async () => { - home = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-home-')); - workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-ws-')); - process.env.TALLY_HOME = home; -}); -afterEach(async () => { - process.env = { ...orig }; - await fs.rm(home, { recursive: true, force: true }); - await fs.rm(workspace, { recursive: true, force: true }); -}); - -describe('GET /api/projects', () => { - it('registry が空なら空配列', async () => { - const res = await GET(); - const body = (await res.json()) as { projects: unknown[] }; - expect(body.projects).toEqual([]); - }); - - it('POST で作ると GET に現れ、lastOpenedAt 降順で並ぶ', async () => { - await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ - projectDir: path.join(workspace, 'a'), - name: 'A', - codebases: [], - }), - }), - ); - await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ - projectDir: path.join(workspace, 'b'), - name: 'B', - codebases: [], - }), - }), - ); - const res = await GET(); - const body = (await res.json()) as { - projects: { id: string; name: string; projectDir: string }[]; - }; - expect(body.projects.map((p) => p.name)).toEqual(['B', 'A']); - }); -}); - -describe('POST /api/projects', () => { - it('codebases を受け付けて registry に登録', async () => { - const res = await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ - projectDir: path.join(workspace, 'x'), - name: 'X', - codebases: [{ id: 'web', label: 'Web', path: '/w' }], - }), - }), - ); - expect(res.status).toBe(201); - }); - - it('codebases 欠落は 400', async () => { - const res = await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ projectDir: path.join(workspace, 'y'), name: 'Y' }), - }), - ); - expect(res.status).toBe(400); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- api/projects/route.test` -Expected: FAIL(旧実装は `codebases` を知らない) - -- [ ] **Step 3: 最小実装** - -`packages/frontend/src/app/api/projects/route.ts`: - -```ts -import { - FileSystemProjectStore, - initProject, - listProjects, -} from '@tally/storage'; -import { CodebaseSchema } from '@tally/core'; -import { NextResponse } from 'next/server'; -import { z } from 'zod'; - -export const dynamic = 'force-dynamic'; - -export async function GET(): Promise { - const entries = await listProjects(); - const projects = await Promise.all( - entries.map(async (e) => { - try { - const store = new FileSystemProjectStore(e.path); - const meta = await store.getProjectMeta(); - if (!meta) return null; - return { - id: meta.id, - name: meta.name, - description: meta.description ?? null, - codebases: meta.codebases, - projectDir: e.path, - createdAt: meta.createdAt, - updatedAt: meta.updatedAt, - lastOpenedAt: e.lastOpenedAt, - }; - } catch { - // path 先が壊れている等は一覧から除外(UI で別途再選択を促す) - return null; - } - }), - ); - return NextResponse.json({ - projects: projects.filter((p): p is NonNullable => p !== null), - }); -} - -const CreateBodySchema = z.object({ - projectDir: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), - codebases: z.array(CodebaseSchema), -}); - -export async function POST(req: Request): Promise { - const raw = await req.json().catch(() => null); - const parsed = CreateBodySchema.safeParse(raw); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.message }, { status: 400 }); - } - try { - const result = await initProject(parsed.data); - return NextResponse.json(result, { status: 201 }); - } catch (err) { - return NextResponse.json({ error: String((err as Error).message ?? err) }, { status: 400 }); - } -} -``` - -- [ ] **Step 4: workspace-candidates を削除** - -```bash -git rm -r packages/frontend/src/app/api/workspace-candidates/ -``` - -- [ ] **Step 5: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- api/projects/route.test` -Expected: PASS - -- [ ] **Step 6: コミット** - -```bash -git add packages/frontend/src/app/api/projects/ -git commit -m "refactor(frontend): GET/POST /api/projects を registry + codebases[] 駆動に刷新 - -workspace-candidates route を削除。" -``` - ---- - -### Task 14: registry import / unregister / touch API - -**Files:** -- Create: `packages/frontend/src/app/api/projects/import/route.ts` + `.test.ts` -- Create: `packages/frontend/src/app/api/projects/[id]/unregister/route.ts` + `.test.ts` -- Modify: `packages/frontend/src/app/api/projects/[id]/route.ts`(touch を GET で呼ぶ) - -- [ ] **Step 1: 失敗するテストを書く(import)** - -`packages/frontend/src/app/api/projects/import/route.test.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { POST } from './route'; - -let home: string; -let ws: string; -const orig = { ...process.env }; - -beforeEach(async () => { - home = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-home-')); - ws = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-ws-')); - process.env.TALLY_HOME = home; -}); -afterEach(async () => { - process.env = { ...orig }; - await fs.rm(home, { recursive: true, force: true }); - await fs.rm(ws, { recursive: true, force: true }); -}); - -describe('POST /api/projects/import', () => { - it('project.yaml を含む dir を登録', async () => { - const dir = path.join(ws, 'imp'); - await fs.mkdir(dir); - await fs.writeFile( - path.join(dir, 'project.yaml'), - 'id: proj-imported\nname: imp\ncodebases: []\ncreatedAt: "2026-04-21T00:00:00Z"\nupdatedAt: "2026-04-21T00:00:00Z"\n', - ); - const res = await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ projectDir: dir }), - }), - ); - expect(res.status).toBe(201); - const body = (await res.json()) as { id: string }; - expect(body.id).toBe('proj-imported'); - }); - - it('project.yaml が無ければ 400', async () => { - const dir = path.join(ws, 'empty'); - await fs.mkdir(dir); - const res = await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ projectDir: dir }), - }), - ); - expect(res.status).toBe(400); - }); - - it('同じ id のプロジェクトが既に登録されていれば 409', async () => { - const dir1 = path.join(ws, 'a'); - const dir2 = path.join(ws, 'b'); - for (const d of [dir1, dir2]) { - await fs.mkdir(d); - await fs.writeFile( - path.join(d, 'project.yaml'), - 'id: proj-same\nname: s\ncodebases: []\ncreatedAt: "2026-04-21T00:00:00Z"\nupdatedAt: "2026-04-21T00:00:00Z"\n', - ); - } - const r1 = await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ projectDir: dir1 }), - }), - ); - expect(r1.status).toBe(201); - const r2 = await POST( - new Request('http://localhost', { - method: 'POST', - body: JSON.stringify({ projectDir: dir2 }), - }), - ); - expect(r2.status).toBe(409); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- projects/import/route.test` -Expected: FAIL - -- [ ] **Step 3: import 実装** - -`packages/frontend/src/app/api/projects/import/route.ts`: - -```ts -import path from 'node:path'; -import { - FileSystemProjectStore, - listProjects, - registerProject, -} from '@tally/storage'; -import { NextResponse } from 'next/server'; -import { z } from 'zod'; - -export const dynamic = 'force-dynamic'; - -const Body = z.object({ projectDir: z.string().min(1) }); - -export async function POST(req: Request): Promise { - const parsed = Body.safeParse(await req.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.message }, { status: 400 }); - } - const absDir = path.resolve(parsed.data.projectDir); - const store = new FileSystemProjectStore(absDir); - const meta = await store.getProjectMeta(); - if (!meta) { - return NextResponse.json( - { error: 'project.yaml が見つからない' }, - { status: 400 }, - ); - } - const existing = await listProjects(); - if (existing.some((p) => p.id === meta.id && p.path !== absDir)) { - return NextResponse.json( - { error: `id 衝突: ${meta.id} は別のパスで既に登録されている` }, - { status: 409 }, - ); - } - await registerProject({ id: meta.id, path: absDir }); - return NextResponse.json({ id: meta.id, projectDir: absDir }, { status: 201 }); -} -``` - -- [ ] **Step 4: unregister テスト + 実装** - -`packages/frontend/src/app/api/projects/[id]/unregister/route.test.ts`: - -```ts -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { listProjects, registerProject } from '@tally/storage'; -import { POST } from './route'; - -let home: string; -const orig = { ...process.env }; -beforeEach(async () => { - home = await fs.mkdtemp(path.join(os.tmpdir(), 'tally-home-')); - process.env.TALLY_HOME = home; -}); -afterEach(async () => { - process.env = { ...orig }; - await fs.rm(home, { recursive: true, force: true }); -}); - -describe('POST /api/projects/:id/unregister', () => { - it('registry から外す(ディレクトリは消さない)', async () => { - await registerProject({ id: 'proj-a', path: '/some/dir' }); - const res = await POST(new Request('http://localhost'), { - params: Promise.resolve({ id: 'proj-a' }), - }); - expect(res.status).toBe(204); - expect(await listProjects()).toEqual([]); - }); -}); -``` - -`packages/frontend/src/app/api/projects/[id]/unregister/route.ts`: - -```ts -import { unregisterProject } from '@tally/storage'; -import { NextResponse } from 'next/server'; - -export const dynamic = 'force-dynamic'; - -export async function POST( - _req: Request, - ctx: { params: Promise<{ id: string }> }, -): Promise { - const { id } = await ctx.params; - await unregisterProject(id); - return new NextResponse(null, { status: 204 }); -} -``` - -- [ ] **Step 5: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- projects/import/ projects/\\[id\\]/unregister/` -Expected: PASS - -- [ ] **Step 6: コミット** - -```bash -git add packages/frontend/src/app/api/projects/import/ \ - packages/frontend/src/app/api/projects/\[id\]/unregister/ -git commit -m "feat(frontend): /api/projects/import と /api/projects/:id/unregister を追加" -``` - ---- - -### Task 15: /api/projects/[id]/route.ts を codebases 対応に - -**Files:** -- Modify: `packages/frontend/src/app/api/projects/[id]/route.ts` -- Modify: `packages/frontend/src/app/api/projects/[id]/route.test.ts` - -- [ ] **Step 1: テストを更新** - -既存テストで `codebasePath` / `additionalCodebasePaths` を使っている箇所を全て `codebases` に置換。追加テスト: - -```ts -it('PATCH codebases を受け付ける', async () => { - // セットアップでプロジェクト作成後 - const patch = { codebases: [{ id: 'a', label: 'A', path: '/a' }] }; - const res = await PATCH( - new Request('http://localhost', { - method: 'PATCH', - body: JSON.stringify(patch), - }), - { params: Promise.resolve({ id: projectId }) }, - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { codebases: unknown }; - expect(body.codebases).toEqual(patch.codebases); -}); - -it('GET 時に touchProject が呼ばれて lastOpenedAt が更新される', async () => { - const before = Date.now(); - await GET(new Request('http://localhost'), { params: Promise.resolve({ id: projectId }) }); - const list = await listProjects(); - const entry = list.find((p) => p.id === projectId); - expect(entry).toBeDefined(); - expect(new Date(entry?.lastOpenedAt ?? '').getTime() >= before).toBe(true); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- api/projects/\\[id\\]/route.test` -Expected: FAIL - -- [ ] **Step 3: 実装** - -`packages/frontend/src/app/api/projects/[id]/route.ts` で: -- project discovery を `listProjects()` + `FileSystemProjectStore` に置換 -- GET 時に `touchProject(id)` -- PATCH は `ProjectMetaPatchSchema` で検証し、codebases 全置換 - -シグネチャ例: - -```ts -import { - FileSystemProjectStore, - listProjects, - touchProject, -} from '@tally/storage'; -import { ProjectMetaPatchSchema } from '@tally/core'; -// ... - -async function resolveDir(id: string): Promise { - const list = await listProjects(); - return list.find((p) => p.id === id)?.path ?? null; -} - -export async function GET( - _req: Request, - ctx: { params: Promise<{ id: string }> }, -): Promise { - const { id } = await ctx.params; - const dir = await resolveDir(id); - if (!dir) return NextResponse.json({ error: 'not found' }, { status: 404 }); - const store = new FileSystemProjectStore(dir); - const project = await store.loadProject(); - if (!project) return NextResponse.json({ error: 'not found' }, { status: 404 }); - await touchProject(id); - return NextResponse.json(project); -} - -export async function PATCH( - req: Request, - ctx: { params: Promise<{ id: string }> }, -): Promise { - const { id } = await ctx.params; - const dir = await resolveDir(id); - if (!dir) return NextResponse.json({ error: 'not found' }, { status: 404 }); - const parsed = ProjectMetaPatchSchema.safeParse(await req.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.message }, { status: 400 }); - } - const store = new FileSystemProjectStore(dir); - const current = await store.getProjectMeta(); - if (!current) return NextResponse.json({ error: 'not found' }, { status: 404 }); - const next = { - ...current, - ...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}), - ...(parsed.data.description !== undefined - ? parsed.data.description === null - ? ({} as Record) // description 削除 - : { description: parsed.data.description } - : {}), - ...(parsed.data.codebases !== undefined ? { codebases: parsed.data.codebases } : {}), - updatedAt: new Date().toISOString(), - }; - if (parsed.data.description === null) delete (next as { description?: unknown }).description; - await store.saveProjectMeta(next); - return NextResponse.json(next); -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- api/projects/\\[id\\]/route.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/app/api/projects/\[id\]/route.ts \ - packages/frontend/src/app/api/projects/\[id\]/route.test.ts -git commit -m "refactor(frontend): /api/projects/[id] を codebases[] + registry 駆動に刷新" -``` - ---- - -## Phase 4: Frontend ライブラリ層 - -### Task 16: api.ts クライアントを新 API に揃える - -**Files:** -- Modify: `packages/frontend/src/lib/api.ts` + `.test.ts` - -- [ ] **Step 1: テスト更新** - -`api.test.ts` で `fetchWorkspaceCandidates` / `WorkspaceCandidate` を参照している箇所を削除し、新 API のテストを追加: - -```ts -describe('registry clients', () => { - it('fetchRegistryProjects が /api/projects を叩いて projects を返す', async () => { - // fetch mock - global.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ projects: [{ id: 'a', name: 'A', codebases: [] }] }), { - status: 200, - }), - ); - const list = await fetchRegistryProjects(); - expect(list[0]?.id).toBe('a'); - }); - - it('importProject が POST /api/projects/import を叩く', async () => { - global.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ id: 'x', projectDir: '/x' }), { status: 201 }), - ); - const res = await importProject('/some/dir'); - expect(res.id).toBe('x'); - }); - - it('listDirectory が /api/fs/ls を叩く', async () => { - global.fetch = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - path: '/a', - parent: null, - entries: [], - containsProjectYaml: false, - }), - { status: 200 }, - ), - ); - const res = await listDirectory('/a'); - expect(res.path).toBe('/a'); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- lib/api.test` -Expected: FAIL - -- [ ] **Step 3: 実装** - -`packages/frontend/src/lib/api.ts` の旧 workspace-candidates 系を削除、新規クライアントを追加: - -```ts -// 旧 WorkspaceCandidate / fetchWorkspaceCandidates を削除。 - -export interface CodebaseDto { - id: string; - label: string; - path: string; -} - -export interface RegistryProjectDto { - id: string; - name: string; - description: string | null; - codebases: CodebaseDto[]; - projectDir: string; - createdAt: string; - updatedAt: string; - lastOpenedAt: string; -} - -export async function fetchRegistryProjects(): Promise { - const res = await fetch('/api/projects'); - if (!res.ok) throw new Error(`API GET /api/projects ${res.status}`); - const body = (await res.json()) as { projects: RegistryProjectDto[] }; - return body.projects; -} - -export interface CreateProjectInput { - projectDir: string; - name: string; - description?: string; - codebases: CodebaseDto[]; -} - -export async function createProject( - input: CreateProjectInput, -): Promise<{ id: string; projectDir: string }> { - const res = await fetch('/api/projects', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(input), - }); - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(err.error ?? `POST /api/projects ${res.status}`); - } - return (await res.json()) as { id: string; projectDir: string }; -} - -export async function importProject( - projectDir: string, -): Promise<{ id: string; projectDir: string }> { - const res = await fetch('/api/projects/import', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ projectDir }), - }); - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(err.error ?? `POST /api/projects/import ${res.status}`); - } - return (await res.json()) as { id: string; projectDir: string }; -} - -export async function unregisterProjectApi(id: string): Promise { - const res = await fetch(`/api/projects/${encodeURIComponent(id)}/unregister`, { - method: 'POST', - }); - if (!res.ok) throw new Error(`POST /unregister ${res.status}`); -} - -export interface FsEntry { - name: string; - path: string; - isHidden: boolean; - hasProjectYaml: boolean; -} - -export interface FsListResult { - path: string; - parent: string | null; - entries: FsEntry[]; - containsProjectYaml: boolean; -} - -export async function listDirectory(path?: string): Promise { - const url = new URL('/api/fs/ls', window.location.origin); - if (path !== undefined) url.searchParams.set('path', path); - const res = await fetch(url.toString()); - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(err.error ?? `GET /api/fs/ls ${res.status}`); - } - return (await res.json()) as FsListResult; -} - -export async function mkdir( - parentPath: string, - name: string, -): Promise<{ path: string }> { - const res = await fetch('/api/fs/mkdir', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ path: parentPath, name }), - }); - if (!res.ok) { - const err = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(err.error ?? `POST /api/fs/mkdir ${res.status}`); - } - return (await res.json()) as { path: string }; -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- lib/api.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/lib/api.ts packages/frontend/src/lib/api.test.ts -git commit -m "refactor(frontend/lib): api.ts を registry + fs + codebases[] に刷新" -``` - ---- - -### Task 17: store.ts を codebases[] 対応に - -**Files:** -- Modify: `packages/frontend/src/lib/store.ts` + `.test.ts` - -- [ ] **Step 1: テスト更新** - -旧 `codebasePath` / `additionalCodebasePaths` 参照を `codebases` に全置換し、`patchProjectMeta` の codebases 全置換テストを追加。 - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- lib/store.test` -Expected: FAIL - -- [ ] **Step 3: 実装** - -`store.ts` の `patchProjectMeta` シグネチャを: - -```ts -patchProjectMeta: (patch: { name?: string; description?: string | null; codebases?: Codebase[] }) => Promise; -``` - -に変更し、内部で `/api/projects/[id]` PATCH を叩く。`Codebase` は `@tally/core` から import。 - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- lib/store.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/lib/store.ts packages/frontend/src/lib/store.test.ts -git commit -m "refactor(frontend/lib): store.patchProjectMeta を codebases[] 対応に" -``` - ---- - -## Phase 5: Frontend ダイアログ & ページ - -### Task 18: FolderBrowserDialog - -**Files:** -- Create: `packages/frontend/src/components/dialog/folder-browser-dialog.tsx` -- Create: `packages/frontend/src/components/dialog/folder-browser-dialog.test.tsx` - -- [ ] **Step 1: 失敗するテストを書く** - -`folder-browser-dialog.test.tsx`: - -```tsx -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { FolderBrowserDialog } from './folder-browser-dialog'; - -beforeEach(() => { - global.fetch = vi.fn().mockImplementation((url: string) => { - const u = new URL(url, 'http://localhost'); - if (u.pathname === '/api/fs/ls') { - const p = u.searchParams.get('path') ?? '/home/you'; - const entries = - p === '/home/you' - ? [ - { name: 'acme', path: '/home/you/acme', isHidden: false, hasProjectYaml: false }, - { name: '.ssh', path: '/home/you/.ssh', isHidden: true, hasProjectYaml: false }, - ] - : []; - return Promise.resolve( - new Response( - JSON.stringify({ - path: p, - parent: p === '/' ? null : '/', - entries, - containsProjectYaml: false, - }), - { status: 200 }, - ), - ); - } - if (u.pathname === '/api/fs/mkdir') { - return Promise.resolve( - new Response(JSON.stringify({ path: '/home/you/new-dir' }), { status: 201 }), - ); - } - return Promise.reject(new Error('unexpected')); - }) as typeof fetch; -}); - -describe('FolderBrowserDialog', () => { - it('初期表示で initialPath の中身を一覧表示', async () => { - render( - {}} - onClose={() => {}} - />, - ); - expect(await screen.findByText('acme')).toBeInTheDocument(); - }); - - it('隠しフォルダはデフォルト非表示、トグルで表示', async () => { - render( - {}} - onClose={() => {}} - />, - ); - await screen.findByText('acme'); - expect(screen.queryByText('.ssh')).not.toBeInTheDocument(); - await userEvent.click(screen.getByLabelText('隠しフォルダを表示')); - expect(await screen.findByText('.ssh')).toBeInTheDocument(); - }); - - it('「選択」で onConfirm に現在のパスを渡す', async () => { - const onConfirm = vi.fn(); - render( - {}} - />, - ); - await screen.findByText('acme'); - await userEvent.click(screen.getByRole('button', { name: '選択' })); - expect(onConfirm).toHaveBeenCalledWith('/home/you'); - }); - - it('import-project で project.yaml 無しなら「選択」は disabled', async () => { - render( - {}} - onClose={() => {}} - />, - ); - await screen.findByText('acme'); - expect(screen.getByRole('button', { name: '選択' })).toBeDisabled(); - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- folder-browser-dialog.test` -Expected: FAIL - -- [ ] **Step 3: 最小実装** - -`folder-browser-dialog.tsx`(骨格): - -```tsx -'use client'; - -import { useCallback, useEffect, useState } from 'react'; - -import { listDirectory, mkdir, type FsListResult } from '@/lib/api'; - -export interface FolderBrowserDialogProps { - open: boolean; - initialPath?: string; - purpose: 'create-project' | 'import-project' | 'add-codebase'; - onConfirm: (absolutePath: string) => void; - onClose: () => void; -} - -export function FolderBrowserDialog(props: FolderBrowserDialogProps) { - const [listing, setListing] = useState(null); - const [error, setError] = useState(null); - const [showHidden, setShowHidden] = useState(false); - const [newDirName, setNewDirName] = useState(''); - - const load = useCallback(async (targetPath?: string) => { - setError(null); - try { - const res = await listDirectory(targetPath); - setListing(res); - } catch (err) { - setError(String((err as Error).message ?? err)); - } - }, []); - - useEffect(() => { - if (props.open) void load(props.initialPath); - }, [props.open, props.initialPath, load]); - - if (!props.open) return null; - - const confirmDisabled = - listing === null || - (props.purpose === 'import-project' && !listing.containsProjectYaml); - - const visibleEntries = (listing?.entries ?? []).filter((e) => showHidden || !e.isHidden); - - const onConfirmClick = () => { - if (!listing) return; - props.onConfirm(listing.path); - }; - - const onCreateDir = async () => { - if (!listing || newDirName.trim().length === 0) return; - try { - const res = await mkdir(listing.path, newDirName.trim()); - setNewDirName(''); - await load(res.path); - } catch (err) { - setError(String((err as Error).message ?? err)); - } - }; - - return ( -
-
-

{titleFor(props.purpose)}

-
- void load(e.target.value)} - aria-label="現在のパス" - /> - -
- {error &&
{error}
} -
    - {visibleEntries.map((e) => ( -
  • - -
  • - ))} -
- -
- setNewDirName(e.target.value)} - aria-label="新規フォルダ名" - /> - -
-
- - -
-
-
- ); -} - -function titleFor(purpose: FolderBrowserDialogProps['purpose']): string { - switch (purpose) { - case 'create-project': - return 'プロジェクトルートを選択'; - case 'import-project': - return '既存プロジェクトを選択'; - case 'add-codebase': - return 'コードベースのリポジトリを選択'; - } -} -``` - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- folder-browser-dialog.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/components/dialog/folder-browser-dialog.tsx \ - packages/frontend/src/components/dialog/folder-browser-dialog.test.tsx -git commit -m "feat(frontend): FolderBrowserDialog 追加(/api/fs/ls ・ /api/fs/mkdir 駆動)" -``` - ---- - -### Task 19: NewProjectDialog 刷新 - -**Files:** -- Modify: `packages/frontend/src/components/dialog/new-project-dialog.tsx`(全面刷新) -- Modify: `packages/frontend/src/components/dialog/new-project-dialog.test.tsx`(全面刷新) - -- [ ] **Step 1: テストを書き換える** - -`new-project-dialog.test.tsx`: - -```tsx -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { NewProjectDialog } from './new-project-dialog'; - -const push = vi.fn(); -vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) })); - -beforeEach(() => { - push.mockReset(); - global.fetch = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => { - if (url.endsWith('/api/projects') && init?.method === 'POST') { - return new Response(JSON.stringify({ id: 'proj-new', projectDir: '/x' }), { status: 201 }); - } - // FolderBrowserDialog 内の /api/fs/ls - return new Response( - JSON.stringify({ - path: '/home/you', - parent: null, - entries: [], - containsProjectYaml: false, - }), - { status: 200 }, - ); - }) as typeof fetch; -}); - -describe('NewProjectDialog', () => { - it('名前が空なら「作成」は disabled', () => { - render( {}} />); - expect(screen.getByRole('button', { name: /作成/ })).toBeDisabled(); - }); - - it('codebases 0 件でも「作成」は押せる', async () => { - render( {}} />); - await userEvent.type(screen.getByLabelText('プロジェクト名'), '思考ログ'); - expect(screen.getByRole('button', { name: /作成/ })).toBeEnabled(); - }); - - it('作成成功時に /projects/:id へ遷移', async () => { - render( {}} />); - await userEvent.type(screen.getByLabelText('プロジェクト名'), '思考ログ'); - await userEvent.click(screen.getByRole('button', { name: /作成/ })); - await screen.findByText(/作成中|作成/); // busy or complete - expect(push).toHaveBeenCalledWith('/projects/proj-new'); - }); - - it('codebases[].id 重複は「作成」disabled', async () => { - render( {}} />); - await userEvent.type(screen.getByLabelText('プロジェクト名'), 'p'); - // 内部に 2 件手動入力する UI を前提。同じ id を入れたときに disabled - // (実装後に具体的な testid を決めてここを埋める) - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- new-project-dialog.test` -Expected: FAIL - -- [ ] **Step 3: 実装(概略)** - -`new-project-dialog.tsx`: - -```tsx -'use client'; - -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import type { Codebase } from '@tally/core'; -import { createProject } from '@/lib/api'; -import { FolderBrowserDialog } from './folder-browser-dialog'; - -interface Props { - open: boolean; - onClose: () => void; -} - -export function NewProjectDialog({ open, onClose }: Props) { - const router = useRouter(); - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [projectDir, setProjectDir] = useState(''); - const [codebases, setCodebases] = useState([]); - const [pickerFor, setPickerFor] = useState(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - if (!open) return null; - - const duplicateIds = new Set(); - const seen = new Set(); - for (const c of codebases) { - if (seen.has(c.id)) duplicateIds.add(c.id); - seen.add(c.id); - } - const disabled = - busy || name.trim().length === 0 || projectDir.trim().length === 0 || duplicateIds.size > 0; - - const onPickRoot = (p: string) => { - setProjectDir(p); - setPickerFor(null); - }; - - const onPickCodebase = (p: string) => { - const slug = p.split('/').pop()?.toLowerCase().replace(/[^a-z0-9-]/g, '-') ?? 'cb'; - let id = slug.slice(0, 32); - if (id.length === 0) id = 'cb'; - while (codebases.some((c) => c.id === id)) id = `${id.slice(0, 28)}-${Math.random().toString(36).slice(2, 4)}`; - setCodebases([...codebases, { id, label: slug, path: p }]); - setPickerFor(null); - }; - - const onSubmit = async () => { - setBusy(true); - setError(null); - try { - const res = await createProject({ - projectDir, - name: name.trim(), - ...(description.trim().length > 0 ? { description: description.trim() } : {}), - codebases, - }); - router.push(`/projects/${encodeURIComponent(res.id)}`); - } catch (e) { - setError(String((e as Error).message ?? e)); - setBusy(false); - } - }; - - return ( -
- - -
- 保存先: {projectDir || '(未選択)'} - -
-
- コードベース: -
    - {codebases.map((c, i) => ( -
  • - { - const next = [...codebases]; - next[i] = { ...c, id: e.target.value }; - setCodebases(next); - }} - /> - { - const next = [...codebases]; - next[i] = { ...c, label: e.target.value }; - setCodebases(next); - }} - /> - {c.path} - {duplicateIds.has(c.id) && id 重複} - -
  • - ))} -
- -
- {error &&
{error}
} -
- - -
- setPickerFor(null)} - /> -
- ); -} -``` - -注: デフォルトのプロジェクトルートパス提案(`/projects//`)は別 API が必要(Task 20 で `/api/projects/default-path?name=...` として追加する手もあるが、最小スコープでは projectDir の初期値を空にしてユーザーに明示選択させる形で OK。ただし UX 的には不便なので Task 20 で追加する)。 - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/frontend test -- new-project-dialog.test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/components/dialog/new-project-dialog.tsx \ - packages/frontend/src/components/dialog/new-project-dialog.test.tsx -git commit -m "feat(frontend): NewProjectDialog を FolderBrowserDialog + codebases[] 対応に全面刷新" -``` - ---- - -### Task 20: デフォルトパス提案 API と NewProjectDialog 連携 - -**Files:** -- Create: `packages/frontend/src/app/api/projects/default-path/route.ts` + `.test.ts` -- Modify: `packages/frontend/src/lib/api.ts`(`fetchDefaultProjectPath` 追加) -- Modify: `packages/frontend/src/components/dialog/new-project-dialog.tsx`(初期値補填) - -- [ ] **Step 1: 失敗するテストを書く** - -`default-path/route.test.ts`: - -```ts -import { describe, expect, it } from 'vitest'; -import { GET } from './route'; - -describe('GET /api/projects/default-path', () => { - it('name を slug 化してデフォルトパス候補を返す', async () => { - const url = 'http://localhost/api/projects/default-path?name=My%20Proj%21'; - const res = await GET(new Request(url)); - expect(res.status).toBe(200); - const body = (await res.json()) as { path: string }; - expect(body.path).toMatch(/\/projects\/my-proj$/); - }); - - it('衝突時にサフィックスを付与', async () => { - // resolveDefaultProjectsRoot に同名 dir を作って干渉させる - // 詳細は実装と合わせて書き直す - }); -}); -``` - -- [ ] **Step 2: テスト失敗確認 → 実装 → 成功確認 → 連携 → コミット** - -(Task 19 と同様の TDD サイクル。実装詳細は省略可だが、`packages/storage` の `resolveDefaultProjectsRoot` を活用し、slug 化と衝突回避を行う。NewProjectDialog 側は name 入力時に debounce でこの API を叩き projectDir を初期値に入れる) - -```bash -git add packages/frontend/src/app/api/projects/default-path/ \ - packages/frontend/src/lib/api.ts \ - packages/frontend/src/components/dialog/new-project-dialog.tsx -git commit -m "feat(frontend): プロジェクト作成時にデフォルト保存先をサーバー提案" -``` - ---- - -### Task 21: ProjectImportDialog - -**Files:** -- Create: `packages/frontend/src/components/dialog/project-import-dialog.tsx` -- Create: `packages/frontend/src/components/dialog/project-import-dialog.test.tsx` - -- [ ] **Step 1: 失敗するテストを書く** - -```tsx -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ProjectImportDialog } from './project-import-dialog'; - -const push = vi.fn(); -vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) })); - -beforeEach(() => { - push.mockReset(); - global.fetch = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => { - if (url.endsWith('/api/projects/import') && init?.method === 'POST') { - return new Response(JSON.stringify({ id: 'proj-imp', projectDir: '/x' }), { status: 201 }); - } - return new Response( - JSON.stringify({ - path: '/home/you', - parent: null, - entries: [ - { name: 'existing', path: '/home/you/existing', isHidden: false, hasProjectYaml: true }, - ], - containsProjectYaml: false, - }), - { status: 200 }, - ); - }) as typeof fetch; -}); - -describe('ProjectImportDialog', () => { - it('project.yaml を含む dir を選び「インポート」で /api/projects/import を叩く', async () => { - render( {}} />); - // folder browser で existing を選択 - await userEvent.click(await screen.findByText('existing', { exact: false })); - // containsProjectYaml: true のディレクトリで「選択」が有効 - // 実装時に具体の操作を詳細化 - }); -}); -``` - -- [ ] **Step 2-5: 実装 → 成功確認 → コミット** - -(FolderBrowserDialog を purpose='import-project' で呼び、返り値を受けて `importProject()` を叩いて `/projects/[id]` に遷移) - -```bash -git add packages/frontend/src/components/dialog/project-import-dialog.tsx \ - packages/frontend/src/components/dialog/project-import-dialog.test.tsx -git commit -m "feat(frontend): ProjectImportDialog 追加" -``` - ---- - -### Task 22: ProjectSettingsDialog を codebases[] 対応に全面刷新 - -**Files:** -- Modify: `packages/frontend/src/components/dialog/project-settings-dialog.tsx` -- Modify: `packages/frontend/src/components/dialog/project-settings-dialog.test.tsx` - -- [ ] **Step 1: テストを書き換える** - -旧 `codebasePath` / `additionalCodebasePaths` 入力 UI のテストを削除し、`codebases[]` の追加・削除・並び替え・ラベル編集の新 UI のテストに置換。 - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/frontend test -- project-settings-dialog.test` -Expected: FAIL - -- [ ] **Step 3: 実装** - -NewProjectDialog の codebases セクションと同じ UI パーツを抽出して共通化(簡易版でよい)、FolderBrowserDialog(purpose: 'add-codebase')で codebase を追加、`patchProjectMeta({ codebases: nextList })` を呼ぶ。 - -codebases 0 件にする操作は、使用中の coderef が無い場合のみ許可(store.ts 側で検証、エラー時はダイアログ内で表示)。 - -- [ ] **Step 4: テスト成功確認 → Step 5: コミット** - -```bash -git add packages/frontend/src/components/dialog/project-settings-dialog.tsx \ - packages/frontend/src/components/dialog/project-settings-dialog.test.tsx -git commit -m "refactor(frontend): ProjectSettingsDialog を codebases[] 管理に全面刷新" -``` - ---- - -### Task 23: トップページ(projects 一覧)を registry 駆動に - -**Files:** -- Modify: `packages/frontend/src/app/page.tsx` -- Modify: `packages/frontend/src/app/page.test.tsx`(あれば) - -- [ ] **Step 1: テスト更新** - -fetch mock を `/api/projects` が `projects[]` を返すように調整し、「+ 新規プロジェクト」「既存を読み込む」の 2 ボタン、各プロジェクト行に「開く」「レジストリから外す」の UI テストを書く。 - -- [ ] **Step 2-4: TDD サイクル** - -実装: `fetchRegistryProjects()` で一覧取得、`unregisterProjectApi(id)` で外す、「既存を読み込む」で `ProjectImportDialog` を開く、「+ 新規プロジェクト」で `NewProjectDialog` を開く。 - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/app/page.tsx packages/frontend/src/app/page.test.tsx -git commit -m "refactor(frontend): トップページを registry 駆動に、インポートUIと新規プロジェクト UI を統合" -``` - ---- - -## Phase 6: AI Engine regression 修正 - -### Task 24: ai-engine の codebasePath シグネチャを `codebases[]` 対応に(in-scope 最小) - -**Files:** -- Modify: `packages/ai-engine/src/agent-runner.ts` + `.test.ts` -- Modify: `packages/ai-engine/src/agents/codebase-anchor.ts` + `.test.ts` -- Modify: `packages/ai-engine/src/agents/find-related-code.ts` + `.test.ts` -- Modify: `packages/ai-engine/src/agents/analyze-impact.ts` + `.test.ts` -- Modify: `packages/ai-engine/src/agents/extract-questions.ts` + `.test.ts` -- Modify: `packages/ai-engine/src/server.test.ts`(テスト側のみ) - -- [ ] **Step 1: 失敗するテストを書く** - -各 agent の `.test.ts` で `codebasePath: '/x'` 引数を `codebase: { id: 'x', label: 'X', path: '/x' }` に書き換える。`agent-runner.test.ts` も同様に呼び出し側を更新。 - -- [ ] **Step 2: テスト失敗確認** - -Run: `pnpm -F @tally/ai-engine test` -Expected: FAIL(旧シグネチャ) - -- [ ] **Step 3: 各 agent の入力シグネチャ変更** - -`codebase-anchor.ts` 例: - -```ts -import type { Codebase } from '@tally/core'; - -export interface CodebaseAnchorInput { - codebase: Codebase; - // ... 既存 -} - -export async function runCodebaseAnchor(input: CodebaseAnchorInput) { - const cwd = input.codebase.path; - // ... 既存ロジック、input.codebasePath を input.codebase.path に置換 -} -``` - -同様に他エージェントも `codebase: Codebase` 引数へ変更。呼び出し箇所(`agent-runner.ts`)で `projectMeta.codebases[0]` を受け取って渡すデフォルト処理を入れる(codebases 0 件のケースは呼び出し側で事前に弾く前提)。 - -- [ ] **Step 4: テスト成功確認** - -Run: `pnpm -F @tally/ai-engine test` -Expected: PASS - -- [ ] **Step 5: コミット** - -```bash -git add packages/ai-engine/src -git commit -m "refactor(ai-engine): agents を codebase: Codebase 引数に変更 - -単一 codebasePath 前提を撤廃し、呼び出し側が Codebase オブジェクトを渡すシグネチャに。 -複数 codebase を跨いだ探索は別 spec のスコープ(out-of-scope)。" -``` - ---- - -### Task 25: ai-actions ボタン群を codebases[] 対応に - -**Files:** -- Modify: `packages/frontend/src/components/ai-actions/codebase-agent-button.tsx` + `.test.tsx` -- Modify: `packages/frontend/src/components/ai-actions/find-related-code-button.tsx` + `.test.tsx` -- Modify: `packages/frontend/src/components/ai-actions/analyze-impact-button.tsx` + `.test.tsx` -- Modify: `packages/frontend/src/components/ai-actions/extract-questions-button.tsx`(必要なら) -- Modify: `packages/frontend/src/components/ai-actions/graph-agent-button.tsx` - -- [ ] **Step 1: 失敗するテストを書く(1 ボタンずつ)** - -codebase-agent-button.test.tsx に: - -```tsx -it('codebases が 0 件なら disabled + tooltip', () => { - // useCanvasStore を mock して projectMeta.codebases: [] を返す - // ボタンが disabled であることと、ツールチップ文言を検証 -}); - -it('codebases が 1 件のみならそれを使う', () => { - // 単一 codebase を渡して agent 呼び出し引数を検証 -}); - -it('codebases が 2 件以上なら選択 UI(ドロップダウン)を表示', () => { - // ドロップダウン選択後にボタン押下、正しい codebase が渡る -}); -``` - -- [ ] **Step 2-4: TDD サイクル** - -実装: 各ボタンで `projectMeta.codebases` を参照し、0 件 → disabled、1 件 → そのまま、2 件以上 → 選択 UI を出す。選択後の codebase を agent 呼び出しに渡す。 - -- [ ] **Step 5: コミット** - -```bash -git add packages/frontend/src/components/ai-actions/ -git commit -m "feat(frontend/ai-actions): codebases[] 対応(0件disabled, 1件自動, 複数件選択UI)" -``` - ---- - -## Phase 7: ドキュメント & フィクスチャ - -### Task 26: ADR-0008 / 0009 / 0010 を書く - -**Files:** -- Create: `docs/adr/0008-project-independent-from-repo.md` -- Create: `docs/adr/0009-project-registry.md` -- Create: `docs/adr/0010-multiple-codebases.md` -- Modify: `docs/adr/0003-git-managed-yaml.md`(Superseded に) - -- [ ] **Step 1: ADR 執筆** - -既存 ADR (`docs/adr/0001-sysml-alignment.md` 等) の形式に合わせて書く: - -```markdown -# ADR-0008: プロジェクトをリポジトリから切り離す - -- **日付**: 2026-04-21 -- **ステータス**: Accepted -- **Supersedes**: ADR-0003 - -## コンテキスト -(spec の「背景」から抜粋・整形) - -## 決定 -プロジェクト = 任意のディレクトリ。`.tally/` サブディレクトリ規約を廃止。 -プロジェクトディレクトリ直下に `project.yaml` / `nodes/` / `edges/` / `chats/` を置く。 - -## 影響 -- 暗黙スキャン(ghq / TALLY_WORKSPACE)全廃(ADR-0009 に続く) -- 1 プロジェクト = 1 リポジトリ前提の解消(ADR-0010 に続く) - -## 参考 -- spec: `docs/superpowers/specs/2026-04-21-project-storage-redesign-design.md` -``` - -ADR-0009 / 0010 も同様に spec の該当セクションから起こす。 - -ADR-0003 のステータスを `Superseded by ADR-0008` に変更し、冒頭に注記を追加。 - -- [ ] **Step 2: コミット** - -```bash -git add docs/adr/0003-git-managed-yaml.md docs/adr/0008-*.md docs/adr/0009-*.md docs/adr/0010-*.md -git commit -m "docs(adr): ADR-0008/0009/0010 追加、ADR-0003 を Superseded に" -``` - ---- - -### Task 27: examples/sample-project を刷新 - -**Files:** -- Delete: `examples/sample-project/.tally/` 以下全て -- Create: `examples/sample-project/project.yaml`, `examples/sample-project/nodes/*`, `examples/sample-project/edges/edges.yaml` - -- [ ] **Step 1: 現在の `.tally/` 中身を確認** - -```bash -ls examples/sample-project/.tally/ -cat examples/sample-project/.tally/project.yaml -``` - -- [ ] **Step 2: `.tally/` 内容を `examples/sample-project/` 直下に移動** - -```bash -mv examples/sample-project/.tally/project.yaml examples/sample-project/ -mv examples/sample-project/.tally/nodes examples/sample-project/ -mv examples/sample-project/.tally/edges examples/sample-project/ -rmdir examples/sample-project/.tally -``` - -- [ ] **Step 3: project.yaml を新スキーマに書き換え** - -`examples/sample-project/project.yaml`: - -```yaml -id: proj-sample-0001 -name: TaskFlow 招待機能追加 -description: SaaS にチーム招待機能を追加するプロジェクト -codebases: - - id: backend - label: TaskFlow API - path: ../taskflow-backend -createdAt: 2026-04-18T10:00:00Z -updatedAt: 2026-04-21T00:00:00Z -``` - -- [ ] **Step 4: 既存の coderef ノードに `codebaseId: backend` を追加** - -```bash -# coderef 系の yaml を検出して手動編集 -grep -l "^type: coderef" examples/sample-project/nodes/*.yaml -# 各ファイルに codebaseId: backend を追加 -``` - -- [ ] **Step 5: 動作確認 + コミット** - -```bash -pnpm -F @tally/storage test # 全体の test を通す -git add examples/sample-project -git commit -m "refactor(examples): sample-project を codebases[] スキーマに移行" -``` - ---- - -### Task 28: CLAUDE.md / README.md / docs 更新 - -**Files:** -- Modify: `CLAUDE.md` -- Modify: `README.md` -- Modify: `docs/03-architecture.md` など(`.tally/` 言及を持つものすべて) - -- [ ] **Step 1: `.tally/` 参照を全 grep** - -```bash -grep -rn "\.tally/" CLAUDE.md README.md docs/ -``` - -- [ ] **Step 2: 各ファイルを更新** - -- `.tally/` → 「プロジェクトディレクトリ」または具体例 `~/.local/share/tally/projects//` -- `TALLY_WORKSPACE` → `TALLY_HOME` -- ghq 連携記述 → 削除、レジストリ + フォルダピッカーの説明に置換 -- ADR-0003 リンク → Superseded の注釈と ADR-0008 への参照 - -- [ ] **Step 3: コミット** - -```bash -git add CLAUDE.md README.md docs/ -git commit -m "docs: .tally/ 規約廃止を反映、registry ベースの利用フローに更新" -``` - ---- - -### Task 29: 全体 E2E 確認 - -- [ ] **Step 1: パッケージごとに test 実行** - -```bash -pnpm -r test -``` - -Expected: すべて PASS - -- [ ] **Step 2: typecheck / lint** - -```bash -pnpm -r typecheck -pnpm -r lint -``` - -Expected: エラーなし - -- [ ] **Step 3: dev 起動して手動確認** - -```bash -pnpm dev -``` - -ブラウザで: -1. `+ 新規プロジェクト` → FolderBrowserDialog で任意の空 dir を選択 → codebase 追加(任意)→ 作成 -2. `既存を読み込む` → FolderBrowserDialog → import -3. トップページでプロジェクト一覧表示、「開く」「レジストリから外す」動作 -4. プロジェクト内で coderef ノード作成、AI ボタンが codebases[] を参照 - -- [ ] **Step 4: 最終コミット(残件があれば)** - -```bash -# 手動確認で発見した fix があればコミット -git commit -m "fix: E2E 確認で発見した問題を修正" -``` - ---- - -## 実装順序の要約 - -1. Phase 1 (Task 1-2): core 型刷新 — 全体のコンパイル起点 -2. Phase 2 (Task 3-10): storage 層 — registry / project-dir / init-project / ストア刷新 -3. Phase 3 (Task 11-15): バックエンド API — fs 系、projects 系 -4. Phase 4 (Task 16-17): frontend lib — api / store -5. Phase 5 (Task 18-23): frontend dialog & page — FolderBrowser / NewProject / Import / Settings / top page -6. Phase 6 (Task 24-25): ai-engine regression fix -7. Phase 7 (Task 26-29): docs / examples / E2E - -途中で型エラーが別パッケージに波及するため、Phase 1 → Phase 2 の境目と、Phase 4 / 5 の境目で `pnpm -r typecheck` をかけて穴を埋める。 - ---- - -## Self-Review 結果 - -- **spec カバレッジ**: spec 各セクション(データモデル / レジストリ / フォルダブラウザ / 変更&削除リスト / テスト戦略 / ADR / スコープ境界)はすべて Task に対応 -- **placeholder**: 未定義関数・TBD・"同様" 参照はなし。一部 UI モック操作の詳細は実装時に `testid` を決めて埋めるとしている(Task 19, 21, 22)— 許容範囲 -- **型整合**: `Codebase` / `ProjectMeta` / `FsListResult` / `RegistryEntry` のフィールド名は全 Task で一貫 -- **coderef vs code**: 実コードの型名 `coderef` を計画内で統一 diff --git a/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md b/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md deleted file mode 100644 index d3929ce..0000000 --- a/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md +++ /dev/null @@ -1,190 +0,0 @@ -# Dogfood Log — Atlassian MCP C フェーズ - -> **目的**: 10 個の Jira エピックで C フェーズ Success Criteria を測定し、A フェーズの ingest-jira-epic agent プロンプト設計の入力を作る。 - -## Setup - -### MCP サーバーの選択肢 - -#### (A) Atlassian 公式 Rovo MCP — OAuth 2.1 (推奨) - -- URL: `https://mcp.atlassian.com/v1/mcp` -- 認証: ユーザーが初回 Tally Chat 利用時に Claude Agent SDK が WWW-Authenticate を解釈し、 - ブラウザ経由で OAuth 2.1 を実行。token は SDK が管理し、Tally process には保存されない。 -- 制約: Atlassian Cloud 専用 (Server/DC 非対応)。 - -#### (B) sooperset/mcp-atlassian — credentials は MCP server 側で管理 - -```bash -# Cloud に対する Basic auth で起動 (token は MCP server プロセスに留まる) -JIRA_USERNAME=you@example.com JIRA_API_TOKEN=xxx \ - uvx mcp-atlassian --transport streamable-http --port 9000 - -# Server/DC に対する Bearer auth で起動 -JIRA_PERSONAL_TOKEN=xxx JIRA_URL=https://jira.your-company.example \ - uvx mcp-atlassian --transport streamable-http --port 9000 -``` - -または Docker: -```bash -docker run -p 9000:9000 -e JIRA_PERSONAL_TOKEN=xxx -e JIRA_URL=... \ - ghcr.io/sooperset/mcp-atlassian:latest --transport streamable-http --port 9000 -``` - -**Tally は (A)/(B) いずれの場合も credentials を一切持ちません。** Tally プロセスから PAT/API key -が漏れる経路が無いことが Premise 9 撤回後の設計です。 - -### Tally プロジェクト設定 - -プロジェクト設定ダイアログ → MCP サーバーを追加: -- ID: `atlassian` -- 名前: `Atlassian Cloud` (任意) -- URL: 上の (A) なら `https://mcp.atlassian.com/v1/mcp`、(B) なら `http://localhost:9000/mcp` - (loopback の http はテスト用に許容) - -### 初回 OAuth フロー (ケース A) - -1. Tally Chat で `@JIRA EPIC-XXX 読んで論点出して` を投げる -2. SDK が 401 を受けて WWW-Authenticate から OAuth metadata を取得 -3. ブラウザが開き Atlassian で auth、token は SDK 内部に保存 -4. 自動で再リクエストが走り、tool 呼び出しが成功する -5. 以降は token が refresh される間、再認証は不要 - -## Epic 1-10 - -各エピックについて以下の項目を記録する。 - -### Epic 1: PMDEV-165【L1】ホーム画面+認証 (proof-of-concept、2026-04-27) - -> **注**: これは **Tally dev server で実機 dogfood する前の事前 PoC**。Claude が atlassian plugin tool 経由で context を取り、「もし Tally AI agent が同じ context で論点抽出したら」のシミュレーション出力を記録。実装が完成したら同じエピックで実機 dogfood し、ここの「期待値」と比べる。 - -- **エピック概要**: バイヤー向けの認証機能 (FUNC-022〜026: ログイン / パスワード再発行 / 自動ログイン記憶 / 新規会員登録 / 退会) と、ホーム画面 (FUNC-060: トップページ / FUNC-016: ゲスト/ログイン差分表示) -- **URL**: https://ignission.atlassian.net/browse/PMDEV-165 -- **規模**: 子チケット **50+** 件 (Tally Premise 7 の `maxChildIssues=30` を超過)、ステータス進行中 - -#### シミュレーション (Claude as Tally AI agent) - -PMDEV-165 description + 子チケット summary 50 件を context に「未決定の設計判断 (論点)」を抽出。各論点は Tally の `question` proposal 形式 (title / body / options[] / sourceRefs[]): - -**論点 1: ログインセッションの「ログイン状態を保持する」既定状態** -- body: FUNC-022/024 で `JWTトークンをHttpOnly Cookieに保存(有効期限7日)` と `「ログイン状態を保持する」が機能する` の AC があるが、**この checkbox の初期値**が決まっていない。チェック ON 既定なら離脱が増えにくいが UX として強制感、OFF 既定だと再ログイン頻度が上がりリテンション低下。 -- options: - - チェック ON 既定 (UX 簡略、長期 cookie 7 日保持) - - チェック OFF 既定 (短期 session cookie、明示同意) - - そもそも checkbox を出さない (常に 7 日保持 / なし) -- sourceRefs: FUNC-022, FUNC-024 - -**論点 2: パスワード再発行メールの送信元アドレス / 文言所有権** -- body: FUNC-023「メールアドレス入力でCognito経由の再発行メールが送信される」だけ書かれているが、**送信元 (no-reply@?)、件名 / 本文の文言、ブランディング、Cognito default テンプレ vs カスタム**が決まっていない。法務 / マーケ含めた決断が要る。 -- options: - - Cognito default テンプレ (Amazon SES) で MVP - - SES + カスタムテンプレ (ブランド整える、デザイン依頼必要) - - 第三者メール SaaS 経由 (SendGrid 等、cost / deliverability) -- sourceRefs: FUNC-023, PMDEV-176 [SRE] メール基盤 - -**論点 3: ゲストの価格・在庫を「非表示」とは具体的に何か** -- body: FUNC-016「未ログイン(ゲスト)状態では商品の価格・在庫情報が非表示になる」とあるが、**ぼかし / プレースホルダ / 「ログインしてご確認ください」誘導 / 完全に隠す**のどれか未決定。商品ヒーロー画像で価格訴求できないと SEO / 流入時の最初の印象が弱い。 -- options: - - 完全非表示 (空のスペース) - - 「会員登録/ログインして表示」CTA で置換 (推奨? CV 期待) - - ぼかし表示 (具体額を ××× 等) -- sourceRefs: FUNC-016, FUNC-060 - -**論点 4: 退会後のデータ保持期間 / 法的要件** -- body: FUNC-026「退会処理でCognitoユーザーが無効化・削除される」だが、**注文履歴 / 請求書 / GDPR 的な個人データ保持期間 / soft delete か hard delete か**が決まっていない。日本の電帳法 (請求書 7 年) と矛盾する可能性。 -- options: - - Cognito user は即削除、注文 / 請求は anonymize で保持 - - Cognito user は soft delete (再開可能)、注文 / 請求は実名保持 (法定期間) - - 退会時に user に「データ削除 / 保持」選択させる (GDPR の権利) -- sourceRefs: FUNC-026 - -**論点 5: パフォーマンス基準 "3 秒以内" の計測条件** -- body: 「ページ初期表示が3秒以内に完了する(dev環境)」と「Core Web Vitalsの基準を満たす」がある。**dev 環境の 3 秒** はネットワーク条件 / キャッシュ状態 / 同時接続数によって大きく揺れる。Core Web Vitals (LCP 2.5s 等) の方が標準的だが、dev 環境では本番回線で測れない。 -- options: - - dev 3 秒 + Core Web Vitals = 二重基準 (dev は緩い目安、本番は CWV) - - Core Web Vitals 一本化 (dev 環境では参考、staging で測定) - - 削除 (dev 3 秒は意味薄、CWV のみ採用) -- sourceRefs: FUNC-060 パフォーマンス節 - -**論点 6: サプライヤー初回ログインのパスワード変更 UX (子チケット由来)** -- body: PMDEV-264 [FE] サプライヤー初回ログイン時のパスワード変更画面 / PMDEV-266 NEW_PASSWORD_REQUIRED チャレンジで 500 エラー (バグ完了)。**初回パスワード生成方式** (招待メールに 1 回限り URL / 初期パスを発行 / 完全自由設定) と **強度ポリシー** が docs に書かれていない。 -- options: - - 招待メール内 1 回限り URL (Cognito の admin invitation flow) - - 招待時に初期 12 文字 random pwd を発行 (メール本文に platintext = リスク) - - サプライヤーが自由設定 (招待メール内のリンクから直接 sign-up) -- sourceRefs: PMDEV-264, PMDEV-266 - -**シミュレーション結果**: -- 生成 question proposal: 6 件 (target 3+ クリア) -- うち「気づかなかった論点」候補 (人間が epic だけ読んでは見落とす): 論点 4 (退会後の法定保持) / 論点 5 (パフォーマンス基準二重) — **2 件以上** (target 3 件には惜しい、実機で深堀れば届きそう) -- AI が子チケット 50 件全部読むと context が爆発する想定。Premise 7 の `maxChildIssues=30` での切り捨ては必要だが、**重要な子チケットの抽出ロジック** (例: status='完了' は除外、bug type は context 寄与低、最新更新優先 等) が dogfood で見えそう - -#### 実機 dogfood 時の確認事項 - -- [ ] Tally Chat で同じ epic を投げ、上記 6 論点と類似の output が出るか -- [ ] 子チケット 50 件 → AI 動作 (context 爆発する? truncate される?) -- [ ] multi-turn で「PMDEV-264 をもっと深く」と聞いたら過去 context を覚えているか -- [ ] 同 sourceUrl 2 度目取り込みの重複ガード発動 - ---- - -(Epic 2-10 を同フォーマットで、実機 dogfood 時に追記) - -## 集計 - -### 量的基準 - -- 合計生成 question proposal: ___ 個 -- 合計採用数: ___ 個 -- **採用率**: ___ % (target: **50%+**) -- 90 秒以内に proposal 3 個以上の Epic 数: ___ / 10 (target: **10/10**) - -### 質的基準 - -- 「気づかなかった論点」合計: ___ 件 (target: **3+**) - - 該当 Epic 一覧: -- multi-turn が機能した Epic: ___ / 10 (target: **10/10**) - -### システム動作 - -- 重複ガード発動数 / 試行数: ___ / ___ -- env 未設定エラーで blocked になった回数: ___ -- MCP 接続エラーの種類: - -## 観察メモ (A フェーズ ingest-jira-epic 設計の入力) - -### プロンプト改善点 - -(AI が安定して論点を出すために、どんな指示が効いたか) - -### tool 呼び出しパターン - -(AI がどの順で `jira_get_issue` / `jira_get_epic_issues` / `jira_search` 等を呼んだか) - -### レイテンシ分布 - -(エピックサイズと所要時間の相関) - -### 失敗パターン - -- 接続失敗: -- rate limit: -- タイムアウト: -- AI が無限ループ: - -### A フェーズ仕様への提案 - -(C で見えた「AI に必須で指示すべき事項」「制限すべき事項」を箇条書き) - ---- - -## 完了判定 - -C フェーズ Success Criteria: -- [ ] 90 秒以内に question proposal 3 個以上 (10 epic 全部) -- [ ] 採用率 50%+ -- [ ] 「気づかなかった論点」3+ 件 -- [ ] multi-turn での context 保持が動作 - -満たせば → A フェーズ (ingest-jira-epic agent + 専用 UI + ADR) へ。 -満たさなければ → 観察結果をもとに plan を再調整。 diff --git a/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase.md b/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase.md deleted file mode 100644 index 6dd1ed7..0000000 --- a/docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase.md +++ /dev/null @@ -1,2525 +0,0 @@ -# Atlassian MCP 連携 — C フェーズ Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Tally の Chat で Atlassian MCP (Jira) を multi-turn 対話で使える完成形 UX を作る。プロジェクト設定から mcpServers[] を登録 → Chat で「@JIRA EPIC-X を読んで論点を出して」→ AI が外部 MCP 経由で Jira 読み → question proposal 生成 → 採用、までが動く。 - -**Architecture:** `chat-runner.ts` と `agent-runner.ts` の `mcpServers: { tally }` ハードコードを `buildMcpServers` utility に抽出し、プロジェクト設定の `mcpServers[]` から外部 MCP (Atlassian HTTP MCP) を動的合成する。ChatBlockSchema に `source: 'internal' | 'external'` を追加し、外部 MCP の tool_use/tool_result を承認なしで永続化。buildChatPrompt を拡張して tool_use/tool_result を replay し、multi-turn で AI が前ターンの Jira 内容を覚える。create-node の重複ガードを strategy-pattern に抽出し、sourceUrl ベースの guard を追加 (chat で anchorId 空でも動く)。 - -**Tech Stack:** TypeScript, pnpm workspaces, Next.js 15 App Router, React Flow, Zustand, Claude Agent SDK (`@anthropic-ai/claude-agent-sdk@0.2.117`), Vitest, Biome. 既存 Tally のパッケージ構成 (`@tally/core`, `@tally/storage`, `@tally/ai-engine`, `@tally/frontend`) に従う。 - -**Related docs:** -- Design doc: `~/.gstack/projects/ignission-tally/knowbe01-main-design-20260423-164810.md` -- Test plan: `~/.gstack/projects/ignission-tally/knowbe01-main-eng-review-test-plan-20260423-212143.md` -- CLAUDE.md / `.claude/rules/testing.md` / `.claude/rules/packages-architecture.md` - ---- - -## Prerequisite: Step 0 Spikes (C 着手前、30-35 分、手動) - -これらは実装でなく調査。結果を design doc 末尾に脚注として追記してから Task 1 を開始する。 - -- [ ] **Spike 0a (30 分): Atlassian MCP 実装選定** - - `sooperset/mcp-atlassian` (OSS、PAT 対応、HTTP transport) を第一候補として起動確認 - - 公式 Atlassian Remote MCP が利用可能なら比較検討、PAT 認証が使えることが必須 (Premise 9) - - 選定結果と tool 一覧 (例: `atlassian_getJiraIssue`, `atlassian_searchJiraIssues` 等) を `~/.gstack/projects/ignission-tally/knowbe01-main-design-20260423-164810.md` 末尾に `## Atlassian MCP Implementation Footnote` として追記 - - tool 名 prefix (例: `mcp__atlassian__`) を記録 → Task 3 / Task 9 で使用 - -- [ ] **Spike 0b (5 分): allowedTools wildcard 動作検証** - - 最小 spike スクリプト `/tmp/spike-allowed-tools.mjs` を書く: - ```javascript - // spike-allowed-tools.mjs - import { query } from '@anthropic-ai/claude-agent-sdk'; - // 外部 MCP は spike 時点では mock、allowedTools: ['mcp__atlassian__*'] で - // SDK が permission エラーを出さないか確認するのみ - ``` - - または既存 `pnpm -F @tally/ai-engine exec tsx` で SDK の `allowedTools: ['mcp__atlassian__*']` が warning/error 出さずに受理されるか確認 - - wildcard 受理 → Task 9 で `['mcp__tally__*', 'mcp__atlassian__*']` パターンを採用 - - 拒否 → Task 9 は Spike 0a で取得した tool 名を `['mcp__tally__*', 'mcp__atlassian__atlassian_getJiraIssue', ...]` と静的列挙 - - 結果を design doc 末尾の Footnote に追記 - ---- - -## File Structure (C フェーズで触る範囲) - -**新規作成:** -- `packages/ai-engine/src/mcp/build-mcp-servers.ts` — プロジェクト設定から SDK の mcpServers を組み立てる -- `packages/ai-engine/src/mcp/build-mcp-servers.test.ts` — 上記のテスト -- `packages/ai-engine/src/mcp/redact.ts` — Authorization header を含むログの redaction utility -- `packages/ai-engine/src/mcp/redact.test.ts` -- `packages/ai-engine/src/duplicate-guards/index.ts` — guard interface + strategy map -- `packages/ai-engine/src/duplicate-guards/coderef.ts` — 既存 coderef 重複ガードを分離 -- `packages/ai-engine/src/duplicate-guards/question.ts` — 既存 question 重複ガードを分離 -- `packages/ai-engine/src/duplicate-guards/source-url.ts` — T1 fix、sourceUrl ベースの新規 guard -- `packages/ai-engine/src/duplicate-guards/*.test.ts` — 各 guard のテスト - -**修正:** -- `packages/core/src/schema.ts` — McpServerConfigSchema / ProjectSchema.mcpServers / ChatBlockSchema.tool_use.source / RequirementNodeSchema.sourceUrl -- `packages/core/src/types.ts` — 対応する型 export -- `packages/core/src/schema.test.ts` — 上記の round-trip / migration テスト -- `packages/ai-engine/src/chat-runner.ts` — buildMcpServers 呼び出し / extractAssistantBlocks 拡張 / buildChatPrompt 拡張 / tool_result truncate -- `packages/ai-engine/src/chat-runner.test.ts` — 対応テスト -- `packages/ai-engine/src/agent-runner.ts` — buildMcpServers 共有 -- `packages/ai-engine/src/agent-runner.test.ts` — regression snapshot -- `packages/ai-engine/src/tools/create-node.ts` — duplicate-guards に委譲 -- `packages/ai-engine/src/tools/create-node.test.ts` -- `packages/frontend/src/app/api/projects/[id]/route.ts` — mcpServers round-trip -- `packages/frontend/src/lib/api.ts` — mcpServers API -- `packages/frontend/src/components/dialog/project-settings-dialog.tsx` — mcpServers CRUD UI -- `packages/frontend/src/components/chat/tool-approval-card.tsx` — source 分岐 -- `packages/frontend/src/components/chat/chat-tab.tsx` — external source の折り畳み表示 - ---- - -## Task 1: core schema 拡張 (McpServerConfigSchema) - -**Files:** -- Modify: `packages/core/src/schema.ts` -- Modify: `packages/core/src/types.ts` -- Test: `packages/core/src/schema.test.ts` - -**Spike 0a の結果を反映**: auth は Basic (Cloud) / Bearer (Server/DC) の 2 scheme。Basic の場合は email + token の両方が必要なので、envVar を `emailEnvVar` + `tokenEnvVar` に分離した discriminated union。 - -- [ ] **Step 1-1: failing test を書く — `McpServerConfigSchema` の round-trip** - -`packages/core/src/schema.test.ts` に追記: - -```typescript -import { McpServerConfigSchema } from './schema'; - -describe('McpServerConfigSchema', () => { - it('Cloud (basic) auth の round-trip が通る', () => { - const raw = { - id: 'atlassian-cloud', - name: 'Atlassian Cloud', - kind: 'atlassian' as const, - url: 'https://mcp.atlassian.example/v1/mcp', - auth: { - type: 'pat' as const, - scheme: 'basic' as const, - emailEnvVar: 'ATLASSIAN_EMAIL', - tokenEnvVar: 'ATLASSIAN_API_TOKEN', - }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }; - const parsed = McpServerConfigSchema.parse(raw); - expect(parsed).toEqual(raw); - }); - - it('Server/DC (bearer) auth の round-trip が通る', () => { - const raw = { - id: 'atlassian-onprem', - name: 'Atlassian On-Prem', - kind: 'atlassian' as const, - url: 'https://jira.example.com/mcp', - auth: { - type: 'pat' as const, - scheme: 'bearer' as const, - tokenEnvVar: 'JIRA_PAT', - }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }; - const parsed = McpServerConfigSchema.parse(raw); - expect(parsed).toEqual(raw); - }); - - it('basic で emailEnvVar 無しは fail', () => { - expect(() => - McpServerConfigSchema.parse({ - id: 'a', name: 'A', kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { type: 'pat', scheme: 'basic', tokenEnvVar: 'T' }, - }), - ).toThrow(); - }); - - it('options 未指定なら default が入る', () => { - const parsed = McpServerConfigSchema.parse({ - id: 'a', name: 'A', kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X_PAT' }, - }); - expect(parsed.options.maxChildIssues).toBe(30); - expect(parsed.options.maxCommentsPerIssue).toBe(5); - }); - - it('url が URL でないと fail', () => { - expect(() => - McpServerConfigSchema.parse({ - id: 'a', name: 'A', kind: 'atlassian', url: 'not a url', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'X' }, - }), - ).toThrow(); - }); -}); -``` - -- [ ] **Step 1-2: test を走らせて fail を確認** - -Run: `pnpm -F @tally/core test -- schema.test` -Expected: FAIL with "McpServerConfigSchema is not exported" - -- [ ] **Step 1-3: `McpServerConfigSchema` を `packages/core/src/schema.ts` に追加** - -既存 `ProjectSchema` の直前に追加: - -```typescript -// Atlassian Cloud は Basic (base64(email:token))、Server/DC は Bearer (pat) の 2 scheme。 -// どちらも PAT ベースの認証 (OAuth は MVP 非対応、Premise 9)。 -const McpAuthSchema = z.discriminatedUnion('scheme', [ - z.object({ - type: z.literal('pat'), - scheme: z.literal('basic'), - emailEnvVar: z.string().min(1), // 例 "ATLASSIAN_EMAIL" - tokenEnvVar: z.string().min(1), // 例 "ATLASSIAN_API_TOKEN" - }), - z.object({ - type: z.literal('pat'), - scheme: z.literal('bearer'), - tokenEnvVar: z.string().min(1), // 例 "JIRA_PAT" - }), -]); - -export const McpServerConfigSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - kind: z.literal('atlassian'), - url: z.string().url(), - auth: McpAuthSchema, - options: z - .object({ - maxChildIssues: z.number().int().positive().default(30), - maxCommentsPerIssue: z.number().int().nonnegative().default(5), - }) - .default({}), -}); - -export type McpServerConfig = z.infer; -``` - -`packages/core/src/types.ts` にも: - -```typescript -export type { McpServerConfig } from './schema'; -``` - -- [ ] **Step 1-4: test を走らせて pass を確認** - -Run: `pnpm -F @tally/core test -- schema.test` -Expected: PASS (McpServerConfigSchema の 3 case) - -- [ ] **Step 1-5: commit** - -```bash -git add packages/core/src/schema.ts packages/core/src/schema.test.ts packages/core/src/types.ts -git commit -m "feat(core): McpServerConfigSchema を追加 (Atlassian MCP 連携の基盤)" -``` - ---- - -## Task 2: core schema 拡張 (ProjectSchema.mcpServers) - -**Files:** -- Modify: `packages/core/src/schema.ts` -- Test: `packages/core/src/schema.test.ts` - -- [ ] **Step 2-1: failing test — ProjectSchema に mcpServers[] が含まれる** - -`packages/core/src/schema.test.ts` に追記: - -```typescript -describe('ProjectSchema.mcpServers', () => { - it('mcpServers 未指定なら default の空配列', () => { - const p = ProjectSchema.parse({ - id: 'p', name: 'P', codebases: [], - createdAt: '2026-04-24T00:00:00Z', - updatedAt: '2026-04-24T00:00:00Z', - }); - expect(p.mcpServers).toEqual([]); - }); - - it('mcpServers 指定で round-trip する', () => { - const input = { - id: 'p', name: 'P', codebases: [], - createdAt: '2026-04-24T00:00:00Z', - updatedAt: '2026-04-24T00:00:00Z', - mcpServers: [ - { - id: 'a', name: 'A', kind: 'atlassian' as const, - url: 'https://x.test/mcp', - auth: { type: 'pat' as const, envVar: 'X' }, - }, - ], - }; - const p = ProjectSchema.parse(input); - expect(p.mcpServers).toHaveLength(1); - expect(p.mcpServers[0].options.maxChildIssues).toBe(30); - }); -}); -``` - -- [ ] **Step 2-2: test fail を確認** - -Run: `pnpm -F @tally/core test -- schema.test` -Expected: FAIL with "Property 'mcpServers' ..." - -- [ ] **Step 2-3: ProjectSchema に mcpServers を追加** - -`packages/core/src/schema.ts` の `ProjectSchema` 定義に: - -```typescript -export const ProjectSchema = z.object({ - // 既存フィールド ... - mcpServers: z.array(McpServerConfigSchema).default([]), -}); -``` - -`ProjectMetaSchema` にも同じ `mcpServers` フィールドを追加 (project.yaml の meta と本体で整合)。既存の Project 型と ProjectMeta 型が何を含むかは既存コードに合わせる。 - -- [ ] **Step 2-4: test pass を確認 + storage の既存 round-trip テストも通る確認** - -Run: `pnpm -F @tally/core test && pnpm -F @tally/storage test` -Expected: PASS 全件。既存 YAML (mcpServers 無し) が optional default で [] として読めること。 - -- [ ] **Step 2-5: commit** - -```bash -git add packages/core/src/schema.ts packages/core/src/schema.test.ts -git commit -m "feat(core): ProjectSchema に mcpServers[] を追加 (default [])" -``` - ---- - -## Task 3: core schema 拡張 (ChatBlockSchema.source / RequirementNodeSchema.sourceUrl) - -**Files:** -- Modify: `packages/core/src/schema.ts` -- Test: `packages/core/src/schema.test.ts` - -- [ ] **Step 3-1: failing test — ChatBlock.tool_use に source が入り、古い YAML (source 無し) が 'internal' として読める** - -```typescript -describe('ChatBlockSchema.tool_use.source', () => { - it('source 未指定の古いデータが "internal" に defaults', () => { - const b = ChatBlockSchema.parse({ - type: 'tool_use', - toolUseId: 'tu-1', - name: 'mcp__tally__create_node', - input: { x: 1 }, - approval: 'approved', - }); - expect(b.type).toBe('tool_use'); - if (b.type === 'tool_use') expect(b.source).toBe('internal'); - }); - - it('source = "external" は承認不要扱い (approval optional)', () => { - const b = ChatBlockSchema.parse({ - type: 'tool_use', - toolUseId: 'tu-2', - name: 'mcp__atlassian__getJiraIssue', - input: { issueKey: 'EPIC-1' }, - source: 'external', - }); - if (b.type === 'tool_use') { - expect(b.source).toBe('external'); - expect(b.approval).toBeUndefined(); - } - }); - - it('source = "internal" で approval 無しは fail', () => { - expect(() => - ChatBlockSchema.parse({ - type: 'tool_use', toolUseId: 'tu-3', - name: 'mcp__tally__create_node', input: {}, - source: 'internal', - }), - ).toThrow(); - }); -}); - -describe('RequirementNodeSchema.sourceUrl', () => { - it('sourceUrl 未指定は optional (既存互換)', () => { - const n = RequirementNodeSchema.parse({ - id: 'n', type: 'requirement', x: 0, y: 0, - title: 'R', body: '', - }); - expect(n.sourceUrl).toBeUndefined(); - }); - - it('sourceUrl 指定で保持', () => { - const n = RequirementNodeSchema.parse({ - id: 'n', type: 'requirement', x: 0, y: 0, - title: 'R', body: '', - sourceUrl: 'https://jira.test/browse/EPIC-1', - }); - expect(n.sourceUrl).toBe('https://jira.test/browse/EPIC-1'); - }); -}); -``` - -- [ ] **Step 3-2: test fail を確認** - -Run: `pnpm -F @tally/core test -- schema.test` - -- [ ] **Step 3-3: schema.ts を修正** - -ChatBlockSchema の tool_use 枝を書き換え: - -```typescript -z.object({ - type: z.literal('tool_use'), - toolUseId: z.string().min(1), - name: z.string().min(1), - input: z.unknown(), - source: z.enum(['internal', 'external']).default('internal'), - approval: z.enum(['pending', 'approved', 'rejected']).optional(), -}).refine( - (b) => b.source === 'external' || b.approval !== undefined, - { message: 'internal tool_use には approval が必要' }, -), -``` - -RequirementNodeSchema に: - -```typescript -// 既存 フィールドに追加: -sourceUrl: z.string().url().optional(), -``` - -- [ ] **Step 3-4: test pass + 既存 agent-runner / chat-runner テストが退行なしで通る** - -Run: `pnpm -F @tally/core test && pnpm -F @tally/ai-engine test && pnpm -F @tally/storage test` - -- [ ] **Step 3-5: commit** - -```bash -git add packages/core/src/schema.ts packages/core/src/schema.test.ts -git commit -m "feat(core): ChatBlock.tool_use に source を追加、Requirement に sourceUrl を追加" -``` - ---- - -## Task 4: redact-logs utility - -**Files:** -- Create: `packages/ai-engine/src/mcp/redact.ts` -- Create: `packages/ai-engine/src/mcp/redact.test.ts` - -- [ ] **Step 4-1: failing test** - -```typescript -// packages/ai-engine/src/mcp/redact.test.ts -import { describe, expect, it } from 'vitest'; -import { redactMcpSecrets } from './redact'; - -describe('redactMcpSecrets', () => { - it('Authorization header を "***" に置換', () => { - const input = { - mcpServers: { - atlassian: { - type: 'http', - url: 'https://x.test/mcp', - headers: { Authorization: 'Bearer abc-123' }, - }, - }, - }; - const out = redactMcpSecrets(input); - expect((out as any).mcpServers.atlassian.headers.Authorization).toBe('***'); - // 元オブジェクトは破壊しない - expect(input.mcpServers.atlassian.headers.Authorization).toBe('Bearer abc-123'); - }); - - it('他フィールドは保持', () => { - const out = redactMcpSecrets({ - mcpServers: { - atlassian: { type: 'http', url: 'https://x.test/mcp', headers: { 'X-Other': 'keep' } }, - }, - }); - expect((out as any).mcpServers.atlassian.url).toBe('https://x.test/mcp'); - expect((out as any).mcpServers.atlassian.headers['X-Other']).toBe('keep'); - }); - - it('mcpServers が無ければそのまま', () => { - const input = { foo: 'bar' }; - expect(redactMcpSecrets(input)).toEqual(input); - }); -}); -``` - -- [ ] **Step 4-2: test fail を確認** - -Run: `pnpm -F @tally/ai-engine test -- redact.test` -Expected: FAIL (モジュール未定義) - -- [ ] **Step 4-3: 実装** - -```typescript -// packages/ai-engine/src/mcp/redact.ts - -// SDK に渡す mcpServers 設定 (特に Authorization ヘッダ) をログに出す前の -// 安全な形に変換する。プロセスメモリには PAT が残るが、ログ出力経路では ***。 -export function redactMcpSecrets(value: unknown): unknown { - if (!value || typeof value !== 'object') return value; - const obj = value as Record; - if (!obj.mcpServers || typeof obj.mcpServers !== 'object') return value; - - const servers = obj.mcpServers as Record; - const redactedServers: Record = {}; - for (const [name, cfg] of Object.entries(servers)) { - if (cfg && typeof cfg === 'object' && 'headers' in cfg) { - const src = cfg as { headers?: Record }; - const headers = src.headers; - if (headers && typeof headers === 'object' && 'Authorization' in headers) { - redactedServers[name] = { - ...src, - headers: { ...headers, Authorization: '***' }, - }; - continue; - } - } - redactedServers[name] = cfg; - } - return { ...obj, mcpServers: redactedServers }; -} -``` - -- [ ] **Step 4-4: test pass** - -Run: `pnpm -F @tally/ai-engine test -- redact.test` - -- [ ] **Step 4-5: commit** - -```bash -git add packages/ai-engine/src/mcp/ -git commit -m "feat(ai-engine): redactMcpSecrets を追加 (Authorization header のログ漏洩予防)" -``` - ---- - -## Task 5: buildMcpServers utility - -**Files:** -- Create: `packages/ai-engine/src/mcp/build-mcp-servers.ts` -- Create: `packages/ai-engine/src/mcp/build-mcp-servers.test.ts` - -**Spike 0a/0b の結果を反映**: auth.scheme で Basic/Bearer 分岐、Basic は `base64(email:token)`。allowedTools は wildcard `mcp____*` を使用 (Spike 0b で確認)。 - -- [ ] **Step 5-1: failing test** - -```typescript -// packages/ai-engine/src/mcp/build-mcp-servers.test.ts -import { afterEach, describe, expect, it } from 'vitest'; -import { buildMcpServers } from './build-mcp-servers'; - -describe('buildMcpServers', () => { - const ORIGINAL_ENV = { ...process.env }; - afterEach(() => { - process.env = { ...ORIGINAL_ENV }; - }); - - it('mcpServers 空配列 → external 無し、allowedTools は tally のみ', () => { - const result = buildMcpServers({ tallyMcp: { type: 'sdk' } as any, configs: [] }); - expect(Object.keys(result.mcpServers)).toEqual(['tally']); - expect(result.allowedTools).toEqual(['mcp__tally__*']); - }); - - it('Bearer (Server/DC) → Authorization: Bearer ', () => { - process.env.JIRA_PAT = 'secret-xyz'; - const result = buildMcpServers({ - tallyMcp: { type: 'sdk' } as any, - configs: [ - { - id: 'atlassian-dc', name: 'A', kind: 'atlassian', - url: 'https://jira.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - }); - const atlassian = result.mcpServers['atlassian-dc'] as any; - expect(atlassian.type).toBe('http'); - expect(atlassian.url).toBe('https://jira.test/mcp'); - expect(atlassian.headers.Authorization).toBe('Bearer secret-xyz'); - expect(result.allowedTools).toContain('mcp__tally__*'); - expect(result.allowedTools).toContain('mcp__atlassian-dc__*'); - }); - - it('Basic (Cloud) → Authorization: Basic ', () => { - process.env.ATLASSIAN_EMAIL = 'user@example.com'; - process.env.ATLASSIAN_API_TOKEN = 'api-token-xyz'; - const result = buildMcpServers({ - tallyMcp: { type: 'sdk' } as any, - configs: [ - { - id: 'atlassian-cloud', name: 'A', kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { - type: 'pat', scheme: 'basic', - emailEnvVar: 'ATLASSIAN_EMAIL', - tokenEnvVar: 'ATLASSIAN_API_TOKEN', - }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - }); - const atlassian = result.mcpServers['atlassian-cloud'] as any; - const expected = Buffer.from('user@example.com:api-token-xyz').toString('base64'); - expect(atlassian.headers.Authorization).toBe(`Basic ${expected}`); - }); - - it('Bearer の tokenEnvVar 未設定 → throw', () => { - delete process.env.JIRA_PAT; - expect(() => - buildMcpServers({ - tallyMcp: { type: 'sdk' } as any, - configs: [ - { - id: 'a', name: 'A', kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - }), - ).toThrowError(/JIRA_PAT/); - }); - - it('Basic の emailEnvVar 未設定 → throw', () => { - delete process.env.ATLASSIAN_EMAIL; - process.env.ATLASSIAN_API_TOKEN = 'x'; - expect(() => - buildMcpServers({ - tallyMcp: { type: 'sdk' } as any, - configs: [ - { - id: 'a', name: 'A', kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { - type: 'pat', scheme: 'basic', - emailEnvVar: 'ATLASSIAN_EMAIL', tokenEnvVar: 'ATLASSIAN_API_TOKEN', - }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - }), - ).toThrowError(/ATLASSIAN_EMAIL/); - }); - - it('env 値が空文字でも → throw', () => { - process.env.JIRA_PAT = ''; - expect(() => - buildMcpServers({ - tallyMcp: { type: 'sdk' } as any, - configs: [ - { - id: 'a', name: 'A', kind: 'atlassian', - url: 'https://x.test/mcp', - auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'JIRA_PAT' }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - }), - ).toThrowError(/JIRA_PAT/); - }); -}); -``` - -- [ ] **Step 5-2: test fail を確認** - -Run: `pnpm -F @tally/ai-engine test -- build-mcp-servers.test` -Expected: FAIL (未実装) - -- [ ] **Step 5-3: 実装** - -```typescript -// packages/ai-engine/src/mcp/build-mcp-servers.ts -import type { McpServerConfig } from '@tally/core'; - -// SDK の mcpServers は Record を受ける (sdk.d.ts:1386 参照)。 -// chat-runner / agent-runner が共通で使える shape にする。 -export interface BuildMcpServersInput { - // createSdkMcpServer で組み立てた Tally MCP。ここでは opaque。 - tallyMcp: unknown; - // プロジェクト設定 project.mcpServers[]。 - configs: McpServerConfig[]; -} - -export interface BuildMcpServersResult { - mcpServers: Record; - allowedTools: string[]; -} - -function requireEnv(varName: string, contextId: string): string { - const v = process.env[varName]; - if (v === undefined || v === '') { - throw new Error( - `MCP 設定 "${contextId}" の env var "${varName}" が未設定または空です`, - ); - } - return v; -} - -function buildAuthHeader(auth: McpServerConfig['auth'], contextId: string): string { - if (auth.scheme === 'bearer') { - const token = requireEnv(auth.tokenEnvVar, contextId); - return `Bearer ${token}`; - } - // basic - const email = requireEnv(auth.emailEnvVar, contextId); - const token = requireEnv(auth.tokenEnvVar, contextId); - const b64 = Buffer.from(`${email}:${token}`).toString('base64'); - return `Basic ${b64}`; -} - -// SDK 設定と allowedTools を組み立てる。env 未設定は throw。 -// 呼び出し元は runUserTurn の都度これを呼ぶ → env 変更がホットリロードされる。 -// allowedTools は wildcard `mcp____*` を使用 (Spike 0b で SDK サポート確認済み)。 -export function buildMcpServers(input: BuildMcpServersInput): BuildMcpServersResult { - const { tallyMcp, configs } = input; - - const mcpServers: Record = { tally: tallyMcp }; - const allowedTools: string[] = ['mcp__tally__*']; - - for (const cfg of configs) { - const authHeader = buildAuthHeader(cfg.auth, cfg.id); - mcpServers[cfg.id] = { - type: 'http' as const, - url: cfg.url, - headers: { Authorization: authHeader }, - }; - allowedTools.push(`mcp__${cfg.id}__*`); - } - - return { mcpServers, allowedTools }; -} -``` - -- [ ] **Step 5-4: test pass** - -Run: `pnpm -F @tally/ai-engine test -- build-mcp-servers.test` - -- [ ] **Step 5-5: commit** - -```bash -git add packages/ai-engine/src/mcp/build-mcp-servers.ts packages/ai-engine/src/mcp/build-mcp-servers.test.ts -git commit -m "feat(ai-engine): buildMcpServers utility を追加 (外部 MCP 合成 + env 検証)" -``` - ---- - -## Task 6: duplicate-guards 骨格 (interface + strategy map) - -**Files:** -- Create: `packages/ai-engine/src/duplicate-guards/index.ts` -- Create: `packages/ai-engine/src/duplicate-guards/index.test.ts` - -- [ ] **Step 6-1: failing test — guard map のディスパッチ** - -```typescript -// packages/ai-engine/src/duplicate-guards/index.test.ts -import { describe, expect, it } from 'vitest'; -import { dispatchDuplicateGuard, type DuplicateGuardContext } from './index'; - -describe('dispatchDuplicateGuard', () => { - const fakeStore = { listNodes: async () => [], findRelatedNodes: async () => [] } as any; - const baseCtx: DuplicateGuardContext = { - store: fakeStore, - anchorId: '', - sessionMemo: new Set(), - }; - - it('adoptAs="requirement" は guard 対象外 → null', async () => { - const result = await dispatchDuplicateGuard('requirement', { title: 't', body: '', additional: undefined }, baseCtx); - expect(result).toBeNull(); - }); - - it('guard 登録が無い adoptAs は null', async () => { - const result = await dispatchDuplicateGuard('usecase' as any, { title: 't', body: '', additional: undefined }, baseCtx); - expect(result).toBeNull(); - }); -}); -``` - -- [ ] **Step 6-2: test fail を確認** - -Run: `pnpm -F @tally/ai-engine test -- duplicate-guards` - -- [ ] **Step 6-3: interface + dispatcher を実装 (個別 guard は後続 task)** - -```typescript -// packages/ai-engine/src/duplicate-guards/index.ts -import type { AdoptableType } from '@tally/core'; -import type { ProjectStore } from '@tally/storage'; - -// create-node 入力のうち guard に必要な最小 shape。 -export interface GuardInput { - title: string; - body: string; - additional: Record | undefined; -} - -// guard が共有するランタイム文脈。 -export interface DuplicateGuardContext { - store: ProjectStore; - // anchor 無し (chat) のときは空文字。anchor 依存 guard は空文字を skip せよ。 - anchorId: string; - // セッション内で生成済みノードの重複記録。キーは guard 実装が決める。 - sessionMemo: Set; - // マルチコードベース対応のために流すコードベース ID (optional)。 - codebaseId?: string; -} - -export interface DuplicateFound { - reason: string; // ユーザー向けメッセージ (既存 node id などを含む) -} - -export interface DuplicateGuard { - // 対象 adoptAs。複数対応は同 guard を複数 adoptAs で登録する。 - adoptAs: AdoptableType; - // 重複があれば DuplicateFound、無ければ null。 - // 副作用: 重複が無く生成が成功するかどうかの追跡は呼び出し側 (create-node) が行う。 - check(input: GuardInput, ctx: DuplicateGuardContext): Promise; - // 生成成功後に呼ばれる (sessionMemo 更新など)。 - onCreated?(input: GuardInput, ctx: DuplicateGuardContext): void; -} - -// adoptAs → Guard[] のレジストリ。Task 7-9 で個別 guard を追加する。 -const REGISTRY = new Map(); - -export function registerGuard(guard: DuplicateGuard): void { - const list = REGISTRY.get(guard.adoptAs) ?? []; - list.push(guard); - REGISTRY.set(guard.adoptAs, list); -} - -export async function dispatchDuplicateGuard( - adoptAs: AdoptableType, - input: GuardInput, - ctx: DuplicateGuardContext, -): Promise { - const guards = REGISTRY.get(adoptAs) ?? []; - for (const g of guards) { - const found = await g.check(input, ctx); - if (found) return found; - } - return null; -} - -export function notifyCreated( - adoptAs: AdoptableType, - input: GuardInput, - ctx: DuplicateGuardContext, -): void { - const guards = REGISTRY.get(adoptAs) ?? []; - for (const g of guards) g.onCreated?.(input, ctx); -} -``` - -- [ ] **Step 6-4: test pass** - -Run: `pnpm -F @tally/ai-engine test -- duplicate-guards` - -- [ ] **Step 6-5: commit** - -```bash -git add packages/ai-engine/src/duplicate-guards/ -git commit -m "feat(ai-engine): duplicate-guards の骨格 (interface + dispatcher) を追加" -``` - ---- - -## Task 7: coderef guard を分離 (既存ロジック移行) - -**Files:** -- Create: `packages/ai-engine/src/duplicate-guards/coderef.ts` -- Create: `packages/ai-engine/src/duplicate-guards/coderef.test.ts` -- Modify: `packages/ai-engine/src/duplicate-guards/index.ts` (register 呼び出し) - -- [ ] **Step 7-1: failing test — 既存挙動を網羅する単体テスト** - -```typescript -// packages/ai-engine/src/duplicate-guards/coderef.test.ts -import { describe, expect, it, vi } from 'vitest'; -import { coderefGuard } from './coderef'; -import type { DuplicateGuardContext } from './index'; - -function makeCtx(nodes: any[], anchorId = ''): DuplicateGuardContext { - return { - store: { - listNodes: async () => nodes, - findRelatedNodes: async () => [], - } as any, - anchorId, - sessionMemo: new Set(), - codebaseId: undefined, - }; -} - -describe('coderefGuard', () => { - it('同一 filePath + 近接 startLine (±10) で重複検知', async () => { - const ctx = makeCtx([ - { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, - ]); - const res = await coderefGuard.check( - { - title: 'T', body: '', - additional: { filePath: 'src/a.ts', startLine: 105, codebaseId: 'cb1' }, - }, - ctx, - ); - expect(res?.reason).toContain('重複'); - }); - - it('11 行以上離れていれば重複ではない', async () => { - const ctx = makeCtx([ - { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, - ]); - const res = await coderefGuard.check( - { - title: 'T', body: '', - additional: { filePath: 'src/a.ts', startLine: 112, codebaseId: 'cb1' }, - }, - ctx, - ); - expect(res).toBeNull(); - }); - - it('codebaseId が異なれば別物扱い', async () => { - const ctx = makeCtx([ - { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb1' }, - ]); - const res = await coderefGuard.check( - { - title: 'T', body: '', - additional: { filePath: 'src/a.ts', startLine: 100, codebaseId: 'cb2' }, - }, - ctx, - ); - expect(res).toBeNull(); - }); - - it('filePath が "./" 付きでも正規化して判定', async () => { - const ctx = makeCtx([ - { id: 'n1', type: 'coderef', filePath: 'src/a.ts', startLine: 100 }, - ]); - const res = await coderefGuard.check( - { - title: 'T', body: '', - additional: { filePath: './src/a.ts', startLine: 100 }, - }, - ctx, - ); - expect(res?.reason).toContain('重複'); - }); -}); -``` - -- [ ] **Step 7-2: test fail を確認** - -Run: `pnpm -F @tally/ai-engine test -- duplicate-guards/coderef` - -- [ ] **Step 7-3: 既存 `findDuplicateCoderef` を移行** - -`packages/ai-engine/src/duplicate-guards/coderef.ts` に既存 create-node.ts の normalizeFilePath + findDuplicateCoderef を guard 形式で書く: - -```typescript -import path from 'node:path'; -import type { DuplicateGuard } from './index'; - -const CODEREF_LINE_TOLERANCE = 10; - -function normalizeFilePath(fp: string): string { - const stripped = fp.startsWith('./') ? fp.slice(2) : fp; - return path.posix.normalize(stripped); -} - -export const coderefGuard: DuplicateGuard = { - adoptAs: 'coderef', - async check(input, ctx) { - const additional = input.additional ?? {}; - const fp = additional.filePath; - const sl = additional.startLine; - if (typeof fp !== 'string' || typeof sl !== 'number') return null; - - const normalized = normalizeFilePath(fp); - const activeCbId = - typeof additional.codebaseId === 'string' ? additional.codebaseId : ctx.codebaseId; - - const all = await ctx.store.listNodes(); - for (const n of all) { - const rec = n as Record; - const type = rec.type as string | undefined; - const adoptAs = rec.adoptAs as string | undefined; - const isCoderef = type === 'coderef' || (type === 'proposal' && adoptAs === 'coderef'); - if (!isCoderef) continue; - const existingFp = rec.filePath as string | undefined; - const existingSl = rec.startLine as number | undefined; - if (!existingFp || typeof existingSl !== 'number') continue; - if (normalizeFilePath(existingFp) !== normalized) continue; - const existingCb = rec.codebaseId as string | undefined; - if (activeCbId !== undefined && existingCb !== undefined && existingCb !== activeCbId) { - continue; - } - if (Math.abs(existingSl - sl) <= CODEREF_LINE_TOLERANCE) { - return { - reason: `重複: ${rec.id} と近接 (filePath=${normalized}, startLine 差=${Math.abs(existingSl - sl)})`, - }; - } - } - return null; - }, -}; -``` - -`packages/ai-engine/src/duplicate-guards/index.ts` の末尾に register: - -```typescript -import { coderefGuard } from './coderef'; -registerGuard(coderefGuard); -``` - -- [ ] **Step 7-4: test pass** - -Run: `pnpm -F @tally/ai-engine test -- duplicate-guards` - -- [ ] **Step 7-5: commit** - -```bash -git add packages/ai-engine/src/duplicate-guards/coderef.ts packages/ai-engine/src/duplicate-guards/coderef.test.ts packages/ai-engine/src/duplicate-guards/index.ts -git commit -m "feat(ai-engine): coderef 重複ガードを duplicate-guards/ に分離" -``` - ---- - -## Task 8: question guard を分離 (既存ロジック移行) - -**Files:** -- Create: `packages/ai-engine/src/duplicate-guards/question.ts` -- Create: `packages/ai-engine/src/duplicate-guards/question.test.ts` -- Modify: `packages/ai-engine/src/duplicate-guards/index.ts` - -- [ ] **Step 8-1: failing test** - -```typescript -// packages/ai-engine/src/duplicate-guards/question.test.ts -import { describe, expect, it } from 'vitest'; -import { questionGuard } from './question'; -import type { DuplicateGuardContext } from './index'; - -function makeCtx(neighbors: any[], anchorId = 'anchor-1'): DuplicateGuardContext { - return { - store: { - listNodes: async () => [], - findRelatedNodes: async () => neighbors, - } as any, - anchorId, - sessionMemo: new Set(), - }; -} - -describe('questionGuard', () => { - it('anchorId が空なら skip (null を返す)', async () => { - const ctx = makeCtx([], ''); - const res = await questionGuard.check({ title: '[AI] Q', body: '', additional: undefined }, ctx); - expect(res).toBeNull(); - }); - - it('同 anchor に同タイトルが既にあれば重複', async () => { - const ctx = makeCtx([ - { id: 'q1', type: 'question', title: 'どうするか', adoptAs: undefined }, - ]); - const res = await questionGuard.check( - { title: '[AI] どうするか', body: '', additional: undefined }, - ctx, - ); - expect(res?.reason).toContain('q1'); - }); - - it('sessionMemo に同 anchor+title が記録済みなら重複', async () => { - const ctx = makeCtx([]); - ctx.sessionMemo.add('anchor-1|どうするか'); - const res = await questionGuard.check( - { title: '[AI] どうするか', body: '', additional: undefined }, - ctx, - ); - expect(res?.reason).toContain('同一セッション'); - }); -}); -``` - -- [ ] **Step 8-2: test fail を確認** - -Run: `pnpm -F @tally/ai-engine test -- duplicate-guards/question` - -- [ ] **Step 8-3: 移行実装** - -```typescript -// packages/ai-engine/src/duplicate-guards/question.ts -import { stripAiPrefix } from '@tally/core'; -import type { DuplicateGuard } from './index'; - -export const questionGuard: DuplicateGuard = { - adoptAs: 'question', - async check(input, ctx) { - // T1: anchorId が空なら anchor ベースのチェックは skip。 - // chat 経由では anchor が無いので、source-url guard が代わりに重複検知する。 - if (!ctx.anchorId) return null; - - const normalizedTitle = stripAiPrefix(input.title); - const sessionKey = `${ctx.anchorId}|${normalizedTitle}`; - if (ctx.sessionMemo.has(sessionKey)) { - return { - reason: `重複 (同一セッション内): anchor ${ctx.anchorId} に既に同タイトル question を生成済み`, - }; - } - - const neighbors = await ctx.store.findRelatedNodes(ctx.anchorId); - for (const n of neighbors) { - const rec = n as unknown as { id: string; type: string; adoptAs?: string; title: string }; - const isQuestion = - rec.type === 'question' || (rec.type === 'proposal' && rec.adoptAs === 'question'); - if (isQuestion && stripAiPrefix(rec.title) === normalizedTitle) { - return { - reason: `重複: anchor ${ctx.anchorId} に既に同タイトル question 候補 ${rec.id} が存在`, - }; - } - } - return null; - }, - onCreated(input, ctx) { - if (!ctx.anchorId) return; - const normalizedTitle = stripAiPrefix(input.title); - ctx.sessionMemo.add(`${ctx.anchorId}|${normalizedTitle}`); - }, -}; -``` - -`packages/ai-engine/src/duplicate-guards/index.ts` に register 追記: - -```typescript -import { questionGuard } from './question'; -registerGuard(questionGuard); -``` - -- [ ] **Step 8-4: test pass** - -Run: `pnpm -F @tally/ai-engine test -- duplicate-guards` - -- [ ] **Step 8-5: commit** - -```bash -git add packages/ai-engine/src/duplicate-guards/question.ts packages/ai-engine/src/duplicate-guards/question.test.ts packages/ai-engine/src/duplicate-guards/index.ts -git commit -m "feat(ai-engine): question 重複ガードを duplicate-guards/ に分離 (anchorId 空は skip)" -``` - ---- - -## Task 9: source-url guard 追加 (T1 fix、chat anchor 無しでも動く) - -**Files:** -- Create: `packages/ai-engine/src/duplicate-guards/source-url.ts` -- Create: `packages/ai-engine/src/duplicate-guards/source-url.test.ts` -- Modify: `packages/ai-engine/src/duplicate-guards/index.ts` - -- [ ] **Step 9-1: failing test** - -```typescript -// packages/ai-engine/src/duplicate-guards/source-url.test.ts -import { describe, expect, it } from 'vitest'; -import { sourceUrlGuard } from './source-url'; -import type { DuplicateGuardContext } from './index'; - -function makeCtx(nodes: any[]): DuplicateGuardContext { - return { - store: { listNodes: async () => nodes, findRelatedNodes: async () => [] } as any, - anchorId: '', - sessionMemo: new Set(), - }; -} - -describe('sourceUrlGuard', () => { - it('sourceUrl が additional に無ければ skip', async () => { - const res = await sourceUrlGuard.check( - { title: 'R', body: '', additional: undefined }, - makeCtx([]), - ); - expect(res).toBeNull(); - }); - - it('同 sourceUrl の requirement が既にあれば重複', async () => { - const ctx = makeCtx([ - { id: 'r1', type: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }, - ]); - const res = await sourceUrlGuard.check( - { - title: 'R', body: '', - additional: { sourceUrl: 'https://jira.test/EPIC-1' }, - }, - ctx, - ); - expect(res?.reason).toContain('r1'); - }); - - it('proposal 段階の sourceUrl も重複検知対象', async () => { - const ctx = makeCtx([ - { - id: 'p1', type: 'proposal', adoptAs: 'requirement', - sourceUrl: 'https://jira.test/EPIC-1', - }, - ]); - const res = await sourceUrlGuard.check( - { - title: 'R', body: '', - additional: { sourceUrl: 'https://jira.test/EPIC-1' }, - }, - ctx, - ); - expect(res?.reason).toContain('p1'); - }); - - it('セッション内 sessionMemo でも重複検知', async () => { - const ctx = makeCtx([]); - ctx.sessionMemo.add('sourceUrl:https://jira.test/EPIC-1'); - const res = await sourceUrlGuard.check( - { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, - ctx, - ); - expect(res?.reason).toContain('同一セッション'); - }); - - it('onCreated で sessionMemo 更新', async () => { - const ctx = makeCtx([]); - sourceUrlGuard.onCreated?.( - { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, - ctx, - ); - expect(ctx.sessionMemo.has('sourceUrl:https://jira.test/EPIC-1')).toBe(true); - }); -}); -``` - -- [ ] **Step 9-2: test fail を確認** - -Run: `pnpm -F @tally/ai-engine test -- duplicate-guards/source-url` - -- [ ] **Step 9-3: 実装** - -```typescript -// packages/ai-engine/src/duplicate-guards/source-url.ts -import type { DuplicateGuard } from './index'; - -// sourceUrl ベースの重複検知。 -// anchor 不要 → chat (anchorId='') でも動く (T1 fix の核)。 -// requirement / proposal(adoptAs=requirement) の全件スキャン。 -export const sourceUrlGuard: DuplicateGuard = { - adoptAs: 'requirement', - async check(input, ctx) { - const sourceUrl = input.additional?.sourceUrl; - if (typeof sourceUrl !== 'string' || sourceUrl.length === 0) return null; - - const sessionKey = `sourceUrl:${sourceUrl}`; - if (ctx.sessionMemo.has(sessionKey)) { - return { reason: `重複 (同一セッション内): sourceUrl ${sourceUrl} を既に生成済み` }; - } - - const all = await ctx.store.listNodes(); - for (const n of all) { - const rec = n as Record; - const type = rec.type as string | undefined; - const adoptAs = rec.adoptAs as string | undefined; - const isRequirement = - type === 'requirement' || (type === 'proposal' && adoptAs === 'requirement'); - if (!isRequirement) continue; - const existingUrl = rec.sourceUrl as string | undefined; - if (existingUrl === sourceUrl) { - return { reason: `重複: sourceUrl ${sourceUrl} は既に node ${rec.id} が保持` }; - } - } - return null; - }, - onCreated(input, ctx) { - const sourceUrl = input.additional?.sourceUrl; - if (typeof sourceUrl === 'string' && sourceUrl.length > 0) { - ctx.sessionMemo.add(`sourceUrl:${sourceUrl}`); - } - }, -}; -``` - -`packages/ai-engine/src/duplicate-guards/index.ts` に register 追記: - -```typescript -import { sourceUrlGuard } from './source-url'; -registerGuard(sourceUrlGuard); -``` - -- [ ] **Step 9-4: test pass** - -Run: `pnpm -F @tally/ai-engine test -- duplicate-guards` - -- [ ] **Step 9-5: commit** - -```bash -git add packages/ai-engine/src/duplicate-guards/source-url.ts packages/ai-engine/src/duplicate-guards/source-url.test.ts packages/ai-engine/src/duplicate-guards/index.ts -git commit -m "feat(ai-engine): sourceUrl ベースの重複ガードを追加 (T1 fix: chat anchor 無しで動く)" -``` - ---- - -## Task 10: create-node を duplicate-guards に委譲 - -**Files:** -- Modify: `packages/ai-engine/src/tools/create-node.ts` -- Modify: `packages/ai-engine/src/tools/create-node.test.ts` - -- [ ] **Step 10-1: failing test — source-url guard の動作と既存 coderef/question regression** - -```typescript -// packages/ai-engine/src/tools/create-node.test.ts に追加: -it('sourceUrl 重複で 2 度目の作成は fail', async () => { - // arrange: 既に sourceUrl を持つ proposal が 1 個存在 - const store = makeFakeStore({ - listNodes: async () => [ - { - id: 'p1', type: 'proposal', adoptAs: 'requirement', - sourceUrl: 'https://jira.test/EPIC-1', - }, - ], - }); - const handler = createNodeHandler({ - store, emit: () => {}, - anchor: { x: 0, y: 0 }, anchorId: '', - agentName: 'ingest-document', - }); - const res = await handler({ - adoptAs: 'requirement', - title: 'R', body: '', - additional: { sourceUrl: 'https://jira.test/EPIC-1' }, - }); - expect(res.ok).toBe(false); - expect(res.output).toContain('sourceUrl'); -}); - -it('既存 coderef 重複 test は引き続き pass (regression)', async () => { - // ... 既存テストをそのまま -}); - -it('既存 question 重複 test (anchor あり) は引き続き pass (regression)', async () => { - // ... 既存テストをそのまま -}); -``` - -- [ ] **Step 10-2: test fail を確認** - -Run: `pnpm -F @tally/ai-engine test -- create-node.test` - -- [ ] **Step 10-3: create-node.ts を dispatcher に委譲するよう書き換え** - -現在の `findDuplicateCoderef` / `sessionQuestionKeys` 関連ロジックを削除し、`dispatchDuplicateGuard` / `notifyCreated` を呼ぶ形に書き換える。normalizeFilePath は coderef guard 側に移したので、create-node で filePath 正規化する処理は「DB 保存前の正規化」目的で残す (guard 側と独立、重複して OK)。 - -```typescript -// packages/ai-engine/src/tools/create-node.ts の該当箇所書き換え -import { - dispatchDuplicateGuard, - notifyCreated, - type DuplicateGuardContext, -} from '../duplicate-guards/index'; - -export function createNodeHandler(deps: CreateNodeDeps) { - let nextOffsetIndex = 0; - const sessionMemo = new Set(); - - return async (input: unknown): Promise => { - const parsed = CreateNodeInputSchema.safeParse(input); - if (!parsed.success) { - return { ok: false, output: `invalid input: ${parsed.error.message}` }; - } - const { adoptAs, title, body, x, y, additional } = parsed.data; - - // coderef の filePath 正規化 + codebaseId 注入 (保存前の integrity) - let normalizedAdditional = additional; - if (adoptAs === 'coderef') { - const base = additional ?? {}; - const withCb: Record = - deps.codebaseId !== undefined && base.codebaseId === undefined - ? { ...base, codebaseId: deps.codebaseId } - : { ...base }; - const fp = withCb.filePath; - if (typeof fp === 'string' && fp.length > 0) { - withCb.filePath = normalizeFilePathForStorage(fp); - } - normalizedAdditional = withCb; - } - - // question の options 正規化 + min 2 検証 (既存ロジック保持) - if (adoptAs === 'question') { - const rawOptions = additional?.options; - const normalizedOptions = Array.isArray(rawOptions) - ? rawOptions - .map((opt) => { - const text = - typeof opt === 'object' && opt !== null && 'text' in opt - ? String((opt as { text: unknown }).text ?? '') - : String(opt ?? ''); - return { id: newQuestionOptionId(), text: text.trim(), selected: false }; - }) - .filter((o) => o.text.length > 0) - : []; - if (normalizedOptions.length < QUESTION_MIN_OPTIONS) { - return { - ok: false, - output: `options は最低 ${QUESTION_MIN_OPTIONS} 個の非空 text を要求します (受け取り: ${normalizedOptions.length} 個)`, - }; - } - normalizedAdditional = { - ...(additional ?? {}), - options: normalizedOptions, - decision: null, - }; - } - - // duplicate-guards dispatch - const guardCtx: DuplicateGuardContext = { - store: deps.store, - anchorId: deps.anchorId, - sessionMemo, - codebaseId: deps.codebaseId, - }; - const dup = await dispatchDuplicateGuard( - adoptAs, - { title, body, additional: normalizedAdditional }, - guardCtx, - ); - if (dup) return { ok: false, output: dup.reason }; - - // 既存の ensureTitle + placement + addNode フロー (変更なし) - const ensuredTitle = title.startsWith('[AI]') ? title : `[AI] ${title}`; - const idx = nextOffsetIndex++; - const placedX = x ?? deps.anchor.x + 260 + idx * 20; - const placedY = y ?? deps.anchor.y + idx * 120; - - try { - const created = (await deps.store.addNode({ - ...(normalizedAdditional ?? {}), - type: 'proposal', - x: placedX, y: placedY, - title: ensuredTitle, body, - adoptAs, - sourceAgentId: deps.agentName, - } as Parameters[0])) as ProposalNode; - deps.emit({ type: 'node_created', node: created }); - - // 生成成功後、guard に通知 (sessionMemo 更新など) - notifyCreated( - adoptAs, - { title, body, additional: normalizedAdditional }, - guardCtx, - ); - return { ok: true, output: JSON.stringify(created) }; - } catch (err) { - return { ok: false, output: `addNode failed: ${String(err)}` }; - } - }; -} - -// 旧 normalizeFilePath を storage 用に残す (guard 側と独立) -function normalizeFilePathForStorage(fp: string): string { - const stripped = fp.startsWith('./') ? fp.slice(2) : fp; - return path.posix.normalize(stripped); -} -``` - -旧 `findDuplicateCoderef` 関数は削除 (coderef guard に移行済み)。 - -- [ ] **Step 10-4: test pass + regression** - -Run: `pnpm -F @tally/ai-engine test -- create-node` -Expected: PASS 全件 (新規 sourceUrl test + 既存 coderef/question test) - -- [ ] **Step 10-5: commit** - -```bash -git add packages/ai-engine/src/tools/create-node.ts packages/ai-engine/src/tools/create-node.test.ts -git commit -m "refactor(ai-engine): create-node を duplicate-guards に委譲、sourceUrl guard を有効化" -``` - ---- - -## Task 11: ChatRunner — buildMcpServers 統合 - -**Files:** -- Modify: `packages/ai-engine/src/chat-runner.ts` -- Modify: `packages/ai-engine/src/chat-runner.test.ts` - -- [ ] **Step 11-1: failing test — プロジェクトの mcpServers[] から外部 MCP が sdk.query に渡る** - -```typescript -// chat-runner.test.ts に追加 -it('プロジェクト設定の mcpServers[] を sdk.query に渡す', async () => { - process.env.TEST_PAT = 'secret'; - const chatStore = new FileSystemChatStore(root); - const projectStore = new FileSystemProjectStore(root); - // saveProjectMeta で mcpServers を含めて保存 - await projectStore.saveProjectMeta({ - id: 'proj-1', name: 'P', codebases: [], - mcpServers: [ - { - id: 'test-mcp', name: 'T', kind: 'atlassian', - url: 'https://t.test/mcp', - auth: { type: 'pat', envVar: 'TEST_PAT' }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - createdAt: '2026-04-24T00:00:00Z', - updatedAt: '2026-04-24T00:00:00Z', - }); - const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); - - const querySpy = vi.fn(() => - (async function* () { - yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; - })(), - ); - const sdk: SdkLike = { query: querySpy }; - const runner = new ChatRunner({ - sdk, chatStore, projectStore, projectDir: root, threadId: thread.id, - }); - const events: ChatEvent[] = []; - for await (const e of runner.runUserTurn('hi')) events.push(e); - - const callArg = querySpy.mock.calls[0][0] as any; - expect(Object.keys(callArg.options.mcpServers)).toEqual( - expect.arrayContaining(['tally', 'test-mcp']), - ); - expect((callArg.options.mcpServers['test-mcp'] as any).headers.Authorization).toBe( - 'Bearer secret', - ); - expect(callArg.options.allowedTools).toContain('mcp__tally__*'); - expect(callArg.options.allowedTools).toContain('mcp__test-mcp__*'); -}); - -it('env 未設定ならエラーイベントを発火 (sdk.query は呼ばない)', async () => { - delete process.env.MISSING_PAT; - const chatStore = new FileSystemChatStore(root); - const projectStore = new FileSystemProjectStore(root); - await projectStore.saveProjectMeta({ - id: 'proj-1', name: 'P', codebases: [], - mcpServers: [ - { - id: 'a', name: 'A', kind: 'atlassian', - url: 'https://t.test/mcp', - auth: { type: 'pat', envVar: 'MISSING_PAT' }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - createdAt: '2026-04-24T00:00:00Z', updatedAt: '2026-04-24T00:00:00Z', - }); - const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); - - const querySpy = vi.fn(); - const sdk: SdkLike = { query: querySpy }; - const runner = new ChatRunner({ - sdk, chatStore, projectStore, projectDir: root, threadId: thread.id, - }); - const events: ChatEvent[] = []; - for await (const e of runner.runUserTurn('hi')) events.push(e); - - expect(querySpy).not.toHaveBeenCalled(); - expect(events.some((e) => e.type === 'error' && /MISSING_PAT/.test(e.message))).toBe(true); -}); -``` - -- [ ] **Step 11-2: test fail** - -Run: `pnpm -F @tally/ai-engine test -- chat-runner` - -- [ ] **Step 11-3: ChatRunner.runUserTurn を書き換え** - -`runUserTurn` の step 5 (sdkDone IIFE) 冒頭で buildMcpServers を呼び、エラー時は error event を push して early return: - -```typescript -// chat-runner.ts の runUserTurn 内 -import { buildMcpServers } from './mcp/build-mcp-servers'; -import { redactMcpSecrets } from './mcp/redact'; - -// ... (既存の step 1-4 は維持) - -// プロジェクトから最新の mcpServers[] を取得 (run ごとにホットリロード) -const projectMeta = await projectStore.getProjectMeta(); -const externalConfigs = projectMeta?.mcpServers ?? []; - -let mcpServers: Record; -let allowedTools: string[]; -try { - const built = buildMcpServers({ tallyMcp: mcp, configs: externalConfigs }); - mcpServers = built.mcpServers; - allowedTools = built.allowedTools; -} catch (err) { - yield { type: 'error', code: 'mcp_config_invalid', message: String(err) }; - return; -} - -// ... sdkDone IIFE 内の sdk.query options を差し替え -const iter = sdk.query({ - prompt, - options: { - systemPrompt, - mcpServers, // 動的生成 - tools: [], - allowedTools, // 動的生成 - permissionMode: 'dontAsk', - settingSources: [], - cwd: projectDir, - // ... (既存 CLAUDE_CODE_PATH 処理) - }, -}); - -// 既存 console.log は redaction 経由に -console.log('[chat-runner] sdk msg:', JSON.stringify(redactMcpSecrets(msg)).slice(0, 200)); -``` - -- [ ] **Step 11-4: test pass + regression (text-only / invokeInterceptedTool テスト)** - -Run: `pnpm -F @tally/ai-engine test -- chat-runner` -Expected: PASS 全件 (新規 2 件 + 既存 3 件) - -- [ ] **Step 11-5: commit** - -```bash -git add packages/ai-engine/src/chat-runner.ts packages/ai-engine/src/chat-runner.test.ts -git commit -m "feat(ai-engine): ChatRunner が buildMcpServers で外部 MCP を合成するように変更" -``` - ---- - -## Task 12: ChatRunner — extractAssistantBlocks + 外部 tool_use 永続化 - -**Files:** -- Modify: `packages/ai-engine/src/chat-runner.ts` -- Modify: `packages/ai-engine/src/chat-runner.test.ts` - -- [ ] **Step 12-1: failing test — 外部 MCP の tool_use block が source='external' で永続化される** - -```typescript -it('SDK から来た外部 tool_use/tool_result は source=external で chatStore に append', async () => { - process.env.TEST_PAT = 'secret'; - const chatStore = new FileSystemChatStore(root); - const projectStore = new FileSystemProjectStore(root); - await projectStore.saveProjectMeta({ - id: 'proj-1', name: 'P', codebases: [], - mcpServers: [ - { - id: 'atlassian', name: 'A', kind: 'atlassian', - url: 'https://t.test/mcp', - auth: { type: 'pat', envVar: 'TEST_PAT' }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - createdAt: '2026-04-24T00:00:00Z', updatedAt: '2026-04-24T00:00:00Z', - }); - const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); - - const sdk: SdkLike = { - query: () => - (async function* () { - yield { - type: 'assistant', - message: { - content: [ - { type: 'text', text: 'Jira を読みます' }, - { - type: 'tool_use', - id: 'atlassian-tu-1', - name: 'mcp__atlassian__getJiraIssue', - input: { key: 'EPIC-1' }, - }, - ], - }, - } as unknown as SdkMessageLike; - yield { - type: 'user', - message: { - content: [ - { - type: 'tool_result', - tool_use_id: 'atlassian-tu-1', - content: [{ type: 'text', text: '{"summary":"Epic title"}' }], - }, - ], - }, - } as unknown as SdkMessageLike; - yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; - })(), - }; - const runner = new ChatRunner({ - sdk, chatStore, projectStore, projectDir: root, threadId: thread.id, - }); - for await (const _ of runner.runUserTurn('@JIRA EPIC-1 を読んで')) {} - - const reloaded = await chatStore.getChat(thread.id); - const asstMsg = reloaded?.messages.find((m) => m.role === 'assistant'); - const toolUse = asstMsg?.blocks.find((b) => b.type === 'tool_use') as any; - expect(toolUse.source).toBe('external'); - expect(toolUse.name).toBe('mcp__atlassian__getJiraIssue'); - expect(toolUse.approval).toBeUndefined(); - - const toolResult = asstMsg?.blocks.find((b) => b.type === 'tool_result') as any; - expect(toolResult.ok).toBe(true); - expect(toolResult.output).toContain('Epic title'); -}); -``` - -- [ ] **Step 12-2: test fail** - -Run: `pnpm -F @tally/ai-engine test -- chat-runner` - -- [ ] **Step 12-3: 実装 — extractAssistantBlocks を拡張** - -```typescript -// chat-runner.ts の下部 helper 書き換え -type ExtractedBlock = - | { type: 'text'; text: string } - | { type: 'tool_use'; toolUseId: string; name: string; input: unknown } - | { type: 'tool_result'; toolUseId: string; ok: boolean; output: string }; - -// SDK から流れてくる assistant message + user message (tool_result を含む) から block 抽出。 -// Tally MCP の tool_use は MCP handler が処理するので、ここでは外部 MCP (mcp____* -// で name !== 'tally') のものだけ拾う。 -function extractExternalBlocks(msg: SdkMessageLike): ExtractedBlock[] { - const m = msg as unknown as { type?: string; message?: { content?: unknown[] } }; - if ((m.type !== 'assistant' && m.type !== 'user') || !m.message?.content) return []; - const out: ExtractedBlock[] = []; - for (const block of m.message.content) { - const b = block as { - type?: string; - text?: string; - id?: string; - name?: string; - input?: unknown; - tool_use_id?: string; - content?: Array<{ type?: string; text?: string }>; - is_error?: boolean; - }; - if (b.type === 'text' && typeof b.text === 'string' && m.type === 'assistant') { - out.push({ type: 'text', text: b.text }); - } else if ( - b.type === 'tool_use' && - typeof b.id === 'string' && - typeof b.name === 'string' && - !b.name.startsWith('mcp__tally__') // Tally MCP は intercept 経路 - ) { - out.push({ - type: 'tool_use', - toolUseId: b.id, - name: b.name, - input: b.input, - }); - } else if ( - b.type === 'tool_result' && - typeof b.tool_use_id === 'string' && - Array.isArray(b.content) - ) { - const text = b.content - .filter((c) => c.type === 'text' && typeof c.text === 'string') - .map((c) => c.text) - .join(''); - out.push({ - type: 'tool_result', - toolUseId: b.tool_use_id, - ok: !b.is_error, - output: text, - }); - } - } - return out; -} -``` - -`runUserTurn` 内 SDK iterate loop を書き換え: - -```typescript -for await (const msg of iter) { - console.log('[chat-runner] sdk msg:', JSON.stringify(redactMcpSecrets(msg)).slice(0, 200)); - const blocks = extractExternalBlocks(msg); - for (const b of blocks) { - if (b.type === 'text') { - textBuffer.push(b.text); - queue.push({ type: 'chat_text_delta', messageId: assistantMsgId, text: b.text }); - } else if (b.type === 'tool_use') { - // 外部 MCP の tool_use: 永続化 + UI 通知 (承認なし) - await chatStore.appendBlockToMessage(threadId, assistantMsgId, { - type: 'tool_use', - toolUseId: b.toolUseId, - name: b.name, - input: b.input, - source: 'external', - }); - queue.push({ - type: 'chat_tool_external_use', - messageId: assistantMsgId, - toolUseId: b.toolUseId, - name: b.name, - input: b.input, - }); - } else if (b.type === 'tool_result') { - await chatStore.appendBlockToMessage(threadId, assistantMsgId, { - type: 'tool_result', - toolUseId: b.toolUseId, - ok: b.ok, - output: b.output, - }); - queue.push({ - type: 'chat_tool_external_result', - messageId: assistantMsgId, - toolUseId: b.toolUseId, - ok: b.ok, - output: b.output, - }); - } - } -} -``` - -`packages/ai-engine/src/stream.ts` に新 event を追加: - -```typescript -| { type: 'chat_tool_external_use'; messageId: string; toolUseId: string; name: string; input: unknown } -| { type: 'chat_tool_external_result'; messageId: string; toolUseId: string; ok: boolean; output: string } -``` - -- [ ] **Step 12-4: test pass** - -Run: `pnpm -F @tally/ai-engine test -- chat-runner` - -- [ ] **Step 12-5: commit** - -```bash -git add packages/ai-engine/src/chat-runner.ts packages/ai-engine/src/chat-runner.test.ts packages/ai-engine/src/stream.ts -git commit -m "feat(ai-engine): ChatRunner が外部 MCP の tool_use/tool_result を source=external で永続化" -``` - ---- - -## Task 13: ChatRunner — tool_result 4KB truncate (永続化時のみ) - -**Files:** -- Modify: `packages/ai-engine/src/chat-runner.ts` -- Modify: `packages/ai-engine/src/chat-runner.test.ts` - -- [ ] **Step 13-1: failing test** - -```typescript -it('tool_result output が 4KB 超えると永続化時に truncate、event は full', async () => { - process.env.TEST_PAT = 'secret'; - const chatStore = new FileSystemChatStore(root); - const projectStore = new FileSystemProjectStore(root); - await projectStore.saveProjectMeta({ - id: 'proj-1', name: 'P', codebases: [], - mcpServers: [ - { - id: 'atlassian', name: 'A', kind: 'atlassian', - url: 'https://t.test/mcp', - auth: { type: 'pat', envVar: 'TEST_PAT' }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - createdAt: '2026-04-24T00:00:00Z', updatedAt: '2026-04-24T00:00:00Z', - }); - const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); - const bigOutput = 'X'.repeat(10_000); - const sdk: SdkLike = { - query: () => - (async function* () { - yield { - type: 'user', - message: { - content: [ - { type: 'tool_result', tool_use_id: 'big-1', content: [{ type: 'text', text: bigOutput }] }, - ], - }, - } as unknown as SdkMessageLike; - yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; - })(), - }; - const runner = new ChatRunner({ - sdk, chatStore, projectStore, projectDir: root, threadId: thread.id, - }); - const events: ChatEvent[] = []; - for await (const e of runner.runUserTurn('q')) events.push(e); - - // event には full output - const evt = events.find((e) => e.type === 'chat_tool_external_result') as any; - expect(evt.output.length).toBe(10_000); - - // YAML 永続化は truncate - const reloaded = await chatStore.getChat(thread.id); - const tr = reloaded?.messages - .flatMap((m) => m.blocks) - .find((b) => b.type === 'tool_result') as any; - expect(tr.output.length).toBeLessThanOrEqual(4200); // 4KB + marker 余裕 - expect(tr.output).toContain('(truncated'); -}); -``` - -- [ ] **Step 13-2: test fail** - -Run: `pnpm -F @tally/ai-engine test -- chat-runner` - -- [ ] **Step 13-3: 実装 — truncate ロジック** - -chat-runner.ts の tool_result 永続化箇所を修正: - -```typescript -const TOOL_RESULT_PERSIST_LIMIT = 4096; - -function truncateForPersistence(output: string): string { - if (output.length <= TOOL_RESULT_PERSIST_LIMIT) return output; - const head = output.slice(0, TOOL_RESULT_PERSIST_LIMIT); - return `${head}\n... (truncated, ${output.length} chars total)`; -} - -// tool_result append: -await chatStore.appendBlockToMessage(threadId, assistantMsgId, { - type: 'tool_result', - toolUseId: b.toolUseId, - ok: b.ok, - output: truncateForPersistence(b.output), -}); -// event は full を流す: -queue.push({ - type: 'chat_tool_external_result', - messageId: assistantMsgId, - toolUseId: b.toolUseId, - ok: b.ok, - output: b.output, -}); -``` - -- [ ] **Step 13-4: test pass** - -Run: `pnpm -F @tally/ai-engine test -- chat-runner` - -- [ ] **Step 13-5: commit** - -```bash -git add packages/ai-engine/src/chat-runner.ts packages/ai-engine/src/chat-runner.test.ts -git commit -m "feat(ai-engine): tool_result output を永続化時 4KB に truncate (event はフル)" -``` - ---- - -## Task 14: ChatRunner — buildChatPrompt が tool_use/tool_result を replay (T4 fix) - -**Files:** -- Modify: `packages/ai-engine/src/chat-runner.ts` -- Modify: `packages/ai-engine/src/chat-runner.test.ts` - -- [ ] **Step 14-1: failing test — multi-turn で前ターンの tool_result が prompt に含まれる** - -```typescript -it('buildChatPrompt が tool_use と tool_result も replay する', async () => { - const chatStore = new FileSystemChatStore(root); - const projectStore = new FileSystemProjectStore(root); - const thread = await chatStore.createChat({ projectId: 'proj-1', title: 't' }); - // 1 ターン目: user + assistant (tool_use + tool_result + text) - await chatStore.appendMessage(thread.id, { - id: 'u1', role: 'user', - blocks: [{ type: 'text', text: '@JIRA EPIC-1 を読んで' }], - createdAt: '2026-04-24T00:00:00Z', - }); - await chatStore.appendMessage(thread.id, { - id: 'a1', role: 'assistant', - blocks: [ - { type: 'text', text: 'Jira を読みます' }, - { - type: 'tool_use', toolUseId: 'tu-1', - name: 'mcp__atlassian__getJiraIssue', input: { key: 'EPIC-1' }, - source: 'external', - }, - { type: 'tool_result', toolUseId: 'tu-1', ok: true, output: '{"summary":"Epic X"}' }, - { type: 'text', text: '読みました。Epic X です' }, - ], - createdAt: '2026-04-24T00:01:00Z', - }); - - // 2 ターン目: 新しい user message - await chatStore.appendMessage(thread.id, { - id: 'u2', role: 'user', - blocks: [{ type: 'text', text: '続けて子チケット STORY-42 を読んで' }], - createdAt: '2026-04-24T00:02:00Z', - }); - - // buildChatPrompt を直接 import (export 必要) - const reloaded = await chatStore.getChat(thread.id); - const prompt = buildChatPromptForTest(reloaded!.messages); - - // 過去 tool_use / tool_result が含まれる - expect(prompt).toContain('Epic X'); - expect(prompt).toContain('getJiraIssue'); - // 直近 user が current message として出る - expect(prompt).toContain('STORY-42'); -}); -``` - -`chat-runner.ts` の `buildChatPrompt` を export に変更する必要がある (或いは test 用に関数エクスポート)。 - -- [ ] **Step 14-2: test fail** - -Run: `pnpm -F @tally/ai-engine test -- chat-runner` - -- [ ] **Step 14-3: buildChatPrompt を拡張** - -```typescript -// chat-runner.ts の buildChatPrompt 書き換え、export を追加 -export function buildChatPromptForTest(messages: ChatMessage[]): string { - return buildChatPrompt(messages); -} - -function buildChatPrompt(messages: ChatMessage[]): string { - const lines: string[] = []; - const last = messages[messages.length - 1]; - const past = last?.role === 'user' ? messages.slice(0, -1) : messages; - - if (past.length > 0) { - lines.push(''); - for (const m of past) { - lines.push(``); - for (const b of m.blocks) { - if (b.type === 'text') { - lines.push(b.text); - } else if (b.type === 'tool_use') { - const srcTag = (b as any).source === 'external' ? ' source="external"' : ''; - lines.push( - `${JSON.stringify(b.input)}`, - ); - } else if (b.type === 'tool_result') { - lines.push( - `${b.output}`, - ); - } - } - lines.push(``); - } - lines.push(''); - } - - if (last && last.role === 'user') { - const texts = last.blocks - .filter((b): b is Extract => b.type === 'text') - .map((b) => b.text); - lines.push(''); - lines.push(texts.join('\n')); - lines.push(''); - } - - return lines.join('\n'); -} -``` - -- [ ] **Step 14-4: test pass + multi-turn E2E (runUserTurn 2 回) の regression なし** - -Run: `pnpm -F @tally/ai-engine test -- chat-runner` - -- [ ] **Step 14-5: commit** - -```bash -git add packages/ai-engine/src/chat-runner.ts packages/ai-engine/src/chat-runner.test.ts -git commit -m "feat(ai-engine): buildChatPrompt が tool_use/tool_result も replay (T4 fix: multi-turn で context 保持)" -``` - ---- - -## Task 15: agent-runner — buildMcpServers 共有 + regression snapshot - -**Files:** -- Modify: `packages/ai-engine/src/agent-runner.ts` -- Modify: `packages/ai-engine/src/agent-runner.test.ts` - -- [ ] **Step 15-1: failing test — プロジェクトの mcpServers[] が agent-runner でも外部 MCP として渡る** - -```typescript -// agent-runner.test.ts -it('プロジェクト mcpServers[] が sdk.query に渡る (agent-runner も外部 MCP 合成)', async () => { - process.env.TEST_PAT = 'secret'; - const store = makeProjectStoreWithMeta({ - mcpServers: [ - { - id: 'atlassian', name: 'A', kind: 'atlassian', - url: 'https://t.test/mcp', - auth: { type: 'pat', envVar: 'TEST_PAT' }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - }); - const querySpy = vi.fn(() => - (async function* () { - yield { type: 'result', subtype: 'success', result: 'ok' } as unknown as SdkMessageLike; - })(), - ); - const sdk: SdkLike = { query: querySpy }; - // 既存 agent (extract-questions) で試行 - await runAgent({ - sdk, store, agentName: 'extract-questions', input: { nodeId: 'n-anchor' }, - projectDir: root, - }); - const call = querySpy.mock.calls[0][0] as any; - expect(Object.keys(call.options.mcpServers)).toEqual(expect.arrayContaining(['tally', 'atlassian'])); -}); -``` - -- [ ] **Step 15-2: test fail** - -Run: `pnpm -F @tally/ai-engine test -- agent-runner` - -- [ ] **Step 15-3: agent-runner.ts の sdk.query options を buildMcpServers 経由にする** - -`packages/ai-engine/src/agent-runner.ts:114` 周辺を書き換え: - -```typescript -import { buildMcpServers } from './mcp/build-mcp-servers'; -import { redactMcpSecrets } from './mcp/redact'; - -// runAgent 内で project meta を取得 → buildMcpServers -const projectMeta = await store.getProjectMeta(); -const externalConfigs = projectMeta?.mcpServers ?? []; -const { mcpServers, allowedTools: externalAllowed } = buildMcpServers({ - tallyMcp: mcp, - configs: externalConfigs, -}); - -// agent の allowedTools + 外部 MCP allowedTools を合成 -const finalAllowedTools = [ - ...agentDef.allowedTools, - ...externalAllowed.filter((t) => t !== 'mcp__tally__*'), // agentDef 側に既に具体指定あれば dedup -]; - -const iter = sdk.query({ - prompt, - options: { - systemPrompt, - mcpServers, - tools: [], - allowedTools: finalAllowedTools, - permissionMode: 'dontAsk', - settingSources: [], - cwd: agentCwd, - // ... - }, -}); - -// ログ redaction -console.log('[agent-runner] msg:', JSON.stringify(redactMcpSecrets(msg)).slice(0, 200)); -``` - -- [ ] **Step 15-4: regression snapshot — 既存 5 agent の動作不変** - -各 agent (decompose-to-stories / extract-questions / find-related-code / analyze-impact / ingest-document) に対して: -- mcpServers[] 空のプロジェクトで runAgent を走らせる -- sdk.query に渡る mcpServers が `{ tally }` のみ -- allowedTools が既存 agentDef.allowedTools と一致 -- agent event の emit シーケンスが不変 - -```typescript -it.each([ - 'decompose-to-stories', - 'extract-questions', - 'find-related-code', - 'analyze-impact', - 'ingest-document', -] as const)('%s は mcpServers[] 空で既存動作不変', async (agentName) => { - // ... 各 agent に最小 valid input を渡し、sdk.query の options snapshot を記録 - // 期待: mcpServers = { tally }, allowedTools = agentDef.allowedTools (外部 MCP 合成なし) -}); -``` - -- [ ] **Step 15-5: test pass** - -Run: `pnpm -F @tally/ai-engine test` - -- [ ] **Step 15-6: commit** - -```bash -git add packages/ai-engine/src/agent-runner.ts packages/ai-engine/src/agent-runner.test.ts -git commit -m "feat(ai-engine): agent-runner を buildMcpServers に統合 (chat-runner と共有、5 agent regression OK)" -``` - ---- - -## Task 16: プロジェクト設定 API — mcpServers round-trip - -**Files:** -- Modify: `packages/frontend/src/app/api/projects/[id]/route.ts` -- Modify: `packages/frontend/src/lib/api.ts` -- Test: `packages/frontend/src/app/api/projects/[id]/route.test.ts` (既存 or 新規) - -- [ ] **Step 16-1: failing test — GET/PUT で mcpServers を round-trip** - -```typescript -// packages/frontend/src/app/api/projects/[id]/route.test.ts -it('PUT with mcpServers → GET で同じ mcpServers が返る', async () => { - // ... test setup - const putRes = await PUT(/* ... */, { - params: { id: 'proj-1' }, - body: { - // 既存 project fields - mcpServers: [ - { - id: 'atlassian', name: 'A', kind: 'atlassian', - url: 'https://t.test/mcp', - auth: { type: 'pat', envVar: 'ATLASSIAN_PAT' }, - options: { maxChildIssues: 30, maxCommentsPerIssue: 5 }, - }, - ], - }, - }); - expect(putRes.status).toBe(200); - - const getRes = await GET(/* ... */, { params: { id: 'proj-1' } }); - const json = await getRes.json(); - expect(json.mcpServers).toHaveLength(1); - expect(json.mcpServers[0].auth.envVar).toBe('ATLASSIAN_PAT'); -}); -``` - -- [ ] **Step 16-2: test fail (まだ route.ts が mcpServers を受けない)** - -- [ ] **Step 16-3: route.ts を修正** - -既存 PUT handler のバリデーションに `mcpServers: McpServerConfig[]` を含めた入力受付を追加。既存 ProjectSchema/ProjectMetaSchema 経由で zod parse が自動的に mcpServers を受けるようになっている (Task 2 で default [] を追加済み) ため、handler 側は明示フィールド追加不要の可能性あり。それでも入力 shape を再確認: - -```typescript -// route.ts の PUT 内 -const body = await request.json(); -const parsed = UpdateProjectInputSchema.parse(body); -// parsed.mcpServers が自動で入る -await projectStore.saveProjectMeta({ - ...existingMeta, - name: parsed.name ?? existingMeta.name, - codebases: parsed.codebases ?? existingMeta.codebases, - mcpServers: parsed.mcpServers ?? existingMeta.mcpServers ?? [], - updatedAt: new Date().toISOString(), -}); -``` - -`packages/frontend/src/lib/api.ts` の UpdateProjectInput 型 (または zod schema) に mcpServers を追加: - -```typescript -export const UpdateProjectInputSchema = z.object({ - name: z.string().optional(), - codebases: z.array(CodebaseSchema).optional(), - mcpServers: z.array(McpServerConfigSchema).optional(), -}); -``` - -- [ ] **Step 16-4: test pass** - -Run: `pnpm -F @tally/frontend test` - -- [ ] **Step 16-5: commit** - -```bash -git add packages/frontend/src/app/api/projects/ packages/frontend/src/lib/api.ts -git commit -m "feat(frontend): projects API が mcpServers[] を受け取る" -``` - ---- - -## Task 17: 設定ダイアログ UI — mcpServers CRUD - -**Files:** -- Modify: `packages/frontend/src/components/dialog/project-settings-dialog.tsx` -- Test: `packages/frontend/src/components/dialog/project-settings-dialog.test.tsx` - -- [ ] **Step 17-1: failing test — mcpServers セクションの CRUD** - -```typescript -it('mcpServers[] セクションで新規追加 → name/url/envVar 入力 → 保存 → 再表示で復元', async () => { - const onSave = vi.fn(); - const { getByText, getByLabelText } = render( - , - ); - fireEvent.click(getByText('MCP サーバーを追加')); - fireEvent.change(getByLabelText('表示名'), { target: { value: 'Atlassian' } }); - fireEvent.change(getByLabelText('URL'), { target: { value: 'https://t.test/mcp' } }); - fireEvent.change(getByLabelText('環境変数名'), { target: { value: 'ATLASSIAN_PAT' } }); - fireEvent.click(getByText('保存')); - expect(onSave).toHaveBeenCalledWith( - expect.objectContaining({ - mcpServers: [ - expect.objectContaining({ - name: 'Atlassian', - url: 'https://t.test/mcp', - auth: expect.objectContaining({ envVar: 'ATLASSIAN_PAT' }), - }), - ], - }), - ); -}); - -it('secret 値 (PAT) の入力欄は表示しない', () => { - const { queryByText, queryByLabelText } = render( - {}} />, - ); - expect(queryByLabelText('PAT')).toBeNull(); - expect(queryByLabelText('シークレット')).toBeNull(); - expect(queryByText(/\.env/)).toBeTruthy(); // 「PAT は .env ファイルに置いてください」的な説明 -}); -``` - -- [ ] **Step 17-2: test fail** - -- [ ] **Step 17-3: 実装 — ダイアログに mcpServers セクションを追加** - -既存の project-settings-dialog.tsx に新セクション: - -```tsx -// ProjectSettingsDialog 内の JSX -
-

MCP サーバー (外部連携)

-

- AI が外部の情報源にアクセスするための接続先。 - 秘密値 (PAT など) はこのフォームではなく .env ファイルに置いてください - (例: ATLASSIAN_PAT=...)。 -

- {mcpServers.map((s, i) => ( -
- updateMcpServer(i, { ...s, id: e.target.value })} - /> - updateMcpServer(i, { ...s, name: e.target.value })} - /> - - updateMcpServer(i, { ...s, url: e.target.value })} - /> - - updateMcpServer(i, { ...s, auth: { ...s.auth, envVar: e.target.value } }) - } - /> - -
- ))} - -
-``` - -`addMcpServer`, `updateMcpServer`, `removeMcpServer` は local state handler。保存時に `onSave({ ..., mcpServers })` を呼ぶ。 - -- [ ] **Step 17-4: test pass** - -Run: `pnpm -F @tally/frontend test` - -- [ ] **Step 17-5: commit** - -```bash -git add packages/frontend/src/components/dialog/ -git commit -m "feat(frontend): プロジェクト設定に MCP サーバー CRUD UI を追加 (secret は .env で管理)" -``` - ---- - -## Task 18: Chat UI — source 分岐 (external は承認 UI 出さない) - -**Files:** -- Modify: `packages/frontend/src/components/chat/tool-approval-card.tsx` -- Modify: `packages/frontend/src/components/chat/chat-tab.tsx` -- Test: 各 test - -- [ ] **Step 18-1: failing test — external tool_use は承認ボタンが出ない** - -```typescript -// tool-approval-card.test.tsx -it('source=external の tool_use は承認ボタンを表示しない', () => { - const { queryByText, getByText } = render( - {}} - onReject={() => {}} - />, - ); - expect(queryByText('承認')).toBeNull(); - expect(queryByText('却下')).toBeNull(); - expect(getByText(/getJiraIssue/)).toBeTruthy(); // AI が読んだ外部ソース表示 -}); - -it('source=internal (approval=pending) の tool_use は承認ボタンが出る (既存挙動 regression)', () => { - const { getByText } = render( - {}} - onReject={() => {}} - />, - ); - expect(getByText('承認')).toBeTruthy(); -}); -``` - -- [ ] **Step 18-2: test fail** - -- [ ] **Step 18-3: tool-approval-card.tsx を source 分岐** - -```tsx -export function ToolApprovalCard({ block, onApprove, onReject }: Props) { - if (block.source === 'external') { - return ( -
-
- 🔗 外部ソース: {block.name} -
{JSON.stringify(block.input, null, 2)}
-
-
- ); - } - // 既存 internal + approval=pending/approved/rejected 表示 (変更なし) - return
{/* 既存 JSX */}
; -} -``` - -- [ ] **Step 18-4: chat-tab.tsx で external tool_result を折り畳み表示** - -chat-tab.tsx 内 block レンダリング箇所: - -```tsx -{block.type === 'tool_result' && ( -
- tool_result ({block.ok ? 'OK' : 'ERROR'}) -
-      {block.output}
-    
-
-)} -``` - -- [ ] **Step 18-5: test pass** - -Run: `pnpm -F @tally/frontend test` - -- [ ] **Step 18-6: commit** - -```bash -git add packages/frontend/src/components/chat/ -git commit -m "feat(frontend): Chat UI が外部 MCP の tool_use/tool_result を折り畳み表示 (承認ボタン非表示)" -``` - ---- - -## Task 19: Dogfooding Protocol (手動、実装ではなく運用手順) - -**Files:** -- Create: `docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md` - -これは実装タスクではない。Task 1-18 完了後、自分の手元で 10 個の Jira エピックを使って動作確認し、Success Criteria を測る。plan ではこの手順だけを明記する。 - -- [ ] **Step 19-1: dogfooding log ファイルを作成** - -```bash -cat > docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md <<'EOF' -# Dogfood Log — Atlassian MCP C フェーズ - -## Setup -- .env に `ATLASSIAN_PAT=` を設定 -- プロジェクト設定で MCP サーバー追加: kind=atlassian, url=, envVar=ATLASSIAN_PAT - -## Epic 1-10 -### Epic N: -- **Turn 1:** `@JIRA を読んで論点を出して` - - 生成 question proposal: N 個 - - 所要時間: <秒> - - 採用: 、却下: - - 採用判断の理由: -- **Turn 2 (multi-turn test):** `続けて子チケット を読んで論点を追加して` - - AI が前ターンの Epic 内容を覚えているか: YES / NO - - 生成 question proposal: N 個 - - 採用: -- **「気づかなかった論点」判定:** YES / NO、YES なら具体: -- **重複ガード動作:** 同 URL 2 度目取り込み → sourceUrl guard 発動: YES / NO - -## 集計 -- 合計生成 question proposal: N 個 -- 合計採用数: N 個 -- 採用率: N% (target: 50%+) -- 「気づかなかった論点」合計: N 件 (target: 3+) -- multi-turn が機能した Epic: N/10 (target: 10/10) -- 重複ガード発動数 / 試行数: N/N - -## 観察メモ (A フェーズの ingest-jira-epic プロンプト設計の入力) -- プロンプト改善点: -- tool 呼び出しパターン: -- レイテンシ分布: -- 失敗パターン (接続失敗 / rate limit / タイムアウト): -EOF -``` - -- [ ] **Step 19-2: 実際に 10 epic で dogfood** - -ユーザーが手元で実施、上記 log に記録。 - -- [ ] **Step 19-3: Success Criteria 判定** - -C フェーズの Success Criteria: -- 90 秒以内に question proposal 3 個以上 (all 10 epics で満たすこと) -- 採用率 50%+ -- 「気づかなかった論点」3+ 件 -- multi-turn での context 保持 - -満たせば A フェーズへ。満たさなければ Task 1-18 のどこかに追加修正。 - -- [ ] **Step 19-4: commit (dogfood log)** - -```bash -git add docs/superpowers/plans/2026-04-24-atlassian-mcp-c-phase-dogfood-log.md -git commit -m "docs: Atlassian MCP C フェーズ dogfood log を記録" -``` - ---- - -## Final Verification - -C フェーズ完了条件: - -- [ ] `pnpm test` 全パッケージ PASS -- [ ] `pnpm -F @tally/ai-engine test` の既存 agent 5 個 (decompose-to-stories / extract-questions / find-related-code / analyze-impact / ingest-document) が regression なしで通る -- [ ] 既存 Chat の Tally MCP 承認フロー (pending → approve → executed) が動作不変 -- [ ] `pnpm lint` PASS (Biome) -- [ ] `pnpm typecheck` PASS (tsc) -- [ ] dogfood log が 10 epic 分記録されている -- [ ] C フェーズ Success Criteria 満たす - -これで A フェーズ (`ingest-jira-epic` agent + 専用ボタン + ADR) の plan を別途書ける。 - ---- - -## Self-Review - -- [x] **Spec coverage:** design doc の C フェーズ Step 1-8 すべてをタスクに落としている。Issue 1-9 + T1-T4 すべてに対応する task がある。 -- [x] **Placeholder scan:** "TBD" / "implement later" / "add validation" なし。各 step にコード記述あり。 -- [x] **Type consistency:** `McpServerConfig` / `DuplicateGuard` / `ChatBlock` の型名・フィールド名が全タスクで一致。 -- [x] **spec 対応:** Test Plan の Coverage Diagram 54 GAP のうち主要 file 単位のテストをすべて TDD で組み込み。dogfooding は Task 19 で記録。 -- [x] **Parallel 可能性:** Task 1-3 (core schema) → Task 4-9 (ai-engine utilities) → Task 10 (create-node refactor) → Task 11-14 (ChatRunner) → Task 15 (agent-runner) → Task 16-18 (frontend) の依存関係は直列寄り。Task 4/5/6 は相互独立なので worktree 並列可。Task 16/17/18 も frontend 内で独立ファイルなので並列可。 diff --git a/docs/superpowers/specs/2026-04-21-project-storage-redesign-design.md b/docs/superpowers/specs/2026-04-21-project-storage-redesign-design.md deleted file mode 100644 index 5563635..0000000 --- a/docs/superpowers/specs/2026-04-21-project-storage-redesign-design.md +++ /dev/null @@ -1,479 +0,0 @@ -# プロジェクトストレージ再設計 - -- **日付**: 2026-04-21 -- **ステータス**: Design (未実装) -- **関連 ADR**: ADR-0008 / ADR-0009 / ADR-0010(新規)、ADR-0003(Supersede) - -## 背景 - -現状の Tally は ADR-0003 に基づき、プロジェクトを対象リポジトリ直下の `.tally/` ディレクトリに YAML ファイル群として保存している。発見は `ghq list -p` と `TALLY_WORKSPACE` 環境変数によるスキャンで行う。 - -この設計は「1 プロジェクト = 1 リポジトリ」を前提としているが、実際のユースケースではフロントエンドとバックエンドが別リポジトリに分かれる構成が頻出する。現状では横断プロジェクトを素直に表現できない。 - -加えて、個人の思考ログ・初期検証段階のアイデアなど「まだリポジトリに紐付けたくない」プロジェクトの置き場所がない。 - -## 目標 - -1. プロジェクトをリポジトリから独立した第一級の存在として扱う -2. 0 件以上のコードベースを 1 プロジェクトから参照できる(未紐付けのアイデアから多リポジトリ横断プロジェクトまでを同一モデルで扱う) -3. 保存先をユーザーが任意に選べる(デフォルトは `~/.local/share/tally/projects/`) -4. 発見ロジックを明示レジストリに統一し、暗黙スキャンを廃止する -5. フォルダ選択ダイアログ(バックエンド駆動)でプロジェクト作成・インポートを行う - -後方互換は維持しない。既存の `.tally/` 規約・`TALLY_WORKSPACE`・ghq 連携はすべて廃止し、ADR-0003 を Supersede する。 - -## アーキテクチャ概要 - -**コアコンセプト**: プロジェクト = 任意のディレクトリ。そのディレクトリ直下に `project.yaml` / `nodes/` / `edges/` / `chats/` を配置する。`.tally/` というサブディレクトリ規約は廃止する。 - -**5 本の柱**: - -1. プロジェクト = ディレクトリ(`.tally/` 命名なし) -2. 場所は自由(デフォルト `~/.local/share/tally/projects//` を提案、ユーザーが最終決定) -3. レジストリで管理(`~/.local/share/tally/registry.yaml`) -4. 複数コードベース(`codebases[]`、code ノードは `codebaseId` 参照) -5. フォルダピッカー必須(バックエンド駆動ブラウザ) - -## データモデル - -### Project 型 - -```ts -interface Project { - id: string; // nanoid (proj_xxxxx) - name: string; - description?: string; - codebases: Codebase[]; // 0 件以上(初期検討用に空配列を許容) - createdAt: string; - updatedAt: string; -} - -interface Codebase { - id: string; // ユーザー指定 short id - label: string; // 表示名 - path: string; // 絶対パス -} -``` - -設計判断: -- `codebasePath: string` / `additionalCodebasePaths: string[]` は完全削除し、`codebases[]` に一元化 -- `codebases` は空配列を許容する。これにより「まだリポジトリを決めていないアイデア段階」のプロジェクトを自然に扱える -- code ノードが存在するときは最低 1 件の codebase が必要(整合性制約)。code ノードが無ければ codebases は 0 件でよい -- `primary` フラグは持たない。必要なら配列順で表現(先頭が主) -- `Codebase.id` は code ノードからの参照キー。人間可読必須 -- パスは絶対パス必須(マシン間持ち回りは別スコープ) - -codebases 0 件時の UI 挙動: -- code ノード追加系 UI(コード参照ボタン、AI の「関連コード探索」等)はすべて無効化し、ツールチップで「コードベースを追加してください」と表示 -- プロジェクト設定から後からいつでも codebase を追加できる - -### CodeNode 型 - -```ts -interface CodeNode { - id: string; - type: 'code'; - codebaseId: string; // 必須 - path: string; // codebase root からの相対パス - // ... 既存フィールド -} -``` - -- `codebaseId` 必須。古いスキーマのロードコードは存在させない -- `codebases[].id` に存在しない `codebaseId` を持つ code ノードは、ロード時に検出してエラー通知(自動削除はしない) - -### バリデーション規約 - -- `codebases` は空配列可(`code` ノードが存在する場合のみ最低 1 件必要) -- `codebases[].id` に重複 → プロジェクト更新拒否 -- `codebases[].id` は `/^[a-z][a-z0-9-]{0,31}$/` に制限(ファイルシステム安全な short ID) -- code ノード保存時、`codebaseId` がプロジェクト内に存在するか検証(失敗時はエラー、code ノードは作成しない) -- 既存 code ノードを持つプロジェクトから codebase を削除しようとした場合、その codebase を参照する code ノードがあるなら削除拒否(あるいは明示確認) - -### project.yaml 例 - -```yaml -id: proj_abc123 -name: TaskFlow 招待機能追加 -description: SaaS にチーム招待機能を追加するプロジェクト -codebases: - - id: frontend - label: TaskFlow Web - path: /Users/you/dev/github.com/acme/taskflow-web - - id: backend - label: TaskFlow API - path: /Users/you/dev/github.com/acme/taskflow-api -createdAt: 2026-04-21T10:00:00Z -updatedAt: 2026-04-21T10:00:00Z -``` - -### nodes/code-*.yaml 例 - -```yaml -id: code_invite_handler -type: code -codebaseId: backend -path: src/handlers/invite.ts -x: 420 -y: 180 -title: 招待ハンドラ -``` - -## レジストリ - -### ファイル配置 - -``` -$XDG_DATA_HOME/tally/ (省略時 ~/.local/share/tally/) -├── registry.yaml # 既知プロジェクト一覧 -└── projects/ # デフォルト作成先(固定ではない) - ├── taskflow-invite/ # ディレクトリ名はユーザーが決める(slug 提案) - │ ├── project.yaml # id は中身で管理(例: proj_abc123) - │ ├── nodes/ - │ ├── edges/ - │ └── chats/ - └── personal-thoughts/ ... -``` - -`projects/` 配下はデフォルトの置き場。ユーザーが別パスを選べばそちらに作られ、`projects/` には作られない。 - -### registry.yaml スキーマ - -```yaml -version: 1 -projects: - - id: proj_abc123 - path: /Users/you/.local/share/tally/projects/taskflow-invite - lastOpenedAt: 2026-04-21T10:00:00Z - - id: proj_xyz789 - path: /Users/you/dev/shared-specs/auth-migration - lastOpenedAt: 2026-04-20T15:00:00Z -``` - -ディレクトリ名とプロジェクト id は独立していることに注意(ユーザーは dir 名を自由に決められる)。 - -- `id` は project.yaml の id と必ず一致 -- `path` は絶対パス(プロジェクトディレクトリそのもの、`project.yaml` の親) -- `lastOpenedAt` は UI のソート用 -- `version` はスキーマ進化のため - -### 環境変数 - -- `TALLY_HOME`: レジストリとデフォルト projects/ の親ディレクトリ。省略時 `$XDG_DATA_HOME/tally` → `~/.local/share/tally` -- `TALLY_WORKSPACE` は廃止 -- ghq 連携は廃止 - -### API(`packages/storage/src/registry.ts`) - -```ts -export interface RegistryEntry { - id: string; - path: string; - lastOpenedAt: string; -} - -export interface Registry { - version: 1; - projects: RegistryEntry[]; -} - -export function resolveTallyHome(): string; -export function resolveRegistryPath(): string; -export function resolveDefaultProjectsRoot(): string; - -export async function loadRegistry(): Promise; -export async function saveRegistry(r: Registry): Promise; - -export async function listProjects(): Promise; // lastOpenedAt 降順 -export async function registerProject(entry: { id: string; path: string }): Promise; -export async function unregisterProject(id: string): Promise; -export async function touchProject(id: string): Promise; // lastOpenedAt 更新 -``` - -### 不整合処理 - -- path 先にディレクトリが無い: UI で「見つからない」状態表示 + 「再選択」or「レジストリから削除」を選ばせる。自動削除はしない -- path 先の project.yaml の id が registry と食い違う: エラー表示、ユーザーに修正させる -- id 重複がレジストリ内に存在: 後勝ち(warn ログ + 後発を採用)。作成時は衝突チェック後に新規 id を再生成 - -### 書き込みアトミシティ - -registry.yaml は temp file → rename で原子的に書く。プロジェクト内の YAML 群も同方式(既存 `yaml.ts` に atomicWriteFile ヘルパを揃える)。 - -## フォルダブラウザ - -### バックエンド API(Next.js Route Handlers) - -``` -GET /api/fs/ls?path= -``` - -レスポンス: - -```ts -interface FsListResponse { - path: string; // 正規化された絶対パス - parent: string | null; // 1 つ上。ルート時は null - entries: FsEntry[]; // ディレクトリのみ - containsProjectYaml: boolean; // この dir が project.yaml を含むか -} - -interface FsEntry { - name: string; - path: string; - isHidden: boolean; // 先頭 "." 判定 - hasProjectYaml: boolean; // この子が project.yaml を含むか(インポート用ヒント) -} -``` - -仕様: -- `path` 未指定時は `os.homedir()` にフォールバック -- ディレクトリのみ返す(ファイル非表示) -- 隠しディレクトリは `isHidden: true` で返し、フロントでトグル表示 -- `~` / 環境変数展開はサーバ側で行わない(クライアントが絶対パスを明示) -- エラーは HTTP 400/403/404 を使い分け(権限・不在・不正パス) - -バリデーション(path パラメータ): -- 絶対パスであること(`path.isAbsolute()` チェック)。相対パスは 400 -- `path.resolve()` 後に再度絶対パス性と正規化(`..` 解決後のパス)を検証 -- 正規化後パスと受信パスが乖離する(= `..` 経由で上位に抜けようとした)場合、受信パスをそのまま受理した結果ではなく正規化後のパスで処理する(path traversal 対策) -- 明示的にアクセス拒否するディレクトリは設けない(ローカル dev tool 前提のため)が、`/proc` `/sys` など読み取り困難な領域は 200 で空 entries に丸める - -セキュリティ: -- Tally は localhost 限定 dev tool 前提。API は任意 `path` を読むため `127.0.0.1` バインド・CORS 無効を維持 -- symlink は辿る(ユーザー期待値)。`/proc`, `/sys` などシステムパスは深入りしない(200 で空 entries) - -``` -POST /api/fs/mkdir -{ "path": string, "name": string } -``` - -- `path/name` で安全に mkdir -- 既存時は 409 -- `name` は path separator・`.`/`..` を拒否、空文字列も拒否 -- `path` は `GET /api/fs/ls` と同じバリデーション(絶対パス + `path.resolve` 後再検証) -- `path.join(path, name)` 後、正規化したパスが元の `path` の配下にあることを再確認(path traversal 二重防御) - -### `FolderBrowserDialog` コンポーネント - -Props: - -```ts -interface FolderBrowserDialogProps { - open: boolean; - initialPath?: string; // 省略時 ~ - purpose: 'create-project' | 'import-project' | 'add-codebase'; - onConfirm: (absolutePath: string) => void; - onClose: () => void; -} -``` - -- `purpose` は表示テキスト・確定ボタンラベル・確定条件を切り替える。**全ケースで返り値は「確定した絶対パス 1 本」** という一貫した契約を持つ - - create-project: 選択 dir を**プロジェクトルートそのもの**として確定。ダイアログ自身は dir の中身を制約せず返り値としてパスを返すだけ(空・非空の判定は呼び出し側 = NewProjectDialog 側で行う。後述のバリデーション参照)。呼び出し側はこのパスに直接 `project.yaml` / `nodes/` / `edges/` を書き込む - - import-project: 選択 dir に `project.yaml` 必須(無ければ disabled)。そのままプロジェクトルートとして登録 - - add-codebase: 選択 dir をそのまま codebase path に -- **ディレクトリ名とプロジェクト id は独立**。id は内部識別子、ディレクトリ名は人間が好きに決める(例:`acme-taskflow-invite/`)。レジストリが両者を紐付ける -- UI ロジックは共通 - -### UI レイアウト(概略) - -``` -┌─────────────────────────────────────────────┐ -│ 保存先フォルダを選択 │ -├─────────────────────────────────────────────┤ -│ [ /Users/you/dev/acme ] [ ↑ 親 ] │ -│ ───────────────────────────────────────────│ -│ 📁 taskflow-web │ -│ 📁 taskflow-api (project.yaml あり)│ -│ 📁 docs │ -│ ... │ -│ [☐] 隠しフォルダを表示 │ -├─────────────────────────────────────────────┤ -│ [+ 新規フォルダ] [キャンセル] [選択] │ -└─────────────────────────────────────────────┘ -``` - -- パンくずはクリッカブル(各階層へジャンプ) -- テキスト入力でパス直打ち + Enter で移動(パワーユーザー向け) -- エントリクリックで潜る -- 子が project.yaml を持つ場合はバッジ表示 -- 「新規フォルダ」は name 入力 → POST /api/fs/mkdir → 作成した dir に自動で移動 -- キーボード: ↑↓ で選択、Enter で潜る、Cmd+Enter で確定 - -### NewProjectDialog 刷新 - -フィールド: -- プロジェクト名(必須) -- 説明(任意) -- プロジェクトルート(既定 `/projects/<プロジェクト名をslug化した候補>/`、「フォルダを変更」で FolderBrowserDialog) -- codebases[](任意、0 件可。「+ 追加」で FolderBrowserDialog → short id / label 入力) - -プロジェクトルートの決定ルール: -- 作成時点で id を先行生成するのではなく、**ユーザーが確定したディレクトリそのもの** をルートとする。id はレジストリ登録時に nanoid で生成し、project.yaml に記録するだけ -- デフォルト候補パスは名前入力に応じてリアルタイムで更新される(例:名前「TaskFlow 招待機能」→ `/projects/taskflow-invite/`)。slug 衝突時はサフィックスを付与 -- デフォルト候補 dir が既に存在する場合、ユーザーに警告を表示し、「別名にする」or「フォルダを変更」を促す -- 「フォルダを変更」でユーザーが指定したパスは、**指定 dir そのものがプロジェクトルートになる**(その中にサブディレクトリを勝手に作らない) - -バリデーション(NewProjectDialog 側で実施、FolderBrowserDialog 確定後に呼び出し側がチェック): -- 名前必須 -- `codebases[].id` 重複不可 -- プロジェクトルートのパスは以下の 4 ケースに分類して判定する: - -| 選択された dir の状態 | 扱い | UI | -|---|---|---| -| 存在しないパス(親ディレクトリは存在する) | **許可** | 作成時に mkdir | -| 既存の空 dir | **許可** | そのまま使う | -| 既存 dir + `project.yaml` を含む | **拒否** | 「既存プロジェクト。インポートする?」と ProjectImportDialog への誘導 | -| 既存 dir + 非空かつ `project.yaml` なし | **拒否** | 「ディレクトリは空ではありません」と警告し disabled | -| 親ディレクトリも存在しない | **拒否** | 「親ディレクトリが存在しません」と disabled | - -### ProjectImportDialog(新規) - -- FolderBrowserDialog(purpose: 'import-project') -- `project.yaml` を含む dir 選択 → registry に登録 -- 重複 id は検出してエラー(別のプロジェクトと id 衝突した旨を表示) - -### トップページ刷新 - -- `fetchWorkspaceCandidates()` 削除 -- `fetchRegistryProjects()` に置換、lastOpenedAt 降順で一覧 -- 各行に「開く」「レジストリから外す」「dir をエクスプローラで開く」 -- 上部に「+ 新規プロジェクト」「既存を読み込む」の 2 アクション - -## 変更対象・削除対象 - -### 削除 - -| パス | 理由 | -|---|---| -| `packages/storage/src/project-resolver.ts` | ghq/workspace scan 廃止、registry に置換 | -| `packages/storage/src/project-resolver.test.ts` | 上記に伴い | -| `packages/storage/src/index.ts` の `discoverProjects` / `listWorkspaceCandidates` / `resolveProjectById` / `loadProjectById` / `ProjectHandle` / `WorkspaceCandidate` export | 旧発見モデル廃止 | -| `packages/storage/src/index.ts` の `resolveTallyPaths` / `TallyPaths` export | `.tally/` 規約廃止(`project-dir.ts` に置換) | -| `packages/frontend/src/app/api/workspace-candidates/route.ts` | candidates API 廃止 | -| `packages/frontend/src/lib/api.ts` の `fetchWorkspaceCandidates`, `WorkspaceCandidate` | candidates モデル廃止 | -| `NewProjectDialog` の candidates UI 一式 | 刷新 | -| 環境変数 `TALLY_WORKSPACE` 参照箇所すべて | 廃止 | -| `.tally/` 規約(コード・ドキュメント・examples・テストフィクスチャ) | プロジェクト dir 名の規約を外す | -| `Project` / `ProjectMeta` の `codebasePath` / `additionalCodebasePaths` フィールド(`packages/core/src/schema.ts`)とその依存テスト | `codebases[]` に統合 | - -### 新規 - -| パス | 役割 | -|---|---| -| `packages/storage/src/registry.ts` | Registry CRUD | -| `packages/storage/src/registry.test.ts` | 単体テスト | -| `packages/storage/src/project-dir.ts` | projectDir からの paths 解決 | -| `packages/frontend/src/app/api/fs/ls/route.ts` | ディレクトリ一覧 API | -| `packages/frontend/src/app/api/fs/mkdir/route.ts` | 新規フォルダ作成 API | -| `packages/frontend/src/components/dialog/folder-browser-dialog.tsx` | フォルダブラウザモーダル | -| `packages/frontend/src/components/dialog/project-import-dialog.tsx` | インポート用 | -| `docs/adr/0008-project-independent-from-repo.md` | ADR-0003 を Supersede | -| `docs/adr/0009-project-registry.md` | レジストリ機構 | -| `docs/adr/0010-multiple-codebases.md` | codebases[] モデル | - -### 変更 - -| パス | 変更 | -|---|---| -| `packages/core/src/schema.ts` | Project / ProjectMeta / Codebase / CodeNode 型刷新(`codebasePath`・`additionalCodebasePaths` 削除、`codebases: Codebase[]` 追加、code ノードに `codebaseId` 追加) | -| `packages/core/src/schema.test.ts` | 上記に追従 | -| `packages/storage/src/paths.ts` | registry 対応、`.tally/` サブディレクトリ廃止 | -| `packages/storage/src/init-project.ts` | registry 登録追加、codebases 0 件可 | -| `packages/storage/src/project-store.ts` | workspaceRoot → projectDir rename、codebases 対応 | -| `packages/frontend/src/components/dialog/new-project-dialog.tsx` | 全面刷新 | -| `packages/frontend/src/components/dialog/project-settings-dialog.tsx` + `.test.tsx`(新規作成) | 削除した旧版に代わり、`codebases[]` の追加・削除・並び替え・ラベル編集を提供。0 件運用(初期アイデア)から後付けで codebase を足せる受け皿。FolderBrowserDialog(purpose: 'add-codebase')を利用 | -| `packages/frontend/src/lib/api.ts` | registry 系クライアント追加、candidates 系削除、project meta CRUD を codebases[] 対応 | -| `packages/frontend/src/lib/store.ts` | `patchProjectMeta` 等を codebases[] 対応に刷新 | -| `packages/frontend/src/app/api/projects/[id]/route.ts` | codebases[] の読み書き、旧フィールド削除 | -| `packages/frontend/src/components/ai-actions/*` (codebase-agent-button, find-related-code-button, analyze-impact, extract-questions, graph-agent-button 等) | code 参照系 UI を codebases[] 前提に調整、codebases 0 件時は disabled | -| AI Engine (`agent-runner.ts`, `agents/codebase-anchor.ts`, `agents/find-related-code.ts`, `agents/analyze-impact.ts` ほか) | 単一 `codebasePath` 前提を捨て、対象 codebase を引数に取るよう改修(0 件時は呼び出し側が制御) | -| トップページ(projects 一覧) | registry 駆動 | -| `examples/sample-project/` | `.tally/` 廃止に伴い構造変更、旧フィクスチャ(taskflow-backend 参照など)を新 codebases[] モデルに書き換え | -| CLAUDE.md | `.tally/` 言及の除去、registry ベースに更新 | -| README.md | 起動方法・利用フロー更新 | -| `docs/adr/0003-git-managed-yaml.md` | ステータスを Superseded に更新、新 ADR へリンク | - -**波及範囲の注記**: 旧 `codebasePath` / `additionalCodebasePaths` を参照するテスト・ロジックは core / storage / frontend / ai-engine 横断で 27 ファイル以上に渡る。実装時は `packages/core` の型変更を起点にコンパイルエラーを潰す方式が安全。 - -## テスト戦略 - -### packages/storage - -- `registry.test.ts`: load/save ラウンドトリップ、重複 id、touch 順序、壊れた YAML のリカバリ -- `project-dir.test.ts`: projectDir から paths 生成の境界 -- `init-project.test.ts`: registry 登録と project dir 作成の原子性、codebases 0 件でも成功すること、project dir が非空かつ project.yaml 無しで拒否、id 衝突時の再生成 -- tmp dir fixture で XDG_DATA_HOME を差し替え、マシン環境に依存しない - -### packages/frontend - -- `folder-browser-dialog.test.tsx`: 潜る / 戻る / 新規作成 / 確定のユーザーフロー(Testing Library) -- `new-project-dialog.test.tsx`: 名前必須・プロジェクトルート空 dir 判定・codebases 0 件でも作成可のバリデーション -- `project-import-dialog.test.tsx`: project.yaml 有無で確定ボタン活性制御 -- API ルート(fs/ls, fs/mkdir)の単体テスト: symlink, 権限エラー, 存在しないパス, path traversal 防止 - -### E2E(Playwright が動く段階で) - -- 新規作成 → FolderBrowser → 作成 → トップに表示 → 開く → リロード後も残る -- 既存プロジェクトのインポート -- レジストリ path 消失後の UI 表示 - -### 廃棄 - -- 既存 `project-resolver.test.ts` -- ghq / TALLY_WORKSPACE を前提とした既存テスト - -## ADR 構成 - -1. **ADR-0008: プロジェクトをリポジトリから切り離す** - - コンテキスト: マルチレポ横断プロジェクト、repo に縛られない思考単位 - - 決定: プロジェクト = 任意のディレクトリ、`.tally/` 規約廃止 - - 影響: ADR-0003 Superseded - -2. **ADR-0009: プロジェクトレジストリによる発見** - - コンテキスト: 暗黙スキャンは予測不能 - - 決定: `~/.local/share/tally/registry.yaml` による明示レジストリのみ - - 影響: TALLY_WORKSPACE 廃止、ghq 連携削除 - -3. **ADR-0010: 複数 codebases の参照モデル** - - コンテキスト: 1 プロジェクト = 1 repo の破綻 - - 決定: `codebases[]`、code ノードは `codebaseId` 参照 - - 影響: AI エージェントの探索対象を「codebase を選択 / すべて」に拡張する後続作業 - -## スコープ境界 - -### AI Engine の in-scope / out-of-scope - -データモデル刷新に伴い AI Engine の関数群もシグネチャ変更が避けられないため、最低限の修正を in-scope に含める。新機能は別 spec に回す。 - -**in-scope(このspec で扱う、regression 防止の最低限修正)**: -- 単一 `codebasePath` への参照を `codebases[]` に置き換えるシグネチャ改修 -- 呼び出し側が対象 codebase(1 件)を引数で指定する形に変更 -- 既存の単一 codebase 機能が壊れない範囲でコンパイルを通す -- codebases 0 件時は呼び出し側(UI)で操作を無効化するため、engine 側は `codebases.length === 0` のケースを受け取らない前提でよい - -**out-of-scope(別 spec で扱う)**: -- 複数 codebases を跨いだ探索(cwd 切替、結果マージ、エージェント並行実行) -- ユーザーが「どの codebase を探索対象にするか」を選ぶ UI と API -- codebases 間のクロスリファレンス(backend の関数から frontend の呼び出し箇所を辿る等) - -### そのほかの非スコープ - -- Git 公開ワークフロー(registry 外部のチーム共有、プロジェクト dir の repo 化ヘルパ) -- マシン間移動(絶対パス持ち回り問題。将来 `Codebase.path` に変数展開や overlay を検討) -- レジストリの並行編集(現時点ではロック不要、将来必要なら fcntl lock) - -## 実装順序の目安 - -1. `packages/core` 型刷新 -2. `packages/storage/src/registry.ts` + テスト -3. `packages/storage/src/init-project.ts` / `project-store.ts` 刷新 -4. バックエンド API(fs/ls, fs/mkdir) -5. `FolderBrowserDialog` + 単体テスト -6. `NewProjectDialog` 刷新 -7. `ProjectImportDialog` -8. トップページ刷新 -9. examples / docs / CLAUDE.md / README.md 更新 -10. ADR 3 本 commit - -詳細な実装計画は別途 `writing-plans` スキルで作成する。 diff --git a/packages/ai-engine/src/duplicate-guards/source-url.test.ts b/packages/ai-engine/src/duplicate-guards/source-url.test.ts index 08022f6..d1bd977 100644 --- a/packages/ai-engine/src/duplicate-guards/source-url.test.ts +++ b/packages/ai-engine/src/duplicate-guards/source-url.test.ts @@ -126,4 +126,45 @@ describe('sourceUrlGuard', () => { sourceUrlGuard.onCreated?.({ title: 'R', body: '', additional: { sourceUrl: '' } }, ctx); expect(memo.size).toBe(0); }); + + // CodeRabbit 指摘 (PR #18): sourceUrl を生値のまま比較していたため、 + // 前後空白付き入力 (" https://... ") が同一 URL の重複検知をすり抜けていた。 + // trim 正規化を入れて、入力側も既存ノード側も揃えてから比較する。 + it('sourceUrl の前後空白は正規化して既存ノードと比較する (重複検知)', async () => { + const ctx = makeCtx([{ id: 'r1', type: 'requirement', sourceUrl: 'https://jira.test/EPIC-1' }]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: ' https://jira.test/EPIC-1 ' } }, + ctx, + ); + expect(res).not.toBeNull(); + }); + + it('既存ノード側の sourceUrl に前後空白があっても正規化して比較する', async () => { + const ctx = makeCtx([ + { id: 'r1', type: 'requirement', sourceUrl: ' https://jira.test/EPIC-1 ' }, + ]); + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: 'https://jira.test/EPIC-1' } }, + ctx, + ); + expect(res).not.toBeNull(); + }); + + it('sourceUrl が空白のみなら skip (null)', async () => { + const res = await sourceUrlGuard.check( + { title: 'R', body: '', additional: { sourceUrl: ' ' } }, + makeCtx([]), + ); + expect(res).toBeNull(); + }); + + it('onCreated でも trim した値が memo に入る', () => { + const memo = new Set(); + const ctx = makeCtx([], { sessionMemo: memo }); + sourceUrlGuard.onCreated?.( + { title: 'R', body: '', additional: { sourceUrl: ' https://jira.test/EPIC-X ' } }, + ctx, + ); + expect(memo.has('sourceUrl:https://jira.test/EPIC-X')).toBe(true); + }); }); diff --git a/packages/ai-engine/src/duplicate-guards/source-url.ts b/packages/ai-engine/src/duplicate-guards/source-url.ts index 5916313..39e5b84 100644 --- a/packages/ai-engine/src/duplicate-guards/source-url.ts +++ b/packages/ai-engine/src/duplicate-guards/source-url.ts @@ -14,11 +14,21 @@ import type { DuplicateGuard } from './index'; // memo キー: `sourceUrl:${url}` (anchor 非依存、グラフ横断で一意) const SESSION_KEY_PREFIX = 'sourceUrl:'; +// 入力 / 永続化済み URL を比較可能な形に揃える。 +// 前後空白がある入力 (" https://jira.../X ") を許容してしまうと、 +// memo キーと比較値がずれて同一 URL が二重登録される。 +// CodeRabbit 指摘 PR #18: trim 必須。空文字 / 非 string は null にする。 +function normalizeSourceUrl(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + export const sourceUrlGuard: DuplicateGuard = { adoptAs: 'requirement', async check(input, ctx) { - const sourceUrl = input.additional?.sourceUrl; - if (typeof sourceUrl !== 'string' || sourceUrl.length === 0) return null; + const sourceUrl = normalizeSourceUrl(input.additional?.sourceUrl); + if (!sourceUrl) return null; const sessionKey = `${SESSION_KEY_PREFIX}${sourceUrl}`; if (ctx.sessionMemo.has(sessionKey)) { @@ -35,7 +45,7 @@ export const sourceUrlGuard: DuplicateGuard = { const isRequirement = type === 'requirement' || (type === 'proposal' && adoptAs === 'requirement'); if (!isRequirement) continue; - const existingUrl = rec.sourceUrl as string | undefined; + const existingUrl = normalizeSourceUrl(rec.sourceUrl); if (existingUrl === sourceUrl) { const id = rec.id as string; return { @@ -46,8 +56,8 @@ export const sourceUrlGuard: DuplicateGuard = { return null; }, onCreated(input, ctx) { - const sourceUrl = input.additional?.sourceUrl; - if (typeof sourceUrl === 'string' && sourceUrl.length > 0) { + const sourceUrl = normalizeSourceUrl(input.additional?.sourceUrl); + if (sourceUrl) { ctx.sessionMemo.add(`${SESSION_KEY_PREFIX}${sourceUrl}`); } }, diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index 4b30037..514617e 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -299,6 +299,41 @@ describe('ChatBlockSchema', () => { }); expect(r.success).toBe(false); }); + // CodeRabbit 指摘 (PR #18): auth_request の status と failureMessage の整合を schema で固定。 + // failed なのに failureMessage 無し → reject。pending/completed に failureMessage が + // 付いている → reject。 + it('auth_request: failed に failureMessage 無しは reject', () => { + const r = ChatBlockSchema.safeParse({ + type: 'auth_request', + mcpServerId: 'atlassian', + mcpServerLabel: 'Atlassian', + authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', + status: 'failed', + }); + expect(r.success).toBe(false); + }); + it('auth_request: pending に failureMessage 付きは reject', () => { + const r = ChatBlockSchema.safeParse({ + type: 'auth_request', + mcpServerId: 'atlassian', + mcpServerLabel: 'Atlassian', + authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', + status: 'pending', + failureMessage: 'should not be here', + }); + expect(r.success).toBe(false); + }); + it('auth_request: completed に failureMessage 付きは reject', () => { + const r = ChatBlockSchema.safeParse({ + type: 'auth_request', + mcpServerId: 'atlassian', + mcpServerLabel: 'Atlassian', + authUrl: 'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc', + status: 'completed', + failureMessage: 'should not be here', + }); + expect(r.success).toBe(false); + }); it('不正な type は reject', () => { expect(ChatBlockSchema.safeParse({ type: 'other', text: 'x' }).success).toBe(false); }); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 73f04c8..ce52111 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -332,15 +332,34 @@ export const ChatBlockSchema = z.discriminatedUnion('type', [ // (URL がプレーンテキスト + redirect 先 localhost:XXXXX が即死) ため、検出して // この auth_request に置き換える。status は同 thread 内の complete_authentication で更新。 // mcpServerLabel は project.mcpServers[].label 由来 (label 未設定なら id を表示)。 - z.object({ - type: z.literal('auth_request'), - mcpServerId: z.string().min(1), - mcpServerLabel: z.string().min(1), - authUrl: z.string().url(), - status: z.enum(['pending', 'completed', 'failed']), - // 失敗時にエラーメッセージを残す。pending/completed では undefined。 - failureMessage: z.string().optional(), - }), + // failureMessage は status='failed' のときだけ持つ。superRefine で永続化フォーマット + // としての不正状態 (failed なのに message 無し / pending・completed に message が付く) + // を弾く (CodeRabbit 指摘 PR #18)。 + z + .object({ + type: z.literal('auth_request'), + mcpServerId: z.string().min(1), + mcpServerLabel: z.string().min(1), + authUrl: z.string().url(), + status: z.enum(['pending', 'completed', 'failed']), + failureMessage: z.string().optional(), + }) + .superRefine((b, ctx) => { + if (b.status === 'failed' && !b.failureMessage) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'failed auth_request には failureMessage が必要', + path: ['failureMessage'], + }); + } + if (b.status !== 'failed' && b.failureMessage !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'failureMessage は failed のときだけ設定できます', + path: ['failureMessage'], + }); + } + }), ]); export const ChatMessageSchema = z.object({ diff --git a/packages/frontend/src/app/api/projects/[id]/route.test.ts b/packages/frontend/src/app/api/projects/[id]/route.test.ts index 6165c45..170d61c 100644 --- a/packages/frontend/src/app/api/projects/[id]/route.test.ts +++ b/packages/frontend/src/app/api/projects/[id]/route.test.ts @@ -116,8 +116,9 @@ describe('PATCH /api/projects/:id', () => { }); it('mcpServers を空配列で全消去できる', async () => { - // 事前に登録 - await PATCH( + // 事前に登録 (失敗していると後続の「空配列で削除」が空 → 空 で偽の成功になる + // ので、ここで 200 を assert しておく)。CodeRabbit 指摘 PR #18。 + const setupRes = await PATCH( new Request('http://localhost', { method: 'PATCH', body: JSON.stringify({ @@ -134,6 +135,7 @@ describe('PATCH /api/projects/:id', () => { }), { params: Promise.resolve({ id: projectId }) }, ); + expect(setupRes.status).toBe(200); // 空配列で全消去 const res = await PATCH( new Request('http://localhost', { From 89e81cac2daf679ddd4f0a62eb270cb7036c9392 Mon Sep 17 00:00:00 2001 From: Shoma Nishitateno Date: Tue, 28 Apr 2026 17:50:48 +0900 Subject: [PATCH 34/34] =?UTF-8?q?chore:=20docs/superpowers/=20=E3=82=92=20?= =?UTF-8?q?gitignore=20+=20untrack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中間 planning doc と実装の不整合が CodeRabbit に毎回指摘される (Premise 9 撤回後の OAuth 2.1 採用 / 長寿命 ChatRunner 等が plan doc に未反映)。 これらは実装の正史ではなく作業メモなので tracking しない方針に。 - .gitignore に docs/superpowers/ 追加 - 既存の plans/ specs/ をリポジトリから untrack (ローカルファイルは残す) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index bb07a62..c9db087 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,9 @@ workspace/ .claude/tmp/ .gstack/ +# superpowers の中間 planning doc。実装と不整合化しやすく review noise になるため untrack。 +docs/superpowers/ + # Playwright packages/*/.playwright-tally-home/ packages/*/playwright-report/