@golden-frijoles/kit 0.10.0 → 0.12.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/dist/intent-match.mjs +65 -23
- package/dist/intent-outcomes.mjs +276 -0
- package/dist/intent-reader.mjs +330 -0
- package/dist/lib/config-registry.mjs +10 -0
- package/dist/lib/config.mjs +1 -0
- package/dist/lib/cross-agent-cli.mjs +22 -12
- package/package.json +1 -1
package/dist/intent-match.mjs
CHANGED
|
@@ -121,6 +121,18 @@ export const INTENT_QUESTIONS = Object.freeze({
|
|
|
121
121
|
'Yes: it names the action, where to do it and the exact result to observe, so two testers would agree.',
|
|
122
122
|
],
|
|
123
123
|
},
|
|
124
|
+
agreement: {
|
|
125
|
+
// Asked by intent-reader.mjs at the architecture lock (D16), once per epic, only when `intent.reader` is on.
|
|
126
|
+
// Not measured: there is no labelled set of reader replies yet. Its answer is one signal among five, advisory.
|
|
127
|
+
type: 'noul',
|
|
128
|
+
instructions:
|
|
129
|
+
'`plan` is the architecture lock a builder wrote for this work. `reading` is what a second reader, given only the pitch, says they would build, would not build, and would ask first. Would the two of them build the same thing?',
|
|
130
|
+
criteria: {
|
|
131
|
+
true: 'Yes: the reading describes the same product and the same scope as the plan; the differences are wording, order, or detail the plan already settles.',
|
|
132
|
+
false:
|
|
133
|
+
'No: the reading would build something the plan does not, leave out something the plan builds, or its first question exposes a decision the plan has not made.',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
124
136
|
route: {
|
|
125
137
|
type: 'choice',
|
|
126
138
|
instructions:
|
|
@@ -150,6 +162,13 @@ export const INTENT_QUESTIONS = Object.freeze({
|
|
|
150
162
|
|
|
151
163
|
const FRONTMATTER_RE = /^---\n[\s\S]*?\n---\n?/;
|
|
152
164
|
|
|
165
|
+
/**
|
|
166
|
+
* A fence line, by CommonMark's rule: at most THREE spaces of indent. A line indented four or more is code, never a
|
|
167
|
+
* fence — and the reader writes its reply indented, so a `\s*` here let an odd fence line in a reply flip the scanner
|
|
168
|
+
* and make the next write delete every section after `## Intent match` (fresh review round 2, #197).
|
|
169
|
+
*/
|
|
170
|
+
export const FENCE_RE = /^ {0,3}(```|~~~)/;
|
|
171
|
+
|
|
153
172
|
/** Flat frontmatter → { key: value } (comment-stripped, quotes stripped). The same rule epic-dod uses. */
|
|
154
173
|
export function frontmatterOf(text) {
|
|
155
174
|
const m = /^---\n([\s\S]*?)\n---/.exec(String(text));
|
|
@@ -167,7 +186,7 @@ export function sections(body) {
|
|
|
167
186
|
const out = [{ heading: null, lines: [] }];
|
|
168
187
|
let fence = false;
|
|
169
188
|
for (const line of String(body).split('\n')) {
|
|
170
|
-
if (
|
|
189
|
+
if (FENCE_RE.test(line)) fence = !fence;
|
|
171
190
|
if (!fence && /^## /.test(line)) out.push({ heading: line.slice(3).trim(), lines: [] });
|
|
172
191
|
else out.at(-1).lines.push(line);
|
|
173
192
|
}
|
|
@@ -184,7 +203,7 @@ export function listItems(lines, { numbered = false } = {}) {
|
|
|
184
203
|
const top = numbered ? /^(\d+)[.)]\s+(.*)$/ : /^(?:[-*+]|\d+[.)])\s+(.*)$/;
|
|
185
204
|
let fence = false;
|
|
186
205
|
for (const raw of lines) {
|
|
187
|
-
if (
|
|
206
|
+
if (FENCE_RE.test(raw)) {
|
|
188
207
|
fence = !fence;
|
|
189
208
|
continue;
|
|
190
209
|
}
|
|
@@ -450,6 +469,24 @@ export function componentsOf(result) {
|
|
|
450
469
|
return Object.fromEntries(Object.entries(result.signals).map(([k, v]) => [k, round(v)]));
|
|
451
470
|
}
|
|
452
471
|
|
|
472
|
+
/**
|
|
473
|
+
* The components a `--write` stored in a seed's `<!-- intent-match: {…} -->` comment, or null. Pure. Only unit
|
|
474
|
+
* numbers survive, so a hand-edited comment can never smuggle a non-number into a total.
|
|
475
|
+
*/
|
|
476
|
+
export function componentsFrom(text) {
|
|
477
|
+
const m = /<!-- intent-match: (\{[^\n]*?\}) -->/.exec(String(text ?? ''));
|
|
478
|
+
if (!m) return null;
|
|
479
|
+
let raw;
|
|
480
|
+
try {
|
|
481
|
+
raw = JSON.parse(m[1]);
|
|
482
|
+
} catch {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
const out = {};
|
|
486
|
+
for (const k of Object.keys(SIGNAL_NAMES)) if (isUnit(raw?.[k])) out[k] = raw[k];
|
|
487
|
+
return Object.keys(out).length ? out : null;
|
|
488
|
+
}
|
|
489
|
+
|
|
453
490
|
/** The `## Intent match` section `--write` puts in the seed. Pure. */
|
|
454
491
|
export function intentSection(result, opts = {}) {
|
|
455
492
|
return [
|
|
@@ -466,34 +503,29 @@ export function intentSection(result, opts = {}) {
|
|
|
466
503
|
].join('\n');
|
|
467
504
|
}
|
|
468
505
|
|
|
469
|
-
/**
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
* `## Intent match` replaces any earlier one or is appended.
|
|
473
|
-
*/
|
|
474
|
-
export function writeIntoSeed(text, result, opts = {}) {
|
|
475
|
-
let src = String(text);
|
|
506
|
+
/** Set `key: value` in a document's frontmatter — replaced, or added before the closing fence. Pure. */
|
|
507
|
+
export function setFrontmatterKey(text, key, value, { onlyIfAbsent = false } = {}) {
|
|
508
|
+
const src = String(text);
|
|
476
509
|
const fm = FRONTMATTER_RE.exec(src);
|
|
477
|
-
if (fm)
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const section = intentSection(result, opts);
|
|
510
|
+
if (!fm) return src;
|
|
511
|
+
let block = fm[0];
|
|
512
|
+
const re = new RegExp(`^${key}:.*$`, 'm');
|
|
513
|
+
if (re.test(block)) {
|
|
514
|
+
if (!onlyIfAbsent) block = block.replace(re, `${key}: ${value}`);
|
|
515
|
+
} else block = block.replace(/\n---\n?$/, `\n${key}: ${value}\n---\n`);
|
|
516
|
+
return block + src.slice(fm[0].length);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Replace the `## Intent match` section (up to the next `## `), or append it. Pure. */
|
|
520
|
+
export function upsertIntentSection(text, section) {
|
|
521
|
+
const src = String(text);
|
|
490
522
|
const lines = src.split('\n');
|
|
491
523
|
// Fence-aware, like sections(): a `## Intent match` inside a code block is an example, and treating it as the
|
|
492
524
|
// section deleted everything up to the next heading, closing fence included (fresh review of #196).
|
|
493
525
|
const headingAt = [];
|
|
494
526
|
let fence = false;
|
|
495
527
|
lines.forEach((l, i) => {
|
|
496
|
-
if (
|
|
528
|
+
if (FENCE_RE.test(l)) fence = !fence;
|
|
497
529
|
else if (!fence && /^## /.test(l)) headingAt.push(i);
|
|
498
530
|
});
|
|
499
531
|
const start = headingAt.find((i) => INTENT_HEADING.test(lines[i].slice(3).trim())) ?? -1;
|
|
@@ -504,6 +536,16 @@ export function writeIntoSeed(text, result, opts = {}) {
|
|
|
504
536
|
.replace(/\n{3,}/g, '\n\n');
|
|
505
537
|
}
|
|
506
538
|
|
|
539
|
+
/**
|
|
540
|
+
* Put the score into the seed text. Pure. `intent_match:` goes into the frontmatter, `intent_ask: verbatim` is added
|
|
541
|
+
* when the seed has an ask and no `intent_ask:` yet, and `## Intent match` replaces any earlier one or is appended.
|
|
542
|
+
*/
|
|
543
|
+
export function writeIntoSeed(text, result, opts = {}) {
|
|
544
|
+
let src = setFrontmatterKey(text, 'intent_match', result.total);
|
|
545
|
+
if (opts.hasAsk) src = setFrontmatterKey(src, 'intent_ask', 'verbatim', { onlyIfAbsent: true });
|
|
546
|
+
return upsertIntentSection(src, intentSection(result, opts));
|
|
547
|
+
}
|
|
548
|
+
|
|
507
549
|
// ── The run, every side effect injected ───────────────────────────────────────────────────────────────────────
|
|
508
550
|
|
|
509
551
|
const couldNotLook = (why) => ({ state: 'could-not-look', why });
|
|
@@ -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));
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// intent-reader.mjs — an OPTIONAL second-family read of a pitch at the architecture lock (intent-match D4, D16).
|
|
3
|
+
//
|
|
4
|
+
// node scripts/intent-reader.mjs --epic <slug> [--timeout <seconds>]
|
|
5
|
+
//
|
|
6
|
+
// The epic kickoff's lock step names this command on every run. With `intent.reader` off — the default — it prints
|
|
7
|
+
// one line and exits before touching any CLI, so it costs nothing. Turned on (`gf-kit config set intent.reader on`),
|
|
8
|
+
// it hands the epic's seed (the pitch file, nothing else) to the first of codex, agy and vibe that is installed,
|
|
9
|
+
// once, with a hard timeout, and asks Jev one Noul: would that reader build the same thing as the lock's plan? The
|
|
10
|
+
// answer is the fifth signal, "agreement", and it is written into the epic README's `## Intent match` section.
|
|
11
|
+
//
|
|
12
|
+
// ── IT NEVER GETS IN THE WAY ─────────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
// Any failure — no reader CLI, a capped or logged-out CLI, a timeout, an empty or unstructured reply, Jev unable to
|
|
14
|
+
// look — prints exactly ONE line, `reader skipped: <why>`, and exits 0. There is no retry and no second family after
|
|
15
|
+
// one has been asked: free-tier CLIs can be finicky, and three reads of every pitch cost more time than they return
|
|
16
|
+
// (the product owner's call at grooming). Exit 1 is kept for a usage error only.
|
|
17
|
+
//
|
|
18
|
+
// ── NEVER CLAUDE ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Planning and building run on Claude; a Claude reader adds no independent read. `FAMILIES` holds no Claude entry
|
|
20
|
+
// and a spec pins that.
|
|
21
|
+
//
|
|
22
|
+
// Every CLI is spawned directly with `timeout`, from the argv builders `lib/cross-agent-cli.mjs` exports — never
|
|
23
|
+
// through `runCodex`/`runAntigravity`/`runVibe`, which have no deadline, retry agy on a second model and print their
|
|
24
|
+
// own warning line (C4). Zero deps — Node 18+.
|
|
25
|
+
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { join, relative, resolve } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import {
|
|
31
|
+
AGY_ARG_LIMIT,
|
|
32
|
+
agyArgs,
|
|
33
|
+
codexExecArgs,
|
|
34
|
+
hasCmd,
|
|
35
|
+
VIBE_ARG_LIMIT,
|
|
36
|
+
vibeArgs,
|
|
37
|
+
} from './lib/cross-agent-cli.mjs';
|
|
38
|
+
import { getKey } from './lib/config.mjs';
|
|
39
|
+
import { collectSecretValues, findSecretLeaks } from './lib/secret-guard.mjs';
|
|
40
|
+
import { askJev, loadJevConfig, readApiKey, repoRoot, stateSize, STATE_CHAR_BUDGET } from './lib/jev.mjs';
|
|
41
|
+
import {
|
|
42
|
+
componentsFrom,
|
|
43
|
+
INTENT_QUESTIONS,
|
|
44
|
+
INTENT_TIMEOUT_MS,
|
|
45
|
+
SIGNAL_NAMES,
|
|
46
|
+
setFrontmatterKey,
|
|
47
|
+
totalOf,
|
|
48
|
+
upsertIntentSection,
|
|
49
|
+
} from './intent-match.mjs';
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_TIMEOUT_S = 120;
|
|
52
|
+
|
|
53
|
+
/** The reader families, in preference order (review-route's order). NEVER Claude (D4). */
|
|
54
|
+
export const FAMILIES = Object.freeze([
|
|
55
|
+
{
|
|
56
|
+
id: 'codex',
|
|
57
|
+
bin: 'codex',
|
|
58
|
+
argv: (prompt) => ({ args: codexExecArgs(prompt.head), input: prompt.body, limit: null }),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'agy',
|
|
62
|
+
bin: 'agy',
|
|
63
|
+
argv: (prompt) => ({ args: agyArgs(prompt.full), input: '', limit: AGY_ARG_LIMIT }),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'vibe',
|
|
67
|
+
bin: 'vibe',
|
|
68
|
+
argv: (prompt) => ({ args: vibeArgs(prompt.full), input: '', limit: VIBE_ARG_LIMIT }),
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/** The fixed prompt (D16): the reader gets the pitch file and these three headings, nothing else. */
|
|
73
|
+
export const READER_PROMPT = `You are reading a product pitch you did not write, before anyone builds it. Do not open, run or change any file: the pitch is given to you below. Answer in exactly three sections, with exactly these headings:
|
|
74
|
+
|
|
75
|
+
## Will build
|
|
76
|
+
- each thing you would build from this pitch, one bullet each
|
|
77
|
+
|
|
78
|
+
## Won't build
|
|
79
|
+
- each thing this pitch leaves out on purpose, or that you would not build from it
|
|
80
|
+
|
|
81
|
+
## First question
|
|
82
|
+
- the one question you would ask the person who wrote it before you started
|
|
83
|
+
|
|
84
|
+
Keep it short. No preamble, no other sections.`;
|
|
85
|
+
|
|
86
|
+
const HEADINGS = [
|
|
87
|
+
/^#+\s*will build\b/im,
|
|
88
|
+
/^#+\s*won['’]?t build\b|^#+\s*will not build\b/im,
|
|
89
|
+
/^#+\s*first question\b/im,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/** Does a reply carry the three headings the prompt asked for? Pure. */
|
|
93
|
+
export const isStructured = (text) => HEADINGS.every((re) => re.test(String(text ?? '')));
|
|
94
|
+
|
|
95
|
+
/** The one line every skip prints. Pure. */
|
|
96
|
+
export const skipLine = (why) => `reader skipped: ${why}`;
|
|
97
|
+
|
|
98
|
+
const lastLine = (t) =>
|
|
99
|
+
String(t ?? '')
|
|
100
|
+
.trim()
|
|
101
|
+
.split('\n')
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.pop() ?? '';
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Ask ONE family, once. Returns { ok: true, text } or { ok: false, why }. Never throws. The spawn result is read the
|
|
107
|
+
* way spawnSync reports a deadline: `error.code === 'ETIMEDOUT'` (with `timeout` set, the child is killed).
|
|
108
|
+
*/
|
|
109
|
+
export function askReader(family, pitch, { spawn = spawnSync, timeoutMs }) {
|
|
110
|
+
const prompt = { head: READER_PROMPT, body: pitch, full: `${READER_PROMPT}\n\n---\n\n${pitch}\n` };
|
|
111
|
+
const { args, input, limit } = family.argv(prompt);
|
|
112
|
+
if (limit && Buffer.byteLength(prompt.full, 'utf8') > limit)
|
|
113
|
+
return { ok: false, why: `${family.id}: the pitch is over its ${limit / 1024} KB argument limit` };
|
|
114
|
+
let r;
|
|
115
|
+
try {
|
|
116
|
+
r = spawn(family.bin, args, {
|
|
117
|
+
input,
|
|
118
|
+
encoding: 'utf8',
|
|
119
|
+
timeout: timeoutMs,
|
|
120
|
+
killSignal: 'SIGKILL',
|
|
121
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
122
|
+
});
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return { ok: false, why: `${family.id}: could not start (${e?.message || e})` };
|
|
125
|
+
}
|
|
126
|
+
if (r?.error?.code === 'ETIMEDOUT')
|
|
127
|
+
return { ok: false, why: `${family.id}: timed out after ${timeoutMs / 1000}s` };
|
|
128
|
+
if (r?.error?.code === 'ENOBUFS') return { ok: false, why: `${family.id}: the reply was over 16 MB` };
|
|
129
|
+
if (r?.error) return { ok: false, why: `${family.id}: could not start (${r.error.message})` };
|
|
130
|
+
if (r?.status !== 0)
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
why: `${family.id}: exited ${r?.status ?? r?.signal ?? '?'}${lastLine(r?.stderr) ? ` — ${lastLine(r.stderr).slice(0, 160)}` : ''}`,
|
|
134
|
+
};
|
|
135
|
+
const text = String(r.stdout ?? '').trim();
|
|
136
|
+
if (!text) return { ok: false, why: `${family.id}: empty reply` };
|
|
137
|
+
if (!isStructured(text))
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
why: `${family.id}: unstructured reply (no Will build / Won't build / First question)`,
|
|
141
|
+
};
|
|
142
|
+
return { ok: true, text };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The first family whose binary is installed, or null. Missing is the ONLY reason to move to the next one. */
|
|
146
|
+
export function pickFamily(has = hasCmd) {
|
|
147
|
+
return FAMILIES.find((f) => has(f.bin)) ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const f2 = (n) => n.toFixed(2);
|
|
151
|
+
|
|
152
|
+
/** The README's `## Intent match` section, with agreement. Pure. */
|
|
153
|
+
export function readerSection({ family, agreement, seedComponents, seedTotal, date, reply }) {
|
|
154
|
+
const withAgreement = totalOf({ ...(seedComponents ?? {}), agreement });
|
|
155
|
+
const seedLine = seedComponents
|
|
156
|
+
? `- Seed score: **${seedTotal ?? '—'}** (${Object.entries(seedComponents)
|
|
157
|
+
.map(([k, v]) => `${SIGNAL_NAMES[k]} ${f2(v)}`)
|
|
158
|
+
.join(' · ')})`
|
|
159
|
+
: '- Seed score: none recorded (run `node scripts/intent-match.mjs <seed> --write` at groom Stage 3.5)';
|
|
160
|
+
const lines = [
|
|
161
|
+
'## Intent match',
|
|
162
|
+
'',
|
|
163
|
+
'_Advisory and uncalibrated (intent-match D1, D3). Agreement written by `intent-reader.mjs` at the architecture lock._',
|
|
164
|
+
'',
|
|
165
|
+
seedLine,
|
|
166
|
+
`- Agreement (reader: ${family}, ${date}): **${f2(agreement)}** — P(the reader, given only the pitch, would build what this plan builds)`,
|
|
167
|
+
];
|
|
168
|
+
if (seedComponents) lines.push(`- Total with agreement: **${withAgreement.total} / 100** — uncalibrated`);
|
|
169
|
+
const clipped = reply.length > 4000 ? `${reply.slice(0, 4000)}\n…[truncated]` : reply;
|
|
170
|
+
// Indented, never fenced: a reply is model output, and a fence it carries (``` or ~~~) would close ours and leave its
|
|
171
|
+
// own `## ` headings outside the section, where the next run cannot find them (fresh review of #197).
|
|
172
|
+
lines.push(
|
|
173
|
+
'',
|
|
174
|
+
"<details><summary>The reader's reply</summary>",
|
|
175
|
+
'',
|
|
176
|
+
...clipped.split('\n').map((l) => ` ${l}`),
|
|
177
|
+
'',
|
|
178
|
+
'</details>',
|
|
179
|
+
''
|
|
180
|
+
);
|
|
181
|
+
lines.push(
|
|
182
|
+
`<!-- intent-match: ${JSON.stringify({ ...(seedComponents ?? {}), agreement: Math.round(agreement * 1000) / 1000, total: seedComponents ? withAgreement.total : null })} -->`,
|
|
183
|
+
''
|
|
184
|
+
);
|
|
185
|
+
return { text: lines.join('\n'), total: seedComponents ? withAgreement.total : null };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Roadmap/<macro>/<slug>/README.md and the seed, or { error }. */
|
|
189
|
+
export function locateEpic(
|
|
190
|
+
root,
|
|
191
|
+
slug,
|
|
192
|
+
{ exists = existsSync, list = (d) => readdirSync(d, { withFileTypes: true }) } = {}
|
|
193
|
+
) {
|
|
194
|
+
const roadmap = join(root, 'Roadmap');
|
|
195
|
+
if (!exists(roadmap)) return { error: `no Roadmap/ under ${root}` };
|
|
196
|
+
const hits = list(roadmap)
|
|
197
|
+
.filter((d) => d.isDirectory() && exists(join(roadmap, d.name, slug, 'README.md')))
|
|
198
|
+
.map((d) => join(roadmap, d.name, slug, 'README.md'));
|
|
199
|
+
if (hits.length !== 1)
|
|
200
|
+
return {
|
|
201
|
+
error: hits.length ? `"${slug}" is under more than one macro` : `no epic "${slug}" under Roadmap/*/`,
|
|
202
|
+
};
|
|
203
|
+
const seed = join(roadmap, '00-ideas', 'seeds', `${slug}.md`);
|
|
204
|
+
return { readme: hits[0], seed: exists(seed) ? seed : null };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function parseArgs(argv) {
|
|
208
|
+
const out = { epic: null, timeout: DEFAULT_TIMEOUT_S, help: false, bad: null };
|
|
209
|
+
for (let i = 0; i < argv.length; i++) {
|
|
210
|
+
const a = argv[i];
|
|
211
|
+
if (a === '--help' || a === '-h') out.help = true;
|
|
212
|
+
else if (a === '--epic') out.epic = argv[++i] ?? null;
|
|
213
|
+
else if (a === '--timeout') {
|
|
214
|
+
const n = Number(argv[++i]);
|
|
215
|
+
if (!Number.isFinite(n) || n <= 0) out.bad = '--timeout needs a positive number of seconds';
|
|
216
|
+
else out.timeout = n;
|
|
217
|
+
} else out.bad = `unknown argument ${a}`;
|
|
218
|
+
}
|
|
219
|
+
if (!out.help && !out.bad && !out.epic) out.bad = '--epic <slug> is required';
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The run. Every side effect injected; returns the exit code (0, or 1 for usage). io = { root, setting, config, key,
|
|
225
|
+
* has, spawn, ask, read, write, out, today }.
|
|
226
|
+
*/
|
|
227
|
+
export async function run(argv, io) {
|
|
228
|
+
const args = parseArgs(argv);
|
|
229
|
+
if (args.help || args.bad) {
|
|
230
|
+
if (args.bad) io.out(`intent-reader: ${args.bad}`);
|
|
231
|
+
io.out('usage: node scripts/intent-reader.mjs --epic <slug> [--timeout <seconds>]');
|
|
232
|
+
return args.help ? 0 : 1;
|
|
233
|
+
}
|
|
234
|
+
// OFF first, before anything is read or spawned: the default costs nothing and waits for nothing (D4).
|
|
235
|
+
const setting = io.setting();
|
|
236
|
+
if (setting !== 'on') {
|
|
237
|
+
io.out(
|
|
238
|
+
`intent reader: off (intent.reader: ${JSON.stringify(setting)}) — no reader asked. Turn it on with \`gf-kit config set intent.reader on\`.`
|
|
239
|
+
);
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
const skip = (why) => {
|
|
243
|
+
io.out(skipLine(why));
|
|
244
|
+
return 0;
|
|
245
|
+
};
|
|
246
|
+
const where = locateEpic(io.root, args.epic);
|
|
247
|
+
if (where.error) return skip(where.error);
|
|
248
|
+
if (!where.seed)
|
|
249
|
+
return skip(
|
|
250
|
+
`no seed at Roadmap/00-ideas/seeds/${args.epic}.md — the reader reads the pitch, and there is none`
|
|
251
|
+
);
|
|
252
|
+
// Agreement is a Jev answer. Check Jev can be asked BEFORE spending up to two minutes on a reader.
|
|
253
|
+
const config = io.config();
|
|
254
|
+
if (config.egress !== true)
|
|
255
|
+
return skip(`Jev cannot score agreement (jev.egress is ${JSON.stringify(config.egress)}, not true)`);
|
|
256
|
+
const key = io.key();
|
|
257
|
+
if (!key) return skip('Jev cannot score agreement (no TYPESAFE_API_KEY)');
|
|
258
|
+
const family = pickFamily(io.has);
|
|
259
|
+
if (!family) return skip('no reader CLI on PATH (codex, agy or vibe)');
|
|
260
|
+
const pitch = io.read(where.seed);
|
|
261
|
+
const plan = io.read(where.readme);
|
|
262
|
+
const reply = askReader(family, pitch, { spawn: io.spawn, timeoutMs: args.timeout * 1000 });
|
|
263
|
+
if (!reply.ok) return skip(reply.why);
|
|
264
|
+
// The reply is about to leave this process twice: to Jev, then into a README that may be public. A reader can READ
|
|
265
|
+
// host files (codex's sandbox allows reads; agy has no tool restriction), so a secret-shaped reply is skipped before
|
|
266
|
+
// either, as cross-review does (lib/secret-guard.mjs). Names only: a value is never printed.
|
|
267
|
+
const leaks = io.secrets(reply.text).leaks;
|
|
268
|
+
if (leaks.length)
|
|
269
|
+
return skip(
|
|
270
|
+
`${family.id}: the reply carried secret-shaped text (${leaks.map((l) => l.name).join(', ')}); nothing sent, nothing written`
|
|
271
|
+
);
|
|
272
|
+
const state = { plan, reading: reply.text };
|
|
273
|
+
if (stateSize(state) > STATE_CHAR_BUDGET)
|
|
274
|
+
return skip(`the plan and the reading are over Jev's state budget (${stateSize(state)} chars)`);
|
|
275
|
+
const res = await io.ask(
|
|
276
|
+
{ state, questions: { agreement: INTENT_QUESTIONS.agreement } },
|
|
277
|
+
{ key, model: config.model }
|
|
278
|
+
);
|
|
279
|
+
const a = res?.ok ? res.answers?.agreement : undefined;
|
|
280
|
+
if (a?.type !== 'noul' || typeof a.noul !== 'number' || !(a.noul >= 0 && a.noul <= 1))
|
|
281
|
+
return skip(
|
|
282
|
+
`Jev could not look (${res?.ok ? 'malformed agreement answer' : (res?.error ?? 'no answer')})`
|
|
283
|
+
);
|
|
284
|
+
const seedText = pitch;
|
|
285
|
+
const seedComponents = componentsFrom(seedText);
|
|
286
|
+
const seedTotal = seedComponents ? totalOf(seedComponents)?.total : null;
|
|
287
|
+
const section = readerSection({
|
|
288
|
+
family: family.id,
|
|
289
|
+
agreement: a.noul,
|
|
290
|
+
seedComponents,
|
|
291
|
+
seedTotal,
|
|
292
|
+
date: io.today,
|
|
293
|
+
reply: reply.text,
|
|
294
|
+
});
|
|
295
|
+
let next = upsertIntentSection(plan, section.text);
|
|
296
|
+
if (section.total != null) next = setFrontmatterKey(next, 'intent_match', section.total);
|
|
297
|
+
io.write(where.readme, next);
|
|
298
|
+
io.out(
|
|
299
|
+
`intent reader: ${family.id} read the pitch — agreement ${f2(a.noul)}${section.total != null ? `; total ${section.total} (seed ${seedTotal})` : ''}, written to ${relative(io.root, where.readme)}`
|
|
300
|
+
);
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function main() {
|
|
305
|
+
const root = repoRoot();
|
|
306
|
+
const code = await run(process.argv.slice(2), {
|
|
307
|
+
root,
|
|
308
|
+
setting: () => getKey('intent.reader', { root }),
|
|
309
|
+
config: () => loadJevConfig({ root }),
|
|
310
|
+
key: () => readApiKey({ root }),
|
|
311
|
+
has: hasCmd,
|
|
312
|
+
secrets: (text) => findSecretLeaks(text, { values: collectSecretValues({ root }) }),
|
|
313
|
+
spawn: spawnSync,
|
|
314
|
+
ask: (req, { key, model }) => askJev(req, { key, model, timeoutMs: INTENT_TIMEOUT_MS }),
|
|
315
|
+
read: (p) => readFileSync(p, 'utf8'),
|
|
316
|
+
write: (p, t) => writeFileSync(p, t),
|
|
317
|
+
out: (l) => process.stdout.write(`${l}\n`),
|
|
318
|
+
today: new Date().toISOString().slice(0, 10),
|
|
319
|
+
});
|
|
320
|
+
process.exitCode = code;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
324
|
+
if (isMain)
|
|
325
|
+
main().catch((e) => {
|
|
326
|
+
// Even an unexpected crash is one skip line and exit 0: the reader never stops a lock (D4). A malformed config
|
|
327
|
+
// is the one thing a person must see, and it is still only one line.
|
|
328
|
+
process.stdout.write(`${skipLine(`internal error (${e?.message || e})`)}\n`);
|
|
329
|
+
process.exitCode = 0;
|
|
330
|
+
});
|
|
@@ -142,6 +142,16 @@ export const REGISTRY = Object.freeze([
|
|
|
142
142
|
default: 'every-risk-high-story-names-its-flag',
|
|
143
143
|
question: 'Should every risk:high story name its kill-switch flag?',
|
|
144
144
|
},
|
|
145
|
+
{
|
|
146
|
+
// intent-match D4/D16: an optional second-family read of the pitch at the architecture lock. OFF unless a person
|
|
147
|
+
// turns it on — never asked (`never-yet`), because a reader that runs by default is the stall D4 rules out.
|
|
148
|
+
key: 'intent.reader',
|
|
149
|
+
module: 'Plan',
|
|
150
|
+
askWhen: 'never-yet',
|
|
151
|
+
default: 'off',
|
|
152
|
+
choices: ['off', 'on'],
|
|
153
|
+
question: 'At the architecture lock, ask one other model family (codex, agy or vibe) to read the pitch and score whether it would build the same thing? Off by default; any failure is skipped.',
|
|
154
|
+
},
|
|
145
155
|
{
|
|
146
156
|
key: 'spend.telemetry',
|
|
147
157
|
module: 'Spend',
|
package/dist/lib/config.mjs
CHANGED
|
@@ -875,11 +875,17 @@ export function runWithCodexFallback({ prompt, stdin, antigravityArgv, builder =
|
|
|
875
875
|
}
|
|
876
876
|
}
|
|
877
877
|
|
|
878
|
+
// The argv for one agy print-mode call. Exported so a caller that needs its OWN spawn (a hard timeout, one model, no
|
|
879
|
+
// fallback line — the intent reader, intent-match C4) builds the identical invocation instead of a second copy of it.
|
|
880
|
+
export function agyArgs(fullArgv, model = AGY_MODEL) {
|
|
881
|
+
return ['-p', fullArgv, '--model', model];
|
|
882
|
+
}
|
|
883
|
+
|
|
878
884
|
// One `agy -p "<prompt>" --model "<MODEL>"` invocation. The prompt+framed context ride in `fullArgv` (stdin is
|
|
879
885
|
// NOT the prompt and must be at EOF — input:'' gives an immediate EOF or print mode blocks forever). Returns
|
|
880
886
|
// the raw spawn result; the caller classifies status/stdout.
|
|
881
887
|
function execAgy(fullArgv, model, spawn) {
|
|
882
|
-
return spawn('agy',
|
|
888
|
+
return spawn('agy', agyArgs(fullArgv, model), {
|
|
883
889
|
input: '',
|
|
884
890
|
encoding: 'utf8',
|
|
885
891
|
maxBuffer: 64 * 1024 * 1024,
|
|
@@ -1252,16 +1258,8 @@ export const CLAUDE_REVIEW_MODEL = process.env.CLAUDE_REVIEW_MODEL || 'sonnet';
|
|
|
1252
1258
|
// Empty stdout is treated as a FAILURE, not as "no findings". Every CLI on this roster can exit 0 having
|
|
1253
1259
|
// produced nothing when it is quota-capped or misconfigured, and a review that silently becomes empty is
|
|
1254
1260
|
// worse than one that errors — it reads as a clean pass. `deps.spawn` is injectable for tests.
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
if (Buffer.byteLength(fullArgv, 'utf8') > VIBE_ARG_LIMIT) {
|
|
1258
|
-
return fail(
|
|
1259
|
-
opts.soft,
|
|
1260
|
-
`input too large for vibe (${Math.round(Buffer.byteLength(fullArgv) / 1024)} KB > ` +
|
|
1261
|
-
`${VIBE_ARG_LIMIT / 1024} KB; vibe takes the prompt in argv, not stdin) — use --agent codex instead.`
|
|
1262
|
-
);
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1261
|
+
// The argv for one vibe call, read-only (every tool disabled). Exported for the same reason as agyArgs (C4).
|
|
1262
|
+
export function vibeArgs(fullArgv) {
|
|
1265
1263
|
const args = [
|
|
1266
1264
|
'--prompt',
|
|
1267
1265
|
fullArgv,
|
|
@@ -1276,8 +1274,20 @@ export function runVibe(fullArgv, opts = {}, deps = {}) {
|
|
|
1276
1274
|
'*',
|
|
1277
1275
|
];
|
|
1278
1276
|
if (VIBE_MODEL) args.push('--model', VIBE_MODEL);
|
|
1277
|
+
return args;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
export function runVibe(fullArgv, opts = {}, deps = {}) {
|
|
1281
|
+
const { spawn = spawnSync } = deps;
|
|
1282
|
+
if (Buffer.byteLength(fullArgv, 'utf8') > VIBE_ARG_LIMIT) {
|
|
1283
|
+
return fail(
|
|
1284
|
+
opts.soft,
|
|
1285
|
+
`input too large for vibe (${Math.round(Buffer.byteLength(fullArgv) / 1024)} KB > ` +
|
|
1286
|
+
`${VIBE_ARG_LIMIT / 1024} KB; vibe takes the prompt in argv, not stdin) — use --agent codex instead.`
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1279
1289
|
|
|
1280
|
-
const r = spawn('vibe',
|
|
1290
|
+
const r = spawn('vibe', vibeArgs(fullArgv), { input: '', encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
1281
1291
|
if (r.error)
|
|
1282
1292
|
return fail(
|
|
1283
1293
|
opts.soft,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@golden-frijoles/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "The scripts the Golden Frijoles skills run, packaged so they work in any repo: planning, build-order, reporting and verification rails. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|