@golden-frijoles/kit 0.3.0 → 0.5.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.
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // config.mjs — `gf-kit config`: read and change golden-frijoles.config.json (golden-frijoles-plugin S4.1, D10).
3
+ //
4
+ // gf-kit config list [--json] every section's effective value, where it came from, any duplicates
5
+ // gf-kit config get <key> [--json] one key's effective value (or its registry default)
6
+ // gf-kit config set <key> <value> write one key; <value> is parsed as JSON when it is valid JSON
7
+ // gf-kit config migrate [--dry-run] fold the legacy files into golden-frijoles.config.json (never edits them)
8
+ //
9
+ // A thin front end: every rule (precedence, the secret guard, what migrate folds) lives in lib/config.mjs, which the
10
+ // `gf` CLI imports too. Exit 0 ok · 1 usage · 2 configuration error (a malformed file, a secret, an unknown section).
11
+
12
+ import { realpathSync } from 'node:fs';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { CONFIG_FILENAME, ConfigError, getKey, loadConfig, migrate, setKey } from './lib/config.mjs';
15
+
16
+ const USAGE = 'usage: gf-kit config list|get <key>|set <key> <value>|migrate [--dry-run] [--json]';
17
+
18
+ /** Pure — a CLI value: JSON when it parses (true, 3, ["a"], null), otherwise the literal string. */
19
+ export function parseValue(text) {
20
+ try {
21
+ return JSON.parse(text);
22
+ } catch {
23
+ return text;
24
+ }
25
+ }
26
+
27
+ export function main(argv, { out = (s) => process.stdout.write(s), err = (s) => process.stderr.write(s) } = {}) {
28
+ const json = argv.includes('--json');
29
+ const args = argv.filter((a) => a !== '--json' && a !== '--dry-run');
30
+ const [verb, key, ...rest] = args;
31
+ try {
32
+ if (verb === 'list') {
33
+ const c = loadConfig();
34
+ if (json) return out(`${JSON.stringify(c, null, 2)}\n`), 0;
35
+ const names = Object.keys(c.sections);
36
+ if (!names.length) out(`No settings yet: no ${CONFIG_FILENAME} and no legacy config files.\n`);
37
+ for (const name of names) {
38
+ out(`${name} (${c.sources[name].map((s) => s.split('/').pop()).join(' + ')})\n`);
39
+ out(`${JSON.stringify(c.sections[name], null, 2).replace(/^/gm, ' ')}\n`);
40
+ }
41
+ if (c.duplicates.length) out(`\nset in both the new file and a legacy file (the new file wins): ${c.duplicates.join(', ')}\n`);
42
+ return 0;
43
+ }
44
+ if (verb === 'get' && key) {
45
+ const value = getKey(key);
46
+ out(json ? `${JSON.stringify({ key, value })}\n` : `${typeof value === 'string' ? value : JSON.stringify(value)}\n`);
47
+ return 0;
48
+ }
49
+ if (verb === 'set' && key && rest.length) {
50
+ setKey(key, parseValue(rest.join(' ')));
51
+ out(json ? `${JSON.stringify({ key, value: getKey(key) })}\n` : `${key} = ${JSON.stringify(getKey(key))}\n`);
52
+ return 0;
53
+ }
54
+ if (verb === 'migrate') {
55
+ const dryRun = argv.includes('--dry-run');
56
+ const r = migrate({ dryRun });
57
+ if (json) return out(`${JSON.stringify({ dryRun, ...r }, null, 2)}\n`), 0;
58
+ if (!r.folded.length) out('Nothing to migrate: no legacy settings the new file does not already have.\n');
59
+ else if (dryRun) out(`Would write ${CONFIG_FILENAME}:\n${JSON.stringify(r.config, null, 2)}\n`);
60
+ else out(`Wrote ${CONFIG_FILENAME} (${r.folded.length} setting(s) folded; the legacy files are untouched).\n`);
61
+ if (r.skipped.length) out(`Skipped (look like secrets; keep them in .env.local): ${r.skipped.join(', ')}\n`);
62
+ return 0;
63
+ }
64
+ } catch (e) {
65
+ if (e instanceof ConfigError) {
66
+ err(`gf-kit config: ${e.message}\n`);
67
+ return 2;
68
+ }
69
+ throw e;
70
+ }
71
+ err(`${USAGE}\n`);
72
+ return 1;
73
+ }
74
+
75
+ const isMain = (() => {
76
+ try {
77
+ return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
78
+ } catch {
79
+ return false;
80
+ }
81
+ })();
82
+ if (isMain) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,145 @@
1
+ // config-registry.mjs — every setting Golden Frijoles can ask for, in one table (golden-frijoles-plugin S4.3, D11).
2
+ //
3
+ // This is the audit §4.3 table as data. Setup reads it for its questions, a script reads it before asking
4
+ // just-in-time (lib/config.mjs `needSetting`), and `gf doctor` reads it to say which modules are configured. A new
5
+ // setting is a new row here, never a question hard-coded in a skill.
6
+ //
7
+ // key dotted, `<section>.<name>`: the path in golden-frijoles.config.json
8
+ // module the product module doctor groups it under (Plan · Build · Ship · Measure · Spend · Operate)
9
+ // askWhen 'setup' | 'first-groom' | 'first-pr' | 'first-smoke' | 'first-report' | 'first-prune'
10
+ // | 'first-high-risk-groom' | 'never-yet' (declared so the schema is ready; nothing reads it this wave)
11
+ // default what a skipped question means. `null` = "unanswered", which a rail must treat as its safest choice
12
+ // question the plain words an agent asks, once
13
+ // choices optional allowed values; `store: 'env'` means the answer lives in .env.local (gf init), never here
14
+ //
15
+ // Zero deps, no imports: config.mjs imports THIS, so it must not import back.
16
+
17
+ export const REGISTRY = Object.freeze([
18
+ // ── Setup (golden-frijoles-plugin S5.1: Q1, Q2, Q4 are asked; Q3, Q5 wait for their modules, X18) ──
19
+ {
20
+ key: 'project.mode',
21
+ module: 'Plan',
22
+ askWhen: 'setup',
23
+ required: true,
24
+ default: 'existing',
25
+ choices: ['existing', 'new', 'planning-only'],
26
+ question: 'What are we working on: an existing repo (adds Roadmap/, keeps everything else), a new project, or planning only (no repo changes)?',
27
+ },
28
+ {
29
+ key: 'project.startPoint',
30
+ module: 'Plan',
31
+ askWhen: 'setup',
32
+ default: 'idea',
33
+ choices: ['idea', 'plan', 'building'],
34
+ question: 'Where are you starting: an idea, you know what to build, or you are already building?',
35
+ },
36
+ {
37
+ key: 'project.account',
38
+ module: 'Ship',
39
+ askWhen: 'setup',
40
+ default: 'later',
41
+ choices: ['later', 'now'],
42
+ store: 'env',
43
+ question: 'Connect a Golden Frijoles account now (gf login + gf init), or later?',
44
+ },
45
+ {
46
+ key: 'board.sink',
47
+ module: 'Plan',
48
+ askWhen: 'never-yet',
49
+ default: 'terminal',
50
+ choices: ['terminal', 'golden-frijoles', 'notion'],
51
+ question: 'Where should the board live?',
52
+ },
53
+ {
54
+ key: 'verify.depth',
55
+ module: 'Build',
56
+ askWhen: 'never-yet',
57
+ default: 'light',
58
+ choices: ['off', 'light', 'standard', 'deep'],
59
+ question: 'How much proof do you want on risky stories?',
60
+ },
61
+ // ── Just in time ──
62
+ {
63
+ key: 'roadmap.areas',
64
+ module: 'Plan',
65
+ askWhen: 'first-groom',
66
+ default: ['09 Platform & Infra'],
67
+ question: 'Which areas (macro-sections) does this roadmap have? 09 Platform & Infra is reserved.',
68
+ },
69
+ {
70
+ key: 'ways.fillIns',
71
+ module: 'Plan',
72
+ askWhen: 'first-groom',
73
+ default: 'Roadmap/fill-ins.yml',
74
+ question: 'Where are this project\'s WAYS-OF-WORKING fill-ins?',
75
+ },
76
+ {
77
+ key: 'review.families',
78
+ module: 'Build',
79
+ askWhen: 'first-pr',
80
+ default: ['claude'],
81
+ question: 'Which reviewer CLIs do you have (codex, agy, vibe, claude)?',
82
+ },
83
+ {
84
+ key: 'review.reviewScope',
85
+ module: 'Build',
86
+ askWhen: 'first-pr',
87
+ default: 'security-paths-only',
88
+ choices: ['every-pr', 'security-paths-only'],
89
+ question: 'Review every PR, or only PRs that touch security paths?',
90
+ },
91
+ {
92
+ key: 'review.securityPaths',
93
+ module: 'Build',
94
+ askWhen: 'first-pr',
95
+ default: null,
96
+ question: 'Which paths are security-sensitive (globs)? Skipping keeps the template\'s list.',
97
+ },
98
+ {
99
+ key: 'jev.egress',
100
+ module: 'Build',
101
+ askWhen: 'first-pr',
102
+ default: null,
103
+ choices: [true, false],
104
+ question: 'Send PR review text and report drafts to TypeSafe (Jev) to judge their quality? Nothing is sent until you say yes.',
105
+ },
106
+ {
107
+ // NOT `smoke.envs`: that is live-smoke's own {name: url} map, and a list saved there broke it (review of #49).
108
+ key: 'smoke.defaultEnv',
109
+ module: 'Build',
110
+ askWhen: 'first-smoke',
111
+ default: 'local',
112
+ question: 'Which environment should live-smoke check when you don\'t name one (local, preview, production)?',
113
+ },
114
+ {
115
+ key: 'reporting.destination',
116
+ module: 'Operate',
117
+ askWhen: 'first-report',
118
+ default: null,
119
+ question: 'Where should standups and recaps go (Telegram, Slack, or print to the terminal)? Secrets stay in .env.local.',
120
+ },
121
+ {
122
+ key: 'deploy.vercelProject',
123
+ module: 'Operate',
124
+ askWhen: 'first-prune',
125
+ default: null,
126
+ question: 'Which Vercel project should stale previews be counted for?',
127
+ },
128
+ {
129
+ key: 'ship.killSwitchPolicy',
130
+ module: 'Ship',
131
+ askWhen: 'first-high-risk-groom',
132
+ default: 'every-risk-high-story-names-its-flag',
133
+ question: 'Should every risk:high story name its kill-switch flag?',
134
+ },
135
+ {
136
+ key: 'spend.telemetry',
137
+ module: 'Spend',
138
+ askWhen: 'never-yet',
139
+ default: 'off',
140
+ choices: ['off', 'on'],
141
+ question: 'Export cost telemetry?',
142
+ },
143
+ ]);
144
+
145
+ export const MODULES = Object.freeze(['Plan', 'Build', 'Ship', 'Measure', 'Spend', 'Operate']);
@@ -0,0 +1,73 @@
1
+ // Types for lib/config.mjs, the one config core (golden-frijoles-plugin D10). Shipped in @golden-frijoles/kit as
2
+ // `@golden-frijoles/kit/config` so the TypeScript `gf` CLI imports the same rules instead of re-implementing them.
3
+
4
+ export declare const CONFIG_FILENAME: 'golden-frijoles.config.json';
5
+ export declare const SECTIONS: readonly string[];
6
+ export declare const LEGACY: Readonly<Record<string, string>>;
7
+ export declare const NEEDS_SETTING: 'GF-NEEDS-SETTING';
8
+ export declare const EXIT_NEEDS_SETTING: 7;
9
+
10
+ export declare class ConfigError extends Error {}
11
+
12
+ export interface IoOptions {
13
+ root?: string;
14
+ read?: (path: string, encoding: 'utf8') => string;
15
+ exists?: (path: string) => boolean;
16
+ }
17
+
18
+ export interface Section {
19
+ raw: Record<string, unknown> | null;
20
+ /** True when either file has the section, even when its value is `null` in the legacy file. */
21
+ present: boolean;
22
+ sources: string[];
23
+ duplicates: string[];
24
+ }
25
+
26
+ /** The project root: GF_PROJECT_ROOT, else (installed) the nearest ancestor of `cwd` with Roadmap/ or .git, else `cwd`. */
27
+ export declare function projectRoot(opts?: { env?: Record<string, string | undefined>; cwd?: string }): string;
28
+ export declare function readConfigFile(opts?: IoOptions): Record<string, unknown> | null;
29
+ export declare function readSection(
30
+ name: string,
31
+ opts?: IoOptions & {
32
+ legacyPath?: string;
33
+ onLegacyError?: (path: string, error: Error) => never;
34
+ legacyRead?: IoOptions['read'];
35
+ legacyExists?: IoOptions['exists'];
36
+ }
37
+ ): Section;
38
+ export declare function loadConfig(opts?: IoOptions): {
39
+ sections: Record<string, Record<string, unknown>>;
40
+ sources: Record<string, string[]>;
41
+ duplicates: string[];
42
+ /** Top-level keys of golden-frijoles.config.json that are not a known section (ignored on read). */
43
+ unknown: string[];
44
+ };
45
+ export declare function getKey(key: string, opts?: IoOptions): unknown;
46
+ export declare function setKey(
47
+ key: string,
48
+ value: unknown,
49
+ opts?: IoOptions & { write?: (path: string, obj: unknown) => void }
50
+ ): Record<string, unknown>;
51
+ export declare function migrate(
52
+ opts?: IoOptions & { write?: (path: string, obj: unknown) => void; dryRun?: boolean }
53
+ ): { config: Record<string, unknown>; folded: string[]; skipped: string[] };
54
+ export declare function looksLikeSecret(key: string, value: unknown): boolean;
55
+ export declare function needsSettingLine(entry: { key: string; question: string; default: unknown }): string;
56
+ export declare function needSetting(
57
+ key: string,
58
+ opts?: IoOptions & { blocking?: boolean; write?: (s: string) => void; exit?: (code: number) => void }
59
+ ): unknown;
60
+
61
+ export interface RegistryEntry {
62
+ key: string;
63
+ module: 'Plan' | 'Build' | 'Ship' | 'Measure' | 'Spend' | 'Operate';
64
+ askWhen: string;
65
+ default: unknown;
66
+ question: string;
67
+ required?: boolean;
68
+ choices?: readonly unknown[];
69
+ store?: 'env';
70
+ }
71
+
72
+ export declare const REGISTRY: readonly RegistryEntry[];
73
+ export declare const MODULES: readonly ['Plan', 'Build', 'Ship', 'Measure', 'Spend', 'Operate'];
@@ -0,0 +1,384 @@
1
+ // config.mjs — one config file a person can read, and one place that decides where each setting comes from
2
+ // (golden-frijoles-plugin D9, S4.1).
3
+ //
4
+ // `golden-frijoles.config.json` at the project root holds every non-secret setting, one section per module. The
5
+ // seven legacy files keep working: for each section this module merges the legacy file and the new file's section,
6
+ // the NEW FILE WINNING PER TOP-LEVEL KEY, and reports a key set in both as a duplicate (never an error).
7
+ //
8
+ // What this module deliberately does NOT do: validate a section. Every rail already owns a parser that knows its
9
+ // shape and throws its own error (parseJevConfig, validateReportingConfig, live-smoke's validateConfig, …). This
10
+ // only resolves SOURCES and hands the merged raw object to that parser, so a rule lives in one place, not two.
11
+ //
12
+ // Three answers, never two (LEARNINGS): an absent file is a quiet fallback; a present-but-malformed file is a
13
+ // CONFIGURATION failure that names the file; and the rail decides what "no section anywhere" means for it.
14
+ //
15
+ // Secrets never go in this file, only the NAMES of the env vars that hold them. `setKey` refuses a value that looks
16
+ // like a credential.
17
+ //
18
+ // Zero deps. Imported by the kit's `gf-kit config` and, via `@golden-frijoles/kit/config`, by the `gf` CLI (D10).
19
+
20
+ import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
21
+ import { join, resolve, sep } from 'node:path';
22
+ import { projectRoot } from './project-root.mjs';
23
+ import { REGISTRY } from './config-registry.mjs';
24
+
25
+ // One import for every front end (D10): the CLI's doctor and setup read the registry through this module too.
26
+ export { REGISTRY, MODULES } from './config-registry.mjs';
27
+ // The project root this core reads and writes under, exported so the `gf` CLI resolves the SAME directory (D10).
28
+ export { projectRoot } from './project-root.mjs';
29
+
30
+ export const CONFIG_FILENAME = 'golden-frijoles.config.json';
31
+
32
+ export const SECTIONS = Object.freeze([
33
+ 'project',
34
+ 'board',
35
+ 'verify',
36
+ 'roadmap',
37
+ 'ways',
38
+ 'review',
39
+ 'jev',
40
+ 'smoke',
41
+ 'reporting',
42
+ 'deploy',
43
+ 'ship',
44
+ 'spend',
45
+ ]);
46
+
47
+ /**
48
+ * The ONE table of legacy sources: section (or a dotted sub-section) → the file it used to live in, relative to the
49
+ * project root. A rail whose legacy path is computed (reporting's `REPORTING_CONFIG`) passes `legacyPath` instead.
50
+ * `ways` has no legacy file: its setting IS a path, `fillIns`, defaulting to the prose file that stays (X15).
51
+ */
52
+ export const LEGACY = Object.freeze({
53
+ jev: 'jev.config.json',
54
+ reporting: 'reporting.config.json',
55
+ smoke: 'live-smoke.config.json',
56
+ 'smoke.triage': 'smoke-triage.config.json',
57
+ 'smoke.perf': 'perf-probe.config.json',
58
+ review: 'scripts/review-config.json',
59
+ });
60
+
61
+ /** The legacy file for a section, absolute. The one place REPORTING_CONFIG is honoured, for every caller. */
62
+ export function legacyPathFor(name, { root = projectRoot(), env = process.env } = {}) {
63
+ if (name === 'reporting' && env.REPORTING_CONFIG) return resolve(root, env.REPORTING_CONFIG);
64
+ return LEGACY[name] ? resolve(root, LEGACY[name]) : null;
65
+ }
66
+
67
+ /** Sub-sections carved out of a parent: reading `smoke` for live-smoke must not hand it the triage/perf policies. */
68
+ const CARVED = Object.freeze({ smoke: ['triage', 'perf'] });
69
+
70
+ export class ConfigError extends Error {
71
+ constructor(message) {
72
+ super(message);
73
+ this.name = 'ConfigError';
74
+ }
75
+ }
76
+
77
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
78
+
79
+ function parseJsonFile(path, { read, onError }) {
80
+ let text;
81
+ try {
82
+ text = read(path, 'utf8');
83
+ } catch (e) {
84
+ return onError(path, e);
85
+ }
86
+ try {
87
+ return JSON.parse(text);
88
+ } catch (e) {
89
+ return onError(path, e);
90
+ }
91
+ }
92
+
93
+ const configFail = (path, e) => {
94
+ throw new ConfigError(`${path}: not valid JSON (${e.message})`);
95
+ };
96
+
97
+ /**
98
+ * A config file on the real filesystem must RESOLVE inside the project. `config list` prints what it reads, so a
99
+ * checkout whose golden-frijoles.config.json (or a legacy file) is a symlink to ~/.npmrc or a cloud credentials file
100
+ * would otherwise print or migrate that file (security lens on #49). Injected IO (tests) is not a filesystem: skipped.
101
+ */
102
+ function assertContained(path, root, read) {
103
+ if (read !== readFileSync) return;
104
+ const realRoot = realpathSync(root);
105
+ const real = realpathSync(path);
106
+ if (real !== realRoot && !real.startsWith(realRoot + sep)) {
107
+ throw new ConfigError(`${path} resolves outside the project (${real}); refusing to read it.`);
108
+ }
109
+ }
110
+
111
+ /** The project's `golden-frijoles.config.json`, parsed; `null` when absent. Malformed → ConfigError. */
112
+ export function readConfigFile({ root = projectRoot(), read = readFileSync, exists = existsSync } = {}) {
113
+ const path = join(root, CONFIG_FILENAME);
114
+ if (!exists(path)) return null;
115
+ assertContained(path, root, read);
116
+ const json = parseJsonFile(path, { read, onError: configFail });
117
+ if (!isObject(json)) throw new ConfigError(`${path}: must be a JSON object of sections`);
118
+ // An unknown section is IGNORED here, never thrown: a file written by a newer kit or CLI must not break the
119
+ // older rails a consumer copied. `loadConfig` reports it, and `setKey` refuses to write one (typos on write).
120
+ return json;
121
+ }
122
+
123
+ /** Pure — sections a config object names that this version doesn't know. */
124
+ export function unknownSections(json) {
125
+ return Object.keys(json ?? {}).filter((k) => !k.startsWith('$') && !SECTIONS.includes(k));
126
+ }
127
+
128
+ /** Walk a dotted path into an object; undefined when any step is missing. */
129
+ function dig(obj, dotted) {
130
+ let cur = obj;
131
+ for (const part of dotted.split('.')) {
132
+ if (!isObject(cur) || !(part in cur)) return undefined;
133
+ cur = cur[part];
134
+ }
135
+ return cur;
136
+ }
137
+
138
+ /**
139
+ * One section, merged. `name` may be dotted (`smoke.triage`).
140
+ *
141
+ * Returns `{ raw, sources, duplicates }`: `raw` is `null` when neither the new file nor the legacy file has it (the
142
+ * rail decides what that means), otherwise the legacy object with the new file's top-level keys laid over it.
143
+ *
144
+ * `legacyPath` overrides the table (absolute, or relative to the root). `onLegacyError(path, error)` lets a rail keep
145
+ * throwing ITS OWN error for an unparseable legacy file, so a legacy-only repo behaves byte-identically.
146
+ */
147
+ export function readSection(
148
+ name,
149
+ {
150
+ root = projectRoot(),
151
+ read = readFileSync,
152
+ exists = existsSync,
153
+ legacyPath,
154
+ env = process.env,
155
+ onLegacyError = configFail,
156
+ // A rail's injected IO has always meant "my legacy file", and its tests rely on that, so it answers ONLY for
157
+ // the legacy file. The new file is looked up with `read`/`exists` (the real filesystem unless a test injects
158
+ // those too).
159
+ legacyRead = read,
160
+ legacyExists = exists,
161
+ } = {}
162
+ ) {
163
+ const file = readConfigFile({ root, read, exists });
164
+ let fromNew = file ? dig(file, name) : undefined;
165
+ if (fromNew !== undefined && !isObject(fromNew)) {
166
+ throw new ConfigError(`${join(root, CONFIG_FILENAME)}: "${name}" must be an object`);
167
+ }
168
+ if (fromNew && CARVED[name]) {
169
+ fromNew = Object.fromEntries(Object.entries(fromNew).filter(([k]) => !CARVED[name].includes(k)));
170
+ }
171
+ // `null` in the new file means "unset", never "override the legacy value with null": saving a registry default
172
+ // of null (jev.egress) must not re-enable something a legacy file had turned off.
173
+ if (fromNew) fromNew = Object.fromEntries(Object.entries(fromNew).filter(([, v]) => v !== null));
174
+
175
+ const legacyAbs = legacyPath ? resolve(root, legacyPath) : legacyPathFor(name, { root, env });
176
+ let fromLegacy;
177
+ if (legacyAbs && legacyExists(legacyAbs)) {
178
+ // REPORTING_CONFIG is the operator's env, not the checkout's: it may name a file anywhere.
179
+ if (!(name === 'reporting' && env.REPORTING_CONFIG && !legacyPath)) assertContained(legacyAbs, root, legacyRead);
180
+ fromLegacy = parseJsonFile(legacyAbs, { read: legacyRead, onError: onLegacyError });
181
+ }
182
+
183
+ const sources = [];
184
+ if (fromLegacy !== undefined) sources.push(legacyAbs);
185
+ if (fromNew !== undefined) sources.push(join(root, CONFIG_FILENAME));
186
+ // `present` is the rail's absence test, never `raw === null`: a legacy file holding JSON null is PRESENT and
187
+ // malformed, and must reach the rail's own parser (which throws), not fall back to defaults.
188
+ if (fromLegacy === undefined && fromNew === undefined) return { raw: null, present: false, sources, duplicates: [] };
189
+ if (fromNew === undefined) return { raw: fromLegacy, present: true, sources, duplicates: [] };
190
+ if (fromLegacy === undefined) return { raw: { ...fromNew }, present: true, sources, duplicates: [] };
191
+ // A legacy file that isn't an object is the rail's parser's error to report, even when the new file has keys.
192
+ if (!isObject(fromLegacy)) return { raw: fromLegacy, present: true, sources, duplicates: [] };
193
+ const duplicates = Object.keys(fromNew).filter((k) => k in fromLegacy && !k.startsWith('$'));
194
+ return { raw: { ...fromLegacy, ...fromNew }, present: true, sources, duplicates };
195
+ }
196
+
197
+ /** Every section's effective value, for `list`/`doctor`. Never throws for an absent file. */
198
+ export function loadConfig({ root = projectRoot(), read = readFileSync, exists = existsSync } = {}) {
199
+ const sections = {};
200
+ const sources = {};
201
+ const duplicates = [];
202
+ const unknown = unknownSections(readConfigFile({ root, read, exists }));
203
+ for (const name of [...SECTIONS, ...Object.keys(LEGACY).filter((k) => k.includes('.'))]) {
204
+ const r = readSection(name, { root, read, exists });
205
+ if (r.raw === null) continue;
206
+ sections[name] = r.raw;
207
+ sources[name] = r.sources;
208
+ for (const k of r.duplicates) duplicates.push(`${name}.${k}`);
209
+ }
210
+ return { sections, sources, duplicates, unknown };
211
+ }
212
+
213
+ /** The effective value of a dotted key (`review.reviewScope`), falling back to the registry default. */
214
+ export function getKey(key, opts = {}) {
215
+ const [head, ...rest] = key.split('.');
216
+ // A dotted sub-section with its own legacy file (smoke.triage.*) is resolved as that sub-section.
217
+ const sub = rest.length && LEGACY[`${head}.${rest[0]}`] ? `${head}.${rest.shift()}` : head;
218
+ const { raw } = readSection(sub, opts);
219
+ const value = rest.length ? dig(raw ?? {}, rest.join('.')) : raw ?? undefined;
220
+ if (value !== undefined) return value;
221
+ const reg = REGISTRY.find((r) => r.key === key);
222
+ return reg ? reg.default : undefined;
223
+ }
224
+
225
+ // ── Writing ─────────────────────────────────────────────────────────────────────────────────────
226
+
227
+ const TOKEN_PREFIXES = /^(sk-|sk_|ghp_|gho_|ghs_|github_pat_|xox[abpr]-|AKIA|eyJ|npm_|glpat-|tsk_|gf_pat_)/;
228
+ // A URL carrying a password in its userinfo (postgres://user:pass@host/db).
229
+ const URL_WITH_PASSWORD = /^[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/i;
230
+ const TELEGRAM_BOT_TOKEN = /^\d{6,}:[A-Za-z0-9_-]{30,}$/;
231
+ const SLACK_WEBHOOK = /^https:\/\/hooks\.slack\.com\//;
232
+ const SECRET_KEY = /(token|secret|password|apikey|api_key|privatekey|webhook)$/i;
233
+ const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
234
+
235
+ /**
236
+ * Pure — does this key/value pair look like a credential rather than the NAME of an env var? A token prefix only
237
+ * counts on a value long enough to be a token (`sk-shop` or `npm_utils` is a name, not a key).
238
+ */
239
+ export function looksLikeSecret(key, value) {
240
+ if (typeof value !== 'string') return false;
241
+ if (TOKEN_PREFIXES.test(value) && value.length >= 20) return true;
242
+ if (TELEGRAM_BOT_TOKEN.test(value) || SLACK_WEBHOOK.test(value) || URL_WITH_PASSWORD.test(value)) return true;
243
+ const leaf = key.split('.').pop();
244
+ return SECRET_KEY.test(leaf) && !ENV_NAME.test(value);
245
+ }
246
+
247
+ /** Pure — every dotted path under `key` whose value looks like a secret, walking nested objects and arrays. */
248
+ export function findSecrets(key, value) {
249
+ if (Array.isArray(value)) return value.flatMap((v, i) => findSecrets(`${key}.${i}`, v));
250
+ if (isObject(value)) return Object.entries(value).flatMap(([k, v]) => findSecrets(`${key}.${k}`, v));
251
+ return looksLikeSecret(key, value) ? [key] : [];
252
+ }
253
+
254
+ const FORBIDDEN_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
255
+
256
+ function writeAtomic(path, obj) {
257
+ const tmp = `${path}.${process.pid}.tmp`;
258
+ writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`);
259
+ renameSync(tmp, path);
260
+ }
261
+
262
+ /**
263
+ * Set a dotted key in the project's config file (created if absent). The first segment must be a known section.
264
+ * A value that looks like a credential is refused: put the env var's NAME here, never its value.
265
+ */
266
+ export function setKey(key, value, { root = projectRoot(), read = readFileSync, exists = existsSync, write = writeAtomic } = {}) {
267
+ const parts = key.split('.');
268
+ if (parts.length < 2 || !SECTIONS.includes(parts[0])) {
269
+ throw new ConfigError(`"${key}": a key is <section>.<name>, with section one of ${SECTIONS.join(', ')}`);
270
+ }
271
+ if (parts.some((p) => FORBIDDEN_SEGMENTS.has(p) || p === '')) throw new ConfigError(`"${key}": not a valid key`);
272
+ const secrets = findSecrets(key, value);
273
+ if (secrets.length) {
274
+ throw new ConfigError(
275
+ `"${secrets[0]}" looks like a secret. Secrets never go in ${CONFIG_FILENAME}: keep the value in .env.local ` +
276
+ 'and put the env var NAME here (e.g. "TYPESAFE_API_KEY").'
277
+ );
278
+ }
279
+ const file = readConfigFile({ root, read, exists }) ?? {};
280
+ // A write deeper than <section>.<name> must not WIPE the legacy siblings of what it changes: the new file's
281
+ // top-level key replaces the legacy one wholesale (D9), so seed it from the effective merged value first.
282
+ if (parts.length > 2) {
283
+ const [section, top] = parts;
284
+ const own = file[section]?.[top];
285
+ if (own === undefined) {
286
+ const effective = readSection(section, { root, read, exists }).raw?.[top];
287
+ if (isObject(effective)) {
288
+ file[section] = isObject(file[section]) ? file[section] : {};
289
+ file[section][top] = structuredClone(effective);
290
+ }
291
+ }
292
+ }
293
+ let cur = file;
294
+ for (const part of parts.slice(0, -1)) {
295
+ if (!isObject(cur[part])) cur[part] = {};
296
+ cur = cur[part];
297
+ }
298
+ cur[parts.at(-1)] = value;
299
+ write(join(root, CONFIG_FILENAME), file);
300
+ return file;
301
+ }
302
+
303
+ /**
304
+ * Fold every legacy file into the new file's sections (new file wins per key). Legacy files are NEVER touched —
305
+ * they keep working as fallbacks. Keys that look like secrets are skipped and reported, never copied.
306
+ * Returns `{ config, folded, skipped }`; `dryRun` writes nothing.
307
+ */
308
+ export function migrate({
309
+ root = projectRoot(),
310
+ read = readFileSync,
311
+ exists = existsSync,
312
+ write = writeAtomic,
313
+ env = process.env,
314
+ dryRun = false,
315
+ } = {}) {
316
+ const config = readConfigFile({ root, read, exists }) ?? {};
317
+ const folded = [];
318
+ const skipped = [];
319
+ for (const name of Object.keys(LEGACY)) {
320
+ const abs = legacyPathFor(name, { root, env });
321
+ if (!exists(abs)) continue;
322
+ if (!(name === 'reporting' && env.REPORTING_CONFIG)) assertContained(abs, root, read);
323
+ const legacy = parseJsonFile(abs, { read, onError: configFail });
324
+ if (!isObject(legacy)) continue;
325
+ const [head, sub] = name.split('.');
326
+ config[head] = isObject(config[head]) ? config[head] : {};
327
+ const target = sub ? (config[head][sub] = isObject(config[head][sub]) ? config[head][sub] : {}) : config[head];
328
+ for (const [k, v] of Object.entries(legacy)) {
329
+ if (k.startsWith('$') || k.startsWith('_')) continue; // comments/notes stay with the legacy file
330
+ if (k in target) continue; // the new file wins
331
+ const secrets = findSecrets(`${name}.${k}`, v);
332
+ if (secrets.length) {
333
+ skipped.push(...secrets); // the whole key stays behind: a partial copy of a credentials block helps nobody
334
+ continue;
335
+ }
336
+ target[k] = v;
337
+ folded.push(`${name}.${k}`);
338
+ }
339
+ }
340
+ for (const head of Object.keys(config)) if (isObject(config[head]) && !Object.keys(config[head]).length) delete config[head];
341
+ if (!dryRun && folded.length) write(join(root, CONFIG_FILENAME), config);
342
+ return { config, folded, skipped };
343
+ }
344
+
345
+ // ── The ask protocol (D11) ──────────────────────────────────────────────────────────────────────
346
+
347
+ export const NEEDS_SETTING = 'GF-NEEDS-SETTING';
348
+ export const EXIT_NEEDS_SETTING = 7;
349
+
350
+ /** Pure — the one stderr line an agent looks for. */
351
+ export function needsSettingLine(entry) {
352
+ return `${NEEDS_SETTING} ${JSON.stringify({ key: entry.key, question: entry.question, default: entry.default })}`;
353
+ }
354
+
355
+ /**
356
+ * A script needs a registered setting that is unset. Prints the protocol line once per process. `blocking` exits 7
357
+ * (the script cannot continue without an answer); otherwise it returns the registry default and carries on.
358
+ * A set key returns its value and prints nothing.
359
+ */
360
+ const asked = new Set();
361
+ export function needSetting(
362
+ key,
363
+ { blocking = false, root = projectRoot(), read = readFileSync, exists = existsSync, write = (s) => process.stderr.write(s), exit = process.exit } = {}
364
+ ) {
365
+ const entry = REGISTRY.find((r) => r.key === key);
366
+ if (!entry) throw new ConfigError(`needSetting("${key}"): not in the registry (lib/config-registry.mjs)`);
367
+ const [head, ...rest] = key.split('.');
368
+ const { raw } = readSection(head, { root, read, exists });
369
+ const value = rest.length ? dig(raw ?? {}, rest.join('.')) : raw;
370
+ const isSet = value !== undefined && value !== null;
371
+ if (isSet) return value;
372
+ if (!asked.has(key)) {
373
+ asked.add(key);
374
+ write(`${needsSettingLine(entry)}\n`);
375
+ }
376
+ if (blocking) exit(EXIT_NEEDS_SETTING);
377
+ return entry.default;
378
+ }
379
+
380
+ /** Test seam: forget which keys this process already asked about. */
381
+ export function _resetAsked() {
382
+ asked.clear();
383
+ }
384
+
package/dist/lib/jev.mjs CHANGED
@@ -14,6 +14,13 @@
14
14
  // 3. ONE COMMITTED SWITCH. `jev.config.json → rails.<rail>.mode: off | shadow | jev`. A missing file is
15
15
  // the defaults (everything off); a MALFORMED file throws, so a typo never silently means "off".
16
16
  // `egress:false` or no key makes the effective mode `off`, with the reason.
17
+ // 4. EGRESS IS A TRI-STATE, AND UNANSWERED BEHAVES AS NO (golden-frijoles-plugin D12, S5.4). `egress`
18
+ // is `true | false | null`. The template ships `null`: a stranger's diffs never leave their machine
19
+ // until they say so. `null` and `false` both make the effective mode `off`; `null` ADDITIONALLY
20
+ // emits the D11 ask (`needSetting('jev.egress', { blocking: false })`) once per process, so an agent
21
+ // asks the question instead of the guard silently staying off forever. The fallback reason for `null`
22
+ // is `egress not answered`: `review-guard.mjs` wraps it into the reason `jev could not look (egress not
23
+ // answered)`, and `prose-guard.mjs`'s judgeProse returns it as `why`. Existing consumers keep their committed `egress: true`.
17
24
  //
18
25
  // Zero deps — Node 18+ (global fetch). Everything with I/O takes injectable deps, so specs never touch the
19
26
  // network.
@@ -22,6 +29,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
22
29
  import { createHash } from 'node:crypto';
23
30
  import { join } from 'node:path';
24
31
  import { projectRoot } from './project-root.mjs';
32
+ import { needSetting, readSection } from './config.mjs';
25
33
 
26
34
  export const JEV_ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
27
35
  export const DEFAULT_MODEL = 'jev-1.13.0';
@@ -74,8 +82,13 @@ export function parseJevConfig(json) {
74
82
  throw new JevConfigError(
75
83
  `jev.config.json: model "${model}" is an alias — pin a version such as ${DEFAULT_MODEL}`
76
84
  );
77
- const egress = json.egress ?? true;
78
- if (typeof egress !== 'boolean') throw new JevConfigError('jev.config.json: egress must be true or false');
85
+ // Tri-state (D12): `true | false | null`. An explicit `null` must survive as `null` — `??` would
86
+ // silently turn it back into `true`, which is exactly the "unanswered defaults to send" bug this
87
+ // decision exists to close. Only an ABSENT key falls back to `true` (today's behaviour for a
88
+ // consumer's committed config that never mentions egress at all).
89
+ const egress = Object.prototype.hasOwnProperty.call(json, 'egress') ? json.egress : true;
90
+ if (egress !== true && egress !== false && egress !== null)
91
+ throw new JevConfigError('jev.config.json: egress must be true, false or null');
79
92
  if (json.rails !== undefined && !isObj(json.rails))
80
93
  throw new JevConfigError('jev.config.json: rails must be an object');
81
94
  const rails = {};
@@ -112,15 +125,25 @@ export function parseJevConfig(json) {
112
125
 
113
126
  /** Read `<root>/jev.config.json`. Missing ⇒ defaults; unreadable or malformed ⇒ throws. */
114
127
  export function loadJevConfig({ root = repoRoot(), read = readFileSync, exists = existsSync } = {}) {
115
- const path = join(root, 'jev.config.json');
116
- if (!exists(path)) return parseJevConfig({});
117
- let json;
118
- try {
119
- json = JSON.parse(read(path, 'utf8'));
120
- } catch (e) {
121
- throw new JevConfigError(`jev.config.json: unparseable (${e.message})`);
122
- }
123
- return parseJevConfig(json);
128
+ // The `jev` section of golden-frijoles.config.json over the legacy jev.config.json (D9); validation stays here.
129
+ const { raw, present } = readSection('jev', {
130
+ root,
131
+ legacyRead: read,
132
+ legacyExists: exists,
133
+ onLegacyError: (_path, e) => {
134
+ throw new JevConfigError(`jev.config.json: unparseable (${e.message})`);
135
+ },
136
+ });
137
+ // Absent everywhere → the defaults (every rail off). A PRESENT legacy file holding JSON null is malformed: the
138
+ // parser throws.
139
+ if (!present) return parseJevConfig({});
140
+ // D12: a section that never gives egress a non-null value is UNANSWERED, never `true`. readSection treats a
141
+ // `null` in golden-frijoles.config.json as unset, so without this a new-file `egress: null` (or a migrated
142
+ // template config) reached parseJevConfig as a MISSING key and became `true` — sending with nobody's yes
143
+ // (review of the S5 diff). Only parseJevConfig called directly keeps the absent → true fallback.
144
+ if (raw && typeof raw === 'object' && !Array.isArray(raw) && (raw.egress === undefined || raw.egress === null))
145
+ return parseJevConfig({ ...raw, egress: null });
146
+ return parseJevConfig(raw);
124
147
  }
125
148
 
126
149
  /** Parse `KEY=value` lines. Enough for .env.local; quotes stripped. */
@@ -161,7 +184,10 @@ export function readApiKey({
161
184
  export function effectiveMode(config, rail, { key } = {}) {
162
185
  const configured = config.rails[rail].mode;
163
186
  if (configured === 'off') return { mode: 'off', configured, why: 'configured off' };
164
- if (!config.egress) return { mode: 'off', configured, why: 'egress disabled in jev.config.json' };
187
+ // Unanswered (D12): distinct from a deliberate `false` so the caller can ask once instead of
188
+ // staying silently off forever. `unanswered: true` is jevContext's cue to fire the D11 protocol.
189
+ if (config.egress === null) return { mode: 'off', configured, why: 'egress not answered', unanswered: true };
190
+ if (!config.egress) return { mode: 'off', configured, why: 'egress disabled (jev.egress: false)' };
165
191
  if (!key) return { mode: 'off', configured, why: 'no TYPESAFE_API_KEY' };
166
192
  return { mode: configured, configured, why: `configured ${configured}` };
167
193
  }
@@ -322,6 +348,18 @@ export function jevContext(rail, deps = {}) {
322
348
  const config = deps.config ?? loadJevConfig({ root });
323
349
  const key = 'key' in deps ? deps.key : readApiKey({ root });
324
350
  const eff = effectiveMode(config, rail, { key });
351
+ // D11's ask protocol, fired exactly once (needSetting's own dedup): egress was never answered, so
352
+ // tell the agent to ask instead of leaving the guard silently off forever. Non-blocking — a judge
353
+ // still returns a decision this run, via the regex fallback the caller already has for `mode:'off'`.
354
+ if (eff.unanswered) {
355
+ (deps.needSetting ?? needSetting)('jev.egress', {
356
+ blocking: false,
357
+ root,
358
+ ...(deps.read ? { read: deps.read } : {}),
359
+ ...(deps.exists ? { exists: deps.exists } : {}),
360
+ ...(deps.write ? { write: deps.write } : {}),
361
+ });
362
+ }
325
363
  return {
326
364
  root,
327
365
  config,
@@ -23,8 +23,8 @@
23
23
  // `--root <dir>` is parsed by kit/bin.mjs only, which exports it as GF_PROJECT_ROOT — no script's own argument
24
24
  // parser ever sees a new flag. Zero deps; no side effects at import.
25
25
 
26
- import { existsSync, readFileSync } from 'node:fs';
27
- import { dirname, join, resolve } from 'node:path';
26
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
27
+ import { dirname, join, resolve, sep } from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
29
 
30
30
  export const KIT_PACKAGE_NAME = '@golden-frijoles/kit';
@@ -72,7 +72,27 @@ export function projectRoot({
72
72
  }
73
73
 
74
74
  /** A project-owned file with a kit default: the project's `scripts/<rel>` if present, else the kit's `<rel>`. */
75
- export function projectAsset(rel, { project = projectRoot(), root = kitRoot(), exists = existsSync } = {}) {
75
+ export class ProjectAssetError extends Error {
76
+ constructor(message) {
77
+ super(message);
78
+ this.name = 'ProjectAssetError';
79
+ }
80
+ }
81
+
82
+ export function projectAsset(
83
+ rel,
84
+ { project = projectRoot(), root = kitRoot(), exists = existsSync, real = realpathSync } = {}
85
+ ) {
76
86
  const own = join(project, 'scripts', rel);
77
- return exists(own) ? own : join(root, rel);
87
+ if (!exists(own)) return join(root, rel);
88
+ // A project-owned asset is read into prompts some rails send to an external model, so it must be the project's
89
+ // own file. A symlink that leaves the project (to .env.local's neighbours, ~/.ssh, …) is refused loudly: it is
90
+ // a configuration problem a person has to look at, never something to follow (golden-frijoles-plugin X20).
91
+ const realOwn = real(own);
92
+ const realProject = real(project);
93
+ if (realOwn !== realProject && !realOwn.startsWith(realProject + sep)) {
94
+ throw new ProjectAssetError(`${own} resolves outside the project (${realOwn}); refusing to read it.`);
95
+ }
96
+ // The REAL path, already checked: returning the symlink would let it be swapped between this check and the read.
97
+ return realOwn;
78
98
  }
@@ -24,10 +24,10 @@
24
24
  // report with the adjectives swapped.
25
25
 
26
26
  import { readFileSync, existsSync } from 'node:fs';
27
- import { join } from 'node:path';
27
+ import { dirname, join, resolve } from 'node:path';
28
28
  import { loadPromptBody } from './cross-agent-cli.mjs';
29
29
  import { loadLessons, buildWriterPrompt } from './prose-writer.mjs';
30
- import { projectAsset } from './project-root.mjs';
30
+ import { kitRoot, projectAsset, projectRoot } from './project-root.mjs';
31
31
 
32
32
  /**
33
33
  * Read a sprint doc's `**Status:**` line. Pure-ish (single read), used to describe sprint movement in
@@ -134,8 +134,14 @@ export function buildQuietBrief(windowLabel) {
134
134
  /** Load the shared persona + a per-surface task file. */
135
135
  export function loadPersonaAndTask(scriptsDir, surface) {
136
136
  return [
137
- // The persona is a TEMPLATE FILL-IN the project owns (D2); the task file is the kit's.
138
- loadPromptBody(projectAsset('prose/cpo-persona.md', { root: scriptsDir })),
137
+ // The persona is a TEMPLATE FILL-IN the project owns (D2); the task file is the kit's. An explicit scriptsDir that
138
+ // isn't this set's own names its project directly (golden-frijoles-plugin X20): a fixture gets ITS persona.
139
+ loadPromptBody(
140
+ projectAsset('prose/cpo-persona.md', {
141
+ root: scriptsDir,
142
+ project: resolve(scriptsDir) === kitRoot() ? projectRoot() : dirname(resolve(scriptsDir)),
143
+ })
144
+ ),
139
145
  loadPromptBody(join(scriptsDir, 'prose', `${surface}.task.md`)),
140
146
  ].join('\n\n');
141
147
  }
@@ -707,7 +707,9 @@ export async function judgeProse(draft, evidence = {}, deps = {}) {
707
707
  const units = proseUnits(draft);
708
708
  const questions = proseQuestions(units, evidence);
709
709
  if (ctx.mode === 'off' || !questions.length || !String(draft ?? '').trim()) {
710
- return { ...regex, decider: 'regex', mode: ctx.mode, regexCodes, jevCodes: null, fallback: [] };
710
+ // `why` names what kept Jev out (`egress not answered`, `no TYPESAFE_API_KEY`, …), the way the review judge's
711
+ // reason does, so a regex-decided draft never reads like the configured path.
712
+ return { ...regex, decider: 'regex', mode: ctx.mode, why: ctx.why, regexCodes, jevCodes: null, fallback: [] };
711
713
  }
712
714
 
713
715
  const chunks = [];
@@ -29,6 +29,7 @@
29
29
  import { readFileSync, existsSync } from 'node:fs';
30
30
  import { dirname, join, resolve } from 'node:path';
31
31
  import { projectRoot } from './project-root.mjs';
32
+ import { readSection } from './config.mjs';
32
33
 
33
34
  export const DEFAULT_ROOT = projectRoot(); // D2
34
35
  export const CONFIG_FILENAME = 'reporting.config.json';
@@ -219,7 +220,16 @@ export function loadReportingConfig({
219
220
  exists = existsSync,
220
221
  } = {}) {
221
222
  const path = configPath({ root, env });
222
- if (!exists(path)) {
223
+ // The `reporting` section of golden-frijoles.config.json over the legacy file (D9); REPORTING_CONFIG still moves
224
+ // the legacy file, the local override still layers on top, and validation stays here.
225
+ const section = readSection('reporting', {
226
+ root,
227
+ legacyRead: read,
228
+ legacyExists: exists,
229
+ legacyPath: path,
230
+ onLegacyError: (p, e) => fail(p, `is not valid JSON (${e.message})`),
231
+ });
232
+ if (!section.present) {
223
233
  throw new ReportingConfigError(
224
234
  `${path} not found — the reporting scripts refuse to guess which repos to read or where to post.\n` +
225
235
  ` Copy ${EXAMPLE_FILENAME} to ${CONFIG_FILENAME} at the repo root, fill it in, and commit it.`
@@ -232,7 +242,7 @@ export function loadReportingConfig({
232
242
  return fail(p, `is not valid JSON (${e.message})`);
233
243
  }
234
244
  };
235
- let raw = parse(path);
245
+ let raw = section.raw;
236
246
  const localPath = join(dirname(path), LOCAL_FILENAME);
237
247
  if (exists(localPath)) raw = mergeLocal(raw, parse(localPath));
238
248
  return validateReportingConfig(raw, path);
@@ -35,6 +35,7 @@ import { existsSync, readFileSync, rmSync, realpathSync } from 'node:fs';
35
35
  import { fileURLToPath } from 'node:url';
36
36
  import { join, isAbsolute, normalize } from 'node:path';
37
37
  import { projectRoot } from './lib/project-root.mjs';
38
+ import { readSection } from './lib/config.mjs';
38
39
 
39
40
  const REPO_ROOT = projectRoot(); // D2
40
41
  export const CONFIG_FILENAME = 'live-smoke.config.json';
@@ -209,13 +210,20 @@ export function planRun({ args, config, env = {}, dotenv = {} }) {
209
210
 
210
211
  export function loadConfig({ root = REPO_ROOT } = {}) {
211
212
  const path = join(root, CONFIG_FILENAME);
212
- if (!existsSync(path)) {
213
+ // The `smoke` section of golden-frijoles.config.json over the legacy file (D9); validation stays here.
214
+ const { raw, present } = readSection('smoke', {
215
+ root,
216
+ onLegacyError: (_p, e) => {
217
+ throw e;
218
+ },
219
+ });
220
+ if (!present) {
213
221
  throw new Error(
214
222
  `${path} not found — live-smoke refuses to guess which app and which URLs to smoke.\n` +
215
223
  ` Copy live-smoke.config.example.json to ${CONFIG_FILENAME}, fill it in, and commit it.`
216
224
  );
217
225
  }
218
- return validateConfig(JSON.parse(readFileSync(path, 'utf8')), path);
226
+ return validateConfig(raw, path);
219
227
  }
220
228
 
221
229
  function die(message) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "The scripts the Golden Frijoles skills run, packaged so they work in any repo: planning, build-order, reporting and verification rails. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,5 +29,13 @@
29
29
  ],
30
30
  "publishConfig": {
31
31
  "access": "public"
32
+ },
33
+ "exports": {
34
+ ".": "./bin.mjs",
35
+ "./config": {
36
+ "types": "./dist/lib/config.d.mts",
37
+ "default": "./dist/lib/config.mjs"
38
+ },
39
+ "./package.json": "./package.json"
32
40
  }
33
41
  }