@north-light/crouter 0.3.231 → 0.3.233

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 (187) 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-memory/internal/memory-loading.md +4 -3
  20. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +6 -1
  21. package/dist/clients/attach/render/diagram.js +13 -5
  22. package/dist/clients/attach/render/page-block.d.ts +0 -1
  23. package/dist/clients/attach/render/page-block.js +4 -57
  24. package/dist/clients/attach/viewer.js +570 -563
  25. package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +9 -0
  26. package/dist/clients/inbox/__tests__/integration/mount-panel.test.js +62 -1
  27. package/dist/clients/inbox/controller.d.ts +10 -0
  28. package/dist/clients/inbox/controller.js +56 -12
  29. package/dist/clients/inbox/tui/input.js +38 -10
  30. package/dist/clients/inbox/tui/page-body.d.ts +10 -0
  31. package/dist/clients/inbox/tui/page-body.js +66 -0
  32. package/dist/clients/inbox/tui/panel.js +6 -4
  33. package/dist/clients/inbox/tui/render.js +89 -17
  34. package/dist/clients/inbox/tui/types.d.ts +4 -4
  35. package/dist/commands/__tests__/human.test.js +18 -3
  36. package/dist/commands/__tests__/node-message.test.js +3 -3
  37. package/dist/commands/api-client.js +1 -7
  38. package/dist/commands/canvas-config.js +6 -14
  39. package/dist/commands/canvas-use.js +4 -6
  40. package/dist/commands/cron.js +16 -22
  41. package/dist/commands/human/prompts.d.ts +1 -1
  42. package/dist/commands/human/prompts.js +118 -112
  43. package/dist/commands/human/request.js +13 -14
  44. package/dist/commands/human/review.js +3 -4
  45. package/dist/commands/human/shared.d.ts +6 -0
  46. package/dist/commands/human/shared.js +43 -5
  47. package/dist/commands/human.js +1 -1
  48. package/dist/commands/memory/delete.js +4 -6
  49. package/dist/commands/memory/edit.js +0 -4
  50. package/dist/commands/memory/move.js +3 -5
  51. package/dist/commands/memory/shared.d.ts +1 -1
  52. package/dist/commands/memory/shared.js +11 -7
  53. package/dist/commands/memory/write.js +60 -29
  54. package/dist/commands/memory.js +1 -1
  55. package/dist/commands/node/bash.js +6 -9
  56. package/dist/commands/node/create.js +89 -22
  57. package/dist/commands/node/inspect.js +3 -3
  58. package/dist/commands/node/lifecycle.js +30 -27
  59. package/dist/commands/node/message.js +24 -45
  60. package/dist/commands/node/subscription.js +6 -15
  61. package/dist/commands/node/wait.js +2 -3
  62. package/dist/commands/node-lifecycle-revive.js +1 -12
  63. package/dist/commands/pkg/browse/actions.js +2 -3
  64. package/dist/commands/pkg/market-manage.js +2 -5
  65. package/dist/commands/pkg/plugin-manage.js +7 -8
  66. package/dist/commands/profile/default.js +5 -5
  67. package/dist/commands/profile/delete.js +1 -1
  68. package/dist/commands/profile/env.js +9 -15
  69. package/dist/commands/profile/kind.js +3 -7
  70. package/dist/commands/profile/meta.js +3 -5
  71. package/dist/commands/profile/new.js +0 -6
  72. package/dist/commands/profile/pause.js +4 -8
  73. package/dist/commands/profile/project.js +5 -9
  74. package/dist/commands/profile/rename.js +3 -7
  75. package/dist/commands/profile/show.js +3 -3
  76. package/dist/commands/profile.js +4 -3
  77. package/dist/commands/surface-tmux-spread.js +1 -3
  78. package/dist/commands/sys/config.js +3 -4
  79. package/dist/commands/sys/support/prepare.js +8 -4
  80. package/dist/commands/sys/support/submit.js +2 -3
  81. package/dist/commands/sys/sync-deps.js +1 -9
  82. package/dist/commands/sys/sync-project-guidance.js +1 -7
  83. package/dist/commands/sys/sync-skills.js +1 -11
  84. package/dist/core/__tests__/cron-node-sink-parked-root.test.d.ts +1 -0
  85. package/dist/core/__tests__/cron-node-sink-parked-root.test.js +147 -0
  86. package/dist/core/__tests__/history-inbox.test.js +11 -1
  87. package/dist/core/__tests__/human-deliver.test.js +2 -1
  88. package/dist/core/__tests__/integration/command-plugins.test.js +0 -1
  89. package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -1
  90. package/dist/core/__tests__/lifecycle.test.js +30 -2
  91. package/dist/core/__tests__/revive-parked-fresh.test.d.ts +1 -0
  92. package/dist/core/__tests__/revive-parked-fresh.test.js +109 -0
  93. package/dist/core/__tests__/seam/dormancy-release.test.js +32 -5
  94. package/dist/core/canvas/attention.d.ts +2 -0
  95. package/dist/core/canvas/attention.js +25 -18
  96. package/dist/core/canvas/extensions.d.ts +1 -1
  97. package/dist/core/canvas/extensions.js +7 -1
  98. package/dist/core/canvas/history.js +20 -2
  99. package/dist/core/canvas/types.d.ts +1 -1
  100. package/dist/core/command.js +33 -8
  101. package/dist/core/help.d.ts +28 -2
  102. package/dist/core/help.js +46 -10
  103. package/dist/core/human/__tests__/page-html-markdown.test.d.ts +1 -0
  104. package/dist/core/human/__tests__/page-html-markdown.test.js +48 -0
  105. package/dist/core/human/component-docs.js +4 -4
  106. package/dist/core/human/page-html-markdown.d.ts +8 -0
  107. package/dist/core/human/page-html-markdown.js +260 -0
  108. package/dist/core/memory/lint.d.ts +15 -0
  109. package/dist/core/memory/lint.js +150 -90
  110. package/dist/core/profiles/__tests__/fuzzy-match.test.d.ts +1 -0
  111. package/dist/core/profiles/__tests__/fuzzy-match.test.js +51 -0
  112. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  113. package/dist/core/profiles/fuzzy-match.js +92 -0
  114. package/dist/core/profiles/manifest.d.ts +14 -7
  115. package/dist/core/profiles/manifest.js +62 -12
  116. package/dist/core/profiles/select.d.ts +3 -1
  117. package/dist/core/profiles/select.js +5 -3
  118. package/dist/core/profiles/state-block.js +4 -3
  119. package/dist/core/runtime/boot-root.d.ts +3 -2
  120. package/dist/core/runtime/canvas-extensions.d.ts +7 -1
  121. package/dist/core/runtime/canvas-extensions.js +8 -1
  122. package/dist/core/runtime/lifecycle.d.ts +11 -2
  123. package/dist/core/runtime/lifecycle.js +15 -2
  124. package/dist/core/runtime/model-selection.d.ts +4 -0
  125. package/dist/core/runtime/model-selection.js +5 -0
  126. package/dist/core/runtime/nodes.js +5 -0
  127. package/dist/core/runtime/reopen.d.ts +6 -0
  128. package/dist/core/runtime/reopen.js +12 -1
  129. package/dist/core/runtime/revive.d.ts +6 -0
  130. package/dist/core/runtime/revive.js +22 -2
  131. package/dist/core/runtime/spawn.d.ts +5 -2
  132. package/dist/core/runtime/spawn.js +18 -32
  133. package/dist/core/runtime/structured-output.d.ts +6 -0
  134. package/dist/core/runtime/structured-output.js +6 -0
  135. package/dist/core/substrate/__tests__/surface-match-command.test.d.ts +1 -0
  136. package/dist/core/substrate/__tests__/surface-match-command.test.js +89 -0
  137. package/dist/core/substrate/__tests__/surface-match-pre-command.test.d.ts +1 -0
  138. package/dist/core/substrate/__tests__/surface-match-pre-command.test.js +92 -0
  139. package/dist/core/substrate/frontmatter-validation.js +1 -1
  140. package/dist/core/substrate/injected-store.d.ts +6 -0
  141. package/dist/core/substrate/injected-store.js +24 -0
  142. package/dist/core/substrate/on-read.d.ts +17 -1
  143. package/dist/core/substrate/on-read.js +38 -3
  144. package/dist/core/substrate/schema.d.ts +3 -3
  145. package/dist/core/substrate/schema.js +4 -4
  146. package/dist/core/substrate/surface-match.d.ts +19 -0
  147. package/dist/core/substrate/surface-match.js +224 -12
  148. package/dist/core/termrender/version.d.ts +1 -1
  149. package/dist/core/termrender/version.js +1 -1
  150. package/dist/core/user-settings.js +1 -1
  151. package/dist/daemon/api/__tests__/broker-settle-park.test.d.ts +1 -0
  152. package/dist/daemon/api/__tests__/broker-settle-park.test.js +102 -0
  153. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.d.ts +1 -0
  154. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +188 -0
  155. package/dist/daemon/api/__tests__/node-create-description.test.d.ts +1 -0
  156. package/dist/daemon/api/__tests__/node-create-description.test.js +83 -0
  157. package/dist/daemon/api/__tests__/profile-metadata-route.test.d.ts +1 -0
  158. package/dist/daemon/api/__tests__/profile-metadata-route.test.js +92 -0
  159. package/dist/daemon/api/__tests__/reopen-delivery.test.d.ts +1 -0
  160. package/dist/daemon/api/__tests__/reopen-delivery.test.js +173 -0
  161. package/dist/daemon/api/handlers/broker-ops.js +21 -0
  162. package/dist/daemon/api/handlers/canvas.js +10 -0
  163. package/dist/daemon/api/handlers/messages.js +25 -16
  164. package/dist/daemon/api/handlers/nodes.js +5 -0
  165. package/dist/daemon/api/handlers/profiles.js +22 -1
  166. package/dist/daemon/cron-run.js +19 -1
  167. package/dist/daemon/manage.d.ts +16 -1
  168. package/dist/daemon/manage.js +20 -1
  169. package/dist/daemon/park-pending.d.ts +13 -0
  170. package/dist/daemon/park-pending.js +42 -0
  171. package/dist/daemon/reconcilers/broker-supervision.d.ts +13 -0
  172. package/dist/daemon/reconcilers/broker-supervision.js +139 -21
  173. package/dist/daemon/reconcilers/live-obligation.d.ts +10 -4
  174. package/dist/daemon/reconcilers/live-obligation.js +7 -3
  175. package/dist/daemon/reconcilers/storage-maintenance.d.ts +0 -4
  176. package/dist/daemon/reconcilers/storage-maintenance.js +1 -33
  177. package/dist/pi-extensions/__tests__/pre-command-gate.test.d.ts +1 -0
  178. package/dist/pi-extensions/__tests__/pre-command-gate.test.js +220 -0
  179. package/dist/pi-extensions/canvas-doc-substrate.d.ts +14 -0
  180. package/dist/pi-extensions/canvas-doc-substrate.js +75 -2
  181. package/dist/pi-extensions/canvas-prompt-scrub.d.ts +13 -0
  182. package/dist/pi-extensions/canvas-prompt-scrub.js +53 -0
  183. package/dist/shared/generated-context.d.ts +7 -0
  184. package/dist/shared/generated-context.js +11 -0
  185. package/package.json +4 -4
  186. package/runtime.lock.json +2 -2
  187. 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
