fix(slice-21 FR-5e): init-workspace MISSION_NOTES scaffold + CLI files.allowlist env overlay (OPR.0.3.2.21.FR-5)

Bundled fix-round per orch dispatch on velocity-qa HG-7 BLOCKING-A
(qitem-20260602044545-9de820df). Two bounded findings; one delta.

A1 — init-workspace does not auto-scaffold MISSION_NOTES.md.

QA observed: fresh `rig config init-workspace` followed by `rig
workspace doctor` returned summary {ok:6, warn:1, fail:0} because
FR-3's MISSION_NOTES auto-scaffold only fires from `rig scope mission
create`. The init-workspace code path creates getting-started via
DEFAULT_MISSIONS + workspaceScaffoldFiles, which had no
MISSION_NOTES.md entry.

Fix shape:
- packages/cli/src/commands/config-init-workspace.ts: workspaceScaffoldFiles
  now emits a `missions/<mission>/MISSION_NOTES.md` entry per default
  mission, reusing the FR-3 `renderMissionNotesTemplate` helper from
  packages/cli/src/lib/scope/templates.ts. Same env-var-pivot pattern
  (OPENRIG_MISSION_NOTES_TEMPLATE_PATH > built-in bundled template)
  the FR-3 surface honors.
- Placeholder substitution: mission.dotId → {{mission_id}},
  mission.title → {{mission_name}}, today's ISO date → {{created_date}}.
- workspaceScaffoldFiles is exported and shared with daemon's
  /api/config/init-workspace route at routes/config.ts:64, so the
  daemon-side UI Settings "init workspace" action lands the same
  scaffold without separate wiring.

Tests added (4 in new test file init-workspace-mission-notes.test.ts):
- ok-path: runInitWorkspace writes missions/getting-started/MISSION_NOTES.md
  with rendered placeholders (no `{{mission_id}}` etc. left in file)
- workspaceScaffoldFiles() emits one MISSION_NOTES entry per default
  mission with rendered content (discriminator-flips on placeholder
  presence)
- dry-run reports MISSION_NOTES.md without writing it to disk
- idempotent re-run without --force preserves operator-edited content

A2 — CLI-side OPENRIG_FILES_ALLOWLIST does not propagate to the
daemon doctor route.

QA observed: setting OPENRIG_FILES_ALLOWLIST=workspace:. in the CLI
shell and running `rig workspace doctor` produced no behavior change
because the daemon process resolves files.allowlist from ITS OWN env,
not the CLI's. The CLI's env-var was invisible to the doctor check #3.

Fix shape:
- packages/cli/src/commands/workspace.ts: the doctor action reads
  process.env.OPENRIG_FILES_ALLOWLIST at request time and forwards
  the raw value as body.filesAllowlistOverride. Mirrors the
  existing --workspace override pattern (CLI passes; daemon route
  applies with source="env"). Empty-string and unset are
  indistinguishable — both omit the override (CLI uses
  `typeof === "string" && length > 0` guard).
- packages/daemon/src/routes/workspace.ts: the doctor route now
  parses body.filesAllowlistOverride; when present, it overrides
  the SettingsStore-resolved allowlistValue + allowlistSource for
  check #3 (with source="env" so the fix-hint targets the right
  remediation channel). The override is bounded to check #3 — all
  other checks continue to use daemon-side resolution.

Tests added (3 CLI + 2 daemon route):
- CLI: env var set → body.filesAllowlistOverride matches the env value
- CLI: env var unset → body has no filesAllowlistOverride field
- CLI: env var empty-string → body has no filesAllowlistOverride
  field (discriminator-flips the length > 0 guard)
- Daemon route: body.filesAllowlistOverride="workspace:." → check
  #3 fails with allowlistSource="env" + summary.fail ≥ 1.
  Discriminator-flips the override branch — without it the stub
  SettingsStore's healthy default would silently pass.
- Daemon route: body.filesAllowlistOverride="" → check #3 uses
  SettingsStore (source="default"), proving the empty-string guard.

Symmetry note: A2 mirrors the slice-01 --workspace override pattern
(CLI passes, daemon uses if provided). The CLI does NOT push the
ConfigStore-resolved value (only env) because the workspace.root
case already has a flag and matching env is the asymmetric case A2
flagged. A broader generic --env-overlay would be scope creep.

Out-of-scope (per orch dispatch, NOT addressed in this delta):
- Symlink workspace daemon-alignment string-path comparison
  (non-blocking observation; 0.3.3 follow-on if anything).

Verification:
- packages/daemon tsc EXIT=0 + workspace-doctor.test.ts + workspace-
  routes.test.ts: 46/46 PASS (35 doctor + 11 routes; was 9 routes,
  now 11 with 2 new A2 daemon discriminators)
- packages/cli tsc EXIT=0 + workspace-doctor-cli.test.ts + init-
  workspace-mission-notes.test.ts: 19/19 PASS (15 doctor CLI + 4
  new init-workspace; was 12 doctor CLI, now 15 with 3 new A2 CLI
  discriminators)
- Pre-commit staged-diff grep for forbidden-attribution pattern:
  ZERO matches
- Pre-commit FULL-CONTENT grep on every touched file (touch-event-
  scope rule): ZERO matches
- Commit body verification grep clean per banked discipline
- git diff --cached --stat: 6 files (+269 / -6)

Branch: slice-21-fr-5-rig-workspace-doctor off LOCAL main e154a5ce.
Chain: a4b4b67d (FR-5a, CLEAR) → e5d61983 (FR-5b, BLOCKED) →
bc5ec05f (FR-5b fix-up, CLEAR) → 6b21ce3c (FR-5c, BLOCKED) →
784222d3 (FR-5c determinism fix-up, CLEAR) → 941a2e31 (FR-5d, CLEAR;
HG-7 dogfood found BLOCKING-A on this tip) → this commit (FR-5e A1+
A2 bundled fix-round). LOCAL only, NO push.

Files:
- packages/cli/src/commands/config-init-workspace.ts (+15 / -0)
- packages/cli/src/commands/workspace.ts (+13 / -1)
- packages/cli/test/init-workspace-mission-notes.test.ts (+103 new)
- packages/cli/test/workspace-doctor-cli.test.ts (+63 / -0)
- packages/daemon/src/routes/workspace.ts (+27 / -3)
- packages/daemon/test/workspace-routes.test.ts (+50 / -0)
This commit is contained in:
mvschwarz committed 2026-06-01 21:51:52 -07:00
1 parent 941a2e3149
commit d7910f8980
6 files changed
+269 -6

No files matched your search

@@ -19,6 +19,7 @@ import { Command } from "commander";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
import { renderMissionNotesTemplate } from "../lib/scope/templates.js";
export interface InitWorkspaceOpts {
root?: string;
@@ -539,6 +540,20 @@ export function workspaceScaffoldFiles(): Array<{ relPath: string; content: stri
files.push(
{ relPath: `missions/${mission.id}/README.md`, content: missionReadme(mission) },
{ relPath: `missions/${mission.id}/PROGRESS.md`, content: missionProgress(mission) },
// FR-5e A1 — reuse the FR-3 MISSION_NOTES scaffold helper so a
// fresh `rig config init-workspace` produces a getting-started
// mission that doctor check #7 (mission_notes_presence) marks
// ok. Same env-var-pivot (OPENRIG_MISSION_NOTES_TEMPLATE_PATH >
// built-in bundled template) FR-3's `rig scope mission create`
// honors.
{
relPath: `missions/${mission.id}/MISSION_NOTES.md`,
content: renderMissionNotesTemplate({
mission_id: mission.dotId,
mission_name: mission.title,
created_date: new Date().toISOString().slice(0, 10),
}).rendered,
},
);
for (const slice of mission.slices) {
files.push(
+13 -1
View File
@@ -163,8 +163,20 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
.action(async (opts: { workspace?: string; json?: boolean; strict?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const requestBody: { workspaceRoot?: string } = {};
const requestBody: { workspaceRoot?: string; filesAllowlistOverride?: string } = {};
if (opts.workspace) requestBody.workspaceRoot = path.resolve(opts.workspace);
// FR-5e A2 — CLI-side env overlay for files.allowlist. The
// daemon runs in its own process with its own env; an
// operator who sets OPENRIG_FILES_ALLOWLIST in their CLI
// shell expects the doctor result to reflect that override
// even though the daemon's env is unchanged. We mirror the
// --workspace override pattern: read the env at request
// time and forward as a per-request overlay; the daemon
// route applies it to check #3 with source="env".
const cliAllowlistEnv = process.env.OPENRIG_FILES_ALLOWLIST;
if (typeof cliAllowlistEnv === "string" && cliAllowlistEnv.length > 0) {
requestBody.filesAllowlistOverride = cliAllowlistEnv;
}
const res = await client.post<DoctorReport>("/api/workspace/doctor", requestBody);
if (res.status >= 400) {
console.error(JSON.stringify(res.data, null, 2));
@@ -0,0 +1,103 @@
// Slice-21 FR-5e A1 — `rig config init-workspace` scaffolds
// MISSION_NOTES.md for each seeded mission so a fresh baseline passes
// doctor check #7 (mission_notes_presence) without manual scope
// scaffolding.
//
// QA finding: fresh `rig config init-workspace` followed by `rig
// workspace doctor` produced summary {ok:6, warn:1, fail:0} because
// the FR-3 auto-scaffold only fired from `rig scope mission create`,
// not the init-workspace path.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
runInitWorkspace,
workspaceScaffoldFiles,
} from "../src/commands/config-init-workspace.js";
let dir: string;
let configPath: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "fr5e-init-"));
configPath = path.join(dir, "test-config.json");
// Seed config with workspace.root pointing into our temp dir so
// ConfigStore's resolveWithSource returns a known root.
fs.writeFileSync(
configPath,
JSON.stringify({ workspace: { root: path.join(dir, "workspace") } }) + "\n",
"utf-8",
);
});
afterEach(() => {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe("FR-5e A1 — init-workspace scaffolds MISSION_NOTES.md", () => {
it("writes missions/getting-started/MISSION_NOTES.md alongside README + PROGRESS", () => {
const result = runInitWorkspace({ configPath });
const notesFile = result.files.find((f) =>
f.relPath === "missions/getting-started/MISSION_NOTES.md");
expect(notesFile).toBeDefined();
expect(notesFile?.created).toBe(true);
expect(fs.existsSync(notesFile!.absPath)).toBe(true);
const content = fs.readFileSync(notesFile!.absPath, "utf-8");
// Discriminator: the rendered template must have placeholder
// substitutions applied. Without applyMissionNotesPlaceholders
// the file would still contain `{{mission_id}}` etc.
expect(content).not.toContain("{{mission_id}}");
expect(content).not.toContain("{{mission_name}}");
expect(content).not.toContain("{{created_date}}");
// Discriminator: at minimum the rendered file should mention the
// mission's display name from DEFAULT_MISSIONS.
expect(content).toContain("Getting Started");
});
// Discriminator-flip: workspaceScaffoldFiles is the canonical
// file-list used by both CLI init and daemon's
// /api/config/init-workspace. Both surfaces need MISSION_NOTES so
// the daemon-side init (UI Settings → init workspace) lands the
// same scaffold.
it("workspaceScaffoldFiles() emits a MISSION_NOTES.md entry per default mission", () => {
const files = workspaceScaffoldFiles();
const notesFiles = files.filter((f) => f.relPath.endsWith("/MISSION_NOTES.md"));
// DEFAULT_MISSIONS has one mission (getting-started). When the
// seed list grows, this discriminator catches the count drift.
expect(notesFiles.length).toBeGreaterThanOrEqual(1);
for (const f of notesFiles) {
expect(f.content).not.toContain("{{mission_id}}");
expect(f.content).not.toContain("{{mission_name}}");
expect(f.content).not.toContain("{{created_date}}");
}
});
// Discriminator-flip: dry-run should NOT write the MISSION_NOTES
// file to disk but should report it as "would create".
it("dry-run reports MISSION_NOTES.md without writing", () => {
const result = runInitWorkspace({ configPath, dryRun: true });
const notesFile = result.files.find((f) =>
f.relPath === "missions/getting-started/MISSION_NOTES.md");
expect(notesFile).toBeDefined();
expect(notesFile?.created).toBe(true);
expect(fs.existsSync(notesFile!.absPath)).toBe(false);
expect(result.dryRun).toBe(true);
});
// Discriminator-flip: idempotent re-run without --force does NOT
// overwrite the existing MISSION_NOTES. Operators who hand-edit the
// notes shouldn't lose work on a second init.
it("re-run without --force skips existing MISSION_NOTES.md", () => {
runInitWorkspace({ configPath });
const notesPath = path.join(dir, "workspace", "missions", "getting-started", "MISSION_NOTES.md");
fs.writeFileSync(notesPath, "operator hand-edited content", "utf-8");
const result = runInitWorkspace({ configPath });
const notesFile = result.files.find((f) =>
f.relPath === "missions/getting-started/MISSION_NOTES.md");
expect(notesFile?.created).toBe(false);
expect(notesFile?.skipped).toBe("exists");
expect(fs.readFileSync(notesPath, "utf-8")).toBe("operator hand-edited content");
});
});
@@ -146,6 +146,69 @@ describe("rig workspace doctor — request shape", () => {
expect(sent.workspaceRoot.startsWith("/")).toBe(true);
expect(sent.workspaceRoot.endsWith("/local-ws")).toBe(true);
});
// GUARD/QA BLOCKING-A2 discriminator: CLI-side
// OPENRIG_FILES_ALLOWLIST must propagate to the daemon as
// body.filesAllowlistOverride. Without this, an operator setting
// the env in their CLI shell sees no doctor behavior change because
// the daemon's env-resolution is decoupled from the CLI process.
it("forwards OPENRIG_FILES_ALLOWLIST from process.env as body.filesAllowlistOverride", async () => {
const original = process.env.OPENRIG_FILES_ALLOWLIST;
process.env.OPENRIG_FILES_ALLOWLIST = "workspace:.";
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
if (original === undefined) delete process.env.OPENRIG_FILES_ALLOWLIST;
else process.env.OPENRIG_FILES_ALLOWLIST = original;
}
const [, body] = post.mock.calls[0]!;
const sent = body as { workspaceRoot?: string; filesAllowlistOverride?: string };
expect(sent.filesAllowlistOverride).toBe("workspace:.");
});
// Discriminator-flip: an unset OPENRIG_FILES_ALLOWLIST must NOT
// attach an override (the daemon should use SettingsStore as the
// source of truth, not silently apply an empty-string).
it("omits filesAllowlistOverride from body when OPENRIG_FILES_ALLOWLIST is unset", async () => {
const original = process.env.OPENRIG_FILES_ALLOWLIST;
delete process.env.OPENRIG_FILES_ALLOWLIST;
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
if (original === undefined) delete process.env.OPENRIG_FILES_ALLOWLIST;
else process.env.OPENRIG_FILES_ALLOWLIST = original;
}
const [, body] = post.mock.calls[0]!;
const sent = body as { filesAllowlistOverride?: string };
expect(sent.filesAllowlistOverride).toBeUndefined();
});
// Discriminator-flip: empty-string env value is treated as unset
// (no override sent). Without the length > 0 check, an empty value
// would land as filesAllowlistOverride="" and the daemon route
// would treat that as an explicit override.
it("treats empty-string OPENRIG_FILES_ALLOWLIST as unset (no override sent)", async () => {
const original = process.env.OPENRIG_FILES_ALLOWLIST;
process.env.OPENRIG_FILES_ALLOWLIST = "";
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
if (original === undefined) delete process.env.OPENRIG_FILES_ALLOWLIST;
else process.env.OPENRIG_FILES_ALLOWLIST = original;
}
const [, body] = post.mock.calls[0]!;
const sent = body as { filesAllowlistOverride?: string };
expect(sent.filesAllowlistOverride).toBeUndefined();
});
});
describe("rig workspace doctor — output formatters", () => {
+25 -5
View File
@@ -9,7 +9,8 @@
// response: FrontmatterValidationReport
//
// POST /api/workspace/doctor (slice-21 FR-5)
// body (all optional): { workspaceRoot?: string }
// body (all optional): { workspaceRoot?: string;
// filesAllowlistOverride?: string }
// response: DoctorReport (7-check workspace-readiness report)
//
// No filesystem mutation. Operator picks the root + kind per invocation.
@@ -85,8 +86,11 @@ export function workspaceRoutes(): Hono {
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
if (!store) return c.json({ error: "settings_unavailable" }, 503);
const body = await c.req.json<{ workspaceRoot?: string }>().catch(
() => ({} as { workspaceRoot?: string }),
const body = await c.req.json<{
workspaceRoot?: string;
filesAllowlistOverride?: string;
}>().catch(
() => ({} as { workspaceRoot?: string; filesAllowlistOverride?: string }),
);
try {
@@ -113,7 +117,23 @@ export function workspaceRoutes(): Hono {
? path.join(body.workspaceRoot, "missions")
: (store.resolveOne("workspace.slices_root").value as string);
// FR-5e A2 — files.allowlist CLI-side env overlay. When the
// CLI sets OPENRIG_FILES_ALLOWLIST in its own shell, the
// daemon's SettingsStore can't observe that env (different
// process); the CLI forwards the raw value as
// body.filesAllowlistOverride and we honor it here with
// source="env" so check #3's fix-hint targets the right
// remediation channel.
const allowlistResolved = store.resolveOne("files.allowlist");
const usingAllowlistOverride =
typeof body.filesAllowlistOverride === "string"
&& body.filesAllowlistOverride.length > 0;
const allowlistValue = usingAllowlistOverride
? body.filesAllowlistOverride!
: (allowlistResolved.value as string);
const allowlistSource: WorkspaceRootSource = usingAllowlistOverride
? "env"
: (allowlistResolved.source as WorkspaceRootSource);
// Daemon start time captured from process.uptime() at request
// time. Per banked discipline (cited in workspace-doctor.ts:
@@ -126,8 +146,8 @@ export function workspaceRoutes(): Hono {
workspaceRoot: workspaceUnderCheck,
workspaceRootSource,
slicesRoot,
allowlistValue: allowlistResolved.value as string,
allowlistSource: allowlistResolved.source as WorkspaceRootSource,
allowlistValue,
allowlistSource,
daemonResolvedWorkspaceRoot: daemonResolved.value as string,
configFilePath: store.configPath,
daemonStartTime,
@@ -212,6 +212,56 @@ describe("workspace doctor HTTP route (slice-21 FR-5)", () => {
expect(body.workspaceRoot).toBe(doctorDir);
});
// GUARD/QA BLOCKING-A2 discriminator: when the CLI forwards an
// OPENRIG_FILES_ALLOWLIST overlay via body.filesAllowlistOverride,
// the daemon route MUST use that value for check #3 instead of the
// daemon's own SettingsStore-resolved files.allowlist. Without the
// override branch the operator's env-var would silently no-op.
it("POST /doctor honors body.filesAllowlistOverride for check #3", async () => {
// workspace:. is a relative path; per FR-5b BLOCKER-1 the
// canonical decoder drops it and check #3 fails with zero usable
// entries. The daemon SettingsStore stub returns a HEALTHY
// allowlist (workspace:${doctorDir}); without the override
// branch the doctor would happily report ok. With the override
// it correctly fails.
const res = await doctorApp.request("/api/workspace/doctor", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ filesAllowlistOverride: "workspace:." }),
});
expect(res.status).toBe(200);
const body = await res.json() as {
checks: Array<{ check: string; status: string; evidence?: { allowlistSource?: string } }>;
summary: { ok: number; warn: number; fail: number };
};
const allowlistCheck = body.checks.find((c) => c.check === "file_allowlist_sane");
expect(allowlistCheck?.status).toBe("fail");
// Evidence must report source="env" so the operator knows the
// override was applied (not the daemon's own resolution).
expect(allowlistCheck?.evidence?.allowlistSource).toBe("env");
expect(body.summary.fail).toBeGreaterThanOrEqual(1);
});
// Discriminator-flip: empty-string filesAllowlistOverride must NOT
// suppress the daemon's own SettingsStore allowlist. Without the
// length > 0 guard, an empty override would falsify the check.
it("ignores empty-string filesAllowlistOverride (uses daemon's SettingsStore)", async () => {
const res = await doctorApp.request("/api/workspace/doctor", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ filesAllowlistOverride: "" }),
});
expect(res.status).toBe(200);
const body = await res.json() as {
checks: Array<{ check: string; status: string; evidence?: { allowlistSource?: string } }>;
};
const allowlistCheck = body.checks.find((c) => c.check === "file_allowlist_sane");
expect(allowlistCheck?.status).toBe("ok");
// The stub returns source="default" for files.allowlist; verify
// we routed through SettingsStore, not the empty override.
expect(allowlistCheck?.evidence?.allowlistSource).toBe("default");
});
// GUARD BLOCKER-1 (qitem-20260602042720-e27ec982) determinism
// discriminator: even under a long process.uptime() (simulating a
// long-running Vitest worker), the healthy fixture must still