@golden-frijoles/kit 0.32.0 → 0.34.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.
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // epic-read.mjs — read an epic's result on its read date: draft the verdict with its evidence; write it on approval.
3
3
  //
4
- // node scripts/epic-read.mjs --epic <slug> # when is the read, and what it needs
4
+ // node scripts/epic-read.mjs --epic <slug> # fetches the number through gf, drafts
5
+ // node scripts/epic-read.mjs --epic <slug> --experiment smart-defaults # … and cites the A/B decision record
5
6
  // node scripts/epic-read.mjs --epic <slug> --actual 72 --evidence north-star:invoices_paid_on_time@2026-11-04
6
7
  // node scripts/epic-read.mjs --epic <slug> --evidence "traffic too low (n = 18)" # no number: unclear
7
8
  // node scripts/epic-read.mjs --epic <slug> --verdict proven --evidence https://… # an owner verdict
8
9
  // … --write # the approval: stamp verdict_* into the README
9
- // options: --today YYYY-MM-DD (default: today, UTC) · --repo-root <dir> · --json
10
+ // options: --project <slug> (gf's; default: the remembered one) · --today YYYY-MM-DD (default: today, UTC)
11
+ // · --repo-root <dir> · --json · GF_BIN=<path to gf> (default: gf on PATH)
10
12
  //
11
13
  // ── The shape (result-record D8) ─────────────────────────────────────────────────────────────────────────────────
12
14
  // The agent does the legwork, the owner decides. Without `--write` this prints a draft and the exact command that
@@ -22,15 +24,19 @@
22
24
  // • More than 90 days after shipping, the read is still written, and said to be late (the extract marks it).
23
25
  // • An epic shipped with no target can be read too — an owner verdict and its evidence, one epic per run.
24
26
  //
25
- // ── With an account, and without ─────────────────────────────────────────────────────────────────────────────────
26
- // No route reads a North Star reading or an A/B decision for a key yet (the lock checked: `gf north-star` only sets,
27
- // `GET /api/v1/north-star` lists metrics and inputs with no values), so the actual always comes from the owner. With the
28
- // project's key (the one `roadmap-push` uses) this asks that route whether `target_metric` is one of the project's
29
- // inputs — grounded or not. No key, or any failure, is "could not check", and the read carries on.
30
- //
27
+ // ── The agent fetches the number (result-record S3, D17) ─────────────────────────────────────────────────────────
28
+ // With a target and no `--actual`, this runs `gf north-star readings <target_metric> --to <today> --json` and takes
29
+ // `latest`: the actual is its value, the evidence `north-star:<input>@<its day>`. A reading dated before the epic shipped
30
+ // is no evidence for it. `--experiment <key>` also runs `gf experiments decision <key> --json`; a recorded decision
31
+ // becomes the evidence, `ab:<key>`. gf is spawned without a shell (`$GF_BIN`, else `gf` on PATH), so a shell alias of
32
+ // the same name never applies. `--actual` / `--evidence` still win: they are the owner's word. No gf, not signed in, no
33
+ // project chosen, or any failure is one "could not fetch (why)" line, and the read asks the owner as before — it never
34
+ // fails because the platform is not linked. Grounded is now "the readings route knows this input".
35
+
31
36
  // Zero deps — Node 18+.
32
37
  import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
33
38
  import { join, resolve } from 'node:path';
39
+ import { spawnSync } from 'node:child_process';
34
40
  import { fileURLToPath } from 'node:url';
35
41
  import { projectRoot } from './lib/project-root.mjs';
