@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.
- package/README.md +30 -3
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +29 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +12 -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 +35 -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 +90 -16
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/skills-root.d.ts +4 -0
- package/dist/cli/skills-root.d.ts.map +1 -0
- package/dist/cli/skills-root.js +45 -0
- package/dist/cli/skills-root.js.map +1 -0
- 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/failure.d.ts +4 -1
- package/dist/execution/failure.d.ts.map +1 -1
- package/dist/execution/failure.js +15 -2
- package/dist/execution/failure.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 +83 -16
- package/dist/execution/stream-turn.js.map +1 -1
- package/dist/interpretation/argv.d.ts +10 -0
- package/dist/interpretation/argv.d.ts.map +1 -1
- package/dist/interpretation/argv.js +4 -0
- 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/dist/interpretation/refusal.d.ts +1 -1
- package/dist/interpretation/refusal.d.ts.map +1 -1
- package/dist/interpretation/refusal.js.map +1 -1
- package/dist/interpretation/resolve-options.d.ts.map +1 -1
- package/dist/interpretation/resolve-options.js +27 -5
- package/dist/interpretation/resolve-options.js.map +1 -1
- package/dist/interpretation/skills-selection.d.ts +22 -0
- package/dist/interpretation/skills-selection.d.ts.map +1 -0
- package/dist/interpretation/skills-selection.js +49 -0
- package/dist/interpretation/skills-selection.js.map +1 -0
- package/dist/knowledge/claude-code.d.ts.map +1 -1
- package/dist/knowledge/claude-code.js +1 -0
- package/dist/knowledge/claude-code.js.map +1 -1
- package/dist/knowledge/codex.d.ts.map +1 -1
- package/dist/knowledge/codex.js +1 -0
- package/dist/knowledge/codex.js.map +1 -1
- package/dist/knowledge/descriptor.d.ts +7 -0
- package/dist/knowledge/descriptor.d.ts.map +1 -1
- package/dist/knowledge/muse.d.ts.map +1 -1
- package/dist/knowledge/muse.js +1 -0
- package/dist/knowledge/muse.js.map +1 -1
- package/dist/knowledge/pi.d.ts.map +1 -1
- package/dist/knowledge/pi.js +1 -0
- package/dist/knowledge/pi.js.map +1 -1
- package/dist/knowledge/profile.d.ts +3 -0
- package/dist/knowledge/profile.d.ts.map +1 -1
- package/dist/knowledge/profile.js +20 -0
- package/dist/knowledge/profile.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/args.ts +39 -2
- package/src/cli/config.ts +14 -1
- package/src/cli/exit-codes.ts +1 -1
- package/src/cli/help.ts +35 -2
- package/src/cli/render.ts +16 -1
- package/src/cli/run.ts +99 -23
- package/src/cli/skills-root.ts +47 -0
- package/src/execution/events.ts +19 -1
- package/src/execution/failure.ts +16 -2
- package/src/execution/stream-turn.ts +91 -15
- package/src/interpretation/argv.ts +14 -0
- package/src/interpretation/question.ts +137 -0
- package/src/interpretation/refusal.ts +1 -0
- package/src/interpretation/resolve-options.ts +29 -5
- package/src/interpretation/skills-selection.ts +73 -0
- package/src/knowledge/claude-code.ts +1 -0
- package/src/knowledge/codex.ts +1 -0
- package/src/knowledge/descriptor.ts +7 -0
- package/src/knowledge/muse.ts +1 -0
- package/src/knowledge/pi.ts +1 -0
- 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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|
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)
|
package/src/execution/failure.ts
CHANGED
|
@@ -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
|
-
}; /**
|
|
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([],
|
|
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
|
-
|
|
150
|
-
? buildLaunchArgv(h,
|
|
151
|
-
: buildResumeArgv(h, { ...
|
|
152
|
-
if (
|
|
153
|
-
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];
|
|
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([],
|
|
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,
|
|
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
|
-
...(
|
|
206
|
-
...(
|
|
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(
|
|
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])
|
|
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
|
-
|
|
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
|
-
? "
|
|
517
|
+
? watchdogReason === "turn-deadline"
|
|
518
|
+
? "killed" // D11: the run was killed on budget, not stalled
|
|
519
|
+
: "stall"
|
|
446
520
|
: exitCode === 0
|
|
447
|
-
?
|
|
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
|
|