Compare commits

...
Author SHA1 Message Date
mvschwarzandClaude Opus 4.7 a7e56382bf fix(cli): slice 12 BC-2 fixes — escape-band exact-[99.0.n] + findMission README gate
Addresses BLOCKING-CONCERN re-verdict on 79a3099a (guard qitem
qitem-20260516072314-9c3dd1e5; 2 specific findings).

BLOCK 1 — escape-band positional grammar under-enforced.
  Per `conventions/scope-and-versioning/README.md` §1 the non-release
  escape band is exactly <PFX>.99.0.<n> — the `0` segment after `99`
  is FIXED, not just "starts with 99". The BC-1 fix only checked
  "depth-3 starting with 99", which accepted OPR.99.7.8 and
  OPR.99.7.8.5 as conformant.

  Fix: extracted isValidMissionVerSegments(segs) shared between
  isMissionDotId and isSliceDotId's parent-version check. When the
  first segment is 99, require length===3 AND segs[1]==="0" AND
  segs[2] is a non-empty digit string. Both helpers now reject
  OPR.99.7.8 / OPR.99.7.8.1 while still accepting OPR.99.0.42 /
  OPR.99.0.42.7.

  Discriminators (5 new):
    - isMissionDotId rejects OPR.99.7.8 + OPR.99.1.0
    - isMissionDotId still accepts OPR.99.0.42 + OPR.99.0.1
    - isSliceDotId rejects OPR.99.7.8.1; accepts OPR.99.0.42.7
    - CLI: rig scope mission create bad-eb --id OPR.99.7.8 exits 1

BLOCK 2 — findMission did not share listMissions' README gate.
  After BC-1, listMissions skipped README-less directories per HG-8,
  but findMission still returned them as MissionInfo with
  readmePath:null. That left mission show / slice create / slice ship
  / slice move able to silently target scratch/junk dirs and bypass
  the lazy-parent-id-persist contract.

  Fix: findMission now throws a 3-part ScopeCliError when the target
  directory exists but has no README.md, naming the case explicitly
  and pointing operators at `rig scope mission create <name>` to
  declare the dir. All mutation surfaces inherit the guard via the
  shared findMission/findSlice path.

  Discriminators (6 new):
    - findMission(no-readme) throws
    - CLI: rig scope mission show no-readme → exits 1 with
      `not a declared mission` / `no README.md` in error.fact
    - CLI: rig scope slice create no-readme foo → exits 1; slices/01-foo
      NOT created on disk
    - CLI: rig scope slice ship 01-debt-foo no-readme → exits 1;
      source 01-debt-foo unmoved
    - CLI: rig scope slice move 01-debt-foo no-readme → exits 1;
      source unmoved

Verification:
- packages/cli npx tsc --noEmit: clean
- packages/cli npx vitest run on slice 12 files: 74/74 PASS
  (was 64/64 on 79a3099a; +10 BC-2 discriminators)

Stable mint path unchanged: inferMissionDotId already produces
OPR.99.0.<n>, so existing escape-band mission auto-mint flows through
the tightened validator without any new errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-16 00:25:57 -07:00
mvschwarzandClaude Opus 4.7 79a3099a63 fix(cli): slice 12 BC fixes — tier-aware ID + README gate + persist parent-id + symlink test
Addresses BLOCKING-CONCERN verdict on 0b77cba4 (guard qitem
qitem-20260516071333-0a9e7184; 4 specific findings, each with fix
recipe + make-it-fail-first discriminator).

