@primitive.ai/prim 0.1.0-alpha.14 → 0.1.0-alpha.16
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/SKILL.md +85 -1
- package/dist/{chunk-3APLWTLB.js → chunk-6SIEWWUL.js} +26 -4
- package/dist/chunk-BEEGFDGU.js +59 -0
- package/dist/chunk-JZGWQDM5.js +199 -0
- package/dist/chunk-PTLXSXIY.js +111 -0
- package/dist/chunk-S47B4VGC.js +122 -0
- package/dist/chunk-UTKQTZHL.js +88 -0
- package/dist/daemon/server.js +241 -0
- package/dist/hooks/post-tool-use.js +128 -0
- package/dist/hooks/pre-commit.js +69 -11
- package/dist/hooks/pre-tool-use.js +220 -0
- package/dist/hooks/prim-hook.js +51 -0
- package/dist/hooks/session-end.js +61 -0
- package/dist/hooks/session-start.js +61 -0
- package/dist/index.js +1541 -56
- package/package.json +11 -5
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// src/daemon/client.ts
|
|
2
|
+
import { createConnection } from "net";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
var SOCK_PATH = join(homedir(), ".config", "prim", "sock");
|
|
6
|
+
var DEFAULT_TIMEOUT_MS = 250;
|
|
7
|
+
var nextRequestId = 1;
|
|
8
|
+
function daemonRequest(method, params = {}, opts = {}) {
|
|
9
|
+
const id = nextRequestId++;
|
|
10
|
+
const request = { id, method, params };
|
|
11
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let settled = false;
|
|
14
|
+
let socket;
|
|
15
|
+
let buffer = "";
|
|
16
|
+
const settle = (value) => {
|
|
17
|
+
if (settled) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
settled = true;
|
|
21
|
+
if (socket) {
|
|
22
|
+
try {
|
|
23
|
+
socket.destroy();
|
|
24
|
+
} catch {
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
resolve(value);
|
|
28
|
+
};
|
|
29
|
+
const timer = setTimeout(() => settle(null), timeoutMs);
|
|
30
|
+
timer.unref();
|
|
31
|
+
try {
|
|
32
|
+
socket = createConnection(SOCK_PATH);
|
|
33
|
+
} catch {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
settle(null);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
socket.on("error", () => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
settle(null);
|
|
41
|
+
});
|
|
42
|
+
socket.on("connect", () => {
|
|
43
|
+
try {
|
|
44
|
+
socket?.write(`${JSON.stringify(request)}
|
|
45
|
+
`);
|
|
46
|
+
} catch {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
settle(null);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
socket.on("data", (chunk) => {
|
|
52
|
+
buffer += chunk.toString("utf-8");
|
|
53
|
+
const newlineIdx = buffer.indexOf("\n");
|
|
54
|
+
if (newlineIdx === -1) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const line = buffer.slice(0, newlineIdx);
|
|
58
|
+
try {
|
|
59
|
+
const res = JSON.parse(line);
|
|
60
|
+
if (res.id !== id) {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
settle(null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
settle(res.ok && res.result !== void 0 ? res.result : null);
|
|
67
|
+
} catch {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
settle(null);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
socket.on("end", () => {
|
|
73
|
+
if (!settled) {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
settle(null);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async function daemonIsLive(timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
81
|
+
const result = await daemonRequest("ping", {}, { timeoutMs });
|
|
82
|
+
return result?.pong === true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export {
|
|
86
|
+
daemonRequest,
|
|
87
|
+
daemonIsLive
|
|
88
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
getClient,
|
|
4
|
+
getTokenExpiresAt,
|
|
5
|
+
refreshToken
|
|
6
|
+
} from "../chunk-6SIEWWUL.js";
|
|
7
|
+
|
|
8
|
+
// src/daemon/server.ts
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
10
|
+
import { createServer } from "net";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
var CONFIG_DIR = join(homedir(), ".config", "prim");
|
|
14
|
+
var SOCK_PATH = join(CONFIG_DIR, "sock");
|
|
15
|
+
var PID_PATH = join(CONFIG_DIR, "daemon.pid");
|
|
16
|
+
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
17
|
+
var TOKEN_CHECK_INTERVAL_MS = 6e4;
|
|
18
|
+
var TOKEN_REFRESH_THRESHOLD_MS = 9e4;
|
|
19
|
+
var PRESENCE_FRESH_WINDOW_MS = 9e4;
|
|
20
|
+
var SOCKET_DIR_MODE = 448;
|
|
21
|
+
var PID_FILE_MODE = 384;
|
|
22
|
+
var EXIT_OK = 0;
|
|
23
|
+
var EXIT_CRASH = 1;
|
|
24
|
+
var startedAt = Date.now();
|
|
25
|
+
var client = getClient();
|
|
26
|
+
var activeSessionId = process.env.PRIM_DAEMON_SESSION_ID ?? `daemon-${process.pid}`;
|
|
27
|
+
var lastHeartbeatAt;
|
|
28
|
+
var lastOnlineCount;
|
|
29
|
+
var lastOkAtLocal;
|
|
30
|
+
var heartbeatTimer;
|
|
31
|
+
var tokenCheckTimer;
|
|
32
|
+
function processIsAlive(pid) {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function takePidfile() {
|
|
41
|
+
if (existsSync(PID_PATH)) {
|
|
42
|
+
const existing = Number(readFileSync(PID_PATH, "utf-8").trim());
|
|
43
|
+
if (!Number.isNaN(existing) && processIsAlive(existing)) {
|
|
44
|
+
throw new Error(`daemon already running (pid=${existing})`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
48
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: SOCKET_DIR_MODE });
|
|
49
|
+
}
|
|
50
|
+
writeFileSync(PID_PATH, String(process.pid), { mode: PID_FILE_MODE });
|
|
51
|
+
}
|
|
52
|
+
function cleanup() {
|
|
53
|
+
try {
|
|
54
|
+
unlinkSync(SOCK_PATH);
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
unlinkSync(PID_PATH);
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function sendHeartbeat() {
|
|
63
|
+
try {
|
|
64
|
+
const result = await client.post("/api/cli/presence/heartbeat", {
|
|
65
|
+
sessionId: activeSessionId
|
|
66
|
+
});
|
|
67
|
+
if (result.accepted) {
|
|
68
|
+
lastOkAtLocal = Date.now();
|
|
69
|
+
if (typeof result.lastHeartbeatAt === "number") {
|
|
70
|
+
lastHeartbeatAt = result.lastHeartbeatAt;
|
|
71
|
+
}
|
|
72
|
+
if (typeof result.onlineCount === "number") {
|
|
73
|
+
lastOnlineCount = result.onlineCount;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
process.stderr.write(
|
|
78
|
+
`[prim-daemon] heartbeat error: ${err instanceof Error ? err.message : String(err)}
|
|
79
|
+
`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function ensureTokenFresh() {
|
|
84
|
+
const expiresAt = getTokenExpiresAt();
|
|
85
|
+
if (!expiresAt) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (Date.now() >= expiresAt - TOKEN_REFRESH_THRESHOLD_MS) {
|
|
89
|
+
try {
|
|
90
|
+
await refreshToken();
|
|
91
|
+
} catch {
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function handleConflictCheck(params) {
|
|
96
|
+
if (typeof params.file !== "string") {
|
|
97
|
+
throw new Error("conflict_check requires `file: string`");
|
|
98
|
+
}
|
|
99
|
+
return await client.post("/api/cli/decisions/conflict-check", { file: params.file });
|
|
100
|
+
}
|
|
101
|
+
function handleStatusSnapshot() {
|
|
102
|
+
const presenceFresh = lastOkAtLocal !== void 0 && Date.now() - lastOkAtLocal < PRESENCE_FRESH_WINDOW_MS;
|
|
103
|
+
const presenceStale = lastOkAtLocal !== void 0 && !presenceFresh;
|
|
104
|
+
return {
|
|
105
|
+
pid: process.pid,
|
|
106
|
+
uptimeMs: Date.now() - startedAt,
|
|
107
|
+
sessionId: activeSessionId,
|
|
108
|
+
lastHeartbeatAt,
|
|
109
|
+
// Withhold a frozen count once it's no longer fresh; the statusline shows
|
|
110
|
+
// "presence: stale" rather than a confident, wrong "team: N".
|
|
111
|
+
onlineCount: presenceFresh ? lastOnlineCount : void 0,
|
|
112
|
+
presenceStale
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async function dispatchRequest(req) {
|
|
116
|
+
const id = req.id;
|
|
117
|
+
try {
|
|
118
|
+
switch (req.method) {
|
|
119
|
+
case "conflict_check": {
|
|
120
|
+
const result = await handleConflictCheck(req.params ?? {});
|
|
121
|
+
return { id, ok: true, result };
|
|
122
|
+
}
|
|
123
|
+
case "session_start": {
|
|
124
|
+
const sid = req.params?.sessionId;
|
|
125
|
+
if (typeof sid === "string" && sid.length > 0) {
|
|
126
|
+
activeSessionId = sid;
|
|
127
|
+
}
|
|
128
|
+
await sendHeartbeat();
|
|
129
|
+
return { id, ok: true, result: { sessionId: activeSessionId } };
|
|
130
|
+
}
|
|
131
|
+
case "session_end": {
|
|
132
|
+
return { id, ok: true, result: { ack: true } };
|
|
133
|
+
}
|
|
134
|
+
case "status_snapshot":
|
|
135
|
+
return { id, ok: true, result: handleStatusSnapshot() };
|
|
136
|
+
case "ping":
|
|
137
|
+
return { id, ok: true, result: { pong: true } };
|
|
138
|
+
default:
|
|
139
|
+
return { id, ok: false, error: `unknown method: ${req.method}` };
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return {
|
|
143
|
+
id,
|
|
144
|
+
ok: false,
|
|
145
|
+
error: err instanceof Error ? err.message : String(err)
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function handleConnection(conn) {
|
|
150
|
+
let buffer = "";
|
|
151
|
+
conn.on("data", (chunk) => {
|
|
152
|
+
buffer += chunk.toString("utf-8");
|
|
153
|
+
let newlineIdx = buffer.indexOf("\n");
|
|
154
|
+
while (newlineIdx !== -1) {
|
|
155
|
+
const line = buffer.slice(0, newlineIdx);
|
|
156
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
157
|
+
if (line.length > 0) {
|
|
158
|
+
try {
|
|
159
|
+
const req = JSON.parse(line);
|
|
160
|
+
dispatchRequest(req).then(
|
|
161
|
+
(res) => {
|
|
162
|
+
conn.write(`${JSON.stringify(res)}
|
|
163
|
+
`);
|
|
164
|
+
},
|
|
165
|
+
() => {
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
} catch {
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
newlineIdx = buffer.indexOf("\n");
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
conn.on("error", () => {
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function startSocketServer() {
|
|
178
|
+
try {
|
|
179
|
+
unlinkSync(SOCK_PATH);
|
|
180
|
+
} catch {
|
|
181
|
+
}
|
|
182
|
+
const server = createServer(handleConnection);
|
|
183
|
+
server.on("error", (err) => {
|
|
184
|
+
process.stderr.write(`[prim-daemon] socket error: ${err.message}
|
|
185
|
+
`);
|
|
186
|
+
});
|
|
187
|
+
server.listen(SOCK_PATH, () => {
|
|
188
|
+
process.stderr.write(`[prim-daemon] listening on ${SOCK_PATH}
|
|
189
|
+
`);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
function startTimers() {
|
|
193
|
+
void sendHeartbeat();
|
|
194
|
+
heartbeatTimer = setInterval(() => {
|
|
195
|
+
void sendHeartbeat();
|
|
196
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
197
|
+
tokenCheckTimer = setInterval(() => {
|
|
198
|
+
void ensureTokenFresh();
|
|
199
|
+
}, TOKEN_CHECK_INTERVAL_MS);
|
|
200
|
+
}
|
|
201
|
+
function stopTimers() {
|
|
202
|
+
if (heartbeatTimer) {
|
|
203
|
+
clearInterval(heartbeatTimer);
|
|
204
|
+
}
|
|
205
|
+
if (tokenCheckTimer) {
|
|
206
|
+
clearInterval(tokenCheckTimer);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function installSignalHandlers() {
|
|
210
|
+
for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
211
|
+
process.on(signal, () => {
|
|
212
|
+
process.stderr.write(`[prim-daemon] ${signal}, shutting down (pid=${process.pid})
|
|
213
|
+
`);
|
|
214
|
+
stopTimers();
|
|
215
|
+
cleanup();
|
|
216
|
+
process.exit(EXIT_OK);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
process.on("uncaughtException", (err) => {
|
|
220
|
+
process.stderr.write(`[prim-daemon] uncaught: ${err.message}
|
|
221
|
+
`);
|
|
222
|
+
stopTimers();
|
|
223
|
+
cleanup();
|
|
224
|
+
process.exit(EXIT_CRASH);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
function main() {
|
|
228
|
+
try {
|
|
229
|
+
takePidfile();
|
|
230
|
+
} catch (err) {
|
|
231
|
+
process.stderr.write(`[prim-daemon] ${err instanceof Error ? err.message : String(err)}
|
|
232
|
+
`);
|
|
233
|
+
process.exit(EXIT_CRASH);
|
|
234
|
+
}
|
|
235
|
+
installSignalHandlers();
|
|
236
|
+
startSocketServer();
|
|
237
|
+
startTimers();
|
|
238
|
+
process.stderr.write(`[prim-daemon] started (pid=${process.pid}, session=${activeSessionId})
|
|
239
|
+
`);
|
|
240
|
+
}
|
|
241
|
+
main();
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
bold,
|
|
4
|
+
color
|
|
5
|
+
} from "../chunk-BEEGFDGU.js";
|
|
6
|
+
import {
|
|
7
|
+
getClient
|
|
8
|
+
} from "../chunk-6SIEWWUL.js";
|
|
9
|
+
import {
|
|
10
|
+
scrubFromCwd,
|
|
11
|
+
toMove
|
|
12
|
+
} from "../chunk-PTLXSXIY.js";
|
|
13
|
+
|
|
14
|
+
// src/hooks/post-tool-use.ts
|
|
15
|
+
import { readFileSync } from "fs";
|
|
16
|
+
import { dirname, join } from "path";
|
|
17
|
+
import { fileURLToPath } from "url";
|
|
18
|
+
|
|
19
|
+
// src/hooks/verdict-footer.ts
|
|
20
|
+
function renderVerdictFooter(ctx) {
|
|
21
|
+
const successPrefix = color("\u2713 Conflict caught before merge", "green");
|
|
22
|
+
const savedCount = `${String(ctx.decisionsSaved)}${ctx.decisionsSavedTruncated ? "+" : ""}`;
|
|
23
|
+
const savedFragment = `${savedCount} decisions saved`;
|
|
24
|
+
const intentFragment = `${bold(ctx.author)}'s intent preserved`;
|
|
25
|
+
return `${successPrefix} \xB7 ${savedFragment} \xB7 ${intentFragment}`;
|
|
26
|
+
}
|
|
27
|
+
function isVerdictFooterContext(value) {
|
|
28
|
+
if (typeof value !== "object" || value === null) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const v = value;
|
|
32
|
+
return typeof v.author === "string" && typeof v.intent === "string" && typeof v.decisionsSaved === "number" && typeof v.decisionsSavedTruncated === "boolean" && typeof v.decisionId === "string";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/hooks/post-tool-use.ts
|
|
36
|
+
var STDIN_TIMEOUT_MS = 1e3;
|
|
37
|
+
var INGEST_TIMEOUT_MS = 4e3;
|
|
38
|
+
var EDITING_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit"]);
|
|
39
|
+
var here = dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
function resolveCliVersion() {
|
|
41
|
+
try {
|
|
42
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "..", "package.json"), "utf-8"));
|
|
43
|
+
return pkg.version ?? "unknown";
|
|
44
|
+
} catch {
|
|
45
|
+
return "unknown";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function readStdin() {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const chunks = [];
|
|
51
|
+
const timer = setTimeout(() => {
|
|
52
|
+
reject(new Error("stdin read timeout"));
|
|
53
|
+
}, STDIN_TIMEOUT_MS);
|
|
54
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
55
|
+
process.stdin.on("end", () => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
resolve(Buffer.concat(chunks).toString("utf-8"));
|
|
58
|
+
});
|
|
59
|
+
process.stdin.on("error", (err) => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
reject(err);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function emit() {
|
|
66
|
+
process.stdout.write("{}\n");
|
|
67
|
+
}
|
|
68
|
+
function debug(msg) {
|
|
69
|
+
if (process.env.PRIM_HOOK_VERBOSE === "1") {
|
|
70
|
+
process.stderr.write(`[prim-post-tool-use] ${msg}
|
|
71
|
+
`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function ingestMove(move) {
|
|
75
|
+
const client = getClient();
|
|
76
|
+
return await client.post(
|
|
77
|
+
"/api/cli/moves/ingest",
|
|
78
|
+
{ batch: [move] },
|
|
79
|
+
{ signal: AbortSignal.timeout(INGEST_TIMEOUT_MS) }
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
async function main() {
|
|
83
|
+
let raw;
|
|
84
|
+
try {
|
|
85
|
+
raw = await readStdin();
|
|
86
|
+
} catch {
|
|
87
|
+
emit();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(raw);
|
|
93
|
+
} catch {
|
|
94
|
+
emit();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const envelope = parsed;
|
|
98
|
+
if (envelope.hook_event_name !== "PostToolUse") {
|
|
99
|
+
emit();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const toolName = typeof envelope.tool_name === "string" ? envelope.tool_name : "";
|
|
103
|
+
if (!EDITING_TOOLS.has(toolName)) {
|
|
104
|
+
emit();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (typeof envelope.session_id !== "string" || envelope.session_id.length === 0) {
|
|
108
|
+
emit();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const cwd = parsed.cwd ?? process.cwd();
|
|
112
|
+
const base = toMove(parsed, resolveCliVersion());
|
|
113
|
+
const move = { ...base, payload: scrubFromCwd(parsed, cwd) };
|
|
114
|
+
try {
|
|
115
|
+
const result = await ingestMove(move);
|
|
116
|
+
debug(`ingested ${move.moveId} (${toolName})`);
|
|
117
|
+
if (isVerdictFooterContext(result.verdictFooter)) {
|
|
118
|
+
process.stderr.write(`${renderVerdictFooter(result.verdictFooter)}
|
|
119
|
+
`);
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
debug(`ingest failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
123
|
+
}
|
|
124
|
+
emit();
|
|
125
|
+
}
|
|
126
|
+
main().catch(() => {
|
|
127
|
+
emit();
|
|
128
|
+
});
|
package/dist/hooks/pre-commit.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
checkAffectedDecisions,
|
|
4
|
+
formatDecisionsWarning,
|
|
5
|
+
getGitContext
|
|
6
|
+
} from "../chunk-S47B4VGC.js";
|
|
2
7
|
import {
|
|
3
8
|
getClient
|
|
4
|
-
} from "../chunk-
|
|
9
|
+
} from "../chunk-6SIEWWUL.js";
|
|
5
10
|
|
|
6
11
|
// src/hooks/pre-commit.ts
|
|
7
12
|
import { execSync } from "child_process";
|
|
@@ -47,7 +52,8 @@ var HOOK_TIMEOUT_MS = 1e4;
|
|
|
47
52
|
var defaultDeps = {
|
|
48
53
|
getClient,
|
|
49
54
|
getStagedFiles,
|
|
50
|
-
getStagedDiff
|
|
55
|
+
getStagedDiff,
|
|
56
|
+
getGitContext
|
|
51
57
|
};
|
|
52
58
|
async function syncAffectedSpecs(deps = defaultDeps) {
|
|
53
59
|
const stagedFiles = deps.getStagedFiles();
|
|
@@ -55,9 +61,18 @@ async function syncAffectedSpecs(deps = defaultDeps) {
|
|
|
55
61
|
return [];
|
|
56
62
|
}
|
|
57
63
|
const client = deps.getClient();
|
|
64
|
+
const gitCtx = deps.getGitContext();
|
|
65
|
+
let mappingsUrl = "/api/cli/specs/mappings";
|
|
66
|
+
if (gitCtx.repoFullName && gitCtx.branch) {
|
|
67
|
+
const params = new URLSearchParams({
|
|
68
|
+
repoFullName: gitCtx.repoFullName,
|
|
69
|
+
branch: gitCtx.branch
|
|
70
|
+
});
|
|
71
|
+
mappingsUrl = `${mappingsUrl}?${params.toString()}`;
|
|
72
|
+
}
|
|
58
73
|
let mappings = [];
|
|
59
74
|
try {
|
|
60
|
-
mappings = await client.get(
|
|
75
|
+
mappings = await client.get(mappingsUrl, {
|
|
61
76
|
signal: AbortSignal.timeout(HOOK_TIMEOUT_MS)
|
|
62
77
|
});
|
|
63
78
|
} catch {
|
|
@@ -66,6 +81,7 @@ async function syncAffectedSpecs(deps = defaultDeps) {
|
|
|
66
81
|
if (mappings.length === 0) {
|
|
67
82
|
return [];
|
|
68
83
|
}
|
|
84
|
+
const specsById = new Map(mappings.map((s) => [s._id, s]));
|
|
69
85
|
const affectedContexts = findAffectedContexts(stagedFiles, mappings);
|
|
70
86
|
if (affectedContexts.size === 0) {
|
|
71
87
|
return [];
|
|
@@ -90,20 +106,51 @@ async function syncAffectedSpecs(deps = defaultDeps) {
|
|
|
90
106
|
console.log(` [skip] ${contextId} \u2014 no diff content`);
|
|
91
107
|
continue;
|
|
92
108
|
}
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
);
|
|
109
|
+
const body = {
|
|
110
|
+
diffContent,
|
|
111
|
+
affectedFiles: affected.matchedFiles
|
|
112
|
+
};
|
|
113
|
+
if (gitCtx.branch) body.branch = gitCtx.branch;
|
|
114
|
+
if (gitCtx.sha) body.sha = gitCtx.sha;
|
|
115
|
+
if (gitCtx.repoFullName) body.repoFullName = gitCtx.repoFullName;
|
|
116
|
+
if (gitCtx.prNumber !== null) body.prNumber = gitCtx.prNumber;
|
|
117
|
+
const response = await client.post(`/api/cli/contexts/${contextId}/sync-diff`, body, {
|
|
118
|
+
signal: AbortSignal.timeout(HOOK_TIMEOUT_MS)
|
|
119
|
+
});
|
|
98
120
|
const name = ctx.name ?? "(unnamed)";
|
|
121
|
+
if (!response.analyzing && response.reason === "not_linked") {
|
|
122
|
+
console.log(
|
|
123
|
+
` [skip] ${contextId} \u2014 ${name} \u2014 not linked to ${gitCtx.branch ?? "(no branch)"}`
|
|
124
|
+
);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const spec = specsById.get(contextId);
|
|
128
|
+
const link = spec?.linkedBranches?.find((l) => l.branch === gitCtx.branch);
|
|
129
|
+
let linkSuffix = "";
|
|
130
|
+
if (link) {
|
|
131
|
+
const prBits = link.prNumber ? ` #${String(link.prNumber)}${link.prState ? ` ${link.prState}` : ""}` : "";
|
|
132
|
+
linkSuffix = ` (linked to ${link.branch}${prBits})`;
|
|
133
|
+
} else if (gitCtx.branch && spec?.linkedBranches?.length === 0) {
|
|
134
|
+
linkSuffix = ` (auto-linking to ${gitCtx.branch})`;
|
|
135
|
+
}
|
|
136
|
+
const review = link?.latestReviewSummary;
|
|
137
|
+
let reviewSuffix = "";
|
|
138
|
+
if (review?.status === "completed") {
|
|
139
|
+
const n = review.findingsCount ?? 0;
|
|
140
|
+
const urlSuffix = review.prCommentUrl ? ` \u2192 ${review.prCommentUrl.replace(/^https?:\/\//, "")}` : "";
|
|
141
|
+
reviewSuffix = ` (reviewed: ${String(n)} finding${n === 1 ? "" : "s"}${urlSuffix})`;
|
|
142
|
+
} else if (review?.status === "failed") {
|
|
143
|
+
reviewSuffix = " (review failed)";
|
|
144
|
+
}
|
|
145
|
+
linkSuffix += reviewSuffix;
|
|
99
146
|
if (response.truncated && response.sizeChars && response.limitChars) {
|
|
100
147
|
const sizeKiB = Math.round(response.sizeChars / 1024);
|
|
101
148
|
const limitKiB = Math.round(response.limitChars / 1024);
|
|
102
149
|
console.log(
|
|
103
|
-
` [synced] ${contextId} \u2014 ${name} (truncated: ${String(sizeKiB)} KiB \u2192 ${String(limitKiB)} KiB analyzed)`
|
|
150
|
+
` [synced] ${contextId} \u2014 ${name} (truncated: ${String(sizeKiB)} KiB \u2192 ${String(limitKiB)} KiB analyzed)${linkSuffix}`
|
|
104
151
|
);
|
|
105
152
|
} else {
|
|
106
|
-
console.log(` [synced] ${contextId} \u2014 ${name}`);
|
|
153
|
+
console.log(` [synced] ${contextId} \u2014 ${name}${linkSuffix}`);
|
|
107
154
|
}
|
|
108
155
|
synced.push(contextId);
|
|
109
156
|
} catch (error) {
|
|
@@ -113,8 +160,19 @@ async function syncAffectedSpecs(deps = defaultDeps) {
|
|
|
113
160
|
}
|
|
114
161
|
return synced;
|
|
115
162
|
}
|
|
163
|
+
async function runDecisionsCheck() {
|
|
164
|
+
const stagedFiles = getStagedFiles();
|
|
165
|
+
if (stagedFiles.length === 0) {
|
|
166
|
+
return { decisions: [], truncated: false };
|
|
167
|
+
}
|
|
168
|
+
return checkAffectedDecisions(stagedFiles);
|
|
169
|
+
}
|
|
116
170
|
async function main() {
|
|
117
|
-
await syncAffectedSpecs();
|
|
171
|
+
const [, decisionsResult] = await Promise.all([syncAffectedSpecs(), runDecisionsCheck()]);
|
|
172
|
+
const warning = formatDecisionsWarning(decisionsResult);
|
|
173
|
+
if (warning) {
|
|
174
|
+
console.error(warning);
|
|
175
|
+
}
|
|
118
176
|
process.exit(0);
|
|
119
177
|
}
|
|
120
178
|
if (!process.env.VITEST) {
|