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

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 (99) hide show
  1. package/README.md +14 -5
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +8 -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 +7 -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 +57 -24
  14. package/dist/cli/inspect.js.map +1 -1
  15. package/dist/cli/run.d.ts.map +1 -1
  16. package/dist/cli/run.js +125 -65
  17. package/dist/cli/run.js.map +1 -1
  18. package/dist/execution/decode.d.ts +3 -3
  19. package/dist/execution/decode.d.ts.map +1 -1
  20. package/dist/execution/decode.js +44 -12
  21. package/dist/execution/decode.js.map +1 -1
  22. package/dist/execution/deps.d.ts +3 -0
  23. package/dist/execution/deps.d.ts.map +1 -1
  24. package/dist/execution/events.d.ts +7 -2
  25. package/dist/execution/events.d.ts.map +1 -1
  26. package/dist/execution/events.js.map +1 -1
  27. package/dist/execution/node-deps.d.ts.map +1 -1
  28. package/dist/execution/node-deps.js +1 -0
  29. package/dist/execution/node-deps.js.map +1 -1
  30. package/dist/execution/open-session.d.ts.map +1 -1
  31. package/dist/execution/open-session.js +36 -5
  32. package/dist/execution/open-session.js.map +1 -1
  33. package/dist/execution/stream-turn.d.ts +4 -0
  34. package/dist/execution/stream-turn.d.ts.map +1 -1
  35. package/dist/execution/stream-turn.js +135 -29
  36. package/dist/execution/stream-turn.js.map +1 -1
  37. package/dist/interpretation/argv.d.ts +7 -0
  38. package/dist/interpretation/argv.d.ts.map +1 -1
  39. package/dist/interpretation/argv.js +24 -4
  40. package/dist/interpretation/argv.js.map +1 -1
  41. package/dist/interpretation/capabilities.d.ts.map +1 -1
  42. package/dist/interpretation/capabilities.js +4 -1
  43. package/dist/interpretation/capabilities.js.map +1 -1
  44. package/dist/interpretation/content.d.ts +9 -1
  45. package/dist/interpretation/content.d.ts.map +1 -1
  46. package/dist/interpretation/content.js +13 -4
  47. package/dist/interpretation/content.js.map +1 -1
  48. package/dist/interpretation/hints.d.ts.map +1 -1
  49. package/dist/interpretation/hints.js +5 -0
  50. package/dist/interpretation/hints.js.map +1 -1
  51. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  52. package/dist/interpretation/resolve-options.js +16 -0
  53. package/dist/interpretation/resolve-options.js.map +1 -1
  54. package/dist/interpretation/tool-selection.d.ts.map +1 -1
  55. package/dist/interpretation/tool-selection.js +9 -0
  56. package/dist/interpretation/tool-selection.js.map +1 -1
  57. package/dist/interpretation/turn-options.d.ts.map +1 -1
  58. package/dist/interpretation/turn-options.js +35 -3
  59. package/dist/interpretation/turn-options.js.map +1 -1
  60. package/dist/knowledge/claude-code.d.ts.map +1 -1
  61. package/dist/knowledge/claude-code.js +21 -0
  62. package/dist/knowledge/claude-code.js.map +1 -1
  63. package/dist/knowledge/codex.d.ts.map +1 -1
  64. package/dist/knowledge/codex.js +9 -0
  65. package/dist/knowledge/codex.js.map +1 -1
  66. package/dist/knowledge/descriptor.d.ts +21 -10
  67. package/dist/knowledge/descriptor.d.ts.map +1 -1
  68. package/dist/knowledge/descriptor.js +6 -0
  69. package/dist/knowledge/descriptor.js.map +1 -1
  70. package/dist/knowledge/matchers.d.ts.map +1 -1
  71. package/dist/knowledge/matchers.js +8 -1
  72. package/dist/knowledge/matchers.js.map +1 -1
  73. package/dist/knowledge/pi.d.ts.map +1 -1
  74. package/dist/knowledge/pi.js +20 -0
  75. package/dist/knowledge/pi.js.map +1 -1
  76. package/package.json +1 -1
  77. package/src/cli/args.ts +7 -0
  78. package/src/cli/config.ts +4 -0
  79. package/src/cli/help.ts +7 -0
  80. package/src/cli/inspect.ts +54 -22
  81. package/src/cli/run.ts +166 -67
  82. package/src/execution/decode.ts +44 -11
  83. package/src/execution/deps.ts +3 -0
  84. package/src/execution/events.ts +5 -2
  85. package/src/execution/node-deps.ts +1 -0
  86. package/src/execution/open-session.ts +34 -3
  87. package/src/execution/stream-turn.ts +134 -27
  88. package/src/interpretation/argv.ts +30 -4
  89. package/src/interpretation/capabilities.ts +4 -1
  90. package/src/interpretation/content.ts +18 -5
  91. package/src/interpretation/hints.ts +8 -0
  92. package/src/interpretation/resolve-options.ts +17 -0
  93. package/src/interpretation/tool-selection.ts +9 -0
  94. package/src/interpretation/turn-options.ts +31 -3
  95. package/src/knowledge/claude-code.ts +21 -0
  96. package/src/knowledge/codex.ts +9 -0
  97. package/src/knowledge/descriptor.ts +28 -10
  98. package/src/knowledge/matchers.ts +8 -1
  99. package/src/knowledge/pi.ts +20 -0
