@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
package/SKILL.md
CHANGED
|
@@ -83,20 +83,60 @@ npx --yes @primitive.ai/prim project create -n "<name>" --spec <contextId> #
|
|
|
83
83
|
|
|
84
84
|
### Link a spec to a branch (and an optional PR)
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
Linking is **automatic** once the pre-commit hook is installed and a spec is bound to your branch — every commit refreshes the link's metadata, including the PR number (detected from `gh pr view` when `gh` is on `PATH`). When a spec was authored for the work on this branch, bind it at the right moment so the hook can take over — do this without waiting for the user to ask. When no spec exists for the work, **do not bind one** — see "Decide whether a spec exists for this branch" below.
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
**At the start of branch work, install the hook** (skip if `.git/hooks/pre-commit` or `.husky/pre-commit` already invokes `prim-pre-commit`):
|
|
89
|
+
```
|
|
90
|
+
npx --yes @primitive.ai/prim hooks install --yes
|
|
91
|
+
```
|
|
89
92
|
|
|
93
|
+
**When you create a spec for branch-scoped work, always pass `--branch`** (and `--pr` if a PR already exists):
|
|
90
94
|
```
|
|
91
|
-
|
|
95
|
+
br=$(git rev-parse --abbrev-ref HEAD)
|
|
96
|
+
pr=$(gh pr view --json number -q .number 2>/dev/null)
|
|
97
|
+
npx --yes @primitive.ai/prim spec create -s project -n "<name>" --file <path> --branch "$br" ${pr:+--pr "$pr"}
|
|
92
98
|
```
|
|
99
|
+
`--branch` requires a GitHub origin; if `git remote get-url origin` isn't GitHub the link is silently dropped (stderr warning). There is no `prim spec link` subcommand in v1 — to rebind a spec to a different branch, edit it from the spec editor UI.
|
|
100
|
+
|
|
101
|
+
**Decide whether a spec exists for this branch.** A spec is "for this branch" only if one of:
|
|
102
|
+
- the user named a spec ID or title in conversation,
|
|
103
|
+
- a spec was created with `--branch "$br"` (visible via `npx --yes @primitive.ai/prim spec list --json | jq --arg br "$br" '.[] | select(.linkedBranches[]?.branch == $br)'`, where `$br` is the same shell variable set in the recipe above).
|
|
104
|
+
|
|
105
|
+
**Do not** browse `prim spec list` and pick the closest- or most-related-sounding spec. Topical proximity is not authorship — two specs that touch the same area of the codebase can describe entirely different intents. An irrelevant link pollutes drift signal and silently misattributes review findings; no link is strictly better than a wrong link.
|
|
106
|
+
|
|
107
|
+
If neither signal applies, **stop and ask the user**:
|
|
108
|
+
|
|
109
|
+
> "I couldn't find a spec associated with this branch. Is there one I should link, or should I draft one from the PR description and our conversation?"
|
|
93
110
|
|
|
94
|
-
|
|
111
|
+
Three legitimate answers:
|
|
112
|
+
- **User names an existing spec** → proceed to "When the user has identified a spec for this branch" below.
|
|
113
|
+
- **User declines a spec entirely** → leave the PR unlinked.
|
|
114
|
+
- **User asks you to draft one** →
|
|
115
|
+
- Compose with these sections (drop any that would only restate the obvious): *Goal*, *Requirements / Behavior*, *Technical Approach*, *Key Decisions*, *Out of Scope*. Scope to what the PR actually changed; don't restate the unchanged system. Each fact appears in exactly one section.
|
|
116
|
+
- Voice: plain language; lead with the point; **intent before mechanism** ("users see each other's edits" before "we use WebSocket"); present tense, active voice; one idea per paragraph; cut sentences that don't earn their place.
|
|
117
|
+
- **Key Decisions** is a markdown table — columns *Decision | Rationale | Trade-offs*, one row per non-obvious choice.
|
|
118
|
+
- Title is just the feature name (no `Spec:` prefix); no numbered or parenthetical headers. Match length to PR complexity — a small fix is one screen; a substantial feature warrants the full shape.
|
|
119
|
+
- Do not paste the PR description verbatim — a spec captures *intent*, not a change log. If the rationale behind a non-obvious decision isn't in conversation, ask one or two targeted questions before drafting; do not invent rationale.
|
|
120
|
+
- Show the user the drafted text and wait for go-ahead before running `spec create`.
|
|
121
|
+
- Then create-and-link using the recipe above.
|
|
95
122
|
|
|
96
|
-
|
|
123
|
+
**When the user has identified a spec for this branch, check whether it's bound to your branch** before committing:
|
|
124
|
+
```
|
|
125
|
+
npx --yes @primitive.ai/prim context get <specId> --json | jq '.linkedBranches[]?.branch'
|
|
126
|
+
```
|
|
127
|
+
- Your branch appears → done; the hook keeps it fresh.
|
|
128
|
+
- Empty or branch absent → the first commit's hook auto-binds it; no CLI step needed.
|
|
129
|
+
- Bound only to another branch → the hook silently excludes it from your branch's syncs; rebind via the editor UI before committing.
|
|
130
|
+
|
|
131
|
+
**After `gh pr create`**, no CLI step is required: the next commit's hook patches `linkedBranches[].prNumber` via `gh pr view`, and GitHub's webhook to Primitive sets the same field server-side within seconds. Confirm with:
|
|
132
|
+
```
|
|
133
|
+
npx --yes @primitive.ai/prim context get <specId> --json | jq .linkedBranches
|
|
134
|
+
```
|
|
97
135
|
|
|
98
|
-
|
|
99
|
-
-
|
|
136
|
+
**On every commit, read the `[synced]` line to verify link state** — it piggybacks the suffix:
|
|
137
|
+
- `(auto-linking to <branch>)` — first sync; server is binding now.
|
|
138
|
+
- `(linked to <branch> #<n> <state>)` — link is sticky.
|
|
139
|
+
- `[skip] <id> — not linked to <branch>` — the spec is bound elsewhere; investigate before continuing.
|
|
100
140
|
|
|
101
141
|
### Trigger PR Intent Review or dispatch drift-fix against a linked PR
|
|
102
142
|
|
|
@@ -68,9 +68,16 @@ function getAuthToken() {
|
|
|
68
68
|
}
|
|
69
69
|
return void 0;
|
|
70
70
|
}
|
|
71
|
-
var
|
|
71
|
+
var DEFAULT_API_URL = "https://api.getprimitive.ai";
|
|
72
72
|
function getSiteUrl() {
|
|
73
|
-
|
|
73
|
+
if (process.env.PRIM_API_URL) {
|
|
74
|
+
return process.env.PRIM_API_URL;
|
|
75
|
+
}
|
|
76
|
+
const envVars = loadEnvFile();
|
|
77
|
+
if (envVars.PRIM_API_URL) {
|
|
78
|
+
return envVars.PRIM_API_URL;
|
|
79
|
+
}
|
|
80
|
+
return DEFAULT_API_URL;
|
|
74
81
|
}
|
|
75
82
|
async function refreshToken() {
|
|
76
83
|
if (!existsSync(REFRESH_TOKEN_PATH)) {
|
|
@@ -87,6 +94,11 @@ async function refreshToken() {
|
|
|
87
94
|
body: JSON.stringify({ refresh_token: refreshTokenValue })
|
|
88
95
|
});
|
|
89
96
|
if (!response.ok) {
|
|
97
|
+
const detail = (await response.text().catch(() => "")).slice(0, 200);
|
|
98
|
+
process.stderr.write(
|
|
99
|
+
`[prim] token refresh rejected by broker: ${response.status} ${response.statusText}${detail ? ` \u2014 ${detail}` : ""}
|
|
100
|
+
`
|
|
101
|
+
);
|
|
90
102
|
return void 0;
|
|
91
103
|
}
|
|
92
104
|
const data = await response.json();
|
|
@@ -100,6 +112,14 @@ async function refreshToken() {
|
|
|
100
112
|
saveTokenExpiry(data.access_token, data.expires_in);
|
|
101
113
|
return data.access_token;
|
|
102
114
|
}
|
|
115
|
+
var HttpError = class extends Error {
|
|
116
|
+
status;
|
|
117
|
+
constructor(status, message) {
|
|
118
|
+
super(message);
|
|
119
|
+
this.name = "HttpError";
|
|
120
|
+
this.status = status;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
103
123
|
var _cachedToken;
|
|
104
124
|
async function request(method, path, body, options) {
|
|
105
125
|
const siteUrl = getSiteUrl();
|
|
@@ -137,10 +157,10 @@ async function request(method, path, body, options) {
|
|
|
137
157
|
}
|
|
138
158
|
if (!res.ok) {
|
|
139
159
|
if (res.status === 401) {
|
|
140
|
-
throw new
|
|
160
|
+
throw new HttpError(401, "Authentication expired. Run `prim auth login` to re-authenticate.");
|
|
141
161
|
}
|
|
142
162
|
const errorBody = await res.json().catch(() => null);
|
|
143
|
-
throw new
|
|
163
|
+
throw new HttpError(res.status, errorBody?.error ?? `HTTP ${res.status}`);
|
|
144
164
|
}
|
|
145
165
|
return res.json();
|
|
146
166
|
}
|
|
@@ -153,34 +173,6 @@ function getClient() {
|
|
|
153
173
|
};
|
|
154
174
|
}
|
|
155
175
|
|
|
156
|
-
// src/utils/git.ts
|
|
157
|
-
import { execSync } from "child_process";
|
|
158
|
-
function safeExec(cmd) {
|
|
159
|
-
try {
|
|
160
|
-
return execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
161
|
-
} catch {
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
function parseRepoFullName(remoteUrl) {
|
|
166
|
-
const match = remoteUrl.match(/(?:github\.com[:/])([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
167
|
-
return match ? `${match[1]}/${match[2]}` : null;
|
|
168
|
-
}
|
|
169
|
-
function getGitContext() {
|
|
170
|
-
const branchRaw = safeExec("git rev-parse --abbrev-ref HEAD");
|
|
171
|
-
const branch = branchRaw && branchRaw !== "HEAD" ? branchRaw : null;
|
|
172
|
-
const sha = safeExec("git rev-parse HEAD");
|
|
173
|
-
const remoteUrl = safeExec("git remote get-url origin");
|
|
174
|
-
const repoFullName = remoteUrl ? parseRepoFullName(remoteUrl) : null;
|
|
175
|
-
let prNumber = null;
|
|
176
|
-
if (safeExec("command -v gh")) {
|
|
177
|
-
const raw = safeExec("gh pr view --json number -q .number");
|
|
178
|
-
const n = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
|
179
|
-
if (Number.isFinite(n)) prNumber = n;
|
|
180
|
-
}
|
|
181
|
-
return { branch, sha, repoFullName, prNumber };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
176
|
export {
|
|
185
177
|
TOKEN_FILE_PATH,
|
|
186
178
|
REFRESH_TOKEN_PATH,
|
|
@@ -189,6 +181,7 @@ export {
|
|
|
189
181
|
getTokenExpiresAt,
|
|
190
182
|
getAuthToken,
|
|
191
183
|
getSiteUrl,
|
|
192
|
-
|
|
193
|
-
|
|
184
|
+
refreshToken,
|
|
185
|
+
HttpError,
|
|
186
|
+
getClient
|
|
194
187
|
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// src/lib/ansi.ts
|
|
2
|
+
var ANSI_CODES = {
|
|
3
|
+
yellow: "\x1B[33m",
|
|
4
|
+
magenta: "\x1B[35m",
|
|
5
|
+
blue: "\x1B[34m",
|
|
6
|
+
cyan: "\x1B[36m",
|
|
7
|
+
green: "\x1B[32m",
|
|
8
|
+
red: "\x1B[31m",
|
|
9
|
+
// 256-color approximation of the orange trigger marker (xterm 208).
|
|
10
|
+
orange: "\x1B[38;5;208m",
|
|
11
|
+
gray: "\x1B[90m"
|
|
12
|
+
};
|
|
13
|
+
var ANSI_RESET = "\x1B[0m";
|
|
14
|
+
var ANSI_BOLD = "\x1B[1m";
|
|
15
|
+
function supportsColor() {
|
|
16
|
+
if (process.env.NO_COLOR !== void 0 && process.env.NO_COLOR !== "") {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return process.stderr.isTTY === true;
|
|
20
|
+
}
|
|
21
|
+
function color(text, c) {
|
|
22
|
+
if (!supportsColor()) {
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
return `${ANSI_CODES[c]}${text}${ANSI_RESET}`;
|
|
26
|
+
}
|
|
27
|
+
function bold(text) {
|
|
28
|
+
if (!supportsColor()) {
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
return `${ANSI_BOLD}${text}${ANSI_RESET}`;
|
|
32
|
+
}
|
|
33
|
+
var AREA_COLORS = {
|
|
34
|
+
auth: "yellow",
|
|
35
|
+
data: "magenta",
|
|
36
|
+
mobile: "blue",
|
|
37
|
+
infra: "cyan",
|
|
38
|
+
ui: "green",
|
|
39
|
+
billing: "orange",
|
|
40
|
+
api: "blue",
|
|
41
|
+
docs: "gray",
|
|
42
|
+
testing: "gray"
|
|
43
|
+
};
|
|
44
|
+
function colorForArea(area) {
|
|
45
|
+
if (!area) {
|
|
46
|
+
return "gray";
|
|
47
|
+
}
|
|
48
|
+
return AREA_COLORS[area] ?? "gray";
|
|
49
|
+
}
|
|
50
|
+
function stripAnsi(text) {
|
|
51
|
+
return text.replace(/\u001b\[[0-9;]*m/g, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
color,
|
|
56
|
+
bold,
|
|
57
|
+
colorForArea,
|
|
58
|
+
stripAnsi
|
|
59
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// src/journal.ts
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
statSync
|
|
9
|
+
} from "fs";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { dirname, join } from "path";
|
|
12
|
+
var JOURNAL_DIR = join(homedir(), ".config", "prim", "moves");
|
|
13
|
+
var LEGACY_JOURNAL_PATH = join(JOURNAL_DIR, "journal.ndjson");
|
|
14
|
+
var UNBOUND_BUCKET = "_unbound";
|
|
15
|
+
var JOURNAL_BASENAME = "journal.ndjson";
|
|
16
|
+
var DIR_MODE = 448;
|
|
17
|
+
var FILE_MODE = 384;
|
|
18
|
+
var SAFE_BUCKET = /^[A-Za-z0-9_-]+$/;
|
|
19
|
+
var RESERVED_BUCKETS = /* @__PURE__ */ new Set([UNBOUND_BUCKET, "_legacy"]);
|
|
20
|
+
function appendMoveToPath(path, move) {
|
|
21
|
+
const dir = dirname(path);
|
|
22
|
+
if (!existsSync(dir)) {
|
|
23
|
+
mkdirSync(dir, { recursive: true, mode: DIR_MODE });
|
|
24
|
+
}
|
|
25
|
+
appendFileSync(path, `${JSON.stringify(move)}
|
|
26
|
+
`, { mode: FILE_MODE });
|
|
27
|
+
}
|
|
28
|
+
function bucketDir(orgId) {
|
|
29
|
+
const safe = orgId !== void 0 && SAFE_BUCKET.test(orgId) && !RESERVED_BUCKETS.has(orgId);
|
|
30
|
+
return join(JOURNAL_DIR, safe ? orgId : UNBOUND_BUCKET);
|
|
31
|
+
}
|
|
32
|
+
function journalPath(orgId) {
|
|
33
|
+
return join(bucketDir(orgId), JOURNAL_BASENAME);
|
|
34
|
+
}
|
|
35
|
+
function appendMove(move, orgId) {
|
|
36
|
+
appendMoveToPath(journalPath(orgId), move);
|
|
37
|
+
}
|
|
38
|
+
function readMovesFromPath(path) {
|
|
39
|
+
if (!existsSync(path)) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const content = readFileSync(path, "utf-8");
|
|
43
|
+
const moves = [];
|
|
44
|
+
for (const line of content.split("\n")) {
|
|
45
|
+
if (line.length === 0) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
moves.push(JSON.parse(line));
|
|
50
|
+
} catch {
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return moves;
|
|
54
|
+
}
|
|
55
|
+
function listBuckets() {
|
|
56
|
+
const out = [];
|
|
57
|
+
if (existsSync(LEGACY_JOURNAL_PATH)) {
|
|
58
|
+
out.push({ bucket: "_legacy", path: LEGACY_JOURNAL_PATH });
|
|
59
|
+
}
|
|
60
|
+
if (!existsSync(JOURNAL_DIR)) {
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
for (const entry of readdirSync(JOURNAL_DIR, { withFileTypes: true })) {
|
|
64
|
+
if (!entry.isDirectory()) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const path = join(JOURNAL_DIR, entry.name, JOURNAL_BASENAME);
|
|
68
|
+
if (existsSync(path)) {
|
|
69
|
+
out.push({ bucket: entry.name, path });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function bucketStats() {
|
|
75
|
+
return listBuckets().map(({ bucket, path }) => {
|
|
76
|
+
const stat = statSync(path);
|
|
77
|
+
const content = readFileSync(path, "utf-8");
|
|
78
|
+
const lineCount = content.split("\n").filter((l) => l.length > 0).length;
|
|
79
|
+
return {
|
|
80
|
+
bucket,
|
|
81
|
+
path,
|
|
82
|
+
sizeBytes: stat.size,
|
|
83
|
+
mtimeMs: stat.mtimeMs,
|
|
84
|
+
lineCount
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/binding.ts
|
|
90
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
91
|
+
import { homedir as homedir2 } from "os";
|
|
92
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
93
|
+
var PRIM_CONFIG_DIR = join2(homedir2(), ".config", "prim");
|
|
94
|
+
var TOKEN_PATH = join2(PRIM_CONFIG_DIR, "token");
|
|
95
|
+
var SESSIONS_DIR = join2(PRIM_CONFIG_DIR, "sessions");
|
|
96
|
+
var WORKSPACE_FILE = ".prim/workspace.json";
|
|
97
|
+
var JWT_PARTS = 3;
|
|
98
|
+
var BASE64_PAD_4 = 4;
|
|
99
|
+
var ENV_TOKEN_LINE = /^\s*PRIM_TOKEN\s*=\s*(.*)$/m;
|
|
100
|
+
var SURROUNDING_QUOTES = /^["']|["']$/g;
|
|
101
|
+
var BASE64URL_MINUS = /-/g;
|
|
102
|
+
var BASE64URL_UNDERSCORE = /_/g;
|
|
103
|
+
function readJsonSafe(path) {
|
|
104
|
+
if (!existsSync2(path)) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(readFileSync2(path, "utf-8"));
|
|
109
|
+
} catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function fromSessionMarker(sessionId) {
|
|
114
|
+
if (!sessionId) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const marker = readJsonSafe(join2(SESSIONS_DIR, `${sessionId}.json`));
|
|
118
|
+
return marker?.orgId;
|
|
119
|
+
}
|
|
120
|
+
function fromWorkspaceFile(startDir) {
|
|
121
|
+
let dir = startDir;
|
|
122
|
+
while (true) {
|
|
123
|
+
const config = readJsonSafe(join2(dir, WORKSPACE_FILE));
|
|
124
|
+
if (config?.orgId) {
|
|
125
|
+
return config.orgId;
|
|
126
|
+
}
|
|
127
|
+
const parent = dirname2(dir);
|
|
128
|
+
if (parent === dir) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
dir = parent;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function readToken(cwd) {
|
|
135
|
+
const fromEnv = process.env.PRIM_TOKEN;
|
|
136
|
+
if (fromEnv) {
|
|
137
|
+
return fromEnv.trim();
|
|
138
|
+
}
|
|
139
|
+
if (existsSync2(TOKEN_PATH)) {
|
|
140
|
+
return readFileSync2(TOKEN_PATH, "utf-8").trim();
|
|
141
|
+
}
|
|
142
|
+
const envLocal = join2(cwd, ".env.local");
|
|
143
|
+
if (existsSync2(envLocal)) {
|
|
144
|
+
const match = readFileSync2(envLocal, "utf-8").match(ENV_TOKEN_LINE);
|
|
145
|
+
if (match) {
|
|
146
|
+
return match[1].trim().replace(SURROUNDING_QUOTES, "");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
function base64UrlDecode(s) {
|
|
152
|
+
const padded = s.padEnd(
|
|
153
|
+
s.length + (BASE64_PAD_4 - s.length % BASE64_PAD_4) % BASE64_PAD_4,
|
|
154
|
+
"="
|
|
155
|
+
);
|
|
156
|
+
const normalized = padded.replace(BASE64URL_MINUS, "+").replace(BASE64URL_UNDERSCORE, "/");
|
|
157
|
+
return Buffer.from(normalized, "base64").toString("utf-8");
|
|
158
|
+
}
|
|
159
|
+
function fromDefaultOrg(cwd) {
|
|
160
|
+
const token = readToken(cwd);
|
|
161
|
+
if (!token) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const parts = token.split(".");
|
|
165
|
+
if (parts.length !== JWT_PARTS) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const claims = JSON.parse(base64UrlDecode(parts[1]));
|
|
170
|
+
return claims.org_id;
|
|
171
|
+
} catch {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function resolveOrg(args) {
|
|
176
|
+
const fromSession = fromSessionMarker(args.sessionId);
|
|
177
|
+
if (fromSession) {
|
|
178
|
+
return { orgId: fromSession, source: "session" };
|
|
179
|
+
}
|
|
180
|
+
const fromWorkspace = fromWorkspaceFile(args.cwd);
|
|
181
|
+
if (fromWorkspace) {
|
|
182
|
+
return { orgId: fromWorkspace, source: "workspace" };
|
|
183
|
+
}
|
|
184
|
+
const fromDefault = fromDefaultOrg(args.cwd);
|
|
185
|
+
if (fromDefault) {
|
|
186
|
+
return { orgId: fromDefault, source: "defaultOrg" };
|
|
187
|
+
}
|
|
188
|
+
return { orgId: void 0, source: "unbound" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export {
|
|
192
|
+
JOURNAL_DIR,
|
|
193
|
+
appendMove,
|
|
194
|
+
readMovesFromPath,
|
|
195
|
+
listBuckets,
|
|
196
|
+
bucketStats,
|
|
197
|
+
SESSIONS_DIR,
|
|
198
|
+
resolveOrg
|
|
199
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/hooks/prim-hook-core.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { platform } from "os";
|
|
4
|
+
|
|
5
|
+
// src/protocol/move.ts
|
|
6
|
+
var ENVELOPE_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
// src/hooks/prim-hook-core.ts
|
|
9
|
+
function toMove(parsed, cliVersion, agent = "claude_code") {
|
|
10
|
+
return {
|
|
11
|
+
moveId: randomUUID(),
|
|
12
|
+
capturedAt: Date.now(),
|
|
13
|
+
sessionId: parsed.session_id ?? "",
|
|
14
|
+
eventType: parsed.hook_event_name ?? "unknown",
|
|
15
|
+
payload: parsed,
|
|
16
|
+
env: {
|
|
17
|
+
cwd: parsed.cwd ?? process.cwd(),
|
|
18
|
+
cliVersion,
|
|
19
|
+
osPlatform: platform()
|
|
20
|
+
},
|
|
21
|
+
envelopeVersion: ENVELOPE_VERSION,
|
|
22
|
+
// Stamp the producer only for Codex; Claude Code moves omit it (the
|
|
23
|
+
// backend defaults an absent value to "claude_code"), keeping the
|
|
24
|
+
// Claude wire shape byte-identical.
|
|
25
|
+
...agent === "codex" ? { producer: "codex" } : {}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function shouldFlushAfter(eventType) {
|
|
29
|
+
return eventType === "SessionEnd";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/hooks/redact.ts
|
|
33
|
+
import { existsSync, readFileSync } from "fs";
|
|
34
|
+
import { join } from "path";
|
|
35
|
+
var DEFAULT_RULES = [
|
|
36
|
+
{ pattern: /Bearer\s+[A-Za-z0-9._\-]+/g, reason: "bearer-token" },
|
|
37
|
+
{ pattern: /sk-[A-Za-z0-9]{32,}/g, reason: "sk-api-key" },
|
|
38
|
+
{
|
|
39
|
+
pattern: /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/g,
|
|
40
|
+
reason: "private-key"
|
|
41
|
+
},
|
|
42
|
+
{ pattern: /xox[abprs]-[A-Za-z0-9-]{10,}/g, reason: "slack-token" },
|
|
43
|
+
{ pattern: /ghp_[A-Za-z0-9]{36,}/g, reason: "github-pat" }
|
|
44
|
+
];
|
|
45
|
+
var MAX_SCRUB_LEN = 256e3;
|
|
46
|
+
var WORKSPACE_FILE = ".prim/redaction.json";
|
|
47
|
+
function debugRedaction(message, err) {
|
|
48
|
+
if (!process.env.PRIM_HOOK_DEBUG) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
52
|
+
process.stderr.write(`[prim-hook] redaction: ${message}: ${detail}
|
|
53
|
+
`);
|
|
54
|
+
}
|
|
55
|
+
function loadWorkspaceRules(cwd) {
|
|
56
|
+
const path = join(cwd, WORKSPACE_FILE);
|
|
57
|
+
if (!existsSync(path)) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
let cfg;
|
|
61
|
+
try {
|
|
62
|
+
cfg = JSON.parse(readFileSync(path, "utf-8"));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
debugRedaction(`ignoring unparseable ${WORKSPACE_FILE}`, err);
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
if (!cfg.rules) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const compiled = [];
|
|
71
|
+
for (const r of cfg.rules) {
|
|
72
|
+
try {
|
|
73
|
+
compiled.push({ pattern: new RegExp(r.pattern, r.flags ?? "g"), reason: r.reason });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
debugRedaction(`skipping invalid redaction rule "${r.reason}"`, err);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return compiled;
|
|
79
|
+
}
|
|
80
|
+
function applyRules(input, rules) {
|
|
81
|
+
if (input.length > MAX_SCRUB_LEN) {
|
|
82
|
+
return "<REDACTED:oversized>";
|
|
83
|
+
}
|
|
84
|
+
let out = input;
|
|
85
|
+
for (const rule of rules) {
|
|
86
|
+
out = out.replace(rule.pattern, `<REDACTED:${rule.reason}>`);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
function scrub(value, rules = DEFAULT_RULES) {
|
|
91
|
+
if (typeof value === "string") {
|
|
92
|
+
return applyRules(value, rules);
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
return value.map((v) => scrub(v, rules));
|
|
96
|
+
}
|
|
97
|
+
if (value && typeof value === "object") {
|
|
98
|
+
const out = {};
|
|
99
|
+
for (const [k, v] of Object.entries(value)) {
|
|
100
|
+
out[k] = scrub(v, rules);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
function scrubFromCwd(value, cwd) {
|
|
107
|
+
const rules = [...DEFAULT_RULES, ...loadWorkspaceRules(cwd)];
|
|
108
|
+
return scrub(value, rules);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export {
|
|
112
|
+
toMove,
|
|
113
|
+
shouldFlushAfter,
|
|
114
|
+
scrubFromCwd
|
|
115
|
+
};
|