@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
@@ -0,0 +1,424 @@
1
+ /**
2
+ * turn-options: rendering and validation of per-call turn options into argv.
3
+ *
4
+ * This lives separately from argv.ts because argv.ts owns argv *assembly and
5
+ * ordering* (where the prompt goes, that tools is last, that resume never
6
+ * inherits launch flags). Turn-option *rendering and validation* - which
7
+ * flag spells a dimension, how its value is validated and quoted, and when
8
+ * it must refuse - is a distinct responsibility with its own vocabulary.
9
+ * Folding it into argv.ts would make the largest interpretation file the
10
+ * place two unrelated questions are answered.
11
+ */
12
+ import type { HarnessDescriptor, OptionRender, SpecBase } from "../knowledge/descriptor.js";
13
+ import { DISCOVERY_FACETS, resolveRender, TURN_OPTION_KEYS } from "../knowledge/descriptor.js";
14
+ import type { DiscoveryOptions, TurnOptions } from "./argv.js";
15
+ import { ArgvRefusalError } from "./refusal.js";
16
+ import { CLEAN_SELECTOR, resolveModel, validateEffort } from "./vocabulary.js";
17
+
18
+ // TOML-quoted value for config-kv: JSON.stringify is sufficient for the
19
+ // closed vocabularies that may use it (enum, effort) - no value contains a
20
+ // quote that would need escaping beyond JSON's.
21
+ const tomlQuote = (value: string): string => JSON.stringify(value);
22
+
23
+ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
24
+ typeof v === "object" && v !== null && !Array.isArray(v);
25
+
26
+ /** Render turn options for a given phase, in TURN_OPTION_KEYS order and with
27
+ * discovery facets in DISCOVERY_FACETS order, de-duplicating by exact token
28
+ * sequence (first wins) so claude's two facets sharing one flag emit once. */
29
+ export const renderTurnOptions = (
30
+ h: HarnessDescriptor,
31
+ opts: TurnOptions,
32
+ phase: "launch" | "resume",
33
+ ): string[] => {
34
+ const sequences: string[][] = [];
35
+
36
+ for (const key of TURN_OPTION_KEYS) {
37
+ const spec = h.turnOptions[key];
38
+ const raw = (opts as unknown as Record<string, unknown>)[key];
39
+
40
+ // Discovery is a table of facets, handled separately.
41
+ if (key === "discovery") {
42
+ const discovery = raw as DiscoveryOptions | undefined;
43
+ // omitted, null, or {} => no-op
44
+ if (discovery === undefined || discovery === null) continue;
45
+ if (!isPlainObject(discovery as unknown)) {
46
+ throw new ArgvRefusalError({
47
+ issue: "invalid-option-value",
48
+ harness: h.name,
49
+ option: "discovery",
50
+ supported: ["discovery must be an object"],
51
+ detail: String(raw),
52
+ });
53
+ }
54
+ const discObj = discovery as Record<string, unknown>;
55
+ // If spec absent, any facet set to false must refuse.
56
+ if (spec === undefined) {
57
+ const requested = DISCOVERY_FACETS.filter((f) => discObj[f] === false);
58
+ if (requested.length === 0) continue;
59
+ throw new ArgvRefusalError({
60
+ issue: "unsupported-option",
61
+ harness: h.name,
62
+ option: "discovery",
63
+ supported: Object.keys(h.turnOptions).length ? Object.keys(h.turnOptions) : ["(none)"],
64
+ detail: String(requested[0]),
65
+ });
66
+ }
67
+ if (spec.kind !== "discovery") {
68
+ throw new ArgvRefusalError({
69
+ issue: "invalid-option-value",
70
+ harness: h.name,
71
+ option: "discovery",
72
+ supported: ["discovery spec malformed"],
73
+ });
74
+ }
75
+ // Validate each facet value is boolean if present
76
+ for (const facet of DISCOVERY_FACETS) {
77
+ if (!(facet in discObj)) continue;
78
+ const v = discObj[facet];
79
+ if (v !== undefined && typeof v !== "boolean") {
80
+ throw new ArgvRefusalError({
81
+ issue: "invalid-option-value",
82
+ harness: h.name,
83
+ option: "discovery",
84
+ facet,
85
+ supported: ["true or false"],
86
+ detail: String(v),
87
+ });
88
+ }
89
+ }
90
+ // Render facets in DISCOVERY_FACETS order
91
+ for (const facet of DISCOVERY_FACETS) {
92
+ const val = discObj[facet];
93
+ if (val === undefined) continue;
94
+ // Polarity handling: disables emits on false, enables emits on true
95
+ const facetSpec = spec.facets[facet];
96
+ if (facetSpec === undefined) {
97
+ // If caller asked to disable (false for disables, true for enables) but facet not declared, refuse
98
+ // But if caller passed true for a disables facet, it's a no-op, not a refusal
99
+ // So only refuse when the caller's value would have emitted
100
+ // For all current descriptors polarity is disables, so false would emit -> refuse
101
+ // For safety, check wouldEmit logic
102
+ // If val would not emit anyway, skip
103
+ // Determine wouldEmit: if facetSpec undefined, we cannot know polarity, so treat false as intended disable -> refuse
104
+ if (val === false) {
105
+ const supportedFacets = Object.keys(spec.facets);
106
+ throw new ArgvRefusalError({
107
+ issue: "unsupported-option-facet",
108
+ harness: h.name,
109
+ option: "discovery",
110
+ facet,
111
+ supported: supportedFacets.length ? supportedFacets : ["(none)"],
112
+ });
113
+ }
114
+ continue;
115
+ }
116
+ // Determine if this value should emit
117
+ let shouldEmit = false;
118
+ if (facetSpec.polarity === "disables" && val === false) shouldEmit = true;
119
+ else if (facetSpec.polarity === "enables" && val === true) shouldEmit = true;
120
+ else {
121
+ // true for disables or false for enables => no-op
122
+ continue;
123
+ }
124
+ if (!shouldEmit) continue;
125
+ // Check resumeRender refusal
126
+ const effective = resolveRender(facetSpec, phase);
127
+ if (effective === null) {
128
+ const resumeSupported = Object.entries(spec.facets)
129
+ .filter(
130
+ ([, v]) =>
131
+ resolveRender(
132
+ v as unknown as import("../knowledge/descriptor.js").SpecBase,
133
+ "resume",
134
+ ) !== null,
135
+ )
136
+ .map(([k]) => k);
137
+ throw new ArgvRefusalError({
138
+ issue: "unsupported-on-resume",
139
+ harness: h.name,
140
+ option: "discovery",
141
+ facet,
142
+ supported: resumeSupported.length ? resumeSupported : ["(none resume)"],
143
+ });
144
+ }
145
+ // Reject config-kv for discovery (only closed vocab, discovery is not)
146
+ if ((effective as OptionRender).kind === "config-kv") {
147
+ throw new ArgvRefusalError({
148
+ issue: "invalid-option-value",
149
+ harness: h.name,
150
+ option: "discovery",
151
+ facet,
152
+ supported: ["flag-list only for discovery"],
153
+ });
154
+ }
155
+ let seq: string[];
156
+ if ((effective as OptionRender).kind === "flag-list")
157
+ seq = [...(effective as Extract<OptionRender, { kind: "flag-list" }>).flags];
158
+ else if ((effective as OptionRender).kind === "flag-value")
159
+ seq = [(effective as Extract<OptionRender, { kind: "flag-value" }>).flag, ""];
160
+ else seq = [];
161
+ sequences.push(seq);
162
+ }
163
+ continue;
164
+ }
165
+
166
+ // Non-discovery keys
167
+ // Handle enum default on launch
168
+ if (raw === undefined) {
169
+ if (
170
+ spec !== undefined &&
171
+ spec.kind === "enum" &&
172
+ spec.default !== undefined &&
173
+ phase === "launch"
174
+ ) {
175
+ const effective = resolveRender(spec as unknown as SpecBase, phase);
176
+ if (effective === null) continue; // should not happen for launch
177
+ // config-kv is allowed for enum (closed vocab) - no rejection needed here
178
+ let seq: string[];
179
+ if ((effective as OptionRender).kind === "flag-value")
180
+ seq = [(effective as Extract<OptionRender, { kind: "flag-value" }>).flag, spec.default];
181
+ else if ((effective as OptionRender).kind === "config-kv")
182
+ seq = [
183
+ (effective as Extract<OptionRender, { kind: "config-kv" }>).flag,
184
+ `${(effective as Extract<OptionRender, { kind: "config-kv" }>).key}=${tomlQuote(spec.default)}`,
185
+ ];
186
+ else if ((effective as OptionRender).kind === "flag-list")
187
+ seq = [...(effective as Extract<OptionRender, { kind: "flag-list" }>).flags];
188
+ else seq = [];
189
+ sequences.push(seq);
190
+ }
191
+ continue;
192
+ }
193
+
194
+ // Raw is defined but spec is absent => unsupported-option
195
+ if (spec === undefined) {
196
+ throw new ArgvRefusalError({
197
+ issue: "unsupported-option",
198
+ harness: h.name,
199
+ option: key,
200
+ supported: Object.keys(h.turnOptions).length ? Object.keys(h.turnOptions) : ["(none)"],
201
+ detail: String(raw),
202
+ });
203
+ }
204
+
205
+ // Check resumeRender null => unsupported-on-resume
206
+ // For discovery we handled per-facet; for others check spec
207
+ if ((spec as { kind: string }).kind !== "discovery") {
208
+ const effective = resolveRender(spec as unknown as SpecBase, phase);
209
+ if (effective === null) {
210
+ const resumeSupported = TURN_OPTION_KEYS.filter((k) => {
211
+ const s = h.turnOptions[k];
212
+ if (!s) return false;
213
+ if ((s as { kind: string }).kind === "discovery") {
214
+ const facets = (s as unknown as { facets: Record<string, unknown> }).facets;
215
+ return Object.keys(facets).length > 0;
216
+ }
217
+ return resolveRender(s as unknown as SpecBase, "resume") !== null;
218
+ }) as string[];
219
+ throw new ArgvRefusalError({
220
+ issue: "unsupported-on-resume",
221
+ harness: h.name,
222
+ option: key,
223
+ supported: resumeSupported.length ? resumeSupported : ["(none)"],
224
+ });
225
+ }
226
+ // Reject config-kv for non-closed vocabulary
227
+ if (
228
+ (spec as unknown as SpecBase).render.kind === "config-kv" &&
229
+ (spec as { kind: string }).kind !== "enum" &&
230
+ (spec as { kind: string }).kind !== "effort"
231
+ ) {
232
+ throw new ArgvRefusalError({
233
+ issue: "invalid-option-value",
234
+ harness: h.name,
235
+ option: key,
236
+ supported: ["config-kv only for enum and effort (closed vocabularies)"],
237
+ });
238
+ }
239
+ }
240
+
241
+ // Per-kind validation and token generation
242
+ const effectiveRender =
243
+ (spec as { kind: string }).kind === "discovery"
244
+ ? null
245
+ : resolveRender(spec as unknown as SpecBase, phase);
246
+ // effectiveRender already checked for null
247
+
248
+ switch (spec.kind) {
249
+ case "effort": {
250
+ if (typeof raw !== "string") {
251
+ throw new ArgvRefusalError({
252
+ issue: "invalid-option-value",
253
+ harness: h.name,
254
+ option: key,
255
+ supported: [...h.vocabulary.efforts],
256
+ detail: String(raw),
257
+ });
258
+ }
259
+ const validated = validateEffort(
260
+ h,
261
+ raw,
262
+ (opts as unknown as Record<string, unknown>).model as string | undefined,
263
+ );
264
+ if (!validated.ok) {
265
+ let ladder = h.vocabulary.efforts;
266
+ const model = (opts as unknown as Record<string, unknown>).model as string | undefined;
267
+ if (model !== undefined && h.vocabulary.effortsByModel !== undefined) {
268
+ const { id } = resolveModel(h, model);
269
+ const perModel = Object.hasOwn(h.vocabulary.effortsByModel, id)
270
+ ? h.vocabulary.effortsByModel[id]
271
+ : undefined;
272
+ if (perModel !== undefined) ladder = perModel;
273
+ }
274
+ throw new ArgvRefusalError({
275
+ issue: "unknown-effort",
276
+ harness: h.name,
277
+ option: key,
278
+ supported: [...ladder],
279
+ detail: raw,
280
+ });
281
+ }
282
+ const r = effectiveRender!;
283
+ let seq: string[];
284
+ if (r.kind === "flag-value") seq = [r.flag, raw];
285
+ else if (r.kind === "config-kv") seq = [r.flag, `${r.key}=${tomlQuote(raw)}`];
286
+ else if (r.kind === "flag-list") seq = [...r.flags];
287
+ else seq = [];
288
+ sequences.push(seq);
289
+ break;
290
+ }
291
+ case "enum": {
292
+ if (typeof raw !== "string") {
293
+ throw new ArgvRefusalError({
294
+ issue: "invalid-option-value",
295
+ harness: h.name,
296
+ option: key,
297
+ supported: [...spec.values],
298
+ detail: String(raw),
299
+ });
300
+ }
301
+ if (!spec.values.includes(raw)) {
302
+ throw new ArgvRefusalError({
303
+ issue: "invalid-option-value",
304
+ harness: h.name,
305
+ option: key,
306
+ supported: [...spec.values],
307
+ detail: raw,
308
+ });
309
+ }
310
+ const r = effectiveRender!;
311
+ let seq: string[];
312
+ if (r.kind === "flag-value") seq = [r.flag, raw];
313
+ else if (r.kind === "config-kv") seq = [r.flag, `${r.key}=${tomlQuote(raw)}`];
314
+ else if (r.kind === "flag-list") seq = [...r.flags];
315
+ else seq = [];
316
+ sequences.push(seq);
317
+ break;
318
+ }
319
+ case "selector": {
320
+ if (typeof raw !== "string") {
321
+ throw new ArgvRefusalError({
322
+ issue: "invalid-option-value",
323
+ harness: h.name,
324
+ option: key,
325
+ supported: ["CLEAN_SELECTOR"],
326
+ detail: String(raw),
327
+ });
328
+ }
329
+ if (!CLEAN_SELECTOR.test(raw)) {
330
+ throw new ArgvRefusalError({
331
+ issue: "invalid-option-value",
332
+ harness: h.name,
333
+ option: key,
334
+ supported: ["must match CLEAN_SELECTOR /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,127}$/"],
335
+ detail: raw,
336
+ });
337
+ }
338
+ const r = effectiveRender!;
339
+ let seq: string[];
340
+ if (r.kind === "flag-value") seq = [r.flag, raw];
341
+ else if (r.kind === "config-kv") seq = [r.flag, `${r.key}=${tomlQuote(raw)}`];
342
+ else if (r.kind === "flag-list") seq = [...r.flags];
343
+ else seq = [];
344
+ sequences.push(seq);
345
+ break;
346
+ }
347
+ case "toggle": {
348
+ if (typeof raw !== "boolean") {
349
+ throw new ArgvRefusalError({
350
+ issue: "invalid-option-value",
351
+ harness: h.name,
352
+ option: key,
353
+ supported: ["true or false"],
354
+ detail: String(raw),
355
+ });
356
+ }
357
+ let shouldEmit = false;
358
+ if (spec.polarity === "disables" && raw === false) shouldEmit = true;
359
+ else if (spec.polarity === "enables" && raw === true) shouldEmit = true;
360
+ if (!shouldEmit) break;
361
+ const r = effectiveRender!;
362
+ let seq: string[];
363
+ if (r.kind === "flag-list") seq = [...r.flags];
364
+ else if (r.kind === "flag-value") seq = [r.flag, String(raw)];
365
+ else if (r.kind === "config-kv") seq = [r.flag, `${r.key}=${tomlQuote(String(raw))}`];
366
+ else seq = [];
367
+ sequences.push(seq);
368
+ break;
369
+ }
370
+ case "integer": {
371
+ if (typeof raw !== "number") {
372
+ throw new ArgvRefusalError({
373
+ issue: "invalid-option-value",
374
+ harness: h.name,
375
+ option: key,
376
+ supported: [`[${spec.min}, ${spec.max}] integer`],
377
+ detail: String(raw),
378
+ });
379
+ }
380
+ if (!Number.isFinite(raw) || !Number.isInteger(raw) || raw < spec.min || raw > spec.max) {
381
+ throw new ArgvRefusalError({
382
+ issue: "invalid-option-value",
383
+ harness: h.name,
384
+ option: key,
385
+ supported: [`[${spec.min}, ${spec.max}] integer`],
386
+ detail: String(raw),
387
+ });
388
+ }
389
+ const r = effectiveRender!;
390
+ let seq: string[];
391
+ if (r.kind === "flag-value") seq = [r.flag, String(raw)];
392
+ else if (r.kind === "config-kv") seq = [r.flag, `${r.key}=${tomlQuote(String(raw))}`];
393
+ else if (r.kind === "flag-list") seq = [...r.flags];
394
+ else seq = [];
395
+ sequences.push(seq);
396
+ break;
397
+ }
398
+ case "discovery":
399
+ // already handled
400
+ break;
401
+ default: {
402
+ const _exhaustive: never = spec as never;
403
+ throw new ArgvRefusalError({
404
+ issue: "invalid-option-value",
405
+ harness: h.name,
406
+ option: key,
407
+ supported: [],
408
+ detail: String(_exhaustive),
409
+ });
410
+ }
411
+ }
412
+ }
413
+
414
+ // De-duplication by exact token sequence, first occurrence winning
415
+ const seen = new Set<string>();
416
+ const deduped: string[][] = [];
417
+ for (const seq of sequences) {
418
+ const k = seq.join("\0");
419
+ if (seen.has(k)) continue;
420
+ seen.add(k);
421
+ deduped.push(seq);
422
+ }
423
+ return deduped.flat();
424
+ };
@@ -18,7 +18,7 @@ export interface ValidationRefusal {
18
18
  * models as provider/id[:thinking], so word characters plus the few real
19
19
  * separators - never whitespace, shell metacharacters, or control/format
20
20
  * characters, and bounded like session ids. */
21
- const CLEAN_SELECTOR = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,127}$/;
21
+ export const CLEAN_SELECTOR = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,127}$/;
22
22
 
