@dungle-scrubs/harness-cli-normalizer 0.3.1 → 0.4.1

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 (112) hide show
  1. package/README.md +64 -86
  2. package/dist/cli/args.d.ts +12 -0
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +44 -5
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/config.d.ts +25 -0
  7. package/dist/cli/config.d.ts.map +1 -0
  8. package/dist/cli/config.js +147 -0
  9. package/dist/cli/config.js.map +1 -0
  10. package/dist/cli/help.d.ts +1 -1
  11. package/dist/cli/help.d.ts.map +1 -1
  12. package/dist/cli/help.js +17 -1
  13. package/dist/cli/help.js.map +1 -1
  14. package/dist/cli/inspect.d.ts.map +1 -1
  15. package/dist/cli/inspect.js +35 -1
  16. package/dist/cli/inspect.js.map +1 -1
  17. package/dist/cli/render.d.ts.map +1 -1
  18. package/dist/cli/render.js +10 -1
  19. package/dist/cli/render.js.map +1 -1
  20. package/dist/cli/run.d.ts.map +1 -1
  21. package/dist/cli/run.js +175 -5
  22. package/dist/cli/run.js.map +1 -1
  23. package/dist/cli/skills-root.d.ts +4 -0
  24. package/dist/cli/skills-root.d.ts.map +1 -0
  25. package/dist/cli/skills-root.js +45 -0
  26. package/dist/cli/skills-root.js.map +1 -0
  27. package/dist/execution/failure.d.ts +24 -4
  28. package/dist/execution/failure.d.ts.map +1 -1
  29. package/dist/execution/failure.js +39 -3
  30. package/dist/execution/failure.js.map +1 -1
  31. package/dist/execution/stream-turn.d.ts +4 -0
  32. package/dist/execution/stream-turn.d.ts.map +1 -1
  33. package/dist/execution/stream-turn.js +43 -6
  34. package/dist/execution/stream-turn.js.map +1 -1
  35. package/dist/interpretation/argv.d.ts +6 -0
  36. package/dist/interpretation/argv.d.ts.map +1 -1
  37. package/dist/interpretation/argv.js +17 -19
  38. package/dist/interpretation/argv.js.map +1 -1
  39. package/dist/interpretation/hints.d.ts +22 -0
  40. package/dist/interpretation/hints.d.ts.map +1 -0
  41. package/dist/interpretation/hints.js +59 -0
  42. package/dist/interpretation/hints.js.map +1 -0
  43. package/dist/interpretation/refusal.d.ts +25 -4
  44. package/dist/interpretation/refusal.d.ts.map +1 -1
  45. package/dist/interpretation/refusal.js +16 -0
  46. package/dist/interpretation/refusal.js.map +1 -1
  47. package/dist/interpretation/resolve-options.d.ts +42 -0
  48. package/dist/interpretation/resolve-options.d.ts.map +1 -0
  49. package/dist/interpretation/resolve-options.js +162 -0
  50. package/dist/interpretation/resolve-options.js.map +1 -0
  51. package/dist/interpretation/skills-selection.d.ts +22 -0
  52. package/dist/interpretation/skills-selection.d.ts.map +1 -0
  53. package/dist/interpretation/skills-selection.js +49 -0
  54. package/dist/interpretation/skills-selection.js.map +1 -0
  55. package/dist/interpretation/support.d.ts +32 -0
  56. package/dist/interpretation/support.d.ts.map +1 -0
  57. package/dist/interpretation/support.js +107 -0
  58. package/dist/interpretation/support.js.map +1 -0
  59. package/dist/interpretation/tool-selection.d.ts +29 -0
  60. package/dist/interpretation/tool-selection.d.ts.map +1 -0
  61. package/dist/interpretation/tool-selection.js +122 -0
  62. package/dist/interpretation/tool-selection.js.map +1 -0
  63. package/dist/interpretation/turn-options.d.ts.map +1 -1
  64. package/dist/interpretation/turn-options.js +4 -0
  65. package/dist/interpretation/turn-options.js.map +1 -1
  66. package/dist/knowledge/claude-code.d.ts.map +1 -1
  67. package/dist/knowledge/claude-code.js +35 -1
  68. package/dist/knowledge/claude-code.js.map +1 -1
  69. package/dist/knowledge/codex.d.ts.map +1 -1
  70. package/dist/knowledge/codex.js +20 -0
  71. package/dist/knowledge/codex.js.map +1 -1
  72. package/dist/knowledge/descriptor.d.ts +39 -0
  73. package/dist/knowledge/descriptor.d.ts.map +1 -1
  74. package/dist/knowledge/muse.d.ts.map +1 -1
  75. package/dist/knowledge/muse.js +20 -0
  76. package/dist/knowledge/muse.js.map +1 -1
  77. package/dist/knowledge/pi.d.ts.map +1 -1
  78. package/dist/knowledge/pi.js +22 -0
  79. package/dist/knowledge/pi.js.map +1 -1
  80. package/dist/knowledge/profile.d.ts +16 -0
  81. package/dist/knowledge/profile.d.ts.map +1 -0
  82. package/dist/knowledge/profile.js +51 -0
  83. package/dist/knowledge/profile.js.map +1 -0
  84. package/package.json +2 -23
  85. package/src/cli/args.ts +64 -3
  86. package/src/cli/config.ts +176 -0
  87. package/src/cli/help.ts +17 -1
  88. package/src/cli/inspect.ts +34 -1
  89. package/src/cli/render.ts +10 -1
  90. package/src/cli/run.ts +203 -6
  91. package/src/cli/skills-root.ts +47 -0
  92. package/src/execution/failure.ts +58 -5
  93. package/src/execution/stream-turn.ts +50 -5
  94. package/src/interpretation/argv.ts +23 -19
  95. package/src/interpretation/hints.ts +99 -0
  96. package/src/interpretation/refusal.ts +33 -3
  97. package/src/interpretation/resolve-options.ts +214 -0
  98. package/src/interpretation/skills-selection.ts +73 -0
  99. package/src/interpretation/support.ts +119 -0
  100. package/src/interpretation/tool-selection.ts +161 -0
  101. package/src/interpretation/turn-options.ts +4 -0
  102. package/src/knowledge/claude-code.ts +35 -1
  103. package/src/knowledge/codex.ts +20 -0
  104. package/src/knowledge/descriptor.ts +39 -0
  105. package/src/knowledge/muse.ts +20 -0
  106. package/src/knowledge/pi.ts +22 -0
  107. package/src/knowledge/profile.ts +53 -0
  108. package/dist/index.d.ts +0 -8
  109. package/dist/index.d.ts.map +0 -1
  110. package/dist/index.js +0 -8
  111. package/dist/index.js.map +0 -1
  112. package/src/index.ts +0 -7
