@primitive.ai/prim 0.1.0-alpha.17 → 0.1.0-alpha.19

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: prim
3
- description: Use the prim CLI for managing Primitive specs, contexts, projects, and pre-commit hooks. TRIGGER when the user mentions Primitive, prim, "specs" (in the Primitive sense), or "contexts" (in the Primitive sense); when the repo's package.json depends on @primitive.ai/prim; when the user asks to sync, map, update, or auto-map a spec; when configuring Primitive pre-commit hooks. SKIP when "spec" means test specs (vitest, jest, rspec), when "context" means React context or LLM context window, or for unrelated CLIs.
3
+ description: Use the prim CLI for Primitive specs, contexts, projects, pre-commit hooks, and the decision graph (passive decision capture, the conflict gate, reconcile, and team presence). TRIGGER when the user mentions Primitive, prim, "specs" or "contexts" (in the Primitive sense), or decisions / the decision graph / a conflict gate / reconcile; when the repo's package.json depends on @primitive.ai/prim; when the user asks to sync, map, update, or auto-map a spec; when an edit is denied or warned by a prior decision; when configuring Primitive hooks. SKIP when "spec" means test specs (vitest, jest, rspec), when "context" means React context or an LLM context window, or for unrelated CLIs.
4
4
  ---
5
5
 
6
6
  # Working with the prim CLI
@@ -35,6 +35,36 @@ The CLI auto-refreshes expired tokens. On unrecoverable expiry it throws `Authen
35
35
  2. Every command accepts `--help`. When unsure of flags, run `npx --yes @primitive.ai/prim <cmd> --help` rather than guessing.
36
36
  3. The CLI prints API errors as one-liners to stderr and exits non-zero. Treat any non-zero exit as actionable. If a command fails with an unrecognized error, re-run with `--help` to check your flags. If auth-related, re-check `auth status`.
37
37
 
38
+ ## Working with the decision graph
39
+
40
+ Separate from specs, prim passively captures the decisions you make during a coding session -- which library, which pattern, which config value -- into a queryable decision graph, and actively **gates** edits that would conflict with a load-bearing prior decision. Capture and the gate run automatically through the session hooks installed by `npx --yes @primitive.ai/prim claude install` (Claude Code) or `npx --yes @primitive.ai/prim codex install` (Codex). You never invoke capture; you *respond* to the gate and *read* the graph.
41
+
42
+ ### Heed the conflict gate
43
+ Before an edit (Claude Code: Edit/Write/MultiEdit; Codex: apply_patch) a PreToolUse hook scores the target file against the graph:
44
+
45
+ - **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.
46
+ - **warn / additional context** -- the edit proceeds, but a relevant prior decision is surfaced. Read it. On Codex a would-be `ask` is delivered as allow-plus-context (Codex can't pause mid-tool), so that context is your only signal -- read it before continuing.
47
+ - **"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.
48
+
49
+ The gate fail-opens on its *own* infrastructure errors (no daemon, network blip, org-unbound token) -- a setup problem never blocks your edit. That is exactly why an "unavailable" note matters: it is the honest signal that the check, not your edit, is what failed.
50
+
51
+ ### Read the graph before large or load-bearing edits
52
+ - `npx --yes @primitive.ai/prim decisions check --files "src/a.ts,src/b.ts"` -- which active decisions reference the files you're about to touch (comma-separated paths, one `--files` value). Run it before a big change.
53
+ - `npx --yes @primitive.ai/prim decisions recent` -- the team's recent decisions, each row badged by author and agent (`Your Claude Code` / `Your Codex`); `--limit <n>` and `--since <dur>` narrow it.
54
+ - `npx --yes @primitive.ai/prim decisions show <idOrShortId>` and `npx --yes @primitive.ai/prim decisions cascade <idOrShortId>` -- full detail, and the downstream blast radius a change would disturb.
55
+
56
+ ### Reconcile and the verdict footer
57
+ `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:
58
+
59
+ ```
60
+ ✓ Conflict caught before merge · N decisions saved · <author>'s intent preserved
61
+ ```
62
+
63
+ `N` is the reconciled decision's downstream live-dependent count, shown as `N+` when the server caps it.
64
+
65
+ ### Presence
66
+ With the daemon running (`npx --yes @primitive.ai/prim daemon start`), `npx --yes @primitive.ai/prim daemon status` includes the live online count in its STDOUT JSON (when presence is fresh); Claude Code surfaces it in the statusline as `team: N online`. Your captured decisions are attributed to your agent automatically -- no flag required.
67
+
38
68
  ## Common workflows
39
69
 
40
70
  ### Read a spec's current text (do this before any partial edit)
@@ -1,34 +1,3 @@
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
1
  // src/hooks/redact.ts
33
2
  import { existsSync, readFileSync } from "fs";
34
3
  import { join } from "path";
@@ -109,7 +78,5 @@ function scrubFromCwd(value, cwd) {
109
78
  }
110
79
 
111
80
  export {
112
- toMove,
113
- shouldFlushAfter,
114
81
  scrubFromCwd
115
82
  };
@@ -0,0 +1,57 @@
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 toCommitMove(commit, cliVersion, cwd) {
29
+ return {
30
+ moveId: `commit:${commit.sha}`,
31
+ capturedAt: Date.now(),
32
+ sessionId: "",
33
+ eventType: "git.commit",
34
+ payload: {
35
+ kind: "git.commit",
36
+ sha: commit.sha,
37
+ parentSha: commit.parentSha,
38
+ branch: commit.branch,
39
+ files: commit.files
40
+ },
41
+ env: {
42
+ cwd,
43
+ cliVersion,
44
+ osPlatform: platform()
45
+ },
46
+ envelopeVersion: ENVELOPE_VERSION
47
+ };
48
+ }
49
+ function shouldFlushAfter(eventType) {
50
+ return eventType === "SessionEnd";
51
+ }
52
+
53
+ export {
54
+ toMove,
55
+ toCommitMove,
56
+ shouldFlushAfter
57
+ };
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ appendMove,
4
+ resolveOrg
5
+ } from "../chunk-JZGWQDM5.js";
6
+ import {
7
+ toCommitMove
8
+ } from "../chunk-7GHOFNJ2.js";
9
+
10
+ // src/hooks/post-commit.ts
11
+ import { execSync, spawn } from "child_process";
12
+ import { readFileSync } from "fs";
13
+ import { dirname, join } from "path";
14
+ import { fileURLToPath } from "url";
15
+ var here = dirname(fileURLToPath(import.meta.url));
16
+ function git(args) {
17
+ try {
18
+ return execSync(`git ${args}`, {
19
+ encoding: "utf-8",
20
+ stdio: ["ignore", "pipe", "ignore"]
21
+ }).trim();
22
+ } catch {
23
+ return;
24
+ }
25
+ }
26
+ function readCommit() {
27
+ const sha = git("rev-parse HEAD");
28
+ if (!sha) {
29
+ return null;
30
+ }
31
+ const branch = git("rev-parse --abbrev-ref HEAD");
32
+ const files = (git("diff-tree --no-commit-id --name-only -r -m --root HEAD") ?? "").split("\n").filter((f) => f.length > 0);
33
+ return {
34
+ sha,
35
+ parentSha: git("rev-parse --verify --quiet HEAD^") || void 0,
36
+ branch: branch && branch !== "HEAD" ? branch : void 0,
37
+ files
38
+ };
39
+ }
40
+ function resolveCliVersion() {
41
+ try {
42
+ const pkg = JSON.parse(readFileSync(join(here, "..", "..", "package.json"), "utf-8"));
43
+ return pkg.version ?? "unknown";
44
+ } catch {
45
+ return "unknown";
46
+ }
47
+ }
48
+ function spawnBackgroundFlush() {
49
+ const entry = join(here, "..", "index.js");
50
+ spawn(process.execPath, [entry, "moves", "flush"], {
51
+ detached: true,
52
+ stdio: "ignore"
53
+ }).unref();
54
+ }
55
+ try {
56
+ const commit = readCommit();
57
+ if (commit) {
58
+ const cwd = git("rev-parse --show-toplevel") ?? process.cwd();
59
+ const move = toCommitMove(commit, resolveCliVersion(), cwd);
60
+ const { orgId } = resolveOrg({ sessionId: "", cwd });
61
+ appendMove(move, orgId);
62
+ spawnBackgroundFlush();
63
+ }
64
+ } catch (err) {
65
+ if (process.env.PRIM_HOOK_DEBUG) {
66
+ const detail = err instanceof Error ? err.message : String(err);
67
+ process.stderr.write(`[prim-post-commit] capture failed: ${detail}
68
+ `);
69
+ }
70
+ }
71
+ process.exit(0);
@@ -7,9 +7,11 @@ import {
7
7
  getClient
8
8
  } from "../chunk-6SIEWWUL.js";
