@golden-frijoles/kit 0.0.0 → 0.2.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 (51) hide show
  1. package/README.md +25 -1
  2. package/bin.mjs +108 -0
  3. package/dist/LICENSE +202 -0
  4. package/dist/NOTICE +7 -0
  5. package/dist/babysit-pr.mjs +213 -0
  6. package/dist/build-order-sync.mjs +141 -0
  7. package/dist/build-order.mjs +178 -0
  8. package/dist/cross-panel.mjs +216 -0
  9. package/dist/cross-panel.prompt.md +115 -0
  10. package/dist/doc-format.enforced.json +4 -0
  11. package/dist/doc-format.mjs +856 -0
  12. package/dist/doc-hygiene.mjs +279 -0
  13. package/dist/lib/cross-agent-cli.mjs +695 -0
  14. package/dist/lib/gh-rest.mjs +229 -0
  15. package/dist/lib/golden-onboarding.mjs +179 -0
  16. package/dist/lib/jev.mjs +334 -0
  17. package/dist/lib/log-branch.mjs +106 -0
  18. package/dist/lib/pmo-benchmarks.mjs +61 -0
  19. package/dist/lib/pmo-delivery.mjs +102 -0
  20. package/dist/lib/pmo-metrics.mjs +200 -0
  21. package/dist/lib/pmo-templates.mjs +132 -0
  22. package/dist/lib/pmo-window-log.mjs +99 -0
  23. package/dist/lib/project-root.mjs +78 -0
  24. package/dist/lib/prose-brief.mjs +191 -0
  25. package/dist/lib/prose-guard.mjs +761 -0
  26. package/dist/lib/prose-writer.mjs +410 -0
  27. package/dist/lib/report-registry.mjs +469 -0
  28. package/dist/lib/reporting-config.mjs +266 -0
  29. package/dist/lib/roadmap-contract.mjs +305 -0
  30. package/dist/lib/roadmap-status-buckets.mjs +20 -0
  31. package/dist/lib/standup-deck.mjs +120 -0
  32. package/dist/lib/telegram-format.mjs +91 -0
  33. package/dist/live-smoke.mjs +303 -0
  34. package/dist/pmo/benchmarks.json +77 -0
  35. package/dist/pmo/templates/metrics-sheet.md +27 -0
  36. package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
  37. package/dist/pmo/templates/weekly-story-deck.md +66 -0
  38. package/dist/pmo-report.mjs +338 -0
  39. package/dist/preflight.mjs +444 -0
  40. package/dist/prose/cpo-persona.md +108 -0
  41. package/dist/prose/internal.task.md +48 -0
  42. package/dist/prose/standup.task.md +38 -0
  43. package/dist/prose/weekly.task.md +36 -0
  44. package/dist/prose-draft.mjs +198 -0
  45. package/dist/prose-lessons.md +59 -0
  46. package/dist/roadmap-extract.mjs +410 -0
  47. package/dist/standup/templates/daily-story-deck.md +46 -0
  48. package/dist/standup.mjs +634 -0
  49. package/dist/vercel-prune-previews.mjs +164 -0
  50. package/dist/weekly-recap.mjs +526 -0
  51. package/package.json +33 -1
