@sema-agent/core 7.17.6 → 7.18.0

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 (164) hide show
  1. package/CHANGELOG.md +165 -13
  2. package/dist/agents/send-message-tool.js +1 -0
  3. package/dist/agents/subagent.d.ts +1 -0
  4. package/dist/agents/subagent.js +5 -0
  5. package/dist/agents/teacher.js +1 -0
  6. package/dist/agents/verify.js +3 -0
  7. package/dist/brain/route-adjudicator.d.ts +18 -0
  8. package/dist/brain/route-adjudicator.js +16 -0
  9. package/dist/core/agent-definition.d.ts +10 -0
  10. package/dist/core/artifact-host.d.ts +41 -4
  11. package/dist/core/artifact-host.js +18 -2
  12. package/dist/core/auto-mode.d.ts +141 -45
  13. package/dist/core/auto-mode.js +109 -3
  14. package/dist/core/checkpoint-store.d.ts +16 -2
  15. package/dist/core/engine-notice.d.ts +47 -1
  16. package/dist/core/engine-notice.js +7 -0
  17. package/dist/core/gate-lanes.js +2 -1
  18. package/dist/core/gate-outcome.d.ts +17 -2
  19. package/dist/core/gate-outcome.js +16 -0
  20. package/dist/core/governance-codes.d.ts +1 -1
  21. package/dist/core/governance-codes.js +8 -0
  22. package/dist/core/hook-seats.d.ts +66 -0
  23. package/dist/core/hook-seats.js +37 -0
  24. package/dist/core/runner/advertised-writable-dirs.d.ts +8 -3
  25. package/dist/core/runner/assemble-result.d.ts +2 -1
  26. package/dist/core/runner/assemble-result.js +1 -1
  27. package/dist/core/runner/classifier-ladder.d.ts +89 -0
  28. package/dist/core/runner/classifier-ladder.js +65 -0
  29. package/dist/core/runner/contracts.d.ts +69 -13
  30. package/dist/core/runner/decide-continuation.d.ts +1 -3
  31. package/dist/core/runner/decide-continuation.js +1 -7
  32. package/dist/core/runner/deferral-census.d.ts +52 -0
  33. package/dist/core/runner/deferral-census.js +44 -0
  34. package/dist/core/runner/derived-route-fallback.d.ts +8 -4
  35. package/dist/core/runner/derived-route-fallback.js +8 -3
  36. package/dist/core/runner/gate-exit.d.ts +12 -0
  37. package/dist/core/runner/gate-exit.js +6 -3
  38. package/dist/core/runner/prepare-artifact.d.ts +10 -2
  39. package/dist/core/runner/prepare-artifact.js +5 -4
  40. package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -2
  41. package/dist/core/runner/prepare-caps-and-workflow.js +30 -31
  42. package/dist/core/runner/prepare-config-doors.d.ts +1 -1
  43. package/dist/core/runner/prepare-config-doors.js +5 -3
  44. package/dist/core/runner/prepare-defer-classify.d.ts +13 -19
  45. package/dist/core/runner/prepare-defer-classify.js +8 -3
  46. package/dist/core/runner/prepare-gate-stations.d.ts +26 -0
  47. package/dist/core/runner/prepare-gate-stations.js +1 -1
  48. package/dist/core/runner/prepare-hands-readface.d.ts +24 -7
  49. package/dist/core/runner/prepare-hands-readface.js +60 -24
  50. package/dist/core/runner/prepare-inherited-gate.d.ts +17 -4
  51. package/dist/core/runner/prepare-inherited-gate.js +10 -2
  52. package/dist/core/runner/prepare-lsp.d.ts +2 -2
  53. package/dist/core/runner/prepare-lsp.js +1 -1
  54. package/dist/core/runner/prepare-policy-chain.js +2 -0
  55. package/dist/core/runner/prepare-project-context.js +4 -2
  56. package/dist/core/runner/prepare-prompt-inputs.d.ts +4 -2
  57. package/dist/core/runner/prepare-prompt-inputs.js +4 -1
  58. package/dist/core/runner/prepare-run-refs.d.ts +9 -1
  59. package/dist/core/runner/prepare-run-refs.js +3 -2
  60. package/dist/core/runner/prepare-suspend-saga.d.ts +5 -0
  61. package/dist/core/runner/prepare-suspend-saga.js +5 -2
  62. package/dist/core/runner/prepare-task.js +18 -13
  63. package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +5 -0
  64. package/dist/core/runner/prepare-tool-disclosure-mount.js +10 -2
  65. package/dist/core/runner/prepare-turn-wiring.d.ts +8 -4
  66. package/dist/core/runner/prepare-turn-wiring.js +3 -2
  67. package/dist/core/runner/prepare-wiring-manifest.d.ts +5 -5
  68. package/dist/core/runner/prepare-wiring-manifest.js +5 -2
  69. package/dist/core/runner/resume-checkpoint-screen.js +7 -0
  70. package/dist/core/runner/run-harness-handlers.js +4 -1
  71. package/dist/core/runner/run-identity-wiring.js +6 -2
  72. package/dist/core/runner/run-leg.d.ts +2 -0
  73. package/dist/core/runner/run-leg.js +35 -41
  74. package/dist/core/runner/run-notification-lane.js +55 -27
  75. package/dist/core/runner/run-settle-and-teardown.js +1 -0
  76. package/dist/core/runner/run-terminal-adoption.js +16 -13
  77. package/dist/core/runner/run-turn-boundary.js +3 -1
  78. package/dist/core/runner/runtask.d.ts +6 -5
  79. package/dist/core/runner/runtask.js +26 -14
  80. package/dist/core/runner/skill-base-dir.d.ts +54 -0
  81. package/dist/core/runner/skill-base-dir.js +42 -0
  82. package/dist/core/runner/synthetic-tools.d.ts +16 -19
  83. package/dist/core/runner/synthetic-tools.js +18 -10
  84. package/dist/core/runner/tool-defer-gate.d.ts +22 -9
  85. package/dist/core/runner/tool-defer-gate.js +15 -5
  86. package/dist/core/runner/tool-disclosure.d.ts +5 -0
  87. package/dist/core/runner/tool-disclosure.js +2 -0
  88. package/dist/core/runner/tool-progress-lane.d.ts +42 -0
  89. package/dist/core/runner/tool-progress-lane.js +61 -0
  90. package/dist/core/runner-deps.d.ts +48 -7
  91. package/dist/core/swappable-deps.d.ts +9 -7
  92. package/dist/core/swappable-deps.js +7 -3
  93. package/dist/core/task-event.d.ts +96 -29
  94. package/dist/core/task-notification.d.ts +142 -16
  95. package/dist/core/task-notification.js +106 -0
  96. package/dist/core/task-result.d.ts +9 -1
  97. package/dist/core/task-spec.d.ts +20 -3
  98. package/dist/core/tighten-task-spec.js +7 -5
  99. package/dist/core/tool-model-gate.d.ts +1 -1
  100. package/dist/core/tool-search-setting.d.ts +78 -0
  101. package/dist/core/tool-search-setting.js +90 -0
  102. package/dist/core/tool-spec.d.ts +8 -0
  103. package/dist/core/types.d.ts +1 -1
  104. package/dist/core/types.js +1 -1
  105. package/dist/core/wiring-manifest.d.ts +46 -0
  106. package/dist/core/wiring-manifest.js +9 -1
  107. package/dist/engine/execution-env/node-execution-env.js +10 -4
  108. package/dist/engine/harness/agent-harness.d.ts +42 -0
  109. package/dist/engine/harness/agent-harness.js +61 -29
  110. package/dist/engine/loop/agent-loop.d.ts +68 -3
  111. package/dist/engine/loop/agent-loop.js +109 -16
  112. package/dist/index.d.ts +10 -5
  113. package/dist/index.js +9 -5
  114. package/dist/internal/harness.d.ts +1 -1
  115. package/dist/orchestration/run-workflow-tool.d.ts +3 -0
  116. package/dist/orchestration/run-workflow-tool.js +6 -1
  117. package/dist/orchestration/workflow-governance.d.ts +7 -1
  118. package/dist/orchestration/workflow-governance.js +9 -5
  119. package/dist/orchestration/workflow-primitives.d.ts +3 -0
  120. package/dist/orchestration/workflow-primitives.js +6 -1
  121. package/dist/orchestration/workflow-types.d.ts +48 -13
  122. package/dist/orchestration/workflow-types.js +1 -1
  123. package/dist/orchestration/workflow.js +45 -17
  124. package/dist/prompt-assembly/epoch.js +2 -0
  125. package/dist/prompt-assembly/packs/sema-default.js +2 -2
  126. package/dist/prompt-assembly/section-usage.d.ts +26 -0
  127. package/dist/prompt-assembly/section-usage.js +10 -0
  128. package/dist/prompt-assembly/types.d.ts +8 -6
  129. package/dist/prompts/default.d.ts +13 -0
  130. package/dist/prompts/default.js +16 -8
  131. package/dist/tools/artifact/artifact-text.d.ts +10 -1
  132. package/dist/tools/artifact/artifact-text.js +6 -3
  133. package/dist/tools/artifact/artifact-tool.d.ts +12 -2
  134. package/dist/tools/artifact/artifact-tool.js +203 -70
  135. package/dist/tools/artifact/conformance.d.ts +56 -0
  136. package/dist/tools/artifact/conformance.js +349 -0
  137. package/dist/tools/artifact/local-stub.js +1 -1
  138. package/dist/tools/artifact/reject-breaker.d.ts +28 -0
  139. package/dist/tools/artifact/reject-breaker.js +42 -0
  140. package/dist/tools/fs/bash-lexer.d.ts +132 -0
  141. package/dist/tools/fs/bash-lexer.js +563 -0
  142. package/dist/tools/fs/bash-program-position.d.ts +142 -0
  143. package/dist/tools/fs/bash-program-position.js +125 -0
  144. package/dist/tools/fs/bash-readonly-classifier.d.ts +54 -16
  145. package/dist/tools/fs/bash-readonly-classifier.js +375 -530
  146. package/dist/tools/fs/containment-face.d.ts +89 -0
  147. package/dist/tools/fs/containment-face.js +81 -0
  148. package/dist/tools/fs/fs-bash.d.ts +46 -1
  149. package/dist/tools/fs/fs-bash.js +158 -22
  150. package/dist/tools/fs/fs-read.d.ts +1 -1
  151. package/dist/tools/fs/fs-search-tools.d.ts +3 -3
  152. package/dist/tools/fs/fs-write.d.ts +4 -3
  153. package/dist/tools/fs/fs-write.js +9 -9
  154. package/dist/tools/fs/index.d.ts +24 -5
  155. package/dist/tools/fs/index.js +18 -6
  156. package/dist/tools/fs/repo-map.d.ts +2 -2
  157. package/dist/tools/fs/safety.d.ts +3 -3
  158. package/dist/tools/fs/safety.js +4 -4
  159. package/package.json +1 -1
  160. package/test/export-surface.snapshot.json +85 -9
  161. package/wire-changes/core@7.17.6.json +81 -0
  162. package/wire-changes/core@7.17.7.json +49 -0
  163. package/dist/tools/fs/read-face.d.ts +0 -68
  164. package/dist/tools/fs/read-face.js +0 -47
