@north-light/crouter 0.3.217 → 0.3.219

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.
Files changed (74) hide show
  1. package/dist/api/client.d.ts +5 -2
  2. package/dist/api/client.js +7 -1
  3. package/dist/api/dto/canvas.d.ts +9 -0
  4. package/dist/api/dto/nodes.d.ts +27 -1
  5. package/dist/builtin-memory/04-base-worker.md +7 -1
  6. package/dist/builtin-memory/internal/plugins.md +67 -0
  7. package/dist/builtin-memory/memory-read-orientation.md +13 -0
  8. package/dist/clients/attach/chrome/bash-jobs.js +1 -1
  9. package/dist/clients/attach/photon_rs_bg.wasm +0 -0
  10. package/dist/clients/attach/viewer.js +478 -478
  11. package/dist/commands/human/shared.js +1 -1
  12. package/dist/commands/memory/edit.js +6 -1
  13. package/dist/commands/memory/lint.d.ts +1 -6
  14. package/dist/commands/memory/lint.js +18 -126
  15. package/dist/commands/memory/list.d.ts +1 -0
  16. package/dist/commands/memory/list.js +17 -2
  17. package/dist/commands/memory/read.js +15 -6
  18. package/dist/commands/memory/shared.d.ts +32 -2
  19. package/dist/commands/memory/shared.js +179 -0
  20. package/dist/commands/memory/write.js +6 -1
  21. package/dist/commands/pkg/browse/catalog.js +2 -0
  22. package/dist/commands/pkg/browse/model.d.ts +4 -1
  23. package/dist/commands/pkg/plugin-manage.js +209 -148
  24. package/dist/core/bash-jobs.d.ts +2 -5
  25. package/dist/core/bash-jobs.js +4 -8
  26. package/dist/core/command-plugins/bundle.d.ts +4 -1
  27. package/dist/core/command-plugins/bundle.js +16 -2
  28. package/dist/core/human/component-docs.js +1 -0
  29. package/dist/core/human/scan.d.ts +5 -4
  30. package/dist/core/human/scan.js +7 -4
  31. package/dist/core/io.js +3 -2
  32. package/dist/core/manifest.d.ts +2 -0
  33. package/dist/core/manifest.js +5 -0
  34. package/dist/core/memory/extensions.d.ts +30 -0
  35. package/dist/core/memory/extensions.js +219 -0
  36. package/dist/core/preview-result-path.d.ts +4 -0
  37. package/dist/core/preview-result-path.js +25 -0
  38. package/dist/core/runtime/launch-config-worker.d.ts +1 -0
  39. package/dist/core/runtime/launch-config-worker.js +12 -0
  40. package/dist/core/runtime/launch.d.ts +16 -2
  41. package/dist/core/runtime/launch.js +55 -21
  42. package/dist/core/runtime/promote.d.ts +20 -2
  43. package/dist/core/runtime/promote.js +63 -59
  44. package/dist/core/runtime/recycle.js +2 -2
  45. package/dist/core/runtime/reset.js +2 -2
  46. package/dist/core/runtime/spawn.js +3 -3
  47. package/dist/core/runtime/warm-pool.js +9 -7
  48. package/dist/core/substrate/frontmatter-validation.d.ts +13 -0
  49. package/dist/core/substrate/frontmatter-validation.js +101 -0
  50. package/dist/core/substrate/index.d.ts +1 -0
  51. package/dist/core/substrate/index.js +1 -0
  52. package/dist/daemon/api/__tests__/bridge-heartbeat.test.d.ts +1 -0
  53. package/dist/daemon/api/__tests__/bridge-heartbeat.test.js +30 -0
  54. package/dist/daemon/api/__tests__/nodes-activity-query.test.d.ts +1 -0
  55. package/dist/daemon/api/__tests__/nodes-activity-query.test.js +101 -0
  56. package/dist/daemon/api/__tests__/seam/api-server.test.js +30 -0
  57. package/dist/daemon/api/bridge.d.ts +6 -0
  58. package/dist/daemon/api/bridge.js +39 -2
  59. package/dist/daemon/api/handlers/canvas.js +3 -0
  60. package/dist/daemon/api/handlers/inbox.js +4 -3
  61. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  62. package/dist/daemon/api/handlers/nodes.js +117 -36
  63. package/dist/daemon/api/handlers/reports.d.ts +4 -0
  64. package/dist/daemon/api/handlers/reports.js +14 -8
  65. package/dist/daemon/crtrd.js +7 -5
  66. package/dist/daemon/manage.d.ts +3 -0
  67. package/dist/daemon/manage.js +14 -0
  68. package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -3
  69. package/dist/pi-extensions/canvas-bash-valve.js +19 -6
  70. package/dist/pi-extensions/canvas-preview-result.d.ts +0 -8
  71. package/dist/pi-extensions/canvas-preview-result.js +9 -23
  72. package/dist/types.d.ts +31 -0
  73. package/package.json +1 -1
  74. package/runtime.lock.json +2 -2
@@ -21,75 +21,48 @@
21
21
  import { getNode, updateNode } from '../canvas/index.js';
22
22
  import { transition } from './lifecycle.js';
23
23
  import { ensureDaemon } from '../../daemon/manage.js';
