@ulpi/cli 0.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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -0
  3. package/dist/auth-PN7TMQHV-2W4ICG64.js +15 -0
  4. package/dist/chunk-247GVVKK.js +2259 -0
  5. package/dist/chunk-2CLNOKPA.js +793 -0
  6. package/dist/chunk-2HEE5OKX.js +79 -0
  7. package/dist/chunk-2MZER6ND.js +415 -0
  8. package/dist/chunk-3SBPZRB5.js +772 -0
  9. package/dist/chunk-4VNS5WPM.js +42 -0
  10. package/dist/chunk-6JCMYYBT.js +1546 -0
  11. package/dist/chunk-6OCEY7JY.js +422 -0
  12. package/dist/chunk-74WVVWJ4.js +375 -0
  13. package/dist/chunk-7AL4DOEJ.js +131 -0
  14. package/dist/chunk-7LXY5UVC.js +330 -0
  15. package/dist/chunk-DBMUNBNB.js +3048 -0
  16. package/dist/chunk-JWUUVXIV.js +13694 -0
  17. package/dist/chunk-KIKPIH6N.js +4048 -0
  18. package/dist/chunk-KLEASXUR.js +70 -0
  19. package/dist/chunk-MIAQVCFW.js +39 -0
  20. package/dist/chunk-NNUWU6CV.js +1610 -0
  21. package/dist/chunk-PKD4ASEM.js +115 -0
  22. package/dist/chunk-Q4HIY43N.js +4230 -0
  23. package/dist/chunk-QJ5GSMEC.js +146 -0
  24. package/dist/chunk-SIAQVRKG.js +2163 -0
  25. package/dist/chunk-SPOI23SB.js +197 -0
  26. package/dist/chunk-YM2HV4IA.js +505 -0
  27. package/dist/codemap-RRJIDBQ5.js +636 -0
  28. package/dist/config-EGAXXCGL.js +127 -0
  29. package/dist/dist-6G7JC2RA.js +90 -0
  30. package/dist/dist-7LHZ65GC.js +418 -0
  31. package/dist/dist-LZKZFPVX.js +140 -0
  32. package/dist/dist-R5F4MX3I.js +107 -0
  33. package/dist/dist-R5ZJ4LX5.js +56 -0
  34. package/dist/dist-RJGCUS3L.js +87 -0
  35. package/dist/dist-RKOGLK7R.js +151 -0
  36. package/dist/dist-W7K4WPAF.js +597 -0
  37. package/dist/export-import-4A5MWLIA.js +53 -0
  38. package/dist/history-ATTUKOHO.js +934 -0
  39. package/dist/index.js +2120 -0
  40. package/dist/init-AY5C2ZAS.js +393 -0
  41. package/dist/launchd-LF2QMSKZ.js +148 -0
  42. package/dist/log-TVTUXAYD.js +75 -0
  43. package/dist/mcp-installer-NQCGKQ23.js +124 -0
  44. package/dist/memory-J3G24QHS.js +406 -0
  45. package/dist/ollama-3XCUZMZT-FYKHW4TZ.js +7 -0
  46. package/dist/openai-E7G2YAHU-UYY4ZWON.js +8 -0
  47. package/dist/projects-ATHDD3D6.js +271 -0
  48. package/dist/review-ADUPV3PN.js +152 -0
  49. package/dist/rules-E427DKYJ.js +134 -0
  50. package/dist/server-MOYPE4SM-N7SE2AN7.js +18 -0
  51. package/dist/server-X5P6WH2M-7K2RY34N.js +11 -0
  52. package/dist/skills/ulpi-generate-guardian/SKILL.md +511 -0
  53. package/dist/skills/ulpi-generate-guardian/references/framework-rules.md +692 -0
  54. package/dist/skills/ulpi-generate-guardian/references/language-rules.md +596 -0
  55. package/dist/skills-CX73O3IV.js +76 -0
  56. package/dist/status-4DFHDJMN.js +66 -0
  57. package/dist/templates/biome.yml +24 -0
  58. package/dist/templates/conventional-commits.yml +18 -0
  59. package/dist/templates/django.yml +30 -0
  60. package/dist/templates/docker.yml +30 -0
  61. package/dist/templates/eslint.yml +13 -0
  62. package/dist/templates/express.yml +20 -0
  63. package/dist/templates/fastapi.yml +23 -0
  64. package/dist/templates/git-flow.yml +26 -0
  65. package/dist/templates/github-flow.yml +27 -0
  66. package/dist/templates/go.yml +33 -0
  67. package/dist/templates/jest.yml +24 -0
  68. package/dist/templates/laravel.yml +30 -0
  69. package/dist/templates/monorepo.yml +26 -0
  70. package/dist/templates/nestjs.yml +21 -0
  71. package/dist/templates/nextjs.yml +31 -0
  72. package/dist/templates/nodejs.yml +33 -0
  73. package/dist/templates/npm.yml +15 -0
  74. package/dist/templates/php.yml +25 -0
  75. package/dist/templates/pnpm.yml +15 -0
  76. package/dist/templates/prettier.yml +23 -0
  77. package/dist/templates/prisma.yml +21 -0
  78. package/dist/templates/python.yml +33 -0
  79. package/dist/templates/quality-of-life.yml +111 -0
  80. package/dist/templates/ruby.yml +25 -0
  81. package/dist/templates/rust.yml +34 -0
  82. package/dist/templates/typescript.yml +14 -0
  83. package/dist/templates/vitest.yml +24 -0
  84. package/dist/templates/yarn.yml +15 -0
  85. package/dist/templates-U7T6MARD.js +156 -0
  86. package/dist/ui-L7UAWXDY.js +167 -0
  87. package/dist/ui.html +698 -0
  88. package/dist/ulpi-RMMCUAGP-JCJ273T6.js +161 -0
  89. package/dist/uninstall-6SW35IK4.js +25 -0
  90. package/dist/update-M2B4RLGH.js +61 -0
  91. package/dist/version-checker-ANCS3IHR.js +10 -0
  92. package/package.json +92 -0
