From 51c2bd9d4d9729f65c3457cc78227f41a22616f6 Mon Sep 17 00:00:00 2001 From: Mike Schwarz <171890339+mvschwarz@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:29:40 -0800 Subject: [PATCH] fix(cli): scope slice ls and mission ls tell shipped, superseded and closed apart (#2434) `rig scope` now gives each slice one standing and counts it once. A slice in `closed/` is closed; one whose status is shipped* or done is shipped; one whose status is closed* or superseded, or whose stage is superseded or retired, is closed; everything else is active, blocked included. `slice ls --state` filters on that standing (the default stays `active`, and the help says what each value means), and `mission ls` and `mission show` count by it, with a new `shipped=` count, so a mission whose slices all shipped no longer reads as all active. A malformed slice file reads as empty frontmatter instead of throwing. The advisory dependency graph reads the active set, so it no longer offers a superseded slice as ready to build. The daemon's copy of the scope types matches the CLI's. Reported in #2427; thanks to @gifflen. --- packages/cli/src/commands/scope.ts | 8 ++- packages/cli/src/lib/scope/scope-fs.ts | 88 ++++++++++------------- packages/cli/src/lib/scope/types.ts | 3 + packages/cli/test/scope-commands.test.ts | 11 +++ packages/cli/test/scope-fs.test.ts | 38 ++++++++++ packages/daemon/src/domain/scope/types.ts | 3 + 6 files changed, 97 insertions(+), 54 deletions(-) diff --git a/packages/cli/src/commands/scope.ts b/packages/cli/src/commands/scope.ts index 143aae57c..6818d5c19 100644 --- a/packages/cli/src/commands/scope.ts +++ b/packages/cli/src/commands/scope.ts @@ -213,7 +213,7 @@ function buildSliceLsCommand(): Command { const cmd = new Command("ls") .description("List slices in a mission (or across all missions)") .option("--mission ", "Restrict to a single mission") - .option("--state ", "Filter: active | closed | shipped | all", "active") + .option("--state ", "Filter: active (in flight, blocked included) | shipped (delivered in place: shipped or done) | closed (in closed/, or superseded, retired or closed in place) | all", "active") .option("--json", "Machine-readable output") .action(async (opts, command) => { const out = makeStdout(); @@ -727,12 +727,13 @@ function buildMissionLsCommand(): Command { id: m.id, path: m.absPath, activeSliceCount: m.activeSliceCount, + shippedSliceCount: m.shippedSliceCount, 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}`), + : rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} shipped=${r.shippedSliceCount} closed=${r.closedSliceCount}`), ); } catch (err) { fail(err, json, out); @@ -764,6 +765,7 @@ function buildMissionShowCommand(): Command { id: mission.id, path: mission.absPath, activeSliceCount: mission.activeSliceCount, + shippedSliceCount: mission.shippedSliceCount, closedSliceCount: mission.closedSliceCount, frontmatter: mission.frontmatter, trust, @@ -778,6 +780,7 @@ function buildMissionShowCommand(): Command { out.write(` id: ${mission.id ?? "—"}\n`); out.write(formatTrustLine(trust)); out.write(` active slices: ${mission.activeSliceCount}\n`); + out.write(` shipped slices: ${mission.shippedSliceCount}\n`); out.write(` closed slices: ${mission.closedSliceCount}\n`); out.write(` path: ${mission.absPath}\n`); if (readme) { @@ -1100,6 +1103,7 @@ function buildAuditCommand(): Command { frontmatter: {}, id: null, activeSliceCount: 0, + shippedSliceCount: 0, closedSliceCount: 0, }; const graph = buildMissionDependencyGraph(auditMission); diff --git a/packages/cli/src/lib/scope/scope-fs.ts b/packages/cli/src/lib/scope/scope-fs.ts index 8ecde139f..b41b23fa1 100644 --- a/packages/cli/src/lib/scope/scope-fs.ts +++ b/packages/cli/src/lib/scope/scope-fs.ts @@ -362,31 +362,23 @@ export function listMissions(missionsRoot: string): MissionInfo[] { const hasReadme = readmePath !== null; 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, - }); + out.push(withSliceCounts({ name: entry.name, absPath, readmePath, frontmatter, id })); } // 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; +/** The mission with each of its slices counted once, by its standing (the same one `slice ls` filters on). */ +function withSliceCounts(base: Omit): MissionInfo { + const mission: MissionInfo = { ...base, activeSliceCount: 0, shippedSliceCount: 0, closedSliceCount: 0 }; + for (const { standing } of slicesWithStanding(mission)) { + if (standing === "active") mission.activeSliceCount++; + else if (standing === "shipped") mission.shippedSliceCount++; + else mission.closedSliceCount++; + } + return mission; } function pickIdFromFrontmatter(fm: Record): string | null { @@ -425,15 +417,7 @@ export function findMission(missionsRoot: string, identifier: string): MissionIn function buildMissionInfo(missionsRoot: string, absPath: string): MissionInfo { const readmePath = resolveNodeFile(absPath); const frontmatter = readmePath ? readFrontmatter(readmePath) : {}; - return { - name: path.basename(absPath), - absPath, - readmePath, - frontmatter, - id: pickIdFromFrontmatter(frontmatter), - activeSliceCount: countSliceDirs(path.join(absPath, "slices")), - closedSliceCount: countSliceDirs(path.join(absPath, "closed")), - }; + return withSliceCounts({ name: path.basename(absPath), absPath, readmePath, frontmatter, id: pickIdFromFrontmatter(frontmatter) }); } // --------------------------------------------------------------------- @@ -444,35 +428,35 @@ 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) { + return slicesWithStanding(mission) + .filter(({ standing }) => state === "all" || standing === state) + .map(({ slice }) => slice); +} + +/** Where a slice stands. In closed/, it's closed. In place: shipped (or done) is delivered; a closed or superseded + * status, or a superseded or retired stage, is an exit, so closed; anything else, blocked work included, is active. */ +export function sliceStanding(slice: SliceInfo, inClosedFolder: boolean): Exclude { + if (inClosedFolder) return "closed"; + const status = (slice.status ?? "").toLowerCase(); + const stage = typeof slice.frontmatter.stage === "string" ? slice.frontmatter.stage.toLowerCase() : ""; + if (status === "done" || status.startsWith("shipped")) return "shipped"; + if (status.startsWith("closed") || status === "superseded" || stage === "superseded" || stage === "retired") return "closed"; + return "active"; +} + +/** Every slice in the mission (slices/ and closed/), in order, each with its standing. */ +function slicesWithStanding(mission: MissionInfo): Array<{ slice: SliceInfo; standing: Exclude }> { + const out: Array<{ slice: SliceInfo; standing: Exclude }> = []; + for (const [folder, inClosedFolder] of [["slices", false], ["closed", true]] as const) { + const root = path.join(mission.absPath, folder); 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 (!entry.isDirectory() || !SLICE_DIRNAME_RE.test(entry.name)) continue; + const slice = buildSliceInfo(mission, root, entry.name); + out.push({ slice, standing: sliceStanding(slice, inClosedFolder) }); } } - out.sort((a, b) => (a.nn ?? Number.MAX_SAFE_INTEGER) - (b.nn ?? Number.MAX_SAFE_INTEGER) || a.name.localeCompare(b.name)); - if (state === "active") { - // active = not in closed/, and not already delivered in-place. - return out.filter((s) => { - const st = (s.status ?? "").toLowerCase(); - return st !== "done" && !st.startsWith("closed") && !st.startsWith("shipped"); - }); - } - if (state === "shipped") { - return out.filter((s) => (s.status ?? "").toLowerCase().startsWith("shipped")); - } + out.sort((a, b) => (a.slice.nn ?? Number.MAX_SAFE_INTEGER) - (b.slice.nn ?? Number.MAX_SAFE_INTEGER) || a.slice.name.localeCompare(b.slice.name)); return out; } diff --git a/packages/cli/src/lib/scope/types.ts b/packages/cli/src/lib/scope/types.ts index 2fb9634e2..73a7655e2 100644 --- a/packages/cli/src/lib/scope/types.ts +++ b/packages/cli/src/lib/scope/types.ts @@ -30,7 +30,10 @@ export interface MissionInfo { /** Minted dot-ID for this mission (read from frontmatter if present, * otherwise inferred from the folder name pattern). */ id: string | null; + /** Slices in flight (blocked included); delivered in place; and closed (closed/, or an exit in place). Each slice is + * counted once, by the standing `slice ls --state` filters on. */ activeSliceCount: number; + shippedSliceCount: number; closedSliceCount: number; } diff --git a/packages/cli/test/scope-commands.test.ts b/packages/cli/test/scope-commands.test.ts index f738a0199..c5f9ae3af 100644 --- a/packages/cli/test/scope-commands.test.ts +++ b/packages/cli/test/scope-commands.test.ts @@ -678,6 +678,17 @@ describe("rig scope mission ls (HG-8)", () => { const names = parsed.missions.map((m: { name: string }) => m.name).sort(); expect(names).toEqual(["backlog", "release-0.3.2"]); }); + + it("counts a mission's shipped slices apart from its active ones (#2427)", async () => { + for (const nn of ["02-shipped-a", "03-shipped-b"]) { + writeFile(path.join(env.missionsRoot, "release-0.3.2", "slices", nn, "README.md"), `---\nid: OPR.0.3.2.${nn[1]}\nstatus: shipped\n---\n# ${nn}\n`); + } + const text = await run(["mission", "ls"], env.missionsRoot); + expect(text.stdout).toContain("release-0.3.2 OPR.0.3.2 active=1 shipped=2 closed=0"); + const json = JSON.parse((await run(["mission", "ls", "--json"], env.missionsRoot)).stdout); + expect(json.missions.find((m: { name: string }) => m.name === "release-0.3.2")) + .toMatchObject({ activeSliceCount: 1, shippedSliceCount: 2, closedSliceCount: 0 }); + }); }); describe("rig scope mission show", () => { diff --git a/packages/cli/test/scope-fs.test.ts b/packages/cli/test/scope-fs.test.ts index 7cf5d8263..0a255930e 100644 --- a/packages/cli/test/scope-fs.test.ts +++ b/packages/cli/test/scope-fs.test.ts @@ -326,6 +326,44 @@ describe("listMissions + listSlices + nextSliceNN", () => { }); }); +describe("a slice's standing: in flight, delivered in place, or closed (#2427)", () => { + let root: string; + let missionsRoot: string; + + beforeEach(() => { + root = mktemp(); + missionsRoot = path.join(root, "missions"); + writeFile(path.join(missionsRoot, "m1", "README.md"), "---\nid: OPR.9.1\n---\n# m1\n"); + const slice = (dir: string, frontmatter: string) => writeFile(path.join(missionsRoot, "m1", dir, "README.md"), `---\n${frontmatter}\n---\nbody\n`); + slice("slices/01-open", "id: OPR.9.1.1\nstatus: active"); + slice("slices/02-blocked", "id: OPR.9.1.2\nstatus: blocked"); + slice("slices/03-shipped", "id: OPR.9.1.3\nstatus: shipped"); + slice("slices/04-done", "id: OPR.9.1.4\nstatus: done"); + slice("slices/05-replaced", "id: OPR.9.1.5\nstatus: active\nstage: superseded\nsuperseded-by: OPR.9.1.9"); + slice("slices/06-superseded", "id: OPR.9.1.6\nstatus: superseded"); + slice("closed/07-stale", "id: OPR.9.1.7\nstatus: closed-stale"); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("slice ls: active is work in flight (blocked included); shipped is delivered in place; closed includes exits in place", () => { + const mission = findMission(missionsRoot, "m1"); + const names = (state: "active" | "shipped" | "closed" | "all") => listSlices(mission, state).map((s) => s.name); + expect(names("active")).toEqual(["01-open", "02-blocked"]); + expect(names("shipped")).toEqual(["03-shipped", "04-done"]); + expect(names("closed")).toEqual(["05-replaced", "06-superseded", "07-stale"]); + expect(names("all")).toHaveLength(7); + }); + + it("mission counts give each slice one standing, the same as slice ls", () => { + const counts = { activeSliceCount: 2, shippedSliceCount: 2, closedSliceCount: 3 }; + expect(listMissions(missionsRoot)[0]).toMatchObject(counts); + expect(findMission(missionsRoot, "m1")).toMatchObject(counts); + }); +}); + describe("findSlice + resolution variants", () => { let root: string; let missionsRoot: string; diff --git a/packages/daemon/src/domain/scope/types.ts b/packages/daemon/src/domain/scope/types.ts index 2fb9634e2..73a7655e2 100644 --- a/packages/daemon/src/domain/scope/types.ts +++ b/packages/daemon/src/domain/scope/types.ts @@ -30,7 +30,10 @@ export interface MissionInfo { /** Minted dot-ID for this mission (read from frontmatter if present, * otherwise inferred from the folder name pattern). */ id: string | null; + /** Slices in flight (blocked included); delivered in place; and closed (closed/, or an exit in place). Each slice is + * counted once, by the standing `slice ls --state` filters on. */ activeSliceCount: number; + shippedSliceCount: number; closedSliceCount: number; }