mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
1 parent
236597084f
commit
eba8fbe4f6
4 files changed
+273
-6
No files matched your search
@@ -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.)
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user