36
42
  import {
@@ -39,10 +45,9 @@ import {
39
45
  parseDocFrontmatter,
40
46
  validateResultFields,
41
47
  } from './lib/roadmap-contract.mjs';
42
- import { READ_CAP_DAYS, isLate, readDateOf, todayUtc, isDay } from './lib/result-dates.mjs';
48
+ import { READ_CAP_DAYS, addDays, dayOf, isLate, readDateOf, todayUtc, isDay } from './lib/result-dates.mjs';
43
49
  import { stampFrontmatter } from './lib/frontmatter-stamp.mjs';
44
50
  import { buildRows } from './roadmap-extract.mjs';
45
- import { apiKeyFrom } from './roadmap-push.mjs';
46
51
 
47
52
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
48
53
  /** `2026-11-04` → `4 Nov 2026`. */
@@ -133,41 +138,90 @@ export function planRead({ fm, shippedAt, shipped, today, actual = null, evidenc
133
138
  return { state: 'ready', target, drafted, owner, fields, late, readDate, derived };
134
139
  }
135
140
 
136
- /** `metric` against the project's North Star inputs, through the existing read route. Never throws. */
137
- export async function groundedCheck({ metric, apiKey, baseUrl, fetchFn = fetch }) {
138
- if (!metric) return { checked: false, why: 'no target metric' };
139
- if (!apiKey)
140
- return { checked: false, why: 'no project key (SELF_PROJECT_API_KEY / GROWTH_ENGINE_API_KEY)' };
141
+ /** Run one `gf … --json` and read its single JSON document. Never throws; `why` says what went wrong in words. */
142
+ export function runGf(args, { env = process.env, spawnFn = spawnSync } = {}) {
143
+ const bin = env.GF_BIN || 'gf';
144
+ const res = spawnFn(bin, [...args, '--json'], { encoding: 'utf8', timeout: 30_000, env, shell: false });
145
+ if (res.error)
146
+ return {
147
+ ok: false,
148
+ why:
149
+ res.error.code === 'ENOENT'
150
+ ? 'gf is not installed (npm i -g @golden-frijoles/cli, then gf login)'
151
+ : `gf did not run: ${res.error.message}`,
152
+ };
153
+ let body = null;
141
154
  try {
142
- const res = await fetchFn(`${String(baseUrl).replace(/\/$/, '')}/api/v1/north-star`, {
143
- headers: { Authorization: `Bearer ${apiKey}` },
144
- signal: AbortSignal.timeout(10_000),
145
- });
146
- if (!res.ok) return { checked: false, why: `the engine answered ${res.status}` };
147
- const body = await res.json();
148
- const keys = (body?.metrics ?? []).flatMap((m) => (m?.inputs ?? []).map((i) => i?.key)).filter(Boolean);
149
- return { checked: true, grounded: keys.includes(metric), keys };
150
- } catch (err) {
151
- return { checked: false, why: `could not reach ${baseUrl}: ${err?.message ?? err}` };
155
+ body = JSON.parse(res.stdout || 'null');
156
+ } catch {
157
+ body = null;
152
158
  }
159
+ if (body && body.ok === true) return { ok: true, body };
160
+ if (res.status === 2) return { ok: false, code: 'unauthorized', why: 'gf is not signed in (run gf login)' };
161
+ const code = body?.code ?? null;
162
+ return { ok: false, code, why: body?.error ?? `gf exited ${res.status}` };
163
+ }
164
+
165
+ /**
166
+ * The number and its evidence, fetched through gf (D17). Pure apart from the injected `run`.
167
+ * Returns `{ fetched, actual?, evidence?, reading?, decision?, grounded, why? }`.
168
+ */
169
+ export function fetchEvidence({ metric, experiment = null, today, shippedAt, project = null, run }) {
170
+ const scope = project ? ['--project', project] : [];
171
+ const out = { fetched: false, grounded: null };
172
+ const readings = run(['north-star', 'readings', metric, '--to', today, ...scope]);
173
+ if (!readings.ok) {
174
+ // `not_found` is also what an unknown or foreign PROJECT answers; only the input's own sentence means "not grounded".
175
+ if (readings.code === 'not_found' && /No North Star input/.test(readings.why ?? '')) out.grounded = false;
176
+ out.why = readings.why;
177
+ } else {
178
+ out.grounded = true;
179
+ const latest = readings.body.latest;
180
+ const shipped = dayOf(shippedAt);
181
+ if (!latest) out.why = `no reading of ${metric} yet`;
182
+ else if (shipped && latest.date < shipped)
183
+ out.why = `no reading of ${metric} since it shipped (the latest is ${latest.date})`;
184
+ else {
185
+ out.fetched = true;
186
+ out.reading = latest;
187
+ out.actual = latest.value;
188
+ out.evidence = `north-star:${metric}@${latest.date}`;
189
+ }
190
+ }
191
+ if (experiment) {
192
+ const decision = run(['experiments', 'decision', experiment, ...scope]);
193
+ if (decision.ok && decision.body.decisions?.state === 'decided') {
194
+ out.decision = decision.body.decisions.current;
195
+ out.evidence = `ab:${experiment}`; // the decision record is the stronger pointer; the reading stays the actual
196
+ } else if (decision.ok) out.decisionWhy = `${experiment} has no decision recorded yet`;
197
+ else out.decisionWhy = decision.why;
198
+ }
199
+ return out;
153
200
  }
154
201
 
155
202
  /** The text a person reads. Pure. */
156
- export function renderPlan(plan, { slug, grounded, write, written }) {
203
+ export function renderPlan(plan, { slug, fetched, write, written }) {
157
204
  const out = [];
158
205
  const t = plan.target;
159
206
  const head = t.metric
160
207
  ? `${slug}: ${t.metric}${t.from !== null && t.to !== null ? ` ${fig(t.from)} → ${fig(t.to)}` : ''}${t.hypothesis ? ` — "${t.hypothesis}"` : ''}`
161
208
  : `${slug}: no target`;
162
209
  out.push(head);
163
- if (grounded) {
164
- if (grounded.checked)
210
+ if (fetched) {
211
+ if (fetched.grounded === true)
212
+ out.push(` grounded: ${t.metric} is one of the project's North Star inputs`);
213
+ if (fetched.grounded === false)
214
+ out.push(` not grounded: ${t.metric} is not one of the project's North Star inputs`);
215
+ if (fetched.fetched)
216
+ out.push(
217
+ ` fetched: ${fig(fetched.reading.value)} on ${longDay(fetched.reading.date)} (gf north-star readings)`
218
+ );
219
+ else if (fetched.why) out.push(` could not fetch the number: ${fetched.why}`);
220
+ if (fetched.decision)
165
221
  out.push(
166
- grounded.grounded
167
- ? ` grounded: ${t.metric} is one of the project's North Star inputs`
168
- : ` not grounded: ${t.metric} is not one of the project's North Star inputs (${grounded.keys.join(', ') || 'none'})`
222
+ ` decision: ${fetched.decision.outcome ?? 'recorded'}${fetched.decision.chosenVariantKey ? ` (${fetched.decision.chosenVariantKey})` : ''} (gf experiments decision)`
169
223
  );
170
- else if (t.metric) out.push(` could not check the metric against the North Star (${grounded.why})`);
224
+ else if (fetched.decisionWhy) out.push(` decision: ${fetched.decisionWhy}`);
171
225
  }
172
226
  switch (plan.state) {
173
227
  case 'already-read':
@@ -236,7 +290,7 @@ const arg = (argv, name) => {
236
290
  return i === -1 ? null : (argv[i + 1] ?? null);
237
291
  };
238
292
 
239
- export async function main(argv, { fetchFn = fetch, env = process.env, stdout = process.stdout } = {}) {
293
+ export async function main(argv, { spawnFn = spawnSync, env = process.env, stdout = process.stdout } = {}) {
240
294
  const slug = arg(argv, '--epic');
241
295
  if (!slug) {
242
296
  process.stderr.write('epic-read: --epic <slug> is required\n');
@@ -268,24 +322,41 @@ export async function main(argv, { fetchFn = fetch, env = process.env, stdout =
268
322
  const row = buildRows({ root, facts: { mode: 'docs', prs: [], branches: [] } }).find(
269
323
  (r) => r.grain === 'Epic' && r.slug === slug
270
324
  );
271
- const plan = planRead({
325
+ const shippedAt = row?.shipped_at ?? null;
326
+ const evidenceFlag = arg(argv, '--evidence');
327
+ const base = {
272
328
  fm: parsed.data,
273
- shippedAt: row?.shipped_at ?? null,
329
+ shippedAt,
274
330
  shipped: row?.stage === 'Shipped',
275
331
  today,
276
- actual,
277
- evidence: arg(argv, '--evidence'),
278
332
  verdict: arg(argv, '--verdict'),
279
- });
280
- const grounded =
281
- plan.state === 'already-read' || plan.state === 'not-shipped'
282
- ? null
283
- : await groundedCheck({
284
- metric: plan.target.metric,
285
- apiKey: apiKeyFrom(env),
286
- baseUrl: env.GROWTH_ENGINE_URL || 'http://localhost:3000',
287
- fetchFn,
288
- });
333
+ };
334
+ let plan = planRead({ ...base, actual, evidence: evidenceFlag });
335
+ // D17 — fetch only when there is a read to make and the owner has not given the number: never before the read date,
336
+ // never for an epic already read or not shipped, never over the owner's own --actual.
337
+ let fetched = null;
338
+ const readable = !['already-read', 'not-shipped', 'not-due'].includes(plan.state);
339
+ // The owner's word wins: a number (--actual), a reason or pointer (--evidence) or a verdict (--verdict) means the
340
+ // owner has answered, and a fetched number must not overrule them (fresh review, #293).
341
+ const ownerAnswered = actual !== null || evidenceFlag !== null || base.verdict !== null;
342
+ if (readable && plan.target.metric && !ownerAnswered) {
343
+ fetched = fetchEvidence({
344
+ metric: plan.target.metric,
345
+ experiment: arg(argv, '--experiment'),
346
+ // Complete days only: today's telemetry count is a partial day (fresh review, #293).
347
+ today: addDays(today, -1),
348
+ shippedAt,
349
+ project: arg(argv, '--project'),
350
+ run: (args) => runGf(args, { env, spawnFn }),
351
+ });
352
+ // Only a reading is a number to draft from; a decision on its own only names the pointer for the owner to confirm.
353
+ if (fetched.fetched)
354
+ plan = planRead({
355
+ ...base,
356
+ actual: fetched.actual ?? null,
357
+ evidence: evidenceFlag ?? fetched.evidence ?? null,
358
+ });
359
+ }
289
360
  const write = argv.includes('--write');
290
361
  let written = null;
291
362
  if (write && plan.state === 'ready') {
@@ -297,16 +368,16 @@ export async function main(argv, { fetchFn = fetch, env = process.env, stdout =
297
368
  const refused = { ...plan, state: 'refused', reasons: offenses };
298
369
  stdout.write(
299
370
  argv.includes('--json')
300
- ? `${JSON.stringify({ slug, ...refused, grounded, written: null })}\n`
301
- : `${renderPlan(refused, { slug, grounded, write })}\n`
371
+ ? `${JSON.stringify({ slug, ...refused, fetched, written: null })}\n`
372
+ : `${renderPlan(refused, { slug, fetched, write })}\n`
302
373
  );
303
374
  return 1;
304
375
  }
305
376
  writeFileSync(path, next);
306
377
  written = path.slice(root.length + 1);
307
378
  }
308
- if (argv.includes('--json')) stdout.write(`${JSON.stringify({ slug, ...plan, grounded, written })}\n`);
309
- else stdout.write(`${renderPlan(plan, { slug, grounded, write, written })}\n`);
379
+ if (argv.includes('--json')) stdout.write(`${JSON.stringify({ slug, ...plan, fetched, written })}\n`);
380
+ else stdout.write(`${renderPlan(plan, { slug, fetched, write, written })}\n`);
310
381
  if (plan.state === 'refused') return 1;
311
382
  if (write && plan.state !== 'ready') return 1; // asked to write, and there was nothing approvable to write
312
383
  return 0;
@@ -54,10 +54,10 @@ export const AGENT_BIN = {
54
54
  // Harmless here since AGY_MODEL/AGY_FALLBACK_MODEL below are always valid, listed model names (checked via
55
55
  // `agy models`), but it means a future typo in either constant would silently review with the WRONG model
56
56
  // instead of failing loud — watch for that if either constant is ever edited.
57
- // agy-doctor: last verified 2026-10-06 against 1.3.0.
57
+ // agy-doctor: last verified 2026-10-07 against 1.3.1.
58
58
  // ^ machine-managed marker — `node scripts/cross-agent-doctor.mjs agy --fix` rewrites it (with the constant
59
59
  // below) after a green live contract probe. Don't hand-edit the marker's shape.
60
- export const AGY_PINNED = '1.3.0';
60
+ export const AGY_PINNED = '1.3.1';
61
61
 
62
62
  // agy's `--print` mode prints NOTHING unless `--model` names a model — and, crucially, it ALSO prints
63
63
  // nothing (exit 0, empty stdout — the error lands only in agy's log, see --log-file) when the model is
@@ -140,6 +140,25 @@ export function validateResultFields(fm) {
140
140
  return offenses;
141
141
  }
142
142
 
143
+ // one-epic-page D11 — the epic's flag, decided at groom Stage 6b and copied into the README by the scaffold. The SDK's
144
+ // flag-key grammar (`FLAG_KEY` in apps/web/lib/flag-admin-operation.ts). Optional: `flag_key: null` (no flag) is the
145
+ // default and never an error; a value that is not a key is named, never pushed (the extract sends null for it).
146
+ export const FLAG_KEY_RE = /^[a-z][a-z0-9_.-]{0,127}$/;
147
+
148
+ /** `flag_key:` → offenses (`contract-flag-key-invalid`). Absent or null is fine. */
149
+ export function validateFlagKey(fm) {
150
+ const v = fm.flag_key;
151
+ if (v === undefined || v === null) return [];
152
+ // The string "null" fits the grammar but means "no flag": named here as the push schema refuses it (codex, #297).
153
+ if (typeof v === 'string' && v !== 'null' && FLAG_KEY_RE.test(v)) return [];
154
+ return [
155
+ {
156
+ rule: 'contract-flag-key-invalid',
157
+ detail: `flag_key: "${v}" is not a flag key (a lowercase letter, then a-z 0-9 _ . -; at most 128) — or null`,
158
+ },
159
+ ];
160
+ }
161
+
143
162
  // live-build-view D10 — `locked_at:` is stamped by `scripts/epic-phase.mjs lock` when the architecture lock is written;
144
163
  // the build view reads its absence as "Locking architecture". Optional (no epic before it has one), an ISO date-time
145
164
  // string when present: the command writes `"2026-10-03T20:34:34Z"` (quoted, so the frontmatter parser keeps the colons).
@@ -355,6 +374,7 @@ export function validateEpicFrontmatter(parsed, ctx = {}) {
355
374
  }
356
375
  offenses.push(...validateFinopsFields(fm));
357
376
  offenses.push(...validateResultFields(fm));
377
+ offenses.push(...validateFlagKey(fm));
358
378
  offenses.push(...validateLockedAt(fm));
359
379
  if (isInt(fm.sprints_total) && isInt(ctx.sprintCount) && fm.sprints_total !== ctx.sprintCount)
360
380
  offenses.push({
@@ -73,6 +73,7 @@ import {
73
73
  FINOPS_NUMERIC_FIELDS,
74
74
  RESULT_DAY_FIELDS,
75
75
  RESULT_FIELDS,
76
+ FLAG_KEY_RE,
76
77
  RESULT_NUMERIC_FIELDS,
77
78
  VERDICTS,
78
79
  } from './lib/roadmap-contract.mjs';
@@ -161,6 +162,21 @@ export function resultFields(fm, shippedAt) {
161
162
  return out;
162
163
  }
163
164
 
165
+ /**
166
+ * one-epic-page D11 — an epic's flag: `flag_key` off its README frontmatter (the SDK's key grammar, else null — the
167
+ * contract names a bad one) and `flag_note`, the README's own `**Flag:** …` line, which is where an epic with no flag
168
+ * says why. Both null when absent; nothing is inferred from prose about a flag the frontmatter does not name.
169
+ */
170
+ export function flagFields(fm, readme) {
171
+ const raw = typeof fm.flag_key === 'string' ? fm.flag_key.trim() : '';
172
+ // A QUOTED `flag_key: "null"` (or `~`) arrives as a string, and "null" fits the key grammar — so the blank
173
+ // spellings are refused before the pattern is asked. (Unquoted `null` is already null from this file's reader.)
174
+ const blank = raw === '' || raw === 'null' || raw === '~';
175
+ const line = /^\*\*Flag:\*\*[ \t]*(.+)$/m.exec(readme ?? '');
176
+ const note = line ? line[1].replace(/[*`]/g, '').trim().slice(0, 280) : '';
177
+ return { flag_key: !blank && FLAG_KEY_RE.test(raw) ? raw : null, flag_note: note || null };
178
+ }
179
+
164
180
  function parseFrontmatter(md) {
165
181
  if (!md.startsWith('---')) return {};
166
182
  const end = md.indexOf('\n---', 3);
@@ -583,6 +599,7 @@ export function buildRows({
583
599
  }
584
600
  }
585
601
 
602
+ const readme = readFileSync(join(e.path, 'README.md'), 'utf8');
586
603
  // Epic row
587
604
  rows.push({
588
605
  name: epicTitle(e.path, e.slug),
@@ -605,7 +622,7 @@ export function buildRows({
605
622
  build_order_num: buildOrderNum(buildOrder),
606
623
  doc_link: readmePath,
607
624
  epic_slug: null,
608
- goal: firstParagraph(readFileSync(join(e.path, 'README.md'), 'utf8'), ['Why', 'Goal', 'Problem']),
625
+ goal: firstParagraph(readme, ['Why', 'Goal', 'Problem']),
609
626
  sprints: boardSprints.map((sp) => ({ n: sp.n, title: sp.title, done: sp.done, total: sp.total })),
610
627
  links: {
611
628
  readme: readmePath,
@@ -618,6 +635,7 @@ export function buildRows({
618
635
  shipped_at: stage === 'Shipped' ? statusDay : null,
619
636
  ...finopsFields(epicFm),
620
637
  ...resultFields(epicFm, stage === 'Shipped' ? statusDay : null),
638
+ ...flagFields(epicFm, readme),
621
639
  });
622
640
 
623
641
  // Sprint rows (one per sprint-N.md), related to the Epic by slug. boardSprints already carries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.32.0",
3
+ "version": "0.34.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": {