diff --git a/packages/cli/src/commands/scope.ts b/packages/cli/src/commands/scope.ts index 5d409ab028..d99dbe4e95 100644 --- a/packages/cli/src/commands/scope.ts +++ b/packages/cli/src/commands/scope.ts @@ -48,6 +48,7 @@ import { moveSlice, rollbackMovedSlice, nextSliceNN, + tryClaimSliceNN, NOTES_FILE_PRECEDENCE, pad2, readFrontmatter, @@ -78,6 +79,7 @@ import { deriveScopeTrust } from "../lib/scope/trust.js"; import { capabilityDeltaExpiryFindings } from "../lib/scope/capability-delta.js"; import { applyMissionCompositionEdits, + appendMissionMembership, nextMissionMembershipOrder, planMissionMembershipAdd, planMissionMembershipRemove, @@ -335,41 +337,50 @@ function buildSliceCreateCommand(): Command { } const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace }); const mission = findMission(missionsRoot, missionName); - const nn = nextSliceNN(mission.absPath); - const sliceFolder = `${pad2(nn)}-${slug}`; - const sliceAbs = path.join(mission.absPath, "slices", sliceFolder); - if (fs.existsSync(sliceAbs)) { - throw new ScopeCliError({ - fact: `Slice folder ${sliceAbs} already exists.`, - consequence: "Refusing to overwrite.", - action: "Pick a different slug, or rm -rf the existing folder first.", - }); - } + let nn = nextSliceNN(mission.absPath); const title = opts.title ?? titleFromSlug(slug); const intent = opts.intent ?? title; - const compositionEdit = planMissionMembershipAdd( - mission.absPath, - `slices/${sliceFolder}/slice.yaml`, - nextMissionMembershipOrder(mission.absPath), - ); const missionId = ensureMissionId(mission, missionsRoot); - const id = sliceIdFromMission(missionId, nn); + let id = sliceIdFromMission(missionId, nn); const dependsOn = Array.isArray(opts.dependsOn) ? [...new Set(opts.dependsOn as string[])] : []; - for (const dependency of dependsOn) { - if (dependency === id) { - throw new ScopeCliError({ - fact: `Slice ${id} cannot depend on itself.`, - consequence: "Slice not created.", - action: "Use a different sibling slice ID, or omit --depends-on.", - }); + // Validate before reserving any number; re-check self-dependency if a + // competing creator claimed the initial candidate. + let sliceFolder: string; + let sliceAbs: string; + for (;;) { + for (const dependency of dependsOn) { + if (dependency === id) { + throw new ScopeCliError({ + fact: `Slice ${id} cannot depend on itself.`, + consequence: "Slice not created.", + action: "Use a different sibling slice ID, or omit --depends-on.", + }); + } + if (!isSliceDotId(dependency) || !dependency.startsWith(`${missionId}.`)) { + throw new ScopeCliError({ + fact: `Dependency "${dependency}" is not a sibling slice dot-ID under ${missionId}.`, + consequence: "Slice not created.", + action: `Use a sibling ID shaped like ${missionId}., or omit --depends-on.`, + }); + } } - if (!isSliceDotId(dependency) || !dependency.startsWith(`${missionId}.`)) { + sliceFolder = `${pad2(nn)}-${slug}`; + sliceAbs = path.join(mission.absPath, "slices", sliceFolder); + if (fs.existsSync(sliceAbs)) { throw new ScopeCliError({ - fact: `Dependency "${dependency}" is not a sibling slice dot-ID under ${missionId}.`, - consequence: "Slice not created.", - action: `Use a sibling ID shaped like ${missionId}., or omit --depends-on.`, + fact: `Slice folder ${sliceAbs} already exists.`, + consequence: "Refusing to overwrite.", + action: "Pick a different slug, or rm -rf the existing folder first.", }); } + // Validate an explicit manifest before reserving an ordinal. The + // post-scaffold write re-plans from the latest manifest and disk. + planMissionMembershipAdd( + mission.absPath, `slices/${pad2(nn)}-${slug}/slice.yaml`, + ); + if (tryClaimSliceNN(mission.absPath, nn, `slices/${sliceFolder}/slice.yaml`)) break; + nn++; + id = sliceIdFromMission(missionId, nn); } const createdDate = todayDateISO(); const body = renderSliceTemplate(kind, { @@ -383,11 +394,12 @@ function buildSliceCreateCommand(): Command { depends_on: dependsOn, }); const proofBody = renderSliceProofTemplate({ id, title }); - const originalMissionNode = mission.readmePath ? fs.readFileSync(mission.readmePath, "utf8") : null; const readmePath = path.join(sliceAbs, "SPEC.md"); + const proofPath = path.join(sliceAbs, "PROOF.md"); + let scaffoldCompleted = false; try { - // Persist the parent id in the same rollback boundary as the child - // and composition membership. Validation above performs no writes. + // Persist the parent id before building the scaffold. Incomplete + // scaffolds roll back; the ordinal remains reserved even then. ensureMissionIdPersisted(mission, missionsRoot); fs.mkdirSync(sliceAbs, { recursive: true }); fs.mkdirSync(path.join(sliceAbs, "proof"), { recursive: true }); @@ -405,11 +417,20 @@ function buildSliceCreateCommand(): Command { fs.writeFileSync(progressPath, renderSliceProgressTemplate(title), "utf8"); } fs.writeFileSync(path.join(sliceAbs, "slice.yaml"), SLICE_MANIFEST, "utf8"); - fs.writeFileSync(path.join(sliceAbs, "PROOF.md"), proofBody, "utf8"); - applyMissionCompositionEdits(compositionEdit ? [compositionEdit] : []); + // Publish the completion sentinel only after its entire contents exist. + const proofTemporary = `${proofPath}.openrig-${process.pid}.tmp`; + fs.writeFileSync(proofTemporary, proofBody, { encoding: "utf8", flag: "wx" }); + fs.renameSync(proofTemporary, proofPath); + scaffoldCompleted = true; + await appendMissionMembership(mission.absPath, `slices/${sliceFolder}/slice.yaml`); } catch (error) { - fs.rmSync(sliceAbs, { recursive: true, force: true }); - if (originalMissionNode !== null && mission.readmePath) fs.writeFileSync(mission.readmePath, originalMissionNode, "utf8"); + // PROOF.md is the final scaffold file. Once complete, leave this + // pending registration available for a later creator to repair. + if (!scaffoldCompleted) { + fs.rmSync(sliceAbs, { recursive: true, force: true }); + // The parent identity may already be shared by another creator. + // Rolling this child back must not undo it or authored parent edits. + } throw error; } const payload = { @@ -424,10 +445,10 @@ function buildSliceCreateCommand(): Command { }, }; emit(out, payload, json, [ - `Created ${mission.name}/slices/${sliceFolder}`, - ` id: ${id}`, + `Created ${payload.slice.mission}/slices/${payload.slice.name}`, + ` id: ${payload.slice.id}`, ` template: ${kind}`, - ` path: ${sliceAbs}`, + ` path: ${payload.slice.path}`, ]); } catch (err) { fail(err, json, out); diff --git a/packages/cli/src/lib/scope/mission-composition.ts b/packages/cli/src/lib/scope/mission-composition.ts index 69ed22afa3..60353346bb 100644 --- a/packages/cli/src/lib/scope/mission-composition.ts +++ b/packages/cli/src/lib/scope/mission-composition.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import { parseDocument } from "yaml"; import { ScopeCliError } from "./types.js"; +import { pendingCompletedSliceRefs, markSliceRegistrationsVerified } from "./slice-claims.js"; import { LifecycleManifestValidationError, validateMissionComposition, @@ -30,11 +31,12 @@ interface LoadedComposition { export function planMissionMembershipAdd( missionPath: string, ref: string, - order: number, + order?: number, ): MissionCompositionEdit | null { const loaded = loadMissionComposition(missionPath); if (!loaded) return null; assertSafeRelative(ref); + order ??= (loaded.members.at(-1)?.order ?? 0) + 10; if (!Number.isInteger(order)) fail(`Membership order for ${ref} must be an integer.`); if (loaded.members.some((member) => path.normalize(member.ref) === path.normalize(ref))) { fail(`Mission composition already contains ${ref}.`); @@ -47,6 +49,65 @@ export function planMissionMembershipAdd( return renderEdit(loaded, members); } +/** Repair only positively unfinished registrations into a fresh union, then verify + * this creator's entry. Rename is not compare-and-swap: a later writer can + * still supersede a verified snapshot, so completed scaffolds stay on disk. */ +export async function appendMissionMembership( + missionPath: string, + ref: string, +): Promise { + const attempts = 10; + for (let attempt = 0; attempt < attempts; attempt++) { + const edit = planCompletedSliceMembership(missionPath, ref); + // README-backed legacy missions do not have a composition manifest. + if (!edit) { + markSliceRegistrationsVerified(missionPath, [ref]); + return; + } + try { + applyMissionCompositionEdits([edit]); + const current = loadMissionComposition(missionPath); + if (current?.members.some((member) => path.normalize(member.ref) === path.normalize(ref))) { + markSliceRegistrationsVerified(missionPath, current.members.map((member) => member.ref)); + return; + } + } catch (error) { + if (!(error instanceof MissionCompositionChangedError)) throw error; + } + if (attempt < attempts - 1) { + await new Promise((resolve) => setTimeout(resolve, 10 + Math.floor(Math.random() * 91))); + } + } + throw new ScopeCliError({ + fact: `Could not verify membership for ${ref} after ${attempts} attempts.`, + consequence: "The complete slice scaffold was kept on disk; this create did not report success.", + action: "Inspect mission.yaml, then run a later slice create to repair unfinished registration.", + }); +} + +/** Preserve authored members and add only positively pending complete scaffolds, plus the + * caller's completed entry. Existing active flags and orders remain intact. */ +function planCompletedSliceMembership(missionPath: string, ref: string): MissionCompositionEdit | null { + const loaded = loadMissionComposition(missionPath); + if (!loaded) return null; + assertSafeRelative(ref); + const members = [...loaded.members]; + const present = new Set(members.map((member) => path.normalize(member.ref))); + const completed = pendingCompletedSliceRefs(missionPath); + completed.push(ref); + let order = Math.max(0, ...members.map((member) => member.order)); + for (const completedRef of completed) { + const normalized = path.normalize(completedRef); + if (present.has(normalized)) continue; + order += 10; + members.push({ ref: completedRef, order, active: true }); + present.add(normalized); + } + return renderEdit(loaded, members); +} + +class MissionCompositionChangedError extends ScopeCliError {} + export function planMissionMembershipRemove( missionPath: string, ref: string, @@ -73,7 +134,7 @@ export function applyMissionCompositionEdits(edits: MissionCompositionEdit[]): v // overwrite another agent's authored membership or partially change a move. for (const edit of unique) { if (fs.readFileSync(edit.manifestPath, "utf8") !== edit.original) { - throw new ScopeCliError({ + throw new MissionCompositionChangedError({ fact: `Mission composition changed since this edit was planned: ${edit.manifestPath}.`, consequence: "Scope composition was not changed.", action: "Read the current mission.yaml and retry the scope operation.", diff --git a/packages/cli/src/lib/scope/scope-fs.ts b/packages/cli/src/lib/scope/scope-fs.ts index 43bc685276..0bea67175c 100644 --- a/packages/cli/src/lib/scope/scope-fs.ts +++ b/packages/cli/src/lib/scope/scope-fs.ts @@ -498,16 +498,18 @@ function findOwningMission(missionsRoot: string, slicePath: string): string | nu // Auto-numbering // --------------------------------------------------------------------- +export { tryClaimSliceNN } from "./slice-claims.js"; + /** Find the next available NN for a mission's slices/ folder. Scans - * BOTH slices/ AND closed/ so numbers are never reused (§3.2). */ + * BOTH slices/ AND closed/, including ordinal claims, so numbers are never reused (§3.2). */ export function nextSliceNN(missionAbsPath: string): number { let max = 0; for (const subdir of ["slices", "closed"]) { const root = path.join(missionAbsPath, subdir); if (!fs.existsSync(root)) continue; for (const entry of fs.readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const m = SLICE_DIRNAME_RE.exec(entry.name); + const marker = subdir === "slices" ? /^\.nn-(\d+)$/.exec(entry.name) : null; + const m = marker ?? (entry.isDirectory() ? SLICE_DIRNAME_RE.exec(entry.name) : null); if (!m) continue; const n = Number(m[1]); if (Number.isFinite(n) && n > max) max = n; diff --git a/packages/cli/src/lib/scope/slice-claims.ts b/packages/cli/src/lib/scope/slice-claims.ts new file mode 100644 index 0000000000..fd0c3aae98 --- /dev/null +++ b/packages/cli/src/lib/scope/slice-claims.ts @@ -0,0 +1,77 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +const SCHEMA = "openrig.slice-claim/v1"; +interface SliceClaim { schema: typeof SCHEMA; ref: string; state: "pending" | "verified" } + +/** One persistent claim reserves the ordinal and records unfinished registration. */ +export function tryClaimSliceNN(missionPath: string, nn: number, ref: string): boolean { + const slices = path.join(missionPath, "slices"); + fs.mkdirSync(slices, { recursive: true }); + let fd: number; + try { + fd = fs.openSync(path.join(slices, `.nn-${String(nn).padStart(2, "0")}`), "wx"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; + } + try { + fs.writeFileSync(fd, JSON.stringify({ schema: SCHEMA, ref, state: "pending" } satisfies SliceClaim) + "\n"); + } finally { + fs.closeSync(fd); + } + return true; +} + +function readClaim(missionPath: string, marker: string): SliceClaim | null { + const ordinal = /^\.nn-(\d+)$/.exec(marker); + if (!ordinal) return null; + const file = path.join(missionPath, "slices", marker); + if (!fs.lstatSync(file).isFile()) return null; + let claim: unknown; + try { claim = JSON.parse(fs.readFileSync(file, "utf8")); } + catch (error) { + if (error instanceof SyntaxError) return null; // Legacy/partial marker reserves only a number. + throw error; + } + if (!claim || typeof claim !== "object") return null; + const value = claim as Partial; + if (value.schema !== SCHEMA || (value.state !== "pending" && value.state !== "verified") || typeof value.ref !== "string") return null; + const folder = /^slices\/(\d+)-[^/\\]+\/slice\.yaml$/.exec(value.ref); + if (!folder || Number(folder[1]) !== Number(ordinal[1])) return null; + return value as SliceClaim; +} + +/** Older unlisted folders and verified registrations are never adopted. */ +export function pendingCompletedSliceRefs(missionPath: string): string[] { + const slices = path.join(missionPath, "slices"); + if (!fs.existsSync(slices)) return []; + const refs: string[] = []; + for (const marker of fs.readdirSync(slices)) { + const claim = readClaim(missionPath, marker); + if (claim?.state !== "pending") continue; + const slice = path.dirname(path.join(missionPath, claim.ref)); + if (fs.existsSync(path.join(slice, "PROOF.md")) && fs.existsSync(path.join(slice, "slice.yaml"))) refs.push(claim.ref); + } + return refs.sort(); +} + +/** Retire pending evidence after observing registration, including a repaired peer. + * A stale manifest rename after this observation is not automatically repaired. */ +export function markSliceRegistrationsVerified(missionPath: string, refs: string[]): void { + const registered = new Set(refs.map((ref) => path.normalize(ref))); + const slices = path.join(missionPath, "slices"); + for (const marker of fs.readdirSync(slices)) { + const claim = readClaim(missionPath, marker); + if (claim?.state !== "pending" || !registered.has(path.normalize(claim.ref))) continue; + const file = path.join(slices, marker); + const temporary = `${file}.${process.pid}-${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporary, JSON.stringify({ ...claim, state: "verified" }) + "\n", { flag: "wx" }); + fs.renameSync(temporary, file); + } finally { + fs.rmSync(temporary, { force: true }); + } + } +} diff --git a/packages/cli/test/scope-slice-claims.test.ts b/packages/cli/test/scope-slice-claims.test.ts new file mode 100644 index 0000000000..f957403f5b --- /dev/null +++ b/packages/cli/test/scope-slice-claims.test.ts @@ -0,0 +1,437 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFile, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; +import { readFrontmatter } from "../src/lib/scope/scope-fs.js"; + +interface Result { + exitCode: number; + stdout: string; + stderr: string; +} + +describe("public slice creation ordinal claims", () => { + let root: string; + let workspace: string; + let mission: string; + let ownedEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-slice-claims-")); + workspace = path.join(root, "internal-docs"); + mission = path.join(workspace, "missions", "release-0.3.2"); + const home = path.join(root, "owned-home"); + fs.mkdirSync(home); + ownedEnv = { + PATH: process.env.PATH, HOME: home, TMPDIR: root, + OPENRIG_HOME: path.join(home, ".openrig"), + CODEX_HOME: path.join(home, "codex"), CLAUDE_CONFIG_DIR: path.join(home, "claude"), + GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull, + }; + execFileSync("git", ["-C", root, "init", "-q"], { env: ownedEnv }); + fs.mkdirSync(path.join(mission, "slices", "01-existing"), { recursive: true }); + fs.writeFileSync(path.join(mission, "README.md"), "---\nid: OPR.0.3.2\n---\n# existing mission\n"); + fs.writeFileSync(path.join(mission, "slices", "01-existing", "README.md"), "---\nid: OPR.0.3.2.1\n---\n# existing slice\n"); + }); + + afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); }); + + async function create(slug: string, extra: string[] = [], preload?: string): Promise { + const args = [ + ...(preload ? ["--import", preload] : []), + fileURLToPath(new URL("../dist/index.js", import.meta.url)), + "scope", "slice", "create", "release-0.3.2", slug, ...extra, + "--workspace", workspace, "--json", + ]; + try { + const result = await promisify(execFile)(process.execPath, args, { + env: ownedEnv, encoding: "utf8", timeout: 15000, + }); + return { exitCode: 0, ...result }; + } catch (error) { + const result = error as Error & { code: unknown; stdout: string; stderr: string }; + if (typeof result.code !== "number") throw error; + return { exitCode: result.code, stdout: result.stdout, stderr: result.stderr }; + } + } + + function successful(results: Result[]) { + expect(results.map((result) => ({ code: result.exitCode, stdout: result.stdout, stderr: result.stderr }))) + .toEqual(results.map((result) => ({ code: 0, stdout: result.stdout, stderr: "" }))); + const slices = results.map((result) => JSON.parse(result.stdout).slice as { + id: string; name: string; readmePath: string; path: string; + }); + expect(new Set(slices.map((slice) => slice.id)).size).toBe(slices.length); + expect(new Set(slices.map((slice) => Number(slice.name.split("-")[0]))).size).toBe(slices.length); + for (const slice of slices) { + expect(readFrontmatter(slice.readmePath).id).toBe(slice.id); + expect(fs.readFileSync(path.join(slice.path, "slice.yaml"), "utf8")).toContain("kind: slice"); + } + return slices; + } + + it("a stale ordinal claim skips only its number and survives later creates", async () => { + const marker = path.join(mission, "slices", ".nn-02"); + fs.writeFileSync(marker, "interrupted creator\n", { flag: "wx" }); + const first = successful([await create("after-interruption")])[0]!; + const second = successful([await create("next")])[0]!; + expect(first.id).toBe("OPR.0.3.2.3"); + expect(second.id).toBe("OPR.0.3.2.4"); + expect(fs.readFileSync(marker, "utf8")).toBe("interrupted creator\n"); + }); + + it("four eight-process README-backed batches create unique slices without refusals", async () => { + const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); + const results: Result[] = []; + for (let batch = 0; batch < 4; batch++) { + results.push(...await Promise.all(Array.from({ length: 8 }, (_, index) => create(`readme-${batch}-${index}`)))); + } + const ids = successful(results).map((slice) => slice.id); + expect(new Set(ids).size).toBe(32); + expect(fs.readdirSync(path.join(mission, "slices"), { withFileTypes: true }).filter((entry) => entry.isDirectory())).toHaveLength(33); + expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); + expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false); + }, 30000); + + it("twenty eight-process manifest-backed batches preserve the admitted concurrency contract", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const authored = { ref, order: 10, active: false }; + const manifest = path.join(mission, "mission.yaml"); + fs.writeFileSync(manifest, "# authored mission comment\n" + YAML.stringify({ + kind: "mission", composition: { slices: [authored] }, + })); + const unrelated = path.join(mission, "slices", "02-older-unlisted"); + fs.mkdirSync(unrelated); + fs.writeFileSync(path.join(unrelated, "slice.yaml"), "kind: slice\n"); + fs.writeFileSync(path.join(unrelated, "PROOF.md"), "# unrelated authored proof\n"); + const results: Result[] = []; + for (let batch = 0; batch < 20; batch++) { + results.push(...await Promise.all(Array.from({ length: 8 }, (_, index) => create(`manifest-${batch}-${index}`)))); + const slices = successful(results); + const refs = [ref, ...slices.map((slice) => `slices/${slice.name}/slice.yaml`)]; + for (const slice of slices) { + for (const file of ["SPEC.md", "PROGRESS.md", "slice.yaml", "PROOF.md"]) { + expect(fs.readFileSync(path.join(slice.path, file), "utf8")).not.toBe(""); + } + expect(fs.statSync(path.join(slice.path, "proof")).isDirectory()).toBe(true); + const ordinal = slice.name.split("-")[0]!; + expect(JSON.parse(fs.readFileSync(path.join(mission, "slices", `.nn-${ordinal}`), "utf8"))).toEqual({ + schema: "openrig.slice-claim/v1", ref: `slices/${slice.name}/slice.yaml`, state: "verified", + }); + } + const bytes = fs.readFileSync(manifest, "utf8"); + const members = (YAML.parse(bytes) as { composition: { slices: Array<{ ref: string; order: number; active: boolean }> } }).composition.slices; + expect(members).toContainEqual(authored); + expect(new Set(members.map((member) => member.ref)).size).toBe(members.length); + expect(new Set(members.map((member) => member.order)).size).toBe(members.length); + expect(members.every((member) => Number.isInteger(member.order) && typeof member.active === "boolean")).toBe(true); + expect(members.map((member) => member.order)).toEqual([...members.map((member) => member.order)].sort((a, b) => a - b)); + expect(members.every((member) => refs.includes(member.ref) && fs.statSync(path.join(mission, member.ref)).isFile())).toBe(true); + expect(members.filter((member) => member.ref !== ref).every((member) => member.active)).toBe(true); + expect(bytes).toContain("# authored mission comment"); + const present = new Set(members.map((member) => member.ref)); + const omitted = slices.filter((slice) => !present.has(`slices/${slice.name}/slice.yaml`)).map((slice) => ({ + ref: `slices/${slice.name}/slice.yaml`, + claim: JSON.parse(fs.readFileSync(path.join(mission, "slices", `.nn-${slice.name.split("-")[0]}`), "utf8")), + })); + if (omitted.length) console.info("Accepted stale-write membership observations:", JSON.stringify({ batch, omitted })); + } + expect(new Set(successful(results).map((slice) => slice.id)).size).toBe(160); + expect(fs.readFileSync(path.join(unrelated, "PROOF.md"), "utf8")).toBe("# unrelated authored proof\n"); + expect(fs.existsSync(path.join(mission, ".openrig-slice-create-lock"))).toBe(false); + }, 180000); + + it("a later create repairs only pending complete registrations and preserves authored membership", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + fs.writeFileSync(manifest, "# preserve authored comment\n" + YAML.stringify({ + kind: "mission", composition: { slices: [{ ref, order: 70, active: false }] }, + })); + for (const name of ["02-complete", "03-incomplete"]) { + fs.mkdirSync(path.join(mission, "slices", name)); + fs.writeFileSync(path.join(mission, "slices", name, "slice.yaml"), "kind: slice\n"); + fs.writeFileSync(path.join(mission, "slices", name, "SPEC.md"), "# scaffold\n"); + } + fs.writeFileSync(path.join(mission, "slices", ".nn-02"), JSON.stringify({ schema: "openrig.slice-claim/v1", ref: "slices/02-complete/slice.yaml", state: "pending" })); + fs.writeFileSync(path.join(mission, "slices", ".nn-03"), JSON.stringify({ schema: "openrig.slice-claim/v1", ref: "slices/03-incomplete/slice.yaml", state: "pending" })); + const proof = path.join(mission, "slices", "02-complete", "PROOF.md"); + fs.writeFileSync(proof, "# completed evidence\n"); + const created = successful([await create("repair")])[0]!; + const bytes = fs.readFileSync(manifest, "utf8"); + const members = YAML.parse(bytes).composition.slices; + expect(members).toContainEqual({ ref, order: 70, active: false }); + expect(members.map((member: { ref: string }) => member.ref).sort()).toEqual([ + ref, "slices/02-complete/slice.yaml", `slices/${created.name}/slice.yaml`, + ].sort()); + expect(bytes).toContain("# preserve authored comment"); + expect(fs.readFileSync(proof, "utf8")).toBe("# completed evidence\n"); + expect(fs.existsSync(path.join(mission, "slices", "03-incomplete", "SPEC.md"))).toBe(true); + }); + + it("verified and repaired registrations stay removed after manual membership removal", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + const authored = { ref, order: 70, active: false }; + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [authored] } })); + const first = successful([await create("registered")])[0]!; + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [authored] } })); + const next = successful([await create("after-removal")])[0]!; + const members = YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices; + expect(members).toEqual([authored, { ref: `slices/${next.name}/slice.yaml`, order: 80, active: true }]); + expect(JSON.parse(fs.readFileSync(path.join(mission, "slices", ".nn-02"), "utf8")).state).toBe("verified"); + expect(fs.existsSync(path.join(first.path, "PROOF.md"))).toBe(true); + }); + + it("older complete unlisted folders without pending evidence remain unlisted", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + const authored = { ref, order: 70, active: false }; + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [authored] } })); + for (const name of ["02-older", "03-legacy-marker", "04-verified"]) { + fs.mkdirSync(path.join(mission, "slices", name)); + fs.writeFileSync(path.join(mission, "slices", name, "slice.yaml"), "kind: slice\n"); + fs.writeFileSync(path.join(mission, "slices", name, "PROOF.md"), "# complete\n"); + } + fs.writeFileSync(path.join(mission, "slices", ".nn-03"), ""); + fs.writeFileSync(path.join(mission, "slices", ".nn-04"), JSON.stringify({ schema: "openrig.slice-claim/v1", ref: "slices/04-verified/slice.yaml", state: "verified" })); + const next = successful([await create("new")])[0]!; + expect(YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices).toEqual([ + authored, { ref: `slices/${next.name}/slice.yaml`, order: 80, active: true }, + ]); + }); + + it("bounded verify failure reports nonzero and retains a complete scaffold for the next create", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [{ ref, order: 10, active: true }] } })); + const injected = path.join(root, "drop-own-member.mjs"); + const writes = path.join(root, "injected-write-count"); + fs.writeFileSync(injected, ` + import fs from "node:fs"; + import { createRequire } from "node:module"; + const require = createRequire(${JSON.stringify(new URL("../../../package.json", import.meta.url).href)}); + const YAML = require("yaml"); + const original = fs.renameSync.bind(fs); + fs.renameSync = (source, target) => { + original(source, target); + if (String(target) !== ${JSON.stringify(manifest)}) return; + const doc = YAML.parse(fs.readFileSync(target, "utf8")); + doc.composition.slices = doc.composition.slices.filter((member) => !member.ref.includes("-lost/slice.yaml")); + fs.writeFileSync(target, YAML.stringify(doc)); + fs.appendFileSync(${JSON.stringify(writes)}, "write\\n"); + }; + `); + const result = await create("lost", [], injected); + expect(result.exitCode).toBe(1); + expect(result.stdout, result.stderr).not.toBe(""); + expect(JSON.parse(result.stdout).error.fact).toContain("after 10 attempts"); + expect(JSON.parse(result.stdout).error.consequence).toContain("scaffold was kept"); + expect(fs.readFileSync(writes, "utf8").trim().split("\n")).toHaveLength(10); + const kept = path.join(mission, "slices", "02-lost"); + for (const file of ["SPEC.md", "slice.yaml", "PROGRESS.md", "PROOF.md"]) expect(fs.existsSync(path.join(kept, file))).toBe(true); + expect(YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices.map((member: { ref: string }) => member.ref)).not.toContain("slices/02-lost/slice.yaml"); + const recovered = successful([await create("recover")])[0]!; + expect(YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices.map((member: { ref: string }) => member.ref).sort()).toEqual([ + ref, "slices/02-lost/slice.yaml", `slices/${recovered.name}/slice.yaml`, + ].sort()); + expect(JSON.parse(fs.readFileSync(path.join(mission, "slices", ".nn-02"), "utf8")).state).toBe("verified"); + const repairedDoc = YAML.parse(fs.readFileSync(manifest, "utf8")); + repairedDoc.composition.slices = repairedDoc.composition.slices.filter((member: { ref: string }) => member.ref !== "slices/02-lost/slice.yaml"); + fs.writeFileSync(manifest, YAML.stringify(repairedDoc)); + successful([await create("after-repaired-removal")]); + expect(YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices.map((member: { ref: string }) => member.ref)).not.toContain("slices/02-lost/slice.yaml"); + }); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)("an actual manifest EACCES preserves the complete slice for later repair", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [{ ref, order: 10, active: true }] } })); + fs.chmodSync(mission, 0o500); + try { + const result = await create("write-denied"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("EACCES"); + const kept = path.join(mission, "slices", "02-write-denied"); + for (const file of ["SPEC.md", "slice.yaml", "PROOF.md"]) expect(fs.existsSync(path.join(kept, file))).toBe(true); + } finally { + fs.chmodSync(mission, 0o700); + } + successful([await create("repair-write")]); + expect(YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices.map((member: { ref: string }) => member.ref)).toContain("slices/02-write-denied/slice.yaml"); + }); + + it("a partial final PROOF write cannot be adopted by a real competing creator", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [{ ref, order: 10, active: true }] } })); + const observed = path.join(root, "competing-manifest"); + const competitorArgs = [fileURLToPath(new URL("../dist/index.js", import.meta.url)), "scope", "slice", "create", "release-0.3.2", "competitor", "--workspace", workspace, "--json"]; + const injected = path.join(root, "partial-proof-write.mjs"); + fs.writeFileSync(injected, ` + import fs from "node:fs"; + import { execFileSync } from "node:child_process"; + const original = fs.writeFileSync.bind(fs); + fs.writeFileSync = (target, data, options) => { + if (/\\/PROOF\\.md(?:\\.openrig-.*\\.tmp)?$/.test(String(target))) { + original(target, "# partial proof", options); + execFileSync(process.execPath, ${JSON.stringify(competitorArgs)}, { env: ${JSON.stringify(ownedEnv)}, timeout: 15000 }); + original(${JSON.stringify(observed)}, fs.readFileSync(${JSON.stringify(manifest)}, "utf8")); + throw Object.assign(new Error("injected partial PROOF write"), { code: "EIO" }); + } + return original(target, data, options); + }; + `); + const before = fs.readFileSync(path.join(mission, "README.md"), "utf8"); + const failed = await create("partial", [], injected); + expect(failed.exitCode).toBe(1); + expect(failed.stdout + failed.stderr).toContain("injected partial PROOF write"); + expect(fs.existsSync(path.join(mission, "slices", "02-partial"))).toBe(false); + expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(before); + expect(fs.existsSync(path.join(mission, "slices", ".nn-02"))).toBe(true); + expect(YAML.parse(fs.readFileSync(observed, "utf8")).composition.slices.map((member: { ref: string }) => member.ref)).not.toContain("slices/02-partial/slice.yaml"); + expect(successful([await create("after-partial")])[0]!.id).toBe("OPR.0.3.2.4"); + }); + + it.each([false, true])("an incomplete creator preserves concurrent parent edits (parent initially has ID: %s)", async (hasId) => { + const parent = path.join(mission, "README.md"); + if (!hasId) fs.writeFileSync(parent, "---\nintent: existing mission\n---\n# existing mission\n"); + const expectedParent = path.join(root, "concurrent-parent-bytes"); + const competitorOutput = path.join(root, "competitor-output.json"); + const competitorArgs = [fileURLToPath(new URL("../dist/index.js", import.meta.url)), "scope", "slice", "create", "release-0.3.2", "successful", "--workspace", workspace, "--json"]; + const injected = path.join(root, "fail-scaffold-after-competitor.mjs"); + fs.writeFileSync(injected, ` + import fs from "node:fs"; + import { execFileSync } from "node:child_process"; + const original = fs.writeFileSync.bind(fs); + fs.writeFileSync = (target, data, options) => { + if (String(target) === ${JSON.stringify(path.join(mission, "slices", "02-failed", "SPEC.md"))}) { + const output = execFileSync(process.execPath, ${JSON.stringify(competitorArgs)}, { env: ${JSON.stringify(ownedEnv)}, encoding: "utf8", timeout: 15000 }); + original(${JSON.stringify(competitorOutput)}, output); + const current = fs.readFileSync(${JSON.stringify(parent)}, "utf8") + "\\nA concurrent authored edit.\\n"; + original(${JSON.stringify(parent)}, current); + original(${JSON.stringify(expectedParent)}, current); + throw Object.assign(new Error("injected incomplete scaffold failure"), { code: "EIO" }); + } + return original(target, data, options); + }; + `); + + const failed = await create("failed", [], injected); + + expect(failed.exitCode).toBe(1); + expect(failed.stdout + failed.stderr).toContain("injected incomplete scaffold failure"); + expect(fs.readFileSync(parent, "utf8")).toBe(fs.readFileSync(expectedParent, "utf8")); + expect(readFrontmatter(parent).id).toBe("OPR.0.3.2"); + const competitor = JSON.parse(fs.readFileSync(competitorOutput, "utf8")).slice; + expect(competitor.id).toBe("OPR.0.3.2.3"); + expect(readFrontmatter(competitor.readmePath).id).toBe(competitor.id); + expect(fs.existsSync(path.join(competitor.path, "PROOF.md"))).toBe(true); + expect(fs.existsSync(path.join(mission, "slices", "02-failed"))).toBe(false); + expect(fs.existsSync(path.join(mission, "slices", ".nn-02"))).toBe(true); + expect(successful([await create("after-failure")])[0]!.id).toBe("OPR.0.3.2.4"); + }); + + it.each([false, true])("an isolated incomplete failure retains parent persistence (parent initially has ID: %s)", async (hasId) => { + const parent = path.join(mission, "README.md"); + if (!hasId) fs.writeFileSync(parent, "---\nintent: authored mission\n---\n# existing mission\n"); + const originalParent = fs.readFileSync(parent, "utf8"); + const writtenParent = path.join(root, "persisted-parent-bytes"); + const parentWrites = path.join(root, "parent-write-count"); + const injected = path.join(root, "isolated-incomplete-failure.mjs"); + fs.writeFileSync(injected, ` + import fs from "node:fs"; + const original = fs.writeFileSync.bind(fs); + fs.writeFileSync = (target, data, options) => { + if (String(target) === ${JSON.stringify(parent)}) fs.appendFileSync(${JSON.stringify(parentWrites)}, "write\\n"); + if (String(target) === ${JSON.stringify(path.join(mission, "slices", "02-failed", "SPEC.md"))}) { + original(${JSON.stringify(writtenParent)}, fs.readFileSync(${JSON.stringify(parent)}, "utf8")); + throw Object.assign(new Error("isolated incomplete scaffold failure"), { code: "EIO" }); + } + return original(target, data, options); + }; + `); + const failed = await create("failed", [], injected); + expect(failed.exitCode).toBe(1); + expect(failed.stdout + failed.stderr).toContain("isolated incomplete scaffold failure"); + const bytes = fs.readFileSync(parent, "utf8"); + expect(bytes).toBe(fs.readFileSync(writtenParent, "utf8")); + const writes = fs.existsSync(parentWrites) ? fs.readFileSync(parentWrites, "utf8").trim().split("\n").length : 0; + expect(writes).toBe(hasId ? 0 : 1); + if (hasId) expect(bytes).toBe(originalParent); + else { + expect(bytes).not.toBe(originalParent); + expect(readFrontmatter(parent).id).toEqual(expect.any(String)); + expect(readFrontmatter(parent).id).not.toBe(""); + expect(readFrontmatter(parent).intent).toBe("authored mission"); + expect(bytes).toContain("# existing mission"); + } + expect(fs.existsSync(path.join(mission, "slices", "02-failed"))).toBe(false); + expect(JSON.parse(fs.readFileSync(path.join(mission, "slices", ".nn-02"), "utf8"))).toEqual({ + schema: "openrig.slice-claim/v1", ref: "slices/02-failed/slice.yaml", state: "pending", + }); + }); + + it("an old mission guard is preserved but does not block a new creator", async () => { + const guard = path.join(mission, ".openrig-slice-create-lock"); + fs.mkdirSync(guard); + fs.writeFileSync(path.join(guard, "inspection-required"), "interrupted guard\n"); + successful([await create("ignores-old-lock")]); + expect(fs.readFileSync(path.join(guard, "inspection-required"), "utf8")).toBe("interrupted guard\n"); + }); + + it("serial creates retain ordinary numbering and scaffold output", async () => { + const ref = "slices/01-existing/slice.yaml"; + fs.writeFileSync(path.join(mission, ref), "kind: slice\n"); + const manifest = path.join(mission, "mission.yaml"); + const authored = { ref, order: 10, active: false }; + fs.writeFileSync(manifest, YAML.stringify({ kind: "mission", composition: { slices: [authored] } })); + const ids: string[] = []; + const refs = [ref]; + for (let index = 0; index < 4; index++) { + const slice = successful([await create(`serial-${index}`)])[0]!; + ids.push(slice.id); + refs.push(`slices/${slice.name}/slice.yaml`); + const members = YAML.parse(fs.readFileSync(manifest, "utf8")).composition.slices; + expect(members.map((member: { ref: string }) => member.ref)).toEqual(refs); + expect(members[0]).toEqual(authored); + } + expect(ids).toEqual(["OPR.0.3.2.2", "OPR.0.3.2.3", "OPR.0.3.2.4", "OPR.0.3.2.5"]); + }); + + it("validation failures do not claim a number or change authored bytes", async () => { + const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); + for (const extra of [["--template", "invalid"], ["--depends-on", "other.1"], ["--depends-on", "OPR.0.3.2.2"]]) { + expect((await create("invalid", extra)).exitCode).toBe(1); + expect(fs.readdirSync(path.join(mission, "slices"))).toEqual(["01-existing"]); + expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); + } + expect(successful([await create("valid")])[0]!.id).toBe("OPR.0.3.2.2"); + }); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)("an actual EACCES claim failure leaves no scaffold or consumed number", async () => { + const slices = path.join(mission, "slices"); + const original = fs.readFileSync(path.join(mission, "README.md"), "utf8"); + fs.chmodSync(slices, 0o500); + try { + const result = await create("denied"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("EACCES"); + expect(fs.readdirSync(slices)).toEqual(["01-existing"]); + expect(fs.readFileSync(path.join(mission, "README.md"), "utf8")).toBe(original); + } finally { + fs.chmodSync(slices, 0o700); + } + expect(successful([await create("retry")])[0]!.id).toBe("OPR.0.3.2.2"); + }); +});