@hegemonart/get-design-done 1.20.0 → 1.21.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/.claude-plugin/marketplace.json +9 -12
  2. package/.claude-plugin/plugin.json +8 -31
  3. package/CHANGELOG.md +78 -0
  4. package/README.md +48 -7
  5. package/bin/gdd-sdk +55 -0
  6. package/package.json +15 -47
  7. package/reference/codex-tools.md +53 -0
  8. package/reference/gemini-tools.md +53 -0
  9. package/reference/registry.json +14 -0
  10. package/scripts/e2e/run-headless.ts +514 -0
  11. package/scripts/lib/cli/commands/audit.ts +382 -0
  12. package/scripts/lib/cli/commands/init.ts +217 -0
  13. package/scripts/lib/cli/commands/query.ts +329 -0
  14. package/scripts/lib/cli/commands/run.ts +656 -0
  15. package/scripts/lib/cli/commands/stage.ts +468 -0
  16. package/scripts/lib/cli/index.ts +167 -0
  17. package/scripts/lib/cli/parse-args.ts +336 -0
  18. package/scripts/lib/context-engine/index.ts +116 -0
  19. package/scripts/lib/context-engine/manifest.ts +69 -0
  20. package/scripts/lib/context-engine/truncate.ts +282 -0
  21. package/scripts/lib/context-engine/types.ts +59 -0
  22. package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
  23. package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
  24. package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
  25. package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
  26. package/scripts/lib/event-stream/index.ts +11 -1
  27. package/scripts/lib/explore-parallel-runner/index.ts +294 -0
  28. package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
  29. package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
  30. package/scripts/lib/explore-parallel-runner/types.ts +139 -0
  31. package/scripts/lib/harness/detect.ts +90 -0
  32. package/scripts/lib/harness/index.ts +64 -0
  33. package/scripts/lib/harness/tool-map.ts +142 -0
  34. package/scripts/lib/init-runner/index.ts +396 -0
  35. package/scripts/lib/init-runner/researchers.ts +245 -0
  36. package/scripts/lib/init-runner/scaffold.ts +224 -0
  37. package/scripts/lib/init-runner/synthesizer.ts +224 -0
  38. package/scripts/lib/init-runner/types.ts +143 -0
  39. package/scripts/lib/logger/index.ts +251 -0
  40. package/scripts/lib/logger/sinks.ts +269 -0
  41. package/scripts/lib/logger/types.ts +110 -0
  42. package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
  43. package/scripts/lib/pipeline-runner/index.ts +527 -0
  44. package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
  45. package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
  46. package/scripts/lib/pipeline-runner/types.ts +183 -0
  47. package/scripts/lib/session-runner/errors.ts +406 -0
  48. package/scripts/lib/session-runner/index.ts +715 -0
  49. package/scripts/lib/session-runner/transcript.ts +189 -0
  50. package/scripts/lib/session-runner/types.ts +144 -0
  51. package/scripts/lib/tool-scoping/index.ts +219 -0
  52. package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
  53. package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
  54. package/scripts/lib/tool-scoping/types.ts +77 -0
