@dungle-scrubs/harness-cli-normalizer 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +144 -7
  2. package/dist/cli/args.d.ts +74 -0
  3. package/dist/cli/args.d.ts.map +1 -0
  4. package/dist/cli/args.js +339 -0
  5. package/dist/cli/args.js.map +1 -0
  6. package/dist/cli/check.d.ts +2 -0
  7. package/dist/cli/check.d.ts.map +1 -0
  8. package/dist/cli/check.js +110 -0
  9. package/dist/cli/check.js.map +1 -0
  10. package/dist/cli/exit-codes.d.ts +7 -0
  11. package/dist/cli/exit-codes.d.ts.map +1 -0
  12. package/dist/cli/exit-codes.js +11 -0
  13. package/dist/cli/exit-codes.js.map +1 -0
  14. package/dist/cli/help.d.ts +7 -0
  15. package/dist/cli/help.d.ts.map +1 -0
  16. package/dist/cli/help.js +110 -0
  17. package/dist/cli/help.js.map +1 -0
  18. package/dist/cli/index.d.ts +4 -0
  19. package/dist/cli/index.d.ts.map +1 -0
  20. package/dist/cli/index.js +170 -0
  21. package/dist/cli/index.js.map +1 -0
  22. package/dist/cli/inspect.d.ts +3 -0
  23. package/dist/cli/inspect.d.ts.map +1 -0
  24. package/dist/cli/inspect.js +170 -0
  25. package/dist/cli/inspect.js.map +1 -0
  26. package/dist/cli/ls.d.ts +2 -0
  27. package/dist/cli/ls.d.ts.map +1 -0
  28. package/dist/cli/ls.js +10 -0
  29. package/dist/cli/ls.js.map +1 -0
  30. package/dist/cli/render.d.ts +8 -0
  31. package/dist/cli/render.d.ts.map +1 -0
  32. package/dist/cli/render.js +55 -0
  33. package/dist/cli/render.js.map +1 -0
  34. package/dist/cli/resolve-harness.d.ts +4 -0
  35. package/dist/cli/resolve-harness.d.ts.map +1 -0
  36. package/dist/cli/resolve-harness.js +31 -0
  37. package/dist/cli/resolve-harness.js.map +1 -0
  38. package/dist/cli/run.d.ts +2 -0
  39. package/dist/cli/run.d.ts.map +1 -0
  40. package/dist/cli/run.js +280 -0
  41. package/dist/cli/run.js.map +1 -0
  42. package/dist/cli/session.d.ts +2 -0
  43. package/dist/cli/session.d.ts.map +1 -0
  44. package/dist/cli/session.js +128 -0
  45. package/dist/cli/session.js.map +1 -0
  46. package/dist/cli/version.d.ts +2 -0
  47. package/dist/cli/version.d.ts.map +1 -0
  48. package/dist/cli/version.js +34 -0
  49. package/dist/cli/version.js.map +1 -0
  50. package/dist/cli.js +2 -0
  51. package/dist/execution/decode.d.ts.map +1 -1
  52. package/dist/execution/decode.js +32 -0
  53. package/dist/execution/decode.js.map +1 -1
  54. package/dist/execution/deps.d.ts +7 -1
  55. package/dist/execution/deps.d.ts.map +1 -1
  56. package/dist/execution/events.d.ts +6 -2
  57. package/dist/execution/events.d.ts.map +1 -1
  58. package/dist/execution/events.js.map +1 -1
  59. package/dist/execution/failure.d.ts +45 -0
  60. package/dist/execution/failure.d.ts.map +1 -0
  61. package/dist/execution/failure.js +121 -0
  62. package/dist/execution/failure.js.map +1 -0
  63. package/dist/execution/node-deps.d.ts.map +1 -1
  64. package/dist/execution/node-deps.js +19 -0
  65. package/dist/execution/node-deps.js.map +1 -1
  66. package/dist/execution/stream-turn.d.ts +2 -0
  67. package/dist/execution/stream-turn.d.ts.map +1 -1
  68. package/dist/execution/stream-turn.js +181 -16
  69. package/dist/execution/stream-turn.js.map +1 -1
  70. package/dist/interpretation/argv.d.ts +16 -4
  71. package/dist/interpretation/argv.d.ts.map +1 -1
  72. package/dist/interpretation/argv.js +68 -19
  73. package/dist/interpretation/argv.js.map +1 -1
  74. package/dist/interpretation/dimensions.d.ts +0 -2
  75. package/dist/interpretation/dimensions.d.ts.map +1 -1
  76. package/dist/interpretation/dimensions.js +0 -2
  77. package/dist/interpretation/dimensions.js.map +1 -1
  78. package/dist/interpretation/limits.d.ts +10 -1
  79. package/dist/interpretation/limits.d.ts.map +1 -1
  80. package/dist/interpretation/limits.js +84 -5
  81. package/dist/interpretation/limits.js.map +1 -1
  82. package/dist/interpretation/refusal.d.ts +31 -0
  83. package/dist/interpretation/refusal.d.ts.map +1 -0
  84. package/dist/interpretation/refusal.js +87 -0
  85. package/dist/interpretation/refusal.js.map +1 -0
  86. package/dist/interpretation/turn-options.d.ts +18 -0
  87. package/dist/interpretation/turn-options.d.ts.map +1 -0
  88. package/dist/interpretation/turn-options.js +417 -0
  89. package/dist/interpretation/turn-options.js.map +1 -0
  90. package/dist/interpretation/vocabulary.d.ts +5 -0
  91. package/dist/interpretation/vocabulary.d.ts.map +1 -1
  92. package/dist/interpretation/vocabulary.js +1 -1
  93. package/dist/interpretation/vocabulary.js.map +1 -1
  94. package/dist/knowledge/claude-code.d.ts +13 -1
  95. package/dist/knowledge/claude-code.d.ts.map +1 -1
  96. package/dist/knowledge/claude-code.js +35 -13
  97. package/dist/knowledge/claude-code.js.map +1 -1
  98. package/dist/knowledge/codex.d.ts.map +1 -1
  99. package/dist/knowledge/codex.js +25 -14
  100. package/dist/knowledge/codex.js.map +1 -1
  101. package/dist/knowledge/descriptor.d.ts +118 -14
  102. package/dist/knowledge/descriptor.d.ts.map +1 -1
  103. package/dist/knowledge/descriptor.js +58 -0
  104. package/dist/knowledge/descriptor.js.map +1 -1
  105. package/dist/knowledge/matchers.d.ts +3 -3
  106. package/dist/knowledge/matchers.d.ts.map +1 -1
  107. package/dist/knowledge/matchers.js +19 -6
  108. package/dist/knowledge/matchers.js.map +1 -1
  109. package/dist/knowledge/muse.d.ts +2 -2
  110. package/dist/knowledge/muse.d.ts.map +1 -1
  111. package/dist/knowledge/muse.js +22 -6
  112. package/dist/knowledge/muse.js.map +1 -1
  113. package/dist/knowledge/overrides.d.ts +7 -0
  114. package/dist/knowledge/overrides.d.ts.map +1 -1
  115. package/dist/knowledge/overrides.js +76 -0
  116. package/dist/knowledge/overrides.js.map +1 -1
  117. package/dist/knowledge/pi.d.ts.map +1 -1
  118. package/dist/knowledge/pi.js +25 -3
  119. package/dist/knowledge/pi.js.map +1 -1
  120. package/package.json +5 -2
  121. package/src/cli/args.ts +381 -0
  122. package/src/cli/check.ts +136 -0
  123. package/src/cli/exit-codes.ts +13 -0
  124. package/src/cli/help.ts +114 -0
  125. package/src/cli/index.ts +177 -0
  126. package/src/cli/inspect.ts +174 -0
  127. package/src/cli/ls.ts +14 -0
  128. package/src/cli/render.ts +62 -0
  129. package/src/cli/resolve-harness.ts +33 -0
  130. package/src/cli/run.ts +280 -0
  131. package/src/cli/session.ts +137 -0
  132. package/src/cli/version.ts +32 -0
  133. package/src/execution/decode.ts +32 -0
  134. package/src/execution/deps.ts +7 -1
  135. package/src/execution/events.ts +9 -2
  136. package/src/execution/failure.ts +159 -0
  137. package/src/execution/node-deps.ts +17 -0
  138. package/src/execution/stream-turn.ts +189 -17
  139. package/src/interpretation/argv.ts +85 -27
  140. package/src/interpretation/dimensions.ts +0 -6
  141. package/src/interpretation/limits.ts +97 -6
  142. package/src/interpretation/refusal.ts +116 -0
  143. package/src/interpretation/turn-options.ts +424 -0
  144. package/src/interpretation/vocabulary.ts +1 -1
  145. package/src/knowledge/claude-code.ts +35 -13
  146. package/src/knowledge/codex.ts +25 -14
  147. package/src/knowledge/descriptor.ts +138 -12
  148. package/src/knowledge/matchers.ts +22 -9
  149. package/src/knowledge/muse.ts +22 -6
  150. package/src/knowledge/overrides.ts +97 -0
  151. package/src/knowledge/pi.ts +25 -3
