mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
merge: slice 21 FR-3 — MISSION_NOTES durable-pattern hardening (release-0.3.2)
This commit is contained in:
commit
58713e0c58
4 files changed
+295
-11
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();
|
||||
@@ -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.)
|
||||
@@ -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,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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user