@dungle-scrubs/harness-cli-normalizer 0.5.4 → 0.5.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 (103) hide show
  1. package/README.md +3 -2
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +16 -8
  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 +11 -4
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/help.d.ts +3 -3
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +32 -23
  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 +10 -7
  14. package/dist/cli/inspect.js.map +1 -1
  15. package/dist/cli/render.d.ts +15 -1
  16. package/dist/cli/render.d.ts.map +1 -1
  17. package/dist/cli/render.js +18 -2
  18. package/dist/cli/render.js.map +1 -1
  19. package/dist/cli/resume-guard.js +29 -2
  20. package/dist/cli/resume-guard.js.map +1 -1
  21. package/dist/cli/run.d.ts.map +1 -1
  22. package/dist/cli/run.js +35 -34
  23. package/dist/cli/run.js.map +1 -1
  24. package/dist/cli/session-json.d.ts +5 -2
  25. package/dist/cli/session-json.d.ts.map +1 -1
  26. package/dist/cli/session-json.js +36 -5
  27. package/dist/cli/session-json.js.map +1 -1
  28. package/dist/cli/session.d.ts.map +1 -1
  29. package/dist/cli/session.js +103 -36
  30. package/dist/cli/session.js.map +1 -1
  31. package/dist/execution/events.d.ts +8 -1
  32. package/dist/execution/events.d.ts.map +1 -1
  33. package/dist/execution/events.js.map +1 -1
  34. package/dist/execution/open-session.d.ts +7 -6
  35. package/dist/execution/open-session.d.ts.map +1 -1
  36. package/dist/execution/open-session.js +88 -59
  37. package/dist/execution/open-session.js.map +1 -1
  38. package/dist/execution/stream-turn.d.ts +4 -7
  39. package/dist/execution/stream-turn.d.ts.map +1 -1
  40. package/dist/execution/stream-turn.js +43 -22
  41. package/dist/execution/stream-turn.js.map +1 -1
  42. package/dist/interpretation/argv.d.ts +9 -5
  43. package/dist/interpretation/argv.d.ts.map +1 -1
  44. package/dist/interpretation/argv.js +2 -7
  45. package/dist/interpretation/argv.js.map +1 -1
  46. package/dist/interpretation/capabilities.d.ts +20 -0
  47. package/dist/interpretation/capabilities.d.ts.map +1 -1
  48. package/dist/interpretation/capabilities.js +27 -0
  49. package/dist/interpretation/capabilities.js.map +1 -1
  50. package/dist/interpretation/hints.js +1 -1
  51. package/dist/interpretation/hints.js.map +1 -1
  52. package/dist/interpretation/question.d.ts +6 -2
  53. package/dist/interpretation/question.d.ts.map +1 -1
  54. package/dist/interpretation/question.js +13 -4
  55. package/dist/interpretation/question.js.map +1 -1
  56. package/dist/interpretation/refusal.d.ts +1 -1
  57. package/dist/interpretation/refusal.d.ts.map +1 -1
  58. package/dist/interpretation/refusal.js +1 -1
  59. package/dist/interpretation/refusal.js.map +1 -1
  60. package/dist/interpretation/skills-selection.d.ts +18 -1
  61. package/dist/interpretation/skills-selection.d.ts.map +1 -1
  62. package/dist/interpretation/skills-selection.js +33 -6
  63. package/dist/interpretation/skills-selection.js.map +1 -1
  64. package/dist/knowledge/claude-code.d.ts.map +1 -1
  65. package/dist/knowledge/claude-code.js +21 -0
  66. package/dist/knowledge/claude-code.js.map +1 -1
  67. package/dist/knowledge/codex.d.ts.map +1 -1
  68. package/dist/knowledge/codex.js +16 -6
  69. package/dist/knowledge/codex.js.map +1 -1
  70. package/dist/knowledge/descriptor.d.ts +39 -12
  71. package/dist/knowledge/descriptor.d.ts.map +1 -1
  72. package/dist/knowledge/descriptor.js +1 -0
  73. package/dist/knowledge/descriptor.js.map +1 -1
  74. package/dist/knowledge/muse.d.ts.map +1 -1
  75. package/dist/knowledge/muse.js +7 -0
  76. package/dist/knowledge/muse.js.map +1 -1
  77. package/dist/knowledge/pi.d.ts.map +1 -1
  78. package/dist/knowledge/pi.js +23 -4
  79. package/dist/knowledge/pi.js.map +1 -1
  80. package/package.json +3 -2
  81. package/src/cli/args.ts +16 -6
  82. package/src/cli/config.ts +13 -4
  83. package/src/cli/help.ts +32 -23
  84. package/src/cli/inspect.ts +8 -6
  85. package/src/cli/render.ts +18 -1
  86. package/src/cli/resume-guard.ts +26 -2
  87. package/src/cli/run.ts +36 -38
  88. package/src/cli/session-json.ts +45 -7
  89. package/src/cli/session.ts +122 -40
  90. package/src/execution/events.ts +9 -1
  91. package/src/execution/open-session.ts +99 -72
  92. package/src/execution/stream-turn.ts +53 -30
  93. package/src/interpretation/argv.ts +11 -12
  94. package/src/interpretation/capabilities.ts +50 -0
  95. package/src/interpretation/hints.ts +1 -1
  96. package/src/interpretation/question.ts +17 -7
  97. package/src/interpretation/refusal.ts +2 -1
  98. package/src/interpretation/skills-selection.ts +46 -8
  99. package/src/knowledge/claude-code.ts +21 -0
  100. package/src/knowledge/codex.ts +16 -6
  101. package/src/knowledge/descriptor.ts +40 -12
  102. package/src/knowledge/muse.ts +7 -0
  103. package/src/knowledge/pi.ts +23 -4
