@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,2120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
discoverUlpiServer,
|
|
4
|
+
registerWithServer,
|
|
5
|
+
waitForServerDecision
|
|
6
|
+
} from "./chunk-2MZER6ND.js";
|
|
7
|
+
import {
|
|
8
|
+
captureCommitDiff,
|
|
9
|
+
extractSections,
|
|
10
|
+
extractTitle,
|
|
11
|
+
getSectionFullText,
|
|
12
|
+
isGitCommitCommand,
|
|
13
|
+
parseMarkdownToBlocks
|
|
14
|
+
} from "./chunk-3SBPZRB5.js";
|
|
15
|
+
import {
|
|
16
|
+
detectStack
|
|
17
|
+
} from "./chunk-2CLNOKPA.js";
|
|
18
|
+
import {
|
|
19
|
+
getDefaultProject,
|
|
20
|
+
getProject,
|
|
21
|
+
registerProject
|
|
22
|
+
} from "./chunk-SPOI23SB.js";
|
|
23
|
+
import {
|
|
24
|
+
injectSkill,
|
|
25
|
+
loadSkillSync
|
|
26
|
+
} from "./chunk-6OCEY7JY.js";
|
|
27
|
+
import {
|
|
28
|
+
evaluateRules,
|
|
29
|
+
loadRulesSync,
|
|
30
|
+
matchesFilePattern
|
|
31
|
+
} from "./chunk-SIAQVRKG.js";
|
|
32
|
+
import {
|
|
33
|
+
buildPrePromptSnapshot,
|
|
34
|
+
buildSessionSummary,
|
|
35
|
+
entryExists,
|
|
36
|
+
findReviewPlansForCommit,
|
|
37
|
+
getCommitDiffStats,
|
|
38
|
+
getCommitMetadata,
|
|
39
|
+
getCommitRawDiff,
|
|
40
|
+
getCurrentHead,
|
|
41
|
+
historyBranchExists,
|
|
42
|
+
listCommitsBetween,
|
|
43
|
+
loadActiveGuards,
|
|
44
|
+
readBranchMeta,
|
|
45
|
+
readEntryTranscript,
|
|
46
|
+
readTranscript,
|
|
47
|
+
updateEntryTranscript,
|
|
48
|
+
writeHistoryEntry
|
|
49
|
+
} from "./chunk-NNUWU6CV.js";
|
|
50
|
+
import {
|
|
51
|
+
JsonSessionStore,
|
|
52
|
+
appendEvent,
|
|
53
|
+
createInitialState,
|
|
54
|
+
projectDirToSlug,
|
|
55
|
+
readEvents,
|
|
56
|
+
updateStateFromInput
|
|
57
|
+
} from "./chunk-YM2HV4IA.js";
|
|
58
|
+
import "./chunk-KIKPIH6N.js";
|
|
59
|
+
import {
|
|
60
|
+
NOTIFICATIONS_LOG_FILE,
|
|
61
|
+
REVIEW_FLAGS_DIR,
|
|
62
|
+
SESSIONS_DIR,
|
|
63
|
+
ULPI_GLOBAL_DIR,
|
|
64
|
+
getApiHost,
|
|
65
|
+
getApiPort,
|
|
66
|
+
globalGuardsFile,
|
|
67
|
+
loadUlpiSettings,
|
|
68
|
+
projectGuardsFile,
|
|
69
|
+
projectGuardsFileAlt,
|
|
70
|
+
projectNoAutoGenFile
|
|
71
|
+
} from "./chunk-7LXY5UVC.js";
|
|
72
|
+
import "./chunk-4VNS5WPM.js";
|
|
73
|
+
|
|
74
|
+
// src/index.ts
|
|
75
|
+
import * as fs4 from "fs";
|
|
76
|
+
import * as path4 from "path";
|
|
77
|
+
|
|
78
|
+
// src/hooks/handler.ts
|
|
79
|
+
import * as fs3 from "fs";
|
|
80
|
+
import * as path3 from "path";
|
|
81
|
+
|
|
82
|
+
// src/hooks/session-start.ts
|
|
83
|
+
import { existsSync } from "fs";
|
|
84
|
+
import { join } from "path";
|
|
85
|
+
async function handleSessionStart(ctx) {
|
|
86
|
+
const { input, state, store, projectDir: projectDir2 } = ctx;
|
|
87
|
+
try {
|
|
88
|
+
state.stack = detectStack(projectDir2);
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const { execFileSync: execFileSync2 } = await import("child_process");
|
|
93
|
+
const branch = execFileSync2("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
94
|
+
cwd: projectDir2,
|
|
95
|
+
encoding: "utf-8",
|
|
96
|
+
timeout: 3e3
|
|
97
|
+
}).trim();
|
|
98
|
+
if (branch && branch !== "HEAD") {
|
|
99
|
+
state.branch = branch;
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const head = getCurrentHead(projectDir2);
|
|
105
|
+
if (head) state.headAtStart = head;
|
|
106
|
+
} catch {
|
|
107
|
+
}
|
|
108
|
+
state.phase = "active";
|
|
109
|
+
state.transcriptPath = input.transcript_path;
|
|
110
|
+
try {
|
|
111
|
+
const { execFileSync: execFileSync2 } = await import("child_process");
|
|
112
|
+
const untracked = execFileSync2("git", ["ls-files", "--others", "--exclude-standard"], {
|
|
113
|
+
cwd: projectDir2,
|
|
114
|
+
encoding: "utf-8",
|
|
115
|
+
timeout: 3e3
|
|
116
|
+
}).toString().trim();
|
|
117
|
+
state.untrackedFilesAtStart = untracked ? untracked.split("\n") : [];
|
|
118
|
+
try {
|
|
119
|
+
execFileSync2("git", ["diff", "--quiet"], { cwd: projectDir2, timeout: 3e3 });
|
|
120
|
+
state.workingTreeDirtyAtStart = false;
|
|
121
|
+
} catch {
|
|
122
|
+
state.workingTreeDirtyAtStart = true;
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
}
|
|
126
|
+
if (!state.sessionName) {
|
|
127
|
+
const now = /* @__PURE__ */ new Date();
|
|
128
|
+
const dateStr = now.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
129
|
+
state.sessionName = state.branch ? `${state.branch} - ${dateStr}` : dateStr;
|
|
130
|
+
}
|
|
131
|
+
store.save(state);
|
|
132
|
+
appendEvent(input.session_id, {
|
|
133
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
134
|
+
event: "session_start",
|
|
135
|
+
hookEvent: input.hook_event_name,
|
|
136
|
+
message: `Session started in ${projectDir2}`
|
|
137
|
+
}, projectDir2);
|
|
138
|
+
if (shouldPromptForGeneration(projectDir2)) {
|
|
139
|
+
outputGenerationPrompt();
|
|
140
|
+
}
|
|
141
|
+
import("./version-checker-ANCS3IHR.js").then((m) => m.checkForUpdates()).catch(() => {
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
const { isMemoryEnabled, loadMemoryConfig, getTopMemories, formatMemoriesForAgent } = await import("./dist-R5F4MX3I.js");
|
|
145
|
+
if (isMemoryEnabled(projectDir2)) {
|
|
146
|
+
const config = loadMemoryConfig(projectDir2);
|
|
147
|
+
if (config.surfaceOnStart) {
|
|
148
|
+
const memories = getTopMemories(projectDir2, config.surfaceLimit);
|
|
149
|
+
if (memories.length > 0) {
|
|
150
|
+
const formatted = formatMemoriesForAgent(memories);
|
|
151
|
+
process.stderr.write(formatted + "\n");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const { loadCodemapConfig } = await import("./dist-LZKZFPVX.js");
|
|
159
|
+
const { getCodemapBranch, getCurrentBranch } = await import("./dist-RKOGLK7R.js");
|
|
160
|
+
const { historyBranchExists: historyBranchExists2 } = await import("./dist-RJGCUS3L.js");
|
|
161
|
+
const codemapConfig = loadCodemapConfig(projectDir2);
|
|
162
|
+
if (codemapConfig.autoImport) {
|
|
163
|
+
const branch = state.branch ?? getCurrentBranch(projectDir2);
|
|
164
|
+
const shadowBranch = getCodemapBranch(branch);
|
|
165
|
+
if (historyBranchExists2(projectDir2, shadowBranch)) {
|
|
166
|
+
const { importIndex } = await import("./dist-LZKZFPVX.js");
|
|
167
|
+
await importIndex(projectDir2, branch);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function shouldPromptForGeneration(projectDir2) {
|
|
174
|
+
const rulesYml = projectGuardsFile(projectDir2);
|
|
175
|
+
const rulesYaml = projectGuardsFileAlt(projectDir2);
|
|
176
|
+
if (existsSync(rulesYml) || existsSync(rulesYaml)) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
const hasPackageJson = existsSync(join(projectDir2, "package.json"));
|
|
180
|
+
const hasPyProject = existsSync(join(projectDir2, "pyproject.toml"));
|
|
181
|
+
const hasCargoToml = existsSync(join(projectDir2, "Cargo.toml"));
|
|
182
|
+
const hasComposerJson = existsSync(join(projectDir2, "composer.json"));
|
|
183
|
+
const hasGemfile = existsSync(join(projectDir2, "Gemfile"));
|
|
184
|
+
const hasGoMod = existsSync(join(projectDir2, "go.mod"));
|
|
185
|
+
if (!hasPackageJson && !hasPyProject && !hasCargoToml && !hasComposerJson && !hasGemfile && !hasGoMod) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
if (hasBeenDismissed(projectDir2)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
function hasBeenDismissed(projectDir2) {
|
|
194
|
+
const dismissFile = projectNoAutoGenFile(projectDir2);
|
|
195
|
+
return existsSync(dismissFile);
|
|
196
|
+
}
|
|
197
|
+
function outputGenerationPrompt() {
|
|
198
|
+
const message = `
|
|
199
|
+
\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E
|
|
200
|
+
\u2502 ULPI Not Configured \u2502
|
|
201
|
+
\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524
|
|
202
|
+
\u2502 \u2502
|
|
203
|
+
\u2502 Would you like to auto-generate hooks for this \u2502
|
|
204
|
+
\u2502 project? This will: \u2502
|
|
205
|
+
\u2502 \u2502
|
|
206
|
+
\u2502 \u2022 Detect your stack (language, framework, tools) \u2502
|
|
207
|
+
\u2502 \u2022 Generate optimized guards.yml \u2502
|
|
208
|
+
\u2502 \u2022 Auto-approve safe operations \u2502
|
|
209
|
+
\u2502 \u2022 Block dangerous commands \u2502
|
|
210
|
+
\u2502 \u2502
|
|
211
|
+
\u2502 To generate: Type "/ulpi-generate-guardian" or say \u2502
|
|
212
|
+
\u2502 "yes, generate guardian" \u2502
|
|
213
|
+
\u2502 \u2502
|
|
214
|
+
\u2502 After generating, manage hooks via web UI: \u2502
|
|
215
|
+
\u2502 ulpi ui \u2502
|
|
216
|
+
\u2502 \u2192 http://localhost:${getApiPort()} \u2502
|
|
217
|
+
\u2502 \u2502
|
|
218
|
+
\u2502 To dismiss: Create empty .ulpi/.no-auto-gen \u2502
|
|
219
|
+
\u2502 file \u2502
|
|
220
|
+
\u2502 \u2502
|
|
221
|
+
\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F
|
|
222
|
+
`;
|
|
223
|
+
process.stderr.write(message);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ../../packages/notifications-engine/dist/index.js
|
|
227
|
+
import { execFileSync } from "child_process";
|
|
228
|
+
import { writeFileSync, unlinkSync } from "fs";
|
|
229
|
+
import { tmpdir } from "os";
|
|
230
|
+
import { join as join2 } from "path";
|
|
231
|
+
import * as os from "os";
|
|
232
|
+
import * as fs from "fs";
|
|
233
|
+
import * as path from "path";
|
|
234
|
+
import * as fs2 from "fs";
|
|
235
|
+
import * as path2 from "path";
|
|
236
|
+
function classifyNotification(input) {
|
|
237
|
+
if (input.notification_type) {
|
|
238
|
+
return input.notification_type;
|
|
239
|
+
}
|
|
240
|
+
const msg = (input.message ?? "").toLowerCase();
|
|
241
|
+
if (msg.includes("permission")) return "permission_prompt";
|
|
242
|
+
if (msg.includes("idle") || msg.includes("waiting")) return "idle_prompt";
|
|
243
|
+
if (msg.includes("auth")) return "auth_success";
|
|
244
|
+
if (msg.includes("elicitation") || msg.includes("input")) return "elicitation_dialog";
|
|
245
|
+
return "unknown";
|
|
246
|
+
}
|
|
247
|
+
var NotificationDeduplicator = class _NotificationDeduplicator {
|
|
248
|
+
seen = /* @__PURE__ */ new Map();
|
|
249
|
+
windowMs;
|
|
250
|
+
maxEntries;
|
|
251
|
+
checkCount = 0;
|
|
252
|
+
static CLEANUP_INTERVAL = 100;
|
|
253
|
+
constructor(windowMs = 5e3, maxEntries = 1e3) {
|
|
254
|
+
this.windowMs = windowMs;
|
|
255
|
+
this.maxEntries = maxEntries;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Check if this notification should be suppressed.
|
|
259
|
+
* Returns true if the notification is a duplicate (should be suppressed).
|
|
260
|
+
*/
|
|
261
|
+
isDuplicate(key) {
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
this.checkCount++;
|
|
264
|
+
if (this.checkCount >= _NotificationDeduplicator.CLEANUP_INTERVAL || this.seen.size > this.maxEntries) {
|
|
265
|
+
this.cleanup(now);
|
|
266
|
+
this.checkCount = 0;
|
|
267
|
+
}
|
|
268
|
+
const lastSeen = this.seen.get(key);
|
|
269
|
+
if (lastSeen !== void 0 && now - lastSeen < this.windowMs) {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
this.seen.set(key, now);
|
|
273
|
+
if (this.seen.size > this.maxEntries) {
|
|
274
|
+
this.evictOldest();
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Remove expired entries.
|
|
280
|
+
*/
|
|
281
|
+
cleanup(now) {
|
|
282
|
+
for (const [key, ts] of this.seen) {
|
|
283
|
+
if (now - ts >= this.windowMs) {
|
|
284
|
+
this.seen.delete(key);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Evict the oldest half of entries when hard cap is exceeded.
|
|
290
|
+
*/
|
|
291
|
+
evictOldest() {
|
|
292
|
+
const entries = Array.from(this.seen.entries()).sort((a, b) => a[1] - b[1]);
|
|
293
|
+
const toRemove = Math.floor(entries.length / 2);
|
|
294
|
+
for (let i = 0; i < toRemove; i++) {
|
|
295
|
+
this.seen.delete(entries[i][0]);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/** Update the deduplication time window. */
|
|
299
|
+
setWindowMs(ms) {
|
|
300
|
+
this.windowMs = ms;
|
|
301
|
+
}
|
|
302
|
+
/** Reset all state. */
|
|
303
|
+
clear() {
|
|
304
|
+
this.seen.clear();
|
|
305
|
+
this.checkCount = 0;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
function sendDesktopNotification(title, message) {
|
|
309
|
+
const platform2 = os.platform();
|
|
310
|
+
try {
|
|
311
|
+
if (platform2 === "darwin") {
|
|
312
|
+
const escapedBody = message.replace(/["\\]/g, "\\$&");
|
|
313
|
+
const escapedTitle = title.replace(/["\\]/g, "\\$&");
|
|
314
|
+
const script = `display notification "${escapedBody}" with title "${escapedTitle}"`;
|
|
315
|
+
execFileSync("osascript", ["-e", script], {
|
|
316
|
+
timeout: 5e3,
|
|
317
|
+
stdio: "ignore"
|
|
318
|
+
});
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
if (platform2 === "linux") {
|
|
322
|
+
execFileSync("notify-send", [title, message], {
|
|
323
|
+
timeout: 5e3,
|
|
324
|
+
stdio: "ignore"
|
|
325
|
+
});
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
if (platform2 === "win32") {
|
|
329
|
+
const escapedTitle = title.replace(/'/g, "''");
|
|
330
|
+
const escapedBody = message.replace(/'/g, "''");
|
|
331
|
+
const ps = [
|
|
332
|
+
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null",
|
|
333
|
+
"$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
|
|
334
|
+
"$textNodes = $template.GetElementsByTagName('text')",
|
|
335
|
+
`$textNodes.Item(0).AppendChild($template.CreateTextNode('${escapedTitle}')) | Out-Null`,
|
|
336
|
+
`$textNodes.Item(1).AppendChild($template.CreateTextNode('${escapedBody}')) | Out-Null`,
|
|
337
|
+
"$toast = [Windows.UI.Notifications.ToastNotification]::new($template)",
|
|
338
|
+
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ULPI').Show($toast)"
|
|
339
|
+
].join("\n");
|
|
340
|
+
const tmpFile = join2(tmpdir(), `ulpi-notify-${Date.now()}.ps1`);
|
|
341
|
+
try {
|
|
342
|
+
writeFileSync(tmpFile, ps);
|
|
343
|
+
execFileSync(
|
|
344
|
+
"powershell",
|
|
345
|
+
["-ExecutionPolicy", "Bypass", "-File", tmpFile],
|
|
346
|
+
{ timeout: 5e3, stdio: "ignore" }
|
|
347
|
+
);
|
|
348
|
+
} finally {
|
|
349
|
+
try {
|
|
350
|
+
unlinkSync(tmpFile);
|
|
351
|
+
} catch {
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
} catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function validateWebhookUrl(url) {
|
|
362
|
+
const parsed = new URL(url);
|
|
363
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
364
|
+
const blocked = [
|
|
365
|
+
"localhost",
|
|
366
|
+
"127.0.0.1",
|
|
367
|
+
"0.0.0.0",
|
|
368
|
+
"::1",
|
|
369
|
+
"[::1]",
|
|
370
|
+
"169.254.169.254"
|
|
371
|
+
// AWS/GCP metadata
|
|
372
|
+
];
|
|
373
|
+
if (blocked.includes(hostname)) {
|
|
374
|
+
throw new Error(`Webhook URL targets blocked address: ${hostname}`);
|
|
375
|
+
}
|
|
376
|
+
if (hostname.startsWith("10.") || hostname.startsWith("192.168.") || hostname.startsWith("172.16.") || hostname.startsWith("172.17.") || hostname.startsWith("172.18.") || hostname.startsWith("172.19.") || hostname.startsWith("172.2") || hostname.startsWith("172.30.") || hostname.startsWith("172.31.")) {
|
|
377
|
+
throw new Error(`Webhook URL targets blocked address: ${hostname}`);
|
|
378
|
+
}
|
|
379
|
+
if (hostname.endsWith(".local") || hostname.endsWith(".internal")) {
|
|
380
|
+
throw new Error(`Webhook URL targets blocked address: ${hostname}`);
|
|
381
|
+
}
|
|
382
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
383
|
+
throw new Error("Webhook URL must use http or https protocol");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function sendWebhookNotification(url, payload, headers) {
|
|
387
|
+
try {
|
|
388
|
+
validateWebhookUrl(url);
|
|
389
|
+
const response = await fetch(url, {
|
|
390
|
+
method: "POST",
|
|
391
|
+
headers: {
|
|
392
|
+
"Content-Type": "application/json",
|
|
393
|
+
"User-Agent": "ULPI/1.0",
|
|
394
|
+
...headers
|
|
395
|
+
},
|
|
396
|
+
body: JSON.stringify(payload),
|
|
397
|
+
signal: AbortSignal.timeout(5e3)
|
|
398
|
+
});
|
|
399
|
+
return response.ok;
|
|
400
|
+
} catch {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
function validateLogPath(logPath) {
|
|
405
|
+
const resolvedPath = path.resolve(logPath);
|
|
406
|
+
const resolvedBase = path.resolve(ULPI_GLOBAL_DIR);
|
|
407
|
+
if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) {
|
|
408
|
+
throw new Error("Log path must be within ULPI config directory");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function logNotification(entry, logPath) {
|
|
412
|
+
const target = logPath ?? NOTIFICATIONS_LOG_FILE;
|
|
413
|
+
try {
|
|
414
|
+
if (logPath) {
|
|
415
|
+
validateLogPath(logPath);
|
|
416
|
+
}
|
|
417
|
+
const dir = path.dirname(target);
|
|
418
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
419
|
+
const line = JSON.stringify(entry) + "\n";
|
|
420
|
+
fs.appendFileSync(target, line, "utf-8");
|
|
421
|
+
return true;
|
|
422
|
+
} catch {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function sendTerminalBell() {
|
|
427
|
+
process.stderr.write("\x07");
|
|
428
|
+
}
|
|
429
|
+
function writeTerminalNotification(title, message) {
|
|
430
|
+
process.stderr.write(`
|
|
431
|
+
[ulpi] ${title}: ${message}
|
|
432
|
+
`);
|
|
433
|
+
}
|
|
434
|
+
var deduplicator = new NotificationDeduplicator();
|
|
435
|
+
function getEventConfig(classified, config) {
|
|
436
|
+
const key = classified;
|
|
437
|
+
const value = config[key];
|
|
438
|
+
if (value && typeof value === "object" && "notify" in value) {
|
|
439
|
+
return value;
|
|
440
|
+
}
|
|
441
|
+
return void 0;
|
|
442
|
+
}
|
|
443
|
+
function collectActions(eventConfig) {
|
|
444
|
+
const actions = [];
|
|
445
|
+
const act = eventConfig.act;
|
|
446
|
+
if (!act) return actions;
|
|
447
|
+
if (act.inject_skill) actions.push(`inject_skill:${act.inject_skill}`);
|
|
448
|
+
if (act.inject_skill_on_retry) actions.push("inject_skill_on_retry");
|
|
449
|
+
if (act.inject_skill_after_n_blocks != null) actions.push("inject_skill_after_n_blocks");
|
|
450
|
+
if (act.alert_after_n_blocks != null) actions.push("alert_after_n_blocks");
|
|
451
|
+
if (act.rate_limit) actions.push("rate_limit");
|
|
452
|
+
if (act.log_failure_details) actions.push("log_failure_details");
|
|
453
|
+
return actions;
|
|
454
|
+
}
|
|
455
|
+
async function dispatchNotification(eventType, title, message, config) {
|
|
456
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
457
|
+
const windowMs = config?.dedup_window_seconds ? config.dedup_window_seconds * 1e3 : 5e3;
|
|
458
|
+
deduplicator.setWindowMs(windowMs);
|
|
459
|
+
const dedupeKey = `${eventType}:${message}`;
|
|
460
|
+
if (deduplicator.isDuplicate(dedupeKey)) {
|
|
461
|
+
return { classified: eventType, channels: [], actions: [], suppressed: true };
|
|
462
|
+
}
|
|
463
|
+
const channels = [];
|
|
464
|
+
const actions = [];
|
|
465
|
+
const eventConfig = config ? getEventConfig(eventType, config) : void 0;
|
|
466
|
+
if (eventConfig) {
|
|
467
|
+
for (const channel of eventConfig.notify) {
|
|
468
|
+
const sent = await routeToChannel(channel, title, message, timestamp, eventType);
|
|
469
|
+
if (sent) channels.push(channel.type);
|
|
470
|
+
}
|
|
471
|
+
actions.push(...collectActions(eventConfig));
|
|
472
|
+
} else {
|
|
473
|
+
logNotification({ event: eventType, title, message, timestamp });
|
|
474
|
+
channels.push("log");
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
classified: eventType,
|
|
478
|
+
channels,
|
|
479
|
+
actions,
|
|
480
|
+
suppressed: false,
|
|
481
|
+
actConfig: eventConfig?.act
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
async function routeNotification(input, config) {
|
|
485
|
+
const classified = classifyNotification(input);
|
|
486
|
+
const title = input.title ?? "ULPI";
|
|
487
|
+
const message = input.message ?? classified;
|
|
488
|
+
return dispatchNotification(classified, title, message, config);
|
|
489
|
+
}
|
|
490
|
+
async function fireNotification(eventType, title, message, config) {
|
|
491
|
+
return dispatchNotification(eventType, title, message, config);
|
|
492
|
+
}
|
|
493
|
+
async function routeToChannel(channel, title, message, timestamp, event) {
|
|
494
|
+
const displayMessage = channel.message ?? message;
|
|
495
|
+
switch (channel.type) {
|
|
496
|
+
case "desktop":
|
|
497
|
+
return sendDesktopNotification(title, displayMessage);
|
|
498
|
+
case "terminal":
|
|
499
|
+
if (channel.sound !== false) {
|
|
500
|
+
sendTerminalBell();
|
|
501
|
+
}
|
|
502
|
+
writeTerminalNotification(title, displayMessage);
|
|
503
|
+
return true;
|
|
504
|
+
case "log": {
|
|
505
|
+
const logPath = channel.path ?? void 0;
|
|
506
|
+
return logNotification(
|
|
507
|
+
{ event, title, message: displayMessage, timestamp },
|
|
508
|
+
logPath
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
case "webhook": {
|
|
512
|
+
if (!channel.url) return false;
|
|
513
|
+
return sendWebhookNotification(channel.url, {
|
|
514
|
+
event,
|
|
515
|
+
title,
|
|
516
|
+
message: displayMessage,
|
|
517
|
+
timestamp
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
default:
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function evaluateNotificationTriggers(state, latestEvent, config, recentEvents) {
|
|
525
|
+
if (!config) return [];
|
|
526
|
+
const triggers = [];
|
|
527
|
+
const ruleBlockedTrigger = checkRuleBlocked(latestEvent, config);
|
|
528
|
+
if (ruleBlockedTrigger) triggers.push(ruleBlockedTrigger);
|
|
529
|
+
const repeatedTrigger = checkRepeatedFailures(state, config);
|
|
530
|
+
if (repeatedTrigger) triggers.push(repeatedTrigger);
|
|
531
|
+
const sensitiveTrigger = checkSensitivePathAccess(
|
|
532
|
+
latestEvent,
|
|
533
|
+
state,
|
|
534
|
+
config
|
|
535
|
+
);
|
|
536
|
+
if (sensitiveTrigger) triggers.push(sensitiveTrigger);
|
|
537
|
+
const rapidTrigger = checkRapidPermissionRequests(recentEvents, config);
|
|
538
|
+
if (rapidTrigger) triggers.push(rapidTrigger);
|
|
539
|
+
const taskFailedTrigger = checkTaskFailed(latestEvent, config);
|
|
540
|
+
if (taskFailedTrigger) triggers.push(taskFailedTrigger);
|
|
541
|
+
const taskCompleteTrigger = checkTaskComplete(latestEvent, state, config);
|
|
542
|
+
if (taskCompleteTrigger) triggers.push(taskCompleteTrigger);
|
|
543
|
+
return triggers;
|
|
544
|
+
}
|
|
545
|
+
function checkRuleBlocked(latestEvent, config) {
|
|
546
|
+
if (!config.rule_blocked) return null;
|
|
547
|
+
if (latestEvent.type !== "tool_blocked") return null;
|
|
548
|
+
const tool = latestEvent.toolName ?? "unknown tool";
|
|
549
|
+
const rule = latestEvent.ruleName ?? "unknown rule";
|
|
550
|
+
const detail = latestEvent.message ? `: ${latestEvent.message}` : "";
|
|
551
|
+
return {
|
|
552
|
+
eventType: "rule_blocked",
|
|
553
|
+
title: "ULPI",
|
|
554
|
+
message: `Blocked ${tool} by rule "${rule}"${detail}`,
|
|
555
|
+
urgency: config.rule_blocked.urgency
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function checkRepeatedFailures(state, config) {
|
|
559
|
+
if (!config.repeated_failures) return null;
|
|
560
|
+
const threshold = config.repeated_failures.threshold ?? 3;
|
|
561
|
+
const blocks = state.consecutiveBlocks;
|
|
562
|
+
if (blocks < threshold) return null;
|
|
563
|
+
if (blocks % threshold !== 0) return null;
|
|
564
|
+
return {
|
|
565
|
+
eventType: "repeated_failures",
|
|
566
|
+
title: "ULPI",
|
|
567
|
+
message: `${blocks} consecutive blocks detected (threshold: ${threshold}). The agent may be stuck in a retry loop.`,
|
|
568
|
+
urgency: config.repeated_failures.urgency
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
function checkSensitivePathAccess(latestEvent, state, config) {
|
|
572
|
+
if (!config.sensitive_path_access) return null;
|
|
573
|
+
const patterns = config.sensitive_path_access.file_patterns;
|
|
574
|
+
if (!patterns || patterns.length === 0) return null;
|
|
575
|
+
const filePath = latestEvent.filePath;
|
|
576
|
+
if (!filePath) return null;
|
|
577
|
+
const projectDir2 = state.projectDir || ".";
|
|
578
|
+
const matched = patterns.some(
|
|
579
|
+
(pattern) => matchesFilePattern(pattern, filePath, projectDir2)
|
|
580
|
+
);
|
|
581
|
+
if (!matched) return null;
|
|
582
|
+
return {
|
|
583
|
+
eventType: "sensitive_path_access",
|
|
584
|
+
title: "ULPI",
|
|
585
|
+
message: `Sensitive file accessed: ${filePath}`,
|
|
586
|
+
urgency: config.sensitive_path_access.urgency
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
function checkRapidPermissionRequests(recentEvents, config) {
|
|
590
|
+
if (!config.rapid_permission_requests) return null;
|
|
591
|
+
if (!recentEvents || recentEvents.length === 0) return null;
|
|
592
|
+
const windowSeconds = config.rapid_permission_requests.window_seconds ?? 60;
|
|
593
|
+
const threshold = config.rapid_permission_requests.threshold ?? 5;
|
|
594
|
+
const now = Date.now();
|
|
595
|
+
const windowMs = windowSeconds * 1e3;
|
|
596
|
+
const permissionEvents = recentEvents.filter((evt) => {
|
|
597
|
+
if (evt.event !== "permission_allow" && evt.event !== "permission_deny") {
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
const evtTime = new Date(evt.ts).getTime();
|
|
601
|
+
return now - evtTime <= windowMs;
|
|
602
|
+
});
|
|
603
|
+
if (permissionEvents.length < threshold) return null;
|
|
604
|
+
return {
|
|
605
|
+
eventType: "rapid_permission_requests",
|
|
606
|
+
title: "ULPI",
|
|
607
|
+
message: `${permissionEvents.length} permission requests in the last ${windowSeconds}s (threshold: ${threshold}). The agent may be requesting excessive permissions.`,
|
|
608
|
+
urgency: config.rapid_permission_requests.urgency
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
function checkTaskFailed(latestEvent, config) {
|
|
612
|
+
if (!config.task_failed) return null;
|
|
613
|
+
if (latestEvent.type !== "postcondition_failed") return null;
|
|
614
|
+
const command2 = latestEvent.command ?? "unknown command";
|
|
615
|
+
const detail = latestEvent.message ? `: ${latestEvent.message}` : "";
|
|
616
|
+
return {
|
|
617
|
+
eventType: "task_failed",
|
|
618
|
+
title: "ULPI",
|
|
619
|
+
message: `Postcondition failed for "${command2}"${detail}`,
|
|
620
|
+
urgency: config.task_failed.urgency
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
function checkTaskComplete(latestEvent, state, config) {
|
|
624
|
+
if (!config.task_complete) return null;
|
|
625
|
+
if (latestEvent.type !== "session_end") return null;
|
|
626
|
+
if (state.filesWritten.length === 0) return null;
|
|
627
|
+
const filesWritten = state.filesWritten.length;
|
|
628
|
+
const blocked = state.actionsBlocked;
|
|
629
|
+
const blockNote = blocked > 0 ? ` (${blocked} action${blocked === 1 ? "" : "s"} blocked)` : "";
|
|
630
|
+
return {
|
|
631
|
+
eventType: "task_complete",
|
|
632
|
+
title: "ULPI",
|
|
633
|
+
message: `Session complete: ${filesWritten} file${filesWritten === 1 ? "" : "s"} written${blockNote}.`,
|
|
634
|
+
urgency: config.task_complete.urgency
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
var rateLimitCounters = /* @__PURE__ */ new Map();
|
|
638
|
+
function isRateLimited(eventType, threshold, windowSeconds) {
|
|
639
|
+
const now = Date.now();
|
|
640
|
+
const windowMs = windowSeconds * 1e3;
|
|
641
|
+
const entry = rateLimitCounters.get(eventType);
|
|
642
|
+
if (!entry || now - entry.windowStart >= windowMs) {
|
|
643
|
+
rateLimitCounters.set(eventType, { count: 1, windowStart: now });
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
entry.count++;
|
|
647
|
+
if (entry.count > threshold) {
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
function executeActions(act, ctx) {
|
|
653
|
+
const result = {
|
|
654
|
+
stderrMessage: ctx.stderrMessage,
|
|
655
|
+
extraChannels: [],
|
|
656
|
+
rateLimitSuppressed: false,
|
|
657
|
+
failureDetailsLogged: false
|
|
658
|
+
};
|
|
659
|
+
if (!act) return result;
|
|
660
|
+
const blocks = ctx.state.consecutiveBlocks;
|
|
661
|
+
if (act.rate_limit) {
|
|
662
|
+
if (isRateLimited(ctx.eventType, act.rate_limit.threshold, act.rate_limit.window_seconds)) {
|
|
663
|
+
result.rateLimitSuppressed = true;
|
|
664
|
+
return result;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (act.inject_skill) {
|
|
668
|
+
result.stderrMessage = appendSkill(result.stderrMessage, act.inject_skill, ctx.projectDir);
|
|
669
|
+
}
|
|
670
|
+
if (act.inject_skill_on_retry && blocks > 0) {
|
|
671
|
+
result.stderrMessage = appendSkill(result.stderrMessage, act.inject_skill_on_retry, ctx.projectDir);
|
|
672
|
+
}
|
|
673
|
+
if (act.inject_skill_after_n_blocks != null && blocks >= act.inject_skill_after_n_blocks && act.inject_skill) {
|
|
674
|
+
result.stderrMessage = appendSkill(result.stderrMessage, act.inject_skill, ctx.projectDir);
|
|
675
|
+
}
|
|
676
|
+
if (act.alert_after_n_blocks != null && blocks >= act.alert_after_n_blocks) {
|
|
677
|
+
if (act.alert_channels && act.alert_channels.length > 0) {
|
|
678
|
+
result.extraChannels = act.alert_channels;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (act.log_failure_details && ctx.latestEvent) {
|
|
682
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
683
|
+
logNotification({
|
|
684
|
+
event: `${ctx.eventType}:failure_details`,
|
|
685
|
+
title: "Failure Details",
|
|
686
|
+
message: JSON.stringify({
|
|
687
|
+
toolName: ctx.latestEvent.toolName,
|
|
688
|
+
toolInput: ctx.latestEvent.toolInput,
|
|
689
|
+
ruleName: ctx.latestEvent.ruleName,
|
|
690
|
+
ruleMessage: ctx.latestEvent.message,
|
|
691
|
+
consecutiveBlocks: blocks,
|
|
692
|
+
filesWritten: ctx.state.filesWritten.length,
|
|
693
|
+
commandsRun: ctx.state.commandsRun.length
|
|
694
|
+
}),
|
|
695
|
+
timestamp
|
|
696
|
+
});
|
|
697
|
+
result.failureDetailsLogged = true;
|
|
698
|
+
}
|
|
699
|
+
return result;
|
|
700
|
+
}
|
|
701
|
+
function appendSkill(message, skillPath, projectDir2) {
|
|
702
|
+
const content = loadSkillSync(skillPath, projectDir2);
|
|
703
|
+
if (!content) return message;
|
|
704
|
+
if (!message) return content;
|
|
705
|
+
return `${message}
|
|
706
|
+
|
|
707
|
+
---
|
|
708
|
+
|
|
709
|
+
${content}`;
|
|
710
|
+
}
|
|
711
|
+
function auditNotification(entry, projectDir2, baseDir) {
|
|
712
|
+
try {
|
|
713
|
+
const slug = projectDirToSlug(projectDir2);
|
|
714
|
+
const dir = baseDir ? path2.join(baseDir, "sessions", slug) : path2.join(SESSIONS_DIR, slug);
|
|
715
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
716
|
+
const filePath = path2.join(dir, "notifications.jsonl");
|
|
717
|
+
const line = JSON.stringify(entry) + "\n";
|
|
718
|
+
fs2.appendFileSync(filePath, line, "utf-8");
|
|
719
|
+
} catch {
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// src/hooks/notify-with-actions.ts
|
|
724
|
+
async function fireWithActions(triggers, responses, ctx) {
|
|
725
|
+
let stderrMessage = ctx.stderrMessage;
|
|
726
|
+
for (const trigger of triggers) {
|
|
727
|
+
try {
|
|
728
|
+
const routeResult = await fireNotification(
|
|
729
|
+
trigger.eventType,
|
|
730
|
+
trigger.title,
|
|
731
|
+
trigger.message,
|
|
732
|
+
responses
|
|
733
|
+
);
|
|
734
|
+
const actionCtx = {
|
|
735
|
+
eventType: trigger.eventType,
|
|
736
|
+
state: ctx.state,
|
|
737
|
+
projectDir: ctx.projectDir,
|
|
738
|
+
stderrMessage,
|
|
739
|
+
latestEvent: ctx.latestEvent ? {
|
|
740
|
+
toolName: ctx.latestEvent.toolName,
|
|
741
|
+
toolInput: ctx.latestEvent.toolInput,
|
|
742
|
+
ruleName: ctx.latestEvent.ruleName,
|
|
743
|
+
message: ctx.latestEvent.message
|
|
744
|
+
} : void 0
|
|
745
|
+
};
|
|
746
|
+
const actionResult = executeActions(routeResult.actConfig, actionCtx);
|
|
747
|
+
if (actionResult.stderrMessage !== stderrMessage) {
|
|
748
|
+
stderrMessage = actionResult.stderrMessage;
|
|
749
|
+
}
|
|
750
|
+
if (actionResult.extraChannels.length > 0) {
|
|
751
|
+
for (const channel of actionResult.extraChannels) {
|
|
752
|
+
await dispatchToChannel(channel, trigger.title, trigger.message);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const auditEntry = {
|
|
756
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
757
|
+
sessionId: ctx.sessionId,
|
|
758
|
+
eventType: trigger.eventType,
|
|
759
|
+
urgency: trigger.urgency,
|
|
760
|
+
title: trigger.title,
|
|
761
|
+
message: trigger.message,
|
|
762
|
+
channels: routeResult.channels,
|
|
763
|
+
actions: routeResult.actions,
|
|
764
|
+
suppressed: routeResult.suppressed || actionResult.rateLimitSuppressed,
|
|
765
|
+
suppressReason: routeResult.suppressed ? "dedup" : actionResult.rateLimitSuppressed ? "rate_limit" : void 0,
|
|
766
|
+
triggerContext: ctx.latestEvent ? {
|
|
767
|
+
toolName: ctx.latestEvent.toolName,
|
|
768
|
+
filePath: ctx.latestEvent.filePath,
|
|
769
|
+
command: ctx.latestEvent.command,
|
|
770
|
+
ruleName: ctx.latestEvent.ruleName,
|
|
771
|
+
consecutiveBlocks: ctx.state.consecutiveBlocks
|
|
772
|
+
} : void 0
|
|
773
|
+
};
|
|
774
|
+
auditNotification(auditEntry, ctx.projectDir);
|
|
775
|
+
} catch {
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return { stderrMessage };
|
|
779
|
+
}
|
|
780
|
+
async function dispatchToChannel(channel, title, message) {
|
|
781
|
+
const displayMessage = channel.message ?? message;
|
|
782
|
+
switch (channel.type) {
|
|
783
|
+
case "desktop":
|
|
784
|
+
await sendDesktopNotification(title, displayMessage);
|
|
785
|
+
break;
|
|
786
|
+
case "terminal":
|
|
787
|
+
if (channel.sound !== false) sendTerminalBell();
|
|
788
|
+
writeTerminalNotification(title, displayMessage);
|
|
789
|
+
break;
|
|
790
|
+
case "log":
|
|
791
|
+
logNotification({
|
|
792
|
+
event: "alert_escalation",
|
|
793
|
+
title,
|
|
794
|
+
message: displayMessage,
|
|
795
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
796
|
+
}, channel.path ?? void 0);
|
|
797
|
+
break;
|
|
798
|
+
case "webhook":
|
|
799
|
+
if (channel.url) {
|
|
800
|
+
await sendWebhookNotification(channel.url, {
|
|
801
|
+
event: "alert_escalation",
|
|
802
|
+
title,
|
|
803
|
+
message: displayMessage,
|
|
804
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// src/hooks/review-integration.ts
|
|
812
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, readdirSync, statSync, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
813
|
+
import { homedir } from "os";
|
|
814
|
+
import { join as join4 } from "path";
|
|
815
|
+
var FLAGS_BASE = REVIEW_FLAGS_DIR;
|
|
816
|
+
function flagsDir(sessionId) {
|
|
817
|
+
return sessionId ? join4(FLAGS_BASE, sessionId) : FLAGS_BASE;
|
|
818
|
+
}
|
|
819
|
+
function ensureFlagsDir(sessionId) {
|
|
820
|
+
const dir = flagsDir(sessionId);
|
|
821
|
+
if (!existsSync2(dir)) mkdirSync3(dir, { recursive: true });
|
|
822
|
+
}
|
|
823
|
+
function getReviewSettings() {
|
|
824
|
+
return loadUlpiSettings().review;
|
|
825
|
+
}
|
|
826
|
+
function isReviewEnabled(type) {
|
|
827
|
+
const review = getReviewSettings();
|
|
828
|
+
if (!review.enabled) return false;
|
|
829
|
+
if (type === "plan") return review.plan_review;
|
|
830
|
+
if (type === "code") return review.code_review;
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
async function extractPlanForReview(input) {
|
|
834
|
+
if (input.tool_input?.plan && typeof input.tool_input.plan === "string") {
|
|
835
|
+
return input.tool_input.plan;
|
|
836
|
+
}
|
|
837
|
+
if (input.tool_input?.planFilePath && typeof input.tool_input.planFilePath === "string") {
|
|
838
|
+
try {
|
|
839
|
+
return readFileSync(input.tool_input.planFilePath, "utf-8");
|
|
840
|
+
} catch {
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (input.transcript_path) {
|
|
844
|
+
const planFromTranscript = extractPlanFromTranscript(input.transcript_path);
|
|
845
|
+
if (planFromTranscript) return planFromTranscript;
|
|
846
|
+
}
|
|
847
|
+
const planDirs = [
|
|
848
|
+
input.cwd ? join4(input.cwd, ".claude", "plans") : null,
|
|
849
|
+
join4(homedir(), ".claude", "plans")
|
|
850
|
+
].filter(Boolean);
|
|
851
|
+
for (const dir of planDirs) {
|
|
852
|
+
const planFromDir = extractPlanFromDirectory(dir);
|
|
853
|
+
if (planFromDir) return planFromDir;
|
|
854
|
+
}
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
function extractPlanFromDirectory(plansDir) {
|
|
858
|
+
try {
|
|
859
|
+
if (!existsSync2(plansDir)) return null;
|
|
860
|
+
const files = readdirSync(plansDir);
|
|
861
|
+
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
862
|
+
if (mdFiles.length === 0) return null;
|
|
863
|
+
let latest = { file: mdFiles[0], mtime: 0 };
|
|
864
|
+
for (const f of mdFiles) {
|
|
865
|
+
const stat = statSync(join4(plansDir, f));
|
|
866
|
+
if (stat.mtimeMs > latest.mtime) {
|
|
867
|
+
latest = { file: f, mtime: stat.mtimeMs };
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const content = readFileSync(join4(plansDir, latest.file), "utf-8");
|
|
871
|
+
if (content.trim()) {
|
|
872
|
+
console.error(`[ulpi] Reading plan from ${plansDir}/${latest.file}`);
|
|
873
|
+
return content;
|
|
874
|
+
}
|
|
875
|
+
} catch {
|
|
876
|
+
}
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
function extractPlanFromTranscript(transcriptPath) {
|
|
880
|
+
try {
|
|
881
|
+
const transcriptText = readFileSync(transcriptPath, "utf-8");
|
|
882
|
+
const lines = transcriptText.trim().split("\n").reverse();
|
|
883
|
+
for (const line of lines) {
|
|
884
|
+
try {
|
|
885
|
+
const entry = JSON.parse(line);
|
|
886
|
+
if (entry.role === "assistant" && typeof entry.content === "string") {
|
|
887
|
+
if (looksLikePlan(entry.content)) return entry.content;
|
|
888
|
+
}
|
|
889
|
+
if (Array.isArray(entry.content)) {
|
|
890
|
+
for (const block of entry.content) {
|
|
891
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
892
|
+
if (looksLikePlan(block.text)) return block.text;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
} catch {
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
} catch {
|
|
900
|
+
}
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
function looksLikePlan(content) {
|
|
904
|
+
return content.includes("#") && content.length > 100;
|
|
905
|
+
}
|
|
906
|
+
function buildRichFeedback(decision, sections) {
|
|
907
|
+
const sectionTitleMap = /* @__PURE__ */ new Map();
|
|
908
|
+
for (const s of sections) {
|
|
909
|
+
sectionTitleMap.set(s.id, s.title);
|
|
910
|
+
}
|
|
911
|
+
const resolveSectionTitle = (sectionId) => {
|
|
912
|
+
if (!sectionId || sectionId === "global" || sectionId === "__global__") return "Global";
|
|
913
|
+
const byId = sectionTitleMap.get(sectionId);
|
|
914
|
+
if (byId) return byId;
|
|
915
|
+
const slugMatch = sections.find(
|
|
916
|
+
(s) => s.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") === sectionId
|
|
917
|
+
);
|
|
918
|
+
if (slugMatch) return slugMatch.title;
|
|
919
|
+
return sectionId;
|
|
920
|
+
};
|
|
921
|
+
const header = decision.behavior === "deny" ? "PLAN REVIEW FEEDBACK \u2014 Changes Requested" : "PLAN REVIEW FEEDBACK \u2014 Approved with Notes";
|
|
922
|
+
const parts = [header, ""];
|
|
923
|
+
const annotations = decision.annotations || [];
|
|
924
|
+
if (annotations.length > 0) {
|
|
925
|
+
parts.push("## Annotations");
|
|
926
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
927
|
+
for (const ann of annotations) {
|
|
928
|
+
const key = ann.sectionId || "__global__";
|
|
929
|
+
const group = grouped.get(key);
|
|
930
|
+
if (group) {
|
|
931
|
+
group.push(ann);
|
|
932
|
+
} else {
|
|
933
|
+
grouped.set(key, [ann]);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
for (const [key, anns] of grouped) {
|
|
937
|
+
for (const ann of anns) {
|
|
938
|
+
const sectionLabel = key === "__global__" ? "Global" : `Section: "${resolveSectionTitle(ann.sectionId)}"`;
|
|
939
|
+
const typeLabel = ann.type.toUpperCase().replace("_", " ");
|
|
940
|
+
parts.push(`### ${sectionLabel} (${typeLabel})`);
|
|
941
|
+
parts.push(ann.text);
|
|
942
|
+
if (ann.imagePaths && ann.imagePaths.length > 0) {
|
|
943
|
+
for (const imgPath of ann.imagePaths) {
|
|
944
|
+
parts.push(` - Attached image: ${imgPath}`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
parts.push("");
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const priorities = decision.priorities || [];
|
|
952
|
+
if (priorities.length > 0) {
|
|
953
|
+
parts.push("## Priorities");
|
|
954
|
+
for (const p of priorities) {
|
|
955
|
+
const title = resolveSectionTitle(p.sectionId);
|
|
956
|
+
const note = p.note ? ` (${p.note})` : "";
|
|
957
|
+
parts.push(`- "${title}" \u2192 ${p.priority.toUpperCase()}${note}`);
|
|
958
|
+
}
|
|
959
|
+
parts.push("");
|
|
960
|
+
}
|
|
961
|
+
const risks = decision.risks || [];
|
|
962
|
+
if (risks.length > 0) {
|
|
963
|
+
parts.push("## Risks");
|
|
964
|
+
for (const r of risks) {
|
|
965
|
+
const title = resolveSectionTitle(r.sectionId);
|
|
966
|
+
parts.push(`- "${title}" \u2192 ${r.level.toUpperCase()}: ${r.description}`);
|
|
967
|
+
}
|
|
968
|
+
parts.push("");
|
|
969
|
+
}
|
|
970
|
+
const instructions = decision.instructions || [];
|
|
971
|
+
if (instructions.length > 0) {
|
|
972
|
+
parts.push("## Instructions");
|
|
973
|
+
for (const inst of instructions) {
|
|
974
|
+
const title = resolveSectionTitle(inst.sectionId);
|
|
975
|
+
const priorityTag = inst.priority ? ` [${inst.priority.toUpperCase()}]` : "";
|
|
976
|
+
parts.push(`- "${title}": ${inst.instruction}${priorityTag}`);
|
|
977
|
+
}
|
|
978
|
+
parts.push("");
|
|
979
|
+
}
|
|
980
|
+
const inlineEdits = decision.inlineEdits || [];
|
|
981
|
+
if (inlineEdits.length > 0) {
|
|
982
|
+
parts.push("## Inline Edits");
|
|
983
|
+
for (const edit of inlineEdits) {
|
|
984
|
+
const title = resolveSectionTitle(edit.sectionId);
|
|
985
|
+
parts.push(`- Section "${title}": Reviewer edited the implementation approach`);
|
|
986
|
+
parts.push(` Original: "${truncate(edit.originalContent, 120)}"`);
|
|
987
|
+
parts.push(` Changed to: "${truncate(edit.editedContent, 120)}"`);
|
|
988
|
+
}
|
|
989
|
+
parts.push("");
|
|
990
|
+
}
|
|
991
|
+
const message = decision.message || decision.feedback;
|
|
992
|
+
if (message) {
|
|
993
|
+
parts.push("## Reviewer Message");
|
|
994
|
+
parts.push(message);
|
|
995
|
+
parts.push("");
|
|
996
|
+
}
|
|
997
|
+
if (decision.behavior === "deny") {
|
|
998
|
+
parts.push("ADDRESS ALL FEEDBACK BEFORE RE-SUBMITTING THE PLAN.");
|
|
999
|
+
}
|
|
1000
|
+
return parts.join("\n");
|
|
1001
|
+
}
|
|
1002
|
+
function truncate(text, maxLen) {
|
|
1003
|
+
const singleLine = text.replace(/\n/g, " ").trim();
|
|
1004
|
+
if (singleLine.length <= maxLen) return singleLine;
|
|
1005
|
+
return singleLine.slice(0, maxLen - 3) + "...";
|
|
1006
|
+
}
|
|
1007
|
+
async function runPlanReviewSession(plan, projectDir2, sessionId) {
|
|
1008
|
+
try {
|
|
1009
|
+
const discovered = await discoverUlpiServer();
|
|
1010
|
+
if (!discovered) {
|
|
1011
|
+
const settings2 = getReviewSettings();
|
|
1012
|
+
if (settings2.require_server) {
|
|
1013
|
+
console.error("[ulpi] No ULPI server running \u2014 blocking plan exit (require_server=true). Start the server with: ulpi ui");
|
|
1014
|
+
return { behavior: "deny", message: "Review server is required but not running. Start it with: ulpi ui" };
|
|
1015
|
+
}
|
|
1016
|
+
console.error("[ulpi] No ULPI server running \u2014 skipping plan review");
|
|
1017
|
+
return { behavior: "allow" };
|
|
1018
|
+
}
|
|
1019
|
+
const { port, secret: apiSecret } = discovered;
|
|
1020
|
+
appendEvent(sessionId, {
|
|
1021
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1022
|
+
event: "review_plan_started",
|
|
1023
|
+
hookEvent: "PermissionRequest",
|
|
1024
|
+
toolName: "ExitPlanMode",
|
|
1025
|
+
message: "Plan review session started"
|
|
1026
|
+
}, projectDir2);
|
|
1027
|
+
const registration = await registerWithServer(port, {
|
|
1028
|
+
type: "plan",
|
|
1029
|
+
plan,
|
|
1030
|
+
projectPath: projectDir2
|
|
1031
|
+
}, apiSecret);
|
|
1032
|
+
if (!registration) {
|
|
1033
|
+
console.error("[ulpi] Failed to register plan review session");
|
|
1034
|
+
return { behavior: "allow" };
|
|
1035
|
+
}
|
|
1036
|
+
console.error(`[ulpi] Plan review session: ${registration.sessionId}`);
|
|
1037
|
+
const apiHost = getApiHost();
|
|
1038
|
+
const planTokenParam = registration.token ? `&token=${encodeURIComponent(registration.token)}` : "";
|
|
1039
|
+
console.error(`[ulpi] Waiting for review decision at http://${apiHost}:${port}/review/plan?session=${registration.sessionId}${planTokenParam}`);
|
|
1040
|
+
const settings = getReviewSettings();
|
|
1041
|
+
const maxWaitMs = settings.review_timeout_seconds > 0 ? settings.review_timeout_seconds * 1e3 : void 0;
|
|
1042
|
+
const decision = await waitForServerDecision(port, registration.sessionId, 3e4, maxWaitMs, registration.token, apiSecret);
|
|
1043
|
+
if (!decision) {
|
|
1044
|
+
const behavior = settings.timeout_behavior ?? "allow";
|
|
1045
|
+
console.error(`[ulpi] Plan review timed out \u2014 ${behavior}`);
|
|
1046
|
+
return { behavior };
|
|
1047
|
+
}
|
|
1048
|
+
const planDecision = decision;
|
|
1049
|
+
appendEvent(sessionId, {
|
|
1050
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1051
|
+
event: "review_plan_decided",
|
|
1052
|
+
hookEvent: "PermissionRequest",
|
|
1053
|
+
toolName: "ExitPlanMode",
|
|
1054
|
+
message: `Plan review: ${planDecision.behavior}`
|
|
1055
|
+
}, projectDir2);
|
|
1056
|
+
const hasReviewData = (planDecision.annotations?.length ?? 0) > 0 || (planDecision.priorities?.length ?? 0) > 0 || (planDecision.risks?.length ?? 0) > 0 || (planDecision.instructions?.length ?? 0) > 0 || (planDecision.inlineEdits?.length ?? 0) > 0;
|
|
1057
|
+
let feedback;
|
|
1058
|
+
if (hasReviewData) {
|
|
1059
|
+
const blocks = parseMarkdownToBlocks(plan);
|
|
1060
|
+
const planSections = extractSections(blocks);
|
|
1061
|
+
feedback = buildRichFeedback(planDecision, planSections);
|
|
1062
|
+
} else {
|
|
1063
|
+
feedback = planDecision.feedback || planDecision.message || "";
|
|
1064
|
+
}
|
|
1065
|
+
return {
|
|
1066
|
+
behavior: planDecision.behavior,
|
|
1067
|
+
message: planDecision.message,
|
|
1068
|
+
feedback,
|
|
1069
|
+
clearContext: planDecision.clearContext
|
|
1070
|
+
};
|
|
1071
|
+
} catch (err) {
|
|
1072
|
+
console.error(`[ulpi] Plan review error: ${err instanceof Error ? err.message : err}`);
|
|
1073
|
+
return { behavior: "allow" };
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async function runCodeReviewSession(diff, commitMessage, projectDir2, sessionId) {
|
|
1077
|
+
try {
|
|
1078
|
+
const discovered = await discoverUlpiServer();
|
|
1079
|
+
if (!discovered) {
|
|
1080
|
+
const settings2 = getReviewSettings();
|
|
1081
|
+
if (settings2.require_server) {
|
|
1082
|
+
console.error("[ulpi] No ULPI server running \u2014 blocking commit (require_server=true). Start the server with: ulpi ui");
|
|
1083
|
+
return { approved: false, message: "Review server is required but not running. Start it with: ulpi ui" };
|
|
1084
|
+
}
|
|
1085
|
+
console.error("[ulpi] No ULPI server running \u2014 skipping code review");
|
|
1086
|
+
return { approved: true };
|
|
1087
|
+
}
|
|
1088
|
+
const { port, secret: apiSecret } = discovered;
|
|
1089
|
+
appendEvent(sessionId, {
|
|
1090
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1091
|
+
event: "review_code_started",
|
|
1092
|
+
hookEvent: "PreToolUse",
|
|
1093
|
+
toolName: "Bash",
|
|
1094
|
+
command: "git commit",
|
|
1095
|
+
message: "Code review session started"
|
|
1096
|
+
}, projectDir2);
|
|
1097
|
+
const registration = await registerWithServer(port, {
|
|
1098
|
+
type: "code",
|
|
1099
|
+
diff,
|
|
1100
|
+
commitMessage,
|
|
1101
|
+
projectPath: projectDir2
|
|
1102
|
+
}, apiSecret);
|
|
1103
|
+
if (!registration) {
|
|
1104
|
+
console.error("[ulpi] Failed to register code review session");
|
|
1105
|
+
return { approved: true };
|
|
1106
|
+
}
|
|
1107
|
+
console.error(`[ulpi] Code review session: ${registration.sessionId}`);
|
|
1108
|
+
const codeApiHost = getApiHost();
|
|
1109
|
+
const codeTokenParam = registration.token ? `&token=${encodeURIComponent(registration.token)}` : "";
|
|
1110
|
+
console.error(`[ulpi] Waiting for review decision at http://${codeApiHost}:${port}/review/code?session=${registration.sessionId}${codeTokenParam}`);
|
|
1111
|
+
const settings = getReviewSettings();
|
|
1112
|
+
const maxWaitMs = settings.review_timeout_seconds > 0 ? settings.review_timeout_seconds * 1e3 : void 0;
|
|
1113
|
+
const decision = await waitForServerDecision(port, registration.sessionId, 3e4, maxWaitMs, registration.token, apiSecret);
|
|
1114
|
+
if (!decision) {
|
|
1115
|
+
const behavior = settings.timeout_behavior ?? "allow";
|
|
1116
|
+
console.error(`[ulpi] Code review timed out \u2014 ${behavior}`);
|
|
1117
|
+
return { approved: behavior === "allow" };
|
|
1118
|
+
}
|
|
1119
|
+
const codeDecision = decision;
|
|
1120
|
+
appendEvent(sessionId, {
|
|
1121
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1122
|
+
event: "review_code_decided",
|
|
1123
|
+
hookEvent: "PreToolUse",
|
|
1124
|
+
toolName: "Bash",
|
|
1125
|
+
command: "git commit",
|
|
1126
|
+
message: `Code review: ${codeDecision.approved ? "approved" : "changes requested"}`
|
|
1127
|
+
}, projectDir2);
|
|
1128
|
+
return {
|
|
1129
|
+
approved: codeDecision.approved,
|
|
1130
|
+
feedback: codeDecision.feedback,
|
|
1131
|
+
message: codeDecision.message
|
|
1132
|
+
};
|
|
1133
|
+
} catch (err) {
|
|
1134
|
+
console.error(`[ulpi] Code review error: ${err instanceof Error ? err.message : err}`);
|
|
1135
|
+
return { approved: true };
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
function writeClearContextFlag(sessionId) {
|
|
1139
|
+
ensureFlagsDir(sessionId);
|
|
1140
|
+
writeFileSync2(join4(flagsDir(sessionId), "clear-context.flag"), JSON.stringify({ timestamp: Date.now() }));
|
|
1141
|
+
}
|
|
1142
|
+
function readClearContextFlag(sessionId) {
|
|
1143
|
+
try {
|
|
1144
|
+
const path5 = join4(flagsDir(sessionId), "clear-context.flag");
|
|
1145
|
+
if (!existsSync2(path5)) return false;
|
|
1146
|
+
const data = JSON.parse(readFileSync(path5, "utf8"));
|
|
1147
|
+
unlinkSync2(path5);
|
|
1148
|
+
const age = Date.now() - (data.timestamp || 0);
|
|
1149
|
+
return age < 5 * 60 * 1e3;
|
|
1150
|
+
} catch {
|
|
1151
|
+
return false;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
function writeReviewFeedbackFlag(feedback, sessionId) {
|
|
1155
|
+
ensureFlagsDir(sessionId);
|
|
1156
|
+
writeFileSync2(join4(flagsDir(sessionId), "review-feedback.flag"), JSON.stringify({ timestamp: Date.now(), feedback }));
|
|
1157
|
+
}
|
|
1158
|
+
function readReviewFeedbackFlag(sessionId) {
|
|
1159
|
+
try {
|
|
1160
|
+
const path5 = join4(flagsDir(sessionId), "review-feedback.flag");
|
|
1161
|
+
if (!existsSync2(path5)) return null;
|
|
1162
|
+
const data = JSON.parse(readFileSync(path5, "utf8"));
|
|
1163
|
+
unlinkSync2(path5);
|
|
1164
|
+
const age = Date.now() - (data.timestamp || 0);
|
|
1165
|
+
if (age >= 5 * 60 * 1e3) return null;
|
|
1166
|
+
return data.feedback || null;
|
|
1167
|
+
} catch {
|
|
1168
|
+
return null;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
function writeTeamDelegationFlag(planTitle, sections, sessionId) {
|
|
1172
|
+
ensureFlagsDir(sessionId);
|
|
1173
|
+
const flag = { timestamp: Date.now(), planTitle, sections };
|
|
1174
|
+
writeFileSync2(join4(flagsDir(sessionId), "team-delegation.flag"), JSON.stringify(flag));
|
|
1175
|
+
}
|
|
1176
|
+
function readTeamDelegationFlag(sessionId) {
|
|
1177
|
+
try {
|
|
1178
|
+
const path5 = join4(flagsDir(sessionId), "team-delegation.flag");
|
|
1179
|
+
if (!existsSync2(path5)) return null;
|
|
1180
|
+
const data = JSON.parse(readFileSync(path5, "utf8"));
|
|
1181
|
+
unlinkSync2(path5);
|
|
1182
|
+
const age = Date.now() - (data.timestamp || 0);
|
|
1183
|
+
if (age >= 5 * 60 * 1e3) return null;
|
|
1184
|
+
return data;
|
|
1185
|
+
} catch {
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
var AGENT_TYPE_KEYWORDS = [
|
|
1190
|
+
{ keywords: ["express", "node", "api", "endpoint", "middleware", "route", "server", "rest", "graphql"], agentType: "express-senior-engineer" },
|
|
1191
|
+
{ keywords: ["react", "component", "tailwind", "css", "ui", "frontend", "vite", "styled", "jsx", "tsx"], agentType: "react-vite-tailwind-engineer" },
|
|
1192
|
+
{ keywords: ["next.js", "nextjs", "app router", "rsc", "server component", "next"], agentType: "nextjs-senior-engineer" },
|
|
1193
|
+
{ keywords: ["laravel", "php", "eloquent", "artisan", "blade", "migration"], agentType: "laravel-senior-engineer" },
|
|
1194
|
+
{ keywords: ["docker", "container", "compose", "dockerfile", "kubernetes", "k8s"], agentType: "devops-docker-senior-engineer" },
|
|
1195
|
+
{ keywords: ["aws", "cdk", "cloudformation", "terraform", "lambda", "s3", "ec2", "iam"], agentType: "devops-aws-senior-engineer" },
|
|
1196
|
+
{ keywords: ["cli", "command", "terminal", "script", "commander", "inquirer", "chalk"], agentType: "nodejs-cli-senior-engineer" }
|
|
1197
|
+
];
|
|
1198
|
+
function inferAgentType(title, content) {
|
|
1199
|
+
const text = `${title} ${content}`.toLowerCase();
|
|
1200
|
+
let bestMatch = "general-purpose";
|
|
1201
|
+
let bestScore = 0;
|
|
1202
|
+
for (const entry of AGENT_TYPE_KEYWORDS) {
|
|
1203
|
+
let score = 0;
|
|
1204
|
+
for (const keyword of entry.keywords) {
|
|
1205
|
+
if (text.includes(keyword)) score++;
|
|
1206
|
+
}
|
|
1207
|
+
if (score > bestScore) {
|
|
1208
|
+
bestScore = score;
|
|
1209
|
+
bestMatch = entry.agentType;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return bestMatch;
|
|
1213
|
+
}
|
|
1214
|
+
function buildDelegationSections(sections, blocks) {
|
|
1215
|
+
return sections.filter((s) => s.level >= 2 && s.title !== "Overview").map((section) => {
|
|
1216
|
+
const content = getSectionFullText(section, blocks);
|
|
1217
|
+
return {
|
|
1218
|
+
title: section.title,
|
|
1219
|
+
agentType: inferAgentType(section.title, content)
|
|
1220
|
+
};
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
function buildDelegationContext(delegation) {
|
|
1224
|
+
const sectionLines = delegation.sections.map((s) => `- "${s.title}" \u2192 ${s.agentType}`).join("\n");
|
|
1225
|
+
return `TEAM DELEGATION INSTRUCTIONS:
|
|
1226
|
+
|
|
1227
|
+
The plan "${delegation.planTitle}" has been approved. You MUST now create a team of specialized agents to execute this plan in parallel using the Task tool.
|
|
1228
|
+
|
|
1229
|
+
As the team lead, you should:
|
|
1230
|
+
1. Analyze each plan section and confirm the recommended agent type (or adjust based on your understanding)
|
|
1231
|
+
2. Launch parallel Task agents for independent sections in a SINGLE message with multiple Task tool calls
|
|
1232
|
+
3. Use sequential execution only where sections have explicit dependencies
|
|
1233
|
+
4. Coordinate results after agents complete and verify integration
|
|
1234
|
+
|
|
1235
|
+
Recommended agent allocation:
|
|
1236
|
+
${sectionLines}
|
|
1237
|
+
|
|
1238
|
+
IMPORTANT: Launch independent agents in PARALLEL (single message, multiple Task tool calls) for maximum efficiency.`;
|
|
1239
|
+
}
|
|
1240
|
+
function collectPostReviewContexts(sessionId) {
|
|
1241
|
+
const contexts = [];
|
|
1242
|
+
if (readClearContextFlag(sessionId)) {
|
|
1243
|
+
contexts.push(
|
|
1244
|
+
"IMPORTANT: The plan reviewer approved this plan with a request to clear context before implementation. You MUST immediately run /compact before implementing. Do not ask the user \u2014 just do it now. After compaction completes, proceed with the approved plan."
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
const reviewFeedback = readReviewFeedbackFlag(sessionId);
|
|
1248
|
+
if (reviewFeedback) {
|
|
1249
|
+
contexts.push(reviewFeedback);
|
|
1250
|
+
}
|
|
1251
|
+
const delegation = readTeamDelegationFlag(sessionId);
|
|
1252
|
+
if (delegation) {
|
|
1253
|
+
contexts.push(buildDelegationContext(delegation));
|
|
1254
|
+
}
|
|
1255
|
+
return contexts;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// src/hooks/pre-tool.ts
|
|
1259
|
+
async function handlePreTool(ctx) {
|
|
1260
|
+
const { input, state, rules, projectDir: projectDir2, store } = ctx;
|
|
1261
|
+
const result = evaluateRules(input, rules, state, projectDir2);
|
|
1262
|
+
for (const { rule, outcome } of result.matchedRules) {
|
|
1263
|
+
appendEvent(input.session_id, {
|
|
1264
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1265
|
+
event: outcome === "fail" ? "tool_blocked" : "tool_allowed",
|
|
1266
|
+
hookEvent: input.hook_event_name,
|
|
1267
|
+
toolName: input.tool_name,
|
|
1268
|
+
filePath: input.tool_input?.file_path,
|
|
1269
|
+
command: input.tool_input?.command,
|
|
1270
|
+
ruleName: rule.id,
|
|
1271
|
+
message: outcome === "fail" ? result.stderrMessage : void 0
|
|
1272
|
+
}, projectDir2);
|
|
1273
|
+
}
|
|
1274
|
+
if (result.matchedRules.length > 0) {
|
|
1275
|
+
state.rulesEnforced += result.matchedRules.length;
|
|
1276
|
+
}
|
|
1277
|
+
const isBlock = result.action === "block" || result.action === "feedback";
|
|
1278
|
+
const failedRule = result.matchedRules.find((m) => m.outcome === "fail");
|
|
1279
|
+
try {
|
|
1280
|
+
const triggers = evaluateNotificationTriggers(state, {
|
|
1281
|
+
type: isBlock ? "tool_blocked" : "tool_allowed",
|
|
1282
|
+
toolName: input.tool_name,
|
|
1283
|
+
filePath: input.tool_input?.file_path,
|
|
1284
|
+
ruleName: failedRule?.rule?.id,
|
|
1285
|
+
message: result.stderrMessage
|
|
1286
|
+
}, rules.responses);
|
|
1287
|
+
const notifyResult = await fireWithActions(triggers, rules.responses, {
|
|
1288
|
+
sessionId: input.session_id,
|
|
1289
|
+
state,
|
|
1290
|
+
projectDir: projectDir2,
|
|
1291
|
+
stderrMessage: result.stderrMessage,
|
|
1292
|
+
latestEvent: {
|
|
1293
|
+
toolName: input.tool_name,
|
|
1294
|
+
toolInput: input.tool_input,
|
|
1295
|
+
filePath: input.tool_input?.file_path,
|
|
1296
|
+
ruleName: failedRule?.rule?.id,
|
|
1297
|
+
message: result.stderrMessage
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
if (notifyResult.stderrMessage && notifyResult.stderrMessage !== result.stderrMessage) {
|
|
1301
|
+
result.stderrMessage = notifyResult.stderrMessage;
|
|
1302
|
+
}
|
|
1303
|
+
for (const trigger of triggers) {
|
|
1304
|
+
appendEvent(input.session_id, {
|
|
1305
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1306
|
+
event: "notification_routed",
|
|
1307
|
+
hookEvent: input.hook_event_name,
|
|
1308
|
+
message: `Notification: ${trigger.eventType} -- ${trigger.message}`
|
|
1309
|
+
}, projectDir2);
|
|
1310
|
+
}
|
|
1311
|
+
} catch {
|
|
1312
|
+
}
|
|
1313
|
+
if (isBlock) {
|
|
1314
|
+
state.actionsBlocked++;
|
|
1315
|
+
state.consecutiveBlocks++;
|
|
1316
|
+
let message = result.stderrMessage ?? "Action blocked by rule";
|
|
1317
|
+
if (failedRule?.rule.skill) {
|
|
1318
|
+
message = injectSkill(message, failedRule.rule.skill, projectDir2);
|
|
1319
|
+
}
|
|
1320
|
+
store.save(state);
|
|
1321
|
+
if (result.stdoutJson) {
|
|
1322
|
+
process.stdout.write(JSON.stringify(result.stdoutJson));
|
|
1323
|
+
}
|
|
1324
|
+
process.stderr.write(message);
|
|
1325
|
+
return 2;
|
|
1326
|
+
}
|
|
1327
|
+
if (input.tool_name === "Bash" && isGitCommitCommand(input.tool_input?.command) && isReviewEnabled("code")) {
|
|
1328
|
+
try {
|
|
1329
|
+
const command2 = input.tool_input?.command;
|
|
1330
|
+
const { diff, commitMessage, allowEmpty } = captureCommitDiff({ command: command2, cwd: projectDir2 });
|
|
1331
|
+
if (allowEmpty && !diff.trim()) {
|
|
1332
|
+
console.error("[ulpi] Skipping code review: --allow-empty commit with no changes.");
|
|
1333
|
+
} else if (diff.trim()) {
|
|
1334
|
+
const reviewResult = await runCodeReviewSession(
|
|
1335
|
+
diff,
|
|
1336
|
+
commitMessage,
|
|
1337
|
+
projectDir2,
|
|
1338
|
+
input.session_id
|
|
1339
|
+
);
|
|
1340
|
+
if (!reviewResult.approved) {
|
|
1341
|
+
state.actionsBlocked++;
|
|
1342
|
+
state.consecutiveBlocks++;
|
|
1343
|
+
const message = reviewResult.feedback || reviewResult.message || "Code review: changes requested.";
|
|
1344
|
+
store.save(state);
|
|
1345
|
+
process.stderr.write(message);
|
|
1346
|
+
return 2;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
} catch (err) {
|
|
1350
|
+
console.error(`[ulpi] Code review error: ${err instanceof Error ? err.message : err}`);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
state.consecutiveBlocks = 0;
|
|
1354
|
+
if (result.stdoutJson) {
|
|
1355
|
+
process.stdout.write(JSON.stringify(result.stdoutJson));
|
|
1356
|
+
}
|
|
1357
|
+
store.save(state);
|
|
1358
|
+
return 0;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// src/hooks/post-tool.ts
|
|
1362
|
+
import * as child_process from "child_process";
|
|
1363
|
+
async function handlePostTool(ctx) {
|
|
1364
|
+
const { input, state, rules, projectDir: projectDir2, store } = ctx;
|
|
1365
|
+
const nextState = updateStateFromInput(state, input);
|
|
1366
|
+
Object.assign(state, nextState);
|
|
1367
|
+
if ((state.phase === "active" || state.phase === "active_committed") && state.headAtStart && historyBranchExists(projectDir2)) {
|
|
1368
|
+
const meta = readBranchMeta(projectDir2);
|
|
1369
|
+
if (meta?.config?.captureStrategy === "on-commit") {
|
|
1370
|
+
const currentHead = getCurrentHead(projectDir2);
|
|
1371
|
+
if (currentHead && currentHead !== state.headAtStart) {
|
|
1372
|
+
state.phase = "active_committed";
|
|
1373
|
+
captureOnCommit(projectDir2, state, input, currentHead).catch(() => {
|
|
1374
|
+
});
|
|
1375
|
+
state.headAtStart = currentHead;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
try {
|
|
1380
|
+
const contexts = collectPostReviewContexts(input.session_id);
|
|
1381
|
+
if (contexts.length > 0) {
|
|
1382
|
+
const contextJson = {
|
|
1383
|
+
hookSpecificOutput: {
|
|
1384
|
+
hookEventName: "PostToolUse",
|
|
1385
|
+
additionalContext: contexts.join("\n\n---\n\n")
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
process.stdout.write(JSON.stringify(contextJson));
|
|
1389
|
+
}
|
|
1390
|
+
} catch {
|
|
1391
|
+
}
|
|
1392
|
+
const result = evaluateRules(input, rules, state, projectDir2);
|
|
1393
|
+
const postconditions = result.postconditionResults ?? [];
|
|
1394
|
+
let hadFailure = false;
|
|
1395
|
+
let blockingFailure = false;
|
|
1396
|
+
let failedCommand = "";
|
|
1397
|
+
let failedMessage = "";
|
|
1398
|
+
if (postconditions.length > 0) {
|
|
1399
|
+
const failureFeedback = [];
|
|
1400
|
+
for (const { rule, command: cmd } of postconditions) {
|
|
1401
|
+
try {
|
|
1402
|
+
child_process.execSync(cmd, {
|
|
1403
|
+
cwd: projectDir2,
|
|
1404
|
+
encoding: "utf-8",
|
|
1405
|
+
timeout: rule.timeout ?? 3e4,
|
|
1406
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1407
|
+
});
|
|
1408
|
+
state.autoActionsRun++;
|
|
1409
|
+
appendEvent(input.session_id, {
|
|
1410
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1411
|
+
event: "postcondition_run",
|
|
1412
|
+
hookEvent: input.hook_event_name,
|
|
1413
|
+
toolName: input.tool_name,
|
|
1414
|
+
command: cmd,
|
|
1415
|
+
message: "Postcondition succeeded"
|
|
1416
|
+
}, projectDir2);
|
|
1417
|
+
} catch (err) {
|
|
1418
|
+
const error = err;
|
|
1419
|
+
const errMsg = error.stderr || error.message || "Unknown error";
|
|
1420
|
+
hadFailure = true;
|
|
1421
|
+
failedCommand = cmd;
|
|
1422
|
+
failedMessage = errMsg;
|
|
1423
|
+
appendEvent(input.session_id, {
|
|
1424
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1425
|
+
event: "postcondition_failed",
|
|
1426
|
+
hookEvent: input.hook_event_name,
|
|
1427
|
+
toolName: input.tool_name,
|
|
1428
|
+
command: cmd,
|
|
1429
|
+
message: errMsg
|
|
1430
|
+
}, projectDir2);
|
|
1431
|
+
failureFeedback.push(`\u2717 ${cmd}
|
|
1432
|
+
${errMsg}`);
|
|
1433
|
+
if (rule.block_on_failure) {
|
|
1434
|
+
blockingFailure = true;
|
|
1435
|
+
break;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
try {
|
|
1440
|
+
const triggers = evaluateNotificationTriggers(state, {
|
|
1441
|
+
type: hadFailure ? "postcondition_failed" : "postcondition_run",
|
|
1442
|
+
toolName: input.tool_name,
|
|
1443
|
+
command: failedCommand || void 0,
|
|
1444
|
+
message: failedMessage || void 0
|
|
1445
|
+
}, rules.responses);
|
|
1446
|
+
await fireWithActions(triggers, rules.responses, {
|
|
1447
|
+
sessionId: input.session_id,
|
|
1448
|
+
state,
|
|
1449
|
+
projectDir: projectDir2,
|
|
1450
|
+
latestEvent: {
|
|
1451
|
+
toolName: input.tool_name,
|
|
1452
|
+
command: failedCommand || void 0,
|
|
1453
|
+
message: failedMessage || void 0
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
for (const trigger of triggers) {
|
|
1457
|
+
appendEvent(input.session_id, {
|
|
1458
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1459
|
+
event: "notification_routed",
|
|
1460
|
+
hookEvent: input.hook_event_name,
|
|
1461
|
+
message: `Notification: ${trigger.eventType} -- ${trigger.message}`
|
|
1462
|
+
}, projectDir2);
|
|
1463
|
+
}
|
|
1464
|
+
} catch {
|
|
1465
|
+
}
|
|
1466
|
+
if (blockingFailure && failureFeedback.length > 0) {
|
|
1467
|
+
store.save(state);
|
|
1468
|
+
process.stderr.write(
|
|
1469
|
+
`[ulpi] Postconditions:
|
|
1470
|
+
${failureFeedback.join("\n\n")}`
|
|
1471
|
+
);
|
|
1472
|
+
return 2;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
try {
|
|
1476
|
+
const { isMemoryEnabled, loadMemoryConfig, appendMemoryEvent, toClassificationEvent } = await import("./dist-R5F4MX3I.js");
|
|
1477
|
+
if (isMemoryEnabled(projectDir2)) {
|
|
1478
|
+
const memConfig = loadMemoryConfig(projectDir2);
|
|
1479
|
+
if (memConfig.captureMode === "continuous") {
|
|
1480
|
+
const classEvent = toClassificationEvent({
|
|
1481
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1482
|
+
event: hadFailure ? "postcondition_failed" : "tool_used",
|
|
1483
|
+
hookEvent: input.hook_event_name,
|
|
1484
|
+
toolName: input.tool_name,
|
|
1485
|
+
filePath: input.tool_input?.file_path,
|
|
1486
|
+
command: input.tool_input?.command,
|
|
1487
|
+
message: void 0
|
|
1488
|
+
});
|
|
1489
|
+
appendMemoryEvent(input.session_id, classEvent, projectDir2);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
} catch {
|
|
1493
|
+
}
|
|
1494
|
+
if (input.tool_name === "Bash" && /\bgit\s+push\b/.test(String(input.tool_input?.command ?? ""))) {
|
|
1495
|
+
try {
|
|
1496
|
+
const { loadCodemapConfig, getCodemapStatus, exportIndex } = await import("./dist-LZKZFPVX.js");
|
|
1497
|
+
const { getCurrentBranch } = await import("./dist-RKOGLK7R.js");
|
|
1498
|
+
const codemapConfig = loadCodemapConfig(projectDir2);
|
|
1499
|
+
if (codemapConfig.autoExport) {
|
|
1500
|
+
const branch = state.branch ?? getCurrentBranch(projectDir2);
|
|
1501
|
+
const codemapStatus = getCodemapStatus(projectDir2, branch);
|
|
1502
|
+
if (codemapStatus.initialized) {
|
|
1503
|
+
exportIndex(projectDir2, branch).catch(() => {
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
} catch {
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
store.save(state);
|
|
1511
|
+
return 0;
|
|
1512
|
+
}
|
|
1513
|
+
async function captureOnCommit(projectDir2, state, input, sha) {
|
|
1514
|
+
if (entryExists(projectDir2, sha)) return;
|
|
1515
|
+
const commit = getCommitMetadata(projectDir2, sha);
|
|
1516
|
+
const diff = getCommitDiffStats(projectDir2, sha);
|
|
1517
|
+
const { diff: rawDiff, truncated } = getCommitRawDiff(projectDir2, sha);
|
|
1518
|
+
const events = readEvents(input.session_id, projectDir2);
|
|
1519
|
+
const sessionSummary = buildSessionSummary(state, events);
|
|
1520
|
+
const guardsYaml = loadActiveGuards(projectDir2);
|
|
1521
|
+
const entry = {
|
|
1522
|
+
version: 1,
|
|
1523
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1524
|
+
commit,
|
|
1525
|
+
diff,
|
|
1526
|
+
rawDiff: rawDiff || void 0,
|
|
1527
|
+
diffTruncated: truncated || void 0,
|
|
1528
|
+
session: sessionSummary,
|
|
1529
|
+
enrichment: null,
|
|
1530
|
+
reviewPlans: null,
|
|
1531
|
+
prePromptSnapshot: buildPrePromptSnapshot(state)
|
|
1532
|
+
};
|
|
1533
|
+
await writeHistoryEntry(projectDir2, entry, {
|
|
1534
|
+
state,
|
|
1535
|
+
events,
|
|
1536
|
+
guardsYaml
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// src/hooks/permission.ts
|
|
1541
|
+
async function handlePermission(ctx) {
|
|
1542
|
+
const { input, state, rules, projectDir: projectDir2, store } = ctx;
|
|
1543
|
+
const result = evaluateRules(input, rules, state, projectDir2);
|
|
1544
|
+
const hookDecision = result.stdoutJson?.hookSpecificOutput?.decision?.behavior ?? result.stdoutJson?.hookSpecificOutput?.permissionDecision;
|
|
1545
|
+
if (hookDecision) {
|
|
1546
|
+
const decision = hookDecision;
|
|
1547
|
+
const reason = result.stdoutJson?.hookSpecificOutput?.decision?.message ?? result.stdoutJson?.hookSpecificOutput?.permissionDecisionReason;
|
|
1548
|
+
appendEvent(input.session_id, {
|
|
1549
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1550
|
+
event: decision === "allow" ? "permission_allow" : "permission_deny",
|
|
1551
|
+
hookEvent: input.hook_event_name,
|
|
1552
|
+
toolName: input.tool_name,
|
|
1553
|
+
filePath: input.tool_input?.file_path,
|
|
1554
|
+
command: input.tool_input?.command,
|
|
1555
|
+
message: reason
|
|
1556
|
+
}, projectDir2);
|
|
1557
|
+
if (decision === "deny") {
|
|
1558
|
+
state.actionsBlocked++;
|
|
1559
|
+
}
|
|
1560
|
+
try {
|
|
1561
|
+
const recentEvents = readEvents(input.session_id, projectDir2);
|
|
1562
|
+
const triggers = evaluateNotificationTriggers(state, {
|
|
1563
|
+
type: decision === "allow" ? "permission_allow" : "permission_deny",
|
|
1564
|
+
toolName: input.tool_name
|
|
1565
|
+
}, rules.responses, recentEvents);
|
|
1566
|
+
const notifyResult = await fireWithActions(triggers, rules.responses, {
|
|
1567
|
+
sessionId: input.session_id,
|
|
1568
|
+
state,
|
|
1569
|
+
projectDir: projectDir2,
|
|
1570
|
+
stderrMessage: reason,
|
|
1571
|
+
latestEvent: {
|
|
1572
|
+
toolName: input.tool_name,
|
|
1573
|
+
toolInput: input.tool_input,
|
|
1574
|
+
filePath: input.tool_input?.file_path,
|
|
1575
|
+
command: input.tool_input?.command
|
|
1576
|
+
}
|
|
1577
|
+
});
|
|
1578
|
+
if (decision === "deny" && notifyResult.stderrMessage && notifyResult.stderrMessage !== reason) {
|
|
1579
|
+
if (result.stderrMessage) {
|
|
1580
|
+
result.stderrMessage = notifyResult.stderrMessage;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
for (const trigger of triggers) {
|
|
1584
|
+
appendEvent(input.session_id, {
|
|
1585
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1586
|
+
event: "notification_routed",
|
|
1587
|
+
hookEvent: input.hook_event_name,
|
|
1588
|
+
message: `Notification: ${trigger.eventType} -- ${trigger.message}`
|
|
1589
|
+
}, projectDir2);
|
|
1590
|
+
}
|
|
1591
|
+
} catch {
|
|
1592
|
+
}
|
|
1593
|
+
store.save(state);
|
|
1594
|
+
process.stdout.write(JSON.stringify(result.stdoutJson));
|
|
1595
|
+
if (result.stderrMessage) {
|
|
1596
|
+
process.stderr.write(result.stderrMessage);
|
|
1597
|
+
}
|
|
1598
|
+
return 2;
|
|
1599
|
+
}
|
|
1600
|
+
if (input.tool_name === "ExitPlanMode" && isReviewEnabled("plan")) {
|
|
1601
|
+
console.error(`[ulpi] ExitPlanMode intercepted \u2014 extracting plan (cwd: ${input.cwd})`);
|
|
1602
|
+
try {
|
|
1603
|
+
const plan = await extractPlanForReview(input);
|
|
1604
|
+
console.error(`[ulpi] Plan extraction result: ${plan ? `${plan.length} chars` : "null"}`);
|
|
1605
|
+
if (plan) {
|
|
1606
|
+
const result2 = await runPlanReviewSession(plan, projectDir2, input.session_id);
|
|
1607
|
+
if (result2.behavior === "allow") {
|
|
1608
|
+
state.planApproved = true;
|
|
1609
|
+
if (result2.clearContext) {
|
|
1610
|
+
writeClearContextFlag(input.session_id);
|
|
1611
|
+
}
|
|
1612
|
+
if (result2.feedback) {
|
|
1613
|
+
writeReviewFeedbackFlag(result2.feedback, input.session_id);
|
|
1614
|
+
}
|
|
1615
|
+
try {
|
|
1616
|
+
const blocks = parseMarkdownToBlocks(plan);
|
|
1617
|
+
const sections = extractSections(blocks);
|
|
1618
|
+
const title = extractTitle(blocks);
|
|
1619
|
+
const delegationSections = buildDelegationSections(sections, blocks);
|
|
1620
|
+
if (delegationSections.length > 0) {
|
|
1621
|
+
writeTeamDelegationFlag(title, delegationSections, input.session_id);
|
|
1622
|
+
}
|
|
1623
|
+
} catch {
|
|
1624
|
+
}
|
|
1625
|
+
const allowJson = {
|
|
1626
|
+
hookSpecificOutput: {
|
|
1627
|
+
hookEventName: "PermissionRequest",
|
|
1628
|
+
decision: { behavior: "allow" }
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
store.save(state);
|
|
1632
|
+
process.stdout.write(JSON.stringify(allowJson));
|
|
1633
|
+
return 2;
|
|
1634
|
+
} else {
|
|
1635
|
+
const feedbackText = result2.feedback || result2.message || "Plan review: changes requested";
|
|
1636
|
+
writeReviewFeedbackFlag(feedbackText, input.session_id);
|
|
1637
|
+
const denyJson = {
|
|
1638
|
+
hookSpecificOutput: {
|
|
1639
|
+
hookEventName: "PermissionRequest",
|
|
1640
|
+
decision: { behavior: "deny", message: feedbackText }
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
state.actionsBlocked++;
|
|
1644
|
+
store.save(state);
|
|
1645
|
+
process.stdout.write(JSON.stringify(denyJson));
|
|
1646
|
+
process.stderr.write(feedbackText);
|
|
1647
|
+
return 2;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
} catch (err) {
|
|
1651
|
+
console.error(`[ulpi] Plan review error: ${err instanceof Error ? err.message : err}`);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
return 0;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// src/hooks/notification.ts
|
|
1658
|
+
async function handleNotification(ctx) {
|
|
1659
|
+
const { input, state, rules, store, projectDir: projectDir2 } = ctx;
|
|
1660
|
+
try {
|
|
1661
|
+
const result = await routeNotification(input, rules.responses);
|
|
1662
|
+
const actionCtx = {
|
|
1663
|
+
eventType: result.classified,
|
|
1664
|
+
state,
|
|
1665
|
+
projectDir: projectDir2
|
|
1666
|
+
};
|
|
1667
|
+
const actionResult = executeActions(result.actConfig, actionCtx);
|
|
1668
|
+
if (actionResult.stderrMessage) {
|
|
1669
|
+
process.stderr.write(actionResult.stderrMessage);
|
|
1670
|
+
}
|
|
1671
|
+
const channelsInfo = result.channels.length > 0 ? ` via ${result.channels.join(", ")}` : "";
|
|
1672
|
+
const suppressedInfo = result.suppressed ? " (suppressed by dedup)" : "";
|
|
1673
|
+
const actionsInfo = result.actions.length > 0 ? ` | actions: ${result.actions.join(", ")}` : "";
|
|
1674
|
+
appendEvent(input.session_id, {
|
|
1675
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1676
|
+
event: "notification_routed",
|
|
1677
|
+
hookEvent: input.hook_event_name,
|
|
1678
|
+
message: `Notification "${result.classified}"${channelsInfo}${suppressedInfo}${actionsInfo}`
|
|
1679
|
+
}, projectDir2);
|
|
1680
|
+
const auditEntry = {
|
|
1681
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1682
|
+
sessionId: input.session_id,
|
|
1683
|
+
eventType: result.classified,
|
|
1684
|
+
title: input.title ?? "ULPI",
|
|
1685
|
+
message: input.message ?? result.classified,
|
|
1686
|
+
channels: result.channels,
|
|
1687
|
+
actions: result.actions,
|
|
1688
|
+
suppressed: result.suppressed || actionResult.rateLimitSuppressed,
|
|
1689
|
+
suppressReason: result.suppressed ? "dedup" : actionResult.rateLimitSuppressed ? "rate_limit" : void 0
|
|
1690
|
+
};
|
|
1691
|
+
auditNotification(auditEntry, projectDir2);
|
|
1692
|
+
state.lastNotifications[result.classified] = Date.now();
|
|
1693
|
+
} catch {
|
|
1694
|
+
}
|
|
1695
|
+
store.save(state);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// src/hooks/stop.ts
|
|
1699
|
+
async function handleStop(ctx) {
|
|
1700
|
+
const { input, state, rules, store, projectDir: projectDir2 } = ctx;
|
|
1701
|
+
const warnings = [];
|
|
1702
|
+
if (state.filesWritten.length > 0 && !state.testsRun) {
|
|
1703
|
+
warnings.push("Tests were not run during this session.");
|
|
1704
|
+
}
|
|
1705
|
+
if (state.filesWritten.length > 0 && !state.lintRun) {
|
|
1706
|
+
warnings.push("Linting was not run during this session.");
|
|
1707
|
+
}
|
|
1708
|
+
try {
|
|
1709
|
+
const triggers = evaluateNotificationTriggers(state, {
|
|
1710
|
+
type: "stop_requested",
|
|
1711
|
+
message: `Stop requested. Files written: ${state.filesWritten.length}`
|
|
1712
|
+
}, rules.responses);
|
|
1713
|
+
const notifyResult = await fireWithActions(triggers, rules.responses, {
|
|
1714
|
+
sessionId: input.session_id,
|
|
1715
|
+
state,
|
|
1716
|
+
projectDir: projectDir2,
|
|
1717
|
+
stderrMessage: warnings.length > 0 ? warnings.join("\n") : void 0
|
|
1718
|
+
});
|
|
1719
|
+
if (notifyResult.stderrMessage && warnings.length > 0) {
|
|
1720
|
+
warnings.push(notifyResult.stderrMessage);
|
|
1721
|
+
}
|
|
1722
|
+
for (const trigger of triggers) {
|
|
1723
|
+
appendEvent(input.session_id, {
|
|
1724
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1725
|
+
event: "notification_routed",
|
|
1726
|
+
hookEvent: input.hook_event_name,
|
|
1727
|
+
message: `Notification: ${trigger.eventType} -- ${trigger.message}`
|
|
1728
|
+
}, projectDir2);
|
|
1729
|
+
}
|
|
1730
|
+
} catch {
|
|
1731
|
+
}
|
|
1732
|
+
appendEvent(input.session_id, {
|
|
1733
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1734
|
+
event: "stop_requested",
|
|
1735
|
+
hookEvent: input.hook_event_name,
|
|
1736
|
+
message: `Stop requested. Files written: ${state.filesWritten.length}, Commands run: ${state.commandsRun.length}`
|
|
1737
|
+
}, projectDir2);
|
|
1738
|
+
store.save(state);
|
|
1739
|
+
if (warnings.length > 0 && input.stop_hook_active) {
|
|
1740
|
+
process.stderr.write(
|
|
1741
|
+
`[ulpi] Session warnings:
|
|
1742
|
+
${warnings.map((w) => ` - ${w}`).join("\n")}`
|
|
1743
|
+
);
|
|
1744
|
+
return 2;
|
|
1745
|
+
}
|
|
1746
|
+
return 0;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// src/hooks/session-end.ts
|
|
1750
|
+
async function captureSessionCommits(ctx) {
|
|
1751
|
+
const { input, state, projectDir: projectDir2 } = ctx;
|
|
1752
|
+
if (!state.headAtStart) return;
|
|
1753
|
+
if (!historyBranchExists(projectDir2)) return;
|
|
1754
|
+
const currentHead = getCurrentHead(projectDir2);
|
|
1755
|
+
if (!currentHead || currentHead === state.headAtStart) return;
|
|
1756
|
+
const newShas = listCommitsBetween(projectDir2, state.headAtStart, currentHead);
|
|
1757
|
+
if (newShas.length === 0) return;
|
|
1758
|
+
const events = readEvents(input.session_id, projectDir2);
|
|
1759
|
+
const sessionSummary = buildSessionSummary(state, events);
|
|
1760
|
+
const guardsYaml = loadActiveGuards(projectDir2);
|
|
1761
|
+
let transcriptContent = null;
|
|
1762
|
+
const meta = readBranchMeta(projectDir2);
|
|
1763
|
+
if (meta?.config?.captureTranscript !== false && input.transcript_path) {
|
|
1764
|
+
const maxSize = meta?.config?.maxTranscriptSize ?? 5242880;
|
|
1765
|
+
const result = readTranscript(input.transcript_path, maxSize);
|
|
1766
|
+
if (result) {
|
|
1767
|
+
transcriptContent = result.content;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
const collectReviewPlans = meta?.config?.collectReviewPlans ?? false;
|
|
1771
|
+
for (const sha of newShas) {
|
|
1772
|
+
try {
|
|
1773
|
+
if (entryExists(projectDir2, sha)) {
|
|
1774
|
+
if (transcriptContent && !readEntryTranscript(projectDir2, sha)) {
|
|
1775
|
+
await updateEntryTranscript(projectDir2, sha, transcriptContent);
|
|
1776
|
+
}
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
const commit = getCommitMetadata(projectDir2, sha);
|
|
1780
|
+
const diff = getCommitDiffStats(projectDir2, sha);
|
|
1781
|
+
const { diff: rawDiff, truncated } = getCommitRawDiff(projectDir2, sha);
|
|
1782
|
+
let reviewPlanData = null;
|
|
1783
|
+
if (collectReviewPlans) {
|
|
1784
|
+
reviewPlanData = findReviewPlansForCommit(
|
|
1785
|
+
commit.authorDate,
|
|
1786
|
+
state.startedAt,
|
|
1787
|
+
projectDir2
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
const entry = {
|
|
1791
|
+
version: 1,
|
|
1792
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1793
|
+
commit,
|
|
1794
|
+
diff,
|
|
1795
|
+
rawDiff: rawDiff || void 0,
|
|
1796
|
+
diffTruncated: truncated || void 0,
|
|
1797
|
+
session: sessionSummary,
|
|
1798
|
+
enrichment: null,
|
|
1799
|
+
reviewPlans: reviewPlanData?.snapshots ?? null,
|
|
1800
|
+
prePromptSnapshot: buildPrePromptSnapshot(state)
|
|
1801
|
+
};
|
|
1802
|
+
await writeHistoryEntry(projectDir2, entry, {
|
|
1803
|
+
state,
|
|
1804
|
+
events,
|
|
1805
|
+
guardsYaml,
|
|
1806
|
+
reviewPlans: reviewPlanData?.rawData,
|
|
1807
|
+
transcript: transcriptContent
|
|
1808
|
+
});
|
|
1809
|
+
} catch {
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
async function handleSessionEnd(ctx) {
|
|
1815
|
+
const { input, state, store, projectDir: projectDir2 } = ctx;
|
|
1816
|
+
state.phase = "ended";
|
|
1817
|
+
appendEvent(input.session_id, {
|
|
1818
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1819
|
+
event: "session_end",
|
|
1820
|
+
hookEvent: input.hook_event_name,
|
|
1821
|
+
message: [
|
|
1822
|
+
`Session ended.`,
|
|
1823
|
+
`Files read: ${state.filesRead.length}`,
|
|
1824
|
+
`Files written: ${state.filesWritten.length}`,
|
|
1825
|
+
`Commands run: ${state.commandsRun.length}`,
|
|
1826
|
+
`Rules enforced: ${state.rulesEnforced}`,
|
|
1827
|
+
`Actions blocked: ${state.actionsBlocked}`,
|
|
1828
|
+
`Auto-actions run: ${state.autoActionsRun}`
|
|
1829
|
+
].join(", ")
|
|
1830
|
+
}, projectDir2);
|
|
1831
|
+
store.save(state);
|
|
1832
|
+
try {
|
|
1833
|
+
await captureSessionCommits(ctx);
|
|
1834
|
+
} catch {
|
|
1835
|
+
}
|
|
1836
|
+
try {
|
|
1837
|
+
const memEngine = await import("./dist-R5F4MX3I.js");
|
|
1838
|
+
if (memEngine.isMemoryEnabled(projectDir2)) {
|
|
1839
|
+
const config = memEngine.loadMemoryConfig(projectDir2);
|
|
1840
|
+
memEngine.finalizeCapture(input.session_id, state, projectDir2);
|
|
1841
|
+
if (config.classifier.enabled) {
|
|
1842
|
+
await memEngine.classifySession(projectDir2, input.session_id);
|
|
1843
|
+
}
|
|
1844
|
+
if (config.autoExport) {
|
|
1845
|
+
try {
|
|
1846
|
+
memEngine.exportMemories(projectDir2);
|
|
1847
|
+
} catch {
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
} catch {
|
|
1852
|
+
}
|
|
1853
|
+
try {
|
|
1854
|
+
const { loadCodemapConfig, getCodemapStatus, exportIndex } = await import("./dist-LZKZFPVX.js");
|
|
1855
|
+
const { getCurrentBranch } = await import("./dist-RKOGLK7R.js");
|
|
1856
|
+
const codemapConfig = loadCodemapConfig(projectDir2);
|
|
1857
|
+
if (codemapConfig.autoExport) {
|
|
1858
|
+
const branch = state.branch ?? getCurrentBranch(projectDir2);
|
|
1859
|
+
const codemapStatus = getCodemapStatus(projectDir2, branch);
|
|
1860
|
+
if (codemapStatus.initialized && state.filesWritten.length > 0) {
|
|
1861
|
+
await exportIndex(projectDir2, branch);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
} catch {
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// src/hooks/handler.ts
|
|
1869
|
+
function readStdinSync() {
|
|
1870
|
+
const raw = fs3.readFileSync(0, "utf-8");
|
|
1871
|
+
return JSON.parse(raw);
|
|
1872
|
+
}
|
|
1873
|
+
function findRulesPath(projectDir2) {
|
|
1874
|
+
const projectRules = projectGuardsFile(projectDir2);
|
|
1875
|
+
if (fs3.existsSync(projectRules)) return projectRules;
|
|
1876
|
+
const projectRulesYaml = projectGuardsFileAlt(projectDir2);
|
|
1877
|
+
if (fs3.existsSync(projectRulesYaml)) return projectRulesYaml;
|
|
1878
|
+
const userRules = globalGuardsFile();
|
|
1879
|
+
if (fs3.existsSync(userRules)) return userRules;
|
|
1880
|
+
return null;
|
|
1881
|
+
}
|
|
1882
|
+
function loadRules(projectDir2) {
|
|
1883
|
+
const rulesPath = findRulesPath(projectDir2);
|
|
1884
|
+
if (rulesPath) {
|
|
1885
|
+
const result = loadRulesSync(rulesPath);
|
|
1886
|
+
if (result) return result;
|
|
1887
|
+
}
|
|
1888
|
+
return {
|
|
1889
|
+
project: {
|
|
1890
|
+
name: path3.basename(projectDir2),
|
|
1891
|
+
runtime: "unknown",
|
|
1892
|
+
package_manager: "npm"
|
|
1893
|
+
},
|
|
1894
|
+
preconditions: {},
|
|
1895
|
+
postconditions: {},
|
|
1896
|
+
permissions: {},
|
|
1897
|
+
pipelines: {}
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
function validateInput(input) {
|
|
1901
|
+
if (!input || typeof input !== "object") {
|
|
1902
|
+
throw new Error("Invalid hook input");
|
|
1903
|
+
}
|
|
1904
|
+
if (!input.session_id || typeof input.session_id !== "string") {
|
|
1905
|
+
throw new Error("Missing or invalid session_id");
|
|
1906
|
+
}
|
|
1907
|
+
if (input.session_id.length > 200) {
|
|
1908
|
+
throw new Error("session_id too long");
|
|
1909
|
+
}
|
|
1910
|
+
if (input.session_id.includes("..") || input.session_id.includes("/") || input.session_id.includes("\\")) {
|
|
1911
|
+
throw new Error("Invalid session_id characters");
|
|
1912
|
+
}
|
|
1913
|
+
if (!input.cwd || typeof input.cwd !== "string") {
|
|
1914
|
+
throw new Error("Missing or invalid cwd");
|
|
1915
|
+
}
|
|
1916
|
+
if (!input.hook_event_name || typeof input.hook_event_name !== "string") {
|
|
1917
|
+
throw new Error("Missing or invalid hook_event_name");
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
async function handleHook(hookName) {
|
|
1921
|
+
const input = readStdinSync();
|
|
1922
|
+
validateInput(input);
|
|
1923
|
+
const projectDir2 = input.cwd;
|
|
1924
|
+
const store = new JsonSessionStore(void 0, projectDir2);
|
|
1925
|
+
let state = store.load(input.session_id);
|
|
1926
|
+
if (!state) {
|
|
1927
|
+
state = createInitialState(input.session_id, projectDir2);
|
|
1928
|
+
}
|
|
1929
|
+
if (input.title && input.title !== state.sessionName) {
|
|
1930
|
+
state.sessionName = input.title;
|
|
1931
|
+
}
|
|
1932
|
+
const rules = loadRules(projectDir2);
|
|
1933
|
+
const ctx = {
|
|
1934
|
+
input,
|
|
1935
|
+
state,
|
|
1936
|
+
rules,
|
|
1937
|
+
projectDir: projectDir2,
|
|
1938
|
+
store
|
|
1939
|
+
};
|
|
1940
|
+
if (hookName === "session-start") {
|
|
1941
|
+
try {
|
|
1942
|
+
registerProject(projectDir2, {
|
|
1943
|
+
sessionId: input.session_id
|
|
1944
|
+
});
|
|
1945
|
+
} catch {
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
let exitCode = 0;
|
|
1949
|
+
switch (hookName) {
|
|
1950
|
+
case "session-start":
|
|
1951
|
+
await handleSessionStart(ctx);
|
|
1952
|
+
break;
|
|
1953
|
+
case "pre-tool":
|
|
1954
|
+
exitCode = await handlePreTool(ctx);
|
|
1955
|
+
break;
|
|
1956
|
+
case "post-tool":
|
|
1957
|
+
exitCode = await handlePostTool(ctx);
|
|
1958
|
+
break;
|
|
1959
|
+
case "permission":
|
|
1960
|
+
exitCode = await handlePermission(ctx);
|
|
1961
|
+
break;
|
|
1962
|
+
case "notification":
|
|
1963
|
+
await handleNotification(ctx);
|
|
1964
|
+
break;
|
|
1965
|
+
case "stop":
|
|
1966
|
+
exitCode = await handleStop(ctx);
|
|
1967
|
+
break;
|
|
1968
|
+
case "session-end":
|
|
1969
|
+
await handleSessionEnd(ctx);
|
|
1970
|
+
break;
|
|
1971
|
+
}
|
|
1972
|
+
process.exit(exitCode);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// src/index.ts
|
|
1976
|
+
var rawArgs = process.argv.slice(2);
|
|
1977
|
+
var hookCommands = [
|
|
1978
|
+
"session-start",
|
|
1979
|
+
"pre-tool",
|
|
1980
|
+
"post-tool",
|
|
1981
|
+
"permission",
|
|
1982
|
+
"notification",
|
|
1983
|
+
"stop",
|
|
1984
|
+
"session-end"
|
|
1985
|
+
];
|
|
1986
|
+
function parseGlobalFlags(args2) {
|
|
1987
|
+
const projectIdx = args2.findIndex((arg) => arg === "--project" || arg === "-p");
|
|
1988
|
+
if (projectIdx !== -1 && args2[projectIdx + 1]) {
|
|
1989
|
+
const projectRef = args2[projectIdx + 1];
|
|
1990
|
+
const entry = getProject(projectRef);
|
|
1991
|
+
if (!entry) {
|
|
1992
|
+
console.error(`Error: Unknown project: ${projectRef}`);
|
|
1993
|
+
console.error("Run 'ulpi projects list' to see registered projects.");
|
|
1994
|
+
process.exit(1);
|
|
1995
|
+
}
|
|
1996
|
+
const remaining = [...args2.slice(0, projectIdx), ...args2.slice(projectIdx + 2)];
|
|
1997
|
+
return { projectDir: entry.path, remainingArgs: remaining };
|
|
1998
|
+
}
|
|
1999
|
+
const cwd = process.cwd();
|
|
2000
|
+
const isProjectDir = fs4.existsSync(path4.join(cwd, ".ulpi"));
|
|
2001
|
+
if (isProjectDir) {
|
|
2002
|
+
return { projectDir: cwd, remainingArgs: args2 };
|
|
2003
|
+
}
|
|
2004
|
+
const defaultProject = getDefaultProject();
|
|
2005
|
+
if (defaultProject) {
|
|
2006
|
+
return { projectDir: defaultProject.path, remainingArgs: args2 };
|
|
2007
|
+
}
|
|
2008
|
+
return { projectDir: cwd, remainingArgs: args2 };
|
|
2009
|
+
}
|
|
2010
|
+
var { projectDir, remainingArgs } = parseGlobalFlags(rawArgs);
|
|
2011
|
+
var args = remainingArgs;
|
|
2012
|
+
var command = args[0];
|
|
2013
|
+
async function main() {
|
|
2014
|
+
if (!command) {
|
|
2015
|
+
printUsage();
|
|
2016
|
+
process.exit(0);
|
|
2017
|
+
}
|
|
2018
|
+
if (hookCommands.includes(command)) {
|
|
2019
|
+
await handleHook(command);
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
switch (command) {
|
|
2023
|
+
case "init": {
|
|
2024
|
+
const initDir = rawArgs.some((a) => a === "--project" || a === "-p") ? projectDir : process.cwd();
|
|
2025
|
+
return (await import("./init-AY5C2ZAS.js")).runInit([initDir, ...args.slice(1)]);
|
|
2026
|
+
}
|
|
2027
|
+
case "rules":
|
|
2028
|
+
return (await import("./rules-E427DKYJ.js")).runRules(args.slice(1), projectDir);
|
|
2029
|
+
case "templates":
|
|
2030
|
+
return (await import("./templates-U7T6MARD.js")).runTemplates(args.slice(1), projectDir);
|
|
2031
|
+
case "skills":
|
|
2032
|
+
return (await import("./skills-CX73O3IV.js")).runSkills(args.slice(1), projectDir);
|
|
2033
|
+
case "status":
|
|
2034
|
+
return (await import("./status-4DFHDJMN.js")).runStatus(args.slice(1), projectDir);
|
|
2035
|
+
case "log":
|
|
2036
|
+
return (await import("./log-TVTUXAYD.js")).runLog(args.slice(1), projectDir);
|
|
2037
|
+
case "export":
|
|
2038
|
+
return (await import("./export-import-4A5MWLIA.js")).runExport(args.slice(1), projectDir);
|
|
2039
|
+
case "import":
|
|
2040
|
+
return (await import("./export-import-4A5MWLIA.js")).runImport(args.slice(1), projectDir);
|
|
2041
|
+
case "uninstall":
|
|
2042
|
+
return (await import("./uninstall-6SW35IK4.js")).runUninstall(args.slice(1), projectDir);
|
|
2043
|
+
case "ui":
|
|
2044
|
+
return (await import("./ui-L7UAWXDY.js")).runUI(args.slice(1), projectDir);
|
|
2045
|
+
case "update":
|
|
2046
|
+
return (await import("./update-M2B4RLGH.js")).runUpdate(args.slice(1));
|
|
2047
|
+
case "history":
|
|
2048
|
+
return (await import("./history-ATTUKOHO.js")).runHistory(args.slice(1), projectDir);
|
|
2049
|
+
case "review":
|
|
2050
|
+
return (await import("./review-ADUPV3PN.js")).runReview(args.slice(1), projectDir);
|
|
2051
|
+
case "config":
|
|
2052
|
+
return (await import("./config-EGAXXCGL.js")).runConfig(args.slice(1));
|
|
2053
|
+
case "codemap":
|
|
2054
|
+
return (await import("./codemap-RRJIDBQ5.js")).runCodemap(args.slice(1), projectDir);
|
|
2055
|
+
case "memory":
|
|
2056
|
+
return (await import("./memory-J3G24QHS.js")).runMemory(args.slice(1), projectDir);
|
|
2057
|
+
case "projects":
|
|
2058
|
+
return (await import("./projects-ATHDD3D6.js")).runProjects(args.slice(1));
|
|
2059
|
+
case "--version":
|
|
2060
|
+
case "-v":
|
|
2061
|
+
console.log("0.1.0");
|
|
2062
|
+
return;
|
|
2063
|
+
case "--help":
|
|
2064
|
+
case "-h":
|
|
2065
|
+
printUsage();
|
|
2066
|
+
return;
|
|
2067
|
+
default:
|
|
2068
|
+
console.error(`Unknown command: ${command}`);
|
|
2069
|
+
printUsage();
|
|
2070
|
+
process.exit(1);
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
function printUsage() {
|
|
2074
|
+
console.log(`
|
|
2075
|
+
ULPI \u2014 Rules engine for AI coding agents
|
|
2076
|
+
|
|
2077
|
+
Usage: ulpi <command> [options]
|
|
2078
|
+
|
|
2079
|
+
Hook Handlers (invoked by Claude Code):
|
|
2080
|
+
session-start Initialize session state
|
|
2081
|
+
pre-tool Evaluate rules before tool execution
|
|
2082
|
+
post-tool Track state and run postconditions
|
|
2083
|
+
permission Auto-approve or deny permissions
|
|
2084
|
+
notification Route notifications
|
|
2085
|
+
stop Final checks before stopping
|
|
2086
|
+
session-end Cleanup and persist summary
|
|
2087
|
+
|
|
2088
|
+
CLI Commands:
|
|
2089
|
+
init [--no-ai] [--model=<id>] Detect stack, generate guards, install hooks
|
|
2090
|
+
projects Manage registered projects (list/add/remove/default)
|
|
2091
|
+
rules Manage rules (list/add/enable/disable/validate)
|
|
2092
|
+
templates Manage templates (list/save/apply/delete)
|
|
2093
|
+
skills Manage skills (list/add/get/attach)
|
|
2094
|
+
status Show current session state
|
|
2095
|
+
log View activity log
|
|
2096
|
+
export Export rules configuration
|
|
2097
|
+
import Import rules configuration
|
|
2098
|
+
uninstall Remove hooks
|
|
2099
|
+
ui Start web UI
|
|
2100
|
+
history Shadow branch history (init/capture/list/show/enrich/backfill)
|
|
2101
|
+
review Plan and code review management (list/show/config/migrate)
|
|
2102
|
+
config Manage settings and API keys
|
|
2103
|
+
codemap Semantic code indexing (init/search/status/reindex/watch)
|
|
2104
|
+
memory Agent memory (init/search/remember/status/export/import/serve)
|
|
2105
|
+
update Check for and install updates
|
|
2106
|
+
|
|
2107
|
+
Global Options:
|
|
2108
|
+
-p, --project <id|path> Target a specific registered project
|
|
2109
|
+
-v, --version Show version
|
|
2110
|
+
-h, --help Show this help
|
|
2111
|
+
`.trim());
|
|
2112
|
+
}
|
|
2113
|
+
main().catch((err) => {
|
|
2114
|
+
if (hookCommands.includes(command)) {
|
|
2115
|
+
console.error(`[ulpi] Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
2116
|
+
process.exit(0);
|
|
2117
|
+
}
|
|
2118
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2119
|
+
process.exit(1);
|
|
2120
|
+
});
|