@@ -31,6 +31,7 @@ import {
31
31
  failureFromLimit,
32
32
  failureFromNative,
33
33
  failureFromRejected,
34
+ failureFromTask,
34
35
  failureFromTimeout,
35
36
  failureFromTransport,
36
37
  reduceFailures,
@@ -110,6 +111,10 @@ export interface TurnRunOptions extends LaunchOptions {
110
111
  * disarms detection. Applies on launch AND resume: it shapes each
111
112
  * turn's prompt and event stream, never a session setting. */
112
113
  readonly escalateQuestions?: boolean;
114
+ /** F-05: caller-requested stop. When aborted, the runner escalates
115
+ * SIGTERM then SIGKILL and classifies the exit as killed with no
116
+ * transport failure for the kill itself. */
117
+ readonly signal?: AbortSignal;
113
118
  }
114
119
 
115
120
  export async function* streamTurn(
@@ -229,6 +234,14 @@ export async function* streamTurn(
229
234
  ...(envKeys?.length ? { envKeys } : {}),
230
235
  });
231
236
 
237
+ // F-23: create-on-missing resume warns before spawn - the harness will
238
+ // accept any id and silently start a blank session, so the consumer
239
+ // must verify the id exists.
240
+ const resumeOnMissingCreate = effective.resume !== undefined && h.resume.onMissing === "create";
241
+ const resumeCreateWarning = resumeOnMissingCreate
242
+ ? `${h.name} creates a new session when ${effective.resume} is unknown; verify the id exists`
243
+ : null;
244
+
232
245
  let proc: SpawnedProcess;
233
246
  try {
234
247
  proc = deps.spawn(argv, {
@@ -248,6 +261,7 @@ export async function* streamTurn(
248
261
  cause: "crash",
249
262
  spawnError: message,
250
263
  });
264
+ if (resumeCreateWarning !== null) yield { kind: "error", message: resumeCreateWarning };
251
265
  yield { kind: "error", message: `spawn failed: ${message}` };
252
266
  yield { kind: "failure", ...failure };
253
267
  yield { kind: "done", exitCode: 127, cause: "failed", failure };
@@ -255,9 +269,13 @@ export async function* streamTurn(
255
269
  }
256
270
 
257
271
  const queue = new AsyncChannel<HarnessEvent>();
272
+ // F-23 warning is an early stream event, before any harness output
273
+ if (resumeCreateWarning !== null)
274
+ void queue.push({ kind: "error", message: resumeCreateWarning });
258
275
  const state = freshDecodeState(effective.resume ?? null);
259
276
  const stderrTail = new StderrTail();
260
277
  let killedByWatchdog = false;
278
+ let killedByAbort = false;
261
279
  let exited = false;
262
280
  let exitCode: number | null = null;
263
281
  let pipesOpenAtExit = false;
@@ -276,6 +294,21 @@ export async function* streamTurn(
276
294
  safeSignal("SIGKILL");
277
295
  }, KILL_GRACE_MS);
278
296
  };
297
+ let abortHandler: (() => void) | null = null;
298
+ if (opts.signal) {
299
+ const onAbort = (): void => {
300
+ if (killedByAbort) return;
301
+ killedByAbort = true;
302
+ escalate();
303
+ };
304
+ if (opts.signal.aborted) {
305
+ killedByAbort = true;
306
+ escalate();
307
+ } else {
308
+ opts.signal.addEventListener("abort", onAbort, { once: true });
309
+ abortHandler = onAbort;
310
+ }
311
+ }
279
312
 
280
313
  const failures: FailureSummary[] = [];
281
314
  const pushFailure = async (f: FailureSummary): Promise<void> => {
@@ -354,32 +387,73 @@ export async function* streamTurn(
354
387
 
355
388
  const pumpStdout = async (): Promise<void> => {
356
389
  const lines = new LineBuffer();
390
+ let identitySeen = false;
391
+ const droppableBuffer: HarnessEvent[] = [];
392
+ const BUFFER_CAP = 256;
393
+ const isDroppable = (kind: string): boolean =>
394
+ kind === "progress" || kind === "token" || kind === "context";
395
+ const flushDroppable = async (): Promise<void> => {
396
+ for (const e of droppableBuffer) await queue.push(e);
397
+ droppableBuffer.length = 0;
398
+ };
399
+ const handleEvent = async (event: HarnessEvent): Promise<void> => {
400
+ if (!identitySeen) {
401
+ if (event.kind === "identity") {
402
+ identitySeen = true;
403
+ await queue.push(event);
404
+ await flushDroppable();
405
+ return;
406
+ }
407
+ if (isDroppable(event.kind)) {
408
+ if (droppableBuffer.length >= BUFFER_CAP) droppableBuffer.shift();
409
+ droppableBuffer.push(event);
410
+ return;
411
+ }
412
+ // Lossless events other than identity flush the buffer before themselves
413
+ await flushDroppable();
414
+ }
415
+ if (event.kind === "failure") {
416
+ const { kind: _kind, ...summary } = event;
417
+ await pushFailure(summary);
418
+ return;
419
+ }
420
+ if (event.kind === "limit") {
421
+ // A wall decoded from stdout counts like one read on stderr: the
422
+ // turn's done must carry it, not only the limit event.
423
+ await queue.push(event);
424
+ await pushFailure(failureFromLimit(event.code));
425
+ return;
426
+ }
427
+ if (event.kind === "error") {
428
+ await queue.push(event);
429
+ if (event.terminal === true) await pushFailure(failureFromTask(event.message));
430
+ return;
431
+ }
432
+ if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
433
+ lastAssistantText = event.text;
434
+ }
435
+ await queue.push(event);
436
+ };
357
437
  for await (const chunk of proc.stdout) {
358
438
  if (cancelled) break;
359
439
  // Any output chunk rearms the inactivity budget, but not the wall-clock deadline
360
440
  if (deps.stallMs !== undefined) rearm();
361
441
  for (const line of lines.push(chunk)) {
362
- for (const event of decodeLine(h, line, state, opts.model ?? "")) {
363
- if ((event as unknown as { kind: string }).kind === "failure") {
364
- // Directly from decode's rate_limit_event handling - track for reduction
365
- failures.push(event as unknown as FailureSummary);
366
- }
367
- if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
368
- lastAssistantText = event.text;
369
- }
370
- await queue.push(event);
442
+ for (const event of decodeLine(h, line, state, opts.model ?? "", granularity)) {
443
+ await handleEvent(event);
371
444
  }
372
445
  }
373
446
  }
374
447
  const rest = lines.flush();
375
448
  if (rest !== null && !cancelled) {
376
- for (const event of decodeLine(h, rest, state, opts.model ?? "")) {
377
- if ((event as unknown as { kind: string }).kind === "failure") {
378
- failures.push(event as unknown as FailureSummary);
379
- }
380
- await queue.push(event);
449
+ for (const event of decodeLine(h, rest, state, opts.model ?? "", granularity)) {
450
+ await handleEvent(event);
381
451
  }
382
452
  }
453
+ // Flush at exit if no identity ever arrived
454
+ if (!identitySeen && droppableBuffer.length > 0) {
455
+ await flushDroppable();
456
+ }
383
457
  };
384
458
 
385
459
  /** issue #41: scan the last assistant message for the hcn-question
@@ -477,6 +551,24 @@ export async function* streamTurn(
477
551
  try {
478
552
  for await (const event of queue) yield event;
479
553
 
554
+ // F-04: a harness binary that is not installed surfaces as an
555
+ // async ENOENT. The adapter records it in startupError and resolves
556
+ // exited with 127 while appending `spawn failed:` to stderr. Treat
557
+ // it like the synchronous-throw branch: transport failure, retryable,
558
+ // done cause failed with the real exit code.
559
+ const startupMessage = proc.startupError?.() ?? null;
560
+ let startupFailed = false;
561
+ if (startupMessage !== null && failures.length === 0) {
562
+ const f = failureFromTransport(`spawn failed: ${startupMessage}`);
563
+ failures.push(f);
564
+ startupFailed = true;
565
+ // The stderr pump appends the spawn line to the tail but does not
566
+ // emit an error event for it; emit the error here to match the sync
567
+ // branch, and suppress the later tail-error path for this case.
568
+ yield { kind: "error", message: `spawn failed: ${startupMessage}` };
569
+ yield { kind: "failure", ...f };
570
+ }
571
+
480
572
  // Post-queue failure sources. Nonzero exit with no other failure and a
481
573
  // non-empty stderr tail is a NATIVE failure (D6): the harness rejected
482
574
  // its own arguments or crashed on them - verbatim stderr, native exit
@@ -484,6 +576,8 @@ export async function* streamTurn(
484
576
  // (a silent nonzero exit reads as an environment problem, not a
485
577
  // harness judgment).
486
578
  if (
579
+ !startupFailed &&
580
+ !killedByAbort &&
487
581
  failures.length === 0 &&
488
582
  exitCode !== 0 &&
489
583
  exitCode !== null &&
@@ -500,7 +594,7 @@ export async function* streamTurn(
500
594
  yield { kind: "failure", ...f };
501
595
  }
502
596
  // Stall watchdog also implies a transport failure if not already present
503
- if (killedByWatchdog && failures.length === 0) {
597
+ if (killedByWatchdog && !killedByAbort && failures.length === 0) {
504
598
  // D11: a wall-clock deadline kill is a timeout, not a stall - the
505
599
  // run was not necessarily silent, it simply outlived its budget.
506
600
  const f =
@@ -513,19 +607,29 @@ export async function* streamTurn(
513
607
 
514
608
  let cause: ExitCause = state.limitSeen
515
609
  ? "limit"
516
- : killedByWatchdog && exitCode !== 0
517
- ? watchdogReason === "turn-deadline"
518
- ? "killed" // D11: the run was killed on budget, not stalled
519
- : "stall"
520
- : exitCode === 0
521
- ? asked
522
- ? "awaiting-input" // issue #41: asking SUCCEEDED the turn
523
- : "clean"
524
- : exitCode === null
525
- ? "killed"
526
- : "crash";
610
+ : killedByAbort
611
+ ? "killed"
612
+ : killedByWatchdog && exitCode !== 0
613
+ ? watchdogReason === "turn-deadline"
614
+ ? "killed" // D11: the run was killed on budget, not stalled
615
+ : "stall"
616
+ : exitCode === 0
617
+ ? asked
618
+ ? "awaiting-input" // issue #41: asking SUCCEEDED the turn
619
+ : "clean"
620
+ : exitCode === null
621
+ ? "killed"
622
+ : startupFailed
623
+ ? "failed"
624
+ : "crash";
527
625
  const reduced = reduceFailures(failures);
528
626
  if (reduced && cause === "clean") cause = "failed";
627
+ // A work-verdict failure (the model ran out of steps, or ended its
628
+ // turn in error) is not a harness crash even when the process exits
629
+ // nonzero: the cause is failed and the real exit code rides along.
630
+ if ((reduced?.class === "budget" || reduced?.class === "task") && cause === "crash") {
631
+ cause = "failed";
632
+ }
529
633
  const tail = stderrTail.snapshot();
530
634
  log({
531
635
  event: "exit",
@@ -542,7 +646,9 @@ export async function* streamTurn(
542
646
  // A failure with captured stderr surfaces as a stream-level error, not
543
647
  // only in the exit log - so a crash from the real adapter's async spawn
544
648
  // failure carries the same error-event signal as the sync-throw path.
545
- if ((cause === "crash" || cause === "killed") && tail.length > 0) {
649
+ // F-04: the startupError path already emitted the spawn error; do not
650
+ // duplicate it via the tail.
651
+ if (!startupFailed && (cause === "crash" || cause === "killed") && tail.length > 0) {
546
652
  yield { kind: "error", message: tail.join("\n").slice(0, 4096) };
547
653
  }
548
654
  terminalEventReached = true;
@@ -559,6 +665,7 @@ export async function* streamTurn(
559
665
  ...(reduced ? { failure: reduced } : {}),
560
666
  };
561
667
  } finally {
668
+ if (abortHandler !== null) opts.signal?.removeEventListener("abort", abortHandler);
562
669
  const abandoned = !terminalEventReached;
563
670
  cancelled = true;
564
671
  queue.close();
@@ -7,7 +7,7 @@
7
7
  import type { HarnessDescriptor, StreamingGranularity } from "../knowledge/descriptor.js";
8
8
  import { defaultDescriptors } from "../knowledge/overrides.js";
9
9
  import { ArgvRefusalError } from "./refusal.js";
10
- import { assertUsableSessionId } from "./session-id.js";
10
+ import { assertUsableSessionId, SESSION_ID_MAX, SessionIdRefusalError } from "./session-id.js";
11
11
  import { renderSkillsSelection } from "./skills-selection.js";
12
12
  import { supportedBy } from "./support.js";
13
13
  import { renderToolSelection } from "./tool-selection.js";
@@ -69,6 +69,13 @@ export interface TurnOptions {
69
69
  readonly write?: boolean;
70
70
  readonly shell?: boolean;
71
71
  readonly maxSteps?: number;
72
+ /** issue #48: replaces the harness's built-in system prompt (opt-in-only,
73
+ * no profile entry). claude/pi: flag-value (claude pairs the dynamic-section
74
+ * exclusion); codex: config-kv `instructions` (literal or path); muse:
75
+ * refuses with hint. */
76
+ readonly systemPrompt?: string;
77
+ /** issue #48: appends to the built-in prompt (claude/pi only). */
78
+ readonly appendSystemPrompt?: string;
72
79
  /** issue #41: question escalation - a BEHAVIOR INSTRUCTION, not a turn
73
80
  * option. It never renders into any harness argv; the CLI layer turns
74
81
  * it into the prompt preamble and arms question-block detection.
@@ -136,8 +143,28 @@ export const buildLaunchArgv = (h: HarnessDescriptor, opts: LaunchOptions): stri
136
143
  ...turnTail(h, opts),
137
144
  ];
138
145
 
146
+ /** A session id that fails the shape rule is a spawn-boundary refusal like
147
+ * any other: typed, so streamTurn turns it into failure + done and the CLI
148
+ * exits 2, instead of a bare SessionIdRefusalError escaping the runner. */
149
+ const refuseUnusableSessionId = (h: HarnessDescriptor, sessionId: string): void => {
150
+ try {
151
+ assertUsableSessionId(sessionId);
152
+ } catch (e) {
153
+ if (!(e instanceof SessionIdRefusalError)) throw e;
154
+ throw new ArgvRefusalError({
155
+ issue: "invalid-option-value",
156
+ harness: h.name,
157
+ message: `${h.name} cannot resume ${e.message}`,
158
+ supported: [
159
+ `a session id of letters, digits, '.', '_', ':', '@', '-' only, starting with a letter or digit, at most ${SESSION_ID_MAX} chars`,
160
+ ],
161
+ detail: e.message,
162
+ });
163
+ }
164
+ };
165
+
139
166
  export const buildResumeArgv = (h: HarnessDescriptor, opts: ResumeOptions): string[] => {
140
- assertUsableSessionId(opts.sessionId);
167
+ refuseUnusableSessionId(h, opts.sessionId);
141
168
  // Subcommands lead, then the resume token and id, then the flags the
142
169
  // RESUME grammar accepts (never inherited launch flags - codex exec
143
170
  // resume rejects --sandbox). One shape serves both styles:
@@ -167,10 +194,9 @@ export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): st
167
194
  supported: ["session is available where sessionMode is declared"],
168
195
  });
169
196
  }
