@dungle-scrubs/harness-cli-normalizer 0.4.0 → 0.4.2

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 (93) hide show
  1. package/README.md +30 -3
  2. package/dist/cli/args.d.ts +1 -0
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +29 -0
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/config.d.ts.map +1 -1
  7. package/dist/cli/config.js +12 -1
  8. package/dist/cli/config.js.map +1 -1
  9. package/dist/cli/exit-codes.js +1 -1
  10. package/dist/cli/exit-codes.js.map +1 -1
  11. package/dist/cli/help.d.ts +2 -2
  12. package/dist/cli/help.d.ts.map +1 -1
  13. package/dist/cli/help.js +35 -2
  14. package/dist/cli/help.js.map +1 -1
  15. package/dist/cli/render.d.ts.map +1 -1
  16. package/dist/cli/render.js +15 -1
  17. package/dist/cli/render.js.map +1 -1
  18. package/dist/cli/run.d.ts.map +1 -1
  19. package/dist/cli/run.js +90 -16
  20. package/dist/cli/run.js.map +1 -1
  21. package/dist/cli/skills-root.d.ts +4 -0
  22. package/dist/cli/skills-root.d.ts.map +1 -0
  23. package/dist/cli/skills-root.js +45 -0
  24. package/dist/cli/skills-root.js.map +1 -0
  25. package/dist/execution/events.d.ts +12 -1
  26. package/dist/execution/events.d.ts.map +1 -1
  27. package/dist/execution/events.js.map +1 -1
  28. package/dist/execution/failure.d.ts +4 -1
  29. package/dist/execution/failure.d.ts.map +1 -1
  30. package/dist/execution/failure.js +15 -2
  31. package/dist/execution/failure.js.map +1 -1
  32. package/dist/execution/stream-turn.d.ts +7 -0
  33. package/dist/execution/stream-turn.d.ts.map +1 -1
  34. package/dist/execution/stream-turn.js +83 -16
  35. package/dist/execution/stream-turn.js.map +1 -1
  36. package/dist/interpretation/argv.d.ts +10 -0
  37. package/dist/interpretation/argv.d.ts.map +1 -1
  38. package/dist/interpretation/argv.js +4 -0
  39. package/dist/interpretation/argv.js.map +1 -1
  40. package/dist/interpretation/question.d.ts +46 -0
  41. package/dist/interpretation/question.d.ts.map +1 -0
  42. package/dist/interpretation/question.js +110 -0
  43. package/dist/interpretation/question.js.map +1 -0
  44. package/dist/interpretation/refusal.d.ts +1 -1
  45. package/dist/interpretation/refusal.d.ts.map +1 -1
  46. package/dist/interpretation/refusal.js.map +1 -1
  47. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  48. package/dist/interpretation/resolve-options.js +27 -5
  49. package/dist/interpretation/resolve-options.js.map +1 -1
  50. package/dist/interpretation/skills-selection.d.ts +22 -0
  51. package/dist/interpretation/skills-selection.d.ts.map +1 -0
  52. package/dist/interpretation/skills-selection.js +49 -0
  53. package/dist/interpretation/skills-selection.js.map +1 -0
  54. package/dist/knowledge/claude-code.d.ts.map +1 -1
  55. package/dist/knowledge/claude-code.js +1 -0
  56. package/dist/knowledge/claude-code.js.map +1 -1
  57. package/dist/knowledge/codex.d.ts.map +1 -1
  58. package/dist/knowledge/codex.js +1 -0
  59. package/dist/knowledge/codex.js.map +1 -1
  60. package/dist/knowledge/descriptor.d.ts +7 -0
  61. package/dist/knowledge/descriptor.d.ts.map +1 -1
  62. package/dist/knowledge/muse.d.ts.map +1 -1
  63. package/dist/knowledge/muse.js +1 -0
  64. package/dist/knowledge/muse.js.map +1 -1
  65. package/dist/knowledge/pi.d.ts.map +1 -1
  66. package/dist/knowledge/pi.js +1 -0
  67. package/dist/knowledge/pi.js.map +1 -1
  68. package/dist/knowledge/profile.d.ts +3 -0
  69. package/dist/knowledge/profile.d.ts.map +1 -1
  70. package/dist/knowledge/profile.js +20 -0
  71. package/dist/knowledge/profile.js.map +1 -1
  72. package/package.json +1 -1
  73. package/src/cli/args.ts +39 -2
  74. package/src/cli/config.ts +14 -1
  75. package/src/cli/exit-codes.ts +1 -1
  76. package/src/cli/help.ts +35 -2
  77. package/src/cli/render.ts +16 -1
  78. package/src/cli/run.ts +99 -23
  79. package/src/cli/skills-root.ts +47 -0
  80. package/src/execution/events.ts +19 -1
  81. package/src/execution/failure.ts +16 -2
  82. package/src/execution/stream-turn.ts +91 -15
  83. package/src/interpretation/argv.ts +14 -0
  84. package/src/interpretation/question.ts +137 -0
  85. package/src/interpretation/refusal.ts +1 -0
  86. package/src/interpretation/resolve-options.ts +29 -5
  87. package/src/interpretation/skills-selection.ts +73 -0
  88. package/src/knowledge/claude-code.ts +1 -0
  89. package/src/knowledge/codex.ts +1 -0
  90. package/src/knowledge/descriptor.ts +7 -0
  91. package/src/knowledge/muse.ts +1 -0
  92. package/src/knowledge/pi.ts +1 -0
  93. package/src/knowledge/profile.ts +20 -0
