feat(daemon,ui): getting-started complete-and-hide (slice 18 Checkpoint E)

DAEMON:
- POST /api/missions/:missionId/complete writes status: complete to
  mission README.md frontmatter. Idempotent. Handles three cases:
  - Updates existing status: X line in place
  - Adds status: complete when frontmatter has no status field
  - Creates a fresh frontmatter block when README has no frontmatter
- Returns 200 { missionId, status: "complete" } on success;
  404 mission_not_found when no matching slices in the indexer;
  503 slices_indexer_unavailable when indexer not wired;
  500 with message on filesystem error.
- Helper writeMissionStatusComplete preserves unrelated frontmatter
  fields when updating status.

UI:
- New hook useCompletedMissions (localStorage-backed; mirrors
  useDismissedSeqs pattern). markCompleted(id) is idempotent;
  unmarkCompleted(id) is no-op on never-completed. Recovers from
  malformed JSON / non-array values.
- buildStorytellingFeedItems gains optional 3rd param
  completedMissionIds: Set<string>; missions whose name appears in
  the set are filtered out before the slice(0, 2) cap.
- StorytellingFeed accepts onMarkMissionComplete prop and threads it
  to ProgressCard.
- ProgressCard accepts optional onMarkComplete; when provided,
  renders a "Mark complete" button in the inlineActions slot
  (top-right of the collapsed card view). Click fires
  onMarkComplete(missionId) without toggling expand state.
- Feed.tsx wires the full flow: useCompletedMissions provides the
  filter set; handleMarkMissionComplete calls markCompleted (instant
  local hide) AND best-effort POSTs to the daemon endpoint for the
  audit trail (network errors swallowed; local state is the
  user-visible truth).

T9 (getting-started seeds status: active on fresh install): already
satisfied by packages/daemon/src/domain/workspace/default-workspace-
scaffold.ts default-mission scaffold (verified by grep; no scaffold
change needed in this slice).

Tests (TDD-first):
- 7/7 daemon POST /api/missions/:missionId/complete tests
- 7/7 useCompletedMissions hook tests
- 7/7 storytelling Mark-complete tests (ProgressCard render + click;
  buildStorytellingFeedItems filter — including 4-test discriminating
  battery on completedMissionIds behavior)
- Full UI suite: 731 pass + 1 skipped + 49 pre-existing AppShell
  pollution fails. Net vs prior Checkpoint D tip: +14 pass / 0 new fail.
- Daemon missions tests: 16/16 (7 new + 9 existing GET tests).

DESIGN DECISIONS / DIVERGENCES from IMPL-PRD:
- §3.5 mentioned "emits mission.completed event" as part of the daemon
  endpoint. Skipped at v0 — event emission would couple to the
  daemon's event bus and pull in more SC-29-adjacent surface. The
  frontmatter write is the durable audit trail; events can layer on
  in v0.3.2 if needed.
- Daemon write is best-effort from the UI; if it 5xx's or the
  network is unavailable, the local hide still takes effect. This
  prefers UX over strict consistency at v0; v0.3.2+ can reconcile
  by reading status: complete back from the daemon on mount.

