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", () => {