9
9
  import {
10
- scrubFromCwd,
10
+ scrubFromCwd
11
+ } from "../chunk-6LAQVM26.js";
12
+ import {
11
13
  toMove
12
- } from "../chunk-LCC66K45.js";
14
+ } from "../chunk-7GHOFNJ2.js";
13
15
  import {
14
16
  parseAgent
15
17
  } from "../chunk-7YRBACIE.js";
@@ -4,10 +4,12 @@ import {
4
4
  resolveOrg
5
5
  } from "../chunk-JZGWQDM5.js";
6
6
  import {
7
- scrubFromCwd,
7
+ scrubFromCwd
8
+ } from "../chunk-6LAQVM26.js";
9
+ import {
8
10
  shouldFlushAfter,
9
11
  toMove
10
- } from "../chunk-LCC66K45.js";
12
+ } from "../chunk-7GHOFNJ2.js";
11
13
  import {
12
14
  parseAgent
13
15
  } from "../chunk-7YRBACIE.js";
package/dist/index.js CHANGED
@@ -34,9 +34,9 @@ import {
34
34
  } from "./chunk-UTKQTZHL.js";
35
35
 
36
36
  // src/index.ts
37
- import { readFileSync as readFileSync10 } from "fs";
38
- import { dirname as dirname5, resolve as resolve4 } from "path";
39
- import { fileURLToPath as fileURLToPath3 } from "url";
37
+ import { readFileSync as readFileSync11 } from "fs";
38
+ import { dirname as dirname6, resolve as resolve4 } from "path";
39
+ import { fileURLToPath as fileURLToPath4 } from "url";
40
40
  import { Command } from "commander";
41
41
  import updateNotifier from "update-notifier";
42
42
 
