@dungle-scrubs/harness-cli-normalizer 0.4.0 → 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 (75) hide show
  1. package/dist/cli/args.d.ts +1 -0
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +21 -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 +8 -0
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/help.d.ts +1 -1
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +17 -1
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/cli/run.d.ts.map +1 -1
  13. package/dist/cli/run.js +42 -2
  14. package/dist/cli/run.js.map +1 -1
  15. package/dist/cli/skills-root.d.ts +4 -0
  16. package/dist/cli/skills-root.d.ts.map +1 -0
  17. package/dist/cli/skills-root.js +45 -0
  18. package/dist/cli/skills-root.js.map +1 -0
  19. package/dist/execution/failure.d.ts +4 -1
  20. package/dist/execution/failure.d.ts.map +1 -1
  21. package/dist/execution/failure.js +15 -2
  22. package/dist/execution/failure.js.map +1 -1
  23. package/dist/execution/stream-turn.d.ts.map +1 -1
  24. package/dist/execution/stream-turn.js +16 -3
  25. package/dist/execution/stream-turn.js.map +1 -1
  26. package/dist/interpretation/argv.d.ts +5 -0
  27. package/dist/interpretation/argv.d.ts.map +1 -1
  28. package/dist/interpretation/argv.js +4 -0
  29. package/dist/interpretation/argv.js.map +1 -1
  30. package/dist/interpretation/refusal.d.ts +1 -1
  31. package/dist/interpretation/refusal.d.ts.map +1 -1
  32. package/dist/interpretation/refusal.js.map +1 -1
  33. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  34. package/dist/interpretation/resolve-options.js +27 -5
  35. package/dist/interpretation/resolve-options.js.map +1 -1
  36. package/dist/interpretation/skills-selection.d.ts +22 -0
  37. package/dist/interpretation/skills-selection.d.ts.map +1 -0
  38. package/dist/interpretation/skills-selection.js +49 -0
  39. package/dist/interpretation/skills-selection.js.map +1 -0
  40. package/dist/knowledge/claude-code.d.ts.map +1 -1
  41. package/dist/knowledge/claude-code.js +1 -0
  42. package/dist/knowledge/claude-code.js.map +1 -1
  43. package/dist/knowledge/codex.d.ts.map +1 -1
  44. package/dist/knowledge/codex.js +1 -0
  45. package/dist/knowledge/codex.js.map +1 -1
  46. package/dist/knowledge/descriptor.d.ts +7 -0
  47. package/dist/knowledge/descriptor.d.ts.map +1 -1
  48. package/dist/knowledge/muse.d.ts.map +1 -1
  49. package/dist/knowledge/muse.js +1 -0
  50. package/dist/knowledge/muse.js.map +1 -1
  51. package/dist/knowledge/pi.d.ts.map +1 -1
  52. package/dist/knowledge/pi.js +1 -0
  53. package/dist/knowledge/pi.js.map +1 -1
  54. package/dist/knowledge/profile.d.ts +3 -0
  55. package/dist/knowledge/profile.d.ts.map +1 -1
  56. package/dist/knowledge/profile.js +20 -0
  57. package/dist/knowledge/profile.js.map +1 -1
  58. package/package.json +1 -1
  59. package/src/cli/args.ts +33 -2
  60. package/src/cli/config.ts +10 -0
  61. package/src/cli/help.ts +17 -1
  62. package/src/cli/run.ts +46 -5
  63. package/src/cli/skills-root.ts +47 -0
  64. package/src/execution/failure.ts +16 -2
  65. package/src/execution/stream-turn.ts +17 -2
  66. package/src/interpretation/argv.ts +9 -0
  67. package/src/interpretation/refusal.ts +1 -0
  68. package/src/interpretation/resolve-options.ts +29 -5
  69. package/src/interpretation/skills-selection.ts +73 -0
  70. package/src/knowledge/claude-code.ts +1 -0
  71. package/src/knowledge/codex.ts +1 -0
  72. package/src/knowledge/descriptor.ts +7 -0
  73. package/src/knowledge/muse.ts +1 -0
  74. package/src/knowledge/pi.ts +1 -0
  75. package/src/knowledge/profile.ts +20 -0
