@dungle-scrubs/harness-cli-normalizer 0.5.5 → 0.5.7

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 (99) hide show
  1. package/README.md +3 -2
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +16 -8
  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 +11 -4
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/help.d.ts +3 -3
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +32 -23
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/cli/inspect.d.ts.map +1 -1
  13. package/dist/cli/inspect.js +10 -7
  14. package/dist/cli/inspect.js.map +1 -1
  15. package/dist/cli/resume-guard.d.ts.map +1 -1
  16. package/dist/cli/resume-guard.js +59 -2
  17. package/dist/cli/resume-guard.js.map +1 -1
  18. package/dist/cli/run.d.ts.map +1 -1
  19. package/dist/cli/run.js +31 -32
  20. package/dist/cli/run.js.map +1 -1
  21. package/dist/cli/session-json.d.ts +5 -2
  22. package/dist/cli/session-json.d.ts.map +1 -1
  23. package/dist/cli/session-json.js +5 -3
  24. package/dist/cli/session-json.js.map +1 -1
  25. package/dist/cli/session.d.ts.map +1 -1
  26. package/dist/cli/session.js +103 -36
  27. package/dist/cli/session.js.map +1 -1
  28. package/dist/execution/events.d.ts +8 -1
  29. package/dist/execution/events.d.ts.map +1 -1
  30. package/dist/execution/events.js.map +1 -1
  31. package/dist/execution/open-session.d.ts +7 -6
  32. package/dist/execution/open-session.d.ts.map +1 -1
  33. package/dist/execution/open-session.js +88 -59
  34. package/dist/execution/open-session.js.map +1 -1
  35. package/dist/execution/stream-turn.d.ts +4 -7
  36. package/dist/execution/stream-turn.d.ts.map +1 -1
  37. package/dist/execution/stream-turn.js +43 -22
  38. package/dist/execution/stream-turn.js.map +1 -1
  39. package/dist/interpretation/argv.d.ts +9 -5
  40. package/dist/interpretation/argv.d.ts.map +1 -1
  41. package/dist/interpretation/argv.js +2 -7
  42. package/dist/interpretation/argv.js.map +1 -1
  43. package/dist/interpretation/capabilities.d.ts +20 -0
  44. package/dist/interpretation/capabilities.d.ts.map +1 -1
  45. package/dist/interpretation/capabilities.js +27 -0
  46. package/dist/interpretation/capabilities.js.map +1 -1
  47. package/dist/interpretation/hints.js +1 -1
  48. package/dist/interpretation/hints.js.map +1 -1
  49. package/dist/interpretation/question.d.ts +6 -2
  50. package/dist/interpretation/question.d.ts.map +1 -1
  51. package/dist/interpretation/question.js +13 -4
  52. package/dist/interpretation/question.js.map +1 -1
  53. package/dist/interpretation/refusal.d.ts +1 -1
  54. package/dist/interpretation/refusal.d.ts.map +1 -1
  55. package/dist/interpretation/refusal.js +1 -1
  56. package/dist/interpretation/refusal.js.map +1 -1
  57. package/dist/interpretation/skills-selection.d.ts +18 -1
  58. package/dist/interpretation/skills-selection.d.ts.map +1 -1
  59. package/dist/interpretation/skills-selection.js +33 -6
  60. package/dist/interpretation/skills-selection.js.map +1 -1
  61. package/dist/knowledge/claude-code.d.ts.map +1 -1
  62. package/dist/knowledge/claude-code.js +21 -0
  63. package/dist/knowledge/claude-code.js.map +1 -1
  64. package/dist/knowledge/codex.d.ts.map +1 -1
  65. package/dist/knowledge/codex.js +26 -7
  66. package/dist/knowledge/codex.js.map +1 -1
  67. package/dist/knowledge/descriptor.d.ts +39 -12
  68. package/dist/knowledge/descriptor.d.ts.map +1 -1
  69. package/dist/knowledge/descriptor.js +1 -0
  70. package/dist/knowledge/descriptor.js.map +1 -1
  71. package/dist/knowledge/muse.d.ts.map +1 -1
  72. package/dist/knowledge/muse.js +7 -0
  73. package/dist/knowledge/muse.js.map +1 -1
  74. package/dist/knowledge/pi.d.ts.map +1 -1
  75. package/dist/knowledge/pi.js +23 -4
  76. package/dist/knowledge/pi.js.map +1 -1
  77. package/package.json +3 -2
  78. package/src/cli/args.ts +16 -6
  79. package/src/cli/config.ts +13 -4
  80. package/src/cli/help.ts +32 -23
  81. package/src/cli/inspect.ts +8 -6
  82. package/src/cli/resume-guard.ts +54 -2
  83. package/src/cli/run.ts +32 -36
  84. package/src/cli/session-json.ts +11 -5
  85. package/src/cli/session.ts +122 -40
  86. package/src/execution/events.ts +9 -1
  87. package/src/execution/open-session.ts +99 -72
  88. package/src/execution/stream-turn.ts +53 -30
  89. package/src/interpretation/argv.ts +11 -12
  90. package/src/interpretation/capabilities.ts +50 -0
  91. package/src/interpretation/hints.ts +1 -1
  92. package/src/interpretation/question.ts +17 -7
  93. package/src/interpretation/refusal.ts +2 -1
  94. package/src/interpretation/skills-selection.ts +46 -8
  95. package/src/knowledge/claude-code.ts +21 -0
  96. package/src/knowledge/codex.ts +26 -7
  97. package/src/knowledge/descriptor.ts +40 -12
  98. package/src/knowledge/muse.ts +7 -0
  99. package/src/knowledge/pi.ts +23 -4
