@primitive.ai/prim 0.1.0-alpha.56 → 0.1.0-alpha.58
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/README.md +6 -7
- package/SKILL.md +18 -28
- package/dist/{chunk-6LAQVM26.js → chunk-GUXBUBQ7.js} +2 -0
- package/dist/{chunk-DWDEQRWN.js → chunk-I3HIPAIR.js} +12 -14
- package/dist/chunk-LDQZASIF.js +166 -0
- package/dist/{chunk-WELBNODJ.js → chunk-R4CUX6FY.js} +1 -1
- package/dist/{chunk-BOTKPLTI.js → chunk-S4LFRVGD.js} +1 -1
- package/dist/{chunk-ROL7SDXX.js → chunk-UBEBEBLM.js} +2 -2
- package/dist/{chunk-F5QCFBJ6.js → chunk-YDBZTFW5.js} +21 -22
- package/dist/{chunk-HSZPTVKU.js → chunk-YIMUIPMD.js} +20 -15
- package/dist/{chunk-VDLAAUWV.js → chunk-YITJ2H3B.js} +1 -1
- package/dist/chunk-YWJHWGXH.js +438 -0
- package/dist/daemon/server.js +0 -11
- package/dist/hooks/post-commit.js +4 -4
- package/dist/hooks/post-tool-use.js +29 -11
- package/dist/hooks/pre-commit.js +2 -2
- package/dist/hooks/pre-tool-use.js +84 -240
- package/dist/hooks/prim-hook.js +12 -10
- package/dist/hooks/session-start.js +5 -5
- package/dist/index.js +203 -111
- package/dist/statusline-main.js +10 -8
- package/package.json +2 -1
- package/dist/chunk-R5ZJRSLV.js +0 -82
package/README.md
CHANGED
|
@@ -9,10 +9,9 @@ presence — from the command line and via session + git hooks.
|
|
|
9
9
|
|
|
10
10
|
> [!NOTE]
|
|
11
11
|
> **Conflict Gates** check each edit against the decision graph and surface any
|
|
12
|
-
> load-bearing decision it conflicts with
|
|
13
|
-
>
|
|
14
|
-
>
|
|
15
|
-
> for your team, contact **support@getprimitive.ai**.
|
|
12
|
+
> load-bearing decision it conflicts with. When **Enforcement** is enabled for your
|
|
13
|
+
> organization, gates can warn, pause, or block a conflicting edit until you reconcile
|
|
14
|
+
> the decision and retry. Automatic decision capture in active repositories is separate.
|
|
16
15
|
|
|
17
16
|
## Installation
|
|
18
17
|
|
|
@@ -197,7 +196,7 @@ installation.
|
|
|
197
196
|
### Daemon
|
|
198
197
|
|
|
199
198
|
A supervised long-lived companion process that continuously drains captured
|
|
200
|
-
Moves, accelerates
|
|
199
|
+
Moves, accelerates decision-graph reads, and powers the "team: N online"
|
|
201
200
|
presence count. `prim setup` requires it to become healthy unless the explicit
|
|
202
201
|
`--no-daemon` opt-out is supplied; hooks still fail soft if it later degrades.
|
|
203
202
|
|
|
@@ -249,8 +248,8 @@ would create a cycle (exit 2); an unresolved id exits 4.
|
|
|
249
248
|
prim reconcile <id> # Mint a single-use bypass for a decision Conflict Gates flagged
|
|
250
249
|
```
|
|
251
250
|
|
|
252
|
-
|
|
253
|
-
|
|
251
|
+
When Conflict Gates **Enforcement** is enabled for your organization, this command
|
|
252
|
+
authorizes one retry for the named decision.
|
|
254
253
|
|
|
255
254
|
### Hooks
|
|
256
255
|
|
package/SKILL.md
CHANGED
|
@@ -9,7 +9,7 @@ description: Use the prim CLI for Primitive’s decision graph. MUST INVOKE befo
|
|
|
9
9
|
|
|
10
10
|
## Mental model
|
|
11
11
|
|
|
12
|
-
In an active repo, prim passively captures the **decisions** your team makes -- which library, which pattern, which config value -- into a queryable graph, and links them: a decision can depend on earlier decisions (auto-linked from shared files, or related by hand — see *Relate decisions*) and reference the files it touched. **Conflict Gates** can check a later change against that graph and surface any load-bearing decision it conflicts with
|
|
12
|
+
In an active repo, prim passively captures the **decisions** your team makes -- which library, which pattern, which config value -- into a queryable graph, and links them: a decision can depend on earlier decisions (auto-linked from shared files, or related by hand — see *Relate decisions*) and reference the files it touched. **Conflict Gates** can check a later change against that graph and surface any load-bearing decision it conflicts with. When **Enforcement** is enabled for an organization, prim can warn, pause, or block the edit until you reconcile the decision and retry. Capture in active repos runs independently.
|
|
13
13
|
|
|
14
14
|
Low-level capture runs automatically in active repos through the session hooks installed by `npx --yes @primitive.ai/prim claude install` (Claude Code), `npx --yes @primitive.ai/prim codex install` (Codex), or `npx --yes @primitive.ai/prim hermes install` (Hermes). An inactive repo is not passively captured. Deliberately record higher-order forks in the road through `prim decisions create` as described below. Your job is also to **read** the graph before load-bearing edits and **answer** the occasional rationale confirmation. (Responding to Conflict Gates applies only once Enforcement is enabled — see below.)
|
|
15
15
|
|
|
@@ -32,17 +32,17 @@ The CLI auto-refreshes a still-valid session from the stored refresh token (proa
|
|
|
32
32
|
3. `<idOrShortId>` arguments accept either a full decision ID or the short ID shown in feeds (and gate reasons, when Conflict Gates are enabled).
|
|
33
33
|
4. In `npx --yes @primitive.ai/prim ...`, `npx --yes` is npm's flag and only skips npm's package-install confirmation. It is not user consent for a Primitive action. Prim's global flag appears after the package name: `npx --yes @primitive.ai/prim --yes ...`.
|
|
34
34
|
|
|
35
|
-
## Conflict Gates & Enforcement
|
|
35
|
+
## Conflict Gates & Enforcement
|
|
36
36
|
|
|
37
|
-
**Conflict Gates** check each edit against the decision graph before it lands and surface any load-bearing decision it conflicts with.
|
|
37
|
+
**Conflict Gates** check each edit against the decision graph before it lands and surface any load-bearing decision it conflicts with. When **Enforcement** is enabled for an organization, a conflicting edit can be warned, blocked, or paused for confirmation until you reconcile the decision and retry. Automatic decision capture in active repos (above) runs independently.
|
|
38
38
|
|
|
39
39
|
When Enforcement is enabled, before an edit (Claude Code: Edit/Write/MultiEdit; Codex: apply_patch; Hermes: write_file/patch) a PreToolUse hook scores the target file against the graph:
|
|
40
40
|
|
|
41
41
|
- **deny** -- the edit is blocked: it conflicts with a load-bearing prior decision. Don't fight it. Read the reason line; it names the decision id. If you genuinely intend to override that decision, run `npx --yes @primitive.ai/prim reconcile dec_<shortId>`, then retry the edit once. Otherwise choose an approach that respects the decision.
|
|
42
|
-
- **warn / additional context** -- the edit proceeds, but a relevant prior decision is surfaced. Read it.
|
|
42
|
+
- **warn / additional context** -- the edit proceeds, but a relevant prior decision is surfaced. Read it. Codex cannot pause mid-tool, so a would-be `ask` arrives as a **deny** carrying the reconcile directive. Hermes also has no soft-confirm tier, so a would-be `ask` arrives as a **deny** carrying the same directive: reconcile and retry, or set `PRIM_HOOK_MODE=warn` to downgrade it to context-only.
|
|
43
43
|
- **"decision check skipped / not verified" or "... partial / truncated"** -- the check could not fully run. Treat constraints as UNKNOWN, not clear; never read silence as approval.
|
|
44
44
|
|
|
45
|
-
When enabled, the gate fail-opens on
|
|
45
|
+
When enabled, the gate sends its preflight check directly to Primitive and fail-opens on infrastructure errors (for example, a network failure or org-unbound token) -- a service problem would not block your edit. That is why an "unavailable" note matters: it is the honest signal that the check, not your edit, is what failed.
|
|
46
46
|
|
|
47
47
|
## Read the graph before large or load-bearing edits
|
|
48
48
|
|
|
@@ -56,7 +56,7 @@ Then tailor the delivery to this requester, not a generic reader. Take your cues
|
|
|
56
56
|
|
|
57
57
|
## Reconcile and the verdict footer
|
|
58
58
|
|
|
59
|
-
Reconcile and the verdict footer are part of Conflict Gates **Enforcement
|
|
59
|
+
Reconcile and the verdict footer are part of Conflict Gates **Enforcement**; the `reconcile` command stays available regardless. When Enforcement is enabled for your organization:
|
|
60
60
|
|
|
61
61
|
`npx --yes @primitive.ai/prim reconcile <idOrShortId>` mints a single-use bypass for the named decision -- it prints `[prim] reconcile bypass issued for dec_<short> (expires in ...)` to STDERR, with the bypass JSON on STDOUT. Your *next* edit to the governed file then goes through, and on that edit prim prints a verdict footer to STDERR -- confirmation the override was recorded, not silently dropped:
|
|
62
62
|
|
|
@@ -107,7 +107,7 @@ an explicit local `prim.active=false` is always inactive. Every
|
|
|
107
107
|
|
|
108
108
|
- An explicit request to record or create that Decision is approval for that one
|
|
109
109
|
invocation; do not ask redundantly.
|
|
110
|
-
- For a proactively identified Decision, present the proposed
|
|
110
|
+
- For a proactively identified Decision, present the proposed decision and supported
|
|
111
111
|
rationale — folding the single rationale question into the same prompt when none
|
|
112
112
|
was found — then wait for approval before creating it. A previous approval never
|
|
113
113
|
carries forward to another Decision.
|
|
@@ -144,25 +144,15 @@ Treat changes to shared agent instructions and standards—such as `AGENTS.md`,
|
|
|
144
144
|
|
|
145
145
|
Apply the same attention during non-code deliberation: planning or “grill me” workflows, spec refinement, behavior design, PR planning or review, and conversations imported from Granola or other connected sources. These workflows can settle important forks before any code changes. Accumulate the confirmed decisions and record them at a natural phase or task boundary; do not interrupt or call Primitive after every answer.
|
|
146
146
|
|
|
147
|
-
### Onboarding:
|
|
147
|
+
### Onboarding: seed the graph from memory, one proposal at a time
|
|
148
148
|
|
|
149
|
-
When onboarding or `prim welcome` reports `"org": "seed"`,
|
|
149
|
+
When onboarding or `prim welcome` reports `"org": "seed"`, seed the viewer's graph by proposing decisions mined from memory. There is no open goals question: the proposals are the whole seeding pass. Propose **one decision per message**, wait for the user's verdict, and only then present the next — never present the candidates as a batch.
|
|
150
150
|
|
|
151
|
-
1.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
'AGENTS.md' ':(glob)**/AGENTS.md' \
|
|
157
|
-
'CLAUDE.md' ':(glob)**/CLAUDE.md'
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
2. Read every path returned before selecting candidates. Never automatically read an untracked or gitignored memory file; use one only when the user explicitly asks to promote it to the team's Primitive graph.
|
|
161
|
-
3. Extract at most three explicit, durable positions that appear to remain in force. A candidate must state a genuine fork in the road—such as a goal, priority, principle, invariant, constraint, default, commitment, tradeoff, or exception. Do not infer decisions from code, repository structure, or unstated implications. Do not bulk-import the documents.
|
|
162
|
-
4. Run `npx --yes @primitive.ai/prim decisions recent --limit 20` and omit candidates equivalent to existing decisions. If the result is unavailable or too incomplete to rule out a duplicate confidently, do not propose the uncertain candidate; explain the limitation.
|
|
163
|
-
5. Present the remaining candidates as proposed decisions. For each one, include the proposed intent, the source path, and only rationale or alternatives explicitly supported by that source; when a source yields no rationale, ask for the why in the same approval prompt. Ask the user to approve or revise the proposals before creating them; do not silently attribute a historical repository decision to the onboarding user.
|
|
164
|
-
|
|
165
|
-
If the scan returns no matching files or no eligible candidates, say so briefly and continue with the normal open onboarding question. Never invent a proposal to fill the gap.
|
|
151
|
+
1. Gather candidates from the repo's Git-tracked shared memory and agent-instruction files (`git ls-files -- 'MEMORY.md' ':(glob)**/MEMORY.md' 'AGENTS.md' ':(glob)**/AGENTS.md' 'CLAUDE.md' ':(glob)**/CLAUDE.md'` from the repository root; read every path returned) and from your own memory and conversation context — positions the user has already *stated*; the setup guide names each agent's memory surface. Never automatically read an untracked, gitignored, or un-shared memory source; use one only when the user explicitly asks to promote it. Only their words count: never infer a position from the repo's code or history, and never invent one they didn't state.
|
|
152
|
+
2. A candidate must state an explicit, durable position that appears to remain in force — a genuine fork in the road such as a goal, priority, principle, invariant, constraint, default, commitment, tradeoff, or exception. Do not infer decisions from code, repository structure, or unstated implications, and do not bulk-import the documents. The personal-environment exclusion above applies: never propose a position that binds no teammate.
|
|
153
|
+
3. Run `npx --yes @primitive.ai/prim decisions recent --limit 20` and drop candidates equivalent to existing decisions. If the result is unavailable or too incomplete to rule out a duplicate confidently, drop the uncertain candidate; explain the limitation.
|
|
154
|
+
4. Propose the strongest candidates one at a time — **at most six in total**, fewer when the material runs out (never invent a proposal to fill the quota), stopping early if the user declines to continue. Each proposal carries the proposed decision, its source (file path or the stated context), and only rationale or alternatives explicitly supported by that source; when the source yields no rationale, fold the single rationale question into the same prompt. In this and any user-facing presentation, label the position **Decision**, never "Intent" — intent is `--intent` flag vocabulary, not user copy — and label the rejected options **Alternatives rejected**, never "alternatives considered". Make clear, at least on the first proposal, that approved decisions join the team's shared decision graph, visible to any teammates — not a private list. Ask the user to approve, revise, or reject it — do not silently attribute a historical repository position to the onboarding user — and create each approved decision (per the wording and flag rules below) before presenting the next.
|
|
155
|
+
5. After the final proposal's verdict — or immediately, when there are no viable candidates (say so briefly) — close with the standing guidance from welcome's STDOUT `seedGuidance` field: the user can tell their agent to add any decision to the Primitive decision graph at any time; otherwise, once the agent's prim hooks are active, Primitive passively captures decisions in the background while they work; and other repositories are captured only after `prim enable` is run in each. On an older CLI without that field, state those three points yourself.
|
|
166
156
|
|
|
167
157
|
Outside onboarding, before creating a proactively identified decision, read recent decisions to avoid duplicating an existing position:
|
|
168
158
|
|
|
@@ -173,22 +163,22 @@ npx --yes @primitive.ai/prim decisions recent --limit 20
|
|
|
173
163
|
If an equivalent decision is already present, do not create or suggest it again. After this duplicate check—or after the user confirms an onboarding proposal—author the decision directly. If the repo is inactive, that confirmation authorizes Prim's `--yes` only for the approved create:
|
|
174
164
|
|
|
175
165
|
```
|
|
176
|
-
npx --yes @primitive.ai/prim decisions create --intent "Adopt prosemirror-collab over Yjs" --attribution user --area data --rationale "Server-authoritative ordering" --alternatives "Yjs
|
|
166
|
+
npx --yes @primitive.ai/prim decisions create --intent "Adopt prosemirror-collab over Yjs" --attribution user --area data --rationale "Server-authoritative ordering" --alternatives "Yjs" --alternatives "Automerge" --decided "Collaborative editing state syncs through prosemirror-collab" --decided "The server is the single authority for step ordering"
|
|
177
167
|
```
|
|
178
168
|
|
|
179
169
|
Inactive-repo form after approval:
|
|
180
170
|
|
|
181
171
|
```
|
|
182
|
-
npx --yes @primitive.ai/prim --yes decisions create --intent "Adopt prosemirror-collab over Yjs" --attribution user --area data --rationale "Server-authoritative ordering" --alternatives "Yjs
|
|
172
|
+
npx --yes @primitive.ai/prim --yes decisions create --intent "Adopt prosemirror-collab over Yjs" --attribution user --area data --rationale "Server-authoritative ordering" --alternatives "Yjs" --alternatives "Automerge" --decided "Collaborative editing state syncs through prosemirror-collab" --decided "The server is the single authority for step ordering"
|
|
183
173
|
```
|
|
184
174
|
|
|
185
175
|
Both `--intent` and `--attribution` are required. Set `--attribution user` only when the person directly stated, selected, or confirmed the exact recorded choice. Set `--attribution agent` when you introduced that exact choice while pursuing a broader request. A broad task prompt, implementation permission, or assignment of responsibility does not turn your implementation choice into a user Decision. If the origin is ambiguous, ask the person to confirm the exact choice before creating it; after confirmation, use `user`. Never guess attribution.
|
|
186
176
|
|
|
187
177
|
Word `--intent` as a short sentence-case normative headline — ideally 4–12 words, no terminal period — stating the standing constraint future work must follow, not a report of the action taken: "Consume AADT and safety data from street_export, not gps_probes_osm", never "Migrate AADT and safety data managers off gps_probes_osm". A one-time-action verb (Migrate, Backfill, Publish) headlines a task execution: restate the ongoing constraint the action implies, and when none exists, do not record a decision at all. Make the intent self-contained for a reader who wasn't in the session — name the release, artifact, or system explicitly, and state what a referenced change does rather than citing a PR or ticket number, which is itself session context; "limit this release" and "keep the GitHub surface (PR 1102)" are failures. Carry one governing decision per intent — a reader of the intent alone must see which option is now in force. Expand every binding specific the choice adopts — scope boundaries, defaults, invariants, schema or API shapes, exceptions, required mechanisms — into `--decided`, and move non-binding reasoning into `--rationale`; a paragraph-length intent that inlines its constraints and reasons is a failure.
|
|
188
178
|
|
|
189
|
-
Other flags: `--kind` (change|exploration|task_execution|unclear, default change), `--rationale` (required by the recording policy above — the CLI accepts its absence, but only for the declined/unanswered fallback), `--area` (a single short lowercase noun — `auth`, `billing`, `infra`), `--decided`, `--alternatives`, `--confidence` (high|medium|low, default high), `--reversibility` (high|low, default high), and `--files` (comma-separated repo-relative paths the decision governs — these are the files Conflict Gates
|
|
179
|
+
Other flags: `--kind` (change|exploration|task_execution|unclear, default change), `--rationale` (required by the recording policy above — the CLI accepts its absence, but only for the declined/unanswered fallback), `--area` (a single short lowercase noun — `auth`, `billing`, `infra`), `--decided`, `--alternatives`, `--confidence` (high|medium|low, default high), `--reversibility` (high|low, default high), and `--files` (comma-separated repo-relative paths the decision governs — these are the files Conflict Gates check on later edits when Enforcement is enabled, in the same path form as `decisions check`). Omit `--files` for broad directions that should not immediately participate in file-based Conflict Gates. STDOUT is the created identity `{ decisionId, shortId, createdAt }`; STDERR prints `[prim] created dec_<short>.` — pass that `dec_<short>` straight into `decisions show` / `cascade` / `confirm`.
|
|
190
180
|
|
|
191
|
-
`--decided` carries the enforceable detail as standalone bullets — one per independently adopted constraint, the core choice first, with exact names and values. Each bullet states what must remain true and makes sense by itself: never narrate the session ("Chose to document the behavior" is a failure) and never pack independent rules into one semicolon-delimited entry. `--alternatives` takes one concise entry per option actually considered and rejected; omitting it when none were named is normal. Both flags
|
|
181
|
+
`--decided` carries the enforceable detail as standalone bullets — one per independently adopted constraint, the core choice first, with exact names and values. Each bullet states what must remain true and makes sense by itself: never narrate the session ("Chose to document the behavior" is a failure) and never pack independent rules into one semicolon-delimited entry. `--alternatives` takes one concise entry per option actually considered and rejected; omitting it when none were named is normal. The decision's own negation is never an alternative — restating the prohibited or displaced behavior adds nothing; list only options a source or the conversation actually names as considered. Both flags are repeatable: pass one entry per flag occurrence, verbatim — repeat `--decided` for each bullet and `--alternatives` for each option. Values are never split on commas, so write entries as natural prose; a repeated flag, not a delimiter, is what separates entries.
|
|
192
182
|
|
|
193
183
|
### Inferred decisions: finish first, then optionally ask once
|
|
194
184
|
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
// src/client.ts
|
|
2
|
-
import { createHash, randomBytes as randomBytes2 } from "crypto";
|
|
3
|
-
import {
|
|
4
|
-
chmodSync,
|
|
5
|
-
existsSync as existsSync2,
|
|
6
|
-
mkdirSync as mkdirSync2,
|
|
7
|
-
readFileSync as readFileSync2,
|
|
8
|
-
renameSync,
|
|
9
|
-
rmSync as rmSync2,
|
|
10
|
-
writeFileSync as writeFileSync2
|
|
11
|
-
} from "fs";
|
|
12
|
-
import { homedir } from "os";
|
|
13
|
-
import { dirname as dirname2, join as join2, resolve } from "path";
|
|
14
|
-
|
|
15
1
|
// src/lib/file-lock.ts
|
|
16
2
|
import { randomBytes } from "crypto";
|
|
17
3
|
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
|
|
@@ -150,6 +136,18 @@ async function withFileLock(lockDir, operation, options = {}) {
|
|
|
150
136
|
}
|
|
151
137
|
|
|
152
138
|
// src/client.ts
|
|
139
|
+
import { createHash, randomBytes as randomBytes2 } from "crypto";
|
|
140
|
+
import {
|
|
141
|
+
chmodSync,
|
|
142
|
+
existsSync as existsSync2,
|
|
143
|
+
mkdirSync as mkdirSync2,
|
|
144
|
+
readFileSync as readFileSync2,
|
|
145
|
+
renameSync,
|
|
146
|
+
rmSync as rmSync2,
|
|
147
|
+
writeFileSync as writeFileSync2
|
|
148
|
+
} from "fs";
|
|
149
|
+
import { homedir } from "os";
|
|
150
|
+
import { dirname as dirname2, join as join2, resolve } from "path";
|
|
153
151
|
var CONFIG_DIR_MODE = 448;
|
|
154
152
|
var CREDENTIAL_FILE_MODE = 384;
|
|
155
153
|
var REFRESH_THRESHOLD_MS = 6e4;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// src/lib/activation.ts
|
|
2
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
3
|
+
import { existsSync, readFileSync } from "fs";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
// src/lib/git.ts
|
|
8
|
+
import { execFileSync } from "child_process";
|
|
9
|
+
import { lstatSync, realpathSync } from "fs";
|
|
10
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "path";
|
|
11
|
+
function gitToplevel(cwd) {
|
|
12
|
+
try {
|
|
13
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
14
|
+
cwd,
|
|
15
|
+
encoding: "utf-8",
|
|
16
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
17
|
+
}).trim();
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function gitValue(cwd, args) {
|
|
23
|
+
try {
|
|
24
|
+
const value = execFileSync("git", args, {
|
|
25
|
+
cwd,
|
|
26
|
+
encoding: "utf-8",
|
|
27
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
28
|
+
}).trim();
|
|
29
|
+
return value || null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function githubRepositoryFullName(cwd) {
|
|
35
|
+
const remote = gitValue(cwd, ["config", "--get", "remote.origin.url"]);
|
|
36
|
+
if (!remote || [...remote].some((char) => char < " " || char === "\x7F")) return null;
|
|
37
|
+
const match = /^(?:git@github\.com:([^?#]+)|(?:https|ssh):\/\/(?:[^/@]+@)?github\.com(?::\d+)?\/([^?#]+))$/i.exec(
|
|
38
|
+
remote
|
|
39
|
+
);
|
|
40
|
+
const path = match?.[1] ?? match?.[2];
|
|
41
|
+
if (!path) return null;
|
|
42
|
+
const parts = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/");
|
|
43
|
+
if (parts.length !== 2 || parts.some((part) => !part || part !== part.trim())) return null;
|
|
44
|
+
return `${parts[0]}/${parts[1]}`;
|
|
45
|
+
}
|
|
46
|
+
function canonicalGitRoot(cwd) {
|
|
47
|
+
try {
|
|
48
|
+
const top = gitToplevel(cwd);
|
|
49
|
+
return top ? realpathSync.native(top) : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function canonicalRepositoryPath(filePath, cwd, repositoryRoot = canonicalGitRoot(cwd)) {
|
|
55
|
+
if (!repositoryRoot || !filePath || [...filePath].some((char) => char < " " || char === "\x7F" || "~*?[]{}$`\\".includes(char)))
|
|
56
|
+
return null;
|
|
57
|
+
const absolute = resolve(cwd, filePath);
|
|
58
|
+
let target;
|
|
59
|
+
try {
|
|
60
|
+
const stat = lstatSync(absolute);
|
|
61
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return null;
|
|
62
|
+
target = realpathSync.native(absolute);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (!(error instanceof Error) || error.code !== "ENOENT") {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const parent = realpathSync.native(dirname(absolute));
|
|
69
|
+
if (!lstatSync(parent).isDirectory()) return null;
|
|
70
|
+
target = resolve(parent, basename(absolute));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const rel = relative(repositoryRoot, target);
|
|
76
|
+
if (!rel || isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return sep === "/" ? rel : rel.split(sep).join("/");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/lib/activation.ts
|
|
83
|
+
var PRIM_ACTIVE_KEY = "prim.active";
|
|
84
|
+
var PRIM_REPO_SYNC_ID_KEY = "prim.repoSyncId";
|
|
85
|
+
var PROJECT_INSTALL_FILES = [".claude/settings.json", ".codex/hooks.json"];
|
|
86
|
+
var PRIM_HOOK_BINS = ["prim-hook", "prim-pre-tool-use", "prim-post-tool-use"];
|
|
87
|
+
function repoActiveFlag(cwd) {
|
|
88
|
+
try {
|
|
89
|
+
const value = execFileSync2("git", ["config", "--get", PRIM_ACTIVE_KEY], {
|
|
90
|
+
cwd,
|
|
91
|
+
encoding: "utf-8",
|
|
92
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
93
|
+
}).trim();
|
|
94
|
+
if (value === "true") return "true";
|
|
95
|
+
if (value === "false") return "false";
|
|
96
|
+
return void 0;
|
|
97
|
+
} catch {
|
|
98
|
+
return void 0;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function isRepoActive(cwd) {
|
|
102
|
+
return repoActiveFlag(cwd) === "true";
|
|
103
|
+
}
|
|
104
|
+
function setRepoActive(cwd, active) {
|
|
105
|
+
execFileSync2("git", ["config", "--local", PRIM_ACTIVE_KEY, active ? "true" : "false"], {
|
|
106
|
+
cwd,
|
|
107
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function repoSyncId(cwd) {
|
|
111
|
+
try {
|
|
112
|
+
return execFileSync2("git", ["config", "--local", "--get", PRIM_REPO_SYNC_ID_KEY], {
|
|
113
|
+
cwd,
|
|
114
|
+
encoding: "utf-8",
|
|
115
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
116
|
+
}).trim() || void 0;
|
|
117
|
+
} catch {
|
|
118
|
+
return void 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function setRepoSyncId(cwd, value) {
|
|
122
|
+
if (!value || value.includes("\0") || value.includes("\n")) {
|
|
123
|
+
throw new Error("invalid repository binding returned by server");
|
|
124
|
+
}
|
|
125
|
+
execFileSync2("git", ["config", "--local", PRIM_REPO_SYNC_ID_KEY, value], {
|
|
126
|
+
cwd,
|
|
127
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function hasProjectPrimInstall(cwd) {
|
|
131
|
+
const root = gitToplevel(cwd);
|
|
132
|
+
if (root === null || root === homedir()) return false;
|
|
133
|
+
for (const rel of PROJECT_INSTALL_FILES) {
|
|
134
|
+
const path = join(root, rel);
|
|
135
|
+
if (existsSync(path)) {
|
|
136
|
+
try {
|
|
137
|
+
const content = readFileSync(path, "utf-8");
|
|
138
|
+
if (PRIM_HOOK_BINS.some((bin) => content.includes(bin))) return true;
|
|
139
|
+
} catch {
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
function isRepoActiveForCapture(cwd) {
|
|
146
|
+
const flag = repoActiveFlag(cwd);
|
|
147
|
+
if (flag === "true") return true;
|
|
148
|
+
if (flag === "false") return false;
|
|
149
|
+
return hasProjectPrimInstall(cwd);
|
|
150
|
+
}
|
|
151
|
+
function decisionIngestionStatus(cwd) {
|
|
152
|
+
return isRepoActiveForCapture(cwd) ? "enabled" : "disabled";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export {
|
|
156
|
+
gitToplevel,
|
|
157
|
+
githubRepositoryFullName,
|
|
158
|
+
canonicalGitRoot,
|
|
159
|
+
canonicalRepositoryPath,
|
|
160
|
+
isRepoActive,
|
|
161
|
+
setRepoActive,
|
|
162
|
+
repoSyncId,
|
|
163
|
+
setRepoSyncId,
|
|
164
|
+
isRepoActiveForCapture,
|
|
165
|
+
decisionIngestionStatus
|
|
166
|
+
};
|
|
@@ -1,40 +1,38 @@
|
|
|
1
1
|
// src/hooks/prim-hook-core.ts
|
|
2
|
-
import { randomUUID } from "crypto";
|
|
2
|
+
import { createHash, randomUUID } from "crypto";
|
|
3
3
|
import { platform } from "os";
|
|
4
4
|
|
|
5
5
|
// src/protocol/move.ts
|
|
6
6
|
var LEGACY_ENVELOPE_VERSION = 1;
|
|
7
|
-
var AGENT_ENVELOPE_VERSION =
|
|
7
|
+
var AGENT_ENVELOPE_VERSION = 3;
|
|
8
8
|
var ENVELOPE_VERSION = LEGACY_ENVELOPE_VERSION;
|
|
9
|
-
function withAgentProvenance(move, producer, workspaceId) {
|
|
10
|
-
return {
|
|
11
|
-
...move,
|
|
12
|
-
env: { ...move.env, workspaceId },
|
|
13
|
-
envelopeVersion: AGENT_ENVELOPE_VERSION,
|
|
14
|
-
producer
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
9
|
|
|
18
10
|
// src/hooks/prim-hook-core.ts
|
|
19
|
-
function
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
function postToolInvocationId(parsed, agent) {
|
|
12
|
+
if (parsed.hook_event_name !== "PostToolUse") return void 0;
|
|
13
|
+
const raw = agent === "hermes" ? parsed.extra?.tool_call_id : parsed.tool_use_id;
|
|
14
|
+
return typeof raw === "string" && raw.length > 0 ? raw : void 0;
|
|
15
|
+
}
|
|
16
|
+
function toMove(parsed, cliVersion, agent = "claude_code", workspaceId, invocationId) {
|
|
17
|
+
const sessionId = parsed.session_id ?? "";
|
|
18
|
+
const eventType = parsed.hook_event_name ?? "unknown";
|
|
19
|
+
const moveId = eventType === "PostToolUse" && sessionId && invocationId ? `posttool:v1:${createHash("sha256").update(JSON.stringify([agent, sessionId, eventType, invocationId])).digest("hex")}` : randomUUID();
|
|
20
|
+
return {
|
|
21
|
+
moveId,
|
|
22
22
|
capturedAt: Date.now(),
|
|
23
|
-
sessionId
|
|
24
|
-
eventType
|
|
23
|
+
sessionId,
|
|
24
|
+
eventType,
|
|
25
25
|
payload: parsed,
|
|
26
26
|
env: {
|
|
27
27
|
cwd: parsed.cwd ?? process.cwd(),
|
|
28
28
|
cliVersion,
|
|
29
|
-
osPlatform: platform()
|
|
29
|
+
osPlatform: platform(),
|
|
30
|
+
...workspaceId ? { workspaceId } : {}
|
|
30
31
|
},
|
|
31
|
-
envelopeVersion:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// keeping the Claude wire shape byte-identical.
|
|
35
|
-
...agent === "claude_code" ? {} : { producer: agent }
|
|
32
|
+
envelopeVersion: AGENT_ENVELOPE_VERSION,
|
|
33
|
+
producer: agent,
|
|
34
|
+
...invocationId ? { invocationId } : {}
|
|
36
35
|
};
|
|
37
|
-
return workspaceId ? withAgentProvenance(move, agent, workspaceId) : move;
|
|
38
36
|
}
|
|
39
37
|
function toCommitMove(commit, cliVersion, cwd) {
|
|
40
38
|
return {
|
|
@@ -62,6 +60,7 @@ function shouldFlushAfter(eventType) {
|
|
|
62
60
|
}
|
|
63
61
|
|
|
64
62
|
export {
|
|
63
|
+
postToolInvocationId,
|
|
65
64
|
toMove,
|
|
66
65
|
toCommitMove,
|
|
67
66
|
shouldFlushAfter
|
|
@@ -10,8 +10,6 @@ import { fileURLToPath } from "url";
|
|
|
10
10
|
var PKG_NAME = "@primitive.ai/prim";
|
|
11
11
|
var ROOT_WALK_LIMIT = 6;
|
|
12
12
|
var NPX_FALLBACK = `npx --yes -p ${PKG_NAME}@latest`;
|
|
13
|
-
var BIN_CACHE_DIR_SH = "${XDG_CACHE_HOME:-$HOME/.cache}/prim/bin";
|
|
14
|
-
var BIN_CACHE_TTL_MIN_DEFAULT = 1440;
|
|
15
13
|
var resolvedRoot;
|
|
16
14
|
function locateRoot() {
|
|
17
15
|
if (resolvedRoot !== void 0) {
|
|
@@ -24,7 +22,7 @@ function locateRoot() {
|
|
|
24
22
|
try {
|
|
25
23
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
26
24
|
if (manifest.name === PKG_NAME && manifest.bin) {
|
|
27
|
-
resolvedRoot = { dir, bin: manifest.bin };
|
|
25
|
+
resolvedRoot = { dir, version: manifest.version, bin: manifest.bin };
|
|
28
26
|
return resolvedRoot;
|
|
29
27
|
}
|
|
30
28
|
} catch {
|
|
@@ -47,18 +45,23 @@ function binFile(bin) {
|
|
|
47
45
|
}
|
|
48
46
|
return isAbsolute(rel) ? rel : join(root.dir, rel);
|
|
49
47
|
}
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
48
|
+
function packageVersion() {
|
|
49
|
+
return locateRoot()?.version ?? null;
|
|
50
|
+
}
|
|
51
|
+
function shellQuote(value) {
|
|
52
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
53
|
+
}
|
|
54
|
+
function pinnedHookCommand(bin, args = "") {
|
|
55
|
+
const version = packageVersion();
|
|
56
|
+
if (!version) throw new Error("cannot determine Primitive package version");
|
|
57
|
+
const suffix = args ? ` ${args}` : "";
|
|
58
|
+
const fallback = `npx --yes -p ${PKG_NAME}@${version} ${bin}${suffix}`;
|
|
59
|
+
const file = binFile(bin);
|
|
60
|
+
if (!file) return fallback;
|
|
61
|
+
return `if [ -x ${shellQuote(process.execPath)} ] && [ -f ${shellQuote(file)} ]; then ${shellQuote(process.execPath)} ${shellQuote(file)}${suffix}; else ${fallback}; fi`;
|
|
59
62
|
}
|
|
60
63
|
function detachedHookShimCommand(bin, args = "") {
|
|
61
|
-
return `payload=$(cat); { trap '' HUP; export npm_config_fetch_retries=2 npm_config_fetch_retry_mintimeout=10000 npm_config_fetch_retry_maxtimeout=10000 npm_config_fetch_timeout=60000; printf '%s' "$payload" | { ${
|
|
64
|
+
return `payload=$(cat); { trap '' HUP; export npm_config_fetch_retries=2 npm_config_fetch_retry_mintimeout=10000 npm_config_fetch_retry_maxtimeout=10000 npm_config_fetch_timeout=60000; printf '%s' "$payload" | { ${pinnedHookCommand(bin, args)}; }; } </dev/null >/dev/null 2>&1 &`;
|
|
62
65
|
}
|
|
63
66
|
function commandMatchesBin(command, bin) {
|
|
64
67
|
if (!command) {
|
|
@@ -68,7 +71,8 @@ function commandMatchesBin(command, bin) {
|
|
|
68
71
|
if (c === bin || c.startsWith(`${bin} `)) {
|
|
69
72
|
return true;
|
|
70
73
|
}
|
|
71
|
-
|
|
74
|
+
const exactBin = new RegExp(`(?:^|\\s)${bin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s|;|$)`);
|
|
75
|
+
return c.includes(`command -v ${bin} `) || c.includes(`-p ${PKG_NAME}@`) && exactBin.test(c);
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
// src/lib/bin-cache.ts
|
|
@@ -106,7 +110,8 @@ function warmBinCache() {
|
|
|
106
110
|
|
|
107
111
|
export {
|
|
108
112
|
binFile,
|
|
109
|
-
|
|
113
|
+
packageVersion,
|
|
114
|
+
pinnedHookCommand,
|
|
110
115
|
detachedHookShimCommand,
|
|
111
116
|
commandMatchesBin,
|
|
112
117
|
warmBinCache
|