@dungle-scrubs/harness-cli-normalizer 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +33 -6
  2. package/dist/cli/args.d.ts +9 -1
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +36 -26
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/check.d.ts +14 -0
  7. package/dist/cli/check.d.ts.map +1 -1
  8. package/dist/cli/check.js +10 -3
  9. package/dist/cli/check.js.map +1 -1
  10. package/dist/cli/config.d.ts +4 -4
  11. package/dist/cli/config.d.ts.map +1 -1
  12. package/dist/cli/config.js +8 -6
  13. package/dist/cli/config.js.map +1 -1
  14. package/dist/cli/exit-codes.d.ts +0 -1
  15. package/dist/cli/exit-codes.d.ts.map +1 -1
  16. package/dist/cli/exit-codes.js +0 -1
  17. package/dist/cli/exit-codes.js.map +1 -1
  18. package/dist/cli/help.d.ts +2 -2
  19. package/dist/cli/help.d.ts.map +1 -1
  20. package/dist/cli/help.js +10 -2
  21. package/dist/cli/help.js.map +1 -1
  22. package/dist/cli/index.d.ts.map +1 -1
  23. package/dist/cli/index.js +3 -1
  24. package/dist/cli/index.js.map +1 -1
  25. package/dist/cli/inspect.d.ts.map +1 -1
  26. package/dist/cli/inspect.js +84 -265
  27. package/dist/cli/inspect.js.map +1 -1
  28. package/dist/cli/plan-turn.d.ts +68 -0
  29. package/dist/cli/plan-turn.d.ts.map +1 -0
  30. package/dist/cli/plan-turn.js +268 -0
  31. package/dist/cli/plan-turn.js.map +1 -0
  32. package/dist/cli/refuse.d.ts.map +1 -1
  33. package/dist/cli/refuse.js +2 -1
  34. package/dist/cli/refuse.js.map +1 -1
  35. package/dist/cli/resolve-harness.d.ts.map +1 -1
  36. package/dist/cli/resolve-harness.js +2 -3
  37. package/dist/cli/resolve-harness.js.map +1 -1
  38. package/dist/cli/run.d.ts.map +1 -1
  39. package/dist/cli/run.js +33 -367
  40. package/dist/cli/run.js.map +1 -1
  41. package/dist/cli/session-json.d.ts +1 -1
  42. package/dist/cli/session-json.d.ts.map +1 -1
  43. package/dist/cli/session-json.js +1 -3
  44. package/dist/cli/session-json.js.map +1 -1
  45. package/dist/cli/session.d.ts.map +1 -1
  46. package/dist/cli/session.js +35 -43
  47. package/dist/cli/session.js.map +1 -1
  48. package/dist/cli/skills-root.d.ts +1 -1
  49. package/dist/cli/skills-root.d.ts.map +1 -1
  50. package/dist/cli/skills-root.js +1 -1
  51. package/dist/cli/skills-root.js.map +1 -1
  52. package/dist/execution/decode.d.ts.map +1 -1
  53. package/dist/execution/decode.js +11 -36
  54. package/dist/execution/decode.js.map +1 -1
  55. package/dist/execution/events.d.ts +4 -1
  56. package/dist/execution/events.d.ts.map +1 -1
  57. package/dist/execution/events.js.map +1 -1
  58. package/dist/execution/failure.d.ts +3 -1
  59. package/dist/execution/failure.d.ts.map +1 -1
  60. package/dist/execution/failure.js +14 -11
  61. package/dist/execution/failure.js.map +1 -1
  62. package/dist/execution/open-session.d.ts +1 -1
  63. package/dist/execution/open-session.d.ts.map +1 -1
  64. package/dist/execution/open-session.js +150 -223
  65. package/dist/execution/open-session.js.map +1 -1
  66. package/dist/execution/stream-turn.d.ts +9 -14
  67. package/dist/execution/stream-turn.d.ts.map +1 -1
  68. package/dist/execution/stream-turn.js +85 -188
  69. package/dist/execution/stream-turn.js.map +1 -1
  70. package/dist/execution/supervisor.d.ts +71 -0
  71. package/dist/execution/supervisor.d.ts.map +1 -0
  72. package/dist/execution/supervisor.js +127 -0
  73. package/dist/execution/supervisor.js.map +1 -0
  74. package/dist/interpretation/argv.d.ts +39 -12
  75. package/dist/interpretation/argv.d.ts.map +1 -1
  76. package/dist/interpretation/argv.js +28 -20
  77. package/dist/interpretation/argv.js.map +1 -1
  78. package/dist/interpretation/content.d.ts +10 -1
  79. package/dist/interpretation/content.d.ts.map +1 -1
  80. package/dist/interpretation/content.js +20 -11
  81. package/dist/interpretation/content.js.map +1 -1
  82. package/dist/interpretation/hints.d.ts +1 -1
  83. package/dist/interpretation/hints.d.ts.map +1 -1
  84. package/dist/interpretation/hints.js +10 -11
  85. package/dist/interpretation/hints.js.map +1 -1
  86. package/dist/interpretation/index.d.ts +0 -1
  87. package/dist/interpretation/index.d.ts.map +1 -1
  88. package/dist/interpretation/index.js +0 -1
  89. package/dist/interpretation/index.js.map +1 -1
  90. package/dist/interpretation/isolation.d.ts +10 -0
  91. package/dist/interpretation/isolation.d.ts.map +1 -0
  92. package/dist/interpretation/isolation.js +30 -0
  93. package/dist/interpretation/isolation.js.map +1 -0
  94. package/dist/interpretation/limits.d.ts +2 -5
  95. package/dist/interpretation/limits.d.ts.map +1 -1
  96. package/dist/interpretation/limits.js +8 -62
  97. package/dist/interpretation/limits.js.map +1 -1
  98. package/dist/interpretation/question.d.ts +27 -0
  99. package/dist/interpretation/question.d.ts.map +1 -1
  100. package/dist/interpretation/question.js +28 -0
  101. package/dist/interpretation/question.js.map +1 -1
  102. package/dist/interpretation/refusal.d.ts +10 -5
  103. package/dist/interpretation/refusal.d.ts.map +1 -1
  104. package/dist/interpretation/refusal.js +24 -15
  105. package/dist/interpretation/refusal.js.map +1 -1
  106. package/dist/interpretation/resolve-options.d.ts +36 -2
  107. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  108. package/dist/interpretation/resolve-options.js +53 -22
  109. package/dist/interpretation/resolve-options.js.map +1 -1
  110. package/dist/interpretation/session-input.d.ts +44 -1
  111. package/dist/interpretation/session-input.d.ts.map +1 -1
  112. package/dist/interpretation/session-input.js +59 -4
  113. package/dist/interpretation/session-input.js.map +1 -1
  114. package/dist/interpretation/shape.d.ts +1 -0
  115. package/dist/interpretation/shape.d.ts.map +1 -1
  116. package/dist/interpretation/shape.js +10 -0
  117. package/dist/interpretation/shape.js.map +1 -1
  118. package/dist/interpretation/skills-selection.d.ts +25 -33
  119. package/dist/interpretation/skills-selection.d.ts.map +1 -1
  120. package/dist/interpretation/skills-selection.js +41 -48
  121. package/dist/interpretation/skills-selection.js.map +1 -1
  122. package/dist/interpretation/support.d.ts.map +1 -1
  123. package/dist/interpretation/support.js +25 -8
  124. package/dist/interpretation/support.js.map +1 -1
  125. package/dist/interpretation/tool-selection.d.ts +7 -6
  126. package/dist/interpretation/tool-selection.d.ts.map +1 -1
  127. package/dist/interpretation/tool-selection.js +54 -116
  128. package/dist/interpretation/tool-selection.js.map +1 -1
  129. package/dist/interpretation/tool-vocabulary.d.ts +31 -12
  130. package/dist/interpretation/tool-vocabulary.d.ts.map +1 -1
  131. package/dist/interpretation/tool-vocabulary.js +19 -41
  132. package/dist/interpretation/tool-vocabulary.js.map +1 -1
  133. package/dist/interpretation/turn-options.d.ts +4 -0
  134. package/dist/interpretation/turn-options.d.ts.map +1 -1
  135. package/dist/interpretation/turn-options.js +107 -241
  136. package/dist/interpretation/turn-options.js.map +1 -1
  137. package/dist/interpretation/vocabulary.d.ts +8 -6
  138. package/dist/interpretation/vocabulary.d.ts.map +1 -1
  139. package/dist/interpretation/vocabulary.js +22 -9
  140. package/dist/interpretation/vocabulary.js.map +1 -1
  141. package/dist/knowledge/claude-code.d.ts +3 -1
  142. package/dist/knowledge/claude-code.d.ts.map +1 -1
  143. package/dist/knowledge/claude-code.js +17 -4
  144. package/dist/knowledge/claude-code.js.map +1 -1
  145. package/dist/knowledge/codex.d.ts.map +1 -1
  146. package/dist/knowledge/codex.js +31 -7
  147. package/dist/knowledge/codex.js.map +1 -1
  148. package/dist/knowledge/descriptor.d.ts +67 -36
  149. package/dist/knowledge/descriptor.d.ts.map +1 -1
  150. package/dist/knowledge/descriptor.js +50 -15
  151. package/dist/knowledge/descriptor.js.map +1 -1
  152. package/dist/knowledge/matchers.d.ts +12 -0
  153. package/dist/knowledge/matchers.d.ts.map +1 -1
  154. package/dist/knowledge/matchers.js +34 -0
  155. package/dist/knowledge/matchers.js.map +1 -1
  156. package/dist/knowledge/muse.d.ts.map +1 -1
  157. package/dist/knowledge/muse.js +13 -6
  158. package/dist/knowledge/muse.js.map +1 -1
  159. package/dist/knowledge/overrides.d.ts.map +1 -1
  160. package/dist/knowledge/overrides.js +9 -20
  161. package/dist/knowledge/overrides.js.map +1 -1
  162. package/dist/knowledge/pi.d.ts.map +1 -1
  163. package/dist/knowledge/pi.js +8 -6
  164. package/dist/knowledge/pi.js.map +1 -1
  165. package/dist/knowledge/profile.d.ts +1 -0
  166. package/dist/knowledge/profile.d.ts.map +1 -1
  167. package/dist/knowledge/profile.js +1 -0
  168. package/dist/knowledge/profile.js.map +1 -1
  169. package/package.json +1 -1
  170. package/src/cli/args.ts +39 -27
  171. package/src/cli/check.ts +10 -3
  172. package/src/cli/config.ts +14 -11
  173. package/src/cli/exit-codes.ts +0 -2
  174. package/src/cli/help.ts +10 -2
  175. package/src/cli/index.ts +3 -1
  176. package/src/cli/inspect.ts +88 -269
  177. package/src/cli/plan-turn.ts +350 -0
  178. package/src/cli/refuse.ts +2 -1
  179. package/src/cli/resolve-harness.ts +4 -5
  180. package/src/cli/run.ts +36 -399
  181. package/src/cli/session-json.ts +1 -6
  182. package/src/cli/session.ts +51 -44
  183. package/src/cli/skills-root.ts +1 -4
  184. package/src/execution/decode.ts +10 -36
  185. package/src/execution/events.ts +4 -1
  186. package/src/execution/failure.ts +18 -11
  187. package/src/execution/open-session.ts +154 -228
  188. package/src/execution/stream-turn.ts +90 -193
  189. package/src/execution/supervisor.ts +199 -0
  190. package/src/interpretation/argv.ts +65 -34
  191. package/src/interpretation/content.ts +30 -13
  192. package/src/interpretation/hints.ts +2 -12
  193. package/src/interpretation/index.ts +0 -1
  194. package/src/interpretation/isolation.ts +39 -0
  195. package/src/interpretation/limits.ts +14 -75
  196. package/src/interpretation/question.ts +46 -0
  197. package/src/interpretation/refusal.ts +28 -18
  198. package/src/interpretation/resolve-options.ts +98 -33
  199. package/src/interpretation/session-input.ts +88 -5
  200. package/src/interpretation/shape.ts +10 -0
  201. package/src/interpretation/skills-selection.ts +70 -74
  202. package/src/interpretation/support.ts +21 -6
  203. package/src/interpretation/tool-selection.ts +67 -122
  204. package/src/interpretation/tool-vocabulary.ts +48 -53
  205. package/src/interpretation/turn-options.ts +125 -220
  206. package/src/interpretation/vocabulary.ts +15 -10
  207. package/src/knowledge/claude-code.ts +17 -4
  208. package/src/knowledge/codex.ts +31 -7
  209. package/src/knowledge/descriptor.ts +106 -49
  210. package/src/knowledge/matchers.ts +35 -0
  211. package/src/knowledge/muse.ts +13 -6
  212. package/src/knowledge/overrides.ts +17 -32
  213. package/src/knowledge/pi.ts +8 -6
  214. package/src/knowledge/profile.ts +1 -0
  215. package/dist/interpretation/dimensions.d.ts +0 -11
  216. package/dist/interpretation/dimensions.d.ts.map +0 -1
  217. package/dist/interpretation/dimensions.js +0 -4
  218. package/dist/interpretation/dimensions.js.map +0 -1
  219. package/src/interpretation/dimensions.ts +0 -14
