@dungle-scrubs/harness-cli-normalizer 0.4.5 → 0.5.0

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 (149) hide show
  1. package/README.md +53 -7
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +13 -0
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/config.d.ts.map +1 -1
  6. package/dist/cli/config.js +65 -0
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/help.d.ts +2 -2
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +28 -7
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/cli/inspect.d.ts.map +1 -1
  13. package/dist/cli/inspect.js +81 -25
  14. package/dist/cli/inspect.js.map +1 -1
  15. package/dist/cli/provenance.d.ts +3 -0
  16. package/dist/cli/provenance.d.ts.map +1 -0
  17. package/dist/cli/provenance.js +11 -0
  18. package/dist/cli/provenance.js.map +1 -0
  19. package/dist/cli/refuse.d.ts +14 -0
  20. package/dist/cli/refuse.d.ts.map +1 -0
  21. package/dist/cli/refuse.js +38 -0
  22. package/dist/cli/refuse.js.map +1 -0
  23. package/dist/cli/run.d.ts.map +1 -1
  24. package/dist/cli/run.js +27 -68
  25. package/dist/cli/run.js.map +1 -1
  26. package/dist/cli/session.d.ts.map +1 -1
  27. package/dist/cli/session.js +3 -3
  28. package/dist/cli/session.js.map +1 -1
  29. package/dist/cli/skills-root.d.ts +2 -1
  30. package/dist/cli/skills-root.d.ts.map +1 -1
  31. package/dist/cli/skills-root.js +6 -4
  32. package/dist/cli/skills-root.js.map +1 -1
  33. package/dist/execution/decode.d.ts.map +1 -1
  34. package/dist/execution/decode.js +2 -1
  35. package/dist/execution/decode.js.map +1 -1
  36. package/dist/execution/deps.d.ts +1 -1
  37. package/dist/execution/deps.js +1 -1
  38. package/dist/execution/failure.d.ts +5 -1
  39. package/dist/execution/failure.d.ts.map +1 -1
  40. package/dist/execution/failure.js +12 -0
  41. package/dist/execution/failure.js.map +1 -1
  42. package/dist/execution/index.d.ts +2 -2
  43. package/dist/execution/index.js +2 -2
  44. package/dist/execution/open-session.d.ts.map +1 -1
  45. package/dist/execution/open-session.js +23 -8
  46. package/dist/execution/open-session.js.map +1 -1
  47. package/dist/execution/stream-turn.d.ts.map +1 -1
  48. package/dist/execution/stream-turn.js +18 -10
  49. package/dist/execution/stream-turn.js.map +1 -1
  50. package/dist/interpretation/argv.d.ts +3 -0
  51. package/dist/interpretation/argv.d.ts.map +1 -1
  52. package/dist/interpretation/argv.js +6 -1
  53. package/dist/interpretation/argv.js.map +1 -1
  54. package/dist/interpretation/content.d.ts.map +1 -1
  55. package/dist/interpretation/content.js +4 -1
  56. package/dist/interpretation/content.js.map +1 -1
  57. package/dist/interpretation/hints.d.ts.map +1 -1
  58. package/dist/interpretation/hints.js +4 -0
  59. package/dist/interpretation/hints.js.map +1 -1
  60. package/dist/interpretation/limits.d.ts +1 -0
  61. package/dist/interpretation/limits.d.ts.map +1 -1
  62. package/dist/interpretation/limits.js +22 -0
  63. package/dist/interpretation/limits.js.map +1 -1
  64. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  65. package/dist/interpretation/resolve-options.js +124 -3
  66. package/dist/interpretation/resolve-options.js.map +1 -1
  67. package/dist/interpretation/support.d.ts +1 -0
  68. package/dist/interpretation/support.d.ts.map +1 -1
  69. package/dist/interpretation/support.js +29 -0
  70. package/dist/interpretation/support.js.map +1 -1
  71. package/dist/interpretation/tool-selection.d.ts +6 -16
  72. package/dist/interpretation/tool-selection.d.ts.map +1 -1
  73. package/dist/interpretation/tool-selection.js +216 -80
  74. package/dist/interpretation/tool-selection.js.map +1 -1
  75. package/dist/interpretation/tool-vocabulary.d.ts +38 -0
  76. package/dist/interpretation/tool-vocabulary.d.ts.map +1 -0
  77. package/dist/interpretation/tool-vocabulary.js +142 -0
  78. package/dist/interpretation/tool-vocabulary.js.map +1 -0
  79. package/dist/interpretation/turn-options.d.ts.map +1 -1
  80. package/dist/interpretation/turn-options.js +107 -2
  81. package/dist/interpretation/turn-options.js.map +1 -1
  82. package/dist/interpretation/versions.d.ts +1 -1
  83. package/dist/interpretation/versions.d.ts.map +1 -1
  84. package/dist/interpretation/vocabulary.d.ts +1 -0
  85. package/dist/interpretation/vocabulary.d.ts.map +1 -1
  86. package/dist/interpretation/vocabulary.js +5 -0
  87. package/dist/interpretation/vocabulary.js.map +1 -1
  88. package/dist/knowledge/claude-code.d.ts.map +1 -1
  89. package/dist/knowledge/claude-code.js +14 -13
  90. package/dist/knowledge/claude-code.js.map +1 -1
  91. package/dist/knowledge/codex.d.ts.map +1 -1
  92. package/dist/knowledge/codex.js +10 -4
  93. package/dist/knowledge/codex.js.map +1 -1
  94. package/dist/knowledge/descriptor.d.ts +20 -1
  95. package/dist/knowledge/descriptor.d.ts.map +1 -1
  96. package/dist/knowledge/descriptor.js +18 -1
  97. package/dist/knowledge/descriptor.js.map +1 -1
  98. package/dist/knowledge/index.d.ts +1 -1
  99. package/dist/knowledge/index.d.ts.map +1 -1
  100. package/dist/knowledge/index.js +1 -1
  101. package/dist/knowledge/index.js.map +1 -1
  102. package/dist/knowledge/matchers.d.ts +2 -1
  103. package/dist/knowledge/matchers.d.ts.map +1 -1
  104. package/dist/knowledge/matchers.js +15 -0
  105. package/dist/knowledge/matchers.js.map +1 -1
  106. package/dist/knowledge/muse.d.ts.map +1 -1
  107. package/dist/knowledge/muse.js +18 -3
  108. package/dist/knowledge/muse.js.map +1 -1
  109. package/dist/knowledge/overrides.d.ts.map +1 -1
  110. package/dist/knowledge/overrides.js +3 -2
  111. package/dist/knowledge/overrides.js.map +1 -1
  112. package/dist/knowledge/pi.d.ts.map +1 -1
  113. package/dist/knowledge/pi.js +12 -8
  114. package/dist/knowledge/pi.js.map +1 -1
  115. package/package.json +1 -1
  116. package/src/cli/args.ts +13 -0
  117. package/src/cli/config.ts +88 -0
  118. package/src/cli/help.ts +28 -7
  119. package/src/cli/inspect.ts +78 -21
  120. package/src/cli/provenance.ts +19 -0
  121. package/src/cli/refuse.ts +50 -0
  122. package/src/cli/run.ts +27 -94
  123. package/src/cli/session.ts +4 -3
  124. package/src/cli/skills-root.ts +11 -4
  125. package/src/execution/decode.ts +2 -1
  126. package/src/execution/deps.ts +1 -1
  127. package/src/execution/failure.ts +17 -1
  128. package/src/execution/index.ts +2 -2
  129. package/src/execution/open-session.ts +33 -7
  130. package/src/execution/stream-turn.ts +21 -9
  131. package/src/interpretation/argv.ts +9 -1
  132. package/src/interpretation/content.ts +5 -1
  133. package/src/interpretation/hints.ts +8 -0
  134. package/src/interpretation/limits.ts +29 -0
  135. package/src/interpretation/resolve-options.ts +129 -3
  136. package/src/interpretation/support.ts +30 -0
  137. package/src/interpretation/tool-selection.ts +236 -96
  138. package/src/interpretation/tool-vocabulary.ts +183 -0
  139. package/src/interpretation/turn-options.ts +99 -2
  140. package/src/interpretation/versions.ts +1 -1
  141. package/src/interpretation/vocabulary.ts +5 -0
  142. package/src/knowledge/claude-code.ts +14 -13
  143. package/src/knowledge/codex.ts +10 -4
  144. package/src/knowledge/descriptor.ts +42 -2
  145. package/src/knowledge/index.ts +5 -1
  146. package/src/knowledge/matchers.ts +17 -1
  147. package/src/knowledge/muse.ts +18 -3
  148. package/src/knowledge/overrides.ts +4 -2
  149. package/src/knowledge/pi.ts +12 -8
