@north-light/crouter 0.3.301 → 0.3.303

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 (45) hide show
  1. package/dist/api/dto/reports.d.ts +8 -8
  2. package/dist/clients/attach/viewer.js +394 -394
  3. package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +30 -1
  4. package/dist/commands/memory/read.js +12 -14
  5. package/dist/commands/node/lifecycle.js +7 -6
  6. package/dist/commands/push.js +4 -4
  7. package/dist/core/__tests__/bash-guard.test.d.ts +1 -0
  8. package/dist/core/__tests__/bash-guard.test.js +190 -0
  9. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  10. package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
  11. package/dist/core/__tests__/integration/revive.test.js +3 -3
  12. package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
  13. package/dist/core/__tests__/seam/dormancy-release.test.js +42 -0
  14. package/dist/core/__tests__/stop-guard.test.js +64 -0
  15. package/dist/core/bash-guard.d.ts +6 -0
  16. package/dist/core/bash-guard.js +393 -0
  17. package/dist/core/canvas/crons.d.ts +20 -4
  18. package/dist/core/canvas/crons.js +54 -7
  19. package/dist/core/memory-resolver.d.ts +3 -1
  20. package/dist/core/memory-resolver.js +3 -3
  21. package/dist/core/nested-stores.d.ts +1 -1
  22. package/dist/core/nested-stores.js +3 -3
  23. package/dist/core/runtime/bearings-render.js +1 -1
  24. package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
  25. package/dist/core/runtime/broker/fault-retry.js +66 -6
  26. package/dist/core/runtime/close.js +8 -6
  27. package/dist/core/runtime/stop-guard.js +6 -6
  28. package/dist/core/shell-segments.d.ts +42 -0
  29. package/dist/core/shell-segments.js +169 -0
  30. package/dist/core/substrate/surface-match.d.ts +0 -9
  31. package/dist/core/substrate/surface-match.js +4 -160
  32. package/dist/core/worktree.js +3 -6
  33. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +21 -11
  34. package/dist/daemon/api/handlers/reports.js +1 -1
  35. package/dist/daemon/api/map.d.ts +1 -1
  36. package/dist/daemon/api/map.js +2 -2
  37. package/dist/daemon/cron/sinks.d.ts +1 -1
  38. package/dist/daemon/cron/sinks.js +2 -2
  39. package/dist/daemon/reconcilers/live-obligation.js +2 -2
  40. package/dist/pi-extensions/canvas-bash-valve.d.ts +0 -3
  41. package/dist/pi-extensions/canvas-bash-valve.js +2 -34
  42. package/package.json +1 -1
  43. package/runtime.lock.json +5 -5
  44. package/dist/daemon/cron-sink.d.ts +0 -26
  45. package/dist/daemon/cron-sink.js +0 -43
@@ -145,6 +145,8 @@ export interface MemoryResolutionOpts extends MemoryCandidateOpts {
145
145
  * boot render, on-read resolvedDocs, the slash-command snapshot, and
146
146
  * persona resolution, which stay on the flat ancestor+profile stack. */
147
147
  includeDescendants?: boolean;
148
+ /** Report when bounded descendant discovery omits an owner. Defaults to the inverse of `quiet`, so callers can preserve quiet malformed-document handling while still making an incomplete corpus visible to their caller. */
149
+ reportDiscovery?: boolean;
148
150
  }
149
151
  /** A loaded corpus and the exact-identity queries over it. */
