@basou/cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/program.js CHANGED
@@ -1512,7 +1512,8 @@ async function importDerivedSessions(paths, manifest, options, sourceKind, candi
1512
1512
  reimported: 0,
1513
1513
  skippedLegacy: 0,
1514
1514
  skippedDecreased: 0,
1515
- skippedDuplicate: 0
1515
+ skippedDuplicate: 0,
1516
+ skippedUnverifiable: 0
1516
1517
  };
1517
1518
  let sanitizedPaths = 0;
1518
1519
  const validate = (payload) => {
@@ -1554,7 +1555,7 @@ async function importDerivedSessions(paths, manifest, options, sourceKind, candi
1554
1555
  if (outcome.status === "skipped") {
1555
1556
  const detail = outcome.reason === "prior_events_unreadable" ? "prior events.jsonl has unreadable lines" : outcome.reason === "prior_chain_broken" ? "prior events.jsonl failed hash-chain verification (run 'basou verify')" : "source changed in a non-append way (derived events would be dropped)";
1556
1557
  console.error(`Import: ${externalId} ${detail}; re-import skipped`);
1557
- counts.skippedNoAction++;
1558
+ counts.skippedUnverifiable++;
1558
1559
  continue;
1559
1560
  }
1560
1561
  counts.reimported++;
@@ -1825,7 +1826,8 @@ function printImportResult(options, results, counts) {
1825
1826
  reimported,
1826
1827
  skippedLegacy,
1827
1828
  skippedDecreased,
1828
- skippedDuplicate
1829
+ skippedDuplicate,
1830
+ skippedUnverifiable
1829
1831
  } = counts;
1830
1832
  if (options.json === true) {
1831
1833
  console.log(
@@ -1844,6 +1846,7 @@ function printImportResult(options, results, counts) {
1844
1846
  skipped_legacy_untracked: skippedLegacy,
1845
1847
  skipped_decreased: skippedDecreased,
1846
1848
  skipped_duplicate: skippedDuplicate,
1849
+ skipped_unverifiable: skippedUnverifiable,
1847
1850
  event_total: eventTotal,
1848
1851
  dry_run: isDry
1849
1852
  })
@@ -1856,6 +1859,8 @@ function printImportResult(options, results, counts) {
1856
1859
  if (skippedLegacy > 0) skipParts.push(`${skippedLegacy} legacy (untracked size)`);
1857
1860
  if (skippedDecreased > 0) skipParts.push(`${skippedDecreased} shrank`);
1858
1861
  if (skippedDuplicate > 0) skipParts.push(`${skippedDuplicate} duplicated`);
1862
+ if (skippedUnverifiable > 0)
1863
+ skipParts.push(`${skippedUnverifiable} unverifiable (run 'basou verify')`);
1859
1864
  const skipSuffix = skipParts.length > 0 ? `; skipped ${skipParts.join(", ")}` : "";
1860
1865
  const eventsPart = replaced > 0 ? `${eventTotal} events, ${replaced} replaced` : `${eventTotal} events`;
1861
1866
  if (isDry) {
@@ -1935,6 +1940,9 @@ function registerInitCommand(program) {
1935
1940
  "Extra import source root, relative to the repo root (repeatable; aggregates sibling repos into this workspace)",
1936
1941
  collectValue,
1937
1942
  []
1943
+ ).option(
1944
+ "--local-only",
1945
+ "Write a .basou/ full-exclude .gitignore block (keep the trail out of version control) instead of the default ignore+commit block"
1938
1946
  ).option("-f, --force", "Overwrite an existing manifest").option("-v, --verbose", "Show error causes").action(async (options) => {
1939
1947
  await runInit(options);
1940
1948
  });
@@ -1971,7 +1979,7 @@ async function doRunInit(options, ctx) {
1971
1979
  });
1972
1980
  await writeManifest(paths, manifest, { force: options.force === true });
1973
1981
  try {
1974
- await appendBasouGitignore(repositoryRoot);
1982
+ await appendBasouGitignore(repositoryRoot, { localOnly: options.localOnly === true });
1975
1983
  } catch (error) {
1976
1984
  renderGitignoreWarning(error, isVerbose(options));
1977
1985
  }
@@ -2000,15 +2008,21 @@ async function resolveRepositoryRootForInit(cwd) {
2000
2008
  }
2001
2009
  }
2002
2010
 
2003
- // src/commands/refresh.ts
2004
- import { assertBasouRootSafe as assertBasouRootSafe7, basouPaths as basouPaths7, findErrorCode as findErrorCode7, resolveRepositoryRoot as resolveRepositoryRoot8 } from "@basou/core";
2005
- import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
2011
+ // src/commands/orient.ts
2012
+ import {
2013
+ assertBasouRootSafe as assertBasouRootSafe7,
2014
+ basouPaths as basouPaths7,
2015
+ findErrorCode as findErrorCode6,
2016
+ renderOrientation as renderOrientation2,
2017
+ writeMarkdownFile as writeMarkdownFile4
2018
+ } from "@basou/core";
2006
2019
 
2007
2020
  // src/lib/provenance-actions.ts
2008
2021
  import {
2009
2022
  readMarkdownFile as readMarkdownFile3,
2010
2023
  renderDecisions as renderDecisions2,
2011
2024
  renderHandoff as renderHandoff2,
2025
+ renderOrientation,
2012
2026
  renderWithMarkers as renderWithMarkers3,
2013
2027
  writeMarkdownFile as writeMarkdownFile3
2014
2028
  } from "@basou/core";
@@ -2059,6 +2073,7 @@ async function runImport(adapter, fn) {
2059
2073
  skippedNoAction: readCount(json.skipped_no_action),
2060
2074
  skippedAlreadyImported: readCount(json.skipped_already_imported),
2061
2075
  skippedLegacyUntracked: readCount(json.skipped_legacy_untracked),
2076
+ skippedUnverifiable: readCount(json.skipped_unverifiable),
2062
2077
  eventTotal: readCount(json.event_total),
2063
2078
  dryRun: json.dry_run === true
2064
2079
  };
@@ -2107,6 +2122,17 @@ async function regenerateDecisions(paths, nowIso, callbacks) {
2107
2122
  );
2108
2123
  return { decisionCount: result.decisionCount };
2109
2124
  }
2125
+ async function regenerateOrientation(paths, nowIso, callbacks) {
2126
+ const result = await renderOrientation({ paths, nowIso, ...callbacks });
2127
+ await writeMarkdownFile3(paths.files.orientation, `${result.body}
2128
+ `);
2129
+ return {
2130
+ sessionCount: result.sessionCount,
2131
+ inFlightTaskCount: result.inFlightTaskCount,
2132
+ pendingApprovalsCount: result.pendingApprovalsCount,
2133
+ suspectCount: result.suspectCount
2134
+ };
2135
+ }
2110
2136
  async function refreshAll(args) {
2111
2137
  const { options, ctx, paths, nowIso } = args;
2112
2138
  const dryRun = options.dryRun === true;
@@ -2114,31 +2140,210 @@ async function refreshAll(args) {
2114
2140
  const codex = await importCodex(options, ctx);
2115
2141
  if (dryRun) {
2116
2142
  const skipped = { status: "skipped", reason: "dry-run" };
2117
- return { claudeCode, codex, handoff: skipped, decisions: skipped, dryRun };
2143
+ return {
2144
+ claudeCode,
2145
+ codex,
2146
+ handoff: skipped,
2147
+ decisions: skipped,
2148
+ orientation: skipped,
2149
+ dryRun
2150
+ };
2118
2151
  }
2119
2152
  const handoffCounts = await regenerateHandoff(paths, nowIso);
2120
2153
  const decisionCounts = await regenerateDecisions(paths, nowIso);
2154
+ const scoped = options.project !== void 0 && options.project.length > 0;
2155
+ const orientationCounts = await regenerateOrientation(
2156
+ paths,
2157
+ nowIso,
2158
+ scoped ? {} : {
2159
+ staleness: {
2160
+ newSessions: 0,
2161
+ updatedSessions: 0,
2162
+ unverifiableSessions: wouldBlock(claudeCode) + wouldBlock(codex)
2163
+ }
2164
+ }
2165
+ );
2121
2166
  return {
2122
2167
  claudeCode,
2123
2168
  codex,
2124
2169
  handoff: { status: "generated", ...handoffCounts },
2125
2170
  decisions: { status: "generated", ...decisionCounts },
2171
+ orientation: { status: "generated", ...orientationCounts },
2126
2172
  dryRun
2127
2173
  };
2128
2174
  }
2175
+ function wouldImport(outcome) {
2176
+ return outcome.status === "ran" ? outcome.importedCount : 0;
2177
+ }
2178
+ function wouldUpdate(outcome) {
2179
+ return outcome.status === "ran" ? outcome.reimportedCount + outcome.replacedCount : 0;
2180
+ }
2181
+ function wouldBlock(outcome) {
2182
+ return outcome.status === "ran" ? outcome.skippedUnverifiable : 0;
2183
+ }
2184
+ async function probeStaleness(args) {
2185
+ try {
2186
+ const dry = await refreshAll({
2187
+ options: { dryRun: true },
2188
+ ctx: args.ctx,
2189
+ paths: args.paths,
2190
+ nowIso: args.nowIso
2191
+ });
2192
+ return {
2193
+ newSessions: wouldImport(dry.claudeCode) + wouldImport(dry.codex),
2194
+ updatedSessions: wouldUpdate(dry.claudeCode) + wouldUpdate(dry.codex),
2195
+ unverifiableSessions: wouldBlock(dry.claudeCode) + wouldBlock(dry.codex)
2196
+ };
2197
+ } catch {
2198
+ return null;
2199
+ }
2200
+ }
2201
+
2202
+ // src/lib/repo-root.ts
2203
+ import { resolveBasouRepositoryRoot } from "@basou/core";
2204
+ async function resolveBasouRootForCommand(cwd, commandName) {
2205
+ try {
2206
+ return await resolveBasouRepositoryRoot(cwd, {
2207
+ onRedirect: ({ via, root }) => console.error(`Resolved workspace view to ${root} (via ${via}).`)
2208
+ });
2209
+ } catch (error) {
2210
+ if (error instanceof Error && error.message === "Not a git repository") {
2211
+ throw new Error(
2212
+ `Not a git repository. Run 'git init' first, then re-run 'basou ${commandName}'.`,
2213
+ { cause: error }
2214
+ );
2215
+ }
2216
+ throw error;
2217
+ }
2218
+ }
2219
+
2220
+ // src/commands/orient.ts
2221
+ function registerOrientCommand(program) {
2222
+ program.command("orient").description("Show the workspace's current position (also writes .basou/orientation.md)").option("-q, --quiet", "Write the file without printing the body").option("-v, --verbose", "Show error causes").action(async (opts) => {
2223
+ await runOrient(opts);
2224
+ });
2225
+ }
2226
+ async function runOrient(options, ctx = {}) {
2227
+ try {
2228
+ await doRunOrient(options, ctx);
2229
+ } catch (error) {
2230
+ renderCliError(error, { verbose: isVerbose(options) });
2231
+ process.exitCode = 1;
2232
+ }
2233
+ }
2234
+ async function doRunOrient(options, ctx) {
2235
+ const cwd = ctx.cwd ?? process.cwd();
2236
+ const repositoryRoot = await resolveBasouRootForCommand(cwd, "orient");
2237
+ const paths = basouPaths7(repositoryRoot);
2238
+ await assertWorkspaceInitialized6(paths.root);
2239
+ const nowIso = (ctx.nowProvider?.() ?? /* @__PURE__ */ new Date()).toISOString();
2240
+ const probeCtx = { cwd: repositoryRoot };
2241
+ if (ctx.claudeProjectsDir !== void 0) probeCtx.claudeProjectsDir = ctx.claudeProjectsDir;
2242
+ if (ctx.codexSessionsDir !== void 0) probeCtx.codexSessionsDir = ctx.codexSessionsDir;
2243
+ const staleness = await probeStaleness({ ctx: probeCtx, paths, nowIso });
2244
+ const result = await renderOrientation2({
2245
+ paths,
2246
+ nowIso,
2247
+ staleness,
2248
+ verbose: options.verbose === true,
2249
+ onWarning: (w, sid) => printReplayWarning(w, sid),
2250
+ onSessionSkip: (sid, reason) => printSessionSkip(sid, reason),
2251
+ onTaskSkip: (taskId, reason) => printTaskSkip(taskId, reason)
2252
+ });
2253
+ await writeMarkdownFile4(paths.files.orientation, `${result.body}
2254
+ `);
2255
+ if (options.quiet === true) {
2256
+ console.log(
2257
+ `Generated .basou/orientation.md (sessions: ${result.sessionCount}, in-flight tasks: ${result.inFlightTaskCount}, pending approvals: ${result.pendingApprovalsCount}, suspect: ${result.suspectCount})`
2258
+ );
2259
+ } else {
2260
+ console.log(result.body);
2261
+ }
2262
+ }
2263
+ async function assertWorkspaceInitialized6(basouRoot) {
2264
+ try {
2265
+ await assertBasouRootSafe7(basouRoot);
2266
+ } catch (error) {
2267
+ if (findErrorCode6(error, "ENOENT")) {
2268
+ throw new Error("Workspace not initialized. Run 'basou init' first.");
2269
+ }
2270
+ throw error;
2271
+ }
2272
+ }
2273
+
2274
+ // src/commands/refresh.ts
2275
+ import { assertBasouRootSafe as assertBasouRootSafe8, basouPaths as basouPaths8, findErrorCode as findErrorCode8 } from "@basou/core";
2276
+ import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
2277
+
2278
+ // src/lib/portfolio-config.ts
2279
+ import { homedir as homedir3 } from "os";
2280
+ import { isAbsolute, join as join4, resolve as resolve3 } from "path";
2281
+ import { readYamlFile as readYamlFile3 } from "@basou/core";
2282
+ var DEFAULT_PORTFOLIO_CONFIG_PATH = join4(homedir3(), ".basou", "portfolio.yaml");
2283
+ function expandTilde(p) {
2284
+ if (p === "~") return homedir3();
2285
+ if (p.startsWith("~/")) return join4(homedir3(), p.slice(2));
2286
+ return p;
2287
+ }
2288
+ function isRecord(value) {
2289
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2290
+ }
2291
+ async function loadPortfolioConfig(configPath = DEFAULT_PORTFOLIO_CONFIG_PATH) {
2292
+ let raw;
2293
+ try {
2294
+ raw = await readYamlFile3(configPath);
2295
+ } catch (error) {
2296
+ if (error instanceof Error && error.message === "YAML file not found") {
2297
+ throw new Error(
2298
+ "No portfolio config at ~/.basou/portfolio.yaml. Create one (a 'workspaces:' list of repo paths) or pass --workspace <path>."
2299
+ );
2300
+ }
2301
+ if (error instanceof Error && error.message === "Failed to parse YAML content") {
2302
+ throw new Error("~/.basou/portfolio.yaml is not valid YAML.");
2303
+ }
2304
+ throw error;
2305
+ }
2306
+ if (!isRecord(raw) || !Array.isArray(raw.workspaces)) {
2307
+ throw new Error("~/.basou/portfolio.yaml must contain a 'workspaces:' list.");
2308
+ }
2309
+ const seen = /* @__PURE__ */ new Set();
2310
+ const result = [];
2311
+ for (const entry of raw.workspaces) {
2312
+ if (!isRecord(entry) || typeof entry.path !== "string" || entry.path.trim().length === 0) {
2313
+ throw new Error("Each portfolio workspace needs a non-empty string 'path'.");
2314
+ }
2315
+ if (entry.label !== void 0 && typeof entry.label !== "string") {
2316
+ throw new Error("A portfolio workspace 'label' must be a string when present.");
2317
+ }
2318
+ const expanded = expandTilde(entry.path.trim());
2319
+ if (!isAbsolute(expanded)) {
2320
+ throw new Error(
2321
+ "Portfolio workspace paths must be absolute (or start with '~'); use --workspace for relative ad-hoc paths."
2322
+ );
2323
+ }
2324
+ const abs = resolve3(expanded);
2325
+ if (seen.has(abs)) continue;
2326
+ seen.add(abs);
2327
+ result.push(entry.label !== void 0 ? { path: abs, label: entry.label } : { path: abs });
2328
+ }
2329
+ if (result.length === 0) {
2330
+ throw new Error("~/.basou/portfolio.yaml has no workspaces.");
2331
+ }
2332
+ return result;
2333
+ }
2129
2334
 
2130
2335
  // src/commands/refresh-watch.ts
2131
2336
  import { readdir as readdir2, stat as stat2 } from "fs/promises";
2132
- import { homedir as homedir3 } from "os";
2133
- import { join as join4 } from "path";
2134
- import { findErrorCode as findErrorCode6 } from "@basou/core";
2337
+ import { homedir as homedir4 } from "os";
2338
+ import { join as join5 } from "path";
2339
+ import { findErrorCode as findErrorCode7 } from "@basou/core";
2135
2340
  var DEFAULT_WATCH_INTERVAL_SEC = 30;
2136
2341
  var MIN_WATCH_INTERVAL_SEC = 5;
2137
2342
  var MAX_WATCH_INTERVAL_SEC = 86400;
2138
2343
  function watchedRoots(ctx) {
2139
2344
  return [
2140
- ctx.codexSessionsDir ?? join4(homedir3(), ".codex", "sessions"),
2141
- ctx.claudeProjectsDir ?? join4(homedir3(), ".claude", "projects")
2345
+ ctx.codexSessionsDir ?? join5(homedir4(), ".codex", "sessions"),
2346
+ ctx.claudeProjectsDir ?? join5(homedir4(), ".claude", "projects")
2142
2347
  ];
2143
2348
  }
2144
2349
  async function scanSourceLogs(roots) {
@@ -2148,11 +2353,11 @@ async function scanSourceLogs(roots) {
2148
2353
  try {
2149
2354
  entries = await readdir2(dir, { withFileTypes: true });
2150
2355
  } catch (error) {
2151
- if (findErrorCode6(error, "ENOENT") || findErrorCode6(error, "ENOTDIR")) return;
2356
+ if (findErrorCode7(error, "ENOENT") || findErrorCode7(error, "ENOTDIR")) return;
2152
2357
  throw new Error("Failed to read a source log directory", { cause: error });
2153
2358
  }
2154
2359
  for (const entry of entries) {
2155
- const full = join4(dir, entry.name);
2360
+ const full = join5(dir, entry.name);
2156
2361
  if (entry.isDirectory()) {
2157
2362
  await walk(full);
2158
2363
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
@@ -2160,7 +2365,7 @@ async function scanSourceLogs(roots) {
2160
2365
  const info = await stat2(full);
2161
2366
  out.set(full, { mtimeMs: info.mtimeMs, size: info.size });
2162
2367
  } catch (error) {
2163
- if (findErrorCode6(error, "ENOENT")) continue;
2368
+ if (findErrorCode7(error, "ENOENT")) continue;
2164
2369
  throw new Error("Failed to stat a source log file", { cause: error });
2165
2370
  }
2166
2371
  }
@@ -2259,19 +2464,19 @@ function parseInterval(value) {
2259
2464
  return seconds;
2260
2465
  }
2261
2466
  function abortableSleep(ms, signal) {
2262
- return new Promise((resolve3) => {
2467
+ return new Promise((resolve7) => {
2263
2468
  if (signal.aborted) {
2264
- resolve3();
2469
+ resolve7();
2265
2470
  return;
2266
2471
  }
2267
2472
  let timer;
2268
2473
  const onAbort = () => {
2269
2474
  clearTimeout(timer);
2270
- resolve3();
2475
+ resolve7();
2271
2476
  };
2272
2477
  timer = setTimeout(() => {
2273
2478
  signal.removeEventListener("abort", onAbort);
2274
- resolve3();
2479
+ resolve7();
2275
2480
  }, ms);
2276
2481
  signal.addEventListener("abort", onAbort, { once: true });
2277
2482
  });
@@ -2285,6 +2490,9 @@ function registerRefreshCommand(program) {
2285
2490
  collectPath2,
2286
2491
  []
2287
2492
  ).option("--force", "Re-import sessions already imported instead of skipping").option("--dry-run", "Preview imports and skip writing handoff / decisions").option("--json", "Output the result as JSON").option(
2493
+ "--portfolio",
2494
+ "Refresh every workspace listed in ~/.basou/portfolio.yaml (each with its own source roots)"
2495
+ ).option(
2288
2496
  "--watch",
2289
2497
  "Keep running: re-import + regenerate when the native logs change (Ctrl-C to stop)"
2290
2498
  ).option(
@@ -2297,7 +2505,9 @@ function registerRefreshCommand(program) {
2297
2505
  }
2298
2506
  async function runRefresh(options, ctx = {}) {
2299
2507
  try {
2300
- if (options.watch === true) {
2508
+ if (options.portfolio === true) {
2509
+ await doRunRefreshPortfolio(options, ctx);
2510
+ } else if (options.watch === true) {
2301
2511
  await doRunRefreshWatch(options, ctx);
2302
2512
  } else {
2303
2513
  await doRunRefresh(options, ctx);
@@ -2307,14 +2517,58 @@ async function runRefresh(options, ctx = {}) {
2307
2517
  process.exitCode = 1;
2308
2518
  }
2309
2519
  }
2520
+ async function doRunRefreshPortfolio(options, ctx) {
2521
+ if (options.watch === true) throw new Error("--portfolio cannot be combined with --watch.");
2522
+ if (options.project !== void 0 && options.project.length > 0) {
2523
+ throw new Error(
2524
+ "--portfolio refreshes each workspace with its own source roots; remove --project."
2525
+ );
2526
+ }
2527
+ const workspaces = await loadPortfolioConfig(ctx.portfolioConfigPath);
2528
+ const rollup = [];
2529
+ for (const ws of workspaces) {
2530
+ const label = ws.label ?? ws.path;
2531
+ try {
2532
+ const result = await computeRefresh(
2533
+ { ...options, portfolio: false },
2534
+ { ...ctx, cwd: ws.path }
2535
+ );
2536
+ rollup.push({ label, path: ws.path, status: "ok", result });
2537
+ if (options.json !== true) {
2538
+ console.log(`
2539
+ ## ${label} (${ws.path})`);
2540
+ printRefreshSummary(result);
2541
+ }
2542
+ } catch (error) {
2543
+ const message = error instanceof Error ? error.message : String(error);
2544
+ rollup.push({ label, path: ws.path, status: "failed", error: message });
2545
+ if (options.json !== true) {
2546
+ console.log(`
2547
+ ## ${label} (${ws.path})`);
2548
+ console.log(` failed: ${message}`);
2549
+ }
2550
+ }
2551
+ }
2552
+ if (options.json === true) {
2553
+ console.log(JSON.stringify({ portfolio: true, workspaces: rollup }));
2554
+ } else {
2555
+ const failed = rollup.filter((r) => r.status === "failed").length;
2556
+ const ok = rollup.length - failed;
2557
+ console.log(
2558
+ `
2559
+ portfolio: ${ok}/${rollup.length} refreshed${failed > 0 ? `, ${failed} failed` : ""}.`
2560
+ );
2561
+ }
2562
+ if (rollup.some((r) => r.status === "failed")) process.exitCode = 1;
2563
+ }
2310
2564
  async function doRunRefreshWatch(options, ctx) {
2311
2565
  if (options.dryRun === true) throw new Error("--watch cannot be combined with --dry-run.");
2312
2566
  if (options.json === true) throw new Error("--watch cannot be combined with --json.");
2313
2567
  if (options.force === true) throw new Error("--watch cannot be combined with --force.");
2314
2568
  const cwd = ctx.cwd ?? process.cwd();
2315
- const repositoryRoot = await resolveRepositoryRootForRefresh(cwd);
2316
- const paths = basouPaths7(repositoryRoot);
2317
- await assertWorkspaceInitialized6(paths.root);
2569
+ const repositoryRoot = await resolveBasouRootForCommand(cwd, "refresh");
2570
+ const paths = basouPaths8(repositoryRoot);
2571
+ await assertWorkspaceInitialized7(paths.root);
2318
2572
  const intervalMs = (options.interval ?? DEFAULT_WATCH_INTERVAL_SEC) * 1e3;
2319
2573
  const controller = new AbortController();
2320
2574
  const onSignal = () => controller.abort();
@@ -2322,7 +2576,9 @@ async function doRunRefreshWatch(options, ctx) {
2322
2576
  process.on("SIGTERM", onSignal);
2323
2577
  try {
2324
2578
  await runRefreshWatch({
2325
- ctx,
2579
+ // Watch from a workspace view: import from the resolved planning repo, not
2580
+ // the raw (non-git) view cwd — mirrors the redirect in computeRefresh.
2581
+ ctx: { ...ctx, cwd: repositoryRoot },
2326
2582
  paths,
2327
2583
  intervalMs,
2328
2584
  importOptions: options.project !== void 0 && options.project.length > 0 ? { project: options.project } : {},
@@ -2336,22 +2592,27 @@ async function doRunRefreshWatch(options, ctx) {
2336
2592
  process.off("SIGTERM", onSignal);
2337
2593
  }
2338
2594
  }
2339
- async function doRunRefresh(options, ctx) {
2595
+ async function computeRefresh(options, ctx) {
2340
2596
  const cwd = ctx.cwd ?? process.cwd();
2341
- const repositoryRoot = await resolveRepositoryRootForRefresh(cwd);
2342
- const paths = basouPaths7(repositoryRoot);
2343
- await assertWorkspaceInitialized6(paths.root);
2597
+ const repositoryRoot = await resolveBasouRootForCommand(cwd, "refresh");
2598
+ const paths = basouPaths8(repositoryRoot);
2599
+ await assertWorkspaceInitialized7(paths.root);
2344
2600
  const nowIso = (ctx.nowProvider?.() ?? /* @__PURE__ */ new Date()).toISOString();
2345
- const result = await refreshAll({
2601
+ return refreshAll({
2346
2602
  options: {
2347
2603
  ...options.project !== void 0 && options.project.length > 0 ? { project: options.project } : {},
2348
2604
  ...options.force === true ? { force: true } : {},
2349
2605
  ...options.dryRun === true ? { dryRun: true } : {}
2350
2606
  },
2351
- ctx,
2607
+ // Import from the resolved repo root, not the raw cwd: a workspace-view cwd
2608
+ // redirects to its planning repo, and the import must run there too.
2609
+ ctx: { ...ctx, cwd: repositoryRoot },
2352
2610
  paths,
2353
2611
  nowIso
2354
2612
  });
2613
+ }
2614
+ async function doRunRefresh(options, ctx) {
2615
+ const result = await computeRefresh(options, ctx);
2355
2616
  if (options.json === true) {
2356
2617
  console.log(JSON.stringify(result));
2357
2618
  } else {
@@ -2387,24 +2648,97 @@ function printRefreshSummary(result) {
2387
2648
  } else {
2388
2649
  console.log(`decisions: skipped (${result.decisions.reason})`);
2389
2650
  }
2651
+ if (result.orientation.status === "generated") {
2652
+ console.log(
2653
+ `orientation: regenerated (in-flight: ${result.orientation.inFlightTaskCount}, pending approvals: ${result.orientation.pendingApprovalsCount}, suspect: ${result.orientation.suspectCount})`
2654
+ );
2655
+ } else {
2656
+ console.log(`orientation: skipped (${result.orientation.reason})`);
2657
+ }
2658
+ }
2659
+ async function assertWorkspaceInitialized7(basouRoot) {
2660
+ try {
2661
+ await assertBasouRootSafe8(basouRoot);
2662
+ } catch (error) {
2663
+ if (findErrorCode8(error, "ENOENT")) {
2664
+ throw new Error("Workspace not initialized. Run 'basou init' first.");
2665
+ }
2666
+ throw error;
2667
+ }
2668
+ }
2669
+
2670
+ // src/commands/report.ts
2671
+ import { isAbsolute as isAbsolute2, resolve as resolve4 } from "path";
2672
+ import {
2673
+ assertBasouRootSafe as assertBasouRootSafe9,
2674
+ basouPaths as basouPaths9,
2675
+ findErrorCode as findErrorCode9,
2676
+ renderReport,
2677
+ resolveRepositoryRoot as resolveRepositoryRoot8,
2678
+ writeMarkdownFile as writeMarkdownFile5
2679
+ } from "@basou/core";
2680
+ function registerReportCommand(program) {
2681
+ const report = program.command("report").description(
2682
+ "Generate a work report \u2014 a shareable export explaining the work in this workspace"
2683
+ );
2684
+ report.command("generate").description("Generate a work report from the current workspace state").option("--out <path>", "Write the markdown report to a file instead of stdout").option("--json", "Emit the structured report data as JSON to stdout").option("--title <text>", "Subject line shown in the report header").option("-v, --verbose", "Show error causes").action(async (opts) => {
2685
+ await runReportGenerate(opts);
2686
+ });
2687
+ }
2688
+ async function runReportGenerate(options, ctx = {}) {
2689
+ try {
2690
+ await doRunReportGenerate(options, ctx);
2691
+ } catch (error) {
2692
+ renderCliError(error, { verbose: isVerbose(options) });
2693
+ process.exitCode = 1;
2694
+ }
2695
+ }
2696
+ async function doRunReportGenerate(options, ctx) {
2697
+ const cwd = ctx.cwd ?? process.cwd();
2698
+ const repositoryRoot = await resolveRepositoryRootForReport(cwd);
2699
+ const paths = basouPaths9(repositoryRoot);
2700
+ await assertWorkspaceInitialized8(paths.root);
2701
+ const nowIso = (ctx.nowProvider?.() ?? /* @__PURE__ */ new Date()).toISOString();
2702
+ const result = await renderReport({
2703
+ paths,
2704
+ nowIso,
2705
+ ...options.title !== void 0 ? { title: options.title } : {},
2706
+ onWarning: (w, sid) => printReplayWarning(w, sid),
2707
+ onSessionSkip: (sid, reason) => printSessionSkip(sid, reason),
2708
+ onTaskSkip: (taskId, reason) => printTaskSkip(taskId, reason)
2709
+ });
2710
+ if (options.out !== void 0) {
2711
+ const outPath = isAbsolute2(options.out) ? options.out : resolve4(cwd, options.out);
2712
+ await writeMarkdownFile5(outPath, result.body);
2713
+ const { sessions, decisions, tasks } = result.data;
2714
+ console.error(
2715
+ `Wrote report to ${options.out} (sessions: ${sessions.total}, decisions: ${decisions.count}, tasks: ${tasks.total})`
2716
+ );
2717
+ }
2718
+ if (options.json === true) {
2719
+ console.log(JSON.stringify(result.data, null, 2));
2720
+ } else if (options.out === void 0) {
2721
+ console.log(result.body);
2722
+ }
2390
2723
  }
2391
- async function resolveRepositoryRootForRefresh(cwd) {
2724
+ async function resolveRepositoryRootForReport(cwd) {
2392
2725
  try {
2393
2726
  return await resolveRepositoryRoot8(cwd);
2394
2727
  } catch (error) {
2395
2728
  if (error instanceof Error && error.message === "Not a git repository") {
2396
- throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou refresh'.", {
2397
- cause: error
2398
- });
2729
+ throw new Error(
2730
+ "Not a git repository. Run 'git init' first, then re-run 'basou report generate'.",
2731
+ { cause: error }
2732
+ );
2399
2733
  }
2400
2734
  throw error;
2401
2735
  }
2402
2736
  }
2403
- async function assertWorkspaceInitialized6(basouRoot) {
2737
+ async function assertWorkspaceInitialized8(basouRoot) {
2404
2738
  try {
2405
- await assertBasouRootSafe7(basouRoot);
2739
+ await assertBasouRootSafe9(basouRoot);
2406
2740
  } catch (error) {
2407
- if (findErrorCode7(error, "ENOENT")) {
2741
+ if (findErrorCode9(error, "ENOENT")) {
2408
2742
  throw new Error("Workspace not initialized. Run 'basou init' first.");
2409
2743
  }
2410
2744
  throw error;
@@ -2413,12 +2747,12 @@ async function assertWorkspaceInitialized6(basouRoot) {
2413
2747
 
2414
2748
  // src/commands/run.ts
2415
2749
  import { mkdir as mkdir2 } from "fs/promises";
2416
- import { homedir as homedir4 } from "os";
2417
- import { join as join5 } from "path";
2750
+ import { homedir as homedir5 } from "os";
2751
+ import { join as join6 } from "path";
2418
2752
  import {
2419
2753
  acquireLock as acquireLock4,
2420
- assertBasouRootSafe as assertBasouRootSafe8,
2421
- basouPaths as basouPaths8,
2754
+ assertBasouRootSafe as assertBasouRootSafe10,
2755
+ basouPaths as basouPaths10,
2422
2756
  ChildProcessRunner as ChildProcessRunner2,
2423
2757
  claudeCodeAdapterMetadata,
2424
2758
  appendChainedEvent as coreAppendChainedEvent2,
@@ -2428,7 +2762,7 @@ import {
2428
2762
  overwriteYamlFile as overwriteYamlFile2,
2429
2763
  prefixedUlid as prefixedUlid4,
2430
2764
  readManifest as readManifest4,
2431
- readYamlFile as readYamlFile3,
2765
+ readYamlFile as readYamlFile4,
2432
2766
  resolveClaudeCodeCommand,
2433
2767
  resolveRepositoryRoot as resolveRepositoryRoot9,
2434
2768
  SessionSchema as SessionSchema2,
@@ -2463,17 +2797,17 @@ async function runClaudeCode(args, options, ctx = {}) {
2463
2797
  const { command } = await resolveCommand();
2464
2798
  const cwd = options.cwd ?? process.cwd();
2465
2799
  const repoRoot = await resolveRepositoryRootForRun(cwd);
2466
- const paths = basouPaths8(repoRoot);
2467
- await assertBasouRootSafe8(paths.root);
2800
+ const paths = basouPaths10(repoRoot);
2801
+ await assertBasouRootSafe10(paths.root);
2468
2802
  const manifest = await readManifest4(paths);
2469
2803
  const sessionId = prefixedUlid4("ses");
2470
- const sessionDir = join5(paths.sessions, sessionId);
2804
+ const sessionDir = join6(paths.sessions, sessionId);
2471
2805
  await mkdir2(sessionDir, { recursive: true });
2472
2806
  const appendEvent = ctx.appendEvent ?? (async (_sessionDir, event) => {
2473
2807
  await coreAppendChainedEvent2(paths, sessionId, event);
2474
2808
  });
2475
2809
  const startedAt = now().toISOString();
2476
- const sessionYamlPath = join5(sessionDir, "session.yaml");
2810
+ const sessionYamlPath = join6(sessionDir, "session.yaml");
2477
2811
  const session = buildInitialSession2({
2478
2812
  id: sessionId,
2479
2813
  command,
@@ -2599,7 +2933,7 @@ async function runClaudeCode(args, options, ctx = {}) {
2599
2933
  const rawRelated = computeRelatedFiles(preSnapshot, postSnapshot, diff);
2600
2934
  const relatedFiles = sanitizeRelatedFiles(rawRelated, {
2601
2935
  workingDirectory: repoRoot,
2602
- homedir: homedir4()
2936
+ homedir: homedir5()
2603
2937
  }).sanitized;
2604
2938
  const finalStatus = decideFinalStatus2(result, signalReceived);
2605
2939
  await appendEvent(sessionDir, {
@@ -2743,7 +3077,7 @@ function buildInitialSession2(input) {
2743
3077
  source: { ...claudeCodeAdapterMetadata },
2744
3078
  started_at: input.startedAt,
2745
3079
  status: "initialized",
2746
- working_directory: sanitizeWorkingDirectory2(input.cwd, { homedir: homedir4() }),
3080
+ working_directory: sanitizeWorkingDirectory2(input.cwd, { homedir: homedir5() }),
2747
3081
  invocation: {
2748
3082
  command: input.command,
2749
3083
  args: [...input.args],
@@ -2755,7 +3089,7 @@ function buildInitialSession2(input) {
2755
3089
  };
2756
3090
  }
2757
3091
  async function mutateSessionYaml2(filePath, mutator) {
2758
- const raw = await readYamlFile3(filePath);
3092
+ const raw = await readYamlFile4(filePath);
2759
3093
  const parsed = SessionSchema2.parse(raw);
2760
3094
  mutator(parsed);
2761
3095
  const validated = SessionSchema2.parse(parsed);
@@ -2816,19 +3150,19 @@ async function resolveRepositoryRootForRun(cwd) {
2816
3150
 
2817
3151
  // src/commands/session.ts
2818
3152
  import { readFile as readFile2 } from "fs/promises";
2819
- import { basename as basename3, isAbsolute, join as join6, relative as relative2 } from "path";
3153
+ import { basename as basename3, isAbsolute as isAbsolute3, join as join7, relative as relative2 } from "path";
2820
3154
  import {
2821
3155
  acquireLock as acquireLock5,
2822
3156
  appendEventToExistingSession as appendEventToExistingSession2,
2823
- assertBasouRootSafe as assertBasouRootSafe9,
2824
- basouPaths as basouPaths9,
3157
+ assertBasouRootSafe as assertBasouRootSafe11,
3158
+ basouPaths as basouPaths11,
2825
3159
  enumerateSessionDirs as enumerateSessionDirs2,
2826
- findErrorCode as findErrorCode8,
3160
+ findErrorCode as findErrorCode10,
2827
3161
  importSessionFromJson as importSessionFromJson2,
2828
3162
  loadSessionEntries,
2829
3163
  readAllEvents,
2830
3164
  readManifest as readManifest5,
2831
- readYamlFile as readYamlFile4,
3165
+ readYamlFile as readYamlFile5,
2832
3166
  rechainSessionInPlace,
2833
3167
  resolveRepositoryRoot as resolveRepositoryRoot10,
2834
3168
  resolveSessionId as resolveSessionId2,
@@ -2841,15 +3175,7 @@ import {
2841
3175
  import { InvalidArgumentError as InvalidArgumentError3 } from "commander";
2842
3176
 
2843
3177
  // src/lib/format-duration.ts
2844
- function formatDurationMs(ms) {
2845
- const totalSeconds = Math.round(ms / 1e3);
2846
- const hours = Math.floor(totalSeconds / 3600);
2847
- const minutes = Math.floor(totalSeconds % 3600 / 60);
2848
- const seconds = totalSeconds % 60;
2849
- if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`;
2850
- if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
2851
- return `${seconds}s`;
2852
- }
3178
+ import { formatDurationMs } from "@basou/core";
2853
3179
 
2854
3180
  // src/commands/session.ts
2855
3181
  var SES_PREFIX3 = "ses_";
@@ -2895,8 +3221,8 @@ async function runSessionList(options, ctx = {}) {
2895
3221
  async function doRunSessionList(options, ctx) {
2896
3222
  const cwd = ctx.cwd ?? process.cwd();
2897
3223
  const repositoryRoot = await resolveRepositoryRootForSession(cwd, "list");
2898
- const paths = basouPaths9(repositoryRoot);
2899
- await assertWorkspaceInitialized7(paths.root);
3224
+ const paths = basouPaths11(repositoryRoot);
3225
+ await assertWorkspaceInitialized9(paths.root);
2900
3226
  const now = /* @__PURE__ */ new Date();
2901
3227
  const records = (await loadSessionEntries(paths, {
2902
3228
  now,
@@ -2947,17 +3273,17 @@ async function runSessionShow(idInput, options, ctx = {}) {
2947
3273
  async function doRunSessionShow(idInput, options, ctx) {
2948
3274
  const cwd = ctx.cwd ?? process.cwd();
2949
3275
  const repositoryRoot = await resolveRepositoryRootForSession(cwd, "show");
2950
- const paths = basouPaths9(repositoryRoot);
2951
- await assertWorkspaceInitialized7(paths.root);
3276
+ const paths = basouPaths11(repositoryRoot);
3277
+ await assertWorkspaceInitialized9(paths.root);
2952
3278
  const sessionId = await resolveSessionId2(paths, idInput);
2953
- const sessionDir = join6(paths.sessions, sessionId);
2954
- const sessionYamlPath = join6(sessionDir, "session.yaml");
3279
+ const sessionDir = join7(paths.sessions, sessionId);
3280
+ const sessionYamlPath = join7(sessionDir, "session.yaml");
2955
3281
  let session;
2956
3282
  try {
2957
- const raw = await readYamlFile4(sessionYamlPath);
3283
+ const raw = await readYamlFile5(sessionYamlPath);
2958
3284
  session = SessionSchema3.parse(raw);
2959
3285
  } catch (error) {
2960
- if (findErrorCode8(error, "ENOENT")) {
3286
+ if (findErrorCode10(error, "ENOENT")) {
2961
3287
  throw new Error(`Session not found: ${idInput}`);
2962
3288
  }
2963
3289
  throw new Error("Failed to read session", { cause: error });
@@ -3072,7 +3398,7 @@ function formatSessionWork(session, events, now) {
3072
3398
  }
3073
3399
  function formatWorkingDir(workingDir, repositoryRoot, options) {
3074
3400
  if (options.fullPath === true) return workingDir;
3075
- if (!isAbsolute(workingDir)) {
3401
+ if (!isAbsolute3(workingDir)) {
3076
3402
  if (workingDir === ".") return "<repository_root>";
3077
3403
  return workingDir;
3078
3404
  }
@@ -3203,11 +3529,11 @@ async function resolveRepositoryRootForSession(cwd, subcmd) {
3203
3529
  throw error;
3204
3530
  }
3205
3531
  }
3206
- async function assertWorkspaceInitialized7(basouRoot) {
3532
+ async function assertWorkspaceInitialized9(basouRoot) {
3207
3533
  try {
3208
- await assertBasouRootSafe9(basouRoot);
3534
+ await assertBasouRootSafe11(basouRoot);
3209
3535
  } catch (error) {
3210
- if (findErrorCode8(error, "ENOENT")) {
3536
+ if (findErrorCode10(error, "ENOENT")) {
3211
3537
  throw new Error("Workspace not initialized. Run 'basou init' first.");
3212
3538
  }
3213
3539
  throw error;
@@ -3245,8 +3571,8 @@ async function runSessionImport(options, ctx = {}) {
3245
3571
  async function doRunSessionImport(options, ctx) {
3246
3572
  const cwd = ctx.cwd ?? process.cwd();
3247
3573
  const repositoryRoot = await resolveRepositoryRootForSession(cwd, "import");
3248
- const paths = basouPaths9(repositoryRoot);
3249
- await assertWorkspaceInitialized7(paths.root);
3574
+ const paths = basouPaths11(repositoryRoot);
3575
+ await assertWorkspaceInitialized9(paths.root);
3250
3576
  const manifest = await readManifest5(paths);
3251
3577
  const rawBody = await readInputFile(options.from);
3252
3578
  const json = parseJsonStrict(rawBody);
@@ -3276,10 +3602,10 @@ async function readInputFile(path) {
3276
3602
  try {
3277
3603
  return await readFile2(path, "utf8");
3278
3604
  } catch (error) {
3279
- if (findErrorCode8(error, "ENOENT")) {
3605
+ if (findErrorCode10(error, "ENOENT")) {
3280
3606
  throw new Error("Import source not found", { cause: error });
3281
3607
  }
3282
- if (findErrorCode8(error, "EISDIR")) {
3608
+ if (findErrorCode10(error, "EISDIR")) {
3283
3609
  throw new Error("Import source is not a file", { cause: error });
3284
3610
  }
3285
3611
  throw new Error("Failed to read import source", { cause: error });
@@ -3359,8 +3685,8 @@ async function doRunSessionNote(sessionIdInput, options, ctx) {
3359
3685
  }
3360
3686
  const cwd = ctx.cwd ?? process.cwd();
3361
3687
  const repositoryRoot = await resolveRepositoryRootForSession(cwd, "note");
3362
- const paths = basouPaths9(repositoryRoot);
3363
- await assertWorkspaceInitialized7(paths.root);
3688
+ const paths = basouPaths11(repositoryRoot);
3689
+ await assertWorkspaceInitialized9(paths.root);
3364
3690
  const sessionId = await resolveSessionId2(paths, sessionIdInput);
3365
3691
  const body = hasBody ? options.body : await readNoteFile(options.fromFile);
3366
3692
  if (body.length === 0) {
@@ -3393,10 +3719,10 @@ async function readNoteFile(path) {
3393
3719
  try {
3394
3720
  return await readFile2(path, "utf8");
3395
3721
  } catch (error) {
3396
- if (findErrorCode8(error, "ENOENT")) {
3722
+ if (findErrorCode10(error, "ENOENT")) {
3397
3723
  throw new Error("Note source not found", { cause: error });
3398
3724
  }
3399
- if (findErrorCode8(error, "EISDIR")) {
3725
+ if (findErrorCode10(error, "EISDIR")) {
3400
3726
  throw new Error("Note source is not a file", { cause: error });
3401
3727
  }
3402
3728
  throw new Error("Failed to read note source", { cause: error });
@@ -3441,8 +3767,8 @@ async function doRunSessionRechain(options, ctx) {
3441
3767
  }
3442
3768
  const cwd = ctx.cwd ?? process.cwd();
3443
3769
  const repositoryRoot = await resolveRepositoryRootForSession(cwd, "rechain");
3444
- const paths = basouPaths9(repositoryRoot);
3445
- await assertWorkspaceInitialized7(paths.root);
3770
+ const paths = basouPaths11(repositoryRoot);
3771
+ await assertWorkspaceInitialized9(paths.root);
3446
3772
  const sessionIds = options.session !== void 0 ? [await resolveSessionId2(paths, options.session)] : await enumerateSessionDirs2(paths);
3447
3773
  const dryRun = options.dryRun === true;
3448
3774
  const rows = [];
@@ -3495,10 +3821,10 @@ function renderRechainRow(row, dryRun) {
3495
3821
 
3496
3822
  // src/commands/stats.ts
3497
3823
  import {
3498
- assertBasouRootSafe as assertBasouRootSafe10,
3499
- basouPaths as basouPaths10,
3824
+ assertBasouRootSafe as assertBasouRootSafe12,
3825
+ basouPaths as basouPaths12,
3500
3826
  computeWorkStats,
3501
- findErrorCode as findErrorCode9,
3827
+ findErrorCode as findErrorCode11,
3502
3828
  resolveRepositoryRoot as resolveRepositoryRoot11
3503
3829
  } from "@basou/core";
3504
3830
  function registerStatsCommand(program) {
@@ -3517,8 +3843,8 @@ async function runStats(options, ctx = {}) {
3517
3843
  async function doRunStats(options, ctx) {
3518
3844
  const cwd = ctx.cwd ?? process.cwd();
3519
3845
  const repositoryRoot = await resolveRepositoryRootForStats(cwd);
3520
- const paths = basouPaths10(repositoryRoot);
3521
- await assertWorkspaceInitialized8(paths.root);
3846
+ const paths = basouPaths12(repositoryRoot);
3847
+ await assertWorkspaceInitialized10(paths.root);
3522
3848
  const now = ctx.nowProvider?.() ?? /* @__PURE__ */ new Date();
3523
3849
  const result = await computeWorkStats({
3524
3850
  paths,
@@ -3612,11 +3938,11 @@ async function resolveRepositoryRootForStats(cwd) {
3612
3938
  throw error;
3613
3939
  }
3614
3940
  }
3615
- async function assertWorkspaceInitialized8(basouRoot) {
3941
+ async function assertWorkspaceInitialized10(basouRoot) {
3616
3942
  try {
3617
- await assertBasouRootSafe10(basouRoot);
3943
+ await assertBasouRootSafe12(basouRoot);
3618
3944
  } catch (error) {
3619
- if (findErrorCode9(error, "ENOENT")) {
3945
+ if (findErrorCode11(error, "ENOENT")) {
3620
3946
  throw new Error("Workspace not initialized. Run 'basou init' first.");
3621
3947
  }
3622
3948
  throw error;
@@ -3625,10 +3951,10 @@ async function assertWorkspaceInitialized8(basouRoot) {
3625
3951
 
3626
3952
  // src/commands/status.ts
3627
3953
  import {
3628
- assertBasouRootSafe as assertBasouRootSafe11,
3629
- basouPaths as basouPaths11,
3954
+ assertBasouRootSafe as assertBasouRootSafe13,
3955
+ basouPaths as basouPaths13,
3630
3956
  buildStatusSnapshot,
3631
- findErrorCode as findErrorCode10,
3957
+ findErrorCode as findErrorCode12,
3632
3958
  readManifest as readManifest6,
3633
3959
  resolveRepositoryRoot as resolveRepositoryRoot12,
3634
3960
  writeStatus
@@ -3649,11 +3975,11 @@ async function runStatus(options, ctx = {}) {
3649
3975
  async function doRunStatus(options, ctx) {
3650
3976
  const cwd = ctx.cwd ?? process.cwd();
3651
3977
  const repositoryRoot = await resolveRepositoryRootForStatus(cwd);
3652
- const paths = basouPaths11(repositoryRoot);
3978
+ const paths = basouPaths13(repositoryRoot);
3653
3979
  try {
3654
- await assertBasouRootSafe11(paths.root);
3980
+ await assertBasouRootSafe13(paths.root);
3655
3981
  } catch (error) {
3656
- if (findErrorCode10(error, "ENOENT")) {
3982
+ if (findErrorCode12(error, "ENOENT")) {
3657
3983
  throw new Error("Workspace not initialized. Run 'basou init' first.");
3658
3984
  }
3659
3985
  throw error;
@@ -3662,7 +3988,7 @@ async function doRunStatus(options, ctx) {
3662
3988
  try {
3663
3989
  manifest = await readManifest6(paths);
3664
3990
  } catch (error) {
3665
- if (findErrorCode10(error, "ENOENT")) {
3991
+ if (findErrorCode12(error, "ENOENT")) {
3666
3992
  throw new Error("Workspace not initialized. Run 'basou init' first.");
3667
3993
  }
3668
3994
  throw new Error("Failed to read workspace manifest", { cause: error });
@@ -3699,16 +4025,16 @@ async function resolveRepositoryRootForStatus(cwd) {
3699
4025
 
3700
4026
  // src/commands/task.ts
3701
4027
  import { readFile as readFile3 } from "fs/promises";
3702
- import { join as join7 } from "path";
4028
+ import { join as join8 } from "path";
3703
4029
  import {
3704
4030
  archiveTask,
3705
- assertBasouRootSafe as assertBasouRootSafe12,
3706
- basouPaths as basouPaths12,
4031
+ assertBasouRootSafe as assertBasouRootSafe14,
4032
+ basouPaths as basouPaths14,
3707
4033
  createTaskWithEvent,
3708
4034
  deleteTask,
3709
4035
  editTask,
3710
4036
  enumerateArchivedTaskIds,
3711
- findErrorCode as findErrorCode11,
4037
+ findErrorCode as findErrorCode13,
3712
4038
  loadSessionEntries as loadSessionEntries2,
3713
4039
  loadTaskEntries,
3714
4040
  prefixedUlid as prefixedUlid5,
@@ -3805,8 +4131,8 @@ async function doRunTaskNew(options, ctx) {
3805
4131
  }
3806
4132
  const cwd = ctx.cwd ?? process.cwd();
3807
4133
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "new");
3808
- const paths = basouPaths12(repositoryRoot);
3809
- await assertWorkspaceInitialized9(paths.root);
4134
+ const paths = basouPaths14(repositoryRoot);
4135
+ await assertWorkspaceInitialized11(paths.root);
3810
4136
  const description = options.description !== void 0 ? options.description : options.fromFile !== void 0 ? await readDescriptionFile(options.fromFile) : "";
3811
4137
  const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
3812
4138
  const occurredAt = now.toISOString();
@@ -3914,8 +4240,8 @@ async function runTaskList(options, ctx = {}) {
3914
4240
  async function doRunTaskList(options, ctx) {
3915
4241
  const cwd = ctx.cwd ?? process.cwd();
3916
4242
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "list");
3917
- const paths = basouPaths12(repositoryRoot);
3918
- await assertWorkspaceInitialized9(paths.root);
4243
+ const paths = basouPaths14(repositoryRoot);
4244
+ await assertWorkspaceInitialized11(paths.root);
3919
4245
  const entries = await loadTaskEntries(paths, {
3920
4246
  onSkip: (id, reason) => printTaskSkip(id, reason)
3921
4247
  });
@@ -4018,15 +4344,15 @@ async function runTaskShow(idInput, options, ctx = {}) {
4018
4344
  async function doRunTaskShow(idInput, options, ctx) {
4019
4345
  const cwd = ctx.cwd ?? process.cwd();
4020
4346
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "show");
4021
- const paths = basouPaths12(repositoryRoot);
4022
- await assertWorkspaceInitialized9(paths.root);
4347
+ const paths = basouPaths14(repositoryRoot);
4348
+ await assertWorkspaceInitialized11(paths.root);
4023
4349
  const taskId = await resolveTaskId2(paths, idInput, { includeArchived: true });
4024
4350
  const { doc, archived } = await readTaskFileWithArchiveFallback(paths, taskId);
4025
4351
  const sessions = await loadSessionEntries2(paths, { now: /* @__PURE__ */ new Date() });
4026
4352
  const events = [];
4027
4353
  const linkedSessionIds = new Set(doc.task.task.linked_sessions);
4028
4354
  for (const s of sessions) {
4029
- const sessionDir = join7(paths.sessions, s.sessionId);
4355
+ const sessionDir = join8(paths.sessions, s.sessionId);
4030
4356
  try {
4031
4357
  for await (const ev of replayEvents2(sessionDir, {
4032
4358
  onWarning: (w) => printReplayWarning(w, s.sessionId)
@@ -4162,8 +4488,8 @@ async function doRunTaskStatus(taskIdInput, newStatusInput, options, ctx) {
4162
4488
  const newStatus = parseTaskStatusPositional(newStatusInput);
4163
4489
  const cwd = ctx.cwd ?? process.cwd();
4164
4490
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "status");
4165
- const paths = basouPaths12(repositoryRoot);
4166
- await assertWorkspaceInitialized9(paths.root);
4491
+ const paths = basouPaths14(repositoryRoot);
4492
+ await assertWorkspaceInitialized11(paths.root);
4167
4493
  const taskId = await resolveTaskId2(paths, taskIdInput);
4168
4494
  const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
4169
4495
  const occurredAt = now.toISOString();
@@ -4239,8 +4565,8 @@ async function runTaskReconcile(options, ctx = {}) {
4239
4565
  async function doRunTaskReconcile(options, ctx) {
4240
4566
  const cwd = ctx.cwd ?? process.cwd();
4241
4567
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "reconcile");
4242
- const paths = basouPaths12(repositoryRoot);
4243
- await assertWorkspaceInitialized9(paths.root);
4568
+ const paths = basouPaths14(repositoryRoot);
4569
+ await assertWorkspaceInitialized11(paths.root);
4244
4570
  const manifest = await readManifest7(paths);
4245
4571
  const nowProvider = ctx.nowProvider ?? (() => /* @__PURE__ */ new Date());
4246
4572
  const write = options.write === true;
@@ -4419,8 +4745,8 @@ async function doRunTaskRefreshLinkage(taskIdInput, options, ctx) {
4419
4745
  }
4420
4746
  const cwd = ctx.cwd ?? process.cwd();
4421
4747
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "refresh-linkage");
4422
- const paths = basouPaths12(repositoryRoot);
4423
- await assertWorkspaceInitialized9(paths.root);
4748
+ const paths = basouPaths14(repositoryRoot);
4749
+ await assertWorkspaceInitialized11(paths.root);
4424
4750
  const manifest = await readManifest7(paths);
4425
4751
  const taskId = await resolveTaskId2(paths, taskIdInput);
4426
4752
  const nowProvider = ctx.nowProvider ?? (() => /* @__PURE__ */ new Date());
@@ -4499,8 +4825,8 @@ async function doRunTaskEdit(taskIdInput, options, ctx) {
4499
4825
  }
4500
4826
  const cwd = ctx.cwd ?? process.cwd();
4501
4827
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "edit");
4502
- const paths = basouPaths12(repositoryRoot);
4503
- await assertWorkspaceInitialized9(paths.root);
4828
+ const paths = basouPaths14(repositoryRoot);
4829
+ await assertWorkspaceInitialized11(paths.root);
4504
4830
  const manifest = await readManifest7(paths);
4505
4831
  const taskId = await resolveTaskId2(paths, taskIdInput);
4506
4832
  const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
@@ -4555,8 +4881,8 @@ async function doRunTaskDelete(taskIdInput, options, ctx) {
4555
4881
  }
4556
4882
  const cwd = ctx.cwd ?? process.cwd();
4557
4883
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "delete");
4558
- const paths = basouPaths12(repositoryRoot);
4559
- await assertWorkspaceInitialized9(paths.root);
4884
+ const paths = basouPaths14(repositoryRoot);
4885
+ await assertWorkspaceInitialized11(paths.root);
4560
4886
  const manifest = await readManifest7(paths);
4561
4887
  const taskId = await resolveTaskId2(paths, taskIdInput);
4562
4888
  if (options.yes !== true) {
@@ -4600,8 +4926,8 @@ async function doRunTaskArchive(taskIdInput, options, ctx) {
4600
4926
  }
4601
4927
  const cwd = ctx.cwd ?? process.cwd();
4602
4928
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "archive");
4603
- const paths = basouPaths12(repositoryRoot);
4604
- await assertWorkspaceInitialized9(paths.root);
4929
+ const paths = basouPaths14(repositoryRoot);
4930
+ await assertWorkspaceInitialized11(paths.root);
4605
4931
  const manifest = await readManifest7(paths);
4606
4932
  const taskId = await resolveTaskId2(paths, taskIdInput);
4607
4933
  if (options.yes !== true) {
@@ -4716,10 +5042,10 @@ async function readDescriptionFile(path) {
4716
5042
  try {
4717
5043
  return await readFile3(path, "utf8");
4718
5044
  } catch (error) {
4719
- if (findErrorCode11(error, "ENOENT")) {
5045
+ if (findErrorCode13(error, "ENOENT")) {
4720
5046
  throw new Error("Description source not found", { cause: error });
4721
5047
  }
4722
- if (findErrorCode11(error, "EISDIR")) {
5048
+ if (findErrorCode13(error, "EISDIR")) {
4723
5049
  throw new Error("Description source is not a file", { cause: error });
4724
5050
  }
4725
5051
  throw new Error("Failed to read description source", { cause: error });
@@ -4738,11 +5064,11 @@ async function resolveRepositoryRootForTask(cwd, subcmd) {
4738
5064
  throw error;
4739
5065
  }
4740
5066
  }
4741
- async function assertWorkspaceInitialized9(basouRoot) {
5067
+ async function assertWorkspaceInitialized11(basouRoot) {
4742
5068
  try {
4743
- await assertBasouRootSafe12(basouRoot);
5069
+ await assertBasouRootSafe14(basouRoot);
4744
5070
  } catch (error) {
4745
- if (findErrorCode11(error, "ENOENT")) {
5071
+ if (findErrorCode13(error, "ENOENT")) {
4746
5072
  throw new Error("Workspace not initialized. Run 'basou init' first.");
4747
5073
  }
4748
5074
  throw error;
@@ -4830,10 +5156,10 @@ function maxLen3(values, floor) {
4830
5156
 
4831
5157
  // src/commands/verify.ts
4832
5158
  import {
4833
- assertBasouRootSafe as assertBasouRootSafe13,
4834
- basouPaths as basouPaths13,
5159
+ assertBasouRootSafe as assertBasouRootSafe15,
5160
+ basouPaths as basouPaths15,
4835
5161
  enumerateSessionDirs as enumerateSessionDirs3,
4836
- findErrorCode as findErrorCode12,
5162
+ findErrorCode as findErrorCode14,
4837
5163
  resolveRepositoryRoot as resolveRepositoryRoot14,
4838
5164
  resolveSessionId as resolveSessionId4,
4839
5165
  verifyEventsChain
@@ -4857,8 +5183,8 @@ async function doRunVerify(options, ctx) {
4857
5183
  }
4858
5184
  const cwd = ctx.cwd ?? process.cwd();
4859
5185
  const repositoryRoot = await resolveRepositoryRootForVerify(cwd);
4860
- const paths = basouPaths13(repositoryRoot);
4861
- await assertWorkspaceInitialized10(paths.root);
5186
+ const paths = basouPaths15(repositoryRoot);
5187
+ await assertWorkspaceInitialized12(paths.root);
4862
5188
  const sessionIds = options.session !== void 0 ? [await resolveSessionId4(paths, options.session)] : await enumerateSessionDirs3(paths);
4863
5189
  const rows = [];
4864
5190
  for (const sessionId of sessionIds) {
@@ -4915,11 +5241,11 @@ async function resolveRepositoryRootForVerify(cwd) {
4915
5241
  throw error;
4916
5242
  }
4917
5243
  }
4918
- async function assertWorkspaceInitialized10(basouRoot) {
5244
+ async function assertWorkspaceInitialized12(basouRoot) {
4919
5245
  try {
4920
- await assertBasouRootSafe13(basouRoot);
5246
+ await assertBasouRootSafe15(basouRoot);
4921
5247
  } catch (error) {
4922
- if (findErrorCode12(error, "ENOENT")) {
5248
+ if (findErrorCode14(error, "ENOENT")) {
4923
5249
  throw new Error("Workspace not initialized. Run 'basou init' first.");
4924
5250
  }
4925
5251
  throw error;
@@ -4928,27 +5254,162 @@ async function assertWorkspaceInitialized10(basouRoot) {
4928
5254
 
4929
5255
  // src/commands/view.ts
4930
5256
  import { spawn } from "child_process";
4931
- import { assertBasouRootSafe as assertBasouRootSafe14, basouPaths as basouPaths14, findErrorCode as findErrorCode14, resolveRepositoryRoot as resolveRepositoryRoot15 } from "@basou/core";
5257
+ import { createHash } from "crypto";
5258
+ import { basename as basename4, resolve as resolve6 } from "path";
5259
+ import {
5260
+ assertBasouRootSafe as assertBasouRootSafe16,
5261
+ basouPaths as basouPaths16,
5262
+ findErrorCode as findErrorCode16,
5263
+ readManifest as readManifest10,
5264
+ resolveRepositoryRoot as resolveRepositoryRoot15
5265
+ } from "@basou/core";
4932
5266
  import { InvalidArgumentError as InvalidArgumentError5 } from "commander";
4933
5267
 
5268
+ // src/lib/portfolio-safety.ts
5269
+ import { execFile } from "child_process";
5270
+ import { lstat, realpath } from "fs/promises";
5271
+ import { isAbsolute as isAbsolute4, join as join9, relative as relative3, resolve as resolve5 } from "path";
5272
+ import { promisify } from "util";
5273
+ import { readManifest as readManifest8 } from "@basou/core";
5274
+ var execFileAsync = promisify(execFile);
5275
+ function errorCode(error) {
5276
+ return error instanceof Error ? error.code : void 0;
5277
+ }
5278
+ async function canonical(p) {
5279
+ try {
5280
+ return await realpath(p);
5281
+ } catch {
5282
+ return resolve5(p);
5283
+ }
5284
+ }
5285
+ function isInside(child, parent) {
5286
+ const rel = relative3(parent, child);
5287
+ return rel === "" || !rel.startsWith("..") && !isAbsolute4(rel);
5288
+ }
5289
+ function isBasouPath(p) {
5290
+ return p === ".basou" || p.startsWith(".basou/") || p.includes("/.basou/") || p.endsWith("/.basou");
5291
+ }
5292
+ async function inspectRepo(repoPath) {
5293
+ let hasEntry = false;
5294
+ try {
5295
+ await lstat(join9(repoPath, ".basou"));
5296
+ hasEntry = true;
5297
+ } catch (error) {
5298
+ if (errorCode(error) !== "ENOENT") {
5299
+ return {
5300
+ kind: "unverifiable",
5301
+ detail: `could not check for a .basou here (${errorCode(error) ?? "unknown error"}) \u2014 treat as unsafe`
5302
+ };
5303
+ }
5304
+ }
5305
+ try {
5306
+ const { stdout } = await execFileAsync("git", ["-C", repoPath, "ls-files", "-z"]);
5307
+ const tracked = stdout.split("\0").some((f) => f.length > 0 && isBasouPath(f));
5308
+ if (tracked) {
5309
+ return {
5310
+ kind: "footprint",
5311
+ detail: "a .basou/ entry is tracked by git here and would be pushed"
5312
+ };
5313
+ }
5314
+ } catch {
5315
+ }
5316
+ if (hasEntry) return { kind: "footprint", detail: "a .basou/ entry exists here" };
5317
+ return null;
5318
+ }
5319
+ async function checkPortfolioSafety(workspaces) {
5320
+ const findings = [];
5321
+ let monitoredReposChecked = 0;
5322
+ for (const ws of workspaces) {
5323
+ const wsReal = await canonical(ws.repoRoot);
5324
+ let sourceRoots = [];
5325
+ try {
5326
+ const manifest = await readManifest8(ws.paths);
5327
+ sourceRoots = manifest.import?.source_roots ?? [];
5328
+ } catch (error) {
5329
+ if (error instanceof Error && error.message === "YAML file not found") {
5330
+ sourceRoots = [];
5331
+ } else {
5332
+ findings.push({
5333
+ workspaceLabel: ws.label,
5334
+ workspaceRoot: ws.repoRoot,
5335
+ monitoredRepo: ws.repoRoot,
5336
+ kind: "unverifiable",
5337
+ detail: "the workspace manifest is present but unreadable \u2014 cannot determine which repos it monitors; treat as unsafe"
5338
+ });
5339
+ continue;
5340
+ }
5341
+ }
5342
+ const monitored = /* @__PURE__ */ new Map();
5343
+ for (const root of sourceRoots) {
5344
+ const display = resolve5(ws.repoRoot, root);
5345
+ const real = await canonical(display);
5346
+ if (real !== wsReal) monitored.set(real, display);
5347
+ }
5348
+ for (const [real, display] of monitored) {
5349
+ monitoredReposChecked++;
5350
+ if (isInside(wsReal, real)) {
5351
+ findings.push({
5352
+ workspaceLabel: ws.label,
5353
+ workspaceRoot: ws.repoRoot,
5354
+ monitoredRepo: display,
5355
+ kind: "overlap",
5356
+ detail: "the workspace (where .basou/ is written) is inside this monitored repo"
5357
+ });
5358
+ }
5359
+ const inspection = await inspectRepo(real);
5360
+ if (inspection !== null) {
5361
+ findings.push({
5362
+ workspaceLabel: ws.label,
5363
+ workspaceRoot: ws.repoRoot,
5364
+ monitoredRepo: display,
5365
+ kind: inspection.kind,
5366
+ detail: inspection.detail
5367
+ });
5368
+ }
5369
+ }
5370
+ }
5371
+ return { findings, workspacesChecked: workspaces.length, monitoredReposChecked };
5372
+ }
5373
+ function formatSafetyReport(result) {
5374
+ if (result.findings.length === 0) {
5375
+ if (result.monitoredReposChecked === 0) {
5376
+ return [
5377
+ `Portfolio safety: OK. ${result.workspacesChecked} workspace(s) checked \u2014 no monitored repos configured (portfolio safety applies when a workspace imports from sibling repos via source_roots).`
5378
+ ];
5379
+ }
5380
+ return [
5381
+ `Portfolio safety: OK. ${result.workspacesChecked} workspace(s), ${result.monitoredReposChecked} monitored repo(s) checked \u2014 no .basou footprint, no overlap.`
5382
+ ];
5383
+ }
5384
+ const lines = [`Portfolio safety: DANGER \u2014 ${result.findings.length} finding(s):`];
5385
+ for (const f of result.findings) {
5386
+ lines.push(` [${f.kind}] ${f.monitoredRepo} (workspace "${f.workspaceLabel}"): ${f.detail}`);
5387
+ }
5388
+ lines.push(
5389
+ "A monitored repo must have no basou footprint. Use a separate workspace repo whose source_roots point at the monitored repo as a sibling; never 'basou init' / 'run' / 'exec' inside a monitored repo."
5390
+ );
5391
+ return lines;
5392
+ }
5393
+
4934
5394
  // src/lib/view-server.ts
4935
5395
  import { createServer } from "http";
4936
- import { join as join8 } from "path";
5396
+ import { join as join10 } from "path";
4937
5397
  import {
4938
5398
  computeWorkStats as computeWorkStats2,
4939
5399
  enumerateApprovals as enumerateApprovals2,
4940
- findErrorCode as findErrorCode13,
5400
+ findErrorCode as findErrorCode15,
4941
5401
  isLazyExpired as isLazyExpired2,
4942
5402
  loadApproval as loadApproval2,
4943
5403
  loadSessionEntries as loadSessionEntries3,
4944
5404
  loadTaskEntries as loadTaskEntries2,
4945
5405
  readAllEvents as readAllEvents2,
4946
- readManifest as readManifest8,
5406
+ readManifest as readManifest9,
4947
5407
  readMarkdownFile as readMarkdownFile4,
4948
5408
  readSessionYaml as readSessionYaml3,
4949
5409
  readTaskFile as readTaskFile2,
4950
5410
  renderDecisions as renderDecisions3,
4951
- renderHandoff as renderHandoff3
5411
+ renderHandoff as renderHandoff3,
5412
+ summarizeOrientation
4952
5413
  } from "@basou/core";
4953
5414
 
4954
5415
  // src/lib/view-ui.ts
@@ -4970,8 +5431,13 @@ var VIEW_HTML = `<!doctype html>
4970
5431
  button.primary { background: #2563eb; color: #fff; border-color: #2563eb; }
4971
5432
  button:disabled { opacity: .5; cursor: default; }
4972
5433
  label.chk { font-size: 13px; opacity: .85; }
5434
+ /* On the portfolio landing there is no selected workspace, so the per-workspace action bar is hidden. */
5435
+ body.landing #project, body.landing label.chk,
5436
+ body.landing #btn-refresh, body.landing #btn-import-claude, body.landing #btn-import-codex,
5437
+ body.landing #btn-gen-handoff, body.landing #btn-gen-decisions { display: none; }
4973
5438
  #status { padding: 6px 16px; font-size: 13px; min-height: 20px; border-bottom: 1px solid #8884; white-space: pre-wrap; }
4974
5439
  #status.err { color: #dc2626; }
5440
+ .err { color: #dc2626; }
4975
5441
  nav { display: flex; gap: 2px; padding: 6px 12px; border-bottom: 1px solid #8884; flex-wrap: wrap; }
4976
5442
  nav button { border: none; border-radius: 6px; background: transparent; }
4977
5443
  nav button.active { background: #2563eb22; font-weight: 600; }
@@ -4985,6 +5451,8 @@ var VIEW_HTML = `<!doctype html>
4985
5451
  #detail { padding: 12px 16px; overflow: auto; max-height: 80vh; }
4986
5452
  .badge { display: inline-block; padding: 0 6px; border-radius: 6px; background: #8882; font-size: 12px; }
4987
5453
  .badge.warn { background: #f59e0b33; }
5454
+ .badge.danger { background: #ef444433; }
5455
+ .badge.ok { background: #22c55e33; }
4988
5456
  pre { background: #8881; padding: 12px; border-radius: 8px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
4989
5457
  table.kv { border-collapse: collapse; }
4990
5458
  table.kv td { padding: 2px 10px 2px 0; vertical-align: top; }
@@ -4993,6 +5461,11 @@ var VIEW_HTML = `<!doctype html>
4993
5461
  .card { border: 1px solid #8884; border-radius: 8px; padding: 10px 14px; min-width: 120px; }
4994
5462
  .card .n { font-size: 22px; font-weight: 700; }
4995
5463
  .card .l { font-size: 12px; opacity: .7; }
5464
+ .pcard { min-width: 240px; max-width: 340px; }
5465
+ .pcard.open { cursor: pointer; }
5466
+ .pcard.open:hover { background: #8881; }
5467
+ .pcard .l { font-size: 14px; font-weight: 700; opacity: 1; margin-bottom: 4px; }
5468
+ .pcard .f { font-size: 13px; }
4996
5469
  .tl { border-left: 2px solid #8885; margin-left: 6px; padding-left: 12px; }
4997
5470
  .tl .ev { margin-bottom: 8px; }
4998
5471
  .tl .ev .t { font-size: 12px; opacity: .65; }
@@ -5002,6 +5475,7 @@ var VIEW_HTML = `<!doctype html>
5002
5475
  <body>
5003
5476
  <header>
5004
5477
  <h1>basou view</h1>
5478
+ <button id="btn-back" style="display:none">&larr; portfolio</button>
5005
5479
  <input type="text" id="project" placeholder="source root (optional override)" />
5006
5480
  <button class="primary" id="btn-refresh">Refresh all</button>
5007
5481
  <button id="btn-import-claude">Import claude-code</button>
@@ -5021,7 +5495,12 @@ var VIEW_HTML = `<!doctype html>
5021
5495
  <script>
5022
5496
  (function () {
5023
5497
  var TABS = ['overview', 'stats', 'sessions', 'tasks', 'decisions', 'approvals', 'handoff'];
5024
- var state = { tab: 'overview', repoRoot: '' };
5498
+ // base is the API prefix for the active workspace: '/api' in single mode,
5499
+ // '/api/ws/<key>' once a portfolio card is opened.
5500
+ // canAct gates the mutating action bar: true only when a concrete workspace
5501
+ // is active (single mode, or a portfolio card opened). It is the real safety
5502
+ // guard \u2014 body.landing also hides the buttons, but that is cosmetic.
5503
+ var state = { tab: 'overview', repoRoot: '', base: '/api', mode: 'single', wsKey: null, canAct: false };
5025
5504
 
5026
5505
  function $(id) { return document.getElementById(id); }
5027
5506
  function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); }
@@ -5085,7 +5564,15 @@ var VIEW_HTML = `<!doctype html>
5085
5564
  for (var i = 0; i < ids.length; i++) $(ids[i]).disabled = busy;
5086
5565
  }
5087
5566
 
5567
+ // Enable the action bar only when a workspace is active; disabled buttons
5568
+ // cannot post to a stale/wrong workspace even if a CSS regression un-hides them.
5569
+ function updateActionBar() {
5570
+ var ids = ['btn-refresh', 'btn-import-claude', 'btn-import-codex', 'btn-gen-handoff', 'btn-gen-decisions'];
5571
+ for (var i = 0; i < ids.length; i++) $(ids[i]).disabled = !state.canAct;
5572
+ }
5573
+
5088
5574
  function post(path, label) {
5575
+ if (!state.canAct) { setStatus('Open a workspace first.', true); return; }
5089
5576
  setBusy(true);
5090
5577
  setStatus(label + '...', false);
5091
5578
  fetchJson(path, {
@@ -5118,6 +5605,155 @@ var VIEW_HTML = `<!doctype html>
5118
5605
  return (o.dryRun ? 'would import ' : 'imported ') + o.importedCount + ' (' + o.eventTotal + ' events)';
5119
5606
  }
5120
5607
 
5608
+ // --- portfolio landing --------------------------------------------------
5609
+
5610
+ function boot() {
5611
+ fetchJson('/api/portfolio').then(function (d) {
5612
+ if (d && d.mode === 'portfolio') { state.mode = 'portfolio'; showLanding(d); }
5613
+ else { enterSingle(); }
5614
+ }).catch(function () {
5615
+ // First-load bootstrap failure: the single-workspace view is the safe default.
5616
+ enterSingle();
5617
+ });
5618
+ }
5619
+
5620
+ // Re-render the portfolio landing (the back button). Unlike boot(), a fetch
5621
+ // failure here keeps the inert landing and shows an error rather than silently
5622
+ // dropping into single mode pointed at the first workspace.
5623
+ function backToPortfolio() {
5624
+ enterLandingChrome();
5625
+ fetchJson('/api/portfolio').then(function (d) {
5626
+ if (d && d.workspaces) renderCards(d);
5627
+ else portfolioError('Portfolio unavailable.');
5628
+ }).catch(function (err) { portfolioError('Could not load portfolio: ' + err.message); });
5629
+ }
5630
+
5631
+ function enterSingle() {
5632
+ state.mode = 'single';
5633
+ state.base = '/api';
5634
+ state.wsKey = null;
5635
+ state.canAct = true;
5636
+ document.body.classList.remove('landing');
5637
+ $('btn-back').style.display = 'none';
5638
+ updateActionBar();
5639
+ buildTabs();
5640
+ loadTab('overview');
5641
+ }
5642
+
5643
+ // Landing chrome: no workspace is active, so actions are disabled (and hidden
5644
+ // by body.landing). The disable is the safety guard; the hide is cosmetic.
5645
+ function enterLandingChrome() {
5646
+ state.wsKey = null;
5647
+ state.canAct = false;
5648
+ document.body.classList.add('landing');
5649
+ $('btn-back').style.display = 'none';
5650
+ setStatus('', false);
5651
+ clear($('tabs'));
5652
+ updateActionBar();
5653
+ single(true);
5654
+ }
5655
+
5656
+ function showLanding(d) { enterLandingChrome(); renderCards(d); }
5657
+
5658
+ function renderCards(d) {
5659
+ var detail = $('detail');
5660
+ clear(detail);
5661
+ var ws = d.workspaces || [];
5662
+ detail.appendChild(el('p', { class: 'muted', text: 'Portfolio \u2014 ' + ws.length + ' workspace(s). Click a card to open it.' }));
5663
+ var cards = el('div', { class: 'cards' }, []);
5664
+ ws.forEach(function (w) { cards.appendChild(portfolioCard(w, d.generatedAt)); });
5665
+ detail.appendChild(cards);
5666
+ }
5667
+
5668
+ function portfolioError(msg) {
5669
+ var detail = $('detail');
5670
+ clear(detail);
5671
+ detail.appendChild(el('p', { class: 'err', text: msg }));
5672
+ detail.appendChild(el('button', { text: 'Retry', onclick: backToPortfolio }));
5673
+ }
5674
+
5675
+ function highestRisk(approvals) {
5676
+ var order = ['critical', 'high', 'medium', 'low'];
5677
+ for (var i = 0; i < order.length; i++) {
5678
+ for (var j = 0; j < approvals.length; j++) {
5679
+ if (approvals[j].risk === order[i]) return order[i];
5680
+ }
5681
+ }
5682
+ return approvals.length ? approvals[0].risk : '';
5683
+ }
5684
+
5685
+ // Human-readable age of an ISO timestamp relative to the portfolio's
5686
+ // generatedAt ("now"), so a stale capture reads as "3d ago" not a raw ISO.
5687
+ function relAge(iso, nowIso) {
5688
+ if (!iso) return '(none)';
5689
+ var ms = Date.parse(nowIso) - Date.parse(iso);
5690
+ if (!isFinite(ms)) return iso;
5691
+ if (ms < 60000) return 'just now';
5692
+ var m = Math.floor(ms / 60000); if (m < 60) return m + 'm ago';
5693
+ var h = Math.floor(m / 60); if (h < 48) return h + 'h ago';
5694
+ return Math.floor(h / 24) + 'd ago';
5695
+ }
5696
+
5697
+ // A "run refresh" badge when a dry-run found uncaptured/changed native sessions,
5698
+ // an "up to date" badge when the capture is current, and nothing loud when the
5699
+ // staleness probe could not run (degrades to a quiet note).
5700
+ function stalenessBadge(st) {
5701
+ if (!st) return null;
5702
+ if (!st.checked) return el('span', { class: 'badge', text: 'freshness unknown' });
5703
+ if (st.unverifiableSessions > 0)
5704
+ return el('span', { class: 'badge danger', text: '\u26A0 ' + st.unverifiableSessions + ' unverifiable \u2014 run verify' });
5705
+ if (st.newSessions > 0)
5706
+ return el('span', { class: 'badge danger', text: '\u26A0 ' + st.newSessions + ' uncaptured \u2014 run refresh' });
5707
+ if (st.updatedSessions > 0)
5708
+ return el('span', { class: 'badge warn', text: st.updatedSessions + ' updated \u2014 run refresh' });
5709
+ return el('span', { class: 'badge ok', text: 'up to date' });
5710
+ }
5711
+
5712
+ function portfolioCard(w, generatedAt) {
5713
+ if (!w.initialized) {
5714
+ return el('div', { class: 'card pcard muted' }, [
5715
+ el('div', { class: 'l', text: w.label }),
5716
+ el('div', { class: 'f', text: w.error ? ('unreadable: ' + w.error) : 'not initialized' })
5717
+ ]);
5718
+ }
5719
+ if (w.error) {
5720
+ return el('div', { class: 'card pcard' }, [
5721
+ el('div', { class: 'l', text: w.label }),
5722
+ el('div', { class: 'f' }, [el('span', { class: 'badge warn', text: 'unreadable: ' + w.error })])
5723
+ ]);
5724
+ }
5725
+ var pend = w.pendingApprovals || [];
5726
+ var pendText = 'pending ' + pend.length + (pend.length ? ' (' + highestRisk(pend) + ')' : '');
5727
+ var now = w.latestSession ? ((w.latestSession.label || '(session)') + ' [' + w.latestSession.status + ']') : '(no live sessions)';
5728
+ var dec = w.latestDecision ? w.latestDecision.title : '(no decisions yet)';
5729
+ var newest = (w.freshness && w.freshness.newestStartedAt) ? w.freshness.newestStartedAt : null;
5730
+ var badge = stalenessBadge(w.staleness);
5731
+ return el('div', { class: 'card pcard open', onclick: function () { openWorkspace(w.key, w.label); } }, [
5732
+ el('div', { class: 'l' }, [
5733
+ el('span', { text: w.label }),
5734
+ badge ? el('span', { text: ' ' }) : null,
5735
+ badge
5736
+ ]),
5737
+ el('div', { class: 'f', text: 'now: ' + now }),
5738
+ el('div', { class: 'f', text: 'latest: ' + dec }),
5739
+ el('div', { class: 'f', text: 'in-flight ' + w.inFlightCount + ' | ' + pendText + ' | suspect ' + w.suspectCount }),
5740
+ el('div', { class: 'f muted', text: 'sessions ' + w.sessionCount + ' | newest ' + relAge(newest, generatedAt) })
5741
+ ]);
5742
+ }
5743
+
5744
+ function openWorkspace(key, label) {
5745
+ state.mode = 'portfolio';
5746
+ state.wsKey = key;
5747
+ state.base = '/api/ws/' + encodeURIComponent(key);
5748
+ state.canAct = true;
5749
+ document.body.classList.remove('landing');
5750
+ $('btn-back').style.display = '';
5751
+ updateActionBar();
5752
+ setStatus('workspace: ' + label, false);
5753
+ buildTabs();
5754
+ loadTab('overview');
5755
+ }
5756
+
5121
5757
  // --- tabs ---------------------------------------------------------------
5122
5758
 
5123
5759
  function buildTabs() {
@@ -5141,16 +5777,16 @@ var VIEW_HTML = `<!doctype html>
5141
5777
  if (name === 'stats') return loadStats();
5142
5778
  if (name === 'sessions') return loadSessions();
5143
5779
  if (name === 'tasks') return loadTasks();
5144
- if (name === 'decisions') return loadMarkdown('/api/decisions', 'decisions');
5780
+ if (name === 'decisions') return loadMarkdown(state.base + '/decisions', 'decisions');
5145
5781
  if (name === 'approvals') return loadApprovals();
5146
- if (name === 'handoff') return loadMarkdown('/api/handoff', 'handoff');
5782
+ if (name === 'handoff') return loadMarkdown(state.base + '/handoff', 'handoff');
5147
5783
  }
5148
5784
 
5149
5785
  function fail(err) { setStatus(err.message, true); }
5150
5786
 
5151
5787
  function loadOverview() {
5152
5788
  single(true);
5153
- fetchJson('/api/overview').then(function (d) {
5789
+ fetchJson(state.base + '/overview').then(function (d) {
5154
5790
  var detail = $('detail');
5155
5791
  if (!d || d.initialized === false) {
5156
5792
  detail.appendChild(el('p', { class: 'muted', text: 'Workspace not initialized.' }));
@@ -5199,7 +5835,7 @@ var VIEW_HTML = `<!doctype html>
5199
5835
 
5200
5836
  function loadStats() {
5201
5837
  single(true);
5202
- fetchJson('/api/stats').then(function (d) {
5838
+ fetchJson(state.base + '/stats').then(function (d) {
5203
5839
  var detail = $('detail');
5204
5840
  var t = d.totals;
5205
5841
  detail.appendChild(el('p', { text: 'Sessions: ' + t.sessionCount }));
@@ -5260,7 +5896,7 @@ var VIEW_HTML = `<!doctype html>
5260
5896
 
5261
5897
  function loadSessions() {
5262
5898
  single(false);
5263
- fetchJson('/api/sessions').then(function (d) {
5899
+ fetchJson(state.base + '/sessions').then(function (d) {
5264
5900
  var list = $('list');
5265
5901
  var rows = (d && d.sessions) || [];
5266
5902
  if (rows.length === 0) { list.appendChild(el('div', { class: 'row muted', text: 'no sessions' })); return; }
@@ -5279,7 +5915,7 @@ var VIEW_HTML = `<!doctype html>
5279
5915
  row.classList.add('active');
5280
5916
  var detail = $('detail');
5281
5917
  clear(detail);
5282
- fetchJson('/api/sessions/' + encodeURIComponent(id)).then(function (d) {
5918
+ fetchJson(state.base + '/sessions/' + encodeURIComponent(id)).then(function (d) {
5283
5919
  var s = d.session.session;
5284
5920
  detail.appendChild(el('h3', { text: s.label || id }));
5285
5921
  detail.appendChild(kv([
@@ -5312,7 +5948,7 @@ var VIEW_HTML = `<!doctype html>
5312
5948
 
5313
5949
  function loadTasks() {
5314
5950
  single(false);
5315
- fetchJson('/api/tasks').then(function (d) {
5951
+ fetchJson(state.base + '/tasks').then(function (d) {
5316
5952
  var list = $('list');
5317
5953
  var rows = (d && d.tasks) || [];
5318
5954
  if (rows.length === 0) { list.appendChild(el('div', { class: 'row muted', text: 'no tasks' })); return; }
@@ -5331,7 +5967,7 @@ var VIEW_HTML = `<!doctype html>
5331
5967
  row.classList.add('active');
5332
5968
  var detail = $('detail');
5333
5969
  clear(detail);
5334
- fetchJson('/api/tasks/' + encodeURIComponent(id)).then(function (d) {
5970
+ fetchJson(state.base + '/tasks/' + encodeURIComponent(id)).then(function (d) {
5335
5971
  detail.appendChild(el('h3', { text: (d.task && (d.task.title || d.task.label)) || id }));
5336
5972
  detail.appendChild(el('pre', { text: JSON.stringify(d.task, null, 2) }));
5337
5973
  if (d.body) detail.appendChild(el('pre', { text: d.body }));
@@ -5350,7 +5986,7 @@ var VIEW_HTML = `<!doctype html>
5350
5986
 
5351
5987
  function loadApprovals() {
5352
5988
  single(true);
5353
- fetchJson('/api/approvals').then(function (d) {
5989
+ fetchJson(state.base + '/approvals').then(function (d) {
5354
5990
  var detail = $('detail');
5355
5991
  var groups = [['pending', d.pending || []], ['resolved', d.resolved || []]];
5356
5992
  groups.forEach(function (g) {
@@ -5375,14 +6011,14 @@ var VIEW_HTML = `<!doctype html>
5375
6011
 
5376
6012
  // --- wire up ------------------------------------------------------------
5377
6013
 
5378
- $('btn-refresh').addEventListener('click', function () { post('/api/refresh', 'Refresh all'); });
5379
- $('btn-import-claude').addEventListener('click', function () { post('/api/import/claude-code', 'Import claude-code'); });
5380
- $('btn-import-codex').addEventListener('click', function () { post('/api/import/codex', 'Import codex'); });
5381
- $('btn-gen-handoff').addEventListener('click', function () { post('/api/handoff/generate', 'Regenerate handoff'); });
5382
- $('btn-gen-decisions').addEventListener('click', function () { post('/api/decisions/generate', 'Regenerate decisions'); });
6014
+ $('btn-back').addEventListener('click', function () { backToPortfolio(); });
6015
+ $('btn-refresh').addEventListener('click', function () { post(state.base + '/refresh', 'Refresh all'); });
6016
+ $('btn-import-claude').addEventListener('click', function () { post(state.base + '/import/claude-code', 'Import claude-code'); });
6017
+ $('btn-import-codex').addEventListener('click', function () { post(state.base + '/import/codex', 'Import codex'); });
6018
+ $('btn-gen-handoff').addEventListener('click', function () { post(state.base + '/handoff/generate', 'Regenerate handoff'); });
6019
+ $('btn-gen-decisions').addEventListener('click', function () { post(state.base + '/decisions/generate', 'Regenerate decisions'); });
5383
6020
 
5384
- buildTabs();
5385
- loadTab('overview');
6021
+ boot();
5386
6022
  })();
5387
6023
  </script>
5388
6024
  </body>
@@ -5397,6 +6033,8 @@ var HttpError = class extends Error {
5397
6033
  status;
5398
6034
  };
5399
6035
  var MAX_BODY_BYTES = 64 * 1024;
6036
+ var API_PREFIX = "/api/";
6037
+ var WS_PREFIX = "/api/ws/";
5400
6038
  function startViewServer(opts) {
5401
6039
  const { port, host = "127.0.0.1", deps } = opts;
5402
6040
  let actionQueue = Promise.resolve();
@@ -5410,7 +6048,7 @@ function startViewServer(opts) {
5410
6048
  };
5411
6049
  let boundPort = port;
5412
6050
  const getPort = () => boundPort;
5413
- return new Promise((resolve3, reject) => {
6051
+ return new Promise((resolve7, reject) => {
5414
6052
  const server = createServer((req, res) => {
5415
6053
  handleRequest(req, res, deps, getPort, runExclusive).catch((error) => {
5416
6054
  sendError(res, error instanceof HttpError ? error.status : 500, pathlessMessage(error));
@@ -5421,7 +6059,7 @@ function startViewServer(opts) {
5421
6059
  const address = server.address();
5422
6060
  boundPort = isAddressInfo(address) ? address.port : port;
5423
6061
  server.off("error", reject);
5424
- resolve3({
6062
+ resolve7({
5425
6063
  url: `http://${host}:${boundPort}`,
5426
6064
  port: boundPort,
5427
6065
  close: () => closeServer(server)
@@ -5433,8 +6071,8 @@ function isAddressInfo(value) {
5433
6071
  return value !== null && typeof value === "object";
5434
6072
  }
5435
6073
  function closeServer(server) {
5436
- return new Promise((resolve3) => {
5437
- server.close(() => resolve3());
6074
+ return new Promise((resolve7) => {
6075
+ server.close(() => resolve7());
5438
6076
  server.closeAllConnections();
5439
6077
  });
5440
6078
  }
@@ -5466,90 +6104,204 @@ async function handleGet(res, pathname, deps) {
5466
6104
  sendHtml(res, VIEW_HTML);
5467
6105
  return;
5468
6106
  }
5469
- if (pathname === "/api/overview") {
5470
- sendJson(res, 200, await overview(deps));
6107
+ if (pathname === "/api/portfolio") {
6108
+ sendJson(res, 200, await portfolio(deps));
5471
6109
  return;
5472
6110
  }
5473
- if (pathname === "/api/sessions") {
5474
- sendJson(res, 200, await sessionsList(deps));
6111
+ const scoped = matchWsRoute(pathname);
6112
+ if (scoped !== null) {
6113
+ const ws = findWorkspace(deps, scoped.key);
6114
+ if (ws === null) {
6115
+ sendError(res, 404, "Unknown workspace");
6116
+ return;
6117
+ }
6118
+ if (!await handleWorkspaceGet(res, scoped.sub, ws, deps.nowProvider)) {
6119
+ sendError(res, 404, "Not found");
6120
+ }
5475
6121
  return;
5476
6122
  }
5477
- const sessionId = matchId(pathname, "/api/sessions/");
5478
- if (sessionId !== null) {
5479
- sendJson(res, 200, await sessionDetail(deps, sessionId));
6123
+ if (pathname.startsWith(API_PREFIX)) {
6124
+ const sub = pathname.slice(API_PREFIX.length);
6125
+ if (!await handleWorkspaceGet(res, sub, primaryWorkspace(deps), deps.nowProvider)) {
6126
+ sendError(res, 404, "Not found");
6127
+ }
5480
6128
  return;
5481
6129
  }
5482
- if (pathname === "/api/tasks") {
5483
- sendJson(res, 200, await tasksList(deps));
6130
+ sendError(res, 404, "Not found");
6131
+ }
6132
+ async function handlePost(res, pathname, body, deps, runExclusive) {
6133
+ const scoped = matchWsRoute(pathname);
6134
+ if (scoped !== null) {
6135
+ const ws = findWorkspace(deps, scoped.key);
6136
+ if (ws === null) {
6137
+ sendError(res, 404, "Unknown workspace");
6138
+ return;
6139
+ }
6140
+ if (!await handleWorkspacePost(res, scoped.sub, ws, body, deps, runExclusive)) {
6141
+ sendError(res, 404, "Not found");
6142
+ }
5484
6143
  return;
5485
6144
  }
5486
- const taskId = matchId(pathname, "/api/tasks/");
5487
- if (taskId !== null) {
5488
- sendJson(res, 200, await taskDetail(deps, taskId));
6145
+ if (pathname.startsWith(API_PREFIX)) {
6146
+ const sub = pathname.slice(API_PREFIX.length);
6147
+ if (!await handleWorkspacePost(res, sub, primaryWorkspace(deps), body, deps, runExclusive)) {
6148
+ sendError(res, 404, "Not found");
6149
+ }
5489
6150
  return;
5490
6151
  }
5491
- if (pathname === "/api/decisions") {
5492
- sendJson(res, 200, await decisionsView(deps));
5493
- return;
6152
+ sendError(res, 404, "Not found");
6153
+ }
6154
+ async function handleWorkspaceGet(res, sub, ws, nowProvider) {
6155
+ if (sub === "overview") {
6156
+ sendJson(res, 200, await overview(ws, nowProvider));
6157
+ return true;
5494
6158
  }
5495
- if (pathname === "/api/approvals") {
5496
- sendJson(res, 200, await approvalsView(deps));
5497
- return;
6159
+ if (sub === "sessions") {
6160
+ sendJson(res, 200, await sessionsList(ws, nowProvider));
6161
+ return true;
5498
6162
  }
5499
- if (pathname === "/api/handoff") {
5500
- sendJson(res, 200, await handoffView(deps));
5501
- return;
6163
+ const sessionId = matchId(sub, "sessions/");
6164
+ if (sessionId !== null) {
6165
+ sendJson(res, 200, await sessionDetail(ws, sessionId));
6166
+ return true;
5502
6167
  }
5503
- if (pathname === "/api/stats") {
5504
- sendJson(res, 200, await computeWorkStats2({ paths: deps.paths, now: deps.nowProvider() }));
5505
- return;
6168
+ if (sub === "tasks") {
6169
+ sendJson(res, 200, await tasksList(ws));
6170
+ return true;
5506
6171
  }
5507
- sendError(res, 404, "Not found");
6172
+ const taskId = matchId(sub, "tasks/");
6173
+ if (taskId !== null) {
6174
+ sendJson(res, 200, await taskDetail(ws, taskId));
6175
+ return true;
6176
+ }
6177
+ if (sub === "decisions") {
6178
+ sendJson(res, 200, await decisionsView(ws, nowProvider));
6179
+ return true;
6180
+ }
6181
+ if (sub === "approvals") {
6182
+ sendJson(res, 200, await approvalsView(ws, nowProvider));
6183
+ return true;
6184
+ }
6185
+ if (sub === "handoff") {
6186
+ sendJson(res, 200, await handoffView(ws, nowProvider));
6187
+ return true;
6188
+ }
6189
+ if (sub === "stats") {
6190
+ sendJson(res, 200, await computeWorkStats2({ paths: ws.paths, now: nowProvider() }));
6191
+ return true;
6192
+ }
6193
+ return false;
5508
6194
  }
5509
- async function handlePost(res, pathname, body, deps, runExclusive) {
6195
+ async function handleWorkspacePost(res, sub, ws, body, deps, runExclusive) {
5510
6196
  const nowIso = deps.nowProvider().toISOString();
5511
6197
  const actionOptions = readActionOptions(body);
5512
- if (pathname === "/api/refresh") {
6198
+ if (sub === "refresh") {
5513
6199
  const result = await runExclusive(
5514
- () => refreshAll({ options: actionOptions, ctx: deps.importCtx, paths: deps.paths, nowIso })
6200
+ () => refreshAll({ options: actionOptions, ctx: ws.importCtx, paths: ws.paths, nowIso })
5515
6201
  );
5516
6202
  sendJson(res, 200, result);
5517
- return;
6203
+ return true;
5518
6204
  }
5519
- if (pathname === "/api/import/claude-code") {
5520
- sendJson(res, 200, await runExclusive(() => importClaudeCode(actionOptions, deps.importCtx)));
5521
- return;
6205
+ if (sub === "import/claude-code") {
6206
+ sendJson(res, 200, await runExclusive(() => importClaudeCode(actionOptions, ws.importCtx)));
6207
+ return true;
5522
6208
  }
5523
- if (pathname === "/api/import/codex") {
5524
- sendJson(res, 200, await runExclusive(() => importCodex(actionOptions, deps.importCtx)));
5525
- return;
6209
+ if (sub === "import/codex") {
6210
+ sendJson(res, 200, await runExclusive(() => importCodex(actionOptions, ws.importCtx)));
6211
+ return true;
5526
6212
  }
5527
- if (pathname === "/api/handoff/generate") {
5528
- sendJson(res, 200, await runExclusive(() => regenerateHandoff(deps.paths, nowIso)));
5529
- return;
6213
+ if (sub === "handoff/generate") {
6214
+ sendJson(res, 200, await runExclusive(() => regenerateHandoff(ws.paths, nowIso)));
6215
+ return true;
5530
6216
  }
5531
- if (pathname === "/api/decisions/generate") {
5532
- sendJson(res, 200, await runExclusive(() => regenerateDecisions(deps.paths, nowIso)));
5533
- return;
6217
+ if (sub === "decisions/generate") {
6218
+ sendJson(res, 200, await runExclusive(() => regenerateDecisions(ws.paths, nowIso)));
6219
+ return true;
5534
6220
  }
5535
- sendError(res, 404, "Not found");
6221
+ return false;
6222
+ }
6223
+ function primaryWorkspace(deps) {
6224
+ const first = deps.workspaces[0];
6225
+ if (first === void 0) throw new HttpError(500, "No workspace configured");
6226
+ return first;
6227
+ }
6228
+ function findWorkspace(deps, key) {
6229
+ return deps.workspaces.find((w) => w.key === key) ?? null;
6230
+ }
6231
+ function matchWsRoute(pathname) {
6232
+ if (!pathname.startsWith(WS_PREFIX)) return null;
6233
+ const rest = pathname.slice(WS_PREFIX.length);
6234
+ const slash = rest.indexOf("/");
6235
+ if (slash <= 0) return null;
6236
+ const sub = rest.slice(slash + 1);
6237
+ if (sub.length === 0) return null;
6238
+ let key;
6239
+ try {
6240
+ key = decodeURIComponent(rest.slice(0, slash));
6241
+ } catch {
6242
+ return null;
6243
+ }
6244
+ if (key.length === 0 || key.includes("/") || key.includes("\0")) return null;
6245
+ return { key, sub };
6246
+ }
6247
+ async function portfolio(deps) {
6248
+ const nowIso = deps.nowProvider().toISOString();
6249
+ const workspaces = await Promise.all(deps.workspaces.map((ws) => portfolioCard(ws, nowIso)));
6250
+ for (let i = 0; i < deps.workspaces.length; i++) {
6251
+ const card = workspaces[i];
6252
+ const ws = deps.workspaces[i];
6253
+ if (ws !== void 0 && card !== void 0 && card.initialized === true && card.error === void 0) {
6254
+ card.staleness = await captureStaleness(ws, nowIso);
6255
+ }
6256
+ }
6257
+ return { mode: deps.mode, generatedAt: nowIso, workspaces };
6258
+ }
6259
+ async function portfolioCard(ws, nowIso) {
6260
+ const base = { key: ws.key, label: ws.label, repoRoot: ws.repoRoot };
6261
+ if (!ws.initialized) {
6262
+ return ws.manifestError !== void 0 ? { ...base, initialized: false, error: ws.manifestError } : { ...base, initialized: false };
6263
+ }
6264
+ try {
6265
+ const s = await summarizeOrientation({ paths: ws.paths, nowIso });
6266
+ return {
6267
+ ...base,
6268
+ initialized: true,
6269
+ sessionCount: s.sessionCount,
6270
+ suspectCount: s.suspects.length,
6271
+ inFlightCount: s.inFlightTasks.length,
6272
+ pendingApprovals: s.pendingApprovals.map((a) => ({
6273
+ risk: a.risk,
6274
+ kind: a.kind,
6275
+ expired: a.expired
6276
+ })),
6277
+ latestDecision: s.latestDecision !== null ? { title: s.latestDecision.title } : null,
6278
+ latestSession: s.latestSession !== null ? { label: s.latestSession.label, status: s.latestSession.status } : null,
6279
+ freshness: { newestStartedAt: s.freshness.newestStartedAt, bySource: s.freshness.bySource }
6280
+ };
6281
+ } catch (error) {
6282
+ return { ...base, initialized: true, error: pathlessMessage(error) };
6283
+ }
6284
+ }
6285
+ async function captureStaleness(ws, nowIso) {
6286
+ const probe = await probeStaleness({ ctx: ws.importCtx, paths: ws.paths, nowIso });
6287
+ return probe === null ? { checked: false } : { checked: true, ...probe };
5536
6288
  }
5537
- async function overview(deps) {
6289
+ async function overview(ws, nowProvider) {
5538
6290
  let manifest;
5539
6291
  try {
5540
- manifest = await readManifest8(deps.paths);
6292
+ manifest = await readManifest9(ws.paths);
5541
6293
  } catch (error) {
5542
- if (findErrorCode13(error, "ENOENT")) {
5543
- return { initialized: false, repoRoot: deps.repoRoot };
6294
+ if (findErrorCode15(error, "ENOENT")) {
6295
+ return { initialized: false, repoRoot: ws.repoRoot };
5544
6296
  }
5545
6297
  throw error;
5546
6298
  }
5547
- const nowIso = deps.nowProvider().toISOString();
5548
- const handoff = await renderHandoff3({ paths: deps.paths, nowIso });
5549
- const approvals = await enumerateApprovals2(deps.paths);
6299
+ const nowIso = nowProvider().toISOString();
6300
+ const handoff = await renderHandoff3({ paths: ws.paths, nowIso });
6301
+ const approvals = await enumerateApprovals2(ws.paths);
5550
6302
  return {
5551
6303
  initialized: true,
5552
- repoRoot: deps.repoRoot,
6304
+ repoRoot: ws.repoRoot,
5553
6305
  workspace: {
5554
6306
  id: manifest.workspace.id,
5555
6307
  name: manifest.workspace.name,
@@ -5567,8 +6319,8 @@ async function overview(deps) {
5567
6319
  generatedAt: nowIso
5568
6320
  };
5569
6321
  }
5570
- async function sessionsList(deps) {
5571
- const entries = await loadSessionEntries3(deps.paths, { now: deps.nowProvider() });
6322
+ async function sessionsList(ws, nowProvider) {
6323
+ const entries = await loadSessionEntries3(ws.paths, { now: nowProvider() });
5572
6324
  const sessions = entries.map((entry) => ({
5573
6325
  sessionId: entry.sessionId,
5574
6326
  label: entry.session.session.label ?? null,
@@ -5583,10 +6335,10 @@ async function sessionsList(deps) {
5583
6335
  })).reverse();
5584
6336
  return { sessions };
5585
6337
  }
5586
- async function sessionDetail(deps, sessionId) {
6338
+ async function sessionDetail(ws, sessionId) {
5587
6339
  let session;
5588
6340
  try {
5589
- session = await readSessionYaml3(deps.paths, sessionId);
6341
+ session = await readSessionYaml3(ws.paths, sessionId);
5590
6342
  } catch (error) {
5591
6343
  if (error instanceof Error && error.message === "YAML file not found") {
5592
6344
  throw new HttpError(404, "Session not found");
@@ -5594,19 +6346,19 @@ async function sessionDetail(deps, sessionId) {
5594
6346
  throw error;
5595
6347
  }
5596
6348
  try {
5597
- const events = await readAllEvents2(join8(deps.paths.sessions, sessionId));
6349
+ const events = await readAllEvents2(join10(ws.paths.sessions, sessionId));
5598
6350
  return { session, events };
5599
6351
  } catch {
5600
6352
  return { session, events: [], degraded: true };
5601
6353
  }
5602
6354
  }
5603
- async function tasksList(deps) {
5604
- const entries = await loadTaskEntries2(deps.paths);
6355
+ async function tasksList(ws) {
6356
+ const entries = await loadTaskEntries2(ws.paths);
5605
6357
  return { tasks: entries.map((entry) => entry.task).reverse() };
5606
6358
  }
5607
- async function taskDetail(deps, taskId) {
6359
+ async function taskDetail(ws, taskId) {
5608
6360
  try {
5609
- const doc = await readTaskFile2(deps.paths, taskId);
6361
+ const doc = await readTaskFile2(ws.paths, taskId);
5610
6362
  return { task: doc.task, body: doc.body };
5611
6363
  } catch (error) {
5612
6364
  if (error instanceof Error && error.message === "Task file not found") {
@@ -5615,22 +6367,22 @@ async function taskDetail(deps, taskId) {
5615
6367
  throw error;
5616
6368
  }
5617
6369
  }
5618
- async function decisionsView(deps) {
5619
- const fromDisk = await readMarkdownFile4(deps.paths.files.decisions);
6370
+ async function decisionsView(ws, nowProvider) {
6371
+ const fromDisk = await readMarkdownFile4(ws.paths.files.decisions);
5620
6372
  if (fromDisk !== null) {
5621
6373
  return { body: fromDisk, fromDisk: true };
5622
6374
  }
5623
- const nowIso = deps.nowProvider().toISOString();
5624
- const result = await renderDecisions3({ paths: deps.paths, nowIso });
6375
+ const nowIso = nowProvider().toISOString();
6376
+ const result = await renderDecisions3({ paths: ws.paths, nowIso });
5625
6377
  return { body: result.body, decisionCount: result.decisionCount, fromDisk: false };
5626
6378
  }
5627
- async function approvalsView(deps) {
5628
- const now = deps.nowProvider();
5629
- const ids = await enumerateApprovals2(deps.paths);
6379
+ async function approvalsView(ws, nowProvider) {
6380
+ const now = nowProvider();
6381
+ const ids = await enumerateApprovals2(ws.paths);
5630
6382
  const toViews = async (list) => {
5631
6383
  const views = [];
5632
6384
  for (const id of list) {
5633
- const loaded = await loadApproval2(deps.paths, id);
6385
+ const loaded = await loadApproval2(ws.paths, id);
5634
6386
  if (loaded === null) continue;
5635
6387
  views.push({ id, expired: isLazyExpired2(loaded.approval, now), approval: loaded.approval });
5636
6388
  }
@@ -5638,13 +6390,13 @@ async function approvalsView(deps) {
5638
6390
  };
5639
6391
  return { pending: await toViews(ids.pending), resolved: await toViews(ids.resolved) };
5640
6392
  }
5641
- async function handoffView(deps) {
5642
- const fromDisk = await readMarkdownFile4(deps.paths.files.handoff);
6393
+ async function handoffView(ws, nowProvider) {
6394
+ const fromDisk = await readMarkdownFile4(ws.paths.files.handoff);
5643
6395
  if (fromDisk !== null) {
5644
6396
  return { body: fromDisk, fromDisk: true };
5645
6397
  }
5646
- const nowIso = deps.nowProvider().toISOString();
5647
- const result = await renderHandoff3({ paths: deps.paths, nowIso });
6398
+ const nowIso = nowProvider().toISOString();
6399
+ const result = await renderHandoff3({ paths: ws.paths, nowIso });
5648
6400
  return { body: result.body, fromDisk: false };
5649
6401
  }
5650
6402
  function readActionOptions(body) {
@@ -5738,8 +6490,18 @@ function parsePort(value) {
5738
6490
  }
5739
6491
  return port;
5740
6492
  }
6493
+ function collectPath3(value, previous = []) {
6494
+ return [...previous, value];
6495
+ }
5741
6496
  function registerViewCommand(program) {
5742
- program.command("view").description("Open a local web UI to browse provenance and run imports (localhost only)").option("--port <number>", "Port to listen on (default 4319)", parsePort).option("--no-open", "Do not open the browser automatically").option("-v, --verbose", "Show error causes").action(async (options) => {
6497
+ program.command("view").description("Open a local web UI to browse provenance and run imports (localhost only)").option("--port <number>", "Port to listen on (default 4319)", parsePort).option("--no-open", "Do not open the browser automatically").option(
6498
+ "--portfolio",
6499
+ "Serve every workspace listed in ~/.basou/portfolio.yaml (cross-repo orientation)"
6500
+ ).option(
6501
+ "--workspace <path>",
6502
+ "Workspace repo path to include (repeatable; implies portfolio mode; resolved against the cwd)",
6503
+ collectPath3
6504
+ ).option("--check", "Run the portfolio safety preflight and exit (no server)").option("--skip-safety-check", "Skip the portfolio safety preflight on start (not recommended)").option("-v, --verbose", "Show error causes").action(async (options) => {
5743
6505
  await runView(options);
5744
6506
  });
5745
6507
  }
@@ -5753,23 +6515,37 @@ async function runView(options, ctx = {}) {
5753
6515
  }
5754
6516
  async function doRunView(options, ctx) {
5755
6517
  const cwd = ctx.cwd ?? process.cwd();
5756
- const repositoryRoot = await resolveRepositoryRootForView(cwd);
5757
- const paths = basouPaths14(repositoryRoot);
5758
- await assertWorkspaceInitialized11(paths.root);
5759
- const deps = {
5760
- paths,
5761
- repoRoot: repositoryRoot,
5762
- importCtx: {
5763
- cwd: repositoryRoot,
5764
- ...ctx.claudeProjectsDir !== void 0 ? { claudeProjectsDir: ctx.claudeProjectsDir } : {},
5765
- ...ctx.codexSessionsDir !== void 0 ? { codexSessionsDir: ctx.codexSessionsDir } : {}
5766
- },
5767
- nowProvider: ctx.nowProvider ?? (() => /* @__PURE__ */ new Date())
5768
- };
6518
+ const workspaceFlags = options.workspace ?? [];
6519
+ const isPortfolio = workspaceFlags.length > 0 || options.portfolio === true;
6520
+ const deps = isPortfolio ? await buildPortfolioDeps(workspaceFlags, ctx, cwd) : await buildSingleDeps(ctx, cwd);
6521
+ if (options.check === true) {
6522
+ const result = await checkPortfolioSafety(deps.workspaces);
6523
+ for (const line of formatSafetyReport(result)) console.log(line);
6524
+ if (result.findings.length > 0) process.exitCode = 1;
6525
+ return;
6526
+ }
6527
+ if (deps.mode === "portfolio" && options.skipSafetyCheck !== true) {
6528
+ const result = await checkPortfolioSafety(deps.workspaces);
6529
+ const blocking = result.findings.filter((f) => f.kind === "footprint" || f.kind === "overlap");
6530
+ if (blocking.length > 0) {
6531
+ for (const line of formatSafetyReport(result)) console.error(line);
6532
+ throw new Error(
6533
+ "Portfolio safety preflight failed (see findings above). Fix the monitored repos, or re-run with --skip-safety-check to override."
6534
+ );
6535
+ }
6536
+ if (result.findings.length > 0) {
6537
+ console.error(
6538
+ `Portfolio safety: ${result.findings.length} unverifiable item(s) \u2014 the read-only view will still open; run 'basou view --check' for detail.`
6539
+ );
6540
+ }
6541
+ }
5769
6542
  const port = options.port ?? DEFAULT_PORT;
5770
6543
  const handle = await startListening(port, deps);
5771
6544
  try {
5772
6545
  console.log(`basou view running at ${handle.url}`);
6546
+ if (deps.mode === "portfolio") {
6547
+ console.log(`Portfolio mode: ${deps.workspaces.length} workspace(s).`);
6548
+ }
5773
6549
  console.log(
5774
6550
  "Localhost only, no authentication. Do not expose this port beyond your machine. Press Ctrl+C to stop."
5775
6551
  );
@@ -5782,11 +6558,69 @@ async function doRunView(options, ctx) {
5782
6558
  await handle.close();
5783
6559
  }
5784
6560
  }
6561
+ async function buildSingleDeps(ctx, cwd) {
6562
+ const repositoryRoot = await resolveRepositoryRootForView(cwd);
6563
+ const paths = basouPaths16(repositoryRoot);
6564
+ await assertWorkspaceInitialized13(paths.root);
6565
+ const entry = await buildWorkspaceEntry(repositoryRoot, ctx);
6566
+ return { workspaces: [entry], mode: "single", nowProvider: nowProviderOf(ctx) };
6567
+ }
6568
+ async function buildPortfolioDeps(workspaceFlags, ctx, cwd) {
6569
+ const specs = workspaceFlags.length > 0 ? workspaceFlags.map((p) => ({ path: resolve6(cwd, p) })) : await loadPortfolioConfig(ctx.portfolioConfigPath);
6570
+ const entries = [];
6571
+ const seenPath = /* @__PURE__ */ new Set();
6572
+ const seenKey = /* @__PURE__ */ new Set();
6573
+ for (const spec of specs) {
6574
+ const repoRoot = resolve6(spec.path);
6575
+ if (seenPath.has(repoRoot)) continue;
6576
+ seenPath.add(repoRoot);
6577
+ const entry = await buildWorkspaceEntry(repoRoot, ctx, spec.label);
6578
+ let key = entry.key;
6579
+ for (let n = 1; seenKey.has(key); n++) key = `${entry.key}-${n}`;
6580
+ seenKey.add(key);
6581
+ entries.push({ ...entry, key });
6582
+ }
6583
+ if (entries.length === 0) throw new Error("No workspaces to show.");
6584
+ return { workspaces: entries, mode: "portfolio", nowProvider: nowProviderOf(ctx) };
6585
+ }
6586
+ async function buildWorkspaceEntry(repoRoot, ctx, labelOverride) {
6587
+ const paths = basouPaths16(repoRoot);
6588
+ const importCtx = {
6589
+ cwd: repoRoot,
6590
+ ...ctx.claudeProjectsDir !== void 0 ? { claudeProjectsDir: ctx.claudeProjectsDir } : {},
6591
+ ...ctx.codexSessionsDir !== void 0 ? { codexSessionsDir: ctx.codexSessionsDir } : {}
6592
+ };
6593
+ try {
6594
+ const manifest = await readManifest10(paths);
6595
+ return {
6596
+ key: manifest.workspace.id,
6597
+ label: labelOverride ?? manifest.workspace.name,
6598
+ paths,
6599
+ repoRoot,
6600
+ importCtx,
6601
+ initialized: true
6602
+ };
6603
+ } catch (error) {
6604
+ const notFound = error instanceof Error && error.message === "YAML file not found";
6605
+ return {
6606
+ key: `ws-${createHash("sha1").update(repoRoot).digest("hex").slice(0, 12)}`,
6607
+ label: labelOverride ?? basename4(repoRoot),
6608
+ paths,
6609
+ repoRoot,
6610
+ importCtx,
6611
+ initialized: false,
6612
+ ...notFound ? {} : { manifestError: "manifest unreadable or invalid" }
6613
+ };
6614
+ }
6615
+ }
6616
+ function nowProviderOf(ctx) {
6617
+ return ctx.nowProvider ?? (() => /* @__PURE__ */ new Date());
6618
+ }
5785
6619
  async function startListening(port, deps) {
5786
6620
  try {
5787
6621
  return await startViewServer({ port, deps });
5788
6622
  } catch (error) {
5789
- if (findErrorCode14(error, "EADDRINUSE")) {
6623
+ if (findErrorCode16(error, "EADDRINUSE")) {
5790
6624
  throw new Error(`Port ${port} is already in use. Pass --port <n> to choose another.`, {
5791
6625
  cause: error
5792
6626
  });
@@ -5809,7 +6643,7 @@ function openInBrowser(url, override) {
5809
6643
  }
5810
6644
  }
5811
6645
  function waitForShutdown(signal) {
5812
- return new Promise((resolve3) => {
6646
+ return new Promise((resolve7) => {
5813
6647
  const cleanup = () => {
5814
6648
  process.off("SIGINT", onSignal);
5815
6649
  process.off("SIGTERM", onSignal);
@@ -5817,18 +6651,18 @@ function waitForShutdown(signal) {
5817
6651
  };
5818
6652
  const onSignal = () => {
5819
6653
  cleanup();
5820
- resolve3();
6654
+ resolve7();
5821
6655
  };
5822
6656
  const onAbort = () => {
5823
6657
  cleanup();
5824
- resolve3();
6658
+ resolve7();
5825
6659
  };
5826
6660
  process.on("SIGINT", onSignal);
5827
6661
  process.on("SIGTERM", onSignal);
5828
6662
  if (signal !== void 0) {
5829
6663
  if (signal.aborted) {
5830
6664
  cleanup();
5831
- resolve3();
6665
+ resolve7();
5832
6666
  return;
5833
6667
  }
5834
6668
  signal.addEventListener("abort", onAbort);
@@ -5847,11 +6681,11 @@ async function resolveRepositoryRootForView(cwd) {
5847
6681
  throw error;
5848
6682
  }
5849
6683
  }
5850
- async function assertWorkspaceInitialized11(basouRoot) {
6684
+ async function assertWorkspaceInitialized13(basouRoot) {
5851
6685
  try {
5852
- await assertBasouRootSafe14(basouRoot);
6686
+ await assertBasouRootSafe16(basouRoot);
5853
6687
  } catch (error) {
5854
- if (findErrorCode14(error, "ENOENT")) {
6688
+ if (findErrorCode16(error, "ENOENT")) {
5855
6689
  throw new Error("Workspace not initialized. Run 'basou init' first.");
5856
6690
  }
5857
6691
  throw error;
@@ -5880,6 +6714,8 @@ function buildProgram() {
5880
6714
  registerTaskCommand(program);
5881
6715
  registerHandoffCommand(program);
5882
6716
  registerDecisionsCommand(program);
6717
+ registerReportCommand(program);
6718
+ registerOrientCommand(program);
5883
6719
  return program;
5884
6720
  }
5885
6721
  export {