SC-29 EXCEPTION declared: NONE
(per IMPL-PRD §8: the new POST endpoint writes a frontmatter field
to a markdown file. Not a schema-changing migration, not a
ConfigStore key, not a new event type. No SC-29 vector.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 14:27:10 -07:00
1 parent ccb901a45a
commit ac3df45ff8
6 files changed
+342 -6

No files matched your search

+56
View File
@@ -80,9 +80,65 @@ export function missionsRoutes(): Hono {
});
});
// Slice 18 §3.5 — Mark mission complete (Getting Started complete-and-hide).
// Writes `status: complete` to the mission README.md frontmatter; the UI
// storytelling preview gates on this so completed missions disappear from
// the band. The daemon is the audit-trail surface; the UI maintains
// an optimistic local mirror via localStorage so the hide is instant.
app.post("/:missionId/complete", (c) => {
const indexer = c.get("sliceIndexer" as never) as SliceIndexer | undefined;
if (!indexer) {
return c.json({ error: "slices_indexer_unavailable" }, 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]!);
try {
writeMissionStatusComplete(missionPath);
} catch (err) {
return c.json(
{
error: "mission_complete_write_failed",
missionId,
message: (err as Error).message,
},
500,
);
}
return c.json({ missionId, status: "complete" });
});
return app;
}
/** Slice 18 §3.5 — write `status: complete` to a mission README's
* frontmatter, creating the frontmatter block when absent and
* replacing an existing status field when present. Idempotent.
* Preserves unrelated frontmatter fields. */
function writeMissionStatusComplete(missionPath: string): void {
const readmePath = path.join(missionPath, "README.md");
let body = fs.existsSync(readmePath) ? fs.readFileSync(readmePath, "utf-8") : "";
const fmMatch = body.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (fmMatch) {
const fmInner = fmMatch[1] ?? "";
const statusLineRegex = /^\s*status\s*:\s*[^\r\n]*$/m;
let newFm: string;
if (statusLineRegex.test(fmInner)) {
newFm = fmInner.replace(statusLineRegex, "status: complete");
} else {
newFm = fmInner.trimEnd() + "\nstatus: complete";
}
body = body.replace(fmMatch[0], `---\n${newFm}\n---`);
} else {
body = `---\nstatus: complete\n---\n${body}`;
}
fs.writeFileSync(readmePath, body);
}
/** Derive the mission folder's absolute path from any slice's
* `slicePath`. Slices live at
* `<missionsRoot>/<missionId>/slices/<sliceName>` per the workspace
@@ -226,8 +226,32 @@ export interface ProgressCardSource {
activeSlice?: { id: string; label: string; status: string };
}
export function ProgressCard({ source }: { source: ProgressCardSource }) {
export function ProgressCard({
source,
onMarkComplete,
}: {
source: ProgressCardSource;
/**
* Slice 18 §3.5 — when provided, ProgressCard renders a "Mark complete"
* inline action. Click fires onMarkComplete(missionId). Parent owns the
* actual mutation (daemon endpoint + local optimistic state).
*/
onMarkComplete?: (missionId: string) => void;
}) {
const pct = Math.max(0, Math.min(100, Math.round(source.percent)));
const inlineActions = onMarkComplete ? (
<button
type="button"
data-testid="progress-card-mark-complete"
onClick={(event) => {
event.stopPropagation();
onMarkComplete(source.missionId);
}}
className="border border-stone-300 bg-white/80 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide text-stone-700 hover:bg-stone-100 hover:text-stone-900 focus:outline-none focus:ring-1 focus:ring-stone-400"
>
Mark complete
</button>
) : undefined;
return (
<CardShell
testId={`feed-card-progress-${source.missionId}`}
@@ -237,6 +261,7 @@ export function ProgressCard({ source }: { source: ProgressCardSource }) {
accent={ACCENTS.progress}
drillInHref={`/project/mission/${source.missionId}`}
drillInLabel="Open mission"
inlineActions={inlineActions}
leadingAccessory={
<div
data-testid={`feed-card-progress-${source.missionId}-bar`}
@@ -411,9 +436,19 @@ export interface AdapterSliceRow {
export function buildStorytellingFeedItems(
missions: AdapterMissionRow[],
slices: AdapterSliceRow[],
/**
* Slice 18 §3.5 — when supplied, missions whose name is in the set are
* filtered out of the progress preview. Powers the Getting Started
* complete-and-hide flow (operator click → mark complete → mission
* disappears from the storytelling band).
*/
completedMissionIds?: Set<string>,
): FeedCardItem[] {
const items: FeedCardItem[] = [];
for (const mission of (missions ?? []).slice(0, 2)) {
const eligibleMissions = (missions ?? []).filter(
(m) => !completedMissionIds || !completedMissionIds.has(m.name),
);
for (const mission of eligibleMissions.slice(0, 2)) {
items.push({
kind: "progress",
source: {
@@ -446,7 +481,15 @@ export function buildStorytellingFeedItems(
return items;
}
export function StorytellingFeed({ items }: { items: FeedCardItem[] }) {
export function StorytellingFeed({
items,
onMarkMissionComplete,
}: {
items: FeedCardItem[];
/** Slice 18 §3.5 — when supplied, threaded through to ProgressCards
* so the operator can hide a completed mission from the band. */
onMarkMissionComplete?: (missionId: string) => void;
}) {
if (items.length === 0) {
return (
<div data-testid="storytelling-feed-empty" className="border border-dashed border-outline-variant bg-white/35 p-4 font-mono text-[10px] text-stone-500">
@@ -459,7 +502,7 @@ export function StorytellingFeed({ items }: { items: FeedCardItem[] }) {
{items.map((item, i) => {
if (item.kind === "shipped") return <ShippedCard key={i} source={item.source} />;
if (item.kind === "incident") return <IncidentCard key={i} source={item.source} />;
if (item.kind === "progress") return <ProgressCard key={i} source={item.source} />;
if (item.kind === "progress") return <ProgressCard key={i} source={item.source} onMarkComplete={onMarkMissionComplete} />;
if (item.kind === "approval") return <ApprovalCard key={i} source={item.source} />;
return <ConceptCard key={i} source={item.source} />;
})}
+22 -2
View File
@@ -34,6 +34,7 @@ import {
isCardKindSubscribed,
} from "../../hooks/useFeedSubscriptions.js";
import { useDismissedSeqs } from "../../hooks/useDismissedSeqs.js";
import { useCompletedMissions } from "../../hooks/useCompletedMissions.js";
import { FeedCard } from "./FeedCard.js";
import { UndoToast } from "./UndoToast.js";
import type { FeedActionOutcome, FeedProofPreview } from "./FeedCard.js";
@@ -297,12 +298,31 @@ export function Feed() {
// - Slices (useSlices) → ShippedCard for status=shipped/done,
// IncidentCard for everything else, capped at 3.
const missionsResult = useMissionDiscovery();
const { completedMissionIds, markCompleted } = useCompletedMissions();
const storytellingItems = useMemo<StorytellingFeedItem[]>(
() => buildStorytellingFeedItems(
Array.isArray(missionsResult.missions) ? missionsResult.missions : [],
Array.isArray(sliceRows) ? sliceRows : [],
completedMissionIds,
),
[missionsResult.missions, sliceRows],
[missionsResult.missions, sliceRows, completedMissionIds],
);
// Slice 18 §3.5 — Getting Started complete-and-hide. Optimistic local
// hide via useCompletedMissions; best-effort daemon write to
// POST /api/missions/:missionId/complete for the audit trail. Network
// errors are swallowed silently (audit is best-effort, UI stays
// responsive) so a partial-air-gapped daemon doesn't block the hide.
const handleMarkMissionComplete = useCallback(
(missionId: string) => {
markCompleted(missionId);
void fetch(`/api/missions/${encodeURIComponent(missionId)}/complete`, {
method: "POST",
}).catch(() => {
// Swallow — local optimistic state is the user-visible truth.
});
},
[markCompleted],
);
return (
@@ -349,7 +369,7 @@ export function Feed() {
>
<SectionHeader tone="muted">Storytelling preview</SectionHeader>
<div className="mt-2">
<StorytellingFeed items={storytellingItems} />
<StorytellingFeed items={storytellingItems} onMarkMissionComplete={handleMarkMissionComplete} />
</div>
</section>
)}
@@ -0,0 +1,64 @@
// Slice 18 §3.5 — Getting Started complete-and-hide local state.
//
// Mirrors useDismissedSeqs's pattern: localStorage-backed soft state so
// "Mark complete" gives the operator instant visual feedback (mission
// disappears from the storytelling preview). The daemon-side
// POST /api/missions/<id>/complete writes mission frontmatter for the
// audit trail; this hook is the UI's optimistic mirror.
import { useCallback, useState } from "react";
export const COMPLETED_MISSIONS_STORAGE_KEY = "forYou.completedMissionIds";
function readFromStorage(): Set<string> {
try {
const raw = localStorage.getItem(COMPLETED_MISSIONS_STORAGE_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
const ids = parsed.filter((v): v is string => typeof v === "string" && v.length > 0);
return new Set(ids);
} catch {
return new Set();
}
}
function writeToStorage(ids: Set<string>): void {
try {
localStorage.setItem(COMPLETED_MISSIONS_STORAGE_KEY, JSON.stringify(Array.from(ids)));
} catch {
// localStorage unavailable; swallow.
}
}
export interface UseCompletedMissionsResult {
completedMissionIds: Set<string>;
markCompleted(id: string): void;
unmarkCompleted(id: string): void;
}
export function useCompletedMissions(): UseCompletedMissionsResult {
const [completedMissionIds, setCompletedMissionIds] = useState<Set<string>>(() => readFromStorage());
const markCompleted = useCallback((id: string) => {
setCompletedMissionIds((prev) => {
if (prev.has(id)) return prev;
const next = new Set(prev);
next.add(id);
writeToStorage(next);
return next;
});
}, []);
const unmarkCompleted = useCallback((id: string) => {
setCompletedMissionIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
writeToStorage(next);
return next;
});
}, []);
return { completedMissionIds, markCompleted, unmarkCompleted };
}
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { createTestRouter } from "./helpers/test-router.js";
import {
ProgressCard,
buildStorytellingFeedItems,
type ProgressCardSource,
} from "../src/components/feed/cards/storytelling-cards.js";
interface MissionRow {
name: string;
path: string;
}
interface SliceRow {
name: string;
displayName?: string;
status?: string;
lastActivityAt?: string;
}
afterEach(() => {
cleanup();
});
const baseSource: ProgressCardSource = {
missionId: "getting-started",
title: "Getting Started",
oneLiner: "First mission.",
percent: 0,
};
function renderProgressCard(props: Parameters<typeof ProgressCard>[0]) {
// ProgressCard uses no router primitives directly; render plain.
return render(<ProgressCard {...props} />);
}
describe("ProgressCard — Mark complete action (slice 18 Checkpoint E)", () => {
it("does NOT render Mark complete button when onMarkComplete is omitted", () => {
renderProgressCard({ source: baseSource });
expect(screen.queryByTestId("progress-card-mark-complete")).toBeNull();
});
it("renders Mark complete button when onMarkComplete is provided", () => {
renderProgressCard({ source: baseSource, onMarkComplete: () => {} });
expect(screen.getByTestId("progress-card-mark-complete")).toBeTruthy();
});
it("clicking Mark complete calls onMarkComplete with the missionId", () => {
const onMarkComplete = vi.fn();
renderProgressCard({ source: baseSource, onMarkComplete });
fireEvent.click(screen.getByTestId("progress-card-mark-complete"));
expect(onMarkComplete).toHaveBeenCalledWith("getting-started");
});
});
describe("buildStorytellingFeedItems — filter completed missions (slice 18 Checkpoint E)", () => {
const missions: MissionRow[] = [
{ name: "getting-started", path: "/missions/getting-started" },
{ name: "release-0-3-1", path: "/missions/release-0-3-1" },
];
const slices: SliceRow[] = [];
it("returns progress items for missions when no completed set is provided (back-compat)", () => {
const items = buildStorytellingFeedItems(missions, slices);
const progressItems = items.filter((i) => i.kind === "progress");
expect(progressItems).toHaveLength(2);
});
it("filters out missions whose ids are in the completed set", () => {
const completed = new Set(["getting-started"]);
const items = buildStorytellingFeedItems(missions, slices, completed);
const progressItems = items.filter((i) => i.kind === "progress");
expect(progressItems).toHaveLength(1);
expect((progressItems[0] as { source: ProgressCardSource }).source.missionId).toBe(
"release-0-3-1",
);
});
it("returns zero progress items when ALL missions are completed", () => {
const completed = new Set(["getting-started", "release-0-3-1"]);
const items = buildStorytellingFeedItems(missions, slices, completed);
expect(items.filter((i) => i.kind === "progress")).toHaveLength(0);
});
it("empty completed set behaves identically to no completed set", () => {
const items = buildStorytellingFeedItems(missions, slices, new Set());
expect(items.filter((i) => i.kind === "progress")).toHaveLength(2);
});
});
@@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
useCompletedMissions,
COMPLETED_MISSIONS_STORAGE_KEY,
} from "../src/hooks/useCompletedMissions.js";
describe("useCompletedMissions", () => {
beforeEach(() => {
localStorage.clear();
});
it("starts empty when localStorage has no entry", () => {
const { result } = renderHook(() => useCompletedMissions());
expect(result.current.completedMissionIds.size).toBe(0);
});
it("reads existing completed mission ids from localStorage", () => {
localStorage.setItem(
COMPLETED_MISSIONS_STORAGE_KEY,
JSON.stringify(["getting-started", "release-0-3-1"]),
);
const { result } = renderHook(() => useCompletedMissions());
expect(result.current.completedMissionIds.has("getting-started")).toBe(true);
expect(result.current.completedMissionIds.has("release-0-3-1")).toBe(true);
});
it("recovers gracefully from malformed JSON", () => {
localStorage.setItem(COMPLETED_MISSIONS_STORAGE_KEY, "{not json[");
const { result } = renderHook(() => useCompletedMissions());
expect(result.current.completedMissionIds.size).toBe(0);
});
it("recovers gracefully from non-array values", () => {
localStorage.setItem(COMPLETED_MISSIONS_STORAGE_KEY, JSON.stringify({ not: "an-array" }));
const { result } = renderHook(() => useCompletedMissions());
expect(result.current.completedMissionIds.size).toBe(0);
});
it("markCompleted(id) adds to the set and persists to localStorage", () => {
const { result } = renderHook(() => useCompletedMissions());
act(() => result.current.markCompleted("getting-started"));
expect(result.current.completedMissionIds.has("getting-started")).toBe(true);
const stored = JSON.parse(localStorage.getItem(COMPLETED_MISSIONS_STORAGE_KEY) ?? "[]") as string[];
expect(stored).toContain("getting-started");
});
it("markCompleted is idempotent — same id twice keeps one entry", () => {
const { result } = renderHook(() => useCompletedMissions());
act(() => result.current.markCompleted("getting-started"));
act(() => result.current.markCompleted("getting-started"));
const stored = JSON.parse(localStorage.getItem(COMPLETED_MISSIONS_STORAGE_KEY) ?? "[]") as string[];
expect(stored.filter((id) => id === "getting-started")).toHaveLength(1);
});
it("unmarkCompleted(id) removes from set and persists", () => {
localStorage.setItem(COMPLETED_MISSIONS_STORAGE_KEY, JSON.stringify(["getting-started"]));
const { result } = renderHook(() => useCompletedMissions());
act(() => result.current.unmarkCompleted("getting-started"));
expect(result.current.completedMissionIds.has("getting-started")).toBe(false);
const stored = JSON.parse(localStorage.getItem(COMPLETED_MISSIONS_STORAGE_KEY) ?? "[]") as string[];
expect(stored).not.toContain("getting-started");
});
});