package/src/cli/help.ts CHANGED
@@ -48,12 +48,12 @@ Options:
48
48
  --no-write Disable write
49
49
  --shell Enable shell (muse)
50
50
  --no-shell Disable shell
51
- --escalate-questions Let the worker ask the caller's user when a
52
- genuine decision blocks progress (DEFAULT;
53
- prompt-preamble transport, question event +
54
- done cause "awaiting-input", exit 0)
55
- --no-escalate-questions Worker never asks: it states the assumption it
56
- proceeded under and continues
51
+ --questions <ask|assume|none>
52
+ Which preamble to inject: ask = escalation
53
+ protocol (DEFAULT, question event +
54
+ done cause "awaiting-input", exit 0), assume =
55
+ never ask, state assumption, none = inject
56
+ nothing
57
57
  --system-prompt <text> Replace the built-in system prompt (claude, pi:
58
58
  flag; codex: -c instructions=<text-or-path>;
59
59
  muse refuses. claude pairs the dynamic-section
@@ -78,12 +78,15 @@ Options:
78
78
  --no-skills Disable skills discovery facet
79
79
  --cwd <path> Working directory for spawn
80
80
  --env KEY=VAL Environment (repeatable; KEY= deletes)
81
- --resume <uuid> Resume session id (UUID). The answer path for
82
- question escalation: resume with the chosen
83
- answer as the prompt; id continuity per
81
+ --resume <uuid> Resume session id (UUID). Continues the
82
+ conversation where it left off. The answer
83
+ path for question escalation: resume with the
84
+ chosen answer as the prompt; id continuity per
84
85
  harness (claude stable, pi/muse caller-assigned,
85
86
  codex minted via identity event)
86
- Note: pi and muse create a new session when the id is unknown - verify it exists
87
+ Note: hcn refuses an unknown id before spawn
88
+ (exit 2) for harnesses that would otherwise
89
+ create a fresh session silently (pi, muse)
87
90
  --session-id <uuid> Alias for --resume (mutually exclusive with --resume)
88
91
  -- Passthrough: native harness args verbatim
89
92
  (failures surface as labeled native errors)
@@ -102,9 +105,8 @@ Defaults with no flags:
102
105
  profile). toolMap is config-only (no flag) - canonical -> native
103
106
  mapping per harness. A bare pi run renders no --tools list: pi's list
104
107
  is a strict allowlist and would drop extension and MCP tools; name
105
- them through toolMap or native:<name> when you grant. Question
106
- escalation defaults ON (config key
107
- "escalateQuestions"; it is a prompt preamble, never a harness flag).
108
+ them through toolMap or native:<name> when you grant. Question mode defaults to ask (config keys
109
+ "questions"; prompt preamble, never a harness flag).
108
110
  Provenance prints to stderr on every run; see
109
111
  'hcn inspect <harness>' for the resolved argv of a bare run.