@@ -0,0 +1,349 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readArtifactHostCapabilities, } from "../../core/artifact-host.js";
3
+ class Violation extends Error {
4
+ }
5
+ const fail = (detail) => {
6
+ throw new Violation(detail);
7
+ };
8
+ const check = (cond, detail) => {
9
+ if (!cond)
10
+ fail(detail);
11
+ };
12
+ const enc = new TextEncoder();
13
+ const FOREIGN_URL = "https://conformance.invalid/artifact/none";
14
+ const HTML_MEDIA = "text/html";
15
+ const ctxOf = (h, attemptId) => ({ taskId: `conformance-${h.runId}`, runId: h.runId, attemptId: attemptId ?? `${h.runId}-${++h.seq}` });
16
+ const fixtureBudget = (h) => Math.max(1, Math.min(h.caps.maxFileBytes, Math.floor(h.caps.maxTotalBytes / 2)));
17
+ const fit = (h, text) => enc.encode(text).slice(0, fixtureBudget(h));
18
+ const el = (tag, inner) => `<${tag}>${inner}</${tag}>`;
19
+ const page = (h, n, extraFiles = []) => [{ path: "index.html", content: fit(h, `${n}${el("title", `C${n}`)}${el("p", String(n))}`), mediaType: HTML_MEDIA }, ...extraFiles];
20
+ const script = (h, n) => ({ path: "assets/app.js", content: fit(h, `${n};console.log(${n})`).slice(0, Math.floor(h.caps.maxTotalBytes / 2)), mediaType: "text/javascript" });
21
+ const req = (files, over = {}) => ({ files, publishContext: "plain", ...over });
22
+ const same = (a, b) => a.byteLength === b.byteLength && a.every((x, i) => x === b[i]);
23
+ async function publish(h, r, attemptId) {
24
+ try {
25
+ return await h.host.publish(r, ctxOf(h, attemptId));
26
+ }
27
+ catch (err) {
28
+ return fail(`publish threw: ${err instanceof Error ? err.message : String(err)}`);
29
+ }
30
+ }
31
+ async function publishOk(h, r, what, attemptId) {
32
+ const out = await publish(h, r, attemptId);
33
+ if (!out.ok)
34
+ return fail(`${what}: expected ok, got ${out.code}${"message" in out ? ` (${out.message})` : ""}`);
35
+ check(typeof out.ref.url === "string" && out.ref.url.length > 0 && typeof out.ref.slug === "string" && typeof out.ref.version === "string", `${what}: the ref must carry url, slug and version strings`);
36
+ return out.ref;
37
+ }
38
+ async function listMine(h) {
39
+ const out = await h.host.list({ scope: "mine", limit: 1000 }, ctxOf(h));
40
+ if (!out.ok)
41
+ return fail(`list mine refused: ${out.message}`);
42
+ return out.rows;
43
+ }
44
+ const vectorTable = [];
45
+ const define = (v, run) => void vectorTable.push({ ...v, run });
46
+ define({ id: "publish.fresh_mints_distinct_pages", describe: "two publishes without `url` mint two pages with distinct urls (fresh never touches an existing page)", requiresActions: ["publish"] }, async (h) => {
47
+ const a = await publishOk(h, req(page(h, 1)), "first fresh");
48
+ const b = await publishOk(h, req(page(h, 2)), "second fresh");
49
+ check(a.url !== b.url, `both fresh publishes answered the same url ${a.url}`);
50
+ return `two pages: ${a.url}, ${b.url}`;
51
+ });
52
+ define({ id: "publish.update_by_url_advances_same_page", describe: "a publish with `url` and the current `baseVersion` advances that page's version on the same url and slug (update never mints a page); where read / read_file are declared, the NEW bytes are what reads back", requiresActions: ["publish"] }, async (h) => {
53
+ const a = await publishOk(h, req(page(h, 1)), "fresh");
54
+ const next = page(h, 2);
55
+ const b = await publishOk(h, req(next, { url: a.url, baseVersion: a.version }), "update");
56
+ check(b.url === a.url && b.slug === a.slug, `the update answered url ${b.url} / slug ${b.slug}, expected ${a.url} / ${a.slug}`);
57
+ check(b.version !== a.version, `the update left the version at ${a.version}`);
58
+ const persisted = [];
59
+ if (h.caps.actions.includes("read")) {
60
+ const r = await h.host.read({ url: a.url }, ctxOf(h));
61
+ check(r.ok && r.content === new TextDecoder().decode(next[0].content) && r.ref.version === b.version, "read after the update returns the PREVIOUS content or version: the update was acknowledged but not persisted");
62
+ persisted.push("read");
63
+ }
64
+ if (h.caps.actions.includes("read_file")) {
65
+ const f = await h.host.readFile({ url: a.url, path: next[0].path }, ctxOf(h));
66
+ check(f.ok && same(f.file.content, next[0].content), "read_file after the update returns the PREVIOUS bytes: the update was acknowledged but not persisted");
67
+ persisted.push("read_file");
68
+ }
69
+ return `version ${a.version} → ${b.version} on ${a.url}${persisted.length > 0 ? `; new bytes read back via ${persisted.join(", ")}` : ""}`;
70
+ });
71
+ define({ id: "publish.no_base_version_is_last_write_wins", describe: "`url` present and `baseVersion` absent ⇒ the write applies (last-write-wins is a defined behaviour, never a conflict)", requiresActions: ["publish"] }, async (h) => {
72
+ const a = await publishOk(h, req(page(h, 1)), "fresh");
73
+ const b = await publishOk(h, req(page(h, 2), { url: a.url }), "first update without baseVersion");
74
+ const c = await publishOk(h, req(page(h, 3), { url: a.url }), "second update without baseVersion");
75
+ check(b.version !== a.version && c.version !== b.version, `versions did not advance: ${a.version}, ${b.version}, ${c.version}`);
76
+ return `three versions on ${a.url}`;
77
+ });
78
+ define({ id: "publish.stale_base_conflicts", describe: "a stale `baseVersion` ⇒ `conflict` carrying the CURRENT version and either a full snapshot or the explicit `\"reread\"` word; nothing is written", requiresActions: ["publish"] }, async (h) => {
79
+ const a = await publishOk(h, req(page(h, 1)), "fresh");
80
+ const b = await publishOk(h, req(page(h, 2), { url: a.url, baseVersion: a.version }), "update to v2");
81
+ const out = await publish(h, req(page(h, 3), { url: a.url, baseVersion: a.version }));
82
+ check(!out.ok && out.code === "conflict", `a stale base answered ${out.ok ? "ok" : out.code}, expected conflict`);
83
+ if (out.ok || out.code !== "conflict")
84
+ return "";
85
+ check(out.currentVersion === b.version, `conflict names currentVersion ${out.currentVersion}, expected ${b.version}`);
86
+ check(out.current === "reread" || (typeof out.current === "object" && Array.isArray(out.current.files)), "conflict.current must be a snapshot object or the string \"reread\"");
87
+ const after = await publishOk(h, req(page(h, 4), { url: a.url, baseVersion: b.version }), "update on the true current version after the conflict");
88
+ check(after.version !== b.version, "the conflict wrote a version: the base the host itself named was stale one call later");
89
+ return `conflict at ${b.version}, current: ${out.current === "reread" ? "reread" : "snapshot"}`;
90
+ });
91
+ for (const action of ["read", "read_file"]) {
92
+ define({ id: `publish.conflict_preserves_${action}`, describe: `a stale-base rejection leaves readable bytes unchanged through ${action}, even if the host preserves the version number and returns a correct pre-write snapshot`, requiresActions: ["publish", action] }, async (h) => {
93
+ const a = await publishOk(h, req(page(h, 1)), "fresh");
94
+ const b = await publishOk(h, req(page(h, 2), { url: a.url, baseVersion: a.version }), "update");
95
+ const observe = async () => {
96
+ if (action === "read") {
97
+ const out = await h.host.read({ url: a.url }, ctxOf(h));
98
+ if (!out.ok)
99
+ return fail(`read refused: ${out.code}`);
100
+ return JSON.stringify([out.ref.url, out.ref.slug, out.ref.version, out.ref.title, out.content, out.isolatedSummary]);
101
+ }
102
+ const out = await h.host.readFile({ url: a.url, path: "index.html" }, ctxOf(h));
103
+ if (!out.ok)
104
+ return fail(`read_file refused: ${out.code}`);
105
+ return JSON.stringify([out.file.path, out.file.mediaType, Array.from(out.file.content)]);
106
+ };
107
+ const before = await observe();
108
+ const rejected = await publish(h, req(page(h, 3), { url: a.url, baseVersion: a.version }));
109
+ check(!rejected.ok && rejected.code === "conflict" && rejected.currentVersion === b.version, "a stale base must conflict at the current version");
110
+ check(await observe() === before, `${action} changed after a rejected publish`);
111
+ return `${action} unchanged after conflict`;
112
+ });
113
+ }
114
+ define({ id: "publish.conflict_snapshot_is_complete", describe: "when the conflict hands back a snapshot it is the WHOLE current version: every published path with its bytes and the media type sent, plus the metadata", requiresActions: ["publish"] }, async (h) => {
115
+ const extra = h.caps.multiFile ? [script(h, 1)] : [];
116
+ const a = await publishOk(h, req(page(h, 1, extra), { title: "Conformance title" }), "fresh");
117
+ const current = page(h, 2, extra);
118
+ const b = await publishOk(h, req(current, { url: a.url, baseVersion: a.version, title: "Conformance title" }), "update to v2");
119
+ const out = await publish(h, req(page(h, 3, extra), { url: a.url, baseVersion: a.version }));
120
+ check(!out.ok && out.code === "conflict", "a stale base must conflict");
121
+ if (out.ok || out.code !== "conflict")
122
+ return "";
123
+ if (out.current === "reread")
124
+ return `host answers "reread" (explicit; snapshot completeness not claimed) at ${b.version}`;
125
+ for (const f of current) {
126
+ const got = out.current.files.find((x) => x.path === f.path);
127
+ check(got !== undefined, `snapshot lacks published path ${f.path}`);
128
+ check(got !== undefined && same(got.content, f.content), `snapshot bytes differ for ${f.path}`);
129
+ check(got !== undefined && got.mediaType === f.mediaType, `snapshot media type for ${f.path} is ${got?.mediaType}, expected ${f.mediaType}`);
130
+ }
131
+ check(out.current.files.length === current.length, `snapshot carries ${out.current.files.length} files, the current version has ${current.length}`);
132
+ check(out.current.title === "Conformance title", `snapshot title is ${out.current.title}, expected the current title`);
133
+ return `snapshot complete: ${current.length} file(s) + metadata`;
134
+ });
135
+ define({ id: "publish.force_overrides_stale_base", describe: "`force: true` over a stale `baseVersion` applies (a plain published version is overwritable)", requiresActions: ["publish"] }, async (h) => {
136
+ const a = await publishOk(h, req(page(h, 1)), "fresh");
137
+ const b = await publishOk(h, req(page(h, 2), { url: a.url, baseVersion: a.version }), "update to v2");
138
+ const forced = await publishOk(h, req(page(h, 3), { url: a.url, baseVersion: a.version, force: true }), "forced publish over a stale base");
139
+ check(forced.version !== b.version, "force answered ok but the version did not advance");
140
+ return `forced ${b.version} → ${forced.version}`;
141
+ });
142
+ define({ id: "publish.force_refused_over_page_saved_version", describe: "`force: true` over a version saved from inside the page ⇒ `force_refused` (the one version force must not discard)", requiresActions: ["publish"], requiresHook: "pageSavedVersion" }, async (h) => {
143
+ const a = await publishOk(h, req(page(h, 1)), "fresh");
144
+ await h.hooks.pageSavedVersion(a);
145
+ const out = await publish(h, req(page(h, 2), { url: a.url, baseVersion: a.version, force: true }));
146
+ check(!out.ok && out.code === "force_refused", `force over a page-saved version answered ${out.ok ? "ok" : out.code}, expected force_refused`);
147
+ return "force_refused";
148
+ });
149
+ define({ id: "publish.attempt_replay_is_idempotent", describe: "the same `attemptId` replays the first outcome: a fresh publish never mints a second page, an update never advances twice", requiresActions: ["publish"] }, async (h) => {
150
+ const before = h.caps.actions.includes("list") ? (await listMine(h)).length : undefined;
151
+ const key = `${h.runId}-replay-fresh`;
152
+ const a = await publishOk(h, req(page(h, 1), { title: "first" }), "fresh under key", key);
153
+ const again = await publishOk(h, req(page(h, 9), { title: "changed" }), "replay under the same key", key);
154
+ check(again.url === a.url && again.version === a.version, `the replay answered ${again.url} @ ${again.version}, expected ${a.url} @ ${a.version}`);
155
+ if (before !== undefined) {
156
+ const after = (await listMine(h)).length;
157
+ check(after === before + 1, `two fresh calls under one key left ${after - before} new page(s) in the listing, expected 1`);
158
+ }
159
+ const key2 = `${h.runId}-replay-update`;
160
+ const u1 = await publishOk(h, req(page(h, 2), { url: a.url, baseVersion: a.version }), "update under key2", key2);
161
+ const u2 = await publishOk(h, req(page(h, 3), { url: a.url, baseVersion: a.version }), "replayed update under key2", key2);
162
+ check(u2.version === u1.version, `the replayed update answered version ${u2.version}, expected ${u1.version}`);
163
+ await publishOk(h, req(page(h, 4), { url: a.url, baseVersion: u1.version }), "an update on the replayed version (proves the version did not advance twice)");
164
+ return `fresh and update each replayed once on ${a.url}`;
165
+ });
166
+ define({ id: "publish.attempt_replay_of_refusal", describe: "a typed refusal is replayed under the same `attemptId` too: a later call under that key with a request that would otherwise SUCCEED still gets the first refusal (the host does not re-decide)", requiresActions: ["publish"] }, async (h) => {
167
+ const key = `${h.runId}-replay-refusal`;
168
+ const a = await publish(h, req(page(h, 1), { url: FOREIGN_URL }), key);
169
+ check(!a.ok && a.code === "url_foreign", `a foreign url answered ${a.ok ? "ok" : a.code}, expected url_foreign`);
170
+ const b = await publish(h, req(page(h, 2)), key);
171
+ check(!b.ok && b.code === "url_foreign", `the replay re-decided: a fresh request under the refused key answered ${b.ok ? "ok" : b.code}, expected the replayed url_foreign`);
172
+ return "the refusal replayed over a request that would otherwise succeed";
173
+ });
174
+ define({ id: "publish.url_foreign_refused", describe: "a `url` this host never minted ⇒ `url_foreign` (a typed refusal, never a throw, never a page)", requiresActions: ["publish"] }, async (h) => {
175
+ const out = await publish(h, req(page(h, 1), { url: FOREIGN_URL }));
176
+ check(!out.ok && out.code === "url_foreign", `a foreign url answered ${out.ok ? "ok" : out.code}, expected url_foreign`);
177
+ return "url_foreign";
178
+ });
179
+ define({ id: "publish.atomic_under_precommit_fault", describe: "a failure injected BEFORE the commit point leaves no observable change: no phantom version (the same base still applies) and, when list is declared, no new page", requiresActions: ["publish"], requiresHook: "precommitFault" }, async (h) => {
180
+ const a = await publishOk(h, req(page(h, 1)), "fresh");
181
+ const before = h.caps.actions.includes("list") ? (await listMine(h)).length : undefined;
182
+ const contentBefore = h.caps.actions.includes("read") ? await h.host.read({ url: a.url }, ctxOf(h)) : undefined;
183
+ await h.hooks.precommitFault.arm();
184
+ let faulted;
185
+ try {
186
+ const out = await h.host.publish(req(page(h, 2), { url: a.url, baseVersion: a.version }), ctxOf(h));
187
+ faulted = out.ok ? "ok" : "typed";
188
+ }
189
+ catch {
190
+ faulted = "threw";
191
+ }
192
+ finally {
193
+ await h.hooks.precommitFault.disarm();
194
+ }
195
+ check(faulted !== "ok", "the armed fault did not fail the publish — the hook is not reaching the host's write path");
196
+ if (contentBefore !== undefined) {
197
+ const contentAfter = await h.host.read({ url: a.url }, ctxOf(h));
198
+ check(contentBefore.ok && contentAfter.ok && contentAfter.content === contentBefore.content && contentAfter.ref.version === contentBefore.ref.version, "the faulted publish changed the readable content or version before any commit");
199
+ }
200
+ const after = await publishOk(h, req(page(h, 3), { url: a.url, baseVersion: a.version }), "update on the pre-fault base");
201
+ check(after.version !== a.version, "the update after the fault did not advance");
202
+ if (before !== undefined)
203
+ check((await listMine(h)).length === before, "the faulted publish left a new page in the listing");
204
+ return `fault ${faulted}; base ${a.version} still applied → ${after.version}`;
205
+ });
206
+ define({ id: "publish.byte_limits_enforced", describe: "one file over `maxFileBytes` ⇒ `payload_too_large`; with multiFile, files each within the per-file limit but summing past `maxTotalBytes` ⇒ `payload_too_large`", requiresActions: ["publish"] }, async (h) => {
207
+ const big = await publish(h, req([{ path: "index.html", content: new Uint8Array(h.caps.maxFileBytes + 1), mediaType: HTML_MEDIA }]));
208
+ check(!big.ok && big.code === "payload_too_large", `a file of maxFileBytes+1 answered ${big.ok ? "ok" : big.code}`);
209
+ if (!h.caps.multiFile)
210
+ return "per-file limit enforced (single-file host)";
211
+ const files = [];
212
+ let left = h.caps.maxTotalBytes + 1;
213
+ for (let i = 0; left > 0; i++) {
214
+ const size = Math.min(left, h.caps.maxFileBytes);
215
+ files.push({ path: i === 0 ? "index.html" : `part${i}.bin`, content: new Uint8Array(size), mediaType: i === 0 ? HTML_MEDIA : "application/octet-stream" });
216
+ left -= size;
217
+ }
218
+ const total = await publish(h, req(files));
219
+ check(!total.ok && total.code === "payload_too_large", `${files.length} files summing to maxTotalBytes+1 answered ${total.ok ? "ok" : total.code}`);
220
+ return `per-file and total limits enforced (${files.length} files)`;
221
+ });
222
+ define({ id: "publish.at_limit_accepted", describe: "a file of exactly `maxFileBytes` (within the total) is accepted; with multiFile, files summing to exactly `maxTotalBytes` are accepted", requiresActions: ["publish"], requiresLargePayloads: true }, async (h) => {
223
+ const fileSize = Math.min(h.caps.maxFileBytes, h.caps.maxTotalBytes);
224
+ await publishOk(h, req([{ path: "index.html", content: new Uint8Array(fileSize), mediaType: HTML_MEDIA }]), `a file of exactly ${fileSize} bytes`);
225
+ if (!h.caps.multiFile)
226
+ return `${fileSize} bytes accepted`;
227
+ const files = [];
228
+ let left = h.caps.maxTotalBytes;
229
+ for (let i = 0; left > 0; i++) {
230
+ const size = Math.min(left, h.caps.maxFileBytes);
231
+ files.push({ path: i === 0 ? "index.html" : `part${i}.bin`, content: new Uint8Array(size), mediaType: i === 0 ? HTML_MEDIA : "application/octet-stream" });
232
+ left -= size;
233
+ }
234
+ await publishOk(h, req(files), `${files.length} files summing to exactly maxTotalBytes`);
235
+ return `at-limit file and at-limit total accepted`;
236
+ });
237
+ define({ id: "list.scope_words", describe: "`mine` lists only the caller's pages (including the ones just published), `shared` only others', `all` both; `truncated` is a boolean on every answer", requiresActions: ["publish", "list"] }, async (h) => {
238
+ const a = await publishOk(h, req(page(h, 1)), "fresh");
239
+ const b = await publishOk(h, req(page(h, 2)), "fresh");
240
+ const mine = await h.host.list({ scope: "mine", limit: 1000 }, ctxOf(h));
241
+ const shared = await h.host.list({ scope: "shared", limit: 1000 }, ctxOf(h));
242
+ const all = await h.host.list({ scope: "all", limit: 1000 }, ctxOf(h));
243
+ for (const [scope, out] of [["mine", mine], ["shared", shared], ["all", all]]) {
244
+ check(out.ok, `list ${scope} refused`);
245
+ check(out.ok && typeof out.truncated === "boolean", `list ${scope}: truncated is not a boolean`);
246
+ }
247
+ if (!mine.ok || !shared.ok || !all.ok)
248
+ return "";
249
+ check(mine.rows.every((r) => r.owner === "mine"), "a `mine` row is not owned by the caller");
250
+ check(shared.rows.every((r) => r.owner === "shared"), "a `shared` row is owned by the caller");
251
+ check([a.url, b.url].every((u) => mine.rows.some((r) => r.url === u)), "the pages just published are missing from `mine`");
252
+ check([a.url, b.url].every((u) => all.rows.some((r) => r.url === u)), "the pages just published are missing from `all`");
253
+ check(all.rows.length >= mine.rows.length, "`all` lists fewer rows than `mine`");
254
+ return `mine ${mine.rows.length}, shared ${shared.rows.length}, all ${all.rows.length}`;
255
+ });
256
+ define({ id: "list.limit_and_truncated", describe: "`limit` bounds the rows and `truncated` says so when more exist", requiresActions: ["publish", "list"] }, async (h) => {
257
+ await publishOk(h, req(page(h, 1)), "fresh");
258
+ await publishOk(h, req(page(h, 2)), "fresh");
259
+ const out = await h.host.list({ scope: "mine", limit: 1 }, ctxOf(h));
260
+ check(out.ok, "list refused");
261
+ if (!out.ok)
262
+ return "";
263
+ check(out.rows.length <= 1, `limit 1 answered ${out.rows.length} rows`);
264
+ check(out.truncated === true, "two pages exist but `truncated` is not true at limit 1");
265
+ return "limit honoured, truncated true";
266
+ });
267
+ define({ id: "read.own_page_full_content", describe: "`read` of the caller's own single-file page returns its full text with `isolatedSummary: false` and the page's ref", requiresActions: ["publish", "read"] }, async (h) => {
268
+ const files = page(h, 1);
269
+ const a = await publishOk(h, req(files), "fresh");
270
+ const out = await h.host.read({ url: a.url }, ctxOf(h));
271
+ check(out.ok, `read refused: ${out.ok ? "" : out.code}`);
272
+ if (!out.ok)
273
+ return "";
274
+ check(out.isolatedSummary === false, "the caller's own page came back as an isolated summary");
275
+ check(out.content === new TextDecoder().decode(files[0].content), "the content differs from the published text");
276
+ check(out.ref.url === a.url, `read.ref.url is ${out.ref.url}, expected ${a.url}`);
277
+ return "full text, own page";
278
+ });
279
+ define({ id: "read.unknown_url_typed", describe: "`read` of a url this host never minted ⇒ a typed `not_found` / `forbidden`, never a throw", requiresActions: ["read"] }, async (h) => {
280
+ let out;
281
+ try {
282
+ out = await h.host.read({ url: FOREIGN_URL }, ctxOf(h));
283
+ }
284
+ catch (err) {
285
+ return fail(`read threw: ${err instanceof Error ? err.message : String(err)}`);
286
+ }
287
+ check(!out.ok && (out.code === "not_found" || out.code === "forbidden"), `an unknown url answered ${out.ok ? "ok" : out.code}`);
288
+ return out.ok ? "" : out.code;
289
+ });
290
+ define({ id: "files.list_and_read_file", describe: "`list_files` names every published path with its byte count; `read_file` returns each file's bytes and media type; an unknown path ⇒ `not_found`", requiresActions: ["publish", "list_files", "read_file"] }, async (h) => {
291
+ const files = page(h, 1, h.caps.multiFile ? [script(h, 2)] : []);
292
+ const a = await publishOk(h, req(files), "fresh");
293
+ const listed = await h.host.listFiles({ url: a.url }, ctxOf(h));
294
+ check(listed.ok, `list_files refused: ${listed.ok ? "" : listed.code}`);
295
+ if (!listed.ok)
296
+ return "";
297
+ for (const f of files) {
298
+ const e = listed.entries.find((x) => x.path === f.path);
299
+ check(e !== undefined && e.bytes === f.content.byteLength, `list_files lacks ${f.path} at ${f.content.byteLength} bytes`);
300
+ const got = await h.host.readFile({ url: a.url, path: f.path }, ctxOf(h));
301
+ check(got.ok, `read_file ${f.path} refused: ${got.ok ? "" : got.code}`);
302
+ if (!got.ok)
303
+ return "";
304
+ check(same(got.file.content, f.content) && got.file.mediaType === f.mediaType, `read_file ${f.path}: bytes or media type differ`);
305
+ }
306
+ const missing = await h.host.readFile({ url: a.url, path: "no/such/file.txt" }, ctxOf(h));
307
+ check(!missing.ok && missing.code === "not_found", `an unknown path answered ${missing.ok ? "ok" : missing.code}`);
308
+ return `${files.length} file(s) listed and read`;
309
+ });
310
+ export const ARTIFACT_CONFORMANCE_VECTORS = vectorTable.map(({ run: _run, ...v }) => Object.freeze(v));
311
+ const applies = (v, caps) => v.requiresActions.every((a) => caps.actions.includes(a)) && (v.requiresMultiFile !== true || caps.multiFile);
312
+ export function artifactConformanceVectors(host) {
313
+ const verdict = readArtifactHostCapabilities(host);
314
+ if (!verdict.ok)
315
+ throw new Error(`artifactConformanceVectors: the host's declaration is invalid — ${verdict.problem}`);
316
+ return ARTIFACT_CONFORMANCE_VECTORS.filter((v) => applies(v, verdict.capabilities));
317
+ }
318
+ export async function runArtifactConformance(host, opts = {}) {
319
+ const verdict = readArtifactHostCapabilities(host);
320
+ if (!verdict.ok)
321
+ throw new Error(`runArtifactConformance: the host's declaration is invalid — ${verdict.problem}`);
322
+ const h = { host, caps: verdict.capabilities, runId: opts.runId ?? randomUUID(), hooks: opts.hooks ?? {}, largePayloads: opts.largePayloads === true, seq: 0 };
323
+ host.onMount?.(h.runId);
324
+ const results = [];
325
+ for (const v of vectorTable) {
326
+ if (!applies(v, h.caps)) {
327
+ results.push({ id: v.id, status: "not_applicable", detail: `requires ${[...v.requiresActions, ...(v.requiresMultiFile ? ["multiFile"] : [])].join(" + ")}; not declared` });
328
+ continue;
329
+ }
330
+ if (v.requiresHook !== undefined && h.hooks[v.requiresHook] === undefined) {
331
+ results.push({ id: v.id, status: "skipped", detail: `hook ${v.requiresHook} not supplied` });
332
+ continue;
333
+ }
334
+ if (v.requiresLargePayloads === true && !h.largePayloads) {
335
+ results.push({ id: v.id, status: "skipped", detail: "largePayloads not enabled" });
336
+ continue;
337
+ }
338
+ let row;
339
+ try {
340
+ row = { id: v.id, status: "pass", detail: await v.run(h) };
341
+ }
342
+ catch (err) {
343
+ row = { id: v.id, status: "fail", detail: err instanceof Violation ? err.message : `threw: ${err instanceof Error ? err.message : String(err)}` };
344
+ }
345
+ results.push(row);
346
+ }
347
+ const count = (s) => results.filter((r) => r.status === s).length;
348
+ return { capabilities: h.caps, runId: h.runId, results, passed: count("pass"), failed: count("fail"), notApplicable: count("not_applicable"), skipped: count("skipped") };
349
+ }
@@ -86,7 +86,7 @@ export class LocalArtifactStub {
86
86
  this.dir = resolve(opts.dir);
87
87
  }
