feat(slice-21 FR-3): rig scope mission create auto-scaffolds MISSION_NOTES.md from template (OPR.0.3.2.21.FR-3)

Closes the product-code half of slice-21 FR-3. The substrate-side
convention + reusable TEMPLATE.md landed in shared-docs as a
separate roll-up commit (substrate/openrig-work/conventions/mission-notes/).
This commit wires the auto-scaffold integration the convention
references — every new mission gets MISSION_NOTES.md from a
bundled template by default, opt-out via --no-mission-notes.

Why this matters: MISSION_NOTES.md is the canonical
compaction-restore + onboarding spine for any mission, but until
this slice it was hand-maintained ("confidently stale" risk). The
convention existed; nothing automated it. Companion to FR-1's boot
instinct surfacing — once agents reach for queue + handoff
instinctively (FR-1), they need a durable shared-memory spine to
coordinate around. That spine is MISSION_NOTES.

Three coordinated edits:

(1) packages/cli/src/lib/scope-templates/mission-notes.md (NEW):
    Bundled template mirroring the substrate-side TEMPLATE.md.
    Frontmatter (mission / name / status / authored / last-updated)
    + canonical sections (§1 top-of-mind, §2 active workstreams +
    drivers + tips IS the mission codemap, §3 open scope-decisions,
    §4 slice inventory, §5 pending dispatches, §6 ledgers, §7
    banked memories that apply here, §8 convention pointers
    inheritance surface, §9 reconstruction protocol, §10 what NOT
    to reconstruct, §A first-seat slot, §B/§C+ placeholder for
    additional seats joining). Each section has a one-paragraph
    "what goes here" prose hint so a scaffolded file is legible
    without the convention README open beside it.

(2) packages/cli/src/lib/scope/templates.ts (+64 / -0):
    Added renderMissionNotesTemplate + resolveMissionNotesTemplatePath
    helpers. Template path resolution per banked env-var-pivot
    pattern: OPENRIG_MISSION_NOTES_TEMPLATE_PATH env var (operator
    override; absolute path; validates existence and 3-part-errors
    on a stale value) > built-in bundled fallback via the existing
    candidate-roots pattern that sibling scope templates use. The
    render helper substitutes three placeholders (mission_id,
    mission_name, created_date) and surfaces the resolution
    decision (env vs built-in) for the caller's response payload.

(3) packages/cli/src/commands/scope.ts (+35 / -6):
    The mission-create command gains a --no-mission-notes flag.
    After the README is written, the auto-scaffold writes
    MISSION_NOTES.md alongside it (default ON) with placeholders
    substituted from the mission id/title/today. The response
    payload (JSON + human) gains missionNotesPath +
    missionNotesResolvedFrom so operators / scripts can see where
    the file landed and which template path was used.

Test additions (packages/cli/test/scope-commands.test.ts +83 / -0):

Four new tests pinning the contract:
- FR-3 default ON: mission create auto-scaffolds MISSION_NOTES.md
  with all three placeholders substituted, frontmatter + canonical
  sections + cont.0 seed entry present.
- FR-3 --no-mission-notes: opt-out — README created, MISSION_NOTES
  is NOT, response payload has missionNotesPath=null.
- FR-3 env-var override: OPENRIG_MISSION_NOTES_TEMPLATE_PATH
  pointing at a custom template file makes the scaffold use that
  file; resolvedFrom = "env"; built-in canonical sections do NOT
  appear (proves the override path was actually taken).
- FR-3 env-var override + missing file: stale env-var path yields
  the 3-part fact/consequence/action error, exits 1, no mission
  dir leaks.

Verification:
- packages/cli scope-commands suite: 33/33 PASS (29 prior + 4 new).
- Full packages/cli test/ suite: 70 files, 1108 PASS, 3 pre-existing
  baseline failures unchanged (DaemonClient --version + 2 preflight
  tests; verified pre-existing on slice-22 + slice-21 FR-1/FR-2
  baselines). FR-3 introduced ZERO new CLI failures.
- packages/cli tsc EXIT=0.
- Commit body verification grep clean per banked discipline.
- git diff --cached --stat: 4 files (+273 / -6).

Branch: slice-21-fr-3-mission-notes-durable-pattern off LOCAL main
23659708. Single product-code commit on this branch (substrate
roll-up landed separately in shared-docs). LOCAL only, NO push.
This commit is contained in:
mvschwarz committed 2026-06-01 05:07:31 -07:00
1 parent 236597084f
commit eba8fbe4f6
4 files changed
+273 -6

No files matched your search

+29 -6
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();
@@ -629,6 +631,30 @@ function buildMissionCreateCommand(): Command {
});
const readmePath = path.join(absPath, "README.md");
fs.writeFileSync(readmePath, body, "utf8");
// OPR.0.3.2.21.FR-3 — auto-scaffold MISSION_NOTES.md from the
// mission-notes template (env-var override > built-in bundled).
// `--no-mission-notes` opts out (rare).
let missionNotesPath: string | null = null;
let missionNotesResolvedFrom: "env" | "built-in" | null = null;
if (opts.missionNotes !== false) {
const rendered = renderMissionNotesTemplate({
mission_id: id,
mission_name: title,
created_date: todayDateISO(),
});
missionNotesPath = path.join(absPath, "MISSION_NOTES.md");
fs.writeFileSync(missionNotesPath, rendered.rendered, "utf8");
missionNotesResolvedFrom = rendered.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 +663,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
+83
View File
@@ -391,6 +391,89 @@ 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/);
} finally {
if (prior === undefined) delete process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
else process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = prior;
}
});
});
// ---------------------------------------------------------------------