@@ -0,0 +1,1610 @@
1
+ import {
2
+ JsonSessionStore,
3
+ readEvents
4
+ } from "./chunk-YM2HV4IA.js";
5
+ import {
6
+ DEFAULT_AI_MODEL,
7
+ REVIEWS_DIR,
8
+ REVIEW_IMAGES_DIR,
9
+ getHistoryBranch,
10
+ projectGuardsFile,
11
+ projectGuardsFileAlt
12
+ } from "./chunk-7LXY5UVC.js";
13
+
14
+ // ../../packages/history-engine/dist/index.js
15
+ import { execFileSync } from "child_process";
16
+ import * as fs from "fs";
17
+ import * as path from "path";
18
+ import * as os from "os";
19
+ import * as fs2 from "fs";
20
+ import * as path2 from "path";
21
+ import * as os2 from "os";
22
+ import * as fs3 from "fs";
23
+ import * as path3 from "path";
24
+ import { createHash } from "crypto";
25
+ import * as fs4 from "fs";
26
+ import * as fs5 from "fs";
27
+ import * as path4 from "path";
28
+ var EXEC_TIMEOUT = 1e4;
29
+ var MAX_BUFFER = 5e7;
30
+ function validateSha(sha) {
31
+ if (!/^[0-9a-f]{4,40}$/i.test(sha)) {
32
+ throw new Error("Invalid SHA");
33
+ }
34
+ }
35
+ function validateWorktreeDir(dir) {
36
+ if (!dir || !path.isAbsolute(dir)) {
37
+ throw new Error("Invalid worktree directory: must be absolute path");
38
+ }
39
+ }
40
+ function validateBranchPath(filePath) {
41
+ if (filePath.includes("..") || filePath.startsWith("/")) {
42
+ throw new Error("Invalid branch path");
43
+ }
44
+ }
45
+ function gitExec(projectDir, args, opts) {
46
+ const timeout = opts?.timeout ?? EXEC_TIMEOUT;
47
+ return execFileSync("git", args, {
48
+ cwd: projectDir,
49
+ encoding: "utf-8",
50
+ timeout,
51
+ stdio: ["pipe", "pipe", "pipe"],
52
+ input: opts?.input,
53
+ maxBuffer: MAX_BUFFER
54
+ }).trim();
55
+ }
56
+ function historyBranchExists(projectDir, branchName) {
57
+ branchName ??= getHistoryBranch();
58
+ try {
59
+ gitExec(projectDir, ["rev-parse", "--verify", `refs/heads/${branchName}`]);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+ function initHistoryBranch(projectDir, projectName, ulpiVersion, branchName) {
66
+ branchName ??= getHistoryBranch();
67
+ if (historyBranchExists(projectDir, branchName)) {
68
+ throw new Error(`Branch "${branchName}" already exists`);
69
+ }
70
+ const readme = [
71
+ `# ULPI History: ${projectName}`,
72
+ "",
73
+ "This branch is maintained by [ULPI](https://github.com/nicholasgriffintn/ulpi).",
74
+ "It stores session metadata for each commit \u2014 separately from your code.",
75
+ "",
76
+ "## How It Works",
77
+ "",
78
+ "- Each commit on your code branches gets a corresponding entry here",
79
+ "- Entries include: git metadata, diff stats, session state, events, active rules",
80
+ "- AI enrichment can be added with `ulpi history enrich`",
81
+ "",
82
+ "## Browse",
83
+ "",
84
+ "| Date | Commit | Branch | Summary | Files | Session | Review | AI |",
85
+ "|------|--------|--------|---------|-------|---------|--------|----|",
86
+ "| _(no entries yet)_ | | | | | | | |",
87
+ "",
88
+ `_Last updated: ${(/* @__PURE__ */ new Date()).toISOString()}_`
89
+ ].join("\n");
90
+ const meta = JSON.stringify(
91
+ {
92
+ version: 1,
93
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
94
+ projectDir,
95
+ projectName,
96
+ totalEntries: 0,
97
+ ulpiVersion,
98
+ config: {
99
+ enabled: true,
100
+ branchName,
101
+ aiEnrichment: true,
102
+ aiModel: DEFAULT_AI_MODEL,
103
+ maxDiffSize: 5e4,
104
+ maxAiDiffSize: 1e4,
105
+ collectReviewPlans: false,
106
+ captureTranscript: true,
107
+ maxTranscriptSize: 5242880,
108
+ captureStrategy: "session-end"
109
+ }
110
+ },
111
+ null,
112
+ 2
113
+ );
114
+ const timeline = JSON.stringify(
115
+ {
116
+ version: 1,
117
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
118
+ projectDir,
119
+ entries: []
120
+ },
121
+ null,
122
+ 2
123
+ );
124
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ulpi-init-"));
125
+ try {
126
+ const readmePath = path.join(tmpDir, "README.md");
127
+ const metaPath = path.join(tmpDir, "meta.json");
128
+ const timelinePath = path.join(tmpDir, "timeline.json");
129
+ fs.writeFileSync(readmePath, readme, "utf-8");
130
+ fs.writeFileSync(metaPath, meta, "utf-8");
131
+ fs.writeFileSync(timelinePath, timeline, "utf-8");
132
+ const readmeHash = gitExec(projectDir, [
133
+ "hash-object",
134
+ "-w",
135
+ readmePath
136
+ ]);
137
+ const metaHash = gitExec(projectDir, ["hash-object", "-w", metaPath]);
138
+ const timelineHash = gitExec(projectDir, [
139
+ "hash-object",
140
+ "-w",
141
+ timelinePath
142
+ ]);
143
+ const emptyTreeHash = gitExec(projectDir, ["mktree", "--missing"], { input: "" });
144
+ const treeContent = [
145
+ `100644 blob ${readmeHash} README.md`,
146
+ `100644 blob ${metaHash} meta.json`,
147
+ `100644 blob ${timelineHash} timeline.json`,
148
+ `040000 tree ${emptyTreeHash} entries`
149
+ ].join("\n");
150
+ const treeHash = gitExec(projectDir, ["mktree"], { input: treeContent });
151
+ const commitHash = gitExec(projectDir, [
152
+ "commit-tree",
153
+ treeHash,
154
+ "-m",
155
+ "chore: initialize ulpi history branch"
156
+ ]);
157
+ gitExec(projectDir, [
158
+ "update-ref",
159
+ `refs/heads/${branchName}`,
160
+ commitHash
161
+ ]);
162
+ } finally {
163
+ fs.rmSync(tmpDir, { recursive: true, force: true });
164
+ }
165
+ }
166
+ function getCommitMetadata(projectDir, sha) {
167
+ validateSha(sha);
168
+ const format = "%H%n%h%n%s%n%B%n%an%n%ae%n%aI";
169
+ const raw = gitExec(projectDir, [
170
+ "log",
171
+ "-1",
172
+ `--format=${format}`,
173
+ sha
174
+ ]);
175
+ const lines = raw.split("\n");
176
+ const fullSha = lines[0];
177
+ const shortSha = lines[1];
178
+ const subject = lines[2];
179
+ const authorName = lines[lines.length - 3];
180
+ const authorEmail = lines[lines.length - 2];
181
+ const authorDate = lines[lines.length - 1];
182
+ const message = lines.slice(3, lines.length - 3).join("\n").trim();
183
+ const parentsRaw = gitExec(projectDir, [
184
+ "log",
185
+ "-1",
186
+ "--format=%P",
187
+ sha
188
+ ]);
189
+ const parents = parentsRaw ? parentsRaw.split(" ").filter(Boolean) : [];
190
+ let branch = "unknown";
191
+ try {
192
+ const refs = gitExec(projectDir, [
193
+ "log",
194
+ "-1",
195
+ "--format=%D",
196
+ sha
197
+ ]);
198
+ if (refs) {
199
+ const branchRef = refs.split(",").map((r) => r.trim()).find((r) => r.startsWith("HEAD -> "));
200
+ if (branchRef) {
201
+ branch = branchRef.replace("HEAD -> ", "");
202
+ } else {
203
+ const localRef = refs.split(",").map((r) => r.trim()).find((r) => !r.startsWith("origin/") && !r.startsWith("tag:") && r !== "HEAD");
204
+ if (localRef) branch = localRef;
205
+ }
206
+ }
207
+ } catch {
208
+ }
209
+ return {
210
+ sha: fullSha,
211
+ shortSha,
212
+ message: message || subject,
213
+ subject,
214
+ authorName,
215
+ authorEmail,
216
+ authorDate,
217
+ branch,
218
+ parents
219
+ };
220
+ }
221
+ function getCommitDiffStats(projectDir, sha) {
222
+ validateSha(sha);
223
+ let raw;
224
+ try {
225
+ raw = gitExec(projectDir, [
226
+ "diff-tree",
227
+ "--numstat",
228
+ "-r",
229
+ "--no-commit-id",
230
+ sha
231
+ ]);
232
+ } catch {
233
+ return { filesChanged: 0, insertions: 0, deletions: 0, files: [] };
234
+ }
235
+ if (!raw) {
236
+ return { filesChanged: 0, insertions: 0, deletions: 0, files: [] };
237
+ }
238
+ const files = [];
239
+ let totalInsertions = 0;
240
+ let totalDeletions = 0;
241
+ for (const line of raw.split("\n")) {
242
+ if (!line.trim()) continue;
243
+ const parts = line.split(" ");
244
+ if (parts.length < 3) continue;
245
+ const additions = parts[0] === "-" ? 0 : parseInt(parts[0], 10);
246
+ const deletions = parts[1] === "-" ? 0 : parseInt(parts[1], 10);
247
+ const filePath = parts[2];
248
+ let status = "modified";
249
+ let oldPathValue;
250
+ if (filePath.includes(" => ")) {
251
+ status = "renamed";
252
+ const match = filePath.match(/^(.+) => (.+)$/);
253
+ if (match) {
254
+ oldPathValue = match[1];
255
+ }
256
+ }
257
+ totalInsertions += additions;
258
+ totalDeletions += deletions;
259
+ files.push({
260
+ path: filePath,
261
+ status,
262
+ additions,
263
+ deletions,
264
+ oldPath: oldPathValue
265
+ });
266
+ }
267
+ try {
268
+ const statusRaw = gitExec(projectDir, [
269
+ "diff-tree",
270
+ "-r",
271
+ "--no-commit-id",
272
+ "--name-status",
273
+ sha
274
+ ]);
275
+ const statusMap = /* @__PURE__ */ new Map();
276
+ for (const line of statusRaw.split("\n")) {
277
+ if (!line.trim()) continue;
278
+ const [flag, ...rest] = line.split(" ");
279
+ const fPath = rest[rest.length - 1];
280
+ if (flag && fPath) statusMap.set(fPath, flag[0]);
281
+ }
282
+ for (const file of files) {
283
+ const flag = statusMap.get(file.path);
284
+ if (flag === "A") file.status = "added";
285
+ else if (flag === "D") file.status = "deleted";
286
+ else if (flag === "R") file.status = "renamed";
287
+ else if (flag === "C") file.status = "copied";
288
+ }
289
+ } catch {
290
+ }
291
+ return {
292
+ filesChanged: files.length,
293
+ insertions: totalInsertions,
294
+ deletions: totalDeletions,
295
+ files
296
+ };
297
+ }
298
+ function getCommitRawDiff(projectDir, sha, maxSize = 5e4) {
299
+ validateSha(sha);
300
+ let raw;
301
+ try {
302
+ raw = gitExec(projectDir, ["diff-tree", "-p", "--no-commit-id", sha]);
303
+ } catch {
304
+ return { diff: "", truncated: false };
305
+ }
306
+ if (raw.length > maxSize) {
307
+ return {
308
+ diff: raw.slice(0, maxSize) + "\n\n... (truncated)",
309
+ truncated: true
310
+ };
311
+ }
312
+ return { diff: raw, truncated: false };
313
+ }
314
+ function getCurrentHead(projectDir) {
315
+ try {
316
+ return gitExec(projectDir, ["rev-parse", "HEAD"]);
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+ function listCommitsBetween(projectDir, startSha, endSha) {
322
+ validateSha(startSha);
323
+ validateSha(endSha);
324
+ try {
325
+ const raw = gitExec(projectDir, [
326
+ "rev-list",
327
+ "--reverse",
328
+ `${startSha}..${endSha}`
329
+ ]);
330
+ return raw ? raw.split("\n").filter(Boolean) : [];
331
+ } catch {
332
+ return [];
333
+ }
334
+ }
335
+ function listRecentCommits(projectDir, maxCount = 20) {
336
+ try {
337
+ const raw = gitExec(projectDir, [
338
+ "rev-list",
339
+ "--reverse",
340
+ "HEAD",
341
+ `--max-count=${maxCount}`
342
+ ]);
343
+ return raw ? raw.split("\n").filter(Boolean) : [];
344
+ } catch {
345
+ return [];
346
+ }
347
+ }
348
+ function readFromBranch(projectDir, filePath, branchName) {
349
+ branchName ??= getHistoryBranch();
350
+ validateBranchPath(filePath);
351
+ try {
352
+ return gitExec(projectDir, [
353
+ "show",
354
+ `${branchName}:${filePath}`
355
+ ]);
356
+ } catch (err) {
357
+ const message = err instanceof Error ? err.message : String(err);
358
+ if (message.includes("does not exist") || message.includes("not found") || message.includes("exists on disk, but not in")) {
359
+ return null;
360
+ }
361
+ if (typeof process !== "undefined" && process.env?.ULPI_DEBUG) {
362
+ console.error(`[ulpi] readFromBranch error for "${filePath}":`, message);
363
+ }
364
+ return null;
365
+ }
366
+ }
367
+ function listBranchDir(projectDir, dirPath, branchName) {
368
+ branchName ??= getHistoryBranch();
369
+ validateBranchPath(dirPath);
370
+ try {
371
+ const raw = gitExec(projectDir, [
372
+ "ls-tree",
373
+ "--name-only",
374
+ `${branchName}:${dirPath}`
375
+ ]);
376
+ return raw ? raw.split("\n").filter(Boolean) : [];
377
+ } catch {
378
+ return [];
379
+ }
380
+ }
381
+ function getWorktreeId(projectDir) {
382
+ try {
383
+ const gitPath = path.join(projectDir, ".git");
384
+ const stat = fs.statSync(gitPath);
385
+ if (stat.isDirectory()) return null;
386
+ const content = fs.readFileSync(gitPath, "utf-8").trim();
387
+ const match = content.match(/worktrees\/([^/\s]+)/);
388
+ return match ? match[1] : null;
389
+ } catch {
390
+ return null;
391
+ }
392
+ }
393
+ var LOCK_TIMEOUT_MS = 1e4;
394
+ var LOCK_RETRY_MS = 200;
395
+ var LOCK_STALE_MS = 6e4;
396
+ function getLockPath(branchName, projectDir) {
397
+ const safeName = branchName.replace(/\//g, "-");
398
+ const worktreeId = projectDir ? getWorktreeId(projectDir) : null;
399
+ const suffix = worktreeId ? `-${worktreeId}` : "";
400
+ return path.join(os.tmpdir(), `ulpi-worktree-${safeName}${suffix}.lock`);
401
+ }
402
+ function acquireLock(branchName, projectDir) {
403
+ const lockPath = getLockPath(branchName, projectDir);
404
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
405
+ while (Date.now() < deadline) {
406
+ try {
407
+ const fd = fs.openSync(lockPath, "wx");
408
+ const info = JSON.stringify({ pid: process.pid, ts: Date.now() });
409
+ fs.writeSync(fd, info);
410
+ fs.closeSync(fd);
411
+ return;
412
+ } catch (err) {
413
+ if (err.code !== "EEXIST") throw err;
414
+ try {
415
+ const raw = fs.readFileSync(lockPath, "utf-8");
416
+ const lock = JSON.parse(raw);
417
+ const isStale = Date.now() - lock.ts > LOCK_STALE_MS || !isProcessAlive(lock.pid);
418
+ if (isStale) {
419
+ fs.unlinkSync(lockPath);
420
+ continue;
421
+ }
422
+ } catch {
423
+ try {
424
+ fs.unlinkSync(lockPath);
425
+ } catch {
426
+ }
427
+ continue;
428
+ }
429
+ const waitMs = Math.min(LOCK_RETRY_MS, deadline - Date.now());
430
+ if (waitMs > 0) {
431
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs);
432
+ }
433
+ }
434
+ }
435
+ throw new Error(`Timed out waiting for worktree lock on ${branchName}`);
436
+ }
437
+ function releaseLock(branchName, projectDir) {
438
+ try {
439
+ fs.unlinkSync(getLockPath(branchName, projectDir));
440
+ } catch {
441
+ }
442
+ }
443
+ function isProcessAlive(pid) {
444
+ try {
445
+ process.kill(pid, 0);
446
+ return true;
447
+ } catch {
448
+ return false;
449
+ }
450
+ }
451
+ async function withWorktree(projectDir, branchName, callback) {
452
+ const worktreeDir = path.join(
453
+ os.tmpdir(),
454
+ `ulpi-worktree-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
455
+ );
456
+ acquireLock(branchName, projectDir);
457
+ let worktreeAdded = false;
458
+ try {
459
+ gitExec(projectDir, [
460
+ "worktree",
461
+ "add",
462
+ worktreeDir,
463
+ branchName
464
+ ]);
465
+ worktreeAdded = true;
466
+ return await callback(worktreeDir);
467
+ } finally {
468
+ if (worktreeAdded) {
469
+ try {
470
+ gitExec(projectDir, ["worktree", "remove", "--force", worktreeDir]);
471
+ } catch {
472
+ try {
473
+ fs.rmSync(worktreeDir, { recursive: true, force: true });
474
+ gitExec(projectDir, ["worktree", "prune"]);
475
+ } catch {
476
+ }
477
+ }
478
+ } else {
479
+ try {
480
+ fs.rmSync(worktreeDir, { recursive: true, force: true });
481
+ } catch {
482
+ }
483
+ }
484
+ releaseLock(branchName, projectDir);
485
+ }
486
+ }
487
+ function writeAndStage(worktreeDir, relativePath, content) {
488
+ validateWorktreeDir(worktreeDir);
489
+ validateBranchPath(relativePath);
490
+ const fullPath = path.join(worktreeDir, relativePath);
491
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
492
+ if (typeof content === "string") {
493
+ fs.writeFileSync(fullPath, content, "utf-8");
494
+ } else {
495
+ fs.writeFileSync(fullPath, content);
496
+ }
497
+ gitExec(worktreeDir, ["add", relativePath]);
498
+ }
499
+ function copyAndStage(worktreeDir, relativePath, sourcePath) {
500
+ validateWorktreeDir(worktreeDir);
501
+ validateBranchPath(relativePath);
502
+ const fullPath = path.join(worktreeDir, relativePath);
503
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
504
+ fs.copyFileSync(sourcePath, fullPath);
505
+ gitExec(worktreeDir, ["add", relativePath]);
506
+ }
507
+ function commitInWorktree(worktreeDir, message) {
508
+ validateWorktreeDir(worktreeDir);
509
+ try {
510
+ gitExec(worktreeDir, ["diff", "--cached", "--quiet"]);
511
+ return gitExec(worktreeDir, ["rev-parse", "HEAD"]);
512
+ } catch {
513
+ }
514
+ gitExec(worktreeDir, ["commit", "-m", message]);
515
+ return gitExec(worktreeDir, ["rev-parse", "HEAD"]);
516
+ }
517
+ function buildSessionSummary(state, events) {
518
+ const eventCounts = countEventsByType(events);
519
+ const blockedTools = /* @__PURE__ */ new Set();
520
+ const autoApproved = /* @__PURE__ */ new Set();
521
+ const preconditionsFired = /* @__PURE__ */ new Set();
522
+ const postconditionsRun = /* @__PURE__ */ new Set();
523
+ const skillsInjected = /* @__PURE__ */ new Set();
524
+ for (const ev of events) {
525
+ if (ev.event === "tool_blocked" && ev.toolName) {
526
+ blockedTools.add(ev.toolName);
527
+ }
528
+ if (ev.event === "permission_allow" && ev.toolName) {
529
+ autoApproved.add(ev.command ?? ev.toolName);
530
+ }
531
+ if (ev.event === "tool_blocked" && ev.ruleName && ev.hookEvent === "PreToolUse") {
532
+ preconditionsFired.add(ev.ruleName);
533
+ }
534
+ if ((ev.event === "postcondition_run" || ev.event === "postcondition_failed") && ev.command) {
535
+ postconditionsRun.add(ev.command);
536
+ }
537
+ if (ev.event === "skill_injected" && ev.message) {
538
+ skillsInjected.add(ev.message);
539
+ }
540
+ }
541
+ return {
542
+ sessionId: state.sessionId,
543
+ sessionName: state.sessionName,
544
+ totalEvents: events.length,
545
+ eventCounts,
546
+ blockedTools: [...blockedTools],
547
+ autoApproved: [...autoApproved],
548
+ preconditionsFired: [...preconditionsFired],
549
+ postconditionsRun: [...postconditionsRun],
550
+ skillsInjected: [...skillsInjected],
551
+ stats: {
552
+ filesRead: state.filesRead.length,
553
+ filesWritten: state.filesWritten.length,
554
+ filesDeleted: state.filesDeleted.length,
555
+ commandsRun: state.commandsRun.length,
556
+ rulesEnforced: state.rulesEnforced,
557
+ actionsBlocked: state.actionsBlocked,
558
+ autoActionsRun: state.autoActionsRun,
559
+ testsRun: state.testsRun,
560
+ testsPassed: state.testsPassed,
561
+ lintRun: state.lintRun,
562
+ buildRun: state.buildRun
563
+ }
564
+ };
565
+ }
566
+ function countEventsByType(events) {
567
+ const counts = {};
568
+ for (const ev of events) {
569
+ counts[ev.event] = (counts[ev.event] ?? 0) + 1;
570
+ }
571
+ return counts;
572
+ }
573
+ function findSessionForCommit(projectDir, commitDate) {
574
+ const commitTime = new Date(commitDate).getTime();
575
+ if (isNaN(commitTime)) return null;
576
+ const store = new JsonSessionStore(void 0, projectDir);
577
+ const sessionIds = store.listByProject(projectDir);
578
+ let bestMatch = null;
579
+ let bestDistance = Infinity;
580
+ for (const id of sessionIds) {
581
+ const state = store.load(id);
582
+ if (!state) continue;
583
+ const startTime = new Date(state.startedAt).getTime();
584
+ if (isNaN(startTime) || startTime > commitTime) continue;
585
+ const events = readEvents(id, projectDir);
586
+ if (events.length === 0) continue;
587
+ const lastEventTs = new Date(events[events.length - 1].ts).getTime();
588
+ if (startTime <= commitTime && lastEventTs >= commitTime) {
589
+ const distance = commitTime - startTime;
590
+ if (distance < bestDistance) {
591
+ bestDistance = distance;
592
+ bestMatch = { state, events };
593
+ }
594
+ }
595
+ }
596
+ if (bestMatch) return bestMatch;
597
+ const legacyBase = path2.join(os2.homedir(), ".ulpi", "hooks");
598
+ try {
599
+ const legacyStore = new JsonSessionStore(legacyBase);
600
+ const legacyIds = legacyStore.list();
601
+ for (const id of legacyIds) {
602
+ const state = legacyStore.load(id);
603
+ if (!state) continue;
604
+ const startTime = new Date(state.startedAt).getTime();
605
+ if (isNaN(startTime) || startTime > commitTime) continue;
606
+ const events = readEvents(id, void 0, legacyBase);
607
+ if (events.length === 0) continue;
608
+ const lastEventTs = new Date(events[events.length - 1].ts).getTime();
609
+ if (startTime <= commitTime && lastEventTs >= commitTime) {
610
+ const distance = commitTime - startTime;
611
+ if (distance < bestDistance) {
612
+ bestDistance = distance;
613
+ bestMatch = { state, events };
614
+ }
615
+ }
616
+ }
617
+ } catch {
618
+ }
619
+ return bestMatch;
620
+ }
621
+ function loadActiveGuards(projectDir) {
622
+ const paths = [
623
+ projectGuardsFile(projectDir),
624
+ projectGuardsFileAlt(projectDir)
625
+ ];
626
+ for (const p of paths) {
627
+ try {
628
+ return fs2.readFileSync(p, "utf-8");
629
+ } catch {
630
+ continue;
631
+ }
632
+ }
633
+ return null;
634
+ }
635
+ function findReviewPlansForCommit(commitDate, sessionStartDate, projectDir) {
636
+ const reviewPlansDir = REVIEWS_DIR;
637
+ if (!fs2.existsSync(reviewPlansDir)) return null;
638
+ const commitTime = new Date(commitDate).getTime();
639
+ const startTime = sessionStartDate ? new Date(sessionStartDate).getTime() : commitTime - 24 * 60 * 60 * 1e3;
640
+ if (isNaN(commitTime) || isNaN(startTime)) return null;
641
+ const snapshots = [];
642
+ const rawData = [];
643
+ let entries;
644
+ try {
645
+ entries = fs2.readdirSync(reviewPlansDir, { withFileTypes: true });
646
+ } catch {
647
+ return null;
648
+ }
649
+ for (const entry of entries) {
650
+ if (!entry.isDirectory()) continue;
651
+ const planDir = path2.join(reviewPlansDir, entry.name);
652
+ const planJsonPath = path2.join(planDir, "plan.json");
653
+ let planContent;
654
+ try {
655
+ planContent = fs2.readFileSync(planJsonPath, "utf-8");
656
+ } catch {
657
+ continue;
658
+ }
659
+ let plan;
660
+ try {
661
+ plan = JSON.parse(planContent);
662
+ } catch {
663
+ continue;
664
+ }
665
+ if (!plan.title || !plan.slug || !plan.version) continue;
666
+ if (projectDir) {
667
+ const planProjectPath = plan.projectPath;
668
+ if (!planProjectPath) {
669
+ continue;
670
+ }
671
+ if (path2.resolve(planProjectPath) !== path2.resolve(projectDir)) {
672
+ continue;
673
+ }
674
+ }
675
+ let matched = false;
676
+ if (plan.version.createdAt >= startTime && plan.version.createdAt <= commitTime) {
677
+ matched = true;
678
+ }
679
+ const versions = [];
680
+ try {
681
+ const planFiles = fs2.readdirSync(planDir);
682
+ for (const f of planFiles) {
683
+ if (/^v\d+\.json$/.test(f)) {
684
+ try {
685
+ const vContent = fs2.readFileSync(path2.join(planDir, f), "utf-8");
686
+ versions.push({ filename: f, content: vContent });
687
+ const vData = JSON.parse(vContent);
688
+ if (vData.createdAt && vData.createdAt >= startTime && vData.createdAt <= commitTime) {
689
+ matched = true;
690
+ }
691
+ } catch {
692
+ continue;
693
+ }
694
+ }
695
+ }
696
+ } catch {
697
+ }
698
+ if (!matched) continue;
699
+ const imageFiles = [];
700
+ const imageRawData = [];
701
+ const allAnnotations = [
702
+ ...plan.version.annotations ?? []
703
+ ];
704
+ for (const v of versions) {
705
+ try {
706
+ const vData = JSON.parse(v.content);
707
+ if (vData.annotations) {
708
+ allAnnotations.push(...vData.annotations);
709
+ }
710
+ } catch {
711
+ }
712
+ }
713
+ for (const ann of allAnnotations) {
714
+ if (!ann.imagePaths) continue;
715
+ for (const imgPath of ann.imagePaths) {
716
+ const filename = path2.basename(imgPath);
717
+ if (fs2.existsSync(imgPath)) {
718
+ imageFiles.push(filename);
719
+ imageRawData.push({ filename, sourcePath: imgPath });
720
+ }
721
+ }
722
+ }
723
+ const planImagesDir = path2.join(planDir, "images");
724
+ const topImagesDir = path2.join(REVIEW_IMAGES_DIR, entry.name);
725
+ for (const imgDir of [planImagesDir, topImagesDir]) {
726
+ if (!fs2.existsSync(imgDir)) continue;
727
+ try {
728
+ for (const imgFile of fs2.readdirSync(imgDir)) {
729
+ if (imgFile.endsWith(".png") || imgFile.endsWith(".jpg")) {
730
+ if (!imageFiles.includes(imgFile)) {
731
+ imageFiles.push(imgFile);
732
+ imageRawData.push({
733
+ filename: imgFile,
734
+ sourcePath: path2.join(imgDir, imgFile)
735
+ });
736
+ }
737
+ }
738
+ }
739
+ } catch {
740
+ }
741
+ }
742
+ snapshots.push({
743
+ title: plan.title,
744
+ slug: plan.slug,
745
+ plan: plan.plan ?? "",
746
+ version: plan.version,
747
+ imageFiles
748
+ });
749
+ rawData.push({
750
+ slug: plan.slug,
751
+ planJson: planContent,
752
+ versions,
753
+ images: imageRawData
754
+ });
755
+ }
756
+ return snapshots.length > 0 ? { snapshots, rawData } : null;
757
+ }
758
+ function buildHookDerivedData(events) {
759
+ const ruleMap = /* @__PURE__ */ new Map();
760
+ for (const ev of events) {
761
+ if (!ev.ruleName) continue;
762
+ const key = `${ev.ruleName}:${ev.hookEvent}`;
763
+ let entry = ruleMap.get(key);
764
+ if (!entry) {
765
+ entry = {
766
+ ruleName: ev.ruleName,
767
+ hookEvent: ev.hookEvent,
768
+ blockedCount: 0,
769
+ allowedCount: 0,
770
+ sampleTools: /* @__PURE__ */ new Set()
771
+ };
772
+ ruleMap.set(key, entry);
773
+ }
774
+ if (ev.event === "tool_blocked" || ev.event === "permission_deny") {
775
+ entry.blockedCount++;
776
+ } else {
777
+ entry.allowedCount++;
778
+ }
779
+ if (ev.toolName && entry.sampleTools.size < 5) {
780
+ entry.sampleTools.add(ev.toolName);
781
+ }
782
+ }
783
+ const ruleEvaluations = [...ruleMap.values()].map(
784
+ (r) => ({
785
+ ruleName: r.ruleName,
786
+ hookEvent: r.hookEvent,
787
+ blockedCount: r.blockedCount,
788
+ allowedCount: r.allowedCount,
789
+ sampleTools: [...r.sampleTools]
790
+ })
791
+ );
792
+ const permMap = /* @__PURE__ */ new Map();
793
+ for (const ev of events) {
794
+ if (ev.hookEvent !== "PermissionRequest") continue;
795
+ let decision;
796
+ if (ev.event === "permission_allow") {
797
+ decision = "allow";
798
+ } else if (ev.event === "permission_deny") {
799
+ decision = "deny";
800
+ } else {
801
+ continue;
802
+ }
803
+ const key = `${decision}:${ev.toolName ?? ""}:${ev.command ?? ""}`;
804
+ let entry = permMap.get(key);
805
+ if (!entry) {
806
+ entry = {
807
+ decision,
808
+ toolName: ev.toolName ?? "unknown",
809
+ command: ev.command,
810
+ ruleName: ev.ruleName,
811
+ count: 0
812
+ };
813
+ permMap.set(key, entry);
814
+ }
815
+ entry.count++;
816
+ }
817
+ const permissionDecisions = [
818
+ ...permMap.values()
819
+ ];
820
+ const postconditionResults = [];
821
+ for (const ev of events) {
822
+ if (ev.event !== "postcondition_run" && ev.event !== "postcondition_failed") {
823
+ continue;
824
+ }
825
+ if (!ev.command) continue;
826
+ postconditionResults.push({
827
+ command: ev.command,
828
+ succeeded: ev.event === "postcondition_run",
829
+ durationMs: ev.durationMs,
830
+ ruleName: ev.ruleName
831
+ });
832
+ }
833
+ const toolMap = /* @__PURE__ */ new Map();
834
+ for (const ev of events) {
835
+ if (!ev.toolName) continue;
836
+ let entry = toolMap.get(ev.toolName);
837
+ if (!entry) {
838
+ entry = { allowed: 0, blocked: 0, total: 0 };
839
+ toolMap.set(ev.toolName, entry);
840
+ }
841
+ entry.total++;
842
+ if (ev.event === "tool_blocked" || ev.event === "permission_deny") {
843
+ entry.blocked++;
844
+ } else {
845
+ entry.allowed++;
846
+ }
847
+ }
848
+ const toolUsageProfile = [...toolMap.entries()].map(([toolName, counts]) => ({ toolName, ...counts })).sort((a, b) => b.total - a.total);
849
+ let totalMs = 0;
850
+ let maxMs = 0;
851
+ let timedCount = 0;
852
+ const countByHook = {};
853
+ for (const ev of events) {
854
+ countByHook[ev.hookEvent] = (countByHook[ev.hookEvent] ?? 0) + 1;
855
+ if (ev.durationMs !== void 0) {
856
+ totalMs += ev.durationMs;
857
+ timedCount++;
858
+ if (ev.durationMs > maxMs) maxMs = ev.durationMs;
859
+ }
860
+ }
861
+ const hookTiming = {
862
+ totalMs,
863
+ averageMs: timedCount > 0 ? Math.round(totalMs / timedCount) : 0,
864
+ maxMs,
865
+ countByHook
866
+ };
867
+ const userMessages = [];
868
+ for (const ev of events) {
869
+ if (ev.event === "user_message" && ev.message) {
870
+ userMessages.push({
871
+ ts: ev.ts,
872
+ message: ev.message
873
+ });
874
+ }
875
+ }
876
+ const conversationFlow = [];
877
+ let currentTurn = null;
878
+ for (const ev of events) {
879
+ if (ev.event === "user_message") {
880
+ currentTurn = {
881
+ userMessage: ev.message,
882
+ ts: ev.ts,
883
+ toolsInvoked: [],
884
+ blocked: false
885
+ };
886
+ conversationFlow.push(currentTurn);
887
+ } else if (currentTurn) {
888
+ if (ev.toolName && !currentTurn.toolsInvoked.includes(ev.toolName)) {
889
+ currentTurn.toolsInvoked.push(ev.toolName);
890
+ }
891
+ if (ev.event === "tool_blocked" || ev.event === "permission_deny") {
892
+ currentTurn.blocked = true;
893
+ }
894
+ }
895
+ }
896
+ return {
897
+ ruleEvaluations,
898
+ permissionDecisions,
899
+ postconditionResults,
900
+ toolUsageProfile,
901
+ hookTiming,
902
+ userMessages,
903
+ conversationFlow
904
+ };
905
+ }
906
+ function buildPrePromptSnapshot(state) {
907
+ if (!state.headAtStart) return null;
908
+ return {
909
+ head: state.headAtStart,
910
+ branch: state.branch ?? "unknown",
911
+ untrackedFiles: state.untrackedFilesAtStart ?? [],
912
+ workingTreeDirty: state.workingTreeDirtyAtStart ?? false,
913
+ capturedAt: state.startedAt
914
+ };
915
+ }
916
+ function generateReadme(meta, timeline) {
917
+ const lines = [];
918
+ lines.push(`# ULPI History: ${meta.projectName}`);
919
+ lines.push("");
920
+ lines.push(
921
+ "This branch is maintained by [ULPI](https://github.com/nicholasgriffintn/ulpi)."
922
+ );
923
+ lines.push(
924
+ "It stores session metadata for each commit \u2014 separately from your code."
925
+ );
926
+ lines.push("");
927
+ lines.push("## Overview");
928
+ lines.push("");
929
+ lines.push(`| Stat | Value |`);
930
+ lines.push(`|------|-------|`);
931
+ lines.push(`| Total entries | ${meta.totalEntries} |`);
932
+ lines.push(`| Branch created | ${formatDate(meta.createdAt)} |`);
933
+ if (meta.lastCaptureAt) {
934
+ lines.push(`| Last capture | ${formatDate(meta.lastCaptureAt)} |`);
935
+ }
936
+ lines.push(`| ULPI version | ${meta.ulpiVersion} |`);
937
+ if (meta.config.collectReviewPlans) {
938
+ lines.push(`| Review plan collection | Enabled |`);
939
+ }
940
+ lines.push("");
941
+ const recentEntries = timeline.entries.slice(0, 50);
942
+ lines.push("## Recent Activity");
943
+ lines.push("");
944
+ if (recentEntries.length === 0) {
945
+ lines.push("_No entries yet. Commits will appear here after capture._");
946
+ } else {
947
+ lines.push(
948
+ "| Date | Commit | Branch | Summary | Files | Session | Review | AI | Transcript |"
949
+ );
950
+ lines.push(
951
+ "|------|--------|--------|---------|-------|---------|--------|----|------------|"
952
+ );
953
+ for (const entry of recentEntries) {
954
+ lines.push(formatTimelineRow(entry));
955
+ }
956
+ if (timeline.entries.length > 50) {
957
+ lines.push("");
958
+ lines.push(
959
+ `_Showing 50 of ${timeline.entries.length} entries. See \`timeline.json\` for the full list._`
960
+ );
961
+ }
962
+ }
963
+ lines.push("");
964
+ lines.push("## What's in Each Entry");
965
+ lines.push("");
966
+ lines.push("Each `entries/<sha>/` directory contains:");
967
+ lines.push("");
968
+ lines.push("| File | Description |");
969
+ lines.push("|------|-------------|");
970
+ lines.push(
971
+ "| `entry.json` | Core entry: git metadata, diff stats, session summary |"
972
+ );
973
+ lines.push("| `state.json` | Full session state snapshot |");
974
+ lines.push("| `events.jsonl` | Raw session events (complete audit trail) |");
975
+ lines.push("| `guards.yml` | Active rules at time of commit |");
976
+ lines.push(
977
+ "| `enrichment.json` | AI analysis (if enriched via `history enrich`) |"
978
+ );
979
+ lines.push(
980
+ "| `review/` | ULPI Review plans authored during the session |"
981
+ );
982
+ lines.push(
983
+ "| `transcript.jsonl` | Claude Code conversation transcript (if captured) |"
984
+ );
985
+ lines.push("");
986
+ lines.push("## CLI Commands");
987
+ lines.push("");
988
+ lines.push("```bash");
989
+ lines.push(
990
+ "ulpi history init # Initialize this branch"
991
+ );
992
+ lines.push(
993
+ "ulpi history capture # Capture current HEAD"
994
+ );
995
+ lines.push(
996
+ "ulpi history backfill 20 # Backfill last 20 commits"
997
+ );
998
+ lines.push(
999
+ "ulpi history list # List entries"
1000
+ );
1001
+ lines.push(
1002
+ "ulpi history show <sha> # Show entry details"
1003
+ );
1004
+ lines.push(
1005
+ "ulpi history enrich # AI-enrich unenriched entries"
1006
+ );
1007
+ lines.push(
1008
+ "ulpi history rewind <sha> # Rewind to a captured commit"
1009
+ );
1010
+ lines.push("```");
1011
+ lines.push("");
1012
+ lines.push(`_Last updated: ${(/* @__PURE__ */ new Date()).toISOString()}_`);
1013
+ return lines.join("\n") + "\n";
1014
+ }
1015
+ function formatDate(iso) {
1016
+ try {
1017
+ const d = new Date(iso);
1018
+ return d.toLocaleDateString("en-US", {
1019
+ month: "short",
1020
+ day: "numeric",
1021
+ year: "numeric"
1022
+ });
1023
+ } catch {
1024
+ return iso;
1025
+ }
1026
+ }
1027
+ function formatShortDate(iso) {
1028
+ try {
1029
+ const d = new Date(iso);
1030
+ return d.toLocaleDateString("en-US", {
1031
+ month: "short",
1032
+ day: "numeric"
1033
+ });
1034
+ } catch {
1035
+ return iso;
1036
+ }
1037
+ }
1038
+ function formatTimelineRow(entry) {
1039
+ const date = formatShortDate(entry.date);
1040
+ const commit = `[\`${entry.shortSha}\`](entries/${entry.shortSha}/)`;
1041
+ const branch = entry.branch === "unknown" ? "" : `\`${entry.branch}\``;
1042
+ const subject = truncate(entry.subject, 50);
1043
+ const files = String(entry.filesChanged);
1044
+ const session = entry.hasSession ? "yes" : "--";
1045
+ const review = entry.hasReviewPlans ? "yes" : "--";
1046
+ const ai = entry.hasEnrichment ? "yes" : "--";
1047
+ const transcript = entry.hasTranscript ? "yes" : "--";
1048
+ return `| ${date} | ${commit} | ${branch} | ${subject} | ${files} | ${session} | ${review} | ${ai} | ${transcript} |`;
1049
+ }
1050
+ function truncate(str, maxLen) {
1051
+ if (str.length <= maxLen) return str;
1052
+ return str.slice(0, maxLen - 3) + "...";
1053
+ }
1054
+ function resolveShortSha(projectDir, sha, branchName) {
1055
+ branchName ??= getHistoryBranch();
1056
+ if (sha.length <= 8) return sha;
1057
+ const timeline = readTimeline(projectDir, branchName);
1058
+ if (timeline) {
1059
+ const entry = timeline.entries.find(
1060
+ (e) => e.sha === sha || sha.startsWith(e.sha)
1061
+ );
1062
+ if (entry) return entry.shortSha;
1063
+ }
1064
+ return sha.slice(0, 7);
1065
+ }
1066
+ function computeContentHash(entry) {
1067
+ const significant = {
1068
+ sha: entry.commit.sha,
1069
+ diff: {
1070
+ filesChanged: entry.diff.filesChanged,
1071
+ insertions: entry.diff.insertions,
1072
+ deletions: entry.diff.deletions
1073
+ },
1074
+ session: entry.session ? {
1075
+ totalEvents: entry.session.totalEvents,
1076
+ stats: entry.session.stats
1077
+ } : null
1078
+ };
1079
+ return createHash("sha256").update(JSON.stringify(significant)).digest("hex");
1080
+ }
1081
+ function normalizeEntry(entry) {
1082
+ return {
1083
+ ...entry,
1084
+ tags: entry.tags ?? {},
1085
+ metadata: entry.metadata ?? {},
1086
+ hookData: entry.hookData ?? null,
1087
+ prePromptSnapshot: entry.prePromptSnapshot ?? null
1088
+ };
1089
+ }
1090
+ var DEFAULT_HISTORY_CONFIG = {
1091
+ enabled: true,
1092
+ branchName: "ulpi/history",
1093
+ // Legacy default; actual branch resolved via getHistoryBranch()
1094
+ aiEnrichment: true,
1095
+ aiModel: DEFAULT_AI_MODEL,
1096
+ maxDiffSize: 5e4,
1097
+ maxAiDiffSize: 1e4,
1098
+ collectReviewPlans: false,
1099
+ captureTranscript: true,
1100
+ maxTranscriptSize: 5242880,
1101
+ captureStrategy: "session-end"
1102
+ };
1103
+ async function writeHistoryEntry(projectDir, entry, rawData, branchName) {
1104
+ branchName ??= getHistoryBranch();
1105
+ const contentHash = computeContentHash(entry);
1106
+ entry.contentHash = contentHash;
1107
+ await withWorktree(projectDir, branchName, (worktreeDir) => {
1108
+ const existing = readHistoryEntry(projectDir, entry.commit.sha, branchName);
1109
+ if (existing?.contentHash === contentHash) return;
1110
+ const shortSha = entry.commit.shortSha;
1111
+ const entryDir = `entries/${shortSha}`;
1112
+ writeAndStage(
1113
+ worktreeDir,
1114
+ `${entryDir}/entry.json`,
1115
+ JSON.stringify(entry, null, 2)
1116
+ );
1117
+ if (rawData.state) {
1118
+ writeAndStage(
1119
+ worktreeDir,
1120
+ `${entryDir}/state.json`,
1121
+ JSON.stringify(rawData.state, null, 2)
1122
+ );
1123
+ }
1124
+ if (rawData.events.length > 0) {
1125
+ const eventsContent = rawData.events.map((e) => JSON.stringify(e)).join("\n") + "\n";
1126
+ writeAndStage(worktreeDir, `${entryDir}/events.jsonl`, eventsContent);
1127
+ }
1128
+ if (rawData.guardsYaml) {
1129
+ writeAndStage(worktreeDir, `${entryDir}/guards.yml`, rawData.guardsYaml);
1130
+ }
1131
+ if (rawData.reviewPlans && rawData.reviewPlans.length > 0) {
1132
+ for (const plan of rawData.reviewPlans) {
1133
+ const reviewDir = `${entryDir}/review/${plan.slug}`;
1134
+ writeAndStage(worktreeDir, `${reviewDir}/plan.json`, plan.planJson);
1135
+ for (const v of plan.versions) {
1136
+ writeAndStage(worktreeDir, `${reviewDir}/${v.filename}`, v.content);
1137
+ }
1138
+ for (const img of plan.images) {
1139
+ try {
1140
+ copyAndStage(
1141
+ worktreeDir,
1142
+ `${reviewDir}/images/${img.filename}`,
1143
+ img.sourcePath
1144
+ );
1145
+ } catch {
1146
+ }
1147
+ }
1148
+ }
1149
+ }
1150
+ if (rawData.transcript) {
1151
+ writeAndStage(worktreeDir, `${entryDir}/transcript.jsonl`, rawData.transcript);
1152
+ entry.transcriptSize = Buffer.byteLength(rawData.transcript, "utf-8");
1153
+ }
1154
+ const timeline = readTimelineFromWorktree(worktreeDir) ?? {
1155
+ version: 1,
1156
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1157
+ projectDir,
1158
+ entries: []
1159
+ };
1160
+ const newTimelineEntry = toTimelineEntry(entry);
1161
+ timeline.entries = [
1162
+ newTimelineEntry,
1163
+ ...timeline.entries.filter((e) => e.sha !== entry.commit.sha)
1164
+ ];
1165
+ timeline.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1166
+ writeAndStage(
1167
+ worktreeDir,
1168
+ "timeline.json",
1169
+ JSON.stringify(timeline, null, 2)
1170
+ );
1171
+ const meta = readMetaFromWorktree(worktreeDir);
1172
+ if (meta) {
1173
+ meta.totalEntries = timeline.entries.length;
1174
+ meta.lastCaptureAt = (/* @__PURE__ */ new Date()).toISOString();
1175
+ writeAndStage(worktreeDir, "meta.json", JSON.stringify(meta, null, 2));
1176
+ const readme = generateReadme(meta, timeline);
1177
+ writeAndStage(worktreeDir, "README.md", readme);
1178
+ }
1179
+ commitInWorktree(
1180
+ worktreeDir,
1181
+ `chore(history): capture ${shortSha} \u2014 ${truncate2(entry.commit.subject, 60)}`
1182
+ );
1183
+ });
1184
+ }
1185
+ function readHistoryEntry(projectDir, sha, branchName) {
1186
+ branchName ??= getHistoryBranch();
1187
+ const shortSha = resolveShortSha(projectDir, sha, branchName);
1188
+ const raw = readFromBranch(
1189
+ projectDir,
1190
+ `entries/${shortSha}/entry.json`,
1191
+ branchName
1192
+ );
1193
+ if (!raw) return null;
1194
+ try {
1195
+ const entry = normalizeEntry(JSON.parse(raw));
1196
+ if (entry.transcriptSize == null || entry.transcriptSize === 0) {
1197
+ const transcript = readFromBranch(
1198
+ projectDir,
1199
+ `entries/${shortSha}/transcript.jsonl`,
1200
+ branchName
1201
+ );
1202
+ if (transcript && transcript.length > 0) {
1203
+ entry.transcriptSize = Buffer.byteLength(transcript, "utf-8");
1204
+ }
1205
+ }
1206
+ return entry;
1207
+ } catch {
1208
+ return null;
1209
+ }
1210
+ }
1211
+ function readEntryRawData(projectDir, sha, branchName) {
1212
+ branchName ??= getHistoryBranch();
1213
+ const shortSha = resolveShortSha(projectDir, sha, branchName);
1214
+ const base = `entries/${shortSha}`;
1215
+ return {
1216
+ state: readFromBranch(projectDir, `${base}/state.json`, branchName),
1217
+ events: readFromBranch(projectDir, `${base}/events.jsonl`, branchName),
1218
+ guards: readFromBranch(projectDir, `${base}/guards.yml`, branchName)
1219
+ };
1220
+ }
1221
+ function readEntryTranscript(projectDir, sha, branchName) {
1222
+ branchName ??= getHistoryBranch();
1223
+ const shortSha = resolveShortSha(projectDir, sha, branchName);
1224
+ return readFromBranch(
1225
+ projectDir,
1226
+ `entries/${shortSha}/transcript.jsonl`,
1227
+ branchName
1228
+ );
1229
+ }
1230
+ function readTimeline(projectDir, branchName) {
1231
+ branchName ??= getHistoryBranch();
1232
+ const raw = readFromBranch(projectDir, "timeline.json", branchName);
1233
+ if (!raw) return null;
1234
+ try {
1235
+ return JSON.parse(raw);
1236
+ } catch {
1237
+ return null;
1238
+ }
1239
+ }
1240
+ function readBranchMeta(projectDir, branchName) {
1241
+ branchName ??= getHistoryBranch();
1242
+ const raw = readFromBranch(projectDir, "meta.json", branchName);
1243
+ if (!raw) return null;
1244
+ try {
1245
+ return JSON.parse(raw);
1246
+ } catch {
1247
+ return null;
1248
+ }
1249
+ }
1250
+ function toTimelineEntry(entry) {
1251
+ return {
1252
+ sha: entry.commit.sha,
1253
+ shortSha: entry.commit.shortSha,
1254
+ subject: entry.commit.subject,
1255
+ date: entry.commit.authorDate,
1256
+ branch: entry.commit.branch,
1257
+ hasSession: entry.session !== null,
1258
+ hasEnrichment: entry.enrichment !== null,
1259
+ hasReviewPlans: entry.reviewPlans !== null && entry.reviewPlans.length > 0,
1260
+ filesChanged: entry.diff.filesChanged,
1261
+ contentHash: entry.contentHash,
1262
+ hasTranscript: (entry.transcriptSize ?? 0) > 0
1263
+ };
1264
+ }
1265
+ function entryExists(projectDir, sha, branchName) {
1266
+ branchName ??= getHistoryBranch();
1267
+ const timeline = readTimeline(projectDir, branchName);
1268
+ if (!timeline) return false;
1269
+ return timeline.entries.some(
1270
+ (e) => e.sha === sha || e.shortSha === sha || e.sha.startsWith(sha)
1271
+ );
1272
+ }
1273
+ async function updateEntryEnrichment(projectDir, sha, enrichment, branchName) {
1274
+ branchName ??= getHistoryBranch();
1275
+ const shortSha = resolveShortSha(projectDir, sha, branchName);
1276
+ await withWorktree(projectDir, branchName, (worktreeDir) => {
1277
+ const entryDir = `entries/${shortSha}`;
1278
+ writeAndStage(
1279
+ worktreeDir,
1280
+ `${entryDir}/enrichment.json`,
1281
+ JSON.stringify(enrichment, null, 2)
1282
+ );
1283
+ const entryRaw = readFromBranch(
1284
+ projectDir,
1285
+ `${entryDir}/entry.json`,
1286
+ branchName
1287
+ );
1288
+ if (entryRaw) {
1289
+ try {
1290
+ const entry = JSON.parse(entryRaw);
1291
+ entry.enrichment = enrichment;
1292
+ writeAndStage(
1293
+ worktreeDir,
1294
+ `${entryDir}/entry.json`,
1295
+ JSON.stringify(entry, null, 2)
1296
+ );
1297
+ } catch {
1298
+ }
1299
+ }
1300
+ const timeline = readTimelineFromWorktree(worktreeDir);
1301
+ if (timeline) {
1302
+ for (const e of timeline.entries) {
1303
+ if (e.sha === sha || e.shortSha === shortSha) {
1304
+ e.hasEnrichment = true;
1305
+ }
1306
+ }
1307
+ timeline.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1308
+ writeAndStage(
1309
+ worktreeDir,
1310
+ "timeline.json",
1311
+ JSON.stringify(timeline, null, 2)
1312
+ );
1313
+ const meta = readMetaFromWorktree(worktreeDir);
1314
+ if (meta) {
1315
+ const readme = generateReadme(meta, timeline);
1316
+ writeAndStage(worktreeDir, "README.md", readme);
1317
+ }
1318
+ }
1319
+ commitInWorktree(
1320
+ worktreeDir,
1321
+ `chore(history): enrich ${shortSha}`
1322
+ );
1323
+ });
1324
+ }
1325
+ async function updateEntryTags(projectDir, sha, tags, branchName) {
1326
+ branchName ??= getHistoryBranch();
1327
+ const shortSha = resolveShortSha(projectDir, sha, branchName);
1328
+ await withWorktree(projectDir, branchName, (worktreeDir) => {
1329
+ const entryDir = `entries/${shortSha}`;
1330
+ const entryPath = path3.join(worktreeDir, entryDir, "entry.json");
1331
+ if (fs3.existsSync(entryPath)) {
1332
+ try {
1333
+ const entry = JSON.parse(fs3.readFileSync(entryPath, "utf-8"));
1334
+ const tagRecord = {};
1335
+ for (const t of tags) tagRecord[t] = "";
1336
+ entry.tags = tagRecord;
1337
+ writeAndStage(worktreeDir, `${entryDir}/entry.json`, JSON.stringify(entry, null, 2));
1338
+ } catch {
1339
+ }
1340
+ }
1341
+ const timeline = readTimelineFromWorktree(worktreeDir);
1342
+ if (timeline) {
1343
+ for (const e of timeline.entries) {
1344
+ if (e.sha === sha || e.shortSha === shortSha) {
1345
+ e.tags = tags;
1346
+ }
1347
+ }
1348
+ timeline.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1349
+ writeAndStage(worktreeDir, "timeline.json", JSON.stringify(timeline, null, 2));
1350
+ }
1351
+ commitInWorktree(worktreeDir, `chore(history): tag ${shortSha}`);
1352
+ });
1353
+ }
1354
+ async function updateEntryTranscript(projectDir, sha, transcript, branchName) {
1355
+ branchName ??= getHistoryBranch();
1356
+ const shortSha = resolveShortSha(projectDir, sha, branchName);
1357
+ const transcriptSize = Buffer.byteLength(transcript, "utf-8");
1358
+ await withWorktree(projectDir, branchName, (worktreeDir) => {
1359
+ const entryDir = `entries/${shortSha}`;
1360
+ writeAndStage(worktreeDir, `${entryDir}/transcript.jsonl`, transcript);
1361
+ const entryPath = path3.join(worktreeDir, entryDir, "entry.json");
1362
+ if (fs3.existsSync(entryPath)) {
1363
+ try {
1364
+ const entry = JSON.parse(
1365
+ fs3.readFileSync(entryPath, "utf-8")
1366
+ );
1367
+ entry.transcriptSize = transcriptSize;
1368
+ writeAndStage(
1369
+ worktreeDir,
1370
+ `${entryDir}/entry.json`,
1371
+ JSON.stringify(entry, null, 2)
1372
+ );
1373
+ } catch {
1374
+ }
1375
+ }
1376
+ const timeline = readTimelineFromWorktree(worktreeDir);
1377
+ if (timeline) {
1378
+ for (const e of timeline.entries) {
1379
+ if (e.sha === sha || e.shortSha === shortSha) {
1380
+ e.hasTranscript = true;
1381
+ }
1382
+ }
1383
+ timeline.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1384
+ writeAndStage(
1385
+ worktreeDir,
1386
+ "timeline.json",
1387
+ JSON.stringify(timeline, null, 2)
1388
+ );
1389
+ }
1390
+ commitInWorktree(
1391
+ worktreeDir,
1392
+ `chore(history): attach transcript to ${shortSha}`
1393
+ );
1394
+ });
1395
+ }
1396
+ function readTimelineFromWorktree(worktreeDir) {
1397
+ try {
1398
+ const raw = fs3.readFileSync(
1399
+ path3.join(worktreeDir, "timeline.json"),
1400
+ "utf-8"
1401
+ );
1402
+ return JSON.parse(raw);
1403
+ } catch {
1404
+ return null;
1405
+ }
1406
+ }
1407
+ function readMetaFromWorktree(worktreeDir) {
1408
+ try {
1409
+ const raw = fs3.readFileSync(
1410
+ path3.join(worktreeDir, "meta.json"),
1411
+ "utf-8"
1412
+ );
1413
+ return JSON.parse(raw);
1414
+ } catch {
1415
+ return null;
1416
+ }
1417
+ }
1418
+ function truncate2(str, maxLen) {
1419
+ if (str.length <= maxLen) return str;
1420
+ return str.slice(0, maxLen - 3) + "...";
1421
+ }
1422
+ function readTranscript(transcriptPath, maxSize = 5242880) {
1423
+ try {
1424
+ const stat = fs4.statSync(transcriptPath);
1425
+ const size = stat.size;
1426
+ if (size <= maxSize) {
1427
+ const content = fs4.readFileSync(transcriptPath, "utf-8");
1428
+ return { content, size, truncated: false };
1429
+ }
1430
+ const fd = fs4.openSync(transcriptPath, "r");
1431
+ try {
1432
+ const buffer = Buffer.alloc(maxSize);
1433
+ const offset = size - maxSize;
1434
+ fs4.readSync(fd, buffer, 0, maxSize, offset);
1435
+ let content = buffer.toString("utf-8");
1436
+ const firstNewline = content.indexOf("\n");
1437
+ if (firstNewline !== -1 && firstNewline < content.length - 1) {
1438
+ content = content.slice(firstNewline + 1);
1439
+ }
1440
+ return { content, size, truncated: true };
1441
+ } finally {
1442
+ fs4.closeSync(fd);
1443
+ }
1444
+ } catch {
1445
+ return null;
1446
+ }
1447
+ }
1448
+ function getTranscriptSize(transcriptPath) {
1449
+ try {
1450
+ return fs4.statSync(transcriptPath).size;
1451
+ } catch {
1452
+ return null;
1453
+ }
1454
+ }
1455
+ var HOOK_MARKER_START = "# ulpi-history";
1456
+ var HOOK_MARKER_END = "# ulpi-history-end";
1457
+ function installGitHooks(projectDir, binaryPath) {
1458
+ const gitDir = resolveGitDir(projectDir);
1459
+ if (!gitDir) return { installed: [], skipped: [] };
1460
+ const hooksDir = path4.join(gitDir, "hooks");
1461
+ fs5.mkdirSync(hooksDir, { recursive: true });
1462
+ const installed = [];
1463
+ const skipped = [];
1464
+ const hooks = {
1465
+ "prepare-commit-msg": generatePrepareCommitMsg(),
1466
+ "post-commit": generatePostCommit(binaryPath),
1467
+ "pre-push": generatePrePush()
1468
+ };
1469
+ for (const [name, content] of Object.entries(hooks)) {
1470
+ const hookPath = path4.join(hooksDir, name);
1471
+ if (fs5.existsSync(hookPath)) {
1472
+ const existing = fs5.readFileSync(hookPath, "utf-8");
1473
+ if (existing.includes(HOOK_MARKER_START)) {
1474
+ skipped.push(name);
1475
+ continue;
1476
+ }
1477
+ fs5.appendFileSync(hookPath, `
1478
+
1479
+ ${content}
1480
+ `);
1481
+ } else {
1482
+ fs5.writeFileSync(hookPath, `#!/bin/sh
1483
+
1484
+ ${content}
1485
+ `, { mode: 493 });
1486
+ }
1487
+ try {
1488
+ fs5.chmodSync(hookPath, 493);
1489
+ } catch {
1490
+ }
1491
+ installed.push(name);
1492
+ }
1493
+ return { installed, skipped };
1494
+ }
1495
+ function uninstallGitHooks(projectDir) {
1496
+ const gitDir = resolveGitDir(projectDir);
1497
+ if (!gitDir) return [];
1498
+ const hooksDir = path4.join(gitDir, "hooks");
1499
+ const removed = [];
1500
+ for (const name of ["prepare-commit-msg", "post-commit", "pre-push"]) {
1501
+ const hookPath = path4.join(hooksDir, name);
1502
+ if (!fs5.existsSync(hookPath)) continue;
1503
+ const content = fs5.readFileSync(hookPath, "utf-8");
1504
+ if (!content.includes(HOOK_MARKER_START)) continue;
1505
+ const startIdx = content.indexOf(HOOK_MARKER_START);
1506
+ const endIdx = content.indexOf(HOOK_MARKER_END);
1507
+ if (startIdx === -1) continue;
1508
+ const endOfMarker = endIdx !== -1 ? endIdx + HOOK_MARKER_END.length : content.length;
1509
+ let cleaned = content.slice(0, startIdx) + content.slice(endOfMarker);
1510
+ cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim();
1511
+ if (cleaned === "#!/bin/sh" || cleaned.trim() === "") {
1512
+ fs5.unlinkSync(hookPath);
1513
+ } else {
1514
+ fs5.writeFileSync(hookPath, cleaned + "\n");
1515
+ }
1516
+ removed.push(name);
1517
+ }
1518
+ return removed;
1519
+ }
1520
+ function resolveGitDir(projectDir) {
1521
+ const gitPath = path4.join(projectDir, ".git");
1522
+ try {
1523
+ const stat = fs5.statSync(gitPath);
1524
+ if (stat.isDirectory()) {
1525
+ return gitPath;
1526
+ }
1527
+ const content = fs5.readFileSync(gitPath, "utf-8").trim();
1528
+ const match = content.match(/^gitdir:\s*(.+)$/);
1529
+ if (match) {
1530
+ const resolvedGitDir = path4.resolve(projectDir, match[1]);
1531
+ const commonDir = path4.join(resolvedGitDir, "..", "..");
1532
+ return fs5.existsSync(path4.join(commonDir, "hooks")) ? commonDir : resolvedGitDir;
1533
+ }
1534
+ return null;
1535
+ } catch {
1536
+ return null;
1537
+ }
1538
+ }
1539
+ function generatePrepareCommitMsg() {
1540
+ return `${HOOK_MARKER_START}
1541
+ # Add ULPI-History trailer to commit messages
1542
+ COMMIT_MSG_FILE="$1"
1543
+ COMMIT_SOURCE="$2"
1544
+ if [ "$COMMIT_SOURCE" = "" ] || [ "$COMMIT_SOURCE" = "message" ]; then
1545
+ SHORT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "pending")
1546
+ if ! grep -q "ULPI-History:" "$COMMIT_MSG_FILE" 2>/dev/null; then
1547
+ printf "\\nULPI-History: %s\\n" "$SHORT_SHA" >> "$COMMIT_MSG_FILE"
1548
+ fi
1549
+ fi
1550
+ ${HOOK_MARKER_END}`;
1551
+ }
1552
+ function generatePostCommit(binaryPath) {
1553
+ const cmd = binaryPath.includes(" ") ? binaryPath : `"${binaryPath}"`;
1554
+ return `${HOOK_MARKER_START}
1555
+ # Trigger on-commit history capture (fail-open)
1556
+ ${cmd} history capture HEAD 2>/dev/null || true
1557
+ ${HOOK_MARKER_END}`;
1558
+ }
1559
+ function generatePrePush() {
1560
+ return `${HOOK_MARKER_START}
1561
+ # Auto-push ulpi/history branch alongside user push
1562
+ REMOTE="$1"
1563
+ if git rev-parse --verify ulpi/history >/dev/null 2>&1; then
1564
+ git push "$REMOTE" ulpi/history 2>/dev/null || true
1565
+ fi
1566
+ ${HOOK_MARKER_END}`;
1567
+ }
1568
+
1569
+ export {
1570
+ historyBranchExists,
1571
+ initHistoryBranch,
1572
+ getCommitMetadata,
1573
+ getCommitDiffStats,
1574
+ getCommitRawDiff,
1575
+ getCurrentHead,
1576
+ listCommitsBetween,
1577
+ listRecentCommits,
1578
+ readFromBranch,
1579
+ listBranchDir,
1580
+ getWorktreeId,
1581
+ withWorktree,
1582
+ writeAndStage,
1583
+ copyAndStage,
1584
+ commitInWorktree,
1585
+ buildSessionSummary,
1586
+ countEventsByType,
1587
+ findSessionForCommit,
1588
+ loadActiveGuards,
1589
+ findReviewPlansForCommit,
1590
+ buildHookDerivedData,
1591
+ buildPrePromptSnapshot,
1592
+ generateReadme,
1593
+ normalizeEntry,
1594
+ DEFAULT_HISTORY_CONFIG,
1595
+ writeHistoryEntry,
1596
+ readHistoryEntry,
1597
+ readEntryRawData,
1598
+ readEntryTranscript,
1599
+ readTimeline,
1600
+ readBranchMeta,
1601
+ toTimelineEntry,
1602
+ entryExists,
1603
+ updateEntryEnrichment,
1604
+ updateEntryTags,
1605
+ updateEntryTranscript,
1606
+ readTranscript,
1607
+ getTranscriptSize,
1608
+ installGitHooks,
1609
+ uninstallGitHooks
1610
+ };