88
88
  capabilities() {
89
- return { plane: "local-stub", actions: ["publish"], defaultVisibility: "private", maxFileBytes: LOCAL_STUB_MAX_FILE_BYTES, maxTotalBytes: LOCAL_STUB_MAX_TOTAL_BYTES, multiFile: false, storageDir: this.dir };
89
+ return { plane: "local-stub", actions: ["publish"], commit: "throw_means_uncommitted", defaultVisibility: "private", maxFileBytes: LOCAL_STUB_MAX_FILE_BYTES, maxTotalBytes: LOCAL_STUB_MAX_TOTAL_BYTES, multiFile: false, storageDir: this.dir };
90
90
  }
91
91
  onMount(runId) {
92
92
  this.#mint.set(runId, new Set());
@@ -0,0 +1,28 @@
1
+ import type { ArtifactResultCode } from "../../core/artifact-host.js";
2
+ /** Rejections of one key/reason/message before the banner rides (upstream `E = 3`). */
3
+ export declare const ARTIFACT_REJECT_BREAKER_THRESHOLD = 3;
4
+ /** Each key component is read at most this long (upstream `I = 1024`). */
5
+ export declare const ARTIFACT_REJECT_KEY_COMPONENT_MAX = 1024;
6
+ /** The banner for a rejection the model can act on (fix the arguments, or stop and tell the user). */
7
+ export declare const ARTIFACT_REJECT_BANNER_RETRYABLE = "IMPORTANT: Artifact calls for this target have now been rejected 3 or more times in this session for the same reason. Do not send the same call again: either make the specific change the error describes, or stop calling Artifact for this target and tell the user what is failing and why.";
8
+ /** The banner for a host-authority refusal that no argument change can get past. */
9
+ export declare const ARTIFACT_REJECT_BANNER_REFUSAL = "IMPORTANT: Artifact calls for this target have now been rejected 3 or more times in this session for the same reason. Stop calling Artifact for this target and tell the user the artifact could not be published and why \u2014 do not try to work around this refusal.";
10
+ /** The result codes whose banner is the refusal form: the host's authority said no, and "work around it" is not an option. */
11
+ export declare const ARTIFACT_REJECT_REFUSAL_CODES: ReadonlySet<ArtifactResultCode>;
12
+ /** The target key of a call: action (omitted ⇒ publish), file_path and url, NUL-joined. */
13
+ export declare function artifactRejectKeyOf(args: unknown): string;
14
+ /** A rejection text with a banner this module put in front of it stripped, so the fingerprint of the third rejection equals the first's. */
15
+ export declare function stripArtifactRejectBanner(text: string): string;
16
+ export declare function artifactRejectBannerFor(reason: string): string;
17
+ export interface ArtifactRejectBreaker {
18
+ /** Record a rejection; the count of consecutive same-key/same-reason/same-message rejections including this one. */
19
+ hit(key: string, reason: string, text: string): number;
20
+ /** A successful call: forget every streak. */
21
+ clear(): void;
22
+ /** Inspection: the live streak of a key, if any. */
23
+ streakOf(key: string): {
24
+ reason: string;
25
+ count: number;
26
+ } | undefined;
27
+ }
28
+ export declare function createArtifactRejectBreaker(): ArtifactRejectBreaker;
@@ -0,0 +1,42 @@
1
+ import { createHash } from "node:crypto";
2
+ export const ARTIFACT_REJECT_BREAKER_THRESHOLD = 3;
3
+ export const ARTIFACT_REJECT_KEY_COMPONENT_MAX = 1024;
4
+ const HEAD = `IMPORTANT: Artifact calls for this target have now been rejected ${ARTIFACT_REJECT_BREAKER_THRESHOLD} or more times in this session for the same reason.`;
5
+ export const ARTIFACT_REJECT_BANNER_RETRYABLE = `${HEAD} Do not send the same call again: either make the specific change the error describes, or stop calling Artifact for this target and tell the user what is failing and why.`;
6
+ export const ARTIFACT_REJECT_BANNER_REFUSAL = `${HEAD} Stop calling Artifact for this target and tell the user the artifact could not be published and why — do not try to work around this refusal.`;
7
+ export const ARTIFACT_REJECT_REFUSAL_CODES = new Set(["artifact.force_refused", "artifact.forbidden"]);
8
+ const clip = (v) => (typeof v === "string" ? v.slice(0, ARTIFACT_REJECT_KEY_COMPONENT_MAX) : "");
9
+ export function artifactRejectKeyOf(args) {
10
+ if (args === null || typeof args !== "object")
11
+ return "publish\0\0";
12
+ const a = args;
13
+ return `${clip(a["action"]) || "publish"}\0${clip(a["file_path"])}\0${clip(a["url"])}`;
14
+ }
15
+ export function stripArtifactRejectBanner(text) {
16
+ for (const banner of [ARTIFACT_REJECT_BANNER_RETRYABLE, ARTIFACT_REJECT_BANNER_REFUSAL])
17
+ if (text.startsWith(`${banner}\n\n`))
18
+ return text.slice(banner.length + 2);
19
+ return text;
20
+ }
21
+ export function artifactRejectBannerFor(reason) {
22
+ return ARTIFACT_REJECT_REFUSAL_CODES.has(reason) ? ARTIFACT_REJECT_BANNER_REFUSAL : ARTIFACT_REJECT_BANNER_RETRYABLE;
23
+ }
24
+ export function createArtifactRejectBreaker() {
25
+ const table = new Map();
26
+ return {
27
+ hit(key, reason, text) {
28
+ const fingerprint = createHash("sha256").update(stripArtifactRejectBanner(text)).digest("hex");
29
+ const prior = table.get(key);
30
+ const count = prior !== undefined && prior.reason === reason && prior.fingerprint === fingerprint ? prior.count + 1 : 1;
31
+ table.set(key, { reason, fingerprint, count });
32
+ return count;
33
+ },
34
+ clear() {
35
+ table.clear();
36
+ },
37
+ streakOf(key) {
38
+ const s = table.get(key);
39
+ return s === undefined ? undefined : { reason: s.reason, count: s.count };
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The ONE shell lexer of the read faces, in two modes.
3
+ *
4
+ * STRICT is the residual space the classify faces live in: the caller has hard-rejected `< > $ ( ) \` \n \r \\`
5
+ * (or, under the rule lane's tolerate option, passes `<`/`>` through as ordinary text), so the whole of bash's
6
+ * word grammar left to model is quote pairing, the four connectors and whitespace. LENIENT is the space the
7
+ * refused-command whitelist reads — everything the strict gate refused, RECORDED instead of refused: heredoc
8
+ * bodies, substitution bodies, redirections, comments, escapes, line continuations, subshell groups,
9
+ * backgrounding, line breaks.
10
+ *
11
+ * One walker serves both. The lenient-only constructs are branches the strict caller can never reach (their
12
+ * characters are gone before it calls), so for every strict-accepted input the two modes produce the same words
13
+ * and the same connectors — up to the strict mode's own two residuals (a `#` is a word there, and JS whitespace
14
+ * rather than bash's blanks bounds a word); the whitelist stopped being a second parser the day it read this stream.
15
+ *
16
+ * Quoting. A quote character opens a region, the matching one closes it, and a quote of the OTHER kind inside an
17
+ * open region is ordinary text. When a region is still open at the end of the input the pass is re-run with
18
+ * quotes as ordinary characters — the QUOTE-BLIND reading, which yields strictly more words and more segments
19
+ * (the fail-closed direction every consumer here errs in). {@link quoteMask} is the positional projection of the
20
+ * same rule, for the callers that need "is this character quoted" rather than tokens.
21
+ *
22
+ * Line continuations. Bash removes a `\<newline>` pair from its input before it recognises anything — except
23
+ * inside single quotes, inside a comment (the newline ends the comment) and inside a QUOTED here-document body.
24
+ * The walker does the same at the point of reading: every lookahead below reads the next LOGICAL character, past
25
+ * any continuation, so a pair can stand inside a construct's spelling (`$\<newline>(…)` opens a substitution)
26
+ * and never inside the three places bash keeps it.
27
+ */
28
+ export type LexMode = "strict" | "lenient";
29
+ export interface LexOptions {
30
+ readonly mode: LexMode;
31
+ /** `"split"` (default): the connectors bound segments. `"text"`: a single segment is being tokenized and an
32
+ * unquoted connector character is a word character — what a caller handing over one already-split segment
33
+ * expects. */
34
+ readonly connectors?: "split" | "text";
35
+ }
36
+ /** An argv word in BOTH spellings. `raw` is what the SHELL sees (quotes, escapes and substitution text
37
+ * retained) — the only spelling in which "is this metacharacter quoted?" is still answerable; `folded` is what
38
+ * the PROGRAM receives after quote removal, with every substitution replaced by {@link SUBSTITUTION_PLACEHOLDER}
39
+ * and a literal leading tilde spelled `./~…` (see {@link literalTildeSpelling}). */
40
+ export interface LexedWord {
41
+ readonly kind: "word";
42
+ readonly raw: string;
43
+ readonly folded: string;
44
+ }
45
+ /** A redirection and the word it takes. A descriptor duplication (`>&2`, `<&0`, `>&-`, `>&1-`) takes none. */
46
+ export interface LexedRedirect {
47
+ readonly kind: "redirect";
48
+ readonly op: "<" | ">" | ">>" | "<<<" | "&>" | "&>>" | ">&" | "<&";
49
+ readonly target?: LexedWord;
50
+ }
51
+ /** A here-document: the body is DATA the program reads on stdin, never command text. `quoted` records whether
52
+ * the delimiter word carried a quote or an escape, which is bash's rule for "the body does not expand". */
53
+ export interface LexedHeredoc {
54
+ readonly kind: "heredoc";
55
+ readonly delimiter: string;
56
+ readonly quoted: boolean;
57
+ readonly body: string;
58
+ }
59
+ /** A subshell parenthesis, standing alone: structure, not a word. */
60
+ export interface LexedGroup {
61
+ readonly kind: "group";
62
+ readonly op: "(" | ")";
63
+ }
64
+ export type LexedToken = LexedWord | LexedRedirect | LexedHeredoc | LexedGroup;
65
+ /** The connector bash ran a segment through — `"start"` for the first one. A closed set, because the read
66
+ * boundary distinguishes all four: `&&` is the only one under which the previous segment is known to have
67
+ * exited ZERO, `||` is the only one under which the previous segment may have been SKIPPED ENTIRELY, `|`
68
+ * puts the segment in a subshell whose directory changes never reach the parent, and `;` is plain sequence. */
69
+ export type ShellSegmentConnector = "start" | ";" | "&&" | "||" | "|";
70
+ /** The lenient mode's two extra boundaries: a backgrounding `&` and a line break. */
71
+ export type LenientConnector = ShellSegmentConnector | "&" | "\n";
72
+ export interface LexedSegment {
73
+ /** The segment's text exactly as written (surrounding whitespace included) — what a caller that wants the
74
+ * string rather than the tokens reads. Here-document bodies are not part of any segment's text. */
75
+ readonly text: string;
76
+ readonly connector: LenientConnector;
77
+ readonly tokens: readonly LexedToken[];
78
+ /** The bodies of the substitutions found in THIS segment (its words and its redirection targets), in order. */
79
+ readonly substitutions: readonly string[];
80
+ }
81
+ export interface LexedCommand {
82
+ readonly segments: readonly LexedSegment[];
83
+ /** Every substitution body of the command, in order (the per-segment lists concatenated). */
84
+ readonly substitutions: readonly string[];
85
+ }
86
+ /** One segment's argv words in both spellings, index-aligned by construction (folding is strictly per word — it
87
+ * never splits or joins). The one shape every downstream arm of the classifier reads. */
88
+ export interface SegmentTokens {
89
+ readonly folded: readonly string[];
90
+ readonly raw: readonly string[];
91
+ }
92
+ /** What a substitution leaves in a word's FOLDED spelling: a marker no argv word can spell, so a consumer that
93
+ * judges words sees "this word carries text the shell computes" rather than the computing text itself. */
94
+ export declare const SUBSTITUTION_PLACEHOLDER = " subst";
95
+ /**
96
+ * Which characters of `s` sit inside a QUOTED region, by the same pairing rule the lexer walks with. The pairing
97
+ * quote characters themselves are reported as quoted — they are not operators either way.
98
+ *
99
+ * `balanced` is false when a region is still open at the end of the string. Callers must then NOT use the mask:
100
+ * an unterminated quote is a string bash itself cannot parse, and every consumer falls back to the quote-BLIND
101
+ * reading, which is the stricter one.
102
+ *
103
+ * EXACTNESS. This models bash only for the residual character space the strict faces operate in, where backslash
104
+ * escapes, `$`, backticks, and subshells are rejected outright — there `'` and `"` pairing is the whole of
105
+ * quoting, so a region computed here is exactly a region bash quotes. It is NOT a general shell quoting parser
106
+ * and must not be used before those characters are excluded.
107
+ */
108
+ export declare function quoteMask(s: string): {
109
+ quoted: readonly boolean[];
110
+ balanced: boolean;
111
+ };
112
+ /** The ONE spelling of a LITERAL leading tilde — `./~…`, the relative path it is — for every position bash does not
113
+ * expand it in (a quoted tilde-prefix, an option payload); a word without a leading tilde is returned as is. */
114
+ export declare function literalTildeSpelling(word: string): string;
115
+ /** The argv-word projection of a segment: redirections, here-documents and groups are not words the program
116
+ * receives (bash removes them before exec). */
117
+ export declare function wordsOf(segment: LexedSegment): SegmentTokens;
118
+ /** The substitution bodies of an UNQUOTED here-document body: `$(…)` and backticks are live there, quotes are
119
+ * ordinary characters, a backslash protects the next character. `undefined` when a substitution never closes. */
120
+ export declare function substitutionsInHeredocBody(body: string): string[] | undefined;
121
+ /**
122
+ * Lex a command. The honoured pass is authoritative when every quote closes; otherwise the quote-blind pass is
123
+ * the answer — the fail-closed fallback every face here has always taken for a string bash itself cannot parse.
124
+ * In STRICT mode that fallback is two-level, as the faces have always read it: the segments are split blind, and
125
+ * each segment's words are then read on their own balance (`echo "a b"; echo '` keeps `a b` one word in the first
126
+ * segment). The lenient face reads an unbalanced command blind throughout. `{ reject }` names a construct the
127
+ * lenient mode cannot read (an unterminated substitution or here-document, an operator with no operand, a
128
+ * substitution this lexer cannot delimit) or, in strict mode, a backgrounding `&`.
129
+ */
130
+ export declare function lexShellText(source: string, opts: LexOptions): LexedCommand | {
131
+ reject: string;
132
+ };