@@ -5,37 +5,62 @@
5
5
  * no caller re-derives them.
6
6
  */
7
7
  import type { HarnessDescriptor, StreamingGranularity } from "../knowledge/descriptor.js";
8
+ import { ArgvRefusalError } from "./refusal.js";
8
9
  import { assertUsableSessionId } from "./session-id.js";
10
+ import { renderTurnOptions } from "./turn-options.js";
9
11
  import { validateModel } from "./vocabulary.js";
10
12
 
11
- /** Raised when launch options would corrupt or subvert the spawned argv. */
12
- export class ArgvRefusalError extends Error {
13
- constructor(
14
- readonly issue: string,
15
- message: string,
16
- ) {
17
- super(message);
18
- this.name = "ArgvRefusalError";
19
- }
20
- }
13
+ export type { RefusalIssue } from "./refusal.js";
14
+ export { ArgvRefusalError, buildRefusalMessage, REFUSAL_ISSUES } from "./refusal.js";
21
15
 
22
16
  /** One guard for every builder that places a positional prompt. Selector
23
17
  * hygiene (session ids) lives in session-id.ts; model selectors go through
24
18
  * validateModel - both refuse, never sanitize. */
25
19
  const assertCleanPrompt = (h: HarnessDescriptor, prompt: string): void => {
26
20
  if (prompt.startsWith("-")) {
27
- throw new ArgvRefusalError(
28
- "prompt-flag-injection",
29
- `positional prompt may not start with '-'; it would be parsed as a flag by ${h.bin}`,
30
- );
21
+ throw new ArgvRefusalError({
22
+ issue: "prompt-flag-injection",
23
+ harness: h.name,
24
+ supported: ["prompt must not start with '-'"],
25
+ detail: `it would be parsed as a flag by ${h.bin}`,
26
+ });
31
27
  }
32
28
  };
