Skip to content

Commit 8b62071

Browse files
authored
feat(tooling): pure issue-drafting core + CLI to expand a loose prompt into a gate-ready draft (#8103) (#8148)
The gate only enforces what an issue explicitly says, so publishable issues need exact file/function/pattern citations -- repeated, real maintainer work today. The new pure core (src/services/issue-drafting.ts) extracts tiered grounding terms from a loose prompt (backticked > path > identifier > word), searches a caller-supplied corpus for real precedent (definition lines first, live code above tests, word-tier restricted to path-named files so vocabulary never grounds to random comment prose), and assembles the heavy-template draft with per-anchor-file Requirements bullets, explicit UNGROUNDED markers wherever no precedent exists, and MAINTAINER markers for every section a human must fill -- never inventing an unverified requirement. Adapter-agnostic per the issue's required shape: no argv/fs/IO in the core (the corpus is data, so a future ORB dashboard API route -- a Worker with no filesystem -- can be a second thin consumer unchanged). scripts/draft-issue.ts is the first thin consumer: walks the checkout, calls the core, writes a local file for the maintainer to edit and publish BY HAND. Never publishes; labels/ milestone/relationships stay maintainer decisions (#8103's hard boundary). 100% line+branch coverage on the core (24 tests), including determinism and the body-text-only boundary.
1 parent fec8f12 commit 8b62071

3 files changed

Lines changed: 590 additions & 0 deletions

File tree

scripts/draft-issue.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env node
2+
// Issue-drafting CLI (#8103, epic #8082) — the FIRST thin consumer of the pure issue-drafting core
3+
// (src/services/issue-drafting.ts): reads the loose prompt, walks the checkout to build the searchable
4+
// corpus, calls the core, and writes the drafted body to a local file for the maintainer to read, edit,
5+
// and only then publish by hand. All grounding/drafting logic lives in the core (unit-tested there); this
6+
// file is the thin IO wrapper — mirrors scripts/export-d1-data.ts's identical role next to
7+
// export-d1-core.ts. NEVER publishes anything, NEVER touches labels/milestones — see the core's own
8+
// boundary comment.
9+
//
10+
// tsx scripts/draft-issue.ts --prompt "<loose intent>" --output <draft.md> [--root .]
11+
// tsx scripts/draft-issue.ts --prompt-file <intent.txt> --output <draft.md> [--root .]
12+
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
13+
import { join, relative } from "node:path";
14+
import { draftIssueBody, type CorpusFile } from "../src/services/issue-drafting.js";
15+
16+
type Args = {
17+
prompt: string | undefined;
18+
promptFile: string | undefined;
19+
output: string | undefined;
20+
root: string;
21+
};
22+
23+
// The corpus mirrors where real precedent lives (the gate's own wantedPaths, minus content-free dirs).
24+
const CORPUS_DIRS = ["src", "packages", "scripts", "test", "migrations", ".github/workflows"];
25+
const CORPUS_EXTENSIONS = [".ts", ".tsx", ".sql", ".yml", ".yaml", ".jsonc", ".md"];
26+
const SKIP_DIR_NAMES = new Set(["node_modules", "dist", "coverage", ".turbo"]);
27+
const MAX_FILE_BYTES = 512 * 1024;
28+
29+
function parseArgs(argv: string[]): Args {
30+
const args: Args = { prompt: undefined, promptFile: undefined, output: undefined, root: "." };
31+
for (let i = 0; i < argv.length; i += 1) {
32+
const flag = argv[i];
33+
if (flag === "--prompt") args.prompt = argv[++i];
34+
else if (flag === "--prompt-file") args.promptFile = argv[++i];
35+
else if (flag === "--output") args.output = argv[++i];
36+
else if (flag === "--root") args.root = argv[++i]!;
37+
}
38+
return args;
39+
}
40+
41+
function collectCorpus(root: string): CorpusFile[] {
42+
const corpus: CorpusFile[] = [];
43+
const walk = (dir: string) => {
44+
let entries: string[];
45+
try {
46+
entries = readdirSync(dir);
47+
} catch {
48+
return; // a listed corpus dir may not exist in a partial checkout -- skip, never crash the draft
49+
}
50+
for (const entry of entries.sort()) {
51+
if (SKIP_DIR_NAMES.has(entry)) continue;
52+
const fullPath = join(dir, entry);
53+
const stats = statSync(fullPath);
54+
if (stats.isDirectory()) walk(fullPath);
55+
else if (CORPUS_EXTENSIONS.some((extension) => entry.endsWith(extension)) && stats.size <= MAX_FILE_BYTES) {
56+
corpus.push({ path: relative(root, fullPath), content: readFileSync(fullPath, "utf8") });
57+
}
58+
}
59+
};
60+
for (const dir of CORPUS_DIRS) walk(join(root, dir));
61+
return corpus;
62+
}
63+
64+
function main() {
65+
const args = parseArgs(process.argv.slice(2));
66+
const prompt = args.prompt ?? (args.promptFile ? readFileSync(args.promptFile, "utf8") : undefined);
67+
if (!prompt || !args.output) {
68+
console.error("Usage: tsx scripts/draft-issue.ts (--prompt <text> | --prompt-file <file>) --output <draft.md> [--root .]");
69+
process.exit(2);
70+
}
71+
72+
const corpus = collectCorpus(args.root);
73+
const result = draftIssueBody(prompt, corpus);
74+
writeFileSync(args.output, result.body);
75+
console.error(
76+
`drafted from ${corpus.length} corpus file(s): ${result.groundedTerms.length} term(s) grounded, ` +
77+
`${result.ungroundedTerms.length} UNGROUNDED marker(s) to resolve by hand → ${args.output}`,
78+
);
79+
console.error("review + edit before publishing — this tool never publishes, and labels/milestone stay your call.");
80+
}
81+
82+
main();