package/src/cli/run.ts CHANGED
@@ -2,12 +2,9 @@ import type { HarnessEvent } from "../execution/events.js";
2
2
  import { nodeRunnerDeps } from "../execution/node-deps.js";
3
3
  import { KILL_GRACE_MS, redactArgv, streamTurn } from "../execution/stream-turn.js";
4
4
  import { buildLaunchArgv, buildResumeArgv } from "../interpretation/argv.js";
5
+ import { composeEscalatedPrompt } from "../interpretation/question.js";
5
6
  import { ArgvRefusalError } from "../interpretation/refusal.js";
6
- import {
7
- FloorExceededError,
8
- type ProvenanceEntry,
9
- resolveEffectiveOptions,
10
- } from "../interpretation/resolve-options.js";
7
+ import { FloorExceededError, resolveEffectiveOptions } from "../interpretation/resolve-options.js";
11
8
  import { recognizeNativeSpelling, supportedBy } from "../interpretation/support.js";
12
9
  import { defaultDescriptors } from "../knowledge/overrides.js";
13
10
  import { parseRunExtra, parseTurnOptions, resolvePromptAsync } from "./args.js";
@@ -186,16 +183,45 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
186
183
 
187
184
  const isExplicit = promptSource !== "positional";
188
185
 
186
+ // issue #38: resolve --skills names against the caller's registry root,
187
+ // then hand the harness its native rendering (pi loads; claude narrows).
188
+ const rawSkills = (turnOpts as unknown as { skills?: string[] }).skills;
189
+ if (rawSkills !== undefined && rawSkills.length > 0) {
190
+ try {
191
+ const { resolveSkillNames, listKnownSkills } = await import("./skills-root.js");
192
+ const resolvedSkills = resolveSkillNames(rawSkills);
193
+ const claudeTokens: string[] = [];
194
+ if (h.name === "claude") {
195
+ const { claudeSkillOverridesArg } = await import("../interpretation/skills-selection.js");
196
+ claudeTokens.push(...claudeSkillOverridesArg(listKnownSkills(), resolvedSkills));
197
+ }
198
+ (turnOpts as unknown as Record<string, unknown>).skills = resolvedSkills;
199
+ (turnOpts as unknown as Record<string, unknown>).__claudeSkillTokens = claudeTokens;
200
+ } catch (err) {
201
+ if (err instanceof ArgvRefusalError) {
202
+ process.stderr.write(`${err.message}\n`);
203
+ if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
204
+ process.exitCode = 2;
205
+ return;
206
+ }
207
+ throw err;
208
+ }
209
+ }
210
+
189
211
  // Defaults profile + user config: LAUNCH-ONLY. A resumed session keeps
190
212
  // its own settings; the resolver never runs on resume paths.
191
- let resolvedProvenance: readonly ProvenanceEntry[] = [];
192
- let resolvedUnrenderable: readonly string[] = [];
193
213
  let effectiveTurnOpts: ReturnType<typeof parseTurnOptions> = turnOpts;
