@golden-frijoles/kit 0.20.0 → 0.22.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.
@@ -59,6 +59,10 @@ import { dirname, join, resolve } from 'node:path';
59
59
  import { PHASES, parseDocFrontmatter } from './lib/roadmap-contract.mjs';
60
60
  import { parseJournal, JOURNAL_BRANCH, JOURNAL_PATH } from './lib/session-journal.mjs';
61
61
  import { branchCandidates, parseBranch } from './lib/work-branch.mjs';
62
+ import { buildRows } from './roadmap-extract.mjs';
63
+ import { gatherFacts } from './lib/stage-facts.mjs';
64
+ import { groupByStage } from './lib/stage.mjs';
65
+ import { getKey } from './lib/config.mjs';
62
66
 
63
67
  const __dirname = dirname(fileURLToPath(import.meta.url));
64
68
 
@@ -161,9 +165,10 @@ const TYPES_KNOWN = ['feature', 'spike', 'bug', 'chore'];
161
165
 
162
166
  /**
163
167
  * What the branch names, longest reading first: an epic, else a seed (a seed that carries `epic:` is that
164
- * epic — once scaffolded the seed is funnel-only), else null.
168
+ * epic — once scaffolded the seed is funnel-only), else null. Exported for epic-actuals.mjs (finops D16): spend is
169
+ * attributed to an epic by exactly this reading of a branch, never a second one.
165
170
  */
