@dev-loops/core 0.1.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.
Files changed (54) hide show
  1. package/bin/capture-deep-persona-signals.mjs +143 -0
  2. package/bin/ensure-phase-files.mjs +7 -0
  3. package/bin/log-bash-exit-1.mjs +7 -0
  4. package/bin/parse-review-threads.mjs +7 -0
  5. package/package.json +78 -0
  6. package/src/analysis/change-classifier.mjs +146 -0
  7. package/src/analysis/diff-analyzer.mjs +285 -0
  8. package/src/bash-exit-one.mjs +130 -0
  9. package/src/cli/helpers.mjs +22 -0
  10. package/src/cli/primitives.mjs +70 -0
  11. package/src/cli/retry-wrapper.mjs +169 -0
  12. package/src/cli/subcommand-runner.mjs +246 -0
  13. package/src/config/config.mjs +965 -0
  14. package/src/debt/cluster.mjs +240 -0
  15. package/src/debt/debt-finding.mjs +68 -0
  16. package/src/debt/debt-signal.mjs +46 -0
  17. package/src/debt/deep-persona-signals.mjs +266 -0
  18. package/src/debt/remediation-to-issue.mjs +121 -0
  19. package/src/debt/score.mjs +127 -0
  20. package/src/debt/shape.mjs +214 -0
  21. package/src/github/copilot-helpers.mjs +343 -0
  22. package/src/github/repo-slug.mjs +105 -0
  23. package/src/github/review-threads.mjs +343 -0
  24. package/src/harness/adapter.mjs +57 -0
  25. package/src/harness/index.mjs +3 -0
  26. package/src/harness/noop-adapter.mjs +22 -0
  27. package/src/harness/pi-adapter.mjs +47 -0
  28. package/src/loop/async-start-contract.mjs +170 -0
  29. package/src/loop/conductor-routing.mjs +817 -0
  30. package/src/loop/copilot-ci-status.mjs +255 -0
  31. package/src/loop/copilot-loop-iterations.mjs +161 -0
  32. package/src/loop/copilot-loop-state.mjs +510 -0
  33. package/src/loop/handoff-envelope.mjs +800 -0
  34. package/src/loop/issue-refinement-artifact.mjs +268 -0
  35. package/src/loop/lifecycle-state.mjs +342 -0
  36. package/src/loop/phase-files.mjs +187 -0
  37. package/src/loop/policy-constants.mjs +17 -0
  38. package/src/loop/pr-gate-coordination.mjs +1278 -0
  39. package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
  40. package/src/loop/public-dev-loop-routing.mjs +1746 -0
  41. package/src/loop/queue-board-ordering.mjs +38 -0
  42. package/src/loop/queue-board-sync.mjs +223 -0
  43. package/src/loop/queue-driver.mjs +164 -0
  44. package/src/loop/queue-parallel.mjs +190 -0
  45. package/src/loop/queue-state.mjs +230 -0
  46. package/src/loop/retrospective-checkpoint.mjs +178 -0
  47. package/src/loop/reviewer-loop-state.mjs +456 -0
  48. package/src/loop/run-inspection.mjs +604 -0
  49. package/src/loop/steering.mjs +793 -0
  50. package/src/loop/timeout-policy.mjs +73 -0
  51. package/src/loop/tracker-first-loop-state.mjs +87 -0
  52. package/src/loop/tracker-pr-state.mjs +301 -0
  53. package/src/loop/worktree-guard.mjs +141 -0
  54. package/src/refinement/ac-dod-matrix.mjs +95 -0
