@gaunt-sloth/core 2.0.0-alpha.4 → 2.0.0-alpha.40
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.
- package/README.md +71 -20
- package/dist/config/colour.d.ts +38 -0
- package/dist/config/colour.js +36 -0
- package/dist/config/colour.js.map +1 -0
- package/dist/config/configDiscovery.d.ts +79 -0
- package/dist/config/configDiscovery.js +80 -0
- package/dist/config/configDiscovery.js.map +1 -0
- package/dist/config/defaults.d.ts +21 -21
- package/dist/config/defaults.js +11 -9
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/filesystem-tools.d.ts +41 -0
- package/dist/config/filesystem-tools.js +56 -0
- package/dist/config/filesystem-tools.js.map +1 -0
- package/dist/config/jsonc.d.ts +12 -0
- package/dist/config/jsonc.js +41 -0
- package/dist/config/jsonc.js.map +1 -0
- package/dist/config/loader.d.ts +165 -6
- package/dist/config/loader.js +963 -109
- package/dist/config/loader.js.map +1 -1
- package/dist/config/mouse.d.ts +50 -0
- package/dist/config/mouse.js +44 -0
- package/dist/config/mouse.js.map +1 -0
- package/dist/config/profiles.d.ts +68 -0
- package/dist/config/profiles.js +93 -0
- package/dist/config/profiles.js.map +1 -0
- package/dist/config/providerKeys.d.ts +69 -0
- package/dist/config/providerKeys.js +69 -0
- package/dist/config/providerKeys.js.map +1 -0
- package/dist/config/schema.d.ts +2695 -130
- package/dist/config/schema.js +1385 -68
- package/dist/config/schema.js.map +1 -1
- package/dist/config/shell-policy.d.ts +899 -111
- package/dist/config/shell-policy.js +800 -70
- package/dist/config/shell-policy.js.map +1 -1
- package/dist/config/tool-descriptions.d.ts +211 -0
- package/dist/config/tool-descriptions.js +272 -0
- package/dist/config/tool-descriptions.js.map +1 -0
- package/dist/config/types.d.ts +372 -34
- package/dist/config/types.js +1 -0
- package/dist/config/types.js.map +1 -1
- package/dist/config.d.ts +35 -1
- package/dist/config.js +16 -1
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +45 -0
- package/dist/constants.js +45 -0
- package/dist/constants.js.map +1 -1
- package/dist/core/GthAbstractAgent.d.ts +190 -11
- package/dist/core/GthAbstractAgent.js +551 -35
- package/dist/core/GthAbstractAgent.js.map +1 -1
- package/dist/core/GthAgentRunner.d.ts +565 -57
- package/dist/core/GthAgentRunner.js +1546 -140
- package/dist/core/GthAgentRunner.js.map +1 -1
- package/dist/core/GthLangChainAgent.d.ts +117 -2
- package/dist/core/GthLangChainAgent.js +644 -18
- package/dist/core/GthLangChainAgent.js.map +1 -1
- package/dist/core/approvals/annotations.d.ts +122 -0
- package/dist/core/approvals/annotations.js +137 -0
- package/dist/core/approvals/annotations.js.map +1 -0
- package/dist/core/approvals/grants.d.ts +216 -0
- package/dist/core/approvals/grants.js +469 -0
- package/dist/core/approvals/grants.js.map +1 -0
- package/dist/core/approvals/matcher.d.ts +202 -0
- package/dist/core/approvals/matcher.js +267 -0
- package/dist/core/approvals/matcher.js.map +1 -0
- package/dist/core/approvals/mcpSubjects.d.ts +40 -0
- package/dist/core/approvals/mcpSubjects.js +99 -0
- package/dist/core/approvals/mcpSubjects.js.map +1 -0
- package/dist/core/approvals/promptHeader.d.ts +28 -0
- package/dist/core/approvals/promptHeader.js +62 -0
- package/dist/core/approvals/promptHeader.js.map +1 -0
- package/dist/core/approvals/toolAnnotationSources.d.ts +105 -0
- package/dist/core/approvals/toolAnnotationSources.js +277 -0
- package/dist/core/approvals/toolAnnotationSources.js.map +1 -0
- package/dist/core/approvals/toolHost.d.ts +46 -0
- package/dist/core/approvals/toolHost.js +108 -0
- package/dist/core/approvals/toolHost.js.map +1 -0
- package/dist/core/debugCapture.d.ts +74 -0
- package/dist/core/debugCapture.js +100 -0
- package/dist/core/debugCapture.js.map +1 -0
- package/dist/core/gthLeanAgentFactory.d.ts +9 -0
- package/dist/core/gthLeanAgentFactory.js +10 -0
- package/dist/core/gthLeanAgentFactory.js.map +1 -0
- package/dist/core/launchBanner.d.ts +127 -0
- package/dist/core/launchBanner.js +414 -0
- package/dist/core/launchBanner.js.map +1 -0
- package/dist/core/modelLabel.d.ts +19 -0
- package/dist/core/modelLabel.js +26 -0
- package/dist/core/modelLabel.js.map +1 -0
- package/dist/core/plainToolIndication.d.ts +15 -0
- package/dist/core/plainToolIndication.js +174 -0
- package/dist/core/plainToolIndication.js.map +1 -0
- package/dist/core/reasoningBlocks.d.ts +65 -0
- package/dist/core/reasoningBlocks.js +103 -0
- package/dist/core/reasoningBlocks.js.map +1 -0
- package/dist/core/refusal.d.ts +53 -0
- package/dist/core/refusal.js +133 -0
- package/dist/core/refusal.js.map +1 -0
- package/dist/core/runHeader.d.ts +38 -0
- package/dist/core/runHeader.js +42 -0
- package/dist/core/runHeader.js.map +1 -0
- package/dist/core/runStats.d.ts +52 -0
- package/dist/core/runStats.js +118 -0
- package/dist/core/runStats.js.map +1 -0
- package/dist/core/shell/ShellCommandFailedError.d.ts +53 -0
- package/dist/core/shell/ShellCommandFailedError.js +67 -0
- package/dist/core/shell/ShellCommandFailedError.js.map +1 -0
- package/dist/core/shell/abstention.d.ts +88 -0
- package/dist/core/shell/abstention.js +184 -0
- package/dist/core/shell/abstention.js.map +1 -0
- package/dist/core/shell/approvalCapture.d.ts +271 -0
- package/dist/core/shell/approvalCapture.js +108 -0
- package/dist/core/shell/approvalCapture.js.map +1 -0
- package/dist/core/shell/approvalStop.d.ts +123 -0
- package/dist/core/shell/approvalStop.js +269 -0
- package/dist/core/shell/approvalStop.js.map +1 -0
- package/dist/core/shell/arity.d.ts +6 -0
- package/dist/core/shell/arity.js +20 -6
- package/dist/core/shell/arity.js.map +1 -1
- package/dist/core/shell/denylist.d.ts +11 -0
- package/dist/core/shell/denylist.js +37 -0
- package/dist/core/shell/denylist.js.map +1 -0
- package/dist/core/shell/escalationSeverity.d.ts +141 -0
- package/dist/core/shell/escalationSeverity.js +89 -0
- package/dist/core/shell/escalationSeverity.js.map +1 -0
- package/dist/core/shell/framing.d.ts +190 -0
- package/dist/core/shell/framing.js +633 -0
- package/dist/core/shell/framing.js.map +1 -0
- package/dist/core/shell/hardline.d.ts +103 -0
- package/dist/core/shell/hardline.js +780 -0
- package/dist/core/shell/hardline.js.map +1 -0
- package/dist/core/shell/negotiation.d.ts +328 -0
- package/dist/core/shell/negotiation.js +488 -0
- package/dist/core/shell/negotiation.js.map +1 -0
- package/dist/core/shell/normalize.d.ts +44 -4
- package/dist/core/shell/normalize.js +61 -7
- package/dist/core/shell/normalize.js.map +1 -1
- package/dist/core/shell/openWorld.d.ts +263 -0
- package/dist/core/shell/openWorld.js +1188 -0
- package/dist/core/shell/openWorld.js.map +1 -0
- package/dist/core/shell/rater.d.ts +873 -0
- package/dist/core/shell/rater.js +1454 -0
- package/dist/core/shell/rater.js.map +1 -0
- package/dist/core/shell/raterModel.d.ts +41 -0
- package/dist/core/shell/raterModel.js +51 -0
- package/dist/core/shell/raterModel.js.map +1 -0
- package/dist/core/shell/raterVocabulary.d.ts +121 -0
- package/dist/core/shell/raterVocabulary.js +116 -0
- package/dist/core/shell/raterVocabulary.js.map +1 -0
- package/dist/core/shell/rejection.d.ts +69 -0
- package/dist/core/shell/rejection.js +38 -0
- package/dist/core/shell/rejection.js.map +1 -0
- package/dist/core/toolCallRepair/grammar.d.ts +41 -0
- package/dist/core/toolCallRepair/grammar.js +116 -0
- package/dist/core/toolCallRepair/grammar.js.map +1 -0
- package/dist/core/toolCallRepair/index.d.ts +2 -0
- package/dist/core/toolCallRepair/index.js +7 -0
- package/dist/core/toolCallRepair/index.js.map +1 -0
- package/dist/core/toolCallRepair/payload.d.ts +36 -0
- package/dist/core/toolCallRepair/payload.js +341 -0
- package/dist/core/toolCallRepair/payload.js.map +1 -0
- package/dist/core/toolCallRepair/promote.d.ts +45 -0
- package/dist/core/toolCallRepair/promote.js +90 -0
- package/dist/core/toolCallRepair/promote.js.map +1 -0
- package/dist/core/toolDisplay.d.ts +123 -0
- package/dist/core/toolDisplay.js +451 -0
- package/dist/core/toolDisplay.js.map +1 -0
- package/dist/core/toolOutputChannel.d.ts +95 -0
- package/dist/core/toolOutputChannel.js +165 -0
- package/dist/core/toolOutputChannel.js.map +1 -0
- package/dist/core/types.d.ts +378 -16
- package/dist/core/types.js.map +1 -1
- package/dist/history/historyFormat.d.ts +28 -0
- package/dist/history/historyFormat.js +127 -0
- package/dist/history/historyFormat.js.map +1 -0
- package/dist/history/historyStore.d.ts +198 -0
- package/dist/history/historyStore.js +482 -0
- package/dist/history/historyStore.js.map +1 -0
- package/dist/history/recordSession.d.ts +37 -0
- package/dist/history/recordSession.js +56 -0
- package/dist/history/recordSession.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/anthropic.d.ts +1 -1
- package/dist/providers/anthropic.js +17 -10
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/configurationPassthrough.d.ts +107 -0
- package/dist/providers/configurationPassthrough.js +148 -0
- package/dist/providers/configurationPassthrough.js.map +1 -0
- package/dist/providers/deepseek.d.ts +1 -1
- package/dist/providers/deepseek.js +5 -10
- package/dist/providers/deepseek.js.map +1 -1
- package/dist/providers/geminiSchemaSanitizer.d.ts +55 -0
- package/dist/providers/geminiSchemaSanitizer.js +347 -0
- package/dist/providers/geminiSchemaSanitizer.js.map +1 -0
- package/dist/providers/geminiThinking.d.ts +60 -0
- package/dist/providers/geminiThinking.js +92 -0
- package/dist/providers/geminiThinking.js.map +1 -0
- package/dist/providers/google-genai.d.ts +1 -1
- package/dist/providers/google-genai.js +23 -11
- package/dist/providers/google-genai.js.map +1 -1
- package/dist/providers/groq.d.ts +1 -1
- package/dist/providers/groq.js +17 -10
- package/dist/providers/groq.js.map +1 -1
- package/dist/providers/huggingface.d.ts +25 -0
- package/dist/providers/huggingface.js +69 -0
- package/dist/providers/huggingface.js.map +1 -0
- package/dist/providers/modelCatalog.d.ts +109 -0
- package/dist/providers/modelCatalog.js +245 -0
- package/dist/providers/modelCatalog.js.map +1 -0
- package/dist/providers/modelDiscovery.d.ts +153 -6
- package/dist/providers/modelDiscovery.js +295 -41
- package/dist/providers/modelDiscovery.js.map +1 -1
- package/dist/providers/ollama.d.ts +19 -5
- package/dist/providers/ollama.js +72 -52
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.d.ts +1 -1
- package/dist/providers/openai.js +39 -10
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts +27 -5
- package/dist/providers/openrouter.js +88 -36
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/providers/vertexai.d.ts +1 -1
- package/dist/providers/vertexai.js +24 -11
- package/dist/providers/vertexai.js.map +1 -1
- package/dist/providers/xai.d.ts +1 -1
- package/dist/providers/xai.js +25 -10
- package/dist/providers/xai.js.map +1 -1
- package/dist/runtime/askStructured.d.ts +105 -0
- package/dist/runtime/askStructured.js +120 -0
- package/dist/runtime/askStructured.js.map +1 -0
- package/dist/runtime/conversation.d.ts +64 -0
- package/dist/runtime/conversation.js +171 -0
- package/dist/runtime/conversation.js.map +1 -0
- package/dist/runtime/singleShot.d.ts +39 -5
- package/dist/runtime/singleShot.js +115 -36
- package/dist/runtime/singleShot.js.map +1 -1
- package/dist/runtime/structuredOutput.d.ts +104 -0
- package/dist/runtime/structuredOutput.js +393 -0
- package/dist/runtime/structuredOutput.js.map +1 -0
- package/dist/utils/ProgressIndicator.d.ts +21 -0
- package/dist/utils/ProgressIndicator.js +30 -3
- package/dist/utils/ProgressIndicator.js.map +1 -1
- package/dist/utils/aiignoreUtils.js.map +1 -1
- package/dist/utils/binaryOutputUtils.js.map +1 -1
- package/dist/utils/consoleUtils.d.ts +95 -0
- package/dist/utils/consoleUtils.js +112 -2
- package/dist/utils/consoleUtils.js.map +1 -1
- package/dist/utils/crashHandler.d.ts +87 -0
- package/dist/utils/crashHandler.js +128 -0
- package/dist/utils/crashHandler.js.map +1 -0
- package/dist/utils/debugDump.d.ts +134 -0
- package/dist/utils/debugDump.js +381 -0
- package/dist/utils/debugDump.js.map +1 -0
- package/dist/utils/debugUtils.d.ts +13 -4
- package/dist/utils/debugUtils.js +36 -13
- package/dist/utils/debugUtils.js.map +1 -1
- package/dist/utils/displayWidth.d.ts +53 -0
- package/dist/utils/displayWidth.js +195 -0
- package/dist/utils/displayWidth.js.map +1 -0
- package/dist/utils/fileUtils.d.ts +20 -1
- package/dist/utils/fileUtils.js +35 -2
- package/dist/utils/fileUtils.js.map +1 -1
- package/dist/utils/llmUtils.d.ts +39 -8
- package/dist/utils/llmUtils.js +76 -8
- package/dist/utils/llmUtils.js.map +1 -1
- package/dist/utils/redactSecrets.d.ts +63 -0
- package/dist/utils/redactSecrets.js +286 -0
- package/dist/utils/redactSecrets.js.map +1 -0
- package/dist/utils/systemPromptNotes.d.ts +222 -0
- package/dist/utils/systemPromptNotes.js +338 -0
- package/dist/utils/systemPromptNotes.js.map +1 -0
- package/dist/utils/systemUtils.d.ts +18 -1
- package/dist/utils/systemUtils.js +38 -3
- package/dist/utils/systemUtils.js.map +1 -1
- package/dist/utils/toolMatching.d.ts +30 -0
- package/dist/utils/toolMatching.js +44 -0
- package/dist/utils/toolMatching.js.map +1 -0
- package/dist/utils/untrustedText.d.ts +86 -0
- package/dist/utils/untrustedText.js +101 -0
- package/dist/utils/untrustedText.js.map +1 -0
- package/package.json +21 -6
- package/schema/gsloth-config.schema.json +1979 -353
- package/dist/core/shell/allowlist.d.ts +0 -75
- package/dist/core/shell/allowlist.js +0 -187
- package/dist/core/shell/allowlist.js.map +0 -1
- package/dist/core/shell/judge.d.ts +0 -161
- package/dist/core/shell/judge.js +0 -261
- package/dist/core/shell/judge.js.map +0 -1
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/shell/negotiation
|
|
3
|
+
*
|
|
4
|
+
* [[EXT-29]] (spec §5) — **the bounded, visible argument between the agent and the rater** that
|
|
5
|
+
* `auto` conducts where `assisted` interrupts a person.
|
|
6
|
+
*
|
|
7
|
+
* A `destructive` rating at `auto` does not go to the human. It goes back to the *agent* as the
|
|
8
|
+
* refused call's tool result (§7), naming what would make the command acceptable (§5.2); the agent
|
|
9
|
+
* may narrow the command or justify the one it chose; the next call is rated again with the
|
|
10
|
+
* exchange in view (§5.1). This module holds the state that makes that a bounded exchange rather
|
|
11
|
+
* than a loop: the transcript, and the two counters that end it at a person.
|
|
12
|
+
*
|
|
13
|
+
* **It decides nothing about safety.** Every round is a full, independent rating by
|
|
14
|
+
* {@link import('./rater.js').mapVerdictToAction}; this only answers *"may another round be
|
|
15
|
+
* served, or is it a human's turn?"*.
|
|
16
|
+
*
|
|
17
|
+
* ## The lifetimes, which are the whole design
|
|
18
|
+
*
|
|
19
|
+
* - **The transcript and the consecutive counter have ONE lifetime, by construction** (§5.3). A
|
|
20
|
+
* successful intervening tool call resets the counter *and clears the transcript with it*, so the
|
|
21
|
+
* rating right after a reset is a round-1 context — the command alone, no transcript and no user
|
|
22
|
+
* messages. They are one field's worth of state precisely because the spec warns that an
|
|
23
|
+
* implementation clearing the counter alone *"looks correct and passes any obvious test"* while
|
|
24
|
+
* letting justification text accumulate across unbounded attempts.
|
|
25
|
+
* - **The reachability bound is monotonic and a reset does not refill it.** See
|
|
26
|
+
* {@link MAX_REJECTIONS_BEFORE_HUMAN}.
|
|
27
|
+
*/
|
|
28
|
+
import { MIN_CONTENT_WIDTH, neutralizeToOneLine, wrapToWidth } from '#src/core/shell/framing.js';
|
|
29
|
+
import { maxDisplayWidth } from '#src/utils/displayWidth.js';
|
|
30
|
+
/**
|
|
31
|
+
* §5.3 — **three CONSECUTIVE rejections end the negotiation** and escalate to the human: the agent
|
|
32
|
+
* and the rater cannot agree, and that is a human's call, not a machine's.
|
|
33
|
+
*
|
|
34
|
+
* Consecutive, not total: a successful intervening tool call — the agent going away to gather
|
|
35
|
+
* information and returning better informed — resets it, because that is progress, not ping-pong.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_CONSECUTIVE_REJECTIONS = 3;
|
|
38
|
+
/**
|
|
39
|
+
* [[EXT-29]] — **the reachability bound: total rejections since a human was last involved.** A
|
|
40
|
+
* reset of {@link MAX_CONSECUTIVE_REJECTIONS} does not refill it; only actually reaching a person
|
|
41
|
+
* clears it ({@link ShellNegotiationState.humanReached}).
|
|
42
|
+
*
|
|
43
|
+
* ## Why this exists, and why §5.3 does not answer it
|
|
44
|
+
*
|
|
45
|
+
* §5.3 says *"no total ceiling is added here, and none is needed"*, and tells the next reader not to
|
|
46
|
+
* add one without a fresh decision. **This is that decision (Andrew, EXT-29), and it answers a
|
|
47
|
+
* different question than the one §5.3 asked.**
|
|
48
|
+
*
|
|
49
|
+
* §5.3's argument is about **safety**, and it is sound and untouched: every attempt is rated
|
|
50
|
+
* independently, the rater does not weaken with repetition, and — because a reset also clears the
|
|
51
|
+
* transcript — a gamed reset approves nothing that would not have been approved on the first try.
|
|
52
|
+
* Nothing here contradicts that, and this bound must never be described as a security control.
|
|
53
|
+
*
|
|
54
|
+
* The question §5.3 never asked is **reachability**: does the human terminus fire at all? Under its
|
|
55
|
+
* own reset predicate it does not. An agent that alternates one approved `ls` with one rejection
|
|
56
|
+
* never accumulates three *consecutive* rejections, so *"three rejections escalate to the human"* —
|
|
57
|
+
* §7's promise that the model is never left to silently give up on a legitimate task — is
|
|
58
|
+
* unreachable for that agent, for any number of rejections. The same shape was measured live on the
|
|
59
|
+
* shipped EXT-65 retry budget, where a human's "No" and a deny-list match each refilled the budget.
|
|
60
|
+
*
|
|
61
|
+
* **`recursionLimit` is not the answer either.** It bounds the *run* at 1000 steps; it does not
|
|
62
|
+
* escalate to a *person*. §5.3's *"that is a human's call"* names an event, and a dying run does not
|
|
63
|
+
* produce it: the user gets a step-limit failure rather than the question they were promised.
|
|
64
|
+
*
|
|
65
|
+
* Nine — three times the consecutive cap — so an agent making genuine progress between rejections
|
|
66
|
+
* still gets several full negotiations before a person is asked, while one making none is in front
|
|
67
|
+
* of a human within a bounded number of rounds.
|
|
68
|
+
*
|
|
69
|
+
* **The shape and the number are Andrew's to change** (a progress-only reset predicate is the
|
|
70
|
+
* standing alternative). What is not negotiable is that some bound ends at a person, so this stays
|
|
71
|
+
* ONE constant and ONE predicate — {@link ShellNegotiationState.recordRejection} — and swapping it
|
|
72
|
+
* is local.
|
|
73
|
+
*/
|
|
74
|
+
export const MAX_REJECTIONS_BEFORE_HUMAN = 9;
|
|
75
|
+
/**
|
|
76
|
+
* §5.1 — how many of the user's most recent messages the runner keeps for the rater. The prompt
|
|
77
|
+
* builder takes the last 5 of whatever it is handed and truncates each; this is the runner's own
|
|
78
|
+
* retention bound, so an unbounded conversation does not accumulate here.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately larger than the builder's window: the builder drops blank messages *before* taking
|
|
81
|
+
* its five, and it can only drop what it was given, so keeping a little slack is what stops a run of
|
|
82
|
+
* empty turns from spending the budget that carries the mandate.
|
|
83
|
+
*/
|
|
84
|
+
export const NEGOTIATION_USER_MESSAGE_RETENTION = 10;
|
|
85
|
+
/**
|
|
86
|
+
* The per-session state of §5's negotiation: one instance per {@link
|
|
87
|
+
* import('../GthAgentRunner.js').GthAgentRunner}.
|
|
88
|
+
*
|
|
89
|
+
* Every mutation is one of four events, and naming them is what keeps the two bounds from being
|
|
90
|
+
* confused with each other:
|
|
91
|
+
*
|
|
92
|
+
* | event | transcript | consecutive | since-human |
|
|
93
|
+
* |---|---|---|---|
|
|
94
|
+
* | a rejection ({@link recordRejection}) | append | +1 | +1 |
|
|
95
|
+
* | the gate approved a call ({@link noteProgress}) | **cleared** | 0 | unchanged |
|
|
96
|
+
* | a human was reached ({@link humanReached}) | cleared | 0 | **0** |
|
|
97
|
+
* | the run halted ({@link humanReached}) | cleared | 0 | 0 |
|
|
98
|
+
*/
|
|
99
|
+
export class ShellNegotiationState {
|
|
100
|
+
/** §5.1's transcript: every round of the CURRENT negotiation, oldest first. */
|
|
101
|
+
rounds = [];
|
|
102
|
+
/** §5.3's consecutive-rejection count. Shares the transcript's lifetime, by construction. */
|
|
103
|
+
consecutive = 0;
|
|
104
|
+
/** The reachability bound's count. Cleared ONLY by {@link humanReached}. */
|
|
105
|
+
sinceHuman = 0;
|
|
106
|
+
/** §5.1's last user messages, oldest first, capped at {@link NEGOTIATION_USER_MESSAGE_RETENTION}. */
|
|
107
|
+
userMessages = [];
|
|
108
|
+
/**
|
|
109
|
+
* §5.1 — the context for the rating about to be made: the justification the agent supplied for
|
|
110
|
+
* *this* command, the user messages, and the rounds so far.
|
|
111
|
+
*
|
|
112
|
+
* Handed over raw. The prompt builder owns every bound and every transform (last 5, truncation,
|
|
113
|
+
* home-folding, one-lining, tag neutralisation), so a caller that pre-processes anything here is
|
|
114
|
+
* doing the work twice and differently.
|
|
115
|
+
*
|
|
116
|
+
* An empty negotiation has no privileged spelling on the builder's side, so this always returns an
|
|
117
|
+
* object and never `undefined`: a cleared transcript IS the round-1 case, with no `if` at the call
|
|
118
|
+
* site — which is exactly what makes §5.6's *"a cleared transcript means a round-1 context"* fall
|
|
119
|
+
* out of the reset rather than out of a second branch that could disagree with it.
|
|
120
|
+
*
|
|
121
|
+
* **Everything except the command is admitted from round 2, never at round 1**, and the transcript
|
|
122
|
+
* is what decides which round this is. §5.1 is unambiguous — *"Round 1 sees the command alone —
|
|
123
|
+
* nothing else"* — and §5.6 spells out the consequence for the round right after a reset: *"the
|
|
124
|
+
* command and nothing else — no transcript, no user messages, because that is what round 1 means"*.
|
|
125
|
+
*
|
|
126
|
+
* Keying them on the transcript rather than on a flag is what makes those two sentences the same
|
|
127
|
+
* fact, and it is why the justification is withheld by the same test rather than passed straight
|
|
128
|
+
* through. §5.1 lists the justification under what *"from round 2 the rater additionally sees"*,
|
|
129
|
+
* and it is the one channel the design allows to LOWER an outcome — so a justification volunteered
|
|
130
|
+
* before any rejection has happened would open that channel on the first attempt, pre-emptively,
|
|
131
|
+
* for the agent or for anything that has injected into the agent's context. Withholding it until
|
|
132
|
+
* round 2 is what the spec's ordering buys, and a round-1 context is byte-identical to a plain
|
|
133
|
+
* rating because of it.
|
|
134
|
+
*
|
|
135
|
+
* The messages and the volunteered justification are NOT destroyed by a reset (they are the
|
|
136
|
+
* conversation and the pending call's own argument, not the exchange), so §5.6's convergence still
|
|
137
|
+
* works: the reply *"just the last two"* is out of view for the round-1 rating after the reset and
|
|
138
|
+
* in view for the round-2 rating that follows it — which is exactly the row the spec's table shows
|
|
139
|
+
* it arriving on. A justification the agent supplies again with its next attempt is admitted then,
|
|
140
|
+
* because by then there is a rejection for it to answer.
|
|
141
|
+
*/
|
|
142
|
+
contextFor(justification) {
|
|
143
|
+
const roundOne = this.rounds.length === 0;
|
|
144
|
+
return {
|
|
145
|
+
justification: roundOne ? undefined : justification,
|
|
146
|
+
userMessages: roundOne ? [] : [...this.userMessages],
|
|
147
|
+
priorRounds: [...this.rounds],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Record a rejected round and decide whether another may be served.
|
|
152
|
+
*
|
|
153
|
+
* **The round is appended BEFORE either bound is tested**, so the rating that escalates is itself
|
|
154
|
+
* on the transcript the human sees. §5.6's escalation example turns on this: what matters on the
|
|
155
|
+
* screen is that the agent proposed the same command three times, and the third proposal is the
|
|
156
|
+
* one being escalated. {@link renderNegotiationRows} marks that last round as the pending request
|
|
157
|
+
* rather than as a prior one, which is the half of this the reader needs and cannot derive.
|
|
158
|
+
*/
|
|
159
|
+
recordRejection(round) {
|
|
160
|
+
this.rounds.push(round);
|
|
161
|
+
this.consecutive += 1;
|
|
162
|
+
this.sinceHuman += 1;
|
|
163
|
+
if (this.consecutive >= MAX_CONSECUTIVE_REJECTIONS)
|
|
164
|
+
return 'escalate';
|
|
165
|
+
if (this.sinceHuman >= MAX_REJECTIONS_BEFORE_HUMAN)
|
|
166
|
+
return 'escalate';
|
|
167
|
+
return 'reject';
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* §5.3 — a tool call the gate let through. Resets the consecutive counter **and clears the
|
|
171
|
+
* transcript with it**; the reachability bound is deliberately untouched.
|
|
172
|
+
*
|
|
173
|
+
* **What it must NOT truncate is the human's record of how hard the agent pushed.** The counter's
|
|
174
|
+
* reset is §5.3's and is sound; the reader at an escalation is asking a different question, and
|
|
175
|
+
* the answer to theirs is {@link NegotiationCounters.rejectionsSinceHuman}, which this leaves
|
|
176
|
+
* standing. {@link renderNegotiationRows} reports that count rather than the surviving rounds, so
|
|
177
|
+
* an argument this erased is still declared even though its rounds are gone; every erased round
|
|
178
|
+
* itself survives whole in [[TUI-C27]]'s archive, which captures per RATING CALL and so is not
|
|
179
|
+
* truncated by anything here.
|
|
180
|
+
*
|
|
181
|
+
* **"Approved" is what the gate can observe, and it is not quite §5.3's "successful".** The
|
|
182
|
+
* decision site sees whether a call was allowed to run, never whether it then exited zero — and
|
|
183
|
+
* the honest alternatives were worse: a tool-result stream carries the *rejected* call's own
|
|
184
|
+
* result too, so counting results would let a rejection reset the counter that exists to bound it.
|
|
185
|
+
* Erring here is permissive on this bound alone, which is precisely what
|
|
186
|
+
* {@link MAX_REJECTIONS_BEFORE_HUMAN} is monotonic for.
|
|
187
|
+
*/
|
|
188
|
+
noteProgress() {
|
|
189
|
+
this.rounds = [];
|
|
190
|
+
this.consecutive = 0;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* A human was actually reached — an escalation presented to them, a new user turn, or a run that
|
|
194
|
+
* ended. Clears everything, including the reachability bound: §5.3's *"three rejections end the
|
|
195
|
+
* negotiation"* means the exchange is over, not merely paused.
|
|
196
|
+
*/
|
|
197
|
+
humanReached() {
|
|
198
|
+
this.rounds = [];
|
|
199
|
+
this.consecutive = 0;
|
|
200
|
+
this.sinceHuman = 0;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* §5.1 — record what the user said, oldest first, for the ratings of this and later turns.
|
|
204
|
+
*
|
|
205
|
+
* Blank messages are dropped here as well as by the builder: an empty turn carries nothing a rater
|
|
206
|
+
* can weigh, and keeping it would spend a retention slot that the message carrying the mandate
|
|
207
|
+
* needs.
|
|
208
|
+
*
|
|
209
|
+
* **A message already retained moves to the newest position rather than being stored twice**, and
|
|
210
|
+
* that is not tidiness. The runner cannot tell "this turn's new message" from "the whole
|
|
211
|
+
* conversation replayed", because both arrive as the same argument — `runtime/conversation.ts`
|
|
212
|
+
* replays the accumulated array on every turn, while the TUI and the readline session pass the one
|
|
213
|
+
* new message. Appending blindly would fill §5.1's five-message window with repeats of the same
|
|
214
|
+
* sentence for the replaying caller, starving the rater of the one context it is allowed. Keeping
|
|
215
|
+
* the newest position is also the right answer when a user genuinely repeats themselves: it is one
|
|
216
|
+
* thing they said, and it was said most recently.
|
|
217
|
+
*/
|
|
218
|
+
noteUserMessages(messages) {
|
|
219
|
+
for (const message of messages) {
|
|
220
|
+
if (message.trim().length === 0)
|
|
221
|
+
continue;
|
|
222
|
+
const seen = this.userMessages.indexOf(message);
|
|
223
|
+
if (seen !== -1)
|
|
224
|
+
this.userMessages.splice(seen, 1);
|
|
225
|
+
this.userMessages.push(message);
|
|
226
|
+
}
|
|
227
|
+
if (this.userMessages.length > NEGOTIATION_USER_MESSAGE_RETENTION) {
|
|
228
|
+
this.userMessages = this.userMessages.slice(-NEGOTIATION_USER_MESSAGE_RETENTION);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* §6 — the rounds to show the human, oldest first. A snapshot: the caller holds it across the
|
|
233
|
+
* {@link humanReached} that immediately follows, and nothing it holds may change underneath it.
|
|
234
|
+
*/
|
|
235
|
+
transcript() {
|
|
236
|
+
return [...this.rounds];
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* [[TUI-C27]] — where this negotiation stands against both bounds, for the `/debug-dump` archive.
|
|
240
|
+
*
|
|
241
|
+
* **There is a real reader for this now, which is what changed.** `sinceHuman` had no accessor
|
|
242
|
+
* because the only thing that wanted it was a spec, and a getter in production for a spec-only
|
|
243
|
+
* reader is a widened class with nothing behind it (`shellNegotiation.spec.ts` reaches it through
|
|
244
|
+
* a cast for exactly that reason, and still does). The archive is a production reader: *"the call
|
|
245
|
+
* was rejected"* and *"the call was the third consecutive rejection, so the next one goes to a
|
|
246
|
+
* person"* are different facts, and a bug report carrying only the first invites the wrong
|
|
247
|
+
* conclusion about why a session started interrupting.
|
|
248
|
+
*
|
|
249
|
+
* **`rejectionsSinceHuman` is also what the escalation prompt COUNTS**, and that is the one place
|
|
250
|
+
* it faces a person rather than a debugging archive. It is read at the escalation site before
|
|
251
|
+
* {@link humanReached} spends it; reading it after would report zero.
|
|
252
|
+
*/
|
|
253
|
+
counters() {
|
|
254
|
+
return {
|
|
255
|
+
consecutiveRejections: this.consecutive,
|
|
256
|
+
rejectionsSinceHuman: this.sinceHuman,
|
|
257
|
+
maxConsecutive: MAX_CONSECUTIVE_REJECTIONS,
|
|
258
|
+
maxBeforeHuman: MAX_REJECTIONS_BEFORE_HUMAN,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/** Drop everything, including the user messages — the TUI's `/clear` rotates the thread. */
|
|
262
|
+
clear() {
|
|
263
|
+
this.humanReached();
|
|
264
|
+
this.userMessages = [];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The prefix a row too wide for the terminal is continued with.
|
|
269
|
+
*
|
|
270
|
+
* **A continuation is where this block could be forged, and an indent alone does not stop it.** The
|
|
271
|
+
* rows carry agent-authored text after this renderer's own `Round N:` / `agent justified:` /
|
|
272
|
+
* `rater answered:` labels, so a long command left to the terminal's own wrap continues at column 0
|
|
273
|
+
* — the flush-left forgery `core/shell/framing` exists to prevent, reached through the one block
|
|
274
|
+
* that was not framed. Wrapping here fixes the column, and a marker no label starts with fixes the
|
|
275
|
+
* rest: a continuation cannot be read as a turn that was never taken, whatever it contains.
|
|
276
|
+
*/
|
|
277
|
+
const CONTINUATION_PREFIX = ' ┊ ';
|
|
278
|
+
/**
|
|
279
|
+
* Terminal rows one element of a round — the command, the justification, the rater's answer — may
|
|
280
|
+
* occupy on a screen before the rest of it is elided.
|
|
281
|
+
*
|
|
282
|
+
* **MEASURED, and it is the height bound this block needs rather than a cap on rounds.** At 80
|
|
283
|
+
* columns three rounds of paragraph-length argument cost 37 rows, of which one round was 12; the
|
|
284
|
+
* whole prompt was 64 rows against a 20-row budget, so the human saw the command and the verdict
|
|
285
|
+
* and neither the later rounds nor the menu line. A cap on the NUMBER of rounds would have changed
|
|
286
|
+
* none of that — §5.3 already bounds the transcript at
|
|
287
|
+
* {@link MAX_CONSECUTIVE_REJECTIONS} — because the cost is per row, not per round. Bounding each
|
|
288
|
+
* element keeps every round structurally on the screen, which is what §5.6 calls the most important
|
|
289
|
+
* thing on it, and pays for it out of the tail of a paragraph the reader was never going to need in
|
|
290
|
+
* full: the archive keeps every round whole ([[TUI-C27]]'s capture is per rating call).
|
|
291
|
+
*
|
|
292
|
+
* Two rather than one so a wrapped command keeps the continuation that shows how it differs from
|
|
293
|
+
* the round above it — the comparison the block exists to make.
|
|
294
|
+
*/
|
|
295
|
+
export const NEGOTIATION_MAX_ROWS_PER_ELEMENT = 2;
|
|
296
|
+
/**
|
|
297
|
+
* Rounds a screen shows, newest last. A backstop rather than today's binding constraint: §5.3
|
|
298
|
+
* escalates at {@link MAX_CONSECUTIVE_REJECTIONS}, so a transcript reaching a human is never longer
|
|
299
|
+
* than this — and an unscrollable prompt must not acquire an unbounded section the moment that
|
|
300
|
+
* number is raised. What is dropped is said out loud in the heading, which carries the true count.
|
|
301
|
+
*/
|
|
302
|
+
export const NEGOTIATION_MAX_ROUNDS_SHOWN = 3;
|
|
303
|
+
/**
|
|
304
|
+
* §6/§5.4 — render a negotiation for the human being asked to rule on it, one terminal row per
|
|
305
|
+
* element, each tagged with the voice speaking it.
|
|
306
|
+
*
|
|
307
|
+
* **The whole exchange is shown, never only the last attempt.** *That the agent proposed
|
|
308
|
+
* `git reset --hard origin/main` three times unchanged, against two rejections that each told it
|
|
309
|
+
* what to fix, is itself the most important thing on the screen, and it is invisible if only the
|
|
310
|
+
* last attempt is shown.* A prompt that shows the final command alone asks the user to rule on a
|
|
311
|
+
* command; this asks them to rule on an argument, which is the decision they actually have.
|
|
312
|
+
*
|
|
313
|
+
* Empty when there are no rounds, so a surface renders no heading over an argument that never
|
|
314
|
+
* happened (`catastrophic`, a declared escalate entry, an unrated rung).
|
|
315
|
+
*
|
|
316
|
+
* **`width` is optional and means "bind the rows to this terminal".** Given one, every row returned
|
|
317
|
+
* fits it under either ambiguous-width policy — measured with the same conservative ruler
|
|
318
|
+
* `core/shell/framing` budgets with, never `.length`, because a row measured as fitting that does
|
|
319
|
+
* not fit is a row the terminal wraps back to column 0. Omitted, rows are returned unwrapped, which
|
|
320
|
+
* is what the §6.2 non-interactive message wants: it is prose in an exception, not a screen.
|
|
321
|
+
*
|
|
322
|
+
* A wrapped row keeps the voice of the row it continues. A continuation painted as chrome would put
|
|
323
|
+
* the rater's words in the agent's colour at exactly the width where a long argument is hardest to
|
|
324
|
+
* read, which is the confusion §5.4 exists to remove.
|
|
325
|
+
*
|
|
326
|
+
* ## The three things this block must not get wrong about its own exchange
|
|
327
|
+
*
|
|
328
|
+
* Every label here sits over data that is already correct, so a label that lies does so silently —
|
|
329
|
+
* and each of these lied toward approving, at the moment a human was deciding whether to overrule
|
|
330
|
+
* a refusal.
|
|
331
|
+
*
|
|
332
|
+
* - **`attempts` is the count, and it is not `rounds.length`.** §5.3 clears the transcript on an
|
|
333
|
+
* approved call, so the rounds handed over are the attempts since the last *approval*, while the
|
|
334
|
+
* fact the reader is weighing is how hard the agent pushed since the last *human* — the caller's
|
|
335
|
+
* `rejectionsSinceHuman`. A measured escalation attempted the same command five times and
|
|
336
|
+
* rendered three. Omit it and this falls back to `rounds.length`, which is the honest reading of
|
|
337
|
+
* a caller that has no better number rather than a claim that none were erased.
|
|
338
|
+
* - **The last round IS the pending rating, not a prior one.** {@link
|
|
339
|
+
* ShellNegotiationState.recordRejection} appends before either bound is tested, deliberately, so
|
|
340
|
+
* that the rating being escalated is on the transcript the human sees. The old heading called
|
|
341
|
+
* them all prior rounds, which both under-reported the argument and put the pending command on
|
|
342
|
+
* the screen twice with nothing saying they were the same call.
|
|
343
|
+
* - **The first round was rated on the command alone.** A cleared transcript IS the round-1 case
|
|
344
|
+
* (see {@link ShellNegotiationState.contextFor}), so §5.1 withheld the justification and the user
|
|
345
|
+
* messages from that rating — while {@link ShellNegotiationState.recordRejection} stores the
|
|
346
|
+
* justification the agent supplied whatever the round. Printed unmarked, the round reads as a
|
|
347
|
+
* rater brushing past a direct answer to its own objection, which is the opposite of what
|
|
348
|
+
* happened and makes the rater look stubborn exactly where the reader is deciding whether to
|
|
349
|
+
* overrule it. The withholding is correct and is not what this marks. The same marker answers
|
|
350
|
+
* *"were the user's own words in view when this was rated?"* — a justification claiming the user
|
|
351
|
+
* asked for a command is exactly what a round-1 rating cannot check, and the user's messages are
|
|
352
|
+
* several rows on a surface with none to spare.
|
|
353
|
+
*
|
|
354
|
+
* **The prompt this feeds does not scroll and nothing else on it can give up rows**, so the height
|
|
355
|
+
* bound is {@link NEGOTIATION_MAX_ROWS_PER_ELEMENT} and every fact above is carried by a row that
|
|
356
|
+
* already exists.
|
|
357
|
+
*/
|
|
358
|
+
export function renderNegotiationRows(rounds, options) {
|
|
359
|
+
if (rounds.length === 0)
|
|
360
|
+
return [];
|
|
361
|
+
const width = options?.width;
|
|
362
|
+
// A screen shows the newest rounds; a consumer with no screen (§6.2's exception message) shows
|
|
363
|
+
// them all, for the same reason it is handed no width.
|
|
364
|
+
const shown = width === undefined ? rounds : rounds.slice(-NEGOTIATION_MAX_ROUNDS_SHOWN);
|
|
365
|
+
// Never fewer attempts than rounds on the screen: a caller that passes a stale or smaller number
|
|
366
|
+
// must not be able to make this block claim less argument than it is about to print.
|
|
367
|
+
const attempts = Math.max(options?.attempts ?? rounds.length, rounds.length);
|
|
368
|
+
// The shown rounds are the most recent ones, so they are attempts `attempts - shown + 1` … N.
|
|
369
|
+
// Numbering them by their true attempt number is what stops the heading's count and the rounds
|
|
370
|
+
// beneath it describing two different exchanges.
|
|
371
|
+
const firstNumber = attempts - shown.length + 1;
|
|
372
|
+
// How many of the transcript's own rounds this screen had to drop, so a marker keyed on a
|
|
373
|
+
// POSITION IN THE TRANSCRIPT (round one, the pending round) stays keyed on it after the slice.
|
|
374
|
+
const dropped = rounds.length - shown.length;
|
|
375
|
+
const rows = [
|
|
376
|
+
{
|
|
377
|
+
voice: 'chrome',
|
|
378
|
+
text: attempts > shown.length
|
|
379
|
+
? `The agent argued with the auto-rater ${attempts} times; the last ${shown.length} of them:`
|
|
380
|
+
: `The agent argued with the auto-rater ${attempts} ${attempts === 1 ? 'time' : 'times'}:`,
|
|
381
|
+
},
|
|
382
|
+
];
|
|
383
|
+
shown.forEach((round, index) => {
|
|
384
|
+
// **Each marker rides the row whose reading it corrects, and no other.** Put on the `Round`
|
|
385
|
+
// row they would be paid for in the one thing that row is for — the command, and how it
|
|
386
|
+
// compares with the round above it — which at a narrow width is most of what fits on it.
|
|
387
|
+
const pending = dropped + index === rounds.length - 1 ? ' (this request)' : '';
|
|
388
|
+
// A cleared transcript IS the round-1 case, so the transcript's FIRST round is the one §5.1
|
|
389
|
+
// rated on the command alone. Derived from the position rather than carried on the round
|
|
390
|
+
// because it is a property of the transcript, not of the rating — but that makes it a fact a
|
|
391
|
+
// reset could quietly invalidate, so it is pinned against a real run rather than by reading.
|
|
392
|
+
const roundOne = dropped + index === 0;
|
|
393
|
+
rows.push({
|
|
394
|
+
voice: 'agent',
|
|
395
|
+
text: ` Round ${firstNumber + index}${pending}: ${oneLine(round.command)}`,
|
|
396
|
+
});
|
|
397
|
+
const justification = round.justification?.trim();
|
|
398
|
+
if (justification) {
|
|
399
|
+
rows.push({
|
|
400
|
+
voice: 'agent',
|
|
401
|
+
text: ` agent justified${roundOne ? ' (not shown to the rater)' : ''}: ${oneLine(justification)}`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
const reason = round.reason.trim();
|
|
405
|
+
rows.push({
|
|
406
|
+
voice: 'rater',
|
|
407
|
+
text: ` rater answered${roundOne ? ' (on the command alone)' : ''}: ${round.outcome}${reason ? ` — ${oneLine(reason)}` : ''}`,
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
if (width === undefined)
|
|
411
|
+
return rows;
|
|
412
|
+
return rows.flatMap((row) => wrapRow(row, width));
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* One logical row as the terminal rows it needs, continuations marked and voice preserved — and
|
|
416
|
+
* never more than {@link NEGOTIATION_MAX_ROWS_PER_ELEMENT} of them.
|
|
417
|
+
*
|
|
418
|
+
* The elision is stated on the last row it keeps rather than on one of its own: a row spent saying
|
|
419
|
+
* a row was dropped saves nothing on a surface whose whole problem is rows.
|
|
420
|
+
*/
|
|
421
|
+
function wrapRow(row, width) {
|
|
422
|
+
const budget = Math.max(MIN_CONTENT_WIDTH, width - maxDisplayWidth(CONTINUATION_PREFIX));
|
|
423
|
+
const lines = wrapToWidth(row.text, budget);
|
|
424
|
+
// **The heading WRAPS but is never clamped, and the distinction is the same one
|
|
425
|
+
// `approvalStop`'s rows make**: the row bound exists to stop agent-authored prose spending a
|
|
426
|
+
// screen that cannot scroll, and the heading is this renderer's own sentence — nothing can forge
|
|
427
|
+
// it, so it may wrap like ordinary prose, and clamping it buys nothing. It costs, though: the
|
|
428
|
+
// heading is where the attempt count lives, and at {@link MIN_FRAME_WIDTH} a clamp lands inside
|
|
429
|
+
// the number, deleting the single most decision-relevant fact on the block to save one row.
|
|
430
|
+
const kept = row.voice === 'chrome' ? [...lines] : lines.slice(0, NEGOTIATION_MAX_ROWS_PER_ELEMENT);
|
|
431
|
+
const hidden = lines.length - kept.length;
|
|
432
|
+
if (hidden > 0 && kept.length > 0) {
|
|
433
|
+
const marker = ` … +${hidden} ${hidden === 1 ? 'row' : 'rows'}`;
|
|
434
|
+
const last = kept.length - 1;
|
|
435
|
+
// **The marker is the fact; the tail of the sentence it truncates is not.** On a frame too
|
|
436
|
+
// narrow to hold both, the content gives way rather than the row overrunning — and the joined
|
|
437
|
+
// row is re-bound afterwards, because `wrapToWidth` can only bind text it was given, and it was
|
|
438
|
+
// never given these two pieces joined. A row measured as fitting that does not fit is a row the
|
|
439
|
+
// terminal wraps back to column 0, which is the one thing every row here is bound to prevent.
|
|
440
|
+
const room = budget - maxDisplayWidth(marker);
|
|
441
|
+
const head = room >= MIN_CONTENT_WIDTH ? (wrapToWidth(kept[last], room)[0] ?? '') : '';
|
|
442
|
+
const composed = `${head}${marker}`;
|
|
443
|
+
kept[last] =
|
|
444
|
+
maxDisplayWidth(composed) <= budget ? composed : (wrapToWidth(composed, budget)[0] ?? '');
|
|
445
|
+
}
|
|
446
|
+
const [head, ...rest] = kept;
|
|
447
|
+
return [
|
|
448
|
+
{ voice: row.voice, text: head },
|
|
449
|
+
...rest.map((text) => ({ voice: row.voice, text: `${CONTINUATION_PREFIX}${text}` })),
|
|
450
|
+
];
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* The same transcript as one string, for a consumer with no screen to lay it out on: §6.2's
|
|
454
|
+
* non-interactive escalation message, where the exchange goes into an exception because that
|
|
455
|
+
* message is the only thing anyone sees on that path.
|
|
456
|
+
*
|
|
457
|
+
* Defined as {@link renderNegotiationRows} joined, so the string and the rows can never come to
|
|
458
|
+
* describe one exchange two ways — the whole reason there is one renderer and two surfaces.
|
|
459
|
+
* `null` when there is nothing to show.
|
|
460
|
+
*
|
|
461
|
+
* `attempts` carries the same fact it carries on a screen. It is the one thing a caller must pass
|
|
462
|
+
* for this message to be true, because the count it defaults to is the one an approved call
|
|
463
|
+
* truncated.
|
|
464
|
+
*/
|
|
465
|
+
export function renderNegotiationTranscript(rounds, attempts) {
|
|
466
|
+
const rows = renderNegotiationRows(rounds, attempts === undefined ? undefined : { attempts });
|
|
467
|
+
if (rows.length === 0)
|
|
468
|
+
return null;
|
|
469
|
+
return rows.map((row) => row.text).join('\n');
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Collapse a value onto one line before it is rendered into a line-structured block.
|
|
473
|
+
*
|
|
474
|
+
* Every value here is agent-authored or agent-influenced, and this block's meaning is carried by its
|
|
475
|
+
* line structure: a newline inside a command or a justification would otherwise let it forge a
|
|
476
|
+
* `Round N` heading and an answer that was never given. The rating prompt's own transcript builder
|
|
477
|
+
* has the identical rule for the identical reason.
|
|
478
|
+
*
|
|
479
|
+
* [[TUI-C26]] — collapsing whitespace is **not** on its own enough for a value bound for a terminal.
|
|
480
|
+
* JavaScript's `\s` covers LF, CR and TAB and covers neither ESC nor the C1 range, so a rater
|
|
481
|
+
* `reason` carrying a screen-clear sequence used to reach the approval dialog intact on a line that
|
|
482
|
+
* merely looked tidy. Neutralisation runs first, and the collapse then only has ordinary spaces left
|
|
483
|
+
* to fold.
|
|
484
|
+
*/
|
|
485
|
+
function oneLine(text) {
|
|
486
|
+
return neutralizeToOneLine(text);
|
|
487
|
+
}
|
|
488
|
+
//# sourceMappingURL=negotiation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"negotiation.js","sourceRoot":"","sources":["../../../src/core/shell/negotiation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAEjG,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAE7D;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,CAAC,CAAC;AAE5C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,CAAC;AAuB7C;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,kCAAkC,GAAG,EAAE,CAAC;AAErD;;;;;;;;;;;;;GAaG;AACH,MAAM,OAAO,qBAAqB;IAChC,+EAA+E;IACvE,MAAM,GAA4B,EAAE,CAAC;IAC7C,6FAA6F;IACrF,WAAW,GAAG,CAAC,CAAC;IACxB,4EAA4E;IACpE,UAAU,GAAG,CAAC,CAAC;IACvB,qGAAqG;IAC7F,YAAY,GAAa,EAAE,CAAC;IAEpC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,UAAU,CAAC,aAAsB;QAC/B,MAAM,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,CAAC;QAC1C,OAAO;YACL,aAAa,EAAE,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,aAAa;YACnD,YAAY,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,YAAY,CAAC;YACpD,WAAW,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC;SAC9B,CAAC;IACJ,CAAC;IAED;;;;;;;;OAQG;IACH,eAAe,CAAC,KAA4B;QAC1C,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACxB,IAAI,CAAC,WAAW,IAAI,CAAC,CAAC;QACtB,IAAI,CAAC,UAAU,IAAI,CAAC,CAAC;QACrB,IAAI,IAAI,CAAC,WAAW,IAAI,0BAA0B;YAAE,OAAO,UAAU,CAAC;QACtE,IAAI,IAAI,CAAC,UAAU,IAAI,2BAA2B;YAAE,OAAO,UAAU,CAAC;QACtE,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,YAAY;QACV,IAAI,CAAC,MAAM,GAAG,EAAE,CAAC;QACjB,IAAI,CAAC,WAAW,GAAG,CAAC,CAAC;IACvB,CAAC;IAED;;;;OAIG;IACH,YAAY;QACV,IAAI,CAAC,MAAM,GAAG,EAAE,CAAC;QACjB,IAAI,CAAC,WAAW,GAAG,CAAC,CAAC;QACrB,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC;IACtB,CAAC;IAED;;;;;;;;;;;;;;;OAeG;IACH,gBAAgB,CAAC,QAA2B;QAC1C,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC;gBAAE,SAAS;YAC1C,MAAM,IAAI,GAAG,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;YAChD,IAAI,IAAI,KAAK,CAAC,CAAC;gBAAE,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;YACnD,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAClC,CAAC;QACD,IAAI,IAAI,CAAC,YAAY,CAAC,MAAM,GAAG,kCAAkC,EAAE,CAAC;YAClE,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,kCAAkC,CAAC,CAAC;QACnF,CAAC;IACH,CAAC;IAED;;;OAGG;IACH,UAAU;QACR,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC;IAC1B,CAAC;IAED;;;;;;;;;;;;;;OAcG;IACH,QAAQ;QACN,OAAO;YACL,qBAAqB,EAAE,IAAI,CAAC,WAAW;YACvC,oBAAoB,EAAE,IAAI,CAAC,UAAU;YACrC,cAAc,EAAE,0BAA0B;YAC1C,cAAc,EAAE,2BAA2B;SAC5C,CAAC;IACJ,CAAC;IAED,4FAA4F;IAC5F,KAAK;QACH,IAAI,CAAC,YAAY,EAAE,CAAC;QACpB,IAAI,CAAC,YAAY,GAAG,EAAE,CAAC;IACzB,CAAC;CACF;AAoBD;;;;;;;;;GASG;AACH,MAAM,mBAAmB,GAAG,UAAU,CAAC;AAEvC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,gCAAgC,GAAG,CAAC,CAAC;AAElD;;;;;GAKG;AACH,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAAC,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,MAAM,UAAU,qBAAqB,CACnC,MAAwC,EACxC,OAOC;IAED,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACnC,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,CAAC;IAC7B,+FAA+F;IAC/F,uDAAuD;IACvD,MAAM,KAAK,GAAG,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,4BAA4B,CAAC,CAAC;IACzF,iGAAiG;IACjG,qFAAqF;IACrF,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,QAAQ,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;IAC7E,8FAA8F;IAC9F,+FAA+F;IAC/F,iDAAiD;IACjD,MAAM,WAAW,GAAG,QAAQ,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;IAChD,0FAA0F;IAC1F,+FAA+F;IAC/F,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC;IAC7C,MAAM,IAAI,GAAqB;QAC7B;YACE,KAAK,EAAE,QAAQ;YACf,IAAI,EACF,QAAQ,GAAG,KAAK,CAAC,MAAM;gBACrB,CAAC,CAAC,wCAAwC,QAAQ,oBAAoB,KAAK,CAAC,MAAM,WAAW;gBAC7F,CAAC,CAAC,wCAAwC,QAAQ,IAAI,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,GAAG;SAC/F;KACF,CAAC;IACF,KAAK,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE;QAC7B,4FAA4F;QAC5F,wFAAwF;QACxF,yFAAyF;QACzF,MAAM,OAAO,GAAG,OAAO,GAAG,KAAK,KAAK,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/E,4FAA4F;QAC5F,yFAAyF;QACzF,6FAA6F;QAC7F,6FAA6F;QAC7F,MAAM,QAAQ,GAAG,OAAO,GAAG,KAAK,KAAK,CAAC,CAAC;QACvC,IAAI,CAAC,IAAI,CAAC;YACR,KAAK,EAAE,OAAO;YACd,IAAI,EAAE,WAAW,WAAW,GAAG,KAAK,GAAG,OAAO,KAAK,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE;SAC5E,CAAC,CAAC;QACH,MAAM,aAAa,GAAG,KAAK,CAAC,aAAa,EAAE,IAAI,EAAE,CAAC;QAClD,IAAI,aAAa,EAAE,CAAC;YAClB,IAAI,CAAC,IAAI,CAAC;gBACR,KAAK,EAAE,OAAO;gBACd,IAAI,EAAE,sBAAsB,QAAQ,CAAC,CAAC,CAAC,2BAA2B,CAAC,CAAC,CAAC,EAAE,KAAK,OAAO,CAAC,aAAa,CAAC,EAAE;aACrG,CAAC,CAAC;QACL,CAAC;QACD,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QACnC,IAAI,CAAC,IAAI,CAAC;YACR,KAAK,EAAE,OAAO;YACd,IAAI,EAAE,qBAAqB,QAAQ,CAAC,CAAC,CAAC,yBAAyB,CAAC,CAAC,CAAC,EAAE,KAAK,KAAK,CAAC,OAAO,GAAG,MAAM,CAAC,CAAC,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE;SACjI,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IACH,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IACrC,OAAO,IAAI,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC;AACpD,CAAC;AAED;;;;;;GAMG;AACH,SAAS,OAAO,CAAC,GAAmB,EAAE,KAAa;IACjD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,iBAAiB,EAAE,KAAK,GAAG,eAAe,CAAC,mBAAmB,CAAC,CAAC,CAAC;IACzF,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC5C,gFAAgF;IAChF,6FAA6F;IAC7F,iGAAiG;IACjG,8FAA8F;IAC9F,gGAAgG;IAChG,4FAA4F;IAC5F,MAAM,IAAI,GACR,GAAG,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,gCAAgC,CAAC,CAAC;IACzF,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;IAC1C,IAAI,MAAM,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAClC,MAAM,MAAM,GAAG,OAAO,MAAM,IAAI,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;QAChE,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;QAC7B,2FAA2F;QAC3F,8FAA8F;QAC9F,gGAAgG;QAChG,gGAAgG;QAChG,8FAA8F;QAC9F,MAAM,IAAI,GAAG,MAAM,GAAG,eAAe,CAAC,MAAM,CAAC,CAAC;QAC9C,MAAM,IAAI,GAAG,IAAI,IAAI,iBAAiB,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QACvF,MAAM,QAAQ,GAAG,GAAG,IAAI,GAAG,MAAM,EAAE,CAAC;QACpC,IAAI,CAAC,IAAI,CAAC;YACR,eAAe,CAAC,QAAQ,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IAC9F,CAAC;IACD,MAAM,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC;IAC7B,OAAO;QACL,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE;QAChC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,mBAAmB,GAAG,IAAI,EAAE,EAAE,CAAC,CAAC;KACrF,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,2BAA2B,CACzC,MAAwC,EACxC,QAAiB;IAEjB,MAAM,IAAI,GAAG,qBAAqB,CAAC,MAAM,EAAE,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC;IAC9F,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACnC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,SAAS,OAAO,CAAC,IAAY;IAC3B,OAAO,mBAAmB,CAAC,IAAI,CAAC,CAAC;AACnC,CAAC"}
|
|
@@ -2,14 +2,42 @@
|
|
|
2
2
|
* @module core/shell/normalize
|
|
3
3
|
*
|
|
4
4
|
* Command-string normalization shared by the shell hardening layer. The hardline
|
|
5
|
-
* blocklist (
|
|
5
|
+
* blocklist (`core/shell/hardline`) and the EXT-9 Tier-2
|
|
6
6
|
* allow-list classifier ({@link ./arity.js}) both match against the *normalized* form so
|
|
7
7
|
* trivial obfuscation (ANSI escapes, fullwidth glyphs, backslash splits, padded
|
|
8
|
-
* whitespace) cannot smuggle a command past the guard. Canonical home is core so
|
|
9
|
-
*
|
|
8
|
+
* whitespace) cannot smuggle a command past the guard. Canonical home is core so every
|
|
9
|
+
* consumer — the allow-list, the hardline floor, the approvals gate — imports one implementation.
|
|
10
10
|
*
|
|
11
11
|
* Patterned after hermes-agent `tools/approval.py:_normalize_command_for_detection`.
|
|
12
12
|
*/
|
|
13
|
+
/**
|
|
14
|
+
* SECURITY / EXT-55 — the single source of truth for **where a new command begins**.
|
|
15
|
+
*
|
|
16
|
+
* This is a regex character-class BODY (embed it as `` `[${COMMAND_SEPARATOR_CLASS}]` ``) listing
|
|
17
|
+
* every character at which the shell stops one command and starts the next: `;`, `&` (hence
|
|
18
|
+
* `&&`), `|` (hence `||`), and a LINE BREAK. Both consumers of the normalized form build their
|
|
19
|
+
* patterns from it — the allow-list classifier's fail-closed check
|
|
20
|
+
* ({@link import('./arity.js').classifyCommand}) and the hardline blocklist's pattern
|
|
21
|
+
* terminators (`core/shell/hardline`) — so the two layers can never again
|
|
22
|
+
* disagree about what a separator is.
|
|
23
|
+
*
|
|
24
|
+
* The layers disagreed before EXT-55: `;`/`&&`/`|` made a command ambiguous (fail-closed) but a
|
|
25
|
+
* newline did not, because {@link normalizeCommand} folded it to a SPACE. `ls -la\nrm -rf /` was
|
|
26
|
+
* therefore classified as the single command `ls`, and an ordinary `ls` grant auto-approved it.
|
|
27
|
+
*
|
|
28
|
+
* `\r` is listed defensively: {@link normalizeCommand} canonicalizes CR/CRLF to LF, so a
|
|
29
|
+
* normalized string never contains one — but a pattern matched against a RAW command still fails
|
|
30
|
+
* closed.
|
|
31
|
+
*/
|
|
32
|
+
export declare const COMMAND_SEPARATOR_CLASS = ";&|\\n\\r";
|
|
33
|
+
/** {@link COMMAND_SEPARATOR_CLASS} as a ready-made single-character matcher. */
|
|
34
|
+
export declare const COMMAND_SEPARATOR_RE: RegExp;
|
|
35
|
+
/**
|
|
36
|
+
* Matches any line break — the separator {@link normalizeCommand} used to destroy (EXT-55).
|
|
37
|
+
* Exported so a caller can ask "is this more than one command?" of a RAW string without
|
|
38
|
+
* depending on someone else's normalizer having preserved the boundary.
|
|
39
|
+
*/
|
|
40
|
+
export declare const LINE_BREAK_RE: RegExp;
|
|
13
41
|
/**
|
|
14
42
|
* Normalize a command string before dangerous-pattern matching.
|
|
15
43
|
*
|
|
@@ -17,9 +45,21 @@
|
|
|
17
45
|
* - strip ANSI escape sequences (CSI / OSC / lone-escape),
|
|
18
46
|
* - drop null bytes,
|
|
19
47
|
* - Unicode NFKC fold (fullwidth `rm` → `rm`, etc.),
|
|
48
|
+
* - canonicalize every line ending (CRLF / lone CR) to a bare `\n`,
|
|
20
49
|
* - collapse shell backslash-escapes (`r\m` → `rm`, `\-rf` → `-rf`),
|
|
21
50
|
* - drop empty-string literals that split tokens (`r''m` / `r""m` → `rm`),
|
|
22
|
-
* - fold runs of whitespace (
|
|
51
|
+
* - fold runs of HORIZONTAL whitespace (spaces/tabs) to single spaces,
|
|
52
|
+
* - collapse each run of line breaks to a single `\n` — **which survives**, because a line break
|
|
53
|
+
* is a command separator, not padding (EXT-55) — and trim the ends.
|
|
54
|
+
*
|
|
55
|
+
* EXT-55: the last two steps used to be one `\s+ → ' '` fold, which erased the command boundary
|
|
56
|
+
* and let `ls -la\nrm -rf /` be read as the single command `ls`. A line break now reaches every
|
|
57
|
+
* consumer intact, exactly like `;`. Leading/trailing breaks are still trimmed, so the very
|
|
58
|
+
* common `"npm test\n"` tool argument remains ONE command and keeps matching the allow-list.
|
|
59
|
+
*
|
|
60
|
+
* A backslash before a line break (a shell line continuation) is deliberately NOT joined: folding
|
|
61
|
+
* it away would re-open the same hole for `ls \<newline>rm -rf /`. Keeping the boundary makes such
|
|
62
|
+
* a command categorically ambiguous, which costs a prompt, not a failure.
|
|
23
63
|
*
|
|
24
64
|
* This is intentionally lossy: the normalized form is ONLY used for detection,
|
|
25
65
|
* never for execution (the original command is what runs).
|