preflight-dev 3.1.0

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.
Files changed (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/cli.js +11 -0
  4. package/dist/cli/init.d.ts +2 -0
  5. package/dist/cli/init.js +154 -0
  6. package/dist/cli/init.js.map +1 -0
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.js +122 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib/config.d.ts +34 -0
  11. package/dist/lib/config.js +118 -0
  12. package/dist/lib/config.js.map +1 -0
  13. package/dist/lib/embeddings.d.ts +11 -0
  14. package/dist/lib/embeddings.js +88 -0
  15. package/dist/lib/embeddings.js.map +1 -0
  16. package/dist/lib/files.d.ts +15 -0
  17. package/dist/lib/files.js +60 -0
  18. package/dist/lib/files.js.map +1 -0
  19. package/dist/lib/git-extractor.d.ts +9 -0
  20. package/dist/lib/git-extractor.js +116 -0
  21. package/dist/lib/git-extractor.js.map +1 -0
  22. package/dist/lib/git.d.ts +29 -0
  23. package/dist/lib/git.js +86 -0
  24. package/dist/lib/git.js.map +1 -0
  25. package/dist/lib/session-parser.d.ts +45 -0
  26. package/dist/lib/session-parser.js +267 -0
  27. package/dist/lib/session-parser.js.map +1 -0
  28. package/dist/lib/state.d.ts +21 -0
  29. package/dist/lib/state.js +86 -0
  30. package/dist/lib/state.js.map +1 -0
  31. package/dist/lib/timeline-db.d.ts +67 -0
  32. package/dist/lib/timeline-db.js +380 -0
  33. package/dist/lib/timeline-db.js.map +1 -0
  34. package/dist/lib/triage.d.ts +29 -0
  35. package/dist/lib/triage.js +193 -0
  36. package/dist/lib/triage.js.map +1 -0
  37. package/dist/profiles.d.ts +3 -0
  38. package/dist/profiles.js +65 -0
  39. package/dist/profiles.js.map +1 -0
  40. package/dist/tools/audit-workspace.d.ts +2 -0
  41. package/dist/tools/audit-workspace.js +86 -0
  42. package/dist/tools/audit-workspace.js.map +1 -0
  43. package/dist/tools/checkpoint.d.ts +2 -0
  44. package/dist/tools/checkpoint.js +108 -0
  45. package/dist/tools/checkpoint.js.map +1 -0
  46. package/dist/tools/clarify-intent.d.ts +2 -0
  47. package/dist/tools/clarify-intent.js +180 -0
  48. package/dist/tools/clarify-intent.js.map +1 -0
  49. package/dist/tools/enrich-agent-task.d.ts +2 -0
  50. package/dist/tools/enrich-agent-task.js +97 -0
  51. package/dist/tools/enrich-agent-task.js.map +1 -0
  52. package/dist/tools/generate-scorecard.d.ts +2 -0
  53. package/dist/tools/generate-scorecard.js +617 -0
  54. package/dist/tools/generate-scorecard.js.map +1 -0
  55. package/dist/tools/log-correction.d.ts +2 -0
  56. package/dist/tools/log-correction.js +76 -0
  57. package/dist/tools/log-correction.js.map +1 -0
  58. package/dist/tools/onboard-project.d.ts +2 -0
  59. package/dist/tools/onboard-project.js +179 -0
  60. package/dist/tools/onboard-project.js.map +1 -0
  61. package/dist/tools/preflight-check.d.ts +2 -0
  62. package/dist/tools/preflight-check.js +229 -0
  63. package/dist/tools/preflight-check.js.map +1 -0
  64. package/dist/tools/prompt-score.d.ts +2 -0
  65. package/dist/tools/prompt-score.js +132 -0
  66. package/dist/tools/prompt-score.js.map +1 -0
  67. package/dist/tools/scan-sessions.d.ts +2 -0
  68. package/dist/tools/scan-sessions.js +182 -0
  69. package/dist/tools/scan-sessions.js.map +1 -0
  70. package/dist/tools/scope-work.d.ts +2 -0
  71. package/dist/tools/scope-work.js +214 -0
  72. package/dist/tools/scope-work.js.map +1 -0
  73. package/dist/tools/search-history.d.ts +2 -0
  74. package/dist/tools/search-history.js +130 -0
  75. package/dist/tools/search-history.js.map +1 -0
  76. package/dist/tools/sequence-tasks.d.ts +2 -0
  77. package/dist/tools/sequence-tasks.js +165 -0
  78. package/dist/tools/sequence-tasks.js.map +1 -0
  79. package/dist/tools/session-handoff.d.ts +2 -0
  80. package/dist/tools/session-handoff.js +113 -0
  81. package/dist/tools/session-handoff.js.map +1 -0
  82. package/dist/tools/session-health.d.ts +2 -0
  83. package/dist/tools/session-health.js +111 -0
  84. package/dist/tools/session-health.js.map +1 -0
  85. package/dist/tools/session-stats.d.ts +2 -0
  86. package/dist/tools/session-stats.js +112 -0
  87. package/dist/tools/session-stats.js.map +1 -0
  88. package/dist/tools/sharpen-followup.d.ts +2 -0
  89. package/dist/tools/sharpen-followup.js +192 -0
  90. package/dist/tools/sharpen-followup.js.map +1 -0
  91. package/dist/tools/timeline-view.d.ts +2 -0
  92. package/dist/tools/timeline-view.js +165 -0
  93. package/dist/tools/timeline-view.js.map +1 -0
  94. package/dist/tools/token-audit.d.ts +2 -0
  95. package/dist/tools/token-audit.js +227 -0
  96. package/dist/tools/token-audit.js.map +1 -0
  97. package/dist/tools/verify-completion.d.ts +2 -0
  98. package/dist/tools/verify-completion.js +154 -0
  99. package/dist/tools/verify-completion.js.map +1 -0
  100. package/dist/tools/what-changed.d.ts +2 -0
  101. package/dist/tools/what-changed.js +40 -0
  102. package/dist/tools/what-changed.js.map +1 -0
  103. package/dist/types.d.ts +78 -0
  104. package/dist/types.js +2 -0
  105. package/dist/types.js.map +1 -0
  106. package/package.json +52 -0
  107. package/src/cli/init.ts +133 -0
  108. package/src/index.ts +135 -0
  109. package/src/lib/config.ts +157 -0
  110. package/src/lib/embeddings.ts +118 -0
  111. package/src/lib/files.ts +59 -0
  112. package/src/lib/git-extractor.ts +137 -0
  113. package/src/lib/git.ts +89 -0
  114. package/src/lib/session-parser.ts +325 -0
  115. package/src/lib/state.ts +86 -0
  116. package/src/lib/timeline-db.ts +490 -0
  117. package/src/lib/triage.ts +255 -0
  118. package/src/profiles.ts +70 -0
  119. package/src/templates/config.yml +23 -0
  120. package/src/templates/triage.yml +27 -0
  121. package/src/tools/audit-workspace.ts +97 -0
  122. package/src/tools/checkpoint.ts +119 -0
  123. package/src/tools/clarify-intent.ts +191 -0
  124. package/src/tools/enrich-agent-task.ts +108 -0
  125. package/src/tools/generate-scorecard.ts +673 -0
  126. package/src/tools/log-correction.ts +89 -0
  127. package/src/tools/onboard-project.ts +214 -0
  128. package/src/tools/preflight-check.ts +263 -0
  129. package/src/tools/prompt-score.ts +150 -0
  130. package/src/tools/scan-sessions.ts +209 -0
  131. package/src/tools/scope-work.ts +238 -0
  132. package/src/tools/search-history.ts +145 -0
  133. package/src/tools/sequence-tasks.ts +182 -0
  134. package/src/tools/session-handoff.ts +125 -0
  135. package/src/tools/session-health.ts +107 -0
  136. package/src/tools/session-stats.ts +134 -0
  137. package/src/tools/sharpen-followup.ts +200 -0
  138. package/src/tools/timeline-view.ts +181 -0
  139. package/src/tools/token-audit.ts +259 -0
  140. package/src/tools/verify-completion.ts +159 -0
  141. package/src/tools/what-changed.ts +48 -0
  142. package/src/types.ts +87 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * git-extractor.ts — Extract git commit history as timeline events.
3
+ */
4
+ import { execSync } from "child_process";
5
+ import { existsSync } from "fs";
6
+ import { join } from "path";
7
+ import { randomUUID } from "crypto";
8
+ import type { TimelineEvent } from "./session-parser.js";
9
+
10
+ // In git --format, %% produces a literal %. So we use %%…%% in the format
11
+ // string, but the actual output contains %…%. We need separate constants.
12
+ const COMMIT_SEP_FMT = "%%COMMIT_START%%";
13
+ const COMMIT_END_FMT = "%%COMMIT_END%%";
14
+ const FIELD_SEP_FMT = "%%F%%";
15
+ // What appears in the output after git processes the format:
16
+ const COMMIT_SEP = "%COMMIT_START%";
17
+ const COMMIT_END = "%COMMIT_END%";
18
+ const FIELD_SEP = "%F%";
19
+
20
+ /**
21
+ * Extract git commits as TimelineEvent[].
22
+ */
23
+ export function extractGitHistory(
24
+ projectDir: string,
25
+ opts?: { since?: Date; branch?: string; maxCount?: number },
26
+ ): TimelineEvent[] {
27
+ if (!existsSync(join(projectDir, ".git"))) {
28
+ // Try bare dir or parent — but most likely just not a repo
29
+ try {
30
+ execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" });
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ const args: string[] = ["git", "log"];
37
+
38
+ if (opts?.branch) {
39
+ args.push(opts.branch);
40
+ } else {
41
+ args.push("--all");
42
+ }
43
+
44
+ const maxCount = opts?.maxCount ?? 10000;
45
+ args.push(`--max-count=${maxCount}`);
46
+
47
+ if (opts?.since) {
48
+ args.push(`--since=${opts.since.toISOString()}`);
49
+ }
50
+
51
+ // Format: structured fields separated by known delimiters
52
+ args.push(
53
+ `--format=${COMMIT_SEP_FMT}${FIELD_SEP_FMT}%H${FIELD_SEP_FMT}%aI${FIELD_SEP_FMT}%an${FIELD_SEP_FMT}%s${FIELD_SEP_FMT}%b${FIELD_SEP_FMT}${COMMIT_END_FMT}`,
54
+ "--stat",
55
+ );
56
+
57
+ let output: string;
58
+ try {
59
+ output = execSync(args.join(" "), {
60
+ cwd: projectDir,
61
+ encoding: "utf-8",
62
+ maxBuffer: 50 * 1024 * 1024,
63
+ stdio: ["pipe", "pipe", "pipe"],
64
+ });
65
+ } catch (err: any) {
66
+ // No commits or other git error
67
+ if (err.stdout) output = err.stdout;
68
+ else return [];
69
+ }
70
+
71
+ if (!output.trim()) return [];
72
+
73
+ const projectName = projectDir.split("/").filter(Boolean).pop() ?? projectDir;
74
+ return parseGitOutput(output, projectDir, projectName);
75
+ }
76
+
77
+ // ── Internal ───────────────────────────────────────────────────────────────
78
+
79
+ function parseGitOutput(raw: string, project: string, projectName: string): TimelineEvent[] {
80
+ const events: TimelineEvent[] = [];
81
+
82
+ // Split on COMMIT_START markers
83
+ const blocks = raw.split(COMMIT_SEP).filter((b) => b.includes(COMMIT_END));
84
+
85
+ for (const block of blocks) {
86
+ try {
87
+ const endIdx = block.indexOf(COMMIT_END);
88
+ const headerPart = block.slice(0, endIdx);
89
+ const statPart = block.slice(endIdx + COMMIT_END.length).trim();
90
+
91
+ const fields = headerPart.split(FIELD_SEP);
92
+ // fields: ["", hash, date, author, subject, body, ""]
93
+ const hash = fields[1]?.trim() ?? "";
94
+ const dateStr = fields[2]?.trim() ?? "";
95
+ const author = fields[3]?.trim() ?? "";
96
+ const subject = fields[4]?.trim() ?? "";
97
+ const body = fields[5]?.trim() ?? "";
98
+
99
+ if (!hash) continue;
100
+
101
+ const commitMsg = body ? `${subject}\n\n${body}` : subject;
102
+ const content = statPart ? `${commitMsg}\n\n${statPart}` : commitMsg;
103
+
104
+ // Parse diffstat summary (last line like " 3 files changed, 10 insertions(+), 2 deletions(-)")
105
+ const statMatch = statPart.match(
106
+ /(\d+) files? changed(?:,\s*(\d+) insertions?\(\+\))?(?:,\s*(\d+) deletions?\(-\))?/,
107
+ );
108
+
109
+ const metadata = JSON.stringify({
110
+ hash,
111
+ author,
112
+ files_changed: statMatch ? parseInt(statMatch[1], 10) : 0,
113
+ insertions: statMatch?.[2] ? parseInt(statMatch[2], 10) : 0,
114
+ deletions: statMatch?.[3] ? parseInt(statMatch[3], 10) : 0,
115
+ });
116
+
117
+ events.push({
118
+ id: randomUUID(),
119
+ timestamp: new Date(dateStr).toISOString(),
120
+ type: "commit",
121
+ project,
122
+ project_name: projectName,
123
+ branch: "all",
124
+ session_id: "",
125
+ source_file: `git:${hash}`,
126
+ source_line: 0,
127
+ content,
128
+ content_preview: subject.length > 120 ? subject.slice(0, 120) + "…" : subject,
129
+ metadata,
130
+ });
131
+ } catch (err) {
132
+ process.stderr.write(`[git-extractor] failed to parse commit block: ${err}\n`);
133
+ }
134
+ }
135
+
136
+ return events;
137
+ }
package/src/lib/git.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { execFileSync } from "child_process";
2
+ import { PROJECT_DIR } from "./files.js";
3
+ import type { RunError } from "../types.js";
4
+
5
+ /**
6
+ * Run a git command safely using execFileSync (no shell injection).
7
+ * Accepts an array of args (preferred) or a string (split on whitespace for backward compat).
8
+ * Returns stdout on success. On failure, returns a descriptive error string.
9
+ */
10
+ export function run(argsOrCmd: string | string[], opts: { timeout?: number } = {}): string {
11
+ const args = typeof argsOrCmd === "string" ? argsOrCmd.split(/\s+/) : argsOrCmd;
12
+ try {
13
+ return execFileSync("git", args, {
14
+ cwd: PROJECT_DIR,
15
+ encoding: "utf-8",
16
+ timeout: opts.timeout || 10000,
17
+ maxBuffer: 1024 * 1024,
18
+ stdio: ["pipe", "pipe", "pipe"],
19
+ }).trim();
20
+ } catch (e: any) {
21
+ const timedOut = e.killed === true || e.signal === "SIGTERM";
22
+ if (timedOut) {
23
+ return `[timed out after ${opts.timeout || 10000}ms]`;
24
+ }
25
+ // Return stderr/stdout if available, otherwise the error message
26
+ const output = e.stdout?.trim() || e.stderr?.trim();
27
+ if (output) return output;
28
+ if (e.code === "ENOENT") return "[git not found]";
29
+ return `[command failed: git ${args.join(" ")} (exit ${e.status ?? "?"})]`;
30
+ }
31
+ }
32
+
33
+ /** Convenience: run a raw command string (split on spaces). Only for simple, known-safe commands. */
34
+ function gitCmd(cmdStr: string, opts?: { timeout?: number }): string {
35
+ return run(cmdStr.split(/\s+/), opts);
36
+ }
37
+
38
+ /** Get the current branch name. */
39
+ export function getBranch(): string {
40
+ return run(["branch", "--show-current"]);
41
+ }
42
+
43
+ /** Get short git status. */
44
+ export function getStatus(): string {
45
+ return run(["status", "--short"]);
46
+ }
47
+
48
+ /** Get recent commits as oneline. */
49
+ export function getRecentCommits(count = 5): string {
50
+ return run(["log", "--oneline", `-${count}`]);
51
+ }
52
+
53
+ /** Get the last commit as oneline. */
54
+ export function getLastCommit(): string {
55
+ return run(["log", "--oneline", "-1"]);
56
+ }
57
+
58
+ /** Get the last commit timestamp. */
59
+ export function getLastCommitTime(): string {
60
+ return run(["log", "-1", "--format=%ci"]);
61
+ }
62
+
63
+ /**
64
+ * Get files changed since `ref`. Falls back to HEAD~1, then "no commits".
65
+ * Uses explicit fallback steps instead of shell chaining.
66
+ */
67
+ export function getDiffFiles(ref = "HEAD~3"): string {
68
+ const result = run(["diff", "--name-only", ref]);
69
+ if (!result.startsWith("[")) return result;
70
+ const fallback = run(["diff", "--name-only", "HEAD~1"]);
71
+ if (!fallback.startsWith("[")) return fallback;
72
+ return "no commits";
73
+ }
74
+
75
+ /** Get staged files. */
76
+ export function getStagedFiles(): string {
77
+ return run(["diff", "--staged", "--name-only"]);
78
+ }
79
+
80
+ /**
81
+ * Get diff stat since `ref`. Falls back to HEAD~3.
82
+ */
83
+ export function getDiffStat(ref = "HEAD~5"): string {
84
+ const result = run(["diff", ref, "--stat"]);
85
+ if (!result.startsWith("[")) return result;
86
+ const fallback = run(["diff", "HEAD~3", "--stat"]);
87
+ if (!fallback.startsWith("[")) return fallback;
88
+ return "no diff stats available";
89
+ }
@@ -0,0 +1,325 @@
1
+ /**
2
+ * session-parser.ts — Parse Claude Code session JSONL files into timeline events.
3
+ */
4
+ import { readFileSync, readdirSync, statSync, existsSync, createReadStream } from "fs";
5
+ import { join, basename } from "path";
6
+ import { homedir } from "os";
7
+ import { randomUUID } from "crypto";
8
+ import { createInterface } from "readline";
9
+
10
+ // ── Types ──────────────────────────────────────────────────────────────────
11
+
12
+ export interface TimelineEvent {
13
+ id: string;
14
+ timestamp: string;
15
+ type: string;
16
+ project: string;
17
+ project_name: string;
18
+ branch: string;
19
+ session_id: string;
20
+ source_file: string;
21
+ source_line: number;
22
+ content: string;
23
+ content_preview: string;
24
+ metadata: string;
25
+ }
26
+
27
+ // ── Constants ──────────────────────────────────────────────────────────────
28
+
29
+ const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
30
+ const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
31
+
32
+ const CORRECTION_PATTERNS = [
33
+ /\bno\b/i, /\bwrong\b/i, /\bnot that\b/i, /\bi meant\b/i,
34
+ /\bactually\b/i, /\binstead\b/i, /\bundo\b/i,
35
+ ];
36
+
37
+ // ── Helpers ────────────────────────────────────────────────────────────────
38
+
39
+ function extractText(content: unknown): string {
40
+ if (typeof content === "string") return content;
41
+ if (Array.isArray(content)) {
42
+ return content
43
+ .filter((b: any) => b.type === "text" && typeof b.text === "string")
44
+ .map((b: any) => b.text)
45
+ .join("\n");
46
+ }
47
+ return "";
48
+ }
49
+
50
+ function extractToolUseBlocks(content: unknown): any[] {
51
+ if (!Array.isArray(content)) return [];
52
+ return content.filter((b: any) => b.type === "tool_use");
53
+ }
54
+
55
+ function normalizeTimestamp(ts: unknown, fallback: string): string {
56
+ if (!ts) return fallback;
57
+ if (typeof ts === "string") {
58
+ const d = new Date(ts);
59
+ return isNaN(d.getTime()) ? fallback : d.toISOString();
60
+ }
61
+ if (typeof ts === "number") {
62
+ // epoch seconds or ms
63
+ const d = new Date(ts < 1e12 ? ts * 1000 : ts);
64
+ return isNaN(d.getTime()) ? fallback : d.toISOString();
65
+ }
66
+ return fallback;
67
+ }
68
+
69
+ function preview(text: string, max = 120): string {
70
+ const line = text.split("\n")[0] ?? "";
71
+ return line.length > max ? line.slice(0, max) + "…" : line;
72
+ }
73
+
74
+ function isCorrection(text: string): boolean {
75
+ return CORRECTION_PATTERNS.some((p) => p.test(text));
76
+ }
77
+
78
+ function makeEvent(
79
+ partial: Omit<TimelineEvent, "id" | "content_preview"> & { content_preview?: string },
80
+ ): TimelineEvent {
81
+ return {
82
+ id: randomUUID(),
83
+ content_preview: partial.content_preview ?? preview(partial.content),
84
+ ...partial,
85
+ } as TimelineEvent;
86
+ }
87
+
88
+ // ── Public API ─────────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Discover all Claude project session directories.
92
+ */
93
+ export function findSessionDirs(): { project: string; projectName: string; sessionDir: string }[] {
94
+ if (!existsSync(CLAUDE_PROJECTS_DIR)) return [];
95
+ const results: { project: string; projectName: string; sessionDir: string }[] = [];
96
+ for (const entry of readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true })) {
97
+ if (!entry.isDirectory()) continue;
98
+ const sessionDir = join(CLAUDE_PROJECTS_DIR, entry.name);
99
+ // Decode project path: leading `-` → `/`, internal `-` are ambiguous
100
+ // but the last path segment is a reasonable "name"
101
+ const decoded = entry.name.replace(/^-/, "/").replace(/-/g, "/");
102
+ const projectName = decoded.split("/").filter(Boolean).pop() ?? entry.name;
103
+ results.push({ project: decoded, projectName, sessionDir });
104
+ }
105
+ return results;
106
+ }
107
+
108
+ /**
109
+ * List JSONL session files in a project directory (including subagent files).
110
+ */
111
+ export function findSessionFiles(
112
+ projectDir: string,
113
+ ): { sessionId: string; path: string; mtime: Date }[] {
114
+ if (!existsSync(projectDir)) return [];
115
+ const results: { sessionId: string; path: string; mtime: Date }[] = [];
116
+
117
+ for (const entry of readdirSync(projectDir, { withFileTypes: true })) {
118
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
119
+ const p = join(projectDir, entry.name);
120
+ results.push({
121
+ sessionId: basename(entry.name, ".jsonl"),
122
+ path: p,
123
+ mtime: statSync(p).mtime,
124
+ });
125
+ }
126
+ // Check for subagent dirs: <uuid>/subagents/<sub-uuid>.jsonl
127
+ if (entry.isDirectory()) {
128
+ const subDir = join(projectDir, entry.name, "subagents");
129
+ if (existsSync(subDir)) {
130
+ for (const sub of readdirSync(subDir)) {
131
+ if (sub.endsWith(".jsonl")) {
132
+ const p = join(subDir, sub);
133
+ results.push({
134
+ sessionId: basename(sub, ".jsonl"),
135
+ path: p,
136
+ mtime: statSync(p).mtime,
137
+ });
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ return results;
144
+ }
145
+
146
+ /**
147
+ * Parse a single JSONL session file into timeline events.
148
+ * For files >10MB, streams line-by-line.
149
+ */
150
+ export function parseSession(
151
+ filePath: string,
152
+ project: string,
153
+ projectName: string,
154
+ ): TimelineEvent[] {
155
+ const stat = statSync(filePath);
156
+ const fallbackTs = stat.mtime.toISOString();
157
+
158
+ if (stat.size > LARGE_FILE_THRESHOLD) {
159
+ // Return empty — caller should use parseSessionAsync for large files
160
+ // But for sync API compat, read anyway with a buffer
161
+ }
162
+
163
+ const lines = readFileSync(filePath, "utf-8").split("\n");
164
+ return parseLinesSync(lines, filePath, project, projectName, fallbackTs);
165
+ }
166
+
167
+ /**
168
+ * Async streaming parser for large files.
169
+ */
170
+ export async function parseSessionAsync(
171
+ filePath: string,
172
+ project: string,
173
+ projectName: string,
174
+ ): Promise<TimelineEvent[]> {
175
+ const stat = statSync(filePath);
176
+ const fallbackTs = stat.mtime.toISOString();
177
+ const events: TimelineEvent[] = [];
178
+ let branch = "";
179
+ let sessionId = basename(filePath, ".jsonl");
180
+ let lastType = "";
181
+ let lineNum = 0;
182
+
183
+ const rl = createInterface({ input: createReadStream(filePath, "utf-8"), crlfDelay: Infinity });
184
+
185
+ for await (const line of rl) {
186
+ lineNum++;
187
+ if (!line.trim()) continue;
188
+ let obj: any;
189
+ try {
190
+ obj = JSON.parse(line);
191
+ } catch {
192
+ process.stderr.write(`[session-parser] malformed line ${lineNum} in ${filePath}\n`);
193
+ continue;
194
+ }
195
+ const evts = processRecord(obj, filePath, project, projectName, branch, sessionId, fallbackTs, lineNum, lastType);
196
+ if (obj.type === "summary") {
197
+ branch = obj.gitBranch ?? "";
198
+ if (obj.sessionId) sessionId = obj.sessionId;
199
+ }
200
+ if (obj.type === "user" || obj.type === "assistant") lastType = obj.type;
201
+ events.push(...evts);
202
+ }
203
+ return events;
204
+ }
205
+
206
+ // ── Internal ───────────────────────────────────────────────────────────────
207
+
208
+ function parseLinesSync(
209
+ lines: string[],
210
+ filePath: string,
211
+ project: string,
212
+ projectName: string,
213
+ fallbackTs: string,
214
+ ): TimelineEvent[] {
215
+ const events: TimelineEvent[] = [];
216
+ let branch = "";
217
+ let sessionId = basename(filePath, ".jsonl");
218
+ let lastType = "";
219
+
220
+ for (let i = 0; i < lines.length; i++) {
221
+ const line = lines[i].trim();
222
+ if (!line) continue;
223
+ let obj: any;
224
+ try {
225
+ obj = JSON.parse(line);
226
+ } catch {
227
+ process.stderr.write(`[session-parser] malformed line ${i + 1} in ${filePath}\n`);
228
+ continue;
229
+ }
230
+
231
+ if (obj.type === "summary") {
232
+ branch = obj.gitBranch ?? "";
233
+ if (obj.sessionId) sessionId = obj.sessionId;
234
+ continue;
235
+ }
236
+
237
+ const evts = processRecord(obj, filePath, project, projectName, branch, sessionId, fallbackTs, i + 1, lastType);
238
+ if (obj.type === "user" || obj.type === "assistant") lastType = obj.type;
239
+ events.push(...evts);
240
+ }
241
+ return events;
242
+ }
243
+
244
+ function processRecord(
245
+ obj: any,
246
+ filePath: string,
247
+ project: string,
248
+ projectName: string,
249
+ branch: string,
250
+ sessionId: string,
251
+ fallbackTs: string,
252
+ lineNum: number,
253
+ lastType: string,
254
+ ): TimelineEvent[] {
255
+ const ts = normalizeTimestamp(obj.timestamp, fallbackTs);
256
+ const base = { project, project_name: projectName, branch, session_id: sessionId, source_file: filePath, source_line: lineNum };
257
+ const events: TimelineEvent[] = [];
258
+
259
+ if (obj.type === "user") {
260
+ const text = extractText(obj.message?.content);
261
+ if (!text) return events;
262
+ const isCorr = lastType === "assistant" && isCorrection(text);
263
+ events.push(makeEvent({ ...base, timestamp: ts, type: isCorr ? "correction" : "prompt", content: text, metadata: "{}" }));
264
+ } else if (obj.type === "assistant") {
265
+ const content = obj.message?.content;
266
+ const text = extractText(content);
267
+ if (text) {
268
+ events.push(makeEvent({ ...base, timestamp: ts, type: "assistant", content: text, metadata: JSON.stringify({ model: obj.model ?? "" }) }));
269
+ }
270
+ for (const tool of extractToolUseBlocks(content)) {
271
+ const name: string = tool.name ?? "unknown";
272
+ const argsStr = typeof tool.input === "string" ? tool.input : JSON.stringify(tool.input ?? {});
273
+ const isSub = name === "Task" || name === "dispatch_agent";
274
+ events.push(makeEvent({
275
+ ...base,
276
+ timestamp: ts,
277
+ type: isSub ? "sub_agent_spawn" : "tool_call",
278
+ content: `${name}: ${argsStr.slice(0, 100)}`,
279
+ metadata: JSON.stringify({ tool: name }),
280
+ }));
281
+ }
282
+ } else if (obj.type === "tool_result") {
283
+ const isErr = obj.is_error === true || (typeof obj.content === "string" && /stderr/i.test(obj.content));
284
+ if (isErr) {
285
+ const text = extractText(obj.content) || JSON.stringify(obj.content ?? "").slice(0, 200);
286
+ events.push(makeEvent({ ...base, timestamp: ts, type: "error", content: text, metadata: JSON.stringify({ tool_use_id: obj.tool_use_id ?? "" }) }));
287
+ }
288
+ } else if (obj.type === "system") {
289
+ const text = extractText(obj.message?.content ?? obj.content ?? "");
290
+ if (/compact/i.test(text) || obj.subtype === "compaction") {
291
+ events.push(makeEvent({ ...base, timestamp: ts, type: "compaction", content: text || "context compacted", metadata: "{}" }));
292
+ }
293
+ }
294
+
295
+ return events;
296
+ }
297
+
298
+ /**
299
+ * Parse all sessions for a project directory, optionally filtering by mtime.
300
+ */
301
+ export function parseAllSessions(
302
+ projectDir: string,
303
+ opts?: { since?: Date },
304
+ ): TimelineEvent[] {
305
+ const files = findSessionFiles(projectDir);
306
+ const { project, projectName } = inferProject(projectDir);
307
+ const events: TimelineEvent[] = [];
308
+
309
+ for (const f of files) {
310
+ if (opts?.since && f.mtime < opts.since) continue;
311
+ try {
312
+ events.push(...parseSession(f.path, project, projectName));
313
+ } catch (err) {
314
+ process.stderr.write(`[session-parser] failed to parse ${f.path}: ${err}\n`);
315
+ }
316
+ }
317
+ return events.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
318
+ }
319
+
320
+ function inferProject(projectDir: string): { project: string; projectName: string } {
321
+ const dir = basename(projectDir);
322
+ const decoded = dir.replace(/^-/, "/").replace(/-/g, "/");
323
+ const projectName = decoded.split("/").filter(Boolean).pop() ?? dir;
324
+ return { project: decoded, projectName };
325
+ }
@@ -0,0 +1,86 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, statSync, renameSync } from "fs";
2
+ import { join } from "path";
3
+ import { PROJECT_DIR } from "./files.js";
4
+
5
+ export const STATE_DIR = join(PROJECT_DIR, ".claude", "preflight-state");
6
+
7
+ /** Max log file size in bytes (5 MB). Triggers rotation. */
8
+ const MAX_LOG_SIZE = 5 * 1024 * 1024;
9
+
10
+ /** Lazily ensures the state directory exists. Called before any write. */
11
+ function ensureStateDir(): void {
12
+ if (!existsSync(STATE_DIR)) {
13
+ mkdirSync(STATE_DIR, { recursive: true });
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Load a JSON state file by name (without extension).
19
+ * Returns empty object if missing or corrupt.
20
+ */
21
+ export function loadState(name: string): Record<string, any> {
22
+ const p = join(STATE_DIR, `${name}.json`);
23
+ if (!existsSync(p)) return {};
24
+ try {
25
+ return JSON.parse(readFileSync(p, "utf-8"));
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Save a JSON state file by name (without extension).
33
+ */
34
+ export function saveState(name: string, data: Record<string, any>): void {
35
+ ensureStateDir();
36
+ writeFileSync(join(STATE_DIR, `${name}.json`), JSON.stringify(data, null, 2));
37
+ }
38
+
39
+ /**
40
+ * Append a JSONL entry to a log file. Rotates if file exceeds MAX_LOG_SIZE.
41
+ */
42
+ export function appendLog(filename: string, entry: Record<string, any>): void {
43
+ ensureStateDir();
44
+ const logFile = join(STATE_DIR, filename);
45
+
46
+ // Rotate if too large
47
+ if (existsSync(logFile)) {
48
+ try {
49
+ const size = statSync(logFile).size;
50
+ if (size > MAX_LOG_SIZE) {
51
+ const backup = logFile + ".old";
52
+ // Keep only one backup; overwrite previous
53
+ renameSync(logFile, backup);
54
+ }
55
+ } catch { /* stat/rename failure is non-fatal */ }
56
+ }
57
+
58
+ appendFileSync(logFile, JSON.stringify(entry) + "\n");
59
+ }
60
+
61
+ /**
62
+ * Read a JSONL log file. Pass `lastN` to only return the last N entries
63
+ * (still reads the file, but avoids allocating all parsed objects).
64
+ */
65
+ export function readLog(filename: string, lastN?: number): Record<string, any>[] {
66
+ const logFile = join(STATE_DIR, filename);
67
+ if (!existsSync(logFile)) return [];
68
+ try {
69
+ const raw = readFileSync(logFile, "utf-8").trim();
70
+ if (!raw) return [];
71
+ const lines = raw.split("\n");
72
+ const subset = lastN != null && lastN > 0 ? lines.slice(-lastN) : lines;
73
+ const results: Record<string, any>[] = [];
74
+ for (const line of subset) {
75
+ try { results.push(JSON.parse(line)); } catch { /* skip corrupt line */ }
76
+ }
77
+ return results;
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ /** ISO timestamp for the current moment. */
84
+ export function now(): string {
85
+ return new Date().toISOString();
86
+ }