@dungle-scrubs/harness-cli-normalizer 0.4.4 → 0.4.6

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 (136) hide show
  1. package/README.md +23 -8
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +16 -0
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/config.d.ts.map +1 -1
  6. package/dist/cli/config.js +4 -0
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/help.d.ts +1 -1
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +10 -0
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/cli/inspect.d.ts.map +1 -1
  13. package/dist/cli/inspect.js +74 -24
  14. package/dist/cli/inspect.js.map +1 -1
  15. package/dist/cli/provenance.d.ts +3 -0
  16. package/dist/cli/provenance.d.ts.map +1 -0
  17. package/dist/cli/provenance.js +11 -0
  18. package/dist/cli/provenance.js.map +1 -0
  19. package/dist/cli/run.d.ts.map +1 -1
  20. package/dist/cli/run.js +135 -85
  21. package/dist/cli/run.js.map +1 -1
  22. package/dist/cli/session.d.ts.map +1 -1
  23. package/dist/cli/session.js +3 -3
  24. package/dist/cli/session.js.map +1 -1
  25. package/dist/cli/skills-root.d.ts +2 -1
  26. package/dist/cli/skills-root.d.ts.map +1 -1
  27. package/dist/cli/skills-root.js +6 -4
  28. package/dist/cli/skills-root.js.map +1 -1
  29. package/dist/execution/decode.d.ts +3 -3
  30. package/dist/execution/decode.d.ts.map +1 -1
  31. package/dist/execution/decode.js +46 -13
  32. package/dist/execution/decode.js.map +1 -1
  33. package/dist/execution/deps.d.ts +4 -1
  34. package/dist/execution/deps.d.ts.map +1 -1
  35. package/dist/execution/deps.js +1 -1
  36. package/dist/execution/events.d.ts +7 -2
  37. package/dist/execution/events.d.ts.map +1 -1
  38. package/dist/execution/events.js.map +1 -1
  39. package/dist/execution/failure.d.ts +5 -1
  40. package/dist/execution/failure.d.ts.map +1 -1
  41. package/dist/execution/failure.js +12 -0
  42. package/dist/execution/failure.js.map +1 -1
  43. package/dist/execution/index.d.ts +2 -2
  44. package/dist/execution/index.js +2 -2
  45. package/dist/execution/node-deps.d.ts.map +1 -1
  46. package/dist/execution/node-deps.js +1 -0
  47. package/dist/execution/node-deps.js.map +1 -1
  48. package/dist/execution/open-session.d.ts.map +1 -1
  49. package/dist/execution/open-session.js +54 -8
  50. package/dist/execution/open-session.js.map +1 -1
  51. package/dist/execution/stream-turn.d.ts +4 -0
  52. package/dist/execution/stream-turn.d.ts.map +1 -1
  53. package/dist/execution/stream-turn.js +147 -33
  54. package/dist/execution/stream-turn.js.map +1 -1
  55. package/dist/interpretation/argv.d.ts +7 -0
  56. package/dist/interpretation/argv.d.ts.map +1 -1
  57. package/dist/interpretation/argv.js +28 -5
  58. package/dist/interpretation/argv.js.map +1 -1
  59. package/dist/interpretation/capabilities.d.ts.map +1 -1
  60. package/dist/interpretation/capabilities.js +4 -1
  61. package/dist/interpretation/capabilities.js.map +1 -1
  62. package/dist/interpretation/content.d.ts +9 -1
  63. package/dist/interpretation/content.d.ts.map +1 -1
  64. package/dist/interpretation/content.js +16 -4
  65. package/dist/interpretation/content.js.map +1 -1
  66. package/dist/interpretation/hints.d.ts.map +1 -1
  67. package/dist/interpretation/hints.js +5 -0
  68. package/dist/interpretation/hints.js.map +1 -1
  69. package/dist/interpretation/limits.d.ts +1 -0
  70. package/dist/interpretation/limits.d.ts.map +1 -1
  71. package/dist/interpretation/limits.js +22 -0
  72. package/dist/interpretation/limits.js.map +1 -1
  73. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  74. package/dist/interpretation/resolve-options.js +16 -0
  75. package/dist/interpretation/resolve-options.js.map +1 -1
  76. package/dist/interpretation/tool-selection.d.ts.map +1 -1
  77. package/dist/interpretation/tool-selection.js +25 -0
  78. package/dist/interpretation/tool-selection.js.map +1 -1
  79. package/dist/interpretation/turn-options.d.ts.map +1 -1
  80. package/dist/interpretation/turn-options.js +35 -3
  81. package/dist/interpretation/turn-options.js.map +1 -1
  82. package/dist/interpretation/versions.d.ts +1 -1
  83. package/dist/interpretation/versions.d.ts.map +1 -1
  84. package/dist/knowledge/claude-code.d.ts.map +1 -1
  85. package/dist/knowledge/claude-code.js +21 -0
  86. package/dist/knowledge/claude-code.js.map +1 -1
  87. package/dist/knowledge/codex.d.ts.map +1 -1
  88. package/dist/knowledge/codex.js +9 -0
  89. package/dist/knowledge/codex.js.map +1 -1
  90. package/dist/knowledge/descriptor.d.ts +25 -10
  91. package/dist/knowledge/descriptor.d.ts.map +1 -1
  92. package/dist/knowledge/descriptor.js +6 -0
  93. package/dist/knowledge/descriptor.js.map +1 -1
  94. package/dist/knowledge/index.d.ts +1 -1
  95. package/dist/knowledge/index.d.ts.map +1 -1
  96. package/dist/knowledge/index.js +1 -1
  97. package/dist/knowledge/index.js.map +1 -1
  98. package/dist/knowledge/matchers.d.ts +2 -1
  99. package/dist/knowledge/matchers.d.ts.map +1 -1
  100. package/dist/knowledge/matchers.js +23 -1
  101. package/dist/knowledge/matchers.js.map +1 -1
  102. package/dist/knowledge/pi.d.ts.map +1 -1
  103. package/dist/knowledge/pi.js +24 -1
  104. package/dist/knowledge/pi.js.map +1 -1
  105. package/package.json +1 -1
  106. package/src/cli/args.ts +15 -0
  107. package/src/cli/config.ts +4 -0
  108. package/src/cli/help.ts +10 -0
  109. package/src/cli/inspect.ts +70 -22
  110. package/src/cli/provenance.ts +19 -0
  111. package/src/cli/run.ts +172 -91
  112. package/src/cli/session.ts +4 -3
  113. package/src/cli/skills-root.ts +11 -4
  114. package/src/execution/decode.ts +46 -12
  115. package/src/execution/deps.ts +4 -1
  116. package/src/execution/events.ts +5 -2
  117. package/src/execution/failure.ts +17 -1
  118. package/src/execution/index.ts +2 -2
  119. package/src/execution/node-deps.ts +1 -0
  120. package/src/execution/open-session.ts +63 -6
  121. package/src/execution/stream-turn.ts +150 -31
  122. package/src/interpretation/argv.ts +34 -5
  123. package/src/interpretation/capabilities.ts +4 -1
  124. package/src/interpretation/content.ts +22 -5
  125. package/src/interpretation/hints.ts +8 -0
  126. package/src/interpretation/limits.ts +29 -0
  127. package/src/interpretation/resolve-options.ts +17 -0
  128. package/src/interpretation/tool-selection.ts +25 -0
  129. package/src/interpretation/turn-options.ts +31 -3
  130. package/src/interpretation/versions.ts +1 -1
  131. package/src/knowledge/claude-code.ts +21 -0
  132. package/src/knowledge/codex.ts +9 -0
  133. package/src/knowledge/descriptor.ts +33 -10
  134. package/src/knowledge/index.ts +5 -1
  135. package/src/knowledge/matchers.ts +25 -2
  136. package/src/knowledge/pi.ts +24 -1
