@sema-agent/core 7.49.0 → 7.49.2

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/CHANGELOG.md +85 -0
  2. package/dist/core/arg-summary.d.ts +4 -4
  3. package/dist/core/arg-summary.js +106 -42
  4. package/dist/core/credential-vocabulary.js +19 -2
  5. package/dist/core/engine-notice.d.ts +11 -4
  6. package/dist/core/gate-fold.js +2 -2
  7. package/dist/core/hooks.d.ts +17 -0
  8. package/dist/core/runner/prepare-gate-stations.js +2 -2
  9. package/dist/core/runner/prepare-lsp.d.ts +4 -4
  10. package/dist/core/runner/prepare-lsp.js +2 -2
  11. package/dist/core/runner/prepare-safety-scan.js +10 -6
  12. package/dist/core/runner/run-harness-handlers.js +2 -2
  13. package/dist/core/runner/tool-turn-repair-seat.d.ts +28 -4
  14. package/dist/core/runner/tool-turn-repair-seat.js +36 -13
  15. package/dist/core/runner-deps.d.ts +2 -2
  16. package/dist/core/shell-wrapper-projection.js +1 -1
  17. package/dist/core/task-spec.d.ts +2 -2
  18. package/dist/core/tool-catalog-entries.js +1 -1
  19. package/dist/core/tool-conformance.js +2 -1
  20. package/dist/core/tool-roster.d.ts +11 -6
  21. package/dist/core/tool-roster.js +8 -4
  22. package/dist/core/write-protect.d.ts +3 -1
  23. package/dist/core/write-protect.js +4 -5
  24. package/dist/tools/fs/bash-program-position.d.ts +2 -1
  25. package/dist/tools/fs/bash-program-position.js +15 -4
  26. package/dist/tools/fs/bash-readonly-classifier.d.ts +2 -2
  27. package/dist/tools/fs/bash-readonly-classifier.js +5 -3
  28. package/dist/tools/fs/program-read-model.d.ts +7 -2
  29. package/dist/tools/fs/program-read-model.js +21 -1
  30. package/dist/tools/fs/program-read-model.rows.js +11 -10
  31. package/dist/tools/fs/read-deny-home.d.ts +5 -1
  32. package/dist/tools/fs/read-deny-home.js +40 -19
  33. package/dist/tools/fs/shell-read-check.d.ts +1 -1
  34. package/dist/tools/fs/shell-read-check.js +43 -14
  35. package/dist/tools/fs/shell-read-environment.d.ts +74 -0
  36. package/dist/tools/fs/shell-read-environment.js +71 -0
  37. package/dist/tools/fs/shell-read-establish.d.ts +8 -4
  38. package/dist/tools/fs/shell-read-establish.js +34 -36
  39. package/dist/tools/fs/shell-read-identity.d.ts +3 -2
  40. package/dist/tools/fs/shell-read-identity.js +5 -3
  41. package/dist/tools/fs/shell-read-judge.d.ts +69 -15
  42. package/dist/tools/fs/shell-read-judge.js +30 -10
  43. package/package.json +1 -1
  44. package/wire-changes/core@7.49.1.json +57 -0
  45. package/wire-changes/core@7.49.2.json +138 -0
@@ -1,15 +1,16 @@
1
1
  import { NAMESPACED_NAME_SHAPES, carriesNamespaceSeparator } from "../protocol-table.js";
2
2
  import { engineCardTypes, handBandEffects } from "../tool-registry.js";
3
- import { callerCardIdOf, offendingResultCard, rosterMemberBoundProblem, schemaKeyBoundProblem } from "../tool-roster.js";
3
+ import { callerCardIdOf, offendingResultCard, rosterMemberBoundProblem, schemaKeyBoundProblem, survivorsByName } from "../tool-roster.js";
4
4
  import { toolFaceProblem } from "./tool-face-overlay.js";
5
5
  import { OFFLOAD_TOOL_NAME } from "../tool-result-store.js";
6
6
  import { PRESENT_PLAN_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME } from "../present-plan-tool.js";
7
7
  const REPEAT_RISK = { read: 0, idempotent: 1, write: 2 };
8
8
  const leastRepeatSafe = (a, b) => (REPEAT_RISK[a] >= REPEAT_RISK[b] ? a : b);
9
+ const tierOf = (t) => t.irreversibility ?? (t.reversibilityProbe ? "maybe" : undefined);
9
10
  export function prepareSafetyScan(input) {
10
11
  const { spec, deps } = input;
11
12
  const toolEffects = new Map();
12
- const ownToolNames = new Set((spec.tools ?? []).map((t) => t.name));
13
+ const survivors = survivorsByName(spec.tools ?? []);
13
14
  const claimedAliases = new Set();
14
15
  const egressTools = new Set();
15
16
  const irreversibleTools = new Set();
@@ -76,10 +77,10 @@ export function prepareSafetyScan(input) {
76
77
  throw e;
77
78
  }
78
79
  }
