@primitive.ai/prim 0.1.0-alpha.15 → 0.1.0-alpha.17
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 +47 -7
- package/dist/{chunk-SHLF6OL2.js → chunk-6SIEWWUL.js} +27 -34
- package/dist/chunk-7YRBACIE.js +9 -0
- package/dist/chunk-BEEGFDGU.js +59 -0
- package/dist/chunk-JZGWQDM5.js +199 -0
- package/dist/chunk-LCC66K45.js +115 -0
- package/dist/chunk-TPQ3X244.js +151 -0
- package/dist/chunk-UTKQTZHL.js +88 -0
- package/dist/daemon/server.js +274 -0
- package/dist/hooks/post-tool-use.js +134 -0
- package/dist/hooks/pre-commit.js +19 -3
- package/dist/hooks/pre-tool-use.js +263 -0
- package/dist/hooks/prim-hook.js +54 -0
- package/dist/hooks/session-end.js +61 -0
- package/dist/hooks/session-start.js +86 -0
- package/dist/index.js +1604 -58
- package/package.json +11 -5
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getClient
|
|
3
|
+
} from "./chunk-6SIEWWUL.js";
|
|
4
|
+
import {
|
|
5
|
+
daemonRequest
|
|
6
|
+
} from "./chunk-UTKQTZHL.js";
|
|
7
|
+
|
|
8
|
+
// src/daemon/proxy.ts
|
|
9
|
+
var DAEMON_HTTP_TIMEOUT_MS = 1e4;
|
|
10
|
+
var DAEMON_PROBE_TIMEOUT_MS = 250;
|
|
11
|
+
async function daemonOrDirect(method, params, direct) {
|
|
12
|
+
const fromDaemon = await daemonRequest(method, params, {
|
|
13
|
+
timeoutMs: DAEMON_PROBE_TIMEOUT_MS
|
|
14
|
+
});
|
|
15
|
+
if (fromDaemon !== null) {
|
|
16
|
+
return fromDaemon;
|
|
17
|
+
}
|
|
18
|
+
return await direct();
|
|
19
|
+
}
|
|
20
|
+
async function daemonOrDirectGet(method, path, client, timeoutMs = DAEMON_HTTP_TIMEOUT_MS) {
|
|
21
|
+
return await daemonOrDirect(
|
|
22
|
+
method,
|
|
23
|
+
{ path },
|
|
24
|
+
async () => await client.get(path, {
|
|
25
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
26
|
+
})
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/hooks/decisions-check.ts
|
|
31
|
+
var DECISIONS_CHECK_TIMEOUT_MS = 1e4;
|
|
32
|
+
var MAX_FILES_PER_REQUEST = 25;
|
|
33
|
+
var defaultDeps = { getClient };
|
|
34
|
+
function chunk(items, size) {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (let i = 0; i < items.length; i += size) {
|
|
37
|
+
out.push(items.slice(i, i + size));
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
async function fetchAffecting(client, batch) {
|
|
42
|
+
const params = new URLSearchParams();
|
|
43
|
+
for (const file of batch) {
|
|
44
|
+
params.append("files", file);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return await daemonOrDirectGet(
|
|
48
|
+
"decisions_affecting",
|
|
49
|
+
`/api/cli/decisions/affecting?${params.toString()}`,
|
|
50
|
+
client,
|
|
51
|
+
DECISIONS_CHECK_TIMEOUT_MS
|
|
52
|
+
);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
55
|
+
return { decisions: [], truncated: false, unavailable: `decision check failed: ${detail}` };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function checkAffectedDecisions(filePaths, deps = defaultDeps) {
|
|
59
|
+
if (filePaths.length === 0) {
|
|
60
|
+
return { decisions: [], truncated: false };
|
|
61
|
+
}
|
|
62
|
+
const client = deps.getClient();
|
|
63
|
+
const responses = await Promise.all(
|
|
64
|
+
chunk(filePaths, MAX_FILES_PER_REQUEST).map((batch) => fetchAffecting(client, batch))
|
|
65
|
+
);
|
|
66
|
+
const byId = /* @__PURE__ */ new Map();
|
|
67
|
+
let truncated = false;
|
|
68
|
+
let unavailable;
|
|
69
|
+
for (const res of responses) {
|
|
70
|
+
if (res.unavailable !== void 0 && unavailable === void 0) {
|
|
71
|
+
unavailable = res.unavailable;
|
|
72
|
+
}
|
|
73
|
+
truncated ||= res.truncated === true;
|
|
74
|
+
for (const d of res.decisions) {
|
|
75
|
+
const existing = byId.get(d.id);
|
|
76
|
+
if (existing) {
|
|
77
|
+
existing.matchedFiles = [.../* @__PURE__ */ new Set([...existing.matchedFiles, ...d.matchedFiles])];
|
|
78
|
+
} else {
|
|
79
|
+
byId.set(d.id, { ...d, matchedFiles: [...d.matchedFiles] });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const result = { decisions: [...byId.values()], truncated };
|
|
84
|
+
if (unavailable !== void 0) {
|
|
85
|
+
result.unavailable = unavailable;
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
var FILES_PREVIEW_LIMIT = 3;
|
|
90
|
+
function formatDecisionsWarning(result) {
|
|
91
|
+
const lines = [];
|
|
92
|
+
if (result.unavailable !== void 0) {
|
|
93
|
+
lines.push(`[prim] decision check not verified \u2014 ${result.unavailable}`);
|
|
94
|
+
}
|
|
95
|
+
if (result.decisions.length > 0) {
|
|
96
|
+
lines.push(
|
|
97
|
+
`[prim] ${String(result.decisions.length)} active decision(s) reference staged files:`
|
|
98
|
+
);
|
|
99
|
+
for (const d of result.decisions) {
|
|
100
|
+
const statusMark = d.status === "under_review" ? " (under review)" : "";
|
|
101
|
+
const preview = d.matchedFiles.slice(0, FILES_PREVIEW_LIMIT).join(", ");
|
|
102
|
+
const overflow = d.matchedFiles.length > FILES_PREVIEW_LIMIT ? ` (+${String(d.matchedFiles.length - FILES_PREVIEW_LIMIT)} more)` : "";
|
|
103
|
+
lines.push(` \xB7 ${d.intent}${statusMark}`);
|
|
104
|
+
lines.push(` files: ${preview}${overflow}`);
|
|
105
|
+
if (d.rationale) {
|
|
106
|
+
lines.push(` rationale: ${d.rationale}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (result.truncated) {
|
|
111
|
+
lines.push(
|
|
112
|
+
"[prim] result truncated \u2014 more files than the server checks per request; not all decisions shown"
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return lines.join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/utils/git.ts
|
|
119
|
+
import { execSync } from "child_process";
|
|
120
|
+
function safeExec(cmd) {
|
|
121
|
+
try {
|
|
122
|
+
return execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function parseRepoFullName(remoteUrl) {
|
|
128
|
+
const match = remoteUrl.match(/(?:github\.com[:/])([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
129
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
130
|
+
}
|
|
131
|
+
function getGitContext() {
|
|
132
|
+
const branchRaw = safeExec("git rev-parse --abbrev-ref HEAD");
|
|
133
|
+
const branch = branchRaw && branchRaw !== "HEAD" ? branchRaw : null;
|
|
134
|
+
const sha = safeExec("git rev-parse HEAD");
|
|
135
|
+
const remoteUrl = safeExec("git remote get-url origin");
|
|
136
|
+
const repoFullName = remoteUrl ? parseRepoFullName(remoteUrl) : null;
|
|
137
|
+
let prNumber = null;
|
|
138
|
+
if (safeExec("command -v gh")) {
|
|
139
|
+
const raw = safeExec("gh pr view --json number -q .number");
|
|
140
|
+
const n = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
|
141
|
+
if (Number.isFinite(n)) prNumber = n;
|
|
142
|
+
}
|
|
143
|
+
return { branch, sha, repoFullName, prNumber };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export {
|
|
147
|
+
daemonOrDirectGet,
|
|
148
|
+
checkAffectedDecisions,
|
|
149
|
+
formatDecisionsWarning,
|
|
150
|
+
getGitContext
|
|
151
|
+
};
|
|
@@ -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,274 @@
|
|
|
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 HTTP_PROXY_TIMEOUT_MS = 1e4;
|
|
20
|
+
var PRESENCE_FRESH_WINDOW_MS = 9e4;
|
|
21
|
+
var SOCKET_DIR_MODE = 448;
|
|
22
|
+
var PID_FILE_MODE = 384;
|
|
23
|
+
var EXIT_OK = 0;
|
|
24
|
+
var EXIT_CRASH = 1;
|
|
25
|
+
var startedAt = Date.now();
|
|
26
|
+
var client = getClient();
|
|
27
|
+
var activeSessionId = process.env.PRIM_DAEMON_SESSION_ID ?? `daemon-${process.pid}`;
|
|
28
|
+
var lastHeartbeatAt;
|
|
29
|
+
var lastOnlineCount;
|
|
30
|
+
var lastOkAtLocal;
|
|
31
|
+
var heartbeatTimer;
|
|
32
|
+
var tokenCheckTimer;
|
|
33
|
+
function processIsAlive(pid) {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function takePidfile() {
|
|
42
|
+
if (existsSync(PID_PATH)) {
|
|
43
|
+
const existing = Number(readFileSync(PID_PATH, "utf-8").trim());
|
|
44
|
+
if (!Number.isNaN(existing) && processIsAlive(existing)) {
|
|
45
|
+
throw new Error(`daemon already running (pid=${existing})`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
49
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: SOCKET_DIR_MODE });
|
|
50
|
+
}
|
|
51
|
+
writeFileSync(PID_PATH, String(process.pid), { mode: PID_FILE_MODE });
|
|
52
|
+
}
|
|
53
|
+
function cleanup() {
|
|
54
|
+
try {
|
|
55
|
+
unlinkSync(SOCK_PATH);
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
unlinkSync(PID_PATH);
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function sendHeartbeat() {
|
|
64
|
+
try {
|
|
65
|
+
const result = await client.post("/api/cli/presence/heartbeat", {
|
|
66
|
+
sessionId: activeSessionId
|
|
67
|
+
});
|
|
68
|
+
if (result.accepted) {
|
|
69
|
+
lastOkAtLocal = Date.now();
|
|
70
|
+
if (typeof result.lastHeartbeatAt === "number") {
|
|
71
|
+
lastHeartbeatAt = result.lastHeartbeatAt;
|
|
72
|
+
}
|
|
73
|
+
if (typeof result.onlineCount === "number") {
|
|
74
|
+
lastOnlineCount = result.onlineCount;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
process.stderr.write(
|
|
79
|
+
`[prim-daemon] heartbeat error: ${err instanceof Error ? err.message : String(err)}
|
|
80
|
+
`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function ensureTokenFresh() {
|
|
85
|
+
const expiresAt = getTokenExpiresAt();
|
|
86
|
+
if (!expiresAt) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (Date.now() >= expiresAt - TOKEN_REFRESH_THRESHOLD_MS) {
|
|
90
|
+
try {
|
|
91
|
+
await refreshToken();
|
|
92
|
+
} catch {
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function handleConflictCheck(params) {
|
|
97
|
+
if (typeof params.file !== "string") {
|
|
98
|
+
throw new Error("conflict_check requires `file: string`");
|
|
99
|
+
}
|
|
100
|
+
return await client.post("/api/cli/decisions/conflict-check", { file: params.file });
|
|
101
|
+
}
|
|
102
|
+
function pathParam(params) {
|
|
103
|
+
if (typeof params.path !== "string" || !params.path.startsWith("/api/cli/")) {
|
|
104
|
+
throw new Error("proxy request requires `path: string` under /api/cli/");
|
|
105
|
+
}
|
|
106
|
+
return params.path;
|
|
107
|
+
}
|
|
108
|
+
function assertEndpointPath(path, endpoint) {
|
|
109
|
+
if (path !== endpoint && !path.startsWith(`${endpoint}?`)) {
|
|
110
|
+
throw new Error(`proxy path must be ${endpoint} or ${endpoint}?...`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function proxyGet(params, allowedPrefix) {
|
|
114
|
+
const path = pathParam(params);
|
|
115
|
+
assertEndpointPath(path, allowedPrefix);
|
|
116
|
+
return await client.get(path, { signal: AbortSignal.timeout(HTTP_PROXY_TIMEOUT_MS) });
|
|
117
|
+
}
|
|
118
|
+
function handleStatusSnapshot() {
|
|
119
|
+
const presenceFresh = lastOkAtLocal !== void 0 && Date.now() - lastOkAtLocal < PRESENCE_FRESH_WINDOW_MS;
|
|
120
|
+
const presenceStale = lastOkAtLocal !== void 0 && !presenceFresh;
|
|
121
|
+
return {
|
|
122
|
+
pid: process.pid,
|
|
123
|
+
uptimeMs: Date.now() - startedAt,
|
|
124
|
+
sessionId: activeSessionId,
|
|
125
|
+
lastHeartbeatAt,
|
|
126
|
+
// Withhold a frozen count once it's no longer fresh; the statusline shows
|
|
127
|
+
// "presence: stale" rather than a confident, wrong "team: N".
|
|
128
|
+
onlineCount: presenceFresh ? lastOnlineCount : void 0,
|
|
129
|
+
presenceStale
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
async function dispatchRequest(req) {
|
|
133
|
+
const id = req.id;
|
|
134
|
+
try {
|
|
135
|
+
switch (req.method) {
|
|
136
|
+
case "conflict_check": {
|
|
137
|
+
const result = await handleConflictCheck(req.params ?? {});
|
|
138
|
+
return { id, ok: true, result };
|
|
139
|
+
}
|
|
140
|
+
case "decisions_recent": {
|
|
141
|
+
const result = await proxyGet(req.params ?? {}, "/api/cli/decisions/recent");
|
|
142
|
+
return { id, ok: true, result };
|
|
143
|
+
}
|
|
144
|
+
case "decisions_show": {
|
|
145
|
+
const result = await proxyGet(req.params ?? {}, "/api/cli/decisions/show");
|
|
146
|
+
return { id, ok: true, result };
|
|
147
|
+
}
|
|
148
|
+
case "decisions_cascade": {
|
|
149
|
+
const result = await proxyGet(req.params ?? {}, "/api/cli/decisions/cascade");
|
|
150
|
+
return { id, ok: true, result };
|
|
151
|
+
}
|
|
152
|
+
case "decisions_affecting": {
|
|
153
|
+
const result = await proxyGet(req.params ?? {}, "/api/cli/decisions/affecting");
|
|
154
|
+
return { id, ok: true, result };
|
|
155
|
+
}
|
|
156
|
+
case "session_start": {
|
|
157
|
+
const sid = req.params?.sessionId;
|
|
158
|
+
if (typeof sid === "string" && sid.length > 0) {
|
|
159
|
+
activeSessionId = sid;
|
|
160
|
+
}
|
|
161
|
+
await sendHeartbeat();
|
|
162
|
+
return { id, ok: true, result: { sessionId: activeSessionId } };
|
|
163
|
+
}
|
|
164
|
+
case "session_end": {
|
|
165
|
+
return { id, ok: true, result: { ack: true } };
|
|
166
|
+
}
|
|
167
|
+
case "status_snapshot":
|
|
168
|
+
return { id, ok: true, result: handleStatusSnapshot() };
|
|
169
|
+
case "ping":
|
|
170
|
+
return { id, ok: true, result: { pong: true } };
|
|
171
|
+
default:
|
|
172
|
+
return { id, ok: false, error: `unknown method: ${req.method}` };
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return {
|
|
176
|
+
id,
|
|
177
|
+
ok: false,
|
|
178
|
+
error: err instanceof Error ? err.message : String(err)
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function handleConnection(conn) {
|
|
183
|
+
let buffer = "";
|
|
184
|
+
conn.on("data", (chunk) => {
|
|
185
|
+
buffer += chunk.toString("utf-8");
|
|
186
|
+
let newlineIdx = buffer.indexOf("\n");
|
|
187
|
+
while (newlineIdx !== -1) {
|
|
188
|
+
const line = buffer.slice(0, newlineIdx);
|
|
189
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
190
|
+
if (line.length > 0) {
|
|
191
|
+
try {
|
|
192
|
+
const req = JSON.parse(line);
|
|
193
|
+
dispatchRequest(req).then(
|
|
194
|
+
(res) => {
|
|
195
|
+
conn.write(`${JSON.stringify(res)}
|
|
196
|
+
`);
|
|
197
|
+
},
|
|
198
|
+
() => {
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
} catch {
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
newlineIdx = buffer.indexOf("\n");
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
conn.on("error", () => {
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
function startSocketServer() {
|
|
211
|
+
try {
|
|
212
|
+
unlinkSync(SOCK_PATH);
|
|
213
|
+
} catch {
|
|
214
|
+
}
|
|
215
|
+
const server = createServer(handleConnection);
|
|
216
|
+
server.on("error", (err) => {
|
|
217
|
+
process.stderr.write(`[prim-daemon] socket error: ${err.message}
|
|
218
|
+
`);
|
|
219
|
+
});
|
|
220
|
+
server.listen(SOCK_PATH, () => {
|
|
221
|
+
process.stderr.write(`[prim-daemon] listening on ${SOCK_PATH}
|
|
222
|
+
`);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
function startTimers() {
|
|
226
|
+
void sendHeartbeat();
|
|
227
|
+
heartbeatTimer = setInterval(() => {
|
|
228
|
+
void sendHeartbeat();
|
|
229
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
230
|
+
tokenCheckTimer = setInterval(() => {
|
|
231
|
+
void ensureTokenFresh();
|
|
232
|
+
}, TOKEN_CHECK_INTERVAL_MS);
|
|
233
|
+
}
|
|
234
|
+
function stopTimers() {
|
|
235
|
+
if (heartbeatTimer) {
|
|
236
|
+
clearInterval(heartbeatTimer);
|
|
237
|
+
}
|
|
238
|
+
if (tokenCheckTimer) {
|
|
239
|
+
clearInterval(tokenCheckTimer);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function installSignalHandlers() {
|
|
243
|
+
for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
244
|
+
process.on(signal, () => {
|
|
245
|
+
process.stderr.write(`[prim-daemon] ${signal}, shutting down (pid=${process.pid})
|
|
246
|
+
`);
|
|
247
|
+
stopTimers();
|
|
248
|
+
cleanup();
|
|
249
|
+
process.exit(EXIT_OK);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
process.on("uncaughtException", (err) => {
|
|
253
|
+
process.stderr.write(`[prim-daemon] uncaught: ${err.message}
|
|
254
|
+
`);
|
|
255
|
+
stopTimers();
|
|
256
|
+
cleanup();
|
|
257
|
+
process.exit(EXIT_CRASH);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
function main() {
|
|
261
|
+
try {
|
|
262
|
+
takePidfile();
|
|
263
|
+
} catch (err) {
|
|
264
|
+
process.stderr.write(`[prim-daemon] ${err instanceof Error ? err.message : String(err)}
|
|
265
|
+
`);
|
|
266
|
+
process.exit(EXIT_CRASH);
|
|
267
|
+
}
|
|
268
|
+
installSignalHandlers();
|
|
269
|
+
startSocketServer();
|
|
270
|
+
startTimers();
|
|
271
|
+
process.stderr.write(`[prim-daemon] started (pid=${process.pid}, session=${activeSessionId})
|
|
272
|
+
`);
|
|
273
|
+
}
|
|
274
|
+
main();
|
|
@@ -0,0 +1,134 @@
|
|
|
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-LCC66K45.js";
|
|
13
|
+
import {
|
|
14
|
+
parseAgent
|
|
15
|
+
} from "../chunk-7YRBACIE.js";
|
|
16
|
+
|
|
17
|
+
// src/hooks/post-tool-use.ts
|
|
18
|
+
import { readFileSync } from "fs";
|
|
19
|
+
import { dirname, join } from "path";
|
|
20
|
+
import { fileURLToPath } from "url";
|
|
21
|
+
|
|
22
|
+
// src/hooks/verdict-footer.ts
|
|
23
|
+
function renderVerdictFooter(ctx) {
|
|
24
|
+
const successPrefix = color("\u2713 Conflict caught before merge", "green");
|
|
25
|
+
const savedCount = `${String(ctx.decisionsSaved)}${ctx.decisionsSavedTruncated ? "+" : ""}`;
|
|
26
|
+
const savedFragment = `${savedCount} decisions saved`;
|
|
27
|
+
const intentFragment = `${bold(ctx.author)}'s intent preserved`;
|
|
28
|
+
return `${successPrefix} \xB7 ${savedFragment} \xB7 ${intentFragment}`;
|
|
29
|
+
}
|
|
30
|
+
function isVerdictFooterContext(value) {
|
|
31
|
+
if (typeof value !== "object" || value === null) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const v = value;
|
|
35
|
+
return typeof v.author === "string" && typeof v.intent === "string" && typeof v.decisionsSaved === "number" && typeof v.decisionsSavedTruncated === "boolean" && typeof v.decisionId === "string";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/hooks/post-tool-use.ts
|
|
39
|
+
var STDIN_TIMEOUT_MS = 1e3;
|
|
40
|
+
var INGEST_TIMEOUT_MS = 4e3;
|
|
41
|
+
var EDITING_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit"]);
|
|
42
|
+
var CODEX_EDITING_TOOLS = /* @__PURE__ */ new Set(["apply_patch"]);
|
|
43
|
+
var here = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
function resolveCliVersion() {
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "..", "package.json"), "utf-8"));
|
|
47
|
+
return pkg.version ?? "unknown";
|
|
48
|
+
} catch {
|
|
49
|
+
return "unknown";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function readStdin() {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const chunks = [];
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
reject(new Error("stdin read timeout"));
|
|
57
|
+
}, STDIN_TIMEOUT_MS);
|
|
58
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
59
|
+
process.stdin.on("end", () => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
resolve(Buffer.concat(chunks).toString("utf-8"));
|
|
62
|
+
});
|
|
63
|
+
process.stdin.on("error", (err) => {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
reject(err);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function emit() {
|
|
70
|
+
process.stdout.write("{}\n");
|
|
71
|
+
}
|
|
72
|
+
function debug(msg) {
|
|
73
|
+
if (process.env.PRIM_HOOK_VERBOSE === "1") {
|
|
74
|
+
process.stderr.write(`[prim-post-tool-use] ${msg}
|
|
75
|
+
`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function ingestMove(move) {
|
|
79
|
+
const client = getClient();
|
|
80
|
+
return await client.post(
|
|
81
|
+
"/api/cli/moves/ingest",
|
|
82
|
+
{ batch: [move] },
|
|
83
|
+
{ signal: AbortSignal.timeout(INGEST_TIMEOUT_MS) }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
async function main() {
|
|
87
|
+
let raw;
|
|
88
|
+
try {
|
|
89
|
+
raw = await readStdin();
|
|
90
|
+
} catch {
|
|
91
|
+
emit();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(raw);
|
|
97
|
+
} catch {
|
|
98
|
+
emit();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const envelope = parsed;
|
|
102
|
+
if (envelope.hook_event_name !== "PostToolUse") {
|
|
103
|
+
emit();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const toolName = typeof envelope.tool_name === "string" ? envelope.tool_name : "";
|
|
107
|
+
const agent = parseAgent(process.argv);
|
|
108
|
+
const editingTools = agent === "codex" ? CODEX_EDITING_TOOLS : EDITING_TOOLS;
|
|
109
|
+
if (!editingTools.has(toolName)) {
|
|
110
|
+
emit();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (typeof envelope.session_id !== "string" || envelope.session_id.length === 0) {
|
|
114
|
+
emit();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const cwd = parsed.cwd ?? process.cwd();
|
|
118
|
+
const base = toMove(parsed, resolveCliVersion(), agent);
|
|
119
|
+
const move = { ...base, payload: scrubFromCwd(parsed, cwd) };
|
|
120
|
+
try {
|
|
121
|
+
const result = await ingestMove(move);
|
|
122
|
+
debug(`ingested ${move.moveId} (${toolName})`);
|
|
123
|
+
if (isVerdictFooterContext(result.verdictFooter)) {
|
|
124
|
+
process.stderr.write(`${renderVerdictFooter(result.verdictFooter)}
|
|
125
|
+
`);
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
debug(`ingest failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
129
|
+
}
|
|
130
|
+
emit();
|
|
131
|
+
}
|
|
132
|
+
main().catch(() => {
|
|
133
|
+
emit();
|
|
134
|
+
});
|
package/dist/hooks/pre-commit.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
checkAffectedDecisions,
|
|
4
|
+
formatDecisionsWarning,
|
|
4
5
|
getGitContext
|
|
5
|
-
} from "../chunk-
|
|
6
|
+
} from "../chunk-TPQ3X244.js";
|
|
7
|
+
import {
|
|
8
|
+
getClient
|
|
9
|
+
} from "../chunk-6SIEWWUL.js";
|
|
10
|
+
import "../chunk-UTKQTZHL.js";
|
|
6
11
|
|
|
7
12
|
// src/hooks/pre-commit.ts
|
|
8
13
|
import { execSync } from "child_process";
|
|
@@ -156,8 +161,19 @@ async function syncAffectedSpecs(deps = defaultDeps) {
|
|
|
156
161
|
}
|
|
157
162
|
return synced;
|
|
158
163
|
}
|
|
164
|
+
async function runDecisionsCheck() {
|
|
165
|
+
const stagedFiles = getStagedFiles();
|
|
166
|
+
if (stagedFiles.length === 0) {
|
|
167
|
+
return { decisions: [], truncated: false };
|
|
168
|
+
}
|
|
169
|
+
return checkAffectedDecisions(stagedFiles);
|
|
170
|
+
}
|
|
159
171
|
async function main() {
|
|
160
|
-
await syncAffectedSpecs();
|
|
172
|
+
const [, decisionsResult] = await Promise.all([syncAffectedSpecs(), runDecisionsCheck()]);
|
|
173
|
+
const warning = formatDecisionsWarning(decisionsResult);
|
|
174
|
+
if (warning) {
|
|
175
|
+
console.error(warning);
|
|
176
|
+
}
|
|
161
177
|
process.exit(0);
|
|
162
178
|
}
|
|
163
179
|
if (!process.env.VITEST) {
|