@primitive.ai/prim 0.1.0-alpha.22 → 0.1.0-alpha.23

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 CHANGED
@@ -83,16 +83,23 @@ prim daemon start # start (stop / restart / status)
83
83
  Read and respond to the decision graph.
84
84
 
85
85
  ```bash
86
- prim decisions recent # Recent decisions feed
87
- prim decisions show <id> # Drill into one decision
88
- prim decisions cascade <id> # Blast radius of a decision
89
- prim decisions check --files <…> # Active decisions referencing files (warn-only)
90
- prim decisions confirm <id> # Answer a rationale-confirmation prompt
86
+ prim decisions recent # Recent decisions feed
87
+ prim decisions show <id> # Drill into one decision
88
+ prim decisions cascade <id> # Blast radius of a decision
89
+ prim decisions check --files <…> # Active decisions referencing files (warn-only)
90
+ prim decisions confirm <id> # Answer a rationale-confirmation prompt
91
+ prim decisions create --intent <…> # Author a decision directly (flags-only)
92
+ prim decisions link <child> --on <parent> # Relate: <child> depends on <parent>
93
+ prim decisions unlink <child> --on <parent> # Remove that dependency
91
94
  ```
92
95
 
93
96
  `<id>` accepts a full decision ID or its short ID. STDOUT is machine-readable
94
97
  JSON; human-readable status goes to STDERR.
95
98
 
99
+ `link` / `unlink` curate the dependency edges the automatic linker would otherwise
100
+ own — `<child>` depends on `<parent>`. Both are idempotent and refuse any link that
101
+ would create a cycle (exit 2); an unresolved id exits 4.
102
+
96
103
  ### Reconcile
97
104
 
