Keep project identity through TUI work navigation

This commit is contained in:
v-openrig-build
2026-09-10 00:36:51 +00:00
parent c873a3c6f0
commit fc4d34bd68
23 changed files with 484 additions and 97 deletions
+23
View File
@@ -73,6 +73,29 @@ ownership receipt. A later seat start with no project-world input preserves it;
an explicit install whose `install.skills` is empty clears it. This keeps
"project not supplied" distinct from "project deliberately selects no skills."
## TUI project selection
Open **PROJECTS** (or enter `projects`) to choose an ID and root from the
configured workspace catalog. `project <id>` selects an exact catalog entry;
`mission <directory>` then opens that project's execution view. The historical
`scopes` machine section ID remains valid. Project selection and its canonical
root travel through mission, slice, source-file and Back navigation. Changing a
catalog root requires selecting it again. Missing or malformed sources remain
unavailable; another project's matching work ID is never a fallback.
`source` opens the current project, mission or slice's actual source through the
existing file-reader allowlist. Catalog membership does not grant file-read or
execution permission. These views only read; they do not select a lifecycle
operation or activate a mission. Execution and slice queue membership require
an exact `project:<id>` tag; lifecycle instances use their authored project
identity. Unscoped historical queue rows and global review artifacts without
project binding are excluded from project-specific claims.
The read API adds `GET /api/scopes/projects` and optional `project` and
`projectRoot` parameters to scopes, execution and slice-detail reads. Slice
detail also requires the selected mission directory. Existing reads without a
project parameter keep their legacy contract.
## UI Mapping
- `workspace.root` maps to the Project workspace.
+7 -35
View File
@@ -1,3 +1,4 @@
import { selectCatalogProject, ProjectReadError } from "@openrig/daemon/project-catalog";
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { parse as parseYaml } from "yaml";
@@ -217,41 +218,12 @@ export function resolveWorkPosition(opts: {
const catalogPath = resolve(opts.catalogPath ?? join(workspaceRoot, "workspace.yaml"));
let projectId: string | null = null;
let projectRoot = workspaceRoot;
if (existsSync(catalogPath)) {
const parsed = readYaml(catalogPath);
if (!parsed.value) return failure("workspace_catalog_invalid", parsed.error!);
const rawProjects = parsed.value["projects"];
if (!Array.isArray(rawProjects)) {
return failure("workspace_catalog_invalid", `${catalogPath} must declare projects: [...]`);
}
const projects = rawProjects.map((entry) => {
if (!isRecord(entry) || typeof entry["id"] !== "string" || typeof entry["root"] !== "string") return null;
return { id: entry["id"], root: entry["root"] };
});
if (projects.some((entry) => entry === null)) {
return failure("workspace_catalog_invalid", `${catalogPath} projects must each declare string id and root values`);
}
const declared = projects as Array<{ id: string; root: string }>;
const candidates = declared.map((entry) => entry.id);
const duplicateId = candidates.find((id, index) => candidates.indexOf(id) !== index);
if (duplicateId) {
return failure("project_identity_ambiguous", `project id '${duplicateId}' names multiple roots in ${catalogPath}`);
}
const selectedId = opts.project ?? (declared.length === 1 ? declared[0]!.id : undefined);
if (!selectedId) {
return failure("project_required", "multiple projects are declared; select one with --project", candidates);
}
const selected = declared.find((entry) => entry.id === selectedId);
if (!selected) {
return failure("project_not_found", `project '${selectedId}' is not declared in ${catalogPath}`, candidates);
}
const nominalRoot = isAbsolute(selected.root) ? resolve(selected.root) : resolve(dirname(catalogPath), selected.root);
const canonicalRoot = canonicalExisting(nominalRoot);
if (!canonicalRoot) {
return failure("project_root_missing", `project '${selectedId}' root does not exist: ${nominalRoot}`);
}
projectId = selectedId;
projectRoot = canonicalRoot;
try {
const selected = selectCatalogProject(catalogPath, opts.project);
if (selected) { projectId = selected.id; projectRoot = selected.root; }
} catch (err) {
if (err instanceof ProjectReadError) return failure(err.code, err.message, err.candidates);
throw err;
}
const projectManifestPath = join(projectRoot, "project.yaml");
+1
View File
@@ -5,6 +5,7 @@
"license": "Apache-2.0",
"type": "module",
"exports": {
"./project-catalog": { "types": "./dist/project-catalog-surface.d.ts", "import": "./dist/project-catalog-surface.js" },
"./daemon-shutdown": {
"types": "./dist/daemon-shutdown.d.ts",
"import": "./dist/daemon-shutdown.js"
+21 -15
View File
@@ -1,3 +1,4 @@
import { belongsToProject } from "./workspace/project-catalog.js";
import { inspectGraph } from "./workflow-reconciliation.js";
import { readMissionReadiness, readProjectReadiness } from "./proof/judgments.js";
import { lifecycleObligations, requiredLifecycleSteps } from "./lifecycle-obligations.js";
@@ -303,7 +304,7 @@ function lifecycleProjectAction(input: {
* lifecycle_binding_json carries identity/provenance only. Older databases
* have no migration-079 columns and therefore return an honest empty list.
*/
function readLifecycleExecutions(db: Database.Database, mission: string): Array<Record<string, unknown>> {
function readLifecycleExecutions(db: Database.Database, mission: string, project?: string): Array<Record<string, unknown>> {
if (!hasColumn(db, "workflow_instances", "lifecycle_binding_json")) return [];
const rows = db.prepare(
`SELECT wi.*, ws.spec_json
@@ -320,7 +321,7 @@ function readLifecycleExecutions(db: Database.Database, mission: string): Array<
for (const row of rows) {
const binding = parseJsonRecord(row["lifecycle_binding_json"]);
const identity = isRecord(binding["identity"]) ? binding["identity"] as Record<string, unknown> : {};
if (identity["mission"] !== mission) continue;
if (identity["mission"] !== mission || (project && identity["project"] !== project)) continue;
const instanceId = String(row["instance_id"]);
const frontier = parseJsonStringList(row["current_frontier_json"]);
const specRoot = parseJsonRecord(row["spec_json"]);
@@ -535,24 +536,25 @@ function readArrangement(missionsRoot: string, mission: string, slices: SliceFac
}
}
function readWaveMap(db: Database.Database, missions: string[]): WaveMapData {
function readWaveMap(db: Database.Database, missions: string[], project?: string): WaveMapData {
const missionWhere = missions.map(() => "tags LIKE ?").join(" OR ");
const row = db
.prepare(
`SELECT qitem_id, body FROM queue_items
`SELECT qitem_id, body, tags FROM queue_items
WHERE tags LIKE '%format:wave-map-v1%' AND (${missionWhere})
ORDER BY ts_created DESC LIMIT 1`,
ORDER BY ts_created DESC`,
)
.get(...missions.map((mission) => `%mission:${mission}%`)) as { qitem_id: string; body: string | null } | undefined;
if (!row?.body) return { rowId: INDETERMINATE, waves: [] };
const block = row.body.match(WAVE_MAP_BLOCK);
.all(...missions.map((mission) => `%mission:${mission}%`)) as Array<{ qitem_id: string; body: string | null; tags: string | null }>;
const selectedRow = row.find(r => !project || belongsToProject(r.tags, project));
if (!selectedRow?.body) return { rowId: INDETERMINATE, waves: [] };
const block = selectedRow.body.match(WAVE_MAP_BLOCK);
if (!block) return { rowId: INDETERMINATE, waves: [] };
try {
const parsed = JSON.parse(block[1] ?? "");
if (parsed?.format !== "wave-map-v1" || !Array.isArray(parsed.waves)) {
return { rowId: INDETERMINATE, waves: [] };
}
return { rowId: row.qitem_id, waves: parsed.waves };
return { rowId: selectedRow.qitem_id, waves: parsed.waves };
} catch {
return { rowId: INDETERMINATE, waves: [] };
}
@@ -631,7 +633,7 @@ function gitAncestor(exec: ExecutionViewDeps["exec"], repoCtx: string, sha: stri
}
}
export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: string; rig?: string }): Record<string, unknown> {
export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: string; rig?: string; project?: string }): Record<string, unknown> {
const now = deps.now ?? (() => new Date());
const exec = deps.exec ?? defaultExec;
const buildInfo = deps.buildInfo ?? BUILD_INFO;
@@ -655,6 +657,7 @@ export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: s
.prepare(`SELECT tags, body FROM queue_items WHERE state = 'in-progress' ORDER BY ts_updated DESC`)
.all() as Array<{ tags: string | null; body: string | null }>;
for (const row of active) {
if (opts?.project && !belongsToProject(row.tags, opts.project)) continue;
const tag = parseTags(row.tags).find((value) => value.startsWith("mission:"));
// Canonical tags win; the conventional handoff line keeps older/body-only batons visible.
const bodyMissions = [...new Set(
@@ -687,7 +690,7 @@ export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: s
? ["%"]
: missionReferences(missionsRoot, mission).map((reference) => `%${reference}%`);
const missionWhere = missionLikes.map(() => "(tags LIKE ? OR body LIKE ?)").join(" OR ");
const rows = deps.db
const candidateRows = deps.db
.prepare(
`SELECT qitem_id, source_session, destination_session, state, tags, body, claimed_at,
last_heartbeat, blocked_on, ts_created, ts_updated,
@@ -700,6 +703,8 @@ export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: s
)
.all(...missionLikes.flatMap((like) => [like, like])) as QueueRowLite[];
const rows = candidateRows.filter(row => !opts?.project || belongsToProject(row.tags, opts.project));
const sliceOfRow = (r: QueueRowLite): string | null => {
for (const t of parseTags(r.tags)) {
const m = t.match(SLICE_TAG);
@@ -794,11 +799,11 @@ export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: s
// ---- arrangement authority: mission/slice YAML, with legacy EC-2 fallback ----
const waveMap = mission === INDETERMINATE
? { rowId: INDETERMINATE as Indeterminate, waves: [] }
: readWaveMap(deps.db, missionReferences(missionsRoot, mission));
: readWaveMap(deps.db, missionReferences(missionsRoot, mission), opts?.project);
const arrangement = missionsRoot && mission !== INDETERMINATE
? readArrangement(missionsRoot, mission, slices)
: null;
const lifecycleExecutions = mission === INDETERMINATE ? [] : readLifecycleExecutions(deps.db, mission);
const lifecycleExecutions = mission === INDETERMINATE ? [] : readLifecycleExecutions(deps.db, mission, opts?.project);
const arrangementSlice = (id: string, dir?: string): ArrangementSlice | null => {
if (arrangement?.state !== "valid") return null;
return arrangement.byId.get(id) ?? (dir ? arrangement.byDir.get(dir) : undefined) ?? null;
@@ -868,10 +873,10 @@ export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: s
// tags, full shas, annotated fields) join by RESOLVED commit; malformed,
// ambiguous, or non-resolving inputs are excluded with the reason carried —
// they neither clear nor poison.
const artifacts = scanReviewArtifacts(rigsRoot, [facts.dir, ...(typeof facts.id === "string" ? [facts.id] : [])]);
const artifacts = opts?.project ? INDETERMINATE : scanReviewArtifacts(rigsRoot, [facts.dir, ...(typeof facts.id === "string" ? [facts.id] : [])]);
let reviewed: Record<string, unknown>;
if (artifacts === INDETERMINATE) {
reviewed = { value: INDETERMINATE, basis: `review-artifact root unreadable (${rigsRoot})`, legs: [] };
reviewed = { value: INDETERMINATE, basis: opts?.project ? "Global review artifacts have no project identity binding" : `review-artifact root unreadable (${rigsRoot})`, legs: [] };
} else if (!candidateSha || !builtToken) {
reviewed = { value: INDETERMINATE, basis: "no built candidate token to scope review legs to", legs: [] };
} else if (!builtResolved) {
@@ -1130,6 +1135,7 @@ export function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: s
};
return {
...(opts?.project ? { project: opts.project, membership: "exact project:<id> queue tags and lifecycle identity; unscoped rows excluded" } : {}),
view: "execution",
readiness,
project_readiness: missionsRoot ? readProjectReadiness(missionsRoot) : null,
@@ -223,6 +223,10 @@ export class SliceDetailProjector {
this.workflowSpecCache = opts.workflowSpecCache;
}
withIndexer(indexer: SliceIndexer): SliceDetailProjector {
return new SliceDetailProjector({ db: this.db, indexer, workflowSpecCache: this.workflowSpecCache });
}
project(slice: SliceRecord): SliceDetailPayload {
// v1: resolve workflow_instance binding once; the bound spec drives
// four downstream dimensions (story phase tags, spec graph, current
@@ -1,3 +1,4 @@
import { belongsToProject } from "../workspace/project-catalog.js";
// Slice Story View v0 — slice indexer.
//
// Reads slice folders from a configured filesystem root. The default
@@ -110,6 +111,8 @@ export interface SliceListEntry {
}
export interface SliceIndexerOpts {
projectId?: string;
missionId?: string;
/** Root directory containing slice folders. */
slicesRoot: string;
/** Additional compatible roots to scan after the primary root. */
@@ -255,6 +258,8 @@ const STATUS_TO_BUCKET: Record<string, SliceStatus> = {
};
export class SliceIndexer {
readonly projectId?: string;
readonly missionId?: string;
readonly slicesRoot: string;
readonly additionalSliceRoots: string[];
readonly dogfoodEvidenceRoot: string | null;
@@ -279,6 +284,8 @@ export class SliceIndexer {
} | null = null;
constructor(opts: SliceIndexerOpts) {
this.projectId = opts.projectId;
this.missionId = opts.missionId;
this.slicesRoot = opts.slicesRoot;
this.additionalSliceRoots = opts.additionalSliceRoots ?? [];
this.dogfoodEvidenceRoot = opts.dogfoodEvidenceRoot;
@@ -510,7 +517,7 @@ export class SliceIndexer {
});
}
}
return locations.sort((a, b) => a.name.localeCompare(b.name));
return locations.filter(location => !this.missionId || location.missionId === this.missionId).sort((a, b) => a.name.localeCompare(b.name));
}
private findSliceLocation(name: string): SliceLocation | null {
@@ -860,6 +867,7 @@ export class SliceIndexer {
`SELECT qitem_id, tags FROM queue_items WHERE tags LIKE '%slice:%' ORDER BY ts_created DESC, qitem_id DESC`,
);
for (const r of stmt.iterate() as Iterable<{ qitem_id: string; tags: string | null }>) {
if (this.projectId && !belongsToProject(r.tags, this.projectId)) continue;
for (const tagged of parseScopeTags(r.tags).slices) {
const bucket = typedBySlice.get(tagged);
if (!bucket) typedBySlice.set(tagged, [r.qitem_id]);
@@ -931,6 +939,7 @@ export class SliceIndexer {
? this.db.prepare(`SELECT qitem_id, body, tags FROM queue_items`)
: this.db.prepare(`SELECT qitem_id, body FROM queue_items`);
for (const r of scan.iterate() as Iterable<{ qitem_id: string; body: string; tags?: string | null }>) {
if (this.projectId && !belongsToProject(r.tags, this.projectId)) continue;
const hayBody = asciiFold(sqliteVisiblePrefix(r.body));
const hayTags = r.tags != null ? asciiFold(sqliteVisiblePrefix(r.tags)) : null;
if (!prefilter.test(hayBody) && !(hayTags !== null && prefilter.test(hayTags))) continue;
+3 -2
View File
@@ -1,3 +1,4 @@
import type { ProjectRead } from "./workspace/project-read.js";
import type Database from "better-sqlite3";
import { lastMeaningfulTransition } from "./queue-waiting.js";
import { derivePickup } from "./queue-pickup.js";
@@ -116,7 +117,7 @@ export class ViewProjector {
* Run a view by name. Built-in names (BUILT_IN_VIEW_NAMES) dispatch to
* hardcoded SQL; other names dispatch to custom-view lookup.
*/
show(viewName: string, opts?: { rig?: string; limit?: number; mission?: string }): ViewQueryResult {
show(viewName: string, opts?: { rig?: string; limit?: number; mission?: string; project?: ProjectRead | null }): ViewQueryResult {
const limit = Math.max(1, Math.min(opts?.limit ?? 100, 1000));
// S27 — the execution view is document-shaped (rows = [one JSON document])
// and derives from fs/git/build-info legs beyond this class's SQL, so it
@@ -128,7 +129,7 @@ export class ViewProjector {
"execution view deps are not wired on this daemon (setExecutionDeps was never called)",
);
}
const doc = buildExecutionView(this.executionDeps, { mission: opts?.mission, rig: opts?.rig });
const doc = buildExecutionView(opts?.project ? { ...this.executionDeps, slicesRoot: () => opts.project!.missionsRoot } : this.executionDeps, { mission: opts?.mission, rig: opts?.rig, project: opts?.project?.id });
return { viewName: "execution", generatedAt: this.now().toISOString(), rows: [doc], rowCount: 1 };
}
if ((BUILT_IN_VIEW_NAMES as readonly string[]).includes(viewName)) {
@@ -0,0 +1,44 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { parse } from "yaml";
export class ProjectReadError extends Error {
constructor(readonly code: string, message: string, readonly candidates?: string[]) { super(message); }
}
export function yamlObject(file: string): Record<string, any> {
try {
const value = parse(fs.readFileSync(file, "utf8"));
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected a YAML object");
return value;
} catch (err) { throw new ProjectReadError("workspace_catalog_invalid", `${file}: ${(err as Error).message}`); }
}
/** The existing workspace.yaml catalog, shared by work-install and project reads. */
export function readProjectCatalog(catalogPath: string): Array<{ id: string; root: string }> | null {
if (!fs.existsSync(catalogPath)) return null;
const entries = yamlObject(catalogPath).projects;
if (!Array.isArray(entries) || entries.some(e => !e || typeof e.id !== "string" || typeof e.root !== "string"))
throw new ProjectReadError("workspace_catalog_invalid", `${catalogPath} projects must each declare string id and root values`);
const ids = entries.map(e => e.id);
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
if (duplicate) throw new ProjectReadError("project_identity_ambiguous", `project id '${duplicate}' names multiple roots in ${catalogPath}`);
return entries.map(e => ({ id: e.id, root: e.root }));
}
export function selectCatalogProject(catalogPath: string, selectedId?: string): { id: string; root: string } | null {
const projects = readProjectCatalog(catalogPath);
if (!projects) return null;
const candidates = projects.map(p => p.id);
const id = selectedId ?? (projects.length === 1 ? projects[0]!.id : undefined);
if (!id) throw new ProjectReadError("project_required", "multiple projects are declared; select one with --project", candidates);
const selected = projects.find(p => p.id === id);
if (!selected) throw new ProjectReadError("project_not_found", `project '${id}' is not declared in ${catalogPath}`, candidates);
const nominal = path.resolve(path.dirname(catalogPath), selected.root);
try { return { id, root: fs.realpathSync(nominal) }; }
catch { throw new ProjectReadError("project_root_missing", `project '${id}' root does not exist: ${nominal}`); }
}
/** Exact project membership; unscoped historical rows are not assigned to a selected project. */
export function belongsToProject(raw: string | null | undefined, id: string): boolean {
try {
const tags: unknown = JSON.parse(raw ?? "null");
return Array.isArray(tags) && tags.flatMap(t => typeof t === "string" ? t.split(",").map(s => s.trim()) : []).includes(`project:${id}`);
} catch { return false; }
}
@@ -0,0 +1,90 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { parse } from "yaml";
import type { SettingsStore } from "../user-settings/settings-store.js";
import { NODE_FILE_PRECEDENCE } from "../scope/node-file.js";
import { readProjectCatalog, selectCatalogProject, yamlObject, ProjectReadError } from "./project-catalog.js";
export interface ProjectRead { id: string; root: string; name: string; sourcePath: string | null; missionsRoot: string; error?: string }
type ReadContext = { get: (key: never) => unknown; req: { query: (key: string) => string | undefined } };
export function projectCatalogPaths(c: Pick<ReadContext, "get">) {
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
const workspace = store?.resolveOne("workspace.root").value;
if (typeof workspace !== "string" || !workspace) throw new ProjectReadError("workspace_root_missing", "Workspace root is not configured");
const configured = store?.resolveOne("workspace.catalog_path").value;
return { workspace, catalog: typeof configured === "string" && configured ? configured : path.join(workspace, "workspace.yaml") };
}
export function insideProject(root: string, target: string): string {
const actual = fs.realpathSync(target);
const rel = path.relative(root, actual);
if (path.isAbsolute(rel) || rel === ".." || rel.startsWith(`..${path.sep}`)) throw new ProjectReadError("project_path_escape", `Source is outside selected project: ${target}`);
return actual;
}
export function workSource(root: string, dir: string): string {
insideProject(root, dir);
const source = NODE_FILE_PRECEDENCE.map(name => path.join(dir, name)).find(file => fs.existsSync(file));
if (!source) throw new ProjectReadError("source_unavailable", `No work source at ${dir}`);
insideProject(root, source);
const text = fs.readFileSync(source, "utf8");
if (text.startsWith("---")) {
const end = text.indexOf("\n---", 3);
if (end < 0) throw new ProjectReadError("source_invalid", `Unterminated frontmatter: ${source}`);
const value = parse(text.slice(3, end));
if (value !== null && (typeof value !== "object" || Array.isArray(value))) throw new ProjectReadError("source_invalid", `Invalid frontmatter: ${source}`);
}
return source;
}
function projectEntry(id: string, root: string): ProjectRead {
let p: ProjectRead = { id, root, name: id, sourcePath: null, missionsRoot: path.join(root, "missions") };
try {
p.root = fs.realpathSync(root);
const manifest = path.join(p.root, "project.yaml");
const value = fs.existsSync(manifest) ? yamlObject(insideProject(p.root, manifest)) : {};
const declared = value.id ?? value.metadata?.id;
if (declared && declared !== id) throw new ProjectReadError("project_identity_conflict", `Catalog ${id} conflicts with project identity ${declared}`);
p.name = typeof value.metadata?.name === "string" ? value.metadata.name : id;
const missions = value.missions?.root ?? "missions";
if (typeof missions !== "string" || path.isAbsolute(missions) || missions.split(/[\\/]/).includes("..")) throw new ProjectReadError("missions_root_escape", "Invalid project missions.root");
p.missionsRoot = path.resolve(p.root, missions);
p.sourcePath = workSource(p.root, p.root);
if (fs.existsSync(p.missionsRoot)) insideProject(p.root, p.missionsRoot);
} catch (err) { p.error = (err as Error).message; }
return p;
}
export function listProjects(c: Pick<ReadContext, "get">): { catalogPath: string; projects: ProjectRead[] } {
const { workspace, catalog } = projectCatalogPaths(c);
const entries = readProjectCatalog(catalog);
if (entries) return { catalogPath: catalog, projects: entries.map(e => projectEntry(e.id, path.resolve(path.dirname(catalog), e.root))) };
// Uncatalogued workspaces keep their existing single-project authority.
const manifest = path.join(workspace, "project.yaml");
const value = fs.existsSync(manifest) ? yamlObject(manifest) : {};
return { catalogPath: catalog, projects: [projectEntry(value.id ?? value.metadata?.id ?? "workspace", workspace)] };
}
export function selectedProject(c: ReadContext): ProjectRead | null {
const id = c.req.query("project");
if (id === undefined) return null;
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id)) throw new ProjectReadError("invalid_project", "Invalid project ID");
const { catalog } = projectCatalogPaths(c);
const selected = selectCatalogProject(catalog, id);
const p = selected ? projectEntry(selected.id, selected.root) : listProjects(c).projects.find(p => p.id === id);
if (!p) throw new ProjectReadError("project_not_found", `Project ${id} is unavailable`);
if (c.req.query("projectRoot") && c.req.query("projectRoot") !== p.root) throw new ProjectReadError("project_changed", `Project ${id} root changed; choose it again`);
if (p.error) throw new ProjectReadError("project_unavailable", `${id}: ${p.error}`);
if (!fs.existsSync(p.missionsRoot)) throw new ProjectReadError("missions_unavailable", `${id}: missions root is unavailable: ${p.missionsRoot}`);
return p;
}
export function projectReadResponse(err: unknown): Response {
return Response.json({ error: err instanceof ProjectReadError ? err.code : "project_source_unavailable", message: (err as Error).message }, { status: 409 });
}
export function projectMission(p: ProjectRead, mission: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(mission)) throw new ProjectReadError("invalid_mission", "Invalid mission directory");
const dir = path.join(p.missionsRoot, mission);
workSource(p.root, dir);
const slices = path.join(dir, "slices");
if (fs.existsSync(slices)) {
insideProject(p.root, slices);
for (const child of fs.readdirSync(slices, { withFileTypes: true }))
if (child.isDirectory() || child.isSymbolicLink()) workSource(p.root, path.join(slices, child.name));
}
return dir;
}
@@ -0,0 +1 @@
export { readProjectCatalog, selectCatalogProject, ProjectReadError } from "./domain/workspace/project-catalog.js";
+30 -4
View File
@@ -1,3 +1,4 @@
import { listProjects, selectedProject, projectReadResponse, projectMission, workSource, insideProject } from "../domain/workspace/project-read.js";
// SCOPES VIEW (sealed plan d64d2f5c) — the store-direct read routes behind the scopes TUI.
// GET /api/scopes?mission=X -> mission slice summaries (cards/counts/locks)
// GET /api/scopes/slice?mission=&slice= -> the full detail (intent/mini-reqs/contract+drops)
@@ -19,7 +20,9 @@ const realFs: ScopeFsDeps = {
isDirectory: (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } },
};
function rootOf(c: { get: (k: never) => unknown }): { root: string } | { error: Response } {
function rootOf(c: Parameters<typeof selectedProject>[0]): { root: string } | { error: Response } {
const selected = selectedProject(c);
if (selected) return { root: selected.missionsRoot };
const indexer = c.get("sliceIndexer" as never) as SliceIndexer | undefined;
if (!indexer) return { error: Response.json({ error: "slices_indexer_unavailable" }, { status: 503 }) };
if (!indexer.isReady()) return { error: Response.json({ error: "slices_root_not_configured" }, { status: 503 }) };
@@ -28,19 +31,24 @@ function rootOf(c: { get: (k: never) => unknown }): { root: string } | { error:
export function scopesRoutes(): Hono {
const app = new Hono();
app.get("/projects", c => { try { return c.json(listProjects(c)); } catch (err) { return projectReadResponse(err); } });
app.onError(err => projectReadResponse(err));
app.get("/", (c) => {
const r = rootOf(c);
if ("error" in r) return r.error;
const selected = selectedProject(c);
const mission = c.req.query("mission");
if (selected && mission) projectMission(selected, mission);
const wantDetail = c.req.query("detail") === "1";
const detailFor = (missionName: string, dirName: string): (SliceScopeDetail & { narrative: string | null }) | null => {
if (selected) workSource(selected.root, path.join(r.root, missionName, "slices", dirName));
const d = projectSliceScope(realFs, path.join(r.root, missionName, "slices", dirName));
if (!d) return null;
// The TUI one-read hydrate: narrative CONTENT rides inline for the `n` DISPLAY —
// still never a data source (the projection never reads it for counts).
const narrative = d.progressPath ? realFs.readFile(d.progressPath) : null;
return { ...d, narrative };
return { ...d, narrative, ...(selected ? { sourcePath: workSource(selected.root, path.join(r.root, missionName, "slices", dirName)) } : {}) };
};
if (mission) {
const m = projectMissionScopes(realFs, r.root, mission);
@@ -51,19 +59,33 @@ export function scopesRoutes(): Hono {
}
// No mission param: list every mission (the explorer tree); ?detail=1 upgrades rows to details.
const missionNames = realFs.listDir(r.root).filter((e) => realFs.isDirectory(path.join(r.root, e)));
const missions = missionNames
const readErrors: string[] = [];
const sources: Record<string, string> = {};
const visibleMissions = missionNames.filter(name => {
if (!selected) return true;
try {
const dir = projectMission(selected, name);
sources[name] = workSource(selected.root, dir);
return true;
} catch (err) { readErrors.push(`${name}: ${(err as Error).message}`); return false; }
});
const missions = visibleMissions
.map((e) => projectMissionScopes(realFs, r.root, e))
.filter((m): m is NonNullable<typeof m> => m !== null)
.map((m) => ({ ...(wantDetail ? { mission: m.mission, slices: m.slices.map((sl) => detailFor(m.mission, sl.dirName)).filter(Boolean) } : m), readiness: readMissionReadiness(path.join(r.root, m.mission)) }));
return c.json({ missions, sourceObservation: proofSourceObservation(c) });
return c.json({ missions, sources, readErrors, project: selected, sourceObservation: proofSourceObservation(c) });
});
app.get("/slice", (c) => {
const r = rootOf(c);
if ("error" in r) return r.error;
const selected = selectedProject(c);
const mission = c.req.query("mission");
if (selected && mission) projectMission(selected, mission);
const slice = c.req.query("slice");
if (selected && slice && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(slice)) return c.json({ error: "invalid_slice" }, 400);
if (!mission || !slice) return c.json({ error: "missing_params", hint: "?mission=&slice=" }, 400);
if (selected && slice) workSource(selected.root, path.join(r.root, mission!, "slices", slice));
const detail = projectSliceScope(realFs, path.join(r.root, mission, "slices", slice));
return detail ? c.json(detail) : c.json({ error: "slice_not_found", mission, slice }, 404);
});
@@ -71,10 +93,14 @@ export function scopesRoutes(): Hono {
app.get("/narrative", (c) => {
const r = rootOf(c);
if ("error" in r) return r.error;
const selected = selectedProject(c);
const mission = c.req.query("mission");
if (selected && mission) projectMission(selected, mission);
const slice = c.req.query("slice");
if (selected && slice && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(slice)) return c.json({ error: "invalid_slice" }, 400);
if (!mission || !slice) return c.json({ error: "missing_params", hint: "?mission=&slice=" }, 400);
const p = path.join(r.root, mission, "slices", slice, "PROGRESS.md");
if (selected) insideProject(selected.root, p);
const content = realFs.readFile(p);
// The narrative DISPLAY (plan atom 3): raw artifact bytes; explicitly not a data source.
return content !== null ? c.json({ path: p, content }) : c.json({ error: "narrative_not_found" }, 404);
+15 -3
View File
@@ -1,3 +1,4 @@
import { selectedProject, projectMission, workSource, projectReadResponse } from "../domain/workspace/project-read.js";
// Slice Story View v0 — HTTP routes.
//
// Endpoints:
@@ -18,8 +19,8 @@ import { Hono } from "hono";
import { readSliceReadiness, readProjectReadiness } from "../domain/proof/judgments.js";
import * as fs from "node:fs";
import * as path from "node:path";
import type { SliceIndexer, SliceListEntry, SliceStatus } from "../domain/slices/slice-indexer.js";
import type { SliceDetailProjector } from "../domain/slices/slice-detail-projector.js";
import { SliceIndexer, SliceListEntry, SliceStatus } from "../domain/slices/slice-indexer.js";
import { SliceDetailProjector } from "../domain/slices/slice-detail-projector.js";
import { findSliceWorkflowBinding } from "../domain/workflow/slice-workflow-binding.js";
export interface SlicesRoutesDeps {
@@ -173,9 +174,20 @@ export function slicesRoutes(): Hono {
// 4) Dynamic `/:name` LAST so the literal routes above are not shadowed.
app.get("/:name", (c) => {
const deps = getDeps(c);
let deps = getDeps(c);
if (!deps) return c.json({ error: "slices_indexer_unavailable" }, 503);
const name = c.req.param("name");
try {
const project = selectedProject(c);
if (project) {
const mission = c.req.query("mission");
if (!mission || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)) return c.json({ error: "exact_mission_and_slice_required" }, 400);
const dir = projectMission(project, mission);
workSource(project.root, path.join(dir, "slices", name));
const indexer = new SliceIndexer({ db: deps.indexer.db, slicesRoot: project.missionsRoot, dogfoodEvidenceRoot: null, projectId: project.id, missionId: mission });
deps = { indexer, projector: deps.projector.withIndexer(indexer) };
}
} catch (err) { return projectReadResponse(err); }
const slice = deps.indexer.get(name);
if (!slice) return c.json({ error: "slice_not_found", name }, 404);
const payload = deps.projector.project(slice);
+7 -1
View File
@@ -1,3 +1,4 @@
import { selectedProject, projectMission, projectReadResponse } from "../domain/workspace/project-read.js";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import type { EventBus } from "../domain/event-bus.js";
@@ -116,7 +117,12 @@ export function viewsRoutes(): Hono {
// in the module; --mission widens/narrows).
const mission = c.req.query("mission") || undefined;
try {
const result = getProjector(c).show(viewName, { rig, limit, mission });
let project;
try {
project = viewName === "execution" ? selectedProject(c) : null;
if (project && mission) projectMission(project, mission);
} catch (err) { return projectReadResponse(err); }
const result = getProjector(c).show(viewName, { rig, limit, mission, project });
return c.json(result);
} catch (err) {
return errorResponse(c, err);
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it } 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 { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { ViewProjector } from "../src/domain/view-projector.js";
import { SliceIndexer } from "../src/domain/slices/slice-indexer.js";
import { SliceDetailProjector } from "../src/domain/slices/slice-detail-projector.js";
import { scopesRoutes } from "../src/routes/scopes.js";
import { viewsRoutes } from "../src/routes/views.js";
import { slicesRoutes } from "../src/routes/slices.js";
import { selectCatalogProject } from "../src/domain/workspace/project-catalog.js";
let root: string, app: Hono, db: ReturnType<typeof createDb>;
function file(name: string, text: string) { fs.mkdirSync(path.dirname(name), { recursive: true }); fs.writeFileSync(name, text); }
function source(text: string) { return `---\nid: same-id\nstatus: active\n---\n# Same name\n\n## Intent\n\n${text}\n`; }
beforeEach(() => {
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "s03-projects-")));
file(path.join(root, "workspace.yaml"), "projects:\n - id: a\n root: a\n - id: b\n root: b\n");
for (const id of ["a", "b"]) {
file(path.join(root, id, "project.yaml"), `metadata:\n id: ${id}\n name: Same project\n`);
file(path.join(root, id, "SPEC.md"), source(`${id} project only`));
file(path.join(root, id, "missions/release-x/SPEC.md"), source(`${id} mission only`));
file(path.join(root, id, "missions/release-x/slices/01-story/SPEC.md"), source(`${id} slice only`));
}
db = createDb(); migrate(db, ALL_MIGRATIONS);
for (const id of ["a", "b", "unscoped"]) db.prepare("INSERT INTO queue_items(qitem_id,ts_created,ts_updated,source_session,destination_session,state,priority,tier,tags,body) VALUES(?,?,?,?,?,'in-progress','normal','light',?,?)")
.run(`qitem-${id}`, "2026-09-10T00:00:00Z", "2026-09-10T00:00:00Z", "root@test", `${id}@test`, JSON.stringify(["mission:release-x", "slice:same-id", ...(id === "unscoped" ? [] : [`project:${id}`])]), `${id} work`);
const projector = new ViewProjector(db, new EventBus(db));
projector.setExecutionDeps({ db, slicesRoot: () => path.join(root, "b/missions") });
const indexer = new SliceIndexer({ db, slicesRoot: path.join(root, "b/missions"), dogfoodEvidenceRoot: null });
const detail = new SliceDetailProjector({ db, indexer });
app = new Hono();
app.use("*", async(c, next) => {
c.set("settingsStore" as never, { resolveOne: (key: string) => ({ value: key === "workspace.root" ? root : path.join(root, "workspace.yaml") }) });
c.set("viewProjector" as never, projector); c.set("sliceIndexer" as never, indexer); c.set("sliceDetailProjector" as never, detail);
await next();
});
app.route("/api/scopes", scopesRoutes()); app.route("/api/views", viewsRoutes()); app.route("/api/slices", slicesRoutes());
});
afterEach(() => { db.close(); fs.rmSync(root, { recursive: true, force: true }); });
async function get(url: string) { const response = await app.request(url); return { status: response.status, body: await response.json() as any }; }
describe("catalog project identity across work reads", () => {
it("shares CLI catalog selection, preserves equal work IDs and filters execution/queue membership", async () => {
const catalog = await get("/api/scopes/projects"); expect(catalog.body.projects.map((p: any) => p.id)).toEqual(["a", "b"]);
for (const id of ["a", "b"]) {
expect(selectCatalogProject(path.join(root, "workspace.yaml"), id)?.root).toBe(path.join(root, id));
const scopes = await get(`/api/scopes?detail=1&project=${id}`);
expect(scopes.body.missions[0].slices[0].intent).toBe(`${id} slice only`);
const execution = await get(`/api/views/execution?mission=release-x&project=${id}`);
expect(execution.status).toBe(200);
expect(JSON.stringify(execution.body)).toContain(`qitem-${id}`);
expect(JSON.stringify(execution.body)).not.toContain(`qitem-${id === "a" ? "b" : "a"}`);
expect(JSON.stringify(execution.body)).not.toContain("qitem-unscoped");
const detail = await get(`/api/slices/01-story?mission=release-x&project=${id}`);
expect(detail.status).toBe(200); expect(detail.body.qitemIds).toEqual([`qitem-${id}`]);
}
});
it("reports removed, changed, malformed and escaping sources without substituting another project", async () => {
fs.renameSync(path.join(root, "a/SPEC.md"), path.join(root, "a/SPEC.retained.md"));
let scopes = await get("/api/scopes?detail=1&project=a"); expect(scopes.status).toBe(409); expect(scopes.body.error).toBe("project_unavailable");
expect(JSON.stringify(scopes.body)).not.toContain("b slice only");
expect((await get("/api/scopes?detail=1&project=b")).status).toBe(200);
file(path.join(root, "a/SPEC.md"), source("a restored"));
expect((await get("/api/scopes?project=a&projectRoot=wrong-root")).body.error).toBe("project_changed");
file(path.join(root, "a/missions/release-x/slices/01-story/SPEC.md"), "---\nid: [broken\n---\n");
scopes = await get("/api/scopes?detail=1&project=a"); expect(scopes.body.missions).toEqual([]); expect(scopes.body.readErrors.length).toBe(1);
expect((await get("/api/views/execution?project=a&mission=../b")).status).toBe(409);
fs.renameSync(path.join(root, "a/missions"), path.join(root, "a/missions-retained"));
fs.symlinkSync(path.join(root, "b/missions"), path.join(root, "a/missions"));
expect((await get("/api/scopes?project=a")).status).toBe(409);
file(path.join(root, "workspace.yaml"), "projects: [broken");
expect((await get("/api/scopes/projects")).status).toBe(409);
});
});
+3
View File
@@ -97,6 +97,9 @@ export const COMMAND_REGISTRY: readonly CommandEntry[] = [
{ name: "recent", aliases: [], args: "<transition-id>", description: "inspect an original event from the served Recent window", context: "standard", sample: "recent 1", complete: ({ snapshot }) => (snapshot.recentTransitions ?? []).map((r) => String(r.transitionId)), build: (id) => /^\d+$/.test(id) && Number.isSafeInteger(Number(id)) ? { type: "recent-open", transitionId: Number(id) } : { type: "error", message: "recent needs a transition id from the served window" } },
{ name: "connections", aliases: [], args: "", description: "legacy gateway/routes view; use CONFIG for instance settings", context: "standard", sample: "connections", build: () => ({ type: "jump", section: "connections" }) },
{ name: "back", aliases: [], args: "", description: "return to the previous view, selection and scroll", context: "standard", sample: "back", build: () => ({ type: "back" }) },
{ name: "projects", aliases: [], args: "", description: "choose a project from the workspace catalog", context: "standard", sample: "projects", build: () => ({ type: "jump", section: "scopes" }) },
{ name: "project", aliases: [], args: "<id>", description: "select an exact catalog project", context: "standard", sample: "project example", complete: ({ snapshot }) => (snapshot.projects?.projects ?? []).map(p => p.id), build: id => id ? { type: "project-select", id } : { type: "error", message: "project needs a catalog ID" } },
{ name: "source", aliases: [], args: "", description: "read the selected project, mission or slice source", context: "standard", sample: "source", build: () => ({ type: "project-source" }) },
{ name: "mission", aliases: [], args: "<name>", description: "open a mission's work and workflows", context: "standard", sample: "mission release-demo", complete: ({ snapshot }) => (snapshot.scopes ?? []).map((m) => m.mission), build: (name) => name ? { type: "scopes-mission-open", mission: name } : { type: "error", message: "mission needs a name" } },
{ name: "workflow", aliases: [], args: "<instance-id>", description: "open a workflow in the selected mission", context: "standard", sample: "workflow example", complete: (ctx) => workflowArgs(ctx, false), build: (name) => name ? { type: "execution-open", key: `workflow:${name}` } : { type: "error", message: "workflow needs an instance id" } },
{ name: "packet", aliases: [], args: "<qitem-id>", description: "open current workflow work in the selected mission", context: "standard", sample: "packet example", complete: (ctx) => workflowArgs(ctx, true), build: (name) => name ? { type: "execution-open", key: `packet:${name}` } : { type: "error", message: "packet needs a queue id" } },
+13 -6
View File
@@ -1,3 +1,4 @@
import type { ProjectSelection } from "./types.js";
// The TUI's ONLY daemon surface — thin typed fetch wrappers over the §4.A
// endpoint table, ONE module by design: the R7 no-new-data source-check is a
// one-file read (every route below is an EXISTING, web-consumed daemon read).
@@ -191,17 +192,23 @@ export class DaemonClient {
// --- Needs-You (§4.A rows 5–6): composeNeedsYou legs + host/rig-down beside ---
/** SCOPES view (d64d2f5c): the store-direct one-read hydrate. */
scopesDetailed() {
return this.get("/api/scopes?detail=1");
projects() { return this.get("/api/scopes/projects"); }
private projectQuery(project?: ProjectSelection | null): string {
return project ? `&project=${encodeURIComponent(project.id)}&projectRoot=${encodeURIComponent(project.root)}` : "";
}
scopesDetailed(project?: ProjectSelection | null) {
return this.get(`/api/scopes?detail=1${this.projectQuery(project)}`);
}
/** EXECUTION view via the shipped generic view route; no new endpoint. */
execution(mission?: string) {
return this.get(`/api/views/execution${mission ? `?mission=${encodeURIComponent(mission)}` : ""}`);
execution(mission?: string, project?: ProjectSelection | null) {
const query = `${mission ? `&mission=${encodeURIComponent(mission)}` : ""}${this.projectQuery(project)}`;
return this.get(`/api/views/execution${query ? "?" + query.slice(1) : ""}`);
}
/** Existing six-tab slice payload; read only for the opened slice. */
sliceDetail(directory: string) {
return this.get(`/api/slices/${encodeURIComponent(directory)}`);
sliceDetail(directory: string, mission?: string | null, project?: ProjectSelection | null) {
const query = `${mission ? `&mission=${encodeURIComponent(mission)}` : ""}${this.projectQuery(project)}`;
return this.get(`/api/slices/${encodeURIComponent(directory)}${query ? "?" + query.slice(1) : ""}`);
}
/** One bounded typed queue chronology for the current topology scope. */
+18 -1
View File
@@ -344,7 +344,7 @@ function agentSpecTruth(raw?: string): { runtime?: string; skills: string[] } {
* avoids re-reading every spec every refresh; the key rolls when the library
* entry's updatedAt changes. Owned by the caller (instance-scoped, no module state). */
export type SpecReviewCache = Map<string, SpecLibraryReviewRead>;
export type HydrateViewContext = Pick<ViewState, "section" | "viewTab" | "drill" | "file" | "externalUrl">;
export type HydrateViewContext = Pick<ViewState, "project" | "section" | "viewTab" | "drill" | "file" | "externalUrl">;
export async function hydrateSnapshot(
client: DaemonClient,
@@ -375,6 +375,23 @@ export async function hydrateSnapshot(
}
if (viewContext?.externalUrl) return { ...emptySnapshot(), hydratedAt: new Date().toISOString() };
// Project reads never fall back to the daemon's default workspace or fleet queue.
if (viewContext?.section === "scopes") {
const projects = await safe<NonNullable<FleetSnapshot["projects"]>>("projects", () => client.projects());
const selected = viewContext.project;
const project = selected && projects?.projects.find(p => p.id === selected.id && p.root === selected.root);
if (selected && (!project || project.error)) readErrors.push(`project ${selected.id}: ${project?.error ?? "selection changed or unavailable; choose the project again"}`);
const readable = !!project && !project.error;
const [scopes, execution, detail, roots] = await Promise.all([
readable ? safe<{ missions: FleetSnapshot["scopes"]; sources?: Record<string, string>; readErrors?: string[] }>("scopes", () => client.scopesDetailed(selected)) : null,
readable && executionMission ? safe<{ rows: NonNullable<FleetSnapshot["execution"]>[] }>("execution", () => client.execution(executionMission, selected)) : null,
readable && executionMission && sliceDetailName ? safe<SliceDetailSnap>("slice-detail", () => client.sliceDetail(sliceDetailName, executionMission, selected)) : null,
safe<Awaited<ReturnType<DaemonClient["fileRoots"]>>>("file-roots", () => client.fileRoots()),
]);
readErrors.push(...(scopes?.readErrors ?? []));
return { ...emptySnapshot(), projects, projectRead: selected, projectSources: scopes?.sources, scopes: scopes?.missions ?? [], execution: execution?.rows[0] ?? null, executionMission, sliceDetail: detail, sliceDetailName, fileRoots: roots?.roots ?? [], readErrors, hydratedAt: new Date().toISOString() };
}
// CONFIG never invokes fleet aggregation, host probes, queue enrichment or provider checks.
// Failures replace earlier values with an explicit unavailable state.
if (viewContext?.section === "config") {
+24 -19
View File
@@ -683,7 +683,7 @@ function instanceContentLines(
function contentLines(state: ViewState, snap: FleetSnapshot, contentWidth: number, motion: MotionCtx): ContentLine[] {
if (state.file) {
const read = JSON.stringify(snap.fileRead?.target) === JSON.stringify(state.file) ? snap.fileRead?.result : null;
return fileLines(read, state.file, contentWidth);
return [...(state.project ? wrapDetailLines([{ text: `Project ${state.project.id} · ${state.project.root}` }], contentWidth) : []), ...fileLines(read, state.file, contentWidth)];
}
if (state.externalUrl) return externalLines(state.externalUrl, contentWidth);
const contentWidthForGraph = contentWidth;
@@ -1111,16 +1111,21 @@ function contentLines(state: ViewState, snap: FleetSnapshot, contentWidth: numbe
return lines;
}
if (state.section === "scopes") {
if (!state.scopesMission && snap.execution) {
const selected = snap.execution;
const instances = selected.lifecycle_instances;
return wrapDetailLines([
{ text: "SCOPES · choose a mission in the explorer" },
listItem(`Default mission · ${selected.mission}`, { type: "scopes-mission-open", mission: selected.mission }),
{ text: ` Workflows: ${instances ? instances.map((instance) => String(instance.status ?? "unknown")).join(", ") || "none bound" : "projection unavailable"}` },
{ text: ` Execution snapshot: ${displayTime(selected.derived_at, state.timeZone)}. Other missions load when selected.` },
], contentWidth);
}
const catalog = snap.projects;
if (!state.project && catalog !== undefined) return wrapDetailLines([
{ text: "PROJECTS · select a project" },
{ text: catalog ? `Catalog: ${catalog.catalogPath}` : "Project catalog unavailable or loading" },
...(snap.readErrors ?? []).map(text => ({ text })),
...(catalog?.projects ?? []).flatMap(p => [listItem(`${p.name} · ${p.id}`, { type: "project-select", id: p.id }), { text: p.root }, ...(p.error ? [{ text: `Unavailable: ${p.error}` }] : [])]),
...(catalog?.projects.length === 0 ? [{ text: "No projects declared in this catalog." }] : []),
], contentWidth);
const identity = state.project ? wrapDetailLines([{ text: `PROJECT ${state.project.id}` }, { text: state.project.root }], contentWidth) : [];
if (state.project && (snap.projectRead?.id !== state.project.id || snap.projectRead?.root !== state.project.root)) return [...identity, { text: "Reading selected project…" }];
const entry = catalog?.projects.find(p => p.id === state.project!.id && p.root === state.project!.root);
const errors = (snap.readErrors ?? []).map(text => ({ text: `Unavailable: ${text}` }));
const projectHeader = state.project ? [...identity, listItem("Read current source", { type: "project-source" }), ...wrapDetailLines(errors, contentWidth)] : [];
if (state.project && (!entry || entry.error)) return [...projectHeader, { text: "Choose a project again or go Back." }];
if (state.project && !state.scopesMission) return [...projectHeader, { text: "Choose a mission" }, ...(snap.scopes ?? []).map(m => listItem(m.mission, { type: "scopes-mission-open", mission: m.mission })), ...(!snap.scopes?.length && !errors.length ? [{ text: "No missions found in this project." }] : [])];
// SCOPES owns both levels. Both mission-graph and Explorer slice routes land
// on the same execution-backed canonical detail; store-direct content is
// composed into that page instead of surviving as a competing destination.
@@ -1131,27 +1136,27 @@ function contentLines(state: ViewState, snap: FleetSnapshot, contentWidth: numbe
? (snap.scopes ?? []).find((m) => m.mission === sel.mission)?.slices.find((sl) => sl.dirName === sel.slice) ?? null
: null;
if (state.executionOpen && execution) {
return executionContentLines(execution, snap.scopes, snap.readErrors, state.executionOpen, contentWidth, false, snap.sliceDetail, {
return [...projectHeader, ...executionContentLines(execution, snap.scopes, snap.readErrors, state.executionOpen, contentWidth, false, snap.sliceDetail, {
collapseReqs: state.scopesCollapseReqs,
narrative: state.scopesNarrative,
}, state.timeZone);
}, state.timeZone)];
}
if (detail && execution) {
return executionContentLines(execution, snap.scopes, snap.readErrors, `slice:${detail.id ?? detail.dirName}`, contentWidth, false, snap.sliceDetail, {
return [...projectHeader, ...executionContentLines(execution, snap.scopes, snap.readErrors, `slice:${detail.id ?? detail.dirName}`, contentWidth, false, snap.sliceDetail, {
collapseReqs: state.scopesCollapseReqs,
narrative: state.scopesNarrative,
}, state.timeZone);
}, state.timeZone)];
}
if (!detail && missionName) {
const lines = executionContentLines(execution, snap.scopes, snap.readErrors, state.executionOpen, contentWidth, !snap.hydratedAt || snap.executionMission !== missionName, undefined, undefined, state.timeZone);
return execution ? lines : [{ text: ` ${missionName} EXECUTION` }, ...lines];
return [...projectHeader, ...(execution ? lines : [{ text: ` ${missionName} EXECUTION` }, ...lines])];
}
return scopesContentLines(detail, missionName, {
return [...projectHeader, ...scopesContentLines(detail, missionName, {
collapseReqs: state.scopesCollapseReqs,
narrative: state.scopesNarrative,
width: contentWidth,
executionStrip: detail ? executionSliceStripLines(null, detail.id ?? detail.dirName, detail.dirName, contentWidth, detail.status) : undefined,
});
})];
}
return [{ text: `(${state.section})` }];
}
@@ -1663,7 +1668,7 @@ export function renderScreen(state: ViewState, snap: FleetSnapshot, options: Ren
lines.push(pad(`${mark} ${label}${alias} ${tail}`, cols));
}
}
const sectionTitle = { topology: "TOPOLOGY", specs: "SPECS", needs: "NEEDS-YOU" }[state.section] ?? state.section.toUpperCase();
const sectionTitle = { topology: "TOPOLOGY", specs: "SPECS", scopes: "PROJECTS", needs: "NEEDS-YOU" }[state.section] ?? state.section.toUpperCase();
// active-pane emphasis (k9s-class chrome): the focused pane's title is bracketed
const explorerTitle = state.focusedPane === "explorer" ? "{ EXPLORER }" : "EXPLORER";
const contentTitle = state.focusedPane === "content" ? `{ ${sectionTitle} }` : sectionTitle;
+1
View File
@@ -20,6 +20,7 @@ export interface ReadinessSnap {
} | null }>;
}
export interface SliceScopeSnap {
sourcePath?: string;
readiness?: ReadinessSnap;
dirName: string;
id: string | null;
+2 -2
View File
@@ -14,8 +14,8 @@ export const SECTION_REGISTRY: readonly SectionDef[] = [
},
{
name: "scopes",
sourceRead: "GET /api/scopes?detail=1 + /api/views/execution?mission= (existing projections)",
drillShape: "mission>execution-row>slice/source",
sourceRead: "GET /api/scopes/projects + project-scoped scopes, execution and slice projections",
drillShape: "project>mission>execution-row>slice/source",
},
{ name: "config", sourceRead: "GET /healthz + /api/config?view=browser + /api/gateway/connections (passive)", drillShape: "category>setting" },
{ name: "connections", sourceRead: "GET /healthz + /api/gateway/connections (passive projection)", drillShape: "instance>human/routes>work" },
+30 -7
View File
@@ -1,3 +1,4 @@
import { fileTargetForPath } from "./reading.js";
import { CONFIG_CATEGORIES } from "./config/config-model.js";
import { availableTabs } from "./commands/registry.js";
import { DEFAULT_TIME_ZONE, resolveTimeZone } from "./time.js";
@@ -73,6 +74,7 @@ export function createViewState(options: CreateViewStateOptions): ViewStateStore
notice: null,
lastError: null,
palette: null,
project: null,
scopesMission: null,
scopesSelected: null,
scopesCollapseReqs: false,
@@ -84,7 +86,7 @@ export function createViewState(options: CreateViewStateOptions): ViewStateStore
function dispatch(action: Action): ViewState {
const previous = state;
if (["jump", "drill", "cross", "tab", "scopes-mission-open", "scopes-open", "health-open", "execution-open", "recent-open", "timezone", "config-category", "config-setting"].includes(action.type)) state = { ...state, file: null, externalUrl: null, recentOpen: null, timeZoneHelp: false };
if (["project-select", "jump", "drill", "cross", "tab", "scopes-mission-open", "scopes-open", "health-open", "execution-open", "recent-open", "timezone", "config-category", "config-setting"].includes(action.type)) state = { ...state, file: null, externalUrl: null, recentOpen: null, timeZoneHelp: false };
state = reduce(state, action, getSnapshot());
// Connections is a side trip from work, including explorer/palette entry.
if (action.type === "jump" && !["connections", "config"].includes(action.section) && !["connections", "config"].includes(previous.section)) state.history = [];
@@ -140,6 +142,7 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
return resetContent({ ...next, section: "config", drill: [], viewTab: "table", configKey: action.key, healthOpen: null });
case "jump": {
// scopes: jumping anywhere (incl. back to :scopes) closes the opened slice.
if (action.section === "scopes") next.project = null;
next.scopesMission = null;
next.scopesSelected = null;
next.executionOpen = null;
@@ -153,7 +156,22 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
snap,
);
}
case "project-select": {
const project = snap.projects?.projects.find(p => p.id === action.id);
if (!project) return { ...next, lastError: `Project ${action.id} is not in the current catalog` };
return syncSelection(resetContent({ ...next, section: "scopes", project: { id: project.id, root: project.root }, drill: [], scopesMission: null, scopesSelected: null, executionOpen: null, scopesNarrative: false, filter: "", expanded: [], viewTab: "table" }), snap);
}
case "project-source": {
if (!state.project || snap.projectRead?.id !== state.project.id || snap.projectRead?.root !== state.project.root) return { ...next, lastError: "Selected project read is pending" };
const entry = snap.projects?.projects.find(p => p.id === state.project!.id && p.root === state.project!.root);
const missionSource = state.scopesMission ? snap.projectSources?.[state.scopesMission] : null;
const sliceDir = state.scopesSelected?.slice ?? snap.sliceDetailName;
const source = sliceDir && state.scopesMission ? snap.scopes?.find(m => m.mission === state.scopesMission)?.slices.find(s => s.dirName === sliceDir)?.sourcePath : missionSource ?? entry?.sourcePath;
if (!source) return { ...next, lastError: "Selected source is unavailable" };
return reduce(next, { type: "file-open", target: fileTargetForPath(source, snap.fileRoots ?? []) ?? { root: "", path: source } }, snap);
}
case "scopes-mission-open": {
if (snap.projects !== undefined && !state.project) return { ...next, lastError: "Choose a project first" };
const key = `scopes-mission:${action.mission}`;
const expanded = state.expanded.includes(key) ? state.expanded : [...state.expanded, key];
return syncSelection(resetContent({ ...next, section: "scopes", drill: [], runningOf: null, viewTab: "table", filter: "", scopesMission: action.mission, scopesSelected: null, executionOpen: null, healthOpen: null, expanded }), snap);
@@ -276,12 +294,12 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
}
function location(s: ViewState): string {
return JSON.stringify([s.section, s.drill, s.runningOf, s.scopesMission, s.scopesSelected, s.executionOpen, s.recentOpen?.transitionId, s.timeZoneHelp, s.configCategory, s.configKey, s.file, s.externalUrl]);
return JSON.stringify([s.project, s.section, s.drill, s.runningOf, s.scopesMission, s.scopesSelected, s.executionOpen, s.recentOpen?.transitionId, s.timeZoneHelp, s.configCategory, s.configKey, s.file, s.externalUrl]);
}
function navigationFrame(s: ViewState): NavigationFrame {
const { file, externalUrl, section, drill, filter, selection, runningOf, viewTab, contentOffset, contentMaxOffset, contentTargetCount, contentSelection, focusedPane, scopesMission, scopesSelected, scopesCollapseReqs, scopesNarrative, executionOpen, expanded, recentOpen, timeZoneHelp, configCategory, configKey } = s;
return { file, externalUrl, section, drill, filter, selection, runningOf, viewTab, contentOffset, contentMaxOffset, contentTargetCount, contentSelection, focusedPane, scopesMission, scopesSelected, scopesCollapseReqs, scopesNarrative, executionOpen, expanded, recentOpen, timeZoneHelp, configCategory, configKey };
const { project, file, externalUrl, section, drill, filter, selection, runningOf, viewTab, contentOffset, contentMaxOffset, contentTargetCount, contentSelection, focusedPane, scopesMission, scopesSelected, scopesCollapseReqs, scopesNarrative, executionOpen, expanded, recentOpen, timeZoneHelp, configCategory, configKey } = s;
return { project, file, externalUrl, section, drill, filter, selection, runningOf, viewTab, contentOffset, contentMaxOffset, contentTargetCount, contentSelection, focusedPane, scopesMission, scopesSelected, scopesCollapseReqs, scopesNarrative, executionOpen, expanded, recentOpen, timeZoneHelp, configCategory, configKey };
}
function clearScopeCoordinatesOnSectionChange(previous: ViewState, next: ViewState): ViewState {
@@ -529,7 +547,7 @@ export function computeExplorerRows(state: ViewState, snap: FleetSnapshot): Expl
? "SPECS"
: section.name === "needs"
? "NEEDS-YOU"
: section.name.toUpperCase();
: section.name === "scopes" ? "PROJECTS" : section.name.toUpperCase();
// A section changes view but has no independent collapse state. Do not draw
// a disclosure glyph that cannot be toggled.
rows.push({ label, action: { type: "jump", section: section.name }, key: `section:${section.name}` });
@@ -540,8 +558,13 @@ export function computeExplorerRows(state: ViewState, snap: FleetSnapshot): Expl
continue;
}
if (section.name === "scopes") {
const expanded = new Set(state.expanded);
rows.push(...scopesExplorerRows(snap.scopes, expanded, " "));
if (snap.projects === undefined) rows.push(...scopesExplorerRows(snap.scopes, new Set(state.expanded), " "));
for (const project of snap.projects?.projects ?? []) {
rows.push({ label: ` ${state.project?.id === project.id ? "●" : "○"} ${project.id}${project.error ? " !" : ""}`, key: `project:${project.id}`, action: { type: "project-select", id: project.id } });
if (state.project?.id === project.id && state.project.root === project.root && snap.projectRead?.id === project.id && snap.projectRead.root === project.root)
rows.push(...scopesExplorerRows(snap.scopes, new Set(state.expanded), " "));
}
if (state.history?.length) rows.push({ label: " Back", key: "project:back", action: { type: "back" } });
continue;
}
if (section.name === "topology") {
+10 -1
View File
@@ -324,7 +324,13 @@ export interface HealthSnapshot {
records: HealthRecord[];
}
export interface ProjectSelection { id: string; root: string }
export interface ProjectEntry extends ProjectSelection { name: string; sourcePath: string | null; missionsRoot: string; error?: string }
export interface FleetSnapshot {
projects?: { catalogPath: string; projects: ProjectEntry[] } | null;
projectRead?: ProjectSelection | null;
projectSources?: Record<string, string>;
fileRead?: { target: import("./reading.js").FileTarget; result: import("./reading.js").FileReadResult; readAt: string };
fileRoots?: import("./reading.js").FileRoot[];
specsLoaded?: boolean;
@@ -438,6 +444,8 @@ export type Action =
| { type: "style"; name: string }
/** REGISTRY I3 — the command palette (open/query/move/close ride dispatch like all state). */
/** SCOPES view: m collapse + n narrative toggles (dispatch-riding). */
| { type: "project-select"; id: string }
| { type: "project-source" }
| { type: "scopes-mission-open"; mission: string }
| { type: "scopes-open"; mission: string; slice: string }
| { type: "scopes-reqs" }
@@ -479,6 +487,7 @@ export interface ViewState {
/** Canonical health finding opened from any instance/rig/seat surface. */
healthOpen: string | null;
/** SCOPES view: the mission whose execution story is open (null = selector only). */
project?: ProjectSelection | null;
scopesMission: string | null;
/** SCOPES view: the opened slice (null = tree only). */
scopesSelected: { mission: string; slice: string } | null;
@@ -515,7 +524,7 @@ export interface ViewState {
lastError: string | null;
}
export type NavigationFrame = Pick<ViewState, "file" | "externalUrl" | "section" | "drill" | "filter" | "selection" | "runningOf" | "viewTab" | "contentOffset" | "contentMaxOffset" | "contentTargetCount" | "contentSelection" | "focusedPane" | "scopesMission" | "scopesSelected" | "scopesCollapseReqs" | "scopesNarrative" | "executionOpen" | "expanded" | "timeZoneHelp" | "recentOpen" | "configCategory" | "configKey">;
export type NavigationFrame = Pick<ViewState, "project" | "file" | "externalUrl" | "section" | "drill" | "filter" | "selection" | "runningOf" | "viewTab" | "contentOffset" | "contentMaxOffset" | "contentTargetCount" | "contentSelection" | "focusedPane" | "scopesMission" | "scopesSelected" | "scopesCollapseReqs" | "scopesNarrative" | "executionOpen" | "expanded" | "timeZoneHelp" | "recentOpen" | "configCategory" | "configKey">;
export interface ViewStateStore {
instanceId: string;
@@ -0,0 +1,49 @@
import { expect, it } from "vitest";
import { DaemonClient } from "../src/daemon-client.js";
import { hydrateSnapshot } from "../src/hydrate.js";
import { createViewState, emptySnapshot } from "../src/state.js";
import { renderScreen } from "../src/render.js";
import { parseCommand } from "../src/grammar.js";
import { demoSnapshot } from "../src/demo-data.js";
import type { FleetSnapshot } from "../src/types.js";
it.each([[140, 42], [80, 24]])("retains exact project through equal-ID work, file, Back and in-flight switching at %ix%i", async (cols, rows) => {
const requests: string[] = [];
let unavailable = false;
const projects = ["a", "b"].map(id => ({ id, name: "Book", root: `/books/${id}`, sourcePath: `/books/${id}/SPEC.md`, missionsRoot: `/books/${id}/missions` }));
const client = new DaemonClient({ baseUrl: "http://fixture", fetchImpl: (async (url, init) => {
const u = new URL(String(url)); requests.push(`${init?.method ?? "GET"} ${u.pathname}${u.search}`);
const id = u.searchParams.get("project");
let body: unknown;
if (u.pathname === "/api/scopes/projects") body = { catalogPath: "/books/workspace.yaml", projects: projects.map(p => ({ ...p, ...(unavailable && p.id === "a" ? { error: "SPEC unavailable" } : {}) })) };
else if (u.pathname === "/api/files/roots") body = { roots: projects.map(p => ({ name: p.id, path: p.root })) };
else if (u.pathname === "/api/scopes") body = { missions: [{ mission: "release-x", slices: [{ ...demoSnapshot().scopes![0]!.slices[0]!, dirName: "01-story", id: "same-id", intent: `${id} unique intent`, sourcePath: `/books/${id}/missions/release-x/slices/01-story/SPEC.md` }] }], sources: { "release-x": `/books/${id}/missions/release-x/SPEC.md` } };
else if (u.pathname === "/api/views/execution") body = { rows: [{ view: "execution", mission: "release-x", sources: {}, q1_lanes: [], q2_sequencing: [], q4_ladder: [], q5_park: [] }] };
else if (u.pathname.startsWith("/api/slices")) body = null;
else if (u.pathname === "/api/files/read") body = { root: u.searchParams.get("root"), path: u.searchParams.get("path"), absolutePath: `/books/${u.searchParams.get("root")}/SPEC.md`, content: "Authored project source", mtime: "2026-09-10T00:00:00Z", contentHash: "abc", size: 10, truncated: false, truncatedAtBytes: null, totalBytes: 10 };
else throw new Error(`Unexpected read ${u.pathname}`);
return Response.json(body);
}) as typeof fetch });
let snap: FleetSnapshot = emptySnapshot();
const view = createViewState({ instanceId: "projects", getSnapshot: () => snap });
async function refresh() { snap = await hydrateSnapshot(client, undefined, view.get().scopesMission, view.get().scopesSelected?.slice, null, view.get()); }
const text = () => renderScreen(view.get(), snap, { cols, rows }).lines.join("\n");
view.dispatch(parseCommand("projects")); await refresh();
expect(text()).toContain("PROJECTS"); expect(text()).toContain("Book · a"); expect(text()).toContain("Book · b");
view.dispatch(parseCommand("project a")); await refresh();
view.dispatch(parseCommand("mission release-x")); await refresh();
view.dispatch({ type: "scopes-open", mission: "release-x", slice: "01-story" }); await refresh();
const caller = view.get();
view.dispatch(parseCommand("source")); expect(view.get().file?.path).toContain("01-story/SPEC.md"); await refresh();
expect(text()).toContain("Project a");
view.dispatch({ type: "back" }); await refresh(); expect(view.get().project).toEqual(caller.project); expect(view.get().scopesSelected).toEqual(caller.scopesSelected);
view.dispatch(parseCommand("project b"));
expect(text()).toContain("Reading selected project"); expect(text()).not.toContain("a unique intent");
await refresh(); expect(text()).toContain("PROJECT b"); expect(view.get().scopesMission).toBeNull();
view.dispatch({ type: "back" }); await refresh(); expect(view.get().project?.id).toBe("a"); expect(view.get().scopesSelected?.slice).toBe("01-story");
unavailable = true; await refresh(); expect(text()).toContain("SPEC unavailable"); expect(text()).not.toContain("a unique intent");
expect(requests.every(r => r.startsWith("GET "))).toBe(true);
for (const request of requests.filter(r => /GET \/api\/(scopes\?|views\/execution|slices\/)/.test(r))) {
expect(request).toMatch(/project=[ab]/); expect(request).toMatch(/projectRoot=%2Fbooks%2F[ab]/);
}
});