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

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 (98) 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.js +29 -2
  16. package/dist/cli/resume-guard.js.map +1 -1
  17. package/dist/cli/run.d.ts.map +1 -1
  18. package/dist/cli/run.js +31 -32
  19. package/dist/cli/run.js.map +1 -1
  20. package/dist/cli/session-json.d.ts +5 -2
  21. package/dist/cli/session-json.d.ts.map +1 -1
  22. package/dist/cli/session-json.js +5 -3
  23. package/dist/cli/session-json.js.map +1 -1
  24. package/dist/cli/session.d.ts.map +1 -1
  25. package/dist/cli/session.js +103 -36
  26. package/dist/cli/session.js.map +1 -1
  27. package/dist/execution/events.d.ts +8 -1
  28. package/dist/execution/events.d.ts.map +1 -1
  29. package/dist/execution/events.js.map +1 -1
  30. package/dist/execution/open-session.d.ts +7 -6
  31. package/dist/execution/open-session.d.ts.map +1 -1
  32. package/dist/execution/open-session.js +88 -59
  33. package/dist/execution/open-session.js.map +1 -1
  34. package/dist/execution/stream-turn.d.ts +4 -7
  35. package/dist/execution/stream-turn.d.ts.map +1 -1
  36. package/dist/execution/stream-turn.js +43 -22
  37. package/dist/execution/stream-turn.js.map +1 -1
  38. package/dist/interpretation/argv.d.ts +9 -5
  39. package/dist/interpretation/argv.d.ts.map +1 -1
  40. package/dist/interpretation/argv.js +2 -7
  41. package/dist/interpretation/argv.js.map +1 -1
  42. package/dist/interpretation/capabilities.d.ts +20 -0
  43. package/dist/interpretation/capabilities.d.ts.map +1 -1
  44. package/dist/interpretation/capabilities.js +27 -0
  45. package/dist/interpretation/capabilities.js.map +1 -1
  46. package/dist/interpretation/hints.js +1 -1
  47. package/dist/interpretation/hints.js.map +1 -1
  48. package/dist/interpretation/question.d.ts +6 -2
  49. package/dist/interpretation/question.d.ts.map +1 -1
  50. package/dist/interpretation/question.js +13 -4
  51. package/dist/interpretation/question.js.map +1 -1
  52. package/dist/interpretation/refusal.d.ts +1 -1
  53. package/dist/interpretation/refusal.d.ts.map +1 -1
  54. package/dist/interpretation/refusal.js +1 -1
  55. package/dist/interpretation/refusal.js.map +1 -1
  56. package/dist/interpretation/skills-selection.d.ts +18 -1
  57. package/dist/interpretation/skills-selection.d.ts.map +1 -1
  58. package/dist/interpretation/skills-selection.js +33 -6
  59. package/dist/interpretation/skills-selection.js.map +1 -1
  60. package/dist/knowledge/claude-code.d.ts.map +1 -1
  61. package/dist/knowledge/claude-code.js +21 -0
  62. package/dist/knowledge/claude-code.js.map +1 -1
  63. package/dist/knowledge/codex.d.ts.map +1 -1
  64. package/dist/knowledge/codex.js +16 -6
  65. package/dist/knowledge/codex.js.map +1 -1
  66. package/dist/knowledge/descriptor.d.ts +39 -12
  67. package/dist/knowledge/descriptor.d.ts.map +1 -1
  68. package/dist/knowledge/descriptor.js +1 -0
  69. package/dist/knowledge/descriptor.js.map +1 -1
  70. package/dist/knowledge/muse.d.ts.map +1 -1
  71. package/dist/knowledge/muse.js +7 -0
  72. package/dist/knowledge/muse.js.map +1 -1
  73. package/dist/knowledge/pi.d.ts.map +1 -1
  74. package/dist/knowledge/pi.js +23 -4
  75. package/dist/knowledge/pi.js.map +1 -1
  76. package/package.json +3 -2
  77. package/src/cli/args.ts +16 -6
  78. package/src/cli/config.ts +13 -4
  79. package/src/cli/help.ts +32 -23
  80. package/src/cli/inspect.ts +8 -6
  81. package/src/cli/resume-guard.ts +26 -2
  82. package/src/cli/run.ts +32 -36
  83. package/src/cli/session-json.ts +11 -5
  84. package/src/cli/session.ts +122 -40
  85. package/src/execution/events.ts +9 -1
  86. package/src/execution/open-session.ts +99 -72
  87. package/src/execution/stream-turn.ts +53 -30
  88. package/src/interpretation/argv.ts +11 -12
  89. package/src/interpretation/capabilities.ts +50 -0
  90. package/src/interpretation/hints.ts +1 -1
  91. package/src/interpretation/question.ts +17 -7
  92. package/src/interpretation/refusal.ts +2 -1
  93. package/src/interpretation/skills-selection.ts +46 -8
  94. package/src/knowledge/claude-code.ts +21 -0
  95. package/src/knowledge/codex.ts +16 -6
  96. package/src/knowledge/descriptor.ts +40 -12
  97. package/src/knowledge/muse.ts +7 -0
  98. package/src/knowledge/pi.ts +23 -4
@@ -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,4 @@
1
- import { existsSync, realpathSync } from "node:fs";
1
+ import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
2
2
  import { storePath } from "../interpretation/store.js";
3
3
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
4
4
 
@@ -31,5 +31,29 @@ export const resumeStore = (
31
31
  } catch {
32
32
  path = null;
33
33
  }
34
- return { path, exists: path !== null && existsSync(path) };
34
+ return { path, exists: path !== null && sessionExistsAt(path, opts.sessionId) };
35
+ };
36
+
37
+ /** A store template that resolves to a FILE names the session directly and
38
+ * existence is the file's. One that resolves to a DIRECTORY names where the
39
+ * harness files every session for this cwd, and the session is one entry in
40
+ * it whose name carries the id somewhere - pi writes `<timestamp>_<id>.jsonl`,
41
+ * so the id's position is not a template. Checking only that the directory
42
+ * exists reported every id as present once any session had ever run in the
43
+ * cwd, which made the guard pass unknown ids on pi and, once `origin` rode
44
+ * on it, reported a brand-new session as resumed. */
45
+ const sessionExistsAt = (path: string, sessionId: string): boolean => {
46
+ if (!existsSync(path)) return false;
47
+ let isDir = false;
48
+ try {
49
+ isDir = statSync(path).isDirectory();
50
+ } catch {
51
+ return false;
52
+ }
53
+ if (!isDir) return true;
54
+ try {
55
+ return readdirSync(path).some((name) => name.includes(sessionId));
56
+ } catch {
57
+ return false;
58
+ }
35
59
  };
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"]);