110
112
  `;
@@ -120,8 +122,9 @@ Options:
120
122
  --json Machine surface: NDJSON events on stdout, NDJSON
121
123
  commands on stdin ({"op":"send","id":..,"text":..},
122
124
  "answer", "close"). Every send is answered with one
123
- disposition (started | queued | rejected); a queued
124
- send's id rides to the turn that consumes it. The
125
+ disposition (started | rejected); every send is
126
+ handed to the harness, and the turn that consumes it
127
+ carries its id. The
125
128
  stream opens with a session event and ends with a
126
129
  closed event. Exit 0 clean, 1 otherwise, 2 refusal.
127
130
  --stall <seconds> Per-turn inactivity budget; the turn ends and the
@@ -129,12 +132,18 @@ Options:
129
132
  (default: no limit)
130
133
  --provider <value> Provider (pi only)
131
134
  --model <id> Model for the session
132
- --session-id <uuid> Session id (UUID, else random; re-enters an
133
- existing session)
134
- --escalate-questions Worker may ask; question renders as a pickable
135
- menu, the answer flows back into the SAME live
136
- session (DEFAULT)
137
- --no-escalate-questions Worker never asks; states its assumption
135
+ --resume <uuid> Resume session id (UUID). Continues the
136
+ conversation where it left off. --session-id
137
+ is an alias (mutually exclusive with --resume).
138
+ If no id is given, a fresh session is started
139
+ with a new id. hcn refuses an unknown id before
140
+ spawn (exit 2) for harnesses that would otherwise
141
+ create a fresh session silently (pi)
142
+ --session-id <uuid> Alias for --resume (mutually exclusive with --resume)
143
+ --questions <ask|assume|none>
144
+ Which preamble to inject: ask = worker may
145
+ ask (DEFAULT, pickable menu), assume = never
146
+ ask, none = inject nothing
138
147
  --cwd <path> Working directory
139
148
  --help Show help
140
149
  --version Show version
@@ -167,11 +176,11 @@ Options:
167
176
  --autonomy / --no-autonomy
168
177
  --write / --no-write
169
178
  --shell / --no-shell
170
- --escalate-questions / --no-escalate-questions
179
+ --questions <ask|assume|none>
171
180
  (accepted; renders nothing - rides the run prompt)
172
181
  --max-steps <n>
173
182
  --no-tools, --no-instruction-files, --no-extensions, --no-skills
174
- --escalate-questions / --no-escalate-questions
183
+ --questions <ask|assume|none>
175
184
  Accepted; renders nothing in argv (the mode
176
185
  rides the run prompt, not a harness flag)
177
186
  --cwd <path>
@@ -210,13 +210,16 @@ export const inspect = async (harnessName: string, rawArgs: string[]): Promise<v
210
210
  try {
211
211
  const { resolveSkillNames, listKnownSkills } = await import("./skills-root.js");
212
212
  const resolvedSkills = resolveSkillNames(rawSkills, h.name);
213
- const claudeTokens: string[] = [];
213
+ const skillTokens: string[] = [];
214
214
  if (h.name === "claude") {
215
215
  const { claudeSkillOverridesArg } = await import("../interpretation/skills-selection.js");
216
- claudeTokens.push(...claudeSkillOverridesArg(listKnownSkills(), resolvedSkills));
216
+ skillTokens.push(...claudeSkillOverridesArg(listKnownSkills(), resolvedSkills));
217
+ } else if (h.name === "codex") {
218
+ const { codexSkillConfigArg } = await import("../interpretation/skills-selection.js");
219
+ skillTokens.push(...codexSkillConfigArg(listKnownSkills(), resolvedSkills));
217
220
  }
218
221
  (turnOpts as unknown as Record<string, unknown>).skills = resolvedSkills;
219
- (turnOpts as unknown as Record<string, unknown>).__claudeSkillTokens = claudeTokens;
222
+ (turnOpts as unknown as Record<string, unknown>).__skillTokens = skillTokens;
220
223
  } catch (err) {
221
224
  if (err instanceof ArgvRefusalError) {
222
225
  process.stderr.write(`${err.message}\n`);
@@ -301,9 +304,8 @@ export const inspect = async (harnessName: string, rawArgs: string[]): Promise<v
301
304
  let argv: string[];
302
305
  try {
303
306
  argv = buildLaunchArgv(h, fullOpts);
304
- const claudeTokens = (effectiveRest as unknown as { __claudeSkillTokens?: string[] })
305
- .__claudeSkillTokens;
306
- if (claudeTokens !== undefined && claudeTokens.length > 0) argv.push(...claudeTokens);
307
+ const skillTokens = (effectiveRest as unknown as { __skillTokens?: string[] }).__skillTokens;
308
+ if (skillTokens !== undefined && skillTokens.length > 0) argv.push(...skillTokens);
307
309
  } catch (err) {
308
310
  if (err instanceof ArgvRefusalError) {
309
311
  refuse(refusalOf(err as ArgvRefusalError), false);
@@ -1,4 +1,5 @@
1
- import { existsSync, realpathSync } from "node:fs";
1
+ import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
2
3
  import { storePath } from "../interpretation/store.js";
3
4
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
4
5
 
@@ -31,5 +32,56 @@ export const resumeStore = (
31
32
  } catch {
32
33
  path = null;
33
34
  }
34
- return { path, exists: path !== null && existsSync(path) };
35
+ return { path, exists: path !== null && sessionExistsAt(path, opts.sessionId) };
36
+ };
37
+
38
+ /** A store template that resolves to a FILE names the session directly and
39
+ * existence is the file's. One that resolves to a DIRECTORY names where the
40
+ * harness files every session, and the session is an entry somewhere under it
41
+ * whose name carries the id - not at a position a template can express:
42
+ *
43
+ * pi <root>/<cwdSlug>/<timestamp>_<id>.jsonl one level down
44
+ * muse <root>/YYYY/MM/DD/<id>/session.jsonl three levels down
45
+ *
46
+ * The first version of this search looked one level only, written against
47
+ * pi, and refused every muse resume (#103) for a session that was right
48
+ * there. It now walks the tree, bounded: the id's own directory is a match
49
+ * and is not descended, and depth is capped so a pathological store cannot
50
+ * turn a pre-spawn check into a crawl. */
51
+ const MAX_STORE_DEPTH = 4;
52
+
53
+ const sessionExistsAt = (path: string, sessionId: string): boolean => {
54
+ if (!existsSync(path)) return false;
55
+ let isDir = false;
56
+ try {
57
+ isDir = statSync(path).isDirectory();
58
+ } catch {
59
+ return false;
60
+ }
61
+ if (!isDir) return true;
62
+ return containsSessionId(path, sessionId, 0);
63
+ };
64
+
65
+ const containsSessionId = (dir: string, sessionId: string, depth: number): boolean => {
66
+ if (depth > MAX_STORE_DEPTH) return false;
67
+ let entries: string[];
68
+ try {
69
+ entries = readdirSync(dir);
70
+ } catch {
71
+ return false;
72
+ }
73
+ for (const name of entries) {
74
+ if (name.includes(sessionId)) return true;
75
+ }
76
+ for (const name of entries) {
77
+ const child = join(dir, name);
78
+ let childIsDir = false;
79
+ try {
80
+ childIsDir = statSync(child).isDirectory();
81
+ } catch {
82
+ continue;
83
+ }
84
+ if (childIsDir && containsSessionId(child, sessionId, depth + 1)) return true;
85
+ }
86
+ return false;
35
87
  };
package/src/cli/run.ts CHANGED
@@ -185,13 +185,16 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
185
185
  try {
186
186
  const { resolveSkillNames, listKnownSkills } = await import("./skills-root.js");
187
187
  const resolvedSkills = resolveSkillNames(rawSkills, h.name);
188
- const claudeTokens: string[] = [];
188
+ const skillTokens: string[] = [];
189
189
  if (h.name === "claude") {
190
190
  const { claudeSkillOverridesArg } = await import("../interpretation/skills-selection.js");
191
- claudeTokens.push(...claudeSkillOverridesArg(listKnownSkills(), resolvedSkills));
191
+ skillTokens.push(...claudeSkillOverridesArg(listKnownSkills(), resolvedSkills));
192
+ } else if (h.name === "codex") {
193
+ const { codexSkillConfigArg } = await import("../interpretation/skills-selection.js");
194
+ skillTokens.push(...codexSkillConfigArg(listKnownSkills(), resolvedSkills));
192
195
  }
193
196
  (turnOpts as unknown as Record<string, unknown>).skills = resolvedSkills;
194
- (turnOpts as unknown as Record<string, unknown>).__claudeSkillTokens = claudeTokens;
197
+ (turnOpts as unknown as Record<string, unknown>).__skillTokens = skillTokens;
195
198
  } catch (err) {
196
199
  if (err instanceof ArgvRefusalError) {
197
200
  refuse(refusalOf(err), wantJson);
@@ -209,9 +212,9 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
209
212
  project?: Partial<ReturnType<typeof parseTurnOptions>>;
210
213
  } = {};
211
214
  // Config files load on EVERY run, launch or resume: the tiers feed the
212
- // defaults profile on launch, and issue #41's escalateQuestions (a
215
+ // defaults profile on launch, and issue #41's questions (a
213
216
  // behavior instruction, not a turn option) resolves from them on resume
214
- // too - otherwise a no-escalate session would flip its preamble on the
217
+ // too - otherwise a no-ask session would flip its preamble on the
215
218
  // answer turn. Resolution of TURN options stays launch-only.
216
219
  const tiers = resolvedTiers;
217
220
  {
@@ -256,39 +259,34 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
256
259
  writeProvenance(h.name, provenance, unrenderable);
257
260
  }
258
261
 
259
- // issue #41: question-escalation precedence arg > project > user >
260
- // default-true (a behavior instruction, not a turn option - the
261
- // default lives OUTSIDE the profile on purpose, per the spec). It
262
- // applies on LAUNCH AND RESUME alike: it shapes each turn's prompt
263
- // preamble and event stream, never a session setting.
264
- const projectEscalate = (resolvedTiers.project as { escalateQuestions?: boolean } | undefined)
265
- ?.escalateQuestions;
266
- const userEscalate = (resolvedTiers.user as { escalateQuestions?: boolean } | undefined)
267
- ?.escalateQuestions;
268
- const escalateQuestions =
269
- turnOpts.escalateQuestions !== undefined
270
- ? turnOpts.escalateQuestions
271
- : projectEscalate !== undefined
272
- ? projectEscalate
273
- : userEscalate !== undefined
274
- ? userEscalate
275
- : true;
276
- const escalateTier =
277
- turnOpts.escalateQuestions !== undefined
262
+ // question mode precedence arg > project > user > default (ask)
263
+ const projectQuestions = (resolvedTiers.project as { questions?: string } | undefined)?.questions;
264
+ const userQuestions = (resolvedTiers.user as { questions?: string } | undefined)?.questions;
265
+ const rawMode = (turnOpts as { questions?: string }).questions;
266
+ const questionMode =
267
+ rawMode !== undefined
268
+ ? (rawMode as import("../interpretation/question.js").QuestionMode)
269
+ : projectQuestions !== undefined
270
+ ? (projectQuestions as import("../interpretation/question.js").QuestionMode)
271
+ : userQuestions !== undefined
272
+ ? (userQuestions as import("../interpretation/question.js").QuestionMode)
273
+ : ("ask" as const);
274
+ const questionTier =
275
+ rawMode !== undefined
278
276
  ? "arg"
279
- : projectEscalate !== undefined
277
+ : projectQuestions !== undefined
280
278
  ? "project-config"
281
- : userEscalate !== undefined
279
+ : userQuestions !== undefined
282
280
  ? "user-config"
283
281
  : "default";
284
282
 
285
283
  const fullOpts = {
286
284
  ...effectiveTurnOpts,
287
- prompt: composeEscalatedPrompt(prompt, escalateQuestions),
285
+ prompt: composeEscalatedPrompt(prompt, questionMode),
288
286
  cwd: extra.cwd,
289
287
  env: extra.env,
290
288
  resume: extra.resume,
291
- escalateQuestions,
289
+ questions: questionMode,
292
290
  ...(passthrough.length > 0 ? { passthrough } : {}),
293
291
  ...(isExplicit ? { __explicitPrompt: true as const } : {}),
294
292
  } as Parameters<typeof streamTurn>[1] & {
@@ -304,7 +302,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
304
302
  if (fullOpts.resume) {
305
303
  // Resume never carries TURN-option profile resolution (launch-only
306
304
  // rule), so it builds from the raw turn options; hcn-owned behavior
307
- // (escalateQuestions preamble, timeout budget) still applies.
305
+ // (questions preamble, timeout budget) still applies.
308
306
  preArgv = buildResumeArgv(h, {
309
307
  ...(turnOpts as object),
310
308
  prompt: fullOpts.prompt,
@@ -321,10 +319,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
321
319
  prompt: fullOpts.prompt,
322
320
  __explicitPrompt: isExplicit,
323
321
  } as never);
324
- const claudeSkillTokens = (effectiveTurnOpts as unknown as { __claudeSkillTokens?: string[] })
325
- .__claudeSkillTokens;
326
- if (claudeSkillTokens !== undefined && claudeSkillTokens.length > 0) {
327
- preArgv.push(...claudeSkillTokens);
322
+ const skillTokens = (effectiveTurnOpts as unknown as { __skillTokens?: string[] })
323
+ .__skillTokens;
324
+ if (skillTokens !== undefined && skillTokens.length > 0) {
325
+ preArgv.push(...skillTokens);
328
326
  }
329
327
  }
330
328
  _validated = true;
@@ -349,9 +347,7 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
349
347
  }
350
348
  // issue #41: the escalation mode rides stderr as provenance, like
351
349
  // every other resolution the turn depends on.
352
- process.stderr.write(
353
- `provenance: escalateQuestions = ${escalateQuestions} (${escalateTier})\n`,
354
- );
350
+ process.stderr.write(`provenance: questions = ${questionMode} (${questionTier})\n`);
355
351
  // On a harness whose include flag is not a strict allowlist (claude),
356
352
  // a name outside the curated set passes through ungated; say which
357
353
  // ones so a wrong-case name is visible. A grant with no known name at
@@ -4,7 +4,7 @@
4
4
  * with the control events (`session`, `turn`, `disposition`, `closed`) that
5
5
  * a program needs to drive a session it does not own the timing of. RFC-01.
6
6
  *
7
- * This owns the wire framing only. The turn lifecycle, the queue, and the id
7
+ * This owns the wire framing only. The turn lifecycle and the id
8
8
  * correlation live in `openSession`; this reads the id off the yielded turn
9
9
  * rather than shadowing the runner's delivery order.
10
10
  */
@@ -16,6 +16,7 @@ import {
16
16
  type SessionHandle,
17
17
  type SessionSendResult,
18
18
  } from "../execution/open-session.js";
19
+ import type { QuestionMode } from "../interpretation/question.js";
19
20
 
20
21
  /** What the CLI reads back after a close, captured from the runner's
21
22
  * `session_close` boundary log. */
@@ -24,15 +25,18 @@ export interface CloseInfo {
24
25
  cause: string;
25
26
  }
26
27
 
28
+ export type SessionOrigin = "fresh" | "resumed";
29
+
27
30
  export interface JsonSessionArgs {
28
31
  readonly handle: SessionHandle;
29
32
  readonly sessionId: string;
30
33
  readonly harness: string;
31
34
  readonly hcnVersion: string;
32
- readonly escalateQuestions: boolean;
35
+ readonly questions?: QuestionMode;
36
+ readonly origin: SessionOrigin;
33
37
  /** Read after close - the runner's final exitCode and cause. */
34
38
  readonly getCloseInfo: () => CloseInfo;
35
- /** Read after close - ids the runner accepted as queued and never
39
+ /** Read after close - ids the runner accepted and never
36
40
  * delivered. Each owes the consumer a rejection (RFC S003). */
37
41
  readonly getDroppedIds?: () => readonly string[];
38
42
  /** Injected for tests; defaults to process.stdin / process.stdout. */
@@ -116,12 +120,14 @@ export const runJsonSession = async (a: JsonSessionArgs): Promise<number> => {
116
120
  return chain;
117
121
  };
118
122
 
123
+ const qMode: QuestionMode = a.questions ?? "ask";
119
124
  await emit({
120
125
  kind: "session",
121
126
  sessionId: a.sessionId,
122
127
  harness: a.harness,
123
128
  hcn: a.hcnVersion,
124
- escalateQuestions: a.escalateQuestions,
129
+ questions: qMode,
130
+ origin: a.origin,
125
131
  });
126
132
 
127
133
  // Shared between the pumps: the last question asked, whether the last turn
@@ -242,7 +248,7 @@ export const runJsonSession = async (a: JsonSessionArgs): Promise<number> => {
242
248
  }
243
249
  unanswered.clear();
244
250
 
245
- // Every input the runner accepted as queued and then lost gets its own
251
+ // Every input the runner accepted and then lost gets its own
246
252
  // rejection, before the terminal line (RFC S003).
247
253
  for (const id of a.getDroppedIds?.() ?? []) {
248
254
  await emit({ kind: "disposition", id, disposition: "rejected", reason: "closed" });
@@ -57,45 +57,96 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
57
57
  }
58
58
 
59
59
  const values = parsed.values as Record<string, unknown>;
60
- const sessionId =
61
- (values["session-id"] as string | undefined) ??
62
- (values.resume as string | undefined) ??
63
- randomUUID();
60
+ // --resume and --session-id are aliases for one concept: resume an existing
61
+ // session. Passing both is refused, matching hcn run's parseRunExtra shape
62
+ // (src/cli/args.ts) rather than inventing a second parser.
63
+ if (values.resume !== undefined && values["session-id"] !== undefined) {
64
+ const { ArgvRefusalError: AliasError } = await import("../interpretation/refusal.js");
65
+ const err = new AliasError({
66
+ issue: "mutually-exclusive-options",
67
+ harness: h.name,
68
+ supported: ["--resume or --session-id, not both (--session-id is an alias for --resume)"],
69
+ detail: "both --resume and --session-id given",
70
+ });
71
+ const { refusalOf: aliasRefusalOf, refuse: aliasRefuse } = await import("./refuse.js");
72
+ aliasRefuse(aliasRefusalOf(err), jsonMode, "closed");
73
+ return;
74
+ }
75
+ const resumeId =
76
+ (values.resume as string | undefined) ?? (values["session-id"] as string | undefined);
77
+ // The two flags are aliases for "use this session". Whether that means
78
+ // RESUME is decided by the store, not by which spelling was typed: an id
79
+ // that exists is resumed (resumeFlag rendered), an id that does not exist
80
+ // names a fresh session (idFlag rendered). --resume on an id that does not
81
+ // exist is the one combination refused, below - a caller who said "resume"
82
+ // and would silently get a fresh conversation is issue #86 exactly.
83
+ const explicitResume = values.resume !== undefined;
84
+ // A caller-supplied id must be the shape this harness's store files
85
+ // sessions under (issue #95). hcn run already holds this line through
86
+ // parse-resume; session only checked the safe-filename rule, so "bogus"
87
+ // reached the harness, rode the session and identity events, and on a
88
+ // create-on-unknown harness became a real file nobody can find again.
89
+ if (resumeId !== undefined && !h.resume.idShape.test(resumeId)) {
90
+ const flag = explicitResume ? "--resume" : "--session-id";
91
+ const { refuse: shapeRefuse } = await import("./refuse.js");
92
+ shapeRefuse(
93
+ {
94
+ message: `${flag} ${JSON.stringify(resumeId.slice(0, 64))} is not a ${h.name} session id`,
95
+ issue: "invalid-option-value",
96
+ supported: [`a session id matching ${String(h.resume.idShape)}`],
97
+ },
98
+ jsonMode,
99
+ "closed",
100
+ );
101
+ return;
102
+ }
103
+ const sessionId = resumeId ?? randomUUID();
104
+ let isResume = false;
64
105
  const model = values.model as string | undefined;
65
106
  const cwd = values.cwd as string | undefined;
66
107
  const provider = values.provider as string | undefined;
67
108
 
68
- // issue #44: same precedence as hcn run - arg > project > user >
69
- // default-true. A behavior instruction, so it rides every send's
70
- // preamble, never a harness flag.
71
- const argEscalate =
72
- values["escalate-questions"] === true
73
- ? true
74
- : values["no-escalate-questions"] === true
75
- ? false
76
- : undefined;
77
- let escalateQuestions: boolean;
78
- let escalateTier: "arg" | "project-config" | "user-config" | "default";
109
+ // question mode precedence arg > project > user > default (ask)
110
+ const rawArgMode = values.questions !== undefined ? String(values.questions) : undefined;
111
+ if (rawArgMode !== undefined && !["ask", "assume", "none"].includes(rawArgMode)) {
112
+ const { refuse, refusalOf } = await import("./refuse.js");
113
+ const { ArgvRefusalError } = await import("../interpretation/refusal.js");
114
+ refuse(
115
+ refusalOf(
116
+ new ArgvRefusalError({
117
+ issue: "invalid-option-value",
118
+ harness: h.name,
119
+ option: "questions",
120
+ supported: ["ask", "assume", "none"],
121
+ detail: rawArgMode,
122
+ }),
123
+ ),
124
+ jsonMode,
125
+ "closed",
126
+ );
127
+ return;
128
+ }
129
+ let questionMode: import("../interpretation/question.js").QuestionMode;
130
+ let questionTier: "arg" | "project-config" | "user-config" | "default";
79
131
  try {
80
132
  const { loadUserConfig, loadProjectConfig } = await import("./config.js");
81
- const user = loadUserConfig()?.config as { escalateQuestions?: boolean } | undefined;
82
- const project = loadProjectConfig()?.config as { escalateQuestions?: boolean } | undefined;
83
- escalateQuestions =
84
- argEscalate !== undefined
85
- ? argEscalate
86
- : project?.escalateQuestions !== undefined
87
- ? project.escalateQuestions
88
- : user?.escalateQuestions !== undefined
89
- ? user.escalateQuestions
90
- : true;
91
- escalateTier =
92
- argEscalate !== undefined
93
- ? "arg"
94
- : project?.escalateQuestions !== undefined
95
- ? "project-config"
96
- : user?.escalateQuestions !== undefined
97
- ? "user-config"
98
- : "default";
133
+ const user = loadUserConfig()?.config as { questions?: string } | undefined;
134
+ const project = loadProjectConfig()?.config as { questions?: string } | undefined;
135
+ const userMode = user?.questions;
136
+ const projectMode = project?.questions;
137
+ if (rawArgMode !== undefined) {
138
+ questionMode = rawArgMode as import("../interpretation/question.js").QuestionMode;
139
+ questionTier = "arg";
140
+ } else if (projectMode !== undefined) {
141
+ questionMode = projectMode as import("../interpretation/question.js").QuestionMode;
142
+ questionTier = "project-config";
143
+ } else if (userMode !== undefined) {
144
+ questionMode = userMode as import("../interpretation/question.js").QuestionMode;
145
+ questionTier = "user-config";
146
+ } else {
147
+ questionMode = "ask";
148
+ questionTier = "default";
149
+ }
99
150
  } catch (configErr) {
100
151
  process.stderr.write(`config error: ${(configErr as Error).message}\n`);
101
152
  if (jsonMode) {
@@ -112,9 +163,38 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
112
163
  process.exitCode = 2;
113
164
  return;
114
165
  }
115
- process.stderr.write(`provenance: escalateQuestions = ${escalateQuestions} (${escalateTier})\n`);
166
+ process.stderr.write(`provenance: questions = ${questionMode} (${questionTier})\n`);
116
167
 
117
168
  // Validate sessionId shape? let openSession handle via assertUsableSessionId
169
+ // Unknown-id refusal reuses the run resume guard (src/cli/resume-guard.ts):
170
+ // harnesses that create on unknown (pi, muse with onMissing === "create")
171
+ // are refused before spawn with the same message shape hcn run uses.
172
+ // claude (onMissing === "error") refuses on its own; no store check.
173
+ if (resumeId !== undefined) {
174
+ const { resumeStore: checkResumeStore } = await import("./resume-guard.js");
175
+ const { path: storePath, exists } = checkResumeStore(h, {
176
+ home: process.env.HOME ?? process.env.USERPROFILE ?? "",
177
+ cwd: cwd ?? process.cwd(),
178
+ sessionId,
179
+ });
180
+ // Exists in the store: resume it, whichever alias was typed. Absent and the
181
+ // caller only named an id: a fresh session under that id, as before.
182
+ isResume = storePath !== null && exists;
183
+ if (explicitResume && storePath !== null && !exists) {
184
+ const { refuse: guardRefuse } = await import("./refuse.js");
185
+ guardRefuse(
186
+ {
187
+ message: `no ${h.name} session ${sessionId} found at ${storePath}`,
188
+ issue: "invalid-option-value",
189
+ supported: [`a session id that exists in ${h.name}'s store`],
190
+ },
191
+ jsonMode,
192
+ "closed",
193
+ );
194
+ return;
195
+ }
196
+ }
197
+
118
198
  delete (process.env as Record<string, string | undefined>).HERDR_ENV;