@@ -0,0 +1,19 @@
1
+ import type { ProvenanceEntry } from "../interpretation/resolve-options.js";
2
+
3
+ export const writeProvenance = (
4
+ harnessName: string,
5
+ provenance: readonly ProvenanceEntry[],
6
+ unrenderable: readonly string[],
7
+ ): void => {
8
+ if (provenance.length === 0 && unrenderable.length === 0) return;
9
+ for (const entry of provenance) {
10
+ process.stderr.write(
11
+ `provenance: ${entry.key} = ${JSON.stringify(entry.value)} (${entry.tier})\n`,
12
+ );
13
+ }
14
+ for (const key of unrenderable) {
15
+ process.stderr.write(
16
+ `divergence: profile ${JSON.stringify(key)} not expressible on ${harnessName}; harness default applies\n`,
17
+ );
18
+ }
19
+ };
@@ -0,0 +1,50 @@
1
+ import { failureFromRejected } from "../execution/failure.js";
2
+ import type { ArgvRefusalError, RefusalIssue } from "../interpretation/refusal.js";
3
+ import { writeEventNdjson } from "./render.js";
4
+
5
+ export interface Refusal {
6
+ readonly message: string;
7
+ readonly issue: RefusalIssue;
8
+ readonly option?: ArgvRefusalError["option"];
9
+ readonly facet?: ArgvRefusalError["facet"];
10
+ readonly supported?: readonly string[];
11
+ readonly supportedBy?: ArgvRefusalError["supportedBy"];
12
+ readonly hint?: string;
13
+ readonly trailer?: readonly string[];
14
+ }
15
+
16
+ export const refusalOf = (err: ArgvRefusalError): Refusal => ({
17
+ message: err.message,
18
+ issue: err.issue,
19
+ option: err.option,
20
+ facet: err.facet,
21
+ supported: err.supported,
22
+ supportedBy: err.supportedBy,
23
+ hint: err.hint,
24
+ });
25
+
26
+ export const refuse = (r: Refusal, json: boolean): void => {
27
+ process.stderr.write(`${r.message}\n`);
28
+ if (r.hint) process.stderr.write(`hint: ${r.hint}\n`);
29
+ if (r.supportedBy?.length) {
30
+ process.stderr.write(
31
+ `supported on: ${r.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
32
+ );
33
+ }
34
+ if (r.supported?.length) process.stderr.write(`supported: ${r.supported.join(", ")}\n`);
35
+ for (const line of r.trailer ?? []) process.stderr.write(`${line}\n`);
36
+ if (json) {
37
+ const summary = failureFromRejected({
38
+ issue: r.issue,
39
+ option: r.option,
40
+ facet: r.facet,
41
+ supported: r.supported,
42
+ supportedBy: r.supportedBy,
43
+ hint: r.hint,
44
+ detail: r.message,
45
+ });
46
+ writeEventNdjson({ kind: "failure", ...summary });
47
+ writeEventNdjson({ kind: "done", exitCode: null, cause: "failed", failure: summary });
48
+ }
49
+ process.exitCode = 2;
50
+ };
package/src/cli/run.ts CHANGED
@@ -1,72 +1,19 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import type { HarnessEvent } from "../execution/events.js";
3
- import { failureFromRejected } from "../execution/failure.js";
4
3
  import { nodeRunnerDeps } from "../execution/node-deps.js";
5
4
  import { KILL_GRACE_MS, redactArgv, streamTurn } from "../execution/stream-turn.js";
6
5
  import { buildLaunchArgv, buildResumeArgv } from "../interpretation/argv.js";
7
6
  import { composeEscalatedPrompt } from "../interpretation/question.js";
8
- import type { RefusalIssue } from "../interpretation/refusal.js";
9
7
  import { ArgvRefusalError } from "../interpretation/refusal.js";
10
8
  import { FloorExceededError, resolveEffectiveOptions } from "../interpretation/resolve-options.js";
11
9
  import { storePath } from "../interpretation/store.js";
12
10
  import { recognizeNativeSpelling, supportedBy } from "../interpretation/support.js";
13
11
  import { defaultDescriptors } from "../knowledge/overrides.js";
14
12
  import { parseRunExtra, parseTurnOptions, resolvePromptAsync } from "./args.js";
13
+ import { refusalOf, refuse } from "./refuse.js";
15
14
  import { createRenderState, renderEvent, writeEventNdjson } from "./render.js";
16
15
  import { resolveHarness } from "./resolve-harness.js";
17
16
 
18
- /** One shape for every hcn-side refusal. Stderr keeps the prose (message,
19
- * hint, support lists, usage pointer); under --json the same fields also
20
- * ride stdout as a `failure` event plus a `done`, so a stream reader never
21
- * meets an empty stream on exit 2. */
22
- interface Refusal {
23
- readonly message: string;
24
- readonly issue: RefusalIssue;
25
- readonly option?: ArgvRefusalError["option"];
26
- readonly facet?: ArgvRefusalError["facet"];
27
- readonly supported?: readonly string[];
28
- readonly supportedBy?: ArgvRefusalError["supportedBy"];
29
- readonly hint?: string;
30
- /** Plain stderr lines written after the structured ones. */
31
- readonly trailer?: readonly string[];
32
- }
33
-
34
- const refusalOf = (err: ArgvRefusalError): Refusal => ({
35
- message: err.message,
36
- issue: err.issue,
37
- option: err.option,
38
- facet: err.facet,
39
- supported: err.supported,
40
- supportedBy: err.supportedBy,
41
- hint: err.hint,
42
- });
43
-
44
- const refuse = (r: Refusal, json: boolean): void => {
45
- process.stderr.write(`${r.message}\n`);
46
- if (r.hint) process.stderr.write(`hint: ${r.hint}\n`);
47
- if (r.supportedBy?.length) {
48
- process.stderr.write(
49
- `supported on: ${r.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
50
- );
51
- }
52
- if (r.supported?.length) process.stderr.write(`supported: ${r.supported.join(", ")}\n`);
53
- for (const line of r.trailer ?? []) process.stderr.write(`${line}\n`);
54
- if (json) {
55
- const summary = failureFromRejected({
56
- issue: r.issue,
57
- option: r.option,
58
- facet: r.facet,
59
- supported: r.supported,
60
- supportedBy: r.supportedBy,
61
- hint: r.hint,
62
- detail: r.message,
63
- });
64
- writeEventNdjson({ kind: "failure", ...summary });
65
- writeEventNdjson({ kind: "done", exitCode: null, cause: "failed", failure: summary });
66
- }
67
- process.exitCode = 2;
68
- };
69
-
70
17
  export const run = async (harnessName: string, rawArgs: string[]): Promise<void> => {
71
18
  const h = resolveHarness(harnessName);
72
19
 
@@ -238,7 +185,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
238
185
  if (rawSkills !== undefined && rawSkills.length > 0) {
239
186
  try {
240
187
  const { resolveSkillNames, listKnownSkills } = await import("./skills-root.js");
241
- const resolvedSkills = resolveSkillNames(rawSkills);
188
+ const resolvedSkills = resolveSkillNames(rawSkills, h.name);
242
189
  const claudeTokens: string[] = [];
243
190
  if (h.name === "claude") {
244
191
  const { claudeSkillOverridesArg } = await import("../interpretation/skills-selection.js");
@@ -295,6 +242,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
295
242
  refuse({ message: resErr.message, issue: "invalid-tool-grant" }, wantJson);
296
243
  return;
297
244
  }
245
+ if (resErr instanceof ArgvRefusalError) {
246
+ refuse(refusalOf(resErr), wantJson);
247
+ return;
248
+ }
298
249
  throw resErr;
299
250
  }
300
251
  const { provenance, unrenderable } = resolved;
@@ -302,30 +253,8 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
302
253
  effectiveTurnOpts = rest as ReturnType<typeof parseTurnOptions>;
303
254
  // Provenance is diagnostic data like the spawn line - stderr in BOTH
304
255
  // render modes, never stdout (stdout carries the NDJSON contract).
305
- if (provenance.length > 0 || unrenderable.length > 0) {
306
- for (const entry of provenance) {
307
- process.stderr.write(
308
- `provenance: ${entry.key} = ${JSON.stringify(entry.value)} (${entry.tier})\n`,
309
- );
310
- }
311
- for (const key of unrenderable) {
312
- process.stderr.write(
313
- `divergence: profile ${JSON.stringify(key)} not expressible on ${h.name}; harness default applies\n`,
314
- );
315
- }
316
- // issue #48 (ratified): when claude's instruction-files isolation
317
- // rides (the --setting-sources project spelling), surface its known
318
- // cost as a divergence line - legal to use, priced in stderr.
319
- if (
320
- h.name === "claude" &&
321
- (resolved.options as { discovery?: { instructionFiles?: boolean } }).discovery
322
- ?.instructionFiles === false
323
- ) {
324
- process.stderr.write(
325
- "divergence: setting-sources isolation also skips hooks, LSP and keychain reads on claude - weighed, not refused\n",
326
- );
327
- }
328
- }
256
+ const { writeProvenance } = await import("./provenance.js");
257
+ writeProvenance(h.name, provenance, unrenderable);
329
258
  }
330
259
 
331
260
  // issue #41: question-escalation precedence arg > project > user >
@@ -430,12 +359,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
430
359
  // all refuses inside buildLaunchArgv below, so a throw here is left
431
360
  // to that path.
432
361
  const grant = (fullOpts.resume ? turnOpts : effectiveTurnOpts).tools;
433
- if (grant !== undefined && grant.length > 0 && !h.tools.includeIsStrictAllowlist) {
362
+ if (grant !== undefined && grant.length > 0) {
434
363
  try {
435
364
  const { renderToolSelection } = await import("../interpretation/tool-selection.js");
436
- const { unmapped } = renderToolSelection(h, { include: [...grant] });
437
- if (unmapped.length > 0) {
438
- process.stderr.write(`provenance: unmapped tools = ${JSON.stringify(unmapped)}\n`);
365
+ const { passthrough } = renderToolSelection(h, { include: [...grant] });
366
+ if (passthrough.length > 0) {
367
+ process.stderr.write(`provenance: native tools = ${JSON.stringify(passthrough)}\n`);
439
368
  }
440
369
  } catch {
441
370
  // refused below with the structured message
@@ -490,7 +419,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
490
419
  const _currentProc: { signal: (sig: "SIGTERM" | "SIGKILL") => void } | null = null;
491
420
  // We'll need to track the spawned process via deps.signal; but streamTurn owns process handle.
492
421
  // Instead we intercept deps.signal via a wrapper that captures proc.
493
- // Simpler: use deps directly and handle SIGINT by calling process.kill? But spec says via injected signal.
422
+ // Simpler: use deps directly and handle SIGINT via injected signal.
494
423
  // We'll create a wrapper deps where signal captures last proc.
495
424
  let lastProc: import("../execution/deps.js").SpawnedProcess | null = null;
496
425
  const originalSignal = deps.signal;
@@ -509,6 +438,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
509
438
 
510
439
  const abortController = new AbortController();
511
440
  let interrupted = false;
441
+ let escalationTimer: ReturnType<typeof setTimeout> | null = null;
512
442
  const onSig = async () => {
513
443
  if (interrupted) return;
514
444
  interrupted = true;
@@ -516,16 +446,15 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
516
446
  if (lastProc) {
517
447
  try {
518
448
  wrappedDeps.signal(lastProc, "SIGTERM");
519
- await new Promise<void>((resolve) => {
520
- setTimeout(() => {
521
- if (lastProc) {
522
- try {
523
- wrappedDeps.signal(lastProc, "SIGKILL");
524
- } catch {}
525
- }
526
- resolve();
527
- }, KILL_GRACE_MS);
528
- });
449
+ escalationTimer = setTimeout(() => {
450
+ if (lastProc) {
451
+ try {
452
+ wrappedDeps.signal(lastProc, "SIGKILL");
453
+ } catch {}
454
+ }
455
+ escalationTimer = null;
456
+ }, KILL_GRACE_MS);
457
+ escalationTimer.unref?.();
529
458
  } catch {}
530
459
  }
531
460
  };
@@ -591,6 +520,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
591
520
  process.off("SIGTERM", onSig);
592
521
  return;
593
522
  } finally {
523
+ if (escalationTimer !== null) {
524
+ clearTimeout(escalationTimer);
525
+ escalationTimer = null;
526
+ }
594
527
  process.off("SIGINT", onSig);
595
528
  process.off("SIGTERM", onSig);
596
529
  }
@@ -3,6 +3,8 @@ import { createInterface } from "node:readline/promises";
3
3
  import { nodeRunnerDeps } from "../execution/node-deps.js";
4
4
  import { CLOSE_GRACE_MS, openSession } from "../execution/open-session.js";
5
5
  import { ArgvRefusalError } from "../interpretation/refusal.js";
6
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
7
+ import { defaultDescriptors } from "../knowledge/overrides.js";
6
8
  import { createRenderState, renderEvent } from "./render.js";
7
9
  import { resolveHarness } from "./resolve-harness.js";
8
10
 
@@ -12,9 +14,8 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
12
14
  // session mode is available the moment its descriptor declares one.
13
15
  const h = resolveHarness(harnessName);
14
16
  if (h.sessionMode === null) {
15
- const supported = ["claude", "codex", "pi", "muse"]
16
- .map((name) => resolveHarness(name))
17
- .filter((d) => d.sessionMode !== null)
17
+ const supported = Object.values(defaultDescriptors())
18
+ .filter((d): d is HarnessDescriptor => d !== undefined && d.sessionMode !== null)
18
19
  .map((d) => d.name);
19
20
  const err = new ArgvRefusalError({
20
21
  issue: "no-session-mode",
@@ -12,6 +12,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
13
  import { join } from "node:path";
14
14
  import { ArgvRefusalError } from "../interpretation/refusal.js";
15
+ import type { HarnessName } from "../knowledge/descriptor.js";
15
16
 
16
17
  export const skillsRoot = (): string =>
17
18
  process.env.HCN_SKILLS_ROOT ?? join(homedir(), ".agents", "skills");
@@ -30,16 +31,22 @@ export const listKnownSkills = (): string[] => {
30
31
  });
31
32
  };
32
33
 
33
- export const resolveSkillNames = (names: readonly string[]): string[] => {
34
+ export const resolveSkillNames = (
35
+ names: readonly string[],
36
+ harness: HarnessName = "claude",
37
+ ): string[] => {
34
38
  const root = skillsRoot();
35
39
  const known = new Set(listKnownSkills());
36
40
  const unknown = names.filter((n) => !known.has(n));
37
41
  if (unknown.length > 0) {
38
42
  throw new ArgvRefusalError({
39
- issue: "unknown-tool-name",
40
- harness: "claude",
43
+ issue: "invalid-option-value",
44
+ harness,
41
45
  option: "skills",
42
- supported: known.size > 0 ? [[...known].join(", ")] : ["(registry is empty)"],
46
+ supported:
47
+ known.size > 0
48
+ ? [`registry at ${root}`, [...known].join(", ")]
49
+ : [`registry at ${root} (empty)`],
43
50
  detail: `unknown skill name(s): ${unknown.join(", ")}`,
44
51
  });
45
52
  }
@@ -69,10 +69,11 @@ export const decodeParsed = (
69
69
  const decoded = decodeIdentity(h, raw, state.lastSeenId, state.requestedId);
70
70
  if (decoded.sessionId !== null) state.lastSeenId = decoded.sessionId;
71
71
  if (decoded.identity !== null) {
72
+ const authority = state.requestedId !== null ? "caller-assigned" : "harness-minted";
72
73
  events.push({
73
74
  kind: "identity",
74
75
  sessionId: decoded.identity,
75
- authority: h.identity.authority,
76
+ authority,
76
77
  capabilities: capabilities(),
77
78
  });
78
79
  } else if (decoded.outcome === "malformed" || decoded.outcome === "rotated") {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Injected runtime primitives (D-005): the execution layer never imports
3
- * child_process, never calls process.kill, and never reads a wall clock -
3
+ * child_process, never signals the process directly, and never reads a wall clock -
4
4
  * spawn, clock, and signalling arrive as data so Node/Bun is a real
5
5
  * portability boundary and tests run against fakes deterministically.
6
6
  */
@@ -13,8 +13,14 @@
13
13
  * options or a different harness, not a different model.
14
14
  */
15
15
 
16
+ import { detectAuthFailureInLine, detectTransportInLine } from "../interpretation/limits.js";
16
17
  import type { RefusalIssue } from "../interpretation/refusal.js";
17
- import type { AuthFailureKind, DiscoveryFacet, LimitCode } from "../knowledge/descriptor.js";
18
+ import type {
19
+ AuthFailureKind,
20
+ DiscoveryFacet,
21
+ HarnessDescriptor,
22
+ LimitCode,
23
+ } from "../knowledge/descriptor.js";
18
24
 
19
25
  export const FAILURE_CLASSES = Object.freeze([
20
26
  "rate-limit",
@@ -131,6 +137,16 @@ export const failureFromAuth = (kind: AuthFailureKind): FailureSummary => ({
131
137
  authKind: kind,
132
138
  });
133
139
 
140
+ /** A terminal error the harness reported on its stream: classify by what
141
+ * it says. An auth wall or a transport fault reached no verdict on the
142
+ * work (retryable); anything else is the model's own failure (task). */
143
+ export const failureFromTerminalError = (h: HarnessDescriptor, message: string): FailureSummary => {
144
+ const auth = detectAuthFailureInLine(h, message);
145
+ if (auth !== null) return failureFromAuth(auth);
146
+ if (detectTransportInLine(message)) return failureFromTransport(message);
147
+ return failureFromTask(message);
148
+ };
149
+
134
150
  export const failureFromTask = (detail?: string): FailureSummary => ({
135
151
  class: "task",
136
152
  retryable: false,
@@ -4,8 +4,8 @@
4
4
  * It exposes openSession() and streamTurn(), which drive a CLI process via
5
5
  * injected {spawn, clock, signal} primitives and emit HarnessEvent values.
6
6
  * This module knows nothing about any chat protocol. Because it must run
7
- * identically on Node and Bun, it never imports child_process and never calls
8
- * process.kill directly outside the node-deps adapter - all process I/O and
7
+ * identically on Node and Bun, it never imports child_process and never
8
+ * signals the process directly outside the node-deps adapter - all process I/O and
9
9
  * signalling flows through the injected primitives.
10
10
  */
11
11
  export { AsyncChannel } from "./channel.js";
@@ -15,7 +15,11 @@
15
15
  */
16
16
  import { buildSessionArgv } from "../interpretation/argv.js";
17
17
  import { capabilitiesOf } from "../interpretation/capabilities.js";
18
- import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
18
+ import {
19
+ detectAuthFailureInLine,
20
+ detectLimitInLine,
21
+ detectTransportInLine,
22
+ } from "../interpretation/limits.js";
19
23
  import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
20
24
  import {
21
25
  encodeSessionInput,
@@ -28,7 +32,14 @@ import { decodeParsed, freshDecodeState } from "./decode.js";
28
32
  import type { RunnerDeps, SpawnedProcess } from "./deps.js";
29
33
  import type { ExitCause, HarnessEvent } from "./events.js";
30
34
  import type { FailureSummary } from "./failure.js";
31
- import { failureFromAuth, failureFromLimit, reduceFailures } from "./failure.js";
35
+ import {
36
+ failureFromAuth,
37
+ failureFromLimit,
38
+ failureFromTask,
39
+ failureFromTerminalError,
40
+ failureFromTransport,
41
+ reduceFailures,
42
+ } from "./failure.js";
32
43
  import { LineBuffer } from "./lines.js";
33
44
  import { KILL_GRACE_MS, PIPE_GRACE_MS, redactArgv, StderrTail } from "./stream-turn.js";
34
45
 
@@ -210,6 +221,9 @@ export const openSession = (
210
221
  if (detection === null) return;
211
222
  if ("malformed" in detection) {
212
223
  activeTurn?.push({ kind: "error", message: detection.malformed });
224
+ const failure = failureFromTask(`malformed hcn-question block: ${detection.malformed}`);
225
+ turnFailures.push(failure);
226
+ void activeTurn?.push({ kind: "failure", ...failure });
213
227
  return;
214
228
  }
215
229
  turnAsked = true;
@@ -240,7 +254,10 @@ export const openSession = (
240
254
  // Every failure was already emitted as an event through pushFailure;
241
255
  // the turn's done carries the reduced summary, as streamTurn's does.
242
256
  const reduced = reduceFailures(turnFailures);
243
- if (reduced !== undefined) done = { ...done, failure: reduced };
257
+ if (reduced !== undefined) {
258
+ if (done.cause === "clean") done = { ...done, cause: "failed", failure: reduced };
259
+ else done = { ...done, failure: reduced };
260
+ }
244
261
  activeTurn.push(done);
245
262
  activeTurn.close();
246
263
  log({
@@ -287,6 +304,15 @@ export const openSession = (
287
304
  return routeEvent({ kind: "failure", ...f });
288
305
  };
289
306
 
307
+ /** A decoded event other than a failure: a terminal error also records
308
+ * the failure it stands for, the way streamTurn does. */
309
+ const routeDecoded = async (event: HarnessEvent): Promise<void> => {
310
+ await routeEvent(event);
311
+ if (event.kind === "error" && event.terminal === true) {
312
+ await pushFailure(failureFromTerminalError(h, event.message));
313
+ }
314
+ };
315
+
290
316
  const pumpStdout = async (): Promise<void> => {
291
317
  const lines = new LineBuffer();
292
318
  const matches = (
@@ -351,7 +377,7 @@ export const openSession = (
351
377
  await routeEvent({
352
378
  kind: "identity",
353
379
  sessionId: announced,
354
- authority: h.identity.authority,
380
+ authority: "harness-minted",
355
381
  capabilities: capabilitiesOf(h, opts.model ?? "", "headless-session"),
356
382
  });
357
383
  }
@@ -361,7 +387,7 @@ export const openSession = (
361
387
  await routeEvent({
362
388
  kind: "identity",
363
389
  sessionId: announced,
364
- authority: h.identity.authority,
390
+ authority: "caller-assigned",
365
391
  capabilities: capabilitiesOf(h, opts.model ?? "", "headless-session"),
366
392
  });
367
393
  }
@@ -392,7 +418,7 @@ export const openSession = (
392
418
  // still track resultError here to classify the done cause.
393
419
  for (const event of events) {
394
420
  if (event.kind === "failure") await pushFailure(summaryOf(event));
395
- else await routeEvent(event);
421
+ else await routeDecoded(event);
396
422
  }
397
423
  if (parsed.is_error === true) resultError = true;
398
424
  endTurn({
@@ -404,7 +430,7 @@ export const openSession = (
404
430
  }
405
431
  for (const event of events) {
406
432
  if (event.kind === "failure") await pushFailure(summaryOf(event));
407
- else await routeEvent(event);
433
+ else await routeDecoded(event);
408
434
  }
409
435
  };
410
436
  for await (const chunk of proc.stdout) {
@@ -16,7 +16,11 @@ import {
16
16
  streamingGranularityOf,
17
17
  } from "../interpretation/argv.js";
18
18
  import { stdinPolicyOf } from "../interpretation/dimensions.js";
19
- import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
19
+ import {
20
+ detectAuthFailureInLine,
21
+ detectLimitInLine,
22
+ detectTransportInLine,
23
+ } from "../interpretation/limits.js";
20
24
  import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
21
25
  import { ArgvRefusalError } from "../interpretation/refusal.js";
22
26
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
@@ -32,6 +36,7 @@ import {
32
36
  failureFromNative,
33
37
  failureFromRejected,
34
38
  failureFromTask,
39
+ failureFromTerminalError,
35
40
  failureFromTimeout,
36
41
  failureFromTransport,
37
42
  reduceFailures,
@@ -312,6 +317,9 @@ export async function* streamTurn(
312
317
 
313
318
  const failures: FailureSummary[] = [];
314
319
  const pushFailure = async (f: FailureSummary): Promise<void> => {
320
+ // Suppress a failure identical in class and message to the previous one
321
+ const prev = failures[failures.length - 1];
322
+ if (prev !== undefined && prev.class === f.class && prev.message === f.message) return;
315
323
  failures.push(f);
316
324
  await queue.push({ kind: "failure", ...f });
317
325
  };
@@ -426,7 +434,7 @@ export async function* streamTurn(
426
434
  }
427
435
  if (event.kind === "error") {
428
436
  await queue.push(event);
429
- if (event.terminal === true) await pushFailure(failureFromTask(event.message));
437
+ if (event.terminal === true) await pushFailure(failureFromTerminalError(h, event.message));
430
438
  return;
431
439
  }
432
440
  if (escalateQuestions && event.kind === "message" && event.role === "assistant") {
@@ -468,6 +476,7 @@ export async function* streamTurn(
468
476
  if (detection === null) return;
469
477
  if ("malformed" in detection) {
470
478
  await queue.push({ kind: "error", message: detection.malformed });
479
+ await pushFailure(failureFromTask(`malformed hcn-question block: ${detection.malformed}`));
471
480
  return;
472
481
  }
473
482
  log({
@@ -585,10 +594,13 @@ export async function* streamTurn(
585
594
  !state.limitSeen
586
595
  ) {
587
596
  const tailForNative = stderrTail.snapshot();
597
+ const transportLine = tailForNative.find((line) => detectTransportInLine(line));
588
598
  const f =
589
- tailForNative.length > 0
590
- ? failureFromNative(exitCode, tailForNative)
591
- : failureFromTransport(`nonzero exit ${exitCode}`);
599
+ transportLine !== undefined
600
+ ? failureFromTransport(transportLine)
601
+ : tailForNative.length > 0
602
+ ? failureFromNative(exitCode, tailForNative)
603
+ : failureFromTransport(`nonzero exit ${exitCode}`);
592
604
  failures.push(f);
593
605
  // Need to emit this failure before done, even though queue is closed
594
606
  yield { kind: "failure", ...f };
@@ -624,10 +636,10 @@ export async function* streamTurn(
624
636
  : "crash";
625
637
  const reduced = reduceFailures(failures);
626
638
  if (reduced && cause === "clean") cause = "failed";
627
- // A work-verdict failure (the model ran out of steps, or ended its
628
- // turn in error) is not a harness crash even when the process exits
629
- // nonzero: the cause is failed and the real exit code rides along.
630
- if ((reduced?.class === "budget" || reduced?.class === "task") && cause === "crash") {
639
+ // A classified failure other than native on a nonzero exit is a failed
640
+ // turn, not a crash: the failure taxonomy already captured the reason.
641
+ // Crash stays for unclassified exits and native failures.
642
+ if (reduced && reduced.class !== "native" && cause === "crash") {
631
643
  cause = "failed";
632
644
  }
633
645
  const tail = stderrTail.snapshot();
@@ -83,6 +83,9 @@ export interface TurnOptions {
83
83
  readonly escalateQuestions?: boolean;
84
84
  /** Internal: set by CLI when prompt came from --prompt/--prompt-file to bypass leading '-' guard */
85
85
  readonly __explicitPrompt?: boolean;
86
+ /** toolMap extensible vocabulary per harness (issue toolMap) */
87
+ readonly toolMap?: Readonly<Record<string, Readonly<Record<string, string>>>>;
88
+ readonly access?: "read" | "write";
86
89
  }
87
90
 
88
91
  export interface ResumeOptions extends TurnOptions {
@@ -124,9 +127,11 @@ const turnTail = (h: HarnessDescriptor, opts: TurnOptions): string[] => {
124
127
  tail.push(h.autonomy.flag);
125
128
  }
126
129
  if (opts.tools !== undefined || opts.excludeTools !== undefined) {
130
+ const perHarnessMap = opts.toolMap?.[h.name];
127
131
  const rendered = renderToolSelection(h, {
128
132
  include: opts.tools,
129
133
  exclude: opts.excludeTools,
134
+ toolMap: perHarnessMap,
130
135
  });
131
136
  tail.push(...rendered.tokens);
132
137
  }
@@ -188,10 +193,13 @@ export interface SessionOptions {
188
193
 
189
194
  export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): string[] => {
190
195
  if (!h.sessionMode) {
196
+ const supported = Object.values(defaultDescriptors())
197
+ .filter((d): d is HarnessDescriptor => d !== undefined && d.sessionMode !== null)
198
+ .map((d) => d.name);
191
199
  throw new ArgvRefusalError({
192
200
  issue: "no-session-mode",
193
201
  harness: h.name,
194
- supported: ["session is available where sessionMode is declared"],
202
+ supported,
195
203
  });
196
204
  }
197
205
  refuseUnusableSessionId(h, opts.sessionId);
@@ -130,10 +130,14 @@ const pi = (r: Record<string, unknown>): ContentEvent[] => {
130
130
  // an expired minimax token: empty content, stopReason error, clean
131
131
  // exit). Without this the failure is invisible - a silent empty turn.
132
132
  if (message.stopReason === "error") {
133
+ const msg =
134
+ typeof message.errorMessage === "string"
135
+ ? `pi turn ended with stopReason error: ${message.errorMessage}`
136
+ : "pi turn ended with stopReason error (provider/auth failure)";
133
137
  return [
134
138
  {
135
139
  kind: "error",
136
- message: "pi turn ended with stopReason error (provider/auth failure)",
140
+ message: msg,
137
141
  terminal: true,
138
142
  },
139
143
  ];
@@ -27,6 +27,10 @@ const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFr
27
27
  "claude has no isolated instruction-file toggle; --setting-sources project isolates from user-level settings but also skips hooks, LSP and keychain reads - weigh that before using it as an approximation",
28
28
  },
29
29
  codex: {
30
+ tools:
31
+ "codex has no per-tool name lists; use --sandbox read-only for a read-only grant or category switches via config keys (features.shell_tool, web_search)",
32
+ excludeTools:
33
+ "codex has no per-tool name lists; use category switches via config keys (features.shell_tool) or --sandbox modes",
30
34
  write:
31
35
  "codex has no write toggle; use --sandbox read-only (config: sandbox_mode) so shell commands cannot write either",
32
36
  // issue #48: the append half has no codex spelling.
@@ -58,6 +62,10 @@ const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFr
58
62
  "pi has no step cap flag; bound the work in the prompt or impose a wall-clock timeout at the caller",
59
63
  },
60
64
  muse: {
65
+ tools:
66
+ "muse has no per-tool name lists; gate execution with --disable-write, --disable-shell, --disable-web-tools - there is no allowlist",
67
+ excludeTools:
68
+ "muse has no per-tool name lists; gate execution with --disable-write, --disable-shell, --disable-web-tools category switches",
61
69
  sandbox:
62
70
  "muse's sandbox is on by default and not selectable per-call; --disable-sandbox exists to turn it OFF, and exposure can be tuned with --disable-write, --disable-shell, --disable-web-tools - there is no mode selector",
63
71
  provider: