@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.
@@ -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 };