119
199
 
120
200
  const wantJson = values.json === true;
@@ -164,7 +244,11 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
164
244
 
165
245
  let handle: ReturnType<typeof openSession>;
166
246
  try {
167
- handle = openSession(h, { sessionId, model, cwd, escalateQuestions, provider }, deps);
247
+ handle = openSession(
248
+ h,
249
+ { sessionId, model, cwd, questions: questionMode, provider, isResume },
250
+ deps,
251
+ );
168
252
  } catch (err) {
169
253
  if (err instanceof ArgvRefusalError) {
170
254
  const { refusalOf, refuse } = await import("./refuse.js");
@@ -196,7 +280,8 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
196
280
  sessionId,
197
281
  harness: h.name,
198
282
  hcnVersion: getVersion(),
199
- escalateQuestions,
283
+ questions: questionMode,
284
+ origin: isResume ? "resumed" : "fresh",
200
285
  getCloseInfo: () => closeInfo,
201
286
  getDroppedIds: () => droppedIds,
202
287
  });
@@ -258,10 +343,7 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
258
343
  const trimmed = line.trim();
259
344
  if (trimmed === "" || trimmed === "exit") break;
260
345
 
261
- const result = handle.send({ id: `you-${++sendCount}`, text: line });
262
- if (result.disposition === "queued") {
263
- process.stderr.write(`disposition: queued (turn in progress)\n`);
264
- }
346
+ handle.send({ id: `you-${++sendCount}`, text: line });
265
347
 
