feat(daemon,ui): render mission + slice README/PROGRESS via scope-markdown hook

V0.3.1 slice 12 walk-items 1, 2, 4 from the 2026-05-11 synthesis.
Slice default tab is now Overview (README + Current Step + Readiness),
mission tabs render their README / PROGRESS content, and the data
layer is generalized so any project scope can read any markdown file
through one hook.

(1) Generalized hook (walk-item 1 foundation):
- `useScopeMarkdown(absoluteScopePath, filename)` at
  packages/ui/src/hooks/useScopeMarkdown.ts. Same allowlist-root
  resolution + relative-path conversion as the slice-06 era hook;
  the filename is now a parameter so callers pick README.md /
  PROGRESS.md / timeline.md / etc.
- `useSliceTimelineMarkdown(slicePath)` is now a backward-compat
  shim that calls `useScopeMarkdown(slicePath, "timeline.md")`.
  Slice-06 TimelineTab callsites continue to work unchanged.

(2) Daemon mission data layer (walk-items 2 + 4 foundation):
- New `GET /api/missions/:missionId` route returns
  { missionId, missionPath, slices: SliceListEntry[] } filtered by
  missionId. Uses the existing SliceIndexer; no new persistence.
  503 when indexer not wired / not ready; 404 when no slices.
- `useMission(missionId)` react-query hook wraps the route with
  refetch-on-window-focus.

(3) Slice scope (walk-item 1):
- SliceScopePage default tab flipped to "overview" (was "story").
- SliceOverviewTab now renders the slice README via
  useScopeMarkdown above Current Step + Readiness. Primary Docs
  filename-duplication section dropped (the README + Docs tab tree
  cover the same surface).

(4) Mission scope (walk-items 2 + 4):
- MissionScopePage Overview tab renders mission README via
  useScopeMarkdown above a compact slice rail (per-slice metrics
  collapsed from a 4-column grid to a single dense status line).
- MissionScopePage Progress tab renders mission PROGRESS.md via
  useScopeMarkdown above the existing ScopeProgressRollup.

Phrasing discipline applied: comments + commit body use walk-item
terminology; no chain-of-authorization attribution. Slice id tokens
retained (stable identifiers).

Tests:
- missions-routes.test.ts (NEW): 5 cases — 200 with missionPath +
  filtered slices; 404 mission_not_found; 503 indexer-unavailable;
  503 slices_root_not_configured; exact-id match no substring leak.
- use-scope-markdown.test.tsx (NEW): 6 cases — pure resolver tests,
  README.md / PROGRESS.md production wire, null-path + 404 paths.
- use-slice-timeline-markdown.test.tsx (existing 7/7) preserved
  via the backward-compat shim.

Targeted: 18/18 (5 missions-routes + 6 use-scope-markdown + 7
preserved slice-06). Full daemon suite: 3364 passed / 1 skipped /
0 failed. UI tests passing for hook tests; the rest of the UI suite
hits a pre-existing worktree-PostCSS infra issue (tailwindcss
module resolution from worktree path) unrelated to this slice.