+ });
@@ -0,0 +1,92 @@
1
+ // Regression: `pre-command` matching holds a command BEFORE it runs, so it
2
+ // costs the agent a whole turn when it fires. Two ways it can be wrong:
3
+ //
4
+ // • It fires on a mention rather than an invocation. A doc-writing command
5
+ // whose heredoc body merely quotes a matched invocation must run untouched
6
+ // — only the heredoc's opening line is a real command.
7
+ // • It ignores the entry's own `gate`. A doc whose entry excludes this node
8
+ // must never hold that node's commands.
9
+ //
10
+ // It also pins the shared-routing invariant: the pre-command matcher is the
11
+ // post-execution `command` matcher plus heredoc stripping, and nothing else.
12
+ // One command-glob semantics, not two.
13
+ //
14
+ // Run: node --conditions=crtr-src --import tsx/esm --test src/core/substrate/__tests__/surface-match-pre-command.test.ts
15
+ import { test } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import { matchesCommandEntry, matchesPreCommandEntry, preCommandDeliveryRung } from '../surface-match.js';
18
+ const SUBJECT = {
19
+ kind: 'developer',
20
+ mode: 'base',
21
+ lifecycle: 'terminal',
22
+ hasManager: true,
23
+ cwd: '/tmp/project',
24
+ scope: 'project',
25
+ orchestration: { depth: 1 },
26
+ profile: 'northlight',
27
+ };
28
+ function entry(match, gate) {
29
+ return { on: 'pre-command', at: 'content', match, ...(gate !== undefined ? { gate } : {}) };
30
+ }
31
+ // --- the behaviour this event adds -----------------------------------------
32
+ test('a matched invocation inside a heredoc BODY does not hold the command', () => {
33
+ const command = [
34
+ "crtr memory write approvals --body <<'EOF'",
35
+ 'Before you run `crtr integration run --action send`, get approval.',
36
+ 'EOF',
37
+ ].join('\n');
38
+ assert.equal(matchesPreCommandEntry(entry(['crtr integration run*']), SUBJECT, command), false);
39
+ });
40
+ test('the same invocation on the heredoc OPENING line still holds the command', () => {
41
+ const command = ["crtr integration run --action send --body <<'EOF'", 'hello', 'EOF'].join('\n');
42
+ assert.equal(matchesPreCommandEntry(entry(['crtr integration run*']), SUBJECT, command), true);
43
+ });
44
+ test('a `<<-` heredoc body is stripped too, tabs and all', () => {
45
+ const command = ['cat <<-EOF', '\tcrtr integration run --action send', '\tEOF'].join('\n');
46
+ assert.equal(matchesPreCommandEntry(entry(['crtr integration run*']), SUBJECT, command), false);
47
+ });
48
+ test('an unterminated heredoc drops its body rather than leaking it back in', () => {
49
+ const command = ["crtr memory write d --body <<'EOF'", 'crtr integration run --action send'].join('\n');
50
+ assert.equal(matchesPreCommandEntry(entry(['crtr integration run*']), SUBJECT, command), false);
51
+ });
52
+ test('an entry whose gate excludes the subject never holds a command', () => {
53
+ const excluded = entry(['crtr integration run*'], { lifecycle: 'resident' });
54
+ assert.equal(matchesPreCommandEntry(excluded, SUBJECT, 'crtr integration run --action send'), false);
55
+ const included = entry(['crtr integration run*'], { lifecycle: 'terminal' });
56
+ assert.equal(matchesPreCommandEntry(included, SUBJECT, 'crtr integration run --action send'), true);
57
+ });
58
+ test('a `command` entry is never consumed by the pre-command matcher, nor the reverse', () => {
59
+ const post = { on: 'command', at: 'content', match: ['crtr integration run*'] };
60
+ assert.equal(matchesPreCommandEntry(post, SUBJECT, 'crtr integration run --action send'), false);
61
+ assert.equal(matchesCommandEntry(entry(['crtr integration run*']), SUBJECT, 'crtr integration run --action send'), false);
62
+ });
63
+ // --- the rung fold ---------------------------------------------------------
64
+ test('the rung fold returns the highest `at` across matching entries, and none when nothing matches', () => {
65
+ const doc = {
66
+ surfaces: [
67
+ entry(['crtr crustdata person search*']),
68
+ entry(['crtr integration run*']),
69
+ { on: 'command', at: 'content', match: ['*'] },
70
+ ],
71
+ };
72
+ assert.equal(preCommandDeliveryRung(doc, SUBJECT, 'crtr integration run --action send'), 'content');
73
+ assert.equal(preCommandDeliveryRung(doc, SUBJECT, 'ls -la'), 'none');
74
+ });
75
+ // --- shared routing: one command-glob semantics, not two --------------------
76
+ test('outside a heredoc, pre-command matching is exactly the `command` matcher', () => {
77
+ // The guard against a second command tokenizer drifting away from the first.
78
+ // Whatever `command` globs mean today, `pre-command` means the same thing —
79
+ // so a sharpening of command-position matching reaches both events at once.
80
+ const glob = 'crtr integration run*';
81
+ for (const command of [
82
+ 'crtr integration run --action send',
83
+ 'cd /tmp && crtr integration run --action send',
84
+ 'echo hi\ncrtr integration run --action send',
85
+ 'NL_ENV=1 crtr integration run --action send',
86
+ 'crtr integration list',
87
+ 'ls -la',
88
+ 'echo "crtr integration run --action send"',
89
+ ]) {
90
+ assert.equal(matchesPreCommandEntry(entry([glob]), SUBJECT, command), matchesCommandEntry({ on: 'command', at: 'content', match: [glob] }, SUBJECT, command), `pre-command and command disagreed on: ${JSON.stringify(command)}`);
91
+ }
92
+ });
@@ -47,7 +47,7 @@ export function lintSubstrateSurfaces(value) {
47
47
  return `invalid surfaces entry \`match-frontmatter\`: ${JSON.stringify(matchFrontmatter)} (expected a field→matcher object)`;
48
48
  }