@@ -23,7 +23,9 @@ import type {
23
23
  HarnessDescriptor,
24
24
  LimitCode,
25
25
  LimitMatcher,
26
+ TransportMatcher,
26
27
  } from "../knowledge/descriptor.js";
28
+ import { SHARED_TRANSPORT_MATCHERS } from "../knowledge/matchers.js";
27
29
 
28
30
  /** Bottom-up batch scans stop after this many non-empty lines: the wall is
29
31
  * virtually always the last thing a dying turn printed, and an unbounded
@@ -152,3 +154,30 @@ export const detectAuthFailureInLine = (
152
154
 
153
155
  export const detectAuthFailure = (h: HarnessDescriptor, output: string): AuthFailureKind | null =>
154
156
  scanTail(output, compileAuthMatchers(h.authMatchers));
157
+
158
+ const transportCache = new WeakMap<
159
+ ReadonlyArray<TransportMatcher>,
160
+ ReadonlyArray<readonly [RegExp, boolean]>
161
+ >();
162
+
163
+ const compileTransportMatchers = (
164
+ matchers: ReadonlyArray<TransportMatcher>,
165
+ ): ReadonlyArray<readonly [RegExp, boolean]> => {
166
+ const cached = transportCache.get(matchers);
167
+ if (cached !== undefined) return cached;
168
+ if (matchers.length > MAX_MATCHERS_PER_KIND) {
169
+ throw new Error(`more than ${MAX_MATCHERS_PER_KIND} matchers per harness per kind`);
170
+ }
171
+ const compiled = matchers.map((m) => [validateAndCompile(m.pattern, m.flags), true] as const);
172
+ transportCache.set(matchers, compiled);
173
+ return compiled;
174
+ };
175
+
176
+ export const detectTransportInLine = (line: string): boolean => {
177
+ const compiled = compileTransportMatchers(SHARED_TRANSPORT_MATCHERS);
178
+ const windowed = line.slice(0, WINDOW).trim();
179
+ for (const [re] of compiled) {
180
+ if (re.test(windowed)) return true;
181
+ }
182
+ return false;
183
+ };
@@ -163,6 +163,23 @@ export const resolveEffectiveOptions = (
163
163
  // the emit-nothing rule, recorded in provenance. On a harness with
164
164
  // dormant built-ins (pi), it becomes the enabling include list.
165
165
  if (key === "tools" && value === "all-known") {
166
+ // --no-tools containment: a tier that switched discovery.tools off
167
+ // must not have the profile grant switch them back on (pi reads
168
+ // --tools as an enabling allowlist). The tier that turned tools off
169
+ // owns the skip.
170
+ const toolsOff = (o: Partial<TurnOptions> | undefined): boolean =>
171
+ o?.discovery?.tools === false;
172
+ const offTier: ProvenanceTier | undefined = toolsOff(effectiveArgs)
173
+ ? "arg"
174
+ : toolsOff(tiers.project)
175
+ ? "project-config"
176
+ : toolsOff(tiers.user)
177
+ ? "user-config"
178
+ : undefined;
179
+ if (offTier !== undefined) {
180
+ provenance.push({ key, value: "none (discovery.tools off)", tier: offTier });
181
+ continue;
182
+ }
166
183
  const enabled = h.tools.builtins.filter((t) => t.defaultEnabled).length;
167
184
  const all = h.tools.builtins.length;
168
185
  if (enabled === all) {
@@ -107,8 +107,33 @@ export const renderToolSelection = (
107
107
 
108
108
  if (hasInclude) {
109
109
  const names = validateNames(h, selection.include!);
110
+ if (names.length === 0) {
111
+ // Empty include list = deny everything. On pi --tools "" has no
112
+ // defined meaning, so refuse; on claude emit the deny complement
113
+ // without an empty include token.
114
+ if (h.tools.includeIsStrictAllowlist || h.tools.excludeFlag === null) {
115
+ throw new ArgvRefusalError({
116
+ issue: "invalid-tool-grant",
117
+ harness: h.name,
118
+ option: "tools",
119
+ supported: ["non-empty tool list (empty include would deny everything)"],
120
+ detail: "empty include list",
121
+ });
122
+ }
123
+ const known = h.tools.builtins.map((t) => t.name);
124
+ return { tokens: [h.tools.excludeFlag, known.join(",")], unmapped: [] };
125
+ }
110
126
  const { mapped, unmapped } = resolveNames(h, names);
111
127
  if (!h.tools.includeIsStrictAllowlist) {
128
+ if (mapped.length === 0 && unmapped.length > 0) {
129
+ throw new ArgvRefusalError({
130
+ issue: "unknown-tool-name",
131
+ harness: h.name,
132
+ option: "tools",
133
+ supported: [`known tool names: ${h.tools.builtins.map((t) => t.name).join(", ")}`],
134
+ detail: `unknown tool name(s) ${unmapped.join(", ")}`,
135
+ });
136
+ }
112
137
  // claude: exact allowlist must reshape the visible set via the deny
113
138
  // complement (probe 2b). The include flag also carries the granted
114
139
  // names (curated + pass-throughs) so they skip approval prompts;
@@ -227,17 +227,22 @@ export const renderTurnOptions = (
227
227
  supported: resumeSupported.length ? resumeSupported : ["(none)"],
228
228
  });
229
229
  }
230
- // Reject config-kv for non-closed vocabulary
230
+ // Reject config-kv for open vocabularies. Exception (issue #48):
231
+ // prompt-text rides config-kv VERBATIM on codex - both a literal and
232
+ // a path are accepted (live-verified 0.146.1); no quoting, because
233
+ // codex's k=v split takes the rest of the token raw and both probed
234
+ // forms passed unquoted.
231
235
  if (
232
236
  (spec as unknown as SpecBase).render.kind === "config-kv" &&
233
237
  (spec as { kind: string }).kind !== "enum" &&
234
- (spec as { kind: string }).kind !== "effort"
238
+ (spec as { kind: string }).kind !== "effort" &&
239
+ (spec as { kind: string }).kind !== "prompt-text"
235
240
  ) {
236
241
  throw new ArgvRefusalError({
237
242
  issue: "invalid-option-value",
238
243
  harness: h.name,
239
244
  option: key,
240
- supported: ["config-kv only for enum and effort (closed vocabularies)"],
245
+ supported: ["config-kv only for enum, effort, and prompt-text (issue #48)"],
241
246
  });
242
247
  }
243
248
  }
@@ -320,6 +325,29 @@ export const renderTurnOptions = (
320
325
  sequences.push(seq);
321
326
  break;
322
327
  }
328
+ case "prompt-text": {
329
+ // issue #48: free-form prompt prose (systemPrompt / appendSystemPrompt).
330
+ // No closed vocabulary - validate non-empty string, render verbatim.
331
+ // Values may be multi-line and may contain shell-hostile characters;
332
+ // they cross as single argv tokens, never through a shell.
333
+ if (typeof raw !== "string" || raw.trim() === "") {
334
+ throw new ArgvRefusalError({
335
+ issue: "invalid-option-value",
336
+ harness: h.name,
337
+ option: key,
338
+ supported: ["non-empty prompt text"],
339
+ detail: typeof raw === "string" ? "(empty)" : String(raw),
340
+ });
341
+ }
342
+ const r = effectiveRender!;
343
+ let seq: string[];
344
+ if (r.kind === "flag-value") seq = [...(r.extraFlags ?? []), r.flag, raw];
345
+ else if (r.kind === "config-kv") seq = [r.flag, `${r.key}=${raw}`];
346
+ else if (r.kind === "flag-list") seq = [...r.flags];
347
+ else seq = [];
348
+ sequences.push(seq);
349
+ break;
350
+ }
323
351
  case "selector": {
324
352
  if (typeof raw !== "string") {
325
353
  throw new ArgvRefusalError({
@@ -7,7 +7,7 @@
7
7
  * precedence.
8
8
  */
