@north-light/crouter 0.3.230 → 0.3.232

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 (173) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +4 -0
  3. package/dist/api/dto/canvas.d.ts +10 -0
  4. package/dist/api/dto/common.d.ts +1 -1
  5. package/dist/api/dto/health.d.ts +2 -1
  6. package/dist/api/dto/lifecycle.d.ts +3 -4
  7. package/dist/api/dto/messages.d.ts +5 -4
  8. package/dist/api/dto/nodes.d.ts +2 -0
  9. package/dist/api/dto/profiles.d.ts +5 -0
  10. package/dist/api/routes.d.ts +1 -0
  11. package/dist/api/routes.js +1 -0
  12. package/dist/builtin-memory/00-runtime-base/00-authoring.md +8 -0
  13. package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
  14. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -1
  15. package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +5 -0
  16. package/dist/builtin-memory/02-turn-lifecycle/02-resident.md +5 -3
  17. package/dist/builtin-memory/04-orchestration-kernel.md +2 -2
  18. package/dist/builtin-memory/insights/capture.md +3 -2
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +6 -1
  20. package/dist/clients/attach/render/diagram.js +13 -5
  21. package/dist/clients/attach/render/page-block.d.ts +0 -1
  22. package/dist/clients/attach/render/page-block.js +4 -57
  23. package/dist/clients/attach/viewer.js +570 -563
  24. package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +9 -0
  25. package/dist/clients/inbox/__tests__/integration/mount-panel.test.js +62 -1
  26. package/dist/clients/inbox/controller.d.ts +10 -0
  27. package/dist/clients/inbox/controller.js +56 -12
  28. package/dist/clients/inbox/tui/input.js +38 -10
  29. package/dist/clients/inbox/tui/page-body.d.ts +10 -0
  30. package/dist/clients/inbox/tui/page-body.js +66 -0
  31. package/dist/clients/inbox/tui/panel.js +6 -4
  32. package/dist/clients/inbox/tui/render.js +89 -17
  33. package/dist/clients/inbox/tui/types.d.ts +4 -4
  34. package/dist/commands/__tests__/human.test.js +18 -3
  35. package/dist/commands/__tests__/node-message.test.js +3 -3
  36. package/dist/commands/api-client.js +1 -7
  37. package/dist/commands/canvas-config.js +6 -14
  38. package/dist/commands/canvas-use.js +4 -6
  39. package/dist/commands/cron.js +16 -22
  40. package/dist/commands/human/prompts.d.ts +1 -1
  41. package/dist/commands/human/prompts.js +118 -112
  42. package/dist/commands/human/request.js +13 -14
  43. package/dist/commands/human/review.js +3 -4
  44. package/dist/commands/human/shared.d.ts +6 -0
  45. package/dist/commands/human/shared.js +43 -5
  46. package/dist/commands/human.js +1 -1
  47. package/dist/commands/memory/delete.js +4 -6
  48. package/dist/commands/memory/edit.js +0 -4
  49. package/dist/commands/memory/move.js +3 -5
  50. package/dist/commands/memory/shared.d.ts +1 -1
  51. package/dist/commands/memory/shared.js +9 -5
  52. package/dist/commands/memory/write.js +60 -29
  53. package/dist/commands/memory.js +1 -1
  54. package/dist/commands/node/bash.js +6 -9
  55. package/dist/commands/node/create.js +89 -22
  56. package/dist/commands/node/inspect.js +3 -3
  57. package/dist/commands/node/lifecycle.js +30 -27
  58. package/dist/commands/node/message.js +24 -45
  59. package/dist/commands/node/subscription.js +6 -15
  60. package/dist/commands/node/wait.js +2 -3
  61. package/dist/commands/node-lifecycle-revive.js +1 -12
  62. package/dist/commands/pkg/browse/actions.js +2 -3
  63. package/dist/commands/pkg/market-manage.js +2 -5
  64. package/dist/commands/pkg/plugin-manage.js +7 -8
  65. package/dist/commands/profile/default.js +5 -5
  66. package/dist/commands/profile/delete.js +1 -1
  67. package/dist/commands/profile/env.js +9 -15
  68. package/dist/commands/profile/kind.js +3 -7
  69. package/dist/commands/profile/meta.js +3 -5
  70. package/dist/commands/profile/new.js +0 -6
  71. package/dist/commands/profile/pause.js +4 -8
  72. package/dist/commands/profile/project.js +5 -9
  73. package/dist/commands/profile/rename.js +3 -7
  74. package/dist/commands/profile/show.js +3 -3
  75. package/dist/commands/profile.js +4 -3
  76. package/dist/commands/surface-tmux-spread.js +1 -3
  77. package/dist/commands/sys/config.js +3 -4
  78. package/dist/commands/sys/support/prepare.js +8 -4
  79. package/dist/commands/sys/support/submit.js +2 -3
  80. package/dist/commands/sys/sync-deps.js +1 -9
  81. package/dist/commands/sys/sync-project-guidance.js +1 -7
  82. package/dist/commands/sys/sync-skills.js +1 -11
  83. package/dist/core/__tests__/cron-node-sink-parked-root.test.d.ts +1 -0
  84. package/dist/core/__tests__/cron-node-sink-parked-root.test.js +147 -0
  85. package/dist/core/__tests__/history-inbox.test.js +11 -1
  86. package/dist/core/__tests__/human-deliver.test.js +2 -1
  87. package/dist/core/__tests__/integration/command-plugins.test.js +0 -1
  88. package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -1
  89. package/dist/core/__tests__/lifecycle.test.js +30 -2
  90. package/dist/core/__tests__/revive-parked-fresh.test.d.ts +1 -0
  91. package/dist/core/__tests__/revive-parked-fresh.test.js +109 -0
  92. package/dist/core/__tests__/seam/dormancy-release.test.js +32 -5
  93. package/dist/core/canvas/attention.d.ts +2 -0
  94. package/dist/core/canvas/attention.js +25 -18
  95. package/dist/core/canvas/extensions.d.ts +1 -1
  96. package/dist/core/canvas/extensions.js +7 -1
  97. package/dist/core/canvas/history.js +20 -2
  98. package/dist/core/canvas/types.d.ts +1 -1
  99. package/dist/core/command.js +33 -8
  100. package/dist/core/help.d.ts +28 -2
  101. package/dist/core/help.js +46 -10
  102. package/dist/core/human/__tests__/page-html-markdown.test.d.ts +1 -0
  103. package/dist/core/human/__tests__/page-html-markdown.test.js +48 -0
  104. package/dist/core/human/component-docs.js +4 -4
  105. package/dist/core/human/page-html-markdown.d.ts +8 -0
  106. package/dist/core/human/page-html-markdown.js +260 -0
  107. package/dist/core/memory/lint.d.ts +15 -0
  108. package/dist/core/memory/lint.js +150 -90
  109. package/dist/core/profiles/__tests__/fuzzy-match.test.d.ts +1 -0
  110. package/dist/core/profiles/__tests__/fuzzy-match.test.js +51 -0
  111. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  112. package/dist/core/profiles/fuzzy-match.js +92 -0
  113. package/dist/core/profiles/manifest.d.ts +14 -7
  114. package/dist/core/profiles/manifest.js +62 -12
  115. package/dist/core/profiles/select.d.ts +3 -1
  116. package/dist/core/profiles/select.js +5 -3
  117. package/dist/core/profiles/state-block.js +4 -3
  118. package/dist/core/runtime/boot-root.d.ts +3 -2
  119. package/dist/core/runtime/canvas-extensions.d.ts +7 -1
  120. package/dist/core/runtime/canvas-extensions.js +8 -1
  121. package/dist/core/runtime/lifecycle.d.ts +11 -2
  122. package/dist/core/runtime/lifecycle.js +15 -2
  123. package/dist/core/runtime/model-selection.d.ts +4 -0
  124. package/dist/core/runtime/model-selection.js +5 -0
  125. package/dist/core/runtime/nodes.js +5 -0
  126. package/dist/core/runtime/reopen.d.ts +6 -0
  127. package/dist/core/runtime/reopen.js +12 -1
  128. package/dist/core/runtime/revive.d.ts +6 -0
  129. package/dist/core/runtime/revive.js +22 -2
  130. package/dist/core/runtime/spawn.d.ts +5 -2
  131. package/dist/core/runtime/spawn.js +18 -32
  132. package/dist/core/runtime/structured-output.d.ts +6 -0
  133. package/dist/core/runtime/structured-output.js +6 -0
  134. package/dist/core/substrate/__tests__/surface-match-command.test.d.ts +1 -0
  135. package/dist/core/substrate/__tests__/surface-match-command.test.js +89 -0
  136. package/dist/core/substrate/on-read.js +2 -1
  137. package/dist/core/substrate/surface-match.js +164 -12
  138. package/dist/core/termrender/version.d.ts +1 -1
  139. package/dist/core/termrender/version.js +1 -1
  140. package/dist/core/user-settings.js +1 -1
  141. package/dist/daemon/api/__tests__/broker-settle-park.test.d.ts +1 -0
  142. package/dist/daemon/api/__tests__/broker-settle-park.test.js +102 -0
  143. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.d.ts +1 -0
  144. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +188 -0
  145. package/dist/daemon/api/__tests__/node-create-description.test.d.ts +1 -0
  146. package/dist/daemon/api/__tests__/node-create-description.test.js +83 -0
  147. package/dist/daemon/api/__tests__/profile-metadata-route.test.d.ts +1 -0
  148. package/dist/daemon/api/__tests__/profile-metadata-route.test.js +92 -0
  149. package/dist/daemon/api/__tests__/reopen-delivery.test.d.ts +1 -0
  150. package/dist/daemon/api/__tests__/reopen-delivery.test.js +173 -0
  151. package/dist/daemon/api/handlers/broker-ops.js +21 -0
  152. package/dist/daemon/api/handlers/canvas.js +10 -0
  153. package/dist/daemon/api/handlers/messages.js +25 -16
  154. package/dist/daemon/api/handlers/nodes.js +5 -0
  155. package/dist/daemon/api/handlers/profiles.js +22 -1
  156. package/dist/daemon/cron-run.js +19 -1
  157. package/dist/daemon/manage.d.ts +16 -1
  158. package/dist/daemon/manage.js +20 -1
  159. package/dist/daemon/park-pending.d.ts +13 -0
  160. package/dist/daemon/park-pending.js +42 -0
  161. package/dist/daemon/reconcilers/broker-supervision.d.ts +13 -0
  162. package/dist/daemon/reconcilers/broker-supervision.js +139 -21
  163. package/dist/daemon/reconcilers/live-obligation.d.ts +10 -4
  164. package/dist/daemon/reconcilers/live-obligation.js +7 -3
  165. package/dist/daemon/reconcilers/storage-maintenance.d.ts +0 -4
  166. package/dist/daemon/reconcilers/storage-maintenance.js +1 -33
  167. package/dist/pi-extensions/canvas-prompt-scrub.d.ts +13 -0
  168. package/dist/pi-extensions/canvas-prompt-scrub.js +53 -0
  169. package/dist/shared/generated-context.d.ts +7 -0
  170. package/dist/shared/generated-context.js +11 -0
  171. package/package.json +4 -4
  172. package/runtime.lock.json +2 -2
  173. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +0 -47
