@golden-frijoles/kit 0.39.0 → 0.40.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/build-state.mjs +117 -10
  2. package/package.json +1 -1
@@ -56,12 +56,12 @@ function repoEnv() {
56
56
  import { readFileSync, readdirSync, existsSync, statSync, realpathSync } from 'node:fs';
57
57
  import { fileURLToPath } from 'node:url';
58
58
  import { dirname, join, resolve } from 'node:path';
59
- import { PHASES, parseDocFrontmatter } from './lib/roadmap-contract.mjs';
59
+ import { PHASES, isDay, 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
62
  import { buildRows } from './roadmap-extract.mjs';
63
63
  import { gatherFacts } from './lib/stage-facts.mjs';
64
- import { groupByStage } from './lib/stage.mjs';
64
+ import { STAGES, groupByStage } from './lib/stage.mjs';
65
65
  import { getKey } from './lib/config.mjs';
66
66
 
67
67
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -222,6 +222,7 @@ function readEpic(root, slug) {
222
222
  locked_at: readme.data.locked_at ?? null, // live-build-view D10 — stamped by scripts/epic-phase.mjs lock
223
223
  lifecycle: readme.data.status ?? null,
224
224
  quote: quoteOf(readme.data),
225
+ target: targetOf(readme.data),
225
226
  contract: readme.hasFrontmatter && !readme.error && 'phase' in readme.data,
226
227
  sprints,
227
228
  };
@@ -435,6 +436,25 @@ function spendLine(spend, quote) {
435
436
  return [`${usd} of quote ${range}`, tok, sessions].filter(Boolean).join(' · ');
436
437
  }
437
438
 
439
+ /**
440
+ * The result record's target off the README frontmatter (build-view-upgrade D1; the fields are result-record's
441
+ * TARGET_FIELDS). A target is the metric with both numbers; a hypothesis on its own is kept, and is not a target.
442
+ */
443
+ export function targetOf(readme) {
444
+ const text = (v) => (typeof v === 'string' && v.trim() && v.trim() !== 'null' ? v.trim() : null);
445
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
446
+ const metric = text(readme.target_metric);
447
+ const from = num(readme.target_from);
448
+ const to = num(readme.target_to);
449
+ return {
450
+ hypothesis: text(readme.hypothesis),
451
+ metric: metric !== null && from !== null && to !== null ? metric : null,
452
+ from,
453
+ to,
454
+ read_date: isDay(readme.read_date) ? readme.read_date : null,
455
+ };
456
+ }
457
+
438
458
  /** The epic's quote off its README frontmatter, or null (an unquoted epic — never a $0 quote, D4). */
439
459
  export function quoteOf(readme) {
440
460
  const lo = readme.quote_low_usd;
@@ -486,7 +506,8 @@ export function boardState({ root, facts, state, now = new Date() }) {
486
506
  qa: cols.QA.length,
487
507
  ready: cols['Ready to build'].length,
488
508
  next: next ? { name: next.name, slug: next.slug, build_order: next.build_order_num ?? null } : null,
489
- url: hubUrl ? `${hubUrl}/board${slug && row ? `?card=${encodeURIComponent(slug)}` : ''}` : null,
509
+ // build-view-upgrade D4 — the work's own page when the board has a row for it, else the board.
510
+ url: hubUrl ? (slug && row ? `${hubUrl}/epic/${encodeURIComponent(slug)}` : `${hubUrl}/board`) : null,
490
511
  },
491
512
  };
492
513
  }
@@ -811,8 +832,15 @@ function resolve_({ root, offline = false, git = makeGit(root), facts = null, gh
811
832
  story_source: storySource,
812
833
  story_note: unreadableSprint ? `${unreadableSprint}${storyNote ? ` — ${storyNote}` : ''}` : storyNote,
813
834
  warning: unreadableSprint,
835
+ target: epic.target,
814
836
  progress: {
815
837
  stories_with_commits: withCommits.size,
838
+ // build-view-upgrade D2 — the same measure per sprint, in build order: [done, total] for each.
839
+ by_sprint: epic.sprints.map((sp) => ({
840
+ n: sp.n,
841
+ done: sp.stories.filter((st) => withCommits.has(st.id)).length,
842
+ total: sp.stories.length,
843
+ })),
816
844
  story: story ? story.ordinal : null,
817
845
  stories: allStories.length,
818
846
  sprint: sprint ? epic.sprints.indexOf(sprint) + 1 : null,
@@ -868,6 +896,12 @@ function elsewhereLines(state, pad) {
868
896
  */
869
897
  export function statusValue(state) {
870
898
  if (!state.stage) return state.status || `unknown${state.warning ? ` — ${state.warning}` : ''}`;
899
+ const { stage, source, age, phase } = stageParts(state);
900
+ return `${stage} · from ${source} (${age})${phase}`;
901
+ }
902
+
903
+ /** The stage in flight, where it came from, how old that is and a differing written phase — statusValue's parts. */
904
+ function stageParts(state) {
871
905
  const source = String(state.stage_source || '').replace(/ · snapshot@\S+$/, '');
872
906
  // live-build-view D11 — the band's refinement of Building, not a new stage (the Hub and the board keep Building): an
873
907
  // epic whose branch is live but whose README carries no `locked_at` is still Locking architecture. The lock is a
@@ -889,7 +923,74 @@ export function statusValue(state) {
889
923
  : 'docs only, no snapshot yet';
890
924
  const phase =
891
925
  state.phase_written && state.phase_written !== stage ? ` · phase ${state.phase_written}` : '';
892
- return `${stage} · from ${source} (${age})${phase}`;
926
+ return { stage, locking, source, age, phase };
927
+ }
928
+
929
+ // build-view-upgrade D3 — the stage as a track, in lib/stage.mjs's words. `To groom` shows only when it is the stage.
930
+ const TRACK_WORDS = { 'To groom': 'Backlog', 'Ready to build': 'Ready' };
931
+ export const TRACK_MARK = '◉';
932
+
933
+ /** `Grooming ─ Ready ─ ◉ Building ─ QA ─ Shipped` for a stage; `◉ Locking` in Building's place while locking. */
934
+ export function stageTrack(stage, locking = false) {
935
+ return STAGES.filter((s) => s !== 'To groom' || stage === 'To groom')
936
+ .map((s) => {
937
+ const word = s === 'Building' && locking ? 'Locking' : (TRACK_WORDS[s] ?? s);
938
+ return s === stage ? `${TRACK_MARK} ${word}` : word;
939
+ })
940
+ .join(' ─ ');
941
+ }
942
+
943
+ /** The Status line and its continuation (D3): the track, then where the stage came from. No stage → one plain line. */
944
+ function statusLines(state, pad, cont) {
945
+ if (!state.stage || !STAGES.includes(state.stage)) return [`${pad('Status')}${statusValue(state)}`];
946
+ const { locking, source, age, phase } = stageParts(state);
947
+ return [`${pad('Status')}${stageTrack(state.stage, locking)}`, `${cont}from ${source} (${age})${phase}`];
948
+ }
949
+
950
+ // build-view-upgrade D1 — why we are building it: the result record's target, or "no target set".
951
+ const WIDTH = 80;
952
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
953
+
954
+ /** `2026-12-04` → `4 Dec` (with the year when it is not `now`'s), read in UTC like every result-record day. */
955
+ export function shortDay(day, now = new Date()) {
956
+ if (!isDay(day)) return null;
957
+ const [y, m, d] = day.split('-').map(Number);
958
+ return `${d} ${MONTHS[m - 1]}${y !== now.getUTCFullYear() ? ` ${y}` : ''}`;
959
+ }
960
+
961
+ // Four significant digits, so a small target (0.004 → 0.006) never rounds to 0 (#312 review).
962
+ const fmtNum = (n) => (Number.isInteger(n) ? String(n) : String(Number(n.toPrecision(4))));
963
+
964
+ /** The Why line and, with a target, its continuation — each clipped to fit 80 columns. */
965
+ export function whyLines(target, pad, cont, now = new Date()) {
966
+ const room = WIDTH - cont.length;
967
+ const t = target || {};
968
+ if (!t.metric) {
969
+ if (!t.hypothesis) return [`${pad('Why')}no target set`];
970
+ return [`${pad('Why')}${clip(t.hypothesis, room)}`, `${cont}no target set`];
971
+ }
972
+ const read = t.read_date ? `read ${shortDay(t.read_date, now)}` : 'read 30 days after shipping';
973
+ const numbers = ` ${fmtNum(t.from)} ━━▸ ${fmtNum(t.to)} · ${read}`;
974
+ const metric = `${clip(t.metric, Math.max(8, room - numbers.length))}${numbers}`;
975
+ return t.hypothesis
976
+ ? [`${pad('Why')}${clip(t.hypothesis, room)}`, `${cont}${clip(metric, room)}`]
977
+ : [`${pad('Why')}${clip(metric, room)}`];
978
+ }
979
+
980
+ // build-view-upgrade D2 — one cell per story, sprint by sprint: ▰ has a commit, ▱ not yet, │ between sprints.
981
+ export const SPRINT_CELLS_MAX = 8;
982
+
983
+ /** `[{done:2,total:3},{done:0,total:4}]` → `▰▰▱│▱▱▱▱`. A sprint wider than SPRINT_CELLS_MAX is scaled to it. */
984
+ export function sprintBars(bySprint) {
985
+ return (bySprint || [])
986
+ .filter((sp) => sp.total > 0)
987
+ .map((sp) => {
988
+ const width = Math.min(sp.total, SPRINT_CELLS_MAX);
989
+ // Rounded down, so a scaled sprint is never drawn full before every story has a commit (#312 review).
990
+ const filled = sp.done >= sp.total ? width : Math.max(0, Math.min(width - 1, Math.floor((sp.done / sp.total) * width)));
991
+ return '▰'.repeat(filled) + '▱'.repeat(width - filled);
992
+ })
993
+ .join('│');
893
994
  }
894
995
 
895
996
  /** The board around this work in one line, and the link to it when `board.hubUrl` is set (S3.1). */
@@ -903,7 +1004,7 @@ function boardLines(state, pad) {
903
1004
  return [`${pad('Board')}${counts}`, ...(b.url ? [`${pad('')}↗ ${b.url}`] : [])];
904
1005
  }
905
1006
 
906
- export function renderLines(state) {
1007
+ export function renderLines(state, now = new Date()) {
907
1008
  const pad = (label) => ` ${label.padEnd(9)}`;
908
1009
  if (!state.in_flight)
909
1010
  return [`No epic in flight — ${state.reason}`, ...elsewhereLines(state, pad), ...boardLines(state, pad)];
@@ -926,7 +1027,7 @@ export function renderLines(state) {
926
1027
  HEADINGS[seed.type] || 'Currently working on',
927
1028
  `${pad(label)}${seed.title}${meta ? ` ${meta}` : ''}`,
928
1029
  `${pad('Seed')}${seed.path}`,
929
- `${pad('Status')}${statusValue(state)}`,
1030
+ ...statusLines(state, pad, ' '.repeat(11)),
930
1031
  ...also,
931
1032
  ...boardLines(state, pad),
932
1033
  ];
@@ -934,7 +1035,11 @@ export function renderLines(state) {
934
1035
  const { epic, story, progress } = state;
935
1036
  const cont = ' '.repeat(11);
936
1037
  const risk = epic.risk ? ` · risk ${epic.risk.toUpperCase()}` : '';
937
- const lines = ['Currently building', `${pad('Epic')}${epic.title} ${epic.area}${risk}`];
1038
+ const lines = [
1039
+ 'Currently building',
1040
+ `${pad('Epic')}${epic.title} ${epic.area}${risk}`,
1041
+ ...whyLines(state.target, pad, cont, now),
1042
+ ];
938
1043
  if (story) {
939
1044
  lines.push(`${pad('Story')}${story.id}${story.title ? ` — ${story.title}` : ''}`);
940
1045
  lines.push(
@@ -948,12 +1053,14 @@ export function renderLines(state) {
948
1053
  }
949
1054
  // S2.2 — stories DONE (with commits), not the in-flight story's position: "Story 1 of 7" at the end of a sprint
950
1055
  // was a position, and read as progress.
951
- const done = `${progress.stories_with_commits ?? 0} of ${progress.stories} stories have commits`;
1056
+ // build-view-upgrade D2: "done" is the same measure, a story with a commit — the bars draw it per sprint.
1057
+ const bars = sprintBars(progress.by_sprint);
1058
+ const done = `${progress.stories_with_commits ?? 0} of ${progress.stories} stories done`;
952
1059
  const inFlight = story ? ` · in flight ${story.id}` : '';
953
1060
  const sprintPart = `Sprint ${progress.sprint ?? '?'} of ${progress.sprints}`;
954
- lines.push(`${pad('Progress')}${done}${inFlight} · ${sprintPart}`);
1061
+ lines.push(`${pad('Progress')}${bars ? `${bars} ` : ''}${done}${inFlight} · ${sprintPart}`);
955
1062
  if (state.spend) lines.push(`${pad('Spend')}${spendValue(state.spend, state.quote ?? null)}`);
956
- lines.push(`${pad('Status')}${statusValue(state)}`);
1063
+ lines.push(...statusLines(state, pad, cont));
957
1064
  return [...lines, ...also, ...boardLines(state, pad)];
958
1065
  }
959
1066
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.39.0",
3
+ "version": "0.40.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": {