194
- if (extra.resume === undefined) {
195
- const tiers: {
196
- user?: Partial<ReturnType<typeof parseTurnOptions>>;
197
- project?: Partial<ReturnType<typeof parseTurnOptions>>;
198
- } = {};
214
+ const resolvedTiers: {
215
+ user?: Partial<ReturnType<typeof parseTurnOptions>>;
216
+ project?: Partial<ReturnType<typeof parseTurnOptions>>;
217
+ } = {};
218
+ // Config files load on EVERY run, launch or resume: the tiers feed the
219
+ // defaults profile on launch, and issue #41's escalateQuestions (a
220
+ // behavior instruction, not a turn option) resolves from them on resume
221
+ // too - otherwise a no-escalate session would flip its preamble on the
222
+ // answer turn. Resolution of TURN options stays launch-only.
223
+ const tiers = resolvedTiers;
224
+ {
199
225
  const { loadUserConfig, loadProjectConfig, ConfigError } = await import("./config.js");
200
226
  try {
201
227
  const loaded = loadUserConfig();
@@ -210,6 +236,8 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
210
236
  }
211
237
  throw configErr;
212
238
  }
239
+ }
240
+ if (extra.resume === undefined) {
213
241
  let resolved: ReturnType<typeof resolveEffectiveOptions>;
214
242
  try {
215
243
  resolved = resolveEffectiveOptions(h, { ...turnOpts, prompt } as never, tiers);
@@ -222,8 +250,6 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
222
250
  throw resErr;
223
251
  }
224
252
  const { provenance, unrenderable } = resolved;
225
- resolvedProvenance = provenance;
226
- resolvedUnrenderable = unrenderable;
227
253
  const { prompt: _p, ...rest } = resolved.options as { prompt: string };
228
254
  effectiveTurnOpts = rest as ReturnType<typeof parseTurnOptions>;
229
255
  // Provenance is diagnostic data like the spawn line - stderr in BOTH
@@ -242,12 +268,39 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
242
268
  }
243
269
  }
244
270
 
