@dungle-scrubs/harness-cli-normalizer 0.6.0 → 0.6.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 (219) hide show
  1. package/README.md +33 -6
  2. package/dist/cli/args.d.ts +9 -1
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +36 -26
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/check.d.ts +14 -0
  7. package/dist/cli/check.d.ts.map +1 -1
  8. package/dist/cli/check.js +10 -3
  9. package/dist/cli/check.js.map +1 -1
  10. package/dist/cli/config.d.ts +4 -4
  11. package/dist/cli/config.d.ts.map +1 -1
  12. package/dist/cli/config.js +8 -6
  13. package/dist/cli/config.js.map +1 -1
  14. package/dist/cli/exit-codes.d.ts +0 -1
  15. package/dist/cli/exit-codes.d.ts.map +1 -1
  16. package/dist/cli/exit-codes.js +0 -1
  17. package/dist/cli/exit-codes.js.map +1 -1
  18. package/dist/cli/help.d.ts +2 -2
  19. package/dist/cli/help.d.ts.map +1 -1
  20. package/dist/cli/help.js +10 -2
  21. package/dist/cli/help.js.map +1 -1
  22. package/dist/cli/index.d.ts.map +1 -1
  23. package/dist/cli/index.js +3 -1
  24. package/dist/cli/index.js.map +1 -1
  25. package/dist/cli/inspect.d.ts.map +1 -1
  26. package/dist/cli/inspect.js +84 -265
  27. package/dist/cli/inspect.js.map +1 -1
  28. package/dist/cli/plan-turn.d.ts +68 -0
  29. package/dist/cli/plan-turn.d.ts.map +1 -0
  30. package/dist/cli/plan-turn.js +268 -0
  31. package/dist/cli/plan-turn.js.map +1 -0
  32. package/dist/cli/refuse.d.ts.map +1 -1
  33. package/dist/cli/refuse.js +2 -1
  34. package/dist/cli/refuse.js.map +1 -1
  35. package/dist/cli/resolve-harness.d.ts.map +1 -1
  36. package/dist/cli/resolve-harness.js +2 -3
  37. package/dist/cli/resolve-harness.js.map +1 -1
  38. package/dist/cli/run.d.ts.map +1 -1
  39. package/dist/cli/run.js +33 -367
  40. package/dist/cli/run.js.map +1 -1
  41. package/dist/cli/session-json.d.ts +1 -1
  42. package/dist/cli/session-json.d.ts.map +1 -1
  43. package/dist/cli/session-json.js +1 -3
  44. package/dist/cli/session-json.js.map +1 -1
  45. package/dist/cli/session.d.ts.map +1 -1
  46. package/dist/cli/session.js +35 -43
  47. package/dist/cli/session.js.map +1 -1
  48. package/dist/cli/skills-root.d.ts +1 -1
  49. package/dist/cli/skills-root.d.ts.map +1 -1
  50. package/dist/cli/skills-root.js +1 -1
  51. package/dist/cli/skills-root.js.map +1 -1
  52. package/dist/execution/decode.d.ts.map +1 -1
  53. package/dist/execution/decode.js +11 -36
  54. package/dist/execution/decode.js.map +1 -1
  55. package/dist/execution/events.d.ts +4 -1
  56. package/dist/execution/events.d.ts.map +1 -1
  57. package/dist/execution/events.js.map +1 -1
  58. package/dist/execution/failure.d.ts +3 -1
  59. package/dist/execution/failure.d.ts.map +1 -1
  60. package/dist/execution/failure.js +14 -11
  61. package/dist/execution/failure.js.map +1 -1
  62. package/dist/execution/open-session.d.ts +1 -1
  63. package/dist/execution/open-session.d.ts.map +1 -1
  64. package/dist/execution/open-session.js +150 -223
  65. package/dist/execution/open-session.js.map +1 -1
  66. package/dist/execution/stream-turn.d.ts +9 -14
  67. package/dist/execution/stream-turn.d.ts.map +1 -1
  68. package/dist/execution/stream-turn.js +85 -188
  69. package/dist/execution/stream-turn.js.map +1 -1
  70. package/dist/execution/supervisor.d.ts +71 -0
  71. package/dist/execution/supervisor.d.ts.map +1 -0
  72. package/dist/execution/supervisor.js +127 -0
  73. package/dist/execution/supervisor.js.map +1 -0
  74. package/dist/interpretation/argv.d.ts +39 -12
  75. package/dist/interpretation/argv.d.ts.map +1 -1
  76. package/dist/interpretation/argv.js +28 -20
  77. package/dist/interpretation/argv.js.map +1 -1
  78. package/dist/interpretation/content.d.ts +10 -1
  79. package/dist/interpretation/content.d.ts.map +1 -1
  80. package/dist/interpretation/content.js +20 -11
  81. package/dist/interpretation/content.js.map +1 -1
  82. package/dist/interpretation/hints.d.ts +1 -1
  83. package/dist/interpretation/hints.d.ts.map +1 -1
  84. package/dist/interpretation/hints.js +10 -11
  85. package/dist/interpretation/hints.js.map +1 -1
  86. package/dist/interpretation/index.d.ts +0 -1
  87. package/dist/interpretation/index.d.ts.map +1 -1
  88. package/dist/interpretation/index.js +0 -1
  89. package/dist/interpretation/index.js.map +1 -1
  90. package/dist/interpretation/isolation.d.ts +10 -0
  91. package/dist/interpretation/isolation.d.ts.map +1 -0
  92. package/dist/interpretation/isolation.js +30 -0
  93. package/dist/interpretation/isolation.js.map +1 -0
  94. package/dist/interpretation/limits.d.ts +2 -5
  95. package/dist/interpretation/limits.d.ts.map +1 -1
  96. package/dist/interpretation/limits.js +8 -62
  97. package/dist/interpretation/limits.js.map +1 -1
  98. package/dist/interpretation/question.d.ts +27 -0
  99. package/dist/interpretation/question.d.ts.map +1 -1
  100. package/dist/interpretation/question.js +28 -0
  101. package/dist/interpretation/question.js.map +1 -1
  102. package/dist/interpretation/refusal.d.ts +10 -5
  103. package/dist/interpretation/refusal.d.ts.map +1 -1
  104. package/dist/interpretation/refusal.js +24 -15
  105. package/dist/interpretation/refusal.js.map +1 -1
  106. package/dist/interpretation/resolve-options.d.ts +36 -2
  107. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  108. package/dist/interpretation/resolve-options.js +53 -22
  109. package/dist/interpretation/resolve-options.js.map +1 -1
  110. package/dist/interpretation/session-input.d.ts +44 -1
  111. package/dist/interpretation/session-input.d.ts.map +1 -1
  112. package/dist/interpretation/session-input.js +59 -4
  113. package/dist/interpretation/session-input.js.map +1 -1
  114. package/dist/interpretation/shape.d.ts +1 -0
  115. package/dist/interpretation/shape.d.ts.map +1 -1
  116. package/dist/interpretation/shape.js +10 -0
  117. package/dist/interpretation/shape.js.map +1 -1
  118. package/dist/interpretation/skills-selection.d.ts +25 -33
  119. package/dist/interpretation/skills-selection.d.ts.map +1 -1
  120. package/dist/interpretation/skills-selection.js +41 -48
  121. package/dist/interpretation/skills-selection.js.map +1 -1
  122. package/dist/interpretation/support.d.ts.map +1 -1
  123. package/dist/interpretation/support.js +25 -8
  124. package/dist/interpretation/support.js.map +1 -1
  125. package/dist/interpretation/tool-selection.d.ts +7 -6
  126. package/dist/interpretation/tool-selection.d.ts.map +1 -1
  127. package/dist/interpretation/tool-selection.js +54 -116
  128. package/dist/interpretation/tool-selection.js.map +1 -1
  129. package/dist/interpretation/tool-vocabulary.d.ts +31 -12
  130. package/dist/interpretation/tool-vocabulary.d.ts.map +1 -1
  131. package/dist/interpretation/tool-vocabulary.js +19 -41
  132. package/dist/interpretation/tool-vocabulary.js.map +1 -1
  133. package/dist/interpretation/turn-options.d.ts +4 -0
  134. package/dist/interpretation/turn-options.d.ts.map +1 -1
  135. package/dist/interpretation/turn-options.js +107 -241
  136. package/dist/interpretation/turn-options.js.map +1 -1
  137. package/dist/interpretation/vocabulary.d.ts +8 -6
  138. package/dist/interpretation/vocabulary.d.ts.map +1 -1
  139. package/dist/interpretation/vocabulary.js +22 -9
  140. package/dist/interpretation/vocabulary.js.map +1 -1
  141. package/dist/knowledge/claude-code.d.ts +3 -1
  142. package/dist/knowledge/claude-code.d.ts.map +1 -1
  143. package/dist/knowledge/claude-code.js +17 -4
  144. package/dist/knowledge/claude-code.js.map +1 -1
  145. package/dist/knowledge/codex.d.ts.map +1 -1
  146. package/dist/knowledge/codex.js +31 -7
  147. package/dist/knowledge/codex.js.map +1 -1
  148. package/dist/knowledge/descriptor.d.ts +67 -36
  149. package/dist/knowledge/descriptor.d.ts.map +1 -1
  150. package/dist/knowledge/descriptor.js +50 -15
  151. package/dist/knowledge/descriptor.js.map +1 -1
  152. package/dist/knowledge/matchers.d.ts +12 -0
  153. package/dist/knowledge/matchers.d.ts.map +1 -1
  154. package/dist/knowledge/matchers.js +34 -0
  155. package/dist/knowledge/matchers.js.map +1 -1
  156. package/dist/knowledge/muse.d.ts.map +1 -1
  157. package/dist/knowledge/muse.js +13 -6
  158. package/dist/knowledge/muse.js.map +1 -1
  159. package/dist/knowledge/overrides.d.ts.map +1 -1
  160. package/dist/knowledge/overrides.js +9 -20
  161. package/dist/knowledge/overrides.js.map +1 -1
  162. package/dist/knowledge/pi.d.ts.map +1 -1
  163. package/dist/knowledge/pi.js +8 -6
  164. package/dist/knowledge/pi.js.map +1 -1
  165. package/dist/knowledge/profile.d.ts +1 -0
  166. package/dist/knowledge/profile.d.ts.map +1 -1
  167. package/dist/knowledge/profile.js +1 -0
  168. package/dist/knowledge/profile.js.map +1 -1
  169. package/package.json +1 -1
  170. package/src/cli/args.ts +39 -27
  171. package/src/cli/check.ts +10 -3
  172. package/src/cli/config.ts +14 -11
  173. package/src/cli/exit-codes.ts +0 -2
  174. package/src/cli/help.ts +10 -2
  175. package/src/cli/index.ts +3 -1
  176. package/src/cli/inspect.ts +88 -269
  177. package/src/cli/plan-turn.ts +350 -0
  178. package/src/cli/refuse.ts +2 -1
  179. package/src/cli/resolve-harness.ts +4 -5
  180. package/src/cli/run.ts +36 -399
  181. package/src/cli/session-json.ts +1 -6
  182. package/src/cli/session.ts +51 -44
  183. package/src/cli/skills-root.ts +1 -4
  184. package/src/execution/decode.ts +10 -36
  185. package/src/execution/events.ts +4 -1
  186. package/src/execution/failure.ts +18 -11
  187. package/src/execution/open-session.ts +154 -228
  188. package/src/execution/stream-turn.ts +90 -193
  189. package/src/execution/supervisor.ts +199 -0
  190. package/src/interpretation/argv.ts +65 -34
  191. package/src/interpretation/content.ts +30 -13
  192. package/src/interpretation/hints.ts +2 -12
  193. package/src/interpretation/index.ts +0 -1
  194. package/src/interpretation/isolation.ts +39 -0
  195. package/src/interpretation/limits.ts +14 -75
  196. package/src/interpretation/question.ts +46 -0
  197. package/src/interpretation/refusal.ts +28 -18
  198. package/src/interpretation/resolve-options.ts +98 -33
  199. package/src/interpretation/session-input.ts +88 -5
  200. package/src/interpretation/shape.ts +10 -0
  201. package/src/interpretation/skills-selection.ts +70 -74
  202. package/src/interpretation/support.ts +21 -6
  203. package/src/interpretation/tool-selection.ts +67 -122
  204. package/src/interpretation/tool-vocabulary.ts +48 -53
  205. package/src/interpretation/turn-options.ts +125 -220
  206. package/src/interpretation/vocabulary.ts +15 -10
  207. package/src/knowledge/claude-code.ts +17 -4
  208. package/src/knowledge/codex.ts +31 -7
  209. package/src/knowledge/descriptor.ts +106 -49
  210. package/src/knowledge/matchers.ts +35 -0
  211. package/src/knowledge/muse.ts +13 -6
  212. package/src/knowledge/overrides.ts +17 -32
  213. package/src/knowledge/pi.ts +8 -6
  214. package/src/knowledge/profile.ts +1 -0
  215. package/dist/interpretation/dimensions.d.ts +0 -11
  216. package/dist/interpretation/dimensions.d.ts.map +0 -1
  217. package/dist/interpretation/dimensions.js +0 -4
  218. package/dist/interpretation/dimensions.js.map +0 -1
  219. package/src/interpretation/dimensions.ts +0 -14
