@golden-frijoles/kit 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+
@@ -156,6 +156,31 @@ export function compareVersions(a, b) {
156
156
  return left.pre < right.pre ? -1 : 1;
157
157
  }
158
158
 
159
+ /**
160
+ * The ONE install prompt a stranger pastes into their agent (golden-frijoles-plugin S3.3/S3.4, D6/D8/X10).
161
+ *
162
+ * This is a CONSTANT, not this repo's business to author: golden-beans `apps/web/lib/install-prompt.ts`
163
+ * is the SOURCE (`export const INSTALL_PROMPT`), and it names only `github.com` /
164
+ * `raw.githubusercontent.com` URLs and no site URL — so unlike `getSiteUrl()`-backed strings there, it
165
+ * needs no per-environment origin and is safe to transcribe byte-for-byte. This is that transcription,
166
+ * exactly as golden-beans' `CopyPromptCard` renders it on the closing CTA, `/install` and onboarding.
167
+ * `scripts/check-onboarding-parity.mjs` asserts this string appears VERBATIM in the repo README.md and
168
+ * the `golden-frijoles` umbrella SKILL.md — a one-word drift fails it. Never hand-edit this without the
169
+ * source (golden-beans `apps/web/lib/install-prompt.ts`) changing first.
170
+ *
171
+ * Text: the audit's §3.1 prompt (`Roadmap/00-ideas/audits/golden-frijoles-unification-2026-09-23.md`,
172
+ * golden-beans checkout), verbatim, its soft-wrapped blockquote lines joined into the one paragraph it
173
+ * renders as.
174
+ */
175
+ export const INSTALL_PROMPT =
176
+ "Install the golden-frijoles plugin. If you're in Claude Code, run `claude plugin marketplace add " +
177
+ 'golden-frijoles/skills`, then `claude plugin install golden-frijoles@golden-frijoles`. If you\'re in ' +
178
+ 'another agent, run `npx skills add golden-frijoles/skills --skill \'*\'` and select your ' +
179
+ 'agent. Use one installation method. You can read the skill directly at ' +
180
+ 'https://github.com/golden-frijoles/skills/blob/main/plugins/golden-frijoles/skills/golden-frijoles/SKILL.md ' +
181
+ '(raw: https://raw.githubusercontent.com/golden-frijoles/skills/main/plugins/golden-frijoles/skills/golden-frijoles/SKILL.md). ' +
182
+ 'Then use the golden-frijoles skill when working on this project, and start with its setup.';
183
+
159
184
  /**
160
185
  * The value of `name` in a dotenv file, or null.
161
186
  *
package/dist/lib/jev.mjs CHANGED
@@ -22,6 +22,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
22
22
  import { createHash } from 'node:crypto';
23
23
  import { join } from 'node:path';
24
24
  import { projectRoot } from './project-root.mjs';
25
+ import { readSection } from './config.mjs';
25
26
 
26
27
  export const JEV_ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
27
28
  export const DEFAULT_MODEL = 'jev-1.13.0';
@@ -112,15 +113,17 @@ export function parseJevConfig(json) {
112
113
 
113
114
  /** Read `<root>/jev.config.json`. Missing ⇒ defaults; unreadable or malformed ⇒ throws. */
114
115
  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);
116
+ // The `jev` section of golden-frijoles.config.json over the legacy jev.config.json (D9); validation stays here.
117
+ const { raw, present } = readSection('jev', {
118
+ root,
119
+ legacyRead: read,
120
+ legacyExists: exists,
121
+ onLegacyError: (_path, e) => {
122
+ throw new JevConfigError(`jev.config.json: unparseable (${e.message})`);
123
+ },
124
+ });
125
+ // Absent everywhere → the defaults. A PRESENT legacy file holding JSON null is malformed: the parser throws.
126
+ return parseJevConfig(present ? raw : {});
124
127
  }
125
128
 
126
129
  /** Parse `KEY=value` lines. Enough for .env.local; quotes stripped. */
@@ -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
  }
@@ -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) {