271
+ // issue #41: question-escalation precedence arg > project > user >
272
+ // default-true (a behavior instruction, not a turn option - the
273
+ // default lives OUTSIDE the profile on purpose, per the spec). It
274
+ // applies on LAUNCH AND RESUME alike: it shapes each turn's prompt
275
+ // preamble and event stream, never a session setting.
276
+ const projectEscalate = (resolvedTiers.project as { escalateQuestions?: boolean } | undefined)
277
+ ?.escalateQuestions;
278
+ const userEscalate = (resolvedTiers.user as { escalateQuestions?: boolean } | undefined)
279
+ ?.escalateQuestions;
280
+ const escalateQuestions =
281
+ turnOpts.escalateQuestions !== undefined
282
+ ? turnOpts.escalateQuestions
283
+ : projectEscalate !== undefined
284
+ ? projectEscalate
285
+ : userEscalate !== undefined
286
+ ? userEscalate
287
+ : true;
288
+ const escalateTier =
289
+ turnOpts.escalateQuestions !== undefined
290
+ ? "arg"
291
+ : projectEscalate !== undefined
292
+ ? "project-config"
293
+ : userEscalate !== undefined
294
+ ? "user-config"
295
+ : "default";
296
+
245
297
  const fullOpts = {
246
298
  ...effectiveTurnOpts,
247
- prompt,
299
+ prompt: composeEscalatedPrompt(prompt, escalateQuestions),
248
300
  cwd: extra.cwd,
249
301
  env: extra.env,
250
302
  resume: extra.resume,
303
+ escalateQuestions,
251
304
  ...(passthrough.length > 0 ? { passthrough } : {}),
252
305
  ...(isExplicit ? { __explicitPrompt: true as const } : {}),
253
306
  } as Parameters<typeof streamTurn>[1] & {
@@ -261,22 +314,30 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
261
314
  let preArgv: string[] | null = null;
262
315
  try {
263
316
  if (fullOpts.resume) {
264
- // Resume never carries profile/config resolution (launch-only rule),
265
- // so it builds from the raw turn options.
317
+ // Resume never carries TURN-option profile resolution (launch-only
318
+ // rule), so it builds from the raw turn options; hcn-owned behavior
319
+ // (escalateQuestions preamble, timeout budget) still applies.
266
320
  preArgv = buildResumeArgv(h, {
267
321
  ...(turnOpts as object),
268
- prompt,
322
+ prompt: fullOpts.prompt,
269
323
  sessionId: fullOpts.resume,
270
324
  __explicitPrompt: isExplicit,
271
325
  } as never);
272
326
  } else {
273
327
  // Launch builds from the RESOLVED options so the spawn line and the
274
- // real argv agree.
328
+ // real argv agree. The prompt here is the COMPOSED one (escalation
329
+ // preamble included) - redactArgv masks by position, so an argv
330
+ // built from the raw prompt would leak it into the spawn line.
275
331
  preArgv = buildLaunchArgv(h, {
276
332
  ...(effectiveTurnOpts as object),
277
- prompt,
333
+ prompt: fullOpts.prompt,
278
334
  __explicitPrompt: isExplicit,
279
335
  } as never);
336
+ const claudeSkillTokens = (effectiveTurnOpts as unknown as { __claudeSkillTokens?: string[] })
337
+ .__claudeSkillTokens;
338
+ if (claudeSkillTokens !== undefined && claudeSkillTokens.length > 0) {
339
+ preArgv.push(...claudeSkillTokens);
340
+ }
280
341
  }
281
342
  _validated = true;
282
343
  } catch (err) {
@@ -299,19 +360,34 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
299
360
  // since buildLaunchArgv now respects __explicitPrompt.
300
361
 
301
362
  if (preArgv) {
302
- const redacted = redactArgv(preArgv, prompt);
363
+ const redacted = redactArgv(preArgv, fullOpts.prompt);
303
364
  if (!wantJson) {
304
365
  process.stderr.write(`spawn: ${redacted.join(" ")}\n`);
305
366
  } else {
306
367
  // In JSON mode, diagnostics to stderr only
307
368
  process.stderr.write(`spawn: ${redacted.join(" ")}\n`);
308
369
  }
370
+ // issue #41: the escalation mode rides stderr as provenance, like
371
+ // every other resolution the turn depends on.
372
+ process.stderr.write(
373
+ `provenance: escalateQuestions = ${escalateQuestions} (${escalateTier})\n`,
374
+ );
309
375
  }
310
376
 
311
377
  // Delete HERDR_ENV before spawn
312
378
  delete (process.env as Record<string, string | undefined>).HERDR_ENV;
313
379
 
314
- const deps = nodeRunnerDeps();
380
+ // D11: opt-in wall-clock budget. Precedence arg > project > user (no
381
+ // profile entry by ratification). 0 = explicit disable.
382
+ const timeoutSeconds =
383
+ extra.timeoutSeconds !== undefined
384
+ ? extra.timeoutSeconds
385
+ : ((resolvedTiers?.project as { timeout?: number } | undefined)?.timeout ??
386
+ (resolvedTiers?.user as { timeout?: number } | undefined)?.timeout);
387
+ const deps =
388
+ timeoutSeconds !== undefined && timeoutSeconds > 0
389
+ ? nodeRunnerDeps({ turnTimeoutMs: timeoutSeconds * 1000 })
390
+ : nodeRunnerDeps();
315
391
 
316
392
  // Signal handling
317
393
  const _currentProc: { signal: (sig: "SIGTERM" | "SIGKILL") => void } | null = null;
@@ -372,7 +448,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
372
448
  renderEvent(event, state);
373
449
  }
374
450
  if (event.kind === "done") {
375
- if (event.cause === "clean") exitCode = 0;
451
+ if (event.cause === "clean" || event.cause === "awaiting-input") exitCode = 0;
376
452
  else exitCode = 1;
377
453
  // If failure class is rejected? But done.cause for rejected would be failed? Still 1 per mapping, but refusal before spawn is 2.
378
454
  // The RFC says limit/auth ->1, transport ->1, refusal ->2 (already handled). So done non-clean =>1.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Skills registry resolution (issue #38): --skills names resolve against
3
+ * the caller's deployed root. The delegating agent's judgment picked the
4
+ * names; this only maps them to paths and refuses unknowns loudly -
5
+ * a typo silently loading nothing is the failure this prevents.
6
+ *
7
+ * Root: $HCN_SKILLS_ROOT, else ~/.agents/skills (the deployed shared
8
+ * registry; ~/.claude/skills symlinks to it, so claude's complement
9
+ * narrowing and pi's loads see the same set).
10
+ */
11
+ import { existsSync, readdirSync, statSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { ArgvRefusalError } from "../interpretation/refusal.js";
15
+
16
+ export const skillsRoot = (): string =>
17
+ process.env.HCN_SKILLS_ROOT ?? join(homedir(), ".agents", "skills");
18
+
19
+ const skillDir = (root: string, name: string): string => join(root, name);
20
+
21
+ export const listKnownSkills = (): string[] => {
22
+ const root = skillsRoot();
23
+ if (!existsSync(root)) return [];
24
+ return readdirSync(root).filter((n) => {
25
+ try {
26
+ return statSync(skillDir(root, n)).isDirectory();
27
+ } catch {
28
+ return false;
29
+ }
30
+ });
31
+ };
32
+
33
+ export const resolveSkillNames = (names: readonly string[]): string[] => {
34
+ const root = skillsRoot();
35
+ const known = new Set(listKnownSkills());
36
+ const unknown = names.filter((n) => !known.has(n));
37
+ if (unknown.length > 0) {
38
+ throw new ArgvRefusalError({
39
+ issue: "unknown-tool-name",
40
+ harness: "claude",
41
+ option: "skills",
42
+ supported: known.size > 0 ? [[...known].join(", ")] : ["(registry is empty)"],
43
+ detail: `unknown skill name(s): ${unknown.join(", ")}`,
44
+ });
45
+ }
46
+ return names.map((n) => skillDir(root, n));
47
+ };
@@ -17,7 +17,16 @@
17
17
  import type { CapabilityResult } from "../interpretation/capabilities.js";
18
18
  import type { FailureSummary } from "./failure.js";
19
19
 
20
- export type ExitCause = "clean" | "limit" | "crash" | "stall" | "killed" | "failed";
20
+ export type ExitCause =
21
+ | "clean"
22
+ | "limit"
23
+ | "crash"
24
+ | "stall"
25
+ | "killed"
26
+ | "failed"
27
+ /** issue #41: the turn ended by asking (escalateQuestions) - a
28
+ * SUCCESSFUL turn (process exit 0); the caller resumes with the answer. */
29
+ | "awaiting-input";
21
30
 
22
31
  export type HarnessEvent =
23
32
  | {
@@ -31,6 +40,15 @@ export type HarnessEvent =
31
40
  | { readonly kind: "progress"; readonly label: string }
32
41
  | { readonly kind: "tool"; readonly name: string; readonly input?: unknown }
33
42
  | { readonly kind: "context"; readonly usedPct: number }
43
+ | {
44
+ /** issue #41: the worker asked the caller's user a question (the
45
+ * final message carried an hcn-question block). Structured-first:
46
+ * these fields ARE the question; prose renders from them. */
47
+ readonly kind: "question";
48
+ readonly question: string;
49
+ readonly options: readonly string[];
50
+ readonly recommended?: string;
51
+ }
34
52
  | { readonly kind: "limit"; readonly code: string; readonly message: string }
35
53
  | { readonly kind: "error"; readonly message: string }
36
54
  | ({ readonly kind: "failure" } & FailureSummary)
@@ -31,6 +31,7 @@ export const FAILURE_CLASSES = Object.freeze([
31
31
  "transport",
32
32
  "rejected",
33
33
  "native",
34
+ "timeout",
34
35
  ] as const);
35
36
  export type FailureClass = (typeof FAILURE_CLASSES)[number];
36
37
 
@@ -56,7 +57,7 @@ export interface FailureSummary {
56
57
  }
57
58
 
58
59
  export const retryableOf = (cls: FailureClass): boolean =>
59
- cls !== "task" && cls !== "budget" && cls !== "rejected" && cls !== "native";
60
+ cls !== "task" && cls !== "budget" && cls !== "rejected" && cls !== "native" && cls !== "timeout";
60
61
 
61
62
  const messageFor = (cls: FailureClass, detail?: string): string => {
62
63
  switch (cls) {
@@ -76,6 +77,11 @@ const messageFor = (cls: FailureClass, detail?: string): string => {
76
77
  return `Transport failure${detail ? ` (${detail})` : ""} - retry or route to another provider`;
77
78
  case "rejected":
78
79
  return `Request rejected${detail ? ` (${detail})` : ""} - change options or harness`;
80
+ case "timeout":
81
+ // D11: hcn's wall-clock budget expired and the run was killed. The
82
+ // caller chose this number (arg or config); retrying unchanged will
83
+ // hit the same wall - retry only with a raised budget.
84
+ return `Timeout: run exceeded its wall-clock budget and was killed (SIGTERM, then SIGKILL after grace) - raise --timeout for this workload or split the task`;
79
85
  case "native":
80
86
  // D6: labeled NATIVE so it can never be confused with an hcn error.
81
87
  // The harness's own message follows verbatim; the process exit code
@@ -86,7 +92,14 @@ const messageFor = (cls: FailureClass, detail?: string): string => {
86
92
  default:
87
93
  return `Failure${detail ? ` (${detail})` : ""}`;
88
94
  }
89
- }; /** D6: a failure that belongs to the harness, not hcn. Carries the
95
+ }; /** D11: the run outlived its caller-set wall-clock budget. */
96
+ export const failureFromTimeout = (): FailureSummary => ({
97
+ class: "timeout",
98
+ retryable: false,
99
+ message: messageFor("timeout"),
100
+ });
101
+
102
+ /** D6: a failure that belongs to the harness, not hcn. Carries the
90
103
  * native stderr verbatim and the native exit code as data. */
91
104
  export const failureFromNative = (
92
105
  nativeExitCode: number | null,
@@ -173,6 +186,7 @@ const PRECEDENCE: Record<FailureClass, number> = {
173
186
  budget: 3,
174
187
  task: 3,
175
188
  transport: 4,
189
+ timeout: 3,
176
190
  // rejected stands alone (checked before precedence applies); native is
177
191
  // terminal-by-classification, never reduced into anything else.
178
192
  rejected: 0,
@@ -17,6 +17,7 @@ import {
17
17
  } from "../interpretation/argv.js";
18
18
  import { stdinPolicyOf } from "../interpretation/dimensions.js";
19
19
  import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
20
+ import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
20
21
  import { ArgvRefusalError } from "../interpretation/refusal.js";
21
22
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
22
23
  import { matcherOverridesOf } from "../knowledge/overrides.js";
@@ -30,6 +31,7 @@ import {
30
31
  failureFromLimit,
31
32
  failureFromNative,
32
33
  failureFromRejected,
34
+ failureFromTimeout,
33
35
  failureFromTransport,
34
36
  reduceFailures,
35
37
  } from "./failure.js";
@@ -101,6 +103,13 @@ export interface TurnRunOptions extends LaunchOptions {
101
103
  * normalized argv. Wrong-harness flags here fail in the harness itself
102
104
  * and surface as native errors - hcn never validates them. */
103
105
  readonly passthrough?: readonly string[];
106
+ /** issue #41: question escalation (behavior instruction, NOT a turn
107
+ * option - no flag ever reaches the harness). True (the default when
108
+ * undefined) prepends the protocol preamble and arms question-block
109
+ * detection; false prepends the state-the-assumption instruction and
110
+ * disarms detection. Applies on launch AND resume: it shapes each
111
+ * turn's prompt and event stream, never a session setting. */
112
+ readonly escalateQuestions?: boolean;
104
113
  }
105
114
 
106
115
  export async function* streamTurn(
@@ -111,6 +120,21 @@ export async function* streamTurn(
111
120
  const turnId = deps.turnId ?? `turn-${++turnCounter}`;
112
121
  const log = deps.log ?? (() => {});
113
122
 
123
+ // issue #41: compose the escalation preamble onto the prompt (the
124
+ // transport IS the prompt - no harness has native question conveyance)
125
+ // and arm detection in the true mode. Composition is idempotent, so a
126
+ // caller that already composed (the CLI does, for spawn-line truth)
127
+ // never double-prepends.
128
+ const escalateQuestions = opts.escalateQuestions !== false;
129
+ const effective: TurnRunOptions = {
130
+ ...opts,
131
+ prompt: composeEscalatedPrompt(opts.prompt, escalateQuestions),
132
+ };
133
+ // The turn's last assistant message - where the protocol says the
134
+ // hcn-question block lives. Tracked only when detection is armed.
135
+ let lastAssistantText: string | null = null;
136
+ let asked = false;
137
+
114
138
  // Validate env before building argv so an invalid env is a refusal, not a spawn
115
139
  if (opts.env !== undefined) {
116
140
  for (const [k, v] of Object.entries(opts.env)) {
@@ -133,7 +157,7 @@ export async function* streamTurn(
133
157
  harness: h.name,
134
158
  issue: refusal.issue,
135
159
  supported: refusal.supported,
136
- argv: redactArgv([], opts.prompt),
160
+ argv: redactArgv([], effective.prompt),
137
161
  });
138
162
  yield { kind: "failure", ...failure };
139
163
  yield { kind: "done", exitCode: null, cause: "failed", failure };
@@ -146,11 +170,18 @@ export async function* streamTurn(
146
170
  let granularity: import("../knowledge/descriptor.js").StreamingGranularity;
147
171
  try {
148
172
  argv =
149
- opts.resume === undefined
150
- ? buildLaunchArgv(h, opts)
151
- : buildResumeArgv(h, { ...opts, sessionId: opts.resume });
152
- if (opts.passthrough !== undefined && opts.passthrough.length > 0) {
153
- argv = [...argv, "--", ...opts.passthrough];
173
+ effective.resume === undefined
174
+ ? buildLaunchArgv(h, effective)
175
+ : buildResumeArgv(h, { ...effective, sessionId: effective.resume });
176
+ if (effective.passthrough !== undefined && effective.passthrough.length > 0) {
177
+ argv = [...argv, "--", ...effective.passthrough];
178
+ }
179
+ // issue #38: claude renders the skills allowlist as settings JSON at
180
+ // the argv tail (the complement-off form).
181
+ const claudeSkillTokens = (opts as unknown as { __claudeSkillTokens?: string[] })
182
+ .__claudeSkillTokens;
183
+ if (claudeSkillTokens !== undefined && claudeSkillTokens.length > 0) {
184
+ argv = [...argv, ...claudeSkillTokens];
154
185
  }
155
186
  granularity = streamingGranularityOf(h, argv);
156
187
  } catch (e) {
@@ -167,7 +198,7 @@ export async function* streamTurn(
167
198
  // No process spawned on a refusal - log rejected instead of spawn
168
199
  let argvForLog: string[] = [];
169
200
  try {
170
- argvForLog = redactArgv([], opts.prompt);
201
+ argvForLog = redactArgv([], effective.prompt);
171
202
  } catch {}
172
203
  log({
173
204
  event: "rejected",
@@ -192,7 +223,7 @@ export async function* streamTurn(
192
223
  event: "spawn",
193
224
  turnId,
194
225
  harness: h.name,
195
- argv: redactArgv(argv, opts.prompt),
226
+ argv: redactArgv(argv, effective.prompt),
196
227
  granularity,
197
228
  ...(matcherOverrides ? { matcherOverrides } : {}),
198
229
  ...(envKeys?.length ? { envKeys } : {}),
@@ -202,8 +233,8 @@ export async function* streamTurn(
202
233
  try {
203
234
  proc = deps.spawn(argv, {
204
235
  stdin: stdinPolicyOf(h) === "close-required" ? "close" : "inherit",
205
- ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
206
- ...(opts.env !== undefined ? { env: opts.env } : {}),
236
+ ...(effective.cwd !== undefined ? { cwd: effective.cwd } : {}),
237
+ ...(effective.env !== undefined ? { env: effective.env } : {}),
207
238
  });
208
239
  } catch (cause) {
209
240
  // Spawn failure is a transport failure, not merely a crash
@@ -224,7 +255,7 @@ export async function* streamTurn(
224
255
  }
225
256
 
226
257
  const queue = new AsyncChannel<HarnessEvent>();
227
- const state = freshDecodeState(opts.resume ?? null);
258
+ const state = freshDecodeState(effective.resume ?? null);
228
259
  const stderrTail = new StderrTail();
229
260
  let killedByWatchdog = false;
230
261
  let exited = false;
@@ -333,6 +364,9 @@ export async function* streamTurn(
333
364
  // Directly from decode's rate_limit_event handling - track for reduction
334
365
  failures.push(event as unknown as FailureSummary);
335
366
  }
367
+ if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
368
+ lastAssistantText = event.text;
369
+ }
336
370
  await queue.push(event);
337
371
  }
338
372
  }
@@ -348,6 +382,37 @@ export async function* streamTurn(
348
382
  }
349
383
  };
350
384
 
385
+ /** issue #41: scan the last assistant message for the hcn-question
386
+ * block. Structured-first - the block's fields become the event; no
387
+ * prose parsing. Runs after the pumps settle (the last message is only
388
+ * last then) and only when detection is armed (escalateQuestions
389
+ * true). A malformed block surfaces as an error event, never a silent
390
+ * no-op. */
391
+ const emitQuestionIfAsked = async (): Promise<void> => {
392
+ if (!escalateQuestions || lastAssistantText === null) return;
393
+ const detection = detectQuestionBlock(lastAssistantText);
394
+ if (detection === null) return;
395
+ if ("malformed" in detection) {
396
+ await queue.push({ kind: "error", message: detection.malformed });
397
+ return;
398
+ }
399
+ log({
400
+ event: "question",
401
+ turnId,
402
+ harness: h.name,
403
+ options: detection.block.options.length,
404
+ });
405
+ asked = true;
406
+ await queue.push({
407
+ kind: "question",
408
+ question: detection.block.question,
409
+ options: detection.block.options,
410
+ ...(detection.block.recommended !== undefined
411
+ ? { recommended: detection.block.recommended }
412
+ : {}),
413
+ });
414
+ };
415
+
351
416
  const pumpStderr = async (): Promise<void> => {
352
417
  const lines = new LineBuffer();
353
418
  for await (const chunk of proc.stderr) {
@@ -405,7 +470,9 @@ export async function* streamTurn(
405
470
  observePump("stdout", pumpStdout()),
406
471
  observePump("stderr", pumpStderr()),
407
472
  ]);
408
- void Promise.all([proc.exited, pumpSettlements]).then(() => queue.close());
473
+ void Promise.all([proc.exited, pumpSettlements])
474
+ .then(() => emitQuestionIfAsked())
475
+ .then(() => queue.close());
409
476
 
410
477
  try {
411
478
  for await (const event of queue) yield event;
@@ -434,7 +501,12 @@ export async function* streamTurn(
434
501
  }
435
502
  // Stall watchdog also implies a transport failure if not already present
436
503
  if (killedByWatchdog && failures.length === 0) {
437
- const f = failureFromTransport(`stalled: ${watchdogReason ?? "inactivity"}`);
504
+ // D11: a wall-clock deadline kill is a timeout, not a stall - the
505
+ // run was not necessarily silent, it simply outlived its budget.
506
+ const f =
507
+ watchdogReason === "turn-deadline"
508
+ ? failureFromTimeout()
509
+ : failureFromTransport(`stalled: ${watchdogReason ?? "inactivity"}`);
438
510
  failures.push(f);
439
511
  yield { kind: "failure", ...f };
440
512
  }
@@ -442,9 +514,13 @@ export async function* streamTurn(
442
514
  let cause: ExitCause = state.limitSeen
443
515
  ? "limit"
444
516
  : killedByWatchdog && exitCode !== 0
445
- ? "stall"
517
+ ? watchdogReason === "turn-deadline"
518
+ ? "killed" // D11: the run was killed on budget, not stalled
519
+ : "stall"
446
520
  : exitCode === 0
447
- ? "clean"
521
+ ? asked
522
+ ? "awaiting-input" // issue #41: asking SUCCEEDED the turn
523
+ : "clean"
448
524
  : exitCode === null
449
525
  ? "killed"
450
526
  : "crash";
@@ -8,6 +8,7 @@ import type { HarnessDescriptor, StreamingGranularity } from "../knowledge/descr
8
8
  import { defaultDescriptors } from "../knowledge/overrides.js";
9
9
  import { ArgvRefusalError } from "./refusal.js";
10
10
  import { assertUsableSessionId } from "./session-id.js";
11
+ import { renderSkillsSelection } from "./skills-selection.js";
11
12
  import { supportedBy } from "./support.js";
12
13
  import { renderToolSelection } from "./tool-selection.js";
13
14
  import { renderTurnOptions } from "./turn-options.js";
@@ -54,6 +55,11 @@ export interface TurnOptions {
54
55
  readonly prompt: string;
55
56
  readonly tools?: readonly string[];
56
57
  readonly excludeTools?: readonly string[];
58
+ /** Caller-directed skills allowlist: resolved absolute paths, one per
59
+ * skill. Rendering: pi loads each via --skill with discovery off;
60
+ * claude turns off the complement via skillOverrides settings; codex
61
+ * and muse refuse (structural - no per-skill surface). */
62
+ readonly skills?: readonly string[];
57
63
  readonly model?: string;
58
64
  readonly autonomy?: boolean;
59
65
  readonly effort?: string;
@@ -63,6 +69,11 @@ export interface TurnOptions {
63
69
  readonly write?: boolean;
64
70
  readonly shell?: boolean;
65
71
  readonly maxSteps?: number;
72
+ /** issue #41: question escalation - a BEHAVIOR INSTRUCTION, not a turn
73
+ * option. It never renders into any harness argv; the CLI layer turns
74
+ * it into the prompt preamble and arms question-block detection.
75
+ * Undefined means the default: true. */
76
+ readonly escalateQuestions?: boolean;
66
77
  /** Internal: set by CLI when prompt came from --prompt/--prompt-file to bypass leading '-' guard */
67
78
  readonly __explicitPrompt?: boolean;
68
79
  }
@@ -112,6 +123,9 @@ const turnTail = (h: HarnessDescriptor, opts: TurnOptions): string[] => {
112
123
  });
113
124
  tail.push(...rendered.tokens);
114
125
  }
126
+ if (opts.skills !== undefined && opts.skills.length > 0) {
127
+ tail.push(...renderSkillsSelection(h, opts.skills));
128
+ }
115
129
  return tail;
116
130
  };
117
131