@@ -12,7 +12,7 @@
12
12
  import { accessSync, constants, cpSync, readdirSync, existsSync, rmSync } from 'node:fs';
13
13
  import { isAbsolute, resolve, join } from 'node:path';
14
14
  import { spawnNode, currentNodeContext, rootOfSpine, newNodeId, preflightNodeId } from './nodes.js';
15
- import { loadProfileManifest } from '../profiles/manifest.js';
15
+ import { resolveProfileOperand } from '../profiles/manifest.js';
16
16
  import { selectProfileForCwd, selectProfileForCwdReadOnly } from '../profiles/select.js';
17
17
  import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
18
18
  import { createManagedWorktree, rollbackManagedWorktree } from '../worktree.js';
@@ -23,7 +23,7 @@ import { hasRoadmap, seedRoadmap } from './roadmap.js';
23
23
  import { buildWakeBearings } from './bearings.js';
24
24
  import { canonicalSessionFile, contextDir, findNodeBySessionFile, getNode, fullName, recordPid } from '../canvas/index.js';
25
25
  import { jobDir } from '../canvas/paths.js';
26
- import { openViewerWindow, focusOf, windowOfPane, currentTmux, focusWindow, } from './placement.js';
26
+ import { openViewerWindow, focusOf, windowOfPane, } from './placement.js';
27
27
  import { waitForBrokerViewSocket } from './placement-tmux.js';
