@golden-frijoles/kit 1.0.1 → 1.1.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.
package/bin.mjs CHANGED
@@ -53,17 +53,6 @@ export function parseArgs(argv) {
53
53
 
54
54
  const USAGE = 'usage: frijoles-kit [--root <dir>] <script> [args…] · frijoles-kit --list · frijoles-kit --version';
55
55
 
56
- /**
57
- * plugin-1-0 D2 — started as the old name `gf-kit`? One line on stderr (stdout is a script's own output, often parsed),
58
- * until the date `scripts/check-deprecations.mjs` enforces. Pure, so the test pins it.
59
- */
60
- export function deprecatedNameNotice(invokedPath) {
61
- const name = String(invokedPath ?? '').split(/[\\/]/).pop() ?? '';
62
- // Unix only: npm's Windows shims start `node …\\bin.mjs`, so argv[1] is the entry file there (verifier, #324).
63
- if (name !== 'gf-kit') return null;
64
- return 'gf-kit is now frijoles-kit. gf-kit stops working on 2026-12-31 (or in kit 1.1.0).\n';
65
- }
66
-
67
56
  function main(argv) {
68
57
  const args = parseArgs(argv);
69
58
  const scripts = listScripts();
@@ -117,7 +106,5 @@ const isMain = (() => {
117
106
  }
118
107
  })();
119
108
  if (isMain) {
120
- const notice = deprecatedNameNotice(process.argv[1]);
121
- if (notice) process.stderr.write(notice);
122
109
  process.exitCode = main(process.argv.slice(2));
123
110
  }
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ // bets-grounded.mjs — the share of a month's bets placed against a North Star input (grounded-bets D2–D4).
3
+ //
4
+ // node scripts/bets-grounded.mjs # every month in the bets ledger
5
+ // node scripts/bets-grounded.mjs --month 2026-10 # one month
6
+ // node scripts/bets-grounded.mjs --json
7
+ // node scripts/bets-grounded.mjs --push # post this month's share as the `grounded_bets_share` input value
8
+ //
9
+ // ── What counts (D3) ──────────────────────────────────────────────────────────────────────────────────────────────
10
+ // A month's bets are the epics and seeds whose `underwritten_by:` names a ledger file `wave-YYYY-MM…` (the stamp
11
+ // fund.mjs writes; the month from that name). Read from each bet, not from the ledger's rows: the rows' shape changed
12
+ // over the months and a bet carries one stamp, so nothing is counted twice. `wave-backfill` has no month and is never
13
+ // counted. Bugs and Chores are left out (`type:`): they keep something working and carry no hypothesis.
14
+ //
15
+ // ── What is grounded (D2) ─────────────────────────────────────────────────────────────────────────────────────────
16
+ // Derived from the bet's target, never from its `grounded:` field alone: its `target_metric` is one of the project's
17
+ // North Star input keys (`Roadmap/00-strategy/north-star.md`, the sync payload) and `target_from` and `target_to` are
18
+ // numbers. A blank `read_date` counts: the read defaults to 30 days after shipping. A bet that records
19
+ // `grounded: true` without such a target is reported, not counted. So bets refined before the field existed are
20
+ // measured by their targets, with no backfill.
21
+ //
22
+ // ── The push (D4) ─────────────────────────────────────────────────────────────────────────────────────────────────
23
+ // `--push` posts `{ occurredOn: today (UTC), value: this month's share }` to `/api/v1/inputs/grounded_bets_share/values`
24
+ // with the same key and URL as roadmap-push.mjs. It skips, cleanly and saying why, when the key is missing, when the
25
+ // project's North Star has no `grounded_bets_share` input (so a project never posts to an input it lacks) or when the
26
+ // month has no counted bets. The strategy is often private (untracked), so a CI checkout has no `north-star.md`: then
27
+ // `--push` asks the engine for the project's input keys (`GET /api/v1/north-star`, the same key) and counts with
28
+ // those (verifier, #334: without it the push could never run in CI). The route is append-only per day: the first value of a day stands, and a later different
29
+ // one is reported as a mismatch, never an error. Plain fetch, not the SDK: this is a zero-dependency kit script.
30
+ //
31
+ // Zero deps — Node 18+.
32
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
33
+ import { join, resolve } from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+ import { parseDocFrontmatter } from './lib/roadmap-contract.mjs';
36
+ import { section } from './lib/strategy-files.mjs';
37
+ import { projectRoot } from './lib/project-root.mjs';
38
+
39
+ export const INPUT_KEY = 'grounded_bets_share';
40
+ const NOT_COUNTED_TYPES = new Set(['bug', 'chore']);
41
+
42
+ /** The North Star input keys in `north-star.md`'s sync payload; [] when there is none or it does not parse. */
43
+ export function northStarInputKeys(text) {
44
+ const body = section(text ?? '', 'Sync payload');
45
+ const fence = body && body.match(/```json\n([\s\S]*?)\n```/);
46
+ if (!fence) return [];
47
+ try {
48
+ const inputs = JSON.parse(fence[1])?.inputs;
49
+ return Array.isArray(inputs)
50
+ ? inputs.map((i) => i?.key).filter((k) => typeof k === 'string' && !/^<.*>$/.test(k))
51
+ : [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ /** `wave-2026-10-04-launch` (or its `.md`) → '2026-10'; null for a ledger with no month (`wave-backfill`). */
58
+ export function monthOf(file) {
59
+ const m = /^wave-(\d{4})-(\d{2})(?:[-.]|$)/.exec(file);
60
+ return m ? `${m[1]}-${m[2]}` : null;
61
+ }
62
+
63
+ /** Whether a bet's frontmatter targets a North Star input with a from and a to (D2). */
64
+ export function isGrounded(fm, inputKeys) {
65
+ const num = (v) => typeof v === 'number' && Number.isFinite(v);
66
+ return inputKeys.includes(fm.target_metric) && num(fm.target_from) && num(fm.target_to);
67
+ }
68
+
69
+ const recordedTrue = (v) => v === true || v === 'true';
70
+
71
+ /**
72
+ * Pure: the share per month. `docs` maps a slug to its frontmatter data (seed overlaid by README, readProject). Months
73
+ * come out oldest first.
74
+ */
75
+ export function shareByMonth({ docs, inputKeys, month = null }) {
76
+ const months = new Map();
77
+ for (const [slug, fm] of docs) {
78
+ const m = typeof fm.underwritten_by === 'string' ? monthOf(fm.underwritten_by.trim()) : null;
79
+ if (!m || (month && m !== month)) continue;
80
+ if (!months.has(m)) months.set(m, []);
81
+ months.get(m).push(slug);
82
+ }
83
+ return [...months.keys()].sort().map((m) => {
84
+ const counted = [];
85
+ const grounded = [];
86
+ const excluded = [];
87
+ const unbacked = [];
88
+ for (const slug of months.get(m).sort()) {
89
+ const fm = docs.get(slug);
90
+ if (NOT_COUNTED_TYPES.has(String(fm.type ?? '').toLowerCase())) {
91
+ excluded.push(slug);
92
+ continue;
93
+ }
94
+ counted.push(slug);
95
+ if (isGrounded(fm, inputKeys)) grounded.push(slug);
96
+ else if (recordedTrue(fm.grounded)) unbacked.push(slug);
97
+ }
98
+ const total = counted.length;
99
+ const share = total ? Math.round((grounded.length / total) * 10000) / 10000 : null;
100
+ return { month: m, total, grounded, excluded, unbacked, share };
101
+ });
102
+ }
103
+
104
+ /** Each bet's frontmatter (its seed, overlaid by its epic README) and the North Star input keys, from a project root. */
105
+ export function readProject(root) {
106
+ const docs = new Map();
107
+ const roadmap = join(root, 'Roadmap');
108
+ const epics = existsSync(roadmap)
109
+ ? readdirSync(roadmap, { withFileTypes: true })
110
+ .filter(
111
+ (d) =>
112
+ d.isDirectory() && /^\d{2}-/.test(d.name) && d.name !== '00-ideas' && d.name !== '00-strategy'
113
+ )
114
+ .flatMap((d) =>
115
+ readdirSync(join(roadmap, d.name), { withFileTypes: true })
116
+ .filter((e) => e.isDirectory())
117
+ .map((e) => ({ slug: e.name, path: join(roadmap, d.name, e.name, 'README.md') }))
118
+ )
119
+ : [];
120
+ const read = (path) => {
121
+ if (!existsSync(path)) return null;
122
+ const parsed = parseDocFrontmatter(readFileSync(path, 'utf8'));
123
+ return parsed.error ? null : parsed.data;
124
+ };
125
+ // The seed holds the funding stamp (`underwritten_by:`); the epic README, once scaffolded, is authoritative for
126
+ // everything it carries (type, target, grounded). So a bet is its seed with its README laid over it.
127
+ const seeds = join(roadmap, '00-ideas', 'seeds');
128
+ if (existsSync(seeds))
129
+ for (const f of readdirSync(seeds).filter((f) => f.endsWith('.md'))) {
130
+ const fm = read(join(seeds, f));
131
+ if (fm) docs.set(f.slice(0, -3), fm);
132
+ }
133
+ for (const { slug, path } of epics) {
134
+ const fm = read(path);
135
+ if (fm) docs.set(slug, { ...(docs.get(slug) ?? {}), ...fm });
136
+ }
137
+ const ns = join(roadmap, '00-strategy', 'north-star.md');
138
+ const inputKeys = existsSync(ns) ? northStarInputKeys(readFileSync(ns, 'utf8')) : [];
139
+ return { docs, inputKeys };
140
+ }
141
+
142
+ export function formatShares(rows, inputKeys) {
143
+ const out = [
144
+ `Grounded bets (${INPUT_KEY}) — North Star inputs: ${inputKeys.length ? inputKeys.join(' · ') : 'none (no strategy yet: nothing can be grounded)'}`,
145
+ ];
146
+ if (!rows.length) out.push(' no bets in the ledger');
147
+ for (const r of rows) {
148
+ const pct = r.share === null ? 'no bets' : `${Math.round(r.share * 1000) / 10}%`;
149
+ out.push(
150
+ ` ${r.month}: ${r.grounded.length} of ${r.total} grounded (${pct})` +
151
+ (r.excluded.length ? ` · not counted (bug/chore): ${r.excluded.length}` : '')
152
+ );
153
+ if (r.grounded.length) out.push(` grounded: ${r.grounded.join(', ')}`);
154
+ if (r.unbacked.length)
155
+ out.push(` ⚠ grounded: true but no North Star target: ${r.unbacked.join(', ')}`);
156
+ }
157
+ return out.join('\n');
158
+ }
159
+
160
+ /**
161
+ * The project's North Star input keys from the engine: `{ keys }`, `{ skip }` when there is no key to ask with, or
162
+ * `{ error }` when the engine could not say (a refusal, a 5xx, a network error). Never throws; the key goes only into
163
+ * the Authorization header. The engine lists every input of every metric it holds, so when it is asked, it — not a
164
+ * local file — decides what counts as grounded for the pushed value (verifier, #334).
165
+ */
166
+ export async function engineInputKeys({ env = process.env, fetchImpl = fetch } = {}) {
167
+ const apiKey = apiKeyFrom(env);
168
+ if (!apiKey) return { skip: 'no SELF_PROJECT_API_KEY or GROWTH_ENGINE_API_KEY' };
169
+ const base = (env.GROWTH_ENGINE_URL || 'http://localhost:3000').replace(/\/+$/, '');
170
+ try {
171
+ const res = await fetchImpl(`${base}/api/v1/north-star`, {
172
+ headers: { Authorization: `Bearer ${apiKey}` },
173
+ });
174
+ const body = await res.json().catch(() => null);
175
+ if (!res.ok || !body?.ok || !Array.isArray(body.metrics))
176
+ return { error: `the engine's North Star could not be read (${res.status})` };
177
+ return {
178
+ keys: body.metrics.flatMap((m) =>
179
+ Array.isArray(m?.inputs) ? m.inputs.map((i) => i?.key).filter((k) => typeof k === 'string') : []
180
+ ),
181
+ };
182
+ } catch {
183
+ return { error: "the engine's North Star could not be read (network error)" };
184
+ }
185
+ }
186
+
187
+ const apiKeyFrom = (env) => env.SELF_PROJECT_API_KEY || env.GROWTH_ENGINE_API_KEY || null; // as roadmap-push.mjs
188
+
189
+ /** Post today's value. Returns a line to print; never throws for a skip. */
190
+ export async function pushShare({ rows, inputKeys, env = process.env, fetchImpl = fetch, today }) {
191
+ const day = today ?? new Date().toISOString().slice(0, 10);
192
+ if (!inputKeys.includes(INPUT_KEY))
193
+ return { ok: true, line: `push skipped: this project's North Star has no ${INPUT_KEY} input` };
194
+ const row = rows.find((r) => r.month === day.slice(0, 7));
195
+ if (!row || row.share === null)
196
+ return { ok: true, line: `push skipped: no counted bets in ${day.slice(0, 7)}` };
197
+ const apiKey = apiKeyFrom(env);
198
+ if (!apiKey) return { ok: true, line: 'push skipped: no SELF_PROJECT_API_KEY or GROWTH_ENGINE_API_KEY' };
199
+ const base = (env.GROWTH_ENGINE_URL || 'http://localhost:3000').replace(/\/+$/, '');
200
+ let res;
201
+ try {
202
+ res = await fetchImpl(`${base}/api/v1/inputs/${INPUT_KEY}/values`, {
203
+ method: 'POST',
204
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
205
+ body: JSON.stringify({ values: [{ occurredOn: day, value: row.share }] }),
206
+ });
207
+ } catch (err) {
208
+ return {
209
+ ok: false,
210
+ line: `push failed: ${err instanceof Error ? err.message.replace(/\/\/[^/\s@]*@/g, '//***@') : 'network error'}`,
211
+ };
212
+ }
213
+ const body = await res.json().catch(() => null);
214
+ if (!res.ok || !body?.ok)
215
+ return { ok: false, line: `push failed (${res.status}): ${body?.error ?? 'no error message'}` };
216
+ if (body.inserted > 0) return { ok: true, line: `pushed ${INPUT_KEY} = ${row.share} for ${day}` };
217
+ const mismatch = Array.isArray(body.mismatchedDuplicates) && body.mismatchedDuplicates.length > 0;
218
+ return {
219
+ ok: true,
220
+ line: mismatch
221
+ ? `already pushed for ${day} with a different value; the first value of a day stands (now ${row.share})`
222
+ : `already pushed for ${day} (${row.share})`,
223
+ };
224
+ }
225
+
226
+ async function main(argv) {
227
+ const opt = (name) => {
228
+ const at = argv.indexOf(name);
229
+ return at === -1 ? null : (argv[at + 1] ?? '');
230
+ };
231
+ const month = opt('--month');
232
+ if (month !== null && !/^\d{4}-\d{2}$/.test(month)) {
233
+ console.error('bets-grounded: --month takes YYYY-MM');
234
+ return 1;
235
+ }
236
+ const root = opt('--root') ? resolve(opt('--root')) : projectRoot();
237
+ const project = readProject(root);
238
+ const { docs } = project;
239
+ let { inputKeys } = project;
240
+ // No local strategy (it is often private and untracked, as in CI): ask the engine when there is a key, for the
241
+ // printed count as well as the push. A failure to read it is a failed push, never a "no input" skip (verifier, #334).
242
+ const push = argv.includes('--push');
243
+ if (!inputKeys.length) {
244
+ const fromEngine = await engineInputKeys();
245
+ if (fromEngine.keys) {
246
+ inputKeys = fromEngine.keys;
247
+ console.error('North Star inputs read from the engine (no local Roadmap/00-strategy/north-star.md).');
248
+ } else if (push) {
249
+ console.error(
250
+ fromEngine.error ? `push failed: ${fromEngine.error}` : `push skipped: ${fromEngine.skip}`
251
+ );
252
+ return fromEngine.error ? 1 : 0;
253
+ } else if (fromEngine.error) {
254
+ // Printing only: still count, but never let a down engine pass for "no strategy" (verifier round 3, #334).
255
+ console.error(`note: ${fromEngine.error}; counting with no North Star inputs`);
256
+ }
257
+ }
258
+ const rows = shareByMonth({ docs, inputKeys, month });
259
+ if (argv.includes('--json')) console.log(JSON.stringify({ inputKeys, months: rows }, null, 2));
260
+ else console.log(formatShares(rows, inputKeys));
261
+ if (!push) return 0;
262
+ const all = month ? shareByMonth({ docs, inputKeys }) : rows;
263
+ const result = await pushShare({ rows: all, inputKeys });
264
+ console.log(result.line);
265
+ return result.ok ? 0 : 1;
266
+ }
267
+
268
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
269
+ main(process.argv.slice(2)).then((code) => {
270
+ process.exitCode = code;
271
+ });
272
+ }
@@ -159,6 +159,31 @@ export function validateFlagKey(fm) {
159
159
  ];
160
160
  }
161
161
 
162
+ // grounded-bets D1 — `grounded:` is the founder's word at Stage 1.5: true (traced to a North Star input), false (funded
163
+ // anyway, and `grounded_reason` says why), or absent/null (a Bug, a Chore, or an epic refined before it existed).
164
+ // Whether a bet COUNTS as grounded is derived from its target (bets-grounded.mjs, D2), never from this field alone.
165
+ /** `grounded:` as a boolean: the frontmatter readers keep a bare `true` as the string "true". Null when absent or neither. */
166
+ export function groundedValue(v) {
167
+ if (v === true || v === 'true') return true;
168
+ if (v === false || v === 'false') return false;
169
+ return null;
170
+ }
171
+
172
+ /** `grounded:` and `grounded_reason:` → offenses (`contract-grounded-invalid`). Absent or null is fine. */
173
+ export function validateGrounded(fm) {
174
+ const offenses = [];
175
+ const bad = (detail) => offenses.push({ rule: 'contract-grounded-invalid', detail });
176
+ const raw = fm.grounded;
177
+ const g = groundedValue(raw);
178
+ const reason = fm.grounded_reason;
179
+ const hasReason = reason !== undefined && reason !== null;
180
+ if (raw !== undefined && raw !== null && g === null) bad(`grounded: "${raw}" is not true, false or null`);
181
+ if (hasReason && (typeof reason !== 'string' || !reason.trim())) bad(`grounded_reason: "${reason}" is not text (or null)`);
182
+ if (g === false && !hasReason) bad('grounded: false needs grounded_reason (why it was funded anyway, one sentence)');
183
+ if (g !== false && hasReason) bad('grounded_reason is set but grounded is not false');
184
+ return offenses;
185
+ }
186
+
162
187
  // live-build-view D10 — `locked_at:` is stamped by `scripts/epic-phase.mjs lock` when the architecture lock is written;
163
188
  // the build view reads its absence as "Locking architecture". Optional (no epic before it has one), an ISO date-time
164
189
  // string when present: the command writes `"2026-10-03T20:34:34Z"` (quoted, so the frontmatter parser keeps the colons).
@@ -375,6 +400,7 @@ export function validateEpicFrontmatter(parsed, ctx = {}) {
375
400
  offenses.push(...validateFinopsFields(fm));
376
401
  offenses.push(...validateResultFields(fm));
377
402
  offenses.push(...validateFlagKey(fm));
403
+ offenses.push(...validateGrounded(fm));
378
404
  offenses.push(...validateLockedAt(fm));
379
405
  if (isInt(fm.sprints_total) && isInt(ctx.sprintCount) && fm.sprints_total !== ctx.sprintCount)
380
406
  offenses.push({
@@ -74,6 +74,7 @@ import {
74
74
  RESULT_DAY_FIELDS,
75
75
  RESULT_FIELDS,
76
76
  FLAG_KEY_RE,
77
+ groundedValue,
77
78
  RESULT_NUMERIC_FIELDS,
78
79
  VERDICTS,
79
80
  } from './lib/roadmap-contract.mjs';
@@ -177,6 +178,16 @@ export function flagFields(fm, readme) {
177
178
  return { flag_key: !blank && FLAG_KEY_RE.test(raw) ? raw : null, flag_note: note || null };
178
179
  }
179
180
 
181
+ /**
182
+ * grounded-bets D9 — the founder's grounding, as recorded at Stage 1.5: `grounded` true | false | null and, when false,
183
+ * `grounded_reason`. Anything else is null (the contract names it); the reason travels only with false.
184
+ */
185
+ export function groundedFields(fm) {
186
+ const grounded = groundedValue(fm.grounded);
187
+ const reason = typeof fm.grounded_reason === 'string' ? fm.grounded_reason.trim().slice(0, 300) : '';
188
+ return { grounded, grounded_reason: grounded === false && reason ? reason : null };
189
+ }
190
+
180
191
  function parseFrontmatter(md) {
181
192
  if (!md.startsWith('---')) return {};
182
193
  const end = md.indexOf('\n---', 3);
@@ -636,6 +647,7 @@ export function buildRows({
636
647
  ...finopsFields(epicFm),
637
648
  ...resultFields(epicFm, stage === 'Shipped' ? statusDay : null),
638
649
  ...flagFields(epicFm, readme),
650
+ ...groundedFields(epicFm),
639
651
  });
640
652
 
641
653
  // Sprint rows (one per sprint-N.md), related to the Epic by slug. boardSprints already carries
package/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "1.0.1",
3
+ "version": "1.1.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": {
7
- "frijoles-kit": "bin.mjs",
8
- "gf-kit": "bin.mjs"
7
+ "frijoles-kit": "bin.mjs"
9
8
  },
10
9
  "files": [
11
10
  "bin.mjs",