diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index 65f6ad87e..4326b42e0 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -650,6 +650,65 @@ describe("boulder-state", () => { expect(progress.completed).toBe(1) expect(progress.isComplete).toBe(false) }) + + test("should count only top-level checkboxes for simple plans with nested tasks", () => { + // given + const planPath = join(TEST_DIR, "simple-nested-plan.md") + writeFileSync(planPath, `# Plan + +- [ ] Top-level task 1 + - [x] Nested task ignored +- [x] Top-level task 2 + * [ ] Another nested task ignored +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(2) + expect(progress.completed).toBe(1) + expect(progress.isComplete).toBe(false) + }) + + test("should treat final-wave-only plans as structured mode", () => { + // given + const planPath = join(TEST_DIR, "final-wave-only-plan.md") + writeFileSync(planPath, `# Plan + +## Final Verification Wave +- [ ] F1. Top-level final review + - [x] Nested verification detail ignored +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(1) + expect(progress.completed).toBe(0) + expect(progress.isComplete).toBe(false) + }) + + test("should ignore mixed indentation levels in simple plans", () => { + // given + const planPath = join(TEST_DIR, "simple-mixed-indentation-plan.md") + writeFileSync(planPath, `# Plan + +* [x] Top-level star task + - [ ] Indented task ignored + - [x] Tab-indented task ignored +- [ ] Top-level dash task +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(2) + expect(progress.completed).toBe(1) + expect(progress.isComplete).toBe(false) + }) }) describe("getPlanName", () => { diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts index 1d5dc2a59..d570ce525 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -226,7 +226,9 @@ export function getPlanProgress(planPath: string): PlanProgress { const lines = content.split(/\r?\n/) // Check if the plan has structured sections (## TODOs / ## Final Verification Wave) - const hasStructuredSections = lines.some((line) => TODO_HEADING_PATTERN.test(line)) + const hasStructuredSections = lines.some( + (line) => TODO_HEADING_PATTERN.test(line) || FINAL_VERIFICATION_HEADING_PATTERN.test(line), + ) if (hasStructuredSections) { // Structured plan: only count top-level checkboxes with numbered labels @@ -291,8 +293,8 @@ function getStructuredPlanProgress(lines: string[]): PlanProgress { } function getSimplePlanProgress(content: string): PlanProgress { - const uncheckedMatches = content.match(/^\s*[-*]\s*\[\s*\]/gm) || [] - const checkedMatches = content.match(/^\s*[-*]\s*\[[xX]\]/gm) || [] + const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || [] + const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || [] const total = uncheckedMatches.length + checkedMatches.length const completed = checkedMatches.length diff --git a/src/features/builtin-commands/templates/ralph-loop.test.ts b/src/features/builtin-commands/templates/ralph-loop.test.ts new file mode 100644 index 000000000..ae8440ae1 --- /dev/null +++ b/src/features/builtin-commands/templates/ralph-loop.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test" +import { ULW_LOOP_TEMPLATE } from "./ralph-loop" + +describe("ULW_LOOP_TEMPLATE", () => { + test("returns the documented iteration caps for ultrawork and normal modes", () => { + // given + const expectedIterationCaps = "The iteration limit is 500 for ultrawork mode, 100 for normal mode" + + // when + const template = ULW_LOOP_TEMPLATE + + // then + expect(template).toContain(expectedIterationCaps) + }) +}) diff --git a/src/features/builtin-commands/templates/ralph-loop.ts b/src/features/builtin-commands/templates/ralph-loop.ts index 5da026a70..1fb8bae50 100644 --- a/src/features/builtin-commands/templates/ralph-loop.ts +++ b/src/features/builtin-commands/templates/ralph-loop.ts @@ -36,7 +36,7 @@ export const ULW_LOOP_TEMPLATE = `You are starting an ULTRAWORK Loop - a self-re 2. When you believe the work is complete, output: \`{{COMPLETION_PROMISE}}\` 3. That does NOT finish the loop yet. The system will require Oracle verification 4. The loop only ends after the system confirms Oracle verified the result -5. There is no iteration limit +5. The iteration limit is 500 for ultrawork mode, 100 for normal mode ## Rules diff --git a/src/features/skill-mcp-manager/connection-env-vars.test.ts b/src/features/skill-mcp-manager/connection-env-vars.test.ts index a535bcb47..60cf20ce6 100644 --- a/src/features/skill-mcp-manager/connection-env-vars.test.ts +++ b/src/features/skill-mcp-manager/connection-env-vars.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, it, mock, test } from "bun:test" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import type { SkillMcpClientInfo, SkillMcpManagerState } from "./types" @@ -89,12 +89,15 @@ function createState(): SkillMcpManagerState { return state } -function createClientInfo(serverName: string): SkillMcpClientInfo { +function createClientInfo( + serverName: string, + scope?: SkillMcpClientInfo["scope"], +): SkillMcpClientInfo { return { serverName, skillName: "env-skill", sessionID: "session-env", - scope: "builtin", + ...(scope !== undefined ? { scope } : {}), } } @@ -126,6 +129,68 @@ afterEach(async () => { }) describe("getOrCreateClient env var expansion", () => { + describe("#given a scope-sensitive stdio skill MCP config", () => { + test.each([ + ["opencode-project", "Authorization:Bearer "], + ["local", "Authorization:Bearer "], + ["user", "Authorization:Bearer xoxp-scope-token"], + ["builtin", "Authorization:Bearer xoxp-scope-token"], + ] satisfies Array<[NonNullable, string]>) ( + "#when creating the client for %s scope #then args expand to %s", + async (scope, expectedAuthorizationHeader) => { + // given + process.env.SLACK_USER_TOKEN = "xoxp-scope-token" + const state = createState() + const info = createClientInfo(`scope-${scope}`, scope) + const clientKey = createClientKey(info) + const config: ClaudeCodeMcpServer = { + command: "npx", + args: [ + "-y", + "mcp-remote", + "https://mcp.slack.com/mcp", + "--header", + "Authorization:Bearer ${SLACK_USER_TOKEN}", + ], + } + + // when + await getOrCreateClient({ state, clientKey, info, config }) + + // then + expect(createdStdioTransports).toHaveLength(1) + expect(createdStdioTransports[0]?.options.args?.[4]).toBe(expectedAuthorizationHeader) + }, + ) + + it("#when creating the client without scope #then env vars remain trusted for backward compatibility", async () => { + // given + process.env.SLACK_USER_TOKEN = "xoxp-undefined-scope-token" + const state = createState() + const info = createClientInfo("scope-undefined") + const clientKey = createClientKey(info) + const config: ClaudeCodeMcpServer = { + command: "npx", + args: [ + "-y", + "mcp-remote", + "https://mcp.slack.com/mcp", + "--header", + "Authorization:Bearer ${SLACK_USER_TOKEN}", + ], + } + + // when + await getOrCreateClient({ state, clientKey, info, config }) + + // then + expect(createdStdioTransports).toHaveLength(1) + expect(createdStdioTransports[0]?.options.args?.[4]).toBe( + "Authorization:Bearer xoxp-undefined-scope-token", + ) + }) + }) + describe("#given a stdio skill MCP config with sensitive env vars in args", () => { it("#when creating the client #then sensitive env vars in args are expanded", async () => { // given diff --git a/src/features/skill-mcp-manager/connection.ts b/src/features/skill-mcp-manager/connection.ts index 2826492b0..2fa4dc3a3 100644 --- a/src/features/skill-mcp-manager/connection.ts +++ b/src/features/skill-mcp-manager/connection.ts @@ -14,6 +14,8 @@ function removeClientIfCurrent(state: SkillMcpManagerState, clientKey: string, c } } +const PROJECT_SCOPES = new Set(["project", "opencode-project", "local"]) + export async function getOrCreateClient(params: { state: SkillMcpManagerState clientKey: string @@ -38,7 +40,7 @@ export async function getOrCreateClient(params: { return pending } - const isTrusted = info.scope !== "project" + const isTrusted = !PROJECT_SCOPES.has(info.scope ?? "") const expandedConfig = expandEnvVarsInObject(config, { trusted: isTrusted }) let currentConnectionPromise!: Promise state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1) diff --git a/src/features/skill-mcp-manager/types.ts b/src/features/skill-mcp-manager/types.ts index 3d2838d55..75ef396cf 100644 --- a/src/features/skill-mcp-manager/types.ts +++ b/src/features/skill-mcp-manager/types.ts @@ -11,7 +11,7 @@ export interface SkillMcpClientInfo { serverName: string skillName: string sessionID: string - scope?: SkillScope + scope?: SkillScope | "local" } export interface SkillMcpServerContext { diff --git a/src/hooks/start-work/context-info-builder.ts b/src/hooks/start-work/context-info-builder.ts index 2fe074429..ecbe1d37a 100644 --- a/src/hooks/start-work/context-info-builder.ts +++ b/src/hooks/start-work/context-info-builder.ts @@ -23,7 +23,7 @@ function normalizePlanLookupValue(value: string): string { .replace(/^["'`]+|["'`]+$/g, "") .toLowerCase() .replace(/[\s_]+/g, "-") - .replace(/[^a-z0-9-]+/g, "-") + .replace(/[^\p{L}\p{N}-]+/gu, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") } diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index c2a4fb09a..63f37f06d 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -443,6 +443,122 @@ You are starting a Sisyphus work session. expect(output.parts[0].text).toContain("my-feature-plan") expect(output.parts[0].text).toContain("Auto-Selected Plan") }) + + test("should match Korean plan names after Unicode-aware normalization", async () => { + // given + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "결제-플로우.md") + writeFileSync(planPath, "# 결제 플로우\n- [ ] 작업 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: createStartWorkPrompt({ userRequest: "결제 플로우" }), + }, + ], + } + + // when + await hook["chat.message"]( + { sessionID: "session-korean-plan" }, + output, + ) + + // then + expect(output.parts[0].text).toContain("결제-플로우") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) + + test("should match Japanese plan names after Unicode-aware normalization", async () => { + // given + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "支払い-フロー.md") + writeFileSync(planPath, "# 支払い フロー\n- [ ] タスク 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: createStartWorkPrompt({ userRequest: "支払い フロー" }), + }, + ], + } + + // when + await hook["chat.message"]( + { sessionID: "session-japanese-plan" }, + output, + ) + + // then + expect(output.parts[0].text).toContain("支払い-フロー") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) + + test("should keep ASCII plan name matching behavior unchanged", async () => { + // given + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "checkout-flow.md") + writeFileSync(planPath, "# Checkout Flow\n- [ ] Task 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: createStartWorkPrompt({ userRequest: "checkout flow" }), + }, + ], + } + + // when + await hook["chat.message"]( + { sessionID: "session-ascii-plan" }, + output, + ) + + // then + expect(output.parts[0].text).toContain("checkout-flow") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) + + test("should match mixed ASCII and non-ASCII plan names", async () => { + // given + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "v2-결제-flow.md") + writeFileSync(planPath, "# v2 결제 flow\n- [ ] Task 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: createStartWorkPrompt({ userRequest: "v2 결제 flow" }), + }, + ], + } + + // when + await hook["chat.message"]( + { sessionID: "session-mixed-plan" }, + output, + ) + + // then + expect(output.parts[0].text).toContain("v2-결제-flow") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) }) describe("session agent management", () => {