@golden-frijoles/kit 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/epic-read.mjs +122 -51
  2. package/package.json +1 -1
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.32.0",
3
+ "version": "0.33.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": {