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:
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>\`
|
||||
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
|
||||
|
||||
|
||||
@@ -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<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", () => {
|
||||
it("#when creating the client #then sensitive env vars in args are expanded", async () => {
|
||||
// given
|
||||
|
||||
@@ -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<Client>
|
||||
state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1)
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface SkillMcpClientInfo {
|
||||
serverName: string
|
||||
skillName: string
|
||||
sessionID: string
|
||||
scope?: SkillScope
|
||||
scope?: SkillScope | "local"
|
||||
}
|
||||
|
||||
export interface SkillMcpServerContext {
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user