@@ -4,24 +4,47 @@
4
4
  * prompt before tool grants) and the spawn-boundary refusals live here so
5
5
  * no caller re-derives them.
6
6
  */
7
- import type { HarnessDescriptor, StreamingGranularity } from "../knowledge/descriptor.js";
7
+ import type {
8
+ AccessValue,
9
+ HarnessDescriptor,
10
+ StreamingGranularity,
11
+ } from "../knowledge/descriptor.js";
8
12
  import { defaultDescriptors } from "../knowledge/overrides.js";
13
+ import { assertIsolationCombination } from "./isolation.js";
9
14
  import { ArgvRefusalError } from "./refusal.js";
10
15
  import { assertUsableSessionId, SESSION_ID_MAX, SessionIdRefusalError } from "./session-id.js";
11
- import { renderSkillsSelection } from "./skills-selection.js";
16
+ import { renderSkillsSelection, type SkillsSelection } from "./skills-selection.js";
12
17
  import { supportedBy } from "./support.js";
13
18
  import { renderToolSelection } from "./tool-selection.js";
19
+ import type { ToolMap } from "./tool-vocabulary.js";
14
20
  import { renderTurnOptions } from "./turn-options.js";
15
21
  import { validateModel } from "./vocabulary.js";
16
22
 
17
23
  export type { RefusalIssue } from "./refusal.js";