266
348
  const turn = (await turns.next()).value as
267
349
  | AsyncIterable<import("../execution/events.js").HarnessEvent>
@@ -25,10 +25,17 @@ export type ExitCause =
25
25
  | "stall"
26
26
  | "killed"
27
27
  | "failed"
28
- /** issue #41: the turn ended by asking (escalateQuestions) - a
28
+ /** issue #41: the turn ended by asking (questions ask) - a
29
29
  * SUCCESSFUL turn (process exit 0); the caller resumes with the answer. */
30
30
  | "awaiting-input";
31
31
 
32
+ export type EscalationMode = "ask" | "assume" | "none";
33
+ export type EscalationDetection = "block" | "malformed" | "none";
34
+ export interface EscalationRecord {
35
+ readonly mode: EscalationMode;
36
+ readonly detection: EscalationDetection;
37
+ }
38
+
32
39
  export type HarnessEvent =
33
40
  | {
34
41
  readonly kind: "identity";
@@ -60,6 +67,7 @@ export type HarnessEvent =
60
67
  readonly exitCode: number | null;
61
68
  readonly cause: ExitCause;
62
69
  readonly failure?: FailureSummary;
70
+ readonly escalation: EscalationRecord;
63
71
  };
64
72
 
65
73
  export const DROPPABLE_KINDS = new Set(["token", "progress", "context"]);