src/services/issue-drafting.ts

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
// Issue-drafting core (#8103, epic #8082) — expands a loose maintainer prompt into a gate-ready draft
2+
// issue body in this repo's heavy template, grounded in REAL precedent from the current checkout. The gate
3+
// only enforces what an issue explicitly says (see .claude/skills/contributor-pipeline-gardening/
4+
// reference.md, "The gate only enforces what the issue explicitly says"), so a publishable issue must cite
5+
// exact files/functions/patterns — this module automates that grounding pass and assembles the draft, and
6+
// it says so EXPLICITLY wherever it could not ground part of the prompt, never inventing a plausible but
7+
// unverified requirement.
8+
//
9+
// PURE, ADAPTER-AGNOSTIC (the issue's own ⚠️ required shape): no process.argv, no fs, no IO of any kind.
10+
// The caller supplies the searchable corpus (the CLI wrapper scripts/draft-issue.ts reads the checkout; a
11+
// future ORB dashboard API route can supply the same shape from whatever storage it has — a Worker has no
12+
// filesystem, which is exactly why the search input is data, not a path) and gets back a structured
13+
// result. Mirrors the pure-core/host-adapter split of packages/loopover-engine/src/calibration/
14+
// signal-tracking.ts + src/review/signal-tracking-wire.ts.
15+
//
16+
// Hard boundaries (#8103): drafts BODY TEXT only. Never publishes, never picks labels/milestone/
17+
// contributor-vs-maintainer-only status, never decides relationships — those stay maintainer decisions on
18+
// every issue, no exceptions.
19+
20+
/** One searchable file of the checkout, supplied by the caller — `path` is repo-relative. */
21+
export type CorpusFile = { path: string; content: string };
22+
23+
/** How specific an extracted term is. Backticked/path/identifier terms name something exact, so failing to
24+
* ground one is a real spec gap the draft must flag; a plain word failing to ground is just vocabulary and
25+
* is silently dropped rather than manufactured into a scary-but-empty ⚠️ warning. */
26+
export type GroundingTermTier = "exact" | "path" | "identifier" | "word";
27+
28+
export type GroundingTerm = { term: string; tier: GroundingTermTier };
29+
30+
/** One place a grounding term was actually found in the supplied corpus. `line` is 1-based. */
31+
export type GroundingMatch = { path: string; line: number; text: string };
32+
33+
/** A term from the loose prompt that WAS grounded in real precedent. */
34+
export type GroundedTerm = GroundingTerm & { matches: readonly GroundingMatch[] };
35+
36+
export type IssueDraftOptions = {
37+
/** Cap on distinct terms extracted from the prompt (default 12). */
38+
maxTerms?: number;
39+
/** Cap on matches kept per grounded term (default 3). */
40+
maxMatchesPerTerm?: number;
41+
};
42+
43+
export type IssueDraftResult = {
44+
/** The drafted issue body (heavy template) for the maintainer to read, edit, and only then publish. */
45+
body: string;
46+
groundedTerms: readonly GroundedTerm[];
47+
/** Specific terms (exact/path/identifier tier) with NO precedent in the searched corpus — surfaced
48+
* verbatim in the body as ⚠️ UNGROUNDED so the human decision point is visible, never papered over. */
49+
ungroundedTerms: readonly GroundingTerm[];
50+
};
51+
52+
const DEFAULT_MAX_TERMS = 12;
53+
const DEFAULT_MAX_MATCHES_PER_TERM = 3;
54+
const MAX_MATCH_TEXT_CHARS = 160;
55+
56+
// Words too generic to ground anything by themselves — searching these would match half the repo and
57+
// produce citation noise, the opposite of the explicit-precedent discipline this tool exists to serve.
58+
const STOPWORDS = new Set([
59+
"the", "and", "for", "with", "that", "this", "from", "into", "when", "then", "them", "they",
60+
"should", "would", "could", "must", "have", "has", "had", "are", "was", "were", "been",
61+
"add", "adds", "added", "new", "make", "makes", "made", "use", "uses", "used", "using",
62+
"file", "files", "code", "test", "tests", "issue", "issues", "also", "only", "over", "under",
63+
"each", "every", "all", "any", "some", "not", "never", "always", "existing", "current", "real",
64+
"same", "way", "more", "less", "one", "two", "like", "its", "our", "your", "their", "than",
65+
]);
66+
67+
/**
68+
* Extract the candidate grounding terms from a loose prompt, most-specific first:
69+
* 1. `exact` — backtick-quoted fragments, kept verbatim (the maintainer already named something);
70+
* 2. `path` — path-shaped tokens (contain `/` or end in a source-file extension);
71+
* 3. `identifier` — camelCase / snake_case / dotted words (the shapes real symbols take);
72+
* 4. `word` — remaining plain words ≥ 4 chars that aren't stopwords.
73+
* Each tier's matches are consumed from the text before the next tier scans, so a fragment never
74+
* double-extracts (e.g. `record.ts` out of `backtest-track-record.ts`). Deduplicated case-insensitively
75+
* in tier order, capped at `maxTerms`.
76+
*/
77+
export function extractGroundingTerms(prompt: string, maxTerms: number = DEFAULT_MAX_TERMS): GroundingTerm[] {
78+
const seen = new Set<string>();
79+
const terms: GroundingTerm[] = [];
80+
const push = (term: string, tier: GroundingTermTier) => {
81+
const key = term.toLowerCase();
82+
if (term.length < 3 || seen.has(key) || STOPWORDS.has(key)) return;
83+
seen.add(key);
84+
terms.push({ term, tier });
85+
};
86+
87+
for (const [, quoted] of prompt.matchAll(/`([^`]+)`/g)) push(quoted!.trim(), "exact");
88+
let rest = prompt.replace(/`[^`]*`/g, " ");
89+
90+
const pathPattern = /[A-Za-z0-9_.-]*\/[A-Za-z0-9_./-]+|[A-Za-z0-9_-]+\.(?:tsx?|sql|ya?ml|jsonc?|md)\b/g;
91+
for (const [token] of rest.matchAll(pathPattern)) push(token, "path");
92+
rest = rest.replace(pathPattern, " ");
93+
94+
const identifierPattern = /\b(?:[a-z0-9]+(?:[A-Z][a-z0-9]*)+|[A-Za-z0-9]+(?:[_.][A-Za-z0-9]+)+)\b/g;
95+
for (const [token] of rest.matchAll(identifierPattern)) push(token, "identifier");
96+
rest = rest.replace(identifierPattern, " ");
97+
98+
for (const [token] of rest.matchAll(/\b[A-Za-z]{4,}\b/g)) push(token, "word");
99+
100+
return terms.slice(0, Math.max(0, maxTerms));
101+
}
102+
103+
/** Rank source paths the way a precedent citation should read: live code first, then shared packages,
104+
* then scripts, then any test file (wherever it lives), everything else (workflows, config) last. */
105+
function pathRank(path: string): number {
106+
if (path.includes("/test/") || path.includes(".test.")) return 3;
107+
if (path.startsWith("src/")) return 0;
108+
if (path.startsWith("packages/")) return 1;
109+
if (path.startsWith("scripts/")) return 2;
110+
return 4;
111+
}
112+
113+
/** Definition lines make better citations than usages or comments — a contributor mirroring precedent
114+
* needs the declaration, not a random mention. */
115+
function lineRank(text: string): number {
116+
return /^(?:export\s|function\s|class\s|const\s|type\s)/.test(text) ? 0 : 1;
117+
}
118+
119+
/**
120+
* Search the supplied corpus for one term (case-insensitive substring). Returns the term's grounded
121+
* matches — definition lines in best-ranked paths first, then by path/line for byte-stable deterministic
122+
* output — capped at `maxMatchesPerTerm`, or null when the corpus has no trace of the term at all.
123+
* A `word`-tier term only searches files whose PATH contains it: a plain word matching arbitrary comment
124+
* prose across the repo is citation noise, but a word that names a file ("backtest", "track") is a real
125+
* anchor — this is what keeps loose vocabulary from grounding to random unrelated lines.
126+
*/
127+
export function groundTerm(
128+
groundingTerm: GroundingTerm,
129+
corpus: readonly CorpusFile[],
130+
maxMatchesPerTerm: number = DEFAULT_MAX_MATCHES_PER_TERM,
131+
): GroundedTerm | null {
132+
const needle = groundingTerm.term.toLowerCase();
133+
const searchable = groundingTerm.tier === "word" ? corpus.filter((file) => file.path.toLowerCase().includes(needle)) : corpus;
134+
const matches: GroundingMatch[] = [];
135+
for (const file of searchable) {
136+
const lines = file.content.split("\n");
137+
for (let i = 0; i < lines.length; i += 1) {
138+
if (!lines[i]!.toLowerCase().includes(needle)) continue;
139+
matches.push({ path: file.path, line: i + 1, text: lines[i]!.trim().slice(0, MAX_MATCH_TEXT_CHARS) });
140+
}
141+
}
142+
if (matches.length === 0) return null;
143+
matches.sort(
144+
(a, b) => pathRank(a.path) - pathRank(b.path) || lineRank(a.text) - lineRank(b.text) || a.path.localeCompare(b.path) || a.line - b.line,
145+
);
146+
return { ...groundingTerm, matches: matches.slice(0, Math.max(1, maxMatchesPerTerm)) };
147+
}
148+
149+
/** True when a cited path is graded by Codecov's patch gate (coverage.include: `src/**` and the engine's
150+
* `src/**` — mirrors codecov.yml's ignore list + vitest.config.ts's include, kept in sync by hand). */
151+
function pathIsCoverageGraded(path: string): boolean {
152+
if (path === "src/env.d.ts") return false;
153+
return path.startsWith("src/") || path.startsWith("packages/loopover-engine/src/");
154+
}
155+
156+
/**
157+
* Draft a gate-ready issue body from a loose prompt + a searchable corpus. The output is a STARTING DRAFT
158+
* in the heavy template (Context / Requirements / Deliverables / Test Coverage Requirements / Expected
159+
* Outcome / Links & Resources) with grounded precedent cited as `path:line`, Requirements grouped one
160+
* bullet per anchor file (never one per raw term — near-duplicate terms grounding to the same file must
161+
* not read as separate requirements), and every ungroundable SPECIFIC term flagged ⚠️ UNGROUNDED at the
162+
* exact spot a human decision is still needed. Sections a human must still fill are explicit
163+
* `<!-- MAINTAINER: ... -->` markers, so nothing half-drafted can read as finished. Throws on a blank
164+
* prompt — there is nothing to ground. Pure and deterministic: same prompt + corpus ⇒ same draft.
165+
*/
166+
export function draftIssueBody(prompt: string, corpus: readonly CorpusFile[], options: IssueDraftOptions = {}): IssueDraftResult {
167+
const trimmedPrompt = prompt.trim();
168+
if (!trimmedPrompt) throw new Error("cannot draft from an empty prompt");
169+
170+
const terms = extractGroundingTerms(trimmedPrompt, options.maxTerms ?? DEFAULT_MAX_TERMS);
171+
const groundedTerms: GroundedTerm[] = [];
172+
const ungroundedTerms: GroundingTerm[] = [];
173+
for (const term of terms) {
174+
const grounded = groundTerm(term, corpus, options.maxMatchesPerTerm ?? DEFAULT_MAX_MATCHES_PER_TERM);
175+
if (grounded) groundedTerms.push(grounded);
176+
// A plain word failing to ground is vocabulary, not a spec gap — only specific tiers get flagged.
177+
else if (term.tier !== "word") ungroundedTerms.push(term);
178+
}
179+
180+
// Dedupe citations by path:line (several terms often ground on the same line), and group the
181+
// Requirements by each grounded term's TOP path so one anchor file yields one bullet.
182+
const citations = new Map<string, { match: GroundingMatch; terms: string[] }>();
183+
for (const grounded of groundedTerms) {
184+
for (const match of grounded.matches) {
185+
const key = `${match.path}:${match.line}`;
186+
const existing = citations.get(key);
187+
if (existing) existing.terms.push(grounded.term);
188+
else citations.set(key, { match, terms: [grounded.term] });
189+
}
190+
}
191+
const anchorGroups = new Map<string, { terms: string[]; topLine: number }>();
192+
for (const grounded of groundedTerms) {
193+
const top = grounded.matches[0]!;
194+
const group = anchorGroups.get(top.path);
195+
if (group) group.terms.push(grounded.term);
196+
else anchorGroups.set(top.path, { terms: [grounded.term], topLine: top.line });
197+
}
198+
const citedPaths = [...new Set(groundedTerms.flatMap((grounded) => grounded.matches.map((match) => match.path)))];
199+
// matches is never empty on a GroundedTerm (groundTerm caps at ≥1), so index directly rather than
200+
// optional-chain through a link that could never take its undefined side.
201+
const anchorPath = groundedTerms.length > 0 ? groundedTerms[0]!.matches[0]!.path : undefined;
202+
203+
const lines: string[] = ["## Context", "", `Loose intent (maintainer's own words): ${trimmedPrompt}`, ""];
204+
if (citations.size > 0) {
205+
lines.push("Real precedent in the current checkout (verified by search, not memory):", "");
206+
for (const citation of citations.values()) {
207+
lines.push(
208+
`- \`${citation.match.path}:${citation.match.line}\` — \`${citation.match.text}\` (grounds ${citation.terms.map((term) => `"${term}"`).join(", ")})`,
209+
);
210+
}
211+
lines.push("");
212+
} else {
213+
lines.push("> ⚠️ NO grounded precedent was found for ANY part of this prompt — every requirement below needs human verification before publishing.", "");
214+
}
215+
216+
lines.push("## Requirements", "");
217+
if (anchorPath) {
218+
lines.push(
219+
`> ⚠️ Required pattern. Mirror the existing implementation in \`${anchorPath}\` — a differently-shaped`,
220+
"> implementation, a second parallel mechanism, or an unspecified choice among multiple plausible",
221+
"> artifacts does NOT satisfy this issue.",
222+
"",
223+
);
224+
}
225+
for (const [path, group] of anchorGroups) {
226+
lines.push(
227+
`- Anchor the ${group.terms.map((term) => `"${term}"`).join(" / ")} work on \`${path}\` (see \`${path}:${group.topLine}\`); state in the PR how the change relates to it.`,
228+
);
229+
}
230+
for (const term of ungroundedTerms) {
231+
lines.push(
232+
`- > ⚠️ UNGROUNDED: no precedent found in the searched checkout for \`${term.term}\` — verify the requirement by hand (or drop it) before publishing; do NOT leave this marker in the published issue.`,
233+
);
234+
}
235+
lines.push("");
236+
237+
lines.push(
238+
"## Deliverables",
239+
"",
240+
"- [ ] <!-- MAINTAINER: name each concrete artifact (exact file paths) — the gate enforces only what is written here. -->",
241+
"",
242+
"## Test Coverage Requirements",
243+
"",
244+
);
245+
if (citedPaths.some(pathIsCoverageGraded)) {
246+
lines.push(
247+
"99%+ Codecov patch coverage (branch-counted) on every changed line — aim for 100%, including both",
248+
"sides of every `??`/ternary/`&&`, invariant tests, and a regression test for any fix.",
249+
);
250+
} else {
251+
lines.push(
252+
"The cited paths are outside coverage.include (`src/**` and the engine's `src/**`), so Codecov does",
253+
"not gate this patch — full unit tests are still required per house convention where logic exists.",
254+
);
255+
}
256+
lines.push(
257+
"",
258+
"## Expected Outcome",
259+
"",
260+
"<!-- MAINTAINER: state what is true after this ships that was not true before. -->",
261+
"",
262+
"## Links & Resources",
263+
"",
264+
);
265+
for (const path of citedPaths) lines.push(`- \`${path}\``);
266+
if (citedPaths.length === 0) lines.push("- <!-- MAINTAINER: no grounded files to cite — add the real anchors by hand. -->");
267+
lines.push("");
268+
269+
return { body: lines.join("\n"), groundedTerms, ungroundedTerms };
270+
}

0 commit comments

Comments
 (0)