33
29
 
30
+ /**
31
+ * Variant that allows a leading '-' when the caller explicitly opted in via
32
+ * --prompt / --prompt-file. The positional guard still applies for implicit
33
+ * positional prompts, but an explicit opt-in bypasses it so `hcn run --prompt "-bad"`
34
+ * succeeds while `hcn run "-bad"` refuses. The caller must set
35
+ * `__explicitPrompt: true` on the options object when the prompt came from an
36
+ * explicit flag.
37
+ */
38
+ const assertCleanPromptMaybe = (h: HarnessDescriptor, prompt: string, explicit?: boolean): void => {
39
+ if (explicit) return;
40
+ assertCleanPrompt(h, prompt);
41
+ };
42
+
43
+ export interface DiscoveryOptions {
44
+ readonly tools?: boolean;
45
+ readonly instructionFiles?: boolean;
46
+ readonly extensions?: boolean;
47
+ readonly skills?: boolean;
48
+ }
49
+
34
50
  export interface TurnOptions {
35
51
  readonly prompt: string;
36
52
  readonly tools?: readonly string[];
37
53
  readonly model?: string;
38
54
  readonly autonomy?: boolean;
55
+ readonly effort?: string;
56
+ readonly sandbox?: string;
57
+ readonly provider?: string;
58
+ readonly discovery?: DiscoveryOptions;
59
+ readonly write?: boolean;
60
+ readonly shell?: boolean;
61
+ readonly maxSteps?: number;
62
+ /** Internal: set by CLI when prompt came from --prompt/--prompt-file to bypass leading '-' guard */
63
+ readonly __explicitPrompt?: boolean;
39
64
  }
