@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.
- package/dist/build-state.mjs +168 -19
- package/dist/epic-actuals.mjs +798 -0
- package/dist/lib/board-text.mjs +50 -0
- package/dist/lib/config-registry.mjs +17 -0
- package/dist/lib/cross-agent-cli.mjs +2 -2
- package/dist/lib/model-prices.mjs +103 -0
- package/dist/lib/roadmap-contract.mjs +57 -4
- package/dist/roadmap-extract.mjs +109 -45
- package/dist/roadmap-push.mjs +210 -0
- package/dist/session-resume.mjs +11 -0
- package/package.json +1 -1
package/dist/build-state.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
);
|
|
249
|
-
const
|
|
250
|
-
|
|
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
|
|
294
|
+
return null;
|
|
253
295
|
}
|
|
254
296
|
}
|
|
255
297
|
|
|
256
|
-
|
|
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),
|
|
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)
|
|
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
|
|
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('
|
|
653
|
-
|
|
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
|