170
- assertUsableSessionId(opts.sessionId);
197
+ refuseUnusableSessionId(h, opts.sessionId);
171
198
  const argv = [
172
199
  h.bin,
173
- ...h.launch.baseFlags,
174
200
  ...h.sessionMode.flags,
175
201
  // idFlag null = the harness refuses unknown ids and mints its own
176
202
  // (pi rpc); the caller-side sessionId stays a correlation handle.
@@ -27,7 +27,10 @@ export const capabilitiesOf = (
27
27
  model: string,
28
28
  mode: HarnessMode,
29
29
  ): CapabilityResult => {
30
- if (!resolveModel(h, model).curated) {
30
+ // F-09: an absent model means the harness default model (curated), not
31
+ // an unknown model. Only degrade when a model was explicitly given and is
32
+ // not curated.
33
+ if (model !== "" && !resolveModel(h, model).curated) {
31
34
  // Curated claims cover curated models only - an extensible registry's
32
35
  // unknown model still degrades here until runtime verification.
33
36
  // Degrade: no raw-image claims, no streaming claims - transcribe/hold.
@@ -18,7 +18,11 @@ export type ContentEvent =
18
18
  | { readonly kind: "message"; readonly role: string; readonly text: string }
19
19
  | { readonly kind: "tool"; readonly name: string; readonly input?: unknown }
20
20
  | { readonly kind: "progress"; readonly label: string }
21
- | { readonly kind: "error"; readonly message: string };
21
+ /** `terminal: true` marks an error that ended the turn (a failed result
22
+ * record); the runner turns it into a task failure. Other errors are
23
+ * informational and the turn goes on. */
24
+ | { readonly kind: "error"; readonly message: string; readonly terminal?: boolean }
25
+ | { readonly kind: "budget"; readonly detail: string };
22
26
 
23
27
  /** Text of an array of `{type:"text", text}` content blocks. */
24
28
  const textOfBlocks = (content: unknown): string =>
@@ -70,7 +74,7 @@ const claude = (r: Record<string, unknown>): ContentEvent[] => {
70
74
  // error) - surface it so a streamTurn consumer sees the failure, not a
71
75
  // clean turn. (openSession handles result boundaries itself.)
72
76
  const sub = typeof r.subtype === "string" ? r.subtype : "result error";
73
- events.push({ kind: "error", message: `turn failed: ${sub}` });
77
+ events.push({ kind: "error", message: `turn failed: ${sub}`, terminal: true });
74
78
  }
75
79
  return events;
76
80
  };
@@ -100,7 +104,7 @@ const codex = (r: Record<string, unknown>): ContentEvent[] => {
100
104
  return [{ kind: "message", role: "assistant", text: item.text }];
101
105
  }
102
106
  if (item.type === "error" && typeof item.message === "string") {
103
- return [{ kind: "error", message: item.message }];
107
+ return [{ kind: "error", message: item.message, terminal: true }];
104
108
  }
105
109
  return [];
106
110
  };
@@ -127,7 +131,11 @@ const pi = (r: Record<string, unknown>): ContentEvent[] => {
127
131
  // exit). Without this the failure is invisible - a silent empty turn.
128
132
  if (message.stopReason === "error") {
129
133
  return [
130
- { kind: "error", message: "pi turn ended with stopReason error (provider/auth failure)" },
134
+ {
135
+ kind: "error",
136
+ message: "pi turn ended with stopReason error (provider/auth failure)",
137
+ terminal: true,
138
+ },
131
139
  ];
132
140
  }
133
141
  const text = textOfBlocks(message.content);
@@ -171,7 +179,12 @@ const muse = (r: Record<string, unknown>): ContentEvent[] => {
171
179
  }
172
180
  if (payload.terminal === "failed") {
173
181
  const reason = typeof payload.reason === "string" ? payload.reason : "run failed";
174
- return [{ kind: "error", message: `muse run failed: ${reason}` }];
182
+ // The muse reader is the muse-specific seam; no descriptor field
183
+ // carries budget phrasings yet, so the pattern lives here.
184
+ if (/did not reach a terminal state within \d+ step/i.test(reason)) {
185
+ return [{ kind: "budget", detail: reason }];
186
+ }
187
+ return [{ kind: "error", message: `muse run failed: ${reason}`, terminal: true }];
175
188
  }
176
189
  }
177
190
  return [];
@@ -29,6 +29,9 @@ const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFr
29
29
  codex: {
30
30
  write:
31
31
  "codex has no write toggle; use --sandbox read-only (config: sandbox_mode) so shell commands cannot write either",
32
+ // issue #48: the append half has no codex spelling.
33
+ appendSystemPrompt:
34
+ "codex has no append-to-prompt flag; -c instructions=<literal or path> REPLACES the whole prompt (both accepted, live-verified) - there is no additive form",
32
35
  shell:
33
36
  "codex has no shell toggle; disable the shell tool via config (-c features.shell_tool=false) or use --sandbox read-only",
34
37
  maxSteps:
@@ -65,6 +68,11 @@ const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFr
65
68
  "muse loads rules per workspace trust; --no-foreign-personal-context excludes foreign personal rules, and withholding --trust-workspace keeps workspace rules unloaded",
66
69
  "discovery.skills":
67
70
  "muse scopes skills by trust like rules; --no-foreign-personal-context drops foreign skills and untrusted workspaces stay unloaded - there is no unconditional skills-off switch",
71
+ // issue #48 (ratified 2026-08-20): muse cannot strip the payload at all.
72
+ systemPrompt:
73
+ "muse has no system-prompt surface; its built-in prompt always applies - there is no replacement or append spelling (structural: nothing to approximate with)",
74
+ appendSystemPrompt:
75
+ "muse has no system-prompt surface; its built-in prompt always applies - there is no replacement or append spelling (structural: nothing to approximate with)",
68
76
  },
69
77
  });
