@crouton-kit/crouter 0.3.27 → 0.3.28
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/builtin-personas/runtime-base.md +3 -0
- package/dist/clients/attach/__tests__/reconnect-giveup.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
- package/dist/clients/attach/attach-cmd.js +187 -19
- package/dist/clients/attach/canvas-panels.d.ts +10 -0
- package/dist/clients/attach/canvas-panels.js +50 -0
- package/dist/clients/attach/graph-overlay.d.ts +34 -0
- package/dist/clients/attach/graph-overlay.js +266 -0
- package/dist/clients/attach/input-controller.d.ts +6 -0
- package/dist/clients/attach/input-controller.js +2 -0
- package/dist/clients/attach/slash-commands.d.ts +22 -1
- package/dist/clients/attach/slash-commands.js +160 -3
- package/dist/clients/attach/view-socket.d.ts +19 -1
- package/dist/clients/attach/view-socket.js +61 -6
- package/dist/commands/human/prompts.js +3 -3
- package/dist/commands/human/queue.d.ts +17 -0
- package/dist/commands/human/queue.js +111 -4
- package/dist/commands/memory/__tests__/lint-schema.test.js +24 -1
- package/dist/commands/memory/lint.d.ts +5 -4
- package/dist/commands/memory/lint.js +9 -5
- package/dist/commands/memory/write.js +12 -3
- package/dist/commands/sys/feedback.d.ts +1 -0
- package/dist/commands/sys/feedback.js +163 -0
- package/dist/commands/sys.js +3 -2
- package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
- package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +7 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +10 -0
- package/dist/core/__tests__/full/placement-teardown.test.js +76 -0
- package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
- package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
- package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
- package/dist/core/canvas/nav-model.d.ts +162 -0
- package/dist/core/canvas/nav-model.js +486 -0
- package/dist/core/canvas/paths.d.ts +7 -0
- package/dist/core/canvas/paths.js +9 -0
- package/dist/core/runtime/broker-sdk.d.ts +0 -12
- package/dist/core/runtime/broker-sdk.js +77 -6
- package/dist/core/runtime/broker.d.ts +2 -1
- package/dist/core/runtime/broker.js +26 -1
- package/dist/core/runtime/front-door.js +23 -8
- package/dist/core/runtime/placement.d.ts +7 -6
- package/dist/core/runtime/placement.js +24 -12
- package/dist/core/runtime/revive.js +9 -0
- package/dist/core/runtime/spawn.d.ts +5 -0
- package/dist/core/runtime/spawn.js +62 -1
- package/dist/core/runtime/tmux.d.ts +9 -0
- package/dist/core/runtime/tmux.js +12 -0
- package/dist/core/spawn.d.ts +14 -0
- package/dist/core/spawn.js +29 -9
- package/dist/core/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +6 -6
- package/dist/core/substrate/injected-store.d.ts +10 -0
- package/dist/core/substrate/injected-store.js +55 -0
- package/dist/core/substrate/schema.d.ts +6 -8
- package/dist/core/substrate/schema.js +26 -28
- package/dist/pi-extensions/canvas-doc-substrate.js +16 -7
- package/dist/pi-extensions/canvas-nav.js +30 -385
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +32 -2
- package/package.json +1 -1
|
@@ -310,7 +310,7 @@ export async function runBroker(nodeId) {
|
|
|
310
310
|
broadcast({ type: 'control_changed', controller_id: controllerId });
|
|
311
311
|
};
|
|
312
312
|
const buildSnapshot = () => ({
|
|
313
|
-
messages: session
|
|
313
|
+
messages: snapshotMessages(session),
|
|
314
314
|
stats: session.getSessionStats(),
|
|
315
315
|
state: {
|
|
316
316
|
sessionId: session.sessionId,
|
|
@@ -907,6 +907,31 @@ export async function runBroker(nodeId) {
|
|
|
907
907
|
}
|
|
908
908
|
}
|
|
909
909
|
// ---------------------------------------------------------------------------
|
|
910
|
+
// snapshotMessages — the catch-up snapshot's ordered message history.
|
|
911
|
+
//
|
|
912
|
+
// The broker is the SOLE writer of the node's session `.jsonl`, so the session
|
|
913
|
+
// manager's persisted tree (`buildSessionContext`) is the canonical, complete,
|
|
914
|
+
// ordered history — byte-identical to what a dormant reader (crouter-web's static
|
|
915
|
+
// normalizer) reconstructs from the same file. We serve THAT, NOT the agent's
|
|
916
|
+
// live `session.messages` (`agent.state.messages`), because pi's recovery paths
|
|
917
|
+
// mutate the live array away from the persisted history: auto-retry, context-
|
|
918
|
+
// overflow recovery, and compaction each SLICE the errored/superseded assistant
|
|
919
|
+
// message out of `state.messages` while DELIBERATELY keeping it on disk ("keep in
|
|
920
|
+
// session for history", agent-session.js). So `session.messages` can OMIT — or, via
|
|
921
|
+
// branch/compaction reshaping, reorder — a turn the `.jsonl` still holds. Pre-
|
|
922
|
+
// close that drift is invisible (the live stream and the live array agree); after
|
|
923
|
+
// a revive the welcome snapshot built from the reloaded array would diverge from
|
|
924
|
+
// the on-disk history a dormant viewer just saw. Reconstructing from the session
|
|
925
|
+
// manager makes the live snapshot == the persisted history (single source of
|
|
926
|
+
// truth); the relayed live stream then continues from there.
|
|
927
|
+
//
|
|
928
|
+
// `AgentSession.sessionManager` is a public readonly field of the real pi SDK; the
|
|
929
|
+
// fake-engine test fixture mirrors the same `sessionManager.buildSessionContext()`
|
|
930
|
+
// surface, so this is a single path with no engine-capability fallback.
|
|
931
|
+
export function snapshotMessages(session) {
|
|
932
|
+
return session.sessionManager.buildSessionContext().messages;
|
|
933
|
+
}
|
|
934
|
+
// ---------------------------------------------------------------------------
|
|
910
935
|
// buildBrokerSession (plan T4 steps 2–4) — turn the launch recipe into a live
|
|
911
936
|
// engine session via the pi SDK SERVICES path. Exported so the C3/C4 real-SDK
|
|
912
937
|
// regression tests can drive the EXACT production wiring (not the mock).
|
|
@@ -21,12 +21,19 @@ function isDir(p) {
|
|
|
21
21
|
return false;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
24
|
+
/** The `--`flags the front door OWNS (everything else after a bare `crtr` is a
|
|
25
|
+
* positional dir/prompt). A leading token in this set still boots a root —
|
|
26
|
+
* without it, `crtr --headless` / `crtr --name X` would fall through to the
|
|
27
|
+
* dispatcher and error as an unknown subcommand. */
|
|
28
|
+
const FRONT_DOOR_FLAGS = new Set(['--name', '--kind', '--headless', '--no-headless']);
|
|
29
|
+
/** Parse `[dir] [prompt]` positionals + the front-door flags out of the leftover
|
|
30
|
+
* tokens after the bare `crtr`. `headless` is tri-state: `true`/`false` from an
|
|
31
|
+
* explicit `--headless`/`--no-headless`, `undefined` to defer to the config. */
|
|
26
32
|
function parseRootArgs(tokens) {
|
|
27
33
|
let cwd = process.cwd();
|
|
28
34
|
let name;
|
|
29
35
|
let kind;
|
|
36
|
+
let headless;
|
|
30
37
|
const positionals = [];
|
|
31
38
|
for (let i = 0; i < tokens.length; i++) {
|
|
32
39
|
const t = tokens[i];
|
|
@@ -36,6 +43,12 @@ function parseRootArgs(tokens) {
|
|
|
36
43
|
else if (t === '--kind') {
|
|
37
44
|
kind = tokens[++i];
|
|
38
45
|
}
|
|
46
|
+
else if (t === '--headless') {
|
|
47
|
+
headless = true;
|
|
48
|
+
}
|
|
49
|
+
else if (t === '--no-headless') {
|
|
50
|
+
headless = false;
|
|
51
|
+
}
|
|
39
52
|
else if (t.startsWith('--')) {
|
|
40
53
|
// ignore unknown flags for the front door
|
|
41
54
|
}
|
|
@@ -48,7 +61,7 @@ function parseRootArgs(tokens) {
|
|
|
48
61
|
cwd = resolvePath(positionals.shift());
|
|
49
62
|
}
|
|
50
63
|
const prompt = positionals.length > 0 ? positionals.join(' ') : undefined;
|
|
51
|
-
return { cwd, prompt, name, kind };
|
|
64
|
+
return { cwd, prompt, name, kind, headless };
|
|
52
65
|
}
|
|
53
66
|
/** Env marker set on every pi the front door boots. Its presence means we are
|
|
54
67
|
* already inside a front-door-booted root, so a nested front-door launch must
|
|
@@ -79,14 +92,16 @@ export function maybeBootRoot(root, argv) {
|
|
|
79
92
|
// • bare `crtr` (no tokens)
|
|
80
93
|
// • `crtr <dir> [prompt]` (first positional is an existing dir)
|
|
81
94
|
// • `crtr "multi word prompt"` (first token contains whitespace)
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
95
|
+
// • `crtr --headless` / `crtr --name X` (a leading front-door flag)
|
|
96
|
+
// Anything else — a bare word like `job`, or an UNKNOWN leading flag — is
|
|
97
|
+
// treated as a mistyped/removed subcommand and handed to the dispatcher, which
|
|
98
|
+
// errors with "unknown subcommand: <token>". Booting pi for such tokens is what
|
|
99
|
+
// let the renamed `agent`/`job` subcommands fork-bomb the front door.
|
|
86
100
|
if (first !== undefined) {
|
|
87
101
|
const looksLikePrompt = /\s/.test(first);
|
|
88
102
|
const looksLikeDir = !first.startsWith('-') && isDir(resolvePath(first));
|
|
89
|
-
|
|
103
|
+
const looksLikeFrontDoorFlag = FRONT_DOOR_FLAGS.has(first);
|
|
104
|
+
if (!looksLikePrompt && !looksLikeDir && !looksLikeFrontDoorFlag)
|
|
90
105
|
return false;
|
|
91
106
|
}
|
|
92
107
|
// Unambiguous front-door launch → boot a resident root inline (exec pi in
|
|
@@ -308,12 +308,13 @@ export declare function recycleFocusPane(nodeId: string, pane: string, launch: R
|
|
|
308
308
|
* manager that is NOT idle-release (done/dead/canceled, or idle with another
|
|
309
309
|
* intent) is one the daemon will NEVER revive, so this returns false WITHOUT
|
|
310
310
|
* repointing — the caller then disarms the freeze (see below).
|
|
311
|
-
* -
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
311
|
+
* - RUNNING backstage pane (a live pi — the normal multi-child state — OR a
|
|
312
|
+
* pi still BOOTING because the daemon already revived the manager off the
|
|
313
|
+
* `push final` before this handoff ran): the daemon never (re)revives it,
|
|
314
|
+
* so we must bring it into the viewport SYNCHRONOUSLY here — swap its
|
|
315
|
+
* backstage pane into the focus slot (MAJOR 1). Otherwise the manager runs
|
|
316
|
+
* off-screen forever while %m sits orphaned in the viewport and the focus
|
|
317
|
+
* row lies about LOCATION.
|
|
317
318
|
* Returns false — the caller closes the focus (Q1: closeFocusToShell disarms
|
|
318
319
|
* remain-on-exit so the pane REAPS on exit) — whenever NO successor will claim
|
|
319
320
|
* the frozen pane: no manager, the manager IS this node, the manager already
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import { spawnSync } from 'node:child_process';
|
|
29
29
|
import { join } from 'node:path';
|
|
30
30
|
import { getRow, getRowByPane, getNode, setPresence, openDb, openFocusRow, setFocusOccupant, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, setFocusPane, listFocuses as listFocusRows, view, } from '../canvas/index.js';
|
|
31
|
-
import { paneExists, paneLocation, paneOfWindow, windowAlive, windowOfPane, ensureSession, openNodeWindow, respawnPaneSync, respawnPaneDetached, breakPaneToSession, splitWindow, swapPaneInPlace, setRemainOnExit, closePane, currentTmux, joinPane, selectLayout, setWindowOption, switchClient, selectWindow, } from './tmux.js';
|
|
31
|
+
import { paneExists, paneRunning, paneLocation, paneOfWindow, windowAlive, windowOfPane, ensureSession, openNodeWindow, respawnPaneSync, respawnPaneDetached, breakPaneToSession, splitWindow, swapPaneInPlace, setRemainOnExit, closePane, currentTmux, joinPane, selectLayout, setWindowOption, switchClient, selectWindow, } from './tmux.js';
|
|
32
32
|
import { homeSessionOf, childBackstageOf, nodeSession, newNodeId, rootOfSpine } from './nodes.js';
|
|
33
33
|
import { nodeDir } from '../canvas/paths.js';
|
|
34
34
|
import { isBusy } from './busy.js';
|
|
@@ -808,12 +808,13 @@ export function recycleFocusPane(nodeId, pane, launch) {
|
|
|
808
808
|
* manager that is NOT idle-release (done/dead/canceled, or idle with another
|
|
809
809
|
* intent) is one the daemon will NEVER revive, so this returns false WITHOUT
|
|
810
810
|
* repointing — the caller then disarms the freeze (see below).
|
|
811
|
-
* -
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
811
|
+
* - RUNNING backstage pane (a live pi — the normal multi-child state — OR a
|
|
812
|
+
* pi still BOOTING because the daemon already revived the manager off the
|
|
813
|
+
* `push final` before this handoff ran): the daemon never (re)revives it,
|
|
814
|
+
* so we must bring it into the viewport SYNCHRONOUSLY here — swap its
|
|
815
|
+
* backstage pane into the focus slot (MAJOR 1). Otherwise the manager runs
|
|
816
|
+
* off-screen forever while %m sits orphaned in the viewport and the focus
|
|
817
|
+
* row lies about LOCATION.
|
|
817
818
|
* Returns false — the caller closes the focus (Q1: closeFocusToShell disarms
|
|
818
819
|
* remain-on-exit so the pane REAPS on exit) — whenever NO successor will claim
|
|
819
820
|
* the frozen pane: no manager, the manager IS this node, the manager already
|
|
@@ -840,11 +841,22 @@ export function handFocusToManager(focusId, managerId) {
|
|
|
840
841
|
const mgr = getRow(managerId);
|
|
841
842
|
if (mgr === null)
|
|
842
843
|
return false; // no row to hand to
|
|
843
|
-
// MAJOR 1 —
|
|
844
|
-
// daemon never revives a live node (it only respawns dead-pi
|
|
845
|
-
// synchronous swap is the ONLY way
|
|
846
|
-
// the occupant repoint only here, on a path that genuinely takes the
|
|
847
|
-
|
|
844
|
+
// MAJOR 1 — manager with a RUNNING backstage pane → swap it into the focus
|
|
845
|
+
// slot NOW. The daemon never revives a live node (it only respawns dead-pi
|
|
846
|
+
// nodes), so this synchronous swap is the ONLY way it claims the frozen %m.
|
|
847
|
+
// Commit the occupant repoint only here, on a path that genuinely takes the
|
|
848
|
+
// pane. The gate is the PANE (`paneRunning`: exists + command running, i.e.
|
|
849
|
+
// not a remain-on-exit corpse), NOT `isPidAlive(mgr.pi_pid)` — the recorded
|
|
850
|
+
// pid is a STALE proxy in the lost-pane race: the finishing child's `push
|
|
851
|
+
// final` seeds the manager's inbox mid-turn, the daemon's second pass revives
|
|
852
|
+
// the still-unfocused manager BACKSTAGE before this agent_end runs, and the
|
|
853
|
+
// fresh pi records its pid only at session_start, seconds into boot. Gating on
|
|
854
|
+
// the old dead pid made this branch miss the booting manager, the dormant
|
|
855
|
+
// branch miss it too (revive already flipped it active), and the caller reap
|
|
856
|
+
// the user's viewport while the manager ran on invisibly backstage. A booting
|
|
857
|
+
// pane is as swappable as a live one — swap it in; only a frozen corpse
|
|
858
|
+
// (pane_dead=1) is excluded, preserving the dead-focus-pane guarantees.
|
|
859
|
+
if (mgr.pane != null && paneRunning(mgr.pane) && f.pane != null) {
|
|
848
860
|
setFocusOccupant(focusId, managerId);
|
|
849
861
|
const focusLoc = paneLocation(f.pane); // F2's window/session — the slot mgr swaps INTO (%m is currently there)
|
|
850
862
|
if (swapPaneInPlace(mgr.pane, f.pane) && focusLoc !== null) {
|
|
@@ -23,6 +23,7 @@ import { reconcile, piCommand, respawnPane, } from './placement.js';
|
|
|
23
23
|
import { hostFor } from './host.js';
|
|
24
24
|
import { nodeSession, childBackstageOf, rootOfSpine } from './nodes.js';
|
|
25
25
|
import { isPidAlive } from '../canvas/pid.js';
|
|
26
|
+
import { clearInjectedDocs } from '../substrate/injected-store.js';
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
// resumeArgs — which session source a revive resumes from
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
@@ -93,6 +94,10 @@ export function reviveNode(nodeId, opts) {
|
|
|
93
94
|
// woke you"); every other reviveNode caller passes nothing → no block.
|
|
94
95
|
const bearings = drainBearings(meta);
|
|
95
96
|
inv = buildPiArgv(meta, { prompt: buildReviveKickoff(meta, bearings, opts.wakeReason) });
|
|
97
|
+
// Fresh (no-resume) revive starts a NEW transcript — reset the on-read doc
|
|
98
|
+
// dedup so the new conversation surfaces docs from scratch (a resume below
|
|
99
|
+
// would instead KEEP the persisted set, continuing the same transcript).
|
|
100
|
+
clearInjectedDocs(nodeId);
|
|
96
101
|
}
|
|
97
102
|
// Placement owns WHERE this revive lands (§1.4): resume into a live focus pane
|
|
98
103
|
// if the node occupies one, else a fresh window in its home_session (the
|
|
@@ -140,6 +145,8 @@ export function reviveInPlace(nodeId, pane, respawn = respawnPane) {
|
|
|
140
145
|
// A refresh-yield is a cycle too — advance the label's trailing N.
|
|
141
146
|
meta.cycles = (meta.cycles ?? 0) + 1;
|
|
142
147
|
updateNode(nodeId, { cycles: meta.cycles });
|
|
148
|
+
// Fresh re-exec → new transcript: reset the on-read doc dedup.
|
|
149
|
+
clearInjectedDocs(nodeId);
|
|
143
150
|
// The node's LOCATION — the session its pane physically lives in. The re-exec
|
|
144
151
|
// is IN PLACE (the pane never moves), so this is preserved unchanged below.
|
|
145
152
|
const session = meta.tmux_session ?? nodeSession();
|
|
@@ -198,6 +205,8 @@ export function relaunchRootInPane(nodeId, pane) {
|
|
|
198
205
|
throw new Error(`relaunchRootInPane: unknown node ${nodeId}`);
|
|
199
206
|
}
|
|
200
207
|
// No prompt, no resume → a brand-new root conversation at cycle 0.
|
|
208
|
+
// Brand-new transcript: reset the on-read doc dedup.
|
|
209
|
+
clearInjectedDocs(nodeId);
|
|
201
210
|
const inv = buildPiArgv(meta, {});
|
|
202
211
|
// Source CRTR_ROOT_SESSION from childBackstageOf, the same backstage rule as
|
|
203
212
|
// reviveInPlace. relaunchRootInPane runs only on a root, whose children must
|
|
@@ -6,6 +6,11 @@ export interface BootRootOpts {
|
|
|
6
6
|
name?: string;
|
|
7
7
|
/** Optional starter prompt (bare `crtr` requires none). */
|
|
8
8
|
prompt?: string;
|
|
9
|
+
/** Tri-state host selection: `true`/`false` from an explicit
|
|
10
|
+
* `--headless`/`--no-headless`, `undefined` to defer to the `headless`
|
|
11
|
+
* config default. When it resolves to broker, the front door boots a
|
|
12
|
+
* DETACHED broker root and auto-attaches a viewer inline in this pane. */
|
|
13
|
+
headless?: boolean;
|
|
9
14
|
}
|
|
10
15
|
/** Create a root node and bring up its pi. Returns the node; for 'inline' this
|
|
11
16
|
* only returns after pi exits (it took over the terminal). */
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
// INDEPENDENT resident root (no subscription back to the spawner,
|
|
10
10
|
// provenance via spawned_by) brought forefront for direct driving.
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { join } from 'node:path';
|
|
12
13
|
import { FRONT_DOOR_ENV } from './front-door.js';
|
|
14
|
+
import { readConfig } from '../config.js';
|
|
15
|
+
import { jobDir } from '../canvas/paths.js';
|
|
13
16
|
import { spawnNode, currentNodeContext, resolveBirthSession, nodeSession, rootOfSpine } from './nodes.js';
|
|
14
17
|
import { buildLaunchSpec, buildPiArgv } from './launch.js';
|
|
15
18
|
import { writeGoal } from './kickoff.js';
|
|
@@ -17,7 +20,7 @@ import { hasRoadmap, seedRoadmap } from './roadmap.js';
|
|
|
17
20
|
import { buildIdentityAssertion, buildWakeBearings } from './bearings.js';
|
|
18
21
|
import { installMenuBinding, installNavBindings, installViewNavBindings } from './tmux-chrome.js';
|
|
19
22
|
import { setPresence, updateNode, getNode, fullName } from '../canvas/index.js';
|
|
20
|
-
import { registerRootFocus, ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, focusWindow, } from './placement.js';
|
|
23
|
+
import { registerRootFocus, ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, focusWindow, waitForBrokerViewSocket, } from './placement.js';
|
|
21
24
|
import { transition } from './lifecycle.js';
|
|
22
25
|
import { headlessBrokerHost } from './host.js';
|
|
23
26
|
import { ensureDaemon } from '../../daemon/manage.js';
|
|
@@ -31,6 +34,10 @@ export function bootRoot(opts) {
|
|
|
31
34
|
}
|
|
32
35
|
catch { /* daemon is best-effort */ }
|
|
33
36
|
const kind = opts.kind ?? 'general';
|
|
37
|
+
// Host precedence: explicit --headless/--no-headless flag > config `headless`
|
|
38
|
+
// default > tmux. Mirrors `crtr node new`'s resolution (commands/node.ts).
|
|
39
|
+
const headless = opts.headless ?? readConfig('user').headless === true;
|
|
40
|
+
const hostKind = headless ? 'broker' : 'tmux';
|
|
34
41
|
// A born-resident root starts in base mode; it earns the orchestrator persona
|
|
35
42
|
// the first time it delegates (or on promotion). Resident lifecycle either way.
|
|
36
43
|
const { launch } = buildLaunchSpec(kind, 'base', { lifecycle: 'resident', hasManager: false });
|
|
@@ -45,6 +52,7 @@ export function bootRoot(opts) {
|
|
|
45
52
|
cwd: opts.cwd,
|
|
46
53
|
name: opts.name ?? kind,
|
|
47
54
|
parent: null,
|
|
55
|
+
hostKind,
|
|
48
56
|
launch,
|
|
49
57
|
});
|
|
50
58
|
// Persist the spawning prompt as the goal so a fresh revive can re-read its
|
|
@@ -70,6 +78,15 @@ export function bootRoot(opts) {
|
|
|
70
78
|
}
|
|
71
79
|
catch { /* best-effort */ }
|
|
72
80
|
}
|
|
81
|
+
// HEADLESS front door: a broker root has NO engine pane — headlessBrokerHost
|
|
82
|
+
// launches a DETACHED broker that hosts the single engine + binds view.sock,
|
|
83
|
+
// opening no tmux window. So instead of exec'ing pi inline we boot the broker,
|
|
84
|
+
// wait for its socket, and auto-attach a VIEWER into THIS pane (the same
|
|
85
|
+
// `crtr attach` viewer focusBroker splits beside a caller). Presence stays
|
|
86
|
+
// null and NO focus row is registered — the viewer pane self-tags @crtr_node
|
|
87
|
+
// on connect, the handle node cycle/close/lifecycle resolve a broker by.
|
|
88
|
+
if (meta.host_kind === 'broker')
|
|
89
|
+
return bootRootBroker(meta, session, opts);
|
|
73
90
|
// inline: the root's pi takes over THIS terminal, so its own window stays
|
|
74
91
|
// where the user is (its tmux_session tracks that real pane so supervision
|
|
75
92
|
// sees it alive). But its children spawn into the shared global session via
|
|
@@ -98,6 +115,50 @@ export function bootRoot(opts) {
|
|
|
98
115
|
const r = spawnSync('pi', inv.argv, { stdio: 'inherit', env });
|
|
99
116
|
process.exit(r.status ?? 0);
|
|
100
117
|
}
|
|
118
|
+
/** The headless front door (host_kind==='broker'): launch a detached broker for
|
|
119
|
+
* this root, wait for its view.sock, then auto-attach a viewer INLINE in this
|
|
120
|
+
* pane. Mirrors placement.focusBroker (ensure-broker → wait-socket → viewer)
|
|
121
|
+
* but the viewer runs in the CURRENT pane (the front door owns it) rather than
|
|
122
|
+
* a split beside a caller. Never returns — it exits with the viewer's status. */
|
|
123
|
+
function bootRootBroker(meta, session, opts) {
|
|
124
|
+
// The shared backstage this subtree's descendants flow into (CRTR_ROOT_SESSION
|
|
125
|
+
// below). Mirrors the inline path; for a broker it is also the (moot) revive
|
|
126
|
+
// home — broker revive launches a detached process, ignoring the session.
|
|
127
|
+
updateNode(meta.node_id, { home_session: session });
|
|
128
|
+
const withSession = getNode(meta.node_id);
|
|
129
|
+
const inv = buildPiArgv(withSession, { prompt: opts.prompt });
|
|
130
|
+
// Authoritative backstage routing on the ENGINE's env (the broker host merges
|
|
131
|
+
// inv.env into the detached broker process), mirroring the inline pi env.
|
|
132
|
+
inv.env = { ...inv.env, CRTR_ROOT_SESSION: session, CRTR_SUBTREE: rootOfSpine(meta.node_id) };
|
|
133
|
+
// Launch the detached broker (it records its own pid as pi_pid via the
|
|
134
|
+
// stophook and binds view.sock). Returns placement all-null — no tmux window.
|
|
135
|
+
headlessBrokerHost.launch(meta.node_id, inv, { cwd: meta.cwd, name: fullName(meta), resuming: false });
|
|
136
|
+
// Close the cold-start race: the broker records its pid during extension bind,
|
|
137
|
+
// then opens view.sock LATER; attach connects once and exits "no broker" if we
|
|
138
|
+
// run it before listen(). Probe for an ACCEPTING socket before attaching. A
|
|
139
|
+
// timeout here does NOT mean the broker died — the engine is a resident root
|
|
140
|
+
// that keeps booting; we just can't attach yet, so point the user at re-focus.
|
|
141
|
+
if (!waitForBrokerViewSocket(meta.node_id)) {
|
|
142
|
+
process.stderr.write(`crtr: ${meta.node_id} is still starting its broker — it is running, but its viewer socket did not come up in time. ` +
|
|
143
|
+
`Re-focus the node to attach (\`crtr node focus ${meta.node_id}\`); see ${join(jobDir(meta.node_id), 'broker.log')} if it never appears.\n`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
// Auto-attach the VIEWER inline: re-invoke OUR OWN cli (process.execPath +
|
|
147
|
+
// its own execArgv + argv[1]) as `attach to <id>` so it needs no PATH `crtr`
|
|
148
|
+
// AND survives a loader-based launch (dev `tsx`/`--import` flags ride
|
|
149
|
+
// execArgv); stdio:'inherit' hands this pane to the viewer's raw-mode TUI.
|
|
150
|
+
// runAttach self-tags the pane @crtr_node on connect; ctrl+c/ctrl+d detaches
|
|
151
|
+
// and the broker engine runs on.
|
|
152
|
+
const entry = process.argv[1];
|
|
153
|
+
if (entry === undefined) {
|
|
154
|
+
process.stderr.write(`crtr: cannot resolve the crtr entrypoint to auto-attach; run \`crtr node focus ${meta.node_id}\`.\n`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
const r = spawnSync(process.execPath, [...process.execArgv, entry, 'attach', 'to', meta.node_id], {
|
|
158
|
+
stdio: 'inherit',
|
|
159
|
+
});
|
|
160
|
+
process.exit(r.status ?? 0);
|
|
161
|
+
}
|
|
101
162
|
/** Resolve a `--fork-from` value to the source pi gets as `--fork <path|id>`.
|
|
102
163
|
* A live node id resolves to its captured session FILE (absolute, cwd-immune),
|
|
103
164
|
* falling back to its bare session id; a path or partial uuid passes straight
|
|
@@ -96,6 +96,15 @@ export declare function paneLocation(pane: string): {
|
|
|
96
96
|
* liveness. We therefore require the echoed `#{pane_id}` to equal the requested
|
|
97
97
|
* pane: a live pane echoes its own id, a gone/bogus one yields empty. */
|
|
98
98
|
export declare function paneExists(pane: string): boolean;
|
|
99
|
+
/** Does this pane exist AND have its command still RUNNING (`#{pane_dead}` = 0)?
|
|
100
|
+
* Distinguishes a pane genuinely hosting a process — a live pi, or a pi still
|
|
101
|
+
* BOOTING whose pid hasn't been recorded yet — from a remain-on-exit corpse
|
|
102
|
+
* frozen after exit (`pane_dead` = 1). Node panes run pi as the pane command
|
|
103
|
+
* (openNodeWindow / respawn-pane), never a shell, so pane-running ⟹ a pi (or
|
|
104
|
+
* its respawn) occupies it. The probe handFocusToManager's live-swap gates on:
|
|
105
|
+
* the recorded `pi_pid` is a stale proxy in the just-revived window (the new pi
|
|
106
|
+
* records its pid only at session_start), but the pane itself never lies. */
|
|
107
|
+
export declare function paneRunning(pane: string): boolean;
|
|
99
108
|
/** Every live pane id on the server (`list-panes -a`), as a Set for membership
|
|
100
109
|
* probes. Returns null when tmux is unreachable (no server / transient failure)
|
|
101
110
|
* so callers can tell "no panes" apart from "can't tell" — a GC pass must skip,
|
|
@@ -191,6 +191,18 @@ export function paneExists(pane) {
|
|
|
191
191
|
const r = tmux(['display-message', '-p', '-t', pane, '#{pane_id}']);
|
|
192
192
|
return r.ok && r.stdout === pane;
|
|
193
193
|
}
|
|
194
|
+
/** Does this pane exist AND have its command still RUNNING (`#{pane_dead}` = 0)?
|
|
195
|
+
* Distinguishes a pane genuinely hosting a process — a live pi, or a pi still
|
|
196
|
+
* BOOTING whose pid hasn't been recorded yet — from a remain-on-exit corpse
|
|
197
|
+
* frozen after exit (`pane_dead` = 1). Node panes run pi as the pane command
|
|
198
|
+
* (openNodeWindow / respawn-pane), never a shell, so pane-running ⟹ a pi (or
|
|
199
|
+
* its respawn) occupies it. The probe handFocusToManager's live-swap gates on:
|
|
200
|
+
* the recorded `pi_pid` is a stale proxy in the just-revived window (the new pi
|
|
201
|
+
* records its pid only at session_start), but the pane itself never lies. */
|
|
202
|
+
export function paneRunning(pane) {
|
|
203
|
+
const r = tmux(['display-message', '-p', '-t', pane, '#{pane_id}\t#{pane_dead}']);
|
|
204
|
+
return r.ok && r.stdout === `${pane}\t0`;
|
|
205
|
+
}
|
|
194
206
|
/** Every live pane id on the server (`list-panes -a`), as a Set for membership
|
|
195
207
|
* probes. Returns null when tmux is unreachable (no server / transient failure)
|
|
196
208
|
* so callers can tell "no panes" apart from "can't tell" — a GC pass must skip,
|
package/dist/core/spawn.d.ts
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
+
/** Does THIS process sit inside a tmux client (its own $TMUX is set)? NOT the
|
|
2
|
+
* gate for the human surface — see tmuxServerReachable. A headless-broker child
|
|
3
|
+
* strips TMUX/TMUX_PANE (broker.ts, anti-stophook-hijack) yet can still drive
|
|
4
|
+
* the canvas tmux server, so gating the surface on this wrongly starves broker
|
|
5
|
+
* nodes of human-in-the-loop. */
|
|
1
6
|
export declare function isInTmux(): boolean;
|
|
7
|
+
/** Is the canvas tmux SERVER reachable? The canvas runs on the DEFAULT tmux
|
|
8
|
+
* socket (no -L/-S in core/runtime/tmux.ts), so any `crtr` child — including a
|
|
9
|
+
* headless-broker child that has deleted its own $TMUX — can split panes into
|
|
10
|
+
* it. The human surface gates on THIS (can I reach the server to open a pane
|
|
11
|
+
* the user is watching?), not on the caller's $TMUX. `list-clients` exits 0
|
|
12
|
+
* whenever a server is up (even with zero attached clients) and non-zero when
|
|
13
|
+
* no server is running — the true "no tmux to surface into" case that should
|
|
14
|
+
* degrade to the inbox-drain follow-up. */
|
|
15
|
+
export declare function tmuxServerReachable(): boolean;
|
|
2
16
|
export declare function shellQuote(s: string): string;
|
|
3
17
|
/** Count panes in a tmux window (0 outside tmux / on error). With `targetPane`,
|
|
4
18
|
* counts the window THAT pane lives in (the placement decision must reflect the
|
package/dist/core/spawn.js
CHANGED
|
@@ -3,13 +3,30 @@
|
|
|
3
3
|
// A small set of tmux primitives used by the `human` command tree to put the
|
|
4
4
|
// humanloop TUI in a detached pane: spawnAndDetach (open a pane running a given
|
|
5
5
|
// command), countPanesInCurrentWindow (placement decision), plus shellQuote and
|
|
6
|
-
//
|
|
7
|
-
// core/runtime/tmux.ts; this module is only the pane-split path the
|
|
8
|
-
// needs.
|
|
6
|
+
// tmuxServerReachable. The canvas runtime has its own one-window-per-node
|
|
7
|
+
// machinery in core/runtime/tmux.ts; this module is only the pane-split path the
|
|
8
|
+
// human TUI needs.
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
|
+
/** Does THIS process sit inside a tmux client (its own $TMUX is set)? NOT the
|
|
11
|
+
* gate for the human surface — see tmuxServerReachable. A headless-broker child
|
|
12
|
+
* strips TMUX/TMUX_PANE (broker.ts, anti-stophook-hijack) yet can still drive
|
|
13
|
+
* the canvas tmux server, so gating the surface on this wrongly starves broker
|
|
14
|
+
* nodes of human-in-the-loop. */
|
|
10
15
|
export function isInTmux() {
|
|
11
16
|
return Boolean(process.env.TMUX);
|
|
12
17
|
}
|
|
18
|
+
/** Is the canvas tmux SERVER reachable? The canvas runs on the DEFAULT tmux
|
|
19
|
+
* socket (no -L/-S in core/runtime/tmux.ts), so any `crtr` child — including a
|
|
20
|
+
* headless-broker child that has deleted its own $TMUX — can split panes into
|
|
21
|
+
* it. The human surface gates on THIS (can I reach the server to open a pane
|
|
22
|
+
* the user is watching?), not on the caller's $TMUX. `list-clients` exits 0
|
|
23
|
+
* whenever a server is up (even with zero attached clients) and non-zero when
|
|
24
|
+
* no server is running — the true "no tmux to surface into" case that should
|
|
25
|
+
* degrade to the inbox-drain follow-up. */
|
|
26
|
+
export function tmuxServerReachable() {
|
|
27
|
+
const r = spawnSync('tmux', ['list-clients', '-F', '#{client_name}'], { encoding: 'utf8' });
|
|
28
|
+
return r.status === 0;
|
|
29
|
+
}
|
|
13
30
|
export function shellQuote(s) {
|
|
14
31
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
15
32
|
}
|
|
@@ -34,7 +51,7 @@ export function countPanesInCurrentWindow() {
|
|
|
34
51
|
* output on an unresolvable pane, so test for non-empty stdout, not just `.ok`.
|
|
35
52
|
* False outside tmux / on error. */
|
|
36
53
|
export function paneAlive(pane) {
|
|
37
|
-
if (
|
|
54
|
+
if (!/^%\d+$/.test(pane))
|
|
38
55
|
return false;
|
|
39
56
|
const r = spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], {
|
|
40
57
|
encoding: 'utf8',
|
|
@@ -46,7 +63,7 @@ export function paneAlive(pane) {
|
|
|
46
63
|
* specify pane here"); only split-window -t takes a pane. null outside tmux /
|
|
47
64
|
* on a bad pane id / on error / empty. */
|
|
48
65
|
export function paneWindowTarget(pane) {
|
|
49
|
-
if (
|
|
66
|
+
if (!/^%\d+$/.test(pane))
|
|
50
67
|
return null;
|
|
51
68
|
const r = spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{session_name}:#{window_index}'], { encoding: 'utf8' });
|
|
52
69
|
if (r.status !== 0)
|
|
@@ -59,8 +76,6 @@ export function paneWindowTarget(pane) {
|
|
|
59
76
|
* to surface a human prompt in the user's view when nothing in the asking
|
|
60
77
|
* node's graph is focused. null outside tmux / no client / on error. */
|
|
61
78
|
export function attachedClientPane() {
|
|
62
|
-
if (!isInTmux())
|
|
63
|
-
return null;
|
|
64
79
|
const clients = spawnSync('tmux', ['list-clients', '-F', '#{client_name}'], {
|
|
65
80
|
encoding: 'utf8',
|
|
66
81
|
});
|
|
@@ -102,10 +117,15 @@ export function scheduleKillCurrentPane(delaySeconds) {
|
|
|
102
117
|
* as soon as the new pane is up; does NOT wait for the command to finish.
|
|
103
118
|
*/
|
|
104
119
|
export function spawnAndDetach(opts) {
|
|
105
|
-
|
|
120
|
+
// Gate on the CANVAS tmux server's reachability, NOT the caller's $TMUX. A
|
|
121
|
+
// headless-broker child strips its own $TMUX (broker.ts) but the canvas server
|
|
122
|
+
// is on the default socket, so `split-window -t <pane>` against a resolved
|
|
123
|
+
// target pane (resolveHumanTarget) works fine from it. Only a genuine
|
|
124
|
+
// no-server case degrades to the inbox-drain follow-up.
|
|
125
|
+
if (!tmuxServerReachable()) {
|
|
106
126
|
return {
|
|
107
127
|
status: 'not-in-tmux',
|
|
108
|
-
message: 'handoff requires
|
|
128
|
+
message: 'handoff requires a reachable tmux server',
|
|
109
129
|
};
|
|
110
130
|
}
|
|
111
131
|
const splitArgs = [];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast,
|
|
1
|
+
export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, FALLBACK_RUNG, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, } from './schema.js';
|
|
2
2
|
export type { DocKind, Rung, GatePredicate, SubstrateSchema, SubstrateDoc } from './schema.js';
|
|
3
3
|
export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
|
|
4
4
|
export type { NodeConfigSubject } from './subject.js';
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// substrate/ — the unified document-substrate keystone: the frontmatter schema,
|
|
2
|
-
// the 4-rung visibility ladder, the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// the 4-rung visibility ladder, the node-config subject assembly, and gate
|
|
3
|
+
// evaluation. Pure + well-typed; the shared base the CLI verbs, boot render,
|
|
4
|
+
// on-read render, and migrator all build on. See design-substrate.md §4 +
|
|
5
|
+
// plan-substrate.md §2.
|
|
6
6
|
export {
|
|
7
7
|
// kinds
|
|
8
8
|
KINDS, isDocKind,
|
|
9
9
|
// ladder
|
|
10
10
|
RUNGS, rungRank, rungAtLeast,
|
|
11
|
-
//
|
|
12
|
-
|
|
11
|
+
// fallback floor
|
|
12
|
+
FALLBACK_RUNG,
|
|
13
13
|
// parse + render-shared helpers
|
|
14
14
|
parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, } from './schema.js';
|
|
15
15
|
export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Rehydrate a node's on-read dedup set from disk. Returns an empty set when the
|
|
2
|
+
* file is absent, unreadable, or malformed (a fresh transcript, or a node that
|
|
3
|
+
* has not yet surfaced any on-read doc). */
|
|
4
|
+
export declare function loadInjectedDocs(nodeId: string): Set<string>;
|
|
5
|
+
/** Persist a node's on-read dedup set. Called after each read that surfaced a
|
|
6
|
+
* new doc, so the grown set survives a later dormancy. */
|
|
7
|
+
export declare function saveInjectedDocs(nodeId: string, seen: Set<string>): void;
|
|
8
|
+
/** Drop a node's persisted dedup set. Called by the launch paths that start a
|
|
9
|
+
* FRESH transcript, so the new conversation surfaces docs from scratch. */
|
|
10
|
+
export declare function clearInjectedDocs(nodeId: string): void;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// injected-store.ts — durable per-node dedup set for on-read doc injection.
|
|
2
|
+
//
|
|
3
|
+
// The on-read substrate hook (canvas-doc-substrate.ts → renderOnReadDocs) dedups
|
|
4
|
+
// so a given doc surfaces at most once per conversation. That set USED to live
|
|
5
|
+
// only in the pi process heap, cleared on session_start. But a node's logical
|
|
6
|
+
// session — the .jsonl transcript — spans MULTIPLE pi processes: a dormancy →
|
|
7
|
+
// revive(resume) cycle exits the old process and launches a fresh `pi --session`
|
|
8
|
+
// that REUSES the same transcript. The fresh process started with an empty set,
|
|
9
|
+
// so any doc already injected before dormancy got injected AGAIN on the next
|
|
10
|
+
// read — the "fires a second time per session" bug.
|
|
11
|
+
//
|
|
12
|
+
// This module persists the set to `nodes/<id>/injected-docs.json` so the resumed
|
|
13
|
+
// process rehydrates it and skips docs already present in the transcript. The
|
|
14
|
+
// launch paths that begin a FRESH transcript (reviveNode resume=false,
|
|
15
|
+
// reviveInPlace, relaunchRootInPane — all in runtime/revive.ts) call
|
|
16
|
+
// clearInjectedDocs(), so a new conversation starts with an empty set.
|
|
17
|
+
//
|
|
18
|
+
// All ops are best-effort: a failed read/write degrades to a possible re-inject,
|
|
19
|
+
// never a crash — a dedup miss must never break a read or a revive.
|
|
20
|
+
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
21
|
+
import { injectedDocsPath } from '../canvas/paths.js';
|
|
22
|
+
/** Rehydrate a node's on-read dedup set from disk. Returns an empty set when the
|
|
23
|
+
* file is absent, unreadable, or malformed (a fresh transcript, or a node that
|
|
24
|
+
* has not yet surfaced any on-read doc). */
|
|
25
|
+
export function loadInjectedDocs(nodeId) {
|
|
26
|
+
try {
|
|
27
|
+
const arr = JSON.parse(readFileSync(injectedDocsPath(nodeId), 'utf8'));
|
|
28
|
+
if (!Array.isArray(arr))
|
|
29
|
+
return new Set();
|
|
30
|
+
return new Set(arr.filter((x) => typeof x === 'string'));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return new Set();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Persist a node's on-read dedup set. Called after each read that surfaced a
|
|
37
|
+
* new doc, so the grown set survives a later dormancy. */
|
|
38
|
+
export function saveInjectedDocs(nodeId, seen) {
|
|
39
|
+
try {
|
|
40
|
+
writeFileSync(injectedDocsPath(nodeId), JSON.stringify([...seen]));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// best-effort — a failed persist only risks a re-inject, never a crash
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Drop a node's persisted dedup set. Called by the launch paths that start a
|
|
47
|
+
* FRESH transcript, so the new conversation surfaces docs from scratch. */
|
|
48
|
+
export function clearInjectedDocs(nodeId) {
|
|
49
|
+
try {
|
|
50
|
+
rmSync(injectedDocsPath(nodeId), { force: true });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// ignore — absence is the desired state anyway
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -11,10 +11,7 @@ export declare function rungRank(r: Rung): number;
|
|
|
11
11
|
/** Does rung `r` disclose at least as much as `min`? — e.g.
|
|
12
12
|
* `rungAtLeast(doc.systemPromptVisibility, 'name')` ⇒ "shows at boot at all". */
|
|
13
13
|
export declare function rungAtLeast(r: Rung, min: Rung): boolean;
|
|
14
|
-
export declare const
|
|
15
|
-
systemPrompt: Rung;
|
|
16
|
-
fileRead: Rung;
|
|
17
|
-
}>;
|
|
14
|
+
export declare const FALLBACK_RUNG: Rung;
|
|
18
15
|
/** A gate predicate tree, evaluated by predicate.ts (`evalCondition`) against
|
|
19
16
|
* the node-config subject. Typed loosely on purpose — the matcher engine owns
|
|
20
17
|
* validation; structurally it is a field→matcher map with optional
|
|
@@ -63,10 +60,11 @@ export interface SubstrateDoc extends SubstrateSchema {
|
|
|
63
60
|
/** Parse a raw frontmatter record (from `parseFrontmatterGeneric`, via the
|
|
64
61
|
* resolver) into a typed schema with defaults applied. Returns `null` when the
|
|
65
62
|
* record is absent or carries no valid `kind` — i.e. it is not a substrate
|
|
66
|
-
* document and cannot be classified
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* throws. Authoring-time enforcement of
|
|
63
|
+
* document and cannot be classified. Tolerant of every other imperfection (a
|
|
64
|
+
* missing `when-and-why-to-read` defaults to '', a missing/bad rung falls back
|
|
65
|
+
* to the neutral floor `none`), so a renderer mapping over many docs never
|
|
66
|
+
* throws. Authoring-time enforcement of these fields lives in `crtr memory
|
|
67
|
+
* write` (on create) and `crtr memory lint`. */
|
|
70
68
|
export declare function parseSubstrateFrontmatter(fm: Record<string, unknown> | null): SubstrateSchema | null;
|
|
71
69
|
/** Parse a resolved MemoryDoc into a fully-typed SubstrateDoc (schema + the
|
|
72
70
|
* resolver's name/scope/path/body). Returns `null` for a non-substrate doc
|