@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
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Descriptor types: the shape of what is KNOWN about a harness CLI, as pure
3
3
  * data. Interpretation functions consume these; nothing here executes.
4
+ *
5
+ * Turn option keys and discovery facets use a closed vocabulary for the same
6
+ * reason `LimitCode` does: a descriptor must not invent an option a consumer
7
+ * has no field for. Every `TurnOptionKey` maps to a field on `TurnOptions`
8
+ * and every `DiscoveryFacet` maps to a field on `DiscoveryOptions`; adding
9
+ * a key without a consumer arm would be dead data that can only drift.
4
10
  */
5
11
 
6
12
  export const HARNESS_NAMES = ["claude", "codex", "pi", "muse"] as const;
@@ -34,13 +40,136 @@ export interface SessionInputContract {
34
40
  /** Consumers branch on these (session-limit: wait for reset; weekly-limit:
35
41
  * route elsewhere), so the vocabulary is closed - a descriptor cannot invent
36
42
  * a code a consumer has no arm for. */
37
- export type LimitCode = "usage-limit" | "session-limit" | "weekly-limit" | "credits" | "quota";
43
+ export type LimitCode =
44
+ | "usage-limit"
45
+ | "session-limit"
46
+ | "weekly-limit"
47
+ | "credits"
48
+ | "quota"
49
+ | "rate-limit";
38
50
 
39
51
  /** Auth walls are separate from usage limits because the remedy is entirely
40
52
  * different (re-auth vs wait), and a detached process misreading one as the
41
53
  * other sends the human to fix the wrong thing. */
42
54
  export type AuthFailureKind = "not-logged-in" | "expired" | "invalid-key";
43
55
 
56
+ /** Serializable wall matcher: pattern and flags are data, not a RegExp
57
+ * literal, so an override file can extend them. `compileMatchers` turns
58
+ * these into RegExps with bounds (pattern length, count, allowed flags)
59
+ * and a WeakMap cache so the same array instance reuses compiled RegExps.
60
+ * The input window (first 4096 chars of a line), not pattern analysis, is
61
+ * the backtracking bound. */
62
+ export interface LimitMatcher {
63
+ readonly pattern: string;
64
+ readonly flags?: string;
65
+ readonly code: LimitCode;
66
+ }
67
+
68
+ export interface AuthMatcher {
69
+ readonly pattern: string;
70
+ readonly flags?: string;
71
+ readonly kind: AuthFailureKind;
72
+ }
73
+
74
+ /** The turn-option vocabulary is closed for the same reason `LimitCode` is:
75
+ * a descriptor must not invent an option a consumer has no field for. Every
76
+ * key here maps to a field on `TurnOptions`; adding a key without a consumer
77
+ * arm would be dead data that can only drift. Render order is the tuple
78
+ * order, so argv is deterministic regardless of caller field order. */
79
+ export const TURN_OPTION_KEYS = deepFreeze([
80
+ "effort",
81
+ "sandbox",
82
+ "provider",
83
+ "discovery",
84
+ "write",
85
+ "shell",
86
+ "maxSteps",
87
+ ] as const);
88
+ export type TurnOptionKey = (typeof TURN_OPTION_KEYS)[number];
89
+
90
+ /** Discovery facets are closed for the same reason as `TurnOptionKey`: each
91
+ * maps to a field on `DiscoveryOptions` and a concrete flag spelling per
92
+ * harness. A harness that cannot express a facet simply omits it from its
93
+ * `turnOptions.discovery.facets` table; a call passing that facet must
94
+ * refuse. */
95
+ export const DISCOVERY_FACETS = deepFreeze([
96
+ "tools",
97
+ "instructionFiles",
98
+ "extensions",
99
+ "skills",
100
+ ] as const);
101
+ export type DiscoveryFacet = (typeof DISCOVERY_FACETS)[number];
102
+
103
+ export type OptionRender =
104
+ /** `--flag <value>` */
105
+ | { readonly kind: "flag-value"; readonly flag: string }
106
+ /** `-c key=value` - codex's config-override grammar. Permitted only for
107
+ * closed-vocabulary specs, so no value can need escaping. */
108
+ | { readonly kind: "config-kv"; readonly flag: string; readonly key: string }
109
+ /** A fixed multi-token flag set emitted verbatim, value-less. */
110
+ | { readonly kind: "flag-list"; readonly flags: readonly string[] };
111
+
112
+ export interface SpecBase {
113
+ readonly render: OptionRender;
114
+ /** The spelling the RESUME grammar accepts. Omitted means "same as
115
+ * `render`"; an explicit `null` declares the option unexpressible on
116
+ * resume, and building a resume argv with it must refuse. */
117
+ readonly resumeRender?: OptionRender | null;
118
+ }
119
+
120
+ export type TurnOptionSpec =
121
+ /** Closed value vocabulary. `default` renders on LAUNCH ONLY. */
122
+ | (SpecBase & {
123
+ readonly kind: "enum";
124
+ readonly values: readonly string[];
125
+ readonly default?: string;
126
+ })
127
+ /** Ladder comes from vocabulary.efforts / effortsByModel, not from here. */
128
+ | (SpecBase & { readonly kind: "effort" })
129
+ /** Open selector, CLEAN_SELECTOR-validated. */
130
+ | (SpecBase & { readonly kind: "selector" })
131
+ /** `polarity: "disables"` emits the render when the caller asks for FALSE. */
132
+ | (SpecBase & { readonly kind: "toggle"; readonly polarity: "enables" | "disables" })
133
+ | (SpecBase & { readonly kind: "integer"; readonly min: number; readonly max: number })
134
+ /** Per-facet toggles; a facet absent from the table cannot be expressed. */
135
+ | {
136
+ readonly kind: "discovery";
137
+ readonly facets: Readonly<
138
+ Partial<Record<DiscoveryFacet, SpecBase & { readonly polarity: "enables" | "disables" }>>
139
+ >;
140
+ };
141
+
142
+ /** Resolve the effective render for a spec at a given phase.
143
+ * - `launch` always uses `render`.
144
+ * - `resume` with `resumeRender: null` is unexpressible (returns null).
145
+ * - `resume` with `resumeRender` omitted resolves to the same render as `render`.
146
+ * - `resume` with an explicit `resumeRender` uses that spelling.
147
+ * Discovery facets use the same rule per facet; this helper works for any
148
+ * `SpecBase` (top-level specs and per-facet specs alike). */
149
+ export const resolveRender = (spec: SpecBase, phase: "launch" | "resume"): OptionRender | null => {
150
+ if (phase === "launch") return spec.render;
151
+ if (spec.resumeRender === null) return null;
152
+ return spec.resumeRender ?? spec.render;
153
+ };
154
+
155
+ /** Alias for `resolveRender` with the resume-only null semantics made
156
+ * explicit in the name; useful for tests asserting the "omitted => same as
157
+ * render, null => unexpressible" contract. */
158
+ export const resolveResumeRender = (spec: SpecBase): OptionRender | null =>
159
+ resolveRender(spec, "resume");
160
+
161
+ /** Like `resolveRender` but for a `TurnOptionSpec` that may be a `discovery`
162
+ * table. Returns null for an unexpressible resume, the spec's render for
163
+ * non-discovery specs, and for discovery returns the spec itself (facets are
164
+ * resolved per-facet via `resolveRender`). */
165
+ export const getOptionRender = (
166
+ spec: TurnOptionSpec,
167
+ phase: "launch" | "resume",
168
+ ): OptionRender | null => {
169
+ if (spec.kind === "discovery") return null;
170
+ return resolveRender(spec, phase);
171
+ };
172
+
44
173
  export interface HarnessDescriptor {
45
174
  readonly name: HarnessName;
46
175
  readonly bin: string;
@@ -147,17 +276,15 @@ export interface HarnessDescriptor {
147
276
  };
148
277
  /** "Stopped on a limit" vs crash vs clean exit: the harness's own wall
149
278
  * phrasings. First match wins per line. */
150
- readonly limitMatchers: ReadonlyArray<readonly [RegExp, LimitCode]>;
279
+ readonly limitMatchers: ReadonlyArray<LimitMatcher>;
151
280
  /** Auth-wall phrasings, same scan discipline as limitMatchers. */
152
- readonly authMatchers: ReadonlyArray<readonly [RegExp, AuthFailureKind]>;
281
+ readonly authMatchers: ReadonlyArray<AuthMatcher>;
153
282
  /** The "run unattended without stops" flag, or null when the harness has
154
283
  * no such mode. */
155
284
  readonly autonomy: { readonly flag: string } | null;
156
285
  /** The harness's own model-id spellings, alias map, and effort ladder.
157
286
  * Curated baseline - pi's registry is runtime-extensible, so validation
158
- * against this vocabulary is a default, not a final word (D-008).
159
- * `effortFlag` is null where effort is not a launch-time flag (claude:
160
- * effort is an in-session command, not argv). */
287
+ * against this vocabulary is a default, not a final word (D-008). */
161
288
  readonly vocabulary: {
162
289
  readonly modelFlag: string;
163
290
  readonly models: readonly string[];
@@ -167,7 +294,6 @@ export interface HarnessDescriptor {
167
294
  * gpt-5.5 tops out at high; gpt-5.6-* starts at medium). Falls back to
168
295
  * the harness-wide `efforts`. */
169
296
  readonly effortsByModel?: Readonly<Record<string, readonly string[]>>;
170
- readonly effortFlag: string | null;
171
297
  /** D-008: an extensible vocabulary (pi) accepts clean unknown model
172
298
  * selectors at argv time; capability claims for them degrade to
173
299
  * unknown until runtime verification. */
@@ -190,14 +316,9 @@ export interface HarnessDescriptor {
190
316
  /** Resume-most-recent support (codex --last), or null. The race it opens
191
317
  * is owned by the corroboration ranking in interpretation. */
192
318
  readonly resumeLast: { readonly flag: string } | null;
193
- /** Provider/model-route flag where present (pi --provider). */
194
- readonly provider: { readonly flag: string } | null;
195
319
  /** Whether backgrounded headless calls must have stdin closed (pi hangs
196
320
  * without `< /dev/null`). */
197
321
  readonly stdin: "inherit" | "close-required";
198
- /** Flags that disable instruction-file/skill/MCP auto-discovery, in the
199
- * spelling the harness accepts. */
200
- readonly discoveryDisableFlags: readonly string[];
201
322
  /** Presence recognition: how an interactive process for a session id shows
202
323
  * up in a process listing. `headlessMarkers` mark a process as headless
203
324
  * (not interactive presence). Known blind spot, inherent to argv matching:
@@ -214,4 +335,9 @@ export interface HarnessDescriptor {
214
335
  readonly streamingByMode: Readonly<Record<HarnessMode, StreamingGranularity>>;
215
336
  readonly session: boolean;
216
337
  };
338
+ /** Per-call turn options this harness can express, keyed by the closed
339
+ * `TurnOptionKey` vocabulary. Absent keys are unexpressible on this
340
+ * harness; a call passing them must refuse. Discovery is a table of
341
+ * per-facet specs rather than a single flag. */
342
+ readonly turnOptions: Readonly<Partial<Record<TurnOptionKey, TurnOptionSpec>>>;
217
343
  }
@@ -4,16 +4,29 @@
4
4
  * or a provider 401 must classify no matter which CLI printed it. Each
5
5
  * descriptor spreads these after its harness-specific phrasings.
6
6
  */
7
- import type { AuthFailureKind, LimitCode } from "./descriptor.js";
7
+ import type { AuthMatcher, LimitMatcher } from "./descriptor.js";
8
8
 
9
- export const SHARED_LIMIT_MATCHERS: ReadonlyArray<readonly [RegExp, LimitCode]> = [
10
- [/you'?ve hit your usage limit/i, "usage-limit"],
11
- [/usage limit (?:reached|exceeded)/i, "usage-limit"],
12
- [/purchase more credits|insufficient credits|out of credits/i, "credits"],
13
- [/resource_exhausted|quota exceeded|exceeded your current quota/i, "quota"],
9
+ export const SHARED_LIMIT_MATCHERS: ReadonlyArray<LimitMatcher> = [
10
+ { pattern: "you'?ve hit your usage limit", flags: "i", code: "usage-limit" },
11
+ { pattern: "usage limit (?:reached|exceeded)", flags: "i", code: "usage-limit" },
12
+ {
13
+ pattern: "purchase more credits|insufficient credits|out of credits",
14
+ flags: "i",
15
+ code: "credits",
16
+ },
17
+ {
18
+ pattern: "resource_exhausted|quota exceeded|exceeded your current quota",
19
+ flags: "i",
20
+ code: "quota",
21
+ },
22
+ // Rate-limit patterns are last so a line with both a usage wall and a 429 keeps usage-limit (first-match-wins, documented cost)
23
+ { pattern: "429", flags: "i", code: "rate-limit" },
24
+ { pattern: "Too Many Requests", flags: "i", code: "rate-limit" },
25
+ { pattern: "rate limit(?:ed|ing)?", flags: "i", code: "rate-limit" },
26
+ { pattern: "Retry-After", flags: "i", code: "rate-limit" },
14
27
  ];
15
28
 
16
- export const SHARED_AUTH_MATCHERS: ReadonlyArray<readonly [RegExp, AuthFailureKind]> = [
17
- [/401 unauthorized/i, "expired"],
18
- [/invalid api key/i, "invalid-key"],
29
+ export const SHARED_AUTH_MATCHERS: ReadonlyArray<AuthMatcher> = [
30
+ { pattern: "401 unauthorized", flags: "i", kind: "expired" },
31
+ { pattern: "invalid api key", flags: "i", kind: "invalid-key" },
19
32
  ];
@@ -4,8 +4,8 @@
4
4
  * (D-003). The v1 scars this encodes: headless re-entry is `muse exec
5
5
  * --session-id <id>` (the positional `muse resume <id>` is the INTERACTIVE
6
6
  * picker - recognized when pasted, never built), and `muse exec` exits 0
7
- * even when the work inside failed (verification always reruns the
8
- * project's own checks).
7
+ * on task failure but 1 on step exhaustion (verified 0.1.0 - see spike
8
+ * report A-003 for `budget` vs `task` split).
9
9
  */
10
10
  import { deepFreeze, type HarnessDescriptor, UUID_SHAPE } from "./descriptor.js";
11
11
  import { SHARED_AUTH_MATCHERS, SHARED_LIMIT_MATCHERS } from "./matchers.js";
@@ -59,8 +59,7 @@ export const museCode: HarnessDescriptor = deepFreeze({
59
59
  modelFlag: "--model",
60
60
  models: ["muse-spark-1.2-contributor", "muse-spark-1.2", "muse-spark-1.1"],
61
61
  aliases: {},
62
- efforts: ["none", "minimal", "low", "medium", "high", "xhigh", "ultra"],
63
- effortFlag: "--reasoning-effort",
62
+ efforts: ["none", "minimal", "low", "medium", "high", "xhigh"],
64
63
  extensible: false,
65
64
  },
66
65
  store: {
@@ -73,9 +72,7 @@ export const museCode: HarnessDescriptor = deepFreeze({
73
72
  contextHook: null,
74
73
  // `muse resume --last` exists (muse resume --help).
75
74
  resumeLast: { flag: "--last" },
76
- provider: null,
77
75
  stdin: "inherit",
78
- discoveryDisableFlags: [],
79
76
  presence: {
80
77
  headlessMarkers: ["exec"],
81
78
  },
@@ -91,4 +88,23 @@ export const museCode: HarnessDescriptor = deepFreeze({
91
88
  },
92
89
  session: false,
93
90
  },
91
+ turnOptions: {
92
+ effort: { kind: "effort", render: { kind: "flag-value", flag: "--reasoning-effort" } },
93
+ write: {
94
+ kind: "toggle",
95
+ polarity: "disables",
96
+ render: { kind: "flag-list", flags: ["--disable-write"] },
97
+ },
98
+ shell: {
99
+ kind: "toggle",
100
+ polarity: "disables",
101
+ render: { kind: "flag-list", flags: ["--disable-shell"] },
102
+ },
103
+ maxSteps: {
104
+ kind: "integer",
105
+ min: 1,
106
+ max: 10000,
107
+ render: { kind: "flag-value", flag: "--max-model-steps" },
108
+ },
109
+ },
94
110
  });
@@ -32,6 +32,11 @@ export const defaultDescriptors = (): DescriptorSet => ({
32
32
  muse: museCode,
33
33
  });
34
34
 
35
+ /** Tracks which descriptors were produced by an override file and what their
36
+ * matcher counts are, so the execution layer can emit `matcherOverrides` on
37
+ * the spawn boundary event without re-deriving it. */
38
+ export const matcherOverridesOf = new WeakMap<HarnessDescriptor, { limit: number; auth: number }>();
39
+
35
40
  export class OverrideRefusalError extends Error {
36
41
  constructor(
37
42
  readonly path: string,
@@ -146,6 +151,80 @@ const mergeValue = (
146
151
  return override;
147
152
  };
148
153
 
154
+ const validateMatcherPattern = (
155
+ pattern: string,
156
+ flags: string | undefined,
157
+ path: string,
158
+ harness: string,
159
+ ): void => {
160
+ const f = flags ?? "i";
161
+ if (pattern.length > 200) {
162
+ throw new OverrideRefusalError(
163
+ path,
164
+ `pattern over 200 characters: ${JSON.stringify(pattern.slice(0, 40))}`,
165
+ harness,
166
+ );
167
+ }
168
+ if (pattern.length === 0) {
169
+ throw new OverrideRefusalError(path, `pattern must not be empty`, harness);
170
+ }
171
+ if (f.includes("g") || f.includes("y")) {
172
+ throw new OverrideRefusalError(
173
+ path,
174
+ `flags must not contain g or y (got ${JSON.stringify(f)})`,
175
+ harness,
176
+ );
177
+ }
178
+ for (const ch of f) {
179
+ if (!"imsu".includes(ch))
180
+ throw new OverrideRefusalError(path, `flag ${JSON.stringify(ch)} outside imsu`, harness);
181
+ }
182
+ try {
183
+ new RegExp(pattern, f);
184
+ } catch (e) {
185
+ throw new OverrideRefusalError(
186
+ path,
187
+ `uncompilable pattern ${JSON.stringify(pattern)}: ${(e as Error).message}`,
188
+ harness,
189
+ );
190
+ }
191
+ };
192
+
193
+ const validateMatchers = (desc: HarnessDescriptor, path: string, harness: string): void => {
194
+ const limitMatchers = (desc as unknown as { limitMatchers: readonly unknown[] }).limitMatchers;
195
+ const authMatchers = (desc as unknown as { authMatchers: readonly unknown[] }).authMatchers;
196
+ if (Array.isArray(limitMatchers)) {
197
+ if (limitMatchers.length > 64) {
198
+ throw new OverrideRefusalError(path, `more than 64 limit matchers`, harness);
199
+ }
200
+ for (const m of limitMatchers) {
201
+ if (!isPlain(m as unknown as Record<string, unknown>)) {
202
+ throw new OverrideRefusalError(path, `limit matcher must be an object`, harness);
203
+ }
204
+ const obj = m as Record<string, unknown>;
205
+ if (typeof obj.pattern !== "string" || typeof obj.code !== "string") {
206
+ throw new OverrideRefusalError(path, `limit matcher must have pattern and code`, harness);
207
+ }
208
+ validateMatcherPattern(obj.pattern, obj.flags as string | undefined, path, harness);
209
+ }
210
+ }
211
+ if (Array.isArray(authMatchers)) {
212
+ if (authMatchers.length > 64) {
213
+ throw new OverrideRefusalError(path, `more than 64 auth matchers`, harness);
214
+ }
215
+ for (const m of authMatchers) {
216
+ if (!isPlain(m as unknown as Record<string, unknown>)) {
217
+ throw new OverrideRefusalError(path, `auth matcher must be an object`, harness);
218
+ }
219
+ const obj = m as Record<string, unknown>;
220
+ if (typeof obj.pattern !== "string" || typeof obj.kind !== "string") {
221
+ throw new OverrideRefusalError(path, `auth matcher must have pattern and kind`, harness);
222
+ }
223
+ validateMatcherPattern(obj.pattern, obj.flags as string | undefined, path, harness);
224
+ }
225
+ }
226
+ };
227
+
149
228
  /** Parse an override document and merge it over the code defaults. */
150
229
  export const parseOverrides = (jsonText: string, path: string): DescriptorSet => {
151
230
  let doc: unknown;
@@ -176,6 +255,24 @@ export const parseOverrides = (jsonText: string, path: string): DescriptorSet =>
176
255
  if (next.store.template.includes("..")) {
177
256
  refuse('store.template must not contain ".."');
178
257
  }
258
+ // Validate matcher compilation at load time, not at first line
259
+ validateMatchers(next, path, name);
260
+ // Record matcher counts for the spawn boundary event when this descriptor was overridden
261
+ const defaultsForHarness = defaults[name as HarnessName] as HarnessDescriptor;
262
+ const limitChanged =
263
+ (next.limitMatchers as unknown as ReadonlyArray<unknown>).length !==
264
+ (defaultsForHarness.limitMatchers as unknown as ReadonlyArray<unknown>).length ||
265
+ JSON.stringify(next.limitMatchers) !== JSON.stringify(defaultsForHarness.limitMatchers);
266
+ const authChanged =
267
+ (next.authMatchers as unknown as ReadonlyArray<unknown>).length !==
268
+ (defaultsForHarness.authMatchers as unknown as ReadonlyArray<unknown>).length ||
269
+ JSON.stringify(next.authMatchers) !== JSON.stringify(defaultsForHarness.authMatchers);
270
+ if (limitChanged || authChanged) {
271
+ matcherOverridesOf.set(next, {
272
+ limit: (next.limitMatchers as unknown as ReadonlyArray<unknown>).length,
273
+ auth: (next.authMatchers as unknown as ReadonlyArray<unknown>).length,
274
+ });
275
+ }
179
276
  merged[name as HarnessName] = next;
180
277
  }
181
278
  return merged;
@@ -62,7 +62,6 @@ export const piCli: HarnessDescriptor = deepFreeze({
62
62
  models: ["zai/glm-5.2"],
63
63
  aliases: {},
64
64
  efforts: ["off", "minimal", "low", "medium", "high", "xhigh", "max"],
65
- effortFlag: "--thinking",
66
65
  // D-008: providers register models at runtime (~/.pi/models.json), so
67
66
  // the vocabulary is open - validation accepts clean unknown selectors
68
67
  // and capability claims degrade to unknown instead.
@@ -78,9 +77,7 @@ export const piCli: HarnessDescriptor = deepFreeze({
78
77
  },
79
78
  contextHook: null,
80
79
  resumeLast: null,
81
- provider: { flag: "--provider" },
82
80
  stdin: "close-required",
83
- discoveryDisableFlags: ["-nt", "-nc", "-ne", "-ns"],
84
81
  presence: {
85
82
  headlessMarkers: ["-p", "--print"],
86
83
  },
@@ -94,4 +91,29 @@ export const piCli: HarnessDescriptor = deepFreeze({
94
91
  },
95
92
  session: false,
96
93
  },
94
+ turnOptions: {
95
+ effort: { kind: "effort", render: { kind: "flag-value", flag: "--thinking" } },
96
+ provider: { kind: "selector", render: { kind: "flag-value", flag: "--provider" } },
97
+ discovery: {
98
+ kind: "discovery",
99
+ facets: {
100
+ tools: {
101
+ polarity: "disables",
102
+ render: { kind: "flag-list", flags: ["-nt"] },
103
+ },
104
+ instructionFiles: {
105
+ polarity: "disables",
106
+ render: { kind: "flag-list", flags: ["-nc"] },
107
+ },
108
+ extensions: {
109
+ polarity: "disables",
110
+ render: { kind: "flag-list", flags: ["-ne"] },
111
+ },
112
+ skills: {
113
+ polarity: "disables",
114
+ render: { kind: "flag-list", flags: ["-ns"] },
115
+ },
116
+ },
117
+ },
118
+ },
97
119
  });