merge: slice 21 FR-3 — MISSION_NOTES durable-pattern hardening (release-0.3.2)

This commit is contained in:
mvschwarz committed 2026-06-01 05:24:54 -07:00
commit 58713e0c58
4 files changed
+295 -11

No files matched your search

+43 -11
View File
@@ -42,6 +42,7 @@ import {
updateFrontmatter,
} from "../lib/scope/scope-fs.js";
import {
renderMissionNotesTemplate,
renderMissionTemplate,
renderSliceTemplate,
titleFromSlug,
@@ -558,6 +559,7 @@ function buildMissionCreateCommand(): Command {
.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("--no-mission-notes", "Skip the auto-scaffold of MISSION_NOTES.md (rare; default is to scaffold from conventions/mission-notes/TEMPLATE.md)")
.option("--json", "Machine-readable output")
.action(async (rawName: string, opts, command) => {
const out = makeStdout();
@@ -614,12 +616,17 @@ function buildMissionCreateCommand(): Command {
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 });
// Resolve titles + render templates BEFORE any filesystem side
// effects (banked verify-first-then-write — guard catch on
// OPR.0.3.2.21.FR-3 qitem-20260601121058: a stale
// OPENRIG_MISSION_NOTES_TEMPLATE_PATH used to throw AFTER
// mkdirSync + README write, leaking a half-created mission dir
// that future `mission create <same-name>` would reject as
// already-existing). Rendering MISSION_NOTES first means a
// stale env-var fails before any disk mutation.
const title = opts.title ?? titleFromSlug(name.replace(/^release-/, ""));
const releaseVersion = isReleaseName ? name.replace(/^release-/, "") : "";
const body = renderMissionTemplate(templateKind, {
const readmeBody = renderMissionTemplate(templateKind, {
id,
slug: name,
mission: name,
@@ -627,8 +634,36 @@ function buildMissionCreateCommand(): Command {
created_date: todayDateISO(),
release_version: releaseVersion,
});
let missionNotesRendered: { rendered: string; resolvedFrom: "env" | "built-in" } | null = null;
if (opts.missionNotes !== false) {
const r = renderMissionNotesTemplate({
mission_id: id,
mission_name: title,
created_date: todayDateISO(),
});
missionNotesRendered = { rendered: r.rendered, resolvedFrom: r.resolvedFrom };
}
// All renders succeeded — safe to touch the filesystem.
fs.mkdirSync(absPath, { recursive: true });
fs.mkdirSync(path.join(absPath, "slices"), { recursive: true });
const readmePath = path.join(absPath, "README.md");
fs.writeFileSync(readmePath, body, "utf8");
fs.writeFileSync(readmePath, readmeBody, "utf8");
let missionNotesPath: string | null = null;
let missionNotesResolvedFrom: "env" | "built-in" | null = null;
if (missionNotesRendered) {
missionNotesPath = path.join(absPath, "MISSION_NOTES.md");
fs.writeFileSync(missionNotesPath, missionNotesRendered.rendered, "utf8");
missionNotesResolvedFrom = missionNotesRendered.resolvedFrom;
}
const humanLines = [
`Created mission ${name}`,
` id: ${id}`,
` template: ${templateKind}`,
` path: ${absPath}`,
];
if (missionNotesPath) {
humanLines.push(` mission-notes: ${missionNotesPath} (template: ${missionNotesResolvedFrom})`);
}
emit(out, {
ok: true,
mission: {
@@ -637,13 +672,10 @@ function buildMissionCreateCommand(): Command {
template: templateKind,
path: absPath,
readmePath,
missionNotesPath,
missionNotesResolvedFrom,
},
}, json, [
`Created mission ${name}`,
` id: ${id}`,
` template: ${templateKind}`,
` path: ${absPath}`,
]);
}, json, humanLines);
} catch (err) {
fail(err, json, out);
}
@@ -0,0 +1,97 @@
---
mission: {{mission_id}}
name: {{mission_name}}
status: active
authored: {{created_date}}
last-updated: {{created_date}}
---
# Mission Notes — {{mission_name}}
> Read the frontmatter `last-updated` first. If stale relative to runtime,
> treat this file as `provisional` and re-verify before relying on it.
> Pointer-first by design — index durable canonical artifacts; don't duplicate
> them (duplication is the stale-breadcrumb failure mode this convention
> exists to kill).
## §1. Top-of-mind context
(Current most-load-bearing state — gates, open scope-decisions, in-flight
surprises. Updated by anyone when material changes. Keep to 5-15 lines.)
## §2. Active workstreams + drivers + tips
(Per-workstream rows. THIS SECTION IS THE MISSION CODEMAP. Each row:
state / driver / branch+tip-SHA / latest blocker or ACK.)
- (workstream-id) — state — driver — tip-SHA — blocker/ACK
## §3. Open scope-decisions
(Labeled gates the operator owns. Per gate: where the gate is, what's needed,
who routed.)
- OQ-1: (decision needed) — gate at <artifact-path> — surfaced by <seat>
## §4. Slice inventory
(Full table of slices on this mission.)
| # | slice | state | driver | blocker |
|---|---|---|---|---|
## §5. Pending dispatches
(Next-go items waiting on driver availability or convergence. Immediately-ready
set, not full backlog.)
## §6. Ledgers
(Mission-specific shared ledgers: release version + tag, SC counts, cumulative
deltas, etc.)
## §7. Banked memories that apply here
(Bulleted: banked-feedback / banked-project memories load-bearing on restore
for this mission. Ones learned during this mission go into the same list.)
## §8. Convention pointers
(Which banked conventions this mission inherits. Cross-reference each by path.
This section is the principles/conventions inheritance surface for the mission.)
- `conventions/mission-notes/README.md` — this convention (apply on every restore)
## §9. Reconstruction protocol
(Commands to run after compaction or for onboarding. Refresh as the mission's
restore-needs evolve.)
1. `rig whoami --json` — confirm seat identity
2. Read this file's §1 + your §A-§X section + this §9
3. Read `missions/{{mission_id}}/README.md` for mission scope
4. Read `missions/{{mission_id}}/PROGRESS.md` for delivery state
5. `rig queue list --destination <your-session>` — your durable inbox
6. State "restored from {{mission_id}}; resumed at <action>" before acting
## §10. What NOT to reconstruct
(Explicit pruning — completed slices / merged commits / closed ACK packets are
recoverable on demand and don't need to be in working memory. Name them to
free attention.)
## §A. <first-seat>@<rig> notes
(First seat's notes. Pattern: append cont.N entries with date + state of the
world from this seat's perspective. Latest = truth. Other seats READ but
don't modify.)
DONE {{created_date}} (cont.0 — mission scaffolded):
- Mission scaffolded via `rig scope mission create`. Template applied; this
file is the seed state. First substantive cont.1 entry follows when work
begins.
## §B / §C / ... — per-seat sections
(Each additional seat adds an h2 ## §X section under their own header as they
join. Section pattern matches §A.)
+64
View File
@@ -72,6 +72,70 @@ export function renderMissionTemplate(kind: MissionTemplateKind, opts: RenderOpt
return applyPlaceholders(raw, opts);
}
// OPR.0.3.2.21.FR-3 — MISSION_NOTES.md scaffold support.
//
// `rig scope mission create` auto-copies a MISSION_NOTES.md alongside
// the README. The template lives at scope-templates/mission-notes.md
// by default; the operator can override via the
// OPENRIG_MISSION_NOTES_TEMPLATE_PATH env var (banked env-var-pivot
// pattern: env > built-in fallback).
export interface MissionNotesRenderOpts {
mission_id: string;
mission_name: string;
created_date: string;
}
function applyMissionNotesPlaceholders(content: string, opts: MissionNotesRenderOpts): string {
return content
.replace(/\{\{mission_id\}\}/g, opts.mission_id)
.replace(/\{\{mission_name\}\}/g, opts.mission_name)
.replace(/\{\{created_date\}\}/g, opts.created_date);
}
/**
* Resolve the MISSION_NOTES.md template absolute path.
* Env-var override > built-in bundled fallback (scope-templates/mission-notes.md).
* Returns the absolute path; the caller reads the file content.
*/
export function resolveMissionNotesTemplatePath(envValue?: string): string {
const fromEnv = envValue ?? process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
if (fromEnv && fromEnv.trim().length > 0) {
const absPath = path.resolve(fromEnv.trim());
if (!fs.existsSync(absPath)) {
throw new ScopeCliError({
fact: `OPENRIG_MISSION_NOTES_TEMPLATE_PATH points at "${fromEnv}", which does not exist.`,
consequence: "MISSION_NOTES.md not scaffolded.",
action: "Set OPENRIG_MISSION_NOTES_TEMPLATE_PATH to an absolute path to a readable template file, or unset it to use the built-in bundled fallback.",
});
}
return absPath;
}
return resolveTemplate("mission-notes.md");
}
/**
* Render a MISSION_NOTES.md by reading the resolved template + substituting
* the three placeholders (mission_id / mission_name / created_date).
*
* Surfaces the resolution decision (env-override vs built-in) for callers
* that want to log or test the path.
*/
export function renderMissionNotesTemplate(
opts: MissionNotesRenderOpts,
envValue?: string,
): { rendered: string; resolvedFrom: "env" | "built-in"; absPath: string } {
const envVal = envValue ?? process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
const usedEnv = Boolean(envVal && envVal.trim().length > 0);
const absPath = resolveMissionNotesTemplatePath(envValue);
const raw = fs.readFileSync(absPath, "utf8");
return {
rendered: applyMissionNotesPlaceholders(raw, opts),
resolvedFrom: usedEnv ? "env" : "built-in",
absPath,
};
}
/** Convert a folder-slug to a title-cased display name. */
export function titleFromSlug(slug: string): string {
return slug
+91
View File
@@ -391,6 +391,97 @@ describe("rig scope mission create (HG-14 + HG-15)", () => {
expect(r.exitCode).toBe(1);
expect(JSON.parse(r.stdout).ok).toBe(false);
});
// OPR.0.3.2.21.FR-3 — auto-scaffold MISSION_NOTES.md alongside README.
it("FR-3: mission create auto-scaffolds MISSION_NOTES.md with placeholders substituted (default ON; built-in template)", async () => {
const r = await run(["mission", "create", "release-0.6.0", "--json"], env.missionsRoot);
expect(r.exitCode).toBe(0);
const parsed = JSON.parse(r.stdout);
expect(parsed.mission.missionNotesPath).toBeTruthy();
expect(parsed.mission.missionNotesResolvedFrom).toBe("built-in");
const mnPath = parsed.mission.missionNotesPath as string;
expect(fs.existsSync(mnPath)).toBe(true);
const content = fs.readFileSync(mnPath, "utf8");
// Placeholders must be substituted.
expect(content).not.toMatch(/\{\{mission_id\}\}/);
expect(content).not.toMatch(/\{\{mission_name\}\}/);
expect(content).not.toMatch(/\{\{created_date\}\}/);
// Substituted values present.
expect(content).toMatch(/mission: OPR\.0\.6\.0/);
// titleFromSlug("0.6.0") → "0.6.0" (no separators to titlecase); the
// bare version string is what lands in mission_name.
expect(content).toMatch(/name: 0\.6\.0/);
expect(content).toMatch(/cont\.0 — mission scaffolded/);
// Canonical structure markers.
expect(content).toMatch(/## §1\. Top-of-mind context/);
expect(content).toMatch(/## §10\. What NOT to reconstruct/);
expect(content).toMatch(/## §A\. <first-seat>@<rig> notes/);
});
it("FR-3: --no-mission-notes opts out — README created, MISSION_NOTES.md is NOT", async () => {
const r = await run([
"mission", "create", "release-0.7.0",
"--no-mission-notes", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(0);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
// README still produced.
expect(fs.existsSync(parsed.mission.readmePath)).toBe(true);
// MISSION_NOTES suppressed.
expect(parsed.mission.missionNotesPath).toBeNull();
expect(parsed.mission.missionNotesResolvedFrom).toBeNull();
expect(fs.existsSync(path.join(parsed.mission.path, "MISSION_NOTES.md"))).toBe(false);
});
it("FR-3: OPENRIG_MISSION_NOTES_TEMPLATE_PATH env var overrides the built-in template", async () => {
// Write a custom template at a temp path containing a distinctive marker.
const customTemplatePath = path.join(env.root, "custom-mission-notes-template.md");
fs.writeFileSync(
customTemplatePath,
"---\nmission: {{mission_id}}\n---\n\n# CUSTOM TEMPLATE — {{mission_name}}\n\nCREATED ON {{created_date}}\n",
"utf8",
);
const prior = process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = customTemplatePath;
try {
const r = await run(["mission", "create", "release-0.8.0", "--json"], env.missionsRoot);
expect(r.exitCode).toBe(0);
const parsed = JSON.parse(r.stdout);
expect(parsed.mission.missionNotesResolvedFrom).toBe("env");
const content = fs.readFileSync(parsed.mission.missionNotesPath as string, "utf8");
expect(content).toMatch(/CUSTOM TEMPLATE — 0\.8\.0/);
expect(content).toMatch(/mission: OPR\.0\.8\.0/);
// Built-in canonical sections must NOT appear (proves we used the custom one).
expect(content).not.toMatch(/## §1\. Top-of-mind context/);
} finally {
if (prior === undefined) delete process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
else process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = prior;
}
});
it("FR-3: OPENRIG_MISSION_NOTES_TEMPLATE_PATH pointing at a missing file fails with a 3-part error and no mission dir leaked", async () => {
const prior = process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = path.join(env.root, "does-not-exist.md");
try {
const r = await run(["mission", "create", "release-0.9.0", "--json"], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toMatch(/OPENRIG_MISSION_NOTES_TEMPLATE_PATH/);
// Discriminator (guard catch qitem-20260601121058): the failed scaffold
// MUST NOT leave a half-created mission dir behind. Without the
// verify-first-then-write reorder, the mission dir + README would
// exist after this command, and a retry would hit
// "Mission folder already exists." which the operator would
// reasonably read as a CLI bug.
const leakedPath = path.join(env.missionsRoot, "release-0.9.0");
expect(fs.existsSync(leakedPath), `expected no half-created mission at ${leakedPath} after stale env-var failure`).toBe(false);
} finally {
if (prior === undefined) delete process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
else process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = prior;
}
});
});
// ---------------------------------------------------------------------