Skip to content

Commit f21c43f

Browse files
committed
feat(tooling): feed recorded drafting misses back into every issue draft as a pre-publish checklist (#8118)
Every draft was independent -- the tool had no memory of past post-merge gaps, the exact failure mode the maintainer named as the bottleneck. Now a real gap, once found, is recorded manually via scripts/record-drafting-miss.ts (appends to scripts/drafting-misses.json -- a plain committed file, no new database) and draftIssueBody applies the accumulated lessons on EVERY subsequent draft: a checklist section grouped per category (repeats counted, most recent lesson wording wins), under the same resolve-then-DELETE contract as the UNGROUNDED markers so it can never survive publishing. parseDraftingMisses is deliberately fail-loud -- silently dropping a malformed lesson would defeat the loop -- and the recorder re-validates the whole file through the same parser on every append so a broken hand-edit surfaces at record time, not on the next draft. Still zero auto-detection: a human decides what counts as a miss (#8118's own boundary). 100% line+branch coverage on the extended core.
1 parent 8b62071 commit f21c43f

4 files changed

Lines changed: 243 additions & 8 deletions

File tree

scripts/draft-issue.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,29 @@
77
// export-d1-core.ts. NEVER publishes anything, NEVER touches labels/milestones — see the core's own
88
// boundary comment.
99
//
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";
10+
// tsx scripts/draft-issue.ts --prompt "<loose intent>" --output <draft.md> [--root .] [--misses <file.json>]
11+
// tsx scripts/draft-issue.ts --prompt-file <intent.txt> --output <draft.md> [--root .] [--misses <file.json>]
12+
//
13+
// #8118: every draft automatically applies the accumulated drafting-miss lessons from
14+
// scripts/drafting-misses.json (recorded via scripts/record-drafting-miss.ts) when that file exists;
15+
// --misses points at a different file. A malformed misses file fails the draft loudly — see
16+
// parseDraftingMisses's own fail-loud rationale.
17+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
1318
import { join, relative } from "node:path";
14-
import { draftIssueBody, type CorpusFile } from "../src/services/issue-drafting.js";
19+
import {
20+
DEFAULT_DRAFTING_MISSES_FILE,
21+
draftIssueBody,
22+
parseDraftingMisses,
23+
type CorpusFile,
24+
type DraftingMiss,
25+
} from "../src/services/issue-drafting.js";
1526

1627
type Args = {
1728
prompt: string | undefined;
1829
promptFile: string | undefined;
1930
output: string | undefined;
2031
root: string;
32+
misses: string | undefined;
2133
};
2234

2335
// The corpus mirrors where real precedent lives (the gate's own wantedPaths, minus content-free dirs).
@@ -27,17 +39,30 @@ const SKIP_DIR_NAMES = new Set(["node_modules", "dist", "coverage", ".turbo"]);
2739
const MAX_FILE_BYTES = 512 * 1024;
2840

2941
function parseArgs(argv: string[]): Args {
30-
const args: Args = { prompt: undefined, promptFile: undefined, output: undefined, root: "." };
42+
const args: Args = { prompt: undefined, promptFile: undefined, output: undefined, root: ".", misses: undefined };
3143
for (let i = 0; i < argv.length; i += 1) {
3244
const flag = argv[i];
3345
if (flag === "--prompt") args.prompt = argv[++i];
3446
else if (flag === "--prompt-file") args.promptFile = argv[++i];
3547
else if (flag === "--output") args.output = argv[++i];
3648
else if (flag === "--root") args.root = argv[++i]!;
49+
else if (flag === "--misses") args.misses = argv[++i];
3750
}
3851
return args;
3952
}
4053

54+
// #8118: apply the accumulated misses on EVERY draft — the default file is picked up automatically when it
55+
// exists, so the loop needs no flag to keep working; an explicitly-passed path must exist (a typo silently
56+
// drafting without the checklist would defeat the loop).
57+
function loadDraftingMisses(root: string, explicitPath: string | undefined): DraftingMiss[] {
58+
const path = explicitPath ?? join(root, DEFAULT_DRAFTING_MISSES_FILE);
59+
if (!existsSync(path)) {
60+
if (explicitPath) throw new Error(`--misses file not found: ${explicitPath}`);
61+
return [];
62+
}
63+
return parseDraftingMisses(readFileSync(path, "utf8"));
64+
}
65+
4166
function collectCorpus(root: string): CorpusFile[] {
4267
const corpus: CorpusFile[] = [];
4368
const walk = (dir: string) => {
@@ -65,16 +90,18 @@ function main() {
6590
const args = parseArgs(process.argv.slice(2));
6691
const prompt = args.prompt ?? (args.promptFile ? readFileSync(args.promptFile, "utf8") : undefined);
6792
if (!prompt || !args.output) {
68-
console.error("Usage: tsx scripts/draft-issue.ts (--prompt <text> | --prompt-file <file>) --output <draft.md> [--root .]");
93+
console.error("Usage: tsx scripts/draft-issue.ts (--prompt <text> | --prompt-file <file>) --output <draft.md> [--root .] [--misses <file.json>]");
6994
process.exit(2);
7095
}
7196

7297
const corpus = collectCorpus(args.root);
73-
const result = draftIssueBody(prompt, corpus);
98+
const misses = loadDraftingMisses(args.root, args.misses);
99+
const result = draftIssueBody(prompt, corpus, { misses });
74100
writeFileSync(args.output, result.body);
75101
console.error(
76102
`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}`,
103+
`${result.ungroundedTerms.length} UNGROUNDED marker(s) to resolve by hand, ` +
104+
`${misses.length} recorded miss(es) applied → ${args.output}`,
78105
);
79106
console.error("review + edit before publishing — this tool never publishes, and labels/milestone stay your call.");
80107
}

scripts/record-drafting-miss.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env node
2+
// Drafting-miss recorder (#8118, extends #8103) — the cheap, MANUAL way the maintainer flags a real
3+
// post-merge gap traceable to a drafted issue: something the draft should have specified but didn't.
4+
// Appends one validated record to the shared misses file (scripts/drafting-misses.json by default);
5+
// scripts/draft-issue.ts reads that file on every subsequent draft and renders the accumulated lessons as
6+
// a pre-publish checklist. Nothing here auto-detects gaps — a human decides what counts as a miss, this
7+
// just captures it once found. Thin IO wrapper; the validation lives in the core's parseDraftingMisses.
8+
//
9+
// tsx scripts/record-drafting-miss.ts --prompt "<the loose prompt used>" --missing "<the reusable lesson>" \
10+
// [--category <gap-category>] [--file scripts/drafting-misses.json]
11+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
12+
import { DEFAULT_DRAFTING_MISSES_FILE, parseDraftingMisses, type DraftingMiss } from "../src/services/issue-drafting.js";
13+
14+
type Args = {
15+
prompt: string | undefined;
16+
missing: string | undefined;
17+
category: string | undefined;
18+
file: string;
19+
};
20+
21+
function parseArgs(argv: string[]): Args {
22+
const args: Args = { prompt: undefined, missing: undefined, category: undefined, file: DEFAULT_DRAFTING_MISSES_FILE };
23+
for (let i = 0; i < argv.length; i += 1) {
24+
const flag = argv[i];
25+
if (flag === "--prompt") args.prompt = argv[++i];
26+
else if (flag === "--missing") args.missing = argv[++i];
27+
else if (flag === "--category") args.category = argv[++i];
28+
else if (flag === "--file") args.file = argv[++i]!;
29+
}
30+
return args;
31+
}
32+
33+
function main() {
34+
const args = parseArgs(process.argv.slice(2));
35+
if (!args.prompt || !args.missing) {
36+
console.error(
37+
'Usage: tsx scripts/record-drafting-miss.ts --prompt "<loose prompt>" --missing "<lesson>" [--category <gap-category>] [--file scripts/drafting-misses.json]',
38+
);
39+
process.exit(2);
40+
}
41+
42+
// Re-validate the whole file through the core parser on every append, so a hand-edit that broke it is
43+
// caught here (at record time) instead of failing the next draft.
44+
const existing: DraftingMiss[] = existsSync(args.file) ? parseDraftingMisses(readFileSync(args.file, "utf8")) : [];
45+
const miss: DraftingMiss = {
46+
recordedAt: new Date().toISOString(),
47+
loosePrompt: args.prompt,
48+
missing: args.missing,
49+
...(args.category ? { category: args.category } : {}),
50+
};
51+
existing.push(miss);
52+
writeFileSync(args.file, `${JSON.stringify(existing, null, 2)}\n`);
53+
console.error(`recorded drafting miss #${existing.length}${args.category ? ` [${args.category}]` : ""}${args.file}`);
54+
}
55+
56+
main();

src/services/issue-drafting.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,33 @@ export type GroundingMatch = { path: string; line: number; text: string };
3333
/** A term from the loose prompt that WAS grounded in real precedent. */
3434
export type GroundedTerm = GroundingTerm & { matches: readonly GroundingMatch[] };
3535

36+
/** One recorded drafting miss (#8118): a real post-merge gap traceable to a draft that should have
37+
* specified something and didn't. Recorded manually by the maintainer after the fact — this module never
38+
* auto-detects gaps, it only learns from the ones a human already confirmed. */
39+
export type DraftingMiss = {
40+
/** ISO timestamp of when the miss was recorded. */
41+
recordedAt: string;
42+
/** The loose prompt the flawed draft was generated from. */
43+
loosePrompt: string;
44+
/** What the draft should have specified but didn't — written as a reusable lesson. */
45+
missing: string;
46+
/** Optional gap category ("unstated-anti-pattern", "unverified-signature", …) used to dedupe the
47+
* checklist: two misses in one category render as one checklist line with a ×N count. */
48+
category?: string;
49+
};
50+
51+
/** Repo-relative default location of the misses file, shared by the recorder and drafter CLIs (#8118) —
52+
* a plain committed JSON file per the issue's "no new database" boundary. The core only exports the
53+
* string; reading/writing it stays the thin consumers' IO. */
54+
export const DEFAULT_DRAFTING_MISSES_FILE = "scripts/drafting-misses.json";
55+
3656
export type IssueDraftOptions = {
3757
/** Cap on distinct terms extracted from the prompt (default 12). */
3858
maxTerms?: number;
3959
/** Cap on matches kept per grounded term (default 3). */
4060
maxMatchesPerTerm?: number;
61+
/** Accumulated drafting misses (#8118) — rendered into every draft as a pre-publish checklist. */
62+
misses?: readonly DraftingMiss[];
4163
};
4264

4365
export type IssueDraftResult = {
@@ -146,6 +168,54 @@ export function groundTerm(
146168
return { ...groundingTerm, matches: matches.slice(0, Math.max(1, maxMatchesPerTerm)) };
147169
}
148170

171+
/**
172+
* Parse the drafting-misses file's JSON content (#8118) into validated {@link DraftingMiss} records.
173+
* FAIL-LOUD, deliberately: this is the maintainer's own accumulated learning data, and silently dropping a
174+
* malformed lesson would defeat the entire feedback loop — a broken file should stop the draft, not shrink
175+
* the checklist. (Contrast with the corpus parsers' fail-open posture, which protect a live review pass.)
176+
*/
177+
export function parseDraftingMisses(json: string): DraftingMiss[] {
178+
let parsed: unknown;
179+
try {
180+
parsed = JSON.parse(json);
181+
} catch {
182+
throw new Error("drafting-misses file is not valid JSON");
183+
}
184+
if (!Array.isArray(parsed)) throw new Error("drafting-misses file must be a JSON array of miss records");
185+
return parsed.map((entry, index) => {
186+
const record = (entry ?? {}) as Record<string, unknown>;
187+
if (typeof record.recordedAt !== "string" || !record.recordedAt || typeof record.loosePrompt !== "string" || typeof record.missing !== "string" || !record.missing) {
188+
throw new Error(`drafting miss #${index} is malformed — need recordedAt, loosePrompt, and a non-empty missing lesson`);
189+
}
190+
const miss: DraftingMiss = { recordedAt: record.recordedAt, loosePrompt: record.loosePrompt, missing: record.missing };
191+
if (typeof record.category === "string" && record.category) miss.category = record.category;
192+
return miss;
193+
});
194+
}
195+
196+
/** Collapse recorded misses into checklist lines: one line per category (uncategorized misses stay
197+
* one-per-lesson), counting repeats and keeping the most recently recorded lesson text as the actionable
198+
* wording. Sorted by label for byte-stable drafts. */
199+
function groupDraftingMisses(misses: readonly DraftingMiss[]): Array<{ label: string; count: number; lesson: string }> {
200+
const groups = new Map<string, { label: string; count: number; lesson: string; lessonAt: string }>();
201+
for (const miss of misses) {
202+
const key = miss.category ?? `uncategorized:${miss.missing}`;
203+
const existing = groups.get(key);
204+
if (!existing) {
205+
groups.set(key, { label: miss.category ?? "one-off", count: 1, lesson: miss.missing, lessonAt: miss.recordedAt });
206+
} else {
207+
existing.count += 1;
208+
if (miss.recordedAt > existing.lessonAt) {
209+
existing.lesson = miss.missing;
210+
existing.lessonAt = miss.recordedAt;
211+
}
212+
}
213+
}
214+
return [...groups.entries()]
215+
.sort(([a], [b]) => a.localeCompare(b))
216+
.map(([, group]) => ({ label: group.label, count: group.count, lesson: group.lesson }));
217+
}
218+
149219
/** True when a cited path is graded by Codecov's patch gate (coverage.include: `src/**` and the engine's
150220
* `src/**` — mirrors codecov.yml's ignore list + vitest.config.ts's include, kept in sync by hand). */
151221
function pathIsCoverageGraded(path: string): boolean {
@@ -264,6 +334,17 @@ export function draftIssueBody(prompt: string, corpus: readonly CorpusFile[], op
264334
);
265335
for (const path of citedPaths) lines.push(`- \`${path}\``);
266336
if (citedPaths.length === 0) lines.push("- <!-- MAINTAINER: no grounded files to cite — add the real anchors by hand. -->");
337+
338+
// #8118: the accumulated-misses checklist — every recorded post-merge gap becomes a concrete
339+
// double-check on every subsequent draft, so the tool gets better instead of repeating its misses.
340+
// Same "resolve, then delete" contract as the UNGROUNDED markers: it must never survive publishing.
341+
const misses = options.misses ?? [];
342+
if (misses.length > 0) {
343+
lines.push("", "## Pre-publish checklist — learned from recorded drafting misses. Resolve each item, then DELETE this section before publishing.", "");
344+
for (const group of groupDraftingMisses(misses)) {
345+
lines.push(`- [ ] ${group.label}${group.count > 1 ? ` (recorded ${group.count}×)` : ""}: ${group.lesson}`);
346+
}
347+
}
267348
lines.push("");
268349

269350
return { body: lines.join("\n"), groundedTerms, ungroundedTerms };

test/unit/issue-drafting.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
DEFAULT_DRAFTING_MISSES_FILE,
34
draftIssueBody,
45
extractGroundingTerms,
56
groundTerm,
7+
parseDraftingMisses,
68
type CorpusFile,
9+
type DraftingMiss,
710
type GroundingTerm,
811
} from "../../src/services/issue-drafting";
912

@@ -236,3 +239,71 @@ describe("issue-drafting draftIssueBody (#8103)", () => {
236239
}
237240
});
238241
});
242+
243+
describe("issue-drafting parseDraftingMisses (#8118)", () => {
244+
const validMiss = { recordedAt: "2026-07-20T00:00:00.000Z", loosePrompt: "add a thing", missing: "state the exact anti-pattern" };
245+
246+
it("parses valid records, keeping category only when present and non-empty", () => {
247+
const parsed = parseDraftingMisses(JSON.stringify([validMiss, { ...validMiss, category: "unstated-anti-pattern" }, { ...validMiss, category: "" }]));
248+
expect(parsed).toHaveLength(3);
249+
expect(parsed[0]).toEqual(validMiss);
250+
expect(parsed[1]!.category).toBe("unstated-anti-pattern");
251+
expect(parsed[2]!.category).toBeUndefined();
252+
});
253+
254+
it("fails loud on invalid JSON, a non-array root, and malformed entries — a broken lesson file must stop the draft", () => {
255+
expect(() => parseDraftingMisses("not json")).toThrow(/not valid JSON/);
256+
expect(() => parseDraftingMisses('{"a":1}')).toThrow(/must be a JSON array/);
257+
expect(() => parseDraftingMisses(JSON.stringify([null]))).toThrow(/miss #0 is malformed/);
258+
expect(() => parseDraftingMisses(JSON.stringify([{ ...validMiss, missing: "" }]))).toThrow(/miss #0 is malformed/);
259+
expect(() => parseDraftingMisses(JSON.stringify([validMiss, { recordedAt: "2026-07-20", loosePrompt: 5, missing: "x" }]))).toThrow(/miss #1 is malformed/);
260+
expect(() => parseDraftingMisses(JSON.stringify([{ ...validMiss, recordedAt: "" }]))).toThrow(/miss #0 is malformed/);
261+
});
262+
263+
it("shares one default misses-file location with the CLIs", () => {
264+
expect(DEFAULT_DRAFTING_MISSES_FILE).toBe("scripts/drafting-misses.json");
265+
});
266+
});
267+
268+
describe("issue-drafting draftIssueBody misses checklist (#8118)", () => {
269+
const miss = (overrides: Partial<DraftingMiss> = {}): DraftingMiss => ({
270+
recordedAt: "2026-07-20T00:00:00.000Z",
271+
loosePrompt: "add a thing",
272+
missing: "verify the exact current function signature against the checkout, not memory",
273+
...overrides,
274+
});
275+
276+
it("renders no checklist section when no misses are supplied (default and explicit empty)", () => {
277+
expect(draftIssueBody("extend detectChangedThresholds", CORPUS).body).not.toContain("Pre-publish checklist");
278+
expect(draftIssueBody("extend detectChangedThresholds", CORPUS, { misses: [] }).body).not.toContain("Pre-publish checklist");
279+
});
280+
281+
it("renders every recorded miss as a checklist line the maintainer must resolve and delete", () => {
282+
const result = draftIssueBody("extend detectChangedThresholds", CORPUS, {
283+
misses: [miss(), miss({ category: "unstated-anti-pattern", missing: "name what does NOT satisfy the issue" })],
284+
});
285+
expect(result.body).toContain("## Pre-publish checklist — learned from recorded drafting misses. Resolve each item, then DELETE this section before publishing.");
286+
expect(result.body).toContain("- [ ] one-off: verify the exact current function signature against the checkout, not memory");
287+
expect(result.body).toContain("- [ ] unstated-anti-pattern: name what does NOT satisfy the issue");
288+
});
289+
290+
it("collapses same-category repeats into one counted line carrying the most recent lesson", () => {
291+
const result = draftIssueBody("extend detectChangedThresholds", CORPUS, {
292+
misses: [
293+
miss({ category: "unverified-signature", recordedAt: "2026-07-19T00:00:00.000Z", missing: "older lesson wording" }),
294+
miss({ category: "unverified-signature", recordedAt: "2026-07-21T00:00:00.000Z", missing: "newer lesson wording" }),
295+
miss({ category: "unverified-signature", recordedAt: "2026-07-20T00:00:00.000Z", missing: "middle lesson wording" }),
296+
],
297+
});
298+
expect(result.body).toContain("- [ ] unverified-signature (recorded 3×): newer lesson wording");
299+
expect(result.body).not.toContain("older lesson wording");
300+
});
301+
302+
it("keeps uncategorized misses one line per distinct lesson, sorted deterministically", () => {
303+
const result = draftIssueBody("extend detectChangedThresholds", CORPUS, {
304+
misses: [miss({ missing: "zeta lesson" }), miss({ missing: "alpha lesson" }), miss({ missing: "alpha lesson" })],
305+
});
306+
const checklistLines = result.body.split("\n").filter((line) => line.startsWith("- [ ] one-off"));
307+
expect(checklistLines).toEqual(["- [ ] one-off (recorded 2×): alpha lesson", "- [ ] one-off: zeta lesson"]);
308+
});
309+
});

0 commit comments

Comments
 (0)