79
- if (t.effect) {
80
+ if (t.effect && survivors.get(t.name) === t) {
80
81
  toolEffects.set(t.name, t.effect);
81
82
  }
82
- const tier = t.irreversibility ?? (t.reversibilityProbe ? "maybe" : undefined);
83
+ const tier = tierOf(t);
83
84
  if (tier !== undefined) {
84
85
  irreversibilityTier.set(t.name, tier);
85
86
  if (tier === "always" || tier === "maybe") {
@@ -98,8 +99,11 @@ export function prepareSafetyScan(input) {
98
99
  egressTools.add(t.name);
99
100
  markedBy(t.name, "deployment");
100
101
  }
102
+ }
103
+ for (const t of survivors.values()) {
104
+ const tier = tierOf(t);
101
105
  for (const alias of t.aliases ?? []) {
102
- if (alias === t.name || ownToolNames.has(alias))
106
+ if (alias === t.name || survivors.has(alias))
103
107
  continue;
104
108
  if (claimedAliases.has(alias))
105
109
  continue;
@@ -122,7 +126,7 @@ export function prepareSafetyScan(input) {
122
126
  }
123
127
  }
124
128
  toolEffects.set(OFFLOAD_TOOL_NAME, "read");
125
- const claimerEffects = new Map([...claimedAliases].map((a) => [a, toolEffects.get(a) ?? "write"]));
129
+ const claimerEffects = new Map([...claimedAliases, ...survivors.keys()].map((a) => [a, toolEffects.get(a) ?? "write"]));
126
130
  const recordHandEffect = (name, effect) => {
127
131
  const claimed = claimerEffects.get(name);
128
132
  toolEffects.set(name, claimed === undefined ? effect : leastRepeatSafe(claimed, effect));
@@ -13,7 +13,7 @@ import { announceToolCall, batchResponseDigest, emitSettled } from "./tool-end-b
13
13
  import { createResponseModelSeat } from "./response-model-seat.js";
14
14
  import { createToolCallIdFoldSeat } from "./tool-call-id-fold-seat.js";
15
15
  import { createFinishReasonSeat } from "./finish-reason-seat.js";
16
- import { createToolTurnIncompleteSeat } from "./tool-turn-repair-seat.js";
16
+ import { createToolTurnIncompleteSeat, turnCarriesToolCall } from "./tool-turn-repair-seat.js";
17
17
  import { wireOf } from "../tool-name-readings.js";
18
18
  import { rosterToolName } from "../tool-roster.js";
19
19
  export function createHarnessHandlers(input) {
@@ -199,7 +199,7 @@ export function createHarnessHandlers(input) {
199
199
  const content = m.content;
200
200
  if (rs.attach.attachState !== undefined)
201
201
  rs.counters.cadenceTurns = advanceCadenceClock(rs.attach.attachState, rs.counters.cadenceTurns, content, writeFamilyOf);
202
- rs.turn.lastTurnHadToolCalls = Array.isArray(content) && content.some((c) => c?.type === "toolCall");
202
+ rs.turn.lastTurnHadToolCalls = turnCarriesToolCall(content);
203
203
  }
204
204
  rs.turn.callStartAt = undefined;
205
205
  rs.turn.firstTokenAt = undefined;
@@ -1,15 +1,39 @@
1
1
  import type { EngineNotice } from "../engine-notice.js";
2
- import { type ToolTurnIncompleteCause, type ToolTurnRepair } from "../../internal/llm.js";
2
+ import { type ToolTurnRepair } from "../../internal/llm.js";
3
3
  /** The notice's attribution ids — the same pair, for the same reason, as the fold seat's. */
4
4
  export interface ToolTurnRepairSeatIds {
5
5
  sessionId: string;
6
6
  runId: string;
7
7
  }
8
+ /** What the message says became of a turn that dropped calls: the run ended on it (`model_output_invalid`), or the next
9
+ * request asks the model to re-issue them. */
10
+ export type ToolTurnOutcome = "terminal" | "reissued";
11
+ /** One thing the person is told, per cause. A dropped-call cause carries the outcome when its message states one (else
12
+ * `undefined`: nothing is said about the run's fate); the others have none. */
13
+ export type ToolTurnIncompleteItem = {
14
+ cause: "truncated";
15
+ count: number;
16
+ outcome: ToolTurnOutcome | undefined;
17
+ } | {
18
+ cause: "unnamed";
19
+ count: number;
20
+ outcome: ToolTurnOutcome | undefined;
21
+ } | {
22
+ cause: "bad_frame";
23
+ count: number;
24
+ } | {
25
+ cause: "missing_result_synthesized";
26
+ count: number;
27
+ toolCallIds: readonly string[];
28
+ };
29
+ /** Does a turn's content carry a tool call? A tool-calling turn GUARANTEES a next request (its results go back to the
30
+ * model): the engine's one continuation fact — the harness's boundary gates and the dropped-call line read it here. */
31
+ export declare function turnCarriesToolCall(content: unknown): boolean;
8
32
  /** The person's line for one cause — once per run per cause, whichever observer saw it first. */
9
33
  export interface ToolTurnIncompleteSeat {
10
- /** Announce `count` items of `cause` (and, for a missing result, the calls) unless this run already said it. */
11
- announce(cause: ToolTurnIncompleteCause, count: number, toolCallIds?: readonly string[]): void;
12
- /** Observe one assistant message: announces each stream drop it reports (`streamDropsOf`). */
34
+ /** Announce `item` unless this run already said its cause. */
35
+ announce(item: ToolTurnIncompleteItem): void;
36
+ /** Observe one assistant message: announces each stream drop it reports (`streamDropsOf`), worded by what became of the turn. */
13
37
  observe(message: {
14
38
  readonly diagnostics?: unknown;
15
39
  }): void;
@@ -1,6 +1,8 @@
1
1
  import { deliverEngineNotice } from "../engine-notice.js";
2
2
  import { isProviderEchoShape } from "../../brain/errors.js";
3
3
  import { streamDropsOf } from "../../internal/llm.js";
4
+ import { readMember } from "../thrown-value.js";
5
+ import { modelOutputInvalidOfMessage } from "./terminal-account.js";
4
6
  const FORM_SENTENCE = {
5
7
  trailing_text: "a block after an assistant's tool calls was moved ahead of them",
6
8
  late_result: "a result recorded later was moved up behind its call",
@@ -9,30 +11,51 @@ const FORM_SENTENCE = {
9
11
  duplicate_call: "a second call with the same id in one assistant message was left out (its results answer the first)",
10
12
  orphan_result: "a result that answers no call before it was left out",
11
13
  };
14
+ const afterDroppedCalls = (outcome) => outcome === "reissued" ? "; the model was asked to re-issue them" : outcome === "terminal" ? "; the run ended with this turn (model_output_invalid)" : "";
12
15
  const INCOMPLETE_SENTENCE = {
13
- truncated: (n) => `${n} tool call(s) the model made were cut off before their arguments were complete, so they did not run; the model was asked to re-issue them`,
14
- unnamed: (n) => `${n} tool call(s) the model made arrived with no tool name, so they did not run; the model was asked to re-issue them`,
15
- bad_frame: (n) => `${n} frame(s) of the model's streamed response could not be read and were dropped, so the response may be missing content; the model was told`,
16
- missing_result_synthesized: (n) => `${n} earlier tool call(s) have no recorded result, so the model was told their outcome is unknown`,
16
+ truncated: (i) => `${i.count} tool call(s) the model made were cut off before their arguments were complete, so they did not run${afterDroppedCalls(i.outcome)}`,
17
+ unnamed: (i) => `${i.count} tool call(s) the model made arrived with no tool name, so they did not run${afterDroppedCalls(i.outcome)}`,
18
+ bad_frame: (i) => `${i.count} frame(s) of the model's streamed response could not be read and were dropped, so the response may be missing content; the model was told`,
19
+ missing_result_synthesized: (i) => `${i.count} earlier tool call(s) have no recorded result, so the model was told their outcome is unknown`,
17
20
  };
21
+ export function turnCarriesToolCall(content) {
22
+ try {
23
+ return Array.isArray(content) && content.some((c) => c?.type === "toolCall");
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ function outcomeOf(message) {
30
+ const stop = readMember(message, "stopReason");
31
+ if (stop === "error")
32
+ return modelOutputInvalidOfMessage(message) !== undefined ? "terminal" : undefined;
33
+ if (stop === "aborted")
34
+ return undefined;
35
+ return turnCarriesToolCall(readMember(message, "content")) || stop === "toolUse" || stop === "length" ? "reissued" : undefined;
36
+ }
18
37
  export function createToolTurnIncompleteSeat(onNotice, ids) {
19
38
  const announced = new Set();
20
- const announce = (cause, count, toolCallIds) => {
21
- if (announced.has(cause))
39
+ const announce = (item) => {
40
+ if (announced.has(item.cause))
22
41
  return;
23
- announced.add(cause);
24
- const shown = (toolCallIds ?? []).filter((id) => isProviderEchoShape(id));
42
+ announced.add(item.cause);
43
+ const shown = "toolCallIds" in item ? item.toolCallIds.filter((id) => isProviderEchoShape(id)) : [];
25
44
  deliverEngineNotice(onNotice, {
26
45
  code: "task.tool_turn_incomplete",
27
- message: `${INCOMPLETE_SENTENCE[cause](count)}. Announced once per run for this cause.`,
28
- detail: { cause, count, ...(shown.length > 0 ? { toolCallIds: shown } : {}), sessionId: ids.sessionId, runId: ids.runId },
46
+ message: `${INCOMPLETE_SENTENCE[item.cause](item)}. Announced once per run for this cause.`,
47
+ detail: { cause: item.cause, count: item.count, ...("outcome" in item && item.outcome !== undefined ? { outcome: item.outcome } : {}), ...(shown.length > 0 ? { toolCallIds: shown } : {}), sessionId: ids.sessionId, runId: ids.runId },
29
48
  });
30
49
  };
31
50
  return {
32
51
  announce,
33
52
  observe(message) {
34
- for (const drop of streamDropsOf(message))
35
- announce(drop.cause, drop.count);
53
+ const drops = streamDropsOf(message);
54
+ if (drops.length === 0)
55
+ return;
56
+ const outcome = outcomeOf(message);
57
+ for (const drop of drops)
58
+ announce(drop.cause === "bad_frame" ? { cause: "bad_frame", count: drop.count } : { cause: drop.cause, count: drop.count, outcome });
36
59
  },
37
60
  };
38
61
  }
@@ -42,7 +65,7 @@ export function createToolTurnRepairSeat(onNotice, ids) {
42
65
  return (repairs) => {
43
66
  const missing = repairs.filter((repair) => repair.form === "missing_result" && repair.beforeRun !== true).flatMap((repair) => repair.toolCallIds);
44
67
  if (missing.length > 0)
45
- incomplete.announce("missing_result_synthesized", missing.length, missing);
68
+ incomplete.announce({ cause: "missing_result_synthesized", count: missing.length, toolCallIds: missing });
46
69
  const fresh = repairs.filter((repair) => {
47
70
  const key = `${repair.form}\u0000${[...repair.toolCallIds].sort().join("\u0000")}`;
48
71
  if (announced.has(key))
@@ -1249,10 +1249,10 @@ export interface RunnerDeps {
1249
1249
  * declaration refusing the batch legs). Absent = no posture check anywhere.
1250
1250
  */
1251
1251
  interactionPosture?: "interactive" | "headless";
1252
- /** LSP code-intelligence seam (design/64 §13.1): when set, every task mounts an `lsp` tool routed to this
1252
+ /** LSP code-intelligence seam (design/64 §13.1): when set, every task WITH HANDS mounts an `lsp` tool routed to this
1253
1253
  * manager (a deployment-provided language-server bridge, e.g. an E2B LSP sidecar). The manager returns
1254
1254
  * `undefined` for a file whose language has no server, and the tool degrades to "fall back to grep". A
1255
- * task's own `lspManager` overrides this. Unset ⇒ no `lsp` tool. */
1255
+ * task's own `lspManager` overrides this. Neither seat set, or a leg without an execution env ⇒ the engine mounts no `lsp` tool. */
1256
1256
  lspManager?: import("./lsp.js").LspServerManager;
1257
1257
  /**
1258
1258
  * The Artifacts hosting seat — a storage/service-tier seam of the same family as
@@ -38,7 +38,7 @@ function projectRow(row) {
38
38
  return executes.length > 0 ? { valueFlags: [], flags: [], commandString: [], executes } : undefined;
39
39
  }
40
40
  const commandString = sorted(all.filter((o) => o.spec.value?.kind === "shell-text").map((o) => o.spelling));
41
- const values = all.filter((o) => o.spec.value?.kind !== "shell-text");
41
+ const values = all.filter((o) => o.spec.value?.kind !== "shell-text" && o.spec.mode !== "queries");
42
42
  const attached = sorted(values.filter((o) => o.arity === "attached" && !o.spelling.startsWith("--")).map((o) => o.spelling));
43
43
  const required = row.launcher.requiredOperands ?? 0;
44
44
  const duration = required > 0 && row.launcher.requiredOperandSpelling === DURATION_SPELLING;
@@ -602,8 +602,8 @@ export interface TaskSpec {
602
602
  * AskUserQuestion tool is mounted (one of the delivery faces in the criterion — see
603
603
  * {@link interactiveTools}). Overrides `RunnerDeps.onQuestion`. Distinct from `onAsk` (permission). */
604
604
  onQuestion?: import("./ask-question.js").OnQuestion;
605
- /** LSP code-intelligence seam (design/64 §13.1): when set, the `lsp` tool is mounted and routed to this
606
- * manager. Overrides `RunnerDeps.lspManager`. Unset ⇒ no `lsp` tool (the model uses grep/read_file). */
605
+ /** LSP code-intelligence seam (design/64 §13.1): when set, the `lsp` tool is mounted on a leg with hands and routed to
606
+ * this manager. Overrides `RunnerDeps.lspManager`. Neither seat set, or no hands ⇒ the engine mounts no `lsp` tool (the model uses grep/read_file). */
607
607
  lspManager?: import("./lsp.js").LspServerManager;
608
608
  /**
609
609
  * design/121 (CC 198 parity): after the agent edits a file, NEW language-server diagnostics are
@@ -98,7 +98,7 @@ export const TOOL_CATALOG_ENTRIES = [
98
98
  },
99
99
  },
100
100
  { id: "repo-map", name: "RepoMap", source: "builtin", definedIn: "src/tools/fs/repo-map.ts", mountedBy: ["hands"], cards: ["repo-map"], face: { family: "search", effect: "read", contract: c("core.repo_map@1") } },
101
- { id: "lsp", name: "LSP", source: "builtin", definedIn: "src/core/lsp.ts", mountedBy: ["optional"], cards: [], face: { family: "search", effect: "read", contract: c("core.lsp@1") } },
101
+ { id: "lsp", name: "LSP", source: "builtin", definedIn: "src/core/lsp.ts", mountedBy: ["hands"], cards: [], face: { family: "search", effect: "read", contract: c("core.lsp@1") } },
102
102
  {
103
103
  id: "artifact", name: ARTIFACT_TOOL_NAME, source: "builtin", definedIn: "src/tools/artifact/artifact-tool.ts", mountedBy: ["optional"], cards: ["artifact"],
104
104
  face: { family: "other", effect: "write", contract: c("core.artifact@1"), ruleFace: { primaryParams: ["action"] }, renderHints: { summaryParams: ["action", "file_path", "url"] } },
@@ -14,6 +14,7 @@ export const TOOL_CONFORMANCE_VECTORS = [
14
14
  { name: "SendMessage", why: "needs the runner self seat (a direct prepare has none)" },
15
15
  { name: "AgentTranscript", why: "needs the runner self seat (a direct prepare has none)" },
16
16
  { name: "ListAgents", why: "mounts with the delegation face only under a runner self seat (a direct prepare has none)" },
17
+ { name: "LSP", why: "needs a wired LSP manager" },
17
18
  ], [OFFLOAD_READER]),
18
19
  vector("hands-readonly", ["always", "hands"], [
19
20
  { name: "Edit", why: "the read-only band mounts the read half only" },
@@ -23,9 +24,9 @@ export const TOOL_CONFORMANCE_VECTORS = [
23
24
  { name: "TaskStop", why: "the background-shell pair mounts on the full shell only" },
24
25
  { name: "EnterWorktree", why: "the worktree pair needs the tracked cwd the full shell owns" },
25
26
  { name: "ExitWorktree", why: "the worktree pair needs the tracked cwd the full shell owns" },
27
+ { name: "LSP", why: "needs a wired LSP manager" },
26
28
  ], [OFFLOAD_READER]),
27
29
  vector("plan-mode", ["always", "optional"], [
28
- { name: "LSP", why: "needs a wired LSP manager" },
29
30
  { name: "Artifact", why: "needs a wired artifact host" },
30
31
  { name: "Skill", why: "needs a skills listing" },
31
32
  { name: "StructuredOutput", why: "needs an output schema" },
@@ -392,6 +392,11 @@ export declare function ruleFaceOfInstance(schema: TSchema | undefined, declared
392
392
  primaryParams: string[];
393
393
  params: string[];
394
394
  };
395
+ /** The instance each NAME dispatches to when several are mounted under it: the LATER mount (the harness keeps the
396
+ * later one). The one selection — the roster's snapshot and every table keyed by a caller tool's name read it here. */
397
+ export declare function survivorsByName<T extends {
398
+ readonly name: string;
399
+ }>(mounted: readonly T[]): Map<string, T>;
395
400
  /** What a mount site knows that the instance does not: where the tool came from and which condition fired. */
396
401
  export interface RosterMountMeta {
397
402
  source: RosterSource;
@@ -443,13 +448,13 @@ export declare class RosterBuilder {
443
448
  get tools(): readonly AgentTool[];
444
449
  get length(): number;
445
450
  metaOf(tool: AgentTool): RosterMountMeta | undefined;
446
- /** Mount one instance at the tail. An instance that DECLARES an effect writes it into the leg's effect map —
447
- * the later mount under a name is the one the harness dispatches to (`snapshot`'s yield rule), so its
451
+ /** Mount one instance at the tail. An engine-lane instance that DECLARES an effect writes it into the leg's effect
452
+ * map — the later mount under a name is the one the harness dispatches to (`snapshot`'s yield rule), so its
448
453
  * declaration is the map's truth for that name; a pre-mount registration (the hand band, the plan pair, an
449
- * MCP axis) stands only for names no mounted instance declares. ONE lane is different by construction: the
450
- * `scenario` lane's declarations were latched by the safety scan BEFORE any mount, on the synchronous
451
- * doors+scan stretch (a microtask cannot flip them) — so a scenario mount never re-reads its instance's
452
- * effect over a registered name; an engine lane's mount under that name still displaces it. */
454
+ * MCP axis) stands only for names no mounted instance declares. The `scenario` lane never writes the map: its
455
+ * declarations were latched by the safety scan BEFORE any mount (on the kept instance of each name, on the
456
+ * synchronous doors+scan stretch — a microtask cannot flip them), so a scenario mount, kept or dropped, leaves
457
+ * the map as the scan made it; an engine lane's mount under that name still displaces it. */
453
458
  mount(tool: AgentTool, meta: RosterMountMeta): void;
454
459
  /** An ENGINE-INJECTED instance the harness list carries outside the mount array (the ToolSearch lane): a roster
455
460
  * row — the model can call it — never a mount-array element (the disclosure lane owns that list and appends the
@@ -294,6 +294,12 @@ function capRenderHints(h) {
294
294
  out.approvalCard = h.approvalCard;
295
295
  return Object.keys(out).length > 0 ? out : undefined;
296
296
  }
297
+ export function survivorsByName(mounted) {
298
+ const winner = new Map();
299
+ for (const t of mounted)
300
+ winner.set(t.name, t);
301
+ return winner;
302
+ }
297
303
  export const faceOf = (t) => t;
298
304
  function configError(code, message) {
299
305
  const e = new Error(message);
@@ -333,7 +339,7 @@ export class RosterBuilder {
333
339
  throw configError("config.tool_roster_mount_tag", `tool ${JSON.stringify(tool.name)}: mount tag ${JSON.stringify(meta.mountedBy)} is outside the closed set`);
334
340
  this.#meta.set(tool, meta);
335
341
  const eff = faceOf(tool).effect;
336
- if (eff !== undefined && !(meta.mountedBy === "scenario" && this.#effects.has(tool.name)))
342
+ if (eff !== undefined && meta.mountedBy !== "scenario")
337
343
  this.#effects.set(tool.name, eff);
338
344
  }
339
345
  mountAll(tools, meta) {
@@ -382,9 +388,7 @@ export class RosterBuilder {
382
388
  }
383
389
  snapshot(facts) {
384
390
  const all = [...this.#tools, ...this.#engineLane];
385
- const winner = new Map();
386
- for (const t of all)
387
- winner.set(t.name, t);
391
+ const winner = survivorsByName(all);
388
392
  const entries = [];
389
393
  const emitted = new Set();
390
394
  for (const t of all) {
@@ -1,4 +1,5 @@
1
1
  import type { ExecutionEnv } from "../internal/harness-types.js";
2
+ import type { ObservedFilesystem } from "../tools/fs/shell-read-environment.js";
2
3
  import type { PermissionResult } from "./tool-policy.js";
3
4
  import { type ToolCallLike } from "./tool-registry.js";
4
5
  /**
@@ -231,7 +232,8 @@ export interface LegWriteJudgment {
231
232
  * write rows together — one reading of the target (its spelling, its real target, its symlink chain; a `canonical` key
232
233
  * judged as a key), one exempt root, one absence law. `undefined` ⇒ no row covers it. Absent when neither table has a
233
234
  * row. */
234
- readonly judgeWriteTarget: ((target: string, opts?: {
235
+ readonly judgeWriteTarget: ((target: string, opts: {
236
+ readonly fs: ObservedFilesystem;
235
237
  readonly signal?: AbortSignal;
236
238
  readonly canonical?: true;
237
239
  }) => Promise<WriteTargetCoverage | undefined>) | undefined;
@@ -223,7 +223,7 @@ export function compileLegWriteJudgment(entries, opts) {
223
223
  judgeWriteTarget: rows.length === 0 && guardRows === undefined
224
224
  ? undefined
225
225
  : async (target, judgeOpts) => {
226
- const hit = await compiled.judgment(target, { ...(judgeOpts?.signal !== undefined ? { signal: judgeOpts.signal } : {}), ...(judgeOpts?.canonical === true ? { canonical: true } : {}) }, guardRows);
226
+ const hit = await compiled.judgment(target, { ...(judgeOpts.signal !== undefined ? { signal: judgeOpts.signal } : {}), ...(judgeOpts.canonical === true ? { canonical: true } : {}) }, judgeOpts.fs, guardRows);
227
227
  return hit === undefined ? undefined : hit.unreadable === undefined && TABLE_ROWS.has(hit.row) ? "protected" : "guarded";
228
228
  },
229
229
  };
@@ -283,9 +283,8 @@ function compileWriteJudgment(rows, origin, opts) {
283
283
  const base = typeof cwd === "string" && cwd.length > 0 ? cwd : undefined;
284
284
  return base !== undefined ? anchorSpelling(pathFamilyOf({ cwd: base }), base, path) : path;
285
285
  };
286
- const env = opts?.env;
287
286
  const rootPath = opts?.rootPath;
288
- const judgment = async (path, ctx, extraSource) => {
287
+ const judgment = async (path, ctx, env, extraSource) => {
289
288
  let noted = false;
290
289
  const noteAbsence = (cause, unresolvedSymlink) => {
291
290
  if (noted)
@@ -384,13 +383,13 @@ function compileWriteJudgment(rows, origin, opts) {
384
383
  };
385
384
  const matcher = {
386
385
  rows,
387
- targetView: env === undefined ? "spelling-only" : "target",
386
+ targetView: opts?.env === undefined ? "spelling-only" : "target",
388
387
  matchPath(path, cwd) {
389
388
  const hit = judgePathRows(tableAt([]), { views: [spelledView(anchoredSpelling(path, cwd), "lexical")] }, "tighten");
390
389
  return hit === undefined ? null : { name: hit.row.name, kind: hit.row.kind };
391
390
  },
392
391
  async judge(path, ctx = {}) {
393
- const hit = await judgment(path, ctx);
392
+ const hit = await judgment(path, ctx, opts?.env);
394
393
  if (hit === undefined)
395
394
  return { kind: "clear" };
396
395
  if (hit.unreadable !== undefined)
@@ -94,7 +94,8 @@ export interface ProgramPosition {
94
94
  /** The program the stream-fed chain member runs when the chain names none (`xargs` runs `echo`). */
95
95
  readonly defaultProgram?: string;
96
96
  /** The program bash RUNS: the first word past the chain. `name` is the folded word as written, `basename` what
97
- * bash resolves (`/usr/bin/cat` is `cat`). `undefined` when the chain names none or the read was refused. */
97
+ * bash resolves (`/usr/bin/cat` is `cat`). `undefined` when the chain names none (a lookup, `command -v git`, runs
98
+ * nothing) or the read was refused. */
98
99
  readonly program: {
99
100
  readonly index: number;
100
101
  readonly name: string;
@@ -1,6 +1,6 @@
1
1
  import { SHELL_CONTROL_PREFIXES } from "../../shell/lex.js";
2
2
  import { shellWordsOf } from "../../shell/shell-words.js";
3
- import { BLOCK_WORD_NAMES, LAUNCHER_NAMES, feedsArgvFromStdin, launcherOptionMovesBase, launcherOptionStep, launcherRunsShell, programRow, selectedOptions, streamDefaultProgram } from "./program-read-model.js";
3
+ import { BLOCK_WORD_NAMES, LAUNCHER_NAMES, feedsArgvFromStdin, launcherOptionMovesBase, launcherOptionQueries, launcherOptionStep, launcherRunsShell, programRow, selectedOptions, streamDefaultProgram } from "./program-read-model.js";
4
4
  export const NOT_AUTO_ALLOWED = "— not auto-allowed";
5
5
  export function programBasename(word) {
6
6
  return word.includes("/") ? (word.split("/").pop() ?? word) : word;
@@ -38,7 +38,7 @@ function launcherRowOf(name) {
38
38
  const row = programRow(name);
39
39
  return row?.launcher !== undefined ? row : undefined;
40
40
  }
41
- function pastLauncherOptions(launcher, folded, i, moved) {
41
+ function pastLauncherOptions(launcher, folded, i, moved, queried) {
42
42
  const row = launcherRowOf(launcher);
43
43
  if (row === undefined)
44
44
  return i;
@@ -57,7 +57,11 @@ function pastLauncherOptions(launcher, folded, i, moved) {
57
57
  return i;
58
58
  if (launcherOptionMovesBase(row, tok))
59
59
  moved?.();
60
+ if (launcherOptionQueries(row, tok))
61
+ queried?.();
60
62
  i += step;
63
+ if (tok === "--")
64
+ return i;
61
65
  }
62
66
  }
63
67
  export const PROGRAM_POSITION_REFUSAL_KINDS = Object.freeze(["option_at_program_position", "control_syntax_at_program_position"]);
@@ -103,12 +107,17 @@ export function readProgramPosition(words) {
103
107
  let reentryDefault = argvFromStdin ? streamDefaultProgram(basename) : undefined;
104
108
  let launcherMovesBase = false;
105
109
  const moved = () => { launcherMovesBase = true; };
110
+ let looksUp = false;
111
+ const queried = () => { looksUp = true; };
106
112
  const chain = (program, refusal) => {
107
113
  const runsDefaultProgram = lastReentry === folded.length - 1;
108
114
  return { controlWords, written, assignments, launchers, argvFromStdin, runsDefaultProgram, ...(runsDefaultProgram && reentryDefault !== undefined ? { defaultProgram: reentryDefault } : {}), program, refusal, launcherMovesBase };
109
115
  };
110
116
  const selectsShell = (l, end) => selectedOptions(launcherRowOf(l.name), folded.slice(l.index + 1, end)).some((o) => o.mode === "interactive");
111
- for (let j = pastLauncherOptions(basename, folded, i + 1, moved); j < folded.length; j++) {
117
+ const first = pastLauncherOptions(basename, folded, i + 1, moved, queried);
118
+ if (looksUp)
119
+ return chain(undefined);
120
+ for (let j = first; j < folded.length; j++) {
112
121
  const t = folded[j];
113
122
  if (owedOperands > 0 && !t.startsWith("-")) {
114
123
  owedOperands -= 1;
@@ -133,7 +142,9 @@ export function readProgramPosition(words) {
133
142
  if (launcherRunsShell(launcherRowOf(wordBasename), folded.slice(j + 1)))
134
143
  return chain(undefined, shellRefusal(j, j + 1));
135
144
  owedOperands = lastRow?.requiredOperands ?? 0;
136
- j = pastLauncherOptions(wordBasename, folded, j + 1, moved) - 1;
145
+ j = pastLauncherOptions(wordBasename, folded, j + 1, moved, queried) - 1;
146
+ if (looksUp)
147
+ return chain(undefined);
137
148
  continue;
138
149
  }
139
150
  if (t.startsWith("-"))
@@ -167,8 +167,8 @@ export interface StrictResidualOptions {
167
167
  * 1. the quote-blind class ({@link SHELL_SEGMENT_QUOTE_BLIND_REJECT}) over every written spelling — a word, a
168
168
  * redirection target under `tolerateRedirection` — plus every group, here-document, line-break connector
169
169
  * and (unless tolerated or data-free) redirection token;
170
- * 2. the expansion character where bash expands it ({@link expandsDollar}), the home-directory word excepted
171
- * under `homeDeclared`;
170
+ * 2. the expansion character where bash expands it and a `~user` / `~+` / `~-` tilde word
171
+ * ({@link expandsBeyondText}), the home-directory word excepted under `homeDeclared`;
172
172
  * 3. a backgrounding `&` and a `|&`;
173
173
  * 4. a word whose value the text does not settle ({@link UNRESOLVED_SENTENCE}) — a compound array, an
174
174
  * off-table ANSI-C escape, a subscript or parameter expansion this lexer does not read.
@@ -26,20 +26,22 @@ function hasBareShellOperator(s, quotedOperatorsAreText) {
26
26
  for (const t of lexed.segments[0].tokens) {
27
27
  if (t.kind !== "word")
28
28
  return true;
29
- if (expandsDollar(t) || t.assignment?.form === "compound")
29
+ if (expandsBeyondText(t) || t.assignment?.form === "compound")
30
30
  return true;
31
31
  if (t.pieces.some((p) => p.quotedBy === "\\" || (p.quotedBy === undefined && p.literal !== undefined && /[\r\\]/.test(p.literal))))
32
32
  return true;
33
33
  }
34
34
  return false;
35
35
  }
36
- function expandsDollar(w) {
36
+ function expandsBeyondText(w) {
37
37
  if (w.quotedSpans.some((q) => q.by === '$"'))
38
38
  return true;
39
39
  for (const p of w.pieces) {
40
40
  const k = p.expansion?.kind;
41
41
  if (k === "parameter" || k === "command" || k === "process" || k === "arithmetic")
42
42
  return true;
43
+ if (k === "tilde" && p.expansion?.body !== "~")
44
+ return true;
43
45
  if (p.literal !== undefined && (p.quotedBy === undefined || p.quotedBy === '"') && /[$`]/.test(p.literal))
44
46
  return true;
45
47
  }
@@ -126,7 +128,7 @@ export function strictResidualRefusal(lexed, source, opts = {}) {
126
128
  }
127
129
  const home = opts.homeDir !== undefined && !textBindsHome(source) ? opts.homeDir : undefined;
128
130
  for (const w of words) {
129
- if (expandsDollar(w) && !(home !== undefined && homeSpelledForClassify(w, home) !== undefined))
131
+ if (expandsBeyondText(w) && !(home !== undefined && homeSpelledForClassify(w, home) !== undefined))
130
132
  return reason;
131
133
  }
132
134
  for (const s of lexed.segments) {
@@ -29,7 +29,7 @@ export declare const PATH_VALUE_FORMS: readonly ["indirect", "form", "header", "
29
29
  export type PathValueForm = (typeof PATH_VALUE_FORMS)[number];
30
30
  /** What selecting an option does to how the program runs ({@link OptionSpec.mode}) — a CLOSED set (`docs/CLOSED-SETS.md`,
31
31
  * `OptionMode`). */
32
- export declare const OPTION_MODES: readonly ["banner", "interactive", "names-program", "follows", "repeats", "quiet", "forced"];
32
+ export declare const OPTION_MODES: readonly ["banner", "interactive", "names-program", "follows", "repeats", "quiet", "forced", "queries"];
33
33
  export type OptionMode = (typeof OPTION_MODES)[number];
34
34
  /**
35
35
  * What one word is in this program's hands — a CLOSED set (`docs/CLOSED-SETS.md`, `ArgRoleKind`), the eleven members the
@@ -188,7 +188,8 @@ export interface OptionSpec {
188
188
  * the program to run without a value (`node --test`; a launcher's, from the caller's environment: `su -m`); `follows`:
189
189
  * follows its files as they grow, until killed (`tail -f`); `repeats`: repeats on an interval until killed (`free -s`);
190
190
  * `quiet`: prints only what the program's own text tells it to (`sed -n`); `forced`: removes without asking, and passes
191
- * over an operand that is not there (`rm -f`). */
191
+ * over an operand that is not there (`rm -f`); `queries`: a LOOKUP — the operands are names it looks up and describes, and
192
+ * it runs none of them (`command -v`). */
192
193
  readonly mode?: OptionMode;
193
194
  /** This switch turns the program's own value glob off (`curl -g`): every {@link ArgRole} `programGlob` value of the
194
195
  * invocation is read literally. */
@@ -717,6 +718,10 @@ export declare function launcherOptionPathRole(row: ProgramRow, tok: string): Ex
717
718
  * written (`nsenter -w` bare takes the target process's directory)? In a cluster, a letter up to the first that takes a
718
719
  * value. `false` for any word the walk does not step. */
719
720
  export declare function launcherOptionMovesBase(row: ProgramRow, tok: string): boolean;
721
+ /** Does this launcher option word select a LOOKUP ({@link OptionSpec.mode} `queries`: `command -v`, `-V`, a cluster
722
+ * carrying one — `-pv`), so that the words after the options are names and no program is run? In a cluster, a letter up
723
+ * to the first that takes a value. `false` for any word the walk does not step. */
724
+ export declare function launcherOptionQueries(row: ProgramRow, tok: string): boolean;
720
725
  /** How many words one launcher option occupies ({@link readLauncherOption}), or `undefined` where the row does not
721
726
  * explain it. */
722
727
  export declare function launcherOptionStep(row: ProgramRow, tok: string): 1 | 2 | undefined;
@@ -2,7 +2,7 @@ import { literalTildeSpelling } from "../../shell/lex.js";
2
2
  import { PROGRAM_ROWS } from "./program-read-model.rows.js";
3
3
  export const DATUM_FORMS = Object.freeze(["count", "pattern", "name-pattern", "label", "message", "delimiter"]);
4
4
  export const PATH_VALUE_FORMS = Object.freeze(["indirect", "form", "header", "url"]);
5
- export const OPTION_MODES = Object.freeze(["banner", "interactive", "names-program", "follows", "repeats", "quiet", "forced"]);
5
+ export const OPTION_MODES = Object.freeze(["banner", "interactive", "names-program", "follows", "repeats", "quiet", "forced", "queries"]);
6
6
  export const ARG_ROLE_KINDS = Object.freeze(["path", "mount-source", "object-path", "program-text", "program-file", "shell-text", "command", "assignment", "binds-name", "entry-probe", "datum"]);
7
7
  export const PATH_EFFECTS = Object.freeze(["read", "names", "write", "create", "edit-in-place"]);
8
8
  export const WRITING_EFFECTS = new Set(["write", "create", "edit-in-place"]);
@@ -1091,6 +1091,26 @@ export function launcherOptionMovesBase(row, tok) {
1091
1091
  }
1092
1092
  return false;
1093
1093
  }
1094
+ export function launcherOptionQueries(row, tok) {
1095
+ if (tok === "-" || tok === "--")
1096
+ return false;
1097
+ if (tok.startsWith("--")) {
1098
+ const eq = tok.indexOf("=");
1099
+ return steppableLauncherOption(row, eq > 2 ? tok.slice(0, eq) : tok)?.spec.mode === "queries";
1100
+ }
1101
+ if (!/^-[^-]/.test(tok))
1102
+ return false;
1103
+ for (let i = 1; i < tok.length; i++) {
1104
+ const m = steppableLauncherOption(row, `-${tok[i]}`);
1105
+ if (m === undefined)
1106
+ return false;
1107
+ if (m.spec.mode === "queries")
1108
+ return true;
1109
+ if (m.arity !== 0)
1110
+ return false;
1111
+ }
1112
+ return false;
1113
+ }
1094
1114
  export function launcherOptionStep(row, tok) {
1095
1115
  return readLauncherOption(row, tok)?.step;
1096
1116
  }