150
152
  export interface MemoryView {
@@ -195,7 +197,7 @@ export declare function openProjectMemoryStore(ownerDir: string): MemoryStoreDes
195
197
  /** Every store a target sees, in precedence order: node, each project store
196
198
  * nearest-first (its enabled plugins after it), then profile, user, builtin.
197
199
  * One real store reached twice contributes once, at its first position. */
198
- export declare function memoryStoresInPrecedence(target: MemoryTarget, scope?: MemoryScope, includeDescendants?: boolean, quiet?: boolean): MemoryStoreDescriptor[];
200
+ export declare function memoryStoresInPrecedence(target: MemoryTarget, scope?: MemoryScope, includeDescendants?: boolean, reportDiscovery?: boolean): MemoryStoreDescriptor[];
199
201
  /** The native (non-plugin) MOUNTED store dirs in resolution precedence.
200
202
  * Addresses a store directly when the document itself may be absent, which is
201
203
  * how a DELETED doc's revision log is still found: history outlives the doc,
@@ -193,7 +193,7 @@ export function openProjectMemoryStore(ownerDir) {
193
193
  /** Every store a target sees, in precedence order: node, each project store
194
194
  * nearest-first (its enabled plugins after it), then profile, user, builtin.
195
195
  * One real store reached twice contributes once, at its first position. */
196
- export function memoryStoresInPrecedence(target, scope, includeDescendants = false, quiet = false) {
196
+ export function memoryStoresInPrecedence(target, scope, includeDescendants = false, reportDiscovery = true) {
197
197
  const out = [];
198
198
  const projectStores = (crtrRoot, projectMemory) => {
199
199
  out.push(projectStoreDescriptor(dirname(crtrRoot), join(crtrRoot, 'memory'), projectMemory));
@@ -211,7 +211,7 @@ export function memoryStoresInPrecedence(target, scope, includeDescendants = fal
211
211
  // nested one. They carry the neutral ceiling — no profile entry names
212
212
  // them, and descendant discovery is off for every automatic delivery path.
213
213
  if (includeDescendants) {
214
- for (const root of descendantStoreRoots(annotated.map(({ root: r }) => r), quiet)) {
214
+ for (const root of descendantStoreRoots(annotated.map(({ root: r }) => r), reportDiscovery)) {
215
215
  projectStores(root, NEUTRAL_PROJECT_MEMORY);
216
216
  }
217
217
  }
@@ -438,7 +438,7 @@ function makeView(stores, docs, scope) {
438
438
  * with its migration remedy and contributes nothing. */
439
439
  export function loadMemoryTargetView(target, opts = {}) {
440
440
  const quiet = opts.quiet ?? false;
441
- const stores = memoryStoresInPrecedence(target, opts.scope, opts.includeDescendants ?? false, quiet);
441
+ const stores = memoryStoresInPrecedence(target, opts.scope, opts.includeDescendants ?? false, opts.reportDiscovery ?? !quiet);
442
442
  const docs = [];
443
443
  for (const store of stores) {
444
444
  if (store.mountStatus === 'absent')
@@ -9,7 +9,7 @@
9
9
  * excluding centrally gives the resolver and lint the identical call with no
10
10
  * duplicated dedupe logic.
11
11
  */
12
- export declare function descendantStoreRoots(ancestorRoots: string[], quiet?: boolean): string[];
12
+ export declare function descendantStoreRoots(ancestorRoots: string[], reportBudgetExceeded?: boolean): string[];
13
13
  /** Complete descendant-store discovery for state migrations. Unlike the
14
14
  * interactive addressing lane, this filesystem walk has no time or depth
15
15
  * budget, crosses embedded repositories, admits plugin-only `.crouter` roots,
@@ -39,7 +39,7 @@ const cache = new Map();
39
39
  * excluding centrally gives the resolver and lint the identical call with no
40
40
  * duplicated dedupe logic.
41
41
  */
42
- export function descendantStoreRoots(ancestorRoots, quiet = false) {
42
+ export function descendantStoreRoots(ancestorRoots, reportBudgetExceeded = true) {
43
43
  const key = [...ancestorRoots].sort().join('\n');
44
44
  const cached = cache.get(key);
45
45
  if (cached !== undefined)
@@ -56,8 +56,8 @@ export function descendantStoreRoots(ancestorRoots, quiet = false) {
56
56
  owners.push(owner);
57
57
  }
58
58
  const tripBudget = (owner) => {
59
- if (!quiet) {
60
- warn(`memory: nested-store discovery under ${owner} exceeded its ${TIME_BUDGET_MS}ms budget — nested stores may be missing from this listing; register deep stores explicitly (add their dirs to the selected profile's projects)`);
59
+ if (reportBudgetExceeded) {
60
+ warn(`memory: nested-store discovery under ${owner} exceeded its ${TIME_BUDGET_MS}ms budget — this result may omit nested stores. To read a document from a known nested store, pass its owner as \`--dir <owner>\`; that exact-store read bypasses discovery.`);
61
61
  }
62
62
  };
63
63
  const found = [];
@@ -237,5 +237,5 @@ export function buildIdentityAssertion(nodeId, kind, mode, forkFrom, sourceLabel
237
237
  export function worktreeNote(worktree) {
238
238
  if (worktree?.state !== 'open')
239
239
  return '';
240
- return `Branch: \`${worktree.branch}\`\nWorktree: \`${worktree.path}\`\nBase: \`${worktree.base_ref}\` @ \`${worktree.base_sha}\`\nCommit work here. Land it with \`crtr node worktree close\` before finishing. Auto-drop applies only when the worktree is clean (no uncommitted changes) and nothing remains to land (zero commits ahead of the base, or its commits are already contained in the base); unlanded commits (commits not already contained in the base) or uncommitted changes require an explicit close/landing decision.`;
240
+ return `Branch: \`${worktree.branch}\`\nWorktree: \`${worktree.path}\`\nBase: \`${worktree.base_ref}\` @ \`${worktree.base_sha}\`\nCommit work here. Before finishing, land it with \`crtr node worktree close\` or keep the checkout and branch when the worktree is clean and its exact tip exists on any branch on origin. Auto-drop applies only when the worktree is clean (no uncommitted changes) and nothing remains to land (zero commits ahead of the base, or its commits are already contained in the base); otherwise, unlanded commits or uncommitted changes block finalization.`;
241
241
  }
@@ -25,6 +25,7 @@ export declare class FaultRetry {
25
25
  private readonly notFoundModels;
26
26
  private providerFallbackInFlight;
27
27
  private notFoundFallbackInFlight;
28
+ private stagedCapacityFailure;
28
29
  constructor(deps: FaultRetryDeps);
29
30
  clearTimer(): void;
30
31
  reset(): void;
@@ -54,6 +55,9 @@ export declare class FaultRetry {
54
55
  schedule(): void;
55
56
  private maybeSwitchProviderForRetry;
56
57
  private maybeFallbackOnUnusableModel;
58
+ private nextEligibleFallbackRoute;
59
+ private targetCannotFitContext;
60
+ private stageCapacityFailure;
57
61
  private recordPendingProviderFault;
58
62
  private sessionFile;
59
63
  private pendingEpisodeMatches;
@@ -22,6 +22,11 @@ export class FaultRetry {
22
22
  notFoundModels = new Set();
23
23
  providerFallbackInFlight = null;
24
24
  notFoundFallbackInFlight = null;
25
+ // A fallback can learn its target cannot hold this session before the
26
+ // current turn settles. Keep that outcome here rather than on the engine's
27
+ // agent_end staging field: message_end precedes agent_end, which resets that
28
+ // field for the ordinary provider result.
29
+ stagedCapacityFailure = null;
25
30
  constructor(deps) {
26
31
  this.deps = deps;
27
32
  }
@@ -35,6 +40,7 @@ export class FaultRetry {
35
40
  this.clearTimer();
36
41
  this.providerFallbackInFlight = null;
37
42
  this.notFoundFallbackInFlight = null;
43
+ this.stagedCapacityFailure = null;
38
44
  }
39
45
  onEvent(event) {
40
46
  this.maybeSwitchProviderForRetry(event);
@@ -54,6 +60,8 @@ export class FaultRetry {
54
60
  const watchdogAbort = generation.stagedWatchdogAbort;
55
61
  const refreshAbort = generation.stagedRefreshAbort;
56
62
  const overflowFailure = generation.stagedOverflowFailure;
63
+ const capacityFailure = this.stagedCapacityFailure?.generation === generation ? this.stagedCapacityFailure : null;
64
+ this.stagedCapacityFailure = null;
57
65
  generation.stagedProviderAgentEnd = null;
58
66
  generation.stagedWatchdogAbort = false;
59
67
  generation.stagedRefreshAbort = false;
@@ -76,12 +84,12 @@ export class FaultRetry {
76
84
  : { since: episodeFault.since, anchorEntryId: episodeFault.anchorEntryId, attempt: episodeFault.retry.attempt };
77
85
  const messages = Array.isArray(agentEnd?.messages) ? agentEnd.messages : [];
78
86
  const last = [...messages].reverse().find((message) => typeof message === 'object' && message !== null && message.role === 'assistant');
79
- if (overflowFailure !== null) {
87
+ if (capacityFailure !== null || overflowFailure !== null) {
80
88
  clearFault(this.deps.nodeId, { link: 'pi→provider' });
81
89
  clearProviderRetryEpisode(this.deps.nodeId);
82
90
  recordFault(this.deps.nodeId, {
83
91
  link: 'pi→provider', op: 'context overflow recovery', kind: 'context-overflow', retry: { disposition: 'fatal' },
84
- message: overflowFailure.errorMessage, anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
92
+ message: capacityFailure?.errorMessage ?? overflowFailure.errorMessage, anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
85
93
  });
86
94
  return;
87
95
  }
@@ -250,9 +258,23 @@ export class FaultRetry {
250
258
  const routes = expandModelCandidates(routeRequest, envNodeCwd() ?? this.deps.cfg.cwd, envProfileId());
251
259
  const current = parseModelSpec(currentSpec);
252
260
  const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
253
- const nextRoute = liveRank === undefined ? undefined : routes.slice(liveRank + 1).find((candidate) => !this.failedRetryRoutes.has(candidate.routeId) && resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, this.deps.registryOf(candidateServices)), automaticFallback: true }]).candidates.length > 0);
254
- if (nextRoute === undefined)
261
+ const fallback = liveRank === undefined
262
+ ? { route: undefined, capacityExhausted: false }
263
+ : this.nextEligibleFallbackRoute(routes.slice(liveRank + 1), this.deps.registryOf(candidateServices), candidateSession.getContextUsage()?.tokens, (candidate) => !this.failedRetryRoutes.has(candidate.routeId));
264
+ if (fallback.route === undefined) {
265
+ if (fallback.capacityExhausted) {
266
+ this.stageCapacityFailure(generation, candidateSession);
267
+ // Pi creates its retry AbortController immediately after it emits this
268
+ // event. Defer the abort so it cancels that controller, then lets the
269
+ // normal agent_settled path publish the staged fatal outcome.
270
+ queueMicrotask(() => {
271
+ if (this.deps.installedGeneration() === generation && this.deps.currentSession() === candidateSession)
272
+ candidateSession.abortRetry();
273
+ });
274
+ }
255
275
  return;
276
+ }
277
+ const nextRoute = fallback.route;
256
278
  const fallbackPromise = (async () => {
257
279
  const target = this.deps.registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
258
280
  if (!target)
@@ -290,9 +312,15 @@ export class FaultRetry {
290
312
  const routeRequest = modelRequestFromConfig(this.deps.cfg.model, envModelIntent(), false);
291
313
  const routes = routeRequest ? expandModelCandidates(routeRequest, envNodeCwd() ?? this.deps.cfg.cwd, envProfileId()) : [];
292
314
  const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
293
- const nextRoute = liveRank === undefined ? undefined : routes.slice(liveRank + 1).find((candidate) => !this.notFoundModels.has(`${candidate.providerId}/${candidate.modelId}`) && resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, this.deps.registryOf(candidateServices)), automaticFallback: true }]).candidates.length > 0);
294
- if (!nextRoute)
315
+ const fallback = liveRank === undefined
316
+ ? { route: undefined, capacityExhausted: false }
317
+ : this.nextEligibleFallbackRoute(routes.slice(liveRank + 1), this.deps.registryOf(candidateServices), candidateSession.getContextUsage()?.tokens, (candidate) => !this.notFoundModels.has(`${candidate.providerId}/${candidate.modelId}`));
318
+ if (fallback.route === undefined) {
319
+ if (fallback.capacityExhausted)
320
+ this.stageCapacityFailure(generation, candidateSession);
295
321
  return;
322
+ }
323
+ const nextRoute = fallback.route;
296
324
  const target = `${nextRoute.providerId}/${nextRoute.modelId}${nextRoute.thinkingLevel ? `:${nextRoute.thinkingLevel}` : ''}`;
297
325
  const fallbackPromise = (async () => {
298
326
  const model = this.deps.registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
@@ -323,6 +351,38 @@ export class FaultRetry {
323
351
  });
324
352
  this.notFoundFallbackInFlight = fallbackPromise;
325
353
  }
354
+ nextEligibleFallbackRoute(routes, registry, contextTokens, include) {
355
+ let available = 0;
356
+ let tooSmall = 0;
357
+ for (const candidate of routes) {
358
+ if (!include(candidate))
359
+ continue;
360
+ if (resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, registry), automaticFallback: true }]).candidates.length === 0)
361
+ continue;
362
+ const target = registry.find(candidate.providerId, candidate.modelId);
363
+ if (target === undefined)
364
+ continue;
365
+ available++;
366
+ if (this.targetCannotFitContext(target.contextWindow, contextTokens)) {
367
+ tooSmall++;
368
+ continue;
369
+ }
370
+ return { route: candidate, capacityExhausted: false };
371
+ }
372
+ return { route: undefined, capacityExhausted: available > 0 && available === tooSmall };
373
+ }
374
+ targetCannotFitContext(contextWindow, contextTokens) {
375
+ return Number.isFinite(contextWindow) && Number.isFinite(contextTokens) && contextWindow < contextTokens;
376
+ }
377
+ stageCapacityFailure(generation, session) {
378
+ const tokens = session.getContextUsage()?.tokens;
379
+ this.stagedCapacityFailure = {
380
+ generation,
381
+ errorMessage: Number.isFinite(tokens)
382
+ ? `All available fallback models have context windows smaller than the current conversation (${tokens} tokens).`
383
+ : 'All available fallback models have context windows smaller than the current conversation.',
384
+ };
385
+ }
326
386
  recordPendingProviderFault(session, input) {
327
387
  const sessionFile = this.sessionFile(session);
328
388
  // A pathless session cannot be crash-recovered, but its live broker still
@@ -2,9 +2,11 @@
2
2
  //
3
3
  // Closing a node tears down the focused node and every descendant it
4
4
  // EXCLUSIVELY owns, walking DOWN the subscribes_to spine (subscriptionsOf = a
5
- // node's reports/children). Nothing is deleted: pi_session_id, the canvas
6
- // edges, and all on-disk state persist, so any closed node can later be revived
7
- // (`crtr node lifecycle revive` / focus → `pi --session <id>`). A close is a pause, not a reap.
5
+ // node's reports/children). A node that never produced substantive assistant
6
+ // output is reaped and cannot be revived. Streaming nodes and nodes with an open
7
+ // managed worktree are retained; other closed nodes keep their pi session,
8
+ // canvas edges, and on-disk state for a later revive (`crtr node lifecycle
9
+ // revive` / focus → `pi --session <id>`).
8
10
  //
9
11
  // Per node, in this order — the order matters twice:
10
12
  //
@@ -171,9 +173,9 @@ export function closeNode(rootId, opts = {}) {
171
173
  // Surviving managers captured BEFORE any teardown — a reap (below) deletes
172
174
  // this node's edges, so the step-4 fan-out must read them up front.
173
175
  const survivors = subscribersOf(id).filter((s) => !closing.has(s.node_id));
174
- // 0) An EMPTY node (engine never produced an assistant message) is a useless
175
- // shell — don't park it as a canceled husk, reap it outright (engine +
176
- // viewer + row + dir). reapIfEmpty handles the teardown; when it fires we
176
+ // 0) A node with no substantive assistant output is a useless shell — don't
177
+ // park it as a canceled husk; reap it outright (engine + viewer + row +
178
+ // dir). reapIfEmpty handles the teardown; when it fires we
177
179
  // skip the cancel transition + resume notice (the node is gone) but still
178
180
  // fan the "child gone" wake out to surviving managers below.
179
181
  if (!reapIfEmpty(id)) {
@@ -23,7 +23,7 @@
23
23
  // manual cleanup.
24
24
  // • otherwise → a TERMINAL node with nothing live to wait for and no
25
25
  // final pushed. Re-prompt it to finish or escalate.
26
- import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCancelOnWakeCron, getNode, contextDir } from '../canvas/index.js';
26
+ import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCronWake, getNode, contextDir } from '../canvas/index.js';
27
27
  import { activeBackgroundBashJobs } from '../bash-jobs.js';
28
28
  import { formatCard, formatStructuredOutputReprompt, STALL_REPROMPT } from '../../shared/generated-context.js';
29
29
  import { readOutputRequest } from './structured-output.js';
@@ -41,12 +41,12 @@ function formatInvalidStructuredOutputReprompt(error) {
41
41
  `After resolving the file, retry with \`crtr push result\` (or \`crtr push result --decline "<reason>" --code <token>\` when the schema cannot be honestly satisfied). ` +
42
42
  `If this is unexpected, escalate to the user using \`crtr human send\`.`);
43
43
  }
44
- /** The wake sources that legitimize a terminal node's dormancy: a pending
45
- * cancel-on-wake deadline, an active subscription to a live publisher, an
46
- * explicit live controller wait, or a live background bash job whose completion
47
- * sends an inbox message. Parentage alone is deliberately absent. */
44
+ /** The wake sources that legitimize a terminal node's dormancy: a pending cron
45
+ * wake, an active subscription to a live publisher, an explicit live controller
46
+ * wait, or a live background bash job whose completion sends an inbox message.
47
+ * Parentage alone is deliberately absent. */
48
48
  function wakeCapableWait(nodeId, backgroundJobsRunning) {
49
- if (hasPendingCancelOnWakeCron(nodeId))
49
+ if (hasPendingCronWake(nodeId))
50
50
  return 'scheduled';
51
51
  if (hasActiveLiveSubscription(nodeId)
52
52
  || hasLiveMessageWait(nodeId)
@@ -0,0 +1,42 @@
1
+ /** One shell word. `opaque` marks a token whose text came from inside quotes:
2
+ * it is data, so it must never be treated as an executable or a matched
3
+ * command word. */
4
+ export type CommandToken = {
5
+ text: string;
6
+ opaque: boolean;
7
+ };
8
+ /** One shell segment: the raw text as the author wrote it, its leading
9
+ * `VAR=value` assignments, and the remaining words — so `tokens[0]` is the
10
+ * executable. An assignment-only segment (`export`-less `FOO=bar`) has
11
+ * assignments and no tokens; the distinction matters, because a leading
12
+ * assignment applies ONLY to the command in its own segment.
13
+ *
14
+ * `background` marks a segment ended by a single `&`. It runs in a subshell,
15
+ * so nothing it assigns or `cd`s reaches the commands after it. */
16
+ export type CommandSegment = {
17
+ text: string;
18
+ assignments: string[];
19
+ tokens: CommandToken[];
20
+ background: boolean;
21
+ };
22
+ /** One raw segment and whether a single `&` — not `&&` — ended it. */
23
+ export type RawSegment = {
24
+ text: string;
25
+ background: boolean;
26
+ };
27
+ /** Split a command into shell segments on unquoted `;`, newline, `&&`, `||`,
28
+ * `|`, and a single `&`. Subshell parentheses and backticks are left inside
29
+ * their segment. Heredoc bodies are a separate pre-read concern (see
30
+ * `stripHeredocs`). */
31
+ export declare function splitCommandSegments(command: string): RawSegment[];
32
+ /** Tokenize one segment into shell words, marking quoted spans opaque. */
33
+ export declare function tokenizeCommandSegment(segment: string): CommandToken[];
34
+ /** Every non-empty command segment, paired with its raw text. */
35
+ export declare function commandSegments(command: string): CommandSegment[];
36
+ /** Remove heredoc BODIES (the stdin data between `<<DELIM` and its closing
37
+ * `DELIM` line) from a command before it is read. The opening line is kept —
38
+ * `crtr push final <<'EOF'` is still a real `push final` invocation — but the
39
+ * body lines, which are data fed on stdin and never executed as commands, are
40
+ * dropped. Without this, a doc-writing command whose body merely MENTIONS a
41
+ * matched invocation would have its whole call held or refused. */
42
+ export declare function stripHeredocs(command: string): string;
@@ -0,0 +1,169 @@
1
+ // shell-segments.ts — best-effort shell text reading, shared by everything in
2
+ // crtr that has to reason about a command string it did not execute: the doc
3
+ // substrate's `command`/`pre-command` surface matching and the bash valve's
4
+ // refusals. Pure text in, structure out; no execution, no expansion.
5
+ //
6
+ // This is deliberately NOT a shell parser. Subshells, backticks, process
7
+ // substitution, and path-prefixed binaries stay unhandled. Every caller must
8
+ // treat a miss as "this text did not obviously do X", never as "this text
9
+ // cannot do X".
10
+ /** Split a command into shell segments on unquoted `;`, newline, `&&`, `||`,
11
+ * `|`, and a single `&`. Subshell parentheses and backticks are left inside
12
+ * their segment. Heredoc bodies are a separate pre-read concern (see
13
+ * `stripHeredocs`). */
14
+ export function splitCommandSegments(command) {
15
+ const segments = [];
16
+ let current = '';
17
+ let quote = null;
18
+ let escaped = false;
19
+ const finish = (background = false) => {
20
+ segments.push({ text: current, background });
21
+ current = '';
22
+ };
23
+ for (let i = 0; i < command.length; i += 1) {
24
+ const ch = command[i];
25
+ if (escaped) {
26
+ current += ch;
27
+ escaped = false;
28
+ continue;
29
+ }
30
+ if (ch === '\\' && quote !== "'") {
31
+ current += ch;
32
+ escaped = true;
33
+ continue;
34
+ }
35
+ if (quote !== null) {
36
+ current += ch;
37
+ if (ch === quote)
38
+ quote = null;
39
+ continue;
40
+ }
41
+ if (ch === "'" || ch === '"') {
42
+ current += ch;
43
+ quote = ch;
44
+ continue;
45
+ }
46
+ if (ch === '\n' || ch === ';') {
47
+ finish();
48
+ continue;
49
+ }
50
+ if ((ch === '&' || ch === '|') && command[i + 1] === ch) {
51
+ finish();
52
+ i += 1;
53
+ continue;
54
+ }
55
+ if (ch === '|') {
56
+ finish();
57
+ continue;
58
+ }
59
+ if (ch === '&') {
60
+ finish(true);
61
+ continue;
62
+ }
63
+ current += ch;
64
+ }
65
+ finish();
66
+ return segments;
67
+ }
68
+ /** Tokenize one segment into shell words, marking quoted spans opaque. */
69
+ export function tokenizeCommandSegment(segment) {
70
+ const tokens = [];
71
+ let text = '';
72
+ let opaque = false;
73
+ let started = false;
74
+ let quote = null;
75
+ let escaped = false;
76
+ const finish = () => {
77
+ if (started)
78
+ tokens.push({ text, opaque });
79
+ text = '';
80
+ opaque = false;
81
+ started = false;
82
+ };
83
+ for (let i = 0; i < segment.length; i += 1) {
84
+ const ch = segment[i];
85
+ if (escaped) {
86
+ text += ch;
87
+ started = true;
88
+ escaped = false;
89
+ continue;
90
+ }
91
+ if (ch === '\\' && quote !== "'") {
92
+ escaped = true;
93
+ started = true;
94
+ continue;
95
+ }
96
+ if (quote !== null) {
97
+ started = true;
98
+ if (ch === quote) {
99
+ quote = null;
100
+ }
101
+ else {
102
+ text += ch;
103
+ }
104
+ continue;
105
+ }
106
+ if (ch === "'" || ch === '"') {
107
+ quote = ch;
108
+ opaque = true;
109
+ started = true;
110
+ continue;
111
+ }
112
+ if (/\s/.test(ch)) {
113
+ finish();
114
+ continue;
115
+ }
116
+ text += ch;
117
+ started = true;
118
+ }
119
+ if (escaped)
120
+ text += '\\';
121
+ finish();
122
+ return tokens;
123
+ }
124
+ /** Every non-empty command segment, paired with its raw text. */
125
+ export function commandSegments(command) {
126
+ return splitCommandSegments(command)
127
+ .map(({ text, background }) => {
128
+ const tokens = tokenizeCommandSegment(text);
129
+ let first = 0;
130
+ while (first < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[first].text))
131
+ first += 1;
132
+ return { text: text.trim(), assignments: tokens.slice(0, first).map((t) => t.text), tokens: tokens.slice(first), background };
133
+ })
134
+ .filter((segment) => segment.assignments.length > 0 || segment.tokens.length > 0);
135
+ }
136
+ const HEREDOC_OPEN = /<<(-?)\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\2/g;
137
+ /** Remove heredoc BODIES (the stdin data between `<<DELIM` and its closing
138
+ * `DELIM` line) from a command before it is read. The opening line is kept —
139
+ * `crtr push final <<'EOF'` is still a real `push final` invocation — but the
140
+ * body lines, which are data fed on stdin and never executed as commands, are
141
+ * dropped. Without this, a doc-writing command whose body merely MENTIONS a
142
+ * matched invocation would have its whole call held or refused. */
143
+ export function stripHeredocs(command) {
144
+ const lines = command.split('\n');
145
+ const out = [];
146
+ let i = 0;
147
+ while (i < lines.length) {
148
+ const line = lines[i];
149
+ out.push(line);
150
+ i += 1;
151
+ // Every heredoc opened on this line, left to right; their bodies stack and
152
+ // are consumed in that order.
153
+ const delims = [];
154
+ HEREDOC_OPEN.lastIndex = 0;
155
+ let m;
156
+ while ((m = HEREDOC_OPEN.exec(line)) !== null)
157
+ delims.push({ name: m[3], dash: m[1] === '-' });
158
+ for (const d of delims) {
159
+ while (i < lines.length) {
160
+ // `<<-` strips leading TABS from the body and the closing delimiter.
161
+ const probe = d.dash ? lines[i].replace(/^\t+/, '') : lines[i];
162
+ i += 1;
163
+ if (probe === d.name)
164
+ break; // closing delimiter line — drop it, stop
165
+ }
166
+ }
167
+ }
168
+ return out.join('\n');
169
+ }
@@ -20,15 +20,6 @@ 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 | null, 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
23
  /** Does a `pre-command` entry fit a command that is ABOUT to run? The same
33
24
  * glob machinery `command` uses — one matcher, one semantics — applied to the
34
25
  * command with its heredoc bodies stripped. Subshell parentheses, backticks,