diff --git a/docs/cli/skills.md b/docs/cli/skills.md index f69c9ee33bd8..a037222a0583 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -126,9 +126,9 @@ Notes: ## Skill Workshop proposals `openclaw skills workshop` manages pending skill proposals in the selected -workspace. Proposals are durable drafts under -`/.openclaw/skill-workshop/proposals/`; they are not active skills -until applied. +workspace. Proposals are durable OpenClaw state under +`/skill-workshop/proposals/`; they are not active skills +until applied. The default state directory is `~/.openclaw`. Create a proposal from a draft markdown file: diff --git a/docs/tools/creating-skills.md b/docs/tools/creating-skills.md index b9e09c41374c..ec33b931fb9f 100644 --- a/docs/tools/creating-skills.md +++ b/docs/tools/creating-skills.md @@ -107,8 +107,9 @@ openclaw skills workshop propose-create \ ``` The draft is stored under -`/.openclaw/skill-workshop/proposals//PROPOSAL.md` and -stays inactive until an operator reviews and applies it: +`/skill-workshop/proposals//PROPOSAL.md` and +stays inactive until an operator reviews and applies it. The default state +directory is `~/.openclaw`: ```bash openclaw skills workshop inspect diff --git a/docs/tools/skills.md b/docs/tools/skills.md index ec89df81c22f..33b0377e0cf4 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -125,7 +125,7 @@ skills without silently mutating active `SKILL.md` files. OpenClaw stores them under: ```text -/.openclaw/skill-workshop/ +/skill-workshop/ proposals.json proposals// proposal.json @@ -133,6 +133,8 @@ under: rollback.json ``` +The default state directory is `~/.openclaw`. + `proposal.json` is the canonical proposal record. `proposals.json` is the fast listing manifest and can be rebuilt from proposal folders when missing or stale. `PROPOSAL.md` marks draft content explicitly with `status: proposal` and diff --git a/src/agents/tools/skill-research-tool.test.ts b/src/agents/tools/skill-research-tool.test.ts index 2a3e2316b92b..818b651d85c4 100644 --- a/src/agents/tools/skill-research-tool.test.ts +++ b/src/agents/tools/skill-research-tool.test.ts @@ -1,13 +1,23 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../../test-utils/env.js"; import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; import { createOpenClawTools } from "../openclaw-tools.js"; import { createSkillResearchTool } from "./skill-research-tool.js"; const tempDirs = createTrackedTempDirs(); +let envSnapshot: ReturnType; +let stateDir = ""; + +beforeEach(async () => { + envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); + stateDir = await tempDirs.make("openclaw-skill-research-state-"); + process.env.OPENCLAW_STATE_DIR = stateDir; +}); afterEach(async () => { + envSnapshot.restore(); await tempDirs.cleanup(); }); @@ -43,8 +53,7 @@ describe("skill_research tool", () => { await expect( fs.readFile( path.join( - workspaceDir, - ".openclaw", + stateDir, "skill-workshop", "proposals", (result.details as { id: string }).id, diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index a12b01147872..81251feb2590 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -469,10 +469,9 @@ export function registerSkillsCli(program: Command) { .command("list") .description("List pending and completed skill proposals") .option("--json", "Output as JSON", false) - .action(async (opts: { json?: boolean; agent?: string }, command: Command) => { + .action(async (opts: { json?: boolean; agent?: string }) => { try { - const { workspaceDir } = resolveSkillsWorkspaceForCommand(command.parent, opts); - const manifest = await listSkillProposals(workspaceDir); + const manifest = await listSkillProposals(); if (opts.json) { defaultRuntime.writeJson(manifest); return; @@ -489,27 +488,24 @@ export function registerSkillsCli(program: Command) { .description("Inspect a skill proposal") .argument("", "Skill proposal id") .option("--json", "Output as JSON", false) - .action( - async (proposalId: string, opts: { json?: boolean; agent?: string }, command: Command) => { - try { - const { workspaceDir } = resolveSkillsWorkspaceForCommand(command.parent, opts); - const proposal = await inspectSkillProposal(workspaceDir, proposalId); - if (!proposal) { - defaultRuntime.error(`Skill proposal not found: ${proposalId}`); - defaultRuntime.exit(1); - return; - } - if (opts.json) { - defaultRuntime.writeJson(proposal); - return; - } - defaultRuntime.writeStdout(formatSkillProposalInspect(proposal)); - } catch (err) { - defaultRuntime.error(String(err)); + .action(async (proposalId: string, opts: { json?: boolean; agent?: string }) => { + try { + const proposal = await inspectSkillProposal(proposalId); + if (!proposal) { + defaultRuntime.error(`Skill proposal not found: ${proposalId}`); defaultRuntime.exit(1); + return; } - }, - ); + if (opts.json) { + defaultRuntime.writeJson(proposal); + return; + } + defaultRuntime.writeStdout(formatSkillProposalInspect(proposal)); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); workshop .command("propose-create") diff --git a/src/cli/skills-cli.workshop.test.ts b/src/cli/skills-cli.workshop.test.ts index 770d5120b659..996c78dd7409 100644 --- a/src/cli/skills-cli.workshop.test.ts +++ b/src/cli/skills-cli.workshop.test.ts @@ -2,10 +2,13 @@ import fs from "node:fs/promises"; import path from "node:path"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { registerSkillsCli } from "./skills-cli.js"; const tempDirs = createTrackedTempDirs(); +let envSnapshot: ReturnType; +let stateDir = ""; const mocks = vi.hoisted(() => { const runtimeStdout: string[] = []; @@ -69,7 +72,10 @@ describe("skills workshop cli", () => { }; beforeEach(async () => { + envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); mocks.workspaceDir = await tempDirs.make("openclaw-skills-cli-workshop-"); + stateDir = await tempDirs.make("openclaw-skills-cli-workshop-state-"); + process.env.OPENCLAW_STATE_DIR = stateDir; mocks.runtimeStdout.length = 0; mocks.runtimeErrors.length = 0; mocks.defaultRuntime.log.mockClear(); @@ -80,6 +86,7 @@ describe("skills workshop cli", () => { }); afterEach(async () => { + envSnapshot.restore(); await tempDirs.cleanup(); }); @@ -135,8 +142,6 @@ describe("skills workshop cli", () => { ).rejects.toThrow("__exit__:1"); expect(mocks.runtimeErrors[0]).toContain("file not found"); - await expect( - fs.access(path.join(mocks.workspaceDir, ".openclaw", "skill-workshop")), - ).rejects.toThrow(); + await expect(fs.access(path.join(stateDir, "skill-workshop"))).rejects.toThrow(); }); }); diff --git a/src/gateway/server-methods/skills.proposals.test.ts b/src/gateway/server-methods/skills.proposals.test.ts index 39e467d678a6..c88f22a414fd 100644 --- a/src/gateway/server-methods/skills.proposals.test.ts +++ b/src/gateway/server-methods/skills.proposals.test.ts @@ -1,9 +1,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../../test-utils/env.js"; import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; const tempDirs = createTrackedTempDirs(); +let envSnapshot: ReturnType; +let stateDir = ""; const mocks = vi.hoisted(() => ({ workspaceDir: "", @@ -74,10 +77,14 @@ async function callHandler(method: string, params: Record) { describe("skills proposal gateway handlers", () => { beforeEach(async () => { + envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); mocks.workspaceDir = await tempDirs.make("openclaw-skills-proposals-gateway-"); + stateDir = await tempDirs.make("openclaw-skills-proposals-gateway-state-"); + process.env.OPENCLAW_STATE_DIR = stateDir; }); afterEach(async () => { + envSnapshot.restore(); await tempDirs.cleanup(); }); @@ -119,8 +126,6 @@ describe("skills proposal gateway handlers", () => { }); expect(result.ok).toBe(false); expect((result.error as { code?: string }).code).toBe("INVALID_REQUEST"); - await expect( - fs.access(path.join(mocks.workspaceDir, ".openclaw", "skill-workshop")), - ).rejects.toThrow(); + await expect(fs.access(path.join(stateDir, "skill-workshop"))).rejects.toThrow(); }); }); diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index 65023d687185..983e4ebde187 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -246,7 +246,7 @@ export const skillsHandlers: GatewayRequestHandlers = { return; } try { - respond(true, await listSkillProposals(resolved.workspaceDir), undefined); + respond(true, await listSkillProposals(), undefined); } catch (err) { respondSkillWorkshopError(respond, err); } @@ -268,7 +268,7 @@ export const skillsHandlers: GatewayRequestHandlers = { return; } try { - const proposal = await inspectSkillProposal(resolved.workspaceDir, params.proposalId); + const proposal = await inspectSkillProposal(params.proposalId); if (!proposal) { respond( false, diff --git a/src/skills/workshop/service.test.ts b/src/skills/workshop/service.test.ts index 77106e77bbf8..c7117e8421e2 100644 --- a/src/skills/workshop/service.test.ts +++ b/src/skills/workshop/service.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../../test-utils/env.js"; import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; import { buildWorkspaceSkillStatus } from "../discovery/status.js"; import { writeSkill } from "../test-support/e2e-test-helpers.js"; @@ -16,8 +17,17 @@ import { import { readSkillProposalManifest, resolveProposalDraftPath } from "./store.js"; const tempDirs = createTrackedTempDirs(); +let envSnapshot: ReturnType; +let stateDir = ""; + +beforeEach(async () => { + envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); + stateDir = await tempDirs.make("openclaw-skill-workshop-state-"); + process.env.OPENCLAW_STATE_DIR = stateDir; +}); afterEach(async () => { + envSnapshot.restore(); await tempDirs.cleanup(); }); @@ -43,10 +53,10 @@ describe("skill workshop proposals", () => { path.join(workspaceDir, "skills", "weather-helper", "SKILL.md"), ); await expect( - fs.readFile(resolveProposalDraftPath(workspaceDir, proposal.record.id), "utf8"), + fs.readFile(resolveProposalDraftPath(proposal.record.id), "utf8"), ).resolves.toContain("status: proposal"); - const listed = await listSkillProposals(workspaceDir); + const listed = await listSkillProposals(); expect(listed.proposals).toHaveLength(1); expect(listed.proposals[0]).toMatchObject({ id: proposal.record.id, @@ -70,9 +80,7 @@ describe("skill workshop proposals", () => { source: "openclaw-workspace", filePath: applied.targetSkillFile, }); - expect((await inspectSkillProposal(workspaceDir, proposal.record.id))?.record.status).toBe( - "applied", - ); + expect((await inspectSkillProposal(proposal.record.id))?.record.status).toBe("applied"); }); it("updates only writable workspace skills and marks stale proposals when the target changes", async () => { @@ -100,9 +108,7 @@ describe("skill workshop proposals", () => { await expect( applySkillProposal({ workspaceDir, proposalId: proposal.record.id }), ).rejects.toThrow("proposal marked stale"); - expect((await inspectSkillProposal(workspaceDir, proposal.record.id))?.record.status).toBe( - "stale", - ); + expect((await inspectSkillProposal(proposal.record.id))?.record.status).toBe("stale"); }); it("applies update proposals with rollback metadata", async () => { @@ -127,14 +133,7 @@ describe("skill workshop proposals", () => { ); const rollback = JSON.parse( await fs.readFile( - path.join( - workspaceDir, - ".openclaw", - "skill-workshop", - "proposals", - proposal.record.id, - "rollback.json", - ), + path.join(stateDir, "skill-workshop", "proposals", proposal.record.id, "rollback.json"), "utf8", ), ) as { previousContent?: string }; @@ -167,7 +166,7 @@ describe("skill workshop proposals", () => { reason: "needs review", }); - const manifest = await readSkillProposalManifest(workspaceDir); + const manifest = await readSkillProposalManifest(); expect(manifest.proposals.map((entry) => [entry.skillKey, entry.status])).toEqual([ ["draft-two", "quarantined"], ["draft-one", "rejected"], @@ -189,12 +188,12 @@ describe("skill workshop proposals", () => { content: "# Manifest Repair\n", }); await fs.writeFile( - path.join(workspaceDir, ".openclaw", "skill-workshop", "proposals.json"), + path.join(stateDir, "skill-workshop", "proposals.json"), "{not-json", "utf8", ); - const manifest = await listSkillProposals(workspaceDir); + const manifest = await listSkillProposals(); expect(manifest.proposals).toHaveLength(1); expect(manifest.proposals[0]?.id).toBe(proposal.record.id); @@ -213,8 +212,6 @@ describe("skill workshop proposals", () => { await expect( applySkillProposal({ workspaceDir, proposalId: proposal.record.id }), ).rejects.toThrow("Proposal scan failed"); - expect((await inspectSkillProposal(workspaceDir, proposal.record.id))?.record.status).toBe( - "quarantined", - ); + expect((await inspectSkillProposal(proposal.record.id))?.record.status).toBe("quarantined"); }); }); diff --git a/src/skills/workshop/service.ts b/src/skills/workshop/service.ts index d62f38f6ae41..2c150a10dc29 100644 --- a/src/skills/workshop/service.ts +++ b/src/skills/workshop/service.ts @@ -48,8 +48,8 @@ type SkillWorkshopWorkspaceOptions = { const WRITABLE_WORKSPACE_SOURCES = new Set(["openclaw-workspace", "agents-skills-project"]); const MAX_PROPOSAL_DRAFT_BYTES = 1024 * 1024; -export async function listSkillProposals(workspaceDir: string): Promise { - return await readSkillProposalManifest(workspaceDir); +export async function listSkillProposals(): Promise { + return await readSkillProposalManifest(); } export async function readSkillProposalDraftFile(filePath: string): Promise { @@ -61,10 +61,9 @@ export async function readSkillProposalDraftFile(filePath: string): Promise { - return await readSkillProposal(workspaceDir, proposalId); + return await readSkillProposal(proposalId); } export async function proposeCreateSkill( @@ -110,7 +109,7 @@ export async function proposeCreateSkill( ...(goal ? { goal } : {}), ...(evidence ? { evidence } : {}), }; - await writeSkillProposal({ workspaceDir: input.workspaceDir, record, content: proposalContent }); + await writeSkillProposal({ record, content: proposalContent }); return { record, content: proposalContent }; } @@ -166,7 +165,7 @@ export async function proposeUpdateSkill( ...(goal ? { goal } : {}), ...(evidence ? { evidence } : {}), }; - await writeSkillProposal({ workspaceDir: input.workspaceDir, record, content: proposalContent }); + await writeSkillProposal({ record, content: proposalContent }); return { record, content: proposalContent }; } @@ -179,7 +178,7 @@ export async function rejectSkillProposal( export async function quarantineSkillProposal( input: SkillProposalActionInput, ): Promise { - const read = await readRequiredProposal(input.workspaceDir, input.proposalId); + const read = await readRequiredProposal(input.proposalId); const now = new Date().toISOString(); const record: SkillProposalRecord = { ...read.record, @@ -192,14 +191,14 @@ export async function quarantineSkillProposal( state: "quarantined", }, }; - await updateSkillProposalRecord({ workspaceDir: input.workspaceDir, record }); + await updateSkillProposalRecord({ record }); return record; } export async function applySkillProposal( input: SkillProposalActionInput, ): Promise { - const read = await readRequiredProposal(input.workspaceDir, input.proposalId); + const read = await readRequiredProposal(input.proposalId); const { record, content } = read; if (record.status !== "pending") { throw new Error(`Only pending proposals can be applied. Current status: ${record.status}.`); @@ -222,7 +221,7 @@ export async function applySkillProposal( scan: { ...scan, state: "quarantined" as const }, statusReason: "Proposal scan failed.", }; - await updateSkillProposalRecord({ workspaceDir: input.workspaceDir, record: updated }); + await updateSkillProposalRecord({ record: updated }); throw new Error("Proposal scan failed; proposal was quarantined."); } @@ -247,7 +246,7 @@ export async function applySkillProposal( staleAt: new Date().toISOString(), statusReason: "Target skill changed after proposal creation.", }; - await updateSkillProposalRecord({ workspaceDir: input.workspaceDir, record: stale }); + await updateSkillProposalRecord({ record: stale }); throw new Error("Target skill changed after proposal creation; proposal marked stale."); } } @@ -259,7 +258,6 @@ export async function applySkillProposal( ...(previousContent !== null ? { previousContent } : {}), }); await writeSkillProposalRollback({ - workspaceDir: input.workspaceDir, proposalId: record.id, rollback, }); @@ -278,8 +276,8 @@ export async function applySkillProposal( appliedAt: now, scan, }; - await updateSkillProposalRecord({ workspaceDir: input.workspaceDir, record: applied }); - await refreshSkillProposalManifest(input.workspaceDir); + await updateSkillProposalRecord({ record: applied }); + await refreshSkillProposalManifest(); return { record: applied, targetSkillFile: record.target.skillFile }; } @@ -303,7 +301,7 @@ async function markProposal( input: SkillProposalActionInput, status: "rejected", ): Promise { - const read = await readRequiredProposal(input.workspaceDir, input.proposalId); + const read = await readRequiredProposal(input.proposalId); const now = new Date().toISOString(); const record: SkillProposalRecord = { ...read.record, @@ -312,15 +310,12 @@ async function markProposal( rejectedAt: now, statusReason: normalizeOptionalString(input.reason), }; - await updateSkillProposalRecord({ workspaceDir: input.workspaceDir, record }); + await updateSkillProposalRecord({ record }); return record; } -async function readRequiredProposal( - workspaceDir: string, - proposalId: string, -): Promise { - const read = await readSkillProposal(workspaceDir, proposalId); +async function readRequiredProposal(proposalId: string): Promise { + const read = await readSkillProposal(proposalId); if (!read) { throw new Error(`Skill proposal not found: ${proposalId}`); } diff --git a/src/skills/workshop/store.ts b/src/skills/workshop/store.ts index 43a34ee404f4..d375fc19bc53 100644 --- a/src/skills/workshop/store.ts +++ b/src/skills/workshop/store.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import path from "node:path"; -import { pathExists, readFileWithinRoot, root } from "../../infra/fs-safe.js"; +import { resolveStateDir } from "../../config/paths.js"; +import { pathExists, root } from "../../infra/fs-safe.js"; import { tryReadJson } from "../../infra/json-files.js"; import { isPathInside } from "../../infra/path-safety.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -16,7 +17,7 @@ import { type SkillProposalRollback, } from "./types.js"; -const WORKSHOP_REL_DIR = path.join(".openclaw", "skill-workshop"); +const WORKSHOP_REL_DIR = "skill-workshop"; const PROPOSALS_REL_DIR = path.join(WORKSHOP_REL_DIR, "proposals"); const MANIFEST_REL_PATH = path.join(WORKSHOP_REL_DIR, "proposals.json"); const PROPOSAL_RECORD_FILE = "proposal.json"; @@ -25,6 +26,11 @@ const PROPOSAL_ROLLBACK_FILE = "rollback.json"; const MAX_PROPOSAL_BYTES = 1024 * 1024; const PROPOSAL_ID_PATTERN = /^[a-z0-9][a-z0-9-]{5,120}$/; +type SkillWorkshopStoreOptions = { + env?: NodeJS.ProcessEnv; + stateDir?: string; +}; + export function createSkillProposalId(name: string, now = new Date()): string { const normalized = normalizeSkillIndexName(name) || "skill"; const date = now.toISOString().slice(0, 10).replaceAll("-", ""); @@ -36,21 +42,34 @@ export function hashSkillProposalContent(content: string): string { return crypto.createHash("sha256").update(content).digest("hex"); } -export function resolveWorkshopPath(workspaceDir: string): string { - return path.resolve(workspaceDir, WORKSHOP_REL_DIR); +function resolveSkillWorkshopStateDir(options: SkillWorkshopStoreOptions = {}): string { + return path.resolve(options.stateDir ?? resolveStateDir(options.env)); } -export function resolveProposalDir(workspaceDir: string, proposalId: string): string { +export function resolveWorkshopPath(options: SkillWorkshopStoreOptions = {}): string { + return path.join(resolveSkillWorkshopStateDir(options), WORKSHOP_REL_DIR); +} + +export function resolveProposalDir( + proposalId: string, + options: SkillWorkshopStoreOptions = {}, +): string { assertProposalId(proposalId); - return path.resolve(workspaceDir, proposalRelativeDir(proposalId)); + return path.join(resolveSkillWorkshopStateDir(options), proposalRelativeDir(proposalId)); } -export function resolveProposalRecordPath(workspaceDir: string, proposalId: string): string { - return path.join(resolveProposalDir(workspaceDir, proposalId), PROPOSAL_RECORD_FILE); +export function resolveProposalRecordPath( + proposalId: string, + options: SkillWorkshopStoreOptions = {}, +): string { + return path.join(resolveProposalDir(proposalId, options), PROPOSAL_RECORD_FILE); } -export function resolveProposalDraftPath(workspaceDir: string, proposalId: string): string { - return path.join(resolveProposalDir(workspaceDir, proposalId), PROPOSAL_DRAFT_FILE); +export function resolveProposalDraftPath( + proposalId: string, + options: SkillWorkshopStoreOptions = {}, +): string { + return path.join(resolveProposalDir(proposalId, options), PROPOSAL_DRAFT_FILE); } export function resolveSkillProposalTarget(params: { workspaceDir: string; skillName: string }): { @@ -70,15 +89,15 @@ export function resolveSkillProposalTarget(params: { workspaceDir: string; skill } export async function readSkillProposal( - workspaceDir: string, proposalId: string, + options: SkillWorkshopStoreOptions = {}, ): Promise { - const record = await readSkillProposalRecord(workspaceDir, proposalId); + const record = await readSkillProposalRecord(proposalId, options); if (!record) { return null; } - const workspaceRoot = await root(workspaceDir); - const draft = await workspaceRoot.read( + const stateRoot = await root(resolveSkillWorkshopStateDir(options)); + const draft = await stateRoot.read( path.join(proposalRelativeDir(proposalId), PROPOSAL_DRAFT_FILE), { hardlinks: "reject", @@ -90,52 +109,52 @@ export async function readSkillProposal( } export async function readSkillProposalRecord( - workspaceDir: string, proposalId: string, + options: SkillWorkshopStoreOptions = {}, ): Promise { - const raw = await tryReadJson(resolveProposalRecordPath(workspaceDir, proposalId)); + const raw = await tryReadJson(resolveProposalRecordPath(proposalId, options)); return parseSkillProposalRecord(raw); } export async function writeSkillProposal(params: { - workspaceDir: string; record: SkillProposalRecord; content: string; + store?: SkillWorkshopStoreOptions; }): Promise { assertProposalId(params.record.id); - const workspaceRoot = await root(params.workspaceDir); + const stateRoot = await root(resolveSkillWorkshopStateDir(params.store)); const relativeDir = proposalRelativeDir(params.record.id); - await workspaceRoot.mkdir(relativeDir); - await workspaceRoot.write(path.join(relativeDir, PROPOSAL_DRAFT_FILE), params.content, { + await stateRoot.mkdir(relativeDir); + await stateRoot.write(path.join(relativeDir, PROPOSAL_DRAFT_FILE), params.content, { encoding: "utf8", }); - await workspaceRoot.writeJson(path.join(relativeDir, PROPOSAL_RECORD_FILE), params.record, { + await stateRoot.writeJson(path.join(relativeDir, PROPOSAL_RECORD_FILE), params.record, { trailingNewline: true, }); - await refreshSkillProposalManifest(params.workspaceDir); + await refreshSkillProposalManifest(params.store); } export async function updateSkillProposalRecord(params: { - workspaceDir: string; record: SkillProposalRecord; + store?: SkillWorkshopStoreOptions; }): Promise { assertProposalId(params.record.id); - const workspaceRoot = await root(params.workspaceDir); - await workspaceRoot.writeJson( + const stateRoot = await root(resolveSkillWorkshopStateDir(params.store)); + await stateRoot.writeJson( path.join(proposalRelativeDir(params.record.id), PROPOSAL_RECORD_FILE), params.record, { trailingNewline: true }, ); - await refreshSkillProposalManifest(params.workspaceDir); + await refreshSkillProposalManifest(params.store); } export async function writeSkillProposalRollback(params: { - workspaceDir: string; proposalId: string; rollback: SkillProposalRollback; + store?: SkillWorkshopStoreOptions; }): Promise { - const workspaceRoot = await root(params.workspaceDir); - await workspaceRoot.writeJson( + const stateRoot = await root(resolveSkillWorkshopStateDir(params.store)); + await stateRoot.writeJson( path.join(proposalRelativeDir(params.proposalId), PROPOSAL_ROLLBACK_FILE), params.rollback, { trailingNewline: true }, @@ -143,29 +162,29 @@ export async function writeSkillProposalRollback(params: { } export async function readSkillProposalManifest( - workspaceDir: string, + options: SkillWorkshopStoreOptions = {}, ): Promise { - const manifestPath = path.resolve(workspaceDir, MANIFEST_REL_PATH); + const manifestPath = path.join(resolveSkillWorkshopStateDir(options), MANIFEST_REL_PATH); const parsed = parseSkillProposalManifest(await tryReadJson(manifestPath)); if (parsed) { return parsed; } - return await refreshSkillProposalManifest(workspaceDir); + return await refreshSkillProposalManifest(options); } export async function refreshSkillProposalManifest( - workspaceDir: string, + options: SkillWorkshopStoreOptions = {}, ): Promise { - const workspaceRoot = await root(workspaceDir); - await workspaceRoot.mkdir(PROPOSALS_REL_DIR); - const entries = await workspaceRoot.list(PROPOSALS_REL_DIR, { withFileTypes: true }); + const stateRoot = await root(resolveSkillWorkshopStateDir(options)); + await stateRoot.mkdir(PROPOSALS_REL_DIR); + const entries = await stateRoot.list(PROPOSALS_REL_DIR, { withFileTypes: true }); const proposals: SkillProposalManifestEntry[] = []; for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) { if (!entry.isDirectory || !PROPOSAL_ID_PATTERN.test(entry.name)) { continue; } - const record = await readSkillProposalRecord(workspaceDir, entry.name); + const record = await readSkillProposalRecord(entry.name, options); if (!record) { continue; } @@ -177,7 +196,7 @@ export async function refreshSkillProposalManifest( updatedAt: new Date().toISOString(), proposals: proposals.toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt)), }; - await workspaceRoot.writeJson(MANIFEST_REL_PATH, manifest, { + await stateRoot.writeJson(MANIFEST_REL_PATH, manifest, { mkdir: true, trailingNewline: true, }); @@ -188,10 +207,11 @@ export async function readWorkspaceSkillFile(filePath: string): Promise