23
23
  export interface ResolvedModel {
24
24
  readonly id: string;
@@ -1,7 +1,19 @@
1
1
  /**
2
2
  * The claude-code descriptor: facts about the `claude` CLI as data, verified
3
- * against claude 2.1.227 and the 00-chat-substrate spike evidence (A-001,
3
+ * against claude 2.1.229 and the 00-chat-substrate spike evidence (A-001,
4
4
  * A-002, A-005). No process logic lives here.
5
+ *
6
+ * Discovery: claude 2.1.229 has no isolated instruction-file toggle.
7
+ * `--bare` would stop `CLAUDE.md` discovery, but it also skips hooks, LSP,
8
+ * plugin sync, auto-memory and keychain reads, and forces auth to
9
+ * `ANTHROPIC_API_KEY` / `apiKeyHelper` (OAuth never read) - so a caller
10
+ * authenticated via OAuth would break. The descriptor therefore offers no
11
+ * `instructionFiles` facet and a call passing it must refuse. Likewise
12
+ * `tools` has no discovery flag on claude and refuses.
13
+ *
14
+ * Effort: A-002 showed `--effort bogus` warns on stderr and runs at DEFAULT
15
+ * effort, exit 0, with nothing echoed in the stream. The library-side
16
+ * `validateEffort` refusal is therefore load-bearing, not cosmetic.
5
17
  */
6
18
  import { deepFreeze, type HarnessDescriptor, UUID_SHAPE } from "./descriptor.js";
7
19
  import { SHARED_AUTH_MATCHERS, SHARED_LIMIT_MATCHERS } from "./matchers.js";
@@ -9,7 +21,7 @@ import { SHARED_AUTH_MATCHERS, SHARED_LIMIT_MATCHERS } from "./matchers.js";
9
21
  export const claudeCode: HarnessDescriptor = deepFreeze({
10
22
  name: "claude",
11
23
  bin: "claude",
12
- verifiedAgainst: "2.1.227",
24
+ verifiedAgainst: "2.1.229",
13
25
  versionSource: { kind: "npm", package: "@anthropic-ai/claude-code" },
14
26
  launch: {
15
27
  baseFlags: ["-p"],
@@ -69,17 +81,17 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
69
81
  limitMatchers: [
70
82
  // Observed phrasings from the live CLI (ported from lucid v1's limits.ts):
71
83
  // "You've hit your session limit · resets 6:30pm"
72
- [/you'?ve hit your session limit/i, "session-limit"],
84
+ { pattern: "you'?ve hit your session limit", flags: "i", code: "session-limit" },
73
85
  // "You've hit your weekly limit · resets 2am (Asia/Bangkok)"
74
- [/you'?ve hit your weekly limit/i, "weekly-limit"],
86
+ { pattern: "you'?ve hit your weekly limit", flags: "i", code: "weekly-limit" },
75
87
  ...SHARED_LIMIT_MATCHERS,
76
88
  ],
77
89
  authMatchers: [
78
90
  // A detached process cannot read Keychain creds, and misreading that as
79
91
  // "not logged in" sends the human to redo a login that was never broken.
80
- [/oauth session expired|could not be refreshed/i, "expired"],
81
- [/failed to authenticate/i, "expired"],
82
- [/not logged in|please run \/login/i, "not-logged-in"],
92
+ { pattern: "oauth session expired|could not be refreshed", flags: "i", kind: "expired" },
93
+ { pattern: "failed to authenticate", flags: "i", kind: "expired" },
94
+ { pattern: "not logged in|please run \\/login", flags: "i", kind: "not-logged-in" },
83
95
  ...SHARED_AUTH_MATCHERS,
84
96
  ],
85
97
  autonomy: { flag: "--dangerously-skip-permissions" },
@@ -93,8 +105,6 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
93
105
  haiku: "claude-haiku-4-5-20251001",
94
106
  },
95
107
  efforts: ["low", "medium", "high", "xhigh", "max"],
96
- // Effort is an in-session command for claude, not a launch flag.
97
- effortFlag: null,
98
108
  extensible: false,
99
109
  },
100
110
  store: {
@@ -111,11 +121,7 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
111
121
  usedPctField: "used_percentage",
112
122
  },
113
123
  resumeLast: null,
114
- provider: null,
115
124
  stdin: "inherit",
116
- // D-025: project-only setting sources is claude's discovery-isolation
117
- // spelling; the child loads no user-level hooks or skills.
118
- discoveryDisableFlags: ["--setting-sources", "project"],
119
125
  presence: {
120
126
  headlessMarkers: ["-p", "--print"],
121
127
  },
@@ -129,4 +135,20 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
129
135
  },
130
136
  session: true,
131
137
  },
138
+ turnOptions: {
139
+ effort: { kind: "effort", render: { kind: "flag-value", flag: "--effort" } },
140
+ discovery: {
141
+ kind: "discovery",
142
+ facets: {
143
+ extensions: {
144
+ polarity: "disables",
145
+ render: { kind: "flag-list", flags: ["--setting-sources", "project"] },
146
+ },
147
+ skills: {
148
+ polarity: "disables",
149
+ render: { kind: "flag-list", flags: ["--setting-sources", "project"] },
150
+ },
151
+ },
152
+ },
153
+ },
132
154
  });
