@tekyzinc/gsd-t 5.9.10 → 5.10.11
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/CHANGELOG.md +28 -0
- package/README.md +1 -1
- package/bin/gsd-t-claude-md-changelog.cjs +360 -0
- package/bin/gsd-t-project-history.cjs +374 -0
- package/bin/gsd-t-rule-mine.cjs +467 -0
- package/bin/gsd-t.js +8 -0
- package/commands/gsd-t-init.md +13 -0
- package/commands/gsd-t-setup.md +108 -74
- package/package.json +1 -1
- package/scripts/gsd-t-changelog-check.js +67 -0
- package/scripts/gsd-t-changelog-post-edit.js +73 -0
- package/templates/CLAUDE-project.md +50 -107
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gsd-t-project-history.cjs
|
|
4
|
+
*
|
|
5
|
+
* M109-D1 — Reads what actually happened in a project, so its CLAUDE.md can be
|
|
6
|
+
* written from evidence rather than from a template.
|
|
7
|
+
*
|
|
8
|
+
* [RULE] history-distinguishes-absent-from-unreadable
|
|
9
|
+
* [RULE] history-names-every-session-it-could-not-read
|
|
10
|
+
* [RULE] history-reads-what-the-user-typed-not-what-was-pasted
|
|
11
|
+
*
|
|
12
|
+
* Three sources, cheapest first: git, the decision log, and what the user typed
|
|
13
|
+
* in past sessions. The transcripts are the richest and the largest — binvoice
|
|
14
|
+
* holds 315 MB across 27 sessions — so they are funnelled, not read whole:
|
|
15
|
+
*
|
|
16
|
+
* 315 MB raw sessions
|
|
17
|
+
* 2.9 MB keep only the user's own turns
|
|
18
|
+
* 645 KB drop pasted logs (35 giant lines held 78% of the bytes)
|
|
19
|
+
* 7.4 KB keep only complaint-shaped lines
|
|
20
|
+
*
|
|
21
|
+
* Two seconds, no subagents, and the result fits in context.
|
|
22
|
+
*
|
|
23
|
+
* ─── Absent is not the same as unreadable ───────────────────────────────────
|
|
24
|
+
* A source that ISN'T THERE is an ordinary fact — a project may have no git
|
|
25
|
+
* history. A source that IS there but cannot be read is a FAILURE, and saying
|
|
26
|
+
* "not available" about it would produce a thinner CLAUDE.md that looks
|
|
27
|
+
* complete. Those two cases are reported differently, and the second one halts.
|
|
28
|
+
*
|
|
29
|
+
* The one sanctioned exception (David's call, 2026-08-07): among many session
|
|
30
|
+
* files, one that cannot be read is skipped and NAMED rather than halting —
|
|
31
|
+
* a single truncated in-progress session should not block the rewrite. Every
|
|
32
|
+
* skipped session is listed in the output and belongs in the generated file.
|
|
33
|
+
*
|
|
34
|
+
* ─── Usage ──────────────────────────────────────────────────────────────────
|
|
35
|
+
* node gsd-t-project-history.cjs --project <dir> [--json] [--sessions N]
|
|
36
|
+
*
|
|
37
|
+
* ─── Exit codes ─────────────────────────────────────────────────────────────
|
|
38
|
+
* 0 read something
|
|
39
|
+
* 4 no history at all — the caller must halt, never write a thin file
|
|
40
|
+
* 64 a source exists but could not be read, or bad input
|
|
41
|
+
*
|
|
42
|
+
* Zero dependencies.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
"use strict";
|
|
46
|
+
|
|
47
|
+
const fs = require("fs");
|
|
48
|
+
const os = require("os");
|
|
49
|
+
const path = require("path");
|
|
50
|
+
const { execFileSync } = require("child_process");
|
|
51
|
+
|
|
52
|
+
const EXIT_OK = 0;
|
|
53
|
+
const EXIT_NO_HISTORY = 4;
|
|
54
|
+
const EXIT_UNREADABLE = 64;
|
|
55
|
+
|
|
56
|
+
// A pasted log is not something the user typed. 35 such lines held 78% of
|
|
57
|
+
// binvoice's post-filter bytes.
|
|
58
|
+
const PASTE_THRESHOLD = 2000;
|
|
59
|
+
|
|
60
|
+
/** Thrown when a source exists but cannot be read. Never treated as absent. */
|
|
61
|
+
class Unreadable extends Error {}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What a complaint actually looks like — measured against real transcripts,
|
|
65
|
+
* not assumed. The obvious guesses all scored zero:
|
|
66
|
+
* "I never asked you to" 0 hits
|
|
67
|
+
* "that's the third time" 0 hits
|
|
68
|
+
* "you were supposed to" 0 hits
|
|
69
|
+
* The user does not accuse. He restates the requirement.
|
|
70
|
+
*/
|
|
71
|
+
const COMPLAINT = new RegExp([
|
|
72
|
+
"still (not|isn'?t|doesn'?t|don'?t|broken|failing|wrong|happening)",
|
|
73
|
+
"you keep",
|
|
74
|
+
"why (did|didn'?t|do|does|is|are) (you|it|this)",
|
|
75
|
+
"keeps? (happening|coming up|breaking|failing)",
|
|
76
|
+
"hard rule",
|
|
77
|
+
"(third|3rd|second|2nd) (time|attempt|try)",
|
|
78
|
+
"same (result|issue|problem|error|thing)",
|
|
79
|
+
"revert(ed|ing)?\\b",
|
|
80
|
+
"that'?s (wrong|not right|incorrect)",
|
|
81
|
+
"without asking",
|
|
82
|
+
"never (do|again|clear|change|touch|delete|assume)",
|
|
83
|
+
"don'?t (just|ever|keep|do that)",
|
|
84
|
+
"i (didn'?t|never) (ask|say|want)",
|
|
85
|
+
"stop (doing|adding|using)",
|
|
86
|
+
"you (broke|removed|deleted|changed) ",
|
|
87
|
+
"not what i (asked|wanted|said)",
|
|
88
|
+
].join("|"), "i");
|
|
89
|
+
|
|
90
|
+
// Turns that are machinery, not the user speaking.
|
|
91
|
+
const NOT_TYPED = [
|
|
92
|
+
/^<command-name>/,
|
|
93
|
+
/^<local-command/,
|
|
94
|
+
/^\[Request interrupted/,
|
|
95
|
+
/^<system-reminder>/,
|
|
96
|
+
/^Caveat: The messages below/,
|
|
97
|
+
/hook (success|feedback)/i,
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/** Where Claude Code keeps a project's sessions. */
|
|
101
|
+
function transcriptDir(projectDir) {
|
|
102
|
+
const encoded = path.resolve(projectDir).replace(/\//g, "-");
|
|
103
|
+
return path.join(os.homedir(), ".claude", "projects", encoded);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The text of one turn, whatever shape the record uses. */
|
|
107
|
+
function turnText(record) {
|
|
108
|
+
const c = record && record.message && record.message.content;
|
|
109
|
+
if (typeof c === "string") return c;
|
|
110
|
+
if (!Array.isArray(c)) return "";
|
|
111
|
+
return c.filter((b) => b && b.type === "text").map((b) => b.text || "").join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The funnel. Returns every complaint-shaped thing the user typed, newest
|
|
116
|
+
* session first. A session that cannot be read is skipped and NAMED.
|
|
117
|
+
*/
|
|
118
|
+
function readTranscripts(projectDir, maxSessions) {
|
|
119
|
+
const dir = transcriptDir(projectDir);
|
|
120
|
+
if (!fs.existsSync(dir)) {
|
|
121
|
+
return { present: false, why: `no session history at ${dir}` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let names;
|
|
125
|
+
try {
|
|
126
|
+
names = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
127
|
+
} catch (e) {
|
|
128
|
+
throw new Unreadable(`${dir} exists but could not be listed: ${e.message}`);
|
|
129
|
+
}
|
|
130
|
+
if (!names.length) return { present: false, why: `no sessions recorded in ${dir}` };
|
|
131
|
+
|
|
132
|
+
let files = names
|
|
133
|
+
.map((f) => {
|
|
134
|
+
const full = path.join(dir, f);
|
|
135
|
+
return { full, mtime: fs.statSync(full).mtimeMs };
|
|
136
|
+
})
|
|
137
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
138
|
+
.map((x) => x.full);
|
|
139
|
+
|
|
140
|
+
if (maxSessions > 0 && files.length > maxSessions) files = files.slice(0, maxSessions);
|
|
141
|
+
|
|
142
|
+
const complaints = [];
|
|
143
|
+
const skipped = [];
|
|
144
|
+
const stats = { sessions: files.length, rawBytes: 0, userTurns: 0, afterPaste: 0, matched: 0 };
|
|
145
|
+
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
let raw;
|
|
148
|
+
try {
|
|
149
|
+
stats.rawBytes += fs.statSync(file).size;
|
|
150
|
+
raw = fs.readFileSync(file, "utf8");
|
|
151
|
+
} catch (e) {
|
|
152
|
+
// Sanctioned: one bad session does not block the rewrite, but it is named
|
|
153
|
+
// here and reported to the user. Never silent.
|
|
154
|
+
skipped.push({ session: path.basename(file, ".jsonl"), why: e.message });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const session = path.basename(file, ".jsonl");
|
|
159
|
+
for (const line of raw.split("\n")) {
|
|
160
|
+
if (!line) continue;
|
|
161
|
+
let rec;
|
|
162
|
+
try { rec = JSON.parse(line); } catch (_) { continue; } // a half-written last line
|
|
163
|
+
if (!rec || rec.type !== "user" || rec.isSidechain || rec.isMeta) continue;
|
|
164
|
+
|
|
165
|
+
const text = turnText(rec).trim();
|
|
166
|
+
if (!text) continue;
|
|
167
|
+
stats.userTurns++;
|
|
168
|
+
|
|
169
|
+
if (text.length > PASTE_THRESHOLD) continue; // pasted, not typed
|
|
170
|
+
if (NOT_TYPED.some((re) => re.test(text))) continue;
|
|
171
|
+
stats.afterPaste++;
|
|
172
|
+
|
|
173
|
+
if (!COMPLAINT.test(text)) continue;
|
|
174
|
+
stats.matched++;
|
|
175
|
+
|
|
176
|
+
complaints.push({
|
|
177
|
+
session,
|
|
178
|
+
when: rec.timestamp || null,
|
|
179
|
+
branch: rec.gitBranch || null,
|
|
180
|
+
text: text.length > 600 ? text.slice(0, 600) + "…" : text,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { present: true, complaints, skipped, stats };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Decision-log entries — already curated, much smaller. */
|
|
189
|
+
function readDecisionLog(projectDir) {
|
|
190
|
+
const p = path.join(projectDir, ".gsd-t", "progress.md");
|
|
191
|
+
if (!fs.existsSync(p)) return { present: false, why: "no .gsd-t/progress.md" };
|
|
192
|
+
|
|
193
|
+
let text;
|
|
194
|
+
try {
|
|
195
|
+
text = fs.readFileSync(p, "utf8");
|
|
196
|
+
} catch (e) {
|
|
197
|
+
throw new Unreadable(`progress.md exists but could not be read: ${e.message}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const start = text.indexOf("## Decision Log");
|
|
201
|
+
if (start === -1) return { present: false, why: "progress.md has no Decision Log section" };
|
|
202
|
+
|
|
203
|
+
// A date stamp carries a time and a zone — "2026-08-05 21:15 PDT: [debug] …" —
|
|
204
|
+
// so the colon that ends it is the one AFTER the zone, not the first one on
|
|
205
|
+
// the line. Matching the first colon finds nothing.
|
|
206
|
+
const entries = [];
|
|
207
|
+
for (const line of text.slice(start).split("\n")) {
|
|
208
|
+
const m = line.match(/^-\s+(\d{4}-\d{2}-\d{2}(?:\s+[\d:]+)?(?:\s+[A-Z]{2,5})?)\s*:\s*(.+)$/);
|
|
209
|
+
if (!m) continue;
|
|
210
|
+
entries.push({ when: m[1], text: m[2].length > 400 ? m[2].slice(0, 400) + "…" : m[2] });
|
|
211
|
+
}
|
|
212
|
+
return { present: true, entries };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Files fixed the same way repeatedly. A file that keeps being fixed is a rule
|
|
217
|
+
* nobody wrote down — and this works when there are no transcripts at all.
|
|
218
|
+
*/
|
|
219
|
+
function readGit(projectDir) {
|
|
220
|
+
const git = (args) => execFileSync("git", ["-C", projectDir, ...args], {
|
|
221
|
+
encoding: "utf8", timeout: 20000, maxBuffer: 32 * 1024 * 1024,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (!fs.existsSync(path.join(projectDir, ".git"))) {
|
|
225
|
+
return { present: false, why: "not a git repository" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let commits, branch;
|
|
229
|
+
try {
|
|
230
|
+
commits = parseInt(git(["rev-list", "--count", "HEAD"]).trim(), 10);
|
|
231
|
+
branch = git(["branch", "--show-current"]).trim();
|
|
232
|
+
} catch (e) {
|
|
233
|
+
throw new Unreadable(`.git exists but git could not read it: ${e.message}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// A real revert says so at the start of its subject. Matching "broke" or a
|
|
237
|
+
// bare "revert" anywhere pulled in 20 ordinary commits on binvoice — every
|
|
238
|
+
// one a false positive, which buries the handful that are real.
|
|
239
|
+
let reverts;
|
|
240
|
+
try {
|
|
241
|
+
reverts = git(["log", "--oneline", "-i", "--grep=^revert", "--grep=^chore: revert", "--grep=reverts commit", "-n", "40"])
|
|
242
|
+
.split("\n").filter(Boolean).slice(0, 20);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
throw new Unreadable(`git log failed while looking for reverts: ${e.message}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let hotFiles;
|
|
248
|
+
try {
|
|
249
|
+
const counts = {};
|
|
250
|
+
const out = git(["log", "-i", "--name-only", "--pretty=format:", "--grep=fix", "-n", "300"]);
|
|
251
|
+
for (const f of out.split("\n")) {
|
|
252
|
+
const name = f.trim();
|
|
253
|
+
if (!name) continue;
|
|
254
|
+
counts[name] = (counts[name] || 0) + 1;
|
|
255
|
+
}
|
|
256
|
+
hotFiles = Object.entries(counts)
|
|
257
|
+
.filter(([, n]) => n >= 3)
|
|
258
|
+
.sort((a, b) => b[1] - a[1])
|
|
259
|
+
.slice(0, 12)
|
|
260
|
+
.map(([file, fixes]) => ({ file, fixes }));
|
|
261
|
+
} catch (e) {
|
|
262
|
+
throw new Unreadable(`git log failed while looking for repeatedly-fixed files: ${e.message}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { present: true, commits, branch, reverts, hotFiles };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function parseArgs(argv) {
|
|
269
|
+
const args = { project: process.cwd(), sessions: 0 };
|
|
270
|
+
for (let i = 2; i < argv.length; i++) {
|
|
271
|
+
const a = argv[i];
|
|
272
|
+
if (a === "--json") args.json = true;
|
|
273
|
+
else if (a === "--project") args.project = argv[++i];
|
|
274
|
+
else if (a === "--sessions") args.sessions = parseInt(argv[++i], 10) || 0;
|
|
275
|
+
}
|
|
276
|
+
return args;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function halt(message, asJson) {
|
|
280
|
+
if (asJson) {
|
|
281
|
+
process.stdout.write(JSON.stringify({ ok: false, exitCode: EXIT_UNREADABLE, halt: message }, null, 2) + "\n");
|
|
282
|
+
} else {
|
|
283
|
+
process.stderr.write(`[gsd-t] ${message}\n`);
|
|
284
|
+
}
|
|
285
|
+
process.exit(EXIT_UNREADABLE);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function main() {
|
|
289
|
+
const args = parseArgs(process.argv);
|
|
290
|
+
const projectDir = path.resolve(args.project);
|
|
291
|
+
const asJson = args.json || !process.stdout.isTTY;
|
|
292
|
+
|
|
293
|
+
if (!fs.existsSync(projectDir)) halt(`No such directory: ${projectDir}`, asJson);
|
|
294
|
+
|
|
295
|
+
const started = Date.now();
|
|
296
|
+
|
|
297
|
+
// A source that exists but cannot be read HALTS. Reporting it as "not
|
|
298
|
+
// available" would produce a thinner CLAUDE.md that looks complete.
|
|
299
|
+
let git, log, sessions;
|
|
300
|
+
try {
|
|
301
|
+
git = readGit(projectDir);
|
|
302
|
+
log = readDecisionLog(projectDir);
|
|
303
|
+
sessions = readTranscripts(projectDir, args.sessions);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
if (e instanceof Unreadable) {
|
|
306
|
+
halt(
|
|
307
|
+
`${e.message}\n\nThis source exists, so its contents belong in the rewrite. ` +
|
|
308
|
+
`Continuing would produce a CLAUDE.md that looks complete but isn't.`,
|
|
309
|
+
asJson
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
throw e;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Every source says whether it was there, so a thin result can never be
|
|
316
|
+
// mistaken for "this project has no history."
|
|
317
|
+
const sources = {
|
|
318
|
+
git: git.present ? `${git.commits} commits` : `none — ${git.why}`,
|
|
319
|
+
decisionLog: log.present ? `${log.entries.length} entries` : `none — ${log.why}`,
|
|
320
|
+
sessions: sessions.present
|
|
321
|
+
? `${sessions.stats.sessions} sessions, ${sessions.stats.matched} things worth reading`
|
|
322
|
+
: `none — ${sessions.why}`,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const anything = git.present || log.present || sessions.present;
|
|
326
|
+
|
|
327
|
+
const result = {
|
|
328
|
+
ok: anything,
|
|
329
|
+
exitCode: anything ? EXIT_OK : EXIT_NO_HISTORY,
|
|
330
|
+
project: path.basename(projectDir),
|
|
331
|
+
sources,
|
|
332
|
+
tookMs: Date.now() - started,
|
|
333
|
+
git: git.present ? git : null,
|
|
334
|
+
decisionLog: log.present ? log.entries.slice(0, 40) : null,
|
|
335
|
+
complaints: sessions.present ? sessions.complaints : null,
|
|
336
|
+
skippedSessions: sessions.present ? sessions.skipped : [],
|
|
337
|
+
funnel: sessions.present ? sessions.stats : null,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
if (!anything) {
|
|
341
|
+
result.halt = "No history at all. Do not write a thinner CLAUDE.md and call it normal — ask the user how to proceed.";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (asJson) {
|
|
345
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
346
|
+
} else {
|
|
347
|
+
process.stdout.write(`History for ${result.project}\n\n`);
|
|
348
|
+
for (const [k, v] of Object.entries(sources)) process.stdout.write(` ${k.padEnd(13)} ${v}\n`);
|
|
349
|
+
if (result.skippedSessions.length) {
|
|
350
|
+
process.stdout.write(`\n ${result.skippedSessions.length} session(s) could NOT be read:\n`);
|
|
351
|
+
for (const s of result.skippedSessions) process.stdout.write(` ${s.session} — ${s.why}\n`);
|
|
352
|
+
}
|
|
353
|
+
if (sessions.present) {
|
|
354
|
+
const s = sessions.stats;
|
|
355
|
+
process.stdout.write(`\n ${(s.rawBytes / 1e6).toFixed(0)} MB raw → ${s.userTurns} typed → ${s.afterPaste} after dropping pastes → ${s.matched} worth reading\n`);
|
|
356
|
+
}
|
|
357
|
+
if (result.complaints && result.complaints.length) {
|
|
358
|
+
process.stdout.write(`\nWhat kept going wrong:\n\n`);
|
|
359
|
+
for (const c of result.complaints.slice(0, 15)) {
|
|
360
|
+
process.stdout.write(` ${(c.when || "").slice(0, 10)} ${c.text.replace(/\s+/g, " ").slice(0, 110)}\n`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (git.present && git.hotFiles.length) {
|
|
364
|
+
process.stdout.write(`\nFiles fixed repeatedly:\n`);
|
|
365
|
+
for (const h of git.hotFiles.slice(0, 8)) process.stdout.write(` ${h.fixes}× ${h.file}\n`);
|
|
366
|
+
}
|
|
367
|
+
process.stdout.write(`\n(${result.tookMs} ms)\n`);
|
|
368
|
+
}
|
|
369
|
+
process.exit(result.exitCode);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (require.main === module) main();
|
|
373
|
+
|
|
374
|
+
module.exports = { readTranscripts, readDecisionLog, readGit, transcriptDir, COMPLAINT, turnText, Unreadable };
|