@@ -11,8 +11,10 @@ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
11
11
  import { defaultDescriptors } from "../knowledge/overrides.js";
12
12
  import { DEFAULT_TURN_PROFILE, type ProfileKey } from "../knowledge/profile.js";
13
13
  import type { TurnOptions } from "./argv.js";
14
+ import { assertIsolationCombination, ISOLATION_OVERRIDES } from "./isolation.js";
15
+ import type { QuestionMode } from "./question.js";
14
16
  import { ArgvRefusalError } from "./refusal.js";
15
- import type { ToolMap } from "./tool-vocabulary.js";
17
+ import type { ToolMapConfig } from "./tool-vocabulary.js";
16
18
  import { allCanonicalNames, mergeToolMaps, validateCanonicalList } from "./tool-vocabulary.js";
17
19
  import { validateAccess } from "./vocabulary.js";
18
20
 
@@ -55,6 +57,7 @@ export interface ResolvedOptions {
55
57
  * resolves to "emit nothing," which every harness can do. Divergence is
56
58
  * reserved for dimensions that would emit a flag the harness lacks. */
57
59
  const EXPRESSIBLE: Readonly<Record<ProfileKey, (h: HarnessDescriptor) => boolean>> = {
60
+ contextWindow: (h) => h.turnOptions.contextWindow !== undefined,
58
61
  effort: (h) => h.turnOptions.effort !== undefined,
59
62
  sandbox: (h) => h.turnOptions.sandbox !== undefined,
60
63
  discovery: () => true,
@@ -64,20 +67,71 @@ const EXPRESSIBLE: Readonly<Record<ProfileKey, (h: HarnessDescriptor) => boolean
64
67
  tools: (h) => h.tools.includeFlag !== null || h.tools.excludeFlag !== null,
65
68
  };
66
69
 
70
+ /** What one parsed config file carries: the turn options it may set, plus
71
+ * the keys that are not turn options - named toolsets, the raw toolMap
72
+ * (harness -> canonical -> native, merged into the one ToolMap shape
73
+ * here), and the wall-clock timeout. */
74
+ export type ConfigTier = Readonly<Omit<Partial<TurnOptions>, "toolMap">> & {
75
+ readonly toolMap?: ToolMapConfig;
76
+ readonly toolsets?: Readonly<Record<string, readonly string[]>>;
77
+ readonly timeout?: number;
78
+ };
79
+
67
80
  export interface ConfigTiers {
68
81
  /** ~/.config/hcn/config.json (XDG) - machine-wide defaults. */
69
- readonly user?: Readonly<Partial<TurnOptions>>;
82
+ readonly user?: ConfigTier;
70
83
  /** <git-root>/.hcn/config.json - auto-discovered (ratified A), the ALL-
71
84
  * OFF tier; its `tools` key is both the default grant and the FLOOR: an
72
85
  * arg grant exceeding it refuses, naming both sets (D5). */
73
- readonly project?: Readonly<Partial<TurnOptions>>;
86
+ readonly project?: ConfigTier;
87
+ }
88
+
89
+ /** Where a resolved behaviour value came from; `default` is hcn's own. */
90
+ export type BehaviorTier = "arg" | "project-config" | "user-config" | "default";
91
+
92
+ /** The hcn-owned behaviour instructions a run resolves on every path,
93
+ * launch, resume, and session alike (RFC-02 change 6): question mode,
94
+ * which rides the prompt rather than the argv, and the wall-clock
95
+ * timeout hcn enforces itself. Neither is a turn option, so the
96
+ * launch-only turn-option resolver never sees them. */
97
+ export interface ResolvedBehavior {
98
+ readonly questions: { readonly value: QuestionMode; readonly tier: BehaviorTier };
99
+ readonly timeoutSeconds: { readonly value: number | undefined; readonly tier: BehaviorTier };
74
100
  }
75
101
 
102
+ /** Precedence arg > project > user > default, with the tier the
103
+ * provenance line prints. Timeout 0 is an explicit disable. */
104
+ export const resolveBehavior = (
105
+ args: { readonly questions?: QuestionMode; readonly timeoutSeconds?: number },
106
+ tiers: ConfigTiers,
107
+ ): ResolvedBehavior => {
108
+ const pick = <T>(
109
+ arg: T | undefined,
110
+ project: T | undefined,
111
+ user: T | undefined,
112
+ fallback: T,
113
+ ): { readonly value: T; readonly tier: BehaviorTier } => {
114
+ if (arg !== undefined) return { value: arg, tier: "arg" };
115
+ if (project !== undefined) return { value: project, tier: "project-config" };
116
+ if (user !== undefined) return { value: user, tier: "user-config" };
117
+ return { value: fallback, tier: "default" };
118
+ };
119
+ return {
120
+ questions: pick(args.questions, tiers.project?.questions, tiers.user?.questions, "ask"),
121
+ timeoutSeconds: pick(
122
+ args.timeoutSeconds,
123
+ tiers.project?.timeout,
124
+ tiers.user?.timeout,
125
+ undefined,
126
+ ),
127
+ };
128
+ };
129
+
76
130
  /** Merge semantics (gap 1, resolved): config keys are scalars and lists in
77
131
  * schema v1 - there is nothing to deep-merge INTO - so precedence is whole-
78
132
  * key replacement: arg > project > user > profile. A future nested key
79
133
  * (per-harness sections) ships with schema v2 and its own merge rule. */
80
- const effectiveConfig = (tiers: ConfigTiers): Readonly<Partial<TurnOptions>> => ({
134
+ const effectiveConfig = (tiers: ConfigTiers): ConfigTier => ({
81
135
  ...(tiers.user ?? {}),
82
136
  ...(tiers.project ?? {}),
83
137
  });
@@ -99,10 +153,17 @@ export const resolveEffectiveOptions = (
99
153
  args: TurnOptions,
100
154
  tiers: ConfigTiers = {},
101
155
  ): ResolvedOptions => {
156
+ assertIsolationCombination(h, args);
102
157
  const provenance: ProvenanceEntry[] = [];
158
+ if (args.isolation !== undefined)
159
+ provenance.push({ key: "isolation", value: args.isolation, tier: "arg" });
103
160
  const unrenderable: string[] = [];
104
- const config = effectiveConfig(tiers);
161
+ const config = { ...effectiveConfig(tiers) };
162
+ if (args.isolation !== undefined) {
163
+ for (const key of ISOLATION_OVERRIDES) delete config[key];
164
+ }
105
165
  const sourceTier = (key: string): ProvenanceTier | undefined => {
166
+ if (config[key as keyof TurnOptions] === undefined) return undefined;
106
167
  if (tiers.project?.[key as keyof TurnOptions] !== undefined) return "project-config";
107
168
  if (tiers.user?.[key as keyof TurnOptions] !== undefined) return "user-config";
108
169
  return undefined;
@@ -112,9 +173,9 @@ export const resolveEffectiveOptions = (
112
173
  // toolset resolves to its list BEFORE the floor check, so a named set
113
174
  // within the floor passes and one exceeding it refuses naming the set's
114
175
  // members. Project toolsets win name collisions over user toolsets.
115
- const toolsets = {
116
- ...((tiers.user as { toolsets?: Record<string, string[]> } | undefined)?.toolsets ?? {}),
117
- ...((tiers.project as { toolsets?: Record<string, string[]> } | undefined)?.toolsets ?? {}),
176
+ const toolsets: Record<string, readonly string[]> = {
177
+ ...(tiers.user?.toolsets ?? {}),
178
+ ...(tiers.project?.toolsets ?? {}),
118
179
  };
119
180
  let effectiveArgs = args;
120
181
  if (
@@ -144,17 +205,22 @@ export const resolveEffectiveOptions = (
144
205
  });
145
206
  }
146
207
  }
147
- // Access exclusivity on codex: explicit --sandbox together with --access refuses.
148
- // Profile sandbox yields to access - only explicit sandbox counts.
149
- if (h.name === "codex" && resolved.access !== undefined) {
150
- const hasExplicitSandbox =
151
- effectiveArgs.sandbox !== undefined || sourceTier("sandbox") !== undefined;
152
- if (hasExplicitSandbox) {
208
+ // The access preset displaces the turn option its spec claims (codex:
209
+ // sandbox). An explicit value of that option alongside access refuses;
210
+ // the profile default yields silently in the profile loop below. Read
211
+ // from the descriptor, so no harness name appears here.
212
+ const accessSpec = h.turnOptions.access;
213
+ const claimed = accessSpec?.kind === "access" ? accessSpec.claims : undefined;
214
+ if (claimed !== undefined && resolved.access !== undefined) {
215
+ const hasExplicit =
216
+ effectiveArgs[claimed as keyof TurnOptions] !== undefined ||
217
+ sourceTier(claimed) !== undefined;
218
+ if (hasExplicit) {
153
219
  throw new ArgvRefusalError({
154
220
  issue: "mutually-exclusive-options",
155
221
  harness: h.name,
156
222
  option: "access",
157
- supported: ["--access or --sandbox, not both on codex"],
223
+ supported: [`--access or --${claimed}, not both on ${h.name}`],
158
224
  detail: "mutual exclusion",
159
225
  });
160
226
  }
@@ -176,20 +242,14 @@ export const resolveEffectiveOptions = (
176
242
  }
177
243
  }
178
244
 
179
- // toolMap merge per harness per canonical (project > user)
180
- const rawToolMapUser = (tiers.user as { toolMap?: ToolMap } | undefined)?.toolMap;
181
- const rawToolMapProject = (tiers.project as { toolMap?: ToolMap } | undefined)?.toolMap;
182
- const mergedToolMap = mergeToolMaps({ user: rawToolMapUser, project: rawToolMapProject });
245
+ // toolMap merge per harness per canonical (project > user). The merged
246
+ // shape is the one shape past this point (RFC-02 change 8).
247
+ const mergedToolMap = mergeToolMaps({
248
+ user: tiers.user?.toolMap,
249
+ project: tiers.project?.toolMap,
250
+ });
183
251
  if (Object.keys(mergedToolMap).length > 0) {
184
- // Convert mergedToolMap to legacy shape for resolved.toolMap consumers
185
- const legacy: Record<string, Record<string, string>> = {};
186
- for (const [harness, per] of Object.entries(mergedToolMap)) {
187
- legacy[harness] = {};
188
- for (const [canon, entry] of Object.entries(per as Record<string, { native: string }>)) {
189
- legacy[harness]![canon] = entry.native;
190
- }
191
- }
192
- resolved.toolMap = legacy as unknown as typeof resolved.toolMap;
252
+ resolved.toolMap = mergedToolMap;
193
253
  const harnessMap = mergedToolMap[h.name];
194
254
  if (harnessMap) {
195
255
  for (const [canonical, entry] of Object.entries(harnessMap)) {
@@ -214,7 +274,7 @@ export const resolveEffectiveOptions = (
214
274
  let allCanonical: readonly string[] | undefined;
215
275
  const getAllCanonical = (): readonly string[] => {
216
276
  if (allCanonical) return allCanonical;
217
- allCanonical = allCanonicalNames(defaultDescriptors(), mergedToolMap as unknown as ToolMap);
277
+ allCanonical = allCanonicalNames(defaultDescriptors(), mergedToolMap);
218
278
  return allCanonical;
219
279
  };
220
280
  if (needsCanonical) {
@@ -236,6 +296,10 @@ export const resolveEffectiveOptions = (
236
296
 
237
297
  // Profile is the floor: apply only where nothing above it set the key.
238
298
  for (const [key, value] of Object.entries(DEFAULT_TURN_PROFILE)) {
299
+ if (args.isolation !== undefined && ISOLATION_OVERRIDES.some((owned) => owned === key)) {
300
+ provenance.push({ key, value: "disabled (tool-free isolation)", tier: "arg" });
301
+ continue;
302
+ }
239
303
  const argsSet = effectiveArgs[key as keyof TurnOptions] !== undefined;
240
304
  const tier = sourceTier(key);
241
305
  if (argsSet) {
@@ -263,8 +327,8 @@ export const resolveEffectiveOptions = (
263
327
  provenance.push({ key, value: "none (access preset)", tier: accessTier });
264
328
  continue;
265
329
  }
266
- // Profile sandbox yields to access - when access is set, drop profile sandbox.
267
- if (key === "sandbox" && resolved.access !== undefined) {
330
+ // The claimed option's profile default yields to a set access preset.
331
+ if (key === claimed && resolved.access !== undefined) {
268
332
  provenance.push({ key, value: `${String(value)} (access)`, tier: "harness" });
269
333
  continue;
270
334
  }
@@ -280,8 +344,9 @@ export const resolveEffectiveOptions = (
280
344
  // must not have the profile grant switch them back on (pi reads
281
345
  // --tools as an enabling allowlist). The tier that turned tools off
282
346
  // owns the skip.
283
- const toolsOff = (o: Partial<TurnOptions> | undefined): boolean =>
284
- o?.discovery?.tools === false;
347
+ const toolsOff = (
348
+ o: { readonly discovery?: TurnOptions["discovery"] } | undefined,
349
+ ): boolean => o?.discovery?.tools === false;
285
350
  const offTier: ProvenanceTier | undefined = toolsOff(effectiveArgs)
286
351
  ? "arg"
287
352
  : toolsOff(tiers.project)
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Pure validation and encoding for descriptor-declared persistent-session
3
- * input. This module owns supported wire records, not process I/O.
3
+ * input, and the decoding of the session records the runner cannot read
4
+ * without harness knowledge. This module owns the wire records and their
5
+ * field names (ADR 0005, RFC-02 change 4); the execution layer branches
6
+ * on the closed kinds it returns and holds no field name of its own.
4
7
  */
5
8
  import type {
6
9
  HarnessDescriptor,
@@ -8,7 +11,7 @@ import type {
8
11
  SessionInputKind,
9
12
  } from "../knowledge/descriptor.js";
10
13
  import { SESSION_INPUT_KINDS } from "../knowledge/descriptor.js";
11
- import { asRecord } from "./shape.js";
14
+ import { asRecord, readPath } from "./shape.js";
12
15
 
13
16
  export type SessionInputIssue = "missing-session-input-contract" | "unsupported-session-input-kind";
14
17
 
@@ -19,6 +22,11 @@ export class SessionInputRefusalError extends Error {
19
22
  }
20
23
  }
21
24
 
25
+ /** The marker ids hcn stamps on the rpc commands it writes, so their
26
+ * responses cannot be confused with anything user-visible. */
27
+ export const IDENTITY_PROBE_ID = "hcn-identity";
28
+ export const SEND_ID = "hcn-send";
29
+
22
30
  const isSessionInputKind = (value: unknown): value is SessionInputKind =>
23
31
  SESSION_INPUT_KINDS.some((kind) => kind === value);
24
32
 
@@ -43,8 +51,83 @@ export const encodeSessionInput = (input: SessionInputContract, text: string): s
43
51
  })}\n`;
44
52
  case "pi-rpc-prompt":
45
53
  // Verified against pi 0.84.2 rpc (spike fixtures): a prompt command
46
- // while idle; hcn never writes mid-run (it queues sends itself), so
47
- // no streamingBehavior field is ever needed.
48
- return `${JSON.stringify({ id: "hcn-send", type: "prompt", message: text })}\n`;
54
+ // while idle. hcn keeps no queue (ADR 0007) and writes a send when it
55
+ // arrives; pi refuses a bare prompt mid-run with success:false (spike
56
+ // fixture 05), so a mid-turn send would need streamingBehavior - a
57
+ // pending change, not expressed here yet.
58
+ return `${JSON.stringify({ id: SEND_ID, type: "prompt", message: text })}\n`;
59
+ }
60
+ };
61
+
62
+ /** The record the runner writes at spawn to learn the session id, or null
63
+ * when the harness announces identity on its stream unprompted. pi rpc is
64
+ * identity-silent at startup (spike fixtures); the response echoes the
65
+ * marker id. */
66
+ export const encodeIdentityProbe = (h: HarnessDescriptor): string | null => {
67
+ const mode = h.sessionMode;
68
+ if (mode === null || mode.identityProbe === null) return null;
69
+ switch (mode.input.kind) {
70
+ case "pi-rpc-prompt":
71
+ return `${JSON.stringify({ id: IDENTITY_PROBE_ID, type: mode.identityProbe.command })}\n`;
72
+ case "claude-sdk-user-message":
73
+ return null;
74
+ }
75
+ };
76
+
77
+ /** What one parsed stdout record means to a session, as a closed kind. */
78
+ export type SessionRecord =
79
+ /** The identity probe answered with the session id. */
80
+ | { readonly kind: "identity"; readonly sessionId: string }
81
+ /** The identity probe answered without an id - surfaced, never silent. */
82
+ | { readonly kind: "probe-failed"; readonly message: string }
83
+ /** An rpc command hcn wrote was refused by the harness. */
84
+ | { readonly kind: "command-failed"; readonly message: string }
85
+ /** Protocol bookkeeping with nothing to surface. */
86
+ | { readonly kind: "ignored" }
87
+ /** The record that delimits a turn, with the harness's own error flag. */
88
+ | { readonly kind: "turn-end"; readonly isError: boolean }
89
+ /** Anything else: content for the stream decoder. */
90
+ | { readonly kind: "content" };
91
+
92
+ const matchesTurnEnd = (
93
+ record: Record<string, unknown>,
94
+ spec: Readonly<Record<string, string>>,
95
+ ): boolean => Object.entries(spec).every(([key, expected]) => record[key] === expected);
96
+
97
+ export const decodeSessionRecord = (
98
+ h: HarnessDescriptor,
99
+ parsed: Record<string, unknown>,
100
+ ): SessionRecord => {
101
+ const mode = h.sessionMode;
102
+ if (mode === null) return { kind: "content" };
103
+ if (mode.input.kind === "pi-rpc-prompt" && parsed.type === "response") {
104
+ const probe = mode.identityProbe;
105
+ if (
106
+ probe !== null &&
107
+ parsed.id === IDENTITY_PROBE_ID &&
108
+ parsed.command === probe.command &&
109
+ parsed.success === true
110
+ ) {
111
+ const announced = readPath(parsed, probe.responseIdField);
112
+ return typeof announced === "string"
113
+ ? { kind: "identity", sessionId: announced }
114
+ : { kind: "probe-failed", message: "identity probe response carried no sessionId" };
115
+ }
116
+ // A failed command is a surfaced error, never a silent drop (spike:
117
+ // mid-stream prompts fail with success:false naming the remedy).
118
+ if (parsed.success === false) {
119
+ return {
120
+ kind: "command-failed",
121
+ message: `rpc command failed: ${JSON.stringify(parsed.command)} - ${JSON.stringify(parsed.error ?? "unknown error")}`,
122
+ };
123
+ }
124
+ return { kind: "ignored" };
125
+ }
126
+ if (matchesTurnEnd(parsed, mode.turnEnd)) {
127
+ // claude's result record carries is_error; pi's agent_settled has no
128
+ // error flag of its own.
129
+ const isError = mode.input.kind === "claude-sdk-user-message" && parsed.is_error === true;
130
+ return { kind: "turn-end", isError };
49
131
  }
132
+ return { kind: "content" };
50
133
  };
@@ -7,6 +7,16 @@
7
7
  export const asRecord = (value: unknown): Record<string, unknown> | null =>
8
8
  typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
9
9
 
10
+ export const readPath = (record: Record<string, unknown>, path: string): unknown => {
11
+ let cursor: unknown = record;
12
+ for (const segment of path.split(".")) {
13
+ const inner = asRecord(cursor);
14
+ if (inner === null) return undefined;
15
+ cursor = inner[segment];
16
+ }
17
+ return cursor;
18
+ };
19
+
10
20
  /**
11
21
  * Split a command line into shell words: whitespace separates, single or
12
22
  * double quotes group (so a flag inside quoted prompt text stays part of
@@ -1,27 +1,36 @@
1
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.
2
+ * Caller-directed skills allowlist rendering (issue #38), descriptor-driven
3
+ * (RFC-02 change 2). The delegating agent picks the subset from its own
4
+ * registry; the CLI supplies the resolved picks and the registry's known
5
+ * names; this module turns them into per-harness argv tokens by reading
6
+ * the descriptor's `skills` field:
5
7
  *
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: per-skill disable via config-kv array `-c
14
- * skills.config=[{path="...", enabled=false}]` for every known skill
15
- * except the picks (complement-off, same inversion as claude). Uses
16
- * `path` selector rather than `name` because a skill's frontmatter
17
- * `name` need not equal its directory basename, and `path` is exact.
18
- * The path for skill <n> under root <root> is <root>/<n>/SKILL.md.
19
- * No global `skills.enabled` switch exists. Requires the known set
20
- * and the resolved picks (root derived from picks via dirname).
21
- * - muse: refuse (structural) with the standard hint shape.
8
+ * - a load flag (pi): the discovery.skills facet off, then one load per
9
+ * pick - the allowlist is exact, only the caller's picks load.
10
+ * - overrides via settings (claude): no per-skill load flag; the registry
11
+ * is already present, so the allowlist renders as the complement OFF -
12
+ * `--settings '{"skillOverrides":{"<name>":"off",...}}'` for every known
13
+ * skill except the picks.
14
+ * - overrides via a config array (codex): the complement OFF through
15
+ * `-c skills.config=[{path="...", enabled=false}]`. Uses the `path`
16
+ * selector because a skill's frontmatter `name` need not equal its
17
+ * directory basename. The path for skill <n> under root <root> is
18
+ * <root>/<n>/SKILL.md, the root taken from the picks (all share one).
19
+ * - null (muse): refuse, with the support list derived like every other
20
+ * refusal's.
22
21
  */
23
22
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
23
+ import { tokensFor } from "../knowledge/descriptor.js";
24
+ import { defaultDescriptors } from "../knowledge/overrides.js";
24
25
  import { ArgvRefusalError } from "./refusal.js";
26
+ import { supportedBy } from "./support.js";
27
+
28
+ /** The skills turn option: the caller's resolved picks (absolute paths)
29
+ * and every skill name in the registry they came from. */
30
+ export interface SkillsSelection {
31
+ readonly picks: readonly string[];
32
+ readonly known: readonly string[];
33
+ }
25
34
 
26
35
  export const basenameOf = (p: string): string => {
27
36
  const i = p.lastIndexOf("/");
@@ -33,11 +42,32 @@ const dirnameOf = (p: string): string => {
33
42
  return i === -1 ? "" : p.slice(0, i);
34
43
  };
35
44
 
45
+ /** The known names that are not picks, in registry order. */
46
+ const complementOf = (skills: SkillsSelection): readonly string[] => {
47
+ const picks = new Set(skills.picks.map(basenameOf));
48
+ return skills.known.filter((name) => !picks.has(name));
49
+ };
50
+
51
+ const settingsOverrides = (skills: SkillsSelection): readonly string[] => {
52
+ const offs: Record<string, string> = {};
53
+ for (const name of complementOf(skills)) offs[name] = "off";
54
+ return ["--settings", JSON.stringify({ skillOverrides: offs })];
55
+ };
56
+
57
+ const configSkillsArray = (skills: SkillsSelection): readonly string[] => {
58
+ const root = skills.picks.length > 0 ? dirnameOf(skills.picks[0] as string) : "";
59
+ const entries = complementOf(skills).map((name) => {
60
+ const path = root ? `${root}/${name}/SKILL.md` : `${name}/SKILL.md`;
61
+ return `{path=${JSON.stringify(path)}, enabled=false}`;
62
+ });
63
+ return entries.length === 0 ? [] : ["-c", `skills.config=[${entries.join(", ")}]`];
64
+ };
65
+
36
66
  export const renderSkillsSelection = (
37
67
  h: HarnessDescriptor,
38
- skills: readonly string[],
68
+ skills: SkillsSelection,
39
69
  ): readonly string[] => {
40
- if (skills.length === 0) return [];
70
+ if (skills.picks.length === 0) return [];
41
71
 
42
72
  if (h.skills === null) {
43
73
  throw new ArgvRefusalError({
@@ -45,67 +75,33 @@ export const renderSkillsSelection = (
45
75
  harness: h.name,
46
76
  option: "skills",
47
77
  supported: ["caller-directed skill sets"],
48
- supportedBy: [
49
- { harness: "pi", spelling: "--skill" },
50
- { harness: "claude", spelling: "skillOverrides" },
51
- { harness: "codex", spelling: "-c skills.config" },
52
- ],
78
+ supportedBy: supportedBy(defaultDescriptors(), "skills"),
53
79
  hint: "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",
54
80
  });
55
81
  }
56
82
 
57
83
  if (h.skills.loadFlag !== null) {
58
- // pi: discovery off so ONLY the picks load.
59
- const tokens: string[] = ["-ns"];
60
- for (const path of skills) tokens.push(h.skills.loadFlag, path);
84
+ // Discovery off so ONLY the picks load; the off spelling is the
85
+ // descriptor's own discovery.skills facet render.
86
+ const facet =
87
+ h.turnOptions.discovery?.kind === "discovery"
88
+ ? h.turnOptions.discovery.facets.skills
89
+ : undefined;
90
+ const tokens: string[] = facet === undefined ? [] : [...tokensFor(facet.render)];
91
+ for (const path of skills.picks) tokens.push(h.skills.loadFlag, path);
61
92
  return tokens;
62
93
  }
63
94
 
64
- // claude and codex: complement-off via CLI layer (settings JSON / config
65
- // array). The descriptor-level render returns [] and tokens append in
66
- // stream-turn / CLI.
67
- return [];
68
- };
69
-
70
- /** The claude complement form, given the full known registry: every known
71
- * name except the picks gets "off". Exported for the CLI layer, which owns
72
- * the registry listing (an fs read - never in interpretation). */
73
- export const claudeSkillOverridesArg = (
74
- knownSkills: readonly string[],
75
- pickedPaths: readonly string[],
76
- ): string[] => {
77
- const picks = new Set(pickedPaths.map(basenameOf));
78
- const offs: Record<string, string> = {};
79
- for (const name of knownSkills) {
80
- if (!picks.has(name)) offs[name] = "off";
81
- }
82
- const json = JSON.stringify({ skillOverrides: offs });
83
- return ["--settings", json];
84
- };
85
-
86
- /** Codex complement form: every known skill except the picks gets
87
- * `{path="<root>/<name>/SKILL.md>", enabled=false}` via `-c
88
- * skills.config=[...]`. Uses `path` (exact) over `name` because
89
- * frontmatter name may diverge from directory basename. Root is derived
90
- * from the picks' dirname (all picks share the same root); if picks is
91
- * empty the complement cannot be rooted and we return [] (caller picks
92
- * nothing - no integration point needs this, and the CLI layer never
93
- * calls with empty picks). Empty complement returns [] (no flag). */
94
- export const codexSkillConfigArg = (
95
- knownSkills: readonly string[],
96
- pickedPaths: readonly string[],
97
- ): string[] => {
98
- const picks = new Set(pickedPaths.map(basenameOf));
99
- // Derive root from first pick's dirname; all picks are under same root
100
- // (skills-root guarantees this). Fall back to "" if no picks.
101
- const root = pickedPaths.length > 0 ? dirnameOf(pickedPaths[0] as string) : "";
102
- const entries: string[] = [];
103
- for (const name of knownSkills) {
104
- if (!picks.has(name)) {
105
- const absPath = root ? `${root}/${name}/SKILL.md` : `${name}/SKILL.md`;
106
- entries.push(`{path=${JSON.stringify(absPath)}, enabled=false}`);
95
+ switch (h.skills.overridesVia) {
96
+ case "settings-skilloverrides":
97
+ return settingsOverrides(skills);
98
+ case "config-skills-array":
99
+ return configSkillsArray(skills);
100
+ case null:
101
+ return [];
102
+ default: {
103
+ const exhaustive: never = h.skills.overridesVia;
104
+ return exhaustive;
107
105
  }
108
106
  }
109
- if (entries.length === 0) return [];
110
- return ["-c", `skills.config=[${entries.join(", ")}]`];
111
107
  };
@@ -27,21 +27,34 @@ const spellingOf = (h: HarnessDescriptor, option: RefusalOption): string | null
27
27
  switch (option) {
28
28
  case "access": {
29
29
  const spec = h.turnOptions.access;
30
- if (spec === undefined) return null;
31
- if (spec.kind === "flag-value") return spec.flag;
32
- if (spec.kind === "flag-list-by-value") return Object.values(spec.flags)[0]?.[0] ?? null;
33
- if (spec.kind === "tool-preset")
34
- return h.tools.includeFlag ?? h.tools.excludeFlag ?? "--sandbox";
35
- return null;
30
+ if (spec === undefined || spec.kind !== "access") return null;
31
+ const read = spec.renders.read;
32
+ if (read === "tool-preset") return h.tools.includeFlag ?? h.tools.excludeFlag ?? null;
33
+ if (read === null) return null;
34
+ return read.render.kind === "flag-list" ? (read.render.flags[0] ?? null) : read.render.flag;
36
35
  }
37
36
  case "tools":
38
37
  return h.tools.includeFlag;
39
38
  case "excludeTools":
40
39
  return h.tools.excludeFlag;
40
+ case "skills": {
41
+ if (h.skills === null) return null;
42
+ if (h.skills.loadFlag !== null) return h.skills.loadFlag;
43
+ switch (h.skills.overridesVia) {
44
+ case "settings-skilloverrides":
45
+ return "skillOverrides";
46
+ case "config-skills-array":
47
+ return "-c skills.config";
48
+ default:
49
+ return null;
50
+ }
51
+ }
41
52
  case "autonomy":
42
53
  return h.autonomy?.flag ?? null;
54
+ case "isolation":
43
55
  case "effort":
44
56
  case "sandbox":
57
+ case "contextWindow":
45
58
  case "provider":
46
59
  case "write":
47
60
  case "shell":
@@ -112,8 +125,10 @@ export const recognizeNativeSpelling = (
112
125
  "tools",
113
126
  "excludeTools",
114
127
  "autonomy",
128
+ "isolation",
115
129
  "effort",
116
130
  "sandbox",
131
+ "contextWindow",
117
132
  "provider",
118
133
  "write",
119
134
  "shell",