@golden-frijoles/kit 0.11.0 → 0.12.1
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/intent-outcomes.mjs +276 -0
- package/package.json +1 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// intent-outcomes.mjs — does the intent score predict anything? One row per scored epic, across repos (intent-match
|
|
3
|
+
// S3.2, D18).
|
|
4
|
+
//
|
|
5
|
+
// node scripts/intent-outcomes.mjs this repo
|
|
6
|
+
// node scripts/intent-outcomes.mjs --repo . --repo ../other-project every repo on the same Roadmap layout
|
|
7
|
+
// node scripts/intent-outcomes.mjs --json the rows as JSON
|
|
8
|
+
//
|
|
9
|
+
// Each row joins three things, all read from files:
|
|
10
|
+
// the SCORE the epic README's `intent_match:`, else its seed's, else the repo's backfill record
|
|
11
|
+
// (`Roadmap/00-ideas/intent-backfill.json`, D19) — and whether the ask was verbatim or a proxy
|
|
12
|
+
// the ANSWER the retrospective's `_Intent: yes | mostly | no_` — the product owner's word, the label
|
|
13
|
+
// CORRECTIONS DERIVED, never self-reported: stories added after the scaffold (the README's `stories_total`
|
|
14
|
+
// now vs. in the commit that added it), dated amendments (`Amended YYYY-MM-DD`), disproved scope
|
|
15
|
+
// (`disprov…`). A plan that needed correcting is evidence the score can be checked against.
|
|
16
|
+
//
|
|
17
|
+
// The footer says how close the data is to the twenty answered epics the calibration waits for ("n of 20"). This
|
|
18
|
+
// reports; it fits nothing (the intent-match no-go) and it gates nothing.
|
|
19
|
+
//
|
|
20
|
+
// git is only used to read the README as first committed. Its environment is SEALED (GIT_* stripped): run from a
|
|
21
|
+
// hook in a linked worktree, an inherited GIT_DIR would point every `git -C <other repo>` at THIS repository.
|
|
22
|
+
// Zero deps — Node 18+.
|
|
23
|
+
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
export const CALIBRATION_N = 20;
|
|
30
|
+
export const BACKFILL_PATH = join('Roadmap', '00-ideas', 'intent-backfill.json');
|
|
31
|
+
|
|
32
|
+
/** Flat frontmatter, comment- and quote-stripped (the epic-dod rule). Pure. */
|
|
33
|
+
export function frontmatter(text) {
|
|
34
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text ?? ''));
|
|
35
|
+
const out = {};
|
|
36
|
+
if (!m) return out;
|
|
37
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
38
|
+
const kv = /^([A-Za-z_][\w-]*):\s*(.*?)\s*(?:#.*)?$/.exec(line);
|
|
39
|
+
if (kv) out[kv[1]] = kv[2].replace(/^["']|["']$/g, '');
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const score = (v) => (/^\d{1,3}$/.test(String(v ?? '')) && Number(v) <= 100 ? Number(v) : null);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The answer — the SAME rule as `epic-dod.mjs`'s `intentAnswer` (a spec pins the two together): comments stripped, a
|
|
48
|
+
* line that is exactly `_Intent: yes_` · `_mostly_` · `_no_`. Pure.
|
|
49
|
+
*/
|
|
50
|
+
export function intentAnswer(retro) {
|
|
51
|
+
const text = String(retro ?? '').replace(/<!--[\s\S]*?-->/g, '');
|
|
52
|
+
const m = /^_Intent:\s*(yes|mostly|no)\s*_\s*$/im.exec(text);
|
|
53
|
+
return m ? m[1].toLowerCase() : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Corrections derivable from the README alone. Pure. `firstReadme` is the README as first committed, or null. */
|
|
57
|
+
export function corrections(readme, firstReadme) {
|
|
58
|
+
const now = Number(frontmatter(readme).stories_total);
|
|
59
|
+
const then = firstReadme == null ? NaN : Number(frontmatter(firstReadme).stories_total);
|
|
60
|
+
return {
|
|
61
|
+
storiesAdded: Number.isFinite(now) && Number.isFinite(then) ? Math.max(0, now - then) : null,
|
|
62
|
+
amendments: (String(readme).match(/\bAmended \d{4}-\d{2}-\d{2}\b/gi) ?? []).length,
|
|
63
|
+
disproved: (String(readme).match(/\bdisprov\w*/gi) ?? []).length,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The backfill record, or [] when there is none. A MALFORMED record throws: evidence tooling fails closed. */
|
|
68
|
+
export function parseBackfill(text, where = BACKFILL_PATH) {
|
|
69
|
+
if (text == null) return [];
|
|
70
|
+
let json;
|
|
71
|
+
try {
|
|
72
|
+
json = JSON.parse(text);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
throw new Error(`${where}: not JSON (${e.message})`);
|
|
75
|
+
}
|
|
76
|
+
if (!Array.isArray(json?.epics)) throw new Error(`${where}: needs an "epics" array`);
|
|
77
|
+
return json.epics.map((e, i) => {
|
|
78
|
+
if (typeof e?.slug !== 'string' || score(e.score) == null)
|
|
79
|
+
throw new Error(`${where}: epics[${i}] needs a slug and a whole-number score 0–100`);
|
|
80
|
+
return { slug: e.slug, score: Number(e.score), ask: e.ask === 'verbatim' ? 'verbatim' : 'proxy' };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One repo's rows. Pure over its inputs: epics = [{ slug, macro, readme, firstReadme, seed, retro }], backfill =
|
|
86
|
+
* parseBackfill(). Only SCORED epics get a row (D18).
|
|
87
|
+
*/
|
|
88
|
+
export function rowsFor(repo, epics, backfill = []) {
|
|
89
|
+
const back = new Map(backfill.map((b) => [b.slug, b]));
|
|
90
|
+
// A backfill slug with no epic folder (a typo, a renamed epic) would silently shrink the table: fail closed.
|
|
91
|
+
const known = new Set(epics.map((e) => e.slug));
|
|
92
|
+
const orphans = backfill.filter((b) => !known.has(b.slug)).map((b) => b.slug);
|
|
93
|
+
if (orphans.length)
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${repo}: intent-backfill.json names epics with no Roadmap folder: ${orphans.join(', ')}`
|
|
96
|
+
);
|
|
97
|
+
const rows = [];
|
|
98
|
+
for (const e of epics) {
|
|
99
|
+
const rfm = frontmatter(e.readme);
|
|
100
|
+
const sfm = frontmatter(e.seed ?? '');
|
|
101
|
+
const b = back.get(e.slug);
|
|
102
|
+
const from =
|
|
103
|
+
score(rfm.intent_match) != null
|
|
104
|
+
? 'readme'
|
|
105
|
+
: score(sfm.intent_match) != null
|
|
106
|
+
? 'seed'
|
|
107
|
+
: b
|
|
108
|
+
? 'backfill'
|
|
109
|
+
: null;
|
|
110
|
+
if (!from) continue;
|
|
111
|
+
const value =
|
|
112
|
+
from === 'readme' ? score(rfm.intent_match) : from === 'seed' ? score(sfm.intent_match) : b.score;
|
|
113
|
+
const ask =
|
|
114
|
+
from === 'backfill'
|
|
115
|
+
? b.ask
|
|
116
|
+
: sfm.intent_ask === 'verbatim'
|
|
117
|
+
? 'verbatim'
|
|
118
|
+
: sfm.intent_ask === 'proxy'
|
|
119
|
+
? 'proxy'
|
|
120
|
+
: 'unknown';
|
|
121
|
+
const c = corrections(e.readme, e.firstReadme);
|
|
122
|
+
rows.push({
|
|
123
|
+
repo,
|
|
124
|
+
epic: e.slug,
|
|
125
|
+
macro: e.macro,
|
|
126
|
+
status: rfm.status ?? null,
|
|
127
|
+
score: value,
|
|
128
|
+
source: from,
|
|
129
|
+
ask,
|
|
130
|
+
answer: intentAnswer(e.retro),
|
|
131
|
+
...c,
|
|
132
|
+
corrected: (c.storiesAdded ?? 0) + c.amendments + c.disproved > 0,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return rows;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The footer: how many rows are labelled, of the twenty the calibration waits for. Pure. */
|
|
139
|
+
export function summary(rows, repoCount) {
|
|
140
|
+
const answered = rows.filter((r) => r.answer);
|
|
141
|
+
const proxy = answered.filter((r) => r.ask === 'proxy').length;
|
|
142
|
+
const waiting = rows.filter((r) => !r.answer && r.status === 'shipped').length;
|
|
143
|
+
return {
|
|
144
|
+
scored: rows.length,
|
|
145
|
+
answered: answered.length,
|
|
146
|
+
proxy,
|
|
147
|
+
waiting,
|
|
148
|
+
line:
|
|
149
|
+
`${answered.length} of ${CALIBRATION_N}: ${answered.length} scored epic(s) answered (${proxy} with a proxy ask), ` +
|
|
150
|
+
`${rows.length} scored across ${repoCount} repo(s); ${waiting} shipped but unanswered. ` +
|
|
151
|
+
(answered.length >= CALIBRATION_N
|
|
152
|
+
? 'Enough to fit the thresholds.'
|
|
153
|
+
: `Thresholds are fitted at ${CALIBRATION_N}.`),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function formatTable(rows) {
|
|
158
|
+
const head = ['repo', 'epic', 'score', 'ask', 'answer', '+stories', 'amended', 'disproved'];
|
|
159
|
+
const body = rows.map((r) => [
|
|
160
|
+
r.repo,
|
|
161
|
+
r.epic,
|
|
162
|
+
String(r.score),
|
|
163
|
+
r.ask === 'proxy' ? 'proxy ⚑' : r.ask,
|
|
164
|
+
r.answer ?? '—',
|
|
165
|
+
r.storiesAdded == null ? '?' : String(r.storiesAdded),
|
|
166
|
+
String(r.amendments),
|
|
167
|
+
String(r.disproved),
|
|
168
|
+
]);
|
|
169
|
+
const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
|
|
170
|
+
const line = (cells) =>
|
|
171
|
+
cells
|
|
172
|
+
.map((c, i) => c.padEnd(w[i]))
|
|
173
|
+
.join(' ')
|
|
174
|
+
.trimEnd();
|
|
175
|
+
return [line(head), ...body.map(line)].join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── I/O ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/** process.env without GIT_DIR and friends — see the header. */
|
|
181
|
+
export function sealedEnv(env = process.env) {
|
|
182
|
+
const out = { ...env };
|
|
183
|
+
for (const k of Object.keys(out)) if (/^GIT_/.test(k)) delete out[k];
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The README exactly as the commit that ADDED it wrote it, or null (no git, never committed, unreadable). */
|
|
188
|
+
export function firstCommitted(repoDir, relPath, exec = spawnSync) {
|
|
189
|
+
const git = (args) => exec('git', ['-C', repoDir, ...args], { encoding: 'utf8', env: sealedEnv() });
|
|
190
|
+
const log = git(['log', '--diff-filter=A', '--format=%H', '--', relPath]);
|
|
191
|
+
if (log.status !== 0) return null;
|
|
192
|
+
const sha = String(log.stdout).trim().split('\n').filter(Boolean).pop();
|
|
193
|
+
if (!sha) return null;
|
|
194
|
+
const show = git(['show', `${sha}:${relPath}`]);
|
|
195
|
+
return show.status === 0 ? String(show.stdout) : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Every epic under <repo>/Roadmap/<macro>/<slug>/README.md, with its seed and retro. */
|
|
199
|
+
export function readRepo(repoDir, { exec = spawnSync } = {}) {
|
|
200
|
+
const roadmap = join(repoDir, 'Roadmap');
|
|
201
|
+
if (!existsSync(roadmap)) throw new Error(`${repoDir}: no Roadmap/ — not a repo on this layout`);
|
|
202
|
+
const read = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null);
|
|
203
|
+
const epics = [];
|
|
204
|
+
for (const macro of readdirSync(roadmap, { withFileTypes: true })) {
|
|
205
|
+
if (!macro.isDirectory() || macro.name === '00-ideas') continue;
|
|
206
|
+
for (const ep of readdirSync(join(roadmap, macro.name), { withFileTypes: true })) {
|
|
207
|
+
if (!ep.isDirectory()) continue;
|
|
208
|
+
const readmePath = join(roadmap, macro.name, ep.name, 'README.md');
|
|
209
|
+
const readme = read(readmePath);
|
|
210
|
+
if (readme == null) continue;
|
|
211
|
+
epics.push({
|
|
212
|
+
slug: ep.name,
|
|
213
|
+
macro: macro.name,
|
|
214
|
+
readme,
|
|
215
|
+
seed: read(join(roadmap, '00-ideas', 'seeds', `${ep.name}.md`)),
|
|
216
|
+
retro: read(join(roadmap, macro.name, ep.name, 'RETROSPECTIVE.md')),
|
|
217
|
+
readmePath: relative(repoDir, readmePath),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const backfill = parseBackfill(
|
|
222
|
+
read(join(repoDir, BACKFILL_PATH)),
|
|
223
|
+
join(basename(resolve(repoDir)), BACKFILL_PATH)
|
|
224
|
+
);
|
|
225
|
+
// Only a scored epic's first commit is worth a git call.
|
|
226
|
+
const scoredSlugs = new Set(rowsFor('', epics, backfill).map((r) => r.epic));
|
|
227
|
+
for (const e of epics)
|
|
228
|
+
e.firstReadme = scoredSlugs.has(e.slug) ? firstCommitted(repoDir, e.readmePath, exec) : null;
|
|
229
|
+
return { epics, backfill };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function parseArgs(argv) {
|
|
233
|
+
const repos = [];
|
|
234
|
+
let json = false;
|
|
235
|
+
for (let i = 0; i < argv.length; i++) {
|
|
236
|
+
if (argv[i] === '--repo') repos.push(argv[++i]);
|
|
237
|
+
else if (argv[i] === '--json') json = true;
|
|
238
|
+
else return { error: `unknown argument ${argv[i]}` };
|
|
239
|
+
}
|
|
240
|
+
if (repos.some((r) => !r)) return { error: '--repo needs a path' };
|
|
241
|
+
return { repos: repos.length ? repos : ['.'], json };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function run(argv, io = {}) {
|
|
245
|
+
const { out = (t) => process.stdout.write(t), err = (t) => process.stderr.write(t), read = readRepo } = io;
|
|
246
|
+
const args = parseArgs(argv);
|
|
247
|
+
if (args.error) {
|
|
248
|
+
err(
|
|
249
|
+
`intent-outcomes: ${args.error}\nusage: node scripts/intent-outcomes.mjs [--repo <path>]... [--json]\n`
|
|
250
|
+
);
|
|
251
|
+
return 1;
|
|
252
|
+
}
|
|
253
|
+
const rows = [];
|
|
254
|
+
for (const r of args.repos) {
|
|
255
|
+
let data;
|
|
256
|
+
try {
|
|
257
|
+
data = read(r);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
// One unreadable repo is "could not look" for the whole report: a table that silently drops a repo would
|
|
260
|
+
// report a smaller n as if it were the whole of it.
|
|
261
|
+
err(`intent-outcomes: could not look — ${e.message}\n`);
|
|
262
|
+
return 2;
|
|
263
|
+
}
|
|
264
|
+
rows.push(...rowsFor(basename(resolve(r)), data.epics, data.backfill));
|
|
265
|
+
}
|
|
266
|
+
const s = summary(rows, args.repos.length);
|
|
267
|
+
if (args.json) out(`${JSON.stringify({ rows, ...s }, null, 2)}\n`);
|
|
268
|
+
else
|
|
269
|
+
out(
|
|
270
|
+
`${rows.length ? formatTable(rows) : '(no scored epics yet)'}\n\n${s.line}\n⚑ = the ask is a proxy (D8): weigh or drop it when fitting.\n`
|
|
271
|
+
);
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
276
|
+
if (isMain) process.exitCode = run(process.argv.slice(2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@golden-frijoles/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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": {
|