Merge pull request #3226 from code-yeongyu/fix/plan-progress-and-trust

fix(plan): skill MCP trust + non-ASCII names + simple-mode progress + ralph-loop cap
This commit is contained in:
YeonGyu-Kim
2026-04-08 17:40:51 +09:00
committed by GitHub
9 changed files with 269 additions and 10 deletions
@@ -650,6 +650,65 @@ describe("boulder-state", () => {
expect(progress.completed).toBe(1) expect(progress.completed).toBe(1)
expect(progress.isComplete).toBe(false) 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", () => { describe("getPlanName", () => {
+5 -3
View File
@@ -226,7 +226,9 @@ export function getPlanProgress(planPath: string): PlanProgress {
const lines = content.split(/\r?\n/) const lines = content.split(/\r?\n/)
// Check if the plan has structured sections (## TODOs / ## Final Verification Wave) // 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) { if (hasStructuredSections) {
// Structured plan: only count top-level checkboxes with numbered labels // Structured plan: only count top-level checkboxes with numbered labels
@@ -291,8 +293,8 @@ function getStructuredPlanProgress(lines: string[]): PlanProgress {
} }
function getSimplePlanProgress(content: string): PlanProgress { function getSimplePlanProgress(content: string): PlanProgress {
const uncheckedMatches = content.match(/^\s*[-*]\s*\[\s*\]/gm) || [] const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || []
const checkedMatches = content.match(/^\s*[-*]\s*\[[xX]\]/gm) || [] const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || []
const total = uncheckedMatches.length + checkedMatches.length const total = uncheckedMatches.length + checkedMatches.length
const completed = checkedMatches.length const completed = checkedMatches.length
@@ -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)
})
})
@@ -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: \`<promise>{{COMPLETION_PROMISE}}</promise>\` 2. When you believe the work is complete, output: \`<promise>{{COMPLETION_PROMISE}}</promise>\`
3. That does NOT finish the loop yet. The system will require Oracle verification 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 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 ## Rules
@@ -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 { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import type { SkillMcpClientInfo, SkillMcpManagerState } from "./types" import type { SkillMcpClientInfo, SkillMcpManagerState } from "./types"
@@ -89,12 +89,15 @@ function createState(): SkillMcpManagerState {
return state return state
} }
function createClientInfo(serverName: string): SkillMcpClientInfo { function createClientInfo(
serverName: string,
scope?: SkillMcpClientInfo["scope"],
): SkillMcpClientInfo {
return { return {
serverName, serverName,
skillName: "env-skill", skillName: "env-skill",
sessionID: "session-env", sessionID: "session-env",
scope: "builtin", ...(scope !== undefined ? { scope } : {}),
} }
} }
@@ -126,6 +129,68 @@ afterEach(async () => {
}) })
describe("getOrCreateClient env var expansion", () => { 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<SkillMcpClientInfo["scope"]>, 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", () => { 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 () => { it("#when creating the client #then sensitive env vars in args are expanded", async () => {
// given // given
+3 -1
View File
@@ -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: { export async function getOrCreateClient(params: {
state: SkillMcpManagerState state: SkillMcpManagerState
clientKey: string clientKey: string
@@ -38,7 +40,7 @@ export async function getOrCreateClient(params: {
return pending return pending
} }
const isTrusted = info.scope !== "project" const isTrusted = !PROJECT_SCOPES.has(info.scope ?? "")
const expandedConfig = expandEnvVarsInObject(config, { trusted: isTrusted }) const expandedConfig = expandEnvVarsInObject(config, { trusted: isTrusted })
let currentConnectionPromise!: Promise<Client> let currentConnectionPromise!: Promise<Client>
state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1) state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1)
+1 -1
View File
@@ -11,7 +11,7 @@ export interface SkillMcpClientInfo {
serverName: string serverName: string
skillName: string skillName: string
sessionID: string sessionID: string
scope?: SkillScope scope?: SkillScope | "local"
} }
export interface SkillMcpServerContext { export interface SkillMcpServerContext {
+1 -1
View File
@@ -23,7 +23,7 @@ function normalizePlanLookupValue(value: string): string {
.replace(/^["'`]+|["'`]+$/g, "") .replace(/^["'`]+|["'`]+$/g, "")
.toLowerCase() .toLowerCase()
.replace(/[\s_]+/g, "-") .replace(/[\s_]+/g, "-")
.replace(/[^a-z0-9-]+/g, "-") .replace(/[^\p{L}\p{N}-]+/gu, "-")
.replace(/-+/g, "-") .replace(/-+/g, "-")
.replace(/^-+|-+$/g, "") .replace(/^-+|-+$/g, "")
} }
+116
View File
@@ -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("my-feature-plan")
expect(output.parts[0].text).toContain("Auto-Selected 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", () => { describe("session agent management", () => {