9
9
 
10
- export type VersionStatus = "ok" | "behind" | "ahead" | "unknown";
10
+ export type VersionStatus = "ok" | "behind" | "ahead" | "unknown" | "drift";
11
11
 
12
12
  const parse = (v: string): number[] =>
13
13
  v
@@ -49,7 +49,10 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
49
49
  // A-001: one process, many turns; `result` delimits turns; mid-turn sends
50
50
  // queue. --setting-sources project isolates the child from user-level
51
51
  // hooks (D-025). Token deltas require this exact output flag set.
52
+ // sessionMode.flags is the complete flag list after the binary, so -p
53
+ // lives here rather than being inherited from launch.baseFlags.
52
54
  flags: [
55
+ "-p",
53
56
  "--input-format",
54
57
  "stream-json",
55
58
  "--output-format",
@@ -139,6 +142,24 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
139
142
  },
140
143
  turnOptions: {
141
144
  effort: { kind: "effort", render: { kind: "flag-value", flag: "--effort" } },
145
+ // issue #48, live-verified 2.1.235: --system-prompt replaces the built-in
146
+ // prompt; --exclude-dynamic-system-prompt-sections strips the dynamic
147
+ // sections (git state, directory listing) that ride even under a
148
+ // replacement. hcn pairs them - a payload-stripping replacement without
149
+ // the exclusion keeps injected sections. Verified live: the pair changes
150
+ // model behavior (NAKED-HAIKU probe vs BASELINE-OK).
151
+ systemPrompt: {
152
+ kind: "prompt-text",
153
+ render: {
154
+ kind: "flag-value",
155
+ flag: "--system-prompt",
156
+ extraFlags: ["--exclude-dynamic-system-prompt-sections"],
157
+ },
158
+ },
159
+ appendSystemPrompt: {
160
+ kind: "prompt-text",
161
+ render: { kind: "flag-value", flag: "--append-system-prompt" },
162
+ },
142
163
  discovery: {
143
164
  kind: "discovery",
144
165
  facets: {
@@ -106,6 +106,15 @@ export const codexCli: HarnessDescriptor = deepFreeze({
106
106
  kind: "effort",
107
107
  render: { kind: "config-kv", flag: "-c", key: "model_reasoning_effort" },
108
108
  },
109
+ // issue #48, live-verified 0.146.1 under --strict-config: the config key
110
+ // `instructions` accepts BOTH a literal string (LITERAL-OK probe) and a
111
+ // file path (FILE-OK probe); hcn passes the value verbatim and codex
112
+ // validates. `model_instructions` / `experimental_instructions_file`
113
+ // are refused by codex (probed) - wrong spellings, not alternates.
114
+ systemPrompt: {
115
+ kind: "prompt-text",
116
+ render: { kind: "config-kv", flag: "-c", key: "instructions" },
117
+ },
109
118
  sandbox: {
110
119
  kind: "enum",
111
120
  values: ["read-only", "workspace-write", "danger-full-access"],
@@ -71,6 +71,11 @@ export interface AuthMatcher {
71
71
  readonly kind: AuthFailureKind;
72
72
  }
73
73
 
74
+ export interface TransportMatcher {
75
+ readonly pattern: string;
76
+ readonly flags?: string;
77
+ }
78
+
74
79
  /** The turn-option vocabulary is closed for the same reason `LimitCode` is:
75
80
  * a descriptor must not invent an option a consumer has no field for. Every
76
81
  * key here maps to a field on `TurnOptions`; adding a key without a consumer
@@ -84,6 +89,12 @@ export const TURN_OPTION_KEYS = deepFreeze([
84
89
  "write",
85
90
  "shell",
86
91
  "maxSteps",
92
+ // issue #48 (ratified 2026-08-20): the payload-stripping dimensions.
93
+ // systemPrompt replaces the built-in prompt; appendSystemPrompt adds to
94
+ // it. Both are opt-in-only (arg/config, no profile entry) - a default
95
+ // that strips the payload changes every bare run's semantics.
96
+ "systemPrompt",
97
+ "appendSystemPrompt",
87
98
  ] as const);
88
99
  export type TurnOptionKey = (typeof TURN_OPTION_KEYS)[number];
89
100
 
@@ -101,8 +112,14 @@ export const DISCOVERY_FACETS = deepFreeze([
101
112
  export type DiscoveryFacet = (typeof DISCOVERY_FACETS)[number];
102
113
 
103
114
  export type OptionRender =
104
- /** `--flag <value>` */
105
- | { readonly kind: "flag-value"; readonly flag: string }
115
+ /** `--flag <value>`; `extraFlags` are fixed companion tokens emitted
116
+ * before the value pair (claude's --system-prompt pairs with
117
+ * --exclude-dynamic-system-prompt-sections - issue #48). */
118
+ | {
119
+ readonly kind: "flag-value";
120
+ readonly flag: string;
121
+ readonly extraFlags?: readonly string[];
122
+ }
106
123
  /** `-c key=value` - codex's config-override grammar. Permitted only for
107
124
  * closed-vocabulary specs, so no value can need escaping. */
108
125
  | { readonly kind: "config-kv"; readonly flag: string; readonly key: string }
@@ -128,6 +145,10 @@ export type TurnOptionSpec =
128
145
  | (SpecBase & { readonly kind: "effort" })
129
146
  /** Open selector, CLEAN_SELECTOR-validated. */
130
147
  | (SpecBase & { readonly kind: "selector" })
148
+ /** Free-form prompt text (issue #48): systemPrompt / appendSystemPrompt.
149
+ * Values are prose, never a closed vocabulary - no validation beyond
150
+ * non-emptiness, rendering is verbatim. */
151
+ | (SpecBase & { readonly kind: "prompt-text" })
131
152
  /** `polarity: "disables"` emits the render when the caller asks for FALSE. */
132
153
  | (SpecBase & { readonly kind: "toggle"; readonly polarity: "enables" | "disables" })
133
154
  | (SpecBase & { readonly kind: "integer"; readonly min: number; readonly max: number })
@@ -239,14 +260,16 @@ export interface HarnessDescriptor {
239
260
  * harness. */
240
261
  readonly onMissing: "error" | "create";
241
262
  };
242
- /** Persistent headless session support: the exact flag set that opens one
243
- * lucid-owned process serving many turns, or null when the harness has no
244
- * such mode. `idFlag` pins the caller-assigned session identity.
245
- * `turnEnd` is the stdout record that delimits one turn (claude: the
246
- * `result` record; pi rpc: `agent_settled`). `identityProbe`, when
247
- * present, names a command the runner writes at spawn whose response
248
- * carries the session id - pi rpc is identity-silent at startup (spike
249
- * evidence: test/fixtures/pi-rpc-spike), so identity needs a round trip. */
263
+ /** Persistent headless session support: the complete flag list that follows
264
+ * the binary to open one lucid-owned process serving many turns, or null
265
+ * when the harness has no such mode. This is the entire argv prefix after
266
+ * the binary - no launch flags are prepended. `idFlag` pins the
267
+ * caller-assigned session identity. `turnEnd` is the stdout record that
268
+ * delimits one turn (claude: the `result` record; pi rpc:
269
+ * `agent_settled`). `identityProbe`, when present, names a command the
270
+ * runner writes at spawn whose response carries the session id - pi rpc
271
+ * is identity-silent at startup (spike evidence:
272
+ * test/fixtures/pi-rpc-spike), so identity needs a round trip. */
250
273
  readonly sessionMode: {
251
274
  readonly flags: readonly string[];
252
275
  /** Pin an EXISTING session id; null when the harness only accepts
@@ -12,7 +12,11 @@
12
12
  export { claudeCode } from "./claude-code.js";
13
13
  export { codexCli } from "./codex.js";
14
14
  export * from "./descriptor.js";
15
- export { SHARED_AUTH_MATCHERS, SHARED_LIMIT_MATCHERS } from "./matchers.js";
15
+ export {
16
+ SHARED_AUTH_MATCHERS,
17
+ SHARED_LIMIT_MATCHERS,
18
+ SHARED_TRANSPORT_MATCHERS,
19
+ } from "./matchers.js";
16
20
  export { museCode } from "./muse.js";
17
21
  export {
18
22
  type DescriptorSet,
@@ -4,7 +4,7 @@
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 { AuthMatcher, LimitMatcher } from "./descriptor.js";
7
+ import type { AuthMatcher, LimitMatcher, TransportMatcher } from "./descriptor.js";
8
8
 
9
9
  export const SHARED_LIMIT_MATCHERS: ReadonlyArray<LimitMatcher> = [
10
10
  { pattern: "you'?ve hit your usage limit", flags: "i", code: "usage-limit" },
@@ -20,7 +20,14 @@ export const SHARED_LIMIT_MATCHERS: ReadonlyArray<LimitMatcher> = [
20
20
  code: "quota",
21
21
  },
22
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" },
23
+ // 429 only next to an HTTP status word: a bare digit run inside an id, a
24
+ // byte count, or a timing must never read as a wall. "429 Too Many
25
+ // Requests" is covered by the next matcher.
26
+ {
27
+ pattern: "\\b(?:HTTP|status(?:[_ ]?code)?|code)\\b\\W*[:=]?\\W*429\\b",
28
+ flags: "i",
29
+ code: "rate-limit",
30
+ },
24
31
  { pattern: "Too Many Requests", flags: "i", code: "rate-limit" },
25
32
  { pattern: "rate limit(?:ed|ing)?", flags: "i", code: "rate-limit" },
26
33
  { pattern: "Retry-After", flags: "i", code: "rate-limit" },
@@ -30,3 +37,19 @@ export const SHARED_AUTH_MATCHERS: ReadonlyArray<AuthMatcher> = [
30
37
  { pattern: "401 unauthorized", flags: "i", kind: "expired" },
31
38
  { pattern: "invalid api key", flags: "i", kind: "invalid-key" },
32
39
  ];
40
+
41
+ export const SHARED_TRANSPORT_MATCHERS: ReadonlyArray<TransportMatcher> = [
42
+ { pattern: "connection error", flags: "i" },
43
+ { pattern: "ECONNREFUSED", flags: "i" },
44
+ { pattern: "ECONNRESET", flags: "i" },
45
+ { pattern: "ENOTFOUND", flags: "i" },
46
+ { pattern: "EAI_AGAIN", flags: "i" },
47
+ { pattern: "ETIMEDOUT", flags: "i" },
48
+ { pattern: "fetch failed", flags: "i" },
49
+ { pattern: "socket hang up", flags: "i" },
50
+ { pattern: "network error", flags: "i" },
51
+ { pattern: "service unavailable", flags: "i" },
52
+ { pattern: "bad gateway", flags: "i" },
53
+ { pattern: "gateway time-?out", flags: "i" },
54
+ { pattern: "\\b(?:HTTP|status(?:[_ ]?code)?|code)\\b\\W*[:=]?\\W*50[234]\\b", flags: "i" },
55
+ ];
@@ -30,6 +30,15 @@ export const piCli: HarnessDescriptor = deepFreeze({
30
30
  // Caller-assigned: the same --session-id re-enters the session. Resume
31
31
  // carries the same structured-output flags launch does, or a resumed
32
32
  // turn would stream plain text the runner cannot decode.
33
+ // Verified 2026-08-21 on pi 0.84.2: a session id minted by `pi --mode rpc`
34
+ // via the get_state probe (01a022e3-9afb-7ce5-88f5-07ad0e9ac8fa) resumed
35
+ // after close through the one-shot grammar `pi --session-id <id> -p
36
+ // --mode json` (flag --session-id, extraFlags -p --mode json), exit 0,
37
+ // 207 lines, and the reply recalled the codeword "otter" (evidence at
38
+ // test/fixtures/pi-rpc-spike/06-resume-after-close/: rpc-turn.ndjson,
39
+ // resume.argv.json, resume.ndjson, README.md). If the resume had not
40
+ // recalled the codeword, this comment would say so and the descriptor
41
+ // would not be changed here.
33
42
  style: "flag",
34
43
  flag: "--session-id",
35
44
  aliases: [],
@@ -69,7 +78,10 @@ export const piCli: HarnessDescriptor = deepFreeze({
69
78
  announce: { match: { type: "session" }, idField: "id" },
70
79
  },
71
80
  limitMatchers: [...SHARED_LIMIT_MATCHERS],
72
- authMatchers: [...SHARED_AUTH_MATCHERS],
81
+ authMatchers: [
82
+ { pattern: "No API key found for", flags: "i", kind: "not-logged-in" },
83
+ ...SHARED_AUTH_MATCHERS,
84
+ ],
73
85
  autonomy: null,
74
86
  vocabulary: {
75
87
  modelFlag: "--model",
@@ -110,6 +122,17 @@ export const piCli: HarnessDescriptor = deepFreeze({
110
122
  turnOptions: {
111
123
  effort: { kind: "effort", render: { kind: "flag-value", flag: "--thinking" } },
112
124
  provider: { kind: "selector", render: { kind: "flag-value", flag: "--provider" } },
125
+ // issue #48, live-verified 0.84.2: pi's --system-prompt replaces the
126
+ // default coding-assistant prompt (PI-NAKED probe). No dynamic-section
127
+ // exclusion exists - pi injects no such sections into a replaced prompt.
128
+ systemPrompt: {
129
+ kind: "prompt-text",
130
+ render: { kind: "flag-value", flag: "--system-prompt" },
131
+ },
132
+ appendSystemPrompt: {
133
+ kind: "prompt-text",
134
+ render: { kind: "flag-value", flag: "--append-system-prompt" },
135
+ },
113
136
  discovery: {
114
137
  kind: "discovery",
115
138
  facets: {