@@ -14,13 +14,13 @@ export const codexCli: HarnessDescriptor = deepFreeze({
14
14
  versionSource: { kind: "npm", package: "@openai/codex" },
15
15
  launch: {
16
16
  // exec --json emits structured item events; without --json, identity
17
- // discovery is blind (v1: requiredArgument "--json"). The sandbox grant
18
- // is v1's proven spawn shape - codex's built-in default is read-only,
19
- // under which a non-autonomy turn cannot write files.
17
+ // discovery is blind (v1: requiredArgument "--json").
20
18
  // --skip-git-repo-check: codex exec refuses to run outside a trusted
21
19
  // git dir without it (verified 0.147.0). cwd targeting is the spawner's
22
20
  // job (spawn opts.cwd), not descriptor data.
23
- baseFlags: ["exec", "--json", "--sandbox", "workspace-write", "--skip-git-repo-check"],
21
+ // Sandbox is now per-call via turnOptions.sandbox with default
22
+ // workspace-write, not a hardcoded base flag.
23
+ baseFlags: ["exec", "--json", "--skip-git-repo-check"],
24
24
  subcommands: ["exec"],
25
25
  promptStyle: "positional",
26
26
  toolsFlag: null,
@@ -54,24 +54,24 @@ export const codexCli: HarnessDescriptor = deepFreeze({
54
54
  announce: { match: { type: "thread.started" }, idField: "thread_id" },
55
55
  },
56
56
  limitMatchers: [...SHARED_LIMIT_MATCHERS],
57
- authMatchers: [[/run codex login/i, "not-logged-in"], ...SHARED_AUTH_MATCHERS],
57
+ authMatchers: [
58
+ { pattern: "run codex login", flags: "i", kind: "not-logged-in" },
59
+ ...SHARED_AUTH_MATCHERS,
60
+ ],
58
61
  // Accepted by codex 0.147.0 as a hidden alias (not in --help).
59
62
  autonomy: { flag: "--yolo" },
60
63
  vocabulary: {
61
64
  modelFlag: "--model",
62
65
  models: ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"],
63
66
  aliases: {},
64
- efforts: ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"],
67
+ efforts: ["minimal", "low", "medium", "high", "xhigh", "max"],
65
68
  // Codex constrains ladders per model generation (v1 registry).
66
69
  effortsByModel: {
67
70
  "gpt-5.5": ["minimal", "low", "medium", "high"],
68
- "gpt-5.6-sol": ["medium", "high", "xhigh", "max", "ultra"],
69
- "gpt-5.6-terra": ["medium", "high", "xhigh", "max", "ultra"],
70
- "gpt-5.6-luna": ["medium", "high", "xhigh", "max", "ultra"],
71
+ "gpt-5.6-sol": ["medium", "high", "xhigh", "max"],
72
+ "gpt-5.6-terra": ["medium", "high", "xhigh", "max"],
73
+ "gpt-5.6-luna": ["medium", "high", "xhigh", "max"],
71
74
  },
72
- // Reasoning effort is a config key (-c model_reasoning_effort=...), not
73
- // a plain flag; argv-level insertion has no spelling to use.
74
- effortFlag: null,
75
75
  extensible: false,
76
76
  },
77
77
  store: {
@@ -84,12 +84,10 @@ export const codexCli: HarnessDescriptor = deepFreeze({
84
84
  contextHook: null,
85
85
  // Valid only in the `exec resume` context: `codex exec resume --last`.
86
86
  resumeLast: { flag: "--last" },
87
- provider: null,
88
87
  // codex exec appends piped stdin as a <stdin> block and can block on an
89
88
  // open stdin - close it (verified 0.147.0: "Reading additional input
90
89
  // from stdin...").
91
90
  stdin: "close-required",
92
- discoveryDisableFlags: [],
93
91
  presence: {
94
92
  headlessMarkers: ["exec"],
95
93
  },
@@ -103,4 +101,17 @@ export const codexCli: HarnessDescriptor = deepFreeze({
103
101
  },
104
102
  session: false,
105
103
  },
104
+ turnOptions: {
105
+ effort: {
106
+ kind: "effort",
107
+ render: { kind: "config-kv", flag: "-c", key: "model_reasoning_effort" },
108
+ },
109
+ sandbox: {
110
+ kind: "enum",
111
+ values: ["read-only", "workspace-write", "danger-full-access"],
112
+ default: "workspace-write",
113
+ render: { kind: "flag-value", flag: "--sandbox" },
114
+ resumeRender: null,
115
+ },
116
+ },
106
117
  });