@@ -22,14 +22,18 @@ import {
22
22
  detectTransportInLine,
23
23
  detectUnavailableInLine,
24
24
  } from "../interpretation/limits.js";
25
- import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
25
+ import {
26
+ composeEscalatedPrompt,
27
+ detectQuestionBlock,
28
+ type QuestionMode,
29
+ } from "../interpretation/question.js";
26
30
  import { ArgvRefusalError } from "../interpretation/refusal.js";
27
31
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
28
32
  import { matcherOverridesOf } from "../knowledge/overrides.js";
29
33
  import { AsyncChannel } from "./channel.js";
30
34
  import { decodeLine, freshDecodeState } from "./decode.js";
31
35
  import type { RunnerDeps, SpawnedProcess } from "./deps.js";
32
- import type { ExitCause, HarnessEvent } from "./events.js";
36
+ import type { EscalationDetection, ExitCause, HarnessEvent } from "./events.js";
33
37
  import type { FailureSummary } from "./failure.js";
34
38
  import {
35
39
  failureFromAuth,
@@ -111,13 +115,9 @@ export interface TurnRunOptions extends LaunchOptions {
111
115
  * normalized argv. Wrong-harness flags here fail in the harness itself
112
116
  * and surface as native errors - hcn never validates them. */
113
117
  readonly passthrough?: readonly string[];
114
- /** issue #41: question escalation (behavior instruction, NOT a turn
115
- * option - no flag ever reaches the harness). True (the default when
116
- * undefined) prepends the protocol preamble and arms question-block
117
- * detection; false prepends the state-the-assumption instruction and
118
- * disarms detection. Applies on launch AND resume: it shapes each
119
- * turn's prompt and event stream, never a session setting. */
120
- readonly escalateQuestions?: boolean;
118
+ /** question mode: which preamble to inject (ask/assume/none).
119
+ * Behavior instruction - never a harness flag. Defaults to "ask". */
120
+ readonly questions?: QuestionMode;
121
121
  /** F-05: caller-requested stop. When aborted, the runner escalates
122
122
  * SIGTERM then SIGKILL and classifies the exit as killed with no
123
123
  * transport failure for the kill itself. */
@@ -132,20 +132,17 @@ export async function* streamTurn(
132
132
  const turnId = deps.turnId ?? `turn-${++turnCounter}`;
133
133
  const log = deps.log ?? (() => {});
134
134
 
135
- // issue #41: compose the escalation preamble onto the prompt (the
136
- // transport IS the prompt - no harness has native question conveyance)
137
- // and arm detection in the true mode. Composition is idempotent, so a
138
- // caller that already composed (the CLI does, for spawn-line truth)
139
- // never double-prepends.
140
- const escalateQuestions = opts.escalateQuestions !== false;
135
+ // compose the preamble onto the prompt based on question mode.
136
+ const questionMode: QuestionMode = opts.questions ?? "ask";
141
137
  const effective: TurnRunOptions = {
142
138
  ...opts,
143
- prompt: composeEscalatedPrompt(opts.prompt, escalateQuestions),
139
+ prompt: composeEscalatedPrompt(opts.prompt, questionMode),
144
140
  };
145
141
  // The turn's last assistant message - where the protocol says the
146
142
  // hcn-question block lives. Tracked only when detection is armed.
147
143
  let lastAssistantText: string | null = null;
148
144
  let asked = false;
145
+ let escalationDetection: EscalationDetection = "none";
149
146
 
150
147
  // Validate env before building argv so an invalid env is a refusal, not a spawn
151
148
  if (opts.env !== undefined) {
@@ -172,7 +169,13 @@ export async function* streamTurn(
172
169
  argv: redactArgv([], effective.prompt),
173
170
  });
174
171
  yield { kind: "failure", ...failure };
175
- yield { kind: "done", exitCode: null, cause: "failed", failure };
172
+ yield {
173
+ kind: "done",
174
+ exitCode: null,
175
+ cause: "failed",
176
+ failure,
177
+ escalation: { mode: questionMode, detection: "none" },
178
+ };
176
179
  return;
177
180
  }
178
181
  }
@@ -188,12 +191,11 @@ export async function* streamTurn(
188
191
  if (effective.passthrough !== undefined && effective.passthrough.length > 0) {
189
192
  argv = [...argv, "--", ...effective.passthrough];
190
193
  }
191
- // issue #38: claude renders the skills allowlist as settings JSON at
192
- // the argv tail (the complement-off form).
193
- const claudeSkillTokens = (opts as unknown as { __claudeSkillTokens?: string[] })
194
- .__claudeSkillTokens;
195
- if (claudeSkillTokens !== undefined && claudeSkillTokens.length > 0) {
196
- argv = [...argv, ...claudeSkillTokens];
194
+ // issue #38: claude/codex render the skills allowlist as complement-off
195
+ // tokens at the argv tail (claude: settings JSON; codex: -c skills.config).
196
+ const skillTokens = (opts as unknown as { __skillTokens?: string[] }).__skillTokens;
197
+ if (skillTokens !== undefined && skillTokens.length > 0) {
198
+ argv = [...argv, ...skillTokens];
197
199
  }
198
200
  granularity = streamingGranularityOf(h, argv);
199
201
  } catch (e) {
@@ -223,7 +225,13 @@ export async function* streamTurn(
223
225
  argv: argvForLog,
224
226
  });
225
227
  yield { kind: "failure", ...failure };
226
- yield { kind: "done", exitCode: null, cause: "failed", failure };
228
+ yield {
229
+ kind: "done",
230
+ exitCode: null,
231
+ cause: "failed",
232
+ failure,
233
+ escalation: { mode: questionMode, detection: "none" },
234
+ };
227
235
  return;
228
236
  }
229
237
  throw e;
@@ -271,7 +279,13 @@ export async function* streamTurn(
271
279
  if (resumeCreateWarning !== null) yield { kind: "error", message: resumeCreateWarning };
272
280
  yield { kind: "error", message: `spawn failed: ${message}` };
273
281
  yield { kind: "failure", ...failure };
274
- yield { kind: "done", exitCode: 127, cause: "failed", failure };
282
+ yield {
283
+ kind: "done",
284
+ exitCode: 127,
285
+ cause: "failed",
286
+ failure,
287
+ escalation: { mode: questionMode, detection: "none" },
288
+ };
275
289
  return;
276
290
  }
277
291
 
@@ -439,7 +453,7 @@ export async function* streamTurn(
439
453
  if (event.terminal === true) await pushFailure(failureFromTerminalError(h, event.message));
440
454
  return;
441
455
  }
442
- if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
456
+ if (questionMode === "ask" && event.kind === "message" && event.role === "assistant") {
443
457
  lastAssistantText = event.text;
444
458
  }
445
459
  await queue.push(event);
@@ -469,18 +483,26 @@ export async function* streamTurn(
469
483
  /** issue #41: scan the last assistant message for the hcn-question
470
484
  * block. Structured-first - the block's fields become the event; no
471
485
  * prose parsing. Runs after the pumps settle (the last message is only
472
- * last then) and only when detection is armed (escalateQuestions
473
- * true). A malformed block surfaces as an error event, never a silent
486
+ * last then) and only when detection is armed (questions ask).
487
+ * A malformed block surfaces as an error event, never a silent
474
488
  * no-op. */
475
489
  const emitQuestionIfAsked = async (): Promise<void> => {
476
- if (!escalateQuestions || lastAssistantText === null) return;
490
+ if (questionMode !== "ask" || lastAssistantText === null) {
491
+ escalationDetection = "none";
492
+ return;
493
+ }
477
494
  const detection = detectQuestionBlock(lastAssistantText);
478
- if (detection === null) return;
495
+ if (detection === null) {
496
+ escalationDetection = "none";
497
+ return;
498
+ }
479
499
  if ("malformed" in detection) {
500
+ escalationDetection = "malformed";
480
501
  await queue.push({ kind: "error", message: detection.malformed });
481
502
  await pushFailure(failureFromTask(`malformed hcn-question block: ${detection.malformed}`));
482
503
  return;
483
504
  }
505
+ escalationDetection = "block";
484
506
  log({
485
507
  event: "question",
486
508
  turnId,
@@ -680,6 +702,7 @@ export async function* streamTurn(
680
702
  exitCode: nativeReduced ? null : exitCode,
681
703
  cause,
682
704
  ...(reduced ? { failure: reduced } : {}),
705
+ escalation: { mode: questionMode, detection: escalationDetection },
683
706
  };
684
707
  } finally {
685
708
  if (abortHandler !== null) opts.signal?.removeEventListener("abort", abortHandler);
@@ -76,11 +76,11 @@ export interface TurnOptions {
76
76
  readonly systemPrompt?: string;
77
77
  /** issue #48: appends to the built-in prompt (claude/pi only). */
78
78
  readonly appendSystemPrompt?: string;
79
- /** issue #41: question escalation - a BEHAVIOR INSTRUCTION, not a turn
80
- * option. It never renders into any harness argv; the CLI layer turns
81
- * it into the prompt preamble and arms question-block detection.
82
- * Undefined means the default: true. */
83
- readonly escalateQuestions?: boolean;
79
+ /** question mode: which preamble hcn injects (ask/assume/none).
80
+ * A BEHAVIOR INSTRUCTION, not a turn option. It never renders into any
81
+ * harness argv; the CLI layer turns it into the prompt preamble and
82
+ * arms question-block detection. Undefined means the default: "ask". */
83
+ readonly questions?: import("./question.js").QuestionMode;
84
84
  /** Internal: set by CLI when prompt came from --prompt/--prompt-file to bypass leading '-' guard */
85
85
  readonly __explicitPrompt?: boolean;
86
86
  /** toolMap extensible vocabulary per harness (issue toolMap) */
@@ -192,6 +192,10 @@ export interface SessionOptions {
192
192
  /** Provider selector (pi). A harness with no provider selector refuses,
193
193
  * the same way a one-shot turn does. */
194
194
  readonly provider?: string;
195
+ /** True when this argv should resume an existing conversation, false for a
196
+ * fresh session. Controls which descriptor flag is rendered: resumeFlag
197
+ * vs idFlag. Only consumers that alias --resume/--session-id set this. */
198
+ readonly isResume?: boolean;
195
199
  }
196
200
 
197
201
  export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): string[] => {
@@ -206,13 +210,8 @@ export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): st
206
210
  });
207
211
  }
208
212
  refuseUnusableSessionId(h, opts.sessionId);
209
- const argv = [
210
- h.bin,
211
- ...h.sessionMode.flags,
212
- // idFlag null = the harness refuses unknown ids and mints its own
213
- // (pi rpc); the caller-side sessionId stays a correlation handle.
214
- ...(h.sessionMode.idFlag !== null ? [h.sessionMode.idFlag, opts.sessionId] : []),
215
- ];
213
+ const flag = opts.isResume ? h.sessionMode.resumeFlag : h.sessionMode.idFlag;
214
+ const argv = [h.bin, ...h.sessionMode.flags, ...(flag !== null ? [flag, opts.sessionId] : [])];
216
215
  if (opts.model !== undefined) {
217
216
  const validated = validateModel(h, opts.model);
218
217
  if (!validated.ok) {
@@ -11,8 +11,30 @@ import type {
11
11
  HarnessMode,
12
12
  StreamingGranularity,
13
13
  } from "../knowledge/descriptor.js";
14
+ import { compareVersions } from "./versions.js";
14
15
  import { resolveModel } from "./vocabulary.js";
15
16
 
17
+ export interface EscalationObservedOn {
18
+ readonly harness: string;
19
+ readonly model: string;
20
+ readonly version: string;
21
+ readonly date: string;
22
+ }
23
+
24
+ /**
25
+ * Escalation claim: whether this harness and model were observed to emit
26
+ * the structured hcn-question block when instructed, NOT "this harness can
27
+ * ask" - live probes show models ask unprompted, so capability-to-ask would
28
+ * be false.
29
+ */
30
+ export interface EscalationClaim {
31
+ /** True when this harness and model were observed to emit the structured block when instructed. */
32
+ readonly supported: boolean;
33
+ readonly source: "runtime-verified" | "curated" | "unknown";
34
+ readonly confidence: "high" | "medium" | "none";
35
+ readonly observedOn?: EscalationObservedOn;
36
+ }
37
+
16
38
  export interface CapabilityResult {
17
39
  readonly vision: boolean;
18
40
  readonly images: boolean;
@@ -20,8 +42,34 @@ export interface CapabilityResult {
20
42
  readonly session: boolean;
21
43
  readonly source: "runtime-verified" | "curated" | "unknown";
22
44
  readonly confidence: "high" | "medium" | "none";
45
+ readonly escalation: EscalationClaim;
23
46
  }
24
47
 
48
+ const escalationOf = (h: HarnessDescriptor): EscalationClaim => {
49
+ const obs = h.escalation.observedOn;
50
+ if (obs !== undefined) {
51
+ const cmp = compareVersions(obs.version, h.verifiedAgainst);
52
+ if (cmp < 0) {
53
+ return {
54
+ supported: true,
55
+ source: "runtime-verified",
56
+ confidence: "medium",
57
+ observedOn: obs,
58
+ };
59
+ }
60
+ return {
61
+ supported: true,
62
+ source: "runtime-verified",
63
+ confidence: "high",
64
+ observedOn: obs,
65
+ };
66
+ }
67
+ if (h.escalation.supported) {
68
+ return { supported: true, source: "curated", confidence: "medium" };
69
+ }
70
+ return { supported: false, source: "unknown", confidence: "none" };
71
+ };
72
+
25
73
  export const capabilitiesOf = (
26
74
  h: HarnessDescriptor,
27
75
  model: string,
@@ -41,6 +89,7 @@ export const capabilitiesOf = (
41
89
  session: false,
42
90
  source: "unknown",
43
91
  confidence: "none",
92
+ escalation: { supported: false, source: "unknown", confidence: "none" },
44
93
  };
45
94
  }
46
95
  return {
@@ -50,5 +99,6 @@ export const capabilitiesOf = (
50
99
  session: h.capabilities.session,
51
100
  source: "curated",
52
101
  confidence: "medium",
102
+ escalation: escalationOf(h),
53
103
  };
54
104
  };
@@ -49,7 +49,7 @@ const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFr
49
49
  "discovery.extensions":
50
50
  "codex loads MCP servers and plugins from config; disable per-server with -c or codex mcp remove rather than a call-time toggle",
51
51
  "discovery.skills":
52
- "codex discovers skills from its skills directory; no call-time toggle - remove or move the skill files instead",
52
+ 'codex has no global skills-off switch (skills.enabled is not a key); disable per skill per call with -c skills.config=[{path=".../SKILL.md", enabled=false}] and bundled skills with -c skills.bundled.enabled=false',
53
53
  },
54
54
  pi: {
55
55
  sandbox:
@@ -47,7 +47,15 @@ If and only if a genuine decision you cannot make defensibly blocks correct prog
47
47
 
48
48
  Say nothing after the block and stop generating. The caller's user will answer, and the answer arrives as the next user message in this session - continue from it. For every choice you can make defensibly yourself, do not ask - decide, act, and state the decision you made.`;
49
49
 
50
- /** Compose the transport preamble onto a prompt. `mode` selects the
50
+ export type QuestionMode = "ask" | "assume" | "none";
51
+
52
+ export const QUESTION_MODES: readonly QuestionMode[] = ["ask", "assume", "none"] as const;
53
+
54
+ export const isQuestionMode = (value: string): value is QuestionMode =>
55
+ (QUESTION_MODES as readonly string[]).includes(value);
56
+
57
+ /** Compose the transport preamble onto a prompt. `mode` selects which
58
+ * preamble to inject (ask/assume/none), and `channel` selects the
51
59
  * contract wording: "turn" (exit-and-resume transport, hcn run) or
52
60
  * "session" (live channel, hcn session). Idempotent: a prompt that
53
61
  * already carries any preamble passes through unchanged - a turn-mode
@@ -55,12 +63,14 @@ Say nothing after the block and stop generating. The caller's user will answer,
55
63
  * both; the already-composed contract stands). */
56
64
  export const composeEscalatedPrompt = (
57
65
  prompt: string,
58
- escalate: boolean,
59
- mode: "turn" | "session" = "turn",
60
- ): string =>
61
- prompt.startsWith(QUESTION_PREAMBLE_MARKER)
62
- ? prompt
63
- : `${mode === "session" && escalate ? SESSION_ESCALATION_PREAMBLE : escalate ? ESCALATION_PREAMBLE : NO_ESCALATION_PREAMBLE}\n\n${prompt}`;
66
+ mode: QuestionMode,
67
+ channel: "turn" | "session" = "turn",
68
+ ): string => {
69
+ if (prompt.startsWith(QUESTION_PREAMBLE_MARKER)) return prompt;
70
+ if (mode === "none") return prompt;
71
+ if (mode === "assume") return `${NO_ESCALATION_PREAMBLE}\n\n${prompt}`;
72
+ return `${channel === "session" ? SESSION_ESCALATION_PREAMBLE : ESCALATION_PREAMBLE}\n\n${prompt}`;
73
+ };
64
74
 
65
75
  /** The structured question a worker asks (the block's fields). */
66
76
  export interface QuestionBlock {
@@ -35,6 +35,7 @@ export type RefusalOption =
35
35
  | "excludeTools"
36
36
  | "skills"
37
37
  | "autonomy"
38
+ | "questions"
38
39
  | `discovery.${string}`;
39
40
 
40
41
  /** One helper builds the message from the structured fields so message and
@@ -89,7 +90,7 @@ export const buildRefusalMessage = (
89
90
  case "no-autonomy-mode":
90
91
  return `${harness} has no unattended-run flag; ${supportedStr} - drop autonomy or route to a supporting harness (claude --dangerously-skip-permissions, codex/muse --yolo)`;
91
92
  case "no-session-mode":
92
- return `${harness} declares no persistent headless session mode; ${supportedStr} - use streamTurn instead of openSession`;
93
+ return `${harness} declares no persistent headless session mode; ${supportedStr} - use hcn run --resume <id>`;
93
94
  default: {
94
95
  const exhaustive: never = issue;
95
96
  return `${exhaustive as string} for ${harness}${optionPart}${detailSuffix}; ${supportedStr}`;
@@ -10,7 +10,15 @@
10
10
  * OFF - `--settings '{"skillOverrides":{"<name>":"off",...}}'` for every
11
11
  * known skill except the picks. Known set comes from the caller's root
12
12
  * listing (same source that resolved the names).
13
- * - codex/muse: refuse (structural) with the standard hint shape.
13
+ * - codex: per-skill disable via config-kv array `-c
14
+ * skills.config=[{path="...", enabled=false}]` for every known skill
15
+ * except the picks (complement-off, same inversion as claude). Uses
16
+ * `path` selector rather than `name` because a skill's frontmatter
17
+ * `name` need not equal its directory basename, and `path` is exact.
18
+ * The path for skill <n> under root <root> is <root>/<n>/SKILL.md.
19
+ * No global `skills.enabled` switch exists. Requires the known set
20
+ * and the resolved picks (root derived from picks via dirname).
21
+ * - muse: refuse (structural) with the standard hint shape.
14
22
  */
15
23
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
16
24
  import { ArgvRefusalError } from "./refusal.js";
@@ -20,6 +28,11 @@ export const basenameOf = (p: string): string => {
20
28
  return i === -1 ? p : p.slice(i + 1);
21
29
  };
22
30
 
31
+ const dirnameOf = (p: string): string => {
32
+ const i = p.lastIndexOf("/");
33
+ return i === -1 ? "" : p.slice(0, i);
34
+ };
35
+
23
36
  export const renderSkillsSelection = (
24
37
  h: HarnessDescriptor,
25
38
  skills: readonly string[],
@@ -35,11 +48,9 @@ export const renderSkillsSelection = (
35
48
  supportedBy: [
36
49
  { harness: "pi", spelling: "--skill" },
37
50
  { harness: "claude", spelling: "skillOverrides" },
51
+ { harness: "codex", spelling: "-c skills.config" },
38
52
  ],
39
- hint:
40
- h.name === "codex"
41
- ? "codex discovers skills from its own directory with no call-time surface - stage the skills into $CODEX_HOME/skills or pass their content in the prompt"
42
- : "muse scopes skills by workspace trust with no per-skill surface - include the skill content in the prompt or use --trust-workspace for the whole registry",
53
+ hint: "muse scopes skills by workspace trust with no per-skill surface - include the skill content in the prompt or use --trust-workspace for the whole registry",
43
54
  });
44
55
  }
45
56
 
@@ -50,9 +61,9 @@ export const renderSkillsSelection = (
50
61
  return tokens;
51
62
  }
52
63
 
53
- // claude: complement-off via settings JSON. Names are the skill dir
54
- // basenames; unknown names in knownSkills would be turned off
55
- // pointlessly, so the caller passes exactly the known set.
64
+ // claude and codex: complement-off via CLI layer (settings JSON / config
65
+ // array). The descriptor-level render returns [] and tokens append in
66
+ // stream-turn / CLI.
56
67
  return [];
57
68
  };
58
69
 
@@ -71,3 +82,30 @@ export const claudeSkillOverridesArg = (
71
82
  const json = JSON.stringify({ skillOverrides: offs });
72
83
  return ["--settings", json];
73
84
  };
85
+
86
+ /** Codex complement form: every known skill except the picks gets
87
+ * `{path="<root>/<name>/SKILL.md>", enabled=false}` via `-c
88
+ * skills.config=[...]`. Uses `path` (exact) over `name` because
89
+ * frontmatter name may diverge from directory basename. Root is derived
90
+ * from the picks' dirname (all picks share the same root); if picks is
91
+ * empty the complement cannot be rooted and we return [] (caller picks
92
+ * nothing - no integration point needs this, and the CLI layer never
93
+ * calls with empty picks). Empty complement returns [] (no flag). */
94
+ export const codexSkillConfigArg = (
95
+ knownSkills: readonly string[],
96
+ pickedPaths: readonly string[],
97
+ ): string[] => {
98
+ const picks = new Set(pickedPaths.map(basenameOf));
99
+ // Derive root from first pick's dirname; all picks are under same root
100
+ // (skills-root guarantees this). Fall back to "" if no picks.
101
+ const root = pickedPaths.length > 0 ? dirnameOf(pickedPaths[0] as string) : "";
102
+ const entries: string[] = [];
103
+ for (const name of knownSkills) {
104
+ if (!picks.has(name)) {
105
+ const absPath = root ? `${root}/${name}/SKILL.md` : `${name}/SKILL.md`;
106
+ entries.push(`{path=${JSON.stringify(absPath)}, enabled=false}`);
107
+ }
108
+ }
109
+ if (entries.length === 0) return [];
110
+ return ["-c", `skills.config=[${entries.join(", ")}]`];
111
+ };
@@ -51,6 +51,19 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
51
51
  // hooks (D-025). Token deltas require this exact output flag set.
52
52
  // sessionMode.flags is the complete flag list after the binary, so -p
53
53
  // lives here rather than being inherited from launch.baseFlags.
54
+ // claude has two session-id flags with opposite unknown-id behaviour:
55
+ // `--session-id` NAMES a fresh session (creates if missing, silent) while
56
+ // `--resume` RESTORES an existing one (refuses unknown id, exit 1
57
+ // "No conversation found" - verified phase11,
58
+ // test/fixtures/phase11-claude-session-resume/resume-unknown-id.stderr.txt).
59
+ // `idFlag` is the naming flag (`--session-id`); `resumeFlag` is the
60
+ // restoring flag (`--resume`) - distinct as data so the renderer cannot
61
+ // pick the wrong one. Unknown-id behaviour reuses `resume.onMissing:
62
+ // "error"` as the single source; sessionMode does not restate it.
63
+ // Verified phase11 (test/fixtures/phase11-claude-session-resume):
64
+ // `session-id-establish.ndjson` creates with `--session-id`, and
65
+ // `resume-restores.ndjson` restores the same id with `--resume` and
66
+ // recalls codeword "pomegranate".
54
67
  flags: [
55
68
  "-p",
56
69
  "--input-format",
@@ -63,6 +76,7 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
63
76
  "project",
64
77
  ],
65
78
  idFlag: "--session-id",
79
+ resumeFlag: "--resume",
66
80
  input: { kind: "claude-sdk-user-message" },
67
81
  turnEnd: { type: "result" },
68
82
  identityProbe: null,
@@ -140,6 +154,13 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
140
154
  },
141
155
  session: true,
142
156
  },
157
+ // Escalation provenance transcribed from test/fixtures/phase7-questions/,
158
+ // committed 2026-08-19. `model` is empty because no fixture on that stream
159
+ // records a model id - absence of evidence, not an unset field.
160
+ escalation: {
161
+ supported: true,
162
+ observedOn: { harness: "claude", model: "", version: "2.1.235", date: "2026-08-19" },
163
+ },
143
164
  turnOptions: {
144
165
  effort: { kind: "effort", render: { kind: "flag-value", flag: "--effort" } },
145
166
  // issue #48, live-verified 2.1.235: --system-prompt replaces the built-in
@@ -101,6 +101,13 @@ export const codexCli: HarnessDescriptor = deepFreeze({
101
101
  },
102
102
  session: false,
103
103
  },
104
+ // Escalation provenance transcribed from test/fixtures/phase7-questions/,
105
+ // committed 2026-08-19. `model` is empty because no fixture on that stream
106
+ // records a model id - absence of evidence, not an unset field.
107
+ escalation: {
108
+ supported: true,
109
+ observedOn: { harness: "codex", model: "", version: "0.146.1", date: "2026-08-19" },
110
+ },
104
111
  turnOptions: {
105
112
  effort: {
106
113
  kind: "effort",
@@ -129,12 +136,15 @@ export const codexCli: HarnessDescriptor = deepFreeze({
129
136
  render: { kind: "flag-value", flag: "--sandbox" },
130
137
  },
131
138
  },
132
- // Phase 0 fixtures: codex-tool-surface.md. No name lists anywhere -
133
- // not on the CLI, not in config.toml. Control is feature booleans
134
- // (reachable per-call via -c key=value / --enable/--disable FEATURE),
135
- // sandbox, and approval policy. MCP servers do have per-tool keys
136
- // (mcp_servers.<id>.tools.<tool>) but built-ins do not.
137
- skills: null,
139
+ // Tools: no built-in name lists; control is feature booleans
140
+ // (reachable per-call via -c key=value), sandbox, and approval policy.
141
+ // MCP servers do have per-tool keys (mcp_servers.<id>.tools.<tool>)
142
+ // but built-ins do not.
143
+ // Skills (verified 2026-08-22, codex 0.147.0, --strict-config probes):
144
+ // per-skill disable via config-kv array -c skills.config=[{path=...,
145
+ // enabled=false}] (selector path or name, validated per entry); also
146
+ // -c skills.bundled.enabled=false. No global skills.enabled switch.
147
+ skills: { loadFlag: null, overridesVia: "config-skills-array" },
138
148
  tools: {
139
149
  includeFlag: null,
140
150
  excludeFlag: null,
@@ -52,6 +52,9 @@ export interface SessionInputContract {
52
52
  readonly kind: SessionInputKind;
53
53
  }
54
54
 
55
+ export const SESSION_RESUME_FLAGS = ["--resume", "--session-id"] as const;
56
+ export type SessionResumeFlag = (typeof SESSION_RESUME_FLAGS)[number];
57
+
55
58
  /** Consumers branch on these (session-limit: wait for reset; weekly-limit:
56
59
  * route elsewhere), so the vocabulary is closed - a descriptor cannot invent
57
60
  * a code a consumer has no arm for. */
@@ -302,19 +305,26 @@ export interface HarnessDescriptor {
302
305
  * the binary to open one lucid-owned process serving many turns, or null
303
306
  * when the harness has no such mode. This is the entire argv prefix after
304
307
  * the binary - no launch flags are prepended. `idFlag` pins the
305
- * caller-assigned session identity. `turnEnd` is the stdout record that
306
- * delimits one turn (claude: the `result` record; pi rpc:
307
- * `agent_settled`). `identityProbe`, when present, names a command the
308
- * runner writes at spawn whose response carries the session id - pi rpc
309
- * is identity-silent at startup (spike evidence:
308
+ * caller-assigned session identity for a FRESH session (naming). `turnEnd`
309
+ * is the stdout record that delimits one turn (claude: the `result`
310
+ * record; pi rpc: `agent_settled`). `identityProbe`, when present, names
311
+ * a command the runner writes at spawn whose response carries the session
312
+ * id - pi rpc is identity-silent at startup (spike evidence:
310
313
  * test/fixtures/pi-rpc-spike), so identity needs a round trip. */
311
314
  readonly sessionMode: {
312
315
  readonly flags: readonly string[];
313
- /** Pin an EXISTING session id; null when the harness only accepts
314
- * caller ids that already exist (pi rpc: `--session` refuses unknown
315
- * ids - spike evidence), so fresh sessions omit the flag and the
316
- * harness mints the id, readable via `identityProbe`. */
317
- readonly idFlag: string | null;
316
+ /** The flag that names a fresh session (caller-assigned identity at
317
+ * spawn). Null only when the harness mints the id itself. */
318
+ readonly idFlag: SessionResumeFlag | null;
319
+ /** The flag that carries an EXISTING session id to resume it in
320
+ * session mode, distinct from `idFlag` which names a fresh session.
321
+ * Closed vocabulary: pi reuses `--session-id` for both (verified
322
+ * phase10, test/fixtures/phase10-pi-rpc-resume); claude uses
323
+ * `--resume` to restore vs `--session-id` to name (verified phase11,
324
+ * test/fixtures/phase11-claude-session-resume). Unknown-id behavior
325
+ * is not duplicated here; it reuses `resume.onMissing` as the single
326
+ * source. */
327
+ readonly resumeFlag: SessionResumeFlag;
318
328
  readonly input: SessionInputContract;
319
329
  readonly turnEnd: Readonly<Record<string, string>>;
320
330
  readonly identityProbe: { readonly command: string } | null;
@@ -407,6 +417,23 @@ export interface HarnessDescriptor {
407
417
  readonly streamingByMode: Readonly<Record<HarnessMode, StreamingGranularity>>;
408
418
  readonly session: boolean;
409
419
  };
420
+ /** Escalation provenance: whether this harness and model were observed to
421
+ * emit the structured hcn-question block when instructed, and when that
422
+ * observation was made. `supported` is documented as "this harness and
423
+ * model were observed to emit the structured block when instructed", NOT
424
+ * as "this harness can ask" - live probes show models ask unprompted, so
425
+ * a capability-to-ask name would be false. `observedOn` is optional;
426
+ * absence means no probe exists for this harness/model. Staleness is
427
+ * derived from version comparison at read time, not stored. */
428
+ readonly escalation: {
429
+ readonly supported: boolean;
430
+ readonly observedOn?: {
431
+ readonly harness: string;
432
+ readonly model: string;
433
+ readonly version: string;
434
+ readonly date: string;
435
+ };
436
+ };
410
437
  /** Per-call turn options this harness can express, keyed by the closed
411
438
  * `TurnOptionKey` vocabulary. Absent keys are unexpressible on this
412
439
  * harness; a call passing them must refuse. Discovery is a table of
@@ -430,10 +457,11 @@ export interface HarnessDescriptor {
430
457
  * pi model registry, D-008). */
431
458
  /** Caller-directed skills allowlist surface (issue #38). pi: repeatable
432
459
  * load flag. claude: per-name "off" overrides via settings JSON.
433
- * codex/muse: null (structural gap - trust/config scoped only). */
460
+ * codex: per-skill "off" via config-kv array (-c skills.config).
461
+ * muse: null (structural gap - trust/config scoped only). */
434
462
  readonly skills: {
435
463
  readonly loadFlag: string | null;
436
- readonly overridesVia: "settings-skilloverrides" | null;
464
+ readonly overridesVia: "settings-skilloverrides" | "config-skills-array" | null;
437
465
  } | null;
438
466
  readonly tools: {
439
467
  readonly includeFlag: string | null;
@@ -91,6 +91,13 @@ export const museCode: HarnessDescriptor = deepFreeze({
91
91
  },
92
92
  session: false,
93
93
  },
94
+ // Escalation provenance transcribed from test/fixtures/phase7-questions/,
95
+ // committed 2026-08-19. `model` is empty because no fixture on that stream
96
+ // records a model id - absence of evidence, not an unset field.
97
+ escalation: {
98
+ supported: true,
99
+ observedOn: { harness: "muse", model: "", version: "0.2.1", date: "2026-08-19" },
100
+ },
94
101
  turnOptions: {
95
102
  effort: { kind: "effort", render: { kind: "flag-value", flag: "--reasoning-effort" } },
96
103
  write: {