diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index aa2f28d6..18568116 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -6091,6 +6091,9 @@ function compactSkillsStatus(value: unknown): Record | null { readonly: record.readonly ?? false, skillCount: record.skillCount ?? 0, version: record.version ?? null, + capabilities: record.capabilities ?? null, + warnings: Array.isArray(record.warnings) ? record.warnings.map(String) : [], + next: record.next ?? null, sourceSkillCount: record.sourceSkillCount ?? null, targetSkillCount: record.targetSkillCount ?? null, sourceMissingSkills: Array.isArray(record.sourceMissingSkills) ? record.sourceMissingSkills.map(String) : [], @@ -6151,6 +6154,8 @@ function compactSkillsSyncStatus(value: unknown, full = false): Record { + test("reports stale and ready source/target capability without blocking", () => { + const temporary = mkdtempSync("/tmp/unidesk-mdtodo-safe-input-"); + try { + const source = join(temporary, "source"); + const staleTarget = join(temporary, "stale-target"); + const readyTarget = join(temporary, "ready-target"); + writeFixture(source, true); + writeFixture(staleTarget, false); + writeFixture(readyTarget, true); + + expect(collectMdtodoSafeTitleStdinCapability(source, staleTarget)).toMatchObject({ + nonBlocking: true, + ready: false, + freshness: "target-stale", + source: { ready: true }, + target: { ready: false }, + }); + expect(collectMdtodoSafeTitleStdinCapability(source, readyTarget)).toMatchObject({ + nonBlocking: true, + ready: true, + freshness: "ready", + warning: null, + }); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }); + + test("keeps Markdown and shell syntax in quoted heredoc stdin", () => { + const temporary = mkdtempSync("/tmp/unidesk-mdtodo-command-"); + try { + const home = join(temporary, "home"); + const scripts = join(home, ".agents", "skills", "mdtodo-edit", "scripts"); + const output = join(temporary, "title.txt"); + const forbidden = join(temporary, "forbidden"); + mkdirSync(scripts, { recursive: true }); + writeFileSync(join(scripts, "mdtodo-edit-cli.py"), [ + "import os, sys", + "open(os.environ['TITLE_OUTPUT'], 'w', encoding='utf-8').write(sys.stdin.read())", + ].join("\n")); + const title = `修复 \`path\` 与 $(touch ${forbidden}) 的 'quoted' 标题`; + const command = renderMdtodoSafeTitleCommand("docs/MDTODO/demo file.md", "add").replace("", title); + + expect(command).toContain("'docs/MDTODO/demo file.md'"); + expect(command).toContain("--stdin <<'EOF'"); + expect(command.split("--stdin", 1)[0]).not.toContain(title); + const result = Bun.spawnSync(["bash", "-c", command], { + env: { ...process.env, HOME: home, TITLE_OUTPUT: output }, + }); + expect(result.exitCode).toBe(0); + expect(readFileSync(output, "utf8")).toBe(`${title}\n`); + expect(existsSync(forbidden)).toBe(false); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }); +}); diff --git a/src/components/microservices/code-queue/src/skill-availability.ts b/src/components/microservices/code-queue/src/skill-availability.ts index f095eac5..8cf0f127 100644 --- a/src/components/microservices/code-queue/src/skill-availability.ts +++ b/src/components/microservices/code-queue/src/skill-availability.ts @@ -81,6 +81,14 @@ export interface SkillAvailabilityReport { sourceSampledSkillNames: string[]; targetSampledSkillNames: string[]; }; + capabilities: { + mdtodoSafeTitleStdin: MdtodoSafeTitleStdinCapability; + }; + warnings: string[]; + next: { + inspect: string; + safeTitleExample: string; + }; repairHint: string | null; error: string | null; valuesPrinted: false; @@ -164,6 +172,10 @@ export interface SkillSyncPreflightReport { sourceSampledSkillNames: string[]; targetSampledSkillNames: string[]; }; + capabilities: { + mdtodoSafeTitleStdin: MdtodoSafeTitleStdinCapability; + }; + warnings: string[]; missing: { sourceSkills: string[]; targetSkills: string[]; @@ -185,10 +197,30 @@ export interface SkillSyncPreflightReport { health: string; runtimePreflight: string; contractTest: string; + safeTitleExample: string; }; valuesPrinted: false; } +export interface MdtodoSafeTitleStdinProbe { + path: string; + exists: boolean; + commands: Record<"add" | "add-sub" | "update", boolean>; + ready: boolean; + identity: string | null; +} + +export interface MdtodoSafeTitleStdinCapability { + name: "mdtodo-safe-title-stdin"; + nonBlocking: true; + ready: boolean; + freshness: "ready" | "source-stale" | "target-stale" | "missing"; + source: MdtodoSafeTitleStdinProbe; + target: MdtodoSafeTitleStdinProbe; + warning: string | null; + next: string; +} + export const defaultRequiredSkills = ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"]; export const defaultSource = "/home/ubuntu/.agents/skills"; export const expectedTarget = "/root/.agents/skills"; @@ -303,6 +335,64 @@ function skillSetVersion(path: string, readable: boolean): SkillSyncPathReport[" } } +function mdtodoCliPath(skillsPath: string): string { + return resolve(skillsPath, "mdtodo-edit", "scripts", "mdtodo-edit-cli.py"); +} + +function probeMdtodoSafeTitleStdin(skillsPath: string): MdtodoSafeTitleStdinProbe { + const path = mdtodoCliPath(skillsPath); + const exists = existsSync(path); + const commands = { add: false, "add-sub": false, update: false }; + if (exists) { + for (const command of Object.keys(commands) as Array<keyof typeof commands>) { + const result = spawnSync("python3", [path, command, "--help"], { encoding: "utf8", timeout: 2000 }); + commands[command] = result.status === 0 && `${result.stdout ?? ""}\n${result.stderr ?? ""}`.includes("--stdin"); + } + } + const ready = Object.values(commands).every(Boolean); + let identity: string | null = null; + if (exists) { + try { + identity = createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 16); + } catch { + identity = null; + } + } + return { path, exists, commands, ready, identity }; +} + +export function collectMdtodoSafeTitleStdinCapability(sourcePath: string, targetPath: string): MdtodoSafeTitleStdinCapability { + const source = probeMdtodoSafeTitleStdin(sourcePath); + const target = probeMdtodoSafeTitleStdin(targetPath); + const freshness = target.ready + ? "ready" + : source.ready + ? "target-stale" + : source.exists || target.exists + ? "source-stale" + : "missing"; + const warning = freshness === "ready" + ? null + : freshness === "target-stale" + ? "MDTODO safe title stdin is available in the approved source but missing from the runner target projection." + : "MDTODO safe title stdin is missing from the approved source; update the managed agent_skills source before refreshing projections."; + return { + name: "mdtodo-safe-title-stdin", + nonBlocking: true, + ready: target.ready, + freshness, + source, + target, + warning, + next: "bun scripts/cli.ts codex skills-sync --dry-run --full", + }; +} + +export function renderMdtodoSafeTitleCommand(file: string, command: "add" | "add-sub" | "update", taskId?: string): string { + const positional = command === "add" ? shellQuote(file) : `${shellQuote(file)} ${shellQuote(taskId ?? "<taskId>")}`; + return `python3 "$HOME/.agents/skills/mdtodo-edit/scripts/mdtodo-edit-cli.py" ${command} ${positional} --stdin <<'EOF'\n<title>\nEOF`; +} + function accessProbe(path: string, mode: number, operation: string): { ok: boolean; failure: { path: string; operation: string; error: string } | null } { try { accessSync(path, mode); @@ -450,6 +540,7 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski : targetSymlinkToSource ? "target-symlink" : "target"; const resolvedPath = target; const selectedReport = targetProbe; + const mdtodoSafeTitleStdin = collectMdtodoSafeTitleStdinCapability(source, target); const runnerUsable = !forbiddenPathConfigured && targetReady && !forbiddenPathExists; const contractOk = runnerUsable; const blocker = forbiddenPathConfigured @@ -539,6 +630,12 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski sourceSampledSkillNames: sourceProbe.version.sampledSkillNames, targetSampledSkillNames: targetProbe.version.sampledSkillNames, }, + capabilities: { mdtodoSafeTitleStdin }, + warnings: mdtodoSafeTitleStdin.warning === null ? [] : [mdtodoSafeTitleStdin.warning], + next: { + inspect: mdtodoSafeTitleStdin.next, + safeTitleExample: renderMdtodoSafeTitleCommand("docs/MDTODO/example.md", "add"), + }, repairHint: contractOk ? null : sourceHasRequiredSkills @@ -561,6 +658,7 @@ export function collectSkillSyncPreflight(options: SkillSyncPreflightOptions = { const forbiddenPathConfigured = forbiddenSourceConfigured || forbiddenTargetConfigured; const forbiddenPathExists = forbiddenTargets.some((path) => existsSync(path)); const permissionFailures = [...source.permissionFailures, ...target.permissionFailures]; + const mdtodoSafeTitleStdin = collectMdtodoSafeTitleStdinCapability(sourcePath, targetPath); const blocker = forbiddenPathConfigured ? "forbidden-skills-path-configured" : !source.report.approved @@ -623,6 +721,8 @@ export function collectSkillSyncPreflight(options: SkillSyncPreflightOptions = { sourceSampledSkillNames: source.report.version.sampledSkillNames, targetSampledSkillNames: target.report.version.sampledSkillNames, }, + capabilities: { mdtodoSafeTitleStdin }, + warnings: mdtodoSafeTitleStdin.warning === null ? [] : [mdtodoSafeTitleStdin.warning], missing: { sourceSkills: source.report.missingSkills, targetSkills: target.report.missingSkills, @@ -659,6 +759,7 @@ export function collectSkillSyncPreflight(options: SkillSyncPreflightOptions = { health: "bun scripts/cli.ts microservice health code-queue", runtimePreflight: "bun scripts/cli.ts codex pr-preflight --remote", contractTest: "bun scripts/code-queue-runner-skills-contract-test.ts", + safeTitleExample: renderMdtodoSafeTitleCommand("docs/MDTODO/example.md", "add"), }, valuesPrinted: false, };