package/src/cli/run.ts CHANGED
@@ -3,6 +3,13 @@ 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
5
  import { ArgvRefusalError } from "../interpretation/refusal.js";
6
+ import {
7
+ FloorExceededError,
8
+ type ProvenanceEntry,
9
+ resolveEffectiveOptions,
10
+ } from "../interpretation/resolve-options.js";
11
+ import { recognizeNativeSpelling, supportedBy } from "../interpretation/support.js";
12
+ import { defaultDescriptors } from "../knowledge/overrides.js";
6
13
  import { parseRunExtra, parseTurnOptions, resolvePromptAsync } from "./args.js";
7
14
  import { createRenderState, renderEvent, writeEventNdjson } from "./render.js";
8
15
  import { resolveHarness } from "./resolve-harness.js";
@@ -16,7 +23,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
16
23
  return;
17
24
  }
18
25
 
19
- const { parseCommonFlags, detectPositionalPromptInjection } = await import("./args.js");
26
+ const { parseCommonFlags, detectPositionalPromptInjection, splitPassthrough } = await import(
27
+ "./args.js"
28
+ );
29
+ const { passthrough } = splitPassthrough(rawArgs);
20
30
  const injection = detectPositionalPromptInjection(rawArgs);
21
31
  if (injection) {
22
32
  const err = new ArgvRefusalError({
@@ -35,7 +45,52 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
35
45
  parsed = parseCommonFlags(rawArgs);
36
46
  } catch (err) {
37
47
  const message = err instanceof Error ? err.message : String(err);
38
- process.stderr.write(`unknown flag: ${message}\n`);
48
+ // D7 part B: a native spelling passed before the separator gets
49
+ // recognized and redirected to the normalized flag instead of a
50
+ // generic unknown-flag error.
51
+ // parseArgs reports unknown long flags as "Unknown option '--x'" but
52
+ // splits bundled short flags ("-nt" -> "Unknown option 'n'"). Match the
53
+ // reported token back against the ORIGINAL argv: a short-flag bundle
54
+ // that some descriptor spells exactly (pi's -nt) is recognizable; a
55
+ // lone unknown token keeps the plain error.
56
+ const flagMatch = message.match(/Unknown option '([A-Za-z0-9_-]+)'/);
57
+ let rawFlag: string | undefined;
58
+ if (flagMatch?.[1] !== undefined) {
59
+ const reported = flagMatch[1].startsWith("-") ? flagMatch[1] : `-${flagMatch[1]}`;
60
+ // Exact long flag: use it. Reported short flag (e.g. -n): the caller
61
+ // may have typed a BUNDLE (-nt) that parseArgs split - find the argv
62
+ // token that starts with the reported short and is longer; recognition
63
+ // then decides whether the whole bundle is a descriptor spelling.
64
+ const fromArgv =
65
+ rawArgs.find((a) => a === reported) ??
66
+ (reported.length === 2
67
+ ? rawArgs.find((a) => a.length > 2 && a.startsWith(reported))
68
+ : undefined);
69
+ rawFlag = fromArgv ?? reported;
70
+ }
71
+ const native =
72
+ rawFlag !== undefined ? recognizeNativeSpelling(defaultDescriptors(), rawFlag) : null;
73
+ if (native !== null) {
74
+ const by = native.option.startsWith("discovery.")
75
+ ? native.entries
76
+ : supportedBy(defaultDescriptors(), native.option);
77
+ const normalizedSpelling =
78
+ native.option === "excludeTools"
79
+ ? "--exclude-tools"
80
+ : native.option.startsWith("discovery.")
81
+ ? `--no-${native.option.split(".")[1] === "instructionFiles" ? "instruction-files" : native.option.split(".")[1]}`
82
+ : `--${native.option}`;
83
+ process.stderr.write(
84
+ `unknown flag: ${rawFlag} is a native spelling (used by ${native.entries.map((e) => e.harness).join(", ")}) - use the normalized ${normalizedSpelling} flag instead\n`,
85
+ );
86
+ if (by.length > 0) {
87
+ process.stderr.write(
88
+ `supported on: ${by.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
89
+ );
90
+ }
91
+ } else {
92
+ process.stderr.write(`unknown flag: ${message}\n`);
93
+ }
39
94
  process.stderr.write(`Run 'hcn run --help' for usage.\n`);
40
95
  process.exitCode = 2;
41
96
  return;
@@ -50,6 +105,11 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
50
105
  process.exitCode = 2;
51
106
  return;
52
107
  }
108
+ if (passthrough.length === 0 && rawArgs.includes("--")) {
109
+ process.stderr.write(`-- separator given but no passthrough tokens followed it\n`);
110
+ process.exitCode = 2;
111
+ return;
112
+ }
53
113
 
54
114
  const wantJson = values.json === true;
55
115
 
@@ -67,6 +127,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
67
127
  } catch (err) {
68
128
  if (err instanceof ArgvRefusalError) {
69
129
  process.stderr.write(`${err.message}\n`);
130
+ if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
131
+ if (err.supportedBy?.length) {
132
+ process.stderr.write(
133
+ `supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
134
+ );
135
+ }
70
136
  if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
71
137
  process.exitCode = 2;
72
138
  return;
@@ -86,6 +152,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
86
152
  } catch (err) {
87
153
  if (err instanceof ArgvRefusalError) {
88
154
  process.stderr.write(`${err.message}\n`);
155
+ if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
156
+ if (err.supportedBy?.length) {
157
+ process.stderr.write(
158
+ `supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
159
+ );
160
+ }
89
161
  if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
90
162
  process.exitCode = 2;
91
163
  return;
@@ -99,6 +171,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
99
171
  } catch (err) {
100
172
  if (err instanceof ArgvRefusalError) {
101
173
  process.stderr.write(`${err.message}\n`);
174
+ if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
175
+ if (err.supportedBy?.length) {
176
+ process.stderr.write(
177
+ `supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
178
+ );
179
+ }
102
180
  if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
103
181
  process.exitCode = 2;
104
182
  return;
@@ -107,20 +185,110 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
107
185
  }
108
186
 
109
187
  const isExplicit = promptSource !== "positional";
188
+
189
+ // issue #38: resolve --skills names against the caller's registry root,
190
+ // then hand the harness its native rendering (pi loads; claude narrows).
191
+ const rawSkills = (turnOpts as unknown as { skills?: string[] }).skills;
192
+ if (rawSkills !== undefined && rawSkills.length > 0) {
193
+ try {
194
+ const { resolveSkillNames, listKnownSkills } = await import("./skills-root.js");
195
+ const resolvedSkills = resolveSkillNames(rawSkills);
196
+ const claudeTokens: string[] = [];
197
+ if (h.name === "claude") {
198
+ const { claudeSkillOverridesArg } = await import("../interpretation/skills-selection.js");
199
+ claudeTokens.push(...claudeSkillOverridesArg(listKnownSkills(), resolvedSkills));
200
+ }
201
+ (turnOpts as unknown as Record<string, unknown>).skills = resolvedSkills;
202
+ (turnOpts as unknown as Record<string, unknown>).__claudeSkillTokens = claudeTokens;
203
+ } catch (err) {
204
+ if (err instanceof ArgvRefusalError) {
205
+ process.stderr.write(`${err.message}\n`);
206
+ if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
207
+ process.exitCode = 2;
208
+ return;
209
+ }
210
+ throw err;
211
+ }
212
+ }
213
+
214
+ // Defaults profile + user config: LAUNCH-ONLY. A resumed session keeps
215
+ // its own settings; the resolver never runs on resume paths.
216
+ let resolvedProvenance: readonly ProvenanceEntry[] = [];
217
+ let resolvedUnrenderable: readonly string[] = [];
218
+ let effectiveTurnOpts: ReturnType<typeof parseTurnOptions> = turnOpts;
219
+ const resolvedTiers: {
220
+ user?: Partial<ReturnType<typeof parseTurnOptions>>;
221
+ project?: Partial<ReturnType<typeof parseTurnOptions>>;
222
+ } = {};
223
+ if (extra.resume === undefined) {
224
+ const tiers = resolvedTiers;
225
+ const { loadUserConfig, loadProjectConfig, ConfigError } = await import("./config.js");
226
+ try {
227
+ const loaded = loadUserConfig();
228
+ if (loaded !== null) tiers.user = loaded.config;
229
+ const proj = loadProjectConfig();
230
+ if (proj !== null) tiers.project = proj.config;
231
+ } catch (configErr) {
232
+ if (configErr instanceof ConfigError) {
233
+ process.stderr.write(`config error: ${(configErr as Error).message}\n`);
234
+ process.exitCode = 2;
235
+ return;
236
+ }
237
+ throw configErr;
238
+ }
239
+ let resolved: ReturnType<typeof resolveEffectiveOptions>;
240
+ try {
241
+ resolved = resolveEffectiveOptions(h, { ...turnOpts, prompt } as never, tiers);
242
+ } catch (resErr) {
243
+ if (resErr instanceof FloorExceededError) {
244
+ process.stderr.write(`${(resErr as Error).message}\n`);
245
+ process.exitCode = 2;
246
+ return;
247
+ }
248
+ throw resErr;
249
+ }
250
+ const { provenance, unrenderable } = resolved;
251
+ resolvedProvenance = provenance;
252
+ resolvedUnrenderable = unrenderable;
253
+ const { prompt: _p, ...rest } = resolved.options as { prompt: string };
254
+ effectiveTurnOpts = rest as ReturnType<typeof parseTurnOptions>;
255
+ // Provenance is diagnostic data like the spawn line - stderr in BOTH
256
+ // render modes, never stdout (stdout carries the NDJSON contract).
257
+ if (provenance.length > 0 || unrenderable.length > 0) {
258
+ for (const entry of provenance) {
259
+ process.stderr.write(
260
+ `provenance: ${entry.key} = ${JSON.stringify(entry.value)} (${entry.tier})\n`,
261
+ );
262
+ }
263
+ for (const key of unrenderable) {
264
+ process.stderr.write(
265
+ `divergence: profile ${JSON.stringify(key)} not expressible on ${h.name}; harness default applies\n`,
266
+ );
267
+ }
268
+ }
269
+ }
270
+
110
271
  const fullOpts = {
111
- ...turnOpts,
272
+ ...effectiveTurnOpts,
112
273
  prompt,
113
274
  cwd: extra.cwd,
114
275
  env: extra.env,
115
276
  resume: extra.resume,
277
+ ...(passthrough.length > 0 ? { passthrough } : {}),
116
278
  ...(isExplicit ? { __explicitPrompt: true as const } : {}),
117
- } as Parameters<typeof streamTurn>[1] & { resume?: string; __explicitPrompt?: boolean };
279
+ } as Parameters<typeof streamTurn>[1] & {
280
+ resume?: string;
281
+ __explicitPrompt?: boolean;
282
+ passthrough?: readonly string[];
283
+ };
118
284
 
119
285
  // Pre-validate via building argv to catch refusals before spawn (so we don't spawn on bad args)
120
286
  let _validated = false;
121
287
  let preArgv: string[] | null = null;
122
288
  try {
123
289
  if (fullOpts.resume) {
290
+ // Resume never carries profile/config resolution (launch-only rule),
291
+ // so it builds from the raw turn options.
124
292
  preArgv = buildResumeArgv(h, {
125
293
  ...(turnOpts as object),
126
294
  prompt,
@@ -128,16 +296,29 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
128
296
  __explicitPrompt: isExplicit,
129
297
  } as never);
130
298
  } else {
299
+ // Launch builds from the RESOLVED options so the spawn line and the
300
+ // real argv agree.
131
301
  preArgv = buildLaunchArgv(h, {
132
- ...(turnOpts as object),
302
+ ...(effectiveTurnOpts as object),
133
303
  prompt,
134
304
  __explicitPrompt: isExplicit,
135
305
  } as never);
306
+ const claudeSkillTokens = (effectiveTurnOpts as unknown as { __claudeSkillTokens?: string[] })
307
+ .__claudeSkillTokens;
308
+ if (claudeSkillTokens !== undefined && claudeSkillTokens.length > 0) {
309
+ preArgv.push(...claudeSkillTokens);
310
+ }
136
311
  }
137
312
  _validated = true;
138
313
  } catch (err) {
139
314
  if (err instanceof ArgvRefusalError) {
140
315
  process.stderr.write(`${err.message}\n`);
316
+ if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
317
+ if (err.supportedBy?.length) {
318
+ process.stderr.write(
319
+ `supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
320
+ );
321
+ }
141
322
  if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
142
323
  process.exitCode = 2;
143
324
  return;
@@ -161,7 +342,17 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
161
342
  // Delete HERDR_ENV before spawn
162
343
  delete (process.env as Record<string, string | undefined>).HERDR_ENV;
163
344
 
164
- const deps = nodeRunnerDeps();
345
+ // D11: opt-in wall-clock budget. Precedence arg > project > user (no
346
+ // profile entry by ratification). 0 = explicit disable.
347
+ const timeoutSeconds =
348
+ extra.timeoutSeconds !== undefined
349
+ ? extra.timeoutSeconds
350
+ : ((resolvedTiers?.project as { timeout?: number } | undefined)?.timeout ??
351
+ (resolvedTiers?.user as { timeout?: number } | undefined)?.timeout);
352
+ const deps =
353
+ timeoutSeconds !== undefined && timeoutSeconds > 0
354
+ ? nodeRunnerDeps({ turnTimeoutMs: timeoutSeconds * 1000 })
355
+ : nodeRunnerDeps();
165
356
 
166
357
  // Signal handling
167
358
  const _currentProc: { signal: (sig: "SIGTERM" | "SIGKILL") => void } | null = null;
@@ -234,6 +425,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
234
425
  } catch (err) {
235
426
  if (err instanceof ArgvRefusalError) {
236
427
  process.stderr.write(`${err.message}\n`);
428
+ if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
429
+ if (err.supportedBy?.length) {
430
+ process.stderr.write(
431
+ `supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
432
+ );
433
+ }
237
434
  if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
238
435
  process.exitCode = 2;
239
436
  process.off("SIGINT", onSig);
@@ -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
+ };
@@ -30,6 +30,8 @@ export const FAILURE_CLASSES = Object.freeze([
30
30
  "task",
31
31
  "transport",
32
32
  "rejected",
33
+ "native",
34
+ "timeout",
33
35
  ] as const);
34
36
  export type FailureClass = (typeof FAILURE_CLASSES)[number];
35
37
 
@@ -41,13 +43,21 @@ export interface FailureSummary {
41
43
  readonly authKind?: AuthFailureKind;
42
44
  readonly resetsAt?: number;
43
45
  readonly issue?: RefusalIssue;
44
- readonly option?: TurnOptionKey;
46
+ readonly option?: import("../interpretation/refusal.js").RefusalOption;
45
47
  readonly facet?: DiscoveryFacet;
46
48
  readonly supported?: readonly string[];
49
+ /** D7: cross-harness support entries derived from descriptors. */
50
+ readonly supportedBy?: ReadonlyArray<{ harness: string; spelling: string }>;
51
+ /** D8: nearest-alternative hint for the current harness. */
52
+ readonly hint?: string;
53
+ /** D6: the harness process's own exit code for a native failure - data,
54
+ * because native conventions differ from hcn's (codex usage errors exit
55
+ * 2, which hcn reserves for refusals). */
56
+ readonly nativeExitCode?: number;
47
57
  }
48
58
 
49
59
  export const retryableOf = (cls: FailureClass): boolean =>
50
- cls !== "task" && cls !== "budget" && cls !== "rejected";
60
+ cls !== "task" && cls !== "budget" && cls !== "rejected" && cls !== "native" && cls !== "timeout";
51
61
 
52
62
  const messageFor = (cls: FailureClass, detail?: string): string => {
53
63
  switch (cls) {
@@ -67,10 +77,42 @@ const messageFor = (cls: FailureClass, detail?: string): string => {
67
77
  return `Transport failure${detail ? ` (${detail})` : ""} - retry or route to another provider`;
68
78
  case "rejected":
69
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`;
85
+ case "native":
86
+ // D6: labeled NATIVE so it can never be confused with an hcn error.
87
+ // The harness's own message follows verbatim; the process exit code
88
+ // rides as data (nativeExitCode), because harness conventions differ
89
+ // (codex exits 2 on usage errors - the same code hcn uses for
90
+ // refusals, so hcn owns its own exit code and reports the native one).
91
+ return `NATIVE ERROR from harness${detail ? `: ${detail}` : ""} - the harness rejected or failed on its own arguments; this is not an hcn error`;
70
92
  default:
71
93
  return `Failure${detail ? ` (${detail})` : ""}`;
72
94
  }
73
- };
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
103
+ * native stderr verbatim and the native exit code as data. */
104
+ export const failureFromNative = (
105
+ nativeExitCode: number | null,
106
+ stderrTail: readonly string[],
107
+ ): FailureSummary => ({
108
+ class: "native",
109
+ retryable: false,
110
+ message: messageFor(
111
+ "native",
112
+ stderrTail.slice(-3).join(" | ").slice(0, 512) || `exit ${nativeExitCode}`,
113
+ ),
114
+ nativeExitCode: nativeExitCode ?? undefined,
115
+ });
74
116
 
75
117
  export const failureFromLimit = (code: LimitCode): FailureSummary => {
76
118
  const cls: FailureClass =
@@ -114,18 +156,25 @@ export const failureFromTransport = (detail?: string): FailureSummary => ({
114
156
 
115
157
  export const failureFromRejected = (opts: {
116
158
  issue: RefusalIssue;
117
- option?: TurnOptionKey;
159
+ option?: import("../interpretation/refusal.js").RefusalOption;
118
160
  facet?: DiscoveryFacet;
119
161
  supported?: readonly string[];
162
+ supportedBy?: ReadonlyArray<{ harness: string; spelling: string }>;
163
+ hint?: string;
120
164
  detail?: string;
121
165
  }): FailureSummary => ({
122
166
  class: "rejected",
123
167
  retryable: false,
168
+ // D8: hint first, support list second - prose order matches the
169
+ // structured fields so an agent scanning the message hits the
170
+ // stay-on-harness suggestion before the switch temptation.
124
171
  message: messageFor("rejected", opts.detail ?? opts.issue),
125
172
  issue: opts.issue,
126
173
  option: opts.option,
127
174
  facet: opts.facet,
128
175
  supported: opts.supported,
176
+ supportedBy: opts.supportedBy,
177
+ hint: opts.hint,
129
178
  });
130
179
 
131
180
  /** Precedence for reduction: lower number = higher priority (wins). */
@@ -137,7 +186,11 @@ const PRECEDENCE: Record<FailureClass, number> = {
137
186
  budget: 3,
138
187
  task: 3,
139
188
  transport: 4,
140
- rejected: 0, // rejected is separate, not reduced with others? But per spec, rejected is non-retryable and stands alone
189
+ timeout: 3,
190
+ // rejected stands alone (checked before precedence applies); native is
191
+ // terminal-by-classification, never reduced into anything else.
192
+ rejected: 0,
193
+ native: 0,
141
194
  };
142
195
 
143
196
  export const reduceFailures = (failures: readonly FailureSummary[]): FailureSummary | undefined => {
@@ -28,7 +28,9 @@ import type { FailureSummary } from "./failure.js";
28
28
  import {
29
29
  failureFromAuth,
30
30
  failureFromLimit,
31
+ failureFromNative,
31
32
  failureFromRejected,
33
+ failureFromTimeout,
32
34
  failureFromTransport,
33
35
  reduceFailures,
34
36
  } from "./failure.js";
@@ -96,6 +98,10 @@ export interface TurnRunOptions extends LaunchOptions {
96
98
  readonly cwd?: string;
97
99
  /** Per-call environment, merged over parent; "" deletes. */
98
100
  readonly env?: Readonly<Record<string, string>>;
101
+ /** D6 passthrough: raw harness tokens appended verbatim after the
102
+ * normalized argv. Wrong-harness flags here fail in the harness itself
103
+ * and surface as native errors - hcn never validates them. */
104
+ readonly passthrough?: readonly string[];
99
105
  }
100
106
 
101
107
  export async function* streamTurn(
@@ -144,6 +150,16 @@ export async function* streamTurn(
144
150
  opts.resume === undefined
145
151
  ? buildLaunchArgv(h, opts)
146
152
  : buildResumeArgv(h, { ...opts, sessionId: opts.resume });
153
+ if (opts.passthrough !== undefined && opts.passthrough.length > 0) {
154
+ argv = [...argv, "--", ...opts.passthrough];
155
+ }
156
+ // issue #38: claude renders the skills allowlist as settings JSON at
157
+ // the argv tail (the complement-off form).
158
+ const claudeSkillTokens = (opts as unknown as { __claudeSkillTokens?: string[] })
159
+ .__claudeSkillTokens;
160
+ if (claudeSkillTokens !== undefined && claudeSkillTokens.length > 0) {
161
+ argv = [...argv, ...claudeSkillTokens];
162
+ }
147
163
  granularity = streamingGranularityOf(h, argv);
148
164
  } catch (e) {
149
165
  if (e instanceof ArgvRefusalError) {
@@ -152,6 +168,8 @@ export async function* streamTurn(
152
168
  option: e.option,
153
169
  facet: e.facet,
154
170
  supported: e.supported,
171
+ supportedBy: e.supportedBy,
172
+ hint: e.hint,
155
173
  detail: e.message,
156
174
  });
157
175
  // No process spawned on a refusal - log rejected instead of spawn
@@ -400,7 +418,12 @@ export async function* streamTurn(
400
418
  try {
401
419
  for await (const event of queue) yield event;
402
420
 
403
- // Post-queue failure sources: nonzero exit with no other failure is transport
421
+ // Post-queue failure sources. Nonzero exit with no other failure and a
422
+ // non-empty stderr tail is a NATIVE failure (D6): the harness rejected
423
+ // its own arguments or crashed on them - verbatim stderr, native exit
424
+ // code as data, hcn exit 1. Without a stderr tail it stays transport
425
+ // (a silent nonzero exit reads as an environment problem, not a
426
+ // harness judgment).
404
427
  if (
405
428
  failures.length === 0 &&
406
429
  exitCode !== 0 &&
@@ -408,14 +431,23 @@ export async function* streamTurn(
408
431
  !killedByWatchdog &&
409
432
  !state.limitSeen
410
433
  ) {
411
- const f = failureFromTransport(`nonzero exit ${exitCode}`);
434
+ const tailForNative = stderrTail.snapshot();
435
+ const f =
436
+ tailForNative.length > 0
437
+ ? failureFromNative(exitCode, tailForNative)
438
+ : failureFromTransport(`nonzero exit ${exitCode}`);
412
439
  failures.push(f);
413
440
  // Need to emit this failure before done, even though queue is closed
414
441
  yield { kind: "failure", ...f };
415
442
  }
416
443
  // Stall watchdog also implies a transport failure if not already present
417
444
  if (killedByWatchdog && failures.length === 0) {
418
- const f = failureFromTransport(`stalled: ${watchdogReason ?? "inactivity"}`);
445
+ // D11: a wall-clock deadline kill is a timeout, not a stall - the
446
+ // run was not necessarily silent, it simply outlived its budget.
447
+ const f =
448
+ watchdogReason === "turn-deadline"
449
+ ? failureFromTimeout()
450
+ : failureFromTransport(`stalled: ${watchdogReason ?? "inactivity"}`);
419
451
  failures.push(f);
420
452
  yield { kind: "failure", ...f };
421
453
  }
@@ -423,7 +455,9 @@ export async function* streamTurn(
423
455
  let cause: ExitCause = state.limitSeen
424
456
  ? "limit"
425
457
  : killedByWatchdog && exitCode !== 0
426
- ? "stall"
458
+ ? watchdogReason === "turn-deadline"
459
+ ? "killed" // D11: the run was killed on budget, not stalled
460
+ : "stall"
427
461
  : exitCode === 0
428
462
  ? "clean"
429
463
  : exitCode === null
@@ -451,7 +485,18 @@ export async function* streamTurn(
451
485
  yield { kind: "error", message: tail.join("\n").slice(0, 4096) };
452
486
  }
453
487
  terminalEventReached = true;
454
- yield { kind: "done", exitCode, cause, ...(reduced ? { failure: reduced } : {}) };
488
+ // D6: when the failure is native, the harness's own exit convention is
489
+ // DATA (nativeExitCode on the failure), not the done event's contract -
490
+ // hcn owns the process exit code (1 for any native failure) because
491
+ // harness conventions collide with hcn's (codex usage errors exit 2,
492
+ // which hcn reserves for refusals).
493
+ const nativeReduced = reduced?.class === "native";
494
+ yield {
495
+ kind: "done",
496
+ exitCode: nativeReduced ? null : exitCode,
497
+ cause,
498
+ ...(reduced ? { failure: reduced } : {}),
499
+ };
455
500
  } finally {
456
501
  const abandoned = !terminalEventReached;
457
502
  cancelled = true;
@@ -5,8 +5,12 @@
5
5
  * no caller re-derives them.
6
6
  */
7
7
  import type { HarnessDescriptor, StreamingGranularity } from "../knowledge/descriptor.js";
8
+ import { defaultDescriptors } from "../knowledge/overrides.js";
8
9
  import { ArgvRefusalError } from "./refusal.js";
9
10
  import { assertUsableSessionId } from "./session-id.js";
11
+ import { renderSkillsSelection } from "./skills-selection.js";
12
+ import { supportedBy } from "./support.js";
13
+ import { renderToolSelection } from "./tool-selection.js";
10
14
  import { renderTurnOptions } from "./turn-options.js";
11
15
  import { validateModel } from "./vocabulary.js";
12
16
 
@@ -50,6 +54,12 @@ export interface DiscoveryOptions {
50
54
  export interface TurnOptions {
51
55
  readonly prompt: string;
52
56
  readonly tools?: readonly string[];
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[];
53
63
  readonly model?: string;
54
64
  readonly autonomy?: boolean;
55
65
  readonly effort?: string;
@@ -90,32 +100,26 @@ const turnTail = (h: HarnessDescriptor, opts: TurnOptions): string[] => {
90
100
  }
91
101
  if (opts.autonomy === true) {
92
102
  if (h.autonomy === null) {
103
+ const by = supportedBy(defaultDescriptors(), "autonomy");
93
104
  throw new ArgvRefusalError({
94
105
  issue: "no-autonomy-mode",
95
106
  harness: h.name,
96
- supported: ["claude --dangerously-skip-permissions", "codex --yolo", "muse --yolo"],
107
+ supported: by.map((e) => `${e.harness} ${e.spelling}`),
108
+ supportedBy: by,
109
+ hint: "pi has no unattended-run flag; approximate with a per-tool allowlist (--tools read,bash) if you need unattended behavior on pi",
97
110
  });
98
111
  }
99
112
  tail.push(h.autonomy.flag);
100
113
  }
101
- if (opts.tools !== undefined) {
102
- if (h.launch.toolsFlag === null) {
103
- throw new ArgvRefusalError({
104
- issue: "unsupported-option",
105
- harness: h.name,
106
- supported: ["--allowedTools is claude-only"],
107
- detail: "tools",
108
- });
109
- }
110
- if (opts.tools.length === 0 || opts.tools.some((t) => t.trim() === "" || t.includes(","))) {
111
- throw new ArgvRefusalError({
112
- issue: "invalid-tool-grant",
113
- harness: h.name,
114
- supported: ["non-empty, comma-free tool names"],
115
- detail: `tools=${JSON.stringify(opts.tools)}`,
116
- });
117
- }
118
- tail.push(h.launch.toolsFlag, opts.tools.join(","));
114
+ if (opts.tools !== undefined || opts.excludeTools !== undefined) {
115
+ const rendered = renderToolSelection(h, {
116
+ include: opts.tools,
117
+ exclude: opts.excludeTools,
118
+ });
119
+ tail.push(...rendered.tokens);
120
+ }
121
+ if (opts.skills !== undefined && opts.skills.length > 0) {
122
+ tail.push(...renderSkillsSelection(h, opts.skills));
119
123
  }
120
124
  return tail;
121
125
  };