mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
Merge OPR.0.4.0.35 rig scope stage and verified enforcement
This commit is contained in:
commit
51554eeec0
5 files changed
+835
-6
No files matched your search
@@ -14,11 +14,14 @@ import {
|
||||
CLOSE_REASONS,
|
||||
MISSION_TEMPLATE_KINDS,
|
||||
SLICE_TEMPLATE_KINDS,
|
||||
STAGE_VALUES,
|
||||
ScopeCliError,
|
||||
type CloseReason,
|
||||
type MissionTemplateKind,
|
||||
type SliceInfo,
|
||||
type SliceTemplateKind,
|
||||
type SliceState,
|
||||
type Stage,
|
||||
} from "../lib/scope/types.js";
|
||||
import {
|
||||
DEFAULT_PROJECT_PREFIX,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
sliceIdFromMission,
|
||||
} from "../lib/scope/dot-id.js";
|
||||
import {
|
||||
ensureMissionId,
|
||||
ensureMissionIdPersisted,
|
||||
findMission,
|
||||
findSlice,
|
||||
@@ -57,6 +61,7 @@ import {
|
||||
PROGRESS_STATUSES,
|
||||
setProgressRow,
|
||||
} from "../lib/scope/progress-edit.js";
|
||||
import { deriveScopeTrust } from "../lib/scope/trust.js";
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
@@ -122,6 +127,17 @@ function getOpts(cmd: Command): RootOpts {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** FR-5: a one-line human render of the derived stage. Shows the declared
|
||||
* stage, and (when a weak `verified` downgrades it) the effective stage +
|
||||
* why. Derived at read time; nothing is written. */
|
||||
function formatTrustLine(trust: ReturnType<typeof deriveScopeTrust>): string {
|
||||
const declared = trust.declaredStage || "—";
|
||||
if (trust.downgraded) {
|
||||
return ` stage: ${declared} (effective: ${trust.effectiveStage} — ${trust.verified.status})\n`;
|
||||
}
|
||||
return ` stage: ${declared}\n`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope slice ls
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -192,6 +208,8 @@ function buildSliceShowCommand(): Command {
|
||||
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 }));
|
||||
// FR-5: derive read-time trust from (stage x verified) — NEVER stored.
|
||||
const trust = deriveScopeTrust(slice.frontmatter);
|
||||
const payload = {
|
||||
ok: true,
|
||||
slice: {
|
||||
@@ -201,6 +219,7 @@ function buildSliceShowCommand(): Command {
|
||||
status: slice.status,
|
||||
path: slice.absPath,
|
||||
frontmatter: slice.frontmatter,
|
||||
trust,
|
||||
readme,
|
||||
children,
|
||||
},
|
||||
@@ -211,6 +230,7 @@ function buildSliceShowCommand(): Command {
|
||||
out.write(`Slice: ${slice.missionName}/${slice.name}\n`);
|
||||
out.write(` id: ${slice.id ?? "—"}\n`);
|
||||
out.write(` status: ${slice.status ?? "—"}\n`);
|
||||
out.write(formatTrustLine(trust));
|
||||
out.write(` path: ${slice.absPath}\n`);
|
||||
out.write(` children: ${children.length}\n`);
|
||||
if (readme) {
|
||||
@@ -541,6 +561,8 @@ function buildMissionShowCommand(): Command {
|
||||
const slices = listSlices(mission, "all").map((s) => ({
|
||||
name: s.name, id: s.id, status: s.status, nn: s.nn,
|
||||
}));
|
||||
// FR-5: derive read-time trust from (stage x verified) — NEVER stored.
|
||||
const trust = deriveScopeTrust(mission.frontmatter);
|
||||
const payload = {
|
||||
ok: true,
|
||||
mission: {
|
||||
@@ -550,6 +572,7 @@ function buildMissionShowCommand(): Command {
|
||||
activeSliceCount: mission.activeSliceCount,
|
||||
closedSliceCount: mission.closedSliceCount,
|
||||
frontmatter: mission.frontmatter,
|
||||
trust,
|
||||
readme,
|
||||
slices,
|
||||
},
|
||||
@@ -559,6 +582,7 @@ function buildMissionShowCommand(): Command {
|
||||
} else {
|
||||
out.write(`Mission: ${mission.name}\n`);
|
||||
out.write(` id: ${mission.id ?? "—"}\n`);
|
||||
out.write(formatTrustLine(trust));
|
||||
out.write(` active slices: ${mission.activeSliceCount}\n`);
|
||||
out.write(` closed slices: ${mission.closedSliceCount}\n`);
|
||||
out.write(` path: ${mission.absPath}\n`);
|
||||
@@ -1056,9 +1080,276 @@ function backfillScopeProgress(scopeDir: string, level: "mission" | "slice"): Ba
|
||||
return { scope: level, name, created: true, reason: "backfilled", path: progressPath };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// OPR.0.4.1.6 — stage + verified verbs (deterministic §2 maturity edits)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Validate a stage against the §2 enum, rejecting invented values. */
|
||||
function validateStage(raw: string): Stage {
|
||||
if (!STAGE_VALUES.includes(raw as Stage)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Invalid stage "${raw}".`,
|
||||
consequence: "Stage not changed.",
|
||||
action: `Use one of: ${STAGE_VALUES.join(" | ")}.`,
|
||||
});
|
||||
}
|
||||
return raw as Stage;
|
||||
}
|
||||
|
||||
/** Surgically set `stage` (+ `superseded-by` when superseded) on a scope
|
||||
* README, enforcing the §2 superseded-needs-successor rule. */
|
||||
function applyStage(readmePath: string, stage: Stage, successor: unknown): void {
|
||||
const updates: Record<string, unknown> = { stage };
|
||||
if (stage === "superseded") {
|
||||
const id = typeof successor === "string" ? successor.trim() : "";
|
||||
if (!id) {
|
||||
throw new ScopeCliError({
|
||||
fact: "stage 'superseded' requires a successor.",
|
||||
consequence: "Stage not changed (a superseded scope must name what replaces it, per scope-and-versioning §2).",
|
||||
action: "Re-run with --successor <id>, e.g. --successor OPR.0.4.1.7.",
|
||||
});
|
||||
}
|
||||
updates["superseded-by"] = id;
|
||||
}
|
||||
updateFrontmatter(readmePath, updates);
|
||||
}
|
||||
|
||||
/** `retired` is an exit, not a rung — warn (do not block). */
|
||||
function warnRetired(stage: Stage): void {
|
||||
if (stage === "retired") {
|
||||
process.stderr.write("Warning: stage 'retired' means do-not-use (an exit, not a maturity rung).\n");
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a --against provenance: mandatory, non-empty, non-whitespace
|
||||
* (the §2 "no bare timestamp" rule). Returns the trimmed source. */
|
||||
function validateAgainst(raw: unknown): string {
|
||||
const source = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!source) {
|
||||
throw new ScopeCliError({
|
||||
fact: "--against provenance is empty or missing.",
|
||||
consequence: "verified not stamped — scope-and-versioning §2 forbids a bare timestamp without a named source.",
|
||||
action: 'Provide what it was verified against, e.g. --against "runtime (npm+tag+origin)".',
|
||||
});
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function buildSliceStageCommand(): Command {
|
||||
return new Command("stage")
|
||||
.description(`Set a slice's epistemic stage (${STAGE_VALUES.join(" | ")}); superseded needs --successor`)
|
||||
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
|
||||
.argument("<new-stage>", `New stage: ${STAGE_VALUES.join(" | ")}`)
|
||||
.option("--successor <id>", "Successor scope id — REQUIRED when new-stage is superseded")
|
||||
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (slicePath: string, newStage: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const stage = validateStage(newStage);
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
|
||||
if (!slice.readmePath) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Slice ${slice.name} has no README.md.`,
|
||||
consequence: "Stage is a frontmatter field on the README; nothing to write.",
|
||||
action: "Create the slice README (rig scope slice create) before setting its stage.",
|
||||
});
|
||||
}
|
||||
applyStage(slice.readmePath, stage, opts.successor);
|
||||
warnRetired(stage);
|
||||
const supersededBy = stage === "superseded" ? String(opts.successor).trim() : undefined;
|
||||
emit(out, { ok: true, scope: { tier: "slice", mission: slice.missionName, name: slice.name, id: slice.id, stage, ...(supersededBy ? { supersededBy } : {}) } }, json, [
|
||||
`Set ${slice.missionName}/${slice.name} stage: ${stage}`,
|
||||
...(supersededBy ? [` superseded-by: ${supersededBy}`] : []),
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildMissionStageCommand(): Command {
|
||||
return new Command("stage")
|
||||
.description(`Set a mission's epistemic stage (${STAGE_VALUES.join(" | ")}); superseded needs --successor`)
|
||||
.argument("<mission>", "Mission name")
|
||||
.argument("<new-stage>", `New stage: ${STAGE_VALUES.join(" | ")}`)
|
||||
.option("--successor <id>", "Successor scope id — REQUIRED when new-stage is superseded")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (missionName: string, newStage: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const stage = validateStage(newStage);
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const mission = findMission(missionsRoot, missionName);
|
||||
if (!mission.readmePath) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Mission ${mission.name} has no README.md.`,
|
||||
consequence: "Stage is a frontmatter field on the README; nothing to write.",
|
||||
action: "Create the mission README before setting its stage.",
|
||||
});
|
||||
}
|
||||
applyStage(mission.readmePath, stage, opts.successor);
|
||||
warnRetired(stage);
|
||||
const supersededBy = stage === "superseded" ? String(opts.successor).trim() : undefined;
|
||||
emit(out, { ok: true, scope: { tier: "mission", name: mission.name, id: mission.id, stage, ...(supersededBy ? { supersededBy } : {}) } }, json, [
|
||||
`Set ${mission.name} stage: ${stage}`,
|
||||
...(supersededBy ? [` superseded-by: ${supersededBy}`] : []),
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildSliceVerifiedCommand(): Command {
|
||||
return new Command("verified")
|
||||
.description("Stamp a slice's verified line: <today> against <source> (provenance mandatory; overwrites the prior line)")
|
||||
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
|
||||
.option("--against <source>", "What it was verified against — MANDATORY (no bare timestamps)")
|
||||
.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 source = validateAgainst(opts.against);
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
|
||||
if (!slice.readmePath) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Slice ${slice.name} has no README.md.`,
|
||||
consequence: "verified is a frontmatter field on the README; nothing to write.",
|
||||
action: "Create the slice README before stamping verified.",
|
||||
});
|
||||
}
|
||||
const verified = `${todayDateISO()} against ${source}`;
|
||||
updateFrontmatter(slice.readmePath, { verified });
|
||||
emit(out, { ok: true, scope: { tier: "slice", mission: slice.missionName, name: slice.name, id: slice.id, verified } }, json, [
|
||||
`Stamped ${slice.missionName}/${slice.name} verified: ${verified}`,
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildMissionVerifiedCommand(): Command {
|
||||
return new Command("verified")
|
||||
.description("Stamp a mission's verified line: <today> against <source> (provenance mandatory; overwrites the prior line)")
|
||||
.argument("<mission>", "Mission name")
|
||||
.option("--against <source>", "What it was verified against — MANDATORY (no bare timestamps)")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (missionName: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const source = validateAgainst(opts.against);
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const mission = findMission(missionsRoot, missionName);
|
||||
if (!mission.readmePath) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Mission ${mission.name} has no README.md.`,
|
||||
consequence: "verified is a frontmatter field on the README; nothing to write.",
|
||||
action: "Create the mission README before stamping verified.",
|
||||
});
|
||||
}
|
||||
const verified = `${todayDateISO()} against ${source}`;
|
||||
updateFrontmatter(mission.readmePath, { verified });
|
||||
emit(out, { ok: true, scope: { tier: "mission", name: mission.name, id: mission.id, verified } }, json, [
|
||||
`Stamped ${mission.name} verified: ${verified}`,
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// OPR.0.4.1.6 FR-4 — frontmatter-conformance backfill (extends `repair`).
|
||||
// `repair` historically backfilled a missing PROGRESS.md only; per the
|
||||
// convention's "consolidate, do not invent" it now ALSO conforms the mandatory
|
||||
// scope-and-versioning §1/§2 frontmatter (id / stage / verified) in the SAME
|
||||
// idempotent verb, rather than adding a parallel `reconcile`.
|
||||
|
||||
interface FrontmatterConformResult {
|
||||
/** Minted+written id, or null if already present / unmintable. */
|
||||
idAdded: string | null;
|
||||
/** Added stage, or null if already present. */
|
||||
stageAdded: string | null;
|
||||
/** Added verified placeholder, or null if already present. */
|
||||
verifiedAdded: string | null;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/** Map a legacy `status:` to a §4 migration stage. Default `wip` when absent
|
||||
* or unmapped (the safe floor). */
|
||||
function mapLegacyStatusToStage(status: unknown): string {
|
||||
const s = typeof status === "string" ? status.toLowerCase().trim() : "";
|
||||
if (s === "placeholder") return "wip";
|
||||
if (s === "draft" || s === "draft-for-comms") return "wip";
|
||||
if (s === "active" || s === "in-flight") return "established";
|
||||
if (s.startsWith("shipped") || s.startsWith("closed")) return "established";
|
||||
if (s === "ready-for-mission" || s === "ready-for-orch-dispatch") return "provisional";
|
||||
return "wip";
|
||||
}
|
||||
|
||||
/** Idempotently conform a scope README's mandatory frontmatter. Only ADDS
|
||||
* absent fields — never clobbers an existing id/stage/verified. `mintId` is
|
||||
* called only when `id` is absent (it may persist a parent id per §1 lazy
|
||||
* adoption). */
|
||||
function conformReadmeFrontmatter(readmePath: string, mintId: () => string | null): FrontmatterConformResult {
|
||||
const fm = readFrontmatter(readmePath);
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
let idAdded: string | null = null;
|
||||
const hasId = typeof fm.id === "string" && fm.id.length > 0;
|
||||
if (!hasId) {
|
||||
const minted = mintId();
|
||||
if (minted) { idAdded = minted; updates.id = minted; }
|
||||
}
|
||||
|
||||
let stageAdded: string | null = null;
|
||||
if (!fm.stage) {
|
||||
stageAdded = mapLegacyStatusToStage(fm.status);
|
||||
updates.stage = stageAdded;
|
||||
}
|
||||
|
||||
let verifiedAdded: string | null = null;
|
||||
if (!fm.verified) {
|
||||
verifiedAdded = `${todayDateISO()} against backfill (rig scope repair)`;
|
||||
updates.verified = verifiedAdded;
|
||||
}
|
||||
|
||||
const changed = Object.keys(updates).length > 0;
|
||||
if (changed) updateFrontmatter(readmePath, updates);
|
||||
return { idAdded, stageAdded, verifiedAdded, changed };
|
||||
}
|
||||
|
||||
/** Mint a slice's id from its (persisted) parent mission id + NN — the §1
|
||||
* lazy parent-ID adoption site. Null when the folder has no NN. */
|
||||
function mintSliceIdClosure(slice: SliceInfo, missionsRoot: string): () => string | null {
|
||||
return () => {
|
||||
if (slice.nn == null) return null;
|
||||
const mission = findMission(missionsRoot, slice.missionName);
|
||||
const missionId = ensureMissionIdPersisted(mission, missionsRoot);
|
||||
return sliceIdFromMission(missionId, slice.nn);
|
||||
};
|
||||
}
|
||||
|
||||
function conformLines(scope: string, r: FrontmatterConformResult): string[] {
|
||||
if (!r.changed) return [` frontmatter: conformant (no change)`];
|
||||
const parts: string[] = [];
|
||||
if (r.idAdded) parts.push(`id=${r.idAdded}`);
|
||||
if (r.stageAdded) parts.push(`stage=${r.stageAdded}`);
|
||||
if (r.verifiedAdded) parts.push(`verified=${r.verifiedAdded}`);
|
||||
return [` frontmatter conformed: ${parts.join(", ")}`];
|
||||
}
|
||||
|
||||
function buildSliceRepairCommand(): Command {
|
||||
return new Command("repair")
|
||||
.description("Backfill a missing PROGRESS.md for a slice (idempotent; skips readme-only and existing rails)")
|
||||
.description("Backfill a slice's missing PROGRESS.md + conform mandatory frontmatter (id/stage/verified); idempotent")
|
||||
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
|
||||
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
|
||||
.option("--json", "Machine-readable output")
|
||||
@@ -1069,9 +1360,13 @@ function buildSliceRepairCommand(): Command {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
|
||||
const result = backfillScopeProgress(slice.absPath, "slice");
|
||||
emit(out, { ok: true, result }, json, [
|
||||
const frontmatter = slice.readmePath
|
||||
? conformReadmeFrontmatter(slice.readmePath, mintSliceIdClosure(slice, missionsRoot))
|
||||
: { idAdded: null, stageAdded: null, verifiedAdded: null, changed: false };
|
||||
emit(out, { ok: true, result, frontmatter }, json, [
|
||||
`${result.created ? "Backfilled" : "Skipped"} ${slice.name}: ${result.reason}`,
|
||||
...(result.path ? [` path: ${result.path}`] : []),
|
||||
...conformLines("slice", frontmatter),
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
@@ -1081,7 +1376,7 @@ function buildSliceRepairCommand(): Command {
|
||||
|
||||
function buildMissionRepairCommand(): Command {
|
||||
return new Command("repair")
|
||||
.description("Backfill missing PROGRESS.md for a mission and its slices (idempotent; skips readme-only)")
|
||||
.description("Backfill missing PROGRESS.md + conform mandatory frontmatter (id/stage/verified) for a mission and its slices; idempotent")
|
||||
.argument("<mission>", "Mission name")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (missionName: string, opts, command) => {
|
||||
@@ -1099,10 +1394,28 @@ function buildMissionRepairCommand(): Command {
|
||||
results.push(backfillScopeProgress(path.join(slicesDir, entry.name), "slice"));
|
||||
}
|
||||
}
|
||||
|
||||
// FR-4: conform mandatory frontmatter — mission first (mints+persists
|
||||
// the mission id), then each slice (child ids derive from the now-
|
||||
// persisted parent id).
|
||||
const conformed: Array<{ scope: "mission" | "slice"; name: string; frontmatter: FrontmatterConformResult }> = [];
|
||||
if (mission.readmePath) {
|
||||
const fm = conformReadmeFrontmatter(mission.readmePath, () => ensureMissionId(mission, missionsRoot));
|
||||
conformed.push({ scope: "mission", name: mission.name, frontmatter: fm });
|
||||
}
|
||||
const freshMission = findMission(missionsRoot, mission.name);
|
||||
for (const slice of listSlices(freshMission, "all")) {
|
||||
if (!slice.readmePath) continue;
|
||||
const fm = conformReadmeFrontmatter(slice.readmePath, mintSliceIdClosure(slice, missionsRoot));
|
||||
conformed.push({ scope: "slice", name: slice.name, frontmatter: fm });
|
||||
}
|
||||
|
||||
const created = results.filter((r) => r.created);
|
||||
emit(out, { ok: true, mission: mission.name, created, results }, json, [
|
||||
`Repaired ${mission.name}: ${created.length} PROGRESS.md backfilled`,
|
||||
...created.map((r) => ` + ${r.scope}/${r.name}`),
|
||||
const fmChanged = conformed.filter((c) => c.frontmatter.changed);
|
||||
emit(out, { ok: true, mission: mission.name, created, results, conformed }, json, [
|
||||
`Repaired ${mission.name}: ${created.length} PROGRESS.md backfilled, ${fmChanged.length} frontmatter conformed`,
|
||||
...created.map((r) => ` + PROGRESS ${r.scope}/${r.name}`),
|
||||
...fmChanged.map((c) => ` ~ frontmatter ${c.scope}/${c.name}`),
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
@@ -1128,6 +1441,8 @@ export function scopeCommand(): Command {
|
||||
slice.addCommand(buildSliceMoveCommand());
|
||||
slice.addCommand(buildSliceProgressCommand());
|
||||
slice.addCommand(buildSliceRepairCommand());
|
||||
slice.addCommand(buildSliceStageCommand());
|
||||
slice.addCommand(buildSliceVerifiedCommand());
|
||||
cmd.addCommand(slice);
|
||||
|
||||
const mission = new Command("mission").description("Mission-tier commands");
|
||||
@@ -1136,6 +1451,8 @@ export function scopeCommand(): Command {
|
||||
mission.addCommand(buildMissionCreateCommand());
|
||||
mission.addCommand(buildMissionProgressCommand());
|
||||
mission.addCommand(buildMissionRepairCommand());
|
||||
mission.addCommand(buildMissionStageCommand());
|
||||
mission.addCommand(buildMissionVerifiedCommand());
|
||||
cmd.addCommand(mission);
|
||||
cmd.addCommand(buildAuditCommand());
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// OPR.0.4.1.6 — read-time-trust derivation for `rig scope` (FR-5).
|
||||
//
|
||||
// The scope-and-versioning convention §2 keystone: an `established`/`canonical`
|
||||
// artifact whose `verified` is stale, absent, bare, or a not-yet-verified
|
||||
// placeholder is treated as `provisional`. Trust is DERIVED at read time from
|
||||
// (stage x verified) and NEVER stored (a stored composite re-rots).
|
||||
//
|
||||
// This is the static-artifact sibling of the daemon's skill-audit. The 90-day
|
||||
// freshness window + the bare/stale/verified taxonomy MIRROR
|
||||
// `packages/daemon/src/domain/skill-audit.ts:34` (`FRESHNESS_WINDOW_DAYS = 90`)
|
||||
// + its `checkStaleness` (:118-126). The daemon module is not importable from
|
||||
// the CLI (separate package; the daemon is a spawned app, not a library), so we
|
||||
// mirror the constant + taxonomy here rather than cross-import. The convention
|
||||
// itself names no age window — 90 days is the load-bearing pin shared with
|
||||
// skill-audit.
|
||||
|
||||
/** Mirror of skill-audit.ts:34 — keep in sync if that window changes. */
|
||||
export const FRESHNESS_WINDOW_DAYS = 90;
|
||||
|
||||
export type VerifiedTrust =
|
||||
| "verified" // <date> against <real source>, within the window
|
||||
| "stale_verified" // <date> against <real source>, older than the window
|
||||
| "bare_verified" // present but no parseable `<date> against <source>`
|
||||
| "missing_verified" // absent / empty
|
||||
| "unverified_provenance"; // a tool-scaffolded placeholder source, not a real verification
|
||||
|
||||
export interface VerifiedTrustResult {
|
||||
status: VerifiedTrust;
|
||||
date?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const VERIFIED_RE = /^(\d{4}-\d{2}-\d{2})\s+against\s+(.+)$/i;
|
||||
|
||||
// Tool-written placeholder provenances. None is a real verification against a
|
||||
// named source, so an established/canonical carrying one cannot coast as fresh
|
||||
// (the exact "looks fresh while lying" trap §2 closes). `rig scope verified`
|
||||
// replaces these with a real source.
|
||||
const PLACEHOLDER_PROVENANCE_RE =
|
||||
/\bscaffold \(rig scope create\)|\bbackfill \(rig scope repair\)|\(unverified\)/i;
|
||||
|
||||
function isValidISODate(dateStr: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return false;
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return false;
|
||||
// Reject overflowed components (e.g. 2026-13-99 → Date rolls over).
|
||||
return d.toISOString().slice(0, 10) === dateStr;
|
||||
}
|
||||
|
||||
/** Classify a `verified:` frontmatter value. Mirrors skill-audit checkStaleness. */
|
||||
export function deriveVerifiedTrust(verified: unknown, now: Date = new Date()): VerifiedTrustResult {
|
||||
if (typeof verified !== "string" || verified.trim().length === 0) {
|
||||
return { status: "missing_verified" };
|
||||
}
|
||||
const m = VERIFIED_RE.exec(verified.trim());
|
||||
if (!m) {
|
||||
return { status: "bare_verified" };
|
||||
}
|
||||
const dateStr = m[1]!;
|
||||
const source = m[2]!.trim();
|
||||
if (!isValidISODate(dateStr)) {
|
||||
return { status: "bare_verified", date: dateStr };
|
||||
}
|
||||
if (PLACEHOLDER_PROVENANCE_RE.test(source)) {
|
||||
return { status: "unverified_provenance", date: dateStr, source };
|
||||
}
|
||||
const daysDiff = (now.getTime() - new Date(dateStr).getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysDiff > FRESHNESS_WINDOW_DAYS) {
|
||||
return { status: "stale_verified", date: dateStr, source };
|
||||
}
|
||||
return { status: "verified", date: dateStr, source };
|
||||
}
|
||||
|
||||
export interface EffectiveStageResult {
|
||||
/** The stage as written in frontmatter (echoed, may be empty). */
|
||||
declaredStage: string;
|
||||
/** The stage to actually trust right now. */
|
||||
effectiveStage: string;
|
||||
/** True when the declared stage was downgraded by a weak `verified`. */
|
||||
downgraded: boolean;
|
||||
/** One-line why, or null when not downgraded. */
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
/** Derive the effective (trust-adjusted) stage. Only `established`/`canonical`
|
||||
* can be downgraded — they are the stages that claim "rely on me", so a weak
|
||||
* `verified` knocks them back to `provisional`. wip/provisional/exits are
|
||||
* echoed unchanged. */
|
||||
export function deriveEffectiveStage(stage: unknown, verifiedStatus: VerifiedTrust): EffectiveStageResult {
|
||||
const declaredStage = typeof stage === "string" ? stage : "";
|
||||
const claimsReliable = declaredStage === "established" || declaredStage === "canonical";
|
||||
const trusted = verifiedStatus === "verified";
|
||||
if (claimsReliable && !trusted) {
|
||||
return {
|
||||
declaredStage,
|
||||
effectiveStage: "provisional",
|
||||
downgraded: true,
|
||||
reason: `${declaredStage} with ${verifiedStatus} verified -> treated as provisional (scope-and-versioning §2)`,
|
||||
};
|
||||
}
|
||||
return { declaredStage, effectiveStage: declaredStage, downgraded: false, reason: null };
|
||||
}
|
||||
|
||||
/** Convenience: derive full read-time trust from a parsed frontmatter object. */
|
||||
export function deriveScopeTrust(
|
||||
frontmatter: Record<string, unknown>,
|
||||
now: Date = new Date(),
|
||||
): { verified: VerifiedTrustResult } & EffectiveStageResult {
|
||||
const verified = deriveVerifiedTrust(frontmatter.verified, now);
|
||||
const effective = deriveEffectiveStage(frontmatter.stage, verified.status);
|
||||
return { verified, ...effective };
|
||||
}
|
||||
@@ -95,3 +95,23 @@ export const CLOSE_REASONS: ReadonlyArray<CloseReason> = [
|
||||
"superseded",
|
||||
"stale",
|
||||
];
|
||||
|
||||
/** OPR.0.4.1.6 — the scope-and-versioning §2 epistemic-maturity `stage` enum
|
||||
* (4 rungs + 2 exits). The ONLY valid stage values; invented values
|
||||
* (shape/shaped/draft) are rejected. `superseded` must name its successor;
|
||||
* `retired` means do-not-use. */
|
||||
export type Stage =
|
||||
| "wip"
|
||||
| "provisional"
|
||||
| "established"
|
||||
| "canonical"
|
||||
| "superseded"
|
||||
| "retired";
|
||||
export const STAGE_VALUES: ReadonlyArray<Stage> = [
|
||||
"wip",
|
||||
"provisional",
|
||||
"established",
|
||||
"canonical",
|
||||
"superseded",
|
||||
"retired",
|
||||
];
|
||||
@@ -735,3 +735,295 @@ describe("rig scope audit edge cases (guard BLOCKING fixes)", () => {
|
||||
expect(sliceEntry.findings.some((f: { kind: string }) => f.kind === "id_convention_violation" || f.kind === "slice_naming_convention")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// OPR.0.4.1.6 — FR-1 stage verb (deterministic maturity)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope <tier> stage (OPR.0.4.1.6 FR-1)", () => {
|
||||
let substrate: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { substrate = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(substrate.root, { recursive: true, force: true }); });
|
||||
|
||||
const sliceReadme = () => path.join(substrate.missionsRoot, "release-0.3.2", "slices", "01-existing", "README.md");
|
||||
|
||||
it("AC-1: sets stage surgically, preserving id + other fields", async () => {
|
||||
const r = await run(["slice", "stage", "01-existing", "provisional", "--mission", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const fm = readFrontmatter(sliceReadme());
|
||||
expect(fm.stage).toBe("provisional");
|
||||
expect(fm.id).toBe("OPR.0.3.2.1"); // untouched
|
||||
expect(fm.status).toBe("active"); // untouched
|
||||
});
|
||||
|
||||
it("AC-1: rejects an invalid stage value and names the valid enum", async () => {
|
||||
const r = await run(["slice", "stage", "01-existing", "shape", "--mission", "release-0.3.2"], substrate.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toMatch(/wip.*provisional.*established.*canonical.*superseded.*retired/s);
|
||||
expect(readFrontmatter(sliceReadme()).stage).toBeUndefined(); // not written
|
||||
});
|
||||
|
||||
it("AC-1: superseded WITHOUT --successor is rejected (names the rule)", async () => {
|
||||
const r = await run(["slice", "stage", "01-existing", "superseded", "--mission", "release-0.3.2"], substrate.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toMatch(/successor/i);
|
||||
expect(readFrontmatter(sliceReadme()).stage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("AC-1: superseded WITH --successor sets stage + records the successor", async () => {
|
||||
const r = await run(["slice", "stage", "01-existing", "superseded", "--successor", "OPR.0.3.2.7", "--mission", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const fm = readFrontmatter(sliceReadme());
|
||||
expect(fm.stage).toBe("superseded");
|
||||
expect(fm["superseded-by"]).toBe("OPR.0.3.2.7");
|
||||
});
|
||||
|
||||
it("retired sets the stage and warns (exit 0)", async () => {
|
||||
const r = await run(["slice", "stage", "01-existing", "retired", "--mission", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(readFrontmatter(sliceReadme()).stage).toBe("retired");
|
||||
});
|
||||
|
||||
it("mission stage sets the mission README stage", async () => {
|
||||
const r = await run(["mission", "stage", "release-0.3.2", "established", "--json"], substrate.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const fm = readFrontmatter(path.join(substrate.missionsRoot, "release-0.3.2", "README.md"));
|
||||
expect(fm.stage).toBe("established");
|
||||
expect(fm.id).toBe("OPR.0.3.2");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// OPR.0.4.1.6 — FR-2 verified verb (provenance-stamped freshness)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope <tier> verified (OPR.0.4.1.6 FR-2)", () => {
|
||||
let substrate: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { substrate = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(substrate.root, { recursive: true, force: true }); });
|
||||
|
||||
const sliceReadme = () => path.join(substrate.missionsRoot, "release-0.3.2", "slices", "01-existing", "README.md");
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
it("AC-2: stamps verified: <today> against <source>, preserving id", async () => {
|
||||
const r = await run(["slice", "verified", "01-existing", "--against", "runtime (npm+tag)", "--mission", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const fm = readFrontmatter(sliceReadme());
|
||||
expect(fm.verified).toBe(`${today()} against runtime (npm+tag)`);
|
||||
expect(fm.id).toBe("OPR.0.3.2.1");
|
||||
});
|
||||
|
||||
it("AC-2: rejects empty/whitespace --against (no bare timestamps)", async () => {
|
||||
const r = await run(["slice", "verified", "01-existing", "--against", " ", "--mission", "release-0.3.2"], substrate.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toMatch(/against|provenance|source/i);
|
||||
expect(readFrontmatter(sliceReadme()).verified).toBeUndefined();
|
||||
});
|
||||
|
||||
it("AC-2: rejects a missing --against entirely", async () => {
|
||||
const r = await run(["slice", "verified", "01-existing", "--mission", "release-0.3.2"], substrate.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("AC-2: overwrites a prior verified line in place", async () => {
|
||||
await run(["slice", "verified", "01-existing", "--against", "first source", "--mission", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
await run(["slice", "verified", "01-existing", "--against", "second source", "--mission", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
const fm = readFrontmatter(sliceReadme());
|
||||
expect(fm.verified).toBe(`${today()} against second source`);
|
||||
});
|
||||
|
||||
it("mission verified stamps the mission README", async () => {
|
||||
const r = await run(["mission", "verified", "release-0.3.2", "--against", "founder review", "--json"], substrate.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(readFrontmatter(path.join(substrate.missionsRoot, "release-0.3.2", "README.md")).verified).toBe(`${today()} against founder review`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// OPR.0.4.1.6 — FR-4 repair frontmatter-conformance backfill
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope repair frontmatter conformance (OPR.0.4.1.6 FR-4)", () => {
|
||||
let substrate: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { substrate = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(substrate.root, { recursive: true, force: true }); });
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
it("AC-4: backfills a missing id + stage + verified on a ghost slice (idempotent re-run)", async () => {
|
||||
const ghost = path.join(substrate.missionsRoot, "release-0.3.2", "slices", "05-ghost");
|
||||
writeFile(path.join(ghost, "README.md"), "---\nstatus: placeholder\n---\n# ghost\n");
|
||||
const ghostReadme = path.join(ghost, "README.md");
|
||||
|
||||
const r1 = await run(["slice", "repair", "05-ghost", "--mission", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
expect(r1.exitCode).toBe(0);
|
||||
const fm1 = readFrontmatter(ghostReadme);
|
||||
expect(fm1.id).toBe("OPR.0.3.2.5"); // minted from parent + NN, registered
|
||||
expect(fm1.stage).toBe("wip"); // placeholder -> wip (migration map)
|
||||
expect(fm1.verified).toBe(`${today()} against backfill (rig scope repair)`);
|
||||
|
||||
// Idempotent: a second run changes nothing.
|
||||
await run(["slice", "repair", "05-ghost", "--mission", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
const fm2 = readFrontmatter(ghostReadme);
|
||||
expect(fm2.id).toBe("OPR.0.3.2.5");
|
||||
expect(fm2.stage).toBe("wip");
|
||||
expect(fm2.verified).toBe(`${today()} against backfill (rig scope repair)`);
|
||||
});
|
||||
|
||||
it("AC-4: does NOT clobber an existing real verified line", async () => {
|
||||
const slice = path.join(substrate.missionsRoot, "release-0.3.2", "slices", "06-has-verified");
|
||||
writeFile(path.join(slice, "README.md"), "---\nid: OPR.0.3.2.6\nstage: established\nverified: 2026-01-02 against runtime (npm+tag)\n---\n# v\n");
|
||||
await run(["slice", "repair", "06-has-verified", "--mission", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
const fm = readFrontmatter(path.join(slice, "README.md"));
|
||||
expect(fm.verified).toBe("2026-01-02 against runtime (npm+tag)"); // untouched
|
||||
expect(fm.stage).toBe("established"); // untouched
|
||||
});
|
||||
|
||||
it("AC-4: mission repair conforms the mission README frontmatter too (lazy parent-id)", async () => {
|
||||
// A mission whose README has no id/stage/verified, plus a ghost slice.
|
||||
const m = path.join(substrate.missionsRoot, "backlog-new");
|
||||
writeFile(path.join(m, "README.md"), "---\nmission: backlog-new\n---\n# new\n");
|
||||
writeFile(path.join(m, "slices", "01-foo", "README.md"), "---\nstatus: active\n---\n# foo\n");
|
||||
const r = await run(["mission", "repair", "backlog-new", "--json"], substrate.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const mfm = readFrontmatter(path.join(m, "README.md"));
|
||||
expect(typeof mfm.id).toBe("string"); // escape-band id minted
|
||||
expect(mfm.stage).toBe("wip");
|
||||
expect(mfm.verified).toBe(`${today()} against backfill (rig scope repair)`);
|
||||
const sfm = readFrontmatter(path.join(m, "slices", "01-foo", "README.md"));
|
||||
expect(typeof sfm.id).toBe("string"); // child id = parentId.1
|
||||
expect(sfm.id).toBe(`${mfm.id}.1`);
|
||||
expect(sfm.stage).toBe("established"); // active -> established (migration map)
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// OPR.0.4.1.6 — FR-5 read-time-trust in show (DERIVE, never store)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope show read-time trust (OPR.0.4.1.6 FR-5)", () => {
|
||||
let substrate: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { substrate = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(substrate.root, { recursive: true, force: true }); });
|
||||
|
||||
const dAgo = (n: number) => new Date(Date.now() - n * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
function seedSlice(name: string, fm: string): string {
|
||||
const dir = path.join(substrate.missionsRoot, "release-0.3.2", "slices", name);
|
||||
writeFile(path.join(dir, "README.md"), fm);
|
||||
return path.join(dir, "README.md");
|
||||
}
|
||||
|
||||
it("AC-5: established + STALE verified shows effectively provisional (derived)", async () => {
|
||||
seedSlice("07-stale", `---\nid: OPR.0.3.2.7\nstage: established\nverified: ${dAgo(200)} against runtime\n---\n# stale\n`);
|
||||
const r = await run(["slice", "show", "07-stale", "--mission", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
const p = JSON.parse(r.stdout);
|
||||
expect(p.slice.trust.declaredStage).toBe("established");
|
||||
expect(p.slice.trust.effectiveStage).toBe("provisional");
|
||||
expect(p.slice.trust.downgraded).toBe(true);
|
||||
expect(p.slice.trust.verified.status).toBe("stale_verified");
|
||||
});
|
||||
|
||||
it("AC-5: established + FRESH verified stays established", async () => {
|
||||
seedSlice("08-fresh", `---\nid: OPR.0.3.2.8\nstage: established\nverified: ${dAgo(5)} against runtime\n---\n# fresh\n`);
|
||||
const r = await run(["slice", "show", "08-fresh", "--mission", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
const p = JSON.parse(r.stdout);
|
||||
expect(p.slice.trust.effectiveStage).toBe("established");
|
||||
expect(p.slice.trust.downgraded).toBe(false);
|
||||
expect(p.slice.trust.verified.status).toBe("verified");
|
||||
});
|
||||
|
||||
it("AC-5: established + SCAFFOLD provenance shows effectively provisional regardless of date", async () => {
|
||||
seedSlice("09-scaffold", `---\nid: OPR.0.3.2.9\nstage: established\nverified: ${dAgo(1)} against scaffold (rig scope create)\n---\n# s\n`);
|
||||
const r = await run(["slice", "show", "09-scaffold", "--mission", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
const p = JSON.parse(r.stdout);
|
||||
expect(p.slice.trust.effectiveStage).toBe("provisional");
|
||||
expect(p.slice.trust.verified.status).toBe("unverified_provenance");
|
||||
});
|
||||
|
||||
it("AC-5: show DERIVES trust and does NOT write it back to disk (no stored composite)", async () => {
|
||||
const readmePath = seedSlice("10-nowrite", `---\nid: OPR.0.3.2.10\nstage: canonical\nverified: ${dAgo(200)} against runtime\n---\n# n\n`);
|
||||
const before = fs.readFileSync(readmePath, "utf8");
|
||||
await run(["slice", "show", "10-nowrite", "--mission", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
const after = fs.readFileSync(readmePath, "utf8");
|
||||
expect(after).toBe(before); // byte-identical — nothing written back
|
||||
const fm = readFrontmatter(readmePath);
|
||||
expect(fm.trust).toBeUndefined();
|
||||
expect(fm.effectiveStage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("mission show also derives read-time trust", async () => {
|
||||
writeFile(
|
||||
path.join(substrate.missionsRoot, "release-0.3.2", "README.md"),
|
||||
`---\nid: OPR.0.3.2\nstage: canonical\nverified: ${dAgo(300)} against design\n---\n# release-0.3.2\n`,
|
||||
);
|
||||
const r = await run(["mission", "show", "release-0.3.2", "--json"], substrate.missionsRoot);
|
||||
const p = JSON.parse(r.stdout);
|
||||
expect(p.mission.trust.effectiveStage).toBe("provisional");
|
||||
expect(p.mission.trust.downgraded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// OPR.0.4.1.6 — FR-3 create regression-lock (already ships; guard the templates)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope create regression-lock (OPR.0.4.1.6 FR-3)", () => {
|
||||
let substrate: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { substrate = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(substrate.root, { recursive: true, force: true }); });
|
||||
|
||||
const WELL_FORMED_VERIFIED = /^\d{4}-\d{2}-\d{2} against .+$/;
|
||||
|
||||
it("AC-3: fresh slice create writes id + stage + a well-formed verified line", async () => {
|
||||
const r = await run(["slice", "create", "release-0.3.2", "new-feature", "--json"], substrate.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const fm = readFrontmatter(path.join(substrate.missionsRoot, "release-0.3.2", "slices", "02-new-feature", "README.md"));
|
||||
expect(fm.id).toBe("OPR.0.3.2.2");
|
||||
expect(fm.stage).toBe("wip");
|
||||
expect(String(fm.verified)).toMatch(WELL_FORMED_VERIFIED);
|
||||
});
|
||||
|
||||
it("AC-3: fresh mission create writes id + stage + a well-formed verified line", async () => {
|
||||
const r = await run(["mission", "create", "release-0.5.0", "--json"], substrate.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const fm = readFrontmatter(path.join(substrate.missionsRoot, "release-0.5.0", "README.md"));
|
||||
expect(typeof fm.id).toBe("string");
|
||||
expect((fm.id as string).length).toBeGreaterThan(0);
|
||||
expect(fm.stage).toBe("wip");
|
||||
expect(String(fm.verified)).toMatch(WELL_FORMED_VERIFIED);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// OPR.0.4.1.6 — FR-6 help teaches the new verbs + enforcement rules
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("OPR.0.4.1.6 FR-6 — help", () => {
|
||||
const renderHelp = (cmd: Command) => cmd.helpInformation().replace(/\s+/g, " ");
|
||||
|
||||
it("stage + verified verbs are registered on both tiers with descriptions", () => {
|
||||
const cmd = scopeCommand();
|
||||
for (const tierName of ["slice", "mission"]) {
|
||||
const tier = cmd.commands.find((c) => c.name() === tierName)!;
|
||||
const verbs = tier.commands.map((c) => c.name());
|
||||
expect(verbs).toContain("stage");
|
||||
expect(verbs).toContain("verified");
|
||||
for (const verb of tier.commands) expect(verb.description()).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("stage help teaches the superseded-needs-successor rule + the valid enum", () => {
|
||||
const stage = scopeCommand().commands.find((c) => c.name() === "slice")!.commands.find((c) => c.name() === "stage")!;
|
||||
const help = renderHelp(stage);
|
||||
expect(help).toMatch(/superseded/i);
|
||||
expect(help).toMatch(/successor/i);
|
||||
expect(help).toMatch(/wip.*provisional.*established.*canonical/i);
|
||||
});
|
||||
|
||||
it("verified help teaches provenance-mandatory", () => {
|
||||
const verified = scopeCommand().commands.find((c) => c.name() === "slice")!.commands.find((c) => c.name() === "verified")!;
|
||||
const help = renderHelp(verified);
|
||||
expect(help).toMatch(/against|provenance|mandatory|source/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// OPR.0.4.1.6 — read-time-trust derivation for rig scope (FR-5).
|
||||
// Mirrors the scope-and-versioning §2 keystone: trust is DERIVED at read time
|
||||
// from (stage x verified), never stored. 90-day freshness window mirrors
|
||||
// packages/daemon/src/domain/skill-audit.ts:34.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FRESHNESS_WINDOW_DAYS,
|
||||
deriveVerifiedTrust,
|
||||
deriveEffectiveStage,
|
||||
} from "../src/lib/scope/trust.js";
|
||||
|
||||
const NOW = new Date("2026-06-20T00:00:00Z");
|
||||
|
||||
function daysAgo(n: number): string {
|
||||
const d = new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
describe("deriveVerifiedTrust (mirrors skill-audit checkStaleness, 90-day window)", () => {
|
||||
it("pins the freshness window at 90 days", () => {
|
||||
expect(FRESHNESS_WINDOW_DAYS).toBe(90);
|
||||
});
|
||||
|
||||
it("verified within the window with a real source => verified", () => {
|
||||
const r = deriveVerifiedTrust(`${daysAgo(19)} against runtime (npm+tag)`, NOW);
|
||||
expect(r.status).toBe("verified");
|
||||
expect(r.source).toBe("runtime (npm+tag)");
|
||||
});
|
||||
|
||||
it("BOUNDARY: 89 days => verified (fresh)", () => {
|
||||
expect(deriveVerifiedTrust(`${daysAgo(89)} against founder review`, NOW).status).toBe("verified");
|
||||
});
|
||||
|
||||
it("BOUNDARY: 91 days => stale_verified", () => {
|
||||
expect(deriveVerifiedTrust(`${daysAgo(91)} against founder review`, NOW).status).toBe("stale_verified");
|
||||
});
|
||||
|
||||
it("BOUNDARY: unparseable/bare date => bare_verified", () => {
|
||||
expect(deriveVerifiedTrust("sometime against the docs", NOW).status).toBe("bare_verified");
|
||||
expect(deriveVerifiedTrust("2026-13-99 against the docs", NOW).status).toBe("bare_verified");
|
||||
});
|
||||
|
||||
it("missing/empty verified => missing_verified", () => {
|
||||
expect(deriveVerifiedTrust(undefined, NOW).status).toBe("missing_verified");
|
||||
expect(deriveVerifiedTrust(" ", NOW).status).toBe("missing_verified");
|
||||
});
|
||||
|
||||
it("a scaffold/backfill/unverified provenance is NOT a real verification (regardless of date)", () => {
|
||||
expect(deriveVerifiedTrust(`${daysAgo(1)} against scaffold (rig scope create)`, NOW).status).toBe("unverified_provenance");
|
||||
expect(deriveVerifiedTrust(`${daysAgo(1)} against backfill (rig scope repair)`, NOW).status).toBe("unverified_provenance");
|
||||
expect(deriveVerifiedTrust(`${daysAgo(1)} against (unverified)`, NOW).status).toBe("unverified_provenance");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveEffectiveStage (the §2 self-correcting downgrade)", () => {
|
||||
it("established + fresh verified stays established", () => {
|
||||
const r = deriveEffectiveStage("established", "verified");
|
||||
expect(r.effectiveStage).toBe("established");
|
||||
expect(r.downgraded).toBe(false);
|
||||
});
|
||||
|
||||
it("canonical + fresh verified stays canonical", () => {
|
||||
expect(deriveEffectiveStage("canonical", "verified").effectiveStage).toBe("canonical");
|
||||
});
|
||||
|
||||
it("established + stale verified => effectively provisional", () => {
|
||||
const r = deriveEffectiveStage("established", "stale_verified");
|
||||
expect(r.effectiveStage).toBe("provisional");
|
||||
expect(r.downgraded).toBe(true);
|
||||
expect(r.reason).toMatch(/stale/);
|
||||
});
|
||||
|
||||
it("established + missing verified => effectively provisional", () => {
|
||||
expect(deriveEffectiveStage("established", "missing_verified").effectiveStage).toBe("provisional");
|
||||
});
|
||||
|
||||
it("canonical + bare/unverified provenance => effectively provisional", () => {
|
||||
expect(deriveEffectiveStage("canonical", "bare_verified").effectiveStage).toBe("provisional");
|
||||
expect(deriveEffectiveStage("canonical", "unverified_provenance").effectiveStage).toBe("provisional");
|
||||
});
|
||||
|
||||
it("wip/provisional are never downgraded (only established/canonical can coast)", () => {
|
||||
expect(deriveEffectiveStage("wip", "missing_verified").downgraded).toBe(false);
|
||||
expect(deriveEffectiveStage("provisional", "stale_verified").downgraded).toBe(false);
|
||||
expect(deriveEffectiveStage("provisional", "stale_verified").effectiveStage).toBe("provisional");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user