49
49
  }
50
- if ((on === 'read' || on === 'memory-read' || on === 'command') && !hasMatch && matchFrontmatter === undefined) {
50
+ if ((on === 'read' || on === 'memory-read' || on === 'command' || on === 'pre-command') && !hasMatch && matchFrontmatter === undefined) {
51
51
  return `invalid surfaces entry: a \`${on}\` entry requires \`match\`${on === 'read' ? ' or `match-frontmatter`' : ''}`;
52
52
  }
53
53
  const gate = entry['gate'];
@@ -24,6 +24,12 @@ export declare function contentExposureIdentity(body: string): string | null;
24
24
  export declare function documentExposedAtOrAbove(state: ContextExposureState, path: string, body: string, rung: Rung): boolean;
25
25
  /** Register both the physical document and its non-empty content alias. */
26
26
  export declare function registerDocumentExposure(target: ExposureTarget, path: string, body: string, rung: Rung): void;
27
+ /** Forget that a document was delivered into the TRANSCRIPT — both its physical
28
+ * identity and its content alias — while leaving any `system` rank intact,
29
+ * because the system prompt is rebuilt on every agent run and survives
30
+ * compaction. This is what re-arms a `pre-command` hold for guidance that
31
+ * compaction dropped out of the transcript. */
32
+ export declare function demoteDocumentTranscriptExposure(state: ContextExposureState, path: string, body: string): void;
27
33
  export declare function freezePreferenceSnapshot(state: ContextExposureState, rendered: string): string;
28
34
  export declare function cloneContextExposureState(source: ContextExposureState): ContextExposureState;
29
35
  export declare function hasTranscriptExposure(state: ContextExposureState): boolean;
@@ -49,6 +49,30 @@ export function registerDocumentExposure(target, path, body, rung) {
49
49
  if (contentIdentity !== null)
50
50
  registerExposure(target, contentIdentity, rung);
51
51
  }
52
+ /** Drop one identity's transcript rank, deleting the entry outright when no
53
+ * `system` rank remains. The v3 loader rejects an entry carrying neither rank,
54
+ * and the writer serializes the map as-is, so leaving `{}` behind would crash
55
+ * the node's substrate on its next load. */
56
+ function dropTranscriptExposure(state, identity) {
57
+ const ranks = state.exposures.get(identity);
58
+ if (ranks === undefined)
59
+ return;
60
+ if (validRank(ranks.system))
61
+ state.exposures.set(identity, { system: ranks.system });
62
+ else
63
+ state.exposures.delete(identity);
64
+ }
65
+ /** Forget that a document was delivered into the TRANSCRIPT — both its physical
66
+ * identity and its content alias — while leaving any `system` rank intact,
67
+ * because the system prompt is rebuilt on every agent run and survives
68
+ * compaction. This is what re-arms a `pre-command` hold for guidance that
69
+ * compaction dropped out of the transcript. */
70
+ export function demoteDocumentTranscriptExposure(state, path, body) {
71
+ dropTranscriptExposure(state, realpathOrSelf(path));
72
+ const contentIdentity = contentExposureIdentity(body);
73
+ if (contentIdentity !== null)
74
+ dropTranscriptExposure(state, contentIdentity);
75
+ }
52
76
  export function freezePreferenceSnapshot(state, rendered) {
53
77
  if (state.preferenceSnapshot === null)
54
78
  state.preferenceSnapshot = rendered;
@@ -1,4 +1,4 @@
1
- import { type ExposureTarget } from './injected-store.js';
1
+ import { type ContextExposureState, type ExposureTarget } from './injected-store.js';
2
2
  import { type Rung, type SubstrateDoc } from './schema.js';
3
3
  import type { NodeConfigSubject } from './subject-fields.js';
4
4
  interface EventCandidate {
@@ -30,4 +30,20 @@ export declare function memoryReadDocBlocks(subject: NodeConfigSubject | null, e
30
30
  * The corpus is the resolved cwd/profile set; a command has no file from which
31
31
  * to discover enclosing project stores. */
32
32
  export declare function renderOnCommandDocsForSubject(subject: NodeConfigSubject, command: string, target?: ExposureTarget): string;
33
+ /** Surface docs whose `pre-command` entries match a bash command that has NOT
34
+ * run yet. The exact twin of the post-execution renderer above, and its empty
35
+ * string is load-bearing: `''` means every matching doc is already exposed at
36
+ * its matching rung or above, which is the release — the caller lets the
37
+ * command through. */
38
+ export declare function renderPreCommandDocsForSubject(subject: NodeConfigSubject, command: string, target?: ExposureTarget): string;
39
+ /** Does ANY doc in this session's corpus carry a `pre-command` entry? The
40
+ * short-circuit a pre-execution caller checks first: with no such doc — the
41
+ * common case — no command need ever pay for a subject lookup. */
42
+ export declare function corpusHasPreCommandSurfaces(): boolean;
43
+ /** Forget the transcript exposure of every `pre-command` doc, re-arming their
44
+ * holds. Compaction drops delivered guidance out of the transcript while the
45
+ * ledger still claims it was delivered, so without this pass a doc could be
46
+ * held once and then silently absent for the rest of the session. Lives here
47
+ * rather than in the extension because the corpus accessor is private. */
48
+ export declare function demotePreCommandExposures(state: ContextExposureState): void;
33
49
  export {};
@@ -20,7 +20,13 @@
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).
25
+ // • `pre-command` — a `bash` tool call ABOUT to run evaluates the same way
26
+ // against every doc's `pre-command` entries, and the call is held until
27
+ // the matching docs have been delivered. Because delivery registers
28
+ // exposure, the agent's re-issue of the same command finds nothing left to
29
+ // deliver and runs.
24
30
  //
25
31
  // All channels register through one context exposure target, so a document
26
32
  // already loaded at the same or a higher rung does not repeat. Each candidate
@@ -36,8 +42,8 @@ import { parseFrontmatterGeneric } from '../frontmatter.js';
36
42
  import { listAllMemoryDocs, listProjectMemoryDocs, loadStoreMemoryDocs, openProjectMemoryStore } from '../memory-resolver.js';
37
43
  import { userScopeRoot } from '../scope.js';
38
44
  import { gatePasses } from './gate.js';
39
- import { commandDeliveryRung, memoryReadDeliveryRung, owningRootOf, readDeliveryRung, workspaceOpenRung } from './surface-match.js';
40
- import { documentExposedAtOrAbove, emptyContextExposureState, exposureTarget, registerDocumentExposure, } from './injected-store.js';
45
+ import { commandDeliveryRung, memoryReadDeliveryRung, owningRootOf, preCommandDeliveryRung, readDeliveryRung, workspaceOpenRung } from './surface-match.js';
46
+ import { demoteDocumentTranscriptExposure, documentExposedAtOrAbove, emptyContextExposureState, exposureTarget, registerDocumentExposure, } from './injected-store.js';
41
47
  import { minRung, parseSubstrateDoc, previewLine } from './schema.js';
42
48
  import { cachedEventCorpusInclusive } from './session-cache.js';
43
49
  const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc']);
@@ -242,3 +248,32 @@ export function renderOnCommandDocsForSubject(subject, command, target = transie
242
248
  const candidates = resolvedDocs().map(({ doc }) => ({ doc, rung: commandDeliveryRung(doc, subject, command) }));
243
249
  return renderCandidates(subject, candidates, target);
244
250
  }
251
+ /** Surface docs whose `pre-command` entries match a bash command that has NOT
252
+ * run yet. The exact twin of the post-execution renderer above, and its empty
253
+ * string is load-bearing: `''` means every matching doc is already exposed at
254
+ * its matching rung or above, which is the release — the caller lets the
255
+ * command through. */
256
+ export function renderPreCommandDocsForSubject(subject, command, target = transientTranscriptTarget()) {
257
+ const candidates = resolvedDocs().map(({ doc }) => ({ doc, rung: preCommandDeliveryRung(doc, subject, command) }));
258
+ return renderCandidates(subject, candidates, target);
259
+ }
260
+ function carriesPreCommandEntry(doc) {
261
+ return doc.surfaces.some((entry) => entry.on === 'pre-command');
262
+ }
263
+ /** Does ANY doc in this session's corpus carry a `pre-command` entry? The
264
+ * short-circuit a pre-execution caller checks first: with no such doc — the
265
+ * common case — no command need ever pay for a subject lookup. */
266
+ export function corpusHasPreCommandSurfaces() {
267
+ return resolvedDocs().some(({ doc }) => carriesPreCommandEntry(doc));
268
+ }
269
+ /** Forget the transcript exposure of every `pre-command` doc, re-arming their
270
+ * holds. Compaction drops delivered guidance out of the transcript while the
271
+ * ledger still claims it was delivered, so without this pass a doc could be
272
+ * held once and then silently absent for the rest of the session. Lives here
273
+ * rather than in the extension because the corpus accessor is private. */
274
+ export function demotePreCommandExposures(state) {
275
+ for (const { doc } of resolvedDocs()) {
276
+ if (carriesPreCommandEntry(doc))
277
+ demoteDocumentTranscriptExposure(state, doc.path, doc.body);
278
+ }
279
+ }
@@ -15,7 +15,7 @@ export declare function rungAtLeast(r: Rung, min: Rung): boolean;
15
15
  * authored rung against its project relationship's cap. */
16
16
  export declare function minRung(a: Rung, b: Rung): Rung;
17
17
  export { normalizeNameSegment, normalizeDocName, resolveLocalName as resolveDocName } from '../memory/identity.js';
18
- export declare const SURFACE_EVENTS: readonly ["boot", "workspace-open", "read", "memory-read", "command"];
18
+ export declare const SURFACE_EVENTS: readonly ["boot", "workspace-open", "read", "memory-read", "command", "pre-command"];
19
19
  export type SurfaceEvent = (typeof SURFACE_EVENTS)[number];
20
20
  /** The rungs an entry may deliver at. There is no `none` — silence is the
21
21
  * absence of an entry. (`Rung`'s `none` survives only as the internal
@@ -30,8 +30,8 @@ export type SurfaceRung = (typeof SURFACE_RUNGS)[number];
30
30
  export interface SurfaceEntry {
31
31
  on: SurfaceEvent;
32
32
  /** Globs vs the event's subject namespace. Required on read/memory-read/
33
- * command (a read entry may carry `matchFrontmatter` instead); meaningless
34
- * on boot/workspace-open (presence of the entry is the match). */
33
+ * command/pre-command (a read entry may carry `matchFrontmatter` instead);
34
+ * meaningless on boot/workspace-open (presence of the entry is the match). */
35
35
  match?: string[];
36
36
  /** Predicate over the read file's own YAML frontmatter — `read` event only.
37
37
  * Frontmatter key `match-frontmatter`. */
@@ -56,7 +56,7 @@ export { normalizeNameSegment, normalizeDocName, resolveLocalName as resolveDocN
56
56
  // no `surfaces` at all does exactly one thing: appears in its directory's
57
57
  // listing.
58
58
  // ---------------------------------------------------------------------------
59
- export const SURFACE_EVENTS = ['boot', 'workspace-open', 'read', 'memory-read', 'command'];
59
+ export const SURFACE_EVENTS = ['boot', 'workspace-open', 'read', 'memory-read', 'command', 'pre-command'];
60
60
  /** The rungs an entry may deliver at. There is no `none` — silence is the
61
61
  * absence of an entry. (`Rung`'s `none` survives only as the internal
62
62
  * ranking floor, e.g. `bootRung` of a doc with no boot entry.) */
@@ -158,8 +158,8 @@ function parseGate(v) {
158
158
  : undefined;
159
159
  }
160
160
  /** Tolerant `surfaces` parse: a non-array yields `[]`; invalid entries are
161
- * dropped (bad `on`, bad `at`, a read/memory-read/command entry left with
162
- * neither `match` nor — read only — `match-frontmatter`). `match-frontmatter`
161
+ * dropped (bad `on`, bad `at`, a read/memory-read/command/pre-command entry
162
+ * left with neither `match` nor — read only — `match-frontmatter`). `match-frontmatter`
163
163
  * on a non-read event is dropped from the entry, which survives iff it still
164
164
  * carries a `match`; an invalid entry `gate` is dropped. Lint owns strict
165
165
  * enforcement; the runtime parser maps over many docs and must never throw. */
@@ -180,7 +180,7 @@ function parseSurfaces(v) {
180
180
  const match = parseMatchGlobs(rec['match']);
181
181
  const matchFrontmatter = on === 'read' ? parseGate(rec['match-frontmatter']) : undefined;
182
182
  const gate = parseGate(rec['gate']);
183
- if ((on === 'read' || on === 'memory-read' || on === 'command') &&
183
+ if ((on === 'read' || on === 'memory-read' || on === 'command' || on === 'pre-command') &&
184
184
  match === undefined &&
185
185
  matchFrontmatter === undefined) {
186
186
  continue;
@@ -20,6 +20,21 @@ export declare function matchesReadEntry(entry: SurfaceEntry, doc: Pick<Substrat
20
20
  export declare function matchesMemoryReadEntry(entry: SurfaceEntry, routingAnchor: string, subject: NodeConfigSubject | null, name: string): boolean;
21
21
  /** Does a `command` entry fit the executed command string? */
22
22
  export declare function matchesCommandEntry(entry: SurfaceEntry, subject: NodeConfigSubject, command: string): boolean;
23
+ /** Remove heredoc BODIES (the stdin data between `<<DELIM` and its closing
24
+ * `DELIM` line) from a command before it is matched. The opening line is kept
25
+ * — `crtr push final <<'EOF'` is still a real `push final` invocation — but the
26
+ * body lines, which are data fed on stdin and never executed as commands, are
27
+ * dropped. Without this a doc-writing command whose body merely MENTIONS a
28
+ * matched invocation would have its whole call held. Post-execution `command`
29
+ * delivery does not need this (a spurious late injection costs a paragraph);
30
+ * a `pre-command` block costs the agent a turn, so it does. */
31
+ export declare function stripHeredocs(command: string): string;
32
+ /** Does a `pre-command` entry fit a command that is ABOUT to run? The same
33
+ * glob machinery `command` uses — one matcher, one semantics — applied to the
34
+ * command with its heredoc bodies stripped. Subshell parentheses, backticks,
35
+ * and path-prefixed binaries stay unhandled: this is a guardrail against the
36
+ * faithful-but-uninformed action, not an enforcement boundary. */
37
+ export declare function matchesPreCommandEntry(entry: SurfaceEntry, subject: NodeConfigSubject, command: string): boolean;
23
38
  /** The doc's delivery rung for a read of `absReadFile` (already realpathed). */
24
39
  export declare function readDeliveryRung(doc: Pick<SubstrateDoc, 'surfaces' | 'path' | 'scope'>, subject: NodeConfigSubject, absReadFile: string, readFrontmatter: Record<string, unknown>): Rung;
25
40
  /** The doc's workspace-open rung. Presence of an entry is the match; callers
@@ -33,3 +48,7 @@ export declare function workspaceOpenRung(doc: Pick<SubstrateDoc, 'surfaces'>, s
33
48
  export declare function memoryReadDeliveryRung(doc: Pick<SubstrateDoc, 'surfaces'>, routingAnchor: string, subject: NodeConfigSubject | null, name: string): Rung;
34
49
  /** The doc's delivery rung for an executed command string. */
35
50
  export declare function commandDeliveryRung(doc: Pick<SubstrateDoc, 'surfaces'>, subject: NodeConfigSubject, command: string): Rung;
51
+ /** The doc's delivery rung for a command about to run — the highest `at`
52
+ * over the doc's matching `pre-command` entries, folded the same way as every
53
+ * other event. */
54
+ export declare function preCommandDeliveryRung(doc: Pick<SubstrateDoc, 'surfaces'>, subject: NodeConfigSubject, command: string): Rung;