@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
package/SKILL.md
CHANGED
|
@@ -81,6 +81,87 @@ npx --yes @primitive.ai/prim project create -n "<name>" -d "<desc>"
|
|
|
81
81
|
npx --yes @primitive.ai/prim project create -n "<name>" --spec <contextId> # value is a context ID
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
### Link a spec to a branch (and an optional PR)
|
|
85
|
+
|
|
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
|
+
|
|
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
|
+
```
|
|
92
|
+
|
|
93
|
+
**When you create a spec for branch-scoped work, always pass `--branch`** (and `--pr` if a PR already exists):
|
|
94
|
+
```
|
|
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"}
|
|
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?"
|
|
110
|
+
|
|
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.
|
|
122
|
+
|
|
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
|
+
```
|
|
135
|
+
|
|
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.
|
|
140
|
+
|
|
141
|
+
### Trigger PR Intent Review or dispatch drift-fix against a linked PR
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
npx --yes @primitive.ai/prim spec review <id> --pr <n> # head SHA defaults to `git rev-parse HEAD`
|
|
145
|
+
npx --yes @primitive.ai/prim spec review <id> --pr <n> --sha <s> # explicit SHA
|
|
146
|
+
npx --yes @primitive.ai/prim spec drift <id> --pr <n> # dispatch the Claude Code drift-fix workflow against the PR
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The review bot runs server-side, posts a PR comment with findings, and the outcome surfaces on the **next** pre-commit sync's `[synced]` line as ` (reviewed: <n> finding(s) → <prCommentUrl>)` or ` (review failed)` — don't poll the API yourself.
|
|
150
|
+
|
|
151
|
+
`spec drift` requires the `primitive-drift-fix.yml` workflow file checked into the repo and the GitHub App's `actions:write` scope granted on the org. The CLI errors out otherwise with a one-liner naming the likely causes.
|
|
152
|
+
|
|
153
|
+
Neither `spec review` nor `spec drift` accepts `--json` in v1 — they emit a single human-readable line on stdout. Capture it as text or branch on `$?`.
|
|
154
|
+
|
|
155
|
+
### Inspect a task's auto-completion state
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
npx --yes @primitive.ai/prim spec status <taskId>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Reports the task's `status`, whether `auto-complete suppressed: yes/no`, and the timestamp + PR # of the most-recent auto-completion activity (with the bot's explanation). Use this after a merge to verify the auto-complete bot acted — or to see *why* it didn't (suppressed via the dashboard, last activity from a different PR, etc.).
|
|
162
|
+
|
|
163
|
+
`spec status` operates on a **task ID**, not a context/spec ID. Discover task IDs from `prim project create` output or from the editor URL. No `--json` support in v1; output is a fixed `key: value` block on stdout.
|
|
164
|
+
|
|
84
165
|
### Install the pre-commit hook
|
|
85
166
|
```
|
|
86
167
|
npx --yes @primitive.ai/prim hooks install # auto-detects Husky and prompts
|
|
@@ -106,6 +187,8 @@ What that means:
|
|
|
106
187
|
- **The hook is not `npx --yes @primitive.ai/prim spec sync`.** `npx --yes @primitive.ai/prim spec sync` re-applies the *existing* spec to the project. The hook calls `sync-diff` -- an LLM updates the spec from the code change, then applies the new spec to the project. The casual "just commit and the hook will sync" is ambiguous; when explaining to the user, specify which operation you mean.
|
|
107
188
|
- **The hook never blocks the commit.** Failures (auth, network, backend) print `[error]` to stderr but exit 0, so a successful `git commit` doesn't prove the spec changed. Check the hook's `[synced]` / `[error]` / `[skip]` output, or verify with `npx --yes @primitive.ai/prim spec get <id>`.
|
|
108
189
|
- **Diffs over 256 KiB are truncated.** The hook logs `(truncated: X KiB -> Y KiB analyzed)`. The LLM only sees the first 256 KiB of the diff.
|
|
190
|
+
- **The hook is branch-aware.** It sends `repoFullName`, `branch`, `sha`, and `prNumber` (the last detected from `gh pr view` when `gh` is on `PATH`, silently null otherwise). The server filters mappings to specs linked to the current branch *or* unlinked (auto-link candidates); specs bound to other branches are silently excluded from the affected list — they don't surface as `[skip]` lines, they just don't appear. If you push an explicit `sync-diff` for an other-branch spec via the API, the hook logs `[skip] <id> — <name> — not linked to <branch>` and continues.
|
|
191
|
+
- **Link state and review results piggyback on the synced line.** `[synced]` lines carry ` (linked to <branch> #<pr> <state>)` or ` (auto-linking to <branch>)`, and once a PR Intent Review completes they grow ` (reviewed: <n> finding(s) → <prCommentUrl>)` or ` (review failed)`. PR `<state>` (`open` / `closed` / `merged`) tracks GitHub webhook deliveries — give it a few seconds to settle after a state change.
|
|
109
192
|
- **To suppress the hook for one commit** (e.g., when intentionally desyncing code from spec, or when committing unrelated changes), use `git commit --no-verify`.
|
|
110
193
|
|
|
111
194
|
## Output formats
|
|
@@ -116,7 +199,7 @@ Every data-returning command accepts `--json`. With `--json` set, stdout is a si
|
|
|
116
199
|
- `npx --yes @primitive.ai/prim spec list --json | jq -r '.[]._id'` — list every spec ID
|
|
117
200
|
- `npx --yes @primitive.ai/prim auth status --json | jq -r .authenticated` — boolean; the exit code remains the authoritative signal
|
|
118
201
|
|
|
119
|
-
Without `--json`, mutating commands (`context create/update/delete/link/unlink`, `spec update/sync/map/unmap/auto-map`, `project create`) emit the bare resource `_id` to **stdout** (one line, no prefix) and human-readable diagnostics to **stderr**. So this also works as a one-liner without `jq`:
|
|
202
|
+
Without `--json`, mutating commands (`context create/update/delete/link/unlink`, `spec create/update/sync/map/unmap/auto-map`, `project create`) emit the bare resource `_id` to **stdout** (one line, no prefix) and human-readable diagnostics to **stderr**. So this also works as a one-liner without `jq`:
|
|
120
203
|
|
|
121
204
|
- `id=$(npx --yes @primitive.ai/prim context create -s global -n foo --text "x")`
|
|
122
205
|
|
|
@@ -139,6 +222,7 @@ Without `--json`, mutating commands (`context create/update/delete/link/unlink`,
|
|
|
139
222
|
- **`npx --yes @primitive.ai/prim spec sync` rejects non-spec contexts** with "Context is not a spec document. Use `prim context` instead." Use `npx --yes @primitive.ai/prim spec list` to find spec IDs.
|
|
140
223
|
- **`npx --yes @primitive.ai/prim context delete` is permanent.** Confirm with the user before deleting.
|
|
141
224
|
- **Scope is set at creation.** To change it, delete and recreate the context.
|
|
225
|
+
- **The hook silently excludes specs bound to other branches.** If you don't see a spec you expected to sync, check its `linkedBranches[]` via `npx --yes @primitive.ai/prim context get <id>` — it may be bound to a different branch. To re-bind, use the spec editor (no CLI subcommand in v1).
|
|
142
226
|
|
|
143
227
|
## After each task
|
|
144
228
|
|
|
@@ -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
|
}
|
|
@@ -161,5 +181,7 @@ export {
|
|
|
161
181
|
getTokenExpiresAt,
|
|
162
182
|
getAuthToken,
|
|
163
183
|
getSiteUrl,
|
|
184
|
+
refreshToken,
|
|
185
|
+
HttpError,
|
|
164
186
|
getClient
|
|
165
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,111 @@
|
|
|
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) {
|
|
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
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function shouldFlushAfter(eventType) {
|
|
25
|
+
return eventType === "SessionEnd";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/hooks/redact.ts
|
|
29
|
+
import { existsSync, readFileSync } from "fs";
|
|
30
|
+
import { join } from "path";
|
|
31
|
+
var DEFAULT_RULES = [
|
|
32
|
+
{ pattern: /Bearer\s+[A-Za-z0-9._\-]+/g, reason: "bearer-token" },
|
|
33
|
+
{ pattern: /sk-[A-Za-z0-9]{32,}/g, reason: "sk-api-key" },
|
|
34
|
+
{
|
|
35
|
+
pattern: /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/g,
|
|
36
|
+
reason: "private-key"
|
|
37
|
+
},
|
|
38
|
+
{ pattern: /xox[abprs]-[A-Za-z0-9-]{10,}/g, reason: "slack-token" },
|
|
39
|
+
{ pattern: /ghp_[A-Za-z0-9]{36,}/g, reason: "github-pat" }
|
|
40
|
+
];
|
|
41
|
+
var MAX_SCRUB_LEN = 256e3;
|
|
42
|
+
var WORKSPACE_FILE = ".prim/redaction.json";
|
|
43
|
+
function debugRedaction(message, err) {
|
|
44
|
+
if (!process.env.PRIM_HOOK_DEBUG) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
48
|
+
process.stderr.write(`[prim-hook] redaction: ${message}: ${detail}
|
|
49
|
+
`);
|
|
50
|
+
}
|
|
51
|
+
function loadWorkspaceRules(cwd) {
|
|
52
|
+
const path = join(cwd, WORKSPACE_FILE);
|
|
53
|
+
if (!existsSync(path)) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
let cfg;
|
|
57
|
+
try {
|
|
58
|
+
cfg = JSON.parse(readFileSync(path, "utf-8"));
|
|
59
|
+
} catch (err) {
|
|
60
|
+
debugRedaction(`ignoring unparseable ${WORKSPACE_FILE}`, err);
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
if (!cfg.rules) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const compiled = [];
|
|
67
|
+
for (const r of cfg.rules) {
|
|
68
|
+
try {
|
|
69
|
+
compiled.push({ pattern: new RegExp(r.pattern, r.flags ?? "g"), reason: r.reason });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
debugRedaction(`skipping invalid redaction rule "${r.reason}"`, err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return compiled;
|
|
75
|
+
}
|
|
76
|
+
function applyRules(input, rules) {
|
|
77
|
+
if (input.length > MAX_SCRUB_LEN) {
|
|
78
|
+
return "<REDACTED:oversized>";
|
|
79
|
+
}
|
|
80
|
+
let out = input;
|
|
81
|
+
for (const rule of rules) {
|
|
82
|
+
out = out.replace(rule.pattern, `<REDACTED:${rule.reason}>`);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function scrub(value, rules = DEFAULT_RULES) {
|
|
87
|
+
if (typeof value === "string") {
|
|
88
|
+
return applyRules(value, rules);
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
return value.map((v) => scrub(v, rules));
|
|
92
|
+
}
|
|
93
|
+
if (value && typeof value === "object") {
|
|
94
|
+
const out = {};
|
|
95
|
+
for (const [k, v] of Object.entries(value)) {
|
|
96
|
+
out[k] = scrub(v, rules);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
function scrubFromCwd(value, cwd) {
|
|
103
|
+
const rules = [...DEFAULT_RULES, ...loadWorkspaceRules(cwd)];
|
|
104
|
+
return scrub(value, rules);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export {
|
|
108
|
+
toMove,
|
|
109
|
+
shouldFlushAfter,
|
|
110
|
+
scrubFromCwd
|
|
111
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getClient
|
|
3
|
+
} from "./chunk-6SIEWWUL.js";
|
|
4
|
+
|
|
5
|
+
// src/hooks/decisions-check.ts
|
|
6
|
+
var DECISIONS_CHECK_TIMEOUT_MS = 1e4;
|
|
7
|
+
var MAX_FILES_PER_REQUEST = 25;
|
|
8
|
+
var defaultDeps = { getClient };
|
|
9
|
+
function chunk(items, size) {
|
|
10
|
+
const out = [];
|
|
11
|
+
for (let i = 0; i < items.length; i += size) {
|
|
12
|
+
out.push(items.slice(i, i + size));
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
async function fetchAffecting(client, batch) {
|
|
17
|
+
const params = new URLSearchParams();
|
|
18
|
+
for (const file of batch) {
|
|
19
|
+
params.append("files", file);
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return await client.get(`/api/cli/decisions/affecting?${params.toString()}`, {
|
|
23
|
+
signal: AbortSignal.timeout(DECISIONS_CHECK_TIMEOUT_MS)
|
|
24
|
+
});
|
|
25
|
+
} catch (err) {
|
|
26
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
27
|
+
return { decisions: [], truncated: false, unavailable: `decision check failed: ${detail}` };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function checkAffectedDecisions(filePaths, deps = defaultDeps) {
|
|
31
|
+
if (filePaths.length === 0) {
|
|
32
|
+
return { decisions: [], truncated: false };
|
|
33
|
+
}
|
|
34
|
+
const client = deps.getClient();
|
|
35
|
+
const responses = await Promise.all(
|
|
36
|
+
chunk(filePaths, MAX_FILES_PER_REQUEST).map((batch) => fetchAffecting(client, batch))
|
|
37
|
+
);
|
|
38
|
+
const byId = /* @__PURE__ */ new Map();
|
|
39
|
+
let truncated = false;
|
|
40
|
+
let unavailable;
|
|
41
|
+
for (const res of responses) {
|
|
42
|
+
if (res.unavailable !== void 0 && unavailable === void 0) {
|
|
43
|
+
unavailable = res.unavailable;
|
|
44
|
+
}
|
|
45
|
+
truncated ||= res.truncated === true;
|
|
46
|
+
for (const d of res.decisions) {
|
|
47
|
+
const existing = byId.get(d.id);
|
|
48
|
+
if (existing) {
|
|
49
|
+
existing.matchedFiles = [.../* @__PURE__ */ new Set([...existing.matchedFiles, ...d.matchedFiles])];
|
|
50
|
+
} else {
|
|
51
|
+
byId.set(d.id, { ...d, matchedFiles: [...d.matchedFiles] });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const result = { decisions: [...byId.values()], truncated };
|
|
56
|
+
if (unavailable !== void 0) {
|
|
57
|
+
result.unavailable = unavailable;
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
var FILES_PREVIEW_LIMIT = 3;
|
|
62
|
+
function formatDecisionsWarning(result) {
|
|
63
|
+
const lines = [];
|
|
64
|
+
if (result.unavailable !== void 0) {
|
|
65
|
+
lines.push(`[prim] decision check not verified \u2014 ${result.unavailable}`);
|
|
66
|
+
}
|
|
67
|
+
if (result.decisions.length > 0) {
|
|
68
|
+
lines.push(
|
|
69
|
+
`[prim] ${String(result.decisions.length)} active decision(s) reference staged files:`
|
|
70
|
+
);
|
|
71
|
+
for (const d of result.decisions) {
|
|
72
|
+
const statusMark = d.status === "under_review" ? " (under review)" : "";
|
|
73
|
+
const preview = d.matchedFiles.slice(0, FILES_PREVIEW_LIMIT).join(", ");
|
|
74
|
+
const overflow = d.matchedFiles.length > FILES_PREVIEW_LIMIT ? ` (+${String(d.matchedFiles.length - FILES_PREVIEW_LIMIT)} more)` : "";
|
|
75
|
+
lines.push(` \xB7 ${d.intent}${statusMark}`);
|
|
76
|
+
lines.push(` files: ${preview}${overflow}`);
|
|
77
|
+
if (d.rationale) {
|
|
78
|
+
lines.push(` rationale: ${d.rationale}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (result.truncated) {
|
|
83
|
+
lines.push(
|
|
84
|
+
"[prim] result truncated \u2014 more files than the server checks per request; not all decisions shown"
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/utils/git.ts
|
|
91
|
+
import { execSync } from "child_process";
|
|
92
|
+
function safeExec(cmd) {
|
|
93
|
+
try {
|
|
94
|
+
return execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function parseRepoFullName(remoteUrl) {
|
|
100
|
+
const match = remoteUrl.match(/(?:github\.com[:/])([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
101
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
102
|
+
}
|
|
103
|
+
function getGitContext() {
|
|
104
|
+
const branchRaw = safeExec("git rev-parse --abbrev-ref HEAD");
|
|
105
|
+
const branch = branchRaw && branchRaw !== "HEAD" ? branchRaw : null;
|
|
106
|
+
const sha = safeExec("git rev-parse HEAD");
|
|
107
|
+
const remoteUrl = safeExec("git remote get-url origin");
|
|
108
|
+
const repoFullName = remoteUrl ? parseRepoFullName(remoteUrl) : null;
|
|
109
|
+
let prNumber = null;
|
|
110
|
+
if (safeExec("command -v gh")) {
|
|
111
|
+
const raw = safeExec("gh pr view --json number -q .number");
|
|
112
|
+
const n = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
|
113
|
+
if (Number.isFinite(n)) prNumber = n;
|
|
114
|
+
}
|
|
115
|
+
return { branch, sha, repoFullName, prNumber };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export {
|
|
119
|
+
checkAffectedDecisions,
|
|
120
|
+
formatDecisionsWarning,
|
|
121
|
+
getGitContext
|
|
122
|
+
};
|