@dungle-scrubs/harness-cli-normalizer 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +30 -3
  2. package/dist/cli/args.d.ts +1 -0
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +29 -0
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/config.d.ts.map +1 -1
  7. package/dist/cli/config.js +12 -1
  8. package/dist/cli/config.js.map +1 -1
  9. package/dist/cli/exit-codes.js +1 -1
  10. package/dist/cli/exit-codes.js.map +1 -1
  11. package/dist/cli/help.d.ts +2 -2
  12. package/dist/cli/help.d.ts.map +1 -1
  13. package/dist/cli/help.js +35 -2
  14. package/dist/cli/help.js.map +1 -1
  15. package/dist/cli/render.d.ts.map +1 -1
  16. package/dist/cli/render.js +15 -1
  17. package/dist/cli/render.js.map +1 -1
  18. package/dist/cli/run.d.ts.map +1 -1
  19. package/dist/cli/run.js +90 -16
  20. package/dist/cli/run.js.map +1 -1
  21. package/dist/cli/skills-root.d.ts +4 -0
  22. package/dist/cli/skills-root.d.ts.map +1 -0
  23. package/dist/cli/skills-root.js +45 -0
  24. package/dist/cli/skills-root.js.map +1 -0
  25. package/dist/execution/events.d.ts +12 -1
  26. package/dist/execution/events.d.ts.map +1 -1
  27. package/dist/execution/events.js.map +1 -1
  28. package/dist/execution/failure.d.ts +4 -1
  29. package/dist/execution/failure.d.ts.map +1 -1
  30. package/dist/execution/failure.js +15 -2
  31. package/dist/execution/failure.js.map +1 -1
  32. package/dist/execution/stream-turn.d.ts +7 -0
  33. package/dist/execution/stream-turn.d.ts.map +1 -1
  34. package/dist/execution/stream-turn.js +83 -16
  35. package/dist/execution/stream-turn.js.map +1 -1
  36. package/dist/interpretation/argv.d.ts +10 -0
  37. package/dist/interpretation/argv.d.ts.map +1 -1
  38. package/dist/interpretation/argv.js +4 -0
  39. package/dist/interpretation/argv.js.map +1 -1
  40. package/dist/interpretation/question.d.ts +46 -0
  41. package/dist/interpretation/question.d.ts.map +1 -0
  42. package/dist/interpretation/question.js +110 -0
  43. package/dist/interpretation/question.js.map +1 -0
  44. package/dist/interpretation/refusal.d.ts +1 -1
  45. package/dist/interpretation/refusal.d.ts.map +1 -1
  46. package/dist/interpretation/refusal.js.map +1 -1
  47. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  48. package/dist/interpretation/resolve-options.js +27 -5
  49. package/dist/interpretation/resolve-options.js.map +1 -1
  50. package/dist/interpretation/skills-selection.d.ts +22 -0
  51. package/dist/interpretation/skills-selection.d.ts.map +1 -0
  52. package/dist/interpretation/skills-selection.js +49 -0
  53. package/dist/interpretation/skills-selection.js.map +1 -0
  54. package/dist/knowledge/claude-code.d.ts.map +1 -1
  55. package/dist/knowledge/claude-code.js +1 -0
  56. package/dist/knowledge/claude-code.js.map +1 -1
  57. package/dist/knowledge/codex.d.ts.map +1 -1
  58. package/dist/knowledge/codex.js +1 -0
  59. package/dist/knowledge/codex.js.map +1 -1
  60. package/dist/knowledge/descriptor.d.ts +7 -0
  61. package/dist/knowledge/descriptor.d.ts.map +1 -1
  62. package/dist/knowledge/muse.d.ts.map +1 -1
  63. package/dist/knowledge/muse.js +1 -0
  64. package/dist/knowledge/muse.js.map +1 -1
  65. package/dist/knowledge/pi.d.ts.map +1 -1
  66. package/dist/knowledge/pi.js +1 -0
  67. package/dist/knowledge/pi.js.map +1 -1
  68. package/dist/knowledge/profile.d.ts +3 -0
  69. package/dist/knowledge/profile.d.ts.map +1 -1
  70. package/dist/knowledge/profile.js +20 -0
  71. package/dist/knowledge/profile.js.map +1 -1
  72. package/package.json +1 -1
  73. package/src/cli/args.ts +39 -2
  74. package/src/cli/config.ts +14 -1
  75. package/src/cli/exit-codes.ts +1 -1
  76. package/src/cli/help.ts +35 -2
  77. package/src/cli/render.ts +16 -1
  78. package/src/cli/run.ts +99 -23
  79. package/src/cli/skills-root.ts +47 -0
  80. package/src/execution/events.ts +19 -1
  81. package/src/execution/failure.ts +16 -2
  82. package/src/execution/stream-turn.ts +91 -15
  83. package/src/interpretation/argv.ts +14 -0
  84. package/src/interpretation/question.ts +137 -0
  85. package/src/interpretation/refusal.ts +1 -0
  86. package/src/interpretation/resolve-options.ts +29 -5
  87. package/src/interpretation/skills-selection.ts +73 -0
  88. package/src/knowledge/claude-code.ts +1 -0
  89. package/src/knowledge/codex.ts +1 -0
  90. package/src/knowledge/descriptor.ts +7 -0
  91. package/src/knowledge/muse.ts +1 -0
  92. package/src/knowledge/pi.ts +1 -0
  93. package/src/knowledge/profile.ts +20 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Question escalation (issue #41): the protocol that lets a headless
3
+ * worker ask the CALLER's user a question. Transport is the prompt - no
4
+ * harness ships native question conveyance - so hcn prepends a protocol
5
+ * contract (escalateQuestions true, the default) or the state-the-
6
+ * assumption instruction (false). The worker's final message then carries
7
+ * a fenced `hcn-question` block; detection is structured-first (fields
8
+ * parsed from the block, prose rendered downstream from them).
9
+ *
10
+ * Ratified design note (2026-08-19): autonomy and escalateQuestions are
11
+ * independent flags carving the same substrate by ORIGIN - autonomy =
12
+ * interrupts the harness raises (permission gates), escalateQuestions =
13
+ * interrupts the model raises (judgment gaps). The true-mode preamble
14
+ * must therefore never conflate asking with permission: "you may ask" is
15
+ * never "you lack permission." Both preambles open with the same marker
16
+ * so composition is idempotent (a composed prompt is never re-composed).
17
+ */
18
+
19
+ /** The shared first line of both preambles - also the idempotence marker
20
+ * for composeEscalatedPrompt. */
21
+ export const QUESTION_PREAMBLE_MARKER = "[hcn question protocol]";
22
+
23
+ export const ESCALATION_PREAMBLE = `${QUESTION_PREAMBLE_MARKER}
24
+ You are running headless: no one is watching this session live, but a caller relays answers between turns. This protocol never changes your permissions - asking is not how you obtain permission, and it never removes any permission this run already has. Use the tools you have exactly as granted.
25
+ If and only if a genuine decision you cannot make defensibly blocks correct progress, ask by ending your turn: emit one fenced code block tagged hcn-question, as the last content of your final message, containing a single JSON object:
26
+
27
+ \`\`\`hcn-question
28
+ {"question": "<the decision you need made>", "options": ["<option 1>", "<option 2>"], "recommended": "<one of options>"}
29
+ \`\`\`
30
+
31
+ Say nothing after the block. Your turn ends there; the caller's user will answer, and your next turn continues from that answer. For every choice you can make defensibly yourself, do not ask - decide, act, and state the decision you made.`;
32
+
33
+ export const NO_ESCALATION_PREAMBLE = `${QUESTION_PREAMBLE_MARKER}
34
+ You are running headless and no one will answer you in this session. Never ask a question, never request input or confirmation, and never end your turn awaiting a reply. When a decision is ambiguous, pick the most defensible reading, state the assumption you proceeded under in one sentence, and continue to completion.`;
35
+
36
+ /** Compose the transport preamble onto a prompt. Idempotent: a prompt
37
+ * that already carries either preamble (re-composition on resume, a
38
+ * caller that pre-composed) passes through unchanged. */
39
+ export const composeEscalatedPrompt = (prompt: string, escalate: boolean): string =>
40
+ prompt.startsWith(QUESTION_PREAMBLE_MARKER)
41
+ ? prompt
42
+ : `${escalate ? ESCALATION_PREAMBLE : NO_ESCALATION_PREAMBLE}\n\n${prompt}`;
43
+
44
+ /** The structured question a worker asks (the block's fields). */
45
+ export interface QuestionBlock {
46
+ readonly question: string;
47
+ readonly options: readonly string[];
48
+ readonly recommended?: string;
49
+ }
50
+
51
+ /** Detection result: a parsed block, a named malformation (the worker
52
+ * tried to ask but botched the shape - surfaced, never swallowed), or
53
+ * null when no hcn-question block is present. */
54
+ export type QuestionDetection = { readonly block: QuestionBlock } | { readonly malformed: string };
55
+
56
+ /** One fenced-block candidate: the body text plus whether the fence was
57
+ * properly closed (an unclosed opener at end-of-text is still examined -
58
+ * the most likely formatting slip is forgetting the closing fence). */
59
+ interface FenceCandidate {
60
+ readonly body: string;
61
+ readonly closed: boolean;
62
+ }
63
+
64
+ const FENCE_OPEN = /(?:^|\n)[ \t]*```[ \t]*hcn-question[ \t]*(?=\n)/g;
65
+
66
+ /** All hcn-question fence bodies in a text, in order. */
67
+ const fenceBodies = (text: string): FenceCandidate[] => {
68
+ const out: FenceCandidate[] = [];
69
+ FENCE_OPEN.lastIndex = 0;
70
+ for (let m = FENCE_OPEN.exec(text); m !== null; m = FENCE_OPEN.exec(text)) {
71
+ const bodyStart = m.index + m[0].length;
72
+ // The closing fence may be indented like the opener (probe evidence:
73
+ // indented blocks occur), so match newline + optional spaces + ```.
74
+ const close = text.slice(bodyStart).search(/\n[ \t]*```/);
75
+ if (close === -1) {
76
+ // Unclosed fence: the body runs to end-of-text. Only meaningful
77
+ // when nothing follows the opener - take it as a candidate anyway.
78
+ out.push({ body: text.slice(bodyStart), closed: false });
79
+ break;
80
+ }
81
+ const bodyEnd = bodyStart + close;
82
+ out.push({ body: text.slice(bodyStart, bodyEnd), closed: true });
83
+ FENCE_OPEN.lastIndex = bodyEnd;
84
+ }
85
+ return out;
86
+ };
87
+
88
+ const parseBlock = (body: string): QuestionDetection => {
89
+ let raw: unknown;
90
+ try {
91
+ raw = JSON.parse(body);
92
+ } catch (e) {
93
+ return { malformed: `hcn-question block is not valid JSON: ${(e as Error).message}` };
94
+ }
95
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
96
+ return { malformed: "hcn-question block must be a JSON object" };
97
+ }
98
+ const obj = raw as Record<string, unknown>;
99
+ const { question, options, recommended } = obj;
100
+ if (typeof question !== "string" || question.trim() === "") {
101
+ return { malformed: 'hcn-question block field "question" must be a non-empty string' };
102
+ }
103
+ if (
104
+ !Array.isArray(options) ||
105
+ options.length === 0 ||
106
+ options.some((o) => typeof o !== "string" || o.trim() === "")
107
+ ) {
108
+ return {
109
+ malformed: 'hcn-question block field "options" must be an array of non-empty strings',
110
+ };
111
+ }
112
+ if (recommended !== undefined && typeof recommended !== "string") {
113
+ return { malformed: 'hcn-question block field "recommended" must be a string' };
114
+ }
115
+ return {
116
+ block: {
117
+ question,
118
+ options,
119
+ ...(recommended !== undefined ? { recommended } : {}),
120
+ },
121
+ };
122
+ };
123
+
124
+ /** Detect the hcn-question block in a message text. The LAST block wins
125
+ * (the protocol makes the block the turn's final content; a corrected
126
+ * re-emit supersedes an earlier one). An empty candidate body (a bare
127
+ * unclosed opener with nothing after it) is not a detection. */
128
+ export const detectQuestionBlock = (text: string): QuestionDetection | null => {
129
+ const candidates = fenceBodies(text);
130
+ for (let i = candidates.length - 1; i >= 0; i--) {
131
+ const candidate = candidates[i];
132
+ if (candidate === undefined) continue;
133
+ if (candidate.body.trim() === "") continue;
134
+ return parseBlock(candidate.body);
135
+ }
136
+ return null;
137
+ };
@@ -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",
@@ -28,6 +28,26 @@ export const DEFAULT_TURN_PROFILE = deepFreeze({
28
28
  // Ratified: autonomy OFF. No bare run is unattended; --autonomy or a
29
29
  // config must say so deliberately.
30
30
  autonomy: false,
31
+ // D9/D10, ratified round 2: write and shell ON - names current
32
+ // behavior (emit-nothing on claude/codex/pi; muse omits its disable
33
+ // flags). Completes the "what can a bare run do to the machine" row of
34
+ // provenance.
35
+ write: true,
36
+ shell: true,
37
+ // D13, ratified: equivalent-as-possible tool defaults. The marker
38
+ // expands per descriptor at resolve time - pi gains its dormant
39
+ // grep/find/ls; claude emits nothing (already everything); codex/muse
40
+ // report divergence (no list surface). Project floors narrow it by
41
+ // the normal precedence chain.
42
+ tools: "all-known",
43
+ // Model is PERMANENTLY out of profile scope (ratified round 2): no
44
+ // cross-harness model namespace exists, pi's registry is
45
+ // runtime-extensible and environment-dependent, and per-harness config
46
+ // already covers "claude on X, pi on Y". Do not add a model entry.
47
+ // timeout and maxSteps are likewise opt-in-only dimensions (D11/D12):
48
+ // no harness ships a wall-clock cap, one prompt expands into an
49
+ // unbounded turn loop, and a fixed default kills legitimate work. They
50
+ // live in args/config only, never here.
31
51
  } as const);
32
52
 
33
53
  export type ProfileKey = keyof typeof DEFAULT_TURN_PROFILE;