@@ -0,0 +1,336 @@
1
+ // scripts/lib/cli/parse-args.ts — Plan 21-09 Task 1 (SDK-21).
2
+ //
3
+ // Hand-rolled argv parser used by the `gdd-sdk` CLI. No external
4
+ // dependency (no yargs / commander / minimist). Supports the exact
5
+ // subset documented in PLAN.md:
6
+ //
7
+ // * Long flags: `--name value` or `--name=value`
8
+ // * Short flags: `-h`, `-v` only (help / version)
9
+ // * Boolean toggles: `--headless` (no value — present == true)
10
+ // * End-of-flags: `--` (everything after goes into `passthrough`)
11
+ //
12
+ // The parser itself does NOT validate that the first positional is a
13
+ // known subcommand — routing lives in `index.ts`. `coerceFlags()` is
14
+ // where type conversion + spec-driven validation happens.
15
+ //
16
+ // Contract:
17
+ // * Pure + deterministic. No I/O, no process reads. Safe to unit-test.
18
+ // * Never throws from `parseArgs()` — all failure surfaces land on the
19
+ // returned shape (unknown flags are captured as strings, not rejected).
20
+ // * `coerceFlags()` DOES throw `ValidationError` for malformed specs
21
+ // (e.g., non-numeric input for a numeric flag). Callers ideally catch
22
+ // and route to exit code 3.
23
+
24
+ import { ValidationError } from '../gdd-errors/index.ts';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Public types.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Result of `parseArgs()`. All fields are frozen so downstream code
32
+ * cannot accidentally mutate the parser output.
33
+ *
34
+ * * `subcommand` — the first non-flag token. `null` when argv is empty
35
+ * or starts with a flag.
36
+ * * `positionals` — every non-flag token AFTER the subcommand (but
37
+ * before `--`).
38
+ * * `flags` — every `--name[=value]` / `-h` token keyed by name with
39
+ * value `true` (boolean toggle) or string (explicit value). No type
40
+ * coercion happens here.
41
+ * * `passthrough` — everything after the sentinel `--`, in order.
42
+ */
43
+ export interface ParsedArgs {
44
+ readonly subcommand: string | null;
45
+ readonly positionals: readonly string[];
46
+ readonly flags: Readonly<Record<string, string | boolean>>;
47
+ readonly passthrough: readonly string[];
48
+ }
49
+
50
+ /** Flag type a spec can declare. */
51
+ export type FlagType = 'string' | 'number' | 'boolean';
52
+
53
+ /**
54
+ * Declarative spec for one flag. Aliases let short names map to a
55
+ * canonical long name (e.g., `-h` → `help`). `default` is returned from
56
+ * `coerceFlags()` when the flag is absent.
57
+ */
58
+ export interface FlagSpec {
59
+ readonly name: string;
60
+ readonly type: FlagType;
61
+ readonly default?: unknown;
62
+ readonly aliases?: readonly string[];
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // parseArgs — pure tokenization pass.
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Parse `argv` into typed `ParsedArgs`. See module header for grammar.
71
+ *
72
+ * The function is tolerant by design: unknown flags still appear in
73
+ * `flags` (caller may warn or error as desired via `coerceFlags`). Bad
74
+ * token order (e.g., two consecutive `--` sentinels) simply folds into
75
+ * passthrough.
76
+ *
77
+ * @param argv Argument tokens, e.g., `process.argv.slice(2)`.
78
+ * @param _specs Optional (reserved for parity with `coerceFlags`). Not
79
+ * used today — kept to match PLAN.md signature.
80
+ */
81
+ export function parseArgs(
82
+ argv: readonly string[],
83
+ _specs?: readonly FlagSpec[],
84
+ ): ParsedArgs {
85
+ const flags: Record<string, string | boolean> = {};
86
+ const positionals: string[] = [];
87
+ const passthrough: string[] = [];
88
+ let subcommand: string | null = null;
89
+
90
+ // State: have we crossed the `--` sentinel? Everything after goes to
91
+ // `passthrough` verbatim.
92
+ let afterDoubleDash = false;
93
+
94
+ for (let i = 0; i < argv.length; i++) {
95
+ const token: string | undefined = argv[i];
96
+ if (token === undefined) continue;
97
+
98
+ if (afterDoubleDash) {
99
+ passthrough.push(token);
100
+ continue;
101
+ }
102
+
103
+ if (token === '--') {
104
+ afterDoubleDash = true;
105
+ continue;
106
+ }
107
+
108
+ // Long flag: `--name` or `--name=value`.
109
+ if (token.startsWith('--')) {
110
+ const body = token.slice(2);
111
+ if (body.length === 0) {
112
+ // Bare `--` handled above; defensive fallthrough.
113
+ afterDoubleDash = true;
114
+ continue;
115
+ }
116
+ const eq = body.indexOf('=');
117
+ if (eq >= 0) {
118
+ const name = body.slice(0, eq);
119
+ const value = body.slice(eq + 1);
120
+ if (name.length > 0) {
121
+ flags[name] = value;
122
+ }
123
+ continue;
124
+ }
125
+ // No `=`. Peek the next token — if it exists and is NOT another
126
+ // flag, consume as the value. Otherwise treat as boolean toggle.
127
+ const next: string | undefined = argv[i + 1];
128
+ if (
129
+ next !== undefined &&
130
+ !next.startsWith('-') &&
131
+ !isLikelyBoolFlag(body)
132
+ ) {
133
+ flags[body] = next;
134
+ i += 1;
135
+ } else {
136
+ flags[body] = true;
137
+ }
138
+ continue;
139
+ }
140
+
141
+ // Short flag: single letter after a single dash.
142
+ if (token.startsWith('-') && token.length >= 2) {
143
+ const rest = token.slice(1);
144
+ // Accept only 1-letter short flags per PLAN.md ("Only 1-letter
145
+ // shorts: `-h`, `-v`"). Anything longer we treat as-is and let the
146
+ // consumer decide — we record it under the first letter.
147
+ if (rest.length === 1) {
148
+ flags[rest] = true;
149
+ continue;
150
+ }
151
+ // Multi-char short (e.g., `-abc`) — treat as an unknown flag
152
+ // literal. Record under the whole body so callers can detect.
153
+ flags[rest] = true;
154
+ continue;
155
+ }
156
+
157
+ // Positional. First positional is the subcommand.
158
+ if (subcommand === null) {
159
+ subcommand = token;
160
+ } else {
161
+ positionals.push(token);
162
+ }
163
+ }
164
+
165
+ return Object.freeze({
166
+ subcommand,
167
+ positionals: Object.freeze(positionals),
168
+ flags: Object.freeze(flags),
169
+ passthrough: Object.freeze(passthrough),
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Known boolean-toggle flag names. When the parser encounters one of
175
+ * these WITHOUT an `=value` it should NOT consume the next token even
176
+ * if that token looks like a value — the next token is a positional
177
+ * arg. Keeps `gdd-sdk stage discuss --parallel plan` parsing correctly:
178
+ * `--parallel` is a bool, `plan` stays in positionals.
179
+ *
180
+ * The list is conservative (only flags the CLI declares as boolean in
181
+ * its specs); unknown bool flags fall through to the generic peek-value
182
+ * heuristic which is safe for the CLI's other flags because every
183
+ * value-carrying flag is numeric or string (never ambiguous).
184
+ */
185
+ const BOOL_FLAG_NAMES: ReadonlySet<string> = new Set([
186
+ 'help',
187
+ 'h',
188
+ 'version',
189
+ 'v',
190
+ 'headless',
191
+ 'interactive',
192
+ 'json',
193
+ 'text',
194
+ 'force',
195
+ 'parallel',
196
+ 'dry-run',
197
+ ]);
198
+
199
+ function isLikelyBoolFlag(name: string): boolean {
200
+ return BOOL_FLAG_NAMES.has(name);
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // coerceFlags — spec-driven type conversion + defaults.
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Apply type coercion + defaults based on `specs`. Aliases let
209
+ * `-h` (parsed as `flags.h = true`) resolve to the canonical `help`
210
+ * key in the returned map.
211
+ *
212
+ * Throws `ValidationError` when a flag was supplied with a value that
213
+ * cannot coerce to the declared type (e.g., `--budget-usd abc`). Flags
214
+ * not declared in `specs` pass through unchanged (as their raw
215
+ * string|boolean value) so callers can still see them.
216
+ */
217
+ export function coerceFlags(
218
+ parsed: ParsedArgs,
219
+ specs: readonly FlagSpec[],
220
+ ): Record<string, unknown> {
221
+ const out: Record<string, unknown> = {};
222
+
223
+ // Collect every alias → canonical map.
224
+ const canonical = new Map<string, FlagSpec>();
225
+ for (const spec of specs) {
226
+ canonical.set(spec.name, spec);
227
+ for (const alias of spec.aliases ?? []) {
228
+ canonical.set(alias, spec);
229
+ }
230
+ }
231
+
232
+ // Build a reverse-lookup of values present on `parsed.flags` keyed by
233
+ // their canonical name (so `-h` and `--help` both land on `help`).
234
+ const resolved: Record<string, string | boolean> = {};
235
+ for (const [key, value] of Object.entries(parsed.flags)) {
236
+ const spec = canonical.get(key);
237
+ const target = spec !== undefined ? spec.name : key;
238
+ // Last-write-wins — operators rarely specify a flag twice; when they
239
+ // do, the final value prevails.
240
+ resolved[target] = value;
241
+ }
242
+
243
+ // Apply defaults + coerce.
244
+ for (const spec of specs) {
245
+ const raw = Object.prototype.hasOwnProperty.call(resolved, spec.name)
246
+ ? resolved[spec.name]
247
+ : undefined;
248
+ if (raw === undefined) {
249
+ if (spec.default !== undefined) {
250
+ out[spec.name] = spec.default;
251
+ }
252
+ continue;
253
+ }
254
+ out[spec.name] = coerceValue(spec, raw);
255
+ }
256
+
257
+ // Pass-through any flags not declared in specs (so `query get --tail 5`
258
+ // keeps `tail` visible even if `tail` isn't in the common-flag spec list).
259
+ for (const [key, value] of Object.entries(resolved)) {
260
+ if (!Object.prototype.hasOwnProperty.call(out, key)) {
261
+ out[key] = value;
262
+ }
263
+ }
264
+
265
+ return out;
266
+ }
267
+
268
+ /**
269
+ * Coerce a single raw value against its spec. Throws `ValidationError`
270
+ * on malformed input so the caller can exit with code 3.
271
+ */
272
+ function coerceValue(spec: FlagSpec, raw: string | boolean): unknown {
273
+ if (spec.type === 'boolean') {
274
+ if (raw === true || raw === false) return raw;
275
+ // String values `"true"` / `"false"` (from `--flag=true`) are honored.
276
+ if (raw === 'true' || raw === '1') return true;
277
+ if (raw === 'false' || raw === '0') return false;
278
+ throw new ValidationError(
279
+ `flag --${spec.name} expects a boolean (true/false/1/0), got "${String(raw)}"`,
280
+ 'INVALID_FLAG_VALUE',
281
+ { flag: spec.name, value: raw },
282
+ );
283
+ }
284
+ if (spec.type === 'number') {
285
+ if (typeof raw === 'boolean') {
286
+ throw new ValidationError(
287
+ `flag --${spec.name} requires a numeric value`,
288
+ 'INVALID_FLAG_VALUE',
289
+ { flag: spec.name },
290
+ );
291
+ }
292
+ const n = Number(raw);
293
+ if (!Number.isFinite(n)) {
294
+ throw new ValidationError(
295
+ `flag --${spec.name} expects a number, got "${raw}"`,
296
+ 'INVALID_FLAG_VALUE',
297
+ { flag: spec.name, value: raw },
298
+ );
299
+ }
300
+ return n;
301
+ }
302
+ // string
303
+ if (typeof raw === 'boolean') {
304
+ throw new ValidationError(
305
+ `flag --${spec.name} requires a string value`,
306
+ 'INVALID_FLAG_VALUE',
307
+ { flag: spec.name },
308
+ );
309
+ }
310
+ return raw;
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Common-flag specs used by multiple subcommands.
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /**
318
+ * Common flags shared across every subcommand. Individual commands may
319
+ * extend this list with their own flags. `default` values follow
320
+ * PLAN.md's recommendations.
321
+ */
322
+ export const COMMON_FLAGS: readonly FlagSpec[] = Object.freeze([
323
+ Object.freeze({ name: 'help', type: 'boolean', default: false, aliases: ['h'] }),
324
+ Object.freeze({ name: 'version', type: 'boolean', default: false, aliases: ['v'] }),
325
+ Object.freeze({ name: 'cwd', type: 'string' }),
326
+ Object.freeze({ name: 'log-level', type: 'string', default: 'info' }),
327
+ Object.freeze({ name: 'headless', type: 'boolean', default: false }),
328
+ Object.freeze({ name: 'interactive', type: 'boolean', default: false }),
329
+ Object.freeze({ name: 'json', type: 'boolean', default: false }),
330
+ Object.freeze({ name: 'text', type: 'boolean', default: false }),
331
+ Object.freeze({ name: 'budget-usd', type: 'number' }),
332
+ Object.freeze({ name: 'budget-input-tokens', type: 'number', default: 200_000 }),
333
+ Object.freeze({ name: 'budget-output-tokens', type: 'number', default: 50_000 }),
334
+ Object.freeze({ name: 'max-turns', type: 'number', default: 40 }),
335
+ Object.freeze({ name: 'concurrency', type: 'number', default: 4 }),
336
+ ]);
@@ -0,0 +1,116 @@
1
+ // scripts/lib/context-engine/index.ts — public API for the context-engine.
2
+ // Pipes { stage, cwd } → typed ContextBundle. Never touches the Agent SDK.
3
+
4
+ import { resolve } from 'node:path';
5
+ import { Buffer } from 'node:buffer';
6
+
7
+ import type { Stage, ContextFile, ContextBundle, BundleOptions } from './types.ts';
8
+ import { MANIFEST, manifestFor, readFileRaw } from './manifest.ts';
9
+ import { truncateMarkdown } from './truncate.ts';
10
+ import { getLogger } from '../logger/index.ts';
11
+
12
+ /** Default 8 KiB truncation threshold. */
13
+ const DEFAULT_THRESHOLD_BYTES = 8192;
14
+
15
+ export type { Stage, ContextFile, ContextBundle, BundleOptions } from './types.ts';
16
+ export { MANIFEST, manifestFor, readFileRaw } from './manifest.ts';
17
+ export { truncateMarkdown } from './truncate.ts';
18
+
19
+ /**
20
+ * Build the context bundle for a given stage. Reads every file in
21
+ * `MANIFEST[stage]` from disk, applies markdown-aware truncation to any file
22
+ * whose raw size exceeds `truncationThresholdBytes` (default 8 KiB), and
23
+ * returns the typed bundle.
24
+ *
25
+ * Missing files are recorded as `present: false` with empty content (unless
26
+ * `strict: true`, in which case the first missing file throws). ENOENT never
27
+ * surfaces to the caller in default mode — other IO errors still propagate.
28
+ */
29
+ export function buildContextBundle(stage: Stage, opts: BundleOptions = {}): ContextBundle {
30
+ const cwd = opts.cwd ?? process.cwd();
31
+ const threshold = opts.truncationThresholdBytes ?? DEFAULT_THRESHOLD_BYTES;
32
+ const strict = opts.strict === true;
33
+
34
+ const manifest = manifestFor(stage);
35
+ const files: ContextFile[] = [];
36
+ let total_bytes = 0;
37
+
38
+ for (const entry of manifest) {
39
+ const absPath = resolve(cwd, entry);
40
+ const { present, raw, raw_bytes } = readFileRaw(absPath);
41
+
42
+ if (!present) {
43
+ if (strict) {
44
+ throw new Error(`context-engine: required file not found: ${entry}`);
45
+ }
46
+ files.push({
47
+ path: entry,
48
+ present: false,
49
+ raw_bytes: 0,
50
+ content: '',
51
+ content_bytes: 0,
52
+ truncated_lines: 0,
53
+ });
54
+ continue;
55
+ }
56
+
57
+ const { content, truncated_lines } = truncateMarkdown(raw, threshold);
58
+ const content_bytes = Buffer.byteLength(content, 'utf8');
59
+ files.push({
60
+ path: entry,
61
+ present: true,
62
+ raw_bytes,
63
+ content,
64
+ content_bytes,
65
+ truncated_lines,
66
+ });
67
+ total_bytes += content_bytes;
68
+ }
69
+
70
+ const bundle: ContextBundle = {
71
+ stage,
72
+ files,
73
+ total_bytes,
74
+ built_at: new Date().toISOString(),
75
+ };
76
+
77
+ // Diagnostic emit. Plan 21-04 Task 4: context-engine consumes the
78
+ // structured logger so CI and the E2E harness can observe bundle
79
+ // construction without screen-scraping stdout.
80
+ try {
81
+ getLogger().debug('bundle built', {
82
+ stage,
83
+ files: files.length,
84
+ total_bytes,
85
+ });
86
+ } catch {
87
+ // getLogger() is defensive; any failure here must not block bundle
88
+ // construction. Callers depend on buildContextBundle returning a
89
+ // valid ContextBundle.
90
+ }
91
+
92
+ return bundle;
93
+ }
94
+
95
+ /**
96
+ * Render a bundle as a single prompt-ready string with per-file HTML-comment
97
+ * headers and `\n---\n` dividers between files. Missing files render as
98
+ * `<!-- file: PATH (missing) -->` with no body.
99
+ *
100
+ * Consumed by pipeline-runner (21-05) and parallel runners (21-06..08) to
101
+ * build the system prompt's context section.
102
+ */
103
+ export function renderBundle(bundle: ContextBundle): string {
104
+ const parts: string[] = [];
105
+ for (const f of bundle.files) {
106
+ if (!f.present) {
107
+ parts.push(`<!-- file: ${f.path} (missing) -->`);
108
+ continue;
109
+ }
110
+ parts.push(`<!-- file: ${f.path} (${f.content_bytes} bytes) -->\n${f.content}`);
111
+ }
112
+ // Ensure MANIFEST import remains live-referenced for consumers that depend
113
+ // on side-effects of module loading (none currently, but harmless).
114
+ void MANIFEST;
115
+ return parts.join('\n---\n');
116
+ }
@@ -0,0 +1,69 @@
1
+ // scripts/lib/context-engine/manifest.ts — locked per-stage file manifest +
2
+ // ENOENT-tolerant disk reader. The MANIFEST object is the single
3
+ // source-of-truth for which `.design/*.md` files each headless stage skill
4
+ // reads; runners in Plans 21-05..08 query it indirectly via manifestFor().
5
+ //
6
+ // Frozen shape: outer Record and every inner array are Object.freeze()d so
7
+ // downstream mutation attempts fail fast in strict mode (TypeScript strict
8
+ // mode already flags them at compile time).
9
+
10
+ import { readFileSync } from 'node:fs';
11
+ import { Buffer } from 'node:buffer';
12
+
13
+ import type { Stage } from './types.ts';
14
+
15
+ /**
16
+ * Per-stage file manifest. Order within each tuple is significant — it is the
17
+ * order files appear in the rendered bundle (see `renderBundle` in index.ts).
18
+ *
19
+ * LOCKED: do not modify without revisiting the skill prompts for every stage
20
+ * listed below. Changes here cascade into Phase 21 runner prompts.
21
+ */
22
+ export const MANIFEST: Readonly<Record<Stage, readonly string[]>> = Object.freeze({
23
+ brief: Object.freeze(['.design/STATE.md', '.design/BRIEF.md']),
24
+ explore: Object.freeze(['.design/STATE.md', '.design/BRIEF.md', '.design/DESIGN-CONTEXT.md']),
25
+ plan: Object.freeze([
26
+ '.design/STATE.md',
27
+ '.design/DESIGN-PLAN.md',
28
+ '.design/DESIGN-CONTEXT.md',
29
+ '.design/RESEARCH.md',
30
+ ]),
31
+ design: Object.freeze(['.design/STATE.md', '.design/DESIGN-PLAN.md']),
32
+ verify: Object.freeze(['.design/STATE.md', '.design/DESIGN-PLAN.md', '.design/SUMMARY.md']),
33
+ init: Object.freeze(['.design/STATE.md']),
34
+ });
35
+
36
+ /**
37
+ * Return the locked manifest entries for a stage. Returned array is
38
+ * Object.freeze()d — callers must not mutate.
39
+ */
40
+ export function manifestFor(stage: Stage): readonly string[] {
41
+ const entries = MANIFEST[stage];
42
+ // Defensive: MANIFEST is typed as Readonly<Record<Stage, ...>> but a caller
43
+ // passing a value that has been cast to Stage at runtime could land here
44
+ // with an unknown key. Return empty array rather than undefined to keep the
45
+ // caller's control flow simple (they iterate and get zero files).
46
+ return entries ?? Object.freeze([]);
47
+ }
48
+
49
+ /**
50
+ * Read one file from disk. Returns `{ present, raw, raw_bytes }`. Never
51
+ * throws on `ENOENT` — returns `{ present: false, raw: '', raw_bytes: 0 }`.
52
+ * Other IO errors (EACCES, EIO, EISDIR, …) propagate to the caller because
53
+ * those are configuration bugs, not missing-file conditions.
54
+ */
55
+ export function readFileRaw(absPath: string): { present: boolean; raw: string; raw_bytes: number } {
56
+ try {
57
+ const raw = readFileSync(absPath, 'utf8');
58
+ return { present: true, raw, raw_bytes: Buffer.byteLength(raw, 'utf8') };
59
+ } catch (err) {
60
+ // Node fs errors carry a `.code` string. Only swallow the missing-file
61
+ // family; everything else is re-thrown so the caller (or strict mode in
62
+ // buildContextBundle) surfaces the real problem.
63
+ const code = (err as NodeJS.ErrnoException | null)?.code;
64
+ if (code === 'ENOENT') {
65
+ return { present: false, raw: '', raw_bytes: 0 };
66
+ }
67
+ throw err;
68
+ }
69
+ }