@dungle-scrubs/harness-cli-normalizer 0.3.0 → 0.4.0
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.
- package/README.md +70 -86
- package/dist/cli/args.d.ts +11 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +23 -5
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/config.d.ts +25 -0
- package/dist/cli/config.d.ts.map +1 -0
- package/dist/cli/config.js +139 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +25 -9
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/inspect.d.ts.map +1 -1
- package/dist/cli/inspect.js +35 -1
- package/dist/cli/inspect.js.map +1 -1
- package/dist/cli/render.d.ts.map +1 -1
- package/dist/cli/render.js +10 -1
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +134 -4
- package/dist/cli/run.js.map +1 -1
- package/dist/cli.js +2 -1
- package/dist/execution/failure.d.ts +21 -4
- package/dist/execution/failure.d.ts.map +1 -1
- package/dist/execution/failure.js +26 -3
- package/dist/execution/failure.js.map +1 -1
- package/dist/execution/stream-turn.d.ts +7 -1
- package/dist/execution/stream-turn.d.ts.map +1 -1
- package/dist/execution/stream-turn.js +43 -20
- package/dist/execution/stream-turn.js.map +1 -1
- package/dist/interpretation/argv.d.ts +1 -0
- package/dist/interpretation/argv.d.ts.map +1 -1
- package/dist/interpretation/argv.js +13 -19
- package/dist/interpretation/argv.js.map +1 -1
- package/dist/interpretation/hints.d.ts +22 -0
- package/dist/interpretation/hints.d.ts.map +1 -0
- package/dist/interpretation/hints.js +59 -0
- package/dist/interpretation/hints.js.map +1 -0
- package/dist/interpretation/refusal.d.ts +25 -4
- package/dist/interpretation/refusal.d.ts.map +1 -1
- package/dist/interpretation/refusal.js +16 -0
- package/dist/interpretation/refusal.js.map +1 -1
- package/dist/interpretation/resolve-options.d.ts +42 -0
- package/dist/interpretation/resolve-options.d.ts.map +1 -0
- package/dist/interpretation/resolve-options.js +140 -0
- package/dist/interpretation/resolve-options.js.map +1 -0
- package/dist/interpretation/store.js +1 -1
- package/dist/interpretation/support.d.ts +32 -0
- package/dist/interpretation/support.d.ts.map +1 -0
- package/dist/interpretation/support.js +107 -0
- package/dist/interpretation/support.js.map +1 -0
- package/dist/interpretation/tool-selection.d.ts +29 -0
- package/dist/interpretation/tool-selection.d.ts.map +1 -0
- package/dist/interpretation/tool-selection.js +122 -0
- package/dist/interpretation/tool-selection.js.map +1 -0
- package/dist/interpretation/turn-options.d.ts.map +1 -1
- package/dist/interpretation/turn-options.js +4 -0
- package/dist/interpretation/turn-options.js.map +1 -1
- package/dist/knowledge/claude-code.d.ts +2 -2
- package/dist/knowledge/claude-code.d.ts.map +1 -1
- package/dist/knowledge/claude-code.js +37 -4
- package/dist/knowledge/claude-code.js.map +1 -1
- package/dist/knowledge/codex.d.ts.map +1 -1
- package/dist/knowledge/codex.js +19 -0
- package/dist/knowledge/codex.js.map +1 -1
- package/dist/knowledge/descriptor.d.ts +32 -0
- package/dist/knowledge/descriptor.d.ts.map +1 -1
- package/dist/knowledge/muse.d.ts.map +1 -1
- package/dist/knowledge/muse.js +22 -0
- package/dist/knowledge/muse.js.map +1 -1
- package/dist/knowledge/pi.d.ts +1 -1
- package/dist/knowledge/pi.d.ts.map +1 -1
- package/dist/knowledge/pi.js +29 -6
- package/dist/knowledge/pi.js.map +1 -1
- package/dist/knowledge/profile.d.ts +13 -0
- package/dist/knowledge/profile.d.ts.map +1 -0
- package/dist/knowledge/profile.js +31 -0
- package/dist/knowledge/profile.js.map +1 -0
- package/package.json +2 -23
- package/src/cli/args.ts +31 -1
- package/src/cli/config.ts +166 -0
- package/src/cli/index.ts +25 -12
- package/src/cli/inspect.ts +34 -1
- package/src/cli/render.ts +10 -1
- package/src/cli/run.ts +161 -5
- package/src/execution/failure.ts +44 -5
- package/src/execution/stream-turn.ts +43 -14
- package/src/interpretation/argv.ts +14 -19
- package/src/interpretation/hints.ts +99 -0
- package/src/interpretation/refusal.ts +32 -3
- package/src/interpretation/resolve-options.ts +190 -0
- package/src/interpretation/store.ts +1 -1
- package/src/interpretation/support.ts +119 -0
- package/src/interpretation/tool-selection.ts +161 -0
- package/src/interpretation/turn-options.ts +4 -0
- package/src/knowledge/claude-code.ts +37 -4
- package/src/knowledge/codex.ts +19 -0
- package/src/knowledge/descriptor.ts +32 -0
- package/src/knowledge/muse.ts +22 -0
- package/src/knowledge/pi.ts +29 -6
- package/src/knowledge/profile.ts +33 -0
- package/dist/index.d.ts +0 -8
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -8
- package/dist/index.js.map +0 -1
- package/src/index.ts +0 -7
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-level config: ~/.config/hcn/config.json (or $HCN_CONFIG_DIR for
|
|
3
|
+
* tests). Hard-fail discipline: unknown keys, malformed JSON, and schema
|
|
4
|
+
* version mismatches exit 2 naming the offender - a config that silently
|
|
5
|
+
* no-ops is a safety hole in a tool whose config can tighten a sandbox.
|
|
6
|
+
*
|
|
7
|
+
* Schema is intentionally minimal and mirrors the normalized option
|
|
8
|
+
* surface; it grows as dimensions ratify. The parser validates SHAPE
|
|
9
|
+
* (key known, value type plausible); value legality (effort in ladder,
|
|
10
|
+
* model in vocabulary) is enforced by the same renderers as args, so
|
|
11
|
+
* config and CLI can never disagree about what is legal.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import type { TurnOptions } from "../interpretation/argv.js";
|
|
17
|
+
|
|
18
|
+
const SCHEMA_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
export class ConfigError extends Error {
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "ConfigError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const userConfigPath = (): string =>
|
|
28
|
+
join(
|
|
29
|
+
process.env.HCN_CONFIG_DIR ??
|
|
30
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "hcn"),
|
|
31
|
+
"config.json",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** Project config: `.hcn/config.json` at the git root (P4 ratified as A -
|
|
35
|
+
* auto-discover, no trust gate; the config is code-reviewed like AGENTS.md
|
|
36
|
+
* and every project-tier provenance line names its path). Null outside a
|
|
37
|
+
* repo or when the repo declares none. */
|
|
38
|
+
export const projectConfigPath = (startDir: string = process.cwd()): string | null => {
|
|
39
|
+
let dir = resolve(startDir);
|
|
40
|
+
for (;;) {
|
|
41
|
+
if (existsSync(join(dir, ".hcn", "config.json"))) return join(dir, ".hcn", "config.json");
|
|
42
|
+
const parent = dirname(dir);
|
|
43
|
+
if (parent === dir) return null;
|
|
44
|
+
// stop at the git root: above it is not this project
|
|
45
|
+
if (existsSync(join(dir, ".git")) && !existsSync(join(parent, ".git"))) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
dir = parent;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type MutableTurnOptions = {
|
|
53
|
+
[K in keyof TurnOptions]?: TurnOptions[K];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const KNOWN_KEYS = new Set([
|
|
57
|
+
"effort",
|
|
58
|
+
"model",
|
|
59
|
+
"provider",
|
|
60
|
+
"sandbox",
|
|
61
|
+
"tools",
|
|
62
|
+
"excludeTools",
|
|
63
|
+
"autonomy",
|
|
64
|
+
"write",
|
|
65
|
+
"shell",
|
|
66
|
+
"maxSteps",
|
|
67
|
+
"toolsets",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const LIST_KEYS = new Set(["tools", "excludeTools"]);
|
|
71
|
+
const BOOL_KEYS = new Set(["autonomy", "write", "shell"]);
|
|
72
|
+
|
|
73
|
+
/** Parse + validate config text. Throws ConfigError with the offending key
|
|
74
|
+
* named on any violation - never warns and continues. */
|
|
75
|
+
export const parseUserConfig = (text: string): Partial<TurnOptions> => {
|
|
76
|
+
let raw: unknown;
|
|
77
|
+
try {
|
|
78
|
+
raw = JSON.parse(text);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
throw new ConfigError(`config is not valid JSON: ${(e as Error).message}`);
|
|
81
|
+
}
|
|
82
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
83
|
+
throw new ConfigError("config root must be a JSON object");
|
|
84
|
+
}
|
|
85
|
+
const obj = raw as Record<string, unknown>;
|
|
86
|
+
|
|
87
|
+
if (obj.version === undefined) {
|
|
88
|
+
throw new ConfigError("config missing required field: version");
|
|
89
|
+
}
|
|
90
|
+
if (obj.version !== SCHEMA_VERSION) {
|
|
91
|
+
throw new ConfigError(
|
|
92
|
+
`config version ${JSON.stringify(obj.version)} not supported (expected ${SCHEMA_VERSION})`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
delete obj.version;
|
|
96
|
+
|
|
97
|
+
const out: MutableTurnOptions = {};
|
|
98
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
99
|
+
if (!KNOWN_KEYS.has(key)) {
|
|
100
|
+
throw new ConfigError(`unknown config key: ${JSON.stringify(key)}`);
|
|
101
|
+
}
|
|
102
|
+
if (key === "toolsets") {
|
|
103
|
+
if (
|
|
104
|
+
typeof value !== "object" ||
|
|
105
|
+
value === null ||
|
|
106
|
+
Array.isArray(value) ||
|
|
107
|
+
Object.entries(value).some(
|
|
108
|
+
([, v]) => !Array.isArray(v) || v.some((t) => typeof t !== "string" || t.trim() === ""),
|
|
109
|
+
)
|
|
110
|
+
) {
|
|
111
|
+
throw new ConfigError(
|
|
112
|
+
'config key "toolsets" must be an object of name -> non-empty string array',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
(out as Record<string, unknown>)[key] = value;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (LIST_KEYS.has(key)) {
|
|
119
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
|
|
120
|
+
throw new ConfigError(`config key ${JSON.stringify(key)} must be an array of strings`);
|
|
121
|
+
}
|
|
122
|
+
(out as Record<string, unknown>)[key] = value;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (BOOL_KEYS.has(key)) {
|
|
126
|
+
if (typeof value !== "boolean") {
|
|
127
|
+
throw new ConfigError(`config key ${JSON.stringify(key)} must be a boolean`);
|
|
128
|
+
}
|
|
129
|
+
(out as Record<string, unknown>)[key] = value;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
133
|
+
throw new ConfigError(
|
|
134
|
+
`config key ${JSON.stringify(key)} must be a string or number, got ${typeof value}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
(out as Record<string, unknown>)[key] = value;
|
|
138
|
+
}
|
|
139
|
+
return out as Partial<TurnOptions>;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Load the user config if the file exists; absent file is an empty config
|
|
143
|
+
* (no tiers engaged), unreadable or invalid file is a hard error. */
|
|
144
|
+
export const loadUserConfig = (): { config: Partial<TurnOptions>; path: string } | null =>
|
|
145
|
+
loadConfigAt(userConfigPath());
|
|
146
|
+
|
|
147
|
+
/** Load the project config (git-root auto-discovery, ratified A). */
|
|
148
|
+
export const loadProjectConfig = (
|
|
149
|
+
startDir?: string,
|
|
150
|
+
): { config: Partial<TurnOptions>; path: string } | null => {
|
|
151
|
+
const path = projectConfigPath(startDir);
|
|
152
|
+
if (path === null) return null;
|
|
153
|
+
return loadConfigAt(path);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const loadConfigAt = (path: string): { config: Partial<TurnOptions>; path: string } | null => {
|
|
157
|
+
let text: string;
|
|
158
|
+
try {
|
|
159
|
+
text = readFileSync(path, "utf8");
|
|
160
|
+
} catch (e) {
|
|
161
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
162
|
+
if (code === "ENOENT") return null;
|
|
163
|
+
throw new ConfigError(`cannot read config at ${path}: ${(e as Error).message}`);
|
|
164
|
+
}
|
|
165
|
+
return { config: parseUserConfig(text), path };
|
|
166
|
+
};
|
package/src/cli/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
2
5
|
import { TOP_LEVEL_HELP } from "./help.js";
|
|
3
6
|
import { getVersion } from "./version.js";
|
|
4
7
|
|
|
@@ -155,23 +158,33 @@ export const dispatch = async (raw: string[]): Promise<void> => {
|
|
|
155
158
|
}
|
|
156
159
|
};
|
|
157
160
|
|
|
158
|
-
const main = async (): Promise<void> => {
|
|
161
|
+
export const main = async (): Promise<void> => {
|
|
159
162
|
await dispatch(process.argv.slice(2));
|
|
160
163
|
};
|
|
161
164
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
);
|
|
167
|
-
})();
|
|
168
|
-
|
|
169
|
-
if (shouldAutoRun) {
|
|
170
|
-
main().catch((err) => {
|
|
165
|
+
// The bin entry invokes this directly. Auto-run below covers direct
|
|
166
|
+
// execution of this module itself (node dist/cli/index.js, bun src/cli/index.ts).
|
|
167
|
+
export const run = (): void => {
|
|
168
|
+
main().catch((err: unknown) => {
|
|
171
169
|
const message = err instanceof Error ? err.message : String(err);
|
|
172
170
|
process.stderr.write(`fatal: ${message}\n`);
|
|
173
171
|
if (err instanceof Error && err.stack) process.stderr.write(`${err.stack}\n`);
|
|
174
172
|
process.exitCode = 1;
|
|
175
|
-
if (process.exitCode === 0) process.exitCode = 1;
|
|
176
173
|
});
|
|
177
|
-
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Run only when this module is the process main entry. process.argv[1] is
|
|
177
|
+
// the path as invoked - a bin symlink such as npm's `hcn` keeps its link
|
|
178
|
+
// name - while import.meta.url is this file's realpath, so resolving argv[1]
|
|
179
|
+
// the same way survives symlinks. Filename suffix sniffing cannot (issue #33).
|
|
180
|
+
const isMainEntry = (() => {
|
|
181
|
+
const entry = process.argv[1];
|
|
182
|
+
if (!entry) return false;
|
|
183
|
+
try {
|
|
184
|
+
return import.meta.url === pathToFileURL(realpathSync(resolve(entry))).href;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
})();
|
|
189
|
+
|
|
190
|
+
if (isMainEntry) run();
|
package/src/cli/inspect.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { redactArgv } from "../execution/stream-turn.js";
|
|
2
2
|
import { buildLaunchArgv } from "../interpretation/argv.js";
|
|
3
3
|
import { ArgvRefusalError } from "../interpretation/refusal.js";
|
|
4
|
+
import { FloorExceededError, resolveEffectiveOptions } from "../interpretation/resolve-options.js";
|
|
4
5
|
import { parseTurnOptions, resolvePromptAsync } from "./args.js";
|
|
5
6
|
import { resolveHarness } from "./resolve-harness.js";
|
|
6
7
|
|
|
@@ -123,8 +124,40 @@ export const inspect = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
123
124
|
throw err;
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
// Inspect resolves exactly as a launch would: profile + user config,
|
|
128
|
+
// launch-only semantics, so --argv previews the truth of a bare run.
|
|
129
|
+
const { loadUserConfig, loadProjectConfig, ConfigError } = await import("./config.js");
|
|
130
|
+
const tiers: {
|
|
131
|
+
user?: Partial<ReturnType<typeof parseTurnOptions>>;
|
|
132
|
+
project?: Partial<ReturnType<typeof parseTurnOptions>>;
|
|
133
|
+
} = {};
|
|
134
|
+
try {
|
|
135
|
+
const loaded = loadUserConfig();
|
|
136
|
+
if (loaded !== null) tiers.user = loaded.config;
|
|
137
|
+
const proj = loadProjectConfig();
|
|
138
|
+
if (proj !== null) tiers.project = proj.config;
|
|
139
|
+
} catch (configErr) {
|
|
140
|
+
if (configErr instanceof ConfigError) {
|
|
141
|
+
process.stderr.write(`config error: ${(configErr as Error).message}\n`);
|
|
142
|
+
process.exitCode = 2;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
throw configErr;
|
|
146
|
+
}
|
|
147
|
+
let resolved: ReturnType<typeof resolveEffectiveOptions>;
|
|
148
|
+
try {
|
|
149
|
+
resolved = resolveEffectiveOptions(h, { ...turnOpts, prompt } as never, tiers);
|
|
150
|
+
} catch (resErr) {
|
|
151
|
+
if (resErr instanceof FloorExceededError) {
|
|
152
|
+
process.stderr.write(`${(resErr as Error).message}\n`);
|
|
153
|
+
process.exitCode = 2;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
throw resErr;
|
|
157
|
+
}
|
|
158
|
+
const { prompt: _p, ...effectiveRest } = resolved.options as { prompt: string };
|
|
126
159
|
const fullOpts = {
|
|
127
|
-
...
|
|
160
|
+
...(effectiveRest as object),
|
|
128
161
|
prompt,
|
|
129
162
|
...(promptSource !== "positional" ? { __explicitPrompt: true as const } : {}),
|
|
130
163
|
} as Parameters<typeof buildLaunchArgv>[1];
|
package/src/cli/render.ts
CHANGED
|
@@ -41,7 +41,16 @@ export const renderEvent = (event: HarnessEvent, state: RenderState): void => {
|
|
|
41
41
|
break;
|
|
42
42
|
case "failure": {
|
|
43
43
|
const detail = event.message ?? `${event.class}`;
|
|
44
|
-
|
|
44
|
+
// D6: native failures get an unmistakable prefix and the native exit
|
|
45
|
+
// code shown as data, so a human never reads a harness error as an
|
|
46
|
+
// hcn error.
|
|
47
|
+
if (event.class === "native") {
|
|
48
|
+
const nat =
|
|
49
|
+
event.nativeExitCode !== undefined ? ` [native exit ${event.nativeExitCode}]` : "";
|
|
50
|
+
process.stdout.write(red(`\n ✗ NATIVE${nat} ${detail}`));
|
|
51
|
+
} else {
|
|
52
|
+
process.stdout.write(red(`\n ✗ failure ${event.class}: ${detail}`));
|
|
53
|
+
}
|
|
45
54
|
break;
|
|
46
55
|
}
|
|
47
56
|
case "done": {
|
package/src/cli/run.ts
CHANGED
|
@@ -3,6 +3,13 @@ import { nodeRunnerDeps } from "../execution/node-deps.js";
|
|
|
3
3
|
import { KILL_GRACE_MS, redactArgv, streamTurn } from "../execution/stream-turn.js";
|
|
4
4
|
import { buildLaunchArgv, buildResumeArgv } from "../interpretation/argv.js";
|
|
5
5
|
import { ArgvRefusalError } from "../interpretation/refusal.js";
|
|
6
|
+
import {
|
|
7
|
+
FloorExceededError,
|
|
8
|
+
type ProvenanceEntry,
|
|
9
|
+
resolveEffectiveOptions,
|
|
10
|
+
} from "../interpretation/resolve-options.js";
|
|
11
|
+
import { recognizeNativeSpelling, supportedBy } from "../interpretation/support.js";
|
|
12
|
+
import { defaultDescriptors } from "../knowledge/overrides.js";
|
|
6
13
|
import { parseRunExtra, parseTurnOptions, resolvePromptAsync } from "./args.js";
|
|
7
14
|
import { createRenderState, renderEvent, writeEventNdjson } from "./render.js";
|
|
8
15
|
import { resolveHarness } from "./resolve-harness.js";
|
|
@@ -16,7 +23,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
16
23
|
return;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
|
-
const { parseCommonFlags, detectPositionalPromptInjection } = await import(
|
|
26
|
+
const { parseCommonFlags, detectPositionalPromptInjection, splitPassthrough } = await import(
|
|
27
|
+
"./args.js"
|
|
28
|
+
);
|
|
29
|
+
const { passthrough } = splitPassthrough(rawArgs);
|
|
20
30
|
const injection = detectPositionalPromptInjection(rawArgs);
|
|
21
31
|
if (injection) {
|
|
22
32
|
const err = new ArgvRefusalError({
|
|
@@ -35,7 +45,52 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
35
45
|
parsed = parseCommonFlags(rawArgs);
|
|
36
46
|
} catch (err) {
|
|
37
47
|
const message = err instanceof Error ? err.message : String(err);
|
|
38
|
-
|
|
48
|
+
// D7 part B: a native spelling passed before the separator gets
|
|
49
|
+
// recognized and redirected to the normalized flag instead of a
|
|
50
|
+
// generic unknown-flag error.
|
|
51
|
+
// parseArgs reports unknown long flags as "Unknown option '--x'" but
|
|
52
|
+
// splits bundled short flags ("-nt" -> "Unknown option 'n'"). Match the
|
|
53
|
+
// reported token back against the ORIGINAL argv: a short-flag bundle
|
|
54
|
+
// that some descriptor spells exactly (pi's -nt) is recognizable; a
|
|
55
|
+
// lone unknown token keeps the plain error.
|
|
56
|
+
const flagMatch = message.match(/Unknown option '([A-Za-z0-9_-]+)'/);
|
|
57
|
+
let rawFlag: string | undefined;
|
|
58
|
+
if (flagMatch?.[1] !== undefined) {
|
|
59
|
+
const reported = flagMatch[1].startsWith("-") ? flagMatch[1] : `-${flagMatch[1]}`;
|
|
60
|
+
// Exact long flag: use it. Reported short flag (e.g. -n): the caller
|
|
61
|
+
// may have typed a BUNDLE (-nt) that parseArgs split - find the argv
|
|
62
|
+
// token that starts with the reported short and is longer; recognition
|
|
63
|
+
// then decides whether the whole bundle is a descriptor spelling.
|
|
64
|
+
const fromArgv =
|
|
65
|
+
rawArgs.find((a) => a === reported) ??
|
|
66
|
+
(reported.length === 2
|
|
67
|
+
? rawArgs.find((a) => a.length > 2 && a.startsWith(reported))
|
|
68
|
+
: undefined);
|
|
69
|
+
rawFlag = fromArgv ?? reported;
|
|
70
|
+
}
|
|
71
|
+
const native =
|
|
72
|
+
rawFlag !== undefined ? recognizeNativeSpelling(defaultDescriptors(), rawFlag) : null;
|
|
73
|
+
if (native !== null) {
|
|
74
|
+
const by = native.option.startsWith("discovery.")
|
|
75
|
+
? native.entries
|
|
76
|
+
: supportedBy(defaultDescriptors(), native.option);
|
|
77
|
+
const normalizedSpelling =
|
|
78
|
+
native.option === "excludeTools"
|
|
79
|
+
? "--exclude-tools"
|
|
80
|
+
: native.option.startsWith("discovery.")
|
|
81
|
+
? `--no-${native.option.split(".")[1] === "instructionFiles" ? "instruction-files" : native.option.split(".")[1]}`
|
|
82
|
+
: `--${native.option}`;
|
|
83
|
+
process.stderr.write(
|
|
84
|
+
`unknown flag: ${rawFlag} is a native spelling (used by ${native.entries.map((e) => e.harness).join(", ")}) - use the normalized ${normalizedSpelling} flag instead\n`,
|
|
85
|
+
);
|
|
86
|
+
if (by.length > 0) {
|
|
87
|
+
process.stderr.write(
|
|
88
|
+
`supported on: ${by.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
process.stderr.write(`unknown flag: ${message}\n`);
|
|
93
|
+
}
|
|
39
94
|
process.stderr.write(`Run 'hcn run --help' for usage.\n`);
|
|
40
95
|
process.exitCode = 2;
|
|
41
96
|
return;
|
|
@@ -50,6 +105,11 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
50
105
|
process.exitCode = 2;
|
|
51
106
|
return;
|
|
52
107
|
}
|
|
108
|
+
if (passthrough.length === 0 && rawArgs.includes("--")) {
|
|
109
|
+
process.stderr.write(`-- separator given but no passthrough tokens followed it\n`);
|
|
110
|
+
process.exitCode = 2;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
53
113
|
|
|
54
114
|
const wantJson = values.json === true;
|
|
55
115
|
|
|
@@ -67,6 +127,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
67
127
|
} catch (err) {
|
|
68
128
|
if (err instanceof ArgvRefusalError) {
|
|
69
129
|
process.stderr.write(`${err.message}\n`);
|
|
130
|
+
if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
|
|
131
|
+
if (err.supportedBy?.length) {
|
|
132
|
+
process.stderr.write(
|
|
133
|
+
`supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
70
136
|
if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
|
|
71
137
|
process.exitCode = 2;
|
|
72
138
|
return;
|
|
@@ -86,6 +152,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
86
152
|
} catch (err) {
|
|
87
153
|
if (err instanceof ArgvRefusalError) {
|
|
88
154
|
process.stderr.write(`${err.message}\n`);
|
|
155
|
+
if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
|
|
156
|
+
if (err.supportedBy?.length) {
|
|
157
|
+
process.stderr.write(
|
|
158
|
+
`supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
89
161
|
if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
|
|
90
162
|
process.exitCode = 2;
|
|
91
163
|
return;
|
|
@@ -99,6 +171,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
99
171
|
} catch (err) {
|
|
100
172
|
if (err instanceof ArgvRefusalError) {
|
|
101
173
|
process.stderr.write(`${err.message}\n`);
|
|
174
|
+
if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
|
|
175
|
+
if (err.supportedBy?.length) {
|
|
176
|
+
process.stderr.write(
|
|
177
|
+
`supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
102
180
|
if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
|
|
103
181
|
process.exitCode = 2;
|
|
104
182
|
return;
|
|
@@ -107,20 +185,84 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
107
185
|
}
|
|
108
186
|
|
|
109
187
|
const isExplicit = promptSource !== "positional";
|
|
188
|
+
|
|
189
|
+
// Defaults profile + user config: LAUNCH-ONLY. A resumed session keeps
|
|
190
|
+
// its own settings; the resolver never runs on resume paths.
|
|
191
|
+
let resolvedProvenance: readonly ProvenanceEntry[] = [];
|
|
192
|
+
let resolvedUnrenderable: readonly string[] = [];
|
|
193
|
+
let effectiveTurnOpts: ReturnType<typeof parseTurnOptions> = turnOpts;
|
|
194
|
+
if (extra.resume === undefined) {
|
|
195
|
+
const tiers: {
|
|
196
|
+
user?: Partial<ReturnType<typeof parseTurnOptions>>;
|
|
197
|
+
project?: Partial<ReturnType<typeof parseTurnOptions>>;
|
|
198
|
+
} = {};
|
|
199
|
+
const { loadUserConfig, loadProjectConfig, ConfigError } = await import("./config.js");
|
|
200
|
+
try {
|
|
201
|
+
const loaded = loadUserConfig();
|
|
202
|
+
if (loaded !== null) tiers.user = loaded.config;
|
|
203
|
+
const proj = loadProjectConfig();
|
|
204
|
+
if (proj !== null) tiers.project = proj.config;
|
|
205
|
+
} catch (configErr) {
|
|
206
|
+
if (configErr instanceof ConfigError) {
|
|
207
|
+
process.stderr.write(`config error: ${(configErr as Error).message}\n`);
|
|
208
|
+
process.exitCode = 2;
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
throw configErr;
|
|
212
|
+
}
|
|
213
|
+
let resolved: ReturnType<typeof resolveEffectiveOptions>;
|
|
214
|
+
try {
|
|
215
|
+
resolved = resolveEffectiveOptions(h, { ...turnOpts, prompt } as never, tiers);
|
|
216
|
+
} catch (resErr) {
|
|
217
|
+
if (resErr instanceof FloorExceededError) {
|
|
218
|
+
process.stderr.write(`${(resErr as Error).message}\n`);
|
|
219
|
+
process.exitCode = 2;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
throw resErr;
|
|
223
|
+
}
|
|
224
|
+
const { provenance, unrenderable } = resolved;
|
|
225
|
+
resolvedProvenance = provenance;
|
|
226
|
+
resolvedUnrenderable = unrenderable;
|
|
227
|
+
const { prompt: _p, ...rest } = resolved.options as { prompt: string };
|
|
228
|
+
effectiveTurnOpts = rest as ReturnType<typeof parseTurnOptions>;
|
|
229
|
+
// Provenance is diagnostic data like the spawn line - stderr in BOTH
|
|
230
|
+
// render modes, never stdout (stdout carries the NDJSON contract).
|
|
231
|
+
if (provenance.length > 0 || unrenderable.length > 0) {
|
|
232
|
+
for (const entry of provenance) {
|
|
233
|
+
process.stderr.write(
|
|
234
|
+
`provenance: ${entry.key} = ${JSON.stringify(entry.value)} (${entry.tier})\n`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
for (const key of unrenderable) {
|
|
238
|
+
process.stderr.write(
|
|
239
|
+
`divergence: profile ${JSON.stringify(key)} not expressible on ${h.name}; harness default applies\n`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
110
245
|
const fullOpts = {
|
|
111
|
-
...
|
|
246
|
+
...effectiveTurnOpts,
|
|
112
247
|
prompt,
|
|
113
248
|
cwd: extra.cwd,
|
|
114
249
|
env: extra.env,
|
|
115
250
|
resume: extra.resume,
|
|
251
|
+
...(passthrough.length > 0 ? { passthrough } : {}),
|
|
116
252
|
...(isExplicit ? { __explicitPrompt: true as const } : {}),
|
|
117
|
-
} as Parameters<typeof streamTurn>[1] & {
|
|
253
|
+
} as Parameters<typeof streamTurn>[1] & {
|
|
254
|
+
resume?: string;
|
|
255
|
+
__explicitPrompt?: boolean;
|
|
256
|
+
passthrough?: readonly string[];
|
|
257
|
+
};
|
|
118
258
|
|
|
119
259
|
// Pre-validate via building argv to catch refusals before spawn (so we don't spawn on bad args)
|
|
120
260
|
let _validated = false;
|
|
121
261
|
let preArgv: string[] | null = null;
|
|
122
262
|
try {
|
|
123
263
|
if (fullOpts.resume) {
|
|
264
|
+
// Resume never carries profile/config resolution (launch-only rule),
|
|
265
|
+
// so it builds from the raw turn options.
|
|
124
266
|
preArgv = buildResumeArgv(h, {
|
|
125
267
|
...(turnOpts as object),
|
|
126
268
|
prompt,
|
|
@@ -128,8 +270,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
128
270
|
__explicitPrompt: isExplicit,
|
|
129
271
|
} as never);
|
|
130
272
|
} else {
|
|
273
|
+
// Launch builds from the RESOLVED options so the spawn line and the
|
|
274
|
+
// real argv agree.
|
|
131
275
|
preArgv = buildLaunchArgv(h, {
|
|
132
|
-
...(
|
|
276
|
+
...(effectiveTurnOpts as object),
|
|
133
277
|
prompt,
|
|
134
278
|
__explicitPrompt: isExplicit,
|
|
135
279
|
} as never);
|
|
@@ -138,6 +282,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
138
282
|
} catch (err) {
|
|
139
283
|
if (err instanceof ArgvRefusalError) {
|
|
140
284
|
process.stderr.write(`${err.message}\n`);
|
|
285
|
+
if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
|
|
286
|
+
if (err.supportedBy?.length) {
|
|
287
|
+
process.stderr.write(
|
|
288
|
+
`supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
141
291
|
if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
|
|
142
292
|
process.exitCode = 2;
|
|
143
293
|
return;
|
|
@@ -234,6 +384,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
|
|
|
234
384
|
} catch (err) {
|
|
235
385
|
if (err instanceof ArgvRefusalError) {
|
|
236
386
|
process.stderr.write(`${err.message}\n`);
|
|
387
|
+
if (err.hint) process.stderr.write(`hint: ${err.hint}\n`);
|
|
388
|
+
if (err.supportedBy?.length) {
|
|
389
|
+
process.stderr.write(
|
|
390
|
+
`supported on: ${err.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
237
393
|
if (err.supported.length) process.stderr.write(`supported: ${err.supported.join(", ")}\n`);
|
|
238
394
|
process.exitCode = 2;
|
|
239
395
|
process.off("SIGINT", onSig);
|
package/src/execution/failure.ts
CHANGED
|
@@ -30,6 +30,7 @@ export const FAILURE_CLASSES = Object.freeze([
|
|
|
30
30
|
"task",
|
|
31
31
|
"transport",
|
|
32
32
|
"rejected",
|
|
33
|
+
"native",
|
|
33
34
|
] as const);
|
|
34
35
|
export type FailureClass = (typeof FAILURE_CLASSES)[number];
|
|
35
36
|
|
|
@@ -41,13 +42,21 @@ export interface FailureSummary {
|
|
|
41
42
|
readonly authKind?: AuthFailureKind;
|
|
42
43
|
readonly resetsAt?: number;
|
|
43
44
|
readonly issue?: RefusalIssue;
|
|
44
|
-
readonly option?:
|
|
45
|
+
readonly option?: import("../interpretation/refusal.js").RefusalOption;
|
|
45
46
|
readonly facet?: DiscoveryFacet;
|
|
46
47
|
readonly supported?: readonly string[];
|
|
48
|
+
/** D7: cross-harness support entries derived from descriptors. */
|
|
49
|
+
readonly supportedBy?: ReadonlyArray<{ harness: string; spelling: string }>;
|
|
50
|
+
/** D8: nearest-alternative hint for the current harness. */
|
|
51
|
+
readonly hint?: string;
|
|
52
|
+
/** D6: the harness process's own exit code for a native failure - data,
|
|
53
|
+
* because native conventions differ from hcn's (codex usage errors exit
|
|
54
|
+
* 2, which hcn reserves for refusals). */
|
|
55
|
+
readonly nativeExitCode?: number;
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
export const retryableOf = (cls: FailureClass): boolean =>
|
|
50
|
-
cls !== "task" && cls !== "budget" && cls !== "rejected";
|
|
59
|
+
cls !== "task" && cls !== "budget" && cls !== "rejected" && cls !== "native";
|
|
51
60
|
|
|
52
61
|
const messageFor = (cls: FailureClass, detail?: string): string => {
|
|
53
62
|
switch (cls) {
|
|
@@ -67,10 +76,30 @@ const messageFor = (cls: FailureClass, detail?: string): string => {
|
|
|
67
76
|
return `Transport failure${detail ? ` (${detail})` : ""} - retry or route to another provider`;
|
|
68
77
|
case "rejected":
|
|
69
78
|
return `Request rejected${detail ? ` (${detail})` : ""} - change options or harness`;
|
|
79
|
+
case "native":
|
|
80
|
+
// D6: labeled NATIVE so it can never be confused with an hcn error.
|
|
81
|
+
// The harness's own message follows verbatim; the process exit code
|
|
82
|
+
// rides as data (nativeExitCode), because harness conventions differ
|
|
83
|
+
// (codex exits 2 on usage errors - the same code hcn uses for
|
|
84
|
+
// refusals, so hcn owns its own exit code and reports the native one).
|
|
85
|
+
return `NATIVE ERROR from harness${detail ? `: ${detail}` : ""} - the harness rejected or failed on its own arguments; this is not an hcn error`;
|
|
70
86
|
default:
|
|
71
87
|
return `Failure${detail ? ` (${detail})` : ""}`;
|
|
72
88
|
}
|
|
73
|
-
};
|
|
89
|
+
}; /** D6: a failure that belongs to the harness, not hcn. Carries the
|
|
90
|
+
* native stderr verbatim and the native exit code as data. */
|
|
91
|
+
export const failureFromNative = (
|
|
92
|
+
nativeExitCode: number | null,
|
|
93
|
+
stderrTail: readonly string[],
|
|
94
|
+
): FailureSummary => ({
|
|
95
|
+
class: "native",
|
|
96
|
+
retryable: false,
|
|
97
|
+
message: messageFor(
|
|
98
|
+
"native",
|
|
99
|
+
stderrTail.slice(-3).join(" | ").slice(0, 512) || `exit ${nativeExitCode}`,
|
|
100
|
+
),
|
|
101
|
+
nativeExitCode: nativeExitCode ?? undefined,
|
|
102
|
+
});
|
|
74
103
|
|
|
75
104
|
export const failureFromLimit = (code: LimitCode): FailureSummary => {
|
|
76
105
|
const cls: FailureClass =
|
|
@@ -114,18 +143,25 @@ export const failureFromTransport = (detail?: string): FailureSummary => ({
|
|
|
114
143
|
|
|
115
144
|
export const failureFromRejected = (opts: {
|
|
116
145
|
issue: RefusalIssue;
|
|
117
|
-
option?:
|
|
146
|
+
option?: import("../interpretation/refusal.js").RefusalOption;
|
|
118
147
|
facet?: DiscoveryFacet;
|
|
119
148
|
supported?: readonly string[];
|
|
149
|
+
supportedBy?: ReadonlyArray<{ harness: string; spelling: string }>;
|
|
150
|
+
hint?: string;
|
|
120
151
|
detail?: string;
|
|
121
152
|
}): FailureSummary => ({
|
|
122
153
|
class: "rejected",
|
|
123
154
|
retryable: false,
|
|
155
|
+
// D8: hint first, support list second - prose order matches the
|
|
156
|
+
// structured fields so an agent scanning the message hits the
|
|
157
|
+
// stay-on-harness suggestion before the switch temptation.
|
|
124
158
|
message: messageFor("rejected", opts.detail ?? opts.issue),
|
|
125
159
|
issue: opts.issue,
|
|
126
160
|
option: opts.option,
|
|
127
161
|
facet: opts.facet,
|
|
128
162
|
supported: opts.supported,
|
|
163
|
+
supportedBy: opts.supportedBy,
|
|
164
|
+
hint: opts.hint,
|
|
129
165
|
});
|
|
130
166
|
|
|
131
167
|
/** Precedence for reduction: lower number = higher priority (wins). */
|
|
@@ -137,7 +173,10 @@ const PRECEDENCE: Record<FailureClass, number> = {
|
|
|
137
173
|
budget: 3,
|
|
138
174
|
task: 3,
|
|
139
175
|
transport: 4,
|
|
140
|
-
|
|
176
|
+
// rejected stands alone (checked before precedence applies); native is
|
|
177
|
+
// terminal-by-classification, never reduced into anything else.
|
|
178
|
+
rejected: 0,
|
|
179
|
+
native: 0,
|
|
141
180
|
};
|
|
142
181
|
|
|
143
182
|
export const reduceFailures = (failures: readonly FailureSummary[]): FailureSummary | undefined => {
|