24
- import { buildLaunchSpec } from './launch.js';
24
+ import { buildLaunchSpec, buildLaunchSpecAsync } from './launch.js';
25
25
  import { hasRoadmap, seedRoadmap, roadmapPath } from './roadmap.js';
26
26
  import { readGoal, goalPath } from './kickoff.js';
27
- /** Shared reshape machinery: rewrite kind and/or model tier — and, when the
28
- * caller supplies them, mode/lifecycle — by rebuilding the launch spec so the
29
- * change is durable across a future revive. MODE-PRESERVING by default (omit
30
- * `mode`/`lifecycle` and the node's own current values carry through) — this
31
- * is what a plain `crtr node yield --kind/--model` reshape rides, with no
32
- * mode flip and no roadmap seeding. `promote()` below is a thin wrapper that
33
- * also forces mode→orchestrator and seeds the roadmap. */
34
- export function reshapeNode(nodeId, opts = {}, beforePersist) {
27
+ function reshapeInputs(nodeId, opts) {
35
28
  const node = getNode(nodeId);
36
29
  if (node === null)
37
30
  throw new Error(`unknown node: ${nodeId}`);
38
- // The node may specialize as it reshapes; default to its current kind.
39
- const targetKind = opts.kind ?? node.kind;
40
- // ...and may raise/change its model tier; default to its current pin (so a
41
- // reshape with no --model preserves whatever it was running on).
42
- const targetModel = opts.model ?? node.model_override ?? undefined;
43
- const targetMode = opts.mode ?? node.mode;
44
- const targetLifecycle = opts.lifecycle ?? node.lifecycle;
45
- const targetProfileId = opts.profileId === undefined ? node.profile_id : opts.profileId;
46
- // Rewrite the launch spec to the target kind/mode's persona so the *next*
47
- // revive comes back as this exact shape (polymorph stage 2). nodeEnv reads
48
- // meta.{kind,mode}, so CRTR_KIND/CRTR_MODE flip immediately for the live
49
- // process's children too.
50
- const { launch } = buildLaunchSpec(targetKind, targetMode, {
51
- lifecycle: targetLifecycle,
52
- hasManager: node.parent !== null,
53
- // A model tier chosen on this call (opts.model) overrides the persona
54
- // default and is persisted below; absent one, the existing pin carries
55
- // across the polymorph (the persona default is recomputed fresh for
56
- // targetKind).
57
- model: targetModel,
58
- // opts.model given — an explicit new pick — lets buildLaunchSpec re-decide
59
- // the pin from ITS raw shape (undefined here). No opts.model — targetModel
60
- // just re-supplies the current value for resolution, not a new user
61
- // choice — so carry the node's existing pin verbatim instead.
62
- modelProviderPinned: opts.model === undefined ? node.launch?.modelProviderPinned : undefined,
63
- modelIntent: opts.model === undefined ? node.launch?.modelIntent : undefined,
64
- // Exact-model human-work forks must stay fail-closed after a polymorph.
65
- // Dropping this bit produces a recipe reviveNode refuses to relaunch.
66
- modelExact: opts.model === undefined ? node.launch?.modelExact : undefined,
67
- // Resolve kind/ladder config against the NODE's own cwd + profile, not
68
- // this process's ambient scope.
69
- cwd: node.cwd,
70
- profileId: targetProfileId,
71
- });
72
- // Promotion seeds after its target launch has been built but before the
73
- // mutation is persisted, preserving the original promote() failure order.
31
+ const kind = opts.kind ?? node.kind;
32
+ const mode = opts.mode ?? node.mode;
33
+ const lifecycle = opts.lifecycle ?? node.lifecycle;
34
+ const profileId = opts.profileId === undefined ? node.profile_id ?? null : opts.profileId;
35
+ return {
36
+ node, kind, mode, lifecycle, profileId,
37
+ launchOpts: {
38
+ lifecycle, hasManager: node.parent !== null, model: opts.model ?? node.model_override ?? undefined,
39
+ modelProviderPinned: opts.model === undefined ? node.launch?.modelProviderPinned : undefined,
40
+ modelIntent: opts.model === undefined ? node.launch?.modelIntent : undefined,
41
+ modelExact: opts.model === undefined ? node.launch?.modelExact : undefined,
42
+ cwd: node.cwd, profileId,
43
+ },
44
+ };
45
+ }
46
+ function persistReshape(nodeId, opts, input, launch, beforePersist) {
74
47
  beforePersist?.();
75
- // If the handle is still the spawn-time kind default (name === old kind),
76
- // carry it to the new kind so the "name === kind ⇒ default, drop it" invariant
77
- // in fullName() holds across a kind change. Otherwise a node spawned `general`
78
- // (name="general") that reshapes to `plan` would surface a stale "general"
79
- // handle in front of its real description.
80
- const carriesDefaultHandle = node.name === node.kind && targetKind !== node.kind;
48
+ const carriesDefaultHandle = input.node.name === input.node.kind && input.kind !== input.node.kind;
81
49
  return updateNode(nodeId, {
82
- kind: targetKind,
83
- mode: targetMode,
84
- lifecycle: targetLifecycle,
85
- profile_id: targetProfileId,
86
- launch,
87
- ...(carriesDefaultHandle ? { name: targetKind } : {}),
88
- // Persist a newly-chosen tier so it is durable across future revives; omit
89
- // when unchanged so the existing pin (or persona default) stands.
50
+ kind: input.kind, mode: input.mode, lifecycle: input.lifecycle, profile_id: input.profileId, launch,
51
+ ...(carriesDefaultHandle ? { name: input.kind } : {}),
90
52
  ...(opts.model !== undefined ? { model_override: opts.model } : {}),
91
53
  });
92
54
  }
55
+ /** Synchronous reshape for CLI callers outside the daemon. */
56
+ export function reshapeNode(nodeId, opts = {}, beforePersist) {
57
+ const input = reshapeInputs(nodeId, opts);
58
+ return persistReshape(nodeId, opts, input, buildLaunchSpec(input.kind, input.mode, input.launchOpts).launch, beforePersist);
59
+ }
60
+ /** Daemon-safe reshape: network-backed config resolution happens off its event loop. */
61
+ export async function reshapeNodeAsync(nodeId, opts = {}, beforePersist) {
62
+ const input = reshapeInputs(nodeId, opts);
63
+ const { launch } = await buildLaunchSpecAsync(input.kind, input.mode, input.launchOpts);
64
+ return persistReshape(nodeId, opts, input, launch, beforePersist);
65
+ }
93
66
  /** Promote a node to an orchestrator (mode→orchestrator), optionally
94
67
  * specializing its kind (e.g. a `general` worker becoming a
95
68
  * `developer.orchestrator`) and optionally also making it resident. Idempotent:
@@ -129,6 +102,25 @@ export function promote(nodeId, opts = {}) {
129
102
  goalPath: goalPath(nodeId),
130
103
  };
131
104
  }
105
+ /** Daemon-safe promotion variant; preserves the same validation/seed ordering. */
106
+ export async function promoteAsync(nodeId, opts = {}) {
107
+ const node = getNode(nodeId);
108
+ if (node === null)
109
+ throw new Error(`unknown node: ${nodeId}`);
110
+ let roadmapWritten = false;
111
+ const meta = await reshapeNodeAsync(nodeId, {
112
+ ...(opts.kind !== undefined ? { kind: opts.kind } : {}),
113
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
114
+ mode: 'orchestrator', lifecycle: opts.resident === true ? 'resident' : node.lifecycle,
115
+ }, () => {
116
+ if (!hasRoadmap(nodeId)) {
117
+ const goal = readGoal(nodeId);
118
+ seedRoadmap(nodeId, goal !== null && goal.trim() !== '' ? { goal: goal.trim() } : {});
119
+ roadmapWritten = true;
120
+ }
121
+ });
122
+ return { meta, roadmapWritten, roadmapPath: roadmapPath(nodeId), goalPath: goalPath(nodeId) };
123
+ }
132
124
  /** Request a refresh-yield: discard in-memory context and revive fresh, mode
133
125
  * and lifecycle UNCHANGED, against the roadmap if one exists else the
134
126
  * original goal (`buildReviveKickoff` falls back to it). A plain yield is
@@ -138,6 +130,18 @@ export function promote(nodeId, opts = {}) {
138
130
  * same path `crtr node promote` would — mode flips to orchestrator and a
139
131
  * roadmap scaffold is seeded. Sets intent='refresh'; the stophook shuts the
140
132
  * process down on the next stop and the daemon revives it fresh. */
133
+ export async function requestYieldAsync(nodeId, opts = {}, deps = {}) {
134
+ const node = getNode(nodeId);
135
+ if (node === null)
136
+ throw new Error(`unknown node: ${nodeId}`);
137
+ if (opts.promote === true) {
138
+ await promoteAsync(nodeId, { ...(opts.kind !== undefined ? { kind: opts.kind } : {}), ...(opts.model !== undefined ? { model: opts.model } : {}) });
139
+ }
140
+ else if (opts.kind !== undefined || opts.model !== undefined) {
141
+ await reshapeNodeAsync(nodeId, { ...(opts.kind !== undefined ? { kind: opts.kind } : {}), ...(opts.model !== undefined ? { model: opts.model } : {}) });
142
+ }
143
+ return { ...requestYield(nodeId, {}, deps), promoted: opts.promote === true };
144
+ }
141
145
  export function requestYield(nodeId, opts = {}, deps = {}) {
142
146
  const ensure = deps.ensure ?? ensureDaemon;
143
147
  const node = getNode(nodeId);
@@ -21,7 +21,7 @@ import { getNode, setPresence, setFocusOccupant, fullName } from '../canvas/inde
21
21
  import { reportsDir } from '../canvas/paths.js';
22
22
  import { pushFinal } from '../feed/feed.js';
23
23
  import { spawnNode, rootOfSpine } from './nodes.js';
24
- import { buildLaunchSpec, buildPiArgv } from './launch.js';
24
+ import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
25
25
  import { focusOf, respawnPaneSync, setPaneOption } from './placement.js';
26
26
  import { waitForBrokerViewSocket, viewerSplitEnv } from './placement-tmux.js';
27
27
  import { headlessBrokerHost } from './host.js';
@@ -102,7 +102,7 @@ export async function recycleNode(nodeId, callerPane) {
102
102
  // ambient scope) and carry the profile onto the fresh root — it is the same
103
103
  // person's seat, so profile-scope defaults (e.g. a persisted /model default)
104
104
  // must keep applying.
105
- const { launch } = buildLaunchSpec('general', 'base', { lifecycle: 'resident', hasManager: false, cwd: meta.cwd, profileId: meta.profile_id });
105
+ const { launch } = await buildLaunchSpecAsync('general', 'base', { lifecycle: 'resident', hasManager: false, cwd: meta.cwd, profileId: meta.profile_id });
106
106
  const root = spawnNode({
107
107
  kind: 'general',
108
108
  mode: 'base',
@@ -25,7 +25,7 @@ import { transition } from './lifecycle.js';
25
25
  import { headlessBrokerHost } from './host.js';
26
26
  import { tearDownNode, focusOf, registerViewerFocus, respawnPaneSync, windowOfPane, renameWindow, } from './placement.js';
27
27
  import { viewerSplitEnv, waitForBrokerViewSocket } from './placement-tmux.js';
28
- import { buildLaunchSpec, buildPiArgv } from './launch.js';
28
+ import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
29
29
  import { spawnNode, rootOfSpine } from './nodes.js';
30
30
  // ---------------------------------------------------------------------------
31
31
  // reapDescendants — tear down a root's descendant sub-DAG (shared helper)
@@ -83,7 +83,7 @@ export async function relaunchRoot(oldId, deps = {}) {
83
83
  // --- mint + boot the NEW broker FIRST (a failure here leaves the old root
84
84
  // untouched) ---
85
85
  // A relaunched root is a fresh resident base, exactly like the front door.
86
- const { launch } = buildLaunchSpec(old.kind, 'base', {
86
+ const { launch } = await buildLaunchSpecAsync(old.kind, 'base', {
87
87
  lifecycle: 'resident',
88
88
  hasManager: false,
89
89
  model: old.model_override ?? undefined,
@@ -14,7 +14,7 @@ import { isAbsolute, resolve, join } from 'node:path';
14
14
  import { spawnNode, currentNodeContext, rootOfSpine, newNodeId, preflightNodeId } from './nodes.js';
15
15
  import { loadProfileManifest } from '../profiles/manifest.js';
16
16
  import { selectProfileForCwd } from '../profiles/select.js';
17
- import { buildLaunchSpec, buildPiArgv } from './launch.js';
17
+ import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
18
18
  import { createManagedWorktree, rollbackManagedWorktree } from '../worktree.js';
19
19
  import { usage, brokerLaunchFailed } from '../errors.js';
20
20
  import { writeGoal } from './kickoff.js';
@@ -237,7 +237,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
237
237
  if (opts.nodeId !== undefined)
238
238
  preflightNodeId(opts.nodeId);
239
239
  const profileId = await resolveProfileId(opts.profile, spawner, opts.cwd);
240
- const preflightLaunch = buildLaunchSpec(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: opts.cwd, profileId }).launch;
240
+ const preflightLaunch = (await buildLaunchSpecAsync(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: opts.cwd, profileId })).launch;
241
241
  await assertLaunchModelRegistered(requestFromLaunch(preflightLaunch), { cwd: opts.cwd, profileId });
242
242
  const forkFrom = opts.forkFrom !== undefined ? resolveForkSource(opts.forkFrom) : undefined;
243
243
  let managedWorktree;
@@ -255,7 +255,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
255
255
  // this process's ambient scope (a front-door shell has no CRTR_PROFILE_ID,
256
256
  // which silently hid every profile-scope kind override).
257
257
  const launch = wantsWorktree
258
- ? buildLaunchSpec(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: spawnCwd, profileId }).launch
258
+ ? (await buildLaunchSpecAsync(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: spawnCwd, profileId })).launch
259
259
  : preflightLaunch;
260
260
  if (wantsWorktree) {
261
261
  await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd: spawnCwd, profileId });
@@ -62,7 +62,7 @@ import { claimWarmSpare, deleteNode, getNode, listNodes, listWarmSpares, registe
62
62
  import { recordedPidLiveness } from '../canvas/pid.js';
63
63
  import { nowIso } from '../fs-utils.js';
64
64
  import { spawnChildPrepared } from './spawn.js';
65
- import { buildLaunchSpec } from './launch.js';
65
+ import { buildLaunchSpecAsync } from './launch.js';
66
66
  import { setModelLive } from './model-swap.js';
67
67
  import { setSituationalContextLive } from './situational-live.js';
68
68
  import { deliverBearingsLive } from './deliver-live.js';
@@ -100,8 +100,8 @@ function warmRecipeKey(cwd, profileId, launch) {
100
100
  }
101
101
  /** Resolve a request's frozen half. Both cwd and profile arrive already
102
102
  * resolved from the create handler, so this is pure launch-spec construction. */
103
- function resolveRecipe(request) {
104
- const { launch } = buildLaunchSpec(request.kind, request.mode, {
103
+ async function resolveRecipe(request) {
104
+ const { launch } = await buildLaunchSpecAsync(request.kind, request.mode, {
105
105
  lifecycle: 'resident',
106
106
  hasManager: false,
107
107
  ...(request.model !== null ? { model: request.model } : {}),
@@ -114,7 +114,7 @@ function resolveRecipe(request) {
114
114
  * pool cannot serve it. The caller falls back to a cold spawn; a claim never
115
115
  * fails the create. */
116
116
  export async function claimWarmNode(request) {
117
- const recipe = resolveRecipe(request);
117
+ const recipe = await resolveRecipe(request);
118
118
  const created = nowIso();
119
119
  // The row's stamp moves inside the claim transaction; meta.json (the identity
120
120
  // source of truth `rebuildIndex` re-derives the row from) follows with the
@@ -261,7 +261,9 @@ export function refillWarmPool(request) {
261
261
  // Mint at the kind's DEFAULT model and with NO situational context: both
262
262
  // are claim-time properties, so baking either in would only narrow who the
263
263
  // spare can serve.
264
- mint(resolveRecipe({ ...request, model: null, situationalContext: null }));
264
+ void resolveRecipe({ ...request, model: null, situationalContext: null })
265
+ .then(mint)
266
+ .catch((err) => console.error(`[warm-pool] recipe resolution failed: ${err.message}`));
265
267
  }
266
268
  catch (err) {
267
269
  console.error(`[warm-pool] recipe resolution failed: ${err.message}`);
@@ -349,7 +351,7 @@ export function prewarmRecentRecipes() {
349
351
  // `general`/`base` is only the shape the spare BOOTS as — the key it is
350
352
  // filed under excludes everything a claim can reshape, so any kind can
351
353
  // claim it.
352
- mint(resolveRecipe({
354
+ void resolveRecipe({
353
355
  kind: 'general',
354
356
  mode: 'base',
355
357
  cwd: candidate.cwd,
@@ -357,7 +359,7 @@ export function prewarmRecentRecipes() {
357
359
  model: null,
358
360
  situationalContext: null,
359
361
  name: null,
360
- }));
362
+ }).then(mint).catch((err) => console.error(`[warm-pool] prewarm failed for ${candidate.cwd}: ${err.message}`));
361
363
  }
362
364
  catch (err) {
363
365
  console.error(`[warm-pool] prewarm failed for ${candidate.cwd}: ${err.message}`);
@@ -0,0 +1,13 @@
1
+ /** Strict validation of one raw routing entry. Runtime parsing deliberately
2
+ * tolerates malformed docs; this authoring/preflight contract does not. */
3
+ export declare function lintSubstrateSurfaces(value: unknown): string | null;
4
+ /** Strict substrate frontmatter checker shared by lint and package preflight. */
5
+ export declare function lintSubstrateFrontmatter(fm: Record<string, unknown> | null): string | null;
6
+ /** Compatibility name for consumers that adopted lint's former export. */
7
+ export declare const lintSubstrateSchema: typeof lintSubstrateFrontmatter;
8
+ /** Parsed routing entries for checks that run after strict validation. */
9
+ export declare function parsedSubstrateSurfaces(fm: Record<string, unknown>): {
10
+ on: string;
11
+ at: string;
12
+ match?: string[];
13
+ }[];
@@ -0,0 +1,101 @@
1
+ import { isDocKind, parseSubstrateFrontmatter, SURFACE_EVENTS, SURFACE_RUNGS } from './schema.js';
2
+ const RETIRED_FIELDS = ['system-prompt-visibility', 'file-read-visibility', 'applies-to', 'read-when'];
3
+ const SURFACE_ENTRY_KEYS = ['on', 'match', 'match-frontmatter', 'at'];
4
+ const SUPPRESSIBLE_RULES = ['length', 'broad-memory-read'];
5
+ /** Strict validation of one raw routing entry. Runtime parsing deliberately
6
+ * tolerates malformed docs; this authoring/preflight contract does not. */
7
+ export function lintSubstrateSurfaces(value) {
8
+ if (value === undefined)
9
+ return null;
10
+ if (!Array.isArray(value))
11
+ return `invalid surfaces: ${JSON.stringify(value)} (expected a list of {on, at, match?, match-frontmatter?} entries)`;
12
+ for (const raw of value) {
13
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
14
+ return `invalid surfaces entry: ${JSON.stringify(raw)} (expected an {on, at, match?, match-frontmatter?} object)`;
15
+ }
16
+ const entry = raw;
17
+ for (const key of Object.keys(entry)) {
18
+ if (!SURFACE_ENTRY_KEYS.includes(key)) {
19
+ return `invalid surfaces entry: unknown key \`${key}\` (an entry carries only on, match, match-frontmatter, at)`;
20
+ }
21
+ }
22
+ const on = entry['on'];
23
+ if (typeof on !== 'string' || !SURFACE_EVENTS.includes(on)) {
24
+ return `invalid surfaces entry \`on\`: ${JSON.stringify(on)} (expected ${SURFACE_EVENTS.join('|')})`;
25
+ }
26
+ const at = entry['at'];
27
+ if (typeof at !== 'string' || !SURFACE_RUNGS.includes(at)) {
28
+ return `invalid surfaces entry \`at\`: ${JSON.stringify(at)} (expected ${SURFACE_RUNGS.join('|')})`;
29
+ }
30
+ let hasMatch = false;
31
+ if (entry['match'] !== undefined) {
32
+ if (on === 'boot' || on === 'workspace-open') {
33
+ return `invalid surfaces entry: \`match\` is meaningless on \`${on}\` — the entry's presence is the match; drop it`;
34
+ }
35
+ const match = entry['match'];
36
+ const globs = typeof match === 'string' ? [match] : Array.isArray(match) && match.every((glob) => typeof glob === 'string') ? match : null;
37
+ if (globs === null || globs.length === 0 || globs.some((glob) => glob.trim() === '')) {
38
+ return `invalid surfaces entry \`match\`: ${JSON.stringify(match)} (expected a non-empty glob or non-empty glob list)`;
39
+ }
40
+ hasMatch = true;
41
+ }
42
+ const matchFrontmatter = entry['match-frontmatter'];
43
+ if (matchFrontmatter !== undefined) {
44
+ if (on !== 'read')
45
+ return 'invalid surfaces entry: `match-frontmatter` is a `read`-event predicate only';
46
+ if (matchFrontmatter === null || typeof matchFrontmatter !== 'object' || Array.isArray(matchFrontmatter)) {
47
+ return `invalid surfaces entry \`match-frontmatter\`: ${JSON.stringify(matchFrontmatter)} (expected a field→matcher object)`;
48
+ }
49
+ }
50
+ if ((on === 'read' || on === 'memory-read' || on === 'command') && !hasMatch && matchFrontmatter === undefined) {
51
+ return `invalid surfaces entry: a \`${on}\` entry requires \`match\`${on === 'read' ? ' or `match-frontmatter`' : ''}`;
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+ /** Strict substrate frontmatter checker shared by lint and package preflight. */
57
+ export function lintSubstrateFrontmatter(fm) {
58
+ if (fm === null)
59
+ return 'missing frontmatter: a memory store doc requires `kind: knowledge|preference`';
60
+ if (!isDocKind(fm.kind))
61
+ return `invalid kind: ${JSON.stringify(fm.kind)} (expected knowledge|preference)`;
62
+ if ('when' in fm || 'why' in fm) {
63
+ return 'retired `when`/`why` keys: merge them into one `when-and-why-to-read` line — "When <circumstance>, this <kind> should be read because <broader downstream payoff>." WHY is the benefit unlocked by reading, not the document summary, rule, or obedience rationale.';
64
+ }
65
+ if (typeof fm['when-and-why-to-read'] !== 'string' || fm['when-and-why-to-read'].trim() === '') {
66
+ return 'missing `when-and-why-to-read`: one routing line — "When <circumstance>, this <kind> should be read because <broader downstream payoff>." WHY is the benefit unlocked by reading, not the document summary, rule, or obedience rationale.';
67
+ }
68
+ for (const field of RETIRED_FIELDS) {
69
+ if (field in fm) {
70
+ return `retired \`${field}\` key: the visibility axes were replaced by \`surfaces\` event routing — run \`crtr sys migrate\` to convert old-format docs, or author \`surfaces\` entries by hand (\`crtr memory write -h\`)`;
71
+ }
72
+ }
73
+ if (fm['unlisted'] !== undefined && typeof fm['unlisted'] !== 'boolean') {
74
+ return `invalid unlisted: ${JSON.stringify(fm['unlisted'])} (expected a boolean)`;
75
+ }
76
+ const surfacesError = lintSubstrateSurfaces(fm['surfaces']);
77
+ if (surfacesError !== null)
78
+ return surfacesError;
79
+ if (fm.gate !== undefined && (fm.gate === null || typeof fm.gate !== 'object' || Array.isArray(fm.gate))) {
80
+ return `invalid gate: ${JSON.stringify(fm.gate)} (expected a field→matcher object)`;
81
+ }
82
+ if (fm.slash !== undefined && typeof fm.slash !== 'boolean') {
83
+ return `invalid slash: ${JSON.stringify(fm.slash)} (expected a boolean)`;
84
+ }
85
+ if (fm.rationale !== undefined && typeof fm.rationale !== 'string') {
86
+ return `invalid rationale: ${JSON.stringify(fm.rationale)} (expected a string)`;
87
+ }
88
+ if (fm['lint-ignore'] !== undefined) {
89
+ const rules = Array.isArray(fm['lint-ignore']) ? fm['lint-ignore'] : [fm['lint-ignore']];
90
+ if (rules.length === 0 || !rules.every((rule) => typeof rule === 'string' && SUPPRESSIBLE_RULES.includes(rule))) {
91
+ return `invalid lint-ignore: ${JSON.stringify(fm['lint-ignore'])} (suppressible rules: ${SUPPRESSIBLE_RULES.map((rule) => `\`${rule}\``).join(', ')})`;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+ /** Compatibility name for consumers that adopted lint's former export. */
97
+ export const lintSubstrateSchema = lintSubstrateFrontmatter;
98
+ /** Parsed routing entries for checks that run after strict validation. */
99
+ export function parsedSubstrateSurfaces(fm) {
100
+ return parseSubstrateFrontmatter(fm)?.surfaces ?? [];
101
+ }
@@ -1,5 +1,6 @@
1
1
  export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, SURFACE_EVENTS, SURFACE_RUNGS, bootRung, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, normalizeNameSegment, normalizeDocName, resolveDocName, } from './schema.js';
2
2
  export type { DocKind, Rung, GatePredicate, SubstrateSchema, SubstrateDoc, SurfaceEntry, SurfaceEvent, SurfaceRung } from './schema.js';
3
+ export { lintSubstrateFrontmatter, lintSubstrateSchema, lintSubstrateSurfaces, parsedSubstrateSurfaces } from './frontmatter-validation.js';
3
4
  export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, owningRootOf, underOwningRoot } from './surface-match.js';
4
5
  export { dirDedupKey, docsByName, isDirName, ancestorDirsOf, renderDirListing } from './listings.js';
5
6
  export { deliveredAtOrAbove, recordDelivery, mergeInjectedDocs } from './injected-store.js';
@@ -16,6 +16,7 @@ parseSubstrateFrontmatter, parseSubstrateDoc, previewLine,
16
16
  normalizeNameSegment, normalizeDocName,
17
17
  // substrate identity (explicit-name-then-path-fallback)
18
18
  resolveDocName, } from './schema.js';
19
+ export { lintSubstrateFrontmatter, lintSubstrateSchema, lintSubstrateSurfaces, parsedSubstrateSurfaces } from './frontmatter-validation.js';
19
20
  export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, owningRootOf, underOwningRoot } from './surface-match.js';
20
21
  export { dirDedupKey, docsByName, isDirName, ancestorDirsOf, renderDirListing } from './listings.js';
21
22
  export { deliveredAtOrAbove, recordDelivery, mergeInjectedDocs } from './injected-store.js';
@@ -0,0 +1,30 @@
1
+ import assert from 'node:assert/strict';
2
+ import { EventEmitter } from 'node:events';
3
+ import test from 'node:test';
4
+ import { startRemoteAttachHeartbeat } from '../bridge.js';
5
+ class FakeSocket extends EventEmitter {
6
+ pings = 0;
7
+ terminated = 0;
8
+ ping() { this.pings += 1; }
9
+ terminate() { this.terminated += 1; }
10
+ }
11
+ function sleep(ms) {
12
+ return new Promise((resolve) => setTimeout(resolve, ms));
13
+ }
14
+ test('remote attach heartbeat pings an idle viewer and keeps a ponging observer alive', async () => {
15
+ const ws = new FakeSocket();
16
+ const stop = startRemoteAttachHeartbeat(ws, 20);
17
+ await sleep(25);
18
+ assert.equal(ws.pings, 1, 'an idle attach receives a WebSocket ping before proxy idle timeout');
19
+ ws.emit('pong');
20
+ await sleep(10);
21
+ assert.equal(ws.terminated, 0, 'a pong renews the heartbeat without terminating the observer');
22
+ stop();
23
+ });
24
+ test('remote attach heartbeat drops only an unresponsive observer socket', async () => {
25
+ const ws = new FakeSocket();
26
+ const stop = startRemoteAttachHeartbeat(ws, 5);
27
+ await sleep(16);
28
+ assert.equal(ws.terminated, 1, 'two missed heartbeat intervals terminate the detached viewer');
29
+ stop();
30
+ });
@@ -0,0 +1,101 @@
1
+ import { after, before, beforeEach, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { createNode } from '../../../core/canvas/canvas.js';
7
+ import { closeDb } from '../../../core/canvas/db.js';
8
+ import { reportsDir } from '../../../core/canvas/paths.js';
9
+ import { invalidateTicketCounts } from '../../../core/canvas/attention.js';
10
+ import { bindTicketReplyRoute } from '../../../core/human/convention.js';
11
+ import { ticketDir } from '../../../core/human/root.js';
12
+ import { submitPage } from '../../../core/human/tickets.js';
13
+ import { handleList } from '../handlers/nodes.js';
14
+ const previousCrtrHome = process.env['CRTR_HOME'];
15
+ let home = '';
16
+ before(() => {
17
+ closeDb();
18
+ home = mkdtempSync(join(tmpdir(), 'crtr-nodes-activity-query-'));
19
+ process.env['CRTR_HOME'] = home;
20
+ });
21
+ beforeEach(() => {
22
+ closeDb();
23
+ invalidateTicketCounts();
24
+ rmSync(home, { recursive: true, force: true });
25
+ mkdirSync(home, { recursive: true });
26
+ });
27
+ after(() => {
28
+ closeDb();
29
+ rmSync(home, { recursive: true, force: true });
30
+ if (previousCrtrHome === undefined)
31
+ delete process.env['CRTR_HOME'];
32
+ else
33
+ process.env['CRTR_HOME'] = previousCrtrHome;
34
+ });
35
+ function node(id, overrides = {}) {
36
+ return {
37
+ node_id: id,
38
+ name: id,
39
+ created: '2026-08-17T00:00:00.000Z',
40
+ cwd: home,
41
+ kind: 'developer',
42
+ mode: 'base',
43
+ lifecycle: 'terminal',
44
+ status: 'active',
45
+ parent: null,
46
+ profile_id: 'activity-profile-7f3a9c01',
47
+ launch: { model: 'anthropic/original', extensions: [], env: {} },
48
+ ...overrides,
49
+ };
50
+ }
51
+ function list(query) {
52
+ const result = handleList({
53
+ method: 'GET',
54
+ path: '/v1/nodes',
55
+ params: {},
56
+ query: new URLSearchParams(query),
57
+ body: null,
58
+ });
59
+ assert.equal(result.status, 200);
60
+ return result.body;
61
+ }
62
+ function report(nodeId, stamp, tier, body) {
63
+ const filename = `${stamp}-${tier}.md`;
64
+ writeFileSync(join(reportsDir(nodeId), filename), `---\nkind: ${tier}\nts: ${stamp}\n---\n${body}\n`);
65
+ return filename;
66
+ }
67
+ test('list activity query composes profile, lifecycle, topology filters and report enrichment', () => {
68
+ const finalName = '20260817T100000-final.md';
69
+ createNode(node('root', {
70
+ status: 'done',
71
+ final_report: finalName,
72
+ finalized_at: '2026-08-17T10:00:00.000Z',
73
+ }));
74
+ report('root', '20260817T090000', 'update', 'Started the task');
75
+ report('root', '20260817T100000', 'final', 'Finished the task');
76
+ report('root', '20260817T110000', 'update', 'Still monitoring the task');
77
+ createNode(node('bridge', { kind: 'human', parent: 'root' }));
78
+ const page = join(home, 'pending-page.tsx');
79
+ writeFileSync(page, `export default function PendingPage() {
80
+ return <Page title="Need a decision"><UserText id="answer" initialText="" /></Page>;
81
+ }
82
+ `);
83
+ const ticket = submitPage({
84
+ dir: ticketDir('bridge'),
85
+ sourceFile: page,
86
+ delivery: { placement: 'inline', inbox: true, reply: true },
87
+ });
88
+ bindTicketReplyRoute(ticket.dir, 'bridge');
89
+ invalidateTicketCounts();
90
+ createNode(node('child', { parent: 'root' }));
91
+ createNode(node('resident', { lifecycle: 'resident' }));
92
+ createNode(node('other-profile', { profile_id: 'other-profile-7f3a9c01' }));
93
+ const roots = list('profile_prefix=activity-profile-&lifecycle=terminal&top_level=true&include=activity');
94
+ assert.deepEqual(roots.map((row) => row.node_id), ['root']);
95
+ assert.equal(roots[0].activity?.final_report?.body, 'Finished the task');
96
+ assert.equal(roots[0].activity?.latest_report?.body, 'Still monitoring the task');
97
+ assert.equal(roots[0].activity?.pending_human_count, 1);
98
+ const children = list('profile_id=activity-profile-7f3a9c01&parent=root');
99
+ assert.deepEqual(children.map((row) => row.node_id), ['bridge', 'child']);
100
+ assert.equal(children[1].activity, undefined);
101
+ });
@@ -169,6 +169,36 @@ test('A4a: WS attach welcome.snapshot is byte-identical to a direct ViewSocketCl
169
169
  assert.equal(JSON.stringify(wsWelcome.snapshot), JSON.stringify(direct.welcome.snapshot), 'WS welcome.snapshot is byte-identical to the direct ViewSocketClient snapshot');
170
170
  direct.close();
171
171
  });
172
+ test('A4a regression: reconnecting an observer keeps the live broker instance', async () => {
173
+ const root = h.spawnRoot('a4 reconnect root');
174
+ const id = (await client.createNode({ kind: 'developer', parent: root, prompt: 'a4 reconnect worker' })).node_id;
175
+ await h.awaitBoot(id);
176
+ const pid = h.node(id)?.pi_pid;
177
+ const boots = h.bootCount(id);
178
+ const attachOnce = () => new Promise((resolveAttach, rejectAttach) => {
179
+ const ws = new WebSocket(`ws+unix://${sockPath}:/v1/nodes/${id}/attach`);
180
+ const timer = setTimeout(() => {
181
+ ws.terminate();
182
+ rejectAttach(new Error('timed out waiting for observer welcome'));
183
+ }, 15_000);
184
+ ws.on('open', () => ws.send(JSON.stringify({ type: 'hello', role: 'observer', client_id: `a4-reconnect-${Math.random()}` })));
185
+ ws.on('message', (data) => {
186
+ if (JSON.parse(data.toString('utf8'))['type'] !== 'welcome')
187
+ return;
188
+ clearTimeout(timer);
189
+ ws.close();
190
+ resolveAttach();
191
+ });
192
+ ws.on('error', (error) => {
193
+ clearTimeout(timer);
194
+ rejectAttach(error);
195
+ });
196
+ });
197
+ await attachOnce();
198
+ await attachOnce();
199
+ assert.equal(h.bootCount(id), boots, 'observer reconnect did not relaunch the broker');
200
+ assert.equal(h.node(id)?.pi_pid, pid, 'the in-flight broker instance was retained');
201
+ });
172
202
  test('A4b: ?revive=0 on a dormant node refuses 409 without launching a broker', async () => {
173
203
  const root = h.spawnRoot('a4b root');
174
204
  // A dormant row: no live broker, no pi_pid, view.sock absent.
@@ -1,5 +1,11 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
2
  import type { Duplex } from 'node:stream';
3
+ import { type WebSocket } from 'ws';
4
+ /** Keep an otherwise quiet observer connection alive through common 60s proxy
5
+ * idle limits. A viewer only observes a broker, so a missed heartbeat may close
6
+ * this socket but must never affect the broker or its in-flight work. */
7
+ export declare const REMOTE_ATTACH_HEARTBEAT_MS = 25000;
8
+ export declare function startRemoteAttachHeartbeat(ws: WebSocket, intervalMs?: number): () => void;
3
9
  /** Handle the HTTP upgrade for `GET /v1/nodes/{id}/attach`. A-9 matches the
4
10
  * route, extracts `{id}`, and calls this with the raw upgrade triplet; this
5
11
  * owns the WS handshake (its own noServer `wss` above) AND the spec §5.3