merge: slice 12 — rig scope CLI primitive (release-0.3.2)

Tree-shaped `rig scope <tier> <verb>` (mission + slice tiers; sub-slice
ad-hoc); mission-create first-class; mints stable dot-IDs per the
scope-and-versioning convention; git-mv history-preserving
ship/move/close; lazy parent-ID adoption on create/ship/move.

Triple-gate: driver + 2 guard cycles (BC-1, BC-2) -> guard ACCEPT @
a7e56382 -> velocity-qa CLEAR @ a7e56382 (sustained git-backed dogfood;
evidence under missions/release-0.3.2/dogfood-evidence/). Base 86da3ce8;
clean --no-ff; 2 inherited host preflight/ABI failures unrelated. Local
main only; origin push deferred to the founder-gated release lifecycle.
This commit is contained in:
mvschwarz
2026-05-16 00:54:18 -07:00
18 changed files with 2836 additions and 1 deletions
+1 -1
View File
@@ -35,7 +35,7 @@
"scripts"
],
"scripts": {
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/",
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/ && mkdir -p dist/lib/scope-templates && cp src/lib/scope-templates/*.md dist/lib/scope-templates/",
"postinstall": "node scripts/check-abi.mjs",
"prepublishOnly": "bash ../../scripts/build-package.sh",
"test": "vitest run",
+681
View File
@@ -0,0 +1,681 @@
// release-0.3.2 slice 12 — `rig scope` CLI primitive.
//
// Command grammar: rig scope <tier> <verb>. v0 ships `mission` +
// `slice` tiers; `project` and `sub-slice` are reserved per the
// substrate convention `conventions/scope-and-versioning/README.md`
// (stage: provisional). The CLI mints stable dot-IDs into created
// mission/slice frontmatter per §1 of that convention.
import fs from "node:fs";
import path from "node:path";
import { Command } from "commander";
import {
CLOSE_REASONS,
MISSION_TEMPLATE_KINDS,
SLICE_TEMPLATE_KINDS,
ScopeCliError,
type CloseReason,
type MissionTemplateKind,
type SliceTemplateKind,
type SliceState,
} from "../lib/scope/types.js";
import {
DEFAULT_PROJECT_PREFIX,
inferMissionDotId,
isMissionDotId,
nextEscapeBandOrdinal,
sliceIdFromMission,
} from "../lib/scope/dot-id.js";
import {
ensureMissionIdPersisted,
findMission,
findSlice,
listMissions,
listSlices,
moveSlice,
nextSliceNN,
pad2,
resolveMissionsRoot,
splitFrontmatter,
todayDateISO,
updateFrontmatter,
} from "../lib/scope/scope-fs.js";
import {
renderMissionTemplate,
renderSliceTemplate,
titleFromSlug,
} from "../lib/scope/templates.js";
// ---------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------
interface Stdout {
write: (text: string) => void;
}
function makeStdout(): Stdout {
return { write: (text: string) => process.stdout.write(text) };
}
function emit(out: Stdout, payload: unknown, json: boolean, lines?: string[]): void {
if (json) {
out.write(JSON.stringify(payload, null, 2) + "\n");
return;
}
if (lines) {
for (const line of lines) out.write(line + "\n");
return;
}
out.write(JSON.stringify(payload, null, 2) + "\n");
}
function fail(err: unknown, json: boolean, out: Stdout): never {
if (err instanceof ScopeCliError) {
if (json) {
out.write(JSON.stringify({
ok: false,
error: { fact: err.fact, consequence: err.consequence, action: err.action },
}, null, 2) + "\n");
} else {
process.stderr.write(`Error: ${err.fact}\n${err.consequence}\n${err.action}\n`);
}
} else {
const message = err instanceof Error ? err.message : String(err);
process.stderr.write(`Error: ${message}\n`);
}
process.exit(1);
}
function slugify(raw: string): string {
return raw
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
interface RootOpts {
workspace?: string;
}
function getOpts(cmd: Command): RootOpts {
// commander v13 attaches opts on the parent.
let walker: Command | null = cmd;
while (walker) {
const o = walker.opts() as RootOpts;
if (o.workspace) return o;
walker = walker.parent;
}
return {};
}
// ---------------------------------------------------------------------
// rig scope slice ls
// ---------------------------------------------------------------------
function buildSliceLsCommand(): Command {
const cmd = new Command("ls")
.description("List slices in a mission (or across all missions)")
.option("--mission <name>", "Restrict to a single mission")
.option("--state <state>", "Filter: active | closed | shipped | all", "active")
.option("--json", "Machine-readable output")
.action(async (opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
const state = (opts.state as SliceState) ?? "active";
if (!["active", "closed", "shipped", "all"].includes(state)) {
fail(new ScopeCliError({
fact: `Unknown --state value "${state}".`,
consequence: "Command did not run.",
action: "Pick one of: active, closed, shipped, all.",
}), json, out);
}
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const missions = opts.mission
? [findMission(missionsRoot, opts.mission)]
: listMissions(missionsRoot);
const rows: unknown[] = [];
const lines: string[] = [];
for (const mission of missions) {
const slices = listSlices(mission, state);
for (const slice of slices) {
rows.push({
mission: mission.name,
name: slice.name,
nn: slice.nn,
slug: slice.slug,
id: slice.id,
status: slice.status,
path: slice.absPath,
});
lines.push(`${mission.name}/${slice.name} ${slice.id ?? "—"} ${slice.status ?? "—"}`);
}
}
emit(out, { ok: true, count: rows.length, slices: rows }, json, lines.length === 0 ? ["(no slices)"] : lines);
} catch (err) {
fail(err, json, out);
}
});
return cmd;
}
// ---------------------------------------------------------------------
// rig scope slice show
// ---------------------------------------------------------------------
function buildSliceShowCommand(): Command {
return new Command("show")
.description("Inspect a single slice (frontmatter + README + children)")
.argument("<slice-path>", "Slice path (absolute, relative-to-substrate, or NN-slug)")
.option("--mission <name>", "Hint mission when path is just NN-slug")
.option("--json", "Machine-readable output")
.action(async (slicePath: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
const readme = slice.readmePath ? fs.readFileSync(slice.readmePath, "utf8") : null;
const children = fs.readdirSync(slice.absPath, { withFileTypes: true })
.map((e) => ({ name: e.name, kind: e.isDirectory() ? "dir" : "file" as const }));
const payload = {
ok: true,
slice: {
mission: slice.missionName,
name: slice.name,
id: slice.id,
status: slice.status,
path: slice.absPath,
frontmatter: slice.frontmatter,
readme,
children,
},
};
if (json) {
out.write(JSON.stringify(payload, null, 2) + "\n");
} else {
out.write(`Slice: ${slice.missionName}/${slice.name}\n`);
out.write(` id: ${slice.id ?? "—"}\n`);
out.write(` status: ${slice.status ?? "—"}\n`);
out.write(` path: ${slice.absPath}\n`);
out.write(` children: ${children.length}\n`);
if (readme) {
out.write("\n--- README ---\n");
out.write(readme);
if (!readme.endsWith("\n")) out.write("\n");
}
}
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope slice create
// ---------------------------------------------------------------------
function buildSliceCreateCommand(): Command {
return new Command("create")
.description("Create a new slice in a mission")
.argument("<mission>", "Mission name")
.argument("<slug>", "Short slug (becomes the folder name's suffix)")
.option("--template <kind>", `Template: ${SLICE_TEMPLATE_KINDS.join(" | ")}`, "placeholder")
.option("--title <text>", "Display title (defaults to titlecased slug)")
.option("--json", "Machine-readable output")
.action(async (missionName: string, rawSlug: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const kind = opts.template as SliceTemplateKind;
if (!SLICE_TEMPLATE_KINDS.includes(kind)) {
throw new ScopeCliError({
fact: `Unknown --template kind "${kind}".`,
consequence: "Slice not created.",
action: `Pick one of: ${SLICE_TEMPLATE_KINDS.join(", ")}.`,
});
}
const slug = slugify(rawSlug);
if (!slug) {
throw new ScopeCliError({
fact: `Slug "${rawSlug}" reduces to empty after slugification.`,
consequence: "Slice not created.",
action: "Pick a slug containing letters or digits.",
});
}
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const mission = findMission(missionsRoot, missionName);
// Persist the parent mission's id back into its README at the
// SAME moment we mint the child's id (per convention §1
// lazy-adoption rule + guard BC BLOCK 3 — every child-mint site
// must persist the parent's id, not just slice create).
const missionId = ensureMissionIdPersisted(mission, missionsRoot);
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.",
});
}
const id = sliceIdFromMission(missionId, nn);
const title = opts.title ?? titleFromSlug(slug);
const body = renderSliceTemplate(kind, {
id,
slice_number: pad2(nn),
slug,
mission: mission.name,
title,
created_date: todayDateISO(),
});
fs.mkdirSync(sliceAbs, { recursive: true });
const readmePath = path.join(sliceAbs, "README.md");
fs.writeFileSync(readmePath, body, "utf8");
const payload = {
ok: true,
slice: {
mission: mission.name,
name: sliceFolder,
id,
path: sliceAbs,
readmePath,
template: kind,
},
};
emit(out, payload, json, [
`Created ${mission.name}/slices/${sliceFolder}`,
` id: ${id}`,
` template: ${kind}`,
` path: ${sliceAbs}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope slice ship
// ---------------------------------------------------------------------
function buildSliceShipCommand(): Command {
return new Command("ship")
.description("Ship a slice to a release mission (preserves git history)")
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
.argument("<release-mission>", "Target release mission name")
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
.option("--json", "Machine-readable output")
.action(async (slicePath: string, releaseMission: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
const target = findMission(missionsRoot, releaseMission);
const targetSlicesDir = path.join(target.absPath, "slices");
fs.mkdirSync(targetSlicesDir, { recursive: true });
const newNN = nextSliceNN(target.absPath);
const slug = slice.slug ?? slugify(slice.name);
const newName = `${pad2(newNN)}-${slug}`;
const destAbs = path.join(targetSlicesDir, newName);
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
const targetId = ensureMissionIdPersisted(target, missionsRoot);
const newSliceId = sliceIdFromMission(targetId, newNN);
const newReadme = path.join(destAbs, "README.md");
if (fs.existsSync(newReadme)) {
updateFrontmatter(newReadme, {
id: newSliceId,
mission: target.name,
status: `shipped-to-${target.name}`,
"shipped-on": todayDateISO(),
"shipped-from": slice.missionName,
});
}
emit(out, {
ok: true,
shipped: {
from: { mission: slice.missionName, name: slice.name, id: slice.id },
to: { mission: target.name, name: newName, id: newSliceId, path: destAbs },
git: { usedGit, repoRoot },
},
}, json, [
`Shipped ${slice.missionName}/${slice.name} → ${target.name}/slices/${newName}`,
` id: ${newSliceId}`,
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope slice close
// ---------------------------------------------------------------------
function buildSliceCloseCommand(): Command {
return new Command("close")
.description("Close a slice (move to <mission>/closed/, update status)")
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
.requiredOption("--reason <reason>", `Closure reason: ${CLOSE_REASONS.join(" | ")}`)
.option("--note <text>", "Optional closure note")
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
.option("--json", "Machine-readable output")
.action(async (slicePath: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const reason = opts.reason as CloseReason;
if (!CLOSE_REASONS.includes(reason)) {
throw new ScopeCliError({
fact: `Unknown --reason "${reason}".`,
consequence: "Slice not closed.",
action: `Pick one of: ${CLOSE_REASONS.join(", ")}.`,
});
}
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
const mission = findMission(missionsRoot, slice.missionName);
const closedDir = path.join(mission.absPath, "closed");
fs.mkdirSync(closedDir, { recursive: true });
const destName = slice.name;
const destAbs = path.join(closedDir, destName);
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
const newReadme = path.join(destAbs, "README.md");
if (fs.existsSync(newReadme)) {
const updates: Record<string, unknown> = {
status: `closed-${reason}`,
"closed-on": todayDateISO(),
};
if (opts.note) updates["closure-note"] = opts.note;
updateFrontmatter(newReadme, updates);
}
emit(out, {
ok: true,
closed: {
mission: slice.missionName,
name: destName,
id: slice.id,
reason,
note: opts.note ?? null,
path: destAbs,
git: { usedGit, repoRoot },
},
}, json, [
`Closed ${slice.missionName}/${slice.name} → ${slice.missionName}/closed/${destName}`,
` reason: ${reason}`,
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope slice move
// ---------------------------------------------------------------------
function buildSliceMoveCommand(): Command {
return new Command("move")
.description("Move a slice between missions (re-numbers in destination)")
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
.argument("<dest-mission>", "Destination mission name")
.option("--mission <name>", "Hint source mission when slice-path is just NN-slug")
.option("--json", "Machine-readable output")
.action(async (slicePath: string, destMission: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
const target = findMission(missionsRoot, destMission);
const targetSlicesDir = path.join(target.absPath, "slices");
fs.mkdirSync(targetSlicesDir, { recursive: true });
const newNN = nextSliceNN(target.absPath);
const slug = slice.slug ?? slugify(slice.name);
const newName = `${pad2(newNN)}-${slug}`;
const destAbs = path.join(targetSlicesDir, newName);
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
const targetId = ensureMissionIdPersisted(target, missionsRoot);
const newSliceId = sliceIdFromMission(targetId, newNN);
const newReadme = path.join(destAbs, "README.md");
if (fs.existsSync(newReadme)) {
updateFrontmatter(newReadme, {
id: newSliceId,
mission: target.name,
"moved-on": todayDateISO(),
"moved-from": slice.missionName,
});
}
emit(out, {
ok: true,
moved: {
from: { mission: slice.missionName, name: slice.name, id: slice.id },
to: { mission: target.name, name: newName, id: newSliceId, path: destAbs },
git: { usedGit, repoRoot },
},
}, json, [
`Moved ${slice.missionName}/${slice.name} → ${target.name}/slices/${newName}`,
` id: ${newSliceId}`,
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope mission ls / show / create
// ---------------------------------------------------------------------
function buildMissionLsCommand(): Command {
return new Command("ls")
.description("List missions (top-level folders with README.md)")
.option("--json", "Machine-readable output")
.action(async (opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const missions = listMissions(missionsRoot);
const rows = missions.map((m) => ({
name: m.name,
id: m.id,
path: m.absPath,
activeSliceCount: m.activeSliceCount,
closedSliceCount: m.closedSliceCount,
}));
emit(out, { ok: true, count: rows.length, missions: rows }, json,
rows.length === 0
? ["(no missions)"]
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} closed=${r.closedSliceCount}`),
);
} catch (err) {
fail(err, json, out);
}
});
}
function buildMissionShowCommand(): Command {
return new Command("show")
.description("Inspect a single mission")
.argument("<mission>", "Mission name")
.option("--json", "Machine-readable output")
.action(async (missionName: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const mission = findMission(missionsRoot, missionName);
const readme = mission.readmePath ? fs.readFileSync(mission.readmePath, "utf8") : null;
const slices = listSlices(mission, "all").map((s) => ({
name: s.name, id: s.id, status: s.status, nn: s.nn,
}));
const payload = {
ok: true,
mission: {
name: mission.name,
id: mission.id,
path: mission.absPath,
activeSliceCount: mission.activeSliceCount,
closedSliceCount: mission.closedSliceCount,
frontmatter: mission.frontmatter,
readme,
slices,
},
};
if (json) {
out.write(JSON.stringify(payload, null, 2) + "\n");
} else {
out.write(`Mission: ${mission.name}\n`);
out.write(` id: ${mission.id ?? "—"}\n`);
out.write(` active slices: ${mission.activeSliceCount}\n`);
out.write(` closed slices: ${mission.closedSliceCount}\n`);
out.write(` path: ${mission.absPath}\n`);
if (readme) {
out.write("\n--- README ---\n");
out.write(readme);
if (!readme.endsWith("\n")) out.write("\n");
}
}
} catch (err) {
fail(err, json, out);
}
});
}
function buildMissionCreateCommand(): Command {
return new Command("create")
.description("Create a new mission (mints a stable dot-ID into frontmatter)")
.argument("<name>", "Mission folder name (e.g., release-0.4.0, backlog-foo)")
.option("--template <kind>", `Template: ${MISSION_TEMPLATE_KINDS.join(" | ")} (auto when name matches release-X.Y.Z)`, "")
.option("--id <dot-id>", "Explicit dot-ID. Overrides name-pattern inference.")
.option("--title <text>", "Display title (defaults to titlecased name)")
.option("--json", "Machine-readable output")
.action(async (rawName: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const name = rawName.trim();
if (!name || /[\\/\s]/.test(name)) {
throw new ScopeCliError({
fact: `Invalid mission name "${rawName}".`,
consequence: "Mission not created.",
action: "Pick a name with no whitespace or path separators.",
});
}
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const absPath = path.join(missionsRoot, name);
if (fs.existsSync(absPath)) {
throw new ScopeCliError({
fact: `Mission folder ${absPath} already exists.`,
consequence: "Refusing to overwrite.",
action: "Pick a different name, or use `rig scope mission show <name>` to inspect the existing mission.",
});
}
// Resolve template kind: explicit > release-pattern auto > placeholder.
const isReleaseName = /^release-\d+\.\d+(?:\.\d+)?$/.test(name);
let templateKind: MissionTemplateKind = opts.template as MissionTemplateKind;
if (!templateKind) templateKind = isReleaseName ? "release" : "placeholder";
if (!MISSION_TEMPLATE_KINDS.includes(templateKind)) {
throw new ScopeCliError({
fact: `Unknown --template kind "${templateKind}".`,
consequence: "Mission not created.",
action: `Pick one of: ${MISSION_TEMPLATE_KINDS.join(", ")}.`,
});
}
// Mint the dot-ID.
let id: string;
if (opts.id) {
// Tier-aware validation per guard BC verdict (BLOCK 1).
// A mission ID has 2-3 numeric segments after the prefix
// (release X.Y or X.Y.Z; escape-band 99.x.y). Reject
// slice-shaped IDs (4 segments) so the parent identity stays
// unambiguous.
if (!isMissionDotId(opts.id)) {
throw new ScopeCliError({
fact: `Supplied --id "${opts.id}" is not a mission-tier dot-ID.`,
consequence: "Mission not created. A mission ID has the shape <PFX>.<ver> (2-3 numeric segments), not a slice shape <PFX>.<ver>.<n>.",
action: "Use a mission-shaped dot-ID like OPR.0.3.2 (release) or OPR.99.0.1 (escape band). For slice IDs, scope automatically mints them when you create a slice.",
});
}
id = opts.id;
} else if (isReleaseName) {
id = inferMissionDotId(name, null);
} else {
const peers = listMissions(missionsRoot);
const ordinal = nextEscapeBandOrdinal(peers.map((p) => p.id));
id = inferMissionDotId(name, ordinal);
}
// Scaffold the mission directory + README.
fs.mkdirSync(absPath, { recursive: true });
fs.mkdirSync(path.join(absPath, "slices"), { recursive: true });
const title = opts.title ?? titleFromSlug(name.replace(/^release-/, ""));
const releaseVersion = isReleaseName ? name.replace(/^release-/, "") : "";
const body = renderMissionTemplate(templateKind, {
id,
slug: name,
mission: name,
title,
created_date: todayDateISO(),
release_version: releaseVersion,
});
const readmePath = path.join(absPath, "README.md");
fs.writeFileSync(readmePath, body, "utf8");
emit(out, {
ok: true,
mission: {
name,
id,
template: templateKind,
path: absPath,
readmePath,
},
}, json, [
`Created mission ${name}`,
` id: ${id}`,
` template: ${templateKind}`,
` path: ${absPath}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// Aggregate
// ---------------------------------------------------------------------
export function scopeCommand(): Command {
const cmd = new Command("scope")
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
.option("--workspace <path>", "Override workspace root (otherwise inferred from cwd or $OPENRIG_WORK_ROOT)");
const slice = new Command("slice").description("Slice-tier commands");
slice.addCommand(buildSliceLsCommand());
slice.addCommand(buildSliceShowCommand());
slice.addCommand(buildSliceCreateCommand());
slice.addCommand(buildSliceShipCommand());
slice.addCommand(buildSliceCloseCommand());
slice.addCommand(buildSliceMoveCommand());
cmd.addCommand(slice);
const mission = new Command("mission").description("Mission-tier commands");
mission.addCommand(buildMissionLsCommand());
mission.addCommand(buildMissionShowCommand());
mission.addCommand(buildMissionCreateCommand());
cmd.addCommand(mission);
return cmd;
}
// Re-exports for tests.
export { DEFAULT_PROJECT_PREFIX, splitFrontmatter };
+3
View File
@@ -17,6 +17,7 @@ import { attachCommand } from "./commands/attach.js";
import { bindCommand } from "./commands/bind.js";
import { adoptCommand, type AdoptDeps } from "./commands/adopt.js";
import { bundleCommand } from "./commands/bundle.js";
import { scopeCommand } from "./commands/scope.js";
import { upCommand } from "./commands/up.js";
import { downCommand } from "./commands/down.js";
import { psCommand } from "./commands/ps.js";
@@ -182,6 +183,8 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(compactPlanCommand(depsOverride?.compactPlanDeps));
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
program.addCommand(seatCommand(depsOverride?.seatDeps));
// release-0.3.2 slice 12 — rig scope CLI primitive.
program.addCommand(scopeCommand());
return program;
}
@@ -0,0 +1,26 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Target
[What is being deprecated]
## Current state
[How it works today; what depends on it]
## Migration
[Path off the deprecated thing]
## Removal
[When/how the deprecated thing gets deleted]
@@ -0,0 +1,22 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Current state
[How the code/system looks today]
## Cost
[What this debt costs us — surface friction, bug class, perf, etc]
## Proposal
[How to pay it down]
@@ -0,0 +1,30 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Repro
[Steps to reproduce]
## Expected
[Expected behavior]
## Actual
[Actual behavior]
## Impact
[Who is affected; severity]
## Fix proposal
[Optional: thinking on the fix]
@@ -0,0 +1,20 @@
---
id: {{id}}
mission: {{mission}}
stage: wip
created: {{created_date}}
---
# Mission — {{title}}
## Purpose
[Why this mission exists and what success looks like]
## Slices
[List notable slices and their state]
## Status
[Where the mission is right now]
@@ -0,0 +1,25 @@
---
id: {{id}}
mission: {{mission}}
release: {{release_version}}
stage: wip
created: {{created_date}}
---
# Release {{release_version}} — {{title}}
## Theme
[What this release is about — the coherent bet]
## Scope
[What ships in v{{release_version}} vs what waits]
## Slices
[Ordering + dependencies; PROGRESS.md tracks state]
## Acceptance
[Release-level done definition]
@@ -0,0 +1,30 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Goal
[1-2 sentence goal.]
## Source material
- [Paths or refs]
## Status
- TODO: [next steps]
## Dependencies
- [Cross-slice / cross-release]
## Acceptance
- [3-5 bullets]
@@ -0,0 +1,26 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Goal
[What this feature ships and why now]
## Scope
[In / out for v0]
## Acceptance
- [3-5 bullets]
## Risks
[Known unknowns]
@@ -0,0 +1,22 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Question
[The thing we are trying to learn]
## Source
[Where the answer is most likely to come from]
## Findings
[Captured as work progresses]
+173
View File
@@ -0,0 +1,173 @@
// release-0.3.2 slice 12 — dot-ID grammar per
// `openrig-work/conventions/scope-and-versioning/README.md` §1.
//
// Grammar (v0 single-project):
// project = <PFX> (2-3 letter; v0 hardcodes "OPR")
// mission = <PFX>.<ver>
// slice = <PFX>.<ver>.<n>
// sub = <PFX>.<ver>.<n>.<m>
//
// Version inference from mission folder name:
// release-X.Y[.Z] → "X.Y[.Z]" (release train)
// anything else → "99.0.<m>" escape band (uniform-numeric;
// NO alpha — convention §1 explicit)
//
// The escape-band <m> is assigned by scanning peer non-release missions
// for an existing id and picking max+1. Releases keep their semver.
import type { DotId } from "./types.js";
import { ScopeCliError } from "./types.js";
/** Default project prefix for the OpenRig single-project workspace.
* Multi-project work waits for a real second project (convention §1). */
export const DEFAULT_PROJECT_PREFIX = "OPR";
const RELEASE_NAME_RE = /^release-(\d+\.\d+(?:\.\d+)?)$/;
// Strict positional grammar: every segment is a number except for the
// 2-3 letter project prefix at position 0. Accepts 2-5 numeric
// segments after the prefix (mission has 2-3, slice has 3-4, sub-slice
// has 4-5). Tier-aware parse helpers below split version/n/m correctly.
const DOT_ID_RE = /^([A-Z]{2,3})\.(\d+(?:\.\d+){1,4})$/;
/** Parse a dot-ID string into structured parts; returns null when the
* string doesn't conform to the §1 positional grammar. When `tier`
* is supplied, the trailing segments are peeled off into `n` (slice)
* or `n` + `m` (sub-slice). With no tier, all numeric segments are
* returned as `version` for backwards compatibility. */
export function parseDotId(
raw: string,
tier?: "mission" | "slice" | "sub-slice",
): DotId | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
const m = DOT_ID_RE.exec(trimmed);
if (!m) return null;
const project = m[1]!;
const segments = m[2]!.split(".");
if (!tier) {
return { project, version: segments.join("."), n: undefined, m: undefined };
}
if (tier === "mission") {
return { project, version: segments.join("."), n: undefined, m: undefined };
}
if (tier === "slice") {
if (segments.length < 3) return null; // slice = ver(>=2) + n
const verSegs = segments.slice(0, -1);
const nSeg = segments[segments.length - 1]!;
return { project, version: verSegs.join("."), n: Number(nSeg), m: undefined };
}
// sub-slice
if (segments.length < 4) return null;
const verSegs = segments.slice(0, -2);
const nSeg = segments[segments.length - 2]!;
const mSeg = segments[segments.length - 1]!;
return { project, version: verSegs.join("."), n: Number(nSeg), m: Number(mSeg) };
}
/** Validate a mission-ver segments array against the §1 escape-band
* rule. The convention defines the non-release escape band as
* `<PFX>.99.0.<n>` — the `0` after `99` is FIXED. A version like
* `99.7.8` does NOT conform. Shared between isMissionDotId and the
* parent-version check inside isSliceDotId so both surfaces stay in
* lockstep. */
function isValidMissionVerSegments(segs: string[]): boolean {
if (segs.length < 2 || segs.length > 3) return false;
if (segs[0] === "99") {
// Escape band: exactly [99, "0", "<ordinal>"]. The `0` segment is
// fixed; `<ordinal>` must be a non-empty numeric string.
return segs.length === 3 && segs[1] === "0" && /^\d+$/.test(segs[2] ?? "");
}
return true;
}
/** Depth-based tier discriminator. Per §1 the positional grammar
* is fixed:
* mission = <PFX>.<ver> (ver = 2-3 numeric segments;
* escape band is exactly 99.0.n)
* slice = <PFX>.<ver>.<n> (3-4 numeric segments)
* sub-slice = <PFX>.<ver>.<n>.<m> (4-5 numeric segments)
* Where a mission shape OVERLAPS with a slice shape (e.g. release
* X.Y mission has 2 segments; release X.Y.Z mission has 3 segments
* which is also the slice shape for release X.Y missions) the tier
* is resolved by depth: mission caps at 3 numeric segments; slice
* always has 3-4; sub-slice has 4-5. Escape band is fully unambiguous
* because §1 fixes both the 99 marker AND the 0 segment that follows. */
export function isMissionDotId(raw: unknown): boolean {
if (typeof raw !== "string") return false;
const parsed = parseDotId(raw, "mission");
if (!parsed) return false;
return isValidMissionVerSegments(parsed.version.split("."));
}
export function isSliceDotId(raw: unknown): boolean {
if (typeof raw !== "string") return false;
const parsed = parseDotId(raw, "slice");
if (!parsed) return false;
// Slice = mission-ver + 1 ordinal. Parent ver must itself be a
// valid mission shape (including the escape-band exact-[99.0.n]
// rule — a parent like 99.7.8 is not a real mission).
return isValidMissionVerSegments(parsed.version.split("."));
}
/** Render a DotId back to its canonical dot-string. */
export function formatDotId(id: DotId): string {
const parts = [id.project, id.version];
if (id.n !== undefined) parts.push(String(id.n));
if (id.m !== undefined) parts.push(String(id.m));
return parts.join(".");
}
/** Compose a slice ID by appending an ordinal to a parent mission ID. */
export function sliceIdFromMission(missionId: string, n: number): string {
return `${missionId}.${n}`;
}
/** Infer the (project, version) pair from a mission folder name when
* no explicit id is supplied. Release missions use their semver; any
* other name falls into the escape band where the caller supplies the
* next ordinal via the second arg. */
export function inferMissionDotId(
missionFolderName: string,
escapeBandOrdinal: number | null,
projectPrefix: string = DEFAULT_PROJECT_PREFIX,
): string {
const releaseMatch = RELEASE_NAME_RE.exec(missionFolderName);
if (releaseMatch) {
return `${projectPrefix}.${releaseMatch[1]}`;
}
if (escapeBandOrdinal === null) {
throw new ScopeCliError({
fact: `Mission "${missionFolderName}" doesn't match the release-X.Y[.Z] pattern and no escape-band ordinal was supplied.`,
consequence: "No dot-ID could be inferred for this mission.",
action: "Pass an explicit ordinal, or rename the mission to release-X.Y.Z, or supply --id on the CLI.",
});
}
return `${projectPrefix}.99.0.${escapeBandOrdinal}`;
}
/** Pick the next escape-band ordinal by scanning peer mission IDs.
* Looks for `<PFX>.99.0.<m>` shapes and returns max(m)+1, starting
* at 1 when no peer exists. */
export function nextEscapeBandOrdinal(
existingIds: ReadonlyArray<string | null>,
projectPrefix: string = DEFAULT_PROJECT_PREFIX,
): number {
let max = 0;
const re = new RegExp(`^${projectPrefix}\\.99\\.0\\.(\\d+)$`);
for (const id of existingIds) {
if (!id) continue;
const m = re.exec(id);
if (!m) continue;
const n = Number(m[1]);
if (Number.isFinite(n) && n > max) max = n;
}
return max + 1;
}
/** Does this candidate string parse as a valid §1 dot-ID at ANY
* tier? Prefer the tier-specific isMissionDotId/isSliceDotId at use
* sites where the tier is known — depth alone doesn't disambiguate
* mission vs slice for release IDs with overlapping shapes. */
export function isConformantDotId(raw: unknown): boolean {
return typeof raw === "string" && parseDotId(raw) !== null;
}
+517
View File
@@ -0,0 +1,517 @@
// release-0.3.2 slice 12 — filesystem + git helpers shared across
// `rig scope` verbs. Mission/slice discovery, frontmatter parse/write,
// auto-numbering, git mv wrapper. Keeps slice/mission command files
// thin.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import YAML from "yaml";
import type {
MissionInfo,
SliceInfo,
SliceState,
} from "./types.js";
import { ScopeCliError } from "./types.js";
import {
DEFAULT_PROJECT_PREFIX,
inferMissionDotId,
nextEscapeBandOrdinal,
} from "./dot-id.js";
const FRONTMATTER_DELIM = "---\n";
const SLICE_DIRNAME_RE = /^(\d+)-(.+)$/;
// ---------------------------------------------------------------------
// Frontmatter parse + write
// ---------------------------------------------------------------------
/** Split a markdown file into [frontmatter, body]. Returns `[{}, content]`
* when no frontmatter delimiter is present. */
export function splitFrontmatter(content: string): {
frontmatter: Record<string, unknown>;
body: string;
} {
if (!content.startsWith(FRONTMATTER_DELIM)) {
return { frontmatter: {}, body: content };
}
const rest = content.slice(FRONTMATTER_DELIM.length);
const endIdx = rest.indexOf(`\n${FRONTMATTER_DELIM.trim()}\n`);
if (endIdx === -1) {
// Allow trailing `---` without a final newline (last char of file).
const endIdx2 = rest.indexOf(`\n---`);
if (endIdx2 === -1) return { frontmatter: {}, body: content };
const raw = rest.slice(0, endIdx2);
return { frontmatter: parseYamlSafely(raw), body: rest.slice(endIdx2 + 4).replace(/^\n/, "") };
}
const raw = rest.slice(0, endIdx);
const body = rest.slice(endIdx + `\n${FRONTMATTER_DELIM.trim()}\n`.length);
return { frontmatter: parseYamlSafely(raw), body };
}
function parseYamlSafely(raw: string): Record<string, unknown> {
try {
const parsed = YAML.parse(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
/** Re-stitch frontmatter + body into a single markdown string. */
export function joinFrontmatter(
frontmatter: Record<string, unknown>,
body: string,
): string {
const yaml = YAML.stringify(frontmatter, { lineWidth: 0 }).trim();
const trailing = body.startsWith("\n") ? "" : "\n";
return `---\n${yaml}\n---\n${trailing}${body}`;
}
/** Read + parse frontmatter from a markdown file. Returns `{}` if the
* file is missing OR has no frontmatter (graceful for placeholder
* missions that pre-date the convention). */
export function readFrontmatter(absPath: string): Record<string, unknown> {
try {
return splitFrontmatter(fs.readFileSync(absPath, "utf8")).frontmatter;
} catch {
return {};
}
}
/** Update specific keys in a markdown file's frontmatter. Existing
* unknown keys are preserved. Generates minimal frontmatter when
* absent. */
export function updateFrontmatter(
absPath: string,
updates: Record<string, unknown>,
): void {
const original = fs.existsSync(absPath) ? fs.readFileSync(absPath, "utf8") : "";
const { frontmatter, body } = splitFrontmatter(original);
const merged = { ...frontmatter, ...updates };
fs.writeFileSync(absPath, joinFrontmatter(merged, body), "utf8");
}
// ---------------------------------------------------------------------
// Mission discovery
// ---------------------------------------------------------------------
/** Locate the missions root for the working substrate. Resolution
* order:
* 1. Explicit override (env or arg)
* 2. $OPENRIG_WORK_ROOT/missions
* 3. Search up from cwd for a `missions/` folder
*/
export function resolveMissionsRoot(opts: {
override?: string | null;
cwd?: string;
} = {}): string {
const cwd = opts.cwd ?? process.cwd();
const fromOverride = opts.override ?? process.env.OPENRIG_WORK_ROOT;
if (fromOverride) {
const candidate = path.isAbsolute(fromOverride) ? fromOverride : path.resolve(cwd, fromOverride);
const missions = path.join(candidate, "missions");
if (fs.existsSync(missions) && fs.statSync(missions).isDirectory()) return missions;
if (path.basename(candidate) === "missions" && fs.existsSync(candidate)) return candidate;
}
let dir = cwd;
for (let i = 0; i < 8; i++) {
const candidate = path.join(dir, "missions");
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new ScopeCliError({
fact: `Could not locate a missions/ root from cwd ${cwd}.`,
consequence: "No mission tree to operate on.",
action: "cd into the substrate workspace, or set OPENRIG_WORK_ROOT=/path/to/substrate/shared-docs/openrig-work.",
});
}
/** List mission folders under the missions root. A mission is any
* top-level folder containing a README.md (per IMPLEMENTATION-PRD
* §2.1 / HG-8). Directories without a README.md are skipped — they
* represent scratch/junk, not declared missions. */
export function listMissions(missionsRoot: string): MissionInfo[] {
if (!fs.existsSync(missionsRoot)) return [];
const entries = fs.readdirSync(missionsRoot, { withFileTypes: true });
const out: MissionInfo[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const absPath = path.join(missionsRoot, entry.name);
const readmePath = path.join(absPath, "README.md");
const hasReadme = fs.existsSync(readmePath);
if (!hasReadme) continue;
const frontmatter = readFrontmatter(readmePath);
const slicesDir = path.join(absPath, "slices");
const closedDir = path.join(absPath, "closed");
const activeSliceCount = countSliceDirs(slicesDir);
const closedSliceCount = countSliceDirs(closedDir);
const id = pickIdFromFrontmatter(frontmatter);
out.push({
name: entry.name,
absPath,
readmePath: hasReadme ? readmePath : null,
frontmatter,
id,
activeSliceCount,
closedSliceCount,
});
}
// Stable order: by mission name.
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function countSliceDirs(dir: string): number {
if (!fs.existsSync(dir)) return 0;
return fs.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && SLICE_DIRNAME_RE.test(e.name))
.length;
}
function pickIdFromFrontmatter(fm: Record<string, unknown>): string | null {
const candidate = fm.id ?? fm.dotId;
return typeof candidate === "string" && candidate.length > 0 ? candidate : null;
}
/** Resolve a mission by name OR path relative to the missions root.
* Throws ScopeCliError on miss OR when the directory exists but
* lacks a README.md (consistent with listMissions' README gate per
* PRD §2.1 / HG-8 — README-less dirs are scratch/junk, not declared
* missions, so mutation commands must not silently target them). */
export function findMission(missionsRoot: string, identifier: string): MissionInfo {
const candidates = [
path.join(missionsRoot, identifier),
path.isAbsolute(identifier) ? identifier : path.resolve(missionsRoot, identifier),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
const readmePath = path.join(candidate, "README.md");
if (!fs.existsSync(readmePath)) {
throw new ScopeCliError({
fact: `Directory "${identifier}" exists at ${candidate} but contains no README.md.`,
consequence: "It is not a declared mission. Command did not run.",
action: "Create it as a mission with: rig scope mission create " + identifier + ". Or add a README.md if the folder is intended to be a mission.",
});
}
return buildMissionInfo(missionsRoot, candidate);
}
}
throw new ScopeCliError({
fact: `Mission "${identifier}" not found under ${missionsRoot}.`,
consequence: "Command did not run.",
action: "List available missions with: rig scope mission ls",
});
}
function buildMissionInfo(missionsRoot: string, absPath: string): MissionInfo {
const readmePath = path.join(absPath, "README.md");
const hasReadme = fs.existsSync(readmePath);
const frontmatter = hasReadme ? readFrontmatter(readmePath) : {};
return {
name: path.basename(absPath),
absPath,
readmePath: hasReadme ? readmePath : null,
frontmatter,
id: pickIdFromFrontmatter(frontmatter),
activeSliceCount: countSliceDirs(path.join(absPath, "slices")),
closedSliceCount: countSliceDirs(path.join(absPath, "closed")),
};
}
// ---------------------------------------------------------------------
// Slice discovery
// ---------------------------------------------------------------------
export function listSlices(
mission: MissionInfo,
state: SliceState,
): SliceInfo[] {
const dirs: Array<{ root: string; bucket: "active" | "closed" }> = [];
if (state === "active" || state === "shipped" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "slices"), bucket: "active" });
}
if (state === "closed" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "closed"), bucket: "closed" });
}
const out: SliceInfo[] = [];
for (const { root } of dirs) {
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);
if (!m) continue;
const sliceInfo = buildSliceInfo(mission, root, entry.name);
out.push(sliceInfo);
}
}
if (state === "active") {
// active = not in closed/, but also not shipped status.
return out.filter((s) => {
const st = (s.status ?? "").toLowerCase();
return !st.startsWith("closed") && !st.startsWith("shipped");
});
}
if (state === "shipped") {
return out.filter((s) => (s.status ?? "").toLowerCase().startsWith("shipped"));
}
return out;
}
function buildSliceInfo(mission: MissionInfo, sliceRoot: string, dirName: string): SliceInfo {
const m = SLICE_DIRNAME_RE.exec(dirName);
const nn = m ? Number(m[1]) : null;
const slug = m ? m[2]! : null;
const absPath = path.join(sliceRoot, dirName);
const readmePath = path.join(absPath, "README.md");
const hasReadme = fs.existsSync(readmePath);
const frontmatter = hasReadme ? readFrontmatter(readmePath) : {};
const id = pickIdFromFrontmatter(frontmatter);
const status = typeof frontmatter.status === "string" ? (frontmatter.status as string).toLowerCase() : null;
return {
name: dirName,
absPath,
readmePath: hasReadme ? readmePath : null,
frontmatter,
nn,
slug,
missionName: mission.name,
id,
status,
};
}
/** Resolve a slice path (absolute, relative-to-substrate, or
* relative-to-mission) into a SliceInfo. */
export function findSlice(
missionsRoot: string,
slicePath: string,
hintMission?: string | null,
): SliceInfo {
const candidates: string[] = [];
if (path.isAbsolute(slicePath)) {
candidates.push(slicePath);
} else {
candidates.push(path.resolve(missionsRoot, "..", slicePath));
candidates.push(path.resolve(missionsRoot, slicePath));
if (hintMission) {
candidates.push(path.join(missionsRoot, hintMission, "slices", slicePath));
candidates.push(path.join(missionsRoot, hintMission, "closed", slicePath));
}
}
for (const candidate of candidates) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
// Walk up to find the owning mission.
const owningMissionPath = findOwningMission(missionsRoot, candidate);
if (!owningMissionPath) {
throw new ScopeCliError({
fact: `Slice path "${slicePath}" resolved to ${candidate} but no parent mission was found.`,
consequence: "Cannot determine mission context for this slice.",
action: "Ensure the slice lives under <missionsRoot>/<mission>/{slices,closed}/.",
});
}
const mission = buildMissionInfo(missionsRoot, owningMissionPath);
const sliceRoot = path.dirname(candidate);
return buildSliceInfo(mission, sliceRoot, path.basename(candidate));
}
}
throw new ScopeCliError({
fact: `Slice "${slicePath}" not found.`,
consequence: "Command did not run.",
action: "Check the path. List slices in a mission with: rig scope slice ls --mission <name>",
});
}
function findOwningMission(missionsRoot: string, slicePath: string): string | null {
let dir = path.dirname(slicePath);
while (dir.startsWith(missionsRoot) && dir !== missionsRoot) {
if (path.dirname(dir) === missionsRoot) return dir;
dir = path.dirname(dir);
}
return null;
}
// ---------------------------------------------------------------------
// Auto-numbering
// ---------------------------------------------------------------------
/** Find the next available NN for a mission's slices/ folder. Scans
* BOTH slices/ AND closed/ 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);
if (!m) continue;
const n = Number(m[1]);
if (Number.isFinite(n) && n > max) max = n;
}
}
return max + 1;
}
/** Mint or read the dot-ID for a mission. If the mission's README has
* an `id:` field, return it. Otherwise infer from the folder name
* (release-X.Y.Z → OPR.X.Y.Z; otherwise escape band). */
export function ensureMissionId(
mission: MissionInfo,
missionsRoot: string,
): string {
if (mission.id) return mission.id;
// Try release-pattern first; if it matches, no peer scan needed.
try {
const id = inferMissionDotId(mission.name, null);
return id;
} catch {
// Fall through to escape-band path.
}
const peers = listMissions(missionsRoot).filter((m) => m.name !== mission.name);
const ordinal = nextEscapeBandOrdinal(peers.map((p) => p.id));
return inferMissionDotId(mission.name, ordinal);
}
/** Same as ensureMissionId, but ALSO writes the inferred id back into
* the mission's README frontmatter when it was absent. Use this at
* every site that mints a child id (slice create / ship / move target)
* — per the convention's lazy-adoption rule: when a child is created
* under a pre-existing parent with no id, assign the parent's id
* on-demand at the same time. Narrow, single-parent, create-triggered;
* never mass-migration. */
export function ensureMissionIdPersisted(
mission: MissionInfo,
missionsRoot: string,
): string {
const id = ensureMissionId(mission, missionsRoot);
if (!mission.id && mission.readmePath) {
updateFrontmatter(mission.readmePath, { id });
// Reflect the write on the in-memory MissionInfo so subsequent
// callers in the same command don't re-mint a different ordinal.
mission.id = id;
}
return id;
}
// ---------------------------------------------------------------------
// Git move
// ---------------------------------------------------------------------
/** Return the git toplevel for a path, or null if not inside a repo. */
export function gitTopLevel(absPath: string): string | null {
const dir = fs.existsSync(absPath) && fs.statSync(absPath).isDirectory() ? absPath : path.dirname(absPath);
try {
const out = execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return out || null;
} catch {
return null;
}
}
/** Refuse to move a path with uncommitted local changes. Catches the
* "dirty working tree" risk in §11.1 / §3.4 — `git mv` would silently
* carry along the user's in-progress edits. */
export function assertCleanWorkingTree(repoRoot: string, relPath: string): void {
let status: string;
try {
status = execFileSync(
"git",
["-C", repoRoot, "status", "--porcelain", "--", relPath],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
} catch (err) {
throw new ScopeCliError({
fact: `git status failed for ${relPath} in ${repoRoot}: ${(err as Error).message}`,
consequence: "Could not verify the slice's working-tree state. Move aborted.",
action: "Check the repository at " + repoRoot + " and retry.",
});
}
if (status.trim().length > 0) {
throw new ScopeCliError({
fact: `Working tree under ${relPath} has uncommitted changes:\n${status.trimEnd()}`,
consequence: "Refusing to git mv — your local edits would silently move with the slice.",
action: "Commit (git commit -m '...') or stash (git stash) and retry.",
});
}
}
/** Move a directory using `git mv` when inside a repo; fall back to
* `fs.renameSync` with a warning when not. Returns true if git was
* used. */
export function moveSlice(srcAbs: string, destAbs: string, opts: {
/** Stage the move (default true). git mv stages by default; we
* preserve that semantic. */
commit?: boolean;
} = {}): { usedGit: boolean; repoRoot: string | null } {
if (!fs.existsSync(srcAbs)) {
throw new ScopeCliError({
fact: `Source path ${srcAbs} does not exist.`,
consequence: "Move did not run.",
action: "Verify the slice path and retry.",
});
}
if (fs.existsSync(destAbs)) {
throw new ScopeCliError({
fact: `Destination path ${destAbs} already exists.`,
consequence: "Refusing to overwrite an existing slice.",
action: "Pick a different destination, or remove the existing folder first.",
});
}
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
const repoRoot = gitTopLevel(srcAbs);
if (!repoRoot) {
fs.renameSync(srcAbs, destAbs);
return { usedGit: false, repoRoot: null };
}
// Normalize symlinks (macOS /var/folders → /private/var/folders) so
// path.relative produces a path INSIDE the repo, not a ../../escape.
const realSrcAbs = fs.realpathSync(srcAbs);
const realDestParent = fs.realpathSync(path.dirname(destAbs));
const realDestAbs = path.join(realDestParent, path.basename(destAbs));
const srcRel = path.relative(repoRoot, realSrcAbs);
assertCleanWorkingTree(repoRoot, srcRel);
const destRel = path.relative(repoRoot, realDestAbs);
try {
execFileSync("git", ["-C", repoRoot, "mv", srcRel, destRel], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
throw new ScopeCliError({
fact: `git mv ${srcRel} ${destRel} failed: ${(err as Error).message}`,
consequence: "Move aborted; source unchanged.",
action: "Inspect the repo state and retry.",
});
}
if (opts.commit) {
// Future hook; v0 doesn't auto-commit.
}
return { usedGit: true, repoRoot };
}
// ---------------------------------------------------------------------
// Misc
// ---------------------------------------------------------------------
export function todayDateISO(): string {
const d = new Date();
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function pad2(n: number): string {
return String(n).padStart(2, "0");
}
export { DEFAULT_PROJECT_PREFIX };
+82
View File
@@ -0,0 +1,82 @@
// release-0.3.2 slice 12 — template loader. Templates live as
// markdown files alongside the source so they can be edited like any
// other doc. The build copies them to dist/ via tsconfig
// rootDir/files behavior — but since .md isn't a .ts file, we read
// them directly via fileURLToPath to remain robust across local dev
// and the published package layout.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { MissionTemplateKind, SliceTemplateKind } from "./types.js";
import { ScopeCliError } from "./types.js";
const here = path.dirname(fileURLToPath(import.meta.url));
/** Candidate template roots, in order: source tree (dev), dist (built).
* We resolve at call time so the first existing directory wins. */
function candidateRoots(): string[] {
return [
// Dev: packages/cli/src/lib/scope/ → ../scope-templates
path.resolve(here, "..", "scope-templates"),
// Built: dist/lib/scope/ → ../../src/lib/scope-templates (one level up further if
// dist lands at packages/cli/dist/lib/scope).
path.resolve(here, "..", "..", "lib", "scope-templates"),
// Fallback: source tree relative to compiled dist when source is co-shipped.
path.resolve(here, "..", "..", "..", "src", "lib", "scope-templates"),
];
}
function resolveTemplate(filename: string): string {
for (const root of candidateRoots()) {
const candidate = path.join(root, filename);
if (fs.existsSync(candidate)) return candidate;
}
throw new ScopeCliError({
fact: `Template ${filename} could not be located.`,
consequence: "Cannot scaffold the new artifact.",
action: "Reinstall @openrig/cli, or run from a checkout with packages/cli/src/lib/scope-templates/ present.",
});
}
export interface RenderOpts {
id: string;
slice_number?: string; // zero-padded; only for slice templates
slug: string;
mission: string;
title: string;
created_date: string;
release_version?: string;
}
function applyPlaceholders(content: string, opts: RenderOpts): string {
return content
.replace(/\{\{id\}\}/g, opts.id)
.replace(/\{\{slice_number\}\}/g, opts.slice_number ?? "")
.replace(/\{\{slug\}\}/g, opts.slug)
.replace(/\{\{mission\}\}/g, opts.mission)
.replace(/\{\{title\}\}/g, opts.title)
.replace(/\{\{created_date\}\}/g, opts.created_date)
.replace(/\{\{release_version\}\}/g, opts.release_version ?? "");
}
export function renderSliceTemplate(kind: SliceTemplateKind, opts: RenderOpts): string {
const filename = `${kind}.md`;
const raw = fs.readFileSync(resolveTemplate(filename), "utf8");
return applyPlaceholders(raw, opts);
}
export function renderMissionTemplate(kind: MissionTemplateKind, opts: RenderOpts): string {
const filename = kind === "release" ? "mission-release.md" : "mission-placeholder.md";
const raw = fs.readFileSync(resolveTemplate(filename), "utf8");
return applyPlaceholders(raw, opts);
}
/** Convert a folder-slug to a title-cased display name. */
export function titleFromSlug(slug: string): string {
return slug
.split(/[-_]/g)
.filter(Boolean)
.map((word) => word[0]!.toUpperCase() + word.slice(1))
.join(" ");
}
+97
View File
@@ -0,0 +1,97 @@
// release-0.3.2 slice 12 — scope CLI primitive types.
//
// Aligned with the substrate convention at
// `openrig-work/conventions/scope-and-versioning/README.md` (stage:
// provisional, authored 2026-05-15). The CLI mints stable dot-IDs
// into created mission/slice frontmatter per §1 of that convention.
/** Scope tiers. v0 ships `mission` + `slice`; `project` and `sub-slice`
* exist in the grammar but have no CLI verbs yet. */
export type ScopeTier = "project" | "mission" | "slice" | "sub-slice";
/** Stable reference ID parts. `OPR.0.3.2.12` → project="OPR",
* version="0.3.2", n=12. Escape-band non-release missions look like
* `OPR.99.0.1` → version="99.0.1", n=undefined. */
export interface DotId {
project: string; // 2-3 letter project prefix
version: string; // semver-shaped (release) or escape-band (99.x.y)
n?: number; // slice ordinal
m?: number; // sub-slice ordinal
}
export interface MissionInfo {
/** Mission folder name (e.g., "release-0.3.2", "backlog"). */
name: string;
/** Absolute path to the mission folder. */
absPath: string;
/** Path to the mission's README.md, if present. */
readmePath: string | null;
/** Parsed frontmatter from the README. */
frontmatter: Record<string, unknown>;
/** Minted dot-ID for this mission (read from frontmatter if present,
* otherwise inferred from the folder name pattern). */
id: string | null;
activeSliceCount: number;
closedSliceCount: number;
}
export interface SliceInfo {
name: string; // folder name like "12-rig-slice-cli-primitive"
absPath: string;
readmePath: string | null;
frontmatter: Record<string, unknown>;
/** "NN-slug" → NN. */
nn: number | null;
/** "NN-slug" → slug. */
slug: string | null;
missionName: string;
/** Minted dot-ID, parent.NN form. */
id: string | null;
/** Frontmatter `status` field, lowercased. */
status: string | null;
}
/** 3-part error shape per `building-agent-software` skill §3.6. */
export class ScopeCliError extends Error {
readonly fact: string;
readonly consequence: string;
readonly action: string;
constructor(opts: { fact: string; consequence: string; action: string }) {
super(`${opts.fact}\n${opts.consequence}\n${opts.action}`);
this.name = "ScopeCliError";
this.fact = opts.fact;
this.consequence = opts.consequence;
this.action = opts.action;
}
}
export type SliceState = "active" | "closed" | "shipped" | "all";
export type SliceTemplateKind =
| "placeholder"
| "bug-fix"
| "backlog-deprecation"
| "backlog-tech-debt"
| "release-feature"
| "research";
export const SLICE_TEMPLATE_KINDS: ReadonlyArray<SliceTemplateKind> = [
"placeholder",
"bug-fix",
"backlog-deprecation",
"backlog-tech-debt",
"release-feature",
"research",
];
export type MissionTemplateKind = "placeholder" | "release";
export const MISSION_TEMPLATE_KINDS: ReadonlyArray<MissionTemplateKind> = [
"placeholder",
"release",
];
export type CloseReason = "wontfix" | "deferred" | "superseded" | "stale";
export const CLOSE_REASONS: ReadonlyArray<CloseReason> = [
"wontfix",
"deferred",
"superseded",
"stale",
];
+565
View File
@@ -0,0 +1,565 @@
// release-0.3.2 slice 12 — `rig scope` command-surface integration
// tests. Drives the commander tree end-to-end with a tmp substrate
// fixture so every HG-N gate has direct coverage.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { Command } from "commander";
import { scopeCommand } from "../src/commands/scope.js";
import { readFrontmatter } from "../src/lib/scope/scope-fs.js";
function mktemp(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-cmd-"));
}
function writeFile(p: string, content: string): void {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content, "utf8");
}
function seedSubstrate(): { root: string; missionsRoot: string } {
const root = mktemp();
const missionsRoot = path.join(root, "openrig-work", "missions");
execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "config", "user.email", "t@e.com"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "config", "user.name", "T"], { stdio: "ignore" });
fs.mkdirSync(missionsRoot, { recursive: true });
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\n# release-0.3.2\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "slices", "01-existing", "README.md"),
"---\nid: OPR.0.3.2.1\nstatus: active\n---\n# existing\n",
);
writeFile(
path.join(missionsRoot, "backlog", "README.md"),
"---\nid: OPR.99.0.1\n---\n# backlog\n",
);
writeFile(
path.join(missionsRoot, "backlog", "slices", "01-debt-foo", "README.md"),
"---\nid: OPR.99.0.1.1\nstatus: active\n---\n# debt\n",
);
execFileSync("git", ["-C", root, "add", "."], { stdio: "ignore" });
execFileSync("git", ["-C", root, "commit", "-m", "seed", "-q"], { stdio: "ignore" });
return { root, missionsRoot };
}
interface CaptureResult {
exitCode: number;
stdout: string;
stderr: string;
}
async function run(args: string[], workspace: string): Promise<CaptureResult> {
const stdoutBuf: string[] = [];
const stderrBuf: string[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
const origErrWrite = process.stderr.write.bind(process.stderr);
const origExit = process.exit;
let exitCode = 0;
process.stdout.write = ((chunk: unknown) => { stdoutBuf.push(String(chunk)); return true; }) as typeof process.stdout.write;
process.stderr.write = ((chunk: unknown) => { stderrBuf.push(String(chunk)); return true; }) as typeof process.stderr.write;
process.exit = ((code?: number) => {
exitCode = code ?? 0;
throw new Error(`__EXIT__${exitCode}`);
}) as typeof process.exit;
const program = new Command();
program.addCommand(scopeCommand());
program.exitOverride();
try {
await program.parseAsync(["node", "rig", "scope", ...args, "--workspace", path.dirname(workspace)]);
} catch (err) {
const msg = (err as Error).message ?? "";
if (!msg.startsWith("__EXIT__")) {
stderrBuf.push(msg + "\n");
}
} finally {
process.stdout.write = origWrite;
process.stderr.write = origErrWrite;
process.exit = origExit;
}
return {
exitCode,
stdout: stdoutBuf.join(""),
stderr: stderrBuf.join(""),
};
}
// ---------------------------------------------------------------------
// HG-1 + HG-9: rig scope slice ls --json + state filter
// ---------------------------------------------------------------------
describe("rig scope slice ls", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("HG-1 + HG-9: --json returns active slices from a specific mission", async () => {
const r = await run(["slice", "ls", "--mission", "release-0.3.2", "--state", "active", "--json"], env.missionsRoot);
expect(r.exitCode).toBe(0);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.slices.map((s: { name: string }) => s.name)).toEqual(["01-existing"]);
expect(parsed.slices[0].id).toBe("OPR.0.3.2.1");
});
it("returns slices from all missions when --mission is omitted", async () => {
const r = await run(["slice", "ls", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.count).toBe(2);
});
});
// ---------------------------------------------------------------------
// HG-2: rig scope slice show
// ---------------------------------------------------------------------
describe("rig scope slice show (HG-2)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("renders frontmatter + README + children", async () => {
const r = await run([
"slice", "show", "01-existing",
"--mission", "release-0.3.2",
"--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.slice.id).toBe("OPR.0.3.2.1");
expect(parsed.slice.readme).toContain("# existing");
});
});
// ---------------------------------------------------------------------
// HG-3 + HG-4 + HG-15: rig scope slice create
// ---------------------------------------------------------------------
describe("rig scope slice create", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("HG-3: auto-finds next NN, never reuses numbers", async () => {
// Add a closed slice 04 so existing NNs are 01 + 04. Next must be 05.
writeFile(
path.join(env.missionsRoot, "release-0.3.2", "closed", "04-old", "README.md"),
"---\nid: OPR.0.3.2.4\nstatus: closed-stale\n---\n",
);
const r = await run([
"slice", "create", "release-0.3.2", "new-thing", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.slice.name).toMatch(/^05-/);
});
it("HG-4: --template populates the template", async () => {
const r = await run([
"slice", "create", "release-0.3.2", "perf-fix",
"--template", "bug-fix", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
const readme = fs.readFileSync(path.join(parsed.slice.path, "README.md"), "utf8");
expect(readme).toMatch(/## Repro/);
expect(readme).toMatch(/## Expected/);
});
it("HG-15: created slice frontmatter has a conformant dot-ID", async () => {
const r = await run([
"slice", "create", "release-0.3.2", "tdd-foo", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
const fm = readFrontmatter(path.join(parsed.slice.path, "README.md"));
expect(fm.id).toBe("OPR.0.3.2.2");
expect(typeof fm.id === "string" && /^OPR\.0\.3\.2\.\d+$/.test(fm.id as string)).toBe(true);
});
});
// ---------------------------------------------------------------------
// HG-5 + HG-9: rig scope slice ship — git mv + frontmatter update
// ---------------------------------------------------------------------
describe("rig scope slice ship (HG-5)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("git mv + frontmatter status: shipped-to-release-X.Y", async () => {
const r = await run([
"slice", "ship", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.shipped.git.usedGit).toBe(true);
// Source removed; target present.
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo"))).toBe(false);
expect(fs.existsSync(parsed.shipped.to.path)).toBe(true);
const fm = readFrontmatter(path.join(parsed.shipped.to.path, "README.md"));
expect(fm.status).toBe("shipped-to-release-0.3.2");
expect(fm.mission).toBe("release-0.3.2");
});
});
// ---------------------------------------------------------------------
// HG-6: rig scope slice close --reason
// ---------------------------------------------------------------------
describe("rig scope slice close (HG-6)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("moves to closed/ + sets status: closed-<reason>", async () => {
const r = await run([
"slice", "close", "01-debt-foo",
"--mission", "backlog",
"--reason", "wontfix",
"--note", "obsolete by design",
"--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo"))).toBe(false);
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "closed", "01-debt-foo"))).toBe(true);
const fm = readFrontmatter(path.join(env.missionsRoot, "backlog", "closed", "01-debt-foo", "README.md"));
expect(fm.status).toBe("closed-wontfix");
expect(fm["closure-note"]).toBe("obsolete by design");
});
it("rejects unknown --reason values", async () => {
const r = await run([
"slice", "close", "01-debt-foo",
"--mission", "backlog",
"--reason", "fishy",
"--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
});
});
// ---------------------------------------------------------------------
// HG-7: rig scope slice move
// ---------------------------------------------------------------------
describe("rig scope slice move (HG-7)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("moves between missions; auto-renumber; frontmatter.mission updates", async () => {
const r = await run([
"slice", "move", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.moved.to.name).toMatch(/^02-/); // 01 was taken
expect(parsed.moved.to.id).toBe("OPR.0.3.2.2");
const fm = readFrontmatter(path.join(parsed.moved.to.path, "README.md"));
expect(fm.mission).toBe("release-0.3.2");
});
});
// ---------------------------------------------------------------------
// HG-8: rig scope mission ls
// ---------------------------------------------------------------------
describe("rig scope mission ls (HG-8)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("--json lists missions with id + slice counts", async () => {
const r = await run(["mission", "ls", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
const names = parsed.missions.map((m: { name: string }) => m.name).sort();
expect(names).toEqual(["backlog", "release-0.3.2"]);
});
});
describe("rig scope mission show", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("renders frontmatter + README + slice inventory", async () => {
const r = await run(["mission", "show", "release-0.3.2", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.mission.id).toBe("OPR.0.3.2");
expect(parsed.mission.slices.length).toBe(1);
});
});
// ---------------------------------------------------------------------
// HG-14 + HG-15: rig scope mission create (PROMOTED to v0 per Amendment §B)
// ---------------------------------------------------------------------
describe("rig scope mission create (HG-14 + HG-15)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("HG-14: mission create is invocable with the SAME pattern as slice create", async () => {
// Symmetry test: both verbs take (positional name) + (--template) +
// (--json) and emit { ok, [tier]: { name, id, template, path } }.
const r = await run([
"mission", "create", "release-0.4.0", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(0);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.mission.name).toBe("release-0.4.0");
expect(parsed.mission.path).toBeTruthy();
expect(parsed.mission.template).toBe("release");
});
it("HG-15: created mission frontmatter has a conformant dot-ID per §1", async () => {
const r = await run(["mission", "create", "release-0.5.0", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.mission.id).toBe("OPR.0.5.0");
const fm = readFrontmatter(path.join(parsed.mission.path, "README.md"));
expect(fm.id).toBe("OPR.0.5.0");
});
it("HG-15: non-release names get an escape-band ID (uniform-numeric)", async () => {
const r = await run([
"mission", "create", "experiments-foo", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.mission.id).toMatch(/^OPR\.99\.0\.\d+$/);
});
it("HG-15: accepts an explicit --id override and rejects malformed ones", async () => {
const ok = await run([
"mission", "create", "weird-thing",
"--id", "OPR.99.0.42", "--json",
], env.missionsRoot);
expect(JSON.parse(ok.stdout).mission.id).toBe("OPR.99.0.42");
const bad = await run([
"mission", "create", "another-weird-thing",
"--id", "OPR.A.1", "--json",
], env.missionsRoot);
expect(bad.exitCode).toBe(1);
});
it("BLOCK 1 discriminator: mission create rejects slice-depth --id (OPR.0.3.2.12)", async () => {
// Per guard BC verdict: a 4-segment slice-shape must NOT pass
// mission --id validation. Without tier-aware validation this
// would succeed and create a mission with a slice-shaped id.
const r = await run([
"mission", "create", "wrong-tier",
"--id", "OPR.0.3.2.12", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toMatch(/not a mission-tier dot-ID/);
});
it("BLOCK 1 follow-up: mission create still accepts valid mission shapes (escape band + release)", async () => {
const eb = await run([
"mission", "create", "exp-band",
"--id", "OPR.99.0.42", "--json",
], env.missionsRoot);
expect(eb.exitCode).toBe(0);
expect(JSON.parse(eb.stdout).mission.id).toBe("OPR.99.0.42");
const rel = await run([
"mission", "create", "release-0.6.0", "--json",
], env.missionsRoot);
expect(rel.exitCode).toBe(0);
expect(JSON.parse(rel.stdout).mission.id).toBe("OPR.0.6.0");
});
it("BC-2 BLOCK 1 discriminator: rejects escape-band with non-zero middle segment (OPR.99.7.8)", async () => {
const r = await run([
"mission", "create", "bad-eb",
"--id", "OPR.99.7.8", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
expect(JSON.parse(r.stdout).ok).toBe(false);
});
});
// ---------------------------------------------------------------------
// BC-2 BLOCK 2: README-less dirs are rejected at every mutation surface
// ---------------------------------------------------------------------
describe("BC-2 BLOCK 2 — README-less dirs are not declared missions at any mutation surface", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => {
env = seedSubstrate();
// Sneak a README-less directory into missions/ to verify every
// surface rejects it consistently with listMissions.
fs.mkdirSync(path.join(env.missionsRoot, "no-readme"), { recursive: true });
});
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("mission ls omits no-readme (already in HG-8 above; sanity check here)", async () => {
const r = await run(["mission", "ls", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.missions.map((m: { name: string }) => m.name)).not.toContain("no-readme");
});
it("mission show no-readme exits 1 with the 3-part 'not a declared mission' error", async () => {
const r = await run(["mission", "show", "no-readme", "--json"], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toMatch(/no README\.md|not a declared mission/);
});
it("slice create no-readme exits 1 + does NOT create slices/foo/", async () => {
const r = await run([
"slice", "create", "no-readme", "foo", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
expect(fs.existsSync(path.join(env.missionsRoot, "no-readme", "slices", "01-foo"))).toBe(false);
});
it("slice ship to no-readme target exits 1; source slice unmoved", async () => {
const srcAbs = path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo");
expect(fs.existsSync(srcAbs)).toBe(true);
const r = await run([
"slice", "ship", "01-debt-foo", "no-readme",
"--mission", "backlog", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
// Source unchanged.
expect(fs.existsSync(srcAbs)).toBe(true);
});
it("slice move to no-readme target exits 1; source slice unmoved", async () => {
const srcAbs = path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo");
const r = await run([
"slice", "move", "01-debt-foo", "no-readme",
"--mission", "backlog", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
expect(fs.existsSync(srcAbs)).toBe(true);
});
});
// ---------------------------------------------------------------------
// BLOCK 3 discriminator: ship + move persist target.id back to target README
// ---------------------------------------------------------------------
describe("BLOCK 3 — ship/move persist target mission id into target README", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => {
env = seedSubstrate();
// Replace release-0.3.2/README.md with an ID-LESS variant so we
// can observe the lazy-adopt-and-persist behavior.
fs.writeFileSync(
path.join(env.missionsRoot, "release-0.3.2", "README.md"),
"# release-0.3.2\n\n(no id frontmatter yet)\n",
"utf8",
);
execFileSync("git", ["-C", env.root, "add", "."], { stdio: "ignore" });
execFileSync("git", ["-C", env.root, "commit", "-m", "strip-target-id", "-q"], { stdio: "ignore" });
});
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("slice ship: target mission gains a persisted id; child id = targetId.NN", async () => {
const r = await run([
"slice", "ship", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
// Target README now has id: OPR.0.3.2 written back.
const targetFm = readFrontmatter(path.join(env.missionsRoot, "release-0.3.2", "README.md"));
expect(targetFm.id).toBe("OPR.0.3.2");
// Child id matches parent.NN.
expect(parsed.shipped.to.id).toBe("OPR.0.3.2.2");
});
it("slice move: target mission gains a persisted id; child id = targetId.NN", async () => {
const r = await run([
"slice", "move", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
const targetFm = readFrontmatter(path.join(env.missionsRoot, "release-0.3.2", "README.md"));
expect(targetFm.id).toBe("OPR.0.3.2");
expect(parsed.moved.to.id).toBe("OPR.0.3.2.2");
});
it("sibling missions without id remain UNCHANGED after a ship/move (narrow lazy adoption, not mass-migrate)", async () => {
// Spawn an unrelated id-less mission alongside; assert it survives
// untouched through a ship to a different mission.
fs.mkdirSync(path.join(env.missionsRoot, "untouched-mission"), { recursive: true });
fs.writeFileSync(
path.join(env.missionsRoot, "untouched-mission", "README.md"),
"# untouched\n",
"utf8",
);
execFileSync("git", ["-C", env.root, "add", "."], { stdio: "ignore" });
execFileSync("git", ["-C", env.root, "commit", "-m", "seed-sibling", "-q"], { stdio: "ignore" });
const before = fs.readFileSync(path.join(env.missionsRoot, "untouched-mission", "README.md"), "utf8");
await run([
"slice", "ship", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const after = fs.readFileSync(path.join(env.missionsRoot, "untouched-mission", "README.md"), "utf8");
expect(after).toBe(before);
expect(readFrontmatter(path.join(env.missionsRoot, "untouched-mission", "README.md")).id).toBeUndefined();
});
});
// ---------------------------------------------------------------------
// HG-10: 3-part error shape
// ---------------------------------------------------------------------
describe("3-part error shape (HG-10)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("ship to nonexistent target mission yields fact/consequence/action", async () => {
const r = await run([
"slice", "ship", "01-debt-foo", "release-9.9.9",
"--mission", "backlog", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toBeTruthy();
expect(parsed.error.consequence).toBeTruthy();
expect(parsed.error.action).toBeTruthy();
});
});
// ---------------------------------------------------------------------
// HG-12: --help exists for every command
// ---------------------------------------------------------------------
describe("--help is present on every command (HG-12)", () => {
it("every tier and verb has help text", () => {
const cmd = scopeCommand();
expect(cmd.description()).toBeTruthy();
const tiers = cmd.commands;
expect(tiers.map((c) => c.name()).sort()).toEqual(["mission", "slice"]);
for (const tier of tiers) {
expect(tier.description()).toBeTruthy();
for (const verb of tier.commands) {
expect(verb.description()).toBeTruthy();
}
}
});
});
+177
View File
@@ -0,0 +1,177 @@
// release-0.3.2 slice 12 — dot-ID grammar unit tests.
//
// Aligned with `openrig-work/conventions/scope-and-versioning/README.md` §1.
import { describe, expect, it } from "vitest";
import {
formatDotId,
inferMissionDotId,
isConformantDotId,
isMissionDotId,
isSliceDotId,
nextEscapeBandOrdinal,
parseDotId,
sliceIdFromMission,
} from "../src/lib/scope/dot-id.js";
describe("dot-ID — parse + format", () => {
it("parses a slice ID like OPR.0.3.2.12", () => {
const id = parseDotId("OPR.0.3.2.12");
expect(id).not.toBeNull();
expect(id!.project).toBe("OPR");
expect(id!.version).toBe("0.3.2.12");
});
it("parses an escape-band ID like OPR.99.0.1", () => {
const id = parseDotId("OPR.99.0.1");
expect(id).not.toBeNull();
expect(id!.project).toBe("OPR");
expect(id!.version).toBe("99.0.1");
});
it("rejects alpha segments (§1 explicit: uniform-numeric escape band)", () => {
expect(parseDotId("OPR.A.1")).toBeNull();
expect(parseDotId("OPR.0.3.A")).toBeNull();
});
it("rejects malformed prefixes", () => {
expect(parseDotId("opr.0.3.2")).toBeNull();
expect(parseDotId("O.0.3.2")).toBeNull();
expect(parseDotId("OPRX.0.3.2")).toBeNull();
});
it("formats a DotId back to canonical string", () => {
expect(formatDotId({ project: "OPR", version: "0.3.2" })).toBe("OPR.0.3.2");
expect(formatDotId({ project: "OPR", version: "0.3.2", n: 12 })).toBe("OPR.0.3.2.12");
});
});
describe("inferMissionDotId — release pattern + escape band", () => {
it("infers OPR.X.Y.Z from release-X.Y.Z folder names", () => {
expect(inferMissionDotId("release-0.3.2", null)).toBe("OPR.0.3.2");
expect(inferMissionDotId("release-0.4.0", null)).toBe("OPR.0.4.0");
expect(inferMissionDotId("release-1.0", null)).toBe("OPR.1.0");
});
it("requires an escape-band ordinal for non-release names", () => {
expect(() => inferMissionDotId("backlog", null)).toThrow();
expect(inferMissionDotId("backlog", 5)).toBe("OPR.99.0.5");
expect(inferMissionDotId("bug-fix", 3)).toBe("OPR.99.0.3");
});
});
describe("nextEscapeBandOrdinal", () => {
it("returns 1 when no peer IDs exist", () => {
expect(nextEscapeBandOrdinal([])).toBe(1);
expect(nextEscapeBandOrdinal([null, null])).toBe(1);
});
it("returns max+1 across existing escape-band peers", () => {
expect(nextEscapeBandOrdinal(["OPR.99.0.1", "OPR.99.0.3", null])).toBe(4);
});
it("ignores non-matching IDs (releases) when picking ordinal", () => {
expect(nextEscapeBandOrdinal(["OPR.0.3.2", "OPR.99.0.2"])).toBe(3);
});
});
describe("sliceIdFromMission", () => {
it("appends the slice ordinal to the mission ID", () => {
expect(sliceIdFromMission("OPR.0.3.2", 12)).toBe("OPR.0.3.2.12");
expect(sliceIdFromMission("OPR.99.0.1", 4)).toBe("OPR.99.0.1.4");
});
});
describe("isConformantDotId", () => {
it("accepts conformant IDs across all tiers", () => {
expect(isConformantDotId("OPR.0.3.2")).toBe(true);
expect(isConformantDotId("OPR.0.3.2.12")).toBe(true);
expect(isConformantDotId("OPR.99.0.1")).toBe(true);
});
it("rejects non-conformant inputs", () => {
expect(isConformantDotId("")).toBe(false);
expect(isConformantDotId(42)).toBe(false);
expect(isConformantDotId("opr.0.3.2")).toBe(false);
});
});
describe("BLOCK 1 — tier-aware validation (isMissionDotId / isSliceDotId)", () => {
it("isMissionDotId accepts release-shaped IDs (2-3 numeric segments)", () => {
expect(isMissionDotId("OPR.0.3.2")).toBe(true);
expect(isMissionDotId("OPR.1.0")).toBe(true);
});
it("isMissionDotId accepts the escape-band shape (99.x.y, exactly 3 segments)", () => {
expect(isMissionDotId("OPR.99.0.1")).toBe(true);
expect(isMissionDotId("OPR.99.0.42")).toBe(true);
});
it("isMissionDotId REJECTS slice-shaped IDs (4+ numeric segments) — guard BC discriminator", () => {
// The crux of BLOCK 1: a slice-depth ID like OPR.0.3.2.12 must
// not pass mission validation, even though it parses as a valid
// dot-ID at SOME tier.
expect(isMissionDotId("OPR.0.3.2.12")).toBe(false);
expect(isMissionDotId("OPR.99.0.1.5")).toBe(false);
});
it("isMissionDotId rejects malformed inputs (alpha / wrong prefix shape)", () => {
expect(isMissionDotId("OPR.A.1")).toBe(false);
expect(isMissionDotId("opr.0.3.2")).toBe(false);
expect(isMissionDotId(42 as unknown)).toBe(false);
expect(isMissionDotId("OPR.99.0")).toBe(false); // escape band must be exactly 3
});
it("isSliceDotId accepts slice-shaped IDs (3-4 numeric segments)", () => {
expect(isSliceDotId("OPR.0.3.2.12")).toBe(true); // release-X.Y.Z slice
expect(isSliceDotId("OPR.1.0.5")).toBe(true); // release-X.Y slice
expect(isSliceDotId("OPR.99.0.1.7")).toBe(true); // escape-band slice
});
it("isSliceDotId admits the inherent depth-3 overlap (release X.Y.Z mission shape = slice for an X.Y parent)", () => {
// The convention's positional grammar can't distinguish a
// release-X.Y.Z mission from a slice of a release-X.Y mission
// by depth alone. Both shapes are accepted by isSliceDotId; the
// CLI disambiguates by tier at use sites (mission create uses
// isMissionDotId; slice IDs are minted from a parent + ordinal,
// never user-typed).
expect(isSliceDotId("OPR.0.3.2")).toBe(true); // release X.Y.Z OR slice of X.Y
// Escape-band IDs are UNAMBIGUOUS by depth: an escape-band mission
// is depth-3 (OPR.99.0.1); an escape-band slice is depth-4
// (OPR.99.0.1.5). Depth-3 with leading 99 is mission-only.
expect(isSliceDotId("OPR.99.0.1")).toBe(false);
// The strictly-impossible-slice case is depth 2 (X.Y only) — no
// parent can have a sub-NN-segment of "Y".
expect(isSliceDotId("OPR.1.0")).toBe(false);
});
it("parseDotId(s, 'slice') peels the last segment off as `n`", () => {
// Per guard discriminator: parsing OPR.0.3.2.12 as a slice must
// yield version=0.3.2 and n=12, not version=0.3.2.12.
const id = parseDotId("OPR.0.3.2.12", "slice");
expect(id).not.toBeNull();
expect(id!.version).toBe("0.3.2");
expect(id!.n).toBe(12);
});
it("parseDotId(s, 'mission') keeps all numeric segments as version", () => {
const id = parseDotId("OPR.0.3.2", "mission");
expect(id).not.toBeNull();
expect(id!.version).toBe("0.3.2");
expect(id!.n).toBeUndefined();
});
it("BC-2 BLOCK 1: escape band rejects [99, !0, n] mission ver (only [99, 0, n] is valid)", () => {
// §1 fixes the escape band as <PFX>.99.0.<n> — the `0` is FIXED.
// Previously the depth-3 check accepted OPR.99.7.8 as a mission;
// now require the middle 0.
expect(isMissionDotId("OPR.99.7.8")).toBe(false);
expect(isMissionDotId("OPR.99.1.0")).toBe(false);
expect(isMissionDotId("OPR.99.0.42")).toBe(true); // still valid
expect(isMissionDotId("OPR.99.0.1")).toBe(true); // still valid
});
it("BC-2 BLOCK 1: escape-band slice parent must also satisfy the [99,0,n] rule", () => {
expect(isSliceDotId("OPR.99.7.8.1")).toBe(false); // parent=99.7.8 invalid
expect(isSliceDotId("OPR.99.0.42.7")).toBe(true); // parent=99.0.42 valid
});
});
+339
View File
@@ -0,0 +1,339 @@
// release-0.3.2 slice 12 — scope-fs helpers + frontmatter parser tests.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import {
ensureMissionId,
findMission,
findSlice,
listMissions,
listSlices,
moveSlice,
nextSliceNN,
readFrontmatter,
resolveMissionsRoot,
splitFrontmatter,
updateFrontmatter,
} from "../src/lib/scope/scope-fs.js";
function mktemp(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-test-"));
}
function writeFile(p: string, content: string): void {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content, "utf8");
}
describe("frontmatter parser", () => {
it("returns {} when there is no frontmatter delimiter", () => {
const { frontmatter, body } = splitFrontmatter("# heading\n\nbody");
expect(frontmatter).toEqual({});
expect(body).toBe("# heading\n\nbody");
});
it("parses simple key/value pairs", () => {
const src = "---\nid: OPR.0.3.2.12\nstatus: active\n---\nbody";
const { frontmatter, body } = splitFrontmatter(src);
expect(frontmatter).toEqual({ id: "OPR.0.3.2.12", status: "active" });
expect(body).toBe("body");
});
it("preserves unknown keys on update", () => {
const dir = mktemp();
const p = path.join(dir, "README.md");
writeFile(p, "---\nstatus: active\ncustom: keep-me\n---\nbody\n");
updateFrontmatter(p, { status: "shipped" });
const fm = readFrontmatter(p);
expect(fm.status).toBe("shipped");
expect(fm.custom).toBe("keep-me");
});
it("generates minimal frontmatter when absent", () => {
const dir = mktemp();
const p = path.join(dir, "README.md");
writeFile(p, "body only\n");
updateFrontmatter(p, { id: "OPR.0.3.2" });
const fm = readFrontmatter(p);
expect(fm.id).toBe("OPR.0.3.2");
});
});
describe("resolveMissionsRoot", () => {
it("uses an explicit override path when present", () => {
const root = mktemp();
fs.mkdirSync(path.join(root, "missions"));
expect(resolveMissionsRoot({ override: root })).toBe(path.join(root, "missions"));
});
it("throws ScopeCliError when no missions/ root is found", () => {
const empty = mktemp();
expect(() => resolveMissionsRoot({ override: empty, cwd: empty })).toThrow();
});
});
describe("listMissions + listSlices + nextSliceNN", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
// mission with 2 active + 1 closed slice
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\n# release-0.3.2\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "slices", "01-foo", "README.md"),
"---\nid: OPR.0.3.2.1\nstatus: active\n---\nbody\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "slices", "02-bar", "README.md"),
"---\nid: OPR.0.3.2.2\nstatus: active\n---\nbody\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "closed", "04-baz", "README.md"),
"---\nid: OPR.0.3.2.4\nstatus: closed-stale\n---\nbody\n",
);
// mission with no README — should be skipped as "no slice count basis"
fs.mkdirSync(path.join(missionsRoot, "no-readme"));
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
it("lists missions sorted by name with slice counts; SKIPS dirs without README.md (HG-8)", () => {
const missions = listMissions(missionsRoot);
// no-readme/ has no README.md and must NOT appear (HG-8 / PRD §2.1).
expect(missions.map((m) => m.name)).toEqual(["release-0.3.2"]);
const r = missions.find((m) => m.name === "release-0.3.2")!;
expect(r.activeSliceCount).toBe(2);
expect(r.closedSliceCount).toBe(1);
expect(r.id).toBe("OPR.0.3.2");
});
it("HG-8 discriminator: 3 dirs / 1 without README → exactly 2 missions listed", () => {
// Seed a third mission alongside the existing fixture for a clear
// make-it-fail-first signal per guard BC verdict.
writeFile(
path.join(missionsRoot, "backlog", "README.md"),
"---\nid: OPR.99.0.1\n---\n# backlog\n",
);
const missions = listMissions(missionsRoot);
expect(missions.map((m) => m.name).sort()).toEqual(["backlog", "release-0.3.2"]);
// The README-less directory survives on disk but does not show up.
expect(fs.existsSync(path.join(missionsRoot, "no-readme"))).toBe(true);
});
it("listSlices active filter excludes closed/shipped (HG-1)", () => {
const mission = findMission(missionsRoot, "release-0.3.2");
const active = listSlices(mission, "active");
expect(active.map((s) => s.name).sort()).toEqual(["01-foo", "02-bar"]);
const closed = listSlices(mission, "closed");
expect(closed.map((s) => s.name)).toEqual(["04-baz"]);
});
it("nextSliceNN skips numbers already used in slices/ AND closed/ (HG-3)", () => {
const missionAbs = path.join(missionsRoot, "release-0.3.2");
// existing: 01, 02, 04 → next should be 5 (not 3 — numbers never reused).
expect(nextSliceNN(missionAbs)).toBe(5);
});
});
describe("findSlice + resolution variants", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\nbody",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "slices", "07-target", "README.md"),
"---\nid: OPR.0.3.2.7\nstatus: active\n---\nbody",
);
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
it("resolves a mission-relative path with --mission hint", () => {
const slice = findSlice(missionsRoot, "07-target", "release-0.3.2");
expect(slice.name).toBe("07-target");
expect(slice.id).toBe("OPR.0.3.2.7");
});
it("resolves an absolute path", () => {
const abs = path.join(missionsRoot, "release-0.3.2", "slices", "07-target");
const slice = findSlice(missionsRoot, abs);
expect(slice.missionName).toBe("release-0.3.2");
});
it("3-part error when slice not found (HG-10)", () => {
expect(() => findSlice(missionsRoot, "99-missing", "release-0.3.2")).toThrow(/not found/);
});
});
describe("BC-2 BLOCK 2 — findMission rejects README-less directories", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\n",
);
// README-less directory next to a real mission — scratch/junk.
fs.mkdirSync(path.join(missionsRoot, "no-readme"));
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
it("findMission throws 3-part error when target directory has no README.md", () => {
expect(() => findMission(missionsRoot, "no-readme")).toThrow(/not a declared mission|no README/);
});
it("findMission still resolves declared missions normally", () => {
expect(findMission(missionsRoot, "release-0.3.2").name).toBe("release-0.3.2");
});
});
describe("ensureMissionId", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
it("returns existing frontmatter id when present", () => {
writeFile(
path.join(missionsRoot, "weird-name", "README.md"),
"---\nid: OPR.99.0.7\n---\n",
);
const m = findMission(missionsRoot, "weird-name");
expect(ensureMissionId(m, missionsRoot)).toBe("OPR.99.0.7");
});
it("derives from release-X.Y.Z pattern when no id present", () => {
writeFile(path.join(missionsRoot, "release-0.4.0", "README.md"), "");
const m = findMission(missionsRoot, "release-0.4.0");
expect(ensureMissionId(m, missionsRoot)).toBe("OPR.0.4.0");
});
it("falls into escape band for non-release names", () => {
writeFile(path.join(missionsRoot, "backlog", "README.md"), "");
const m = findMission(missionsRoot, "backlog");
const id = ensureMissionId(m, missionsRoot);
expect(id).toMatch(/^OPR\.99\.0\.\d+$/);
});
});
// ---------------------------------------------------------------------
// git mv path — uses real git in a tmp repo (HG-5, HG-11)
// ---------------------------------------------------------------------
function initRepo(root: string): void {
execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "config", "user.email", "test@example.com"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "config", "user.name", "Tester"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "commit", "--allow-empty", "-m", "init", "-q"], { stdio: "ignore" });
}
describe("moveSlice — git mv preserves history (HG-5) + refuses dirty tree (HG-11)", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
initRepo(root);
fs.mkdirSync(missionsRoot, { recursive: true });
writeFile(
path.join(missionsRoot, "backlog", "README.md"),
"---\nid: OPR.99.0.1\n---\n# backlog\n",
);
writeFile(
path.join(missionsRoot, "backlog", "slices", "01-foo", "README.md"),
"---\nid: OPR.99.0.1.1\nstatus: active\n---\n# foo\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\n# release\n",
);
execFileSync("git", ["-C", root, "add", "."], { stdio: "ignore" });
execFileSync("git", ["-C", root, "commit", "-m", "seed", "-q"], { stdio: "ignore" });
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
it("uses git mv inside a repo and preserves the file via git log --follow", () => {
const src = path.join(missionsRoot, "backlog", "slices", "01-foo");
const dest = path.join(missionsRoot, "release-0.3.2", "slices", "01-foo");
const result = moveSlice(src, dest);
expect(result.usedGit).toBe(true);
execFileSync("git", ["-C", root, "commit", "-m", "ship", "-q"], { stdio: "ignore" });
const log = execFileSync("git", [
"-C", root, "log", "--follow", "--pretty=format:%s", "--", path.relative(root, path.join(dest, "README.md")),
], { encoding: "utf8" });
expect(log).toMatch(/seed/);
expect(log).toMatch(/ship/);
});
it("refuses to move when slice has uncommitted local edits (HG-11)", () => {
const src = path.join(missionsRoot, "backlog", "slices", "01-foo");
fs.writeFileSync(path.join(src, "README.md"), "dirty\n", "utf8");
const dest = path.join(missionsRoot, "release-0.3.2", "slices", "02-foo");
expect(() => moveSlice(src, dest)).toThrow(/uncommitted/);
});
it("falls back to fs.rename outside a git repo", () => {
const dir = mktemp();
const src = path.join(dir, "src");
const dest = path.join(dir, "dest");
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, "README.md"), "body");
const result = moveSlice(src, dest);
expect(result.usedGit).toBe(false);
expect(fs.existsSync(dest)).toBe(true);
fs.rmSync(dir, { recursive: true, force: true });
});
it("BLOCK 4 discriminator: handles symlinked src/dest paths without escaping the repo (macOS /var↔/private/var class)", () => {
// Build a parallel tree of symlinks pointing at the real repo
// tree. Without realpath normalization inside moveSlice, path.relative
// would resolve to ../.. and git mv would reject with "outside
// repository". With normalization, git mv succeeds and history
// follows the move via --follow.
const linkParent = mktemp();
const linkRoot = path.join(linkParent, "linked-substrate");
fs.symlinkSync(root, linkRoot);
// Sanity: the symlinked tree resolves to a different absolute path
// than the real tree (covers the /var↔/private/var class on macOS
// and any other path-resolving symlink class on other OSes).
expect(fs.realpathSync(linkRoot)).not.toBe(linkRoot);
const src = path.join(linkRoot, "missions", "backlog", "slices", "01-foo");
const dest = path.join(linkRoot, "missions", "release-0.3.2", "slices", "01-foo");
const result = moveSlice(src, dest);
expect(result.usedGit).toBe(true);
expect(result.repoRoot).toBe(fs.realpathSync(root)); // git returned the realpath
// git mv staged a rename; history follows.
execFileSync("git", ["-C", root, "commit", "-m", "ship-via-symlink", "-q"], { stdio: "ignore" });
const log = execFileSync("git", [
"-C", root, "log", "--follow", "--pretty=format:%s", "--",
"missions/release-0.3.2/slices/01-foo/README.md",
], { encoding: "utf8" });
expect(log).toMatch(/seed/);
expect(log).toMatch(/ship-via-symlink/);
fs.rmSync(linkParent, { recursive: true, force: true });
});
});