Skip to content

Commit 797daaf

Browse files
committed
feat(review): accept/skip TUI closes core loop — hook → proposer → review → TRACE.md
1 parent e240fd9 commit 797daaf

7 files changed

Lines changed: 260 additions & 1 deletion

File tree

.claude/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"hooks":{"PostToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"node /Users/shandarjunaid/Downloads/Trace/dist/proposer.js"}]}]}}

.trace/.claude/settings.local.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Skill(update-config)"
5+
]
6+
}
7+
}

.trace/pending.jsonl

Whitespace-only changes.

TRACE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,7 @@ This is where TRACE has to surface something that even Anthropic finds interesti
299299
---
300300

301301
*Plan ready. Execute mode next.*
302+
303+
## Decision Log
304+
305+
- Confirmed README.md exists with TRACE as the project title, establishing baseline repo documentation.

src/cli.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { Command } from "commander";
33
import { writeFileSync } from "node:fs";
44
import { resolve } from "node:path";
5+
import { review } from "./review.js";
56

67
const TEMPLATE = `# TRACE — Living PRD
78
@@ -72,4 +73,11 @@ program
7273
}
7374
});
7475

75-
program.parse();
76+
program
77+
.command("review")
78+
.description("Review pending proposed edits and append accepted ones to TRACE.md.")
79+
.action(async () => {
80+
await review(process.cwd());
81+
});
82+
83+
program.parseAsync();

src/proposer.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/usr/bin/env node
2+
import { readFileSync, appendFileSync } from "node:fs";
3+
import { resolve } from "node:path";
4+
5+
const SYSTEM_PROMPT =
6+
'You are a PRD updater. Given a Claude Code tool event and the current TRACE.md, propose ONE short edit — a single sentence to add to the Decision Log section. Return ONLY a JSON object: {"section": "Decision Log", "edit": "<one sentence>", "confidence": 0.0-1.0}. No preamble. No markdown.';
7+
8+
const MODEL = "claude-opus-4-7";
9+
const API_URL = "https://api.anthropic.com/v1/messages";
10+
const ANTHROPIC_VERSION = "2023-06-01";
11+
12+
interface HookEvent {
13+
cwd?: string;
14+
[k: string]: unknown;
15+
}
16+
17+
interface AnthropicContentBlock {
18+
type: string;
19+
text?: string;
20+
}
21+
22+
interface AnthropicResponse {
23+
content?: AnthropicContentBlock[];
24+
}
25+
26+
async function readStdin(): Promise<string> {
27+
const chunks: Buffer[] = [];
28+
for await (const chunk of process.stdin) {
29+
chunks.push(chunk as Buffer);
30+
}
31+
return Buffer.concat(chunks).toString("utf8");
32+
}
33+
34+
async function main(): Promise<void> {
35+
const raw = await readStdin();
36+
if (!raw.trim()) return;
37+
38+
let event: HookEvent;
39+
try {
40+
event = JSON.parse(raw) as HookEvent;
41+
} catch {
42+
return;
43+
}
44+
45+
const cwd = event.cwd;
46+
if (typeof cwd !== "string" || cwd.length === 0) return;
47+
48+
const apiKey = process.env.ANTHROPIC_API_KEY;
49+
if (!apiKey) return;
50+
51+
let traceMd: string;
52+
try {
53+
traceMd = readFileSync(resolve(cwd, "TRACE.md"), "utf8");
54+
} catch {
55+
return;
56+
}
57+
58+
const userContent = `<event>\n${JSON.stringify(event, null, 2)}\n</event>\n\n<trace_md>\n${traceMd}\n</trace_md>`;
59+
60+
const res = await fetch(API_URL, {
61+
method: "POST",
62+
headers: {
63+
"content-type": "application/json",
64+
"x-api-key": apiKey,
65+
"anthropic-version": ANTHROPIC_VERSION,
66+
},
67+
body: JSON.stringify({
68+
model: MODEL,
69+
max_tokens: 300,
70+
system: SYSTEM_PROMPT,
71+
messages: [{ role: "user", content: userContent }],
72+
}),
73+
});
74+
75+
if (!res.ok) return;
76+
77+
const data = (await res.json()) as AnthropicResponse;
78+
const text = data.content?.[0]?.text?.trim();
79+
if (!text) return;
80+
81+
let normalized: string;
82+
try {
83+
normalized = JSON.stringify(JSON.parse(text));
84+
} catch {
85+
return;
86+
}
87+
88+
const pendingPath = resolve(cwd, ".trace/pending.jsonl");
89+
appendFileSync(pendingPath, normalized + "\n");
90+
}
91+
92+
main().catch(() => {
93+
// Silent. Never block the Claude Code session.
94+
});

src/review.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
2+
import { resolve } from "node:path";
3+
import { createInterface } from "node:readline/promises";
4+
import { stdin as input, stdout as output } from "node:process";
5+
6+
interface PendingEdit {
7+
section: string;
8+
edit: string;
9+
confidence: number;
10+
}
11+
12+
function parsePending(raw: string): PendingEdit[] {
13+
const out: PendingEdit[] = [];
14+
for (const line of raw.split("\n")) {
15+
const trimmed = line.trim();
16+
if (!trimmed) continue;
17+
try {
18+
const obj = JSON.parse(trimmed) as Partial<PendingEdit>;
19+
if (typeof obj.section === "string" && typeof obj.edit === "string") {
20+
out.push({
21+
section: obj.section,
22+
edit: obj.edit,
23+
confidence:
24+
typeof obj.confidence === "number" ? obj.confidence : 0,
25+
});
26+
}
27+
} catch {
28+
// skip malformed line
29+
}
30+
}
31+
return out;
32+
}
33+
34+
function appendToDecisionLog(tracePath: string, edit: string): void {
35+
let doc = readFileSync(tracePath, "utf8");
36+
if (!doc.endsWith("\n")) doc += "\n";
37+
const bullet = `- ${edit}\n`;
38+
39+
const heading = /^##\s+Decision Log\b.*$/im.exec(doc);
40+
if (!heading) {
41+
writeFileSync(tracePath, `${doc}\n## Decision Log\n\n${bullet}`, "utf8");
42+
return;
43+
}
44+
45+
const headingLineEnd = doc.indexOf("\n", heading.index) + 1;
46+
const rest = doc.slice(headingLineEnd);
47+
const nextHeading = /^##\s+/m.exec(rest);
48+
const sectionEnd = nextHeading
49+
? headingLineEnd + nextHeading.index
50+
: doc.length;
51+
52+
const before = doc.slice(0, headingLineEnd);
53+
const body = doc.slice(headingLineEnd, sectionEnd).replace(/\s*$/, "");
54+
const after = doc.slice(sectionEnd);
55+
56+
const newBody = body === "" ? `\n${bullet}` : `${body}\n${bullet}`;
57+
const separator = after ? "\n" : "";
58+
writeFileSync(tracePath, before + newBody + separator + after, "utf8");
59+
}
60+
61+
export async function review(cwd: string): Promise<void> {
62+
const pendingPath = resolve(cwd, ".trace/pending.jsonl");
63+
const tracePath = resolve(cwd, "TRACE.md");
64+
65+
if (!existsSync(tracePath)) {
66+
console.error(
67+
"trace: no TRACE.md in current directory. Run `trace init` first.",
68+
);
69+
process.exit(1);
70+
}
71+
72+
if (!existsSync(pendingPath)) {
73+
console.log("trace: no pending edits.");
74+
return;
75+
}
76+
77+
const edits = parsePending(readFileSync(pendingPath, "utf8"));
78+
if (edits.length === 0) {
79+
console.log("trace: no pending edits.");
80+
return;
81+
}
82+
83+
const rl = createInterface({ input, output });
84+
let accepted = 0;
85+
let skipped = 0;
86+
let i = 0;
87+
let aborted = false;
88+
89+
try {
90+
for (; i < edits.length; i++) {
91+
const { section, edit, confidence } = edits[i];
92+
console.log("");
93+
console.log(`── proposal ${i + 1} of ${edits.length} ──`);
94+
console.log(`section: ${section}`);
95+
console.log(`confidence: ${confidence.toFixed(2)}`);
96+
console.log(`edit: ${edit}`);
97+
console.log("");
98+
99+
let answer: string;
100+
try {
101+
answer = (await rl.question("[a]ccept / [s]kip? "))
102+
.trim()
103+
.toLowerCase();
104+
} catch {
105+
console.log("\n→ input closed. Remaining proposals kept in queue.");
106+
aborted = true;
107+
break;
108+
}
109+
110+
if (answer === "a" || answer === "accept") {
111+
appendToDecisionLog(tracePath, edit);
112+
accepted++;
113+
console.log("→ accepted, appended to Decision Log.");
114+
} else {
115+
skipped++;
116+
console.log("→ skipped.");
117+
}
118+
}
119+
} finally {
120+
rl.close();
121+
}
122+
123+
if (aborted) {
124+
const remaining = edits
125+
.slice(i)
126+
.map((e) => JSON.stringify(e))
127+
.join("\n");
128+
writeFileSync(
129+
pendingPath,
130+
remaining.length ? remaining + "\n" : "",
131+
"utf8",
132+
);
133+
console.log("");
134+
console.log(
135+
`trace: reviewed ${i} of ${edits.length}${accepted} accepted, ${skipped} skipped. ${edits.length - i} kept in queue.`,
136+
);
137+
return;
138+
}
139+
140+
writeFileSync(pendingPath, "", "utf8");
141+
console.log("");
142+
console.log(
143+
`trace: reviewed ${edits.length}${accepted} accepted, ${skipped} skipped. Queue cleared.`,
144+
);
145+
}

0 commit comments

Comments
 (0)