@primitive.ai/prim 0.1.0-alpha.14 → 0.1.0-alpha.15

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
@@ -81,6 +81,47 @@ npx --yes @primitive.ai/prim project create -n "<name>" -d "<desc>"
81
81
  npx --yes @primitive.ai/prim project create -n "<name>" --spec <contextId> # value is a context ID
82
82
  ```
83
83
 
84
+ ### Link a spec to a branch (and an optional PR)
85
+
86
+ A branch-linked spec only auto-syncs from commits on its branch — so per-branch work doesn't mutate specs that aren't relevant. The link is the contract the pre-commit hook checks before every `sync-diff`.
87
+
88
+ Two ways to bind a spec to a branch:
89
+
90
+ ```
91
+ npx --yes @primitive.ai/prim spec create -s project -n "<name>" --file <path> --branch <branch> --pr <n> # explicit at creation; --pr is optional
92
+ ```
93
+
94
+ Or implicitly: the pre-commit hook **auto-links an unlinked spec to the current branch** the first time it sees a sync on that branch — no flag needed. The `[synced]` line on that first sync prints ` (auto-linking to <branch>)`; subsequent syncs print ` (linked to <branch> #<pr> <state>)` once the link sticks.
95
+
96
+ Inspect a spec's bindings via `npx --yes @primitive.ai/prim context get <id>`. The `linkedBranches[]` field lists every `(branch, prNumber, prState, prReviewDecision)` the spec is bound to. The editor UI surfaces the same data as a status pill.
97
+
98
+ - **`--branch` requires a GitHub origin.** With `--branch`, the CLI reads `repoFullName` from `git remote get-url origin`. If origin isn't GitHub, the link is silently dropped with a warning on stderr — the spec is still created, just unlinked — fix it later from the editor UI.
99
+ - **There is no `prim spec link` subcommand in v1.** To re-link a spec to a different branch, edit it from the spec editor. The CLI only ever auto-links on first sync or accepts `--branch` at creation.
100
+
101
+ ### Trigger PR Intent Review or dispatch drift-fix against a linked PR
102
+
103
+ ```
104
+ npx --yes @primitive.ai/prim spec review <id> --pr <n> # head SHA defaults to `git rev-parse HEAD`
105
+ npx --yes @primitive.ai/prim spec review <id> --pr <n> --sha <s> # explicit SHA
106
+ npx --yes @primitive.ai/prim spec drift <id> --pr <n> # dispatch the Claude Code drift-fix workflow against the PR
107
+ ```
108
+
109
+ The review bot runs server-side, posts a PR comment with findings, and the outcome surfaces on the **next** pre-commit sync's `[synced]` line as ` (reviewed: <n> finding(s) → <prCommentUrl>)` or ` (review failed)` — don't poll the API yourself.
110
+
111
+ `spec drift` requires the `primitive-drift-fix.yml` workflow file checked into the repo and the GitHub App's `actions:write` scope granted on the org. The CLI errors out otherwise with a one-liner naming the likely causes.
112
+
113
+ Neither `spec review` nor `spec drift` accepts `--json` in v1 — they emit a single human-readable line on stdout. Capture it as text or branch on `$?`.
114
+
115
+ ### Inspect a task's auto-completion state
116
+
117
+ ```
118
+ npx --yes @primitive.ai/prim spec status <taskId>
119
+ ```
120
+
121
+ Reports the task's `status`, whether `auto-complete suppressed: yes/no`, and the timestamp + PR # of the most-recent auto-completion activity (with the bot's explanation). Use this after a merge to verify the auto-complete bot acted — or to see *why* it didn't (suppressed via the dashboard, last activity from a different PR, etc.).
122
+
123
+ `spec status` operates on a **task ID**, not a context/spec ID. Discover task IDs from `prim project create` output or from the editor URL. No `--json` support in v1; output is a fixed `key: value` block on stdout.
124
+
84
125
  ### Install the pre-commit hook
