@dungle-scrubs/harness-cli-normalizer 0.4.1 → 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.
- package/README.md +30 -3
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +8 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +4 -1
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/exit-codes.js +1 -1
- package/dist/cli/exit-codes.js.map +1 -1
- package/dist/cli/help.d.ts +2 -2
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +19 -2
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/render.d.ts.map +1 -1
- package/dist/cli/render.js +15 -1
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +49 -15
- package/dist/cli/run.js.map +1 -1
- package/dist/execution/events.d.ts +12 -1
- package/dist/execution/events.d.ts.map +1 -1
- package/dist/execution/events.js.map +1 -1
- package/dist/execution/stream-turn.d.ts +7 -0
- package/dist/execution/stream-turn.d.ts.map +1 -1
- package/dist/execution/stream-turn.js +67 -13
- package/dist/execution/stream-turn.js.map +1 -1
- package/dist/interpretation/argv.d.ts +5 -0
- package/dist/interpretation/argv.d.ts.map +1 -1
- package/dist/interpretation/argv.js.map +1 -1
- package/dist/interpretation/question.d.ts +46 -0
- package/dist/interpretation/question.d.ts.map +1 -0
- package/dist/interpretation/question.js +110 -0
- package/dist/interpretation/question.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/args.ts +6 -0
- package/src/cli/config.ts +4 -1
- package/src/cli/exit-codes.ts +1 -1
- package/src/cli/help.ts +19 -2
- package/src/cli/render.ts +16 -1
- package/src/cli/run.ts +54 -19
- package/src/execution/events.ts +19 -1
- package/src/execution/stream-turn.ts +74 -13
- package/src/interpretation/argv.ts +5 -0
- package/src/interpretation/question.ts +137 -0
package/src/cli/help.ts
CHANGED
|
@@ -42,6 +42,12 @@ Options:
|
|
|
42
42
|
--no-write Disable write
|
|
43
43
|
--shell Enable shell (muse)
|
|
44
44
|
--no-shell Disable shell
|
|
45
|
+
--escalate-questions Let the worker ask the caller's user when a
|
|
46
|
+
genuine decision blocks progress (DEFAULT;
|
|
47
|
+
prompt-preamble transport, question event +
|
|
48
|
+
done cause "awaiting-input", exit 0)
|
|
49
|
+
--no-escalate-questions Worker never asks: it states the assumption it
|
|
50
|
+
proceeded under and continues
|
|
45
51
|
--max-steps <n> Max steps (muse, 1-10000)
|
|
46
52
|
--timeout <seconds> Wall-clock budget for the run (all harnesses,
|
|
47
53
|
hcn-enforced; 0 disables; no default)
|
|
@@ -51,7 +57,11 @@ Options:
|
|
|
51
57
|
--no-skills Disable skills discovery facet
|
|
52
58
|
--cwd <path> Working directory for spawn
|
|
53
59
|
--env KEY=VAL Environment (repeatable; KEY= deletes)
|
|
54
|
-
--resume <uuid> Resume session id (UUID)
|
|
60
|
+
--resume <uuid> Resume session id (UUID). The answer path for
|
|
61
|
+
question escalation: resume with the chosen
|
|
62
|
+
answer as the prompt; id continuity per
|
|
63
|
+
harness (claude stable, pi/muse caller-assigned,
|
|
64
|
+
codex minted via identity event)
|
|
55
65
|
-- Passthrough: native harness args verbatim
|
|
56
66
|
(failures surface as labeled native errors)
|
|
57
67
|
--json NDJSON HarnessEvent to stdout
|
|
@@ -64,7 +74,9 @@ Defaults with no flags:
|
|
|
64
74
|
> harness default. The profile pins: effort medium, sandbox
|
|
65
75
|
workspace-write (codex only; other harnesses report divergence),
|
|
66
76
|
discovery on, autonomy off, write/shell on. timeout and max-steps have
|
|
67
|
-
no default.
|
|
77
|
+
no default. Question escalation defaults ON (config key
|
|
78
|
+
"escalateQuestions"; it is a prompt preamble, never a harness flag).
|
|
79
|
+
Provenance prints to stderr on every run; see
|
|
68
80
|
'hcn inspect <harness>' for the resolved argv of a bare run.
|
|
69
81
|
`;
|
|
70
82
|
|
|
@@ -102,8 +114,13 @@ Options:
|
|
|
102
114
|
--autonomy / --no-autonomy
|
|
103
115
|
--write / --no-write
|
|
104
116
|
--shell / --no-shell
|
|
117
|
+
--escalate-questions / --no-escalate-questions
|
|
118
|
+
(accepted; renders nothing - rides the run prompt)
|
|
105
119
|
--max-steps <n>
|
|
106
120
|
--no-tools, --no-instruction-files, --no-extensions, --no-skills
|
|
121
|
+
--escalate-questions / --no-escalate-questions
|
|
122
|
+
Accepted; renders nothing in argv (the mode
|
|
123
|
+
rides the run prompt, not a harness flag)
|
|
107
124
|
--cwd <path>
|
|
108
125
|
--env KEY=VAL
|
|
109
126
|
--resume <uuid>
|
package/src/cli/render.ts
CHANGED
|
@@ -4,6 +4,7 @@ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
|
|
4
4
|
const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
|
|
5
5
|
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
|
6
6
|
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
|
|
7
|
+
const blue = (s: string) => `\x1b[34m${s}\x1b[0m`;
|
|
7
8
|
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
|
8
9
|
|
|
9
10
|
export interface RenderState {
|
|
@@ -36,6 +37,15 @@ export const renderEvent = (event: HarnessEvent, state: RenderState): void => {
|
|
|
36
37
|
case "limit":
|
|
37
38
|
process.stdout.write(yellow(`\n ⚠ limit: ${event.code} ${event.message}`));
|
|
38
39
|
break;
|
|
40
|
+
case "question":
|
|
41
|
+
// issue #41: the structured question fields ARE the question - this
|
|
42
|
+
// render is a convenience view of them, not the contract.
|
|
43
|
+
process.stdout.write(blue(`\n ? ${event.question}\n`));
|
|
44
|
+
for (const option of event.options) {
|
|
45
|
+
const mark = option === event.recommended ? "*" : " ";
|
|
46
|
+
process.stdout.write(blue(` ${mark} ${option}\n`));
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
39
49
|
case "error":
|
|
40
50
|
process.stdout.write(red(`\n ✗ ${event.message}`));
|
|
41
51
|
break;
|
|
@@ -54,7 +64,12 @@ export const renderEvent = (event: HarnessEvent, state: RenderState): void => {
|
|
|
54
64
|
break;
|
|
55
65
|
}
|
|
56
66
|
case "done": {
|
|
57
|
-
const mark =
|
|
67
|
+
const mark =
|
|
68
|
+
event.cause === "clean" || event.cause === "awaiting-input"
|
|
69
|
+
? event.cause === "awaiting-input"
|
|
70
|
+
? blue("○ awaiting input")
|
|
71
|
+
: green("○ clean")
|
|
72
|
+
: red(`○ ${event.cause}`);
|
|
58
73
|
const tail = event.failure ? ` ${event.failure.class}: ${event.failure.message}` : "";
|
|
59
74
|
process.stdout.write(`\n ${mark} (exit ${event.exitCode ?? "none"})${tail}\n`);
|
|
60
75
|
break;
|
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";
|
|
@@ -213,15 +210,18 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
213
210
|
|
|
214
211
|
// Defaults profile + user config: LAUNCH-ONLY. A resumed session keeps
|
|
215
212
|
// its own settings; the resolver never runs on resume paths.
|
|
216
|
-
let resolvedProvenance: readonly ProvenanceEntry[] = [];
|
|
217
|
-
let resolvedUnrenderable: readonly string[] = [];
|
|
218
213
|
let effectiveTurnOpts: ReturnType<typeof parseTurnOptions> = turnOpts;
|
|
219
214
|
const resolvedTiers: {
|
|
220
215
|
user?: Partial<ReturnType<typeof parseTurnOptions>>;
|
|
221
216
|
project?: Partial<ReturnType<typeof parseTurnOptions>>;
|
|
222
217
|
} = {};
|
|
223
|
-
|
|
224
|
-
|
|
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
|
+
{
|
|
225
225
|
const { loadUserConfig, loadProjectConfig, ConfigError } = await import("./config.js");
|
|
226
226
|
try {
|
|
227
227
|
const loaded = loadUserConfig();
|
|
@@ -236,6 +236,8 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
236
236
|
}
|
|
237
237
|
throw configErr;
|
|
238
238
|
}
|
|
239
|
+
}
|
|
240
|
+
if (extra.resume === undefined) {
|
|
239
241
|
let resolved: ReturnType<typeof resolveEffectiveOptions>;
|
|
240
242
|
try {
|
|
241
243
|
resolved = resolveEffectiveOptions(h, { ...turnOpts, prompt } as never, tiers);
|
|
@@ -248,8 +250,6 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
248
250
|
throw resErr;
|
|
249
251
|
}
|
|
250
252
|
const { provenance, unrenderable } = resolved;
|
|
251
|
-
resolvedProvenance = provenance;
|
|
252
|
-
resolvedUnrenderable = unrenderable;
|
|
253
253
|
const { prompt: _p, ...rest } = resolved.options as { prompt: string };
|
|
254
254
|
effectiveTurnOpts = rest as ReturnType<typeof parseTurnOptions>;
|
|
255
255
|
// Provenance is diagnostic data like the spawn line - stderr in BOTH
|
|
@@ -268,12 +268,39 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
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
|
+
|
|
271
297
|
const fullOpts = {
|
|
272
298
|
...effectiveTurnOpts,
|
|
273
|
-
prompt,
|
|
299
|
+
prompt: composeEscalatedPrompt(prompt, escalateQuestions),
|
|
274
300
|
cwd: extra.cwd,
|
|
275
301
|
env: extra.env,
|
|
276
302
|
resume: extra.resume,
|
|
303
|
+
escalateQuestions,
|
|
277
304
|
...(passthrough.length > 0 ? { passthrough } : {}),
|
|
278
305
|
...(isExplicit ? { __explicitPrompt: true as const } : {}),
|
|
279
306
|
} as Parameters<typeof streamTurn>[1] & {
|
|
@@ -287,20 +314,23 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
287
314
|
let preArgv: string[] | null = null;
|
|
288
315
|
try {
|
|
289
316
|
if (fullOpts.resume) {
|
|
290
|
-
// Resume never carries profile
|
|
291
|
-
// 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.
|
|
292
320
|
preArgv = buildResumeArgv(h, {
|
|
293
321
|
...(turnOpts as object),
|
|
294
|
-
prompt,
|
|
322
|
+
prompt: fullOpts.prompt,
|
|
295
323
|
sessionId: fullOpts.resume,
|
|
296
324
|
__explicitPrompt: isExplicit,
|
|
297
325
|
} as never);
|
|
298
326
|
} else {
|
|
299
327
|
// Launch builds from the RESOLVED options so the spawn line and the
|
|
300
|
-
// 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.
|
|
301
331
|
preArgv = buildLaunchArgv(h, {
|
|
302
332
|
...(effectiveTurnOpts as object),
|
|
303
|
-
prompt,
|
|
333
|
+
prompt: fullOpts.prompt,
|
|
304
334
|
__explicitPrompt: isExplicit,
|
|
305
335
|
} as never);
|
|
306
336
|
const claudeSkillTokens = (effectiveTurnOpts as unknown as { __claudeSkillTokens?: string[] })
|
|
@@ -330,13 +360,18 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
330
360
|
// since buildLaunchArgv now respects __explicitPrompt.
|
|
331
361
|
|
|
332
362
|
if (preArgv) {
|
|
333
|
-
const redacted = redactArgv(preArgv, prompt);
|
|
363
|
+
const redacted = redactArgv(preArgv, fullOpts.prompt);
|
|
334
364
|
if (!wantJson) {
|
|
335
365
|
process.stderr.write(`spawn: ${redacted.join(" ")}\n`);
|
|
336
366
|
} else {
|
|
337
367
|
// In JSON mode, diagnostics to stderr only
|
|
338
368
|
process.stderr.write(`spawn: ${redacted.join(" ")}\n`);
|
|
339
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
|
+
);
|
|
340
375
|
}
|
|
341
376
|
|
|
342
377
|
// Delete HERDR_ENV before spawn
|
|
@@ -413,7 +448,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
413
448
|
renderEvent(event, state);
|
|
414
449
|
}
|
|
415
450
|
if (event.kind === "done") {
|
|
416
|
-
if (event.cause === "clean") exitCode = 0;
|
|
451
|
+
if (event.cause === "clean" || event.cause === "awaiting-input") exitCode = 0;
|
|
417
452
|
else exitCode = 1;
|
|
418
453
|
// If failure class is rejected? But done.cause for rejected would be failed? Still 1 per mapping, but refusal before spawn is 2.
|
|
419
454
|
// The RFC says limit/auth ->1, transport ->1, refusal ->2 (already handled). So done non-clean =>1.
|
package/src/execution/events.ts
CHANGED
|
@@ -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 =
|
|
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)
|
|
@@ -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";
|
|
@@ -102,6 +103,13 @@ export interface TurnRunOptions extends LaunchOptions {
|
|
|
102
103
|
* normalized argv. Wrong-harness flags here fail in the harness itself
|
|
103
104
|
* and surface as native errors - hcn never validates them. */
|
|
104
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;
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
export async function* streamTurn(
|
|
@@ -112,6 +120,21 @@ export async function* streamTurn(
|
|
|
112
120
|
const turnId = deps.turnId ?? `turn-${++turnCounter}`;
|
|
113
121
|
const log = deps.log ?? (() => {});
|
|
114
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
|
+
|
|
115
138
|
// Validate env before building argv so an invalid env is a refusal, not a spawn
|
|
116
139
|
if (opts.env !== undefined) {
|
|
117
140
|
for (const [k, v] of Object.entries(opts.env)) {
|
|
@@ -134,7 +157,7 @@ export async function* streamTurn(
|
|
|
134
157
|
harness: h.name,
|
|
135
158
|
issue: refusal.issue,
|
|
136
159
|
supported: refusal.supported,
|
|
137
|
-
argv: redactArgv([],
|
|
160
|
+
argv: redactArgv([], effective.prompt),
|
|
138
161
|
});
|
|
139
162
|
yield { kind: "failure", ...failure };
|
|
140
163
|
yield { kind: "done", exitCode: null, cause: "failed", failure };
|
|
@@ -147,11 +170,11 @@ export async function* streamTurn(
|
|
|
147
170
|
let granularity: import("../knowledge/descriptor.js").StreamingGranularity;
|
|
148
171
|
try {
|
|
149
172
|
argv =
|
|
150
|
-
|
|
151
|
-
? buildLaunchArgv(h,
|
|
152
|
-
: buildResumeArgv(h, { ...
|
|
153
|
-
if (
|
|
154
|
-
argv = [...argv, "--", ...
|
|
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];
|
|
155
178
|
}
|
|
156
179
|
// issue #38: claude renders the skills allowlist as settings JSON at
|
|
157
180
|
// the argv tail (the complement-off form).
|
|
@@ -175,7 +198,7 @@ export async function* streamTurn(
|
|
|
175
198
|
// No process spawned on a refusal - log rejected instead of spawn
|
|
176
199
|
let argvForLog: string[] = [];
|
|
177
200
|
try {
|
|
178
|
-
argvForLog = redactArgv([],
|
|
201
|
+
argvForLog = redactArgv([], effective.prompt);
|
|
179
202
|
} catch {}
|
|
180
203
|
log({
|
|
181
204
|
event: "rejected",
|
|
@@ -200,7 +223,7 @@ export async function* streamTurn(
|
|
|
200
223
|
event: "spawn",
|
|
201
224
|
turnId,
|
|
202
225
|
harness: h.name,
|
|
203
|
-
argv: redactArgv(argv,
|
|
226
|
+
argv: redactArgv(argv, effective.prompt),
|
|
204
227
|
granularity,
|
|
205
228
|
...(matcherOverrides ? { matcherOverrides } : {}),
|
|
206
229
|
...(envKeys?.length ? { envKeys } : {}),
|
|
@@ -210,8 +233,8 @@ export async function* streamTurn(
|
|
|
210
233
|
try {
|
|
211
234
|
proc = deps.spawn(argv, {
|
|
212
235
|
stdin: stdinPolicyOf(h) === "close-required" ? "close" : "inherit",
|
|
213
|
-
...(
|
|
214
|
-
...(
|
|
236
|
+
...(effective.cwd !== undefined ? { cwd: effective.cwd } : {}),
|
|
237
|
+
...(effective.env !== undefined ? { env: effective.env } : {}),
|
|
215
238
|
});
|
|
216
239
|
} catch (cause) {
|
|
217
240
|
// Spawn failure is a transport failure, not merely a crash
|
|
@@ -232,7 +255,7 @@ export async function* streamTurn(
|
|
|
232
255
|
}
|
|
233
256
|
|
|
234
257
|
const queue = new AsyncChannel<HarnessEvent>();
|
|
235
|
-
const state = freshDecodeState(
|
|
258
|
+
const state = freshDecodeState(effective.resume ?? null);
|
|
236
259
|
const stderrTail = new StderrTail();
|
|
237
260
|
let killedByWatchdog = false;
|
|
238
261
|
let exited = false;
|
|
@@ -341,6 +364,9 @@ export async function* streamTurn(
|
|
|
341
364
|
// Directly from decode's rate_limit_event handling - track for reduction
|
|
342
365
|
failures.push(event as unknown as FailureSummary);
|
|
343
366
|
}
|
|
367
|
+
if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
|
|
368
|
+
lastAssistantText = event.text;
|
|
369
|
+
}
|
|
344
370
|
await queue.push(event);
|
|
345
371
|
}
|
|
346
372
|
}
|
|
@@ -356,6 +382,37 @@ export async function* streamTurn(
|
|
|
356
382
|
}
|
|
357
383
|
};
|
|
358
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
|
+
|
|
359
416
|
const pumpStderr = async (): Promise<void> => {
|
|
360
417
|
const lines = new LineBuffer();
|
|
361
418
|
for await (const chunk of proc.stderr) {
|
|
@@ -413,7 +470,9 @@ export async function* streamTurn(
|
|
|
413
470
|
observePump("stdout", pumpStdout()),
|
|
414
471
|
observePump("stderr", pumpStderr()),
|
|
415
472
|
]);
|
|
416
|
-
void Promise.all([proc.exited, pumpSettlements])
|
|
473
|
+
void Promise.all([proc.exited, pumpSettlements])
|
|
474
|
+
.then(() => emitQuestionIfAsked())
|
|
475
|
+
.then(() => queue.close());
|
|
417
476
|
|
|
418
477
|
try {
|
|
419
478
|
for await (const event of queue) yield event;
|
|
@@ -459,7 +518,9 @@ export async function* streamTurn(
|
|
|
459
518
|
? "killed" // D11: the run was killed on budget, not stalled
|
|
460
519
|
: "stall"
|
|
461
520
|
: exitCode === 0
|
|
462
|
-
?
|
|
521
|
+
? asked
|
|
522
|
+
? "awaiting-input" // issue #41: asking SUCCEEDED the turn
|
|
523
|
+
: "clean"
|
|
463
524
|
: exitCode === null
|
|
464
525
|
? "killed"
|
|
465
526
|
: "crash";
|
|
@@ -69,6 +69,11 @@ export interface TurnOptions {
|
|
|
69
69
|
readonly write?: boolean;
|
|
70
70
|
readonly shell?: boolean;
|
|
71
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;
|
|
72
77
|
/** Internal: set by CLI when prompt came from --prompt/--prompt-file to bypass leading '-' guard */
|
|
73
78
|
readonly __explicitPrompt?: boolean;
|
|
74
79
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Question escalation (issue #41): the protocol that lets a headless
|
|
3
|
+
* worker ask the CALLER's user a question. Transport is the prompt - no
|
|
4
|
+
* harness ships native question conveyance - so hcn prepends a protocol
|
|
5
|
+
* contract (escalateQuestions true, the default) or the state-the-
|
|
6
|
+
* assumption instruction (false). The worker's final message then carries
|
|
7
|
+
* a fenced `hcn-question` block; detection is structured-first (fields
|
|
8
|
+
* parsed from the block, prose rendered downstream from them).
|
|
9
|
+
*
|
|
10
|
+
* Ratified design note (2026-08-19): autonomy and escalateQuestions are
|
|
11
|
+
* independent flags carving the same substrate by ORIGIN - autonomy =
|
|
12
|
+
* interrupts the harness raises (permission gates), escalateQuestions =
|
|
13
|
+
* interrupts the model raises (judgment gaps). The true-mode preamble
|
|
14
|
+
* must therefore never conflate asking with permission: "you may ask" is
|
|
15
|
+
* never "you lack permission." Both preambles open with the same marker
|
|
16
|
+
* so composition is idempotent (a composed prompt is never re-composed).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The shared first line of both preambles - also the idempotence marker
|
|
20
|
+
* for composeEscalatedPrompt. */
|
|
21
|
+
export const QUESTION_PREAMBLE_MARKER = "[hcn question protocol]";
|
|
22
|
+
|
|
23
|
+
export const ESCALATION_PREAMBLE = `${QUESTION_PREAMBLE_MARKER}
|
|
24
|
+
You are running headless: no one is watching this session live, but a caller relays answers between turns. This protocol never changes your permissions - asking is not how you obtain permission, and it never removes any permission this run already has. Use the tools you have exactly as granted.
|
|
25
|
+
If and only if a genuine decision you cannot make defensibly blocks correct progress, ask by ending your turn: emit one fenced code block tagged hcn-question, as the last content of your final message, containing a single JSON object:
|
|
26
|
+
|
|
27
|
+
\`\`\`hcn-question
|
|
28
|
+
{"question": "<the decision you need made>", "options": ["<option 1>", "<option 2>"], "recommended": "<one of options>"}
|
|
29
|
+
\`\`\`
|
|
30
|
+
|
|
31
|
+
Say nothing after the block. Your turn ends there; the caller's user will answer, and your next turn continues from that answer. For every choice you can make defensibly yourself, do not ask - decide, act, and state the decision you made.`;
|
|
32
|
+
|
|
33
|
+
export const NO_ESCALATION_PREAMBLE = `${QUESTION_PREAMBLE_MARKER}
|
|
34
|
+
You are running headless and no one will answer you in this session. Never ask a question, never request input or confirmation, and never end your turn awaiting a reply. When a decision is ambiguous, pick the most defensible reading, state the assumption you proceeded under in one sentence, and continue to completion.`;
|
|
35
|
+
|
|
36
|
+
/** Compose the transport preamble onto a prompt. Idempotent: a prompt
|
|
37
|
+
* that already carries either preamble (re-composition on resume, a
|
|
38
|
+
* caller that pre-composed) passes through unchanged. */
|
|
39
|
+
export const composeEscalatedPrompt = (prompt: string, escalate: boolean): string =>
|
|
40
|
+
prompt.startsWith(QUESTION_PREAMBLE_MARKER)
|
|
41
|
+
? prompt
|
|
42
|
+
: `${escalate ? ESCALATION_PREAMBLE : NO_ESCALATION_PREAMBLE}\n\n${prompt}`;
|
|
43
|
+
|
|
44
|
+
/** The structured question a worker asks (the block's fields). */
|
|
45
|
+
export interface QuestionBlock {
|
|
46
|
+
readonly question: string;
|
|
47
|
+
readonly options: readonly string[];
|
|
48
|
+
readonly recommended?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Detection result: a parsed block, a named malformation (the worker
|
|
52
|
+
* tried to ask but botched the shape - surfaced, never swallowed), or
|
|
53
|
+
* null when no hcn-question block is present. */
|
|
54
|
+
export type QuestionDetection = { readonly block: QuestionBlock } | { readonly malformed: string };
|
|
55
|
+
|
|
56
|
+
/** One fenced-block candidate: the body text plus whether the fence was
|
|
57
|
+
* properly closed (an unclosed opener at end-of-text is still examined -
|
|
58
|
+
* the most likely formatting slip is forgetting the closing fence). */
|
|
59
|
+
interface FenceCandidate {
|
|
60
|
+
readonly body: string;
|
|
61
|
+
readonly closed: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const FENCE_OPEN = /(?:^|\n)[ \t]*```[ \t]*hcn-question[ \t]*(?=\n)/g;
|
|
65
|
+
|
|
66
|
+
/** All hcn-question fence bodies in a text, in order. */
|
|
67
|
+
const fenceBodies = (text: string): FenceCandidate[] => {
|
|
68
|
+
const out: FenceCandidate[] = [];
|
|
69
|
+
FENCE_OPEN.lastIndex = 0;
|
|
70
|
+
for (let m = FENCE_OPEN.exec(text); m !== null; m = FENCE_OPEN.exec(text)) {
|
|
71
|
+
const bodyStart = m.index + m[0].length;
|
|
72
|
+
// The closing fence may be indented like the opener (probe evidence:
|
|
73
|
+
// indented blocks occur), so match newline + optional spaces + ```.
|
|
74
|
+
const close = text.slice(bodyStart).search(/\n[ \t]*```/);
|
|
75
|
+
if (close === -1) {
|
|
76
|
+
// Unclosed fence: the body runs to end-of-text. Only meaningful
|
|
77
|
+
// when nothing follows the opener - take it as a candidate anyway.
|
|
78
|
+
out.push({ body: text.slice(bodyStart), closed: false });
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
const bodyEnd = bodyStart + close;
|
|
82
|
+
out.push({ body: text.slice(bodyStart, bodyEnd), closed: true });
|
|
83
|
+
FENCE_OPEN.lastIndex = bodyEnd;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const parseBlock = (body: string): QuestionDetection => {
|
|
89
|
+
let raw: unknown;
|
|
90
|
+
try {
|
|
91
|
+
raw = JSON.parse(body);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return { malformed: `hcn-question block is not valid JSON: ${(e as Error).message}` };
|
|
94
|
+
}
|
|
95
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
96
|
+
return { malformed: "hcn-question block must be a JSON object" };
|
|
97
|
+
}
|
|
98
|
+
const obj = raw as Record<string, unknown>;
|
|
99
|
+
const { question, options, recommended } = obj;
|
|
100
|
+
if (typeof question !== "string" || question.trim() === "") {
|
|
101
|
+
return { malformed: 'hcn-question block field "question" must be a non-empty string' };
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
!Array.isArray(options) ||
|
|
105
|
+
options.length === 0 ||
|
|
106
|
+
options.some((o) => typeof o !== "string" || o.trim() === "")
|
|
107
|
+
) {
|
|
108
|
+
return {
|
|
109
|
+
malformed: 'hcn-question block field "options" must be an array of non-empty strings',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (recommended !== undefined && typeof recommended !== "string") {
|
|
113
|
+
return { malformed: 'hcn-question block field "recommended" must be a string' };
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
block: {
|
|
117
|
+
question,
|
|
118
|
+
options,
|
|
119
|
+
...(recommended !== undefined ? { recommended } : {}),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Detect the hcn-question block in a message text. The LAST block wins
|
|
125
|
+
* (the protocol makes the block the turn's final content; a corrected
|
|
126
|
+
* re-emit supersedes an earlier one). An empty candidate body (a bare
|
|
127
|
+
* unclosed opener with nothing after it) is not a detection. */
|
|
128
|
+
export const detectQuestionBlock = (text: string): QuestionDetection | null => {
|
|
129
|
+
const candidates = fenceBodies(text);
|
|
130
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
131
|
+
const candidate = candidates[i];
|
|
132
|
+
if (candidate === undefined) continue;
|
|
133
|
+
if (candidate.body.trim() === "") continue;
|
|
134
|
+
return parseBlock(candidate.body);
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
};
|