@@ -8,6 +8,9 @@ export declare const DEFAULT_TURN_PROFILE: {
8
8
  readonly skills: true;
9
9
  };
10
10
  readonly autonomy: false;
11
+ readonly write: true;
12
+ readonly shell: true;
13
+ readonly tools: "all-known";
11
14
  };
12
15
  export type ProfileKey = keyof typeof DEFAULT_TURN_PROFILE;
13
16
  //# sourceMappingURL=profile.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"profile.d.ts","sourceRoot":"","sources":["../../src/knowledge/profile.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,oBAAoB;qBACvB,QAAQ;sBAKP,iBAAiB;;iBAMb,KAAK;iBAAQ,gBAAgB;iBAAQ,UAAU;iBAAQ,MAAM;;;CAIjE,CAAC;AAEZ,MAAM,MAAM,UAAU,GAAG,MAAM,OAAO,oBAAoB,CAAC"}
1
+ {"version":3,"file":"profile.d.ts","sourceRoot":"","sources":["../../src/knowledge/profile.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,oBAAoB;qBACvB,QAAQ;sBAKP,iBAAiB;;iBAMb,KAAK;iBAAQ,gBAAgB;iBAAQ,UAAU;iBAAQ,MAAM;;;;;oBAenE,WAAW;CAST,CAAC;AAEZ,MAAM,MAAM,UAAU,GAAG,MAAM,OAAO,oBAAoB,CAAC"}
@@ -27,5 +27,25 @@ export const DEFAULT_TURN_PROFILE = deepFreeze({
27
27
  // Ratified: autonomy OFF. No bare run is unattended; --autonomy or a
28
28
  // config must say so deliberately.
29
29
  autonomy: false,
30
+ // D9/D10, ratified round 2: write and shell ON - names current
31
+ // behavior (emit-nothing on claude/codex/pi; muse omits its disable
32
+ // flags). Completes the "what can a bare run do to the machine" row of
33
+ // provenance.
34
+ write: true,
35
+ shell: true,
36
+ // D13, ratified: equivalent-as-possible tool defaults. The marker
37
+ // expands per descriptor at resolve time - pi gains its dormant
38
+ // grep/find/ls; claude emits nothing (already everything); codex/muse
39
+ // report divergence (no list surface). Project floors narrow it by
40
+ // the normal precedence chain.
41
+ tools: "all-known",
42
+ // Model is PERMANENTLY out of profile scope (ratified round 2): no
43
+ // cross-harness model namespace exists, pi's registry is
44
+ // runtime-extensible and environment-dependent, and per-harness config
45
+ // already covers "claude on X, pi on Y". Do not add a model entry.
46
+ // timeout and maxSteps are likewise opt-in-only dimensions (D11/D12):
47
+ // no harness ships a wall-clock cap, one prompt expands into an
48
+ // unbounded turn loop, and a fixed default kills legitimate work. They
49
+ // live in args/config only, never here.
30
50
  });
31
51
  //# sourceMappingURL=profile.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"profile.js","sourceRoot":"","sources":["../../src/knowledge/profile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,MAAM,CAAC,MAAM,oBAAoB,GAAG,UAAU,CAAC;IAC7C,MAAM,EAAE,QAAQ;IAChB,uEAAuE;IACvE,iEAAiE;IACjE,qEAAqE;IACrE,yBAAyB;IACzB,OAAO,EAAE,iBAAiB;IAC1B,iEAAiE;IACjE,wEAAwE;IACxE,8DAA8D;IAC9D,wEAAwE;IACxE,QAAQ;IACR,SAAS,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;IAClF,qEAAqE;IACrE,mCAAmC;IACnC,QAAQ,EAAE,KAAK;CACP,CAAC,CAAC"}
1
+ {"version":3,"file":"profile.js","sourceRoot":"","sources":["../../src/knowledge/profile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,MAAM,CAAC,MAAM,oBAAoB,GAAG,UAAU,CAAC;IAC7C,MAAM,EAAE,QAAQ;IAChB,uEAAuE;IACvE,iEAAiE;IACjE,qEAAqE;IACrE,yBAAyB;IACzB,OAAO,EAAE,iBAAiB;IAC1B,iEAAiE;IACjE,wEAAwE;IACxE,8DAA8D;IAC9D,wEAAwE;IACxE,QAAQ;IACR,SAAS,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;IAClF,qEAAqE;IACrE,mCAAmC;IACnC,QAAQ,EAAE,KAAK;IACf,+DAA+D;IAC/D,oEAAoE;IACpE,uEAAuE;IACvE,cAAc;IACd,KAAK,EAAE,IAAI;IACX,KAAK,EAAE,IAAI;IACX,kEAAkE;IAClE,gEAAgE;IAChE,sEAAsE;IACtE,mEAAmE;IACnE,+BAA+B;IAC/B,KAAK,EAAE,WAAW;IAClB,mEAAmE;IACnE,yDAAyD;IACzD,uEAAuE;IACvE,mEAAmE;IACnE,sEAAsE;IACtE,gEAAgE;IAChE,uEAAuE;IACvE,wCAAwC;CAChC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dungle-scrubs/harness-cli-normalizer",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "One stable CLI to four coding-agent harnesses: normalized flags, defaults, and events.",
5
5
  "license": "MIT",
6
6
  "author": "Kevin Frilot",
package/src/cli/args.ts CHANGED
@@ -166,6 +166,11 @@ export const parseTurnOptions = (values: Record<string, unknown>): TurnOptions =
166
166
  const raw = String(values["exclude-tools"]);
167
167
  opts.excludeTools = raw.length === 0 ? [] : raw.split(",").map((s) => s.trim());
168
168
  }
169
+ if (values.skills !== undefined) {
170
+ const raw = String(values.skills);
171
+ (opts as Record<string, unknown>).skills =
172
+ raw.length === 0 ? [] : raw.split(",").map((s) => s.trim());
173
+ }
169
174
  if (values.autonomy === true) opts.autonomy = true;
170
175
  else if (values["no-autonomy"] === true) opts.autonomy = false;
171
176
  else if (values.autonomy === false) opts.autonomy = false; // for completeness
@@ -216,8 +221,31 @@ export const parseTurnOptions = (values: Record<string, unknown>): TurnOptions =
216
221
 
217
222
  export const parseRunExtra = (
218
223
  values: Record<string, unknown>,
219
- ): { cwd?: string; env?: Record<string, string>; resume?: string } => {
220
- const extra: { cwd?: string; env?: Record<string, string>; resume?: string } = {};
224
+ ): {
225
+ cwd?: string;
226
+ env?: Record<string, string>;
227
+ resume?: string;
228
+ timeoutSeconds?: number;
229
+ } => {
230
+ const extra: {
231
+ cwd?: string;
232
+ env?: Record<string, string>;
233
+ resume?: string;
234
+ timeoutSeconds?: number;
235
+ } = {};
236
+ if (values.timeout !== undefined) {
237
+ const n = Number(values.timeout);
238
+ if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {
239
+ throw new ArgvRefusalError({
240
+ issue: "invalid-option-value",
241
+ harness: "claude",
242
+ option: "maxSteps",
243
+ supported: ["whole seconds, >= 0 (0 disables)"],
244
+ detail: String(values.timeout),
245
+ });
246
+ }
247
+ extra.timeoutSeconds = n;
248
+ }
221
249
  if (values.cwd !== undefined) extra.cwd = String(values.cwd);
222
250
  if (values.resume !== undefined) extra.resume = String(values.resume);
223
251
  if (values["session-id"] !== undefined) extra.resume = String(values["session-id"]);
@@ -280,6 +308,7 @@ const FLAGS_WITH_VALUE = new Set([
280
308
  "--env",
281
309
  "--resume",
282
310
  "--session-id",
311
+ "--timeout",
283
312
  ]);
284
313
 
285
314
  export const detectPositionalPromptInjection = (argv: string[]): string | null => {
@@ -385,6 +414,7 @@ export const parseCommonFlags = (
385
414
  provider: { type: "string" as const },
386
415
  tools: { type: "string" as const },
387
416
  "exclude-tools": { type: "string" as const },
417
+ skills: { type: "string" as const },
388
418
  autonomy: { type: "boolean" as const },
389
419
  "no-autonomy": { type: "boolean" as const },
390
420
  write: { type: "boolean" as const },
@@ -400,6 +430,7 @@ export const parseCommonFlags = (
400
430
  env: { type: "string" as const, multiple: true },
401
431
  resume: { type: "string" as const },
402
432
  "session-id": { type: "string" as const },
433
+ timeout: { type: "string" as const },
403
434
  json: { type: "boolean" as const },
404
435
  argv: { type: "boolean" as const },
405
436
  help: { type: "boolean" as const },
package/src/cli/config.ts CHANGED
@@ -65,6 +65,7 @@ const KNOWN_KEYS = new Set([
65
65
  "shell",
66
66
  "maxSteps",
67
67
  "toolsets",
68
+ "timeout",
68
69
  ]);
69
70
 
70
71
  const LIST_KEYS = new Set(["tools", "excludeTools"]);
@@ -115,6 +116,15 @@ export const parseUserConfig = (text: string): Partial<TurnOptions> => {
115
116
  (out as Record<string, unknown>)[key] = value;
116
117
  continue;
117
118
  }
119
+ if (key === "timeout") {
120
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
121
+ throw new ConfigError(
122
+ 'config key "timeout" must be a whole number of seconds, >= 0 (0 disables)',
123
+ );
124
+ }
125
+ (out as Record<string, unknown>)[key] = value;
126
+ continue;
127
+ }
118
128
  if (LIST_KEYS.has(key)) {
119
129
  if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
120
130
  throw new ConfigError(`config key ${JSON.stringify(key)} must be an array of strings`);
package/src/cli/help.ts CHANGED
@@ -32,7 +32,10 @@ Options:
32
32
  --effort <value> Effort level (validated per harness/model)
33
33
  --sandbox <value> Sandbox mode (codex only)
34
34
  --provider <value> Provider (pi only)
35
- --tools <a,b> Tool grants (claude only, comma-separated)
35
+ --tools <a,b> Tool grant allowlist (claude, pi; a bare name
36
+ matching a configured toolset expands to it)
37
+ --exclude-tools <a,b> Complement over known tools (claude, pi;
38
+ mutually exclusive with --tools)
36
39
  --autonomy Enable autonomy flag (claude/codex/muse)
37
40
  --no-autonomy Disable autonomy
38
41
  --write Enable write (muse)
@@ -40,6 +43,8 @@ Options:
40
43
  --shell Enable shell (muse)
41
44
  --no-shell Disable shell
42
45
  --max-steps <n> Max steps (muse, 1-10000)
46
+ --timeout <seconds> Wall-clock budget for the run (all harnesses,
47
+ hcn-enforced; 0 disables; no default)
43
48
  --no-tools Disable tools discovery facet
44
49
  --no-instruction-files Disable instructionFiles discovery facet
45
50
  --no-extensions Disable extensions discovery facet
@@ -47,9 +52,20 @@ Options:
47
52
  --cwd <path> Working directory for spawn
48
53
  --env KEY=VAL Environment (repeatable; KEY= deletes)
49
54
  --resume <uuid> Resume session id (UUID)
55
+ -- Passthrough: native harness args verbatim
56
+ (failures surface as labeled native errors)
50
57
  --json NDJSON HarnessEvent to stdout
51
58
  -h, --help Show help
52
59
  -V, --version Show version
60
+
61
+ Defaults with no flags:
62
+ Every launch resolves args > project config (.hcn/config.json at the
63
+ git root) > user config (~/.config/hcn/config.json) > built-in profile
64
+ > harness default. The profile pins: effort medium, sandbox
65
+ workspace-write (codex only; other harnesses report divergence),
66
+ discovery on, autonomy off, write/shell on. timeout and max-steps have
67
+ no default. Provenance prints to stderr on every run; see
68
+ 'hcn inspect <harness>' for the resolved argv of a bare run.
53
69
  `;
54
70
 
55
71
  export const SESSION_HELP = `hcn session - Interactive session (Claude-only)
package/src/cli/run.ts CHANGED
@@ -186,16 +186,42 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
186
186
 
187
187
  const isExplicit = promptSource !== "positional";
188
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
+
189
214
  // Defaults profile + user config: LAUNCH-ONLY. A resumed session keeps
190
215
  // its own settings; the resolver never runs on resume paths.
191
216
  let resolvedProvenance: readonly ProvenanceEntry[] = [];
192
217
  let resolvedUnrenderable: readonly string[] = [];
193
218
  let effectiveTurnOpts: ReturnType<typeof parseTurnOptions> = turnOpts;
219
+ const resolvedTiers: {
220
+ user?: Partial<ReturnType<typeof parseTurnOptions>>;
221
+ project?: Partial<ReturnType<typeof parseTurnOptions>>;
222
+ } = {};
194
223
  if (extra.resume === undefined) {
195
- const tiers: {
196
- user?: Partial<ReturnType<typeof parseTurnOptions>>;
197
- project?: Partial<ReturnType<typeof parseTurnOptions>>;
198
- } = {};
224
+ const tiers = resolvedTiers;
199
225
  const { loadUserConfig, loadProjectConfig, ConfigError } = await import("./config.js");
200
226
  try {
201
227
  const loaded = loadUserConfig();
@@ -277,6 +303,11 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
277
303
  prompt,
278
304
  __explicitPrompt: isExplicit,
279
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
+ }
280
311
  }
281
312
  _validated = true;
282
313
  } catch (err) {
@@ -311,7 +342,17 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
311
342
  // Delete HERDR_ENV before spawn
312
343
  delete (process.env as Record<string, string | undefined>).HERDR_ENV;
313
344
 
314
- 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();
315
356
 
316
357
  // Signal handling
317
358
  const _currentProc: { signal: (sig: "SIGTERM" | "SIGKILL") => void } | null = null;
@@ -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
+ };
@@ -31,6 +31,7 @@ export const FAILURE_CLASSES = Object.freeze([
31
31
  "transport",
32
32
  "rejected",
33
33
  "native",
34
+ "timeout",
34
35
  ] as const);
35
36
  export type FailureClass = (typeof FAILURE_CLASSES)[number];
36
37
 
@@ -56,7 +57,7 @@ export interface FailureSummary {
56
57
  }
57
58
 
58
59
  export const retryableOf = (cls: FailureClass): boolean =>
59
- cls !== "task" && cls !== "budget" && cls !== "rejected" && cls !== "native";
60
+ cls !== "task" && cls !== "budget" && cls !== "rejected" && cls !== "native" && cls !== "timeout";
60
61
 
61
62
  const messageFor = (cls: FailureClass, detail?: string): string => {
62
63
  switch (cls) {
@@ -76,6 +77,11 @@ const messageFor = (cls: FailureClass, detail?: string): string => {
76
77
  return `Transport failure${detail ? ` (${detail})` : ""} - retry or route to another provider`;
77
78
  case "rejected":
78
79
  return `Request rejected${detail ? ` (${detail})` : ""} - change options or harness`;
80
+ case "timeout":
81
+ // D11: hcn's wall-clock budget expired and the run was killed. The
82
+ // caller chose this number (arg or config); retrying unchanged will
83
+ // hit the same wall - retry only with a raised budget.
84
+ return `Timeout: run exceeded its wall-clock budget and was killed (SIGTERM, then SIGKILL after grace) - raise --timeout for this workload or split the task`;
79
85
  case "native":
80
86
  // D6: labeled NATIVE so it can never be confused with an hcn error.
81
87
  // The harness's own message follows verbatim; the process exit code
@@ -86,7 +92,14 @@ const messageFor = (cls: FailureClass, detail?: string): string => {
86
92
  default:
87
93
  return `Failure${detail ? ` (${detail})` : ""}`;
88
94
  }
89
- }; /** D6: a failure that belongs to the harness, not hcn. Carries the
95
+ }; /** D11: the run outlived its caller-set wall-clock budget. */
96
+ export const failureFromTimeout = (): FailureSummary => ({
97
+ class: "timeout",
98
+ retryable: false,
99
+ message: messageFor("timeout"),
100
+ });
101
+
102
+ /** D6: a failure that belongs to the harness, not hcn. Carries the
90
103
  * native stderr verbatim and the native exit code as data. */
91
104
  export const failureFromNative = (
92
105
  nativeExitCode: number | null,
@@ -173,6 +186,7 @@ const PRECEDENCE: Record<FailureClass, number> = {
173
186
  budget: 3,
174
187
  task: 3,
175
188
  transport: 4,
189
+ timeout: 3,
176
190
  // rejected stands alone (checked before precedence applies); native is
177
191
  // terminal-by-classification, never reduced into anything else.
178
192
  rejected: 0,
@@ -30,6 +30,7 @@ import {
30
30
  failureFromLimit,
31
31
  failureFromNative,
32
32
  failureFromRejected,
33
+ failureFromTimeout,
33
34
  failureFromTransport,
34
35
  reduceFailures,
35
36
  } from "./failure.js";
@@ -152,6 +153,13 @@ export async function* streamTurn(
152
153
  if (opts.passthrough !== undefined && opts.passthrough.length > 0) {
153
154
  argv = [...argv, "--", ...opts.passthrough];
154
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
+ }
155
163
  granularity = streamingGranularityOf(h, argv);
156
164
  } catch (e) {
157
165
  if (e instanceof ArgvRefusalError) {
@@ -434,7 +442,12 @@ export async function* streamTurn(
434
442
  }
435
443
  // Stall watchdog also implies a transport failure if not already present
436
444
  if (killedByWatchdog && failures.length === 0) {
437
- 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"}`);
438
451
  failures.push(f);
439
452
  yield { kind: "failure", ...f };
440
453
  }
@@ -442,7 +455,9 @@ export async function* streamTurn(
442
455
  let cause: ExitCause = state.limitSeen
443
456
  ? "limit"
444
457
  : killedByWatchdog && exitCode !== 0
445
- ? "stall"
458
+ ? watchdogReason === "turn-deadline"
459
+ ? "killed" // D11: the run was killed on budget, not stalled
460
+ : "stall"
446
461
  : exitCode === 0
447
462
  ? "clean"
448
463
  : exitCode === null
@@ -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;
@@ -112,6 +118,9 @@ const turnTail = (h: HarnessDescriptor, opts: TurnOptions): string[] => {
112
118
  });
113
119
  tail.push(...rendered.tokens);
114
120
  }
121
+ if (opts.skills !== undefined && opts.skills.length > 0) {
122
+ tail.push(...renderSkillsSelection(h, opts.skills));
123
+ }
115
124
  return tail;
116
125
  };
117
126
 
@@ -33,6 +33,7 @@ export type RefusalOption =
33
33
  | TurnOptionKey
34
34
  | "tools"
35
35
  | "excludeTools"
36
+ | "skills"
36
37
  | "autonomy"
37
38
  | `discovery.${string}`;
38
39
 
@@ -54,6 +54,9 @@ const EXPRESSIBLE: Readonly<Record<ProfileKey, (h: HarnessDescriptor) => boolean
54
54
  sandbox: (h) => h.turnOptions.sandbox !== undefined,
55
55
  discovery: () => true,
56
56
  autonomy: () => true,
57
+ write: () => true,
58
+ shell: () => true,
59
+ tools: (h) => h.tools.includeFlag !== null || h.tools.excludeFlag !== null,
57
60
  };
58
61
 
59
62
  export interface ConfigTiers {
@@ -155,12 +158,33 @@ export const resolveEffectiveOptions = (
155
158
  provenance.push({ key, value, tier: "harness" });
156
159
  continue;
157
160
  }
161
+ // D13: the tools marker expands per descriptor. On a harness whose
162
+ // default is already everything (claude), expansion emits nothing -
163
+ // the emit-nothing rule, recorded in provenance. On a harness with
164
+ // dormant built-ins (pi), it becomes the enabling include list.
165
+ if (key === "tools" && value === "all-known") {
166
+ const enabled = h.tools.builtins.filter((t) => t.defaultEnabled).length;
167
+ const all = h.tools.builtins.length;
168
+ if (enabled === all) {
169
+ provenance.push({ key, value: "all known (already default)", tier: "profile" });
170
+ continue;
171
+ }
172
+ const expanded = h.tools.builtins.map((t) => t.name);
173
+ resolved[key] = expanded;
174
+ provenance.push({ key, value: expanded, tier: "profile" });
175
+ continue;
176
+ }
158
177
  // Dimensions whose value reduces to "emit nothing" (autonomy false,
159
- // discovery all-on) stay ABSENT from the resolved options - the
160
- // harness's default already satisfies the profile, and emitting
161
- // explicit on-flags would change resume grammar and add breakage
162
- // surface for no semantic gain. Provenance still records the tier.
163
- if ((key === "autonomy" && value === false) || (key === "discovery" && emitsNothing(value))) {
178
+ // discovery all-on, write/shell true) stay ABSENT from the resolved
179
+ // options - the harness's default already satisfies the profile, and
180
+ // emitting explicit on-flags would change resume grammar and add
181
+ // breakage surface for no semantic gain. Provenance still records
182
+ // the tier.
183
+ if (
184
+ (key === "autonomy" && value === false) ||
185
+ (key === "discovery" && emitsNothing(value)) ||
186
+ ((key === "write" || key === "shell") && value === true)
187
+ ) {
164
188
  provenance.push({ key, value, tier: "profile" });
165
189
  continue;
166
190
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Caller-directed skills allowlist rendering (issue #38). The delegating
3
+ * agent picks the subset from its own registry; this module turns the
4
+ * resolved paths into per-harness argv tokens.
5
+ *
6
+ * - pi: `-ns` (discovery off) + one `--skill <path>` per entry - the
7
+ * allowlist is exact: only the caller's picks load.
8
+ * - claude: no per-skill load flag; the registry is already present via
9
+ * the personal skills dir, so the allowlist renders as the complement
10
+ * OFF - `--settings '{"skillOverrides":{"<name>":"off",...}}'` for every
11
+ * known skill except the picks. Known set comes from the caller's root
12
+ * listing (same source that resolved the names).
13
+ * - codex/muse: refuse (structural) with the standard hint shape.
14
+ */
15
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
16
+ import { ArgvRefusalError } from "./refusal.js";
17
+
18
+ export const basenameOf = (p: string): string => {
19
+ const i = p.lastIndexOf("/");
20
+ return i === -1 ? p : p.slice(i + 1);
21
+ };
22
+
23
+ export const renderSkillsSelection = (
24
+ h: HarnessDescriptor,
25
+ skills: readonly string[],
26
+ ): readonly string[] => {
27
+ if (skills.length === 0) return [];
28
+
29
+ if (h.skills === null) {
30
+ throw new ArgvRefusalError({
31
+ issue: "unsupported-option",
32
+ harness: h.name,
33
+ option: "skills",
34
+ supported: ["caller-directed skill sets"],
35
+ supportedBy: [
36
+ { harness: "pi", spelling: "--skill" },
37
+ { harness: "claude", spelling: "skillOverrides" },
38
+ ],
39
+ hint:
40
+ h.name === "codex"
41
+ ? "codex discovers skills from its own directory with no call-time surface - stage the skills into $CODEX_HOME/skills or pass their content in the prompt"
42
+ : "muse scopes skills by workspace trust with no per-skill surface - include the skill content in the prompt or use --trust-workspace for the whole registry",
43
+ });
44
+ }
45
+
46
+ if (h.skills.loadFlag !== null) {
47
+ // pi: discovery off so ONLY the picks load.
48
+ const tokens: string[] = ["-ns"];
49
+ for (const path of skills) tokens.push(h.skills.loadFlag, path);
50
+ return tokens;
51
+ }
52
+
53
+ // claude: complement-off via settings JSON. Names are the skill dir
54
+ // basenames; unknown names in knownSkills would be turned off
55
+ // pointlessly, so the caller passes exactly the known set.
56
+ return [];
57
+ };
58
+
59
+ /** The claude complement form, given the full known registry: every known
60
+ * name except the picks gets "off". Exported for the CLI layer, which owns
61
+ * the registry listing (an fs read - never in interpretation). */
62
+ export const claudeSkillOverridesArg = (
63
+ knownSkills: readonly string[],
64
+ pickedPaths: readonly string[],
65
+ ): string[] => {
66
+ const picks = new Set(pickedPaths.map(basenameOf));
67
+ const offs: Record<string, string> = {};
68
+ for (const name of knownSkills) {
69
+ if (!picks.has(name)) offs[name] = "off";
70
+ }
71
+ const json = JSON.stringify({ skillOverrides: offs });
72
+ return ["--settings", json];
73
+ };
@@ -161,6 +161,7 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
161
161
  // compose, deny winning on overlap. Patterns (Bash(git *)) valid in both
162
162
  // lists; unknown PATTERN spellings warn on stderr, unknown exact names
163
163
  // are the silent-acceptance hazard the curated vocabulary guards.
164
+ skills: { loadFlag: null, overridesVia: "settings-skilloverrides" },
164
165
  tools: {
165
166
  includeFlag: "--allowedTools",
166
167
  excludeFlag: "--disallowedTools",
@@ -119,6 +119,7 @@ export const codexCli: HarnessDescriptor = deepFreeze({
119
119
  // (reachable per-call via -c key=value / --enable/--disable FEATURE),
120
120
  // sandbox, and approval policy. MCP servers do have per-tool keys
121
121
  // (mcp_servers.<id>.tools.<tool>) but built-ins do not.
122
+ skills: null,
122
123
  tools: {
123
124
  includeFlag: null,
124
125
  excludeFlag: null,
@@ -356,6 +356,13 @@ export interface HarnessDescriptor {
356
356
  * tools register at runtime and are NEVER enumerated here - name
357
357
  * validation is a default, not a refusal authority (same stance as the
358
358
  * pi model registry, D-008). */
359
+ /** Caller-directed skills allowlist surface (issue #38). pi: repeatable
360
+ * load flag. claude: per-name "off" overrides via settings JSON.
361
+ * codex/muse: null (structural gap - trust/config scoped only). */
362
+ readonly skills: {
363
+ readonly loadFlag: string | null;
364
+ readonly overridesVia: "settings-skilloverrides" | null;
365
+ } | null;
359
366
  readonly tools: {
360
367
  readonly includeFlag: string | null;
361
368
  readonly excludeFlag: string | null;
@@ -116,6 +116,7 @@ export const museCode: HarnessDescriptor = deepFreeze({
116
116
  // Tool names follow muse.<name> (write_file, edit_file, bash, bash_input,
117
117
  // add_memory, edit_memory, web_search). --disable-web-tools has no
118
118
  // normalized turnOption yet - candidate for a web toggle or passthrough.
119
+ skills: null,
119
120
  tools: {
120
121
  includeFlag: null,
121
122
  excludeFlag: null,
@@ -122,6 +122,7 @@ export const piCli: HarnessDescriptor = deepFreeze({
122
122
  // exclude subtracts from include. --tools is strict over BUILT-INS but
123
123
  // does not strip MCP/extension registrations (additive over them);
124
124
  // -nbt (built-ins only off) exists but has no normalized spelling yet.
125
+ skills: { loadFlag: "--skill", overridesVia: null },
125
126
  tools: {
126
127
  includeFlag: "--tools",
127
128
  excludeFlag: "--exclude-tools",