40
65
 
41
66
  export interface ResumeOptions extends TurnOptions {
@@ -48,25 +73,47 @@ export type LaunchOptions = TurnOptions;
48
73
  * validated selections, with the variadic tools flag LAST and fed exactly
49
74
  * one joined token so nothing after it can be swallowed as a tool name. */
50
75
  const turnTail = (h: HarnessDescriptor, opts: TurnOptions): string[] => {
51
- assertCleanPrompt(h, opts.prompt);
76
+ assertCleanPromptMaybe(h, opts.prompt, opts.__explicitPrompt);
52
77
  const tail = [opts.prompt, ...h.launch.streamFlags];
53
78
  if (opts.model !== undefined) {
54
79
  const validated = validateModel(h, opts.model);
55
- if (!validated.ok) throw new ArgvRefusalError("unknown-model", validated.reason);
80
+ if (!validated.ok) {
81
+ const supported = [...h.vocabulary.models, ...Object.keys(h.vocabulary.aliases)];
82
+ throw new ArgvRefusalError({
83
+ issue: "unknown-model",
84
+ harness: h.name,
85
+ supported: supported.length ? supported : ["no curated models"],
86
+ detail: opts.model,
87
+ });
88
+ }
56
89
  tail.push(h.vocabulary.modelFlag, validated.id);
57
90
  }
58
91
  if (opts.autonomy === true) {
59
92
  if (h.autonomy === null) {
60
- throw new ArgvRefusalError("no-autonomy-mode", `${h.bin} has no unattended-run flag`);
93
+ throw new ArgvRefusalError({
94
+ issue: "no-autonomy-mode",
95
+ harness: h.name,
96
+ supported: ["claude --dangerously-skip-permissions", "codex --yolo", "muse --yolo"],
97
+ });
61
98
  }
62
99
  tail.push(h.autonomy.flag);
63
100
  }
64
- if (opts.tools !== undefined && h.launch.toolsFlag !== null) {
101
+ if (opts.tools !== undefined) {
102
+ if (h.launch.toolsFlag === null) {
103
+ throw new ArgvRefusalError({
104
+ issue: "unsupported-option",
105
+ harness: h.name,
106
+ supported: ["--allowedTools is claude-only"],
107
+ detail: "tools",
108
+ });
109
+ }
65
110
  if (opts.tools.length === 0 || opts.tools.some((t) => t.trim() === "" || t.includes(","))) {
66
- throw new ArgvRefusalError(
67
- "invalid-tool-grant",
68
- `tool grant for ${h.bin} contains an empty entry or a comma; a blank ${h.launch.toolsFlag} value grants nothing detectable, and a comma inside one name silently splits the grant`,
69
- );
111
+ throw new ArgvRefusalError({
112
+ issue: "invalid-tool-grant",
113
+ harness: h.name,
114
+ supported: ["non-empty, comma-free tool names"],
115
+ detail: `tools=${JSON.stringify(opts.tools)}`,
116
+ });
70
117
  }
71
118
  tail.push(h.launch.toolsFlag, opts.tools.join(","));
72
119
  }
@@ -76,6 +123,7 @@ const turnTail = (h: HarnessDescriptor, opts: TurnOptions): string[] => {
76
123
  export const buildLaunchArgv = (h: HarnessDescriptor, opts: LaunchOptions): string[] => [
77
124
  h.bin,
78
125
  ...h.launch.baseFlags,
126
+ ...renderTurnOptions(h, opts, "launch"),
79
127
  ...turnTail(h, opts),
80
128
  ];
81
129
 
@@ -92,6 +140,7 @@ export const buildResumeArgv = (h: HarnessDescriptor, opts: ResumeOptions): stri
92
140
  h.resume.flag,
93
141
  opts.sessionId,
94
142
  ...h.resume.extraFlags,
143
+ ...renderTurnOptions(h, opts, "resume"),
95
144
  ...turnTail(h, opts),
96
145
  ];
97
146
  };
@@ -103,10 +152,11 @@ export interface SessionOptions {
103
152
 
104
153
  export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): string[] => {
105
154
  if (!h.sessionMode) {
106
- throw new ArgvRefusalError(
107
- "no-session-mode",
108
- `${h.bin} declares no persistent headless session mode`,
109
- );
155
+ throw new ArgvRefusalError({
156
+ issue: "no-session-mode",
157
+ harness: h.name,
158
+ supported: ["session is claude-only"],
159
+ });
110
160
  }
111
161
  assertUsableSessionId(opts.sessionId);
112
162
  const argv = [
@@ -118,7 +168,15 @@ export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): st
118
168
  ];
119
169
  if (opts.model !== undefined) {
120
170
  const validated = validateModel(h, opts.model);
121
- if (!validated.ok) throw new ArgvRefusalError("unknown-model", validated.reason);
171
+ if (!validated.ok) {
172
+ const supported = [...h.vocabulary.models, ...Object.keys(h.vocabulary.aliases)];
173
+ throw new ArgvRefusalError({
174
+ issue: "unknown-model",
175
+ harness: h.name,
176
+ supported: supported.length ? supported : ["no curated models"],
177
+ detail: opts.model,
178
+ });
179
+ }
122
180
  argv.push(h.vocabulary.modelFlag, validated.id);
123
181
  }
124
182
  return argv;
@@ -8,13 +8,7 @@ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
8
8
 
9
9
  export const stdinPolicyOf = (h: HarnessDescriptor): "inherit" | "close-required" => h.stdin;
10
10
 
11
- export const providerFlagOf = (h: HarnessDescriptor): string | null =>
12
- h.provider === null ? null : h.provider.flag;
13
-
14
11
  export const toolsFlagOf = (h: HarnessDescriptor): string | null => h.launch.toolsFlag;
15
12
 
16
- export const discoveryDisableFlagsOf = (h: HarnessDescriptor): readonly string[] =>
17
- h.discoveryDisableFlags;
18
-
19
13
  export const autonomyFlagOf = (h: HarnessDescriptor): string | null =>
20
14
  h.autonomy === null ? null : h.autonomy.flag;
@@ -9,21 +9,112 @@
9
9
  * Feed these wall-eligible output only - stderr and the non-JSON tail of a
10
10
  * dying turn - never assistant message content, where the model merely
11
11
  * TALKING about limits would match.
12
+ *
13
+ * Matchers are serializable objects {pattern, flags, code/kind}; compilation
14
+ * to RegExp happens here with bounded inputs (pattern length, count, flags)
15
+ * and a WeakMap cache. The input window (first 4096 chars of a line), not
16
+ * pattern analysis, is the backtracking bound - a malicious pattern could
17
+ * otherwise catastrophically backtrack on a long line.
12
18
  */
13
- import type { AuthFailureKind, HarnessDescriptor, LimitCode } from "../knowledge/descriptor.js";
19
+
20
+ import type {
21
+ AuthFailureKind,
22
+ AuthMatcher,
23
+ HarnessDescriptor,
24
+ LimitCode,
25
+ LimitMatcher,
26
+ } from "../knowledge/descriptor.js";
14
27
 
15
28
  /** Bottom-up batch scans stop after this many non-empty lines: the wall is
16
29
  * virtually always the last thing a dying turn printed, and an unbounded
17
30
  * scan over an accumulating session buffer is O(turns x output). */
18
31
  const BATCH_SCAN_MAX_LINES = 200;
19
32
 
33
+ /** Max pattern length and max matchers per harness per kind - load-bearing
34
+ * bounds that prevent a crafted override file from DoS'ing the matcher
35
+ * compiler or the scanner. */
36
+ const MAX_PATTERN_LENGTH = 200;
37
+ const MAX_MATCHERS_PER_KIND = 64;
38
+ const WINDOW = 4096;
39
+
40
+ // WeakMap cache: same matcher array instance reuses identical RegExp objects
41
+ const limitCache = new WeakMap<
42
+ ReadonlyArray<LimitMatcher>,
43
+ ReadonlyArray<readonly [RegExp, LimitCode]>
44
+ >();
45
+ const authCache = new WeakMap<
46
+ ReadonlyArray<AuthMatcher>,
47
+ ReadonlyArray<readonly [RegExp, AuthFailureKind]>
48
+ >();
49
+
50
+ const validateAndCompile = (pattern: string, flags: string | undefined): RegExp => {
51
+ const f = flags ?? "i";
52
+ if (pattern.length > MAX_PATTERN_LENGTH) {
53
+ throw new Error(`pattern over ${MAX_PATTERN_LENGTH} characters`);
54
+ }
55
+ if (f.includes("g") || f.includes("y")) {
56
+ throw new Error(`flags must not contain g or y (got ${JSON.stringify(f)})`);
57
+ }
58
+ for (const ch of f) {
59
+ if (!"imsu".includes(ch)) throw new Error(`flag ${JSON.stringify(ch)} outside imsu`);
60
+ }
61
+ try {
62
+ return new RegExp(pattern, f);
63
+ } catch (e) {
64
+ throw new Error(`uncompilable pattern ${JSON.stringify(pattern)}: ${(e as Error).message}`);
65
+ }
66
+ };
67
+
68
+ export const compileLimitMatchers = (
69
+ matchers: ReadonlyArray<LimitMatcher>,
70
+ ): ReadonlyArray<readonly [RegExp, LimitCode]> => {
71
+ const cached = limitCache.get(matchers);
72
+ if (cached !== undefined) return cached;
73
+ if (matchers.length > MAX_MATCHERS_PER_KIND) {
74
+ throw new Error(`more than ${MAX_MATCHERS_PER_KIND} matchers per harness per kind`);
75
+ }
76
+ const compiled = matchers.map((m) => [validateAndCompile(m.pattern, m.flags), m.code] as const);
77
+ limitCache.set(matchers, compiled);
78
+ return compiled;
79
+ };
80
+
81
+ export const compileAuthMatchers = (
82
+ matchers: ReadonlyArray<AuthMatcher>,
83
+ ): ReadonlyArray<readonly [RegExp, AuthFailureKind]> => {
84
+ const cached = authCache.get(matchers);
85
+ if (cached !== undefined) return cached;
86
+ if (matchers.length > MAX_MATCHERS_PER_KIND) {
87
+ throw new Error(`more than ${MAX_MATCHERS_PER_KIND} matchers per harness per kind`);
88
+ }
89
+ const compiled = matchers.map((m) => [validateAndCompile(m.pattern, m.flags), m.kind] as const);
90
+ authCache.set(matchers, compiled);
91
+ return compiled;
92
+ };
93
+
94
+ // Generic alias for tests that call compileMatchers directly
95
+ export const compileMatchers = <T extends LimitMatcher | AuthMatcher>(
96
+ matchers: ReadonlyArray<T>,
97
+ ): ReadonlyArray<readonly [RegExp, unknown]> => {
98
+ // Dispatch based on first element's shape - limit has code, auth has kind
99
+ if (matchers.length === 0) return [];
100
+ const first = matchers[0] as unknown as Record<string, unknown>;
101
+ if ("code" in first)
102
+ return compileLimitMatchers(
103
+ matchers as unknown as ReadonlyArray<LimitMatcher>,
104
+ ) as unknown as ReadonlyArray<readonly [RegExp, unknown]>;
105
+ return compileAuthMatchers(
106
+ matchers as unknown as ReadonlyArray<AuthMatcher>,
107
+ ) as unknown as ReadonlyArray<readonly [RegExp, unknown]>;
108
+ };
109
+
20
110
  const scanLine = <Code>(
21
111
  line: string,
22
112
  matchers: ReadonlyArray<readonly [RegExp, Code]>,
23
113
  ): Code | null => {
114
+ const windowed = line.slice(0, WINDOW);
24
115
  for (let i = 0; i < matchers.length; i++) {
25
116
  const matcher = matchers[i];
26
- if (matcher?.[0].test(line)) return matcher[1];
117
+ if (matcher?.[0].test(windowed)) return matcher[1];
27
118
  }
28
119
  return null;
29
120
  };
@@ -48,16 +139,16 @@ const scanTail = <Code>(
48
139
 
49
140
  /** Per-line entry point for streaming readers: O(1) per line, no rescans. */
50
141
  export const detectLimitInLine = (h: HarnessDescriptor, line: string): LimitCode | null =>
51
- scanLine(line.trim(), h.limitMatchers);
142
+ scanLine(line.trim(), compileLimitMatchers(h.limitMatchers));
52
143
 
53
144
  /** Batch convenience over a turn's tail, bounded and bottom-up. */
54
145
  export const detectLimit = (h: HarnessDescriptor, output: string): LimitCode | null =>
55
- scanTail(output, h.limitMatchers);
146
+ scanTail(output, compileLimitMatchers(h.limitMatchers));
56
147
 
57
148
  export const detectAuthFailureInLine = (
58
149
  h: HarnessDescriptor,
59
150
  line: string,
60
- ): AuthFailureKind | null => scanLine(line.trim(), h.authMatchers);
151
+ ): AuthFailureKind | null => scanLine(line.trim(), compileAuthMatchers(h.authMatchers));
61
152
 
62
153
  export const detectAuthFailure = (h: HarnessDescriptor, output: string): AuthFailureKind | null =>
63
- scanTail(output, h.authMatchers);
154
+ scanTail(output, compileAuthMatchers(h.authMatchers));
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Structured argv refusals: closed issue vocabulary and the typed error
3
+ * that carries harness, option, facet, and supported alternatives. The
4
+ * helper builds the message from those fields so the two cannot drift - a
5
+ * consumer branching on `issue` never has to parse English.
6
+ */
7
+
8
+ import type { DiscoveryFacet, HarnessName, TurnOptionKey } from "../knowledge/descriptor.js";
9
+ import { deepFreeze } from "../knowledge/descriptor.js";
10
+
11
+ export const REFUSAL_ISSUES = deepFreeze([
12
+ "unsupported-option",
13
+ "unsupported-option-facet",
14
+ "unsupported-on-resume",
15
+ "invalid-option-value",
16
+ "unknown-effort",
17
+ "unknown-model",
18
+ "invalid-env",
19
+ "invalid-tool-grant",
20
+ "prompt-flag-injection",
21
+ "no-autonomy-mode",
22
+ "no-session-mode",
23
+ ] as const);
24
+ export type RefusalIssue = (typeof REFUSAL_ISSUES)[number];
25
+
26
+ /** One helper builds the message from the structured fields so message and
27
+ * fields cannot drift. Every message names an alternative, not only a
28
+ * negation, so an agent can pivot without reading the descriptor. */
29
+ export const buildRefusalMessage = (
30
+ issue: RefusalIssue,
31
+ harness: HarnessName,
32
+ option?: TurnOptionKey,
33
+ facet?: DiscoveryFacet,
34
+ supported: readonly string[] = [],
35
+ detail?: string,
36
+ ): string => {
37
+ const supportedStr =
38
+ supported.length > 0 ? `supported: ${supported.join(", ")}` : "supported: (none)";
39
+ const facetSuffix = facet ? ` facet ${JSON.stringify(facet)}` : "";
40
+ const optionPart = option
41
+ ? ` option ${JSON.stringify(option)}${facetSuffix}`
42
+ : facet
43
+ ? ` facet ${JSON.stringify(facet)}`
44
+ : "";
45
+ const detailSuffix = detail ? ` (${detail})` : "";
46
+ switch (issue) {
47
+ case "unsupported-option":
48
+ return `${harness} cannot express${optionPart}${detailSuffix}; ${supportedStr} - drop the option or route this work to a harness that supports it`;
49
+ case "unsupported-option-facet":
50
+ return `${harness} cannot express discovery${facetSuffix}${detailSuffix}; ${supportedStr} - drop the facet or route this work to a harness that supports it`;
51
+ case "unsupported-on-resume": {
52
+ const name = option
53
+ ? JSON.stringify(option) + (facet ? `:${facet}` : "")
54
+ : facet
55
+ ? JSON.stringify(facet)
56
+ : "option";
57
+ return `${name} cannot be expressed on resume for ${harness}${detailSuffix}; ${supportedStr} - re-launch instead of resuming or drop the option`;
58
+ }
59
+ case "invalid-option-value":
60
+ return `invalid value for${optionPart} on ${harness}${detailSuffix}; ${supportedStr} - use a supported value instead`;
61
+ case "unknown-effort":
62
+ return `unknown effort for ${harness}${detail ? ` ${JSON.stringify(detail)}` : ""}; ${supportedStr} - use one of the ladder values`;
63
+ case "unknown-model":
64
+ return `unknown ${harness} model${detail ? ` ${JSON.stringify(detail)}` : ""}; ${supportedStr} - use one of the supported models`;
65
+ case "invalid-env":
66
+ return `invalid env key or value for ${harness}${detailSuffix}; ${supportedStr} - keys must match ^[A-Za-z_][A-Za-z0-9_]*$ and contain no NUL`;
67
+ case "invalid-tool-grant":
68
+ 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`;
69
+ case "prompt-flag-injection":
70
+ 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`;
71
+ case "no-autonomy-mode":
72
+ return `${harness} has no unattended-run flag; ${supportedStr} - drop autonomy or route to a supporting harness (claude --dangerously-skip-permissions, codex/muse --yolo)`;
73
+ case "no-session-mode":
74
+ return `${harness} declares no persistent headless session mode; ${supportedStr} - use streamTurn instead of openSession`;
75
+ default: {
76
+ const exhaustive: never = issue;
77
+ return `${exhaustive as string} for ${harness}${optionPart}${detailSuffix}; ${supportedStr}`;
78
+ }
79
+ }
80
+ };
81
+
82
+ /** Raised when launch options would corrupt or subvert the spawned argv. */
83
+ export class ArgvRefusalError extends Error {
84
+ readonly issue: RefusalIssue;
85
+ readonly harness: HarnessName;
86
+ readonly option?: TurnOptionKey;
87
+ readonly facet?: DiscoveryFacet;
88
+ readonly supported: readonly string[];
89
+ constructor(args: {
90
+ readonly issue: RefusalIssue;
91
+ readonly harness: HarnessName;
92
+ readonly option?: TurnOptionKey;
93
+ readonly facet?: DiscoveryFacet;
94
+ readonly supported?: readonly string[];
95
+ readonly detail?: string;
96
+ readonly message?: string;
97
+ }) {
98
+ const message =
99
+ args.message ??
100
+ buildRefusalMessage(
101
+ args.issue,
102
+ args.harness,
103
+ args.option,
104
+ args.facet,
105
+ args.supported ?? [],
106
+ args.detail,
107
+ );
108
+ super(message);
109
+ this.name = "ArgvRefusalError";
110
+ this.issue = args.issue;
111
+ this.harness = args.harness;
112
+ this.option = args.option;
113
+ this.facet = args.facet;
114
+ this.supported = args.supported ?? [];
115
+ }
116
+ }