@@ -125,7 +125,8 @@ function registerAuthCommands(program2) {
125
125
  res.end("<h1>Authentication successful!</h1><p>You can close this tab.</p>");
126
126
  exchangeCode(siteUrl, code, verifier, `http://${LOCALHOST}:${port}/callback`).then((token) => {
127
127
  saveToken(token);
128
- console.log(`Authenticated! Token saved to ${TOKEN_FILE_PATH}`);
128
+ console.error(`Authenticated! Token saved to ${TOKEN_FILE_PATH}`);
129
+ console.log(JSON.stringify({ authenticated: true, tokenFile: TOKEN_FILE_PATH }));
129
130
  server.close();
130
131
  process.exit(0);
131
132
  }).catch((err) => {
@@ -151,12 +152,12 @@ function registerAuthCommands(program2) {
151
152
  authUrl.searchParams.set("state", state);
152
153
  authUrl.searchParams.set("code_challenge", challenge);
153
154
  authUrl.searchParams.set("code_challenge_method", "S256");
154
- console.log("Opening browser for authentication...");
155
+ console.error("Opening browser for authentication...");
155
156
  openBrowser(authUrl.toString());
156
- console.log(`If the browser doesn't open, visit:
157
+ console.error(`If the browser doesn't open, visit:
157
158
  ${authUrl.toString()}
158
159
  `);
159
- console.log("Waiting for callback...");
160
+ console.error("Waiting for callback...");
160
161
  setTimeout(() => {
161
162
  console.error("Authentication timed out.");
162
163
  server.close();
@@ -281,33 +282,94 @@ async function exchangeCode(siteUrl, code, codeVerifier, redirectUri) {
281
282
  // src/commands/claude-install.ts
282
283
  import {
283
284
  closeSync,
284
- existsSync as existsSync2,
285
+ existsSync as existsSync3,
285
286
  fsyncSync,
286
287
  mkdirSync as mkdirSync2,
287
288
  openSync,
288
- readFileSync as readFileSync2,
289
+ readFileSync as readFileSync3,
289
290
  renameSync,
290
291
  writeFileSync as writeFileSync2
291
292
  } from "fs";
292
293
  import { homedir } from "os";
293
- import { dirname as dirname2, join } from "path";
294
- var CAPTURE_COMMAND = "prim-hook";
295
- var GATE_COMMAND = "prim-pre-tool-use";
296
- var POST_TOOL_USE_COMMAND = "prim-post-tool-use";
297
- var SESSION_START_COMMAND = "prim-session-start";
298
- var SESSION_END_COMMAND = "prim-session-end";
299
- var STATUSLINE_COMMAND = "prim statusline";
294
+ import { dirname as dirname3, join as join2 } from "path";
295
+
296
+ // src/lib/bin-path.ts
297
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
298
+ import { dirname as dirname2, isAbsolute, join } from "path";
299
+ import { fileURLToPath } from "url";
300
+ var PKG_NAME = "@primitive.ai/prim";
301
+ var ROOT_WALK_LIMIT = 6;
302
+ var NPX_FALLBACK = `npx --yes -p ${PKG_NAME}@latest`;
303
+ var resolvedRoot;
304
+ function locateRoot() {
305
+ if (resolvedRoot !== void 0) {
306
+ return resolvedRoot;
307
+ }
308
+ let dir = dirname2(fileURLToPath(import.meta.url));
309
+ for (let depth = 0; depth < ROOT_WALK_LIMIT; depth++) {
310
+ const manifestPath = join(dir, "package.json");
311
+ if (existsSync2(manifestPath)) {
312
+ try {
313
+ const manifest = JSON.parse(readFileSync2(manifestPath, "utf-8"));
314
+ if (manifest.name === PKG_NAME && manifest.bin) {
315
+ resolvedRoot = { dir, bin: manifest.bin };
316
+ return resolvedRoot;
317
+ }
318
+ } catch {
319
+ }
320
+ }
321
+ const parent = dirname2(dir);
322
+ if (parent === dir) {
323
+ break;
324
+ }
325
+ dir = parent;
326
+ }
327
+ resolvedRoot = null;
328
+ return resolvedRoot;
329
+ }
330
+ function binFile(bin) {
331
+ const root = locateRoot();
332
+ const rel = root?.bin[bin];
333
+ if (!root || !rel) {
334
+ return null;
335
+ }
336
+ return isAbsolute(rel) ? rel : join(root.dir, rel);
337
+ }
338
+ function hookShimCommand(bin, args = "") {
339
+ const invoke = (cmd) => args ? `${cmd} ${args}` : cmd;
340
+ return `if command -v ${bin} >/dev/null 2>&1; then ${invoke(bin)}; elif [ -f "./node_modules/.bin/${bin}" ]; then ${invoke(`./node_modules/.bin/${bin}`)}; else ${invoke(`${NPX_FALLBACK} ${bin}`)}; fi`;
341
+ }
342
+ function commandMatchesBin(command, bin) {
343
+ if (!command) {
344
+ return false;
345
+ }
346
+ const c = command.trim();
347
+ if (c === bin || c.startsWith(`${bin} `)) {
348
+ return true;
349
+ }
350
+ return c.includes(`command -v ${bin} `);
351
+ }
352
+
353
+ // src/commands/claude-install.ts
354
+ var CAPTURE_BIN = "prim-hook";
355
+ var GATE_BIN = "prim-pre-tool-use";
356
+ var POST_TOOL_USE_BIN = "prim-post-tool-use";
357
+ var SESSION_START_BIN = "prim-session-start";
358
+ var SESSION_END_BIN = "prim-session-end";
359
+ var STATUSLINE_BIN = "prim";
360
+ var STATUSLINE_ARGS = "statusline";
361
+ var STATUSLINE_COMMAND = hookShimCommand(STATUSLINE_BIN, STATUSLINE_ARGS);
300
362
  var STATUSLINE_REFRESH_SECONDS = 5;
301
- var PRIM_COMMANDS = /* @__PURE__ */ new Set([
302
- CAPTURE_COMMAND,
303
- GATE_COMMAND,
304
- POST_TOOL_USE_COMMAND,
305
- SESSION_START_COMMAND,
306
- SESSION_END_COMMAND
307
- ]);
363
+ var PRIM_BINS = [
364
+ CAPTURE_BIN,
365
+ GATE_BIN,
366
+ POST_TOOL_USE_BIN,
367
+ SESSION_START_BIN,
368
+ SESSION_END_BIN
369
+ ];
308
370
  var JSON_INDENT = 2;
309
- var USER_SCOPE_PATH = join(homedir(), ".claude", "settings.json");
310
- var PROJECT_SCOPE_PATH = join(process.cwd(), ".claude", "settings.json");
371
+ var USER_SCOPE_PATH = join2(homedir(), ".claude", "settings.json");
372
+ var PROJECT_SCOPE_PATH = join2(process.cwd(), ".claude", "settings.json");
311
373
  var CAPTURE_EVENTS = [
312
374
  "SessionStart",
313
375
  "UserPromptSubmit",
@@ -317,21 +379,24 @@ var CAPTURE_EVENTS = [
317
379
  "SessionEnd",
318
380
  "SubagentStop"
319
381
  ];
382
+ function makeRegistration(event, matcher, bin, args = "") {
383
+ return { event, matcher, bin, command: hookShimCommand(bin, args) };
384
+ }
320
385
  var REGISTRATIONS = [
321
- ...CAPTURE_EVENTS.map((event) => ({ event, matcher: "*", command: CAPTURE_COMMAND })),
322
- { event: "PreToolUse", matcher: "Edit|Write|MultiEdit", command: GATE_COMMAND },
323
- { event: "PostToolUse", matcher: "Edit|Write|MultiEdit", command: POST_TOOL_USE_COMMAND },
324
- { event: "SessionStart", matcher: "*", command: SESSION_START_COMMAND },
325
- { event: "SessionEnd", matcher: "*", command: SESSION_END_COMMAND }
386
+ ...CAPTURE_EVENTS.map((event) => makeRegistration(event, "*", CAPTURE_BIN)),
387
+ makeRegistration("PreToolUse", "Edit|Write|MultiEdit", GATE_BIN),
388
+ makeRegistration("PostToolUse", "Edit|Write|MultiEdit", POST_TOOL_USE_BIN),
389
+ makeRegistration("SessionStart", "*", SESSION_START_BIN),
390
+ makeRegistration("SessionEnd", "*", SESSION_END_BIN)
326
391
  ];
327
392
  function settingsPathFor(scope) {
328
393
  return scope === "user" ? USER_SCOPE_PATH : PROJECT_SCOPE_PATH;
329
394
  }
330
395
  function readSettings(path) {
331
- if (!existsSync2(path)) {
396
+ if (!existsSync3(path)) {
332
397
  return {};
333
398
  }
334
- const raw = readFileSync2(path, "utf-8");
399
+ const raw = readFileSync3(path, "utf-8");
335
400
  try {
336
401
  return JSON.parse(raw);
337
402
  } catch (err) {
@@ -339,16 +404,16 @@ function readSettings(path) {
339
404
  throw new Error(`${path} is not valid JSON: ${detail}`);
340
405
  }
341
406
  }
342
- function entryHasCommand(entry, command) {
343
- return entry.hooks?.some((h) => h.command === command) ?? false;
407
+ function entryHasCommand(entry, bin) {
408
+ return entry.hooks?.some((h) => commandMatchesBin(h.command, bin)) ?? false;
344
409
  }
345
410
  function canonicalEntry(reg) {
346
411
  return { matcher: reg.matcher, hooks: [{ type: "command", command: reg.command }] };
347
412
  }
348
- function stripCommand(list, command) {
413
+ function stripCommand(list, bin) {
349
414
  const out = [];
350
415
  for (const e of list) {
351
- const hooks = (e.hooks ?? []).filter((h) => h.command !== command);
416
+ const hooks = (e.hooks ?? []).filter((h) => !commandMatchesBin(h.command, bin));
352
417
  if (hooks.length > 0) {
353
418
  out.push({ ...e, hooks });
354
419
  }
@@ -362,11 +427,16 @@ function ensureRegistration(list, reg, force) {
362
427
  if (hasCanonical && !force) {
363
428
  return list;
364
429
  }
365
- return [...stripCommand(list, reg.command), canonicalEntry(reg)];
430
+ return [...stripCommand(list, reg.bin), canonicalEntry(reg)];
366
431
  }
432
+ var LEGACY_STATUSLINE_COMMAND = "prim statusline";
367
433
  function isPrimStatusLine(settings) {
368
434
  const s = settings.statusLine;
369
- return s?.type === "command" && s?.command === STATUSLINE_COMMAND;
435
+ if (s?.type !== "command") {
436
+ return false;
437
+ }
438
+ const c = (s.command ?? "").trim();
439
+ return c === LEGACY_STATUSLINE_COMMAND || c.includes("@primitive.ai/prim") && c.includes("statusline");
370
440
  }
371
441
  function applyStatusLine(settings) {
372
442
  if (settings.statusLine && !isPrimStatusLine(settings)) {
@@ -393,8 +463,8 @@ function applyUninstall(settings) {
393
463
  const hooks = {};
394
464
  for (const event of Object.keys(source)) {
395
465
  let list = source[event] ?? [];
396
- for (const command of PRIM_COMMANDS) {
397
- list = stripCommand(list, command);
466
+ for (const bin of PRIM_BINS) {
467
+ list = stripCommand(list, bin);
398
468
  }
399
469
  if (list.length > 0) {
400
470
  hooks[event] = list;
@@ -408,18 +478,18 @@ function applyUninstall(settings) {
408
478
  }
409
479
  function captureInstalled(settings) {
410
480
  return CAPTURE_EVENTS.some(
411
- (event) => (settings.hooks?.[event] ?? []).some((e) => entryHasCommand(e, CAPTURE_COMMAND))
481
+ (event) => (settings.hooks?.[event] ?? []).some((e) => entryHasCommand(e, CAPTURE_BIN))
412
482
  );
413
483
  }
414
484
  function statuslineInstalled(settings) {
415
485
  return isPrimStatusLine(settings);
416
486
  }
417
487
  function isGateInstalled(settings) {
418
- return (settings.hooks?.PreToolUse ?? []).some((e) => entryHasCommand(e, GATE_COMMAND));
488
+ return (settings.hooks?.PreToolUse ?? []).some((e) => entryHasCommand(e, GATE_BIN));
419
489
  }
420
490
  function atomicWrite(path, content) {
421
- const dir = dirname2(path);
422
- if (!existsSync2(dir)) {
491
+ const dir = dirname3(path);
492
+ if (!existsSync3(dir)) {
423
493
  mkdirSync2(dir, { recursive: true });
424
494
  }
425
495
  const tmp = `${path}.tmp.${String(Date.now())}`;
@@ -535,11 +605,12 @@ ${line("project", result.project)}`);
535
605
 
536
606
  // src/commands/codex-install.ts
537
607
  import { homedir as homedir2 } from "os";
538
- import { join as join2 } from "path";
539
- var CAPTURE_COMMAND2 = "prim-hook --agent codex";
540
- var GATE_COMMAND2 = "prim-pre-tool-use --agent codex";
541
- var POST_TOOL_USE_COMMAND2 = "prim-post-tool-use --agent codex";
542
- var SESSION_START_COMMAND2 = "prim-session-start --agent codex";
608
+ import { join as join3 } from "path";
609
+ var CAPTURE_BIN2 = "prim-hook";
610
+ var GATE_BIN2 = "prim-pre-tool-use";
611
+ var POST_TOOL_USE_BIN2 = "prim-post-tool-use";
612
+ var SESSION_START_BIN2 = "prim-session-start";
613
+ var CODEX_ARGS = "--agent codex";
543
614
  var JSON_INDENT2 = 2;
544
615
  var CODEX_CAPTURE_EVENTS = [
545
616
  "SessionStart",
@@ -549,20 +620,15 @@ var CODEX_CAPTURE_EVENTS = [
549
620
  "Stop",
550
621
  "SubagentStop"
551
622
  ];
552
- var PRIM_COMMANDS2 = /* @__PURE__ */ new Set([
553
- CAPTURE_COMMAND2,
554
- GATE_COMMAND2,
555
- POST_TOOL_USE_COMMAND2,
556
- SESSION_START_COMMAND2
557
- ]);
623
+ var PRIM_BINS2 = [CAPTURE_BIN2, GATE_BIN2, POST_TOOL_USE_BIN2, SESSION_START_BIN2];
558
624
  var CODEX_REGISTRATIONS = [
559
- ...CODEX_CAPTURE_EVENTS.map((event) => ({ event, matcher: "*", command: CAPTURE_COMMAND2 })),
560
- { event: "PreToolUse", matcher: "apply_patch", command: GATE_COMMAND2 },
561
- { event: "PostToolUse", matcher: "apply_patch", command: POST_TOOL_USE_COMMAND2 },
562
- { event: "SessionStart", matcher: "*", command: SESSION_START_COMMAND2 }
625
+ ...CODEX_CAPTURE_EVENTS.map((event) => makeRegistration(event, "*", CAPTURE_BIN2, CODEX_ARGS)),
626
+ makeRegistration("PreToolUse", "apply_patch", GATE_BIN2, CODEX_ARGS),
627
+ makeRegistration("PostToolUse", "apply_patch", POST_TOOL_USE_BIN2, CODEX_ARGS),
628
+ makeRegistration("SessionStart", "*", SESSION_START_BIN2, CODEX_ARGS)
563
629
  ];
564
- var USER_SCOPE_PATH2 = join2(homedir2(), ".codex", "hooks.json");
565
- var PROJECT_SCOPE_PATH2 = join2(process.cwd(), ".codex", "hooks.json");
630
+ var USER_SCOPE_PATH2 = join3(homedir2(), ".codex", "hooks.json");
631
+ var PROJECT_SCOPE_PATH2 = join3(process.cwd(), ".codex", "hooks.json");
566
632
  function settingsPathFor2(scope) {
567
633
  return scope === "user" ? USER_SCOPE_PATH2 : PROJECT_SCOPE_PATH2;
568
634
  }
@@ -578,8 +644,8 @@ function applyUninstall2(settings) {
578
644
  const hooks = {};
579
645
  for (const event of Object.keys(source)) {
580
646
  let list = source[event] ?? [];
581
- for (const command of PRIM_COMMANDS2) {
582
- list = stripCommand(list, command);
647
+ for (const bin of PRIM_BINS2) {
648
+ list = stripCommand(list, bin);
583
649
  }
584
650
  if (list.length > 0) {
585
651
  hooks[event] = list;
@@ -589,11 +655,11 @@ function applyUninstall2(settings) {
589
655
  }
590
656
  function captureInstalled2(settings) {
591
657
  return CODEX_CAPTURE_EVENTS.some(
592
- (event) => (settings.hooks?.[event] ?? []).some((e) => entryHasCommand(e, CAPTURE_COMMAND2))
658
+ (event) => (settings.hooks?.[event] ?? []).some((e) => entryHasCommand(e, CAPTURE_BIN2))
593
659
  );
594
660
  }
595
661
  function isGateInstalled2(settings) {
596
- return (settings.hooks?.PreToolUse ?? []).some((e) => entryHasCommand(e, GATE_COMMAND2));
662
+ return (settings.hooks?.PreToolUse ?? []).some((e) => entryHasCommand(e, GATE_BIN2));
597
663
  }
598
664
  function resultFor(scope, path, after, changed) {
599
665
  return {
@@ -682,7 +748,7 @@ ${line("project", result.project)}`);
682
748
  }
683
749
 
684
750
  // src/commands/context.ts
685
- import { readFileSync as readFileSync3 } from "fs";
751
+ import { readFileSync as readFileSync4 } from "fs";
686
752
  function registerContextCommands(program2) {
687
753
  const context = program2.command("context").description("Manage contexts");
688
754
  context.command("list").description("List contexts").option("-s, --scope <scope>", "Filter by scope: project, global, external").option("-t, --project-id <projectId>", "List contexts linked to a specific project").option("--json", "Output as JSON").action(async (opts) => {
@@ -711,7 +777,7 @@ function registerContextCommands(program2) {
711
777
  const client = getClient();
712
778
  let text = opts.text;
713
779
  if (opts.file) {
714
- text = readFileSync3(opts.file, "utf-8");
780
+ text = readFileSync4(opts.file, "utf-8");
715
781
  }
716
782
  const taskIds = opts.projectId ? opts.projectId.split(",").map((id) => id.trim()) : void 0;
717
783
  const result = await client.post("/api/cli/contexts", {
@@ -734,7 +800,7 @@ function registerContextCommands(program2) {
734
800
  const client = getClient();
735
801
  let text = opts.text;
736
802
  if (opts.file) {
737
- text = readFileSync3(opts.file, "utf-8");
803
+ text = readFileSync4(opts.file, "utf-8");
738
804
  }
739
805
  await client.patch(`/api/cli/contexts/${contextId}`, {
740
806
  name: opts.name,
@@ -800,22 +866,26 @@ ${contexts.length} context(s)`);
800
866
 
801
867
  // src/commands/daemon.ts
802
868
  import { spawn } from "child_process";
803
- import { existsSync as existsSync3, readFileSync as readFileSync4, unlinkSync } from "fs";
869
+ import { existsSync as existsSync4, readFileSync as readFileSync5, unlinkSync } from "fs";
804
870
  import { homedir as homedir3 } from "os";
805
- import { join as join3 } from "path";
871
+ import { join as join4 } from "path";
806
872
  var DAEMON_BIN = "prim-daemon-server";
807
- var PID_PATH = join3(homedir3(), ".config", "prim", "daemon.pid");
808
- var SOCK_PATH = join3(homedir3(), ".config", "prim", "sock");
873
+ var PID_PATH = join4(homedir3(), ".config", "prim", "daemon.pid");
874
+ var SOCK_PATH = join4(homedir3(), ".config", "prim", "sock");
809
875
  var STOP_TIMEOUT_MS = 5e3;
810
876
  var STOP_POLL_MS = 100;
811
877
  var STATUS_PROBE_TIMEOUT_MS = 500;
812
- var POST_START_WAIT_MS = 400;
878
+ var READY_TIMEOUT_MS = 5e3;
879
+ var READY_POLL_MS = 100;
880
+ var READY_PROBE_TIMEOUT_MS = 250;
881
+ var EXIT_OK = 0;
813
882
  var EXIT_NOT_RUNNING = 2;
883
+ var EXIT_BOOTING = 3;
814
884
  function readPidfile() {
815
- if (!existsSync3(PID_PATH)) {
885
+ if (!existsSync4(PID_PATH)) {
816
886
  return null;
817
887
  }
818
- const raw = readFileSync4(PID_PATH, "utf-8").trim();
888
+ const raw = readFileSync5(PID_PATH, "utf-8").trim();
819
889
  const pid = Number(raw);
820
890
  if (!Number.isInteger(pid) || pid <= 0) {
821
891
  return null;
@@ -846,6 +916,20 @@ function sleep(ms) {
846
916
  timer.unref();
847
917
  });
848
918
  }
919
+ function spawnDaemon(options) {
920
+ const file = binFile(DAEMON_BIN);
921
+ return file ? spawn(process.execPath, [file], options) : spawn(DAEMON_BIN, [], options);
922
+ }
923
+ async function waitForReady() {
924
+ const deadline = Date.now() + READY_TIMEOUT_MS;
925
+ while (Date.now() < deadline) {
926
+ if (await daemonIsLive(READY_PROBE_TIMEOUT_MS)) {
927
+ return true;
928
+ }
929
+ await sleep(READY_POLL_MS);
930
+ }
931
+ return daemonIsLive(READY_PROBE_TIMEOUT_MS);
932
+ }
849
933
  async function daemonStart(opts) {
850
934
  const existing = readPidfile();
851
935
  if (existing?.alive) {
@@ -858,29 +942,32 @@ async function daemonStart(opts) {
858
942
  clearStaleArtifacts();
859
943
  }
860
944
  if (opts.foreground) {
861
- const child2 = spawn(DAEMON_BIN, [], { stdio: "inherit" });
945
+ const child2 = spawnDaemon({ stdio: "inherit" });
862
946
  child2.on("exit", (code) => {
863
947
  process.exit(code ?? 0);
864
948
  });
865
949
  return;
866
950
  }
867
- const child = spawn(DAEMON_BIN, [], {
868
- detached: true,
869
- stdio: ["ignore", "ignore", "ignore"]
870
- });
951
+ const child = spawnDaemon({ detached: true, stdio: ["ignore", "ignore", "ignore"] });
871
952
  child.unref();
872
- await sleep(POST_START_WAIT_MS);
873
- const after = readPidfile();
874
- if (after?.alive) {
875
- process.stderr.write(`[prim] daemon started (pid=${after.pid}, socket=${SOCK_PATH})
876
- `);
877
- console.log(JSON.stringify({ started: true, pid: after.pid }, null, 2));
953
+ const live = await waitForReady();
954
+ if (live) {
955
+ const after = readPidfile();
956
+ process.stderr.write(
957
+ `[prim] \u2713 daemon started (pid=${after?.pid ?? "?"}, socket=${SOCK_PATH})
958
+ `
959
+ );
960
+ console.log(JSON.stringify({ started: true, pid: after?.pid }, null, 2));
878
961
  return;
879
962
  }
880
963
  process.stderr.write(
881
- "[prim] daemon start: bin spawned but no pidfile observed (check that `prim-daemon-server` is on PATH)\n"
964
+ `[prim] \u2717 daemon start: spawned but the socket did not respond within ${READY_TIMEOUT_MS}ms (check that \`${DAEMON_BIN}\` resolves, and see its log)
965
+ `
882
966
  );
883
967
  console.log(JSON.stringify({ started: false }, null, 2));
968
+ if (!process.exitCode) {
969
+ process.exitCode = EXIT_NOT_RUNNING;
970
+ }
884
971
  }
885
972
  async function daemonStop() {
886
973
  const existing = readPidfile();
@@ -922,41 +1009,48 @@ async function daemonStop() {
922
1009
  );
923
1010
  console.log(JSON.stringify({ stopped: false, pid: existing.pid }, null, 2));
924
1011
  }
925
- async function daemonStatus() {
926
- const pid = readPidfile();
927
- if (!pid?.alive) {
928
- process.stderr.write("[prim] \u2717 daemon down\n");
929
- console.log(JSON.stringify({ running: false }, null, 2));
930
- if (!process.exitCode) {
931
- process.exitCode = EXIT_NOT_RUNNING;
932
- }
933
- return;
1012
+ function classifyStatus(pidAlive, responding, snapshot, pid) {
1013
+ if (!pidAlive) {
1014
+ return { json: { running: false }, exitCode: EXIT_NOT_RUNNING };
934
1015
  }
935
- const live = await daemonIsLive(STATUS_PROBE_TIMEOUT_MS);
936
- if (!live) {
937
- process.stderr.write(`[prim] \u2717 daemon pid=${pid.pid} alive but socket not responding
938
- `);
939
- console.log(JSON.stringify({ running: true, responding: false, pid: pid.pid }, null, 2));
940
- if (!process.exitCode) {
941
- process.exitCode = EXIT_NOT_RUNNING;
942
- }
943
- return;
1016
+ if (!responding) {
1017
+ return {
1018
+ json: { running: true, responding: false, state: "starting", pid },
1019
+ exitCode: EXIT_BOOTING
1020
+ };
944
1021
  }
945
- const snapshot = await daemonRequest(
1022
+ if (!snapshot) {
1023
+ return { json: { running: true, responding: true }, exitCode: EXIT_OK };
1024
+ }
1025
+ return { json: { running: true, responding: true, ...snapshot }, exitCode: EXIT_OK };
1026
+ }
1027
+ async function daemonStatus() {
1028
+ const pid = readPidfile();
1029
+ const pidAlive = pid?.alive ?? false;
1030
+ const responding = pidAlive ? await daemonIsLive(STATUS_PROBE_TIMEOUT_MS) : false;
1031
+ const snapshot = responding ? await daemonRequest(
946
1032
  "status_snapshot",
947
1033
  {},
948
1034
  { timeoutMs: STATUS_PROBE_TIMEOUT_MS }
949
- );
950
- if (!snapshot) {
1035
+ ) : null;
1036
+ const { json, exitCode } = classifyStatus(pidAlive, responding, snapshot, pid?.pid);
1037
+ if (!pidAlive) {
1038
+ process.stderr.write("[prim] \u2717 daemon down\n");
1039
+ } else if (!responding) {
1040
+ process.stderr.write(`[prim] \u25CC daemon pid=${pid?.pid} starting (socket not responding yet)
1041
+ `);
1042
+ } else if (!snapshot) {
951
1043
  process.stderr.write("[prim] \u2713 daemon live (no snapshot)\n");
952
- console.log(JSON.stringify({ running: true, responding: true }, null, 2));
953
- return;
954
- }
955
- process.stderr.write(
956
- `[prim] \u2713 daemon live \xB7 pid=${snapshot.pid} \xB7 uptime=${Math.round(snapshot.uptimeMs / 1e3)}s \xB7 session=${snapshot.sessionId}
1044
+ } else {
1045
+ process.stderr.write(
1046
+ `[prim] \u2713 daemon live \xB7 pid=${snapshot.pid} \xB7 uptime=${Math.round(snapshot.uptimeMs / 1e3)}s \xB7 session=${snapshot.sessionId}
957
1047
  `
958
- );
959
- console.log(JSON.stringify({ running: true, responding: true, ...snapshot }, null, 2));
1048
+ );
1049
+ }
1050
+ console.log(JSON.stringify(json, null, 2));
1051
+ if (exitCode !== EXIT_OK && !process.exitCode) {
1052
+ process.exitCode = exitCode;
1053
+ }
960
1054
  }
961
1055
  async function daemonRestart(opts) {
962
1056
  await daemonStop();
@@ -1574,33 +1668,42 @@ function registerDecisionsCommands(program2) {
1574
1668
 
1575
1669
  // src/commands/hooks.ts
1576
1670
  import { execSync } from "child_process";
1577
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync5, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
1671
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync6, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
1578
1672
  import { resolve } from "path";
1579
1673
  import { Option } from "commander";
1580
- var HOOK_SCRIPT = `#!/bin/sh
1581
- # prim pre-commit hook \u2014 auto-syncs affected specs on commit
1582
- # Installed by: prim hooks install
1583
-
1584
- # Find the nearest node_modules/.bin with prim, or use npx
1585
- if command -v prim-pre-commit >/dev/null 2>&1; then
1586
- prim-pre-commit
1587
- elif [ -f "./node_modules/.bin/prim-pre-commit" ]; then
1588
- ./node_modules/.bin/prim-pre-commit
1674
+ var PRE_COMMIT = { hookName: "pre-commit", binName: "prim-pre-commit" };
1675
+ var POST_COMMIT = { hookName: "post-commit", binName: "prim-post-commit" };
1676
+ var HOOKS = [PRE_COMMIT, POST_COMMIT];
1677
+ function blockMarkers(spec) {
1678
+ return {
1679
+ start: `# >>> prim ${spec.hookName} hook >>>`,
1680
+ end: `# <<< prim ${spec.hookName} hook <<<`
1681
+ };
1682
+ }
1683
+ var PRIM_BLOCK_START = blockMarkers(PRE_COMMIT).start;
1684
+ var PRIM_BLOCK_END = blockMarkers(PRE_COMMIT).end;
1685
+ function hookShim(binName) {
1686
+ return `if command -v ${binName} >/dev/null 2>&1; then
1687
+ ${binName}
1688
+ elif [ -f "./node_modules/.bin/${binName}" ]; then
1689
+ ./node_modules/.bin/${binName}
1589
1690
  else
1590
- npx --yes -p @primitive.ai/prim prim-pre-commit 2>/dev/null || true
1591
- fi
1691
+ npx --yes -p @primitive.ai/prim ${binName} 2>/dev/null || true
1692
+ fi`;
1693
+ }
1694
+ function dotGitScript(spec) {
1695
+ return `#!/bin/sh
1696
+ # prim ${spec.hookName} hook \u2014 installed by: prim hooks install
1697
+
1698
+ ${hookShim(spec.binName)}
1592
1699
  `;
1593
- var PRIM_BLOCK_START = "# >>> prim pre-commit hook >>>";
1594
- var PRIM_BLOCK_END = "# <<< prim pre-commit hook <<<";
1595
- var PRIM_HUSKY_BLOCK = `${PRIM_BLOCK_START}
1596
- if command -v prim-pre-commit >/dev/null 2>&1; then
1597
- prim-pre-commit
1598
- elif [ -f "./node_modules/.bin/prim-pre-commit" ]; then
1599
- ./node_modules/.bin/prim-pre-commit
1600
- else
1601
- npx --yes -p @primitive.ai/prim prim-pre-commit 2>/dev/null || true
1602
- fi
1603
- ${PRIM_BLOCK_END}`;
1700
+ }
1701
+ function huskyBlock(spec) {
1702
+ const { start, end } = blockMarkers(spec);
1703
+ return `${start}
1704
+ ${hookShim(spec.binName)}
1705
+ ${end}`;
1706
+ }
1604
1707
  function getGitRoot() {
1605
1708
  return execSync("git rev-parse --show-toplevel", {
1606
1709
  encoding: "utf-8"
@@ -1608,13 +1711,13 @@ function getGitRoot() {
1608
1711
  }
1609
1712
  function detectHusky(gitRoot) {
1610
1713
  const huskyDir = resolve(gitRoot, ".husky");
1611
- if (!existsSync4(huskyDir)) return false;
1612
- if (existsSync4(resolve(huskyDir, "_"))) return true;
1613
- if (existsSync4(resolve(huskyDir, "pre-commit"))) return true;
1714
+ if (!existsSync5(huskyDir)) return false;
1715
+ if (existsSync5(resolve(huskyDir, "_"))) return true;
1716
+ if (existsSync5(resolve(huskyDir, "pre-commit"))) return true;
1614
1717
  const pkgPath = resolve(gitRoot, "package.json");
1615
- if (existsSync4(pkgPath)) {
1718
+ if (existsSync5(pkgPath)) {
1616
1719
  try {
1617
- const pkg2 = JSON.parse(readFileSync5(pkgPath, "utf-8"));
1720
+ const pkg2 = JSON.parse(readFileSync6(pkgPath, "utf-8"));
1618
1721
  const scripts = pkg2.scripts ?? {};
1619
1722
  if (/husky/i.test(scripts.prepare ?? "") || /husky/i.test(scripts.postinstall ?? "")) {
1620
1723
  return true;
@@ -1624,8 +1727,8 @@ function detectHusky(gitRoot) {
1624
1727
  }
1625
1728
  return false;
1626
1729
  }
1627
- function containsPrimHook(content) {
1628
- return content.includes("prim-pre-commit");
1730
+ function containsPrimHook(content, binName = PRE_COMMIT.binName) {
1731
+ return content.includes(binName);
1629
1732
  }
1630
1733
  async function askConfirmation(question) {
1631
1734
  if (!process.stdin.isTTY) return false;
@@ -1639,52 +1742,63 @@ async function askConfirmation(question) {
1639
1742
  rl.close();
1640
1743
  }
1641
1744
  }
1642
- function installToHusky(gitRoot) {
1643
- const hookPath = resolve(gitRoot, ".husky", "pre-commit");
1644
- if (existsSync4(hookPath)) {
1645
- const existing = readFileSync5(hookPath, "utf-8");
1646
- if (containsPrimHook(existing)) {
1647
- console.log("Prim pre-commit hook is already installed in .husky/pre-commit.");
1745
+ function installToHusky(gitRoot, spec = PRE_COMMIT) {
1746
+ const hookPath = resolve(gitRoot, ".husky", spec.hookName);
1747
+ if (existsSync5(hookPath)) {
1748
+ const existing = readFileSync6(hookPath, "utf-8");
1749
+ if (containsPrimHook(existing, spec.binName)) {
1750
+ console.log(`Prim ${spec.hookName} hook is already installed in .husky/${spec.hookName}.`);
1648
1751
  return;
1649
1752
  }
1650
1753
  const separator = existing.endsWith("\n") ? "\n" : "\n\n";
1651
- writeFileSync3(hookPath, `${existing}${separator}${PRIM_HUSKY_BLOCK}
1754
+ writeFileSync3(hookPath, `${existing}${separator}${huskyBlock(spec)}
1652
1755
  `, {
1653
1756
  mode: 493
1654
1757
  });
1655
- console.log("Appended prim hook block to .husky/pre-commit.");
1758
+ console.log(`Appended prim hook block to .husky/${spec.hookName}.`);
1656
1759
  } else {
1657
1760
  writeFileSync3(hookPath, `#!/bin/sh
1658
1761
 
1659
- ${PRIM_HUSKY_BLOCK}
1762
+ ${huskyBlock(spec)}
1660
1763
  `, {
1661
1764
  mode: 493
1662
1765
  });
1663
- console.log("Created .husky/pre-commit with prim hook block.");
1766
+ console.log(`Created .husky/${spec.hookName} with prim hook block.`);
1664
1767
  }
1665
1768
  }
1666
- function installToDotGit(gitRoot) {
1769
+ function installToDotGit(gitRoot, spec = PRE_COMMIT) {
1667
1770
  const hooksDir = resolve(gitRoot, ".git", "hooks");
1668
- const hookPath = resolve(hooksDir, "pre-commit");
1669
- if (!existsSync4(hooksDir)) {
1771
+ const hookPath = resolve(hooksDir, spec.hookName);
1772
+ if (!existsSync5(hooksDir)) {
1670
1773
  mkdirSync3(hooksDir, { recursive: true });
1671
1774
  }
1672
- if (existsSync4(hookPath)) {
1673
- const existing = readFileSync5(hookPath, "utf-8");
1674
- if (containsPrimHook(existing)) {
1675
- console.log("Prim pre-commit hook is already installed at .git/hooks/pre-commit.");
1775
+ if (existsSync5(hookPath)) {
1776
+ const existing = readFileSync6(hookPath, "utf-8");
1777
+ if (containsPrimHook(existing, spec.binName)) {
1778
+ console.log(`Prim ${spec.hookName} hook is already installed at ${hookPath}.`);
1676
1779
  return;
1677
1780
  }
1678
- console.log(`A pre-commit hook already exists at ${hookPath}.`);
1781
+ console.log(`A ${spec.hookName} hook already exists at ${hookPath}.`);
1679
1782
  console.log("To replace it, run: prim hooks uninstall && prim hooks install");
1680
1783
  return;
1681
1784
  }
1682
- writeFileSync3(hookPath, HOOK_SCRIPT, { mode: 493 });
1683
- console.log(`Installed pre-commit hook at ${hookPath}`);
1785
+ writeFileSync3(hookPath, dotGitScript(spec), { mode: 493 });
1786
+ console.log(`Installed ${spec.hookName} hook at ${hookPath}`);
1787
+ }
1788
+ function installHooks(gitRoot, target) {
1789
+ for (const spec of HOOKS) {
1790
+ if (target === "husky") {
1791
+ installToHusky(gitRoot, spec);
1792
+ } else {
1793
+ installToDotGit(gitRoot, spec);
1794
+ }
1795
+ }
1684
1796
  }
1685
1797
  function registerHooksCommands(program2) {
1686
1798
  const hooks = program2.command("hooks").description("Manage git hooks");
1687
- hooks.command("install").description("Install the prim pre-commit hook (auto-detects Husky; use --target to override)").addOption(
1799
+ hooks.command("install").description(
1800
+ "Install the prim git hooks \u2014 pre-commit + post-commit (auto-detects Husky; use --target to override)"
1801
+ ).addOption(
1688
1802
  new Option("--target <where>", "install destination; bypasses Husky detection").choices([
1689
1803
  "husky",
1690
1804
  "git-hooks"
@@ -1695,10 +1809,10 @@ function registerHooksCommands(program2) {
1695
1809
  globals.nonInteractive || process.env.CI || process.env.PRIM_NON_INTERACTIVE
1696
1810
  );
1697
1811
  const gitRoot = getGitRoot();
1698
- if (opts.target === "husky") return installToHusky(gitRoot);
1699
- if (opts.target === "git-hooks") return installToDotGit(gitRoot);
1812
+ if (opts.target === "husky") return installHooks(gitRoot, "husky");
1813
+ if (opts.target === "git-hooks") return installHooks(gitRoot, "git-hooks");
1700
1814
  if (detectHusky(gitRoot)) {
1701
- if (globals.yes) return installToHusky(gitRoot);
1815
+ if (globals.yes) return installHooks(gitRoot, "husky");
1702
1816
  if (nonInteractive) {
1703
1817
  throw new Error(
1704
1818
  "--non-interactive set, refusing to prompt for Husky-hook installation. Pass --yes to confirm or --target=git-hooks to choose."
@@ -1709,30 +1823,36 @@ function registerHooksCommands(program2) {
1709
1823
  "Note: Husky detected but stdin is not a TTY \u2014 falling back to .git/hooks. Pass --yes for Husky or --non-interactive to fail fast."
1710
1824
  );
1711
1825
  } else if (await askConfirmation(
1712
- "Husky detected. Install prim hook into .husky/pre-commit instead of .git/hooks/pre-commit?"
1826
+ "Husky detected. Install prim hooks into .husky/ instead of .git/hooks/?"
1713
1827
  )) {
1714
- return installToHusky(gitRoot);
1828
+ return installHooks(gitRoot, "husky");
1715
1829
  } else {
1716
- console.log("Falling back to .git/hooks/pre-commit install.");
1830
+ console.log("Falling back to .git/hooks install.");
1717
1831
  }
1718
1832
  }
1719
- installToDotGit(gitRoot);
1833
+ installHooks(gitRoot, "git-hooks");
1720
1834
  });
1721
- hooks.command("uninstall").description("Remove the prim pre-commit hook").action(() => {
1835
+ hooks.command("uninstall").description("Remove the prim git hooks (.git/hooks)").action(() => {
1722
1836
  const gitRoot = getGitRoot();
1723
- const hookPath = resolve(gitRoot, ".git", "hooks", "pre-commit");
1724
- if (!existsSync4(hookPath)) {
1725
- console.log("No pre-commit hook found.");
1726
- return;
1837
+ for (const spec of HOOKS) {
1838
+ const hookPath = resolve(gitRoot, ".git", "hooks", spec.hookName);
1839
+ if (!existsSync5(hookPath)) {
1840
+ console.log(`No ${spec.hookName} hook found.`);
1841
+ continue;
1842
+ }
1843
+ if (containsPrimHook(readFileSync6(hookPath, "utf-8"), spec.binName)) {
1844
+ unlinkSync2(hookPath);
1845
+ console.log(`Removed ${spec.hookName} hook at ${hookPath}`);
1846
+ } else {
1847
+ console.log(`Left ${spec.hookName} hook at ${hookPath} untouched (not a prim hook).`);
1848
+ }
1727
1849
  }
1728
- unlinkSync2(hookPath);
1729
- console.log(`Removed pre-commit hook at ${hookPath}`);
1730
1850
  });
1731
1851
  }
1732
1852
 
1733
1853
  // src/commands/moves.ts
1734
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, unlinkSync as unlinkSync4, writeFileSync as writeFileSync4 } from "fs";
1735
- import { join as join4 } from "path";
1854
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, unlinkSync as unlinkSync4, writeFileSync as writeFileSync4 } from "fs";
1855
+ import { join as join5 } from "path";
1736
1856
 
1737
1857
  // src/flusher.ts
1738
1858
  import { renameSync as renameSync2, unlinkSync as unlinkSync3 } from "fs";
@@ -1841,19 +1961,19 @@ function registerMovesCommands(program2) {
1841
1961
  }
1842
1962
  });
1843
1963
  moves.command("bind").description("Pin the current directory to an org via .prim/workspace.json").requiredOption("--orgId <orgId>", "Convex organization id").action((opts) => {
1844
- const dir = join4(process.cwd(), ".prim");
1845
- if (!existsSync5(dir)) {
1964
+ const dir = join5(process.cwd(), ".prim");
1965
+ if (!existsSync6(dir)) {
1846
1966
  mkdirSync4(dir, { recursive: true, mode: DIR_MODE });
1847
1967
  }
1848
- const file = join4(process.cwd(), WORKSPACE_FILE);
1968
+ const file = join5(process.cwd(), WORKSPACE_FILE);
1849
1969
  writeFileSync4(file, JSON.stringify({ orgId: opts.orgId, boundAt: Date.now() }, null, 2), {
1850
1970
  mode: FILE_MODE2
1851
1971
  });
1852
1972
  console.log(`[prim] bound ${process.cwd()} to org ${opts.orgId}`);
1853
1973
  });
1854
1974
  moves.command("drop").description("Remove the .prim/workspace.json binding from the cwd").action(() => {
1855
- const file = join4(process.cwd(), WORKSPACE_FILE);
1856
- if (!existsSync5(file)) {
1975
+ const file = join5(process.cwd(), WORKSPACE_FILE);
1976
+ if (!existsSync6(file)) {
1857
1977
  console.log("[prim] no workspace binding in cwd");
1858
1978
  return;
1859
1979
  }
@@ -1885,7 +2005,7 @@ function registerProjectCommands(program2) {
1885
2005
  }
1886
2006
 
1887
2007
  // src/commands/reconcile.ts
1888
- var EXIT_OK = 0;
2008
+ var EXIT_OK2 = 0;
1889
2009
  var EXIT_USAGE = 2;
1890
2010
  var EXIT_SERVER = 3;
1891
2011
  var HTTP_CLIENT_ERROR_MIN = 400;
@@ -1948,7 +2068,7 @@ async function performReconcile(idOrShortId, opts = {}) {
1948
2068
  `
1949
2069
  );
1950
2070
  console.log(JSON.stringify(response, null, 2));
1951
- process.exitCode = EXIT_OK;
2071
+ process.exitCode = EXIT_OK2;
1952
2072
  return;
1953
2073
  }
1954
2074
  process.stderr.write("[prim] reconcile: malformed server response\n");
@@ -1968,23 +2088,23 @@ function registerReconcileCommands(program2) {
1968
2088
 
1969
2089
  // src/commands/session.ts
1970
2090
  import {
1971
- existsSync as existsSync6,
2091
+ existsSync as existsSync7,
1972
2092
  mkdirSync as mkdirSync5,
1973
- readFileSync as readFileSync6,
2093
+ readFileSync as readFileSync7,
1974
2094
  readdirSync,
1975
2095
  unlinkSync as unlinkSync5,
1976
2096
  writeFileSync as writeFileSync5
1977
2097
  } from "fs";
1978
- import { join as join5 } from "path";
2098
+ import { join as join6 } from "path";
1979
2099
  var DIR_MODE2 = 448;
1980
2100
  var FILE_MODE3 = 384;
1981
2101
  function ensureDir() {
1982
- if (!existsSync6(SESSIONS_DIR)) {
2102
+ if (!existsSync7(SESSIONS_DIR)) {
1983
2103
  mkdirSync5(SESSIONS_DIR, { recursive: true, mode: DIR_MODE2 });
1984
2104
  }
1985
2105
  }
1986
2106
  function markerPath(sessionId) {
1987
- return join5(SESSIONS_DIR, `${sessionId}.json`);
2107
+ return join6(SESSIONS_DIR, `${sessionId}.json`);
1988
2108
  }
1989
2109
  function registerSessionCommands(program2) {
1990
2110
  const session = program2.command("session").description("Decision Event Pipeline \u2014 session binding markers");
@@ -2000,7 +2120,7 @@ function registerSessionCommands(program2) {
2000
2120
  console.log(`[prim] session ${sessionId} bound to org ${opts.orgId}`);
2001
2121
  });
2002
2122
  session.command("list").description("List active session markers").action(() => {
2003
- if (!existsSync6(SESSIONS_DIR)) {
2123
+ if (!existsSync7(SESSIONS_DIR)) {
2004
2124
  console.log("[prim] no session markers");
2005
2125
  return;
2006
2126
  }
@@ -2012,7 +2132,7 @@ function registerSessionCommands(program2) {
2012
2132
  for (const f of files) {
2013
2133
  const sessionId = f.replace(/\.json$/, "");
2014
2134
  try {
2015
- const m = JSON.parse(readFileSync6(join5(SESSIONS_DIR, f), "utf-8"));
2135
+ const m = JSON.parse(readFileSync7(join6(SESSIONS_DIR, f), "utf-8"));
2016
2136
  console.log(`${sessionId} org=${m.orgId}`);
2017
2137
  } catch {
2018
2138
  }
@@ -2020,7 +2140,7 @@ function registerSessionCommands(program2) {
2020
2140
  });
2021
2141
  session.command("drop <sessionId>").description("Remove a session marker").action((sessionId) => {
2022
2142
  const p = markerPath(sessionId);
2023
- if (!existsSync6(p)) {
2143
+ if (!existsSync7(p)) {
2024
2144
  console.log(`[prim] no marker for session ${sessionId}`);
2025
2145
  return;
2026
2146
  }
@@ -2032,17 +2152,17 @@ function registerSessionCommands(program2) {
2032
2152
  // src/commands/skill.ts
2033
2153
  import {
2034
2154
  closeSync as closeSync2,
2035
- existsSync as existsSync7,
2155
+ existsSync as existsSync8,
2036
2156
  fsyncSync as fsyncSync2,
2037
2157
  openSync as openSync2,
2038
- readFileSync as readFileSync7,
2158
+ readFileSync as readFileSync8,
2039
2159
  renameSync as renameSync3,
2040
2160
  writeFileSync as writeFileSync6
2041
2161
  } from "fs";
2042
- import { dirname as dirname3, resolve as resolve2 } from "path";
2043
- import { fileURLToPath } from "url";
2162
+ import { dirname as dirname4, resolve as resolve2 } from "path";
2163
+ import { fileURLToPath as fileURLToPath2 } from "url";
2044
2164
  import { createPatch } from "diff";
2045
- var __dirname = dirname3(fileURLToPath(import.meta.url));
2165
+ var __dirname = dirname4(fileURLToPath2(import.meta.url));
2046
2166
  var SKILL_BEGIN = "<!-- BEGIN PRIM SKILL v1 -->";
2047
2167
  var SKILL_END = "<!-- END PRIM SKILL v1 -->";
2048
2168
  var TARGET_CANDIDATES = [
@@ -2055,15 +2175,15 @@ var TARGET_CANDIDATES = [
2055
2175
  var DEFAULT_TARGET = "CLAUDE.md";
2056
2176
  function loadSkill() {
2057
2177
  let dir = __dirname;
2058
- while (dir !== dirname3(dir)) {
2178
+ while (dir !== dirname4(dir)) {
2059
2179
  const p = resolve2(dir, "SKILL.md");
2060
- if (existsSync7(p)) return readFileSync7(p, "utf-8");
2061
- dir = dirname3(dir);
2180
+ if (existsSync8(p)) return readFileSync8(p, "utf-8");
2181
+ dir = dirname4(dir);
2062
2182
  }
2063
2183
  throw new Error("SKILL.md not found in package");
2064
2184
  }
2065
2185
  function detectTargets(cwd) {
2066
- return TARGET_CANDIDATES.filter((p) => existsSync7(resolve2(cwd, p)));
2186
+ return TARGET_CANDIDATES.filter((p) => existsSync8(resolve2(cwd, p)));
2067
2187
  }
2068
2188
  function detectNewline(content) {
2069
2189
  return content.includes("\r\n") ? "\r\n" : "\n";
@@ -2112,7 +2232,7 @@ function resolveTarget(cwd, override) {
2112
2232
  function runInstall(cwd, opts) {
2113
2233
  const target = resolveTarget(cwd, opts.target);
2114
2234
  if (target === null) return 1;
2115
- const existing = existsSync7(target) ? readFileSync7(target, "utf-8") : "";
2235
+ const existing = existsSync8(target) ? readFileSync8(target, "utf-8") : "";
2116
2236
  const eol = existing ? detectNewline(existing) : "\n";
2117
2237
  const block = composeBlock(loadSkill(), eol);
2118
2238
  const next = applyBlock(existing, block, eol);
@@ -2131,11 +2251,11 @@ function runInstall(cwd, opts) {
2131
2251
  function runUninstall(cwd, opts) {
2132
2252
  const target = resolveTarget(cwd, opts.target);
2133
2253
  if (target === null) return 1;
2134
- if (!existsSync7(target)) {
2254
+ if (!existsSync8(target)) {
2135
2255
  console.log(`Skill block not present at ${target}`);
2136
2256
  return 0;
2137
2257
  }
2138
- const existing = readFileSync7(target, "utf-8");
2258
+ const existing = readFileSync8(target, "utf-8");
2139
2259
  const next = removeBlock(existing);
2140
2260
  if (next === null) {
2141
2261
  console.log(`Skill block not present at ${target}`);
@@ -2148,10 +2268,10 @@ function runUninstall(cwd, opts) {
2148
2268
  function runStatus(cwd, opts) {
2149
2269
  const target = resolveTarget(cwd, opts.target);
2150
2270
  if (target === null) return 1;
2151
- const fileExists = existsSync7(target);
2271
+ const fileExists = existsSync8(target);
2152
2272
  let installed = false;
2153
2273
  if (fileExists) {
2154
- const content = readFileSync7(target, "utf-8");
2274
+ const content = readFileSync8(target, "utf-8");
2155
2275
  installed = content.includes(SKILL_BEGIN) && content.includes(SKILL_END);
2156
2276
  }
2157
2277
  if (opts.json) {
@@ -2188,7 +2308,7 @@ function registerSkillCommands(program2) {
2188
2308
  }
2189
2309
 
2190
2310
  // src/commands/spec.ts
2191
- import { readFileSync as readFileSync8 } from "fs";
2311
+ import { readFileSync as readFileSync9 } from "fs";
2192
2312
  function registerSpecCommands(program2) {
2193
2313
  const spec = program2.command("spec").description("Manage spec documents");
2194
2314
  spec.command("list").description("List spec documents").option("-t, --project-id <projectId>", "List spec for a specific root project").option("--json", "Output as JSON").action(async (opts) => {
@@ -2242,7 +2362,7 @@ ${contexts.length} spec(s)`);
2242
2362
  const client = getClient();
2243
2363
  let text = opts.text;
2244
2364
  if (opts.file) {
2245
- text = readFileSync8(opts.file, "utf-8");
2365
+ text = readFileSync9(opts.file, "utf-8");
2246
2366
  }
2247
2367
  const taskIds = opts.projectId ? opts.projectId.split(",").map((id) => id.trim()) : void 0;
2248
2368
  let linkedBranch;
@@ -2283,7 +2403,7 @@ ${contexts.length} spec(s)`);
2283
2403
  const client = getClient();
2284
2404
  let text = opts.text;
2285
2405
  if (opts.file) {
2286
- text = readFileSync8(opts.file, "utf-8");
2406
+ text = readFileSync9(opts.file, "utf-8");
2287
2407
  }
2288
2408
  if (!(text || opts.name)) {
2289
2409
  console.error("Provide --text, --file, or --name to update.");
@@ -2454,17 +2574,17 @@ ${preview}`);
2454
2574
  }
2455
2575
 
2456
2576
  // src/commands/statusline.ts
2457
- import { readFileSync as readFileSync9 } from "fs";
2458
- import { dirname as dirname4, resolve as resolve3 } from "path";
2459
- import { fileURLToPath as fileURLToPath2 } from "url";
2577
+ import { readFileSync as readFileSync10 } from "fs";
2578
+ import { dirname as dirname5, resolve as resolve3 } from "path";
2579
+ import { fileURLToPath as fileURLToPath3 } from "url";
2460
2580
  var STATUSLINE_TIMEOUT_MS = 200;
2461
2581
  function readPackageVersion() {
2462
2582
  try {
2463
- const here = dirname4(fileURLToPath2(import.meta.url));
2583
+ const here = dirname5(fileURLToPath3(import.meta.url));
2464
2584
  const candidates = [resolve3(here, "../../package.json"), resolve3(here, "../package.json")];
2465
2585
  for (const path of candidates) {
2466
2586
  try {
2467
- const pkg2 = JSON.parse(readFileSync9(path, "utf-8"));
2587
+ const pkg2 = JSON.parse(readFileSync10(path, "utf-8"));
2468
2588
  if (pkg2.version) {
2469
2589
  return pkg2.version;
2470
2590
  }
@@ -2509,8 +2629,8 @@ function registerStatuslineCommands(program2) {
2509
2629
  }
2510
2630
 
2511
2631
  // src/index.ts
2512
- var __dirname2 = dirname5(fileURLToPath3(import.meta.url));
2513
- var pkg = JSON.parse(readFileSync10(resolve4(__dirname2, "../package.json"), "utf-8"));
2632
+ var __dirname2 = dirname6(fileURLToPath4(import.meta.url));
2633
+ var pkg = JSON.parse(readFileSync11(resolve4(__dirname2, "../package.json"), "utf-8"));
2514
2634
  updateNotifier({ pkg }).notify();
2515
2635
  var program = new Command();
2516
2636
  program.name("prim").description("CLI for managing Primitive specs and contexts").version(pkg.version).option("-y, --yes", "auto-confirm prompts").option(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitive.ai/prim",
3
- "version": "0.1.0-alpha.17",
3
+ "version": "0.1.0-alpha.19",
4
4
  "description": "CLI for managing Primitive specs, contexts, and git hooks",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  "bin": {
31
31
  "prim": "dist/index.js",
32
32
  "prim-pre-commit": "dist/hooks/pre-commit.js",
33
+ "prim-post-commit": "dist/hooks/post-commit.js",
33
34
  "prim-hook": "dist/hooks/prim-hook.js",
34
35
  "prim-pre-tool-use": "dist/hooks/pre-tool-use.js",
35
36
  "prim-post-tool-use": "dist/hooks/post-tool-use.js",
@@ -45,9 +46,9 @@
45
46
  "SKILL.md"
46
47
  ],
47
48
  "scripts": {
48
- "build": "tsup src/index.ts src/hooks/pre-commit.ts src/hooks/prim-hook.ts src/hooks/pre-tool-use.ts src/hooks/post-tool-use.ts src/hooks/session-start.ts src/hooks/session-end.ts src/daemon/server.ts --format esm --clean",
49
- "postbuild": "chmod +x ./dist/index.js ./dist/hooks/pre-commit.js ./dist/hooks/prim-hook.js ./dist/hooks/pre-tool-use.js ./dist/hooks/post-tool-use.js ./dist/hooks/session-start.js ./dist/hooks/session-end.js ./dist/daemon/server.js",
50
- "dev": "tsup src/index.ts src/hooks/pre-commit.ts src/hooks/prim-hook.ts src/hooks/pre-tool-use.ts src/hooks/post-tool-use.ts src/hooks/session-start.ts src/hooks/session-end.ts src/daemon/server.ts --format esm --watch --clean",
49
+ "build": "tsup src/index.ts src/hooks/pre-commit.ts src/hooks/post-commit.ts src/hooks/prim-hook.ts src/hooks/pre-tool-use.ts src/hooks/post-tool-use.ts src/hooks/session-start.ts src/hooks/session-end.ts src/daemon/server.ts --format esm --clean",
50
+ "postbuild": "chmod +x ./dist/index.js ./dist/hooks/pre-commit.js ./dist/hooks/post-commit.js ./dist/hooks/prim-hook.js ./dist/hooks/pre-tool-use.js ./dist/hooks/post-tool-use.js ./dist/hooks/session-start.js ./dist/hooks/session-end.js ./dist/daemon/server.js",
51
+ "dev": "tsup src/index.ts src/hooks/pre-commit.ts src/hooks/post-commit.ts src/hooks/prim-hook.ts src/hooks/pre-tool-use.ts src/hooks/post-tool-use.ts src/hooks/session-start.ts src/hooks/session-end.ts src/daemon/server.ts --format esm --watch --clean",
51
52
  "clean": "rm -rf dist coverage",
52
53
  "lint": "biome check src/",
53
54
  "format": "biome check --fix src/",