@golden-frijoles/kit 0.30.0 → 0.31.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.
@@ -0,0 +1,75 @@
1
+ // result-dates.mjs — the result record's date arithmetic, written once (result-record D5). Pure: zero I/O, zero deps.
2
+ //
3
+ // An epic that carries a target (`target_metric`) is read on its `read_date`. When none was written, the read falls
4
+ // 30 days after shipping; a verdict written more than 90 days after shipping is late (it is kept, and it does not count
5
+ // for the North Star). Those two numbers are `Roadmap/00-strategy/north-star.md`'s rule. Extract, `epic-read` and
6
+ // `session-resume` import them from here; `apps/web/lib/roadmap-result.ts` holds a copy a spec pins to this one.
7
+ //
8
+ // Every date is a calendar day, `YYYY-MM-DD`, read and written in UTC so a reader's timezone never moves a read. The
9
+ // day check itself is the contract's (`isDay`), which stays dependency-free.
10
+
11
+ export const READ_DEFAULT_DAYS = 30;
12
+ export const READ_CAP_DAYS = 90;
13
+
14
+ import { isDay } from './roadmap-contract.mjs';
15
+
16
+ export { isDay };
17
+
18
+ const MS_PER_DAY = 86_400_000;
19
+
20
+ /** The calendar day part of a date or date-time string, or null. `2026-10-04T12:00:00Z` → `2026-10-04`. */
21
+ export function dayOf(v) {
22
+ if (typeof v !== 'string') return null;
23
+ const day = v.slice(0, 10);
24
+ return isDay(day) ? day : null;
25
+ }
26
+
27
+ /** `day` + `n` days, as `YYYY-MM-DD`. Null when `day` is not a day. */
28
+ export function addDays(day, n) {
29
+ const d = dayOf(day);
30
+ if (!d) return null;
31
+ return new Date(Date.parse(`${d}T00:00:00Z`) + n * MS_PER_DAY).toISOString().slice(0, 10);
32
+ }
33
+
34
+ /** Whole days from `a` to `b` (positive when `b` is later). Null when either is not a day. */
35
+ export function daysBetween(a, b) {
36
+ const x = dayOf(a);
37
+ const y = dayOf(b);
38
+ if (!x || !y) return null;
39
+ return Math.round((Date.parse(`${y}T00:00:00Z`) - Date.parse(`${x}T00:00:00Z`)) / MS_PER_DAY);
40
+ }
41
+
42
+ /** Today in UTC, `YYYY-MM-DD`. */
43
+ export function todayUtc(now = new Date()) {
44
+ return now.toISOString().slice(0, 10);
45
+ }
46
+
47
+ /**
48
+ * The read date an epic is held to: the one written (or ship day, if it passed before shipping), else — only for a SHIPPED epic that HAS a target — 30 days after
49
+ * shipping, marked derived. An epic with no target has no read date (it can still be read by hand), so the 54 epics
50
+ * shipped before the result record existed are never due (the no-backfill rule).
51
+ */
52
+ export function readDateOf({ readDate, targetMetric, shippedAt }) {
53
+ // A day written at grooming that passes before the epic ships is read on ship day (codex review, #290): there was
54
+ // nothing out there to read before then, and "overdue since before release" would be a false alarm.
55
+ const shipped = dayOf(shippedAt);
56
+ if (isDay(readDate))
57
+ return { readDate: shipped && readDate < shipped ? shipped : readDate, derived: false };
58
+ if (!targetMetric || !dayOf(shippedAt)) return { readDate: null, derived: false };
59
+ return { readDate: addDays(shippedAt, READ_DEFAULT_DAYS), derived: true };
60
+ }
61
+
62
+ /** Whether a verdict written on `verdictAt` is late: more than 90 days after shipping. False when either is unknown. */
63
+ export function isLate({ verdictAt, shippedAt }) {
64
+ const days = daysBetween(shippedAt, verdictAt);
65
+ return days !== null && days > READ_CAP_DAYS;
66
+ }
67
+
68
+ /**
69
+ * Whether a read is due today: a shipped epic with a target, no verdict yet, and a read date that has arrived. An epic
70
+ * not yet shipped is never due, even past a read date written at grooming: there is nothing out there to read.
71
+ */
72
+ export function isReadDue({ targetMetric, verdict, readDate, shipped }, today = todayUtc()) {
73
+ if (!shipped || !targetMetric || verdict || !isDay(readDate)) return false;
74
+ return today >= readDate;
75
+ }
@@ -55,6 +55,91 @@ export const FINOPS_FIELDS = [
55
55
  'actual_basis',
56
56
  ];