18
24
  export { ArgvRefusalError, buildRefusalMessage, REFUSAL_ISSUES } from "./refusal.js";
19
25
 
20
- /** One guard for every builder that places a positional prompt. Selector
26
+ /** A prompt carries its own provenance (RFC-02 change 13): a plain string
27
+ * is an implicit, positional prompt; the object form came from an explicit
28
+ * flag or file, so a leading dash is the caller's intent, not a flag. */
29
+ export type Prompt = string | { readonly text: string; readonly explicit: boolean };
30
+
31
+ /** The one accessor for the prompt's text, for builders, the runner, and
32
+ * redaction alike. */
33
+ export const promptTextOf = (opts: { readonly prompt: Prompt }): string =>
34
+ typeof opts.prompt === "string" ? opts.prompt : opts.prompt.text;
35
+
36
+ /** The same prompt with new text: the composed form keeps its provenance. */
37
+ export const withPromptText = (prompt: Prompt, text: string): Prompt =>
38
+ typeof prompt === "string" ? text : { ...prompt, text };
39
+
40
+ /** One guard for every builder that places a positional prompt: an
41
+ * implicit prompt may not start with '-' (it would be parsed as a flag),
42
+ * while an explicit one - `hcn run --prompt "-bad"` - passes. Selector
21
43
  * hygiene (session ids) lives in session-id.ts; model selectors go through
22
44
  * validateModel - both refuse, never sanitize. */