98
105
  ```bash
package/SKILL.md CHANGED
@@ -9,7 +9,7 @@ description: Use the prim CLI for Primitive's decision graph — passive decisio
9
9
 
10
10
  ## Mental model
11
11
 
12
- As your team codes, prim passively captures the **decisions** you make -- which library, which pattern, which config value -- into a queryable graph, and links them: a decision can depend on earlier decisions and reference the files it touched. When a later change conflicts with a load-bearing prior decision, prim **gates** the edit and surfaces the decision for review.
12
+ As your team codes, prim passively captures the **decisions** you make -- 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. When a later change conflicts with a load-bearing prior decision, prim **gates** the edit and surfaces the decision for review.
13
13
 
14
14
  You never invoke capture. It runs 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). Your job is to **respond** to the gate, **read** the graph before load-bearing edits, and **answer** the occasional rationale confirmation.
15
15
 
@@ -77,6 +77,25 @@ npx --yes @primitive.ai/prim decisions create --intent "Adopt prosemirror-collab
77
77
 
78
78
  Only `--intent` is required. Optional: `--kind` (change|exploration|task_execution|unclear, default change), `--rationale`, `--area`, `--decided`, `--alternatives` (comma-separated), `--confidence` (high|medium|low, default high), `--reversibility` (high|low, default high), and `--files` (comma-separated repo-relative paths the decision governs — pass these to make the conflict gate fire on later edits to those files, same path form as `decisions check`). STDOUT is the created identity `{ decisionId, shortId, createdAt }`; STDERR prints `[prim] created dec_<short>.` — pass that `dec_<short>` straight into `decisions show` / `cascade` / `confirm`. Author on the user's behalf only when they ask for a decision to be recorded; don't narrate your own routine edits into the graph (the hooks already do that).
79
79
 
80
+ ## Relate decisions (link / unlink)
81
+
82
+ prim links decisions automatically when their files overlap, but that heuristic misses real connections and occasionally invents wrong ones. When the user asks you to **relate two existing decisions** — "B depends on A", "these are connected", wiring up two orphans — or to **cut a wrong link**, do it by hand:
83
+
84
+ ```
85
+ npx --yes @primitive.ai/prim decisions link <child> --on <parent> # record that <child> depends on <parent>
86
+ npx --yes @primitive.ai/prim decisions unlink <child> --on <parent> # remove that dependency
87
+ ```
88
+
89
+ Direction is **`<child>` depends on `<parent>`** — the parent is the prerequisite. Read the echoed verdict to confirm you got the arrow right: `[prim] <child> now depends on <parent>.` After linking, `decisions show <child>` lists `<parent>` upstream and `decisions cascade <parent>` shows `<child>` in its downstream blast radius; after unlinking they drop. Both ids accept `dec_<short>` or a full id and may be any two decisions in your org, regardless of status.
90
+
91
+ Safe to run repeatedly:
92
+
93
+ - **Idempotent** — re-linking an existing edge (or unlinking a missing one) is a no-op that still exits 0 (`already_linked` / `not_linked`).
94
+ - **Acyclic** — a self-loop, or any link that would close a dependency cycle, is refused with exit 2 (with the offending chain when it's short enough to render); the graph stays a DAG.
95
+ - **Exit codes** (treat non-zero as actionable): `0` success or no-op; `2` a refused link (self-loop, cycle, or an ambiguous short id — retry with the full id); `4` an id that doesn't resolve. After a non-zero exit, branch on the exit code and the `[prim]` STDERR verdict, **not** on STDOUT keys: only the exit-0 outcomes carry the full `{ outcome, childId, childShortId, parentId, parentShortId }`; a refused link prints a smaller `{ outcome, … }`, and an unresolved id (exit 4) prints nothing to STDOUT.
96
+
97
+ Like authoring, relate only what the user asks for — don't invent relationships they didn't state.
98
+
80
99
  ## Presence
81
100
 
82
101
  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.
package/dist/index.js CHANGED
@@ -1228,6 +1228,9 @@ async function fetchRecent(args, deps = defaultDeps2) {
1228
1228
  RECENT_TIMEOUT_MS
1229
1229
  );
1230
1230
  const result = { decisions: res.decisions };
1231
+ if (res.viewerHasDecisions !== void 0) {
1232
+ result.viewerHasDecisions = res.viewerHasDecisions;
1233
+ }
1231
1234
  if (res.unavailable !== void 0) {
1232
1235
  result.unavailable = res.unavailable;
1233
1236
  }
@@ -1414,8 +1417,85 @@ function formatCreateJson(outcome) {
1414
1417
  return JSON.stringify(outcome, null, 2);
1415
1418
  }
1416
1419
 
1417
- // src/decisions/show.ts
1420
+ // src/decisions/link.ts
1421
+ var RELATE_TIMEOUT_MS = 1e4;
1422
+ var defaultDeps5 = { getClient };
1418
1423
  var NOT_FOUND_RE3 = /not found/i;
1424
+ var AMBIGUOUS_RE2 = /ambiguous/i;
1425
+ var CYCLE_RE = /cycle/i;
1426
+ var LinkNotFoundError = class extends Error {
1427
+ constructor(which) {
1428
+ super(`Decision not found: ${which}`);
1429
+ this.name = "LinkNotFoundError";
1430
+ }
1431
+ };
1432
+ function isRelateRejection(outcome) {
1433
+ return outcome.outcome === "self_loop" || outcome.outcome === "would_cycle" || outcome.outcome === "ambiguous";
1434
+ }
1435
+ function foldRelateError(err) {
1436
+ if (err instanceof Error) {
1437
+ if (NOT_FOUND_RE3.test(err.message)) {
1438
+ throw new LinkNotFoundError(err.message.includes("parent") ? "parent" : "child");
1439
+ }
1440
+ if (AMBIGUOUS_RE2.test(err.message)) {
1441
+ return { outcome: "ambiguous", which: err.message.includes("(parent)") ? "parent" : "child" };
1442
+ }
1443
+ if (CYCLE_RE.test(err.message)) {
1444
+ return { outcome: "would_cycle", detail: err.message };
1445
+ }
1446
+ if (/itself/i.test(err.message)) {
1447
+ return { outcome: "self_loop" };
1448
+ }
1449
+ }
1450
+ throw err;
1451
+ }
1452
+ async function relate(path, request, deps) {
1453
+ const client = deps.getClient();
1454
+ try {
1455
+ const outcome = await client.post(path, request, {
1456
+ signal: AbortSignal.timeout(RELATE_TIMEOUT_MS)
1457
+ });
1458
+ return { request, outcome };
1459
+ } catch (err) {
1460
+ return { request, outcome: foldRelateError(err) };
1461
+ }
1462
+ }
1463
+ function fetchLink(child, parent, deps = defaultDeps5) {
1464
+ return relate("/api/cli/decisions/link", { child, parent }, deps);
1465
+ }
1466
+ function fetchUnlink(child, parent, deps = defaultDeps5) {
1467
+ return relate("/api/cli/decisions/unlink", { child, parent }, deps);
1468
+ }
1469
+ function endpointRef(outcome, side) {
1470
+ return side === "child" ? renderIdentifier({ shortId: outcome.childShortId, id: outcome.childId }) : renderIdentifier({ shortId: outcome.parentShortId, id: outcome.parentId });
1471
+ }
1472
+ function formatRelateHuman(result) {
1473
+ const { request, outcome } = result;
1474
+ switch (outcome.outcome) {
1475
+ case "linked":
1476
+ return `[prim] ${endpointRef(outcome, "child")} now depends on ${endpointRef(outcome, "parent")}.`;
1477
+ case "already_linked":
1478
+ return `[prim] ${endpointRef(outcome, "child")} already depends on ${endpointRef(outcome, "parent")}; nothing to change.`;
1479
+ case "unlinked":
1480
+ return `[prim] ${endpointRef(outcome, "child")} no longer depends on ${endpointRef(outcome, "parent")}.`;
1481
+ case "not_linked":
1482
+ return `[prim] ${endpointRef(outcome, "child")} did not depend on ${endpointRef(outcome, "parent")}; nothing to change.`;
1483
+ case "self_loop":
1484
+ return "[prim] a decision cannot depend on itself.";
1485
+ case "would_cycle":
1486
+ return `[prim] refusing to link \u2014 ${outcome.detail}.`;
1487
+ default: {
1488
+ const typed = outcome.which === "parent" ? request.parent : request.child;
1489
+ return `[prim] the ${outcome.which} id "${typed}" is ambiguous in this organization \u2014 retry with the full decision id.`;
1490
+ }
1491
+ }
1492
+ }
1493
+ function formatRelateJson(result) {
1494
+ return JSON.stringify(result.outcome, null, 2);
1495
+ }
1496
+
1497
+ // src/decisions/show.ts
1498
+ var NOT_FOUND_RE4 = /not found/i;
1419
1499
  function colorStatus(status) {
1420
1500
  if (status === "under_review") {
1421
1501
  return color(status, "orange");
@@ -1426,14 +1506,14 @@ function colorStatus(status) {
1426
1506
  return color(status, "gray");
1427
1507
  }
1428
1508
  var SHOW_TIMEOUT_MS = 1e4;
1429
- var defaultDeps5 = { getClient };
1509
+ var defaultDeps6 = { getClient };
1430
1510
  var DecisionNotFoundError = class extends Error {
1431
1511
  constructor(idOrShortId) {
1432
1512
  super(`Decision not found: ${idOrShortId}`);
1433
1513
  this.name = "DecisionNotFoundError";
1434
1514
  }
1435
1515
  };
1436
- async function fetchShow(idOrShortId, deps = defaultDeps5) {
1516
+ async function fetchShow(idOrShortId, deps = defaultDeps6) {
1437
1517
  const params = new URLSearchParams({ id: idOrShortId });
1438
1518
  const client = deps.getClient();
1439
1519
  try {
@@ -1444,7 +1524,7 @@ async function fetchShow(idOrShortId, deps = defaultDeps5) {
1444
1524
  SHOW_TIMEOUT_MS
1445
1525
  );
1446
1526
  } catch (err) {
1447
- if (err instanceof Error && NOT_FOUND_RE3.test(err.message)) {
1527
+ if (err instanceof Error && NOT_FOUND_RE4.test(err.message)) {
1448
1528
  throw new DecisionNotFoundError(idOrShortId);
1449
1529
  }
1450
1530
  throw err;
@@ -1654,6 +1734,40 @@ function registerDecisionsCommands(program2) {
1654
1734
  throw err;
1655
1735
  }
1656
1736
  });
1737
+ decisions.command("link <child>").description("Record that <child> depends on <parent> (adds a dependency edge)").requiredOption("--on <parent>", "The decision <child> depends on").action(async (child, opts) => {
1738
+ try {
1739
+ const result = await fetchLink(child, opts.on);
1740
+ console.error(formatRelateHuman(result));
1741
+ console.log(formatRelateJson(result));
1742
+ if (isRelateRejection(result.outcome)) {
1743
+ process.exitCode = EXIT_USAGE;
1744
+ }
1745
+ } catch (err) {
1746
+ if (err instanceof LinkNotFoundError) {
1747
+ console.error(`[prim] ${err.message}`);
1748
+ process.exitCode = EXIT_NOT_FOUND;
1749
+ return;
1750
+ }
1751
+ throw err;
1752
+ }
1753
+ });
1754
+ decisions.command("unlink <child>").description("Remove <child>'s recorded dependency on <parent>").requiredOption("--on <parent>", "The decision <child> no longer depends on").action(async (child, opts) => {
1755
+ try {
1756
+ const result = await fetchUnlink(child, opts.on);
1757
+ console.error(formatRelateHuman(result));
1758
+ console.log(formatRelateJson(result));
1759
+ if (isRelateRejection(result.outcome)) {
1760
+ process.exitCode = EXIT_USAGE;
1761
+ }
1762
+ } catch (err) {
1763
+ if (err instanceof LinkNotFoundError) {
1764
+ console.error(`[prim] ${err.message}`);
1765
+ process.exitCode = EXIT_NOT_FOUND;
1766
+ return;
1767
+ }
1768
+ throw err;
1769
+ }
1770
+ });
1657
1771
  }
1658
1772
 
1659
1773
  // src/commands/hooks.ts
@@ -2347,14 +2461,32 @@ var REVERSE_PROMPT_LINES = [
2347
2461
  "to focus on those goals?"
2348
2462
  ];
2349
2463
  var REVERSE_PROMPT = REVERSE_PROMPT_LINES.join(" ");
2464
+ var CALLOUT_TITLE = "Your turn";
2465
+ var CALLOUT_INDENT = " ";
2466
+ function ruledQuestion(lines) {
2467
+ const prefix = `\u250C\u2500 ${CALLOUT_TITLE} `;
2468
+ const width = Math.max(
2469
+ `${prefix}\u2510`.length,
2470
+ ...lines.map((line) => CALLOUT_INDENT.length + line.length + 1)
2471
+ );
2472
+ const top = `${prefix}${"\u2500".repeat(width - prefix.length - 1)}\u2510`;
2473
+ const bottom = `\u2514${"\u2500".repeat(top.length - 2)}\u2518`;
2474
+ return [
2475
+ color(top, "green"),
2476
+ ...lines.map((line) => `${CALLOUT_INDENT}${line}`),
2477
+ color(bottom, "green")
2478
+ ];
2479
+ }
2350
2480
  function welcomeStateFromRecent(result) {
2351
2481
  if (result.unavailable !== void 0) {
2352
2482
  return { org: "unknown" };
2353
2483
  }
2354
- if (result.decisions.length === 0) {
2355
- return { org: "empty" };
2484
+ const recent = result.decisions.slice(0, RECENT_LIMIT);
2485
+ const viewerHasDecisions = result.viewerHasDecisions ?? result.decisions.length > 0;
2486
+ if (!viewerHasDecisions) {
2487
+ return { org: "seed", recent };
2356
2488
  }
2357
- return { org: "active", recent: result.decisions.slice(0, RECENT_LIMIT) };
2489
+ return { org: "active", recent };
2358
2490
  }
2359
2491
  function formatWelcome(state) {
2360
2492
  const cmd = (command, desc) => ` ${dim(command.padEnd(CMD_GUTTER))}${desc}`;
@@ -2385,14 +2517,15 @@ function formatWelcome(state) {
2385
2517
  cmd("prim decisions check --files <files>", "what governs files you're about to change"),
2386
2518
  cmd("prim --help", "everything else")
2387
2519
  ];
2388
- } else if (state.org === "empty") {
2520
+ } else if (state.org === "seed") {
2521
+ const teamContext = state.recent.length > 0 ? [bold("Recent team decisions"), ...state.recent.map(formatRecentRow), ""] : [];
2389
2522
  body = [
2523
+ ...teamContext,
2390
2524
  bold("Let's seed your decision graph"),
2391
- "Your team has no decisions recorded yet. Tell me, in your own words:",
2525
+ "You haven't recorded a decision yet \u2014 answer this and I'll record",
2526
+ "each goal as a decision:",
2392
2527
  "",
2393
- ...REVERSE_PROMPT_LINES.map((line) => ` ${line}`),
2394
- "",
2395
- "Share your answer and I'll record each goal as a decision."
2528
+ ...ruledQuestion(REVERSE_PROMPT_LINES)
2396
2529
  ];
2397
2530
  } else {
2398
2531
  body = [
@@ -2402,14 +2535,20 @@ function formatWelcome(state) {
2402
2535
  cmd("prim --help", "everything else")
2403
2536
  ];
2404
2537
  }
2405
- return [...head, ...body, "", dim("App: https://app.getprimitive.ai")].join("\n");
2538
+ const footer = state.org === "seed" ? [] : ["", dim("App: https://app.getprimitive.ai")];
2539
+ return [...head, ...body, ...footer].join("\n");
2406
2540
  }
2407
2541
  function welcomeJson(state) {
2408
2542
  if (state.org === "active") {
2409
2543
  return { welcomed: true, org: "active", recent: state.recent };
2410
2544
  }
2411
- if (state.org === "empty") {
2412
- return { welcomed: true, org: "empty", reversePrompt: REVERSE_PROMPT };
2545
+ if (state.org === "seed") {
2546
+ return {
2547
+ welcomed: true,
2548
+ org: "seed",
2549
+ reversePrompt: REVERSE_PROMPT,
2550
+ recent: state.recent
2551
+ };
2413
2552
  }
2414
2553
  return { welcomed: true, org: "unknown" };
2415
2554
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitive.ai/prim",
3
- "version": "0.1.0-alpha.22",
3
+ "version": "0.1.0-alpha.23",
4
4
  "description": "CLI for Primitive's decision graph — passive decision capture, conflict gate, and team presence",
5
5
  "type": "module",
6
6
  "license": "MIT",