@@ -0,0 +1,761 @@
1
+ // prose-guard.mjs — a PURE, mechanical check on machine-drafted prose before it reaches a human.
2
+ //
3
+ // PORTED from ~/dobby/golden-beans/scripts/lib/prose-guard.mjs (2026-07-26), with the reasoning
4
+ // intact and one rule added for this repo (`flag-state-claim`, see below). The comments here are
5
+ // not decoration: nearly every rule records a *measured* failure of a real draft, and the reason a
6
+ // rule exists is the only thing that stops someone deleting it as noise.
7
+ //
8
+ // ── Since jev-semantic-guards (2026-09-23): the four SEMANTIC families are decided by Jev ──────────
9
+ // Fix claim, invented beneficiary, liveness claim and invented commitment are judged by `judgeProse` (bottom of
10
+ // this file) whenever jev.config.json → rails.prose.mode is `jev`. The regex families below are now the
11
+ // OFFLINE FALLBACK — they decide, unchanged, when Jev cannot look or the rail is off, and `checkProse` still
12
+ // supplies the mechanical rules (length, banned words, tool names, unfinished) every time. Measured on 158
13
+ // labelled drafts: the judge 88.0%, these regexes alone 72.2%, every family at or above them (sprint-5.md).
14
+ // The incident comments on each family stay: they are the fallback's reasoning, and the eval fixtures.
15
+ //
16
+ // ── Why this exists ───────────────────────────────────────────────────────────────────────────
17
+ // Prompt instructions reduce hallucination; they do not eliminate it. Measured on golden-beans'
18
+ // commit-report rail (2026-07-25), a cheap model given a dense engineering commit produced material
19
+ // falsehoods on two of its first three runs: it invented customer impact for internal tooling, and
20
+ // it claimed a commit had FIXED an open-redirect bug when the commit only added tests for a fix
21
+ // that shipped weeks earlier. Both read as confident good news, which is exactly what makes them
22
+ // dangerous in a channel treated as status. the product owner reads the standup on their phone; a wrong report
23
+ // there is the real risk of this epic (README → "Risk tier").
24
+ //
25
+ // A guard cannot verify truth. What it CAN do is catch the specific, recurring shapes those
26
+ // failures take, and hand them back to the writer as a concrete revision note. That converts a
27
+ // silent falsehood into a retry with a named problem — which is the difference between "usually
28
+ // fine" and "fails loudly when it isn't".
29
+ //
30
+ // Everything here is pure and unit-tested: no I/O, no spawn, no clock. It runs on every draft
31
+ // regardless of which model wrote it (README D2 — the same module runs on the local rail and on the
32
+ // routine's `--post` step), so promoting or swapping the writer never silently drops the check.
33
+
34
+ import { jevContext } from './jev.mjs';
35
+
36
+ /**
37
+ * Marketing vocabulary that signals the model has drifted from reporting into selling. Each one is
38
+ * a word that adds emphasis without adding information — the tell that a sentence is decorating a
39
+ * fact rather than stating one.
40
+ */
41
+ export const BANNED_PHRASES = [
42
+ 'seamless',
43
+ 'seamlessly',
44
+ 'robust',
45
+ 'leverage',
46
+ 'leverages',
47
+ 'unlock',
48
+ 'unlocks',
49
+ 'empower',
50
+ 'empowers',
51
+ 'delighted',
52
+ 'excited to announce',
53
+ 'game-changing',
54
+ 'game changer',
55
+ 'revolutionary',
56
+ 'cutting-edge',
57
+ 'best-in-class',
58
+ 'world-class',
59
+ 'supercharge',
60
+ 'effortless',
61
+ 'blazing fast',
62
+ ];
63
+
64
+ /**
65
+ * Implementation nouns the reader either already knows or does not need. The brief says the
66
+ * engineering story is already in the commit and the sprint doc; naming the stack is the most
67
+ * common way a draft slides back into being an engineering report.
68
+ *
69
+ * Entries are regex fragments, matched on word boundaries so ordinary words are never caught —
70
+ * "next" the adverb must not trip the "Next.js" rule, and "react" the verb must not trip React.
71
+ *
72
+ * A project adds ITS OWN stack's names (its commerce engine, auth provider, hosting, integrations —
73
+ * the names its drafts actually reach for) through reporting.config.json's
74
+ * `prose.extraBannedToolNames`, passed to checkProse as `evidence.extraBannedToolNames`. They are not
75
+ * in this list because this list ships to every project.
76
+ */
77
+ export const BANNED_TOOL_NAMES = [
78
+ 'playwright',
79
+ 'next\\.js',
80
+ 'nextjs',
81
+ 'supabase',
82
+ 'postgres',
83
+ 'postgresql',
84
+ 'vercel',
85
+ 'gemini',
86
+ 'gpt',
87
+ 'claude',
88
+ 'devin',
89
+ 'antigravity',
90
+ 'typescript',
91
+ 'javascript',
92
+ 'eslint',
93
+ 'prettier',
94
+ 'node\\.js',
95
+ 'github actions',
96
+ 'telegram',
97
+ // DELIBERATELY ABSENT: 'react' and 'next' on their own — golden-beans' omission, carried over
98
+ // with its reasoning. Both are ordinary English words — "the page does not react to a stale
99
+ // value", "whoever ships next" — and a guard that rejects a correct sentence is worse than one
100
+ // that misses a rare mention: it teaches whoever maintains this to bypass the check. `next\.js`
101
+ // above still catches the framework by its real name. Same reason `stripe` is absent: "a stripe
102
+ // of the catalog" is rare, but "Stripe" appears in our docs constantly as the *subject* of a
103
+ // product decision, not as machinery — banning it would reject the sentences we most want.
104
+ ];
105
+
106
+ /** Turn a BANNED_TOOL_NAMES regex fragment back into something readable for the finding note. */
107
+ function displayToolName(fragment) {
108
+ return fragment.replace(/\\(.)/g, '$1').replace(/\?/g, '');
109
+ }
110
+
111
+ /**
112
+ * Phrasings that assert a FIX, in the past tense, as an accomplished outcome.
113
+ *
114
+ * These are not banned outright — a change that genuinely fixes something should say so. They are
115
+ * flagged only when the source data gives no evidence of a fix (`evidence.allowsFixClaim`), because
116
+ * the observed failure was a draft asserting a fix that a DIFFERENT, earlier change had made. Our
117
+ * commit messages and sprint docs routinely cite past incidents to explain why present work matters
118
+ * — the whole `LEARNINGS.md` habit is built on it — and a cheap model cannot reliably tell "we
119
+ * fixed X" from "X was fixed once, so we now test for it".
120
+ */
121
+ const FIX_CLAIM_PATTERNS = [
122
+ // Includes the -ING form on purpose. The real failing draft read "…is blocked, ELIMINATING a
123
+ // potential open-redirect attack" — a participle, not a finite verb, and an earlier version of
124
+ // this list matched only `eliminated|eliminates` and sailed straight past the exact sentence it
125
+ // was written to catch. A claim is a claim whatever its inflection.
126
+ /\b(?:fix(?:ed|es|ing)|resolv(?:ed|es|ing)|patch(?:ed|es|ing)|clos(?:ed|es|ing)|eliminat(?:ed|es|ing)|prevent(?:ed|s|ing)|remov(?:ed|es|ing))\b/i,
127
+ /\bno longer (?:vulnerable|possible|happens|occurs|breaks|fails)\b/i,
128
+ // The passive shape: the subject is the defect, and something has been done to it.
129
+ /\b(?:bug|vulnerability|exploit|attack|bypass|breach|leak|regression)\b[^.]{0,60}\b(?:blocked|closed|removed|gone|impossible|shut)\b/i,
130
+ /\b(?:blocked|closed|removed|gone|impossible)\b[^.]{0,60}\b(?:bug|vulnerability|exploit|attack|bypass|breach|leak|regression)\b/i,
131
+ /\bis now (?:secure|safe|protected|impossible)\b/i,
132
+ ];
133
+
134
+ /**
135
+ * Invented commitments — the THIRD measured failure mode (2026-07-25, golden-beans' standup rail's
136
+ * first live run).
137
+ *
138
+ * The draft ended: _"design sign-off on the Sprint 2 layout is owed before tomorrow"_. No such
139
+ * commitment existed anywhere in the source data; the model manufactured a deadline and an owner
140
+ * because a standup usually has one. This is more corrosive than the other two failures — a report
141
+ * that invents obligations makes people chase work nobody agreed to, and it is completely plausible
142
+ * on its face.
143
+ *
144
+ * Git history contains no deadlines, so ANY future-dated commitment in a git-derived report is
145
+ * fabricated by construction. Flagged unconditionally, with no evidence flag to unlock it (D6).
146
+ *
147
+ * This fires MORE here than at golden-beans, not less: our roadmap docs are full of *target-shaped*
148
+ * language — "owed to the product owner", "pending", "held on a smoke" — which is exactly the vocabulary a
149
+ * model completes into a deadline. That is the reason it stays unconditional rather than becoming a
150
+ * flag someone can switch off when it gets noisy.
151
+ */
152
+ const INVENTED_COMMITMENT_PATTERNS = [
153
+ /\b(?:by|before|due|no later than)\s+(?:tomorrow|today|tonight|monday|tuesday|wednesday|thursday|friday|saturday|sunday|next week|end of (?:day|week|month|the week)|eod|eow)\b/i,
154
+ /\b(?:deadline|sign-?off|approval)\b[^.]{0,40}\b(?:owed|due|required|needed|pending)\b/i,
155
+ /\b(?:owed|due|scheduled|slated|planned)\b\s+(?:for\s+)?(?:tomorrow|today|next week|this week|monday|friday)\b/i,
156
+ /\bwill (?:ship|land|be (?:ready|done|live))\b[^.]{0,30}\b(?:tomorrow|today|next week|this week|by)\b/i,
157
+ ];
158
+
159
+ /** Claims of customer/tenant/user impact — the second measured failure mode. */
160
+ const BENEFICIARY_PATTERNS = [
161
+ /\b(?:customers?|tenants?|users?|clients?|buyers?|merchants?|shoppers?|subscribers?)\b/i,
162
+ ];
163
+
164
+ /**
165
+ * Sentences that mention a beneficiary only to DENY impact on them.
166
+ *
167
+ * Without this the guard contradicts its own brief. prose-lessons.md ends with "Say 'no user-visible
168
+ * effect' when that is the truth", and the persona instructs the writer to state plainly that
169
+ * customers are unaffected when work is internal — then the beneficiary rule fired on the very
170
+ * sentence that complied. Measured 2026-07-26 on a hand-written draft whose opening clause was
171
+ * "…rather than anything a shopper would see": flagged as an invented beneficiary.
172
+ *
173
+ * A guard that rejects the sentence its own lessons file demands is worse than no guard: it teaches
174
+ * the writer to avoid the honest phrasing, which is precisely the failure the rule exists to prevent.
175
+ * So a beneficiary mention inside a no-impact construction is allowed; a positive claim still is not.
176
+ */
177
+ const NO_IMPACT_PATTERNS = [
178
+ /\bno (?:user|customer|tenant|merchant|buyer|client|shopper|subscriber)[- ]visible\b/i,
179
+ /\b(?:nothing|none|no)\b[^.]{0,40}\b(?:customers?|tenants?|users?|clients?|buyers?|merchants?|shoppers?|subscribers?)\b[^.]{0,40}\b(?:see|saw|notice|noticed|observe)\b/i,
180
+ /\b(?:customers?|tenants?|users?|clients?|buyers?|merchants?|shoppers?|subscribers?)\b[^.]{0,40}\b(?:are|is|were|was)\s+(?:not|un)(?:affected|changed|impacted)\b/i,
181
+ /\b(?:not|nothing)\b[^.]{0,30}\b(?:a|any)\s+(?:customers?|tenants?|users?|clients?|buyers?|merchants?|shoppers?|subscribers?)\b[^.]{0,30}\b(?:would|will|can|could)\s+(?:see|notice|observe)\b/i,
182
+ /\bunaffected\b/i,
183
+ // "…rather than anything a shopper would see" — the phrasing a real draft actually used. The
184
+ // negation here is carried by "rather than"/"instead of"/"other than", not by a "no"/"not" token,
185
+ // which is why the patterns above missed it.
186
+ /\b(?:rather than|instead of|other than)\b[^.]{0,60}\b(?:customers?|tenants?|users?|clients?|buyers?|merchants?|shoppers?|subscribers?)\b/i,
187
+ /\banything\b[^.]{0,30}\b(?:customers?|tenants?|users?|clients?|buyers?|merchants?|shoppers?|subscribers?)\b[^.]{0,30}\b(?:would|will|could|can)\s+(?:see|notice|observe)\b/i,
188
+ ];
189
+
190
+ /**
191
+ * ── NEW RULE, ours, not golden-beans' (README D6) ─────────────────────────────────────────────
192
+ * `flag-state-claim` — a draft asserting a capability is live/enabled/available/in production when
193
+ * the evidence pack does not say so.
194
+ *
195
+ * golden-beans does not need this: it ships straight to production. We do not. Every one of the
196
+ * last five epics shipped DARK behind a default-OFF flag and was flipped later, sometimes days
197
+ * later, sometimes never (`promoter.partner_portfolio_enabled` was born OFF and is still OFF). A
198
+ * report saying a capability "is live" when its flag is OFF is our single highest-risk falsehood:
199
+ * it is confident, it is plausible, it reads as good news, and it is the exact sentence that would
200
+ * stop the product owner from doing the flip the epic is waiting on.
201
+ *
202
+ * `evidence.liveFlags` is the corroboration: the list of things the pack can actually prove are on.
203
+ * Entries are usually flag keys (`promoter.activation_crm_enabled`), but any capability NAME works
204
+ * — a change with no flag at all is corroborated by listing the capability itself. That is
205
+ * deliberate: the escape valve is shaped like evidence, not like a boolean someone can set to true
206
+ * to make the guard quiet.
207
+ */
208
+ const LIVENESS_CLAIM_PATTERNS = [
209
+ /\b(?:is|are|was|were|went|goes|now)\s+live\b/i,
210
+ /\blive\s+(?:now|today|in production|for everyone)\b/i,
211
+ /\bis now (?:enabled|available|switched on|turned on|on)\b/i,
212
+ /\b(?:enabled|turned on|switched on|flipped on|rolled out|shipped|released)\s+(?:to|for|in)\s+(?:everyone|all|production|prod)\b/i,
213
+ /\b(?:runs|running|available|enabled|active)\s+in production\b/i,
214
+ /\bin production (?:now|today)\b/i,
215
+ // "can now <do X>" — ADDED after cross-review found the original omission was a real hole.
216
+ //
217
+ // The omission was reasoned as "D5 asks the persona to lead with what someone can DO now, so
218
+ // banning this would reject the sentences the epic exists to buy", and a test pinned it as legal.
219
+ // That reasoning conflated two different escape valves. "A shop owner can now hand a partner one
220
+ // shop" is FALSE when the flag is off — in substance it is a liveness claim, and it is precisely
221
+ // the shape prose-lessons.md warns about.
222
+ //
223
+ // The right valve is not "don't match the pattern"; it is "match it, and let EVIDENCE clear it" —
224
+ // exactly how every rule above works. With the flag on, `liveFlags` corroborates and the sentence
225
+ // passes; with it off, the claim is caught. The persona keeps its register and the guard keeps its
226
+ // teeth, where the omission gave up the teeth to protect the register.
227
+ /\bcan now\b/i,
228
+ ];
229
+
230
+ /**
231
+ * Negation immediately preceding a liveness claim. An honest report about a dark epic reads "the
232
+ * rail exists; nothing is live yet because the flag is off" (D5 explicitly wants that sentence),
233
+ * and flagging it would be the guard rejecting the truth it was written to protect.
234
+ *
235
+ * Scoped to a short window BEFORE the claim rather than to the whole sentence. Sentence-wide was
236
+ * the first cut and it is too generous: "the portfolio is live for every merchant, and no smoke has
237
+ * run yet" would be excused by a negation that belongs to a different clause — laundering the exact
238
+ * false claim this rule exists for.
239
+ */
240
+ const LIVENESS_NEGATORS =
241
+ /\b(?:not|isn'?t|aren'?t|wasn'?t|weren'?t|never|no|nothing|nobody|none|neither|nor|without|yet to)\b[^a-z0-9]*$/i;
242
+
243
+ /** How far back to look for a negator. Long enough for "nothing yet", short enough to stay a clause. */
244
+ const NEGATOR_WINDOW = 40;
245
+
246
+ /** Split into sentence-ish spans. Not linguistics — enough to scope a negation to its own clause. */
247
+ export function sentences(text) {
248
+ return String(text ?? '')
249
+ .split(/(?<=[.!?;])\s+/)
250
+ .map((s) => s.trim())
251
+ .filter(Boolean);
252
+ }
253
+
254
+ /**
255
+ * Tokens a `liveFlags` entry can be recognised by in prose. `promoter.partner_portfolio_enabled`
256
+ * → ['partner', 'portfolio']: the namespace and the `_enabled`/`_disabled` suffix are plumbing a
257
+ * report would never say out loud, the middle is the capability's actual name.
258
+ *
259
+ * Deliberately generous — one matching token corroborates. The rule's job is to catch "we said
260
+ * live and NOTHING is live", not to do semantic matching a regex cannot do honestly. A stricter
261
+ * match would produce false positives on correct reports, which is the failure mode that gets a
262
+ * guard bypassed.
263
+ */
264
+ export function flagTokens(flagKey) {
265
+ return String(flagKey ?? '')
266
+ .toLowerCase()
267
+ .replace(/_(enabled|disabled)\b/g, '')
268
+ .split('.')
269
+ .slice(-1)[0]
270
+ .split(/[^a-z0-9]+/)
271
+ .filter((t) => t.length >= 4);
272
+ }
273
+
274
+ /**
275
+ * Sentence-final punctuation, or a close-paren/quote after it. Used to detect a draft that ran out
276
+ * of room mid-clause — a trailing fragment reads as a broken tool, not as brevity.
277
+ */
278
+ // The `unfinished` check exists for CHAT-MESSAGE surfaces, where a trailing fragment reads as a broken
279
+ // tool. It misfires on INTERNAL artifacts, which legitimately end in structural markdown — and that
280
+ // misfire is not cosmetic: measured on the first real prose-draft run (2026-07-26), every `--kind poster`
281
+ // draft ended with the house table row
282
+ // | [slug](slug/) | one-line what | ✅ **Shipped …** |
283
+ // and was flagged `unfinished` on both writers, through all four attempts. A guard that rejects correct
284
+ // output is worse than one that misses a rare fault: it trains whoever maintains this to ignore the
285
+ // flag, which then hides the real findings it exists to surface (the same reasoning that keeps bare
286
+ // `react`/`next` out of BANNED_TOOL_NAMES).
287
+ //
288
+ // So a structural ending — a table row, a list item, a heading, a fenced block — counts as clean when
289
+ // the surface is allowed structural markdown. Prose surfaces keep the strict sentence-final rule.
290
+ function endsCleanly(text, { allowsMarkdown = false } = {}) {
291
+ const trimmed = String(text ?? '').trim();
292
+ if (/[.!?]["')\]]?\s*$/.test(trimmed)) return true;
293
+ if (!allowsMarkdown) return false;
294
+ const lastLine = trimmed.split('\n').pop().trim();
295
+ return (
296
+ /\|\s*$/.test(lastLine) || // a markdown table row
297
+ /^#{1,6}\s/.test(lastLine) || // a heading
298
+ /^([-*+]|\d+\.)\s/.test(lastLine) || // a list item
299
+ /^```/.test(lastLine) || // a fenced block delimiter
300
+ /^\[GAP:/.test(lastLine) // the documented "source material insufficient" marker
301
+ );
302
+ }
303
+
304
+ /** Split into sentences well enough to count them. Not linguistics — just a length sanity check. */
305
+ export function countSentences(text) {
306
+ return (String(text ?? '').match(/[^.!?]+[.!?]/g) ?? []).length;
307
+ }
308
+
309
+ export function countWords(text) {
310
+ return String(text ?? '')
311
+ .trim()
312
+ .split(/\s+/)
313
+ .filter(Boolean).length;
314
+ }
315
+
316
+ /**
317
+ * Check a draft against the mechanical rules.
318
+ *
319
+ * `evidence` describes what the SOURCE DATA actually supports, so the guard can distinguish a
320
+ * legitimate claim from an invented one:
321
+ * - `allowsFixClaim` — the change genuinely fixes something (e.g. the commit subject is a
322
+ * `fix:`). When false, past-tense fix language is a finding.
323
+ * - `allowsBeneficiary`— the change plausibly touches a surface a merchant/buyer can observe.
324
+ * When false, naming customers/merchants/users is a finding.
325
+ * - `liveFlags` — capabilities the pack can PROVE are on (flag keys, or capability names
326
+ * for unflagged work). Empty ⇒ no liveness may be asserted (D6).
327
+ * - `maxWords`/`minWords` — the length budget for this surface.
328
+ *
329
+ * Note what is NOT here: any switch that turns off `invented-commitment`. Both the flags above
330
+ * describe something the source data can genuinely support; no source data can support a date that
331
+ * does not exist, so there is nothing for a flag to gate.
332
+ *
333
+ * Returns `{ ok, findings }`. Each finding carries a `note` written to be handed straight back to
334
+ * the writer as a revision instruction — a guard that only says "rejected" produces another guess;
335
+ * one that says what is wrong produces a correction.
336
+ */
337
+ export function checkProse(draft, evidence = {}) {
338
+ const {
339
+ allowsFixClaim = false,
340
+ allowsBeneficiary = false,
341
+ liveFlags = [],
342
+ maxWords = 60,
343
+ minWords = 8,
344
+ // Internal engineering artifacts (retros, poster entries, sprint-wraps) are REQUIRED to use
345
+ // structural markdown; chat-message reports are forbidden from it. Only the `unfinished` check
346
+ // varies on this — every other rule applies identically to both surfaces.
347
+ allowsMarkdown = false,
348
+ } = evidence;
349
+ const text = String(draft ?? '').trim();
350
+ const findings = [];
351
+
352
+ if (!text) {
353
+ return { ok: false, findings: [{ code: 'empty', note: 'The draft is empty. Write the report.' }] };
354
+ }
355
+
356
+ const words = countWords(text);
357
+ if (words > maxWords) {
358
+ findings.push({
359
+ code: 'too-long',
360
+ note: `The draft is ${words} words; the limit is ${maxWords}. Cut it down — decide what the single point is and say only that.`,
361
+ });
362
+ }
363
+ if (words < minWords) {
364
+ findings.push({
365
+ code: 'too-short',
366
+ note: `The draft is only ${words} words. That is not a report — say who is affected and what changed for them.`,
367
+ });
368
+ }
369
+
370
+ if (!endsCleanly(text, { allowsMarkdown })) {
371
+ findings.push({
372
+ code: 'unfinished',
373
+ note: 'The last sentence is unfinished. End on a complete sentence — stop at the previous full stop rather than starting a clause you cannot complete.',
374
+ });
375
+ }
376
+
377
+ const lower = text.toLowerCase();
378
+
379
+ const banned = BANNED_PHRASES.filter((p) =>
380
+ new RegExp(`\\b${p.replace(/[-\s]/g, '[-\\s]')}\\b`, 'i').test(lower)
381
+ );
382
+ if (banned.length) {
383
+ findings.push({
384
+ code: 'marketing-language',
385
+ note: `Remove marketing words and state the fact plainly: ${banned.join(', ')}.`,
386
+ });
387
+ }
388
+
389
+ const tools = [...BANNED_TOOL_NAMES, ...(evidence.extraBannedToolNames || [])].filter((t) =>
390
+ new RegExp(`\\b${t}\\b`, 'i').test(lower)
391
+ );
392
+ if (tools.length) {
393
+ findings.push({
394
+ code: 'names-implementation',
395
+ note: `Do not name tools, frameworks or models — the reader either knows them or does not need them. Found: ${tools.map(displayToolName).join(', ')}.`,
396
+ });
397
+ }
398
+
399
+ if (!allowsFixClaim) {
400
+ const claimed = FIX_CLAIM_PATTERNS.some((re) => re.test(text));
401
+ if (claimed) {
402
+ findings.push({
403
+ code: 'unsupported-fix-claim',
404
+ note:
405
+ 'The draft claims something was fixed, resolved or prevented, but the source data does not show this change making that fix. ' +
406
+ 'Commit messages and sprint docs here often mention a PAST incident to explain why the present work matters — that is context, never the outcome. ' +
407
+ 'Describe what this change itself did.',
408
+ });
409
+ }
410
+ }
411
+
412
+ // Unconditional: git history and roadmap docs contain no deadlines, so a future-dated commitment
413
+ // in a derived report is fabricated by construction. There is no evidence flag that could
414
+ // legitimately unlock it, which is why this check sits outside the `allows*` gates (D6).
415
+ if (INVENTED_COMMITMENT_PATTERNS.some((re) => re.test(text))) {
416
+ findings.push({
417
+ code: 'invented-commitment',
418
+ note:
419
+ 'The draft states a deadline, due date or sign-off that appears nowhere in the source data. ' +
420
+ 'Commits and roadmap docs contain no deadlines, so any date or commitment here is invented — ' +
421
+ 'and a report that manufactures obligations makes people chase work nobody agreed to. ' +
422
+ 'Remove it. If something is genuinely owed, say what and to whom, with no date attached.',
423
+ });
424
+ }
425
+
426
+ if (!allowsBeneficiary) {
427
+ // Per SENTENCE: a mention that DENIES impact is the honest internal framing the persona asks for,
428
+ // but it only excuses its own sentence. Tested against the whole draft, one "no customer-visible
429
+ // effect" would launder a separate invented benefit elsewhere — measured failure 1 again.
430
+ // Split on line breaks too: a bullet list has no terminal punctuation, and "- no customer-visible
431
+ // effect" must not excuse the NEXT bullet's claim.
432
+ const named = text
433
+ .split(/(?<=[.!?])\s+|\n+/)
434
+ .some(
435
+ (sentence) =>
436
+ BENEFICIARY_PATTERNS.some((re) => re.test(sentence)) &&
437
+ !NO_IMPACT_PATTERNS.some((re) => re.test(sentence))
438
+ );
439
+ if (named) {
440
+ findings.push({
441
+ code: 'invented-beneficiary',
442
+ note:
443
+ 'The draft names customers/merchants/users, but this change does not touch a surface they can observe. ' +
444
+ 'Say plainly that it is internal, and name the real beneficiary and the real effect — a bug class that can no longer reach production, ' +
445
+ 'a mistake caught in seconds instead of after a deploy.',
446
+ });
447
+ }
448
+ }
449
+
450
+ // ── flag-state-claim (D6) ──────────────────────────────────────────────────────────────────
451
+ // Scoped per SENTENCE, twice over: a negator only excuses the clause it sits in ("the rail
452
+ // exists, but nothing is live yet"), and corroboration has to come from the same sentence that
453
+ // made the claim — otherwise one genuinely-live capability elsewhere in the paragraph would
454
+ // launder every other liveness claim in it.
455
+ const unsupported = sentences(text).filter((s) => {
456
+ // EVERY occurrence, not just the first: "nothing is live yet, though the rail is live" carries
457
+ // one negated claim and one bare one, and stopping at the first would excuse the second.
458
+ const asserted = LIVENESS_CLAIM_PATTERNS.flatMap((re) => [
459
+ ...s.matchAll(new RegExp(re.source, 'gi')),
460
+ ]).some((m) => !LIVENESS_NEGATORS.test(s.slice(Math.max(0, m.index - NEGATOR_WINDOW), m.index)));
461
+ if (!asserted) return false;
462
+ const sl = s.toLowerCase();
463
+ return !liveFlags.some((f) => {
464
+ const tokens = flagTokens(f);
465
+ return tokens.length > 0 && tokens.some((t) => sl.includes(t));
466
+ });
467
+ });
468
+ if (unsupported.length) {
469
+ findings.push({
470
+ code: 'flag-state-claim',
471
+ note:
472
+ 'The draft says a capability is live, enabled or in production, and the evidence pack does not show that. ' +
473
+ (liveFlags.length
474
+ ? `The only capabilities the pack proves are on are: ${liveFlags.join(', ')}. `
475
+ : 'The pack lists NO capability as on. ') +
476
+ 'Almost everything here ships dark behind a default-off flag and is flipped later, so "it landed" and "it is live" are different facts and only one of them is in evidence. ' +
477
+ 'Say what shipped and say plainly that it is off until the flag is flipped. ' +
478
+ `Rewrite this: "${unsupported[0]}"`,
479
+ });
480
+ }
481
+
482
+ return { ok: findings.length === 0, findings };
483
+ }
484
+
485
+ /** Render findings as a revision instruction to hand back to the writer on a retry. */
486
+ export function findingsToRevisionNote(findings) {
487
+ return [
488
+ 'Your previous draft was rejected by an automated check. Fix EVERY point below and rewrite it in full.',
489
+ '',
490
+ ...findings.map((f, i) => `${i + 1}. ${f.note}`),
491
+ '',
492
+ 'Output only the corrected report — no preamble, no explanation of the changes.',
493
+ ].join('\n');
494
+ }
495
+
496
+ // ── Jev decides the SEMANTIC families (jev-semantic-guards D4/D6) ───────────────────────────────────
497
+ // Four of the rules above are language judgements wearing regexes: a FIX claim, an invented BENEFICIARY, a
498
+ // LIVENESS claim, an invented COMMITMENT. Each carries an incident comment for the phrasing it missed last
499
+ // time, and NO_IMPACT_PATTERNS exists only because honest negations ("rather than anything a shopper would
500
+ // see") kept tripping them. `judgeProse` asks Jev those four questions instead — one Noul per sentence per
501
+ // family, in one batched call — by `jev.config.json → rails.prose.mode`:
502
+ // off → exactly `checkProse`; no call, no log line. The kill-switch.
503
+ // shadow → `checkProse` decides; Jev is asked and both verdicts are logged.
504
+ // jev → the mechanical rules (length, banned phrases, tool names, unfinished) still come from
505
+ // `checkProse` — they are real if-statements. Each semantic family comes from Jev (claim at
506
+ // noul ≥ thresholds.claim); a family Jev could not look at falls back to the regex, alone.
507
+ // What Jev does NOT decide: the EVIDENCE. `allowsFixClaim` / `allowsBeneficiary` still skip those families
508
+ // outright, and a liveness claim is still cleared only by a `liveFlags` token in the same sentence — Jev says
509
+ // whether the sentence ASSERTS liveness, code says whether the pack CORROBORATES it. And invented-commitment
510
+ // still has no evidence flag that disables it. Findings carry the same codes and the same notes as
511
+ // `checkProse`, so the writer's revision loop does not change.
512
+
513
+ export const SEMANTIC_CODES = [
514
+ 'unsupported-fix-claim',
515
+ 'invented-beneficiary',
516
+ 'flag-state-claim',
517
+ 'invented-commitment',
518
+ ];
519
+
520
+ /** More (sentence × family) questions than this are split into parallel calls. */
521
+ export const PROSE_CHUNK = 120;
522
+
523
+ // The wording below is MEASURED, not guessed — on the 62 labelled prose fixtures in jev-eval.fixtures.json
524
+ // (2026-09-23, jev-1.13.0, claim ≥ 0.5). The first liveness question ("does it assert something is live,
525
+ // enabled… or usable now?") flagged 12 sentences that merely describe behaviour ("now groups", "no longer
526
+ // breaks"); separating RELEASE STATE from BEHAVIOUR took whole-draft accuracy from 48/62 to 60/62, against
527
+ // the regex's 53/62, with every family at or above the regex. Re-measure with `jev-eval --live` before
528
+ // changing a word.
529
+ export const PROSE_FAMILIES = [
530
+ {
531
+ code: 'unsupported-fix-claim',
532
+ key: 'fix',
533
+ skip: (ev) => Boolean(ev.allowsFixClaim),
534
+ question:
535
+ 'Does `sentence` claim that the change being reported FIXED, resolved, closed, patched, prevented or eliminated a specific bug, vulnerability or problem — as an accomplished outcome of this change?',
536
+ criteria: {
537
+ true: 'It asserts this change fixed, resolved, prevented, removed or closed a specific defect or risk (any tense or phrasing, including "…, eliminating X", "X no longer happens", "won\'t get stuck anymore").',
538
+ false:
539
+ 'It only describes a check, test or process that detects or catches mistakes; or it describes what the change adds; or it mentions a past incident as context; or it denies a fix.',
540
+ },
541
+ },
542
+ {
543
+ code: 'invented-beneficiary',
544
+ key: 'beneficiary',
545
+ skip: (ev) => Boolean(ev.allowsBeneficiary),
546
+ question:
547
+ 'Does `sentence` claim that end users of the product — customers, users, merchants, sellers, buyers, shoppers, tenants, clients, subscribers, or "the people buying from us" — gain, notice or experience something because of this change?',
548
+ criteria: {
549
+ true: 'It says or implies a named group of end users benefits, notices, gets or can do something because of this change.',
550
+ false:
551
+ 'No end-user group is claimed to benefit: "everyone"/"anyone"/"nobody" used only to describe availability; a statement that nobody can use it yet; or it says end users are NOT affected / would see nothing / the work is internal.',
552
+ },
553
+ },
554
+ {
555
+ code: 'flag-state-claim',
556
+ key: 'live',
557
+ skip: () => false,
558
+ question:
559
+ 'Does `sentence` claim that a capability has been SWITCHED ON for real use — its release state, not its behaviour? Examples of such claims: "is live", "went live", "is now enabled", "is rolled out to everyone", "is now available", "is running in production", "the switch has been flipped", "X can now do Y" (a newly usable capability), "as of this morning people can…".',
560
+ criteria: {
561
+ true: 'It states as fact that something is on / live / released / available for use now.',
562
+ false:
563
+ 'It only describes what a change does or how something behaves ("now groups", "now checks", "no longer breaks", "now caught in seconds", "benefit from", "validates", "won\'t get stuck"); or it claims a fix or a benefit without claiming a release state; or it says the thing is NOT live, dark, off, or not yet available.',
564
+ },
565
+ },
566
+ {
567
+ code: 'invented-commitment',
568
+ key: 'commitment',
569
+ skip: () => false,
570
+ question:
571
+ 'Does `sentence` state a deadline, a due date, a scheduled date, or a sign-off or approval owed by some time (today, tomorrow, a weekday, next week, end of day…)?',
572
+ criteria: {
573
+ true: 'It binds something to a future time: "by Friday", "due tomorrow", "sign-off is owed before the demo", "will ship next week".',
574
+ false: 'No future time is attached: past facts, or something owed with no date.',
575
+ },
576
+ },
577
+ ];
578
+
579
+ /** The notes `checkProse` writes for each semantic family — one map, held to its text by a spec. */
580
+ export function semanticNote(code, { liveFlags = [], sentence = '' } = {}) {
581
+ switch (code) {
582
+ case 'unsupported-fix-claim':
583
+ return (
584
+ 'The draft claims something was fixed, resolved or prevented, but the source data does not show this change making that fix. ' +
585
+ 'Commit messages and sprint docs here often mention a PAST incident to explain why the present work matters — that is context, never the outcome. ' +
586
+ 'Describe what this change itself did.'
587
+ );
588
+ case 'invented-commitment':
589
+ return (
590
+ 'The draft states a deadline, due date or sign-off that appears nowhere in the source data. ' +
591
+ 'Commits and roadmap docs contain no deadlines, so any date or commitment here is invented — ' +
592
+ 'and a report that manufactures obligations makes people chase work nobody agreed to. ' +
593
+ 'Remove it. If something is genuinely owed, say what and to whom, with no date attached.'
594
+ );
595
+ case 'invented-beneficiary':
596
+ return (
597
+ 'The draft names customers/merchants/users, but this change does not touch a surface they can observe. ' +
598
+ 'Say plainly that it is internal, and name the real beneficiary and the real effect — a bug class that can no longer reach production, ' +
599
+ 'a mistake caught in seconds instead of after a deploy.'
600
+ );
601
+ case 'flag-state-claim':
602
+ return (
603
+ 'The draft says a capability is live, enabled or in production, and the evidence pack does not show that. ' +
604
+ (liveFlags.length
605
+ ? `The only capabilities the pack proves are on are: ${liveFlags.join(', ')}. `
606
+ : 'The pack lists NO capability as on. ') +
607
+ 'Almost everything here ships dark behind a default-off flag and is flipped later, so "it landed" and "it is live" are different facts and only one of them is in evidence. ' +
608
+ 'Say what shipped and say plainly that it is off until the flag is flipped. ' +
609
+ `Rewrite this: "${sentence}"`
610
+ );
611
+ default:
612
+ return '';
613
+ }
614
+ }
615
+
616
+ /**
617
+ * The units Jev judges: the guard's own `sentences()`, further split on line breaks so each bullet of a list
618
+ * (which has no terminal punctuation) is its own unit — the same reason the beneficiary rule splits on `\n`.
619
+ */
620
+ export function proseUnits(text) {
621
+ return (
622
+ sentences(text)
623
+ .flatMap((s) => s.split(/\n+/))
624
+ // A markdown HEADING is structure, never a claim: "## What shipped" scored 0.50–0.78 as a liveness
625
+ // claim across the 2026-09-23 retrospective backtest, and no heading can assert anything on its own.
626
+ .filter((s) => !/^\s*#{1,6}\s/.test(s))
627
+ .map((s) => s.replace(/^\s*(?:[-*+]|\d+\.)\s+/, '').trim())
628
+ .filter((s) => /[a-z]/i.test(s))
629
+ );
630
+ }
631
+
632
+ /** Is this liveness sentence corroborated by the pack? The same token test `checkProse` applies. */
633
+ const corroborated = (sentence, liveFlags) => {
634
+ const sl = sentence.toLowerCase();
635
+ return liveFlags.some((f) => {
636
+ const tokens = flagTokens(f);
637
+ return tokens.length > 0 && tokens.some((t) => sl.includes(t));
638
+ });
639
+ };
640
+
641
+ /** Build the batched questions for a draft. Pure. Returns [{ id, unit, family, question }]. */
642
+ export function proseQuestions(units, evidence = {}) {
643
+ const families = PROSE_FAMILIES.filter((f) => !f.skip(evidence));
644
+ return units.flatMap((unit, i) =>
645
+ families.map((f) => ({
646
+ id: `s${i}_${f.key}`,
647
+ unit: i,
648
+ family: f.code,
649
+ question: {
650
+ type: 'noul',
651
+ instructions: { sentence: unit, question: f.question },
652
+ criteria: f.criteria,
653
+ },
654
+ }))
655
+ );
656
+ }
657
+
658
+ /**
659
+ * Pure: the semantic findings Jev's answers imply. answers: { [id]: noul }. Families whose answers are missing
660
+ * are returned in `unanswered` so the caller can fall back to the regex for exactly those.
661
+ */
662
+ export function decideProse({ units, questions, answers, evidence = {}, threshold }) {
663
+ const liveFlags = evidence.liveFlags ?? [];
664
+ const byFamily = new Map();
665
+ const unanswered = new Set();
666
+ let confidence = 0;
667
+ for (const q of questions) {
668
+ const noul = answers[q.id];
669
+ if (typeof noul !== 'number' || noul < 0 || noul > 1) {
670
+ unanswered.add(q.family);
671
+ continue;
672
+ }
673
+ confidence = Math.max(confidence, noul);
674
+ if (noul < threshold) continue;
675
+ const sentence = units[q.unit];
676
+ if (q.family === 'flag-state-claim' && corroborated(sentence, liveFlags)) continue;
677
+ if (!byFamily.has(q.family)) byFamily.set(q.family, { sentence, noul });
678
+ }
679
+ // Every claim Jev DID find is a finding — including in a family where some other chunk could not look.
680
+ // Dropping those would let a caught claim through whenever one chunk hit a 529 (fresh review, PR #36);
681
+ // the caller unions them with the regex for exactly those families.
682
+ const findings = SEMANTIC_CODES.filter((c) => byFamily.has(c)).map((code) => {
683
+ const { sentence, noul } = byFamily.get(code);
684
+ const note = semanticNote(code, { liveFlags, sentence });
685
+ return {
686
+ code,
687
+ note: code === 'flag-state-claim' ? note : `${note} The sentence: "${sentence}"`,
688
+ sentence,
689
+ noul,
690
+ };
691
+ });
692
+ return { findings, unanswered: [...unanswered], confidence };
693
+ }
694
+
695
+ /** The draft as Jev sees it: the full text for context; each question points at one `sentence`. */
696
+ const proseState = (draft) => ({ report_draft: String(draft) });
697
+
698
+ /**
699
+ * THE JUDGE. async; never throws on a Jev failure (a malformed jev.config.json DOES throw — loudly).
700
+ * Returns the `checkProse` shape `{ ok, findings }` plus `{ decider, mode, regexCodes, jevCodes, fallback }`.
701
+ * deps: see jevContext (config, key, ask, log, root) — the same injection as the review judge.
702
+ */
703
+ export async function judgeProse(draft, evidence = {}, deps = {}) {
704
+ const regex = checkProse(draft, evidence);
705
+ const ctx = jevContext('prose', deps);
706
+ const regexCodes = regex.findings.map((f) => f.code).filter((c) => SEMANTIC_CODES.includes(c));
707
+ const units = proseUnits(draft);
708
+ const questions = proseQuestions(units, evidence);
709
+ if (ctx.mode === 'off' || !questions.length || !String(draft ?? '').trim()) {
710
+ return { ...regex, decider: 'regex', mode: ctx.mode, regexCodes, jevCodes: null, fallback: [] };
711
+ }
712
+
713
+ const chunks = [];
714
+ for (let i = 0; i < questions.length; i += PROSE_CHUNK) chunks.push(questions.slice(i, i + PROSE_CHUNK));
715
+ const results = await Promise.all(
716
+ chunks.map((c) =>
717
+ ctx.ask({ state: proseState(draft), questions: Object.fromEntries(c.map((q) => [q.id, q.question])) })
718
+ )
719
+ );
720
+ const answers = {};
721
+ const errors = [];
722
+ results.forEach((r) => {
723
+ if (!r.ok) return errors.push(r.error);
724
+ for (const [id, a] of Object.entries(r.answers ?? {})) answers[id] = a?.noul;
725
+ });
726
+ const jev = decideProse({ units, questions, answers, evidence, threshold: ctx.rail.thresholds.claim });
727
+ const jevCodes = jev.findings.map((f) => f.code);
728
+ const fallback = jev.unanswered;
729
+
730
+ let out;
731
+ if (ctx.mode === 'shadow') {
732
+ out = { ...regex, decider: 'regex' };
733
+ } else {
734
+ const mechanical = regex.findings.filter((f) => !SEMANTIC_CODES.includes(f.code));
735
+ // A fallback family is the UNION of the regex's verdict and whatever Jev found in the chunks that answered.
736
+ const jevHit = new Set(jevCodes);
737
+ const fromRegex = regex.findings.filter((f) => fallback.includes(f.code) && !jevHit.has(f.code));
738
+ const findings = [...mechanical, ...jev.findings, ...fromRegex];
739
+ out = { ok: findings.length === 0, findings, decider: fallback.length ? 'jev+regex' : 'jev' };
740
+ }
741
+ ctx.log({
742
+ rail: 'prose',
743
+ mode: ctx.mode,
744
+ decider: out.decider,
745
+ regex: regexCodes,
746
+ jev: jevCodes,
747
+ confidence: Number(jev.confidence.toFixed(3)),
748
+ text: draft,
749
+ // What the draft was judged against, so a disagreement can become a labelled fixture (jev-report).
750
+ evidence: {
751
+ allowsFixClaim: Boolean(evidence.allowsFixClaim),
752
+ allowsBeneficiary: Boolean(evidence.allowsBeneficiary),
753
+ allowsMarkdown: Boolean(evidence.allowsMarkdown),
754
+ liveFlags: evidence.liveFlags ?? [],
755
+ ...(evidence.maxWords ? { maxWords: evidence.maxWords } : {}),
756
+ ...(evidence.minWords ? { minWords: evidence.minWords } : {}),
757
+ },
758
+ error: errors.length ? errors[0] : null,
759
+ });
760
+ return { ...out, mode: ctx.mode, regexCodes, jevCodes, fallback, errors };
761
+ }