@treeseed/core 0.8.18 → 0.9.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.
@@ -5,9 +5,11 @@ import '../../../styles/ui.css';
5
5
  import '../../../styles/forms.css';
6
6
  import '../../../styles/app-shell.css';
7
7
  import ThemeScript from '../theme/ThemeScript.astro';
8
+ import ThemeMenu from '../theme/ThemeMenu.astro';
8
9
  import RailNav from './RailNav.astro';
9
10
  import BottomNav from './BottomNav.astro';
10
11
  import TopBar from './TopBar.astro';
12
+ import ShellIconLink from './ShellIconLink.astro';
11
13
  import Button from '../forms/Button.astro';
12
14
  import DevWatchReload from '../../DevWatchReload.astro';
13
15
  import type { ButtonAction } from '../types.js';
@@ -64,11 +66,13 @@ const {
64
66
  <a class="ts-skip-link" href="#main-content">Skip to content</a>
65
67
  <div class="ts-app-shell">
66
68
  <aside class="ts-app-shell__rail">
67
- <TopBar brand={brand} />
68
- <div class="ts-app-shell__rail-context">
69
- <slot name="railContext" />
69
+ <div class="ts-app-shell__rail-scroll">
70
+ <TopBar brand={brand} />
71
+ <div class="ts-app-shell__rail-context">
72
+ <slot name="railContext" />
73
+ </div>
74
+ <RailNav items={navItems} currentPath={currentPath} />
70
75
  </div>
71
- <RailNav items={navItems} currentPath={currentPath} />
72
76
  {quickActions.length > 0 ? (
73
77
  <div class="ts-app-shell__quick-actions">
74
78
  <p class="ts-app-shell__eyebrow">Quick actions</p>
@@ -91,6 +95,10 @@ const {
91
95
  </aside>
92
96
  <main class="ts-app-shell__main" id="main-content">
93
97
  <TopBar brand={brand} class="ts-app-shell__mobile-top">
98
+ <Fragment slot="actions">
99
+ <ThemeMenu selectedScheme={appearance.scheme} selectedMode={appearance.mode} />
100
+ <ShellIconLink href="/" label="Book home" icon="book" />
101
+ </Fragment>
94
102
  </TopBar>
95
103
  <header class="ts-app-shell__header">
96
104
  <div class="ts-app-shell__title">
@@ -98,6 +106,10 @@ const {
98
106
  <p>{description}</p>
99
107
  </div>
100
108
  <div class="ts-app-shell__header-actions">
109
+ <div class="ts-shell-utility-actions">
110
+ <ThemeMenu selectedScheme={appearance.scheme} selectedMode={appearance.mode} />
111
+ <ShellIconLink href="/" label="Book home" icon="book" />
112
+ </div>
101
113
  <slot name="headerAction" />
102
114
  </div>
103
115
  </header>
@@ -19,10 +19,17 @@ const {
19
19
  class: className,
20
20
  } = Astro.props as Props;
21
21
 
22
+ function normalizePath(path: string) {
23
+ if (path.length <= 1) return path;
24
+ return path.replace(/\/+$/u, '');
25
+ }
26
+
22
27
  function isCurrentPath(href: string) {
23
- if (href === '/') return currentPath === '/';
24
- if (href.endsWith('/')) return currentPath === href || currentPath.startsWith(href);
25
- return currentPath === href || currentPath.startsWith(`${href}/`);
28
+ const current = normalizePath(currentPath);
29
+ const target = normalizePath(href);
30
+ if (target === '/') return current === '/';
31
+ if (target === '/app') return current === '/app';
32
+ return current === target || current.startsWith(`${target}/`);
26
33
  }
27
34
  ---
28
35
 
@@ -19,10 +19,17 @@ const {
19
19
  class: className,
20
20
  } = Astro.props as Props;
21
21
 
22
+ function normalizePath(path: string) {
23
+ if (path.length <= 1) return path;
24
+ return path.replace(/\/+$/u, '');
25
+ }
26
+
22
27
  function isCurrentPath(href: string) {
23
- if (href === '/') return currentPath === '/';
24
- if (href.endsWith('/')) return currentPath === href || currentPath.startsWith(href);
25
- return currentPath === href || currentPath.startsWith(`${href}/`);
28
+ const current = normalizePath(currentPath);
29
+ const target = normalizePath(href);
30
+ if (target === '/') return current === '/';
31
+ if (target === '/app') return current === '/app';
32
+ return current === target || current.startsWith(`${target}/`);
26
33
  }
27
34
  ---
28
35
 
@@ -0,0 +1,30 @@
1
+ ---
2
+ interface Props {
3
+ href: string;
4
+ label: string;
5
+ icon: 'book' | 'manager';
6
+ }
7
+
8
+ const { href, label, icon } = Astro.props as Props;
9
+ ---
10
+
11
+ <a
12
+ href={href}
13
+ aria-label={label}
14
+ title={label}
15
+ class="ts-public-shell__icon-link ts-public-shell__icon-link--stroke"
16
+ >
17
+ {icon === 'manager' ? (
18
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
19
+ <path d="M12 15.25a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5Z" />
20
+ <path d="M19.43 12.98c.04-.32.07-.65.07-.98s-.02-.66-.07-.98l2.05-1.6-2-3.46-2.42.98a7.65 7.65 0 0 0-1.7-.98L15 3.38h-4l-.36 2.58c-.6.24-1.17.57-1.7.98l-2.42-.98-2 3.46 2.05 1.6c-.04.32-.07.65-.07.98s.02.66.07.98l-2.05 1.6 2 3.46 2.42-.98c.52.41 1.09.74 1.7.98l.36 2.58h4l.36-2.58c.6-.24 1.17-.57 1.7-.98l2.42.98 2-3.46-2.05-1.6Z" />
21
+ </svg>
22
+ ) : (
23
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
24
+ <path d="M4.75 5.75A2.75 2.75 0 0 1 7.5 3h11.75v16.25H7.5a2.75 2.75 0 0 0-2.75 2.75V5.75Z" />
25
+ <path d="M7.5 19.25h11.75" />
26
+ <path d="M8.25 7.25h7.5" />
27
+ <path d="M8.25 10.75h6" />
28
+ </svg>
29
+ )}
30
+ </a>
@@ -5,6 +5,7 @@ export declare function createTreeseedTenantCollections(manifestPath?: string):
5
5
  objectives: any;
6
6
  people: any;
7
7
  agents: any;
8
+ agent_tests?: any;
8
9
  books: any;
9
10
  docs: any;
10
11
  workdays?: any;
package/dist/content.js CHANGED
@@ -2,6 +2,7 @@ import { defineCollection, reference } from "astro:content";
2
2
  import { z } from "astro/zod";
3
3
  import { glob } from "astro/loaders";
4
4
  import { existsSync, readdirSync } from "node:fs";
5
+ import { dirname, resolve } from "node:path";
5
6
  import { AGENT_CLI_ALLOW_TOOLS } from "@treeseed/sdk/types/agents";
6
7
  import { loadTreeseedPluginRuntime } from "@treeseed/sdk/platform/plugins";
7
8
  import { loadTreeseedDeployConfig } from "@treeseed/sdk/platform/deploy-config";
@@ -23,7 +24,7 @@ const statusValues = ["live", "in progress", "exploratory", "planned", "speculat
23
24
  const pageLayoutValues = ["article", "bridge"];
24
25
  const questionTypeValues = ["research", "implementation", "strategy", "evaluation"];
25
26
  const proposalTypeValues = ["strategy", "policy", "implementation", "research"];
26
- const decisionTypeValues = ["approved", "rejected", "deferred", "superseded"];
27
+ const decisionTypeValues = ["approved", "rejected", "deferred", "request_changes", "superseded"];
27
28
  const timeHorizonValues = ["near-term", "mid-term", "long-term"];
28
29
  const runtimeStatusValues = ["active", "experimental", "dormant"];
29
30
  const agentTriggerTypeValues = ["schedule", "message", "follow", "startup"];
@@ -362,6 +363,15 @@ function createTreeseedCollections(tenantConfig, { docsLoader, docsSchema }) {
362
363
  outputs: agentOutputSchema.default({}),
363
364
  governance: agentGovernanceSchema.optional()
364
365
  }));
366
+ const agentTestSchema = z.object({
367
+ id: z.string(),
368
+ agent: z.string(),
369
+ kind: z.enum(["spec", "handler", "message_chain", "manager_worker", "workday", "api", "ui"]),
370
+ fixture: z.string().optional(),
371
+ trigger: z.record(z.any()).default({}),
372
+ expect: z.record(z.any()).default({}),
373
+ tags: z.array(z.string()).default([])
374
+ });
365
375
  const bookSchema = z.preprocess((value) => preprocessAliasedRecord(bookFieldAliases, value), z.object({
366
376
  order: z.number().int().nonnegative(),
367
377
  slug: z.string(),
@@ -450,7 +460,7 @@ function createTreeseedCollections(tenantConfig, { docsLoader, docsSchema }) {
450
460
  completedAt: z.coerce.date().optional(),
451
461
  lastErrorCode: z.string().nullable().optional(),
452
462
  lastErrorMessage: z.string().nullable().optional(),
453
- lastEventKind: z.string().optional(),
463
+ lastEventKind: z.string().nullable().optional(),
454
464
  outputCount: z.number().int().optional(),
455
465
  changedFiles: z.array(z.string()).default([])
456
466
  });
@@ -528,6 +538,13 @@ function createTreeseedCollections(tenantConfig, { docsLoader, docsSchema }) {
528
538
  schema: docsCollectionProvider.schema
529
539
  })
530
540
  };
541
+ const agentTestsRoot = resolve(dirname(tenantConfig.content.agents), "agent-tests");
542
+ if (existsSync(agentTestsRoot)) {
543
+ collections.agent_tests = defineCollection({
544
+ loader: optionalMarkdownGlob(agentTestsRoot),
545
+ schema: agentTestSchema
546
+ });
547
+ }
531
548
  if (tenantConfig.content.workdays) {
532
549
  collections.workdays = defineCollection({
533
550
  loader: optionalMarkdownGlob(tenantConfig.content.workdays),
package/dist/dev.d.ts CHANGED
@@ -32,11 +32,13 @@ export type TreeseedIntegratedDevOptions = {
32
32
  webPort?: number;
33
33
  apiHost?: string;
34
34
  apiPort?: number;
35
+ webRuntime?: TreeseedLocalRuntimeMode;
35
36
  setupMode?: TreeseedIntegratedDevSetupMode;
36
37
  feedbackMode?: TreeseedIntegratedDevFeedbackMode;
37
38
  openMode?: TreeseedIntegratedDevOpenMode;
38
39
  plan?: boolean;
39
40
  reset?: boolean;
41
+ force?: boolean;
40
42
  json?: boolean;
41
43
  includeServices?: boolean;
42
44
  projectId?: string;
@@ -97,6 +99,7 @@ export type TreeseedIntegratedDevPlan = {
97
99
  readyChecks: TreeseedIntegratedDevReadinessCheck[];
98
100
  watchEntries: TreeseedIntegratedDevWatchEntry[];
99
101
  commands: TreeseedIntegratedDevCommand[];
102
+ logPath: string;
100
103
  localRuntimes: Record<string, TreeseedLocalRuntimeSelection>;
101
104
  restartPolicy: {
102
105
  initialBackoffMs: number;
@@ -127,6 +130,13 @@ type TreeseedIntegratedDevDependencies = {
127
130
  startWatch: WatchStarter;
128
131
  removePath: (path: string) => void;
129
132
  stopMailpitContainers: () => boolean;
133
+ inspectPortOwners: (ports: readonly number[]) => TreeseedDevPortOwner[];
134
+ };
135
+ export type TreeseedDevPortOwner = {
136
+ port: number;
137
+ pid: number | null;
138
+ processName?: string;
139
+ detail: string;
130
140
  };
131
141
  export declare function createTreeseedIntegratedDevResetPlan(options: {
132
142
  tenantRoot: string;
package/dist/dev.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { spawn, spawnSync } from "node:child_process";
3
3
  import { createRequire } from "node:module";
4
4
  import { dirname, isAbsolute, resolve, sep } from "node:path";
@@ -32,7 +32,8 @@ const TREESEED_DEFAULT_LOCAL_SMTP_HOST = "127.0.0.1";
32
32
  const TREESEED_DEFAULT_LOCAL_SMTP_PORT = 1025;
33
33
  const TREESEED_DEFAULT_MAILPIT_UI_PORT = 8025;
34
34
  const DEV_RELOAD_FILE = "public/__treeseed/dev-reload.json";
35
- const DEV_RUNTIME_FILE = ".treeseed/generated/dev/runtime.json";
35
+ const DEV_RUNTIME_DIR = ".treeseed/generated/dev";
36
+ const DEV_RUNTIME_LEGACY_FILE = ".treeseed/generated/dev/runtime.json";
36
37
  const DEFAULT_READINESS_TIMEOUT_MS = 9e4;
37
38
  const DEFAULT_SETUP_STEP_TIMEOUT_MS = 3e5;
38
39
  const DEFAULT_PROCESS_READY_GRACE_MS = 1200;
@@ -132,10 +133,10 @@ function fallbackWebProviderFromDeployConfig(deployConfig) {
132
133
  const record = deployConfig && typeof deployConfig === "object" ? deployConfig : {};
133
134
  return normalizeProvider(record.providers?.deploy, "local");
134
135
  }
135
- function selectWebLocalRuntime(surfaceConfig, providerFallback = "local") {
136
+ function selectWebLocalRuntime(surfaceConfig, providerFallback = "local", overrideRuntime) {
136
137
  const record = surfaceConfig && typeof surfaceConfig === "object" ? surfaceConfig : {};
137
138
  const provider = normalizeProvider(record.provider, providerFallback);
138
- const requested = normalizeLocalRuntimeMode(record.local?.runtime);
139
+ const requested = overrideRuntime ?? normalizeLocalRuntimeMode(record.local?.runtime);
139
140
  if (provider === "cloudflare" && requested !== "local") {
140
141
  return {
141
142
  requested,
@@ -151,7 +152,7 @@ function selectWebLocalRuntime(surfaceConfig, providerFallback = "local") {
151
152
  requested,
152
153
  provider,
153
154
  selected: "astro-local",
154
- reason: requested === "local" ? "Configured to use the full local Astro runtime." : `Provider "${provider}" has no provider-local web runtime; using Astro local.`
155
+ reason: overrideRuntime === "local" ? "CLI override selected the full local Astro runtime for faster UI development." : requested === "local" ? "Configured to use the full local Astro runtime." : `Provider "${provider}" has no provider-local web runtime; using Astro local.`
155
156
  };
156
157
  }
157
158
  function loadDevDeployConfig(tenantRoot) {
@@ -271,6 +272,30 @@ function resolveSeededLocalProjectId(persistTo, projectSlug = "market") {
271
272
  db?.close();
272
273
  }
273
274
  }
275
+ function resolveSeededLocalTeamId(persistTo, projectId, teamSlug = "treeseed") {
276
+ const sqlitePath = resolveLocalD1SqlitePath(persistTo);
277
+ if (!sqlitePath) return null;
278
+ let db = null;
279
+ try {
280
+ db = new DatabaseSync(sqlitePath, { readOnly: true });
281
+ if (projectId) {
282
+ const projectRow = db.prepare(
283
+ `SELECT team_id FROM projects WHERE id = ? LIMIT 1`
284
+ ).get(projectId);
285
+ if (typeof projectRow?.team_id === "string" && projectRow.team_id.trim()) {
286
+ return projectRow.team_id.trim();
287
+ }
288
+ }
289
+ const teamRow = db.prepare(
290
+ `SELECT id FROM teams WHERE LOWER(slug) = LOWER(?) ORDER BY created_at ASC LIMIT 1`
291
+ ).get(teamSlug);
292
+ return typeof teamRow?.id === "string" && teamRow.id.trim() ? teamRow.id.trim() : null;
293
+ } catch {
294
+ return null;
295
+ } finally {
296
+ db?.close();
297
+ }
298
+ }
274
299
  function createTreeseedIntegratedDevResetPlan(options) {
275
300
  if (!options.enabled) {
276
301
  return null;
@@ -529,10 +554,13 @@ function createTreeseedIntegratedDevPlan(options = {}) {
529
554
  const agentPackageRoot = resolvePackageRootEnvOverride(mergedEnv, "TREESEED_AGENT_PACKAGE_ROOT", tenantRoot) ?? resolveOptionalPackageRoot("@treeseed/agent", tenantRoot);
530
555
  const cliPackageRoot = resolveOptionalPackageRoot("@treeseed/cli", tenantRoot);
531
556
  const deployConfig = loadDevDeployConfig(tenantRoot);
532
- const webLocalRuntime = selectWebLocalRuntime(deployConfig?.surfaces?.web, fallbackWebProviderFromDeployConfig(deployConfig));
557
+ const webLocalRuntime = selectWebLocalRuntime(deployConfig?.surfaces?.web, fallbackWebProviderFromDeployConfig(deployConfig), options.webRuntime);
533
558
  const usesCloudflareWebRuntime = webLocalRuntime.selected === "cloudflare-wrangler-local";
534
- const localD1PersistTo = mergedEnv.TREESEED_API_D1_LOCAL_PERSIST_TO ?? (usesCloudflareWebRuntime ? resolve(tenantRoot, ".treeseed", "generated", "environments", "local", ".wrangler", "state", "v3", "d1") : resolve(tenantRoot, ".wrangler", "state", "v3", "d1"));
559
+ const usesGeneratedLocalD1State = usesCloudflareWebRuntime || webLocalRuntime.provider === "cloudflare" || selectedCommandIds.some((id) => id !== "web");
560
+ const localD1PersistTo = mergedEnv.TREESEED_API_D1_LOCAL_PERSIST_TO ?? (usesGeneratedLocalD1State ? resolve(tenantRoot, ".treeseed", "generated", "environments", "local", ".wrangler", "state", "v3", "d1") : resolve(tenantRoot, ".wrangler", "state", "v3", "d1"));
535
561
  const projectId = options.projectId ?? mergedEnv.TREESEED_PROJECT_ID ?? resolveSeededLocalProjectId(localD1PersistTo);
562
+ const resolvedHostingTeamId = teamId ?? mergedEnv.TREESEED_HOSTING_TEAM_ID;
563
+ const resolvedTeamId = mergedEnv.TREESEED_TEAM_ID ?? resolvedHostingTeamId ?? resolveSeededLocalTeamId(localD1PersistTo, projectId ?? null);
536
564
  const webEntrypoint = resolveNodeEntrypoint(
537
565
  sdkPackageRoot,
538
566
  "scripts/tenant-astro-command.ts",
@@ -557,13 +585,14 @@ function createTreeseedIntegratedDevPlan(options = {}) {
557
585
  const resetRequested = options.reset === true;
558
586
  const sharedEnv = {
559
587
  ...mergedEnv,
560
- TREESEED_LOCAL_DEV_MODE: mergedEnv.TREESEED_LOCAL_DEV_MODE ?? "cloudflare",
588
+ TREESEED_LOCAL_DEV_MODE: usesCloudflareWebRuntime ? mergedEnv.TREESEED_LOCAL_DEV_MODE ?? "cloudflare" : void 0,
561
589
  TREESEED_SITE_URL: mergedEnv.TREESEED_SITE_URL ?? webUrl,
562
590
  BETTER_AUTH_URL: mergedEnv.BETTER_AUTH_URL ?? webUrl,
563
591
  TREESEED_API_BASE_URL: apiBaseUrl,
564
592
  TREESEED_MARKET_API_BASE_URL: mergedEnv.TREESEED_MARKET_API_BASE_URL ?? apiBaseUrl,
565
593
  TREESEED_PROJECT_ID: projectId ?? mergedEnv.TREESEED_PROJECT_ID,
566
- TREESEED_HOSTING_TEAM_ID: teamId ?? mergedEnv.TREESEED_HOSTING_TEAM_ID,
594
+ TREESEED_TEAM_ID: resolvedTeamId ?? mergedEnv.TREESEED_TEAM_ID,
595
+ TREESEED_HOSTING_TEAM_ID: resolvedHostingTeamId ?? mergedEnv.TREESEED_HOSTING_TEAM_ID,
567
596
  TREESEED_API_D1_DATABASE_NAME: mergedEnv.TREESEED_API_D1_DATABASE_NAME ?? "SITE_DATA_DB",
568
597
  SITE_DATA_DB: mergedEnv.SITE_DATA_DB ?? "SITE_DATA_DB",
569
598
  TREESEED_API_D1_LOCAL_PERSIST_TO: localD1PersistTo,
@@ -645,6 +674,7 @@ function createTreeseedIntegratedDevPlan(options = {}) {
645
674
  readyChecks,
646
675
  watchEntries,
647
676
  commands,
677
+ logPath: resolve(tenantRoot, ".treeseed", "logs", `dev-${runtimeScopeKey(commands.map((command) => command.id))}.jsonl`),
648
678
  localRuntimes: {
649
679
  ...commands.some((command) => command.id === "web") ? { web: webLocalRuntime } : {},
650
680
  ...commands.some((command) => command.id === "api") ? { api: nodeLocalRuntime("Treeseed API") } : {},
@@ -686,6 +716,29 @@ function defaultProcessIsAlive(pid) {
686
716
  return false;
687
717
  }
688
718
  }
719
+ function defaultInspectPortOwners(ports) {
720
+ const uniquePorts = [...new Set(ports.filter((port) => Number.isInteger(port) && port > 0))];
721
+ if (uniquePorts.length === 0) return [];
722
+ const result = spawnSync("ss", ["-ltnp"], { encoding: "utf8" });
723
+ if ((result.status ?? 1) !== 0) return [];
724
+ const lines = String(result.stdout ?? "").split(/\r?\n/u);
725
+ const owners = [];
726
+ for (const port of uniquePorts) {
727
+ const portPattern = new RegExp(`:${port}\\b`, "u");
728
+ for (const line of lines) {
729
+ if (!portPattern.test(line)) continue;
730
+ const pidMatch = line.match(/pid=(\d+)/u);
731
+ const nameMatch = line.match(/users:\(\("([^"]+)"/u);
732
+ owners.push({
733
+ port,
734
+ pid: pidMatch ? Number(pidMatch[1]) : null,
735
+ processName: nameMatch?.[1],
736
+ detail: line.trim()
737
+ });
738
+ }
739
+ }
740
+ return owners;
741
+ }
689
742
  function defaultRemovePath(path) {
690
743
  rmSync(path, { recursive: true, force: true });
691
744
  }
@@ -782,44 +835,131 @@ function resolveLocalMachineEnv(tenantRoot) {
782
835
  return {};
783
836
  }
784
837
  }
785
- function devRuntimeStatePath(tenantRoot) {
786
- return resolve(tenantRoot, DEV_RUNTIME_FILE);
838
+ function devRuntimeStateDir(tenantRoot) {
839
+ return resolve(tenantRoot, DEV_RUNTIME_DIR);
840
+ }
841
+ function devRuntimeStatePath(tenantRoot, key) {
842
+ return resolve(devRuntimeStateDir(tenantRoot), `runtime-${key}.json`);
843
+ }
844
+ function legacyDevRuntimeStatePath(tenantRoot) {
845
+ return resolve(tenantRoot, DEV_RUNTIME_LEGACY_FILE);
787
846
  }
788
- function readDevRuntimeState(tenantRoot) {
847
+ function runtimeScopeKey(commandIds) {
848
+ const selected = CANONICAL_COMMAND_IDS.filter((id) => commandIds.includes(id));
849
+ return selected.length > 0 ? selected.join("-") : "integrated";
850
+ }
851
+ function readDevRuntimeStateFile(path) {
789
852
  try {
790
- const parsed = JSON.parse(readFileSync(devRuntimeStatePath(tenantRoot), "utf8"));
853
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
791
854
  if (!Number.isInteger(parsed.pid) || typeof parsed.tenantRoot !== "string" || typeof parsed.startedAt !== "string") {
792
855
  return null;
793
856
  }
857
+ const commandIds = Array.isArray(parsed.commandIds) ? parsed.commandIds.filter((id) => CANONICAL_COMMAND_IDS.includes(id)) : void 0;
794
858
  return {
795
859
  pid: parsed.pid,
796
860
  tenantRoot: parsed.tenantRoot,
797
- startedAt: parsed.startedAt
861
+ startedAt: parsed.startedAt,
862
+ ...commandIds ? { commandIds } : {},
863
+ statePath: path
798
864
  };
799
865
  } catch {
800
866
  return null;
801
867
  }
802
868
  }
803
- function writeCurrentDevRuntimeState(tenantRoot) {
804
- const outputPath = devRuntimeStatePath(tenantRoot);
869
+ function listDevRuntimeStates(tenantRoot) {
870
+ const states = [];
871
+ const legacy = readDevRuntimeStateFile(legacyDevRuntimeStatePath(tenantRoot));
872
+ if (legacy) {
873
+ states.push(legacy);
874
+ }
875
+ try {
876
+ for (const entry of readdirSync(devRuntimeStateDir(tenantRoot))) {
877
+ if (!entry.startsWith("runtime-") || !entry.endsWith(".json")) {
878
+ continue;
879
+ }
880
+ const state = readDevRuntimeStateFile(resolve(devRuntimeStateDir(tenantRoot), entry));
881
+ if (state) {
882
+ states.push(state);
883
+ }
884
+ }
885
+ } catch {
886
+ }
887
+ return states;
888
+ }
889
+ function runtimeStateOverlaps(state, commandIds) {
890
+ if (!state.commandIds || state.commandIds.length === 0) {
891
+ return true;
892
+ }
893
+ return state.commandIds.some((id) => commandIds.includes(id));
894
+ }
895
+ function listLiveOverlappingDevRuntimeStates(tenantRoot, commandIds, processIsAlive) {
896
+ const live = [];
897
+ for (const state of listDevRuntimeStates(tenantRoot)) {
898
+ const statePath = state.statePath;
899
+ if (!statePath || !runtimeStateOverlaps(state, commandIds)) {
900
+ continue;
901
+ }
902
+ if (state.pid === process.pid) {
903
+ continue;
904
+ }
905
+ if (!processIsAlive(state.pid)) {
906
+ rmSync(statePath, { force: true });
907
+ continue;
908
+ }
909
+ live.push(state);
910
+ }
911
+ return live;
912
+ }
913
+ function parsePortFromUrl(value) {
914
+ if (!value) return null;
915
+ try {
916
+ const url = new URL(value);
917
+ const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
918
+ return Number.isInteger(port) && port > 0 ? port : null;
919
+ } catch {
920
+ return null;
921
+ }
922
+ }
923
+ function requiredDevPorts(plan) {
924
+ const ports = [];
925
+ for (const command of plan.commands) {
926
+ if (command.id === "web") {
927
+ const port = parsePortFromUrl(plan.webUrl ?? void 0);
928
+ if (port) ports.push(port);
929
+ }
930
+ if (command.id === "api") {
931
+ const port = parsePortFromUrl(plan.apiBaseUrl);
932
+ if (port) ports.push(port);
933
+ }
934
+ }
935
+ return [...new Set(ports)];
936
+ }
937
+ function formatPortOwner(owner) {
938
+ return `port ${owner.port}${owner.pid ? ` pid ${owner.pid}` : ""}${owner.processName ? ` (${owner.processName})` : ""}`;
939
+ }
940
+ function writeCurrentDevRuntimeState(tenantRoot, commandIds) {
941
+ const outputPath = devRuntimeStatePath(tenantRoot, runtimeScopeKey(commandIds));
805
942
  mkdirSync(dirname(outputPath), { recursive: true });
806
943
  writeFileSync(
807
944
  outputPath,
808
945
  `${JSON.stringify({
809
946
  pid: process.pid,
810
947
  tenantRoot,
948
+ commandIds,
811
949
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
812
950
  }, null, 2)}
813
951
  `,
814
952
  "utf8"
815
953
  );
954
+ return outputPath;
816
955
  }
817
- function removeCurrentDevRuntimeState(tenantRoot) {
818
- const state = readDevRuntimeState(tenantRoot);
956
+ function removeCurrentDevRuntimeState(tenantRoot, commandIds) {
957
+ const statePath = devRuntimeStatePath(tenantRoot, runtimeScopeKey(commandIds));
958
+ const state = readDevRuntimeStateFile(statePath);
819
959
  if (!state || state.pid !== process.pid) {
820
960
  return;
821
961
  }
822
- rmSync(devRuntimeStatePath(tenantRoot), { force: true });
962
+ rmSync(statePath, { force: true });
823
963
  }
824
964
  async function waitForProcessExit(pid, processIsAlive, timeoutMs) {
825
965
  const startedAt = Date.now();
@@ -831,38 +971,106 @@ async function waitForProcessExit(pid, processIsAlive, timeoutMs) {
831
971
  }
832
972
  return !processIsAlive(pid);
833
973
  }
834
- async function stopPreviousDevRuntime(tenantRoot, options, deps) {
835
- const state = readDevRuntimeState(tenantRoot);
836
- if (!state) {
837
- return;
838
- }
839
- const statePath = devRuntimeStatePath(tenantRoot);
840
- if (state.pid === process.pid) {
841
- return;
842
- }
843
- if (!deps.processIsAlive(state.pid)) {
974
+ async function stopPreviousDevRuntimes(tenantRoot, commandIds, options, deps) {
975
+ for (const state of listLiveOverlappingDevRuntimeStates(tenantRoot, commandIds, deps.processIsAlive)) {
976
+ const statePath = state.statePath;
977
+ if (!statePath) continue;
978
+ emitEvent(options, deps.write, {
979
+ type: "replace",
980
+ message: `Stopping previous Treeseed dev runtime (${state.pid}) before starting overlapping surfaces.`,
981
+ detail: { pid: state.pid, startedAt: state.startedAt, commandIds: state.commandIds ?? null }
982
+ });
983
+ try {
984
+ deps.killProcess(state.pid, "SIGTERM");
985
+ } catch {
986
+ }
987
+ if (await waitForProcessExit(state.pid, deps.processIsAlive, options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS)) {
988
+ rmSync(statePath, { force: true });
989
+ continue;
990
+ }
991
+ try {
992
+ deps.killProcess(state.pid, "SIGKILL");
993
+ } catch {
994
+ }
995
+ await waitForProcessExit(state.pid, deps.processIsAlive, DEFAULT_KILL_GRACE_MS);
844
996
  rmSync(statePath, { force: true });
845
- return;
846
997
  }
847
- emitEvent(options, deps.write, {
848
- type: "replace",
849
- message: `Stopping previous Treeseed dev runtime (${state.pid}) before starting a new one.`,
850
- detail: { pid: state.pid, startedAt: state.startedAt }
851
- });
852
- try {
853
- deps.killProcess(state.pid, "SIGTERM");
854
- } catch {
998
+ }
999
+ async function stopPortOwners(owners, options, deps) {
1000
+ const pids = [...new Set(owners.map((owner) => owner.pid).filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid))];
1001
+ for (const pid of pids) {
1002
+ emitEvent(options, deps.write, {
1003
+ type: "replace",
1004
+ message: `Stopping service on required dev port (pid ${pid}).`,
1005
+ detail: owners.filter((owner) => owner.pid === pid)
1006
+ });
1007
+ try {
1008
+ deps.killProcess(pid, "SIGTERM");
1009
+ } catch {
1010
+ }
1011
+ if (await waitForProcessExit(pid, deps.processIsAlive, options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS)) {
1012
+ continue;
1013
+ }
1014
+ try {
1015
+ deps.killProcess(pid, "SIGKILL");
1016
+ } catch {
1017
+ }
1018
+ await waitForProcessExit(pid, deps.processIsAlive, DEFAULT_KILL_GRACE_MS);
855
1019
  }
856
- if (await waitForProcessExit(state.pid, deps.processIsAlive, options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS)) {
857
- rmSync(statePath, { force: true });
858
- return;
1020
+ }
1021
+ async function prepareDevRuntimeSlots(plan, options, deps) {
1022
+ const commandIds = plan.commands.map((command) => command.id);
1023
+ const liveRuntimeStates = listLiveOverlappingDevRuntimeStates(plan.tenantRoot, commandIds, deps.processIsAlive);
1024
+ const ports = requiredDevPorts(plan);
1025
+ const portOwners = deps.inspectPortOwners(ports).filter((owner) => owner.pid !== process.pid);
1026
+ if (options.force !== true) {
1027
+ if (liveRuntimeStates.length > 0 || portOwners.length > 0) {
1028
+ emitEvent(options, deps.write, {
1029
+ type: "error",
1030
+ status: "existing-service",
1031
+ message: [
1032
+ "Treeseed dev found an existing runtime or service on a required port.",
1033
+ "Stop it first, or rerun with --force to terminate overlapping Treeseed dev services and port owners."
1034
+ ].join(" "),
1035
+ detail: {
1036
+ runtimes: liveRuntimeStates.map((state) => ({
1037
+ pid: state.pid,
1038
+ startedAt: state.startedAt,
1039
+ commandIds: state.commandIds ?? null,
1040
+ statePath: state.statePath ?? null
1041
+ })),
1042
+ ports: portOwners.map((owner) => ({ ...owner, label: formatPortOwner(owner) }))
1043
+ }
1044
+ });
1045
+ return false;
1046
+ }
1047
+ return true;
859
1048
  }
860
- try {
861
- deps.killProcess(state.pid, "SIGKILL");
862
- } catch {
1049
+ await stopPreviousDevRuntimes(plan.tenantRoot, commandIds, options, deps);
1050
+ if (portOwners.length > 0) {
1051
+ const ownersWithoutPid = portOwners.filter((owner) => owner.pid == null);
1052
+ if (ownersWithoutPid.length > 0) {
1053
+ emitEvent(options, deps.write, {
1054
+ type: "error",
1055
+ status: "existing-service",
1056
+ message: `Cannot force-stop required dev ports because some listeners did not expose process ids: ${ownersWithoutPid.map(formatPortOwner).join(", ")}.`,
1057
+ detail: ownersWithoutPid
1058
+ });
1059
+ return false;
1060
+ }
1061
+ await stopPortOwners(portOwners, options, deps);
863
1062
  }
864
- await waitForProcessExit(state.pid, deps.processIsAlive, DEFAULT_KILL_GRACE_MS);
865
- rmSync(statePath, { force: true });
1063
+ const remainingPortOwners = deps.inspectPortOwners(ports).filter((owner) => owner.pid !== process.pid);
1064
+ if (remainingPortOwners.length > 0) {
1065
+ emitEvent(options, deps.write, {
1066
+ type: "error",
1067
+ status: "existing-service",
1068
+ message: `Required dev ports are still occupied after --force: ${remainingPortOwners.map(formatPortOwner).join(", ")}.`,
1069
+ detail: remainingPortOwners
1070
+ });
1071
+ return false;
1072
+ }
1073
+ return true;
866
1074
  }
867
1075
  function emitEvent(options, write, event, stream = event.type === "error" ? "stderr" : "stdout") {
868
1076
  if (options.json) {
@@ -875,6 +1083,20 @@ function emitEvent(options, write, event, stream = event.type === "error" ? "std
875
1083
  write(`${surface} ${String(message)}
876
1084
  `, stream);
877
1085
  }
1086
+ function createDevLogWrite(baseWrite, logPath) {
1087
+ mkdirSync(dirname(logPath), { recursive: true });
1088
+ appendFileSync(logPath, `${JSON.stringify({
1089
+ schemaVersion: 1,
1090
+ kind: "treeseed.dev.log",
1091
+ type: "start",
1092
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1093
+ })}
1094
+ `, "utf8");
1095
+ return (line, stream) => {
1096
+ baseWrite(line, stream);
1097
+ appendFileSync(logPath, line, "utf8");
1098
+ };
1099
+ }
878
1100
  function runTreeseedIntegratedDevReset(reset, options, deps) {
879
1101
  if (!reset?.enabled) {
880
1102
  return null;
@@ -979,6 +1201,8 @@ function writePlan(plan, options, write) {
979
1201
  `, "stdout");
980
1202
  }
981
1203
  write(`api: ${plan.apiBaseUrl}
1204
+ `, "stdout");
1205
+ write(`log: ${plan.logPath}
982
1206
  `, "stdout");
983
1207
  for (const [name, runtime] of Object.entries(plan.localRuntimes)) {
984
1208
  write(`runtime ${name}: ${runtime.selected} (${runtime.provider}, requested ${runtime.requested})${runtime.reason ? ` - ${runtime.reason}` : ""}
@@ -1216,7 +1440,7 @@ function failedSetupMessage(failed) {
1216
1440
  }
1217
1441
  async function runTreeseedIntegratedDev(options = {}, deps = {}) {
1218
1442
  const tenantRoot = resolve(options.cwd ?? process.cwd());
1219
- const write = deps.write ?? defaultWrite;
1443
+ let write = deps.write ?? defaultWrite;
1220
1444
  const spawnProcess = deps.spawn ?? spawn;
1221
1445
  const spawnSyncProcess = deps.spawnSync ?? spawnSync;
1222
1446
  const onSignal = deps.onSignal ?? defaultSignalRegistrar;
@@ -1228,6 +1452,7 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
1228
1452
  const prepareEnvironment = deps.prepareEnvironment ?? defaultPrepareEnvironment;
1229
1453
  const removePath = deps.removePath ?? defaultRemovePath;
1230
1454
  const stopMailpit = deps.stopMailpitContainers ?? stopKnownMailpitContainers;
1455
+ const inspectPortOwners = deps.inspectPortOwners ?? defaultInspectPortOwners;
1231
1456
  prepareEnvironment(tenantRoot);
1232
1457
  const plan = createTreeseedIntegratedDevPlan({
1233
1458
  ...options,
@@ -1241,8 +1466,15 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
1241
1466
  writePlan(plan, options, write);
1242
1467
  return 0;
1243
1468
  }
1244
- if (readDevRuntimeState(tenantRoot)) {
1245
- await stopPreviousDevRuntime(tenantRoot, options, { write, killProcess, processIsAlive });
1469
+ const commandIds = plan.commands.map((command) => command.id);
1470
+ write = createDevLogWrite(write, plan.logPath);
1471
+ emitEvent(options, write, {
1472
+ type: "log",
1473
+ message: `Writing Treeseed dev logs to ${plan.logPath}.`,
1474
+ detail: { logPath: plan.logPath }
1475
+ });
1476
+ if (!await prepareDevRuntimeSlots(plan, options, { write, killProcess, processIsAlive, inspectPortOwners })) {
1477
+ return 1;
1246
1478
  }
1247
1479
  const resetResults = runTreeseedIntegratedDevReset(plan.reset, options, {
1248
1480
  write,
@@ -1258,7 +1490,7 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
1258
1490
  });
1259
1491
  return 1;
1260
1492
  }
1261
- writeCurrentDevRuntimeState(tenantRoot);
1493
+ writeCurrentDevRuntimeState(tenantRoot, commandIds);
1262
1494
  const children = /* @__PURE__ */ new Map();
1263
1495
  const commandsById = new Map(plan.commands.map((command) => [command.id, command]));
1264
1496
  const requiredSurfaceIds = new Set(plan.readyChecks.filter((check) => check.required).map((check) => check.id));
@@ -1311,7 +1543,7 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
1311
1543
  for (const dispose of disposers) {
1312
1544
  dispose();
1313
1545
  }
1314
- removeCurrentDevRuntimeState(tenantRoot);
1546
+ removeCurrentDevRuntimeState(tenantRoot, commandIds);
1315
1547
  emitEvent(
1316
1548
  options,
1317
1549
  write,
@@ -9,16 +9,53 @@ type LocalContributor = {
9
9
  };
10
10
  };
11
11
 
12
+ type MetadataItem = {
13
+ label: string;
14
+ value?: unknown;
15
+ href?: string;
16
+ };
17
+
12
18
  function entryTitle(entry: RuntimeReferenceEntry) {
13
19
  return entry.data.title ?? entry.data.name ?? entry.id;
14
20
  }
15
21
 
22
+ function displayValue(value: unknown) {
23
+ if (value instanceof Date) return value.toISOString().slice(0, 10);
24
+ if (Array.isArray(value)) return value.map((entry) => displayValue(entry)).filter(Boolean).join(', ');
25
+ if (typeof value === 'string') return value.trim();
26
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
27
+ return '';
28
+ }
29
+
30
+ function metadataItem(label: string, value: unknown, href?: string): MetadataItem | null {
31
+ const display = displayValue(value);
32
+ return display ? { label, value: display, href } : null;
33
+ }
34
+
35
+ function metadataKey(item: MetadataItem) {
36
+ return `${item.label.toLowerCase()}::${displayValue(item.value).toLowerCase()}::${item.href ?? ''}`;
37
+ }
38
+
39
+ function uniqueMetadata(items: MetadataItem[]) {
40
+ const seen = new Set<string>();
41
+ return items.filter((item) => {
42
+ const key = metadataKey(item);
43
+ if (seen.has(key)) return false;
44
+ seen.add(key);
45
+ return true;
46
+ });
47
+ }
48
+
16
49
  const {
17
50
  entry,
18
51
  currentPath,
19
52
  contributor,
53
+ contentTypeLabel = 'Content',
54
+ contentId,
55
+ collectionLabel,
20
56
  metaLabel,
21
57
  metaValue,
58
+ metadataItems = [],
22
59
  relatedQuestions = [],
23
60
  relatedObjectives = [],
24
61
  relatedNotes = [],
@@ -35,11 +72,17 @@ const {
35
72
  date: Date;
36
73
  motivation?: string;
37
74
  tags: string[];
75
+ canonicalRoute?: string;
76
+ author?: string;
38
77
  };
39
78
  currentPath: string;
40
79
  contributor: LocalContributor | RuntimeReferenceEntry | undefined | null;
80
+ contentTypeLabel?: string;
81
+ contentId?: string;
82
+ collectionLabel?: string;
41
83
  metaLabel?: string;
42
84
  metaValue?: string;
85
+ metadataItems?: MetadataItem[];
43
86
  relatedQuestions?: RuntimeReferenceEntry[];
44
87
  relatedObjectives?: RuntimeReferenceEntry[];
45
88
  relatedNotes?: RuntimeReferenceEntry[];
@@ -48,88 +91,105 @@ const {
48
91
  relatedBooks?: RuntimeReferenceEntry[];
49
92
  introText?: string;
50
93
  };
94
+
95
+ const contributorName = contributor?.data.name ?? entry.author ?? '';
96
+ const normalizedTypeLabel = contentTypeLabel.trim() || 'Content';
97
+ const contentKind = displayValue(metaValue);
98
+ const recordMetadata = uniqueMetadata([
99
+ metadataItem('ID', contentId),
100
+ metadataItem('Collection', collectionLabel ?? (currentPath.replaceAll('/', '') || undefined)),
101
+ metadataItem(metaLabel ?? '', metaValue),
102
+ metadataItem('Route', entry.canonicalRoute, entry.canonicalRoute),
103
+ ...metadataItems.map((item) => metadataItem(item.label, item.value, item.href)).filter(Boolean),
104
+ ].filter(Boolean) as MetadataItem[]);
105
+
106
+ const relationSections = [
107
+ { label: 'Questions', hrefBase: '/questions', items: relatedQuestions },
108
+ { label: 'Objectives', hrefBase: '/objectives', items: relatedObjectives },
109
+ { label: 'Notes', hrefBase: '/notes', items: relatedNotes },
110
+ { label: 'Proposals', hrefBase: '/proposals', items: relatedProposals },
111
+ { label: 'Decisions', hrefBase: '/decisions', items: relatedDecisions },
112
+ { label: 'Books', hrefBase: '/books', items: relatedBooks },
113
+ ].filter((section) => section.items.length > 0);
114
+
115
+ const relationCount = relationSections.reduce((total, section) => total + section.items.length, 0);
116
+ const plainIntroText = displayValue(introText ?? entry.motivation);
51
117
  ---
52
118
 
53
119
  <MainLayout title={entry.title} description={entry.description} currentPath={currentPath}>
54
- <article class="max-w-4xl space-y-8">
55
- <div class="space-y-4 border-b border-[color:var(--ts-color-border)] pb-8">
56
- <div class="flex flex-wrap items-center gap-3">
57
- <StatusBadge status={entry.status} />
58
- <p class="text-sm font-medium text-[color:var(--ts-color-text-subtle)]">{entry.date.toISOString().slice(0, 10)}</p>
59
- {contributor && <p class="text-sm text-[color:var(--ts-color-text-subtle)]">{contributor.data.name}</p>}
60
- {metaLabel && metaValue && <p class="text-sm text-[color:var(--ts-color-text-subtle)]">{metaLabel}: {metaValue}</p>}
120
+ <article class="max-w-6xl space-y-8">
121
+ <header class="grid gap-6 border-b border-[color:var(--ts-color-border)] pb-7 lg:grid-cols-[minmax(0,1fr)_18rem]">
122
+ <div class="space-y-4">
123
+ <div class="flex flex-wrap items-center gap-2.5">
124
+ <p class="rounded-full border border-[color:var(--ts-color-border)] px-3 py-1 text-xs font-semibold uppercase text-[color:var(--ts-color-info-text)]">{normalizedTypeLabel}</p>
125
+ {contentKind && <p class="rounded-full bg-[color:var(--ts-color-surface-muted)] px-3 py-1 text-xs font-semibold uppercase text-[color:var(--ts-color-text-subtle)]">{contentKind}</p>}
126
+ <StatusBadge status={entry.status} />
127
+ <p class="text-sm font-medium text-[color:var(--ts-color-text-subtle)]">{entry.date.toISOString().slice(0, 10)}</p>
128
+ {contributorName && <p class="text-sm text-[color:var(--ts-color-text-subtle)]">{contributorName}</p>}
129
+ </div>
130
+ <h1 class="max-w-4xl font-serif text-4xl font-bold tracking-normal text-[color:var(--ts-color-text)] md:text-5xl">{entry.title}</h1>
131
+ <p class="max-w-3xl text-lg leading-8 text-[color:var(--ts-color-text-muted)] md:text-xl md:leading-9">{entry.summary}</p>
132
+ {plainIntroText && (
133
+ <div class="max-w-3xl border-l-2 border-[color:var(--ts-color-border-strong)] pl-4">
134
+ <p class="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--ts-color-info-text)]">Why it matters</p>
135
+ <p class="mt-1 text-sm leading-7 text-[color:var(--ts-color-text-muted)]">{plainIntroText}</p>
136
+ </div>
137
+ )}
138
+ {entry.tags.length > 0 && (
139
+ <ul class="flex flex-wrap gap-2" aria-label="Tags">
140
+ {entry.tags.map((tag) => (
141
+ <li class="rounded-full bg-[color:var(--ts-color-surface-muted)] px-3 py-1 text-xs font-semibold uppercase text-[color:var(--ts-color-accent-strong)]">{tag}</li>
142
+ ))}
143
+ </ul>
144
+ )}
61
145
  </div>
62
- <h1 class="max-w-4xl font-serif text-5xl font-bold tracking-tight text-[color:var(--ts-color-text)] md:text-6xl">{entry.title}</h1>
63
- <p class="max-w-3xl text-xl leading-10 text-[color:var(--ts-color-text-muted)]">{entry.summary}</p>
64
- {(introText ?? entry.motivation) && <p class="max-w-3xl text-base leading-8 text-[color:var(--ts-color-text-muted)]">{introText ?? entry.motivation}</p>}
65
- {entry.tags.length > 0 && (
66
- <p class="text-sm uppercase tracking-[0.14em] text-[color:var(--ts-color-accent-strong)]">{entry.tags.join(' / ')}</p>
67
- )}
68
- </div>
69
- <div class="prose-karyon">
146
+ <aside class="self-start border-l border-[color:var(--ts-color-border)] pl-5">
147
+ <div class="flex items-center justify-between gap-3 border-b border-[color:var(--ts-color-border)] pb-3">
148
+ <div>
149
+ <p class="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--ts-color-info-text)]">Record</p>
150
+ <p class="mt-1 text-sm text-[color:var(--ts-color-text-muted)]">{relationCount} linked record{relationCount === 1 ? '' : 's'}</p>
151
+ </div>
152
+ </div>
153
+ <dl class="mt-2 text-sm">
154
+ {recordMetadata.map((item) => (
155
+ <div class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 py-2.5">
156
+ <dt class="text-[color:var(--ts-color-text-subtle)]">{item.label}</dt>
157
+ <dd class="min-w-0 break-words font-medium text-[color:var(--ts-color-text)]">
158
+ {item.href ? <a class="underline decoration-[color:var(--ts-color-border-strong)] underline-offset-4 hover:text-[color:var(--ts-color-accent-strong)]" href={item.href}>{displayValue(item.value)}</a> : displayValue(item.value)}
159
+ </dd>
160
+ </div>
161
+ ))}
162
+ </dl>
163
+ </aside>
164
+ </header>
165
+ <div class="prose-karyon max-w-3xl">
70
166
  <slot />
71
167
  </div>
72
- <div class="grid gap-6 border-t border-[color:var(--ts-color-border)] pt-8 md:grid-cols-3">
73
- {relatedQuestions.length > 0 && (
74
- <div>
75
- <p class="text-sm font-semibold uppercase tracking-[0.14em] text-[color:var(--ts-color-info-text)]">Related questions</p>
76
- <ul class="mt-3 space-y-2 text-[color:var(--ts-color-text-muted)]">
77
- {relatedQuestions.map((question) => (
78
- <li><a href={`/questions/${question.id}/`} class="hover:text-[color:var(--ts-color-text)]">{entryTitle(question)}</a></li>
79
- ))}
80
- </ul>
81
- </div>
82
- )}
83
- {relatedObjectives.length > 0 && (
84
- <div>
85
- <p class="text-sm font-semibold uppercase tracking-[0.14em] text-[color:var(--ts-color-info-text)]">Related objectives</p>
86
- <ul class="mt-3 space-y-2 text-[color:var(--ts-color-text-muted)]">
87
- {relatedObjectives.map((objective) => (
88
- <li><a href={`/objectives/${objective.id}/`} class="hover:text-[color:var(--ts-color-text)]">{entryTitle(objective)}</a></li>
89
- ))}
90
- </ul>
168
+ {relationSections.length > 0 && (
169
+ <section class="border-t border-[color:var(--ts-color-border)] pt-7">
170
+ <div class="mb-4 flex flex-wrap items-end justify-between gap-3">
171
+ <div>
172
+ <p class="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--ts-color-info-text)]">Connections</p>
91
173
  </div>
92
- )}
93
- {relatedNotes.length > 0 && (
94
- <div>
95
- <p class="text-sm font-semibold uppercase tracking-[0.14em] text-[color:var(--ts-color-info-text)]">Related notes</p>
96
- <ul class="mt-3 space-y-2 text-[color:var(--ts-color-text-muted)]">
97
- {relatedNotes.map((note) => (
98
- <li><a href={`/notes/${note.id}/`} class="hover:text-[color:var(--ts-color-text)]">{entryTitle(note)}</a></li>
99
- ))}
100
- </ul>
101
- </div>
102
- )}
103
- {relatedProposals.length > 0 && (
104
- <div>
105
- <p class="text-sm font-semibold uppercase tracking-[0.14em] text-[color:var(--ts-color-info-text)]">Related proposals</p>
106
- <ul class="mt-3 space-y-2 text-[color:var(--ts-color-text-muted)]">
107
- {relatedProposals.map((proposal) => (
108
- <li><a href={`/proposals/${proposal.id}/`} class="hover:text-[color:var(--ts-color-text)]">{entryTitle(proposal)}</a></li>
109
- ))}
110
- </ul>
111
- </div>
112
- )}
113
- {relatedDecisions.length > 0 && (
114
- <div>
115
- <p class="text-sm font-semibold uppercase tracking-[0.14em] text-[color:var(--ts-color-info-text)]">Related decisions</p>
116
- <ul class="mt-3 space-y-2 text-[color:var(--ts-color-text-muted)]">
117
- {relatedDecisions.map((decision) => (
118
- <li><a href={`/decisions/${decision.id}/`} class="hover:text-[color:var(--ts-color-text)]">{entryTitle(decision)}</a></li>
119
- ))}
120
- </ul>
121
- </div>
122
- )}
123
- {relatedBooks.length > 0 && (
124
- <div>
125
- <p class="text-sm font-semibold uppercase tracking-[0.14em] text-[color:var(--ts-color-info-text)]">Related books</p>
126
- <ul class="mt-3 space-y-2 text-[color:var(--ts-color-text-muted)]">
127
- {relatedBooks.map((book) => (
128
- <li><a href={`/books/${book.id}/`} class="hover:text-[color:var(--ts-color-text)]">{entryTitle(book)}</a></li>
129
- ))}
130
- </ul>
131
- </div>
132
- )}
133
- </div>
174
+ </div>
175
+ <div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
176
+ {relationSections.map((section) => (
177
+ <div class="border-l border-[color:var(--ts-color-border)] pl-4">
178
+ <p class="text-xs font-semibold uppercase tracking-[0.12em] text-[color:var(--ts-color-text-subtle)]">{section.label}</p>
179
+ <ul class="mt-2 space-y-2">
180
+ {section.items.map((item) => (
181
+ <li>
182
+ <a href={`${section.hrefBase}/${item.id}/`} class="block text-[color:var(--ts-color-text)] hover:text-[color:var(--ts-color-accent-strong)]">
183
+ <span class="block text-sm font-semibold">{entryTitle(item)}</span>
184
+ <span class="block text-xs text-[color:var(--ts-color-text-subtle)]">{item.id}</span>
185
+ </a>
186
+ </li>
187
+ ))}
188
+ </ul>
189
+ </div>
190
+ ))}
191
+ </div>
192
+ </section>
193
+ )}
134
194
  </article>
135
195
  </MainLayout>
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import '../styles/global.css';
3
3
  import PublicShell from '../components/ui/shell/PublicShell.astro';
4
+ import ShellIconLink from '../components/ui/shell/ShellIconLink.astro';
4
5
  import { SITE_NAV_GROUPS } from '../utils/routes';
5
6
  import { SITE } from '../utils/seo';
6
7
  import { SITE_THEME_CSS } from '../utils/site-config';
@@ -36,6 +37,7 @@ const navItems = SITE_NAV_GROUPS.flatMap((group) => group.items);
36
37
  {SITE_THEME_CSS && <style is:global>{SITE_THEME_CSS}</style>}
37
38
  </Fragment>
38
39
  <Fragment slot="actions">
40
+ <ShellIconLink href="/app/" label="Manager" icon="manager" />
39
41
  <a
40
42
  href={SITE.githubRepository}
41
43
  target="_blank"
@@ -57,9 +57,17 @@ const supersededDecisions = publishedRuntime
57
57
  entry={decision.data}
58
58
  currentPath="/decisions/"
59
59
  contributor={contributor}
60
+ contentTypeLabel="Decision"
61
+ contentId={decision.id}
62
+ collectionLabel="decisions"
60
63
  metaLabel="Decision type"
61
64
  metaValue={String(decision.data.decisionType ?? '')}
62
- introText={`${decision.data.rationale ?? ''} Authority: ${decision.data.authority ?? ''}.`}
65
+ introText={decision.data.rationale}
66
+ metadataItems={[
67
+ { label: 'Authority', value: decision.data.authority },
68
+ { label: 'Route', value: decision.data.canonicalRoute, href: decision.data.canonicalRoute },
69
+ { label: 'Implements', value: decision.data.implements },
70
+ ]}
63
71
  relatedObjectives={relatedObjectives}
64
72
  relatedQuestions={relatedQuestions}
65
73
  relatedNotes={relatedNotes}
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  import { getCollection, render } from 'astro:content';
3
- import NoteLayout from '../../layouts/NoteLayout.astro';
3
+ import AuthoredEntryLayout from '../../layouts/AuthoredEntryLayout.astro';
4
4
  import PublishedContentBody from '../../components/site/PublishedContentBody.astro';
5
5
  import RouteNotFound from '../../components/site/RouteNotFound.astro';
6
- import { isPublishedRuntimeContentMode, loadPublishedEntry } from '../../utils/site-content-runtime';
6
+ import { isPublishedRuntimeContentMode, loadPublishedEntry, metadataFromPublishedContent } from '../../utils/site-content-runtime';
7
7
 
8
8
  export const prerender = false;
9
9
 
@@ -13,6 +13,7 @@ const notes = publishedRuntime ? [] : await getCollection('notes', ({ data }) =>
13
13
  const localNote = publishedRuntime ? null : notes.find((candidate) => candidate.id === slug) ?? null;
14
14
  const publishedNote = publishedRuntime ? await loadPublishedEntry(Astro.locals, 'notes', slug) : null;
15
15
  const note = publishedRuntime ? publishedNote?.entry ?? null : localNote;
16
+ const metadata = publishedRuntime ? metadataFromPublishedContent(publishedNote?.content) : null;
16
17
  if (!note) {
17
18
  Astro.response.status = 404;
18
19
  }
@@ -24,8 +25,20 @@ const Content = rendered?.Content ?? null;
24
25
  !note || (!Content && !publishedNote?.html) ? (
25
26
  <RouteNotFound title="Note not found" description="The requested note could not be found in this Treeseed." currentPath="/notes/" />
26
27
  ) : (
27
- <NoteLayout note={note.data}>
28
+ <AuthoredEntryLayout
29
+ entry={note.data}
30
+ currentPath="/notes/"
31
+ contributor={null}
32
+ contentTypeLabel="Note"
33
+ contentId={note.id}
34
+ collectionLabel="notes"
35
+ metaLabel="Author"
36
+ metaValue={String(note.data.author ?? metadata?.author ?? '')}
37
+ metadataItems={[
38
+ { label: 'Route', value: note.data.canonicalRoute ?? metadata?.canonicalRoute, href: note.data.canonicalRoute ?? metadata?.canonicalRoute },
39
+ ]}
40
+ >
28
41
  {publishedRuntime ? <PublishedContentBody html={publishedNote?.html ?? ''} /> : <Content />}
29
- </NoteLayout>
42
+ </AuthoredEntryLayout>
30
43
  )
31
44
  }
@@ -45,8 +45,12 @@ const relatedBooks = publishedRuntime
45
45
  entry={objective.data}
46
46
  currentPath="/objectives/"
47
47
  contributor={contributor}
48
+ contentTypeLabel="Objective"
49
+ contentId={objective.id}
50
+ collectionLabel="objectives"
48
51
  metaLabel="Time horizon"
49
52
  metaValue={String(objective.data.timeHorizon ?? '')}
53
+ metadataItems={[]}
50
54
  relatedQuestions={relatedQuestions}
51
55
  relatedBooks={relatedBooks}
52
56
  >
@@ -57,8 +57,14 @@ const supersededProposals = publishedRuntime
57
57
  entry={proposal.data}
58
58
  currentPath="/proposals/"
59
59
  contributor={contributor}
60
+ contentTypeLabel="Proposal"
61
+ contentId={proposal.id}
62
+ collectionLabel="proposals"
60
63
  metaLabel="Proposal type"
61
64
  metaValue={String(proposal.data.proposalType ?? '')}
65
+ metadataItems={[
66
+ { label: 'Route', value: proposal.data.canonicalRoute, href: proposal.data.canonicalRoute },
67
+ ]}
62
68
  relatedObjectives={relatedObjectives}
63
69
  relatedQuestions={relatedQuestions}
64
70
  relatedNotes={relatedNotes}
@@ -45,8 +45,12 @@ const relatedBooks = publishedRuntime
45
45
  entry={question.data}
46
46
  currentPath="/questions/"
47
47
  contributor={contributor}
48
+ contentTypeLabel="Question"
49
+ contentId={question.id}
50
+ collectionLabel="questions"
48
51
  metaLabel="Question type"
49
52
  metaValue={String(question.data.questionType ?? '')}
53
+ metadataItems={[]}
50
54
  relatedObjectives={relatedObjectives}
51
55
  relatedBooks={relatedBooks}
52
56
  >
@@ -391,7 +391,7 @@ async function main() {
391
391
  writeCompatibilityEntrypoint(resolve(distRoot, 'config.d.ts'), "export declare function createTreeseedTenantSite(manifestPath?: string): import('astro').AstroUserConfig<never, never, never>;");
392
392
  writeCompatibilityEntrypoint(resolve(distRoot, 'content.d.ts'), "export declare function createTreeseedCollections(tenantConfig: any, dependencies: any): Record<string, any>;");
393
393
  writeCompatibilityEntrypoint(resolve(distRoot, 'content-config.js'), "import { loadTreeseedManifest } from '@treeseed/sdk/platform/tenant-config';\nimport { docsLoader } from './vendor/starlight/loaders.js';\nimport { docsSchema } from './vendor/starlight/schema.js';\nimport { createTreeseedCollections } from './content.js';\n\nexport function createTreeseedTenantCollections(manifestPath) {\n\tconst tenant = loadTreeseedManifest(manifestPath);\n\treturn createTreeseedCollections(tenant, { docsLoader, docsSchema });\n}");
394
- writeCompatibilityEntrypoint(resolve(distRoot, 'content-config.d.ts'), "export declare function createTreeseedTenantCollections(manifestPath?: string): {\n\tpages: any;\n\tnotes: any;\n\tquestions: any;\n\tobjectives: any;\n\tpeople: any;\n\tagents: any;\n\tbooks: any;\n\tdocs: any;\n\tworkdays?: any;\n};");
394
+ writeCompatibilityEntrypoint(resolve(distRoot, 'content-config.d.ts'), "export declare function createTreeseedTenantCollections(manifestPath?: string): {\n\tpages: any;\n\tnotes: any;\n\tquestions: any;\n\tobjectives: any;\n\tpeople: any;\n\tagents: any;\n\tagent_tests?: any;\n\tbooks: any;\n\tdocs: any;\n\tworkdays?: any;\n};");
395
395
  writeCompatibilityEntrypoint(resolve(distRoot, 'utils/forms/service.d.ts'), "import type { APIContext } from 'astro';\nimport type { SubmitResult } from '../../types/forms.js';\nexport declare function handleTokenRequest(context: APIContext): Promise<Response>;\nexport declare function handleFormSubmission(context: APIContext): Promise<SubmitResult>;");
396
396
  rmSync(resolve(distRoot, 'config.d.js'), { force: true });
397
397
  rmSync(resolve(distRoot, 'content-config.d.js'), { force: true });
@@ -50,6 +50,26 @@ function parseOpenMode(value) {
50
50
  }
51
51
  return undefined;
52
52
  }
53
+ function parseLocalRuntimeMode(value) {
54
+ if (value === 'auto' || value === 'provider' || value === 'local') {
55
+ return value;
56
+ }
57
+ return undefined;
58
+ }
59
+ function readForwardedEnvironment() {
60
+ const keys = [
61
+ 'TREESEED_DOCS_AUTOMATION_MODE',
62
+ 'TREESEED_WORKDAY_ID',
63
+ 'TREESEED_CAPACITY_BUDGET',
64
+ 'TREESEED_WORKDAY_TASK_CREDIT_BUDGET',
65
+ 'TREESEED_APPROVAL_POLICY',
66
+ 'TREESEED_MANAGER_CONSOLE_SUMMARY',
67
+ 'TREESEED_WORKER_CONSOLE_SUMMARY',
68
+ ];
69
+ return Object.fromEntries(keys
70
+ .map((key) => [key, process.env[key]])
71
+ .filter((entry) => typeof entry[1] === 'string' && entry[1].length > 0));
72
+ }
53
73
  const exitCode = await runTreeseedIntegratedDev({
54
74
  surface: parseSurface(readOption('--surface')),
55
75
  surfaces: readOption('--surfaces'),
@@ -58,13 +78,16 @@ const exitCode = await runTreeseedIntegratedDev({
58
78
  webPort: readNumberOption('--port'),
59
79
  apiHost: readOption('--api-host'),
60
80
  apiPort: readNumberOption('--api-port'),
81
+ webRuntime: parseLocalRuntimeMode(readOption('--web-runtime')),
61
82
  setupMode: parseSetupMode(readOption('--setup')),
62
83
  feedbackMode: parseFeedbackMode(readOption('--feedback')),
63
84
  openMode: parseOpenMode(readOption('--open')),
64
85
  plan: readFlag('--plan'),
65
86
  reset: readFlag('--reset'),
87
+ force: readFlag('--force'),
66
88
  json: readFlag('--json'),
67
89
  projectId: readOption('--project-id'),
68
90
  teamId: readOption('--team-id'),
91
+ env: readForwardedEnvironment(),
69
92
  });
70
93
  process.exit(exitCode);
package/dist/site.js CHANGED
@@ -313,6 +313,9 @@ function createTreeseedSite(tenantConfig, { starlight }) {
313
313
  __TREESEED_DEPLOY_CONFIG__: injectedDeployConfig,
314
314
  __TREESEED_BOOK_RUNTIME__: injectedBookRuntime
315
315
  },
316
+ optimizeDeps: {
317
+ exclude: ["libsodium-wrappers-sumo"]
318
+ },
316
319
  plugins: [
317
320
  createTenantThemeVitePlugin(tenantThemeCss),
318
321
  tailwindcss(),
@@ -27,15 +27,25 @@
27
27
  align-self: start;
28
28
  background: var(--ts-color-canvas-subtle);
29
29
  border-right: 1px solid var(--ts-color-border);
30
+ box-sizing: border-box;
30
31
  display: grid;
31
32
  gap: var(--ts-space-3);
33
+ grid-template-rows: minmax(0, 1fr) auto;
32
34
  height: 100vh;
33
- overflow: auto;
35
+ overflow: hidden;
34
36
  padding: var(--ts-space-3);
35
37
  position: sticky;
36
38
  top: 0;
37
39
  }
38
40
 
41
+ .ts-app-shell__rail-scroll {
42
+ display: grid;
43
+ gap: var(--ts-space-3);
44
+ min-height: 0;
45
+ overflow: auto;
46
+ padding-right: 0.15rem;
47
+ }
48
+
39
49
  .ts-shell-brand {
40
50
  align-items: center;
41
51
  color: var(--ts-color-text);
@@ -109,6 +119,12 @@
109
119
  min-width: 0;
110
120
  }
111
121
 
122
+ .ts-shell-utility-actions {
123
+ align-items: center;
124
+ display: inline-flex;
125
+ gap: var(--ts-space-1);
126
+ }
127
+
112
128
  .ts-app-shell__rail-context,
113
129
  .ts-app-shell__quick-actions {
114
130
  border-top: 1px solid var(--ts-color-border);
@@ -117,6 +133,11 @@
117
133
  padding-top: var(--ts-space-2);
118
134
  }
119
135
 
136
+ .ts-app-shell__quick-actions {
137
+ background: var(--ts-color-canvas-subtle);
138
+ padding-bottom: 0.1rem;
139
+ }
140
+
120
141
  .ts-app-shell__eyebrow {
121
142
  color: var(--ts-color-text-subtle);
122
143
  font-size: 0.75rem;
@@ -568,6 +589,10 @@
568
589
  width: 100%;
569
590
  }
570
591
 
592
+ .ts-app-shell__header-actions .ts-shell-utility-actions {
593
+ display: none;
594
+ }
595
+
571
596
  .ts-bottom-nav {
572
597
  display: grid;
573
598
  }
@@ -20,13 +20,13 @@ body {
20
20
 
21
21
  .ts-theme-menu__trigger {
22
22
  align-items: center;
23
- background: var(--ts-color-surface);
24
- border: 1px solid var(--ts-color-border);
23
+ background: transparent;
24
+ border: 1px solid transparent;
25
25
  border-radius: var(--ts-radius-md);
26
26
  color: var(--ts-color-text-muted);
27
27
  cursor: pointer;
28
28
  display: inline-flex;
29
- height: 2rem;
29
+ height: 2.25rem;
30
30
  justify-content: center;
31
31
  list-style: none;
32
32
  padding: 0;
@@ -34,7 +34,7 @@ body {
34
34
  background-color 140ms ease,
35
35
  border-color 140ms ease,
36
36
  color 140ms ease;
37
- width: 2rem;
37
+ width: 2.25rem;
38
38
  }
39
39
 
40
40
  .ts-theme-menu__trigger::-webkit-details-marker {
@@ -43,8 +43,8 @@ body {
43
43
 
44
44
  .ts-theme-menu__trigger:hover,
45
45
  .ts-theme-menu[open] .ts-theme-menu__trigger {
46
- background: var(--ts-color-surface-muted);
47
- border-color: var(--ts-color-border-strong);
46
+ background: var(--ts-color-surface-raised);
47
+ border-color: var(--ts-color-border);
48
48
  color: var(--ts-color-text);
49
49
  }
50
50
 
@@ -56,8 +56,8 @@ body {
56
56
  .ts-theme-menu__trigger svg {
57
57
  display: block;
58
58
  fill: currentColor;
59
- height: 1rem;
60
- width: 1rem;
59
+ height: 1.15rem;
60
+ width: 1.15rem;
61
61
  }
62
62
 
63
63
  .ts-theme-menu__panel {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/core",
3
- "version": "0.8.18",
3
+ "version": "0.9.0",
4
4
  "description": "Treeseed web framework package for Astro/Starlight site runtimes.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -70,7 +70,7 @@
70
70
  "@astrojs/sitemap": "3.7.0",
71
71
  "@astrojs/starlight": "0.37.6",
72
72
  "@tailwindcss/vite": "^4.1.4",
73
- "@treeseed/sdk": "0.8.18",
73
+ "@treeseed/sdk": "0.9.0",
74
74
  "astro": "^5.6.1",
75
75
  "esbuild": "^0.28.0",
76
76
  "katex": "^0.16.22",