Deferred (flagged for orch decision):
- Workspace Topology swap to HostMultiRigGraph snapshot
- Mission Progress vs Artifacts heat-map differentiation
Both are visual refinements that need dev-server walk to evaluate;
core walk-items 1, 2, 4 are covered by this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 06:32:46 -07:00
1 parent ca6c7f13b0
commit fccd819694
8 files changed
+668 -181

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
// V0.3.1 slice 12 walk-item 1 — mission scope data layer.
//
// GET /api/missions/:missionId — returns aggregated mission metadata
// (missionPath + slices in this mission). Powers the Mission Overview
// + Mission Progress tabs in the Project surface; pairs with the
// generalized useScopeMarkdown hook which reads README.md / PROGRESS.md
// content via the existing /api/files/read route.
//
// Returns:
// 200 { missionId, missionPath, slices: SliceListEntry[] }
// 404 { error: "mission_not_found" } when no slices match
// 503 { error: "slices_indexer_unavailable" } when indexer not wired
// 503 { error: "slices_root_not_configured" } when indexer not ready
//
// This route does NOT itself read frontmatter / README / PROGRESS
// content — it's the metadata layer. File content reuses
// /api/files/read so the route surface stays minimal.
import { Hono } from "hono";
import * as path from "node:path";
import type { SliceIndexer, SliceListEntry } from "../domain/slices/slice-indexer.js";
export function missionsRoutes(): Hono {
const app = new Hono();
app.get("/:missionId", (c) => {
const indexer = c.get("sliceIndexer" as never) as SliceIndexer | undefined;
if (!indexer) {
return c.json(
{
error: "slices_indexer_unavailable",
hint: "Mission data layer requires the SliceIndexer to be wired into AppDeps.",
},
503,
);
}
if (!indexer.isReady()) {
return c.json(
{
error: "slices_root_not_configured",
hint: "Run rig config init-workspace, or set workspace.slices_root to workspace/missions. Supported shape: missions/<mission>/slices/<slice>.",
},
503,
);
}
const missionId = c.req.param("missionId");
const allSlices = indexer.list();
const slices = allSlices.filter((s) => s.missionId === missionId);
if (slices.length === 0) {
return c.json({ error: "mission_not_found", missionId }, 404);
}
const missionPath = computeMissionPath(slices[0]!);
return c.json({
missionId,
missionPath,
slices,
});
});
return app;
}
/** Derive the mission folder's absolute path from any slice's
* `slicePath`. Slices live at
* `<missionsRoot>/<missionId>/slices/<sliceName>` per the workspace
* contract, so going up two levels yields the mission folder. */
function computeMissionPath(slice: SliceListEntry): string {
// slicePath = <root>/<missionId>/slices/<sliceName>
// up 2 levels = <root>/<missionId>
return path.resolve(slice.slicePath, "..", "..");
}
+5
View File
@@ -77,6 +77,7 @@ import { watchdogRoutes } from "./routes/watchdog.js";
import { workflowRoutes } from "./routes/workflow.js";
import { missionControlRoutes } from "./routes/mission-control.js";
import { slicesRoutes } from "./routes/slices.js";
import { missionsRoutes } from "./routes/missions.js";
import { filesRoutes } from "./routes/files.js";
import { progressRoutes } from "./routes/progress.js";
import { steeringRoutes } from "./routes/steering.js";
@@ -470,6 +471,10 @@ export function createApp(deps: AppDeps): Hono {
);
// Slice Story View v0 — slice indexer + per-tab payload routes.
app.route("/api/slices", slicesRoutes());
// V0.3.1 slice 12 walk-item 1 — mission scope data layer
// (aggregated mission metadata + slices filter; pairs with
// useScopeMarkdown for README / PROGRESS content via /api/files/read).
app.route("/api/missions", missionsRoutes());
// UI Enhancement Pack v0 — files (item 3 + item 4) + progress (item 1B) routes.
app.route("/api/files", filesRoutes());
app.route("/api/progress", progressRoutes());
@@ -0,0 +1,137 @@
// V0.3.1 slice 12 walk-item 1 — mission scope data layer.
//
// GET /api/missions/:missionId returns aggregated mission metadata:
// - missionId: echo of the requested id
// - missionPath: absolute filesystem path of the mission folder
// (the parent of the slices folder containing the
// matched slices)
// - slices: SliceListEntry[] filtered to entries with this missionId
//
// The route does NOT itself read README.md / PROGRESS.md content —
// the UI fetches those via the existing /api/files/read route through
// the generalized useScopeMarkdown hook. This route is the mission
// METADATA layer; the file content layer is reused.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
import { SliceIndexer } from "../src/domain/slices/slice-indexer.js";
import { missionsRoutes } from "../src/routes/missions.js";
function buildApp(indexer: SliceIndexer): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("sliceIndexer" as never, indexer);
await next();
});
app.route("/api/missions", missionsRoutes());
return app;
}
function writeSliceInMission(
missionsRoot: string,
missionId: string,
sliceName: string,
frontmatter: Record<string, string> = {},
): void {
const dir = path.join(missionsRoot, missionId, "slices", sliceName);
fs.mkdirSync(dir, { recursive: true });
const fm = Object.entries(frontmatter)
.map(([k, v]) => `${k}: ${v}`)
.join("\n");
fs.writeFileSync(
path.join(dir, "README.md"),
`---\n${fm}\n---\n# ${sliceName}\n`,
);
}
let db: Database.Database;
let cleanupRoot: string;
let missionsRoot: string;
let indexer: SliceIndexer;
let app: Hono;
beforeEach(() => {
db = createDb();
migrate(db, [coreSchema, eventsSchema, streamItemsSchema, queueItemsSchema]);
cleanupRoot = fs.mkdtempSync(path.join(os.tmpdir(), "missions-routes-"));
missionsRoot = path.join(cleanupRoot, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
indexer = new SliceIndexer({ slicesRoot: missionsRoot, dogfoodEvidenceRoot: null, db });
app = buildApp(indexer);
});
afterEach(() => {
db.close();
fs.rmSync(cleanupRoot, { recursive: true, force: true });
});
describe("GET /api/missions/:missionId", () => {
it("returns 200 with missionPath + slices filtered to the requested mission", async () => {
writeSliceInMission(missionsRoot, "getting-started", "first-slice", { status: "active" });
writeSliceInMission(missionsRoot, "getting-started", "second-slice", { status: "done" });
writeSliceInMission(missionsRoot, "other-mission", "third-slice", { status: "active" });
const res = await app.request("/api/missions/getting-started");
expect(res.status).toBe(200);
const body = (await res.json()) as {
missionId: string;
missionPath: string;
slices: Array<{ name: string; missionId: string }>;
};
expect(body.missionId).toBe("getting-started");
expect(body.missionPath).toBe(path.join(missionsRoot, "getting-started"));
expect(body.slices.map((s) => s.name).sort()).toEqual(["first-slice", "second-slice"]);
expect(body.slices.every((s) => s.missionId === "getting-started")).toBe(true);
});
it("returns 404 when no slices exist for the missionId", async () => {
writeSliceInMission(missionsRoot, "getting-started", "first-slice", { status: "active" });
const res = await app.request("/api/missions/unknown-mission");
expect(res.status).toBe(404);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("mission_not_found");
});
it("returns 503 when the indexer isn't wired", async () => {
const bareApp = new Hono();
bareApp.route("/api/missions", missionsRoutes());
const res = await bareApp.request("/api/missions/anything");
expect(res.status).toBe(503);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("slices_indexer_unavailable");
});
it("returns 503 when the indexer is not ready (slices root not configured)", async () => {
const emptyIndexer = new SliceIndexer({
slicesRoot: "",
dogfoodEvidenceRoot: null,
db,
});
const emptyApp = buildApp(emptyIndexer);
const res = await emptyApp.request("/api/missions/getting-started");
expect(res.status).toBe(503);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("slices_root_not_configured");
});
it("only counts slices with the exact missionId (no substring match)", async () => {
writeSliceInMission(missionsRoot, "release-0.3.1", "slice-a", { status: "active" });
writeSliceInMission(missionsRoot, "release-0.3.1-followup", "slice-b", { status: "active" });
const res = await app.request("/api/missions/release-0.3.1");
expect(res.status).toBe(200);
const body = (await res.json()) as { slices: Array<{ name: string }> };
expect(body.slices.map((s) => s.name)).toEqual(["slice-a"]);
});
});
@@ -41,6 +41,9 @@ import {
} from "../../lib/project-mission-state.js";
import { TimelineTab } from "../slices/tabs/TimelineTab.js";
import { useSliceTimelineMarkdown } from "../../hooks/useSliceTimelineMarkdown.js";
import { useScopeMarkdown } from "../../hooks/useScopeMarkdown.js";
import { useMission } from "../../hooks/useMission.js";
import { MarkdownViewer } from "../markdown/MarkdownViewer.js";
import { AcceptanceTab } from "../slices/tabs/AcceptanceTab.js";
import { DocsTab } from "../slices/tabs/DocsTab.js";
import { DecisionsTab } from "../slices/tabs/DecisionsTab.js";
@@ -711,6 +714,15 @@ export function MissionScopePage() {
const { missionId } = useParams({ from: "/project/mission/$missionId" });
const [active, setActive] = useState<SharedTab>("overview");
const rollup = useProjectScopeRollup(missionId, active !== "overview");
// V0.3.1 slice 12 walk-item 1 — fetch aggregated mission metadata
// (missionPath for README/PROGRESS lookup; slices already covered
// by rollup). README + PROGRESS render via useScopeMarkdown above
// the existing slice rail.
const missionData = useMission(missionId);
const missionPath =
missionData.data && "missionPath" in missionData.data ? missionData.data.missionPath : null;
const missionReadme = useScopeMarkdown(missionPath, "README.md");
const missionProgress = useScopeMarkdown(missionPath, "PROGRESS.md");
return (
<ScopeShell
eyebrow="Mission"
@@ -720,38 +732,41 @@ export function MissionScopePage() {
onSelect={(id) => setActive(id as SharedTab)}
>
{active === "overview" ? (
<div data-testid="mission-overview-panel" className="space-y-3">
{rollup.rows.length > 0 ? (
rollup.rows.map((slice) => (
<article key={slice.name} className="border border-outline-variant bg-white/35 p-3 backdrop-blur-sm">
<Link
to="/project/slice/$sliceId"
params={{ sliceId: slice.name }}
className="font-mono text-[12px] uppercase tracking-[0.12em] text-stone-900 hover:underline"
>
{slice.displayName}
</Link>
<div className="mt-2 flex flex-wrap gap-1.5">
<ProjectPill token={scopeToken("slice")} compact />
<ProjectPill token={{ label: slice.status, tone: stateTone(slice.status) }} compact />
<DateChip value={slice.lastActivityAt} />
</div>
<div className="mt-2 grid gap-2 font-mono text-[10px] text-stone-700 sm:grid-cols-4">
<SliceMetric label="Status" value={slice.status} />
<SliceMetric label="Qitems" value={slice.qitemCount} />
<SliceMetric label="Proof" value={slice.hasProofPacket ? "yes" : "no"} />
<SliceMetric label="Last activity" value={formatMaybeDate(slice.lastActivityAt)} />
</div>
</article>
))
) : (
<EmptyState
label="NO SLICES"
description="No indexed slices are attached to this mission."
variant="card"
testId="mission-overview-empty"
/>
<div data-testid="mission-overview-panel" className="space-y-6">
{missionReadme.content && (
<section data-testid="mission-overview-readme" className="border border-outline-variant bg-white/20 p-4">
<MarkdownViewer content={missionReadme.content} hideFrontmatter hideRawToggle />
</section>
)}
<div className="space-y-3">
{rollup.rows.length > 0 ? (
rollup.rows.map((slice) => (
<article key={slice.name} className="border border-outline-variant bg-white/35 p-3 backdrop-blur-sm">
<Link
to="/project/slice/$sliceId"
params={{ sliceId: slice.name }}
className="font-mono text-[12px] uppercase tracking-[0.12em] text-stone-900 hover:underline"
>
{slice.displayName}
</Link>
<div className="mt-2 flex flex-wrap items-center gap-1.5 font-mono text-[10px] text-stone-600">
<ProjectPill token={scopeToken("slice")} compact />
<ProjectPill token={{ label: slice.status, tone: stateTone(slice.status) }} compact />
<DateChip value={slice.lastActivityAt} />
<span>· {slice.qitemCount} qitems</span>
{slice.hasProofPacket && <span>· proof</span>}
</div>
</article>
))
) : (
<EmptyState
label="NO SLICES"
description="No indexed slices are attached to this mission."
variant="card"
testId="mission-overview-empty"
/>
)}
</div>
</div>
) : null}
{active === "story" ? (
@@ -763,11 +778,18 @@ export function MissionScopePage() {
/>
) : null}
{active === "progress" ? (
<ScopeProgressRollup
rows={rollup.rows}
detailsByName={rollup.details.itemsByName}
isLoading={rollup.list.isLoading || rollup.details.isFetching}
/>
<div data-testid="mission-progress-panel" className="space-y-6">
{missionProgress.content && (
<section data-testid="mission-progress-readme" className="border border-outline-variant bg-white/20 p-4">
<MarkdownViewer content={missionProgress.content} hideFrontmatter hideRawToggle />
</section>
)}
<ScopeProgressRollup
rows={rollup.rows}
detailsByName={rollup.details.itemsByName}
isLoading={rollup.list.isLoading || rollup.details.isFetching}
/>
</div>
) : null}
{active === "artifacts" ? (
<ScopeArtifactsRollup rows={rollup.rows} detailsByName={rollup.details.itemsByName} />
@@ -989,9 +1011,11 @@ function SliceArtifactsTab({ detail }: { detail: SliceDetail }) {
function SliceOverviewTab({ detail }: { detail: SliceDetail }) {
const currentStep = detail.acceptance.currentStep;
const primaryDocs = detail.docs.tree.filter((entry) =>
entry.type === "file" && /(^|\/)(README|IMPLEMENTATION-PRD|PROGRESS)\.md$/i.test(entry.relPath),
);
// V0.3.1 slice 12 walk-item 1 — render slice README via the
// generalized scope-markdown reader; the Primary Docs filename
// duplication section is dropped (the README itself + the Docs tab
// tree are sufficient).
const readmeMd = useScopeMarkdown(detail.slicePath ?? null, "README.md");
return (
<div data-testid="slice-overview-tab" className="space-y-6">
@@ -1002,6 +1026,12 @@ function SliceOverviewTab({ detail }: { detail: SliceDetail }) {
<SliceMetric label="Last Activity" value={formatMaybeDate(detail.lastActivityAt)} />
</section>
{readmeMd.content && (
<section data-testid="slice-overview-readme" className="border border-outline-variant bg-white/20 p-4">
<MarkdownViewer content={readmeMd.content} hideFrontmatter hideRawToggle />
</section>
)}
<section data-testid="slice-overview-current-step" className="border border-outline-variant bg-white/20 p-4">
<SectionHeader tone="muted">Current Step</SectionHeader>
{currentStep ? (
@@ -1049,31 +1079,16 @@ function SliceOverviewTab({ detail }: { detail: SliceDetail }) {
)}
</div>
</section>
<section data-testid="slice-overview-docs" className="border border-outline-variant bg-white/20 p-4">
<SectionHeader tone="muted">Primary Docs</SectionHeader>
{primaryDocs.length > 0 ? (
<ul className="mt-3 divide-y divide-outline-variant border border-outline-variant bg-white/30">
{primaryDocs.map((entry) => (
<li key={entry.relPath} className="px-3 py-2 font-mono text-[10px] text-stone-900">
<span className="inline-flex min-w-0 items-center gap-1.5">
<ToolMark tool={entry.relPath} size="xs" />
<span className="truncate">{entry.relPath}</span>
</span>
</li>
))}
</ul>
) : (
<div className="mt-3 font-mono text-[10px] text-stone-400">No README, implementation PRD, or progress file indexed.</div>
)}
</section>
</div>
);
}
export function SliceScopePage() {
const { sliceId } = useParams({ from: "/project/slice/$sliceId" });
const [active, setActive] = useState<SliceTab>("story");
// V0.3.1 slice 12 walk-item 1 — default tab is Overview (README +
// current step + readiness) instead of Story; the first thing the
// operator should see on a slice is its README, not a metric grid.
const [active, setActive] = useState<SliceTab>("overview");
const detailQuery = useSliceDetail(sliceId);
const queueItems = useQueueItemMap(detailQuery.data?.qitemIds ?? []);
const queueItemsById = useMemo(() => queueItems.itemsById, [queueItems.itemsById]);
+56
View File
@@ -0,0 +1,56 @@
// V0.3.1 slice 12 walk-item 1 — mission scope data hook.
//
// Wraps GET /api/missions/:missionId, the aggregated mission metadata
// route that returns missionPath + filtered SliceListEntry[]. Pairs
// with useScopeMarkdown for README / PROGRESS content via
// /api/files/read.
import { useQuery } from "@tanstack/react-query";
import type { SliceListEntry } from "./useSlices.js";
export interface MissionDataResponse {
missionId: string;
/** Absolute filesystem path of the mission folder. */
missionPath: string;
/** Slices in this mission (SliceListEntry[] filtered). */
slices: SliceListEntry[];
}
export interface MissionUnavailable {
unavailable: true;
error: string;
hint?: string;
}
async function fetchMission(missionId: string): Promise<MissionDataResponse | MissionUnavailable> {
const res = await fetch(`/api/missions/${encodeURIComponent(missionId)}`);
if (res.status === 503) {
const body = (await res.json().catch(() => ({}))) as { error?: string; hint?: string };
return {
unavailable: true,
error: body.error ?? "missions_route_unavailable",
hint: body.hint,
};
}
if (res.status === 404) {
return {
unavailable: true,
error: "mission_not_found",
};
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as MissionDataResponse;
}
export function useMission(missionId: string | null) {
return useQuery({
queryKey: ["mission", "detail", missionId],
queryFn: () => fetchMission(missionId!),
enabled: !!missionId,
staleTime: 30_000,
// V0.3.1 slice 17 workspace-state-correctness pattern: refetch on
// window focus so the operator who creates a slice folder + comes
// back to the tab sees the new slice without a manual refresh.
refetchOnWindowFocus: true,
});
}
+136
View File
@@ -0,0 +1,136 @@
// V0.3.1 slice 12 walk-item 1 — generalized scope-markdown reader.
//
// Reads `<scopePath>/<filename>` via the existing /api/files/read
// daemon route so any tab that needs to render a markdown file from a
// project scope (slice, mission, workspace) can do so through a single
// hook with allowlist-root resolution baked in.
//
// scopePath is an ABSOLUTE filesystem path (e.g.,
// /Users/x/code/substrate/.../missions/release-0.3.1 or
// /Users/x/code/substrate/.../slices/06-…). The daemon's
// /api/files/read route REQUIRES a path RELATIVE TO ONE OF THE
// REGISTERED ALLOWLIST ROOTS and rejects absolute paths outright.
//
// This hook resolves the scope's absolute path against
// /api/files/roots (cached for 60s via useFilesRoots) to find which
// root contains the scope, then issues the read with the correct
// relative path. When no allowlist root contains the scope, the hook
// returns `unavailable: true` — graceful degradation matching
// useMissionDiscovery / useSliceTimelineMarkdown precedent.
//
// Generalization of the slice-06-era useSliceTimelineMarkdown.
// useSliceTimelineMarkdown is kept exported as a thin wrapper so
// existing callsites (TimelineTab) keep working without modification.
import { useFilesRead, useFilesRoots, type AllowlistRoot } from "./useFiles.js";
export interface UseScopeMarkdownResult {
/** Raw file content when the file exists; null otherwise. */
content: string | null;
isLoading: boolean;
/** True when the file does not exist, no allowlist root contains
* the scope, or the read failed. Distinct from `content === null`
* during initial load. */
unavailable: boolean;
/** mtime of the file when known. */
mtime: string | null;
/** Diagnostic: the (root, relPath) pair the hook computed for the
* /api/files/read call. Exposed so integration tests can assert
* the production call shape (absolute → relative conversion). */
resolved: { rootName: string; relPath: string } | null;
}
/** Returns true when `parent` is a path-prefix of `child`, treating
* both as absolute filesystem paths. Segment-boundary aware so
* `/work` is NOT a prefix of `/workspace`. */
function isPathPrefix(parent: string, child: string): boolean {
const p = parent.replace(/\/+$/, "");
const c = child.replace(/\/+$/, "");
if (c === p) return true;
return c.startsWith(p + "/");
}
/** Compute the path under a root: `<root>/<rel>` → `<rel>`. */
function relativeUnder(rootPath: string, absChild: string): string {
const p = rootPath.replace(/\/+$/, "");
const c = absChild.replace(/\/+$/, "");
if (c === p) return "";
if (c.startsWith(p + "/")) return c.slice(p.length + 1);
return c;
}
/** Pick the deepest-matching allowlist root that contains the
* absolute path. Returns null when no root contains it. Exported
* for the integration test surface. */
export function resolveScopePathToAllowlist(
roots: AllowlistRoot[],
absoluteScopePath: string,
): { rootName: string; relPath: string } | null {
// Exact match wins; otherwise deepest prefix.
const exact = roots.find((r) => r.path.replace(/\/+$/, "") === absoluteScopePath.replace(/\/+$/, ""));
if (exact) return { rootName: exact.name, relPath: "" };
const prefixed = roots
.filter((r) => isPathPrefix(r.path, absoluteScopePath))
.sort((a, b) => b.path.length - a.path.length);
const winner = prefixed[0];
if (!winner) return null;
return { rootName: winner.name, relPath: relativeUnder(winner.path, absoluteScopePath) };
}
/** Fetch `<absoluteScopePath>/<filename>` via /api/files/read with
* allowlist-root resolution. Returns the same shape as the slice-06
* useSliceTimelineMarkdown hook so callsites can swap between the two
* via a single mental model. */
export function useScopeMarkdown(
absoluteScopePath: string | null,
filename: string,
): UseScopeMarkdownResult {
const rootsQuery = useFilesRoots();
const rootsResp = rootsQuery.data;
const rootsList: AllowlistRoot[] | null =
rootsResp && "roots" in rootsResp ? rootsResp.roots : null;
const resolved =
absoluteScopePath && rootsList
? resolveScopePathToAllowlist(rootsList, absoluteScopePath)
: null;
const filePath = resolved
? resolved.relPath
? `${resolved.relPath}/${filename}`
: filename
: null;
const readQuery = useFilesRead(
resolved ? resolved.rootName : null,
filePath,
);
if (!absoluteScopePath) {
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved: null };
}
if (rootsQuery.isLoading) {
return { content: null, isLoading: true, unavailable: false, mtime: null, resolved: null };
}
if (!resolved) {
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved: null };
}
if (readQuery.isLoading) {
return { content: null, isLoading: true, unavailable: false, mtime: null, resolved };
}
if (readQuery.isError || !readQuery.data) {
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved };
}
return {
content: readQuery.data.content ?? null,
isLoading: false,
unavailable: false,
mtime: readQuery.data.mtime ?? null,
resolved,
};
}
+22 -123
View File
@@ -1,131 +1,30 @@
// 0.3.1 slice 06 — fetch `<slicePath>/timeline.md` via the existing
// /api/files/read daemon route so TimelineTab can render the curated
// narrative above the auto-captured event feed.
// V0.3.1 slice 12 walk-item 1 — backward-compat shim.
//
// SliceDetail.slicePath is an ABSOLUTE filesystem path (e.g.,
// /Users/x/code/substrate/.../missions/foo/slices/06-…). The daemon's
// /api/files/read route REQUIRES a path RELATIVE TO ONE OF THE
// REGISTERED ALLOWLIST ROOTS and rejects absolute paths outright.
//
// This hook resolves the slice's absolute path against
// `/api/files/roots` (cached for 60s) to find which root contains the
// slice, then issues the read with the correct relative path. When no
// allowlist root contains the slice, the hook returns
// `unavailable: true` — graceful degradation matching the
// useMissionDiscovery precedent.
// Historically `useSliceTimelineMarkdown(absoluteSlicePath)` fetched
// `<slicePath>/timeline.md`. Slice 12 generalized the implementation
// to `useScopeMarkdown(absoluteScopePath, filename)` so any scope
// (slice, mission, workspace) can read any markdown file through the
// same allowlist-root-aware path. This file is now a thin re-export
// shim so slice-06 TimelineTab callsites (and other code referencing
// `useSliceTimelineMarkdown` / `resolveSlicePathToAllowlist`) keep
// working without modification.
import { useFilesRead, useFilesRoots, type AllowlistRoot } from "./useFiles.js";
import {
useScopeMarkdown,
resolveScopePathToAllowlist,
type UseScopeMarkdownResult,
} from "./useScopeMarkdown.js";
export interface UseSliceTimelineMarkdownResult {
/** Raw timeline.md content when the file exists; null otherwise.
* TimelineTab renders the content above the event feed when
* present and falls through to the standard feed when not. */
content: string | null;
isLoading: boolean;
/** True when the file does not exist, no allowlist root contains
* the slice, or the read failed. Distinct from `content === null`
* during initial load. */
unavailable: boolean;
/** mtime of timeline.md when known. */
mtime: string | null;
/** Diagnostic: the (root, relPath) pair the hook computed for the
* /api/files/read call. Exposed so integration tests can assert
* the production call shape (absolute → relative conversion). */
resolved: { rootName: string; relPath: string } | null;
}
export type UseSliceTimelineMarkdownResult = UseScopeMarkdownResult;
/** Returns true when `parent` is a path-prefix of `child`, treating
* both as absolute filesystem paths. Segment-boundary aware so
* `/work` is NOT a prefix of `/workspace`. Mirrors the
* useMissionDiscovery helper. */
function isPathPrefix(parent: string, child: string): boolean {
const p = parent.replace(/\/+$/, "");
const c = child.replace(/\/+$/, "");
if (c === p) return true;
return c.startsWith(p + "/");
}
/** Compute the path under a root: `<root>/<rel>` → `<rel>`. */
function relativeUnder(rootPath: string, absChild: string): string {
const p = rootPath.replace(/\/+$/, "");
const c = absChild.replace(/\/+$/, "");
if (c === p) return "";
if (c.startsWith(p + "/")) return c.slice(p.length + 1);
return c;
}
/** Pick the deepest-matching allowlist root that contains the
* absolute path. Returns null when no root contains it. Exported
* for the integration test surface. */
export function resolveSlicePathToAllowlist(
roots: AllowlistRoot[],
absoluteSlicePath: string,
): { rootName: string; relPath: string } | null {
// Exact match wins; otherwise deepest prefix.
const exact = roots.find((r) => r.path.replace(/\/+$/, "") === absoluteSlicePath.replace(/\/+$/, ""));
if (exact) return { rootName: exact.name, relPath: "" };
const prefixed = roots
.filter((r) => isPathPrefix(r.path, absoluteSlicePath))
.sort((a, b) => b.path.length - a.path.length);
const winner = prefixed[0];
if (!winner) return null;
return { rootName: winner.name, relPath: relativeUnder(winner.path, absoluteSlicePath) };
}
/** Fetch `<slicePath>/timeline.md` for a slice scope.
* `absoluteSlicePath` is the SliceDetail.slicePath value verbatim
* (daemon emits absolute paths). The hook handles the allowlist-root
* resolution + relative-path construction internally so call sites
* stay simple. */
/** Backward-compat alias for slice-06 / Phase 6 callsites. New code
* should call useScopeMarkdown directly with an explicit filename
* ("README.md" / "PROGRESS.md" / etc.). */
export function useSliceTimelineMarkdown(
absoluteSlicePath: string | null,
): UseSliceTimelineMarkdownResult {
const rootsQuery = useFilesRoots();
const rootsResp = rootsQuery.data;
const rootsList: AllowlistRoot[] | null =
rootsResp && "roots" in rootsResp ? rootsResp.roots : null;
const resolved =
absoluteSlicePath && rootsList
? resolveSlicePathToAllowlist(rootsList, absoluteSlicePath)
: null;
const timelinePath = resolved
? resolved.relPath
? `${resolved.relPath}/timeline.md`
: "timeline.md"
: null;
const readQuery = useFilesRead(
resolved ? resolved.rootName : null,
timelinePath,
);
if (!absoluteSlicePath) {
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved: null };
}
if (rootsQuery.isLoading) {
return { content: null, isLoading: true, unavailable: false, mtime: null, resolved: null };
}
if (!resolved) {
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved: null };
}
if (readQuery.isLoading) {
return { content: null, isLoading: true, unavailable: false, mtime: null, resolved };
}
if (readQuery.isError || !readQuery.data) {
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved };
}
return {
content: readQuery.data.content ?? null,
isLoading: false,
unavailable: false,
mtime: readQuery.data.mtime ?? null,
resolved,
};
return useScopeMarkdown(absoluteSlicePath, "timeline.md");
}
/** Backward-compat alias for the resolver export. */
export const resolveSlicePathToAllowlist = resolveScopePathToAllowlist;
@@ -0,0 +1,168 @@
// V0.3.1 slice 12 walk-item 1 — useScopeMarkdown generalization tests.
//
// useScopeMarkdown(scopePath, filename) generalizes the slice-06 era
// useSliceTimelineMarkdown to read any markdown file under any project
// scope (mission, slice, workspace). The backward-compat shim at
// useSliceTimelineMarkdown.ts is exercised separately; this file
// exercises the generalized hook directly with the README.md and
// PROGRESS.md filenames the Mission tabs consume.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement, type ReactNode } from "react";
import {
useScopeMarkdown,
resolveScopePathToAllowlist,
} from "../src/hooks/useScopeMarkdown.js";
const originalFetch = globalThis.fetch;
let fetchSpy: ReturnType<typeof vi.fn>;
function makeWrapper() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client }, children);
}
beforeEach(() => {
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
});
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("resolveScopePathToAllowlist (pure)", () => {
it("matches the deepest root that prefixes the absolute scope path", () => {
const roots = [
{ name: "home", path: "/Users/x" },
{ name: "substrate", path: "/Users/x/code/substrate" },
{ name: "workspace", path: "/Users/x/code/substrate/shared-docs/openrig-work" },
];
const r = resolveScopePathToAllowlist(
roots,
"/Users/x/code/substrate/shared-docs/openrig-work/missions/release-0.3.1",
);
expect(r).not.toBeNull();
expect(r!.rootName).toBe("workspace");
expect(r!.relPath).toBe("missions/release-0.3.1");
});
it("returns null when no allowlist root contains the absolute path", () => {
const roots = [{ name: "ws", path: "/Users/x/code/openrig-work" }];
expect(resolveScopePathToAllowlist(roots, "/somewhere/else")).toBeNull();
});
});
describe("useScopeMarkdown — production wire honors arbitrary filename", () => {
it("fetches <scopePath>/README.md when filename='README.md' (mission Overview tab path)", async () => {
fetchSpy.mockImplementation(async (url: string) => {
if (url.startsWith("/api/files/roots")) {
return new Response(JSON.stringify({
roots: [
{ name: "workspace", path: "/Users/example/code/openrig-work" },
],
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.startsWith("/api/files/read")) {
return new Response(JSON.stringify({
root: "workspace",
path: "missions/getting-started/README.md",
absolutePath: "/Users/example/code/openrig-work/missions/getting-started/README.md",
content: "---\nstatus: active\n---\n# Getting Started\n",
mtime: "2026-05-11T00:00:00Z",
contentHash: "rdme",
size: 50,
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("not found", { status: 404 });
});
const { result } = renderHook(
() => useScopeMarkdown(
"/Users/example/code/openrig-work/missions/getting-started",
"README.md",
),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.content).toContain("# Getting Started");
const readCall = fetchSpy.mock.calls.find((c) => String(c[0]).startsWith("/api/files/read"));
expect(readCall).toBeDefined();
const url = new URL(`http://localhost${readCall![0]}`);
expect(url.searchParams.get("root")).toBe("workspace");
expect(url.searchParams.get("path")).toBe("missions/getting-started/README.md");
});
it("fetches <scopePath>/PROGRESS.md when filename='PROGRESS.md' (mission Progress tab path)", async () => {
fetchSpy.mockImplementation(async (url: string) => {
if (url.startsWith("/api/files/roots")) {
return new Response(JSON.stringify({
roots: [{ name: "ws", path: "/Users/example/code/openrig-work" }],
}), { status: 200 });
}
if (url.startsWith("/api/files/read")) {
const reqUrl = new URL(`http://localhost${url}`);
expect(reqUrl.searchParams.get("path")).toBe("missions/getting-started/PROGRESS.md");
return new Response(JSON.stringify({
root: "ws",
path: "missions/getting-started/PROGRESS.md",
absolutePath: "/Users/example/code/openrig-work/missions/getting-started/PROGRESS.md",
content: "# Progress\n\n## Done\n- thing 1\n",
mtime: "2026-05-11T00:00:00Z",
contentHash: "prog",
size: 25,
}), { status: 200 });
}
return new Response("?", { status: 404 });
});
const { result } = renderHook(
() => useScopeMarkdown(
"/Users/example/code/openrig-work/missions/getting-started",
"PROGRESS.md",
),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.content).toContain("## Done");
});
it("returns unavailable=true when scopePath is null (no scope selected)", async () => {
fetchSpy.mockImplementation(async () => new Response(JSON.stringify({ roots: [] }), { status: 200 }));
const { result } = renderHook(
() => useScopeMarkdown(null, "README.md"),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.unavailable).toBe(true);
expect(result.current.content).toBeNull();
expect(result.current.resolved).toBeNull();
});
it("returns unavailable=true when file is missing on disk (404 from /api/files/read)", async () => {
fetchSpy.mockImplementation(async (url: string) => {
if (url.startsWith("/api/files/roots")) {
return new Response(JSON.stringify({
roots: [{ name: "ws", path: "/Users/example/code/openrig-work" }],
}), { status: 200 });
}
if (url.startsWith("/api/files/read")) return new Response("missing", { status: 404 });
return new Response("?", { status: 500 });
});
const { result } = renderHook(
() => useScopeMarkdown(
"/Users/example/code/openrig-work/missions/empty",
"PROGRESS.md",
),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.unavailable).toBe(true);
expect(result.current.content).toBeNull();
});
});