@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.
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/auth-PN7TMQHV-2W4ICG64.js +15 -0
- package/dist/chunk-247GVVKK.js +2259 -0
- package/dist/chunk-2CLNOKPA.js +793 -0
- package/dist/chunk-2HEE5OKX.js +79 -0
- package/dist/chunk-2MZER6ND.js +415 -0
- package/dist/chunk-3SBPZRB5.js +772 -0
- package/dist/chunk-4VNS5WPM.js +42 -0
- package/dist/chunk-6JCMYYBT.js +1546 -0
- package/dist/chunk-6OCEY7JY.js +422 -0
- package/dist/chunk-74WVVWJ4.js +375 -0
- package/dist/chunk-7AL4DOEJ.js +131 -0
- package/dist/chunk-7LXY5UVC.js +330 -0
- package/dist/chunk-DBMUNBNB.js +3048 -0
- package/dist/chunk-JWUUVXIV.js +13694 -0
- package/dist/chunk-KIKPIH6N.js +4048 -0
- package/dist/chunk-KLEASXUR.js +70 -0
- package/dist/chunk-MIAQVCFW.js +39 -0
- package/dist/chunk-NNUWU6CV.js +1610 -0
- package/dist/chunk-PKD4ASEM.js +115 -0
- package/dist/chunk-Q4HIY43N.js +4230 -0
- package/dist/chunk-QJ5GSMEC.js +146 -0
- package/dist/chunk-SIAQVRKG.js +2163 -0
- package/dist/chunk-SPOI23SB.js +197 -0
- package/dist/chunk-YM2HV4IA.js +505 -0
- package/dist/codemap-RRJIDBQ5.js +636 -0
- package/dist/config-EGAXXCGL.js +127 -0
- package/dist/dist-6G7JC2RA.js +90 -0
- package/dist/dist-7LHZ65GC.js +418 -0
- package/dist/dist-LZKZFPVX.js +140 -0
- package/dist/dist-R5F4MX3I.js +107 -0
- package/dist/dist-R5ZJ4LX5.js +56 -0
- package/dist/dist-RJGCUS3L.js +87 -0
- package/dist/dist-RKOGLK7R.js +151 -0
- package/dist/dist-W7K4WPAF.js +597 -0
- package/dist/export-import-4A5MWLIA.js +53 -0
- package/dist/history-ATTUKOHO.js +934 -0
- package/dist/index.js +2120 -0
- package/dist/init-AY5C2ZAS.js +393 -0
- package/dist/launchd-LF2QMSKZ.js +148 -0
- package/dist/log-TVTUXAYD.js +75 -0
- package/dist/mcp-installer-NQCGKQ23.js +124 -0
- package/dist/memory-J3G24QHS.js +406 -0
- package/dist/ollama-3XCUZMZT-FYKHW4TZ.js +7 -0
- package/dist/openai-E7G2YAHU-UYY4ZWON.js +8 -0
- package/dist/projects-ATHDD3D6.js +271 -0
- package/dist/review-ADUPV3PN.js +152 -0
- package/dist/rules-E427DKYJ.js +134 -0
- package/dist/server-MOYPE4SM-N7SE2AN7.js +18 -0
- package/dist/server-X5P6WH2M-7K2RY34N.js +11 -0
- package/dist/skills/ulpi-generate-guardian/SKILL.md +511 -0
- package/dist/skills/ulpi-generate-guardian/references/framework-rules.md +692 -0
- package/dist/skills/ulpi-generate-guardian/references/language-rules.md +596 -0
- package/dist/skills-CX73O3IV.js +76 -0
- package/dist/status-4DFHDJMN.js +66 -0
- package/dist/templates/biome.yml +24 -0
- package/dist/templates/conventional-commits.yml +18 -0
- package/dist/templates/django.yml +30 -0
- package/dist/templates/docker.yml +30 -0
- package/dist/templates/eslint.yml +13 -0
- package/dist/templates/express.yml +20 -0
- package/dist/templates/fastapi.yml +23 -0
- package/dist/templates/git-flow.yml +26 -0
- package/dist/templates/github-flow.yml +27 -0
- package/dist/templates/go.yml +33 -0
- package/dist/templates/jest.yml +24 -0
- package/dist/templates/laravel.yml +30 -0
- package/dist/templates/monorepo.yml +26 -0
- package/dist/templates/nestjs.yml +21 -0
- package/dist/templates/nextjs.yml +31 -0
- package/dist/templates/nodejs.yml +33 -0
- package/dist/templates/npm.yml +15 -0
- package/dist/templates/php.yml +25 -0
- package/dist/templates/pnpm.yml +15 -0
- package/dist/templates/prettier.yml +23 -0
- package/dist/templates/prisma.yml +21 -0
- package/dist/templates/python.yml +33 -0
- package/dist/templates/quality-of-life.yml +111 -0
- package/dist/templates/ruby.yml +25 -0
- package/dist/templates/rust.yml +34 -0
- package/dist/templates/typescript.yml +14 -0
- package/dist/templates/vitest.yml +24 -0
- package/dist/templates/yarn.yml +15 -0
- package/dist/templates-U7T6MARD.js +156 -0
- package/dist/ui-L7UAWXDY.js +167 -0
- package/dist/ui.html +698 -0
- package/dist/ulpi-RMMCUAGP-JCJ273T6.js +161 -0
- package/dist/uninstall-6SW35IK4.js +25 -0
- package/dist/update-M2B4RLGH.js +61 -0
- package/dist/version-checker-ANCS3IHR.js +10 -0
- 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
|
+
};
|