@@ -0,0 +1,130 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export const DEFAULT_OUTPUT_LIMIT = 4000;
5
+
6
+ function requireNonEmptyString(value, fieldName) {
7
+ if (typeof value !== "string" || value.trim().length === 0) {
8
+ throw new Error(`${fieldName} must be a non-empty string`);
9
+ }
10
+
11
+ return value.trim();
12
+ }
13
+
14
+ export function truncateText(value, limit = DEFAULT_OUTPUT_LIMIT) {
15
+ if (value === undefined || value === null) {
16
+ return undefined;
17
+ }
18
+
19
+ const text = String(value);
20
+ if (text.length <= limit) {
21
+ return text;
22
+ }
23
+
24
+ const truncatedCount = text.length - limit;
25
+ return `${text.slice(0, limit)}…[truncated ${truncatedCount} chars]`;
26
+ }
27
+
28
+ export function normalizeBashExitOneRecord(record) {
29
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
30
+ throw new Error("record must be an object");
31
+ }
32
+
33
+ const exitCode = Number(record.exitCode);
34
+ if (exitCode !== 1) {
35
+ throw new Error(`exitCode must be 1, received ${record.exitCode}`);
36
+ }
37
+
38
+ const normalized = {
39
+ timestamp:
40
+ typeof record.timestamp === "string" && record.timestamp.trim().length > 0
41
+ ? record.timestamp.trim()
42
+ : new Date().toISOString(),
43
+ phase: requireNonEmptyString(record.phase, "phase"),
44
+ cwd: requireNonEmptyString(record.cwd, "cwd"),
45
+ command: requireNonEmptyString(record.command, "command"),
46
+ exitCode: 1,
47
+ purpose: requireNonEmptyString(record.purpose, "purpose"),
48
+ summary: requireNonEmptyString(record.summary, "summary"),
49
+ };
50
+
51
+ const stdout = truncateText(record.stdout);
52
+ const stderr = truncateText(record.stderr);
53
+ const artifactPath = truncateText(record.artifactPath, 2000);
54
+
55
+ if (stdout !== undefined && stdout.length > 0) {
56
+ normalized.stdout = stdout;
57
+ }
58
+
59
+ if (stderr !== undefined && stderr.length > 0) {
60
+ normalized.stderr = stderr;
61
+ }
62
+
63
+ if (artifactPath !== undefined && artifactPath.length > 0) {
64
+ normalized.artifactPath = artifactPath;
65
+ }
66
+
67
+ return normalized;
68
+ }
69
+
70
+ export function formatBashExitOneRecord(record) {
71
+ return `${JSON.stringify(normalizeBashExitOneRecord(record))}\n`;
72
+ }
73
+
74
+ export async function appendBashExitOneRecord(logPath, record) {
75
+ const normalized = normalizeBashExitOneRecord(record);
76
+ await mkdir(path.dirname(logPath), { recursive: true });
77
+ await appendFile(logPath, `${JSON.stringify(normalized)}\n`, "utf8");
78
+ return normalized;
79
+ }
80
+
81
+ export function parseCliArgs(argv) {
82
+ const args = [...argv];
83
+ let logPath;
84
+ let recordJson;
85
+
86
+ while (args.length > 0) {
87
+ const token = args.shift();
88
+ if (token === "--log") {
89
+ logPath = args.shift();
90
+ continue;
91
+ }
92
+
93
+ if (token === "--record") {
94
+ recordJson = args.shift();
95
+ continue;
96
+ }
97
+
98
+ throw new Error(`Unknown argument: ${token}`);
99
+ }
100
+
101
+ if (!logPath) {
102
+ throw new Error("Missing required --log <path> argument");
103
+ }
104
+
105
+ return { logPath, recordJson };
106
+ }
107
+
108
+ export async function readRecordFromStdin(stream = process.stdin) {
109
+ let input = "";
110
+
111
+ for await (const chunk of stream) {
112
+ input += chunk;
113
+ }
114
+
115
+ if (input.trim().length === 0) {
116
+ throw new Error("Expected a JSON record via --record or stdin");
117
+ }
118
+
119
+ return JSON.parse(input);
120
+ }
121
+
122
+ export async function runCli(argv = process.argv.slice(2), stream = process.stdin) {
123
+ const { logPath, recordJson } = parseCliArgs(argv);
124
+ const record = recordJson ? JSON.parse(recordJson) : await readRecordFromStdin(stream);
125
+ const normalized = await appendBashExitOneRecord(logPath, record);
126
+
127
+ process.stdout.write(
128
+ `${JSON.stringify({ ok: true, logPath, record: normalized })}\n`,
129
+ );
130
+ }
@@ -0,0 +1,22 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /**
5
+ * Shared CLI helpers for script boilerplate reduction.
6
+ * Extracted from scripts/_core-helpers.mjs per issue #548 Phase 2.
7
+ */
8
+
9
+ export function buildParseError(usage) {
10
+ return function parseError(message) {
11
+ return Object.assign(new Error(message), { usage });
12
+ };
13
+ }
14
+
15
+ export function isDirectCliRun(importMetaUrl, argv1 = process.argv[1]) {
16
+ if (typeof argv1 !== "string" || argv1.length === 0) { return false; }
17
+ try {
18
+ return realpathSync(argv1) === realpathSync(fileURLToPath(importMetaUrl));
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
@@ -0,0 +1,70 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /**
4
+ * Shared CLI primitives for arg parsing, validation, and child process execution.
5
+ * Extracted from scripts/_cli-primitives.mjs per issue #548 Phase 2.
6
+ */
7
+
8
+ function toCliError(message, parseError) {
9
+ if (typeof parseError === "function") {
10
+ return parseError(message);
11
+ }
12
+ return new Error(message);
13
+ }
14
+
15
+ export function requireOptionValue(args, flag, parseError = null, { flagPattern = /^--/u } = {}) {
16
+ const value = args.shift();
17
+ if (typeof value !== "string" || value.length === 0 || flagPattern.test(value)) {
18
+ throw toCliError(`Missing value for ${flag}`, parseError);
19
+ }
20
+ return value;
21
+ }
22
+
23
+ export function parsePositiveInteger(value, flag, parseError = null) {
24
+ if (!/^\d+$/.test(value) || Number(value) === 0) {
25
+ throw toCliError(`${flag} must be a positive integer`, parseError);
26
+ }
27
+ return Number(value);
28
+ }
29
+
30
+ export function parseNonNegativeInteger(value, flag, parseError = null) {
31
+ if (!/^\d+$/.test(value)) {
32
+ throw toCliError(`${flag} must be a non-negative integer`, parseError);
33
+ }
34
+ return Number(value);
35
+ }
36
+
37
+ export function parsePrNumber(value, parseError = null) {
38
+ return parsePositiveInteger(value, "--pr", parseError);
39
+ }
40
+
41
+ export function parseIssueNumber(value, parseError = null) {
42
+ return parsePositiveInteger(value, "--issue", parseError);
43
+ }
44
+
45
+ export function runChild(command, args, env = process.env) {
46
+ return new Promise((resolve, reject) => {
47
+ const child = spawn(command, args, { env, stdio: ["ignore", "pipe", "pipe"] });
48
+ let stdout = "";
49
+ let stderr = "";
50
+ child.stdout.on("data", (chunk) => { stdout += String(chunk); });
51
+ child.stderr.on("data", (chunk) => { stderr += String(chunk); });
52
+ child.on("error", reject);
53
+ child.on("close", (code) => { resolve({ code, stdout, stderr }); });
54
+ });
55
+ }
56
+
57
+ export function runCommand(command, args, { cwd = process.cwd(), env = process.env } = {}) {
58
+ return new Promise((resolve, reject) => {
59
+ const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
60
+ let stdout = "";
61
+ let stderr = "";
62
+ child.stdout.on("data", (chunk) => { stdout += String(chunk); });
63
+ child.stderr.on("data", (chunk) => { stderr += String(chunk); });
64
+ child.on("error", reject);
65
+ child.on("close", (code) => {
66
+ if (code === 0) { resolve({ stdout, stderr }); return; }
67
+ reject(new Error(stderr.trim().length > 0 ? stderr.trim() : `${command} exited with code ${code}`));
68
+ });
69
+ });
70
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Deterministic retry wrapper for CLI usage/flag errors.
3
+ *
4
+ * When a script receives unknown flags, it emits a usage/flag error to stderr.
5
+ * This wrapper detects such errors, parses the usage output for valid flags,
6
+ * and retries once with corrected args.
7
+ *
8
+ * Only retries on usage/flag errors — NOT on network/auth/data errors.
9
+ *
10
+ * Detection contract:
11
+ * A usage/flag error is stderr JSON with { ok: false, usage: "..." }
12
+ * OR stderr text matching known argument-error patterns.
13
+ *
14
+ * Non-retryable: runtime errors ({ ok: false } without usage field),
15
+ * network errors, auth errors, data errors, signal exits.
16
+ *
17
+ * Issue: #483
18
+ */
19
+
20
+ const USAGE_PATTERNS = [
21
+ /\bUnknown argument:/i,
22
+ /\bMissing required option:/i,
23
+ /\bMissing value for\b/i,
24
+ /\bhas been removed/i,
25
+ /\bUnrecognized\b/i,
26
+ /\bMissing command\b/i,
27
+ ];
28
+
29
+ /**
30
+ * Check if stderr represents a CLI usage/flag error that is retryable.
31
+ * @param {string|null|undefined} stderr
32
+ * @returns {boolean}
33
+ */
34
+ export function isUsageError(stderr) {
35
+ if (!stderr || stderr.trim().length === 0) return false;
36
+
37
+ const trimmed = stderr.trim();
38
+
39
+ // Try JSON parse first: { ok: false, usage: "..." }
40
+ try {
41
+ const parsed = JSON.parse(trimmed);
42
+ if (parsed && parsed.ok === false && typeof parsed.usage === 'string' && parsed.usage.length > 0) {
43
+ return true;
44
+ }
45
+ } catch {
46
+ // Not JSON — fall through to pattern matching
47
+ }
48
+
49
+ return USAGE_PATTERNS.some((p) => p.test(trimmed));
50
+ }
51
+
52
+ /**
53
+ * Extract valid flag names from usage text.
54
+ * Finds all --flag-name patterns and returns them as a Set.
55
+ *
56
+ * Only matches flags that look like valid CLI option names:
57
+ * letters and hyphens after --, minimum 2 chars (--x is not a valid flag).
58
+ *
59
+ * @param {string} usageText
60
+ * @returns {Set<string>}
61
+ */
62
+ export function extractValidFlags(usageText) {
63
+ const flags = new Set();
64
+ if (!usageText || usageText.length === 0) return flags;
65
+
66
+ // Match --flag-name (letters and hyphens after --)
67
+ const flagPattern = /--([a-z][a-z0-9-]*)/gi;
68
+ let match;
69
+ while ((match = flagPattern.exec(usageText)) !== null) {
70
+ flags.add(`--${match[1]}`);
71
+ }
72
+ return flags;
73
+ }
74
+
75
+ /**
76
+ * Extract the canonical usage text from stderr.
77
+ *
78
+ * For JSON stderr: returns the `usage` field value.
79
+ * For non-JSON stderr: tries to find a "Usage:" section and returns that;
80
+ * if no Usage: section is found, returns null to avoid false positives.
81
+ *
82
+ * @param {string} stderr
83
+ * @returns {string|null}
84
+ */
85
+ export function extractUsageText(stderr) {
86
+ if (!stderr || stderr.trim().length === 0) return null;
87
+
88
+ try {
89
+ const parsed = JSON.parse(stderr.trim());
90
+ if (parsed && typeof parsed.usage === 'string' && parsed.usage.length > 0) {
91
+ return parsed.usage;
92
+ }
93
+ // JSON but no usage field — not a usage error, don't return raw text
94
+ return null;
95
+ } catch {
96
+ // Not JSON — try to find a "Usage:" section
97
+ }
98
+
99
+ const trimmed = stderr.trim();
100
+
101
+ // Find "Usage:" line and take from there to end
102
+ const usageIdx = trimmed.search(/^Usage:/im);
103
+ if (usageIdx >= 0) {
104
+ return trimmed.slice(usageIdx);
105
+ }
106
+
107
+ // No usage marker — can't reliably extract
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * Filter original args to keep only recognized flags and their value args.
113
+ * A value arg is the token immediately following a recognized flag
114
+ * that does not start with '-'.
115
+ *
116
+ * @param {string[]} originalArgs
117
+ * @param {Set<string>} validFlags
118
+ * @returns {string[]}
119
+ */
120
+ export function filterArgs(originalArgs, validFlags) {
121
+ const filtered = [];
122
+ for (let i = 0; i < originalArgs.length; i++) {
123
+ const arg = originalArgs[i];
124
+ if (validFlags.has(arg)) {
125
+ filtered.push(arg);
126
+ // If next arg exists and doesn't look like a flag, it's a value
127
+ if (i + 1 < originalArgs.length && !originalArgs[i + 1].startsWith('-')) {
128
+ filtered.push(originalArgs[i + 1]);
129
+ i++; // consume the value
130
+ }
131
+ }
132
+ }
133
+ return filtered;
134
+ }
135
+
136
+ /**
137
+ * Build a corrected args array from the original args and the stderr usage error.
138
+ * Returns null if no correction is possible or needed.
139
+ *
140
+ * Steps:
141
+ * 1. Extract canonical usage text from stderr.
142
+ * 2. Extract valid flags from usage text.
143
+ * 3. Filter original args to keep only recognized flags + values.
144
+ * 4. If filtered args differ from original, return them; otherwise return null.
145
+ *
146
+ * @param {string[]} originalArgs
147
+ * @param {string} stderr
148
+ * @returns {string[]|null} Corrected args, or null if no correction needed.
149
+ */
150
+ export function buildCorrectedArgs(originalArgs, stderr) {
151
+ if (!originalArgs || originalArgs.length === 0) return null;
152
+
153
+ const usageText = extractUsageText(stderr);
154
+ if (!usageText) return null;
155
+
156
+ const validFlags = extractValidFlags(usageText);
157
+ if (validFlags.size === 0) return null;
158
+
159
+ const corrected = filterArgs(originalArgs, validFlags);
160
+ if (corrected.length === 0) return null;
161
+
162
+ // Only retry if args actually changed
163
+ if (corrected.length === originalArgs.length &&
164
+ corrected.every((v, i) => v === originalArgs[i])) {
165
+ return null;
166
+ }
167
+
168
+ return corrected;
169
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Shared subcommand runner for standardizing CLI script boilerplate.
3
+ * Extracted per issue #548 Phase 3.
4
+ *
5
+ * Replaces per-script: USAGE string, parseError, arg-parsing loop,
6
+ * removed-flags handling, and direct-invocation boilerplate.
7
+ */
8
+
9
+ import { buildParseError, isDirectCliRun } from "./helpers.mjs";
10
+ import { requireOptionValue, parsePrNumber, parseIssueNumber,
11
+ parsePositiveInteger, parseNonNegativeInteger } from "./primitives.mjs";
12
+
13
+ /**
14
+ * Option descriptor for defineSubcommand.
15
+ *
16
+ * @typedef {Object} CliOption
17
+ * @property {string} flag - e.g. "--repo"
18
+ * @property {string} [key] - override output key name; defaults to dashed→camelCase (e.g. "--head-sha" → "headSha")
19
+ * @property {string} [valueName] - human-readable value name for usage (ignored for boolean flags)
20
+ * @property {string} [description] - help text
21
+ * @property {"string"|"number"|"boolean"|"pr"|"issue"|"positiveInt"|"nonNegativeInt"} [type] - default "string"
22
+ * @property {boolean} [required] - default false
23
+ * @property {*} [default] - default value
24
+ * @property {string[]} [choices] - allowed values
25
+ * @property {string[]} [removedAliases] - flags that should be rejected with a message
26
+ */
27
+
28
+ /** Convert a dashed flag name (e.g. "--head-sha") to camelCase (e.g. "headSha"). */
29
+ function dashedToCamel(flag) {
30
+ const stem = flag.replace(/^--/, "");
31
+ return stem.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
32
+ }
33
+
34
+ /** Return the parsed output key for an option. Respects opt.key override. */
35
+ function optionKey(opt) {
36
+ if (opt.key) return opt.key;
37
+ return dashedToCamel(opt.flag);
38
+ }
39
+
40
+ /** Render a single option's usage fragment for the generated help text. */
41
+ function optionUsageFragment(opt) {
42
+ if (opt.type === "boolean") return opt.flag;
43
+ const vn = opt.valueName || opt.flag.replace(/^--/, "").replace(/-/g, "_").toUpperCase();
44
+ return `${opt.flag} <${vn}>`;
45
+ }
46
+
47
+ /**
48
+ * Define a subcommand with auto-generated usage, arg parsing, and help.
49
+ *
50
+ * @param {Object} def
51
+ * @param {string} def.name - subcommand name for usage
52
+ * @param {string} def.description - one-line description
53
+ * @param {string} [def.longDescription] - extended help text
54
+ * @param {CliOption[]} def.options - option descriptors
55
+ * @param {Function} def.run - async (parsed, { args, stdout, stderr }) => exitCode
56
+ * @param {Object} [def.extraUsage] - extra usage lines
57
+ * @param {Object} [def.outputSchema] - stdout JSON schema description
58
+ * @returns {{ parseArgs, runAsScript, usage, parseError }}
59
+ */
60
+ export function defineSubcommand(def) {
61
+ const {
62
+ name,
63
+ description,
64
+ longDescription = "",
65
+ options = [],
66
+ run,
67
+ extraUsage = {},
68
+ outputSchema = null,
69
+ } = def;
70
+
71
+ // Auto-build usage string
72
+ const requiredOpts = options.filter((o) => o.required);
73
+ const optionalOpts = options.filter((o) => !o.required);
74
+
75
+ const usageLines = [`Usage: dev-loops ${name}`];
76
+ for (const opt of requiredOpts) {
77
+ usageLines.push(` ${optionUsageFragment(opt)}`);
78
+ }
79
+ if (optionalOpts.length > 0) {
80
+ const optStrs = optionalOpts.map((o) => optionUsageFragment(o));
81
+ usageLines.push(` [${optStrs.join("] [")}]`);
82
+ }
83
+
84
+ if (description) usageLines.push("", description);
85
+ if (longDescription) usageLines.push("", longDescription);
86
+
87
+ if (requiredOpts.length > 0) {
88
+ usageLines.push("", "Required:");
89
+ for (const opt of requiredOpts) {
90
+ usageLines.push(` ${optionUsageFragment(opt)}${opt.description ? ` ${opt.description}` : ""}`);
91
+ }
92
+ }
93
+
94
+ if (optionalOpts.length > 0) {
95
+ usageLines.push("", "Optional:");
96
+ for (const opt of optionalOpts) {
97
+ usageLines.push(` ${optionUsageFragment(opt)}${opt.description ? ` ${opt.description}` : ""}`);
98
+ }
99
+ }
100
+
101
+ if (extraUsage.before) usageLines.splice(1, 0, ...extraUsage.before);
102
+ if (extraUsage.after) usageLines.push(...extraUsage.after);
103
+
104
+ if (outputSchema) {
105
+ usageLines.push("", "Output (stdout, JSON):", JSON.stringify(outputSchema, null, 2));
106
+ }
107
+
108
+ const usage = usageLines.join("\n");
109
+ const parseError = buildParseError(usage);
110
+
111
+ // Build removed-flags set
112
+ const removedFlags = new Set();
113
+ for (const opt of options) {
114
+ if (opt.removedAliases) {
115
+ for (const alias of opt.removedAliases) removedFlags.add(alias);
116
+ }
117
+ }
118
+
119
+ function parseValue(raw, opt) {
120
+ if (raw === undefined) return opt.default;
121
+ switch (opt.type) {
122
+ case "number": case "positiveInt": case "nonNegativeInt": {
123
+ if (opt.type === "positiveInt") return parsePositiveInteger(raw, opt.flag, parseError);
124
+ if (opt.type === "nonNegativeInt") return parseNonNegativeInteger(raw, opt.flag, parseError);
125
+ const n = Number(raw);
126
+ if (isNaN(n)) throw parseError(`${opt.flag} must be a number`);
127
+ return n;
128
+ }
129
+ case "pr": return parsePrNumber(raw, parseError);
130
+ case "issue": return parseIssueNumber(raw, parseError);
131
+ case "string":
132
+ default: {
133
+ const v = raw.trim();
134
+ if (opt.choices && !opt.choices.includes(v)) {
135
+ throw parseError(`${opt.flag} must be one of: ${opt.choices.join(", ")}`);
136
+ }
137
+ return v;
138
+ }
139
+ }
140
+ }
141
+
142
+ function parseArgs(argv) {
143
+ const args = [...argv];
144
+ const parsed = {};
145
+ // Initialize defaults for all options
146
+ for (const opt of options) {
147
+ if (opt.default !== undefined) {
148
+ parsed[optionKey(opt)] = opt.default;
149
+ }
150
+ }
151
+
152
+ while (args.length > 0) {
153
+ const token = args.shift();
154
+
155
+ if (token === "--help" || token === "-h") {
156
+ return { help: true };
157
+ }
158
+
159
+ if (removedFlags.has(token)) {
160
+ throw parseError(
161
+ `${token} has been removed. Omit the flag.`,
162
+ );
163
+ }
164
+
165
+ const opt = options.find((o) => o.flag === token);
166
+ if (opt) {
167
+ if (opt.type === "boolean") {
168
+ // Boolean flags are presence-only: --flag sets true, no value required.
169
+ parsed[optionKey(opt)] = true;
170
+ } else {
171
+ const raw = requireOptionValue(args, opt.flag, parseError);
172
+ parsed[optionKey(opt)] = parseValue(raw, opt);
173
+ }
174
+ continue;
175
+ }
176
+
177
+ throw parseError(`Unknown argument: ${token}`);
178
+ }
179
+
180
+ // Validate option definitions
181
+ for (const opt of options) {
182
+ if (opt.required && opt.default !== undefined) {
183
+ throw new Error(`Option ${opt.flag}: 'required' and 'default' conflict`);
184
+ }
185
+ }
186
+
187
+ // Check required
188
+ for (const opt of requiredOpts) {
189
+ const key = optionKey(opt);
190
+ if (parsed[key] === undefined) {
191
+ throw parseError(`Missing required option: ${opt.flag}`);
192
+ }
193
+ }
194
+
195
+ return { parsed };
196
+ }
197
+
198
+ async function runAsScript(scriptArgv = process.argv.slice(2)) {
199
+ try {
200
+ const result = parseArgs(scriptArgv);
201
+ if (result.help) {
202
+ process.stdout.write(`${usage}\n`);
203
+ process.exitCode = 0;
204
+ return;
205
+ }
206
+ const code = await run(result.parsed, { args: scriptArgv, usage });
207
+ process.exitCode = typeof code === "number" ? code : 0;
208
+ } catch (error) {
209
+ const msg = error instanceof Error ? error.message : String(error);
210
+ if (error instanceof Error && typeof error.usage === "string") {
211
+ process.stderr.write(JSON.stringify({ ok: false, error: msg, usage: error.usage }) + "\n");
212
+ } else {
213
+ process.stderr.write(JSON.stringify({ ok: false, error: msg }) + "\n");
214
+ }
215
+ process.exitCode = 1;
216
+ }
217
+ }
218
+
219
+ return { parseArgs, runAsScript, usage, parseError };
220
+ }
221
+
222
+ /**
223
+ * Run a CLI function as the main entrypoint when the module is invoked directly.
224
+ * Handles process.exitCode and error formatting.
225
+ *
226
+ * Usage: replace `if (isDirectCliRun(import.meta.url)) { ... }` with:
227
+ * if (isDirectCliRun(import.meta.url)) { runAsMain(runCli); }
228
+ *
229
+ * @param {Function} fn - async function returning exit code or void
230
+ * @param {Object} [opts]
231
+ * @param {Function} [opts.formatError] - error formatter (default: JSON.stringify)
232
+ */
233
+ export function runAsMain(fn, { formatError } = {}) {
234
+ Promise.resolve(fn()).then(
235
+ (code) => { process.exitCode = typeof code === "number" ? code : 0; },
236
+ (error) => {
237
+ const msg = formatError
238
+ ? formatError(error)
239
+ : JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) });
240
+ process.stderr.write(`${msg}\n`);
241
+ process.exitCode = 1;
242
+ },
243
+ );
244
+ }
245
+
246
+ export { isDirectCliRun };