@golden-frijoles/kit 0.0.0 → 0.3.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/README.md +25 -1
- package/bin.mjs +108 -0
- package/dist/LICENSE +202 -0
- package/dist/NOTICE +7 -0
- package/dist/babysit-pr.mjs +213 -0
- package/dist/build-order-sync.mjs +141 -0
- package/dist/build-order.mjs +178 -0
- package/dist/cross-panel.mjs +216 -0
- package/dist/cross-panel.prompt.md +115 -0
- package/dist/doc-format.enforced.json +4 -0
- package/dist/doc-format.mjs +856 -0
- package/dist/doc-hygiene.mjs +279 -0
- package/dist/init.mjs +132 -0
- package/dist/lib/cross-agent-cli.mjs +695 -0
- package/dist/lib/gh-rest.mjs +229 -0
- package/dist/lib/golden-onboarding.mjs +204 -0
- package/dist/lib/jev.mjs +334 -0
- package/dist/lib/log-branch.mjs +106 -0
- package/dist/lib/pmo-benchmarks.mjs +61 -0
- package/dist/lib/pmo-delivery.mjs +102 -0
- package/dist/lib/pmo-metrics.mjs +200 -0
- package/dist/lib/pmo-templates.mjs +132 -0
- package/dist/lib/pmo-window-log.mjs +99 -0
- package/dist/lib/project-root.mjs +78 -0
- package/dist/lib/prose-brief.mjs +191 -0
- package/dist/lib/prose-guard.mjs +761 -0
- package/dist/lib/prose-writer.mjs +410 -0
- package/dist/lib/report-registry.mjs +469 -0
- package/dist/lib/reporting-config.mjs +266 -0
- package/dist/lib/roadmap-contract.mjs +305 -0
- package/dist/lib/roadmap-status-buckets.mjs +20 -0
- package/dist/lib/standup-deck.mjs +120 -0
- package/dist/lib/telegram-format.mjs +91 -0
- package/dist/live-smoke.mjs +303 -0
- package/dist/pmo/benchmarks.json +77 -0
- package/dist/pmo/templates/metrics-sheet.md +27 -0
- package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
- package/dist/pmo/templates/weekly-story-deck.md +66 -0
- package/dist/pmo-report.mjs +338 -0
- package/dist/preflight.mjs +444 -0
- package/dist/prose/cpo-persona.md +108 -0
- package/dist/prose/internal.task.md +48 -0
- package/dist/prose/standup.task.md +38 -0
- package/dist/prose/weekly.task.md +36 -0
- package/dist/prose-draft.mjs +198 -0
- package/dist/prose-lessons.md +59 -0
- package/dist/roadmap-extract.mjs +410 -0
- package/dist/skeleton/Roadmap/00-ideas/README.md +85 -0
- package/dist/skeleton/Roadmap/00-ideas/audits/.gitkeep +0 -0
- package/dist/skeleton/Roadmap/00-ideas/seeds/.gitkeep +0 -0
- package/dist/skeleton/Roadmap/LEARNINGS.md +164 -0
- package/dist/skeleton/Roadmap/README.md +67 -0
- package/dist/skeleton/Roadmap/WAYS-OF-WORKING.md +233 -0
- package/dist/standup/templates/daily-story-deck.md +46 -0
- package/dist/standup.mjs +634 -0
- package/dist/vercel-prune-previews.mjs +164 -0
- package/dist/weekly-recap.mjs +526 -0
- package/package.json +33 -1
package/dist/lib/jev.mjs
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// jev.mjs — the ONE place that knows the TypeSafe System One API (jev-semantic-guards D1–D3).
|
|
2
|
+
//
|
|
3
|
+
// Jev answers typed questions (Noul / Choice / Score) about a `state` with calibrated probabilities. The
|
|
4
|
+
// review and prose guards use it for the LANGUAGE judgements their regexes kept getting wrong, and keep
|
|
5
|
+
// the regexes as the fallback. Three rules shape every line below:
|
|
6
|
+
//
|
|
7
|
+
// 1. THREE STATES, NEVER TWO. No key, a 401/422, a 429/529 after backoff, a timeout, a network error, an
|
|
8
|
+
// unparseable body or an over-budget state are all `{ ok:false, state:'could-not-look', error }`.
|
|
9
|
+
// "Jev was unreachable" must never read as a verdict — the caller falls back to today's regex.
|
|
10
|
+
// askJev never throws.
|
|
11
|
+
// 2. A PINNED MODEL. Gates use `jev-1.13.0`, never `jev-latest`: an alias moves when a release ships,
|
|
12
|
+
// so the answers behind a gate would change without a diff (the agy-pin lesson, applied to a model).
|
|
13
|
+
// Bump it deliberately, after `node scripts/jev-eval.mjs --live`.
|
|
14
|
+
// 3. ONE COMMITTED SWITCH. `jev.config.json → rails.<rail>.mode: off | shadow | jev`. A missing file is
|
|
15
|
+
// the defaults (everything off); a MALFORMED file throws, so a typo never silently means "off".
|
|
16
|
+
// `egress:false` or no key makes the effective mode `off`, with the reason.
|
|
17
|
+
//
|
|
18
|
+
// Zero deps — Node 18+ (global fetch). Everything with I/O takes injectable deps, so specs never touch the
|
|
19
|
+
// network.
|
|
20
|
+
|
|
21
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { projectRoot } from './project-root.mjs';
|
|
25
|
+
|
|
26
|
+
export const JEV_ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
|
|
27
|
+
export const DEFAULT_MODEL = 'jev-1.13.0';
|
|
28
|
+
export const MODES = ['off', 'shadow', 'jev'];
|
|
29
|
+
export const RAILS = ['review', 'prose'];
|
|
30
|
+
|
|
31
|
+
/** 32k tokens covers state + the longest question. ~4 chars/token, with headroom for the question. */
|
|
32
|
+
export const STATE_CHAR_BUDGET = 110_000;
|
|
33
|
+
export const DEFAULT_TIMEOUT_MS = 8_000;
|
|
34
|
+
const RETRY_STATUSES = new Set([429, 529]);
|
|
35
|
+
const MAX_RETRIES = 2;
|
|
36
|
+
|
|
37
|
+
/** The project root — the directory that holds `jev.config.json` (D2: `projectRoot()`, wherever this set runs). */
|
|
38
|
+
export const repoRoot = () => projectRoot();
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
41
|
+
model: DEFAULT_MODEL,
|
|
42
|
+
egress: true,
|
|
43
|
+
rails: {
|
|
44
|
+
// Modes stay `off` for a repo with NO jev.config.json: it never opted in to sending text to Jev. The
|
|
45
|
+
// thresholds are the MEASURED ones (jev-semantic-guards S5.2, sprint-5.md), not the pitch's starting guesses.
|
|
46
|
+
review: { mode: 'off', thresholds: { real: 0.85, notReal: 0.3 }, shadowExpires: null },
|
|
47
|
+
prose: { mode: 'off', thresholds: { claim: 0.8 }, shadowExpires: null },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export class JevConfigError extends Error {}
|
|
52
|
+
|
|
53
|
+
const isUnit = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate a parsed jev.config.json against the defaults. Pure; throws JevConfigError naming the problem.
|
|
57
|
+
* Unknown rails are refused (a misspelt rail would otherwise be a switch wired to nothing).
|
|
58
|
+
*/
|
|
59
|
+
export function parseJevConfig(json) {
|
|
60
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
61
|
+
// Unknown keys are REFUSED, not ignored: `{ "egres": false }` meant to stop data leaving, and ignoring it
|
|
62
|
+
// would keep egress on — a typo must fail loud, never silently mean something (fresh review, PR #34).
|
|
63
|
+
const refuseUnknown = (obj, allowed, where) => {
|
|
64
|
+
const extra = Object.keys(obj).filter((k) => !allowed.includes(k) && !k.startsWith('$'));
|
|
65
|
+
if (extra.length)
|
|
66
|
+
throw new JevConfigError(`jev.config.json: unknown key(s) in ${where}: ${extra.join(', ')}`);
|
|
67
|
+
};
|
|
68
|
+
if (!isObj(json)) throw new JevConfigError('jev.config.json: not an object');
|
|
69
|
+
refuseUnknown(json, ['model', 'egress', 'rails'], 'the top level');
|
|
70
|
+
const model = json.model ?? DEFAULT_MODEL;
|
|
71
|
+
if (typeof model !== 'string' || !model)
|
|
72
|
+
throw new JevConfigError('jev.config.json: model must be a string');
|
|
73
|
+
if (/latest|preview/.test(model))
|
|
74
|
+
throw new JevConfigError(
|
|
75
|
+
`jev.config.json: model "${model}" is an alias — pin a version such as ${DEFAULT_MODEL}`
|
|
76
|
+
);
|
|
77
|
+
const egress = json.egress ?? true;
|
|
78
|
+
if (typeof egress !== 'boolean') throw new JevConfigError('jev.config.json: egress must be true or false');
|
|
79
|
+
if (json.rails !== undefined && !isObj(json.rails))
|
|
80
|
+
throw new JevConfigError('jev.config.json: rails must be an object');
|
|
81
|
+
const rails = {};
|
|
82
|
+
for (const name of Object.keys(json.rails ?? {}))
|
|
83
|
+
if (!RAILS.includes(name))
|
|
84
|
+
throw new JevConfigError(`jev.config.json: unknown rail "${name}" (known: ${RAILS.join(', ')})`);
|
|
85
|
+
for (const name of RAILS) {
|
|
86
|
+
const d = DEFAULT_CONFIG.rails[name];
|
|
87
|
+
const r = json.rails?.[name] ?? {};
|
|
88
|
+
if (!isObj(r)) throw new JevConfigError(`jev.config.json: rails.${name} must be an object`);
|
|
89
|
+
refuseUnknown(r, ['mode', 'thresholds', 'shadowExpires'], `rails.${name}`);
|
|
90
|
+
const mode = r.mode ?? d.mode;
|
|
91
|
+
if (!MODES.includes(mode))
|
|
92
|
+
throw new JevConfigError(`jev.config.json: rails.${name}.mode must be one of ${MODES.join(' | ')}`);
|
|
93
|
+
if (r.thresholds !== undefined && !isObj(r.thresholds))
|
|
94
|
+
throw new JevConfigError(`jev.config.json: rails.${name}.thresholds must be an object`);
|
|
95
|
+
refuseUnknown(r.thresholds ?? {}, Object.keys(d.thresholds), `rails.${name}.thresholds`);
|
|
96
|
+
const thresholds = { ...d.thresholds, ...(r.thresholds ?? {}) };
|
|
97
|
+
for (const [k, v] of Object.entries(thresholds))
|
|
98
|
+
if (!isUnit(v)) throw new JevConfigError(`jev.config.json: rails.${name}.thresholds.${k} must be 0…1`);
|
|
99
|
+
if (name === 'review' && thresholds.notReal >= thresholds.real)
|
|
100
|
+
throw new JevConfigError('jev.config.json: rails.review.thresholds.notReal must be below .real');
|
|
101
|
+
const shadowExpires = r.shadowExpires ?? null;
|
|
102
|
+
if (shadowExpires !== null && !/^\d{4}-\d{2}-\d{2}$/.test(String(shadowExpires)))
|
|
103
|
+
throw new JevConfigError(`jev.config.json: rails.${name}.shadowExpires must be YYYY-MM-DD`);
|
|
104
|
+
if (mode === 'shadow' && !shadowExpires)
|
|
105
|
+
throw new JevConfigError(
|
|
106
|
+
`jev.config.json: rails.${name} is in shadow with no shadowExpires — shadow must expire (set a date ≤21 days out)`
|
|
107
|
+
);
|
|
108
|
+
rails[name] = { mode, thresholds, shadowExpires };
|
|
109
|
+
}
|
|
110
|
+
return { model, egress, rails };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Read `<root>/jev.config.json`. Missing ⇒ defaults; unreadable or malformed ⇒ throws. */
|
|
114
|
+
export function loadJevConfig({ root = repoRoot(), read = readFileSync, exists = existsSync } = {}) {
|
|
115
|
+
const path = join(root, 'jev.config.json');
|
|
116
|
+
if (!exists(path)) return parseJevConfig({});
|
|
117
|
+
let json;
|
|
118
|
+
try {
|
|
119
|
+
json = JSON.parse(read(path, 'utf8'));
|
|
120
|
+
} catch (e) {
|
|
121
|
+
throw new JevConfigError(`jev.config.json: unparseable (${e.message})`);
|
|
122
|
+
}
|
|
123
|
+
return parseJevConfig(json);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Parse `KEY=value` lines. Enough for .env.local; quotes stripped. */
|
|
127
|
+
function envFileValue(text, key) {
|
|
128
|
+
for (const line of String(text).split('\n')) {
|
|
129
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
130
|
+
if (m && m[1] === key) return m[2].trim().replace(/^(['"])(.*)\1$/, '$2') || null;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The API key: env first, then `.env.local` at the repo root, then the cwd. null when none. */
|
|
136
|
+
export function readApiKey({
|
|
137
|
+
env = process.env,
|
|
138
|
+
root = repoRoot(),
|
|
139
|
+
cwd = process.cwd(),
|
|
140
|
+
read = readFileSync,
|
|
141
|
+
exists = existsSync,
|
|
142
|
+
} = {}) {
|
|
143
|
+
if (env.TYPESAFE_API_KEY) return env.TYPESAFE_API_KEY;
|
|
144
|
+
for (const dir of [root, cwd]) {
|
|
145
|
+
const p = join(dir, '.env.local');
|
|
146
|
+
if (!exists(p)) continue;
|
|
147
|
+
try {
|
|
148
|
+
const v = envFileValue(read(p, 'utf8'), 'TYPESAFE_API_KEY');
|
|
149
|
+
if (v) return v;
|
|
150
|
+
} catch {
|
|
151
|
+
/* unreadable env file = no key from it */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The mode a rail ACTUALLY runs in, and why. `off` whenever Jev cannot legitimately be asked — egress
|
|
159
|
+
* disabled or no key — so a missing key degrades to today's behaviour instead of logging failures.
|
|
160
|
+
*/
|
|
161
|
+
export function effectiveMode(config, rail, { key } = {}) {
|
|
162
|
+
const configured = config.rails[rail].mode;
|
|
163
|
+
if (configured === 'off') return { mode: 'off', configured, why: 'configured off' };
|
|
164
|
+
if (!config.egress) return { mode: 'off', configured, why: 'egress disabled in jev.config.json' };
|
|
165
|
+
if (!key) return { mode: 'off', configured, why: 'no TYPESAFE_API_KEY' };
|
|
166
|
+
return { mode: configured, configured, why: `configured ${configured}` };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const could = (error) => ({ ok: false, state: 'could-not-look', error });
|
|
170
|
+
|
|
171
|
+
/** Serialized state size, the thing the 32k budget is about. */
|
|
172
|
+
export const stateSize = (state) => (typeof state === 'string' ? state : JSON.stringify(state)).length;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Ask Jev. Returns `{ ok:true, answers, usage, model }` or `{ ok:false, state:'could-not-look', error }`.
|
|
176
|
+
* Never throws. deps: { fetch, key, model, timeoutMs, sleep }.
|
|
177
|
+
*/
|
|
178
|
+
export async function askJev(req, deps = {}) {
|
|
179
|
+
// The whole body is guarded: a circular or undefined `state`, a BigInt, a fetch that resolves to nothing —
|
|
180
|
+
// any of them used to throw out of here and take the review down with it instead of falling back to the
|
|
181
|
+
// regex (fresh review, PR #34). "Never throws" is the contract every caller relies on.
|
|
182
|
+
try {
|
|
183
|
+
return await askJevUnguarded(req ?? {}, deps);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return could(`internal: ${e?.message || e}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function askJevUnguarded({ state, questions }, deps) {
|
|
190
|
+
const {
|
|
191
|
+
fetch: doFetch = globalThis.fetch,
|
|
192
|
+
key = readApiKey(),
|
|
193
|
+
model = DEFAULT_MODEL,
|
|
194
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
195
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
196
|
+
} = deps;
|
|
197
|
+
if (!key) return could('no key');
|
|
198
|
+
if (typeof doFetch !== 'function') return could('no fetch in this runtime');
|
|
199
|
+
const ids = Object.keys(questions ?? {});
|
|
200
|
+
if (!ids.length) return could('no questions');
|
|
201
|
+
if (state === undefined || state === null) return could('no state');
|
|
202
|
+
if (stateSize(state) > STATE_CHAR_BUDGET) return could(`state too large (${stateSize(state)} chars)`);
|
|
203
|
+
|
|
204
|
+
const body = JSON.stringify({ state, model, questions });
|
|
205
|
+
for (let attempt = 0; ; attempt++) {
|
|
206
|
+
// ONE deadline per attempt, covering the body read too: clearing it once headers arrived let a stalled
|
|
207
|
+
// body hang until the runtime's own ~300 s body timeout (fresh review, PR #34).
|
|
208
|
+
const ctrl = new AbortController();
|
|
209
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
210
|
+
let outcome;
|
|
211
|
+
try {
|
|
212
|
+
outcome = await attemptOnce({ doFetch, body, key, signal: ctrl.signal, ids, model });
|
|
213
|
+
} catch (e) {
|
|
214
|
+
outcome = {
|
|
215
|
+
done: could(ctrl.signal.aborted ? `timeout after ${timeoutMs}ms` : `network: ${e?.message || e}`),
|
|
216
|
+
};
|
|
217
|
+
} finally {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
}
|
|
220
|
+
if (outcome.retry && attempt < MAX_RETRIES) {
|
|
221
|
+
await sleep(outcome.retryAfterMs ?? 250 * 2 ** attempt);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
return outcome.done ?? could(`HTTP ${outcome.status}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** One HTTP attempt, body included. Returns { done } or { retry, status, retryAfterMs }. May throw (caller maps). */
|
|
229
|
+
async function attemptOnce({ doFetch, body, key, signal, ids, model }) {
|
|
230
|
+
const res = await doFetch(JEV_ENDPOINT, {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
233
|
+
body,
|
|
234
|
+
signal,
|
|
235
|
+
});
|
|
236
|
+
if (!res || typeof res.status !== 'number') return { done: could('no response') };
|
|
237
|
+
if (RETRY_STATUSES.has(res.status)) {
|
|
238
|
+
const after = Number(res.headers?.get?.('retry-after'));
|
|
239
|
+
return {
|
|
240
|
+
retry: true,
|
|
241
|
+
status: res.status,
|
|
242
|
+
retryAfterMs: Number.isFinite(after) && after > 0 ? Math.min(after * 1000, 4000) : undefined,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (res.status !== 200) {
|
|
246
|
+
let detail = '';
|
|
247
|
+
try {
|
|
248
|
+
detail = String(await res.text()).slice(0, 160);
|
|
249
|
+
} catch {
|
|
250
|
+
/* no body */
|
|
251
|
+
}
|
|
252
|
+
return { done: could(`HTTP ${res.status}${detail ? `: ${detail}` : ''}`) };
|
|
253
|
+
}
|
|
254
|
+
let json;
|
|
255
|
+
try {
|
|
256
|
+
json = await res.json();
|
|
257
|
+
} catch (e) {
|
|
258
|
+
if (signal.aborted) throw e;
|
|
259
|
+
return { done: could('unparseable response body') };
|
|
260
|
+
}
|
|
261
|
+
const answers = json?.answers;
|
|
262
|
+
const missing = ids.filter((id) => !answers || typeof answers[id] !== 'object' || answers[id] === null);
|
|
263
|
+
if (missing.length) return { done: could(`response missing answers: ${missing.slice(0, 3).join(', ')}`) };
|
|
264
|
+
return { done: { ok: true, answers, usage: json.usage ?? null, model: json.model ?? model } };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A stable, short content hash — the log's join key without having to compare whole texts. */
|
|
268
|
+
export const textHash = (text) =>
|
|
269
|
+
createHash('sha256')
|
|
270
|
+
.update(String(text ?? ''))
|
|
271
|
+
.digest('hex')
|
|
272
|
+
.slice(0, 16);
|
|
273
|
+
|
|
274
|
+
export const LOG_TEXT_LIMIT = 4000;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Append one decision to `<root>/.jev/decisions.jsonl`. A write failure WARNS and returns false: the log is
|
|
278
|
+
* evidence about a decision, never a reason to change or abort one.
|
|
279
|
+
*/
|
|
280
|
+
export function logDecision(entry, deps = {}) {
|
|
281
|
+
const {
|
|
282
|
+
root = repoRoot(),
|
|
283
|
+
append = appendFileSync,
|
|
284
|
+
mkdir = mkdirSync,
|
|
285
|
+
now = () => new Date().toISOString(),
|
|
286
|
+
warn = (m) => process.stderr.write(`${m}\n`),
|
|
287
|
+
} = deps;
|
|
288
|
+
try {
|
|
289
|
+
const text = String(entry.text ?? '');
|
|
290
|
+
const line = {
|
|
291
|
+
rail: entry.rail,
|
|
292
|
+
mode: entry.mode,
|
|
293
|
+
decider: entry.decider,
|
|
294
|
+
regex: entry.regex ?? null,
|
|
295
|
+
jev: entry.jev ?? null,
|
|
296
|
+
confidence: entry.confidence ?? null,
|
|
297
|
+
textHash: textHash(text),
|
|
298
|
+
text: text.length > LOG_TEXT_LIMIT ? `${text.slice(0, LOG_TEXT_LIMIT)}…[truncated]` : text,
|
|
299
|
+
...(entry.sha ? { sha: entry.sha } : {}),
|
|
300
|
+
...(entry.source ? { source: entry.source } : {}),
|
|
301
|
+
...(entry.evidence ? { evidence: entry.evidence } : {}),
|
|
302
|
+
...(entry.error ? { error: entry.error } : {}),
|
|
303
|
+
ts: now(),
|
|
304
|
+
};
|
|
305
|
+
const dir = join(root, '.jev');
|
|
306
|
+
// Owner-only: the log quotes reviewer replies and drafts, which can quote private code (PR #35 security lens).
|
|
307
|
+
mkdir(dir, { recursive: true, mode: 0o700 });
|
|
308
|
+
append(join(dir, 'decisions.jsonl'), `${JSON.stringify(line)}\n`, { mode: 0o600 });
|
|
309
|
+
return true;
|
|
310
|
+
} catch (e) {
|
|
311
|
+
warn(`⚠ jev: could not write the decision log (${e?.message || e}) — the decision stands.`);
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Everything a judge needs, resolved once: config, key, effective mode. deps can override any of it (specs).
|
|
318
|
+
* A malformed config THROWS here — loud, never silently off.
|
|
319
|
+
*/
|
|
320
|
+
export function jevContext(rail, deps = {}) {
|
|
321
|
+
const root = deps.root ?? repoRoot();
|
|
322
|
+
const config = deps.config ?? loadJevConfig({ root });
|
|
323
|
+
const key = 'key' in deps ? deps.key : readApiKey({ root });
|
|
324
|
+
const eff = effectiveMode(config, rail, { key });
|
|
325
|
+
return {
|
|
326
|
+
root,
|
|
327
|
+
config,
|
|
328
|
+
hasKey: Boolean(key),
|
|
329
|
+
...eff,
|
|
330
|
+
rail: config.rails[rail],
|
|
331
|
+
ask: deps.ask ?? ((req) => askJev(req, { key, model: config.model, fetch: deps.fetch })),
|
|
332
|
+
log: deps.log ?? ((entry) => logDecision(entry, { root })),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// log-branch.mjs — persist a single data file across separate routine sessions via a dedicated
|
|
2
|
+
// `claude/`-prefixed branch, using git PLUMBING only (hash-object/mktree/commit-tree/push). This never
|
|
3
|
+
// checks anything out and never touches the working tree, the index, or whatever branch is currently
|
|
4
|
+
// checked out — it only creates objects and moves one ref.
|
|
5
|
+
//
|
|
6
|
+
// Why this exists (not `git commit`/`git push` to `main` directly, as standup.mjs/weekly-recap.mjs
|
|
7
|
+
// originally did): a routine's DEFAULT push scope only covers `claude/`-prefixed branches; pushing a log
|
|
8
|
+
// commit straight to `main` needs "Allow unrestricted branch pushes" enabled on the routine — and live
|
|
9
|
+
// 2026-07-02/03, that toggle's Save button failed ("Failed to save changes") in the claude.ai Routines
|
|
10
|
+
// UI. Moving log persistence onto a `claude/`-prefixed branch removes the dependency on that toggle
|
|
11
|
+
// entirely — it's the routine's default scope, no extra permission needed, no UI bug to work around.
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
function git(args, opts = {}) {
|
|
16
|
+
return spawnSync('git', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, ...opts });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Reads `path`'s content from the tip of `branch` on `origin`, without any checkout. Returns null if the
|
|
20
|
+
// branch doesn't exist yet (first run) or the file isn't present on it. Only logs the fetch failure when
|
|
21
|
+
// it ISN'T the benign "branch doesn't exist yet" case (git's own "couldn't find remote ref" message) — an
|
|
22
|
+
// auth/network failure would otherwise look identical to a normal first run in routine transcripts.
|
|
23
|
+
export function readLogFromBranch({ cwd, branch, path }) {
|
|
24
|
+
const fetch = git(['fetch', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], { cwd });
|
|
25
|
+
if (fetch.status !== 0) {
|
|
26
|
+
const stderr = (fetch.stderr || '').trim();
|
|
27
|
+
if (!/couldn't find remote ref/i.test(stderr)) {
|
|
28
|
+
console.error(`log-branch: git fetch ${branch} failed (not just "doesn't exist yet"): ${stderr}`);
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const show = git(['show', `origin/${branch}:${path}`], { cwd });
|
|
33
|
+
return show.status === 0 ? show.stdout : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Writes `content` to `path` on `branch`, creating the branch if it doesn't exist yet. Pure plumbing:
|
|
37
|
+
// hash-object (write a blob) → mktree (a single-file tree) → commit-tree (parented on the branch's
|
|
38
|
+
// current tip, if any) → push the new commit straight to the branch ref. Returns true on success.
|
|
39
|
+
//
|
|
40
|
+
// `path` MUST be a flat filename (no `/`) — `git mktree` builds a single-level tree, and a slash needs a
|
|
41
|
+
// nested subtree this function doesn't build. Passing a nested path fails loud (logged below), not
|
|
42
|
+
// silently — confirmed live: an earlier version swallowed git's stderr entirely, turning "path contains
|
|
43
|
+
// slash" (a one-line, obvious fix) into an unexplained `false`.
|
|
44
|
+
export function writeLogToBranch({ cwd, branch, path, content, message }) {
|
|
45
|
+
if (path.includes('/')) {
|
|
46
|
+
console.error(
|
|
47
|
+
`log-branch: writeLogToBranch path "${path}" must be a flat filename (no "/") — git mktree only builds single-level trees.`
|
|
48
|
+
);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
git(['fetch', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], { cwd }); // best-effort; branch may not exist yet
|
|
53
|
+
|
|
54
|
+
const hashObj = git(['hash-object', '-w', '--stdin'], { cwd, input: content });
|
|
55
|
+
if (hashObj.status !== 0) {
|
|
56
|
+
console.error(`log-branch: git hash-object failed: ${(hashObj.stderr || '').trim()}`);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const blobSha = hashObj.stdout.trim();
|
|
60
|
+
|
|
61
|
+
const mktree = git(['mktree'], { cwd, input: `100644 blob ${blobSha}\t${path}\n` });
|
|
62
|
+
if (mktree.status !== 0) {
|
|
63
|
+
console.error(`log-branch: git mktree failed: ${(mktree.stderr || '').trim()}`);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const treeSha = mktree.stdout.trim();
|
|
67
|
+
|
|
68
|
+
const revParse = git(['rev-parse', '--verify', `origin/${branch}`], { cwd });
|
|
69
|
+
const parent = revParse.status === 0 ? revParse.stdout.trim() : null;
|
|
70
|
+
|
|
71
|
+
const commitArgs = ['commit-tree', treeSha, '-m', message];
|
|
72
|
+
if (parent) commitArgs.push('-p', parent);
|
|
73
|
+
const commitTree = git(commitArgs, { cwd });
|
|
74
|
+
if (commitTree.status !== 0) {
|
|
75
|
+
console.error(`log-branch: git commit-tree failed: ${(commitTree.stderr || '').trim()}`);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
const commitSha = commitTree.stdout.trim();
|
|
79
|
+
|
|
80
|
+
const push = git(['push', 'origin', `${commitSha}:refs/heads/${branch}`], { cwd });
|
|
81
|
+
if (push.status !== 0) {
|
|
82
|
+
console.error(`log-branch: git push failed: ${(push.stderr || '').trim()}`);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Read-modify-write with a bounded retry-on-conflict: appends `line` to the file's current content,
|
|
89
|
+
// re-reading the branch's CURRENT tip fresh on each attempt. Without this, a concurrent write to the same
|
|
90
|
+
// branch between our read and our push (a non-fast-forward push failure) would silently drop this run's
|
|
91
|
+
// snapshot entirely — this system has no realistic concurrent-execution path today (nightly/weekly,
|
|
92
|
+
// single-fire cron triggers), but the fix is cheap and removes the risk outright rather than assuming it
|
|
93
|
+
// away. Returns true only if a write eventually succeeded.
|
|
94
|
+
export function appendLineToBranch({ cwd, branch, path, line, message, retries = 2 }) {
|
|
95
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
96
|
+
const existing = readLogFromBranch({ cwd, branch, path }) || '';
|
|
97
|
+
const updated = `${existing}${line}`;
|
|
98
|
+
if (writeLogToBranch({ cwd, branch, path, content: updated, message })) return true;
|
|
99
|
+
if (attempt < retries) {
|
|
100
|
+
console.error(
|
|
101
|
+
`log-branch: append attempt ${attempt + 1} failed for ${branch} — retrying with a fresh read.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { kitRoot } from './project-root.mjs';
|
|
4
|
+
|
|
5
|
+
export const BENCHMARKS_PATH = join(kitRoot(), 'pmo', 'benchmarks.json'); // D2: a kit asset
|
|
6
|
+
|
|
7
|
+
const REQUIRED_SOURCE_FIELDS = ['title', 'publisher', 'url', 'publishedDate', 'accessedDate'];
|
|
8
|
+
|
|
9
|
+
export function validateBenchmarkDataset(dataset) {
|
|
10
|
+
const errors = [];
|
|
11
|
+
if (dataset?.schema !== 'pmo-benchmarks-v1') errors.push('schema must be pmo-benchmarks-v1');
|
|
12
|
+
if (!dataset?.generatedAt) errors.push('generatedAt is required');
|
|
13
|
+
if (!dataset?.framing || !/not a controlled experiment/i.test(dataset.framing)) {
|
|
14
|
+
errors.push('framing must include the honest "not a controlled experiment" caveat');
|
|
15
|
+
}
|
|
16
|
+
if (!dataset?.figures || typeof dataset.figures !== 'object') {
|
|
17
|
+
errors.push('figures object is required');
|
|
18
|
+
return errors;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const [key, figure] of Object.entries(dataset.figures)) {
|
|
22
|
+
if (typeof figure.value !== 'number' || !Number.isFinite(figure.value)) {
|
|
23
|
+
errors.push(`${key}: numeric value is required`);
|
|
24
|
+
}
|
|
25
|
+
if (!figure.unit) errors.push(`${key}: unit is required`);
|
|
26
|
+
if (!['higher_is_better', 'lower_is_better'].includes(figure.direction)) {
|
|
27
|
+
errors.push(`${key}: direction must be higher_is_better or lower_is_better`);
|
|
28
|
+
}
|
|
29
|
+
for (const field of REQUIRED_SOURCE_FIELDS) {
|
|
30
|
+
if (!figure.source?.[field]) errors.push(`${key}: source.${field} is required`);
|
|
31
|
+
}
|
|
32
|
+
if (figure.source?.url && !/^https:\/\//.test(figure.source.url)) {
|
|
33
|
+
errors.push(`${key}: source.url must be https`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return errors;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function loadBenchmarkDataset(path = BENCHMARKS_PATH) {
|
|
40
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function benchmarkTemplateValues(dataset) {
|
|
44
|
+
const f = dataset.figures;
|
|
45
|
+
return {
|
|
46
|
+
framing: dataset.framing,
|
|
47
|
+
deploysPerWeek: f.deploysPerWeekDailyThreshold.value,
|
|
48
|
+
prCycleMedianHours: f.prCycleMedianHoursOneDayThreshold.value,
|
|
49
|
+
epicLeadMedianDays: f.epicLeadMedianDaysOneWeekThreshold.value,
|
|
50
|
+
changeFailureRatePercent: f.changeFailureRateMaxPercent.value,
|
|
51
|
+
restoreTimeHours: f.restoreTimeHoursOneDayThreshold.value,
|
|
52
|
+
sources: Object.values(f).map((figure) => ({
|
|
53
|
+
label: figure.label,
|
|
54
|
+
title: figure.source.title,
|
|
55
|
+
publisher: figure.source.publisher,
|
|
56
|
+
url: figure.source.url,
|
|
57
|
+
publishedDate: figure.source.publishedDate,
|
|
58
|
+
accessedDate: figure.source.accessedDate,
|
|
59
|
+
})),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatTelegramHtmlLink,
|
|
3
|
+
telegramHtmlVisibleLength,
|
|
4
|
+
truncateForTelegram,
|
|
5
|
+
} from './telegram-format.mjs';
|
|
6
|
+
|
|
7
|
+
export const TELEGRAM_MAX_CHARS = 4096;
|
|
8
|
+
|
|
9
|
+
function fmt(value, suffix = '') {
|
|
10
|
+
if (value === null || value === undefined || Number.isNaN(value)) return 'n/a';
|
|
11
|
+
return `${value}${suffix}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function windowLabel(metrics) {
|
|
15
|
+
return `${metrics.window.sinceISO.slice(0, 10)} to ${metrics.window.untilISO.slice(0, 10)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function artifactLabel(name) {
|
|
19
|
+
return (
|
|
20
|
+
{
|
|
21
|
+
weekly: 'Story-deck',
|
|
22
|
+
monthly: 'Monthly packet',
|
|
23
|
+
sheet: 'Metrics sheet',
|
|
24
|
+
}[name] || name
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function artifactLinkText(name) {
|
|
29
|
+
return (
|
|
30
|
+
{
|
|
31
|
+
weekly: 'open weekly deck',
|
|
32
|
+
monthly: 'open monthly packet',
|
|
33
|
+
sheet: 'open live sheet',
|
|
34
|
+
}[name] || 'open report'
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildTelegramDeliveryMessage({ metrics, artifacts, maxChars = TELEGRAM_MAX_CHARS }) {
|
|
39
|
+
const retro = metrics.docOps.retroCoverage;
|
|
40
|
+
const body = [
|
|
41
|
+
`PMO weekly - ${windowLabel(metrics)}`,
|
|
42
|
+
`Stories shipped: ${metrics.throughput.shippedStories} | Epics shipped: ${metrics.throughput.shippedEpics}`,
|
|
43
|
+
`Median PR cycle: ${fmt(metrics.prCycleTime.medianHours, 'h')} | p90: ${fmt(metrics.prCycleTime.p90Hours, 'h')}`,
|
|
44
|
+
`Deploys: ${metrics.deployFrequency.total} merges to main | Reverts/hotfix: ${metrics.changeFailProxy.count}`,
|
|
45
|
+
`Doc-ops: LEARNINGS ${metrics.docOps.learningsPromotions} | Retros ${retro.covered}/${retro.total}`,
|
|
46
|
+
].join('\n');
|
|
47
|
+
|
|
48
|
+
const links = artifacts
|
|
49
|
+
.filter((artifact) => artifact.url)
|
|
50
|
+
.map(
|
|
51
|
+
(artifact) =>
|
|
52
|
+
`${artifactLabel(artifact.name)}: ${formatTelegramHtmlLink(artifactLinkText(artifact.name), artifact.url)}`
|
|
53
|
+
);
|
|
54
|
+
const footer = links.length ? `\n\n${links.join('\n')}` : '';
|
|
55
|
+
if (!footer) return truncateForTelegram(body, maxChars);
|
|
56
|
+
|
|
57
|
+
const footerVisibleLength = telegramHtmlVisibleLength(footer);
|
|
58
|
+
if (footerVisibleLength >= maxChars) return truncateForTelegram(body, maxChars);
|
|
59
|
+
const bodyLimit = maxChars - footerVisibleLength;
|
|
60
|
+
return `${truncateForTelegram(body, bodyLimit)}${footer}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function sendTelegramMessage({
|
|
64
|
+
chatId,
|
|
65
|
+
text,
|
|
66
|
+
token = process.env.TELEGRAM_BOT_TOKEN,
|
|
67
|
+
fetchImpl = fetch,
|
|
68
|
+
}) {
|
|
69
|
+
if (!token) throw new Error('TELEGRAM_BOT_TOKEN is not set — export it before running pmo-report.mjs.');
|
|
70
|
+
if (!chatId)
|
|
71
|
+
throw new Error(
|
|
72
|
+
'No Telegram chat id configured — set telegram.chatIds.pmo or telegram.chatId in reporting.config.json, or TELEGRAM_CHAT_ID.'
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const res = await fetchImpl(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
chat_id: chatId,
|
|
80
|
+
text,
|
|
81
|
+
parse_mode: 'HTML',
|
|
82
|
+
disable_web_page_preview: true,
|
|
83
|
+
}),
|
|
84
|
+
signal: AbortSignal.timeout(10000),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
let bodyText = '';
|
|
88
|
+
try {
|
|
89
|
+
bodyText = await res.text();
|
|
90
|
+
} catch {
|
|
91
|
+
/* response body unavailable */
|
|
92
|
+
}
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
let detail = bodyText;
|
|
95
|
+
try {
|
|
96
|
+
detail = JSON.stringify(JSON.parse(bodyText));
|
|
97
|
+
} catch {
|
|
98
|
+
/* keep raw non-JSON body */
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`Telegram sendMessage failed: ${res.status} ${detail.slice(0, 300)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|