85
126
  ```
86
127
  npx --yes @primitive.ai/prim hooks install # auto-detects Husky and prompts
@@ -106,6 +147,8 @@ What that means:
106
147
  - **The hook is not `npx --yes @primitive.ai/prim spec sync`.** `npx --yes @primitive.ai/prim spec sync` re-applies the *existing* spec to the project. The hook calls `sync-diff` -- an LLM updates the spec from the code change, then applies the new spec to the project. The casual "just commit and the hook will sync" is ambiguous; when explaining to the user, specify which operation you mean.
107
148
  - **The hook never blocks the commit.** Failures (auth, network, backend) print `[error]` to stderr but exit 0, so a successful `git commit` doesn't prove the spec changed. Check the hook's `[synced]` / `[error]` / `[skip]` output, or verify with `npx --yes @primitive.ai/prim spec get <id>`.
108
149
  - **Diffs over 256 KiB are truncated.** The hook logs `(truncated: X KiB -> Y KiB analyzed)`. The LLM only sees the first 256 KiB of the diff.
150
+ - **The hook is branch-aware.** It sends `repoFullName`, `branch`, `sha`, and `prNumber` (the last detected from `gh pr view` when `gh` is on `PATH`, silently null otherwise). The server filters mappings to specs linked to the current branch *or* unlinked (auto-link candidates); specs bound to other branches are silently excluded from the affected list — they don't surface as `[skip]` lines, they just don't appear. If you push an explicit `sync-diff` for an other-branch spec via the API, the hook logs `[skip] <id> — <name> — not linked to <branch>` and continues.
151
+ - **Link state and review results piggyback on the synced line.** `[synced]` lines carry ` (linked to <branch> #<pr> <state>)` or ` (auto-linking to <branch>)`, and once a PR Intent Review completes they grow ` (reviewed: <n> finding(s) → <prCommentUrl>)` or ` (review failed)`. PR `<state>` (`open` / `closed` / `merged`) tracks GitHub webhook deliveries — give it a few seconds to settle after a state change.
109
152
  - **To suppress the hook for one commit** (e.g., when intentionally desyncing code from spec, or when committing unrelated changes), use `git commit --no-verify`.
110
153
 
111
154
  ## Output formats
@@ -116,7 +159,7 @@ Every data-returning command accepts `--json`. With `--json` set, stdout is a si
116
159
  - `npx --yes @primitive.ai/prim spec list --json | jq -r '.[]._id'` — list every spec ID
117
160
  - `npx --yes @primitive.ai/prim auth status --json | jq -r .authenticated` — boolean; the exit code remains the authoritative signal
118
161
 
119
- Without `--json`, mutating commands (`context create/update/delete/link/unlink`, `spec update/sync/map/unmap/auto-map`, `project create`) emit the bare resource `_id` to **stdout** (one line, no prefix) and human-readable diagnostics to **stderr**. So this also works as a one-liner without `jq`:
162
+ Without `--json`, mutating commands (`context create/update/delete/link/unlink`, `spec create/update/sync/map/unmap/auto-map`, `project create`) emit the bare resource `_id` to **stdout** (one line, no prefix) and human-readable diagnostics to **stderr**. So this also works as a one-liner without `jq`:
120
163
 
121
164
  - `id=$(npx --yes @primitive.ai/prim context create -s global -n foo --text "x")`
122
165
 
@@ -139,6 +182,7 @@ Without `--json`, mutating commands (`context create/update/delete/link/unlink`,
139
182
  - **`npx --yes @primitive.ai/prim spec sync` rejects non-spec contexts** with "Context is not a spec document. Use `prim context` instead." Use `npx --yes @primitive.ai/prim spec list` to find spec IDs.
140
183
  - **`npx --yes @primitive.ai/prim context delete` is permanent.** Confirm with the user before deleting.
141
184
  - **Scope is set at creation.** To change it, delete and recreate the context.
185
+ - **The hook silently excludes specs bound to other branches.** If you don't see a spec you expected to sync, check its `linkedBranches[]` via `npx --yes @primitive.ai/prim context get <id>` — it may be bound to a different branch. To re-bind, use the spec editor (no CLI subcommand in v1).
142
186
 
143
187
  ## After each task
144
188
 
@@ -153,6 +153,34 @@ function getClient() {
153
153
  };
154
154
  }
155
155
 
156
+ // src/utils/git.ts
157
+ import { execSync } from "child_process";
158
+ function safeExec(cmd) {
159
+ try {
160
+ return execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+ function parseRepoFullName(remoteUrl) {
166
+ const match = remoteUrl.match(/(?:github\.com[:/])([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
167
+ return match ? `${match[1]}/${match[2]}` : null;
168
+ }
169
+ function getGitContext() {
170
+ const branchRaw = safeExec("git rev-parse --abbrev-ref HEAD");
171
+ const branch = branchRaw && branchRaw !== "HEAD" ? branchRaw : null;
172
+ const sha = safeExec("git rev-parse HEAD");
173
+ const remoteUrl = safeExec("git remote get-url origin");
174
+ const repoFullName = remoteUrl ? parseRepoFullName(remoteUrl) : null;
175
+ let prNumber = null;
176
+ if (safeExec("command -v gh")) {
177
+ const raw = safeExec("gh pr view --json number -q .number");
178
+ const n = raw ? Number.parseInt(raw, 10) : Number.NaN;
179
+ if (Number.isFinite(n)) prNumber = n;
180
+ }
181
+ return { branch, sha, repoFullName, prNumber };
182
+ }
183
+
156
184
  export {
157
185
  TOKEN_FILE_PATH,
158
186
  REFRESH_TOKEN_PATH,
@@ -161,5 +189,6 @@ export {
161
189
  getTokenExpiresAt,
162
190
  getAuthToken,
163
191
  getSiteUrl,
164
- getClient
192
+ getClient,
193
+ getGitContext
165
194
  };
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- getClient
4
- } from "../chunk-3APLWTLB.js";
3
+ getClient,
4
+ getGitContext
5
+ } from "../chunk-SHLF6OL2.js";
5
6
 
6
7
  // src/hooks/pre-commit.ts
7
8
  import { execSync } from "child_process";
@@ -47,7 +48,8 @@ var HOOK_TIMEOUT_MS = 1e4;
47
48
  var defaultDeps = {
48
49
  getClient,
49
50
  getStagedFiles,
50
- getStagedDiff
51
+ getStagedDiff,
52
+ getGitContext
51
53
  };
52
54
  async function syncAffectedSpecs(deps = defaultDeps) {
53
55
  const stagedFiles = deps.getStagedFiles();
@@ -55,9 +57,18 @@ async function syncAffectedSpecs(deps = defaultDeps) {
55
57
  return [];
56
58
  }
57
59
  const client = deps.getClient();
60
+ const gitCtx = deps.getGitContext();
61
+ let mappingsUrl = "/api/cli/specs/mappings";
62
+ if (gitCtx.repoFullName && gitCtx.branch) {
63
+ const params = new URLSearchParams({
64
+ repoFullName: gitCtx.repoFullName,
65
+ branch: gitCtx.branch
66
+ });
67
+ mappingsUrl = `${mappingsUrl}?${params.toString()}`;
68
+ }
58
69
  let mappings = [];
59
70
  try {
60
- mappings = await client.get("/api/cli/specs/mappings", {
71
+ mappings = await client.get(mappingsUrl, {
61
72
  signal: AbortSignal.timeout(HOOK_TIMEOUT_MS)
62
73
  });
63
74
  } catch {
@@ -66,6 +77,7 @@ async function syncAffectedSpecs(deps = defaultDeps) {
66
77
  if (mappings.length === 0) {
67
78
  return [];
68
79
  }
80
+ const specsById = new Map(mappings.map((s) => [s._id, s]));
69
81
  const affectedContexts = findAffectedContexts(stagedFiles, mappings);
70
82
  if (affectedContexts.size === 0) {
71
83
  return [];
@@ -90,20 +102,51 @@ async function syncAffectedSpecs(deps = defaultDeps) {
90
102
  console.log(` [skip] ${contextId} \u2014 no diff content`);
91
103
  continue;
92
104
  }
93
- const response = await client.post(
94
- `/api/cli/contexts/${contextId}/sync-diff`,
95
- { diffContent, affectedFiles: affected.matchedFiles },
96
- { signal: AbortSignal.timeout(HOOK_TIMEOUT_MS) }
97
- );
105
+ const body = {
106
+ diffContent,
107
+ affectedFiles: affected.matchedFiles
108
+ };
109
+ if (gitCtx.branch) body.branch = gitCtx.branch;
110
+ if (gitCtx.sha) body.sha = gitCtx.sha;
111
+ if (gitCtx.repoFullName) body.repoFullName = gitCtx.repoFullName;
112
+ if (gitCtx.prNumber !== null) body.prNumber = gitCtx.prNumber;
113
+ const response = await client.post(`/api/cli/contexts/${contextId}/sync-diff`, body, {
114
+ signal: AbortSignal.timeout(HOOK_TIMEOUT_MS)
115
+ });
98
116
  const name = ctx.name ?? "(unnamed)";
117
+ if (!response.analyzing && response.reason === "not_linked") {
118
+ console.log(
119
+ ` [skip] ${contextId} \u2014 ${name} \u2014 not linked to ${gitCtx.branch ?? "(no branch)"}`
120
+ );
121
+ continue;
122
+ }
123
+ const spec = specsById.get(contextId);
124
+ const link = spec?.linkedBranches?.find((l) => l.branch === gitCtx.branch);
125
+ let linkSuffix = "";
126
+ if (link) {
127
+ const prBits = link.prNumber ? ` #${String(link.prNumber)}${link.prState ? ` ${link.prState}` : ""}` : "";
128
+ linkSuffix = ` (linked to ${link.branch}${prBits})`;
129
+ } else if (gitCtx.branch && spec?.linkedBranches?.length === 0) {
130
+ linkSuffix = ` (auto-linking to ${gitCtx.branch})`;
131
+ }
132
+ const review = link?.latestReviewSummary;
133
+ let reviewSuffix = "";
134
+ if (review?.status === "completed") {
135
+ const n = review.findingsCount ?? 0;
136
+ const urlSuffix = review.prCommentUrl ? ` \u2192 ${review.prCommentUrl.replace(/^https?:\/\//, "")}` : "";
137
+ reviewSuffix = ` (reviewed: ${String(n)} finding${n === 1 ? "" : "s"}${urlSuffix})`;
138
+ } else if (review?.status === "failed") {
139
+ reviewSuffix = " (review failed)";
140
+ }
141
+ linkSuffix += reviewSuffix;
99
142
  if (response.truncated && response.sizeChars && response.limitChars) {
100
143
  const sizeKiB = Math.round(response.sizeChars / 1024);
101
144
  const limitKiB = Math.round(response.limitChars / 1024);
102
145
  console.log(
103
- ` [synced] ${contextId} \u2014 ${name} (truncated: ${String(sizeKiB)} KiB \u2192 ${String(limitKiB)} KiB analyzed)`
146
+ ` [synced] ${contextId} \u2014 ${name} (truncated: ${String(sizeKiB)} KiB \u2192 ${String(limitKiB)} KiB analyzed)${linkSuffix}`
104
147
  );
105
148
  } else {
106
- console.log(` [synced] ${contextId} \u2014 ${name}`);
149
+ console.log(` [synced] ${contextId} \u2014 ${name}${linkSuffix}`);
107
150
  }
108
151
  synced.push(contextId);
109
152
  } catch (error) {
package/dist/index.js CHANGED
@@ -5,10 +5,11 @@ import {
5
5
  TOKEN_FILE_PATH,
6
6
  getAuthToken,
7
7
  getClient,
8
+ getGitContext,
8
9
  getSiteUrl,
9
10
  getTokenExpiresAt,
10
11
  saveTokenExpiry
11
- } from "./chunk-3APLWTLB.js";
12
+ } from "./chunk-SHLF6OL2.js";
12
13
 
13
14
  // src/index.ts
14
15
  import { readFileSync as readFileSync6 } from "fs";
@@ -759,6 +760,47 @@ ${contexts.length} spec(s)`);
759
760
  }
760
761
  printSpec(ctx);
761
762
  });
763
+ spec.command("create").description("Create a new spec document").requiredOption("-s, --scope <scope>", "Scope: project, global, external").requiredOption("-n, --name <name>", "Spec name").option("-t, --text <text>", "Spec text content").option("-f, --file <path>", "Read text content from file").option("--project-id <projectId>", "Link to project(s), comma-separated").option("--branch <branch>", "Link spec to this branch on the current repo").option("--pr <prNumber>", "Optional PR number to attach to the link").option("--json", "Output as JSON").action(
764
+ async (opts) => {
765
+ const client = getClient();
766
+ let text = opts.text;
767
+ if (opts.file) {
768
+ text = readFileSync5(opts.file, "utf-8");
769
+ }
770
+ const taskIds = opts.projectId ? opts.projectId.split(",").map((id) => id.trim()) : void 0;
771
+ let linkedBranch;
772
+ if (opts.branch) {
773
+ const { repoFullName } = getGitContext();
774
+ if (!repoFullName) {
775
+ console.warn(
776
+ "[prim] --branch supplied but origin remote is not GitHub; skipping link."
777
+ );
778
+ } else {
779
+ linkedBranch = { repoFullName, branch: opts.branch };
780
+ if (opts.pr) {
781
+ const n = Number.parseInt(opts.pr, 10);
782
+ if (Number.isFinite(n)) linkedBranch.prNumber = n;
783
+ }
784
+ }
785
+ }
786
+ const result = await client.post("/api/cli/contexts", {
787
+ scope: opts.scope === "project" ? "task" : opts.scope,
788
+ name: opts.name,
789
+ text,
790
+ taskIds,
791
+ isSpecDocument: true,
792
+ linkedBranch
793
+ });
794
+ if (opts.json) {
795
+ printJson({ _id: result._id });
796
+ return;
797
+ }
798
+ console.error(
799
+ `Created spec: ${result._id}${linkedBranch ? ` (linked to ${linkedBranch.branch})` : ""}`
800
+ );
801
+ console.log(result._id);
802
+ }
803
+ );
762
804
  spec.command("update <contextId>").description("Update a spec document's text content").option("-t, --text <text>", "New text content").option("-f, --file <path>", "Read text content from file").option("-n, --name <name>", "New name").option("--json", "Output as JSON").action(
763
805
  async (contextId, opts) => {
764
806
  const client = getClient();
@@ -806,6 +848,65 @@ ${contexts.length} spec(s)`);
806
848
  }
807
849
  console.log(contextId);
808
850
  });
851
+ spec.command("review <contextId>").description("Manually trigger the PR Intent Review bot for a spec").requiredOption("--pr <prNumber>", "PR number to review against").option("--sha <headSha>", "Commit SHA the review runs against (defaults to current HEAD)").action(async (contextId, opts) => {
852
+ const prNumber = Number.parseInt(opts.pr, 10);
853
+ if (!Number.isFinite(prNumber)) {
854
+ console.error("--pr must be an integer.");
855
+ process.exit(1);
856
+ }
857
+ const headSha = opts.sha ?? getGitContext().sha;
858
+ if (!headSha) {
859
+ console.error("Could not determine head SHA \u2014 pass --sha or run inside a git checkout.");
860
+ process.exit(1);
861
+ }
862
+ const client = getClient();
863
+ await client.post(`/api/cli/contexts/${contextId}/review`, {
864
+ prNumber,
865
+ headSha
866
+ });
867
+ console.log(
868
+ `Scheduled review: ${contextId} against PR #${String(prNumber)} @ ${headSha.slice(0, 7)}`
869
+ );
870
+ });
871
+ spec.command("drift <contextId>").description("Dispatch the Claude Code drift-fix workflow against a PR").requiredOption("--pr <prNumber>", "PR number to dispatch the drift-fix workflow against").action(async (contextId, opts) => {
872
+ const prNumber = Number.parseInt(opts.pr, 10);
873
+ if (!Number.isFinite(prNumber)) {
874
+ console.error("--pr must be an integer.");
875
+ process.exit(1);
876
+ }
877
+ const client = getClient();
878
+ const result = await client.post(`/api/cli/contexts/${contextId}/drift`, {
879
+ prNumber
880
+ });
881
+ if (result.dispatched) {
882
+ const ref = result.runUrl ? `: ${result.runUrl}` : "";
883
+ console.log(`Dispatched drift-fix workflow${ref}`);
884
+ } else {
885
+ console.error(
886
+ "Drift-fix dispatch failed. Likely causes: actions:write App scope not granted, primitive-drift-fix.yml workflow file missing, or no findings on the latest review."
887
+ );
888
+ process.exit(1);
889
+ }
890
+ });
891
+ spec.command("status <taskId>").description(
892
+ "Show task status, auto-complete suppression flag, and the most-recent bot auto-completion"
893
+ ).action(async (taskId) => {
894
+ const client = getClient();
895
+ const result = await client.get(`/api/cli/tasks/${taskId}/status`);
896
+ console.log(`status: ${result.status}`);
897
+ console.log(`auto-complete suppressed: ${result.autoCompleteSuppressed ? "yes" : "no"}`);
898
+ const last = result.lastAutoCompleteActivity;
899
+ if (last) {
900
+ const when = last.createdAt ? new Date(last.createdAt).toISOString() : "\u2014";
901
+ const pr = last.prNumber ? `#${String(last.prNumber)}` : "\u2014";
902
+ console.log(`last auto-complete: ${when} (PR ${pr})`);
903
+ if (last.explanation) {
904
+ console.log(` ${last.explanation}`);
905
+ }
906
+ } else {
907
+ console.log("last auto-complete: \u2014");
908
+ }
909
+ });
809
910
  spec.command("map <contextId>").description("Map file patterns to a spec (used by pre-commit hook to detect affected specs)").requiredOption(
810
911
  "-p, --pattern <patterns...>",
811
912
  'Glob pattern(s) to associate, e.g. "src/auth/**"'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitive.ai/prim",
3
- "version": "0.1.0-alpha.14",
3
+ "version": "0.1.0-alpha.15",
4
4
  "description": "CLI for managing Primitive specs, contexts, and git hooks",
5
5
  "type": "module",
6
6
  "license": "MIT",