BLOCK 1 — mission --id validation is not tier-aware.
  Added isMissionDotId + isSliceDotId helpers that enforce the
  positional depth invariants:
    mission   = 2-3 numeric segments; escape-band is exactly 3 with
                first segment 99
    slice     = 3-4 numeric segments; same escape-band exact-3 rule
                on the parent-ver portion
    sub-slice = parseDotId(s, "sub-slice") peels n + m
  parseDotId now accepts an optional `tier` arg ("mission"|"slice"|
  "sub-slice"); without it returns all segments as version
  (backward-compat). mission --id validation switched from the
  tier-agnostic isConformantDotId to isMissionDotId so a slice-shaped
  --id (OPR.0.3.2.12) is rejected with a 3-part error that names
  mission-shape vs slice-shape explicitly.

  Discriminators:
    - parseDotId("OPR.0.3.2.12", "slice") yields version=0.3.2, n=12
    - parseDotId("OPR.0.3.2", "mission") yields version=0.3.2, n=undefined
    - isMissionDotId rejects OPR.0.3.2.12 / OPR.99.0.1.5 / OPR.A.1 /
      OPR.99.0 (escape band must be exactly 3 segments)
    - CLI: `rig scope mission create wrong-tier --id OPR.0.3.2.12`
      exits 1 with `error.fact: not a mission-tier dot-ID`

BLOCK 2 — mission ls included no-README dirs contrary to HG-8.
  listMissions now skips directories without a README.md per PRD §2.1.
  The existing fixture test's assertion was inverted (it asserted the
  no-README dir WAS listed); corrected and added a fresh discriminator
  test seeding 3 dirs / 1 without README → only 2 listed.

BLOCK 3 — lazy parent-id adoption was persisted only for slice create.
  Added ensureMissionIdPersisted(mission, missionsRoot) helper that
  returns the mission id and, when mission.readmePath exists and
  mission.id is absent, writes it back to the target README in the
  same call. Threaded through slice create + slice ship + slice move
  so every child-mint site adopts the parent's id consistently
  (per convention §1 lazy-adoption rule). Sibling missions without
  id remain untouched — narrow create-triggered, not mass migrate.

  Discriminators (3 new tests):
    - slice ship to an id-less release-0.3.2 → target README gains
      `id: OPR.0.3.2`; child id = OPR.0.3.2.NN
    - slice move with the same setup
    - sibling mission "untouched-mission" without id remains
      byte-for-byte identical after the ship completes

BLOCK 4 — macOS /var realpath proof was overclaimed.
  Added a focused symlink regression test that:
    1. mktemp a parallel symlink tree pointing at the real repo
    2. asserts realpathSync(linkRoot) !== linkRoot (covers the
       /var↔/private/var class AND any other path-resolving symlink)
    3. invokes moveSlice with src/dest paths flowing through the
       symlinked tree
    4. asserts result.usedGit is true, result.repoRoot equals the
       git-returned realpath, and git log --follow on the dest
       picks up the seed history
  The test fails if scope-fs.ts is reverted to compute path.relative
  against an un-normalized repo root.

Verification:
- packages/cli npx tsc --noEmit: clean
- packages/cli npx vitest run on slice 12 files: 64/64 PASS
  (was 49/49 on 0b77cba4; +15 BC discriminators)
- packages/cli full vitest: 1018/1020 PASS — same 2 pre-existing
  preflight failures present on main; not introduced here

No production behavior regression: existing scope tests still pass
verbatim. New behavior is strictly additive (tier-aware validation
on a path that was previously over-permissive) or strictly more
correct (mission ls README gate; persisted parent-id) or strictly
more covered (symlink discriminator).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-16 00:20:48 -07:00
mvschwarzandClaude Opus 4.7 0b77cba4c5 feat(cli): rig scope CLI primitive — mission + slice tiers (release-0.3.2 slice 12)
Implements the `rig scope <tier> <verb>` command family per
`openrig-work/missions/release-0.3.2/slices/12-rig-slice-cli-primitive/IMPLEMENTATION-PRD.md`
(Amendment §A–E, 2026-05-15) and `conventions/scope-and-versioning/README.md`
(provisional, §1 dot-ID grammar).