166
- function resolveTarget(root, branch) {
171
+ export function resolveTarget(root, branch) {
167
172
  for (const c of branchCandidates(branch)) {
168
173
  const epic = readEpic(root, c.slug);
169
174
  if (epic) return { kind: 'epic', epic, sprint: c.sprint, match: c.exact ? 'exact' : 'prefix' };
@@ -239,21 +244,71 @@ function readJournalLocal(git) {
239
244
  return { entries: [], ref: null };
240
245
  }
241
246
 
242
- function ghOpenPr(root, branch) {
247
+ /**
248
+ * The open PR for `branch`, read from the run's facts (board-sinks-and-scrumban S3.1): the ONE `gh pr list` the facts
249
+ * made already holds every PR, so the build view asks GitHub nothing more. `ok` is whether those facts are live.
250
+ */
251
+ export function prFromFacts(facts, branch) {
252
+ const open = (facts?.prs ?? []).filter((p) => p.head === branch && p.state === 'OPEN');
253
+ const pr = open.reduce((best, p) => (best === null || p.number > best.number ? p : best), null);
254
+ // Only LIVE facts may lift the status to In review: a failed online gather that fell back to the snapshot must not
255
+ // claim a PR from hours ago (before this, a failed gh call meant no lift; fresh review, #227).
256
+ const live = facts?.mode === 'live';
257
+ return { ok: live, pr: live && pr ? { number: pr.number, url: pr.url } : null };
258
+ }
259
+
260
+ const notInFlight = (reason, extra = {}) => ({ in_flight: false, reason, ...extra });
261
+
262
+ // ── Spend (finops S1.3, D5) ─────────────────────────────────────────────────────────────────────────────
263
+ // The band only READS: `<main checkout>/.golden-frijoles/usage-summary.json`, written by epic-actuals.mjs (refreshed
264
+ // by the mod off the hot path, and by any CLI run). Nothing here scans a transcript. No summary, or no row for this
265
+ // epic, means no Spend line — never a zero.
266
+ export const USAGE_SUMMARY = ['.golden-frijoles', 'usage-summary.json'];
267
+
268
+ /** The main checkout of `root` (the parent of git's common dir) — where the usage summary lives (finops D19). */
269
+ function mainCheckoutOf(git, root) {
270
+ const common = tryGit(git, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
271
+ if (!common) return root;
272
+ return /[\\/]\.git$/.test(common) ? dirname(common) : root;
273
+ }
274
+
275
+ /** This epic's row of the usage summary, or null. Never throws. */
276
+ export function spendFor(root, git, slug) {
243
277
  try {
244
- const out = execFileSync(
245
- 'gh',
246
- ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number,url', '--limit', '1'],
247
- { cwd: root, encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }
248
- );
249
- const [pr] = JSON.parse(out);
250
- return { ok: true, pr: pr || null };
278
+ const path = join(mainCheckoutOf(git, root), ...USAGE_SUMMARY);
279
+ if (!existsSync(path)) return null;
280
+ const summary = JSON.parse(readFileSync(path, 'utf8'));
281
+ // A first scan that ran out of its time budget is not a total yet: no row until it completes, never a low number.
282
+ if (summary?.complete === false) return null;
283
+ const row = summary?.epics?.[slug];
284
+ if (!row || typeof row.usd !== 'number') return null;
285
+ return {
286
+ usd: row.usd,
287
+ usd_known: row.usd_known !== false,
288
+ mtok: typeof row.mtok === 'number' ? row.mtok : null,
289
+ sessions: Number.isInteger(row.sessions) ? row.sessions : null,
290
+ basis: typeof summary.basis === 'string' ? summary.basis : 'this machine',
291
+ generated_at: summary.generated_at ?? null,
292
+ };
251
293
  } catch {
252
- return { ok: false, pr: null };
294
+ return null;
253
295
  }
254
296
  }
255
297
 
256
- const notInFlight = (reason, extra = {}) => ({ in_flight: false, reason, ...extra });
298
+ /** `≈$38` (a lower bound reads `≥$38`: some turns could not be priced — finops D4). */
299
+ export function dollars(usd, known = true) {
300
+ const sign = known ? '≈' : '≥';
301
+ return usd > 0 && usd < 1 ? `${sign}$${usd.toFixed(2)}` : `${sign}$${Math.round(usd)}`;
302
+ }
303
+
304
+ /** The Spend line's value (finops S1.3): `≈$38 · 1.9M tok · 4 sessions · this machine`. */
305
+ export function spendValue(spend) {
306
+ const parts = [dollars(spend.usd, spend.usd_known)];
307
+ if (spend.mtok !== null) parts.push(`${spend.mtok}M tok`);
308
+ if (spend.sessions !== null) parts.push(`${spend.sessions} session${spend.sessions === 1 ? '' : 's'}`);
309
+ parts.push(spend.basis);
310
+ return parts.join(' · ');
311
+ }
257
312
 
258
313
  /** Does `text` name `slug` as a whole slug — `aws` in "aws S1.1", but not inside "aws-s3 S1.1"? */
259
314
  export function namesSlug(text, slug) {
@@ -261,6 +316,48 @@ export function namesSlug(text, slug) {
261
316
  return new RegExp(`(^|[^A-Za-z0-9-])${esc}($|[^A-Za-z0-9-])`).test(String(text));
262
317
  }
263
318
 
319
+ /** "just now", "12m ago", "3h ago", "2 days ago" — how old the facts behind a stage are. */
320
+ export function ageOf(iso, now = new Date()) {
321
+ const ms = now.getTime() - new Date(iso).getTime();
322
+ if (!Number.isFinite(ms)) return null;
323
+ const m = Math.floor(ms / 60_000);
324
+ if (m < 1) return 'just now';
325
+ if (m < 60) return `${m}m ago`;
326
+ const h = Math.floor(m / 60);
327
+ if (h < 24) return `${h}h ago`;
328
+ const d = Math.floor(h / 24);
329
+ return d === 1 ? 'yesterday' : `${d} days ago`;
330
+ }
331
+
332
+ /**
333
+ * The stage of what is in flight, and the board around it (board-sinks-and-scrumban S3.1, D2/D14). The stage is the
334
+ * EXTRACTOR's row for this initiative — the same `buildRows()` the Hub's push and BUILD-ORDER.md read — so the build
335
+ * view cannot say a different word than the board. `dates: false` keeps it inside the hook's budget (~50 ms).
336
+ */
337
+ export function boardState({ root, facts, state, now = new Date() }) {
338
+ const rows = buildRows({ facts: facts ?? undefined, root, dates: false });
339
+ const slug = state.in_flight ? (state.epic?.slug ?? state.seed?.slug ?? null) : null;
340
+ const row = slug ? rows.find((r) => r.grain !== 'Sprint' && r.slug === slug) : null;
341
+ const cols = groupByStage(rows);
342
+ const next = cols['Ready to build'][0] ?? null;
343
+ const hub = getKey('board.hubUrl', { root });
344
+ const hubUrl = typeof hub === 'string' && /^https:\/\//.test(hub) ? hub.replace(/\/+$/, '') : null;
345
+ return {
346
+ stage: row ? row.stage : null,
347
+ stage_source: row ? row.stage_source : null,
348
+ stage_age:
349
+ facts?.mode === 'live' ? 'just now' : facts?.generated_at ? ageOf(facts.generated_at, now) : null,
350
+ facts_mode: facts?.mode ?? 'docs',
351
+ board: {
352
+ building: cols.Building.length,
353
+ qa: cols.QA.length,
354
+ ready: cols['Ready to build'].length,
355
+ next: next ? { name: next.name, slug: next.slug, build_order: next.build_order_num ?? null } : null,
356
+ url: hubUrl ? `${hubUrl}/board${slug && row ? `?card=${encodeURIComponent(slug)}` : ''}` : null,
357
+ },
358
+ };
359
+ }
360
+
264
361
  /**
265
362
  * Resolve the build state. Every external read is injectable so the tests drive real fixture repos with
266
363
  * a fake `gh`: { root, offline, git, gh }. It NEVER throws — it runs once per turn inside a CLI hook, and
@@ -268,14 +365,31 @@ export function namesSlug(text, slug) {
268
365
  * `elsewhere: false` skips the other-worktrees / open-epics scan (used when resolving those worktrees).
269
366
  */
270
367
  export function resolveBuildState(opts = {}) {
368
+ // board-sinks-and-scrumban S3.1 — ONE facts gather per run: the snapshot offline (the hook never goes online), else
369
+ // one `git ls-remote` + one `gh pr list`, which also refreshes the snapshot. Injectable for the spec.
370
+ let facts = null;
371
+ if (opts.board !== false) {
372
+ try {
373
+ facts = (opts.gather ?? gatherFacts)({ root: opts.root, mode: opts.offline ? 'snapshot' : 'live' });
374
+ } catch {
375
+ facts = null;
376
+ }
377
+ }
271
378
  let state;
272
379
  try {
273
- state = resolve_(opts);
380
+ state = resolve_({ ...opts, facts });
274
381
  } catch (err) {
275
382
  state = notInFlight(
276
383
  `the resolver could not read this checkout (${err && err.message ? err.message : err})`
277
384
  );
278
385
  }
386
+ if (opts.board !== false) {
387
+ try {
388
+ Object.assign(state, boardState({ root: opts.root, facts, state, now: opts.now ?? new Date() }));
389
+ } catch {
390
+ // A roadmap this extractor cannot read (an out-of-enum status) leaves the view without a stage, never blank.
391
+ }
392
+ }
279
393
  if (opts.elsewhere === false) return state;
280
394
  try {
281
395
  state.elsewhere = elsewhere_({ root: opts.root, git: opts.git || makeGit(opts.root), state });
@@ -326,7 +440,7 @@ function elsewhere_({ root, git, state }) {
326
440
  if (!w.branch || w.bare || w.prunable || samePath(w.path, top) || !existsSync(w.path)) continue;
327
441
  if (!branchCandidates(w.branch).length) continue; // main, release branches: nothing to resolve
328
442
  if (resolved++ >= MAX_WORKTREES) break;
329
- const s = resolveBuildState({ root: w.path, offline: true, elsewhere: false });
443
+ const s = resolveBuildState({ root: w.path, offline: true, elsewhere: false, board: false });
330
444
  if (!s.in_flight || DONE_LIFECYCLES.includes(s.lifecycle)) continue;
331
445
  const item = s.epic
332
446
  ? { kind: 'epic', slug: s.epic.slug, title: s.epic.title }
@@ -401,7 +515,8 @@ function seedState({ root, git, gh, offline, branch, seed, match }) {
401
515
  };
402
516
  }
403
517
 
404
- function resolve_({ root, offline = false, git = makeGit(root), gh = ghOpenPr } = {}) {
518
+ function resolve_({ root, offline = false, git = makeGit(root), facts = null, gh } = {}) {
519
+ gh = gh ?? ((_root, branch) => prFromFacts(facts, branch));
405
520
  if (tryGit(git, ['rev-parse', '--is-inside-work-tree']) !== 'true')
406
521
  return notInFlight('not inside a git checkout (or git is not installed)');
407
522
  const branch = tryGit(git, ['symbolic-ref', '-q', '--short', 'HEAD']);
@@ -533,6 +648,7 @@ function resolve_({ root, offline = false, git = makeGit(root), gh = ghOpenPr }
533
648
  kind: 'epic',
534
649
  branch,
535
650
  branch_match: target.match,
651
+ spend: spendFor(root, git, epic.slug),
536
652
  lifecycle: epic.lifecycle,
537
653
  epic: {
538
654
  slug: epic.slug,
@@ -605,9 +721,40 @@ function elsewhereLines(state, pad) {
605
721
  * work in other worktrees appears as one `Also` line when this checkout is building, and as the list
606
722
  * itself when it isn't.
607
723
  */
724
+ /**
725
+ * The stage, said with where it came from and how old that is (board-sinks-and-scrumban S3.1): "Building · from git:
726
+ * feat/x · facts from the snapshot, 3h ago". The written `phase:` stays only as a detail (D12). Without a stage (the
727
+ * roadmap could not be read) it falls back to the old written-phase status, labelled as such.
728
+ */
729
+ export function statusValue(state) {
730
+ if (!state.stage) return state.status || `unknown${state.warning ? ` — ${state.warning}` : ''}`;
731
+ const source = String(state.stage_source || '').replace(/ · snapshot@\S+$/, '');
732
+ const age =
733
+ state.facts_mode === 'live'
734
+ ? 'live'
735
+ : state.facts_mode === 'snapshot'
736
+ ? `snapshot, ${state.stage_age ?? 'age unknown'}`
737
+ : 'docs only, no snapshot yet';
738
+ const phase =
739
+ state.phase_written && state.phase_written !== state.stage ? ` · phase ${state.phase_written}` : '';
740
+ return `${state.stage} · from ${source} (${age})${phase}`;
741
+ }
742
+
743
+ /** The board around this work in one line, and the link to it when `board.hubUrl` is set (S3.1). */
744
+ function boardLines(state, pad) {
745
+ const b = state.board;
746
+ if (!b) return [];
747
+ const next = b.next
748
+ ? ` · next to pull: ${clip(b.next.name, 40)}${b.next.build_order !== null ? ` (#${b.next.build_order})` : ''}`
749
+ : '';
750
+ const counts = `Building ${b.building} · QA ${b.qa} · Ready to build ${b.ready}${next}`;
751
+ return [`${pad('Board')}${counts}`, ...(b.url ? [`${pad('')}↗ ${b.url}`] : [])];
752
+ }
753
+
608
754
  export function renderLines(state) {
609
755
  const pad = (label) => ` ${label.padEnd(9)}`;
610
- if (!state.in_flight) return [`No epic in flight — ${state.reason}`, ...elsewhereLines(state, pad)];
756
+ if (!state.in_flight)
757
+ return [`No epic in flight — ${state.reason}`, ...elsewhereLines(state, pad), ...boardLines(state, pad)];
611
758
  const others = state.elsewhere?.worktrees?.length || 0;
612
759
  const also = others
613
760
  ? [
@@ -627,8 +774,9 @@ export function renderLines(state) {
627
774
  HEADINGS[seed.type] || 'Currently working on',
628
775
  `${pad(label)}${seed.title}${meta ? ` ${meta}` : ''}`,
629
776
  `${pad('Seed')}${seed.path}`,
630
- `${pad('Status')}${state.status || 'unknown'}`,
777
+ `${pad('Status')}${statusValue(state)}`,
631
778
  ...also,
779
+ ...boardLines(state, pad),
632
780
  ];
633
781
  }
634
782
  const { epic, story, progress } = state;
@@ -649,8 +797,9 @@ export function renderLines(state) {
649
797
  const storyPart = `Story ${progress.story ?? '?'} of ${progress.stories}`;
650
798
  const sprintPart = `Sprint ${progress.sprint ?? '?'} of ${progress.sprints}`;
651
799
  lines.push(`${pad('Progress')}${storyPart} · ${sprintPart}`);
652
- lines.push(`${pad('Status')}${state.status || `unknown${state.warning ? ` — ${state.warning}` : ''}`}`);
653
- return [...lines, ...also];
800
+ if (state.spend) lines.push(`${pad('Spend')}${spendValue(state.spend)}`);
801
+ lines.push(`${pad('Status')}${statusValue(state)}`);
802
+ return [...lines, ...also, ...boardLines(state, pad)];
654
803
  }
655
804
 
656
805
  // realpath on both sides: a plugin or checkout reached through a symlink (macOS /tmp → /private/tmp) would