70
78
 
@@ -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) {
@@ -109,6 +109,15 @@ export const renderToolSelection = (
109
109
  const names = validateNames(h, selection.include!);
110
110
  const { mapped, unmapped } = resolveNames(h, names);
111
111
  if (!h.tools.includeIsStrictAllowlist) {
112
+ if (mapped.length === 0 && unmapped.length > 0) {
113
+ throw new ArgvRefusalError({
114
+ issue: "unknown-tool-name",
115
+ harness: h.name,
116
+ option: "tools",
117
+ supported: [`known tool names: ${h.tools.builtins.map((t) => t.name).join(", ")}`],
118
+ detail: `unknown tool name(s) ${unmapped.join(", ")}`,
119
+ });
120
+ }
112
121
  // claude: exact allowlist must reshape the visible set via the deny
113
122
  // complement (probe 2b). The include flag also carries the granted
114
123
  // 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({
@@ -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"],
@@ -84,6 +84,12 @@ export const TURN_OPTION_KEYS = deepFreeze([
84
84
  "write",
85
85
  "shell",
86
86
  "maxSteps",
87
+ // issue #48 (ratified 2026-08-20): the payload-stripping dimensions.
88
+ // systemPrompt replaces the built-in prompt; appendSystemPrompt adds to
89
+ // it. Both are opt-in-only (arg/config, no profile entry) - a default
90
+ // that strips the payload changes every bare run's semantics.
91
+ "systemPrompt",
92
+ "appendSystemPrompt",
87
93
  ] as const);
88
94
  export type TurnOptionKey = (typeof TURN_OPTION_KEYS)[number];
89
95
 
@@ -101,8 +107,14 @@ export const DISCOVERY_FACETS = deepFreeze([
101
107
  export type DiscoveryFacet = (typeof DISCOVERY_FACETS)[number];
102
108
 
103
109
  export type OptionRender =
104
- /** `--flag <value>` */
105
- | { readonly kind: "flag-value"; readonly flag: string }
110
+ /** `--flag <value>`; `extraFlags` are fixed companion tokens emitted
111
+ * before the value pair (claude's --system-prompt pairs with
112
+ * --exclude-dynamic-system-prompt-sections - issue #48). */
113
+ | {
114
+ readonly kind: "flag-value";
115
+ readonly flag: string;
116
+ readonly extraFlags?: readonly string[];
117
+ }
106
118
  /** `-c key=value` - codex's config-override grammar. Permitted only for
107
119
  * closed-vocabulary specs, so no value can need escaping. */
108
120
  | { readonly kind: "config-kv"; readonly flag: string; readonly key: string }
@@ -128,6 +140,10 @@ export type TurnOptionSpec =
128
140
  | (SpecBase & { readonly kind: "effort" })
129
141
  /** Open selector, CLEAN_SELECTOR-validated. */
130
142
  | (SpecBase & { readonly kind: "selector" })
143
+ /** Free-form prompt text (issue #48): systemPrompt / appendSystemPrompt.
144
+ * Values are prose, never a closed vocabulary - no validation beyond
145
+ * non-emptiness, rendering is verbatim. */
146
+ | (SpecBase & { readonly kind: "prompt-text" })
131
147
  /** `polarity: "disables"` emits the render when the caller asks for FALSE. */
132
148
  | (SpecBase & { readonly kind: "toggle"; readonly polarity: "enables" | "disables" })
133
149
  | (SpecBase & { readonly kind: "integer"; readonly min: number; readonly max: number })
@@ -239,14 +255,16 @@ export interface HarnessDescriptor {
239
255
  * harness. */
240
256
  readonly onMissing: "error" | "create";
241
257
  };
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. */
258
+ /** Persistent headless session support: the complete flag list that follows
259
+ * the binary to open one lucid-owned process serving many turns, or null
260
+ * when the harness has no such mode. This is the entire argv prefix after
261
+ * the binary - no launch flags are prepended. `idFlag` pins the
262
+ * caller-assigned session identity. `turnEnd` is the stdout record that
263
+ * delimits one turn (claude: the `result` record; pi rpc:
264
+ * `agent_settled`). `identityProbe`, when present, names a command the
265
+ * runner writes at spawn whose response carries the session id - pi rpc
266
+ * is identity-silent at startup (spike evidence:
267
+ * test/fixtures/pi-rpc-spike), so identity needs a round trip. */
250
268
  readonly sessionMode: {
251
269
  readonly flags: readonly string[];
252
270
  /** Pin an EXISTING session id; null when the harness only accepts
@@ -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" },