v0 ships the mission + slice tiers (project + sub-slice reserved). The
CLI mints stable dot-IDs into created mission/slice frontmatter — IDs
live in frontmatter, never in folder names ("names are prompts; IDs
are the stable handle"). Mission creation is first-class in v0 per
Amendment §B (the rename's whole rationale: making a mission must
feel as light as making a slice).

Commands:
  rig scope slice ls [--mission <m>] [--state active|closed|shipped|all] [--json]
  rig scope slice show <slice-path> [--mission <m>] [--json]
  rig scope slice create <mission> <slug> [--template <kind>] [--json]
  rig scope slice ship <slice-path> <release-mission> [--json]
  rig scope slice close <slice-path> --reason <wontfix|deferred|superseded|stale> [--note <text>] [--json]
  rig scope slice move <slice-path> <dest-mission> [--json]
  rig scope mission ls [--json]
  rig scope mission show <mission> [--json]
  rig scope mission create <name> [--template <kind>] [--id <dot-id>] [--json]

New code:
  packages/cli/src/commands/scope.ts                       — command surface
  packages/cli/src/lib/scope/types.ts                      — scope tier types + ScopeCliError
  packages/cli/src/lib/scope/dot-id.ts                     — §1 grammar parse/mint
  packages/cli/src/lib/scope/scope-fs.ts                   — fs+git+frontmatter helpers
  packages/cli/src/lib/scope/templates.ts                  — template loader
  packages/cli/src/lib/scope-templates/{placeholder,bug-fix,backlog-deprecation,backlog-tech-debt,release-feature,research}.md
  packages/cli/src/lib/scope-templates/{mission-placeholder,mission-release}.md

Tests (49 new):
  packages/cli/test/scope-dot-id.test.ts                   — grammar parse/format/mint
  packages/cli/test/scope-fs.test.ts                       — frontmatter, discovery, auto-numbering, git mv (incl. macOS /var symlink normalization)
  packages/cli/test/scope-commands.test.ts                 — end-to-end command surface covering HG-1..HG-15

Hard gates verified (HG-1..HG-15):
  HG-1  slice ls --state filter        ✓ scope-commands.test.ts
  HG-2  slice show renders fm + README ✓
  HG-3  create auto-finds next NN; numbers never reused ✓
  HG-4  --template populates content   ✓
  HG-5  ship uses git mv; history preserved via --follow ✓ scope-fs.test.ts
  HG-6  close --reason updates status  ✓
  HG-7  move re-numbers in destination ✓
  HG-8  mission ls lists missions      ✓
  HG-9  --json output on every command ✓
  HG-10 3-part error shape             ✓
  HG-11 dirty working tree refused     ✓
  HG-12 --help present on every command ✓
  HG-13 typecheck + vitest green       ✓ (49 new pass; 0 new regressions; 2 pre-existing preflight failures present on main)
  HG-14 mission create symmetry        ✓ (same positional name + --template + --json shape)
  HG-15 dot-ID minted into frontmatter ✓ (release-pattern + escape-band + explicit --id)

Sequencing note (Amendment §E): built against
`conventions/scope-and-versioning/` stage:provisional. The convention's
§4 mass-migration of stage/verified frontmatter is OUT of scope here
(separate slice-02 adoption step). New artifacts created by this CLI
mint id + stage: wip + status by design; older artifacts remain
untouched.

build pipeline: package.json build script now copies
src/lib/scope-templates/*.md to dist/lib/scope-templates/ so the
template loader resolves the same files at runtime whether running
from source (dev) or dist (published).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-16 00:05:05 -07:00
mvschwarzandClaude Opus 4.7 86da3ce8a7 docs: de-number domain-services in README architecture diagram
The architecture diagram annotated "Domain services (52)" — a count
from the 2026-04-10 launch README rewrite, pre-0.2.0. The actual
domain-module count is now ~132 (the daemon grew substantially across
0.2.0/0.3.0/0.3.1). A precise count in an architecture diagram is a
maintenance liability that re-rots every release; the diagram conveys
the layering without it. Dropped the parenthetical rather than
chasing the number.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-15 02:48:30 -07:00
19 changed files with 2837 additions and 2 deletions

No files matched your search

+1 -1
View File
@@ -94,7 +94,7 @@ CLI / UI / MCP
|
Hono HTTP daemon
|
Domain services (52)
Domain services
|
SQLite + tmux + runtime adapters
```
+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", "[email protected]"], { 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", "[email protected]"], { 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 });
});
});