28
28
  import { transition } from './lifecycle.js';
29
29
  import { beginBootFaultAttempt } from './fault.js';
@@ -33,6 +33,7 @@ import { ensureDaemon } from '../../daemon/manage.js';
33
33
  import { readConfig } from '../config.js';
34
34
  import { modelRequestFromConfig, unregisteredConcreteModel } from '../model-routes.js';
35
35
  import { openModelRegistry } from './model-registry.js';
36
+ import { MODEL_SPEC_NEXT } from './model-selection.js';
36
37
  import { initializeForkContextExposure } from '../substrate/injected-store.js';
37
38
  // ---------------------------------------------------------------------------
38
39
  // Shared broker-launch wrapper — catch launch/preflight failures at the call
@@ -147,7 +148,7 @@ export function resolveForkSource(value) {
147
148
  return resolveSessionUuid(v);
148
149
  }
149
150
  /** Resolve the profile a spawned child runs under: an explicit `--profile`
150
- * operand (id or name) validated through `loadProfileManifest`, else INHERIT
151
+ * operand (id, name, or closest match) resolved through `resolveProfileOperand`, else INHERIT
151
152
  * the spawner's current `profile_id` (null when the spawner has none). `--root`
152
153
  * never resets this to null on its own; only an explicit override does. When
153
154
  * there is NO spawner at all — `crtr node new --root` run directly from a
@@ -158,7 +159,10 @@ export async function resolveProfileId(explicit, spawner, cwd, options = {}) {
158
159
  if (explicit === null)
159
160
  return null;
160
161
  if (explicit !== undefined && explicit !== '') {
161
- return loadProfileManifest(explicit).profileId;
162
+ // `explicit` originates as a user-typed `--profile`, so tolerate a near
163
+ // miss. The CLI normally resolves it to an id first; this covers callers
164
+ // that reach the daemon with a name.
165
+ return resolveProfileOperand(explicit).profileId;
162
166
  }
163
167
  if (spawner !== null)
164
168
  return getNode(spawner)?.profile_id ?? null;
@@ -183,7 +187,7 @@ export async function assertLaunchModelRegistered(request, options, registry) {
183
187
  return;
184
188
  throw usage(`model '${unknown.request.spec}' is not registered in this daemon.`, {
185
189
  received: unknown.request.spec,
186
- next: 'Pass a registered provider/id, provider/tier where provider is anthropic|openai and tier is ultra|strong|medium|light, a bare tier (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku).',
190
+ next: MODEL_SPEC_NEXT,
187
191
  });
188
192
  }
189
193
  /** Resolve who a spawn is attributed to. A managed child needs a spine parent
@@ -272,6 +276,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
272
276
  lifecycle,
273
277
  cwd: spawnCwd,
274
278
  name: opts.name ?? opts.kind,
279
+ description: opts.description,
275
280
  // A root has no spine parent (top-level, nobody subscribes); it still
276
281
  // records spawned_by=spawner when a node (not a human shell) spawned it.
277
282
  // A child's parent IS its manager.
@@ -331,11 +336,6 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
331
336
  }
332
337
  // (The three scoped long-term memory stores are seeded for EVERY node at birth
333
338
  // in spawnNode — no orchestrator-gated seeding needed here.)
334
- // An ATTENDED --root spawned from inside tmux opens its foreground viewer
335
- // in the CALLER'S CURRENT session (below), so it appears where the spawner is
336
- // working; a managed child is a headless broker with NO viewer at all, and
337
- // so is an unattended terminal root (nobody asked to watch it).
338
- const here = attendedRoot ? currentTmux() : null;
339
339
  const inv = buildPiArgv(meta, { prompt: kickoff, forkFrom });
340
340
  // CRTR_SUBTREE (the spine root) rides the detached broker's env so it can group
341
341
  // the subtree; the host sets CRTR_FRONT_DOOR itself.
@@ -377,30 +377,16 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
377
377
  ? 'Resolve the cause named above, then retry.'
378
378
  : `The broker engine exited before binding its view socket. Inspect ${jobDir(meta.node_id)}/broker.log for the underlying cause (a missing dependency, model auth, or a crash), then retry.`);
379
379
  }
380
- // An ATTENDED --root is spawned to be DRIVEN directly: when the caller is
381
- // inside tmux, open ONE foreground viewer in the caller's CURRENT session and
382
- // bring it forefront so whoever asked for it picks up the conversation.
383
- // Outside tmux a --root opens no viewer (an agent handing a root off); an
384
- // UNATTENDED terminal root (rootLifecycle: 'terminal') opens none anywhere —
385
- // stealing focus for machinery nobody is watching is the bug, not the
386
- // feature. A managed child opens nothing. Viewer placement remains non-fatal
387
- // after the readiness proof.
380
+ // A --root opens NO viewer from here. spawnChild runs daemon-side only, and
381
+ // the daemon is paneless by construction (manage.ts strips TMUX/TMUX_PANE
382
+ // from its env precisely so this code cannot resolve someone else's pane),
383
+ // so a root's foreground viewer is placed by whoever asked for it — the
384
+ // front door, or `node new`'s own openSpawnViewer in the CLI process. Only a
385
+ // managed child is placed here, and placement stays non-fatal after the
386
+ // readiness proof.
388
387
  let window = null;
389
388
  let session = null;
390
- if (attendedRoot && here !== null) {
391
- const viewer = openViewerWindow(meta.node_id, here.session, { name: fullName(meta), cwd: opts.cwd });
392
- if (viewer !== null && viewer.pane !== null && viewer.pane !== '') {
393
- session = here.session;
394
- window = windowOfPane(viewer.pane);
395
- if (window !== null) {
396
- try {
397
- focusWindow(here.session, window);
398
- }
399
- catch { /* best-effort */ }
400
- }
401
- }
402
- }
403
- else if (!root && spawner !== null && readConfig('user').auto_open_child_viewers) {
389
+ if (!root && spawner !== null && readConfig('user').auto_open_child_viewers) {
404
390
  // When enabled, a managed child opens beside a HUMAN actively watching
405
391
  // its spawner. Turning the setting off leaves every child headless until
406
392
  // someone explicitly focuses or attaches it. The broker is headless
@@ -13,6 +13,12 @@ export type StructuredOutputState = {
13
13
  state: 'invalid';
14
14
  error: string;
15
15
  };
16
+ /** The `--output-schema` transport clause, rendered verbatim by every leaf that
17
+ * accepts one. Declared once so the call sites cannot drift apart. */
18
+ export declare const OUTPUT_SCHEMA_TRANSPORT = "A .json file path, or inline JSON beginning with `{`.";
19
+ /** Provider-constrained generation supports the structural subset below; every
20
+ * other schema keyword remains model-visible and is checked after generation. */
21
+ export declare const OUTPUT_SCHEMA_CONSTRAINTS = "Generation is constrained by structure alone \u2014 type, properties, required, items, anyOf, and the standard string formats; every other keyword (enum, pattern, minLength, numeric bounds) reaches the model as description text and is checked only after it answers, so an over-constrained schema costs retries.";
16
22
  export declare function outputSchemaPath(nodeId: string): string;
17
23
  export declare function outputResultPath(nodeId: string): string;
18
24
  export declare function parseOutputSchemaValue(value: string | undefined): Record<string, unknown> | null;
@@ -4,6 +4,12 @@ import { randomUUID } from 'node:crypto';
4
4
  import { nodeDir, contextDir } from '../canvas/paths.js';
5
5
  import { InputError } from '../io.js';
6
6
  import { isRecord } from '../../shared/predicates.js';
7
+ /** The `--output-schema` transport clause, rendered verbatim by every leaf that
8
+ * accepts one. Declared once so the call sites cannot drift apart. */
9
+ export const OUTPUT_SCHEMA_TRANSPORT = 'A .json file path, or inline JSON beginning with `{`.';
10
+ /** Provider-constrained generation supports the structural subset below; every
11
+ * other schema keyword remains model-visible and is checked after generation. */
12
+ export const OUTPUT_SCHEMA_CONSTRAINTS = 'Generation is constrained by structure alone — type, properties, required, items, anyOf, and the standard string formats; every other keyword (enum, pattern, minLength, numeric bounds) reaches the model as description text and is checked only after it answers, so an over-constrained schema costs retries.';
7
13
  export function outputSchemaPath(nodeId) {
8
14
  return `${nodeDir(nodeId)}/output-schema.json`;
9
15
  }
@@ -0,0 +1,89 @@
1
+ // Deliberate, user-approved exception to crouter's standing policy of not using unit tests: glob matching is pure string logic with a large space of near-miss inputs, where a table of real cases is the only economical way to pin behavior, and where BOTH false negatives and false positives are defects.
2
+ //
3
+ // Run: node --conditions=crtr-src --import tsx/esm --test src/core/substrate/__tests__/surface-match-command.test.ts
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { commandDeliveryRung, matchesCommandEntry } from '../surface-match.js';
7
+ const subject = {
8
+ kind: 'developer',
9
+ mode: 'base',
10
+ lifecycle: 'terminal',
11
+ hasManager: false,
12
+ cwd: '/tmp/project',
13
+ scope: 'project',
14
+ orchestration: { depth: 0 },
15
+ profile: null,
16
+ };
17
+ function entry(match, on = 'command', at = 'preview') {
18
+ return { on, at, match };
19
+ }
20
+ test('real command globs match the direct invocation they describe', () => {
21
+ const cases = [
22
+ { name: 'linear issues', glob: 'linear *', command: 'linear issues list', expected: true },
23
+ { name: 'capture screenshot', glob: '*capture *', command: 'capture screenshot --url https://example.com', expected: true },
24
+ { name: 'grove status', glob: '*grove*', command: 'grove status', expected: true },
25
+ { name: 'crtr human send', glob: '*crtr human *', command: 'crtr human send --message "Approve this change"', expected: true },
26
+ { name: 'git commit', glob: '*git commit*', command: 'git commit -m "ship fix"', expected: true },
27
+ { name: 'git push', glob: '*git push*', command: 'git push origin main', expected: true },
28
+ { name: 'npm publish', glob: '*npm publish*', command: 'npm publish --access public', expected: true },
29
+ { name: 'pnpm publish', glob: '*pnpm publish*', command: 'pnpm publish --access public', expected: true },
30
+ { name: 'termrender doc check', glob: '*termrender*', command: 'termrender doc check ./README.md', expected: true },
31
+ { name: 'single-character question mark', glob: '*git p?sh*', command: 'git push origin main', expected: true },
32
+ { name: 'trailing star permits bare tool', glob: 'linear *', command: 'linear', expected: true },
33
+ { name: 'literal glob is an exact command', glob: 'git status', command: 'git status', expected: true },
34
+ { name: 'literal glob does not imply arguments', glob: 'git status', command: 'git status --short', expected: false },
35
+ ];
36
+ for (const testCase of cases) {
37
+ assert.equal(matchesCommandEntry(entry([testCase.glob]), subject, testCase.command), testCase.expected, testCase.name);
38
+ }
39
+ });
40
+ test('a command is found after ordinary shell composition and whitespace variation', () => {
41
+ const cases = [
42
+ { name: 'after directory change', glob: 'linear *', command: 'cd apps/core && linear issues list', expected: true },
43
+ { name: 'after semicolon', glob: '*grove*', command: 'printf ready; grove status', expected: true },
44
+ { name: 'after or-list', glob: '*crtr human *', command: 'test -f answer.txt || crtr human send --message "Choose one"', expected: true },
45
+ { name: 'leading environment assignment', glob: 'linear *', command: 'CI=1 linear issues list', expected: true },
46
+ { name: 'tool inside a pipeline', glob: '*termrender*', command: 'termrender doc check README.md | grep -q error', expected: true },
47
+ { name: 'tool on the right side of a pipe', glob: '*termrender*', command: 'cat README.md | termrender', expected: true },
48
+ { name: 'two spaces between tokens', glob: 'linear *', command: 'linear issues list', expected: true },
49
+ { name: 'tab between tokens', glob: 'linear *', command: 'linear\tissues list', expected: true },
50
+ { name: 'trailing newline', glob: 'linear *', command: 'linear issues list\n', expected: true },
51
+ ];
52
+ for (const testCase of cases) {
53
+ assert.equal(matchesCommandEntry(entry([testCase.glob]), subject, testCase.command), testCase.expected, testCase.name);
54
+ }
55
+ });
56
+ test('command text and token near-misses do not wake a surface', () => {
57
+ const cases = [
58
+ { name: 'linear only appears in quoted text', glob: 'linear *', command: 'echo "linear issues list"', expected: false },
59
+ { name: 'capture only appears in quoted text', glob: '*capture *', command: 'echo "capture screenshot"', expected: false },
60
+ { name: 'git push only appears in a commit message', glob: '*git push*', command: 'git commit -m "please git push later"', expected: false },
61
+ { name: 'grove is part of a longer word', glob: '*grove*', command: 'echo grover', expected: false },
62
+ { name: 'capture is part of a longer word', glob: '*capture *', command: 'printf "%s\\n" mycapture', expected: false },
63
+ { name: 'linear is part of a filename', glob: 'linear *', command: 'python linear-regression.py', expected: false },
64
+ { name: 'git commit is a sibling subcommand', glob: '*git commit*', command: 'git commit-tree -m "ship fix"', expected: false },
65
+ { name: 'termrender only appears in quoted text', glob: '*termrender*', command: 'echo "termrender"', expected: false },
66
+ // A path mention is not an invocation: an unrelated listing should not wake the grove doc.
67
+ { name: 'grove only appears in an unrelated path', glob: '*grove*', command: 'ls /tmp/grove/config', expected: false },
68
+ ];
69
+ for (const testCase of cases) {
70
+ assert.equal(matchesCommandEntry(entry([testCase.glob]), subject, testCase.command), testCase.expected, testCase.name);
71
+ }
72
+ });
73
+ test('entry and match-list composition stays within the command event', () => {
74
+ const anyGlobCases = [
75
+ { name: 'second glob satisfies the list', globs: ['*npm publish*', '*pnpm publish*'], command: 'pnpm publish --access public', expected: true },
76
+ { name: 'no glob satisfies the list', globs: ['*npm publish*', '*pnpm publish*'], command: 'yarn publish', expected: false },
77
+ ];
78
+ for (const testCase of anyGlobCases) {
79
+ assert.equal(matchesCommandEntry(entry(testCase.globs), subject, testCase.command), testCase.expected, testCase.name);
80
+ }
81
+ assert.equal(commandDeliveryRung({ surfaces: [entry(['*npm publish*'], 'command', 'name'), entry(['*pnpm publish*'], 'command', 'content')] }, subject, 'pnpm publish --access public'), 'content', 'matching command entries OR together and retain the highest matching rung');
82
+ const otherEventCases = [
83
+ { name: 'read entry never fires for a command', on: 'read' },
84
+ { name: 'memory-read entry never fires for a command', on: 'memory-read' },
85
+ ];
86
+ for (const testCase of otherEventCases) {
87
+ assert.equal(matchesCommandEntry(entry(['linear *'], testCase.on), subject, 'linear issues list'), false, testCase.name);
88
+ }
89
+ });
@@ -20,7 +20,8 @@
20
20
  // workspace-wide context arrives before the task rather than waiting for
21
21
  // an arbitrary first file.
22
22
  // • `command` — a `bash` tool call evaluates the executed command string
23
- // against every doc's `command` entries (string globs, `*` crossing `/`).
23
+ // against every doc's `command` entries (per shell segment, wildcards at
24
+ // token granularity — see surface-match.ts).
24
25
  //
25
26
  // All channels register through one context exposure target, so a document
26
27
  // already loaded at the same or a higher rung does not repeat. Each candidate
@@ -102,21 +102,173 @@ export function matchesMemoryReadEntry(entry, routingAnchor, subject, name) {
102
102
  return false;
103
103
  return entry.match.some((g) => memoryReadGlobMatches(g, routingAnchor, name));
104
104
  }
105
- /** A command glob is a STRING glob over the whole command line, not a path
106
- * glob: `*` crosses every character (including `/` — command lines carry
107
- * paths), `?` matches one. Translated to a regex rather than fed to
108
- * `matchesGlob`, whose `*` stops at path separators. */
109
- function commandGlobMatches(glob, command) {
110
- const rx = glob
105
+ function splitCommandSegments(command) {
106
+ const segments = [];
107
+ let current = '';
108
+ let quote = null;
109
+ let escaped = false;
110
+ const finish = () => {
111
+ segments.push(current);
112
+ current = '';
113
+ };
114
+ for (let i = 0; i < command.length; i += 1) {
115
+ const ch = command[i];
116
+ if (escaped) {
117
+ current += ch;
118
+ escaped = false;
119
+ continue;
120
+ }
121
+ if (ch === '\\' && quote !== "'") {
122
+ current += ch;
123
+ escaped = true;
124
+ continue;
125
+ }
126
+ if (quote !== null) {
127
+ current += ch;
128
+ if (ch === quote)
129
+ quote = null;
130
+ continue;
131
+ }
132
+ if (ch === "'" || ch === '"') {
133
+ current += ch;
134
+ quote = ch;
135
+ continue;
136
+ }
137
+ if (ch === '\n' || ch === ';') {
138
+ finish();
139
+ continue;
140
+ }
141
+ if ((ch === '&' || ch === '|') && command[i + 1] === ch) {
142
+ finish();
143
+ i += 1;
144
+ continue;
145
+ }
146
+ if (ch === '|') {
147
+ finish();
148
+ continue;
149
+ }
150
+ current += ch;
151
+ }
152
+ finish();
153
+ return segments;
154
+ }
155
+ function tokenizeCommandSegment(segment) {
156
+ const tokens = [];
157
+ let text = '';
158
+ let opaque = false;
159
+ let started = false;
160
+ let quote = null;
161
+ let escaped = false;
162
+ const finish = () => {
163
+ if (started)
164
+ tokens.push({ text, opaque });
165
+ text = '';
166
+ opaque = false;
167
+ started = false;
168
+ };
169
+ for (let i = 0; i < segment.length; i += 1) {
170
+ const ch = segment[i];
171
+ if (escaped) {
172
+ text += ch;
173
+ started = true;
174
+ escaped = false;
175
+ continue;
176
+ }
177
+ if (ch === '\\' && quote !== "'") {
178
+ escaped = true;
179
+ started = true;
180
+ continue;
181
+ }
182
+ if (quote !== null) {
183
+ started = true;
184
+ if (ch === quote) {
185
+ quote = null;
186
+ }
187
+ else {
188
+ text += ch;
189
+ }
190
+ continue;
191
+ }
192
+ if (ch === "'" || ch === '"') {
193
+ quote = ch;
194
+ opaque = true;
195
+ started = true;
196
+ continue;
197
+ }
198
+ if (/\s/.test(ch)) {
199
+ finish();
200
+ continue;
201
+ }
202
+ text += ch;
203
+ started = true;
204
+ }
205
+ if (escaped)
206
+ text += '\\';
207
+ finish();
208
+ return tokens;
209
+ }
210
+ function commandSegments(command) {
211
+ return splitCommandSegments(command)
212
+ .map(tokenizeCommandSegment)
213
+ .map((tokens) => {
214
+ let first = 0;
215
+ while (first < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[first].text))
216
+ first += 1;
217
+ return tokens.slice(first);
218
+ })
219
+ .filter((tokens) => tokens.length > 0);
220
+ }
221
+ function commandGlobPattern(glob) {
222
+ const words = glob.trim().split(/\s+/);
223
+ if (words.length === 1 && words[0] === '')
224
+ return null;
225
+ const pattern = [];
226
+ for (const word of words) {
227
+ const parts = word.split('*');
228
+ for (let i = 0; i < parts.length; i += 1) {
229
+ if (parts[i] !== '')
230
+ pattern.push({ kind: 'literal', text: parts[i] });
231
+ if (i < parts.length - 1)
232
+ pattern.push({ kind: 'any' });
233
+ }
234
+ }
235
+ return pattern;
236
+ }
237
+ function tokenMatches(pattern, token) {
238
+ const rx = pattern
111
239
  .split('')
112
- .map((ch) => (ch === '*' ? '[\\s\\S]*' : ch === '?' ? '[\\s\\S]' : ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')))
240
+ .map((ch) => (ch === '?' ? '[\\s\\S]' : ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')))
113
241
  .join('');
114
- try {
115
- return new RegExp(`^${rx}$`).test(command);
116
- }
117
- catch {
242
+ return new RegExp(`^${rx}$`).test(token);
243
+ }
244
+ function segmentMatches(pattern, tokens) {
245
+ const memo = new Map();
246
+ const match = (patternIndex, tokenIndex) => {
247
+ const key = `${patternIndex}:${tokenIndex}`;
248
+ const cached = memo.get(key);
249
+ if (cached !== undefined)
250
+ return cached;
251
+ let result;
252
+ if (patternIndex === pattern.length) {
253
+ result = tokenIndex === tokens.length;
254
+ }
255
+ else if (pattern[patternIndex].kind === 'any') {
256
+ result = match(patternIndex + 1, tokenIndex) || (tokenIndex < tokens.length && match(patternIndex, tokenIndex + 1));
257
+ }
258
+ else {
259
+ const token = tokens[tokenIndex];
260
+ result = token !== undefined && !token.opaque && tokenMatches(pattern[patternIndex].text, token.text) && match(patternIndex + 1, tokenIndex + 1);
261
+ }
262
+ memo.set(key, result);
263
+ return result;
264
+ };
265
+ return match(0, 0);
266
+ }
267
+ function commandGlobMatches(glob, command) {
268
+ const pattern = commandGlobPattern(glob);
269
+ if (pattern === null)
118
270
  return false;
119
- }
271
+ return commandSegments(command).some((tokens) => segmentMatches(pattern, tokens));
120
272
  }
121
273
  /** Does a `command` entry fit the executed command string? */
122
274
  export function matchesCommandEntry(entry, subject, command) {
@@ -1 +1 @@
1
- export declare const TERMRENDER_VERSION = "4.12.8";
1
+ export declare const TERMRENDER_VERSION = "4.12.11";
@@ -1 +1 @@
1
- export const TERMRENDER_VERSION = '4.12.8';
1
+ export const TERMRENDER_VERSION = '4.12.11';
@@ -197,7 +197,7 @@ export const CONFIG_SETTINGS_COVERAGE = {
197
197
  setting: {
198
198
  key: 'page_surface',
199
199
  label: 'Page human surface',
200
- description: 'Prompt agents to use JSX pages by default for structured or interactive content shown to you.',
200
+ description: 'This host reads pages in a browser rather than a terminal. Agents are told the page module runs as written, HTML pages are offered, and document review and live display leave the command tree.',
201
201
  valueConstraint: 'true|false',
202
202
  applies: 'Applies when human command help is assembled.',
203
203
  group: 'display',
@@ -0,0 +1,102 @@
1
+ // Run with: node --conditions=crtr-src --import tsx/esm --test src/daemon/api/__tests__/broker-settle-park.test.ts
2
+ //
3
+ // The park clock hands terminalization to broker settlement: it arms a pending
4
+ // park, delivers one summary prompt to the live engine, and this handler
5
+ // decides what the ending turn meant. The defect surface is the re-evaluation —
6
+ // the verdict the clock reached 15 minutes ago must not be enacted when the
7
+ // summary turn itself created live work (a human ask, new wake-worthy mail, a
8
+ // pushed final), and the pending park must be consumed exactly once regardless.
9
+ import { after, before, beforeEach, test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { mkdtempSync, rmSync } from 'node:fs';
12
+ import { createNode, getNode } from '../../../core/canvas/canvas.js';
13
+ import { closeDb } from '../../../core/canvas/db.js';
14
+ import { appendInbox } from '../../../core/feed/inbox.js';
15
+ import { beginPendingPark, isPendingPark } from '../../park-pending.js';
16
+ import { brokerOperationRoutes } from '../handlers/broker-ops.js';
17
+ let home = '';
18
+ let originalHome;
19
+ const settleRoute = brokerOperationRoutes.find((route) => route.method === 'POST' && route.pattern === '/v1/nodes/:id/broker/settle');
20
+ before(() => {
21
+ originalHome = process.env['CRTR_HOME'];
22
+ home = mkdtempSync('/tmp/crtr-settle-park-');
23
+ process.env['CRTR_HOME'] = home;
24
+ });
25
+ beforeEach(() => {
26
+ closeDb();
27
+ rmSync(home, { recursive: true, force: true });
28
+ });
29
+ after(() => {
30
+ closeDb();
31
+ rmSync(home, { recursive: true, force: true });
32
+ if (originalHome === undefined)
33
+ delete process.env['CRTR_HOME'];
34
+ else
35
+ process.env['CRTR_HOME'] = originalHome;
36
+ });
37
+ /** A live resident mid-parking-turn: the clock armed the park and the summary
38
+ * turn is what is now settling. */
39
+ function residentMidParkingTurn(id) {
40
+ const meta = {
41
+ node_id: id,
42
+ name: id,
43
+ created: new Date().toISOString(),
44
+ cwd: home,
45
+ kind: 'general',
46
+ mode: 'base',
47
+ lifecycle: 'resident',
48
+ status: 'active',
49
+ };
50
+ createNode(meta);
51
+ beginPendingPark(id, Date.now());
52
+ }
53
+ async function settle(id, over = {}) {
54
+ const ctx = {
55
+ method: 'POST',
56
+ path: `/v1/nodes/${id}/broker/settle`,
57
+ params: { id },
58
+ query: new URLSearchParams(),
59
+ body: {
60
+ stopReason: 'stop',
61
+ backgroundJobsRunning: false,
62
+ pushedFinal: false,
63
+ requestedHumanReply: false,
64
+ ...over,
65
+ },
66
+ };
67
+ return (await settleRoute.handler(ctx)).body;
68
+ }
69
+ test('a summary turn that ends with nothing new parks the node: done + intent=parked, no latch', async () => {
70
+ residentMidParkingTurn('summarized');
71
+ const directive = await settle('summarized');
72
+ assert.deepEqual(directive, { action: 'shutdown' });
73
+ const parked = getNode('summarized');
74
+ assert.equal(parked.status, 'done');
75
+ assert.equal(parked.intent, 'parked');
76
+ assert.equal(parked.final_report ?? null, null, 'a park never sets the finalization latch');
77
+ assert.equal(isPendingPark('summarized'), false, 'the pending park is consumed');
78
+ });
79
+ test('a human ask raised during the parking turn keeps the node live instead of parking it', async () => {
80
+ residentMidParkingTurn('asked');
81
+ const directive = await settle('asked', { requestedHumanReply: true });
82
+ assert.deepEqual(directive, { action: 'stay_dormant' });
83
+ assert.equal(getNode('asked').status, 'active', 'the node stays live awaiting its answer');
84
+ assert.equal(getNode('asked').intent ?? null, null);
85
+ assert.equal(isPendingPark('asked'), false, 'the park is discarded, not held for the next settle');
86
+ });
87
+ test('wake-worthy mail arriving during the parking turn flips the verdict back to release', async () => {
88
+ residentMidParkingTurn('mailed');
89
+ appendInbox('mailed', { from: null, tier: 'normal', kind: 'message', label: 'x', data: { body: 'work' } });
90
+ const directive = await settle('mailed');
91
+ assert.equal(directive.action, 'stay_dormant');
92
+ assert.equal(getNode('mailed').status, 'active', 're-evaluation saw the new obligation and did not park');
93
+ assert.equal(getNode('mailed').intent ?? null, null);
94
+ assert.equal(isPendingPark('mailed'), false);
95
+ });
96
+ test('a final pushed during the parking turn finishes the node rather than parking it', async () => {
97
+ residentMidParkingTurn('finished');
98
+ const directive = await settle('finished', { pushedFinal: true });
99
+ assert.deepEqual(directive, { action: 'shutdown' });
100
+ assert.equal(getNode('finished').intent ?? null, null, 'a pushed final is not a park');
101
+ assert.equal(isPendingPark('finished'), false);
102
+ });