23
- const assertCleanPrompt = (h: HarnessDescriptor, prompt: string): void => {
24
- if (prompt.startsWith("-")) {
45
+ const assertCleanPrompt = (h: HarnessDescriptor, prompt: Prompt): void => {
46
+ if (typeof prompt !== "string" && prompt.explicit) return;
47
+ if (promptTextOf({ prompt }).startsWith("-")) {
25
48
  throw new ArgvRefusalError({
26
49
  issue: "prompt-flag-injection",
27
50
  harness: h.name,
@@ -31,19 +54,6 @@ const assertCleanPrompt = (h: HarnessDescriptor, prompt: string): void => {
31
54
  }
32
55
  };
33
56
 
34
- /**
35
- * Variant that allows a leading '-' when the caller explicitly opted in via
36
- * --prompt / --prompt-file. The positional guard still applies for implicit
37
- * positional prompts, but an explicit opt-in bypasses it so `hcn run --prompt "-bad"`
38
- * succeeds while `hcn run "-bad"` refuses. The caller must set
39
- * `__explicitPrompt: true` on the options object when the prompt came from an
40
- * explicit flag.
41
- */
42
- const assertCleanPromptMaybe = (h: HarnessDescriptor, prompt: string, explicit?: boolean): void => {
43
- if (explicit) return;
44
- assertCleanPrompt(h, prompt);
45
- };
46
-
47
57
  export interface DiscoveryOptions {
48
58
  readonly tools?: boolean;
49
59
  readonly instructionFiles?: boolean;
@@ -52,18 +62,20 @@ export interface DiscoveryOptions {
52
62
  }
53
63
 
54
64
  export interface TurnOptions {
55
- readonly prompt: string;
65
+ readonly isolation?: "tool-free";
66
+ readonly prompt: Prompt;
56
67
  readonly tools?: readonly string[];
57
68
  readonly excludeTools?: readonly string[];
58
- /** Caller-directed skills allowlist: resolved absolute paths, one per
59
- * skill. Rendering: pi loads each via --skill with discovery off;
60
- * claude turns off the complement via skillOverrides settings; codex
61
- * and muse refuse (structural - no per-skill surface). */
62
- readonly skills?: readonly string[];
69
+ /** Caller-directed skills allowlist: the resolved picks and the
70
+ * registry's known names. Rendered per descriptor by
71
+ * renderSkillsSelection (pi loads each pick with discovery off; claude
72
+ * and codex turn the complement off); muse refuses. */
73
+ readonly skills?: SkillsSelection;
63
74
  readonly model?: string;
64
75
  readonly autonomy?: boolean;
65
76
  readonly effort?: string;
66
77
  readonly sandbox?: string;
78
+ readonly contextWindow?: number;
67
79
  readonly provider?: string;
68
80
  readonly discovery?: DiscoveryOptions;
69
81
  readonly write?: boolean;
@@ -81,11 +93,10 @@ export interface TurnOptions {
81
93
  * harness argv; the CLI layer turns it into the prompt preamble and
82
94
  * arms question-block detection. Undefined means the default: "ask". */
83
95
  readonly questions?: import("./question.js").QuestionMode;
84
- /** Internal: set by CLI when prompt came from --prompt/--prompt-file to bypass leading '-' guard */
85
- readonly __explicitPrompt?: boolean;
86
- /** toolMap extensible vocabulary per harness (issue toolMap) */
87
- readonly toolMap?: Readonly<Record<string, Readonly<Record<string, string>>>>;
88
- readonly access?: "read" | "write";
96
+ /** The merged toolMap (every harness, native plus tier), the one shape
97
+ * past option resolution (RFC-02 change 8). */
98
+ readonly toolMap?: ToolMap;
99
+ readonly access?: AccessValue;
89
100
  }
90
101
 
91
102
  export interface ResumeOptions extends TurnOptions {
@@ -98,8 +109,8 @@ export type LaunchOptions = TurnOptions;
98
109
  * validated selections, with the variadic tools flag LAST and fed exactly
99
110
  * one joined token so nothing after it can be swallowed as a tool name. */
100
111
  const turnTail = (h: HarnessDescriptor, opts: TurnOptions): string[] => {
101
- assertCleanPromptMaybe(h, opts.prompt, opts.__explicitPrompt);
102
- const tail = [opts.prompt, ...h.launch.streamFlags];
112
+ assertCleanPrompt(h, opts.prompt);
113
+ const tail = [promptTextOf(opts), ...h.launch.streamFlags];
103
114
  if (opts.model !== undefined) {
104
115
  const validated = validateModel(h, opts.model);
105
116
  if (!validated.ok) {
@@ -127,15 +138,14 @@ const turnTail = (h: HarnessDescriptor, opts: TurnOptions): string[] => {
127
138
  tail.push(h.autonomy.flag);
128
139
  }
129
140
  if (opts.tools !== undefined || opts.excludeTools !== undefined) {
130
- const perHarnessMap = opts.toolMap?.[h.name];
131
141
  const rendered = renderToolSelection(h, {
132
142
  include: opts.tools,
133
143
  exclude: opts.excludeTools,
134
- toolMap: perHarnessMap,
144
+ toolMap: opts.toolMap,
135
145
  });
136
146
  tail.push(...rendered.tokens);
137
147
  }
138
- if (opts.skills !== undefined && opts.skills.length > 0) {
148
+ if (opts.skills !== undefined) {
139
149
  tail.push(...renderSkillsSelection(h, opts.skills));
140
150
  }
141
151
  return tail;
@@ -186,6 +196,27 @@ export const buildResumeArgv = (h: HarnessDescriptor, opts: ResumeOptions): stri
186
196
  ];
187
197
  };
188
198
 
199
+ /** What a spawn needs beyond the turn options: the session to resume, if
200
+ * any, and the raw passthrough tail (ADR 0003). */
201
+ export interface SpawnArgvOptions extends TurnOptions {
202
+ readonly resume?: string;
203
+ readonly passthrough?: readonly string[];
204
+ }
205
+
206
+ /** The argv a turn spawns: launch or resume per `resume`, then the
207
+ * passthrough tail after a bare separator. One owner, so the CLI's preview
208
+ * and the runner's spawn agree by construction (RFC-02 change 10). */
209
+ export const buildSpawnArgv = (h: HarnessDescriptor, opts: SpawnArgvOptions): string[] => {
210
+ assertIsolationCombination(h, opts);
211
+ const base =
212
+ opts.resume === undefined
213
+ ? buildLaunchArgv(h, opts)
214
+ : buildResumeArgv(h, { ...opts, sessionId: opts.resume });
215
+ return opts.passthrough !== undefined && opts.passthrough.length > 0
216
+ ? [...base, "--", ...opts.passthrough]
217
+ : base;
218
+ };
219
+
189
220
  export interface SessionOptions {
190
221
  readonly sessionId: string;
191
222
  readonly model?: string;
@@ -10,8 +10,8 @@
10
10
  * payload-discriminated records - for the flat match/field spec that
11
11
  * identity decoding uses.)
12
12
  */
13
- import type { HarnessName } from "../knowledge/descriptor.js";
14
- import { asRecord } from "./shape.js";
13
+ import type { HarnessName, LimitCode } from "../knowledge/descriptor.js";
14
+ import { asRecord, readPath as at } from "./shape.js";
15
15
 
16
16
  export type ContentEvent =
17
17
  | { readonly kind: "token"; readonly text: string }
@@ -22,7 +22,16 @@ export type ContentEvent =
22
22
  * record); the runner turns it into a task failure. Other errors are
23
23
  * informational and the turn goes on. */
24
24
  | { readonly kind: "error"; readonly message: string; readonly terminal?: boolean }
25
- | { readonly kind: "budget"; readonly detail: string };
25
+ | { readonly kind: "budget"; readonly detail: string }
26
+ /** A structured limit record on the stream (claude's rate_limit_event);
27
+ * the runner turns it into a limit-class failure. `resetsAt` is epoch
28
+ * milliseconds when the harness reported one. */
29
+ | {
30
+ readonly kind: "limit";
31
+ readonly code: LimitCode;
32
+ readonly detail: string;
33
+ readonly resetsAt?: number;
34
+ };
26
35
 
27
36
  /** Text of an array of `{type:"text", text}` content blocks. */
28
37
  const textOfBlocks = (content: unknown): string =>
@@ -34,16 +43,6 @@ const textOfBlocks = (content: unknown): string =>
34
43
  .join("")
35
44
  : "";
36
45
 
37
- const at = (record: Record<string, unknown>, path: string): unknown => {
38
- let cursor: unknown = record;
39
- for (const seg of path.split(".")) {
40
- const inner = asRecord(cursor);
41
- if (inner === null) return undefined;
42
- cursor = inner[seg];
43
- }
44
- return cursor;
45
- };
46
-
47
46
  const claude = (r: Record<string, unknown>): ContentEvent[] => {
48
47
  const events: ContentEvent[] = [];
49
48
  if (r.type === "assistant") {
@@ -75,6 +74,24 @@ const claude = (r: Record<string, unknown>): ContentEvent[] => {
75
74
  // clean turn. (openSession handles result boundaries itself.)
76
75
  const sub = typeof r.subtype === "string" ? r.subtype : "result error";
77
76
  events.push({ kind: "error", message: `turn failed: ${sub}`, terminal: true });
77
+ } else if (r.type === "rate_limit_event") {
78
+ // Only "rejected" is a limit; "allowed_warning" still serves the request.
79
+ // overageStatus is a separate billing signal, not a rate limit.
80
+ // resetsAt arrives in seconds; the event carries milliseconds. No wall
81
+ // clock is read.
82
+ const info = asRecord(r.rate_limit_info);
83
+ const status = info?.status;
84
+ if (status === "rejected") {
85
+ const raw = info?.resetsAt;
86
+ const milliseconds = typeof raw === "number" ? raw * 1000 : Number.NaN;
87
+ const resetsAt = Number.isFinite(milliseconds) && milliseconds > 0 ? milliseconds : undefined;
88
+ events.push({
89
+ kind: "limit",
90
+ code: "rate-limit",
91
+ detail: `rate_limit_event status=${String(status)}`,
92
+ ...(resetsAt !== undefined ? { resetsAt } : {}),
93
+ });
94
+ }
78
95
  }
79
96
  return events;
80
97
  };
@@ -6,10 +6,10 @@
6
6
  * stay-on-harness suggestion first. Wording is locked verbatim; unit tests
7
7
  * pin every string.
8
8
  */
9
- import type { HarnessDescriptor } from "../knowledge/descriptor.js";
9
+ import { deepFreeze, type HarnessDescriptor } from "../knowledge/descriptor.js";
10
10
  import type { RefusalOption } from "./refusal.js";
11
11
 
12
- const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFreezeSafe({
12
+ const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFreeze({
13
13
  claude: {
14
14
  sandbox:
15
15
  "claude has no sandbox modes; approximate with a per-tool allowlist (--tools Read,Bash) or --disallowedTools to keep tools out, and run untrusted work in a disposable directory or container",
@@ -84,16 +84,6 @@ const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFr
84
84
  },
85
85
  });
86
86
 
87
- /** deepFreeze without importing the descriptor's (which carries extra
88
- * machinery); structure is plain JSON so Object.freeze all the way down. */
89
- function deepFreezeSafe<T>(value: T): T {
90
- if (typeof value === "object" && value !== null) {
91
- for (const v of Object.values(value as Record<string, unknown>)) deepFreezeSafe(v);
92
- Object.freeze(value);
93
- }
94
- return value;
95
- }
96
-
97
87
  /** The confirmed hint for a refused option on a harness, or undefined. */
98
88
  export const hintFor = (harness: string, option: RefusalOption): string | undefined =>
99
89
  HINTS[harness]?.[option];
@@ -10,7 +10,6 @@
10
10
  export * from "./argv.js";
11
11
  export * from "./capabilities.js";
12
12
  export * from "./context.js";
13
- export * from "./dimensions.js";
14
13
  export * from "./identity.js";
15
14
  export * from "./limits.js";
16
15
  export * from "./parse-resume.js";
@@ -0,0 +1,39 @@
1
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
2
+ import type { TurnOptions } from "./argv.js";
3
+ import { ArgvRefusalError } from "./refusal.js";
4
+
5
+ /** Isolation owns these dimensions for the whole invocation. Config defaults
6
+ * yield to it; explicit competing selections refuse before registry reads. */
7
+ export const ISOLATION_OVERRIDES = [
8
+ "tools",
9
+ "excludeTools",
10
+ "skills",
11
+ "access",
12
+ "autonomy",
13
+ "discovery",
14
+ ] as const;
15
+ export const assertIsolationCombination = (
16
+ h: HarnessDescriptor,
17
+ opts: Partial<TurnOptions> & {
18
+ readonly skillNames?: readonly string[];
19
+ readonly passthrough?: readonly string[];
20
+ },
21
+ ): void => {
22
+ if (opts.isolation === undefined) return;
23
+ const conflict =
24
+ ISOLATION_OVERRIDES.find((key) =>
25
+ key === "autonomy" ? opts[key] === true : opts[key] !== undefined,
26
+ ) ??
27
+ (opts.skillNames !== undefined ? "skills" : undefined) ??
28
+ (opts.passthrough?.length ? "native passthrough" : undefined);
29
+ if (conflict === undefined) return;
30
+ throw new ArgvRefusalError({
31
+ issue: "mutually-exclusive-options",
32
+ harness: h.name,
33
+ option: "isolation",
34
+ detail: conflict,
35
+ supported: [
36
+ "tool-free without explicit tools, skills, access, autonomy, discovery, or native passthrough",
37
+ ],
38
+ });
39
+ };
@@ -25,18 +25,15 @@ import type {
25
25
  LimitMatcher,
26
26
  PhraseMatcher,
27
27
  } from "../knowledge/descriptor.js";
28
- import { SHARED_TRANSPORT_MATCHERS, SHARED_UNAVAILABLE_MATCHERS } from "../knowledge/matchers.js";
29
-
30
- /** Bottom-up batch scans stop after this many non-empty lines: the wall is
31
- * virtually always the last thing a dying turn printed, and an unbounded
32
- * scan over an accumulating session buffer is O(turns x output). */
33
- const BATCH_SCAN_MAX_LINES = 200;
34
-
35
- /** Max pattern length and max matchers per harness per kind - load-bearing
36
- * bounds that prevent a crafted override file from DoS'ing the matcher
37
- * compiler or the scanner. */
38
- const MAX_PATTERN_LENGTH = 200;
39
- const MAX_MATCHERS_PER_KIND = 64;
28
+ import {
29
+ compileMatcher,
30
+ MAX_MATCHERS_PER_KIND,
31
+ SHARED_TRANSPORT_MATCHERS,
32
+ SHARED_UNAVAILABLE_MATCHERS,
33
+ } from "../knowledge/matchers.js";
34
+
35
+ /** The pattern bounds live with the matchers in the knowledge layer
36
+ * (compileMatcher); this is the per-line input window. */
40
37
  const WINDOW = 4096;
41
38
 
42
39
  // WeakMap cache: same matcher array instance reuses identical RegExp objects
@@ -49,24 +46,6 @@ const authCache = new WeakMap<
49
46
  ReadonlyArray<readonly [RegExp, AuthFailureKind]>
50
47
  >();
51
48
 
52
- const validateAndCompile = (pattern: string, flags: string | undefined): RegExp => {
53
- const f = flags ?? "i";
54
- if (pattern.length > MAX_PATTERN_LENGTH) {
55
- throw new Error(`pattern over ${MAX_PATTERN_LENGTH} characters`);
56
- }
57
- if (f.includes("g") || f.includes("y")) {
58
- throw new Error(`flags must not contain g or y (got ${JSON.stringify(f)})`);
59
- }
60
- for (const ch of f) {
61
- if (!"imsu".includes(ch)) throw new Error(`flag ${JSON.stringify(ch)} outside imsu`);
62
- }
63
- try {
64
- return new RegExp(pattern, f);
65
- } catch (e) {
66
- throw new Error(`uncompilable pattern ${JSON.stringify(pattern)}: ${(e as Error).message}`);
67
- }
68
- };
69
-
70
49
  export const compileLimitMatchers = (
71
50
  matchers: ReadonlyArray<LimitMatcher>,
72
51
  ): ReadonlyArray<readonly [RegExp, LimitCode]> => {
@@ -75,7 +54,7 @@ export const compileLimitMatchers = (
75
54
  if (matchers.length > MAX_MATCHERS_PER_KIND) {
76
55
  throw new Error(`more than ${MAX_MATCHERS_PER_KIND} matchers per harness per kind`);
77
56
  }
78
- const compiled = matchers.map((m) => [validateAndCompile(m.pattern, m.flags), m.code] as const);
57
+ const compiled = matchers.map((m) => [compileMatcher(m.pattern, m.flags), m.code] as const);
79
58
  limitCache.set(matchers, compiled);
80
59
  return compiled;
81
60
  };
@@ -88,27 +67,11 @@ export const compileAuthMatchers = (
88
67
  if (matchers.length > MAX_MATCHERS_PER_KIND) {
89
68
  throw new Error(`more than ${MAX_MATCHERS_PER_KIND} matchers per harness per kind`);
90
69
  }
91
- const compiled = matchers.map((m) => [validateAndCompile(m.pattern, m.flags), m.kind] as const);
70
+ const compiled = matchers.map((m) => [compileMatcher(m.pattern, m.flags), m.kind] as const);
92
71
  authCache.set(matchers, compiled);
93
72
  return compiled;
94
73
  };
95
74
 
96
- // Generic alias for tests that call compileMatchers directly
97
- export const compileMatchers = <T extends LimitMatcher | AuthMatcher>(
98
- matchers: ReadonlyArray<T>,
99
- ): ReadonlyArray<readonly [RegExp, unknown]> => {
100
- // Dispatch based on first element's shape - limit has code, auth has kind
101
- if (matchers.length === 0) return [];
102
- const first = matchers[0] as unknown as Record<string, unknown>;
103
- if ("code" in first)
104
- return compileLimitMatchers(
105
- matchers as unknown as ReadonlyArray<LimitMatcher>,
106
- ) as unknown as ReadonlyArray<readonly [RegExp, unknown]>;
107
- return compileAuthMatchers(
108
- matchers as unknown as ReadonlyArray<AuthMatcher>,
109
- ) as unknown as ReadonlyArray<readonly [RegExp, unknown]>;
110
- };
111
-
112
75
  const scanLine = <Code>(
113
76
  line: string,
114
77
  matchers: ReadonlyArray<readonly [RegExp, Code]>,
@@ -121,40 +84,16 @@ const scanLine = <Code>(
121
84
  return null;
122
85
  };
123
86
 
124
- const scanTail = <Code>(
125
- output: string,
126
- matchers: ReadonlyArray<readonly [RegExp, Code]>,
127
- ): Code | null => {
128
- let end = output.length;
129
- let scanned = 0;
130
- while (end > 0 && scanned < BATCH_SCAN_MAX_LINES) {
131
- const start = output.lastIndexOf("\n", end - 1);
132
- const line = output.slice(start + 1, end).trim();
133
- end = start;
134
- if (line === "") continue;
135
- scanned++;
136
- const code = scanLine(line, matchers);
137
- if (code !== null) return code;
138
- }
139
- return null;
140
- };
141
-
142
- /** Per-line entry point for streaming readers: O(1) per line, no rescans. */
87
+ /** Per-line entry point for streaming readers: O(1) per line, no rescans.
88
+ * Both runners feed lines as they arrive; there is no batch form. */
143
89
  export const detectLimitInLine = (h: HarnessDescriptor, line: string): LimitCode | null =>
144
90
  scanLine(line.trim(), compileLimitMatchers(h.limitMatchers));
145
91
 
146
- /** Batch convenience over a turn's tail, bounded and bottom-up. */
147
- export const detectLimit = (h: HarnessDescriptor, output: string): LimitCode | null =>
148
- scanTail(output, compileLimitMatchers(h.limitMatchers));
149
-
150
92
  export const detectAuthFailureInLine = (
151
93
  h: HarnessDescriptor,
152
94
  line: string,
153
95
  ): AuthFailureKind | null => scanLine(line.trim(), compileAuthMatchers(h.authMatchers));
154
96
 
155
- export const detectAuthFailure = (h: HarnessDescriptor, output: string): AuthFailureKind | null =>
156
- scanTail(output, compileAuthMatchers(h.authMatchers));
157
-
158
97
  const phraseCache = new WeakMap<ReadonlyArray<PhraseMatcher>, ReadonlyArray<RegExp>>();
159
98
 
160
99
  const compilePhraseMatchers = (matchers: ReadonlyArray<PhraseMatcher>): ReadonlyArray<RegExp> => {
@@ -163,7 +102,7 @@ const compilePhraseMatchers = (matchers: ReadonlyArray<PhraseMatcher>): Readonly
163
102
  if (matchers.length > MAX_MATCHERS_PER_KIND) {
164
103
  throw new Error(`more than ${MAX_MATCHERS_PER_KIND} matchers per harness per kind`);
165
104
  }
166
- const compiled = matchers.map((m) => validateAndCompile(m.pattern, m.flags));
105
+ const compiled = matchers.map((m) => compileMatcher(m.pattern, m.flags));
167
106
  phraseCache.set(matchers, compiled);
168
107
  return compiled;
169
108
  };
@@ -47,6 +47,12 @@ If and only if a genuine decision you cannot make defensibly blocks correct prog
47
47
 
48
48
  Say nothing after the block and stop generating. The caller's user will answer, and the answer arrives as the next user message in this session - continue from it. For every choice you can make defensibly yourself, do not ask - decide, act, and state the decision you made.`;
49
49
 
50
+ /** The wrapper hcn composes around the user's answer to an escalated
51
+ * question, so a consumer never re-derives it (RFC-01; the README quotes
52
+ * the exact text). One owner for both session surfaces. */
53
+ export const composeAnswer = (question: string, answer: string): string =>
54
+ `The user answered the question: "${question}" with: ${answer}. Continue accordingly.`;
55
+
50
56
  export type QuestionMode = "ask" | "assume" | "none";
51
57
 
52
58
  export const QUESTION_MODES: readonly QuestionMode[] = ["ask", "assume", "none"] as const;
@@ -152,6 +158,46 @@ const parseBlock = (body: string): QuestionDetection => {
152
158
  };
153
159
  };
154
160
 
161
+ /** What a turn's close found, recorded on `done` (RFC-01). */
162
+ export type QuestionDetectionKind = "block" | "malformed" | "none";
163
+
164
+ /** The typed question event a detected block becomes: the fields ARE the
165
+ * question; prose renders from them downstream. */
166
+ export interface QuestionEvent {
167
+ readonly kind: "question";
168
+ readonly question: string;
169
+ readonly options: readonly string[];
170
+ readonly recommended?: string;
171
+ }
172
+
173
+ /** The one place a detected block becomes a question event (RFC-02
174
+ * change 5). Both runners call it at turn close with the last assistant
175
+ * message: detection is armed only in `ask` mode; a malformed block names
176
+ * its malformation and is never a silent no-op. */
177
+ export const questionEventOf = (
178
+ lastAssistantText: string | null,
179
+ mode: QuestionMode,
180
+ ):
181
+ | { readonly detection: "none" }
182
+ | { readonly detection: "malformed"; readonly malformed: string }
183
+ | { readonly detection: "block"; readonly event: QuestionEvent } => {
184
+ if (mode !== "ask" || lastAssistantText === null) return { detection: "none" };
185
+ const detected = detectQuestionBlock(lastAssistantText);
186
+ if (detected === null) return { detection: "none" };
187
+ if ("malformed" in detected) return { detection: "malformed", malformed: detected.malformed };
188
+ return {
189
+ detection: "block",
190
+ event: {
191
+ kind: "question",
192
+ question: detected.block.question,
193
+ options: detected.block.options,
194
+ ...(detected.block.recommended !== undefined
195
+ ? { recommended: detected.block.recommended }
196
+ : {}),
197
+ },
198
+ };
199
+ };
200
+
155
201
  /** Detect the hcn-question block in a message text. The LAST block wins
156
202
  * (the protocol makes the block the turn's final content; a corrected
157
203
  * re-emit supersedes an earlier one). An empty candidate body (a bare
@@ -36,14 +36,18 @@ export type RefusalOption =
36
36
  | "skills"
37
37
  | "autonomy"
38
38
  | "questions"
39
+ | "timeout"
39
40
  | `discovery.${string}`;
40
41
 
41
42
  /** One helper builds the message from the structured fields so message and
42
43
  * fields cannot drift. Every message names an alternative, not only a
43
- * negation, so an agent can pivot without reading the descriptor. */
44
+ * negation, so an agent can pivot without reading the descriptor. A
45
+ * refusal raised before a harness is chosen (argument parsing, config)
46
+ * passes no harness, and the message carries no harness fragment (RFC-02
47
+ * change 11) instead of naming one the caller never picked. */
44
48
  export const buildRefusalMessage = (
45
49
  issue: RefusalIssue,
46
- harness: HarnessName,
50
+ harness: HarnessName | undefined,
47
51
  option?: RefusalOption,
48
52
  facet?: DiscoveryFacet,
49
53
  supported: readonly string[] = [],
@@ -58,42 +62,46 @@ export const buildRefusalMessage = (
58
62
  ? ` facet ${JSON.stringify(facet)}`
59
63
  : "";
60
64
  const detailSuffix = detail ? ` (${detail})` : "";
65
+ // The harness fragments: present only when a harness was chosen.
66
+ const who = harness ?? "the harness";
67
+ const onHarness = harness === undefined ? "" : ` on ${harness}`;
68
+ const forHarness = harness === undefined ? "" : ` for ${harness}`;
61
69
  switch (issue) {
62
70
  case "unsupported-option":
63
- return `${harness} cannot express${optionPart}${detailSuffix}; ${supportedStr} - drop the option or route this work to a harness that supports it`;
71
+ return `${who} cannot express${optionPart}${detailSuffix}; ${supportedStr} - drop the option or route this work to a harness that supports it`;
64
72
  case "unsupported-option-facet":
65
- return `${harness} cannot express discovery${facetSuffix}${detailSuffix}; ${supportedStr} - drop the facet or route this work to a harness that supports it`;
73
+ return `${who} cannot express discovery${facetSuffix}${detailSuffix}; ${supportedStr} - drop the facet or route this work to a harness that supports it`;
66
74
  case "unsupported-on-resume": {
67
75
  const name = option
68
76
  ? JSON.stringify(option) + (facet ? `:${facet}` : "")
69
77
  : facet
70
78
  ? JSON.stringify(facet)
71
79
  : "option";
72
- return `${name} cannot be expressed on resume for ${harness}${detailSuffix}; ${supportedStr} - re-launch instead of resuming or drop the option`;
80
+ return `${name} cannot be expressed on resume${forHarness}${detailSuffix}; ${supportedStr} - re-launch instead of resuming or drop the option`;
73
81
  }
74
82
  case "invalid-option-value":
75
- return `invalid value for${optionPart} on ${harness}${detailSuffix}; ${supportedStr} - use a supported value instead`;
83
+ return `invalid value for${optionPart}${onHarness}${detailSuffix}; ${supportedStr} - use a supported value instead`;
76
84
  case "unknown-effort":
77
- return `unknown effort for ${harness}${detail ? ` ${JSON.stringify(detail)}` : ""}; ${supportedStr} - use one of the ladder values`;
85
+ return `unknown effort${forHarness}${detail ? ` ${JSON.stringify(detail)}` : ""}; ${supportedStr} - use one of the ladder values`;
78
86
  case "unknown-model":
79
- return `unknown ${harness} model${detail ? ` ${JSON.stringify(detail)}` : ""}; ${supportedStr} - use one of the supported models`;
87
+ return `unknown ${harness === undefined ? "" : `${harness} `}model${detail ? ` ${JSON.stringify(detail)}` : ""}; ${supportedStr} - use one of the supported models`;
80
88
  case "invalid-env":
81
- return `invalid env key or value for ${harness}${detailSuffix}; ${supportedStr} - keys must match ^[A-Za-z_][A-Za-z0-9_]*$ and contain no NUL`;
89
+ return `invalid env key or value${forHarness}${detailSuffix}; ${supportedStr} - keys must match ^[A-Za-z_][A-Za-z0-9_]*$ and contain no NUL`;
82
90
  case "invalid-tool-grant":
83
- return `tool grant for ${harness} contains an empty entry or a comma; a blank tool flag value grants nothing detectable, and a comma inside one name silently splits the grant; ${supportedStr} - provide comma-free, non-empty tool names as separate entries`;
91
+ return `tool grant${forHarness} contains an empty entry or a comma; a blank tool flag value grants nothing detectable, and a comma inside one name silently splits the grant; ${supportedStr} - provide comma-free, non-empty tool names as separate entries`;
84
92
  case "unknown-tool-name":
85
- return `${harness} cannot compute a tool complement around an unknown name${detailSuffix}; ${supportedStr} - exclude only curated names, or pass the unknown name through an include list instead`;
93
+ return `${who} cannot compute a tool complement around an unknown name${detailSuffix}; ${supportedStr} - exclude only curated names, or pass the unknown name through an include list instead`;
86
94
  case "mutually-exclusive-options":
87
- return `${harness} cannot combine${optionPart}${detailSuffix}; ${supportedStr} - pass exactly one of them`;
95
+ return `${harness === undefined ? "cannot combine" : `${harness} cannot combine`}${optionPart}${detailSuffix}; ${supportedStr} - pass exactly one of them`;
88
96
  case "prompt-flag-injection":
89
- return `positional prompt may not start with '-'; it would be parsed as a flag by ${harness}${detailSuffix}; ${supportedStr} - remove leading '-' or prefix with a space`;
97
+ return `positional prompt may not start with '-'; it would be parsed as a flag by ${who}${detailSuffix}; ${supportedStr} - remove leading '-' or prefix with a space`;
90
98
  case "no-autonomy-mode":
91
- return `${harness} has no unattended-run flag; ${supportedStr} - drop autonomy or route to a supporting harness (claude --dangerously-skip-permissions, codex/muse --yolo)`;
99
+ return `${who} has no unattended-run flag; ${supportedStr} - drop autonomy or route to a supporting harness (claude --dangerously-skip-permissions, codex/muse --yolo)`;
92
100
  case "no-session-mode":
93
- return `${harness} declares no persistent headless session mode; ${supportedStr} - use hcn run --resume <id>`;
101
+ return `${who} declares no persistent headless session mode; ${supportedStr} - use hcn run --resume <id>`;
94
102
  default: {
95
103
  const exhaustive: never = issue;
96
- return `${exhaustive as string} for ${harness}${optionPart}${detailSuffix}; ${supportedStr}`;
104
+ return `${exhaustive as string}${forHarness}${optionPart}${detailSuffix}; ${supportedStr}`;
97
105
  }
98
106
  }
99
107
  };
@@ -101,7 +109,9 @@ export const buildRefusalMessage = (
101
109
  /** Raised when launch options would corrupt or subvert the spawned argv. */
102
110
  export class ArgvRefusalError extends Error {
103
111
  readonly issue: RefusalIssue;
104
- readonly harness: HarnessName;
112
+ /** The harness the refusal is about; absent when it was raised before
113
+ * one was chosen (argument parsing, config loading). */
114
+ readonly harness?: HarnessName;
105
115
  readonly option?: RefusalOption;
106
116
  readonly facet?: DiscoveryFacet;
107
117
  readonly supported: readonly string[];
@@ -115,7 +125,7 @@ export class ArgvRefusalError extends Error {
115
125
  readonly hint?: string;
116
126
  constructor(args: {
117
127
  readonly issue: RefusalIssue;
118
- readonly harness: HarnessName;
128
+ readonly harness?: HarnessName;
119
129
  readonly option?: RefusalOption;
120
130
  readonly facet?: DiscoveryFacet;
121
131
  readonly supported?: readonly string[];