57
57
 
58
+ // result-record D1/D2 — the result record: what an epic should move (written at grooming) and its verdict (written at
59
+ // the read), declared ONCE here. All optional: no target is "no target", never an error. Dates are calendar days.
60
+ export const VERDICTS = ['proven', 'disproven', 'unclear'];
61
+ export const TARGET_FIELDS = ['hypothesis', 'target_metric', 'target_from', 'target_to', 'read_date'];
62
+ export const VERDICT_FIELDS = ['verdict', 'verdict_actual', 'verdict_evidence', 'verdict_at'];
63
+ export const RESULT_FIELDS = [...TARGET_FIELDS, ...VERDICT_FIELDS];
64
+ export const RESULT_NUMERIC_FIELDS = ['target_from', 'target_to', 'verdict_actual'];
65
+ export const RESULT_DAY_FIELDS = ['read_date', 'verdict_at'];
66
+ export const RESULT_TEXT_FIELDS = ['hypothesis', 'target_metric', 'verdict_evidence'];
67
+
68
+ // D2 — what proven or disproven must point at, checked offline as syntax only: a link, a North Star input's reading
69
+ // on a day, or an experiment's A/B decision record. Keys are the platform's own shape (lowercase, digits, _ . -).
70
+ // Case-SENSITIVE on purpose (codex review, #290): with `/i`, `HTTPS:///` matched here and then skipped the URL parse
71
+ // below, which keys on the lowercase scheme. Keys take either case; the scheme and prefixes are lowercase.
72
+ export const EVIDENCE_POINTER_RE =
73
+ /^(?:https:\/\/\S+|north-star:[A-Za-z0-9][A-Za-z0-9_.-]*@\d{4}-\d{2}-\d{2}|ab:[A-Za-z0-9][A-Za-z0-9_.-]*)$/;
74
+
75
+ // Zero dependencies is load-bearing: projects and fixtures copy this file on its own. So the day check lives here and
76
+ // `result-dates.mjs` imports it, not the other way round.
77
+ /** Whether `v` is a real calendar day written `YYYY-MM-DD` (`2026-02-30` is not), read in UTC. */
78
+ export function isDay(v) {
79
+ if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
80
+ const d = new Date(`${v}T00:00:00Z`);
81
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
82
+ }
83
+
84
+ /** Whether `v` is an evidence pointer (D2). A `north-star:` pointer's day must be a real calendar day. */
85
+ export function isEvidencePointer(v) {
86
+ if (typeof v !== 'string' || !EVIDENCE_POINTER_RE.test(v.trim())) return false;
87
+ const t = v.trim();
88
+ // A link must PARSE (codex review, #290): the regex alone lets `https:///` through. The WHATWG parser refuses an
89
+ // https URL with no host, so a successful parse is the whole check — the scheme was already matched above.
90
+ if (t.startsWith('https://')) {
91
+ try {
92
+ new URL(t);
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+ const day = /@(\d{4}-\d{2}-\d{2})$/.exec(t)?.[1];
99
+ return !day || isDay(day);
100
+ }
101
+
102
+ /** The result-record fields of an epic's frontmatter data → offenses (`contract-result-invalid`). Absent is fine. */
103
+ export function validateResultFields(fm) {
104
+ const offenses = [];
105
+ const bad = (detail) => offenses.push({ rule: 'contract-result-invalid', detail });
106
+ const has = (key) => fm[key] !== undefined && fm[key] !== null;
107
+ for (const key of RESULT_NUMERIC_FIELDS)
108
+ if (has(key) && (typeof fm[key] !== 'number' || !Number.isFinite(fm[key])))
109
+ bad(`${key}: "${fm[key]}" is not a number (or null)`);
110
+ for (const key of RESULT_DAY_FIELDS)
111
+ if (has(key) && !isDay(fm[key])) bad(`${key}: "${fm[key]}" is not a day written YYYY-MM-DD (or null)`);
112
+ for (const key of RESULT_TEXT_FIELDS)
113
+ if (has(key) && (typeof fm[key] !== 'string' || !fm[key].trim())) bad(`${key}: "${fm[key]}" is not text (or null)`);
114
+ // A target is the three together — which number, from what, to what (fresh review, #290): from/to or a read date
115
+ // with no metric would never come due (everything keys off target_metric), and a metric with no numbers has no
116
+ // direction to judge. A hypothesis on its own is allowed: a sentence is not a target.
117
+ const targetKeys = ['target_metric', 'target_from', 'target_to', 'read_date'];
118
+ if (targetKeys.some(has)) {
119
+ for (const key of ['target_metric', 'target_from', 'target_to'])
120
+ if (!has(key)) bad(`a target needs target_metric, target_from and target_to together — ${key} is missing`);
121
+ }
122
+ if (has('target_from') && has('target_to') && fm.target_from === fm.target_to)
123
+ bad(`target_from and target_to are both ${fm.target_from}: a target has to move the number`);
124
+ if (has('verdict') && !VERDICTS.includes(fm.verdict))
125
+ bad(`verdict: "${fm.verdict}" is not one of ${VERDICTS.join(' | ')}`);
126
+ if (!has('verdict')) {
127
+ for (const key of VERDICT_FIELDS.slice(1)) if (has(key)) bad(`${key} is set but there is no verdict`);
128
+ } else {
129
+ if (!has('verdict_at')) bad('a verdict needs verdict_at (the day it was read)');
130
+ if (!has('verdict_evidence')) bad('a verdict needs verdict_evidence (a pointer, or for unclear, the reason)');
131
+ if (fm.verdict === 'proven' || fm.verdict === 'disproven') {
132
+ if (!has('verdict_actual')) bad(`verdict: ${fm.verdict} needs verdict_actual (the number that was read)`);
133
+ if (has('verdict_evidence') && !isEvidencePointer(fm.verdict_evidence))
134
+ bad(
135
+ `verdict: ${fm.verdict} needs evidence that points somewhere: an https:// link, ` +
136
+ `north-star:<input>@YYYY-MM-DD or ab:<experiment>, not "${fm.verdict_evidence}"`
137
+ );
138
+ }
139
+ }
140
+ return offenses;
141
+ }
142
+
58
143
  // live-build-view D10 — `locked_at:` is stamped by `scripts/epic-phase.mjs lock` when the architecture lock is written;
59
144
  // the build view reads its absence as "Locking architecture". Optional (no epic before it has one), an ISO date-time
60
145
  // string when present: the command writes `"2026-10-03T20:34:34Z"` (quoted, so the frontmatter parser keeps the colons).
@@ -269,6 +354,7 @@ export function validateEpicFrontmatter(parsed, ctx = {}) {
269
354
  offenses.push({ rule: 'contract-total-invalid', detail: `${key}: "${fm[key]}" is not a whole number` });
270
355
  }
271
356
  offenses.push(...validateFinopsFields(fm));
357
+ offenses.push(...validateResultFields(fm));
272
358
  offenses.push(...validateLockedAt(fm));
273
359
  if (isInt(fm.sprints_total) && isInt(ctx.sprintCount) && fm.sprints_total !== ctx.sprintCount)
274
360
  offenses.push({
@@ -68,7 +68,15 @@ import { attributeFacts, resolveStage } from './lib/stage.mjs';
68
68
  import { gatherFacts } from './lib/stage-facts.mjs';
69
69
  import { epicKickoffFromDir, sprintBranch } from './lib/epic-kickoff.mjs';
70
70
  import { renderBoardText } from './lib/board-text.mjs';
71
- import { FINOPS_FIELDS, FINOPS_NUMERIC_FIELDS } from './lib/roadmap-contract.mjs';
71
+ import {
72
+ FINOPS_FIELDS,
73
+ FINOPS_NUMERIC_FIELDS,
74
+ RESULT_DAY_FIELDS,
75
+ RESULT_FIELDS,
76
+ RESULT_NUMERIC_FIELDS,
77
+ VERDICTS,
78
+ } from './lib/roadmap-contract.mjs';
79
+ import { isDay, isLate, readDateOf } from './lib/result-dates.mjs';
72
80
  import { pushRoadmap, reportPush } from './roadmap-push.mjs';
73
81
 
74
82
  const REPO = projectRoot(); // D2 — the CLI's default root; buildRows takes its own
@@ -120,6 +128,39 @@ export function finopsFields(fm) {
120
128
  return out;
121
129
  }
122
130
 
131
+ /**
132
+ * result-record D5 — an epic's target and verdict, off its README frontmatter, plus what is DERIVED from them: the
133
+ * default read date (30 days after shipping, only for a shipped epic that has a target) and whether a verdict came
134
+ * late (more than 90 days after shipping). Derived here and labelled, never written back into the README. A value
135
+ * that does not read as its type is null (doc-format names it); a number is never invented as 0.
136
+ */
137
+ export function resultFields(fm, shippedAt) {
138
+ const out = {};
139
+ for (const key of RESULT_FIELDS) {
140
+ const raw = fm[key];
141
+ const blank = raw === null || raw === undefined || raw === '' || raw === 'null' || raw === '~';
142
+ if (RESULT_NUMERIC_FIELDS.includes(key)) {
143
+ // A numeral or a number, nothing else (codex review, #290): `Number(' ')` is 0 and `Number(true)` is 1.
144
+ const numeral = typeof raw === 'string' && /^-?\d+(?:\.\d+)?$/.test(raw.trim());
145
+ const n = typeof raw === 'number' ? raw : numeral ? Number(raw) : NaN;
146
+ out[key] = !blank && Number.isFinite(n) ? n : null;
147
+ } else if (RESULT_DAY_FIELDS.includes(key)) out[key] = isDay(raw) ? raw : null;
148
+ else if (key === 'verdict') out[key] = VERDICTS.includes(raw) ? raw : null;
149
+ else out[key] = !blank && typeof raw === 'string' ? raw : null;
150
+ }
151
+ // A target is the three together (D1): a metric with no numbers gets no derived read date (codex review, #290).
152
+ const complete = out.target_metric !== null && out.target_from !== null && out.target_to !== null;
153
+ const { readDate, derived } = readDateOf({
154
+ readDate: out.read_date,
155
+ targetMetric: complete ? out.target_metric : null,
156
+ shippedAt,
157
+ });
158
+ out.read_date = readDate;
159
+ out.read_date_derived = derived;
160
+ out.read_late = out.verdict ? isLate({ verdictAt: out.verdict_at, shippedAt }) : false;
161
+ return out;
162
+ }
163
+
123
164
  function parseFrontmatter(md) {
124
165
  if (!md.startsWith('---')) return {};
125
166
  const end = md.indexOf('\n---', 3);
@@ -576,6 +617,7 @@ export function buildRows({
576
617
  kickoff,
577
618
  shipped_at: stage === 'Shipped' ? statusDay : null,
578
619
  ...finopsFields(epicFm),
620
+ ...resultFields(epicFm, stage === 'Shipped' ? statusDay : null),
579
621
  });
580
622
 
581
623
  // Sprint rows (one per sprint-N.md), related to the Epic by slug. boardSprints already carries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.30.0",
3
+ "version": "0.31.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": {