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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -83,20 +83,60 @@ npx --yes @primitive.ai/prim project create -n "<name>" --spec <contextId> #
83
83
 
84
84
  ### Link a spec to a branch (and an optional PR)
85
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`.
86
+ Linking is **automatic** once the pre-commit hook is installed and a spec is bound to your branch — every commit refreshes the link's metadata, including the PR number (detected from `gh pr view` when `gh` is on `PATH`). When a spec was authored for the work on this branch, bind it at the right moment so the hook can take over — do this without waiting for the user to ask. When no spec exists for the work, **do not bind one** — see "Decide whether a spec exists for this branch" below.
87
87
 
88
- Two ways to bind a spec to a branch:
88
+ **At the start of branch work, install the hook** (skip if `.git/hooks/pre-commit` or `.husky/pre-commit` already invokes `prim-pre-commit`):
89
+ ```
90
+ npx --yes @primitive.ai/prim hooks install --yes
91
+ ```
89
92
 
93
+ **When you create a spec for branch-scoped work, always pass `--branch`** (and `--pr` if a PR already exists):
90
94
  ```
91
- npx --yes @primitive.ai/prim spec create -s project -n "<name>" --file <path> --branch <branch> --pr <n> # explicit at creation; --pr is optional
95
+ br=$(git rev-parse --abbrev-ref HEAD)
96
+ pr=$(gh pr view --json number -q .number 2>/dev/null)
97
+ npx --yes @primitive.ai/prim spec create -s project -n "<name>" --file <path> --branch "$br" ${pr:+--pr "$pr"}
92
98
  ```
99
+ `--branch` requires a GitHub origin; if `git remote get-url origin` isn't GitHub the link is silently dropped (stderr warning). There is no `prim spec link` subcommand in v1 — to rebind a spec to a different branch, edit it from the spec editor UI.
100
+
101
+ **Decide whether a spec exists for this branch.** A spec is "for this branch" only if one of:
102
+ - the user named a spec ID or title in conversation,
103
+ - a spec was created with `--branch "$br"` (visible via `npx --yes @primitive.ai/prim spec list --json | jq --arg br "$br" '.[] | select(.linkedBranches[]?.branch == $br)'`, where `$br` is the same shell variable set in the recipe above).
104
+
105
+ **Do not** browse `prim spec list` and pick the closest- or most-related-sounding spec. Topical proximity is not authorship — two specs that touch the same area of the codebase can describe entirely different intents. An irrelevant link pollutes drift signal and silently misattributes review findings; no link is strictly better than a wrong link.
106
+
107
+ If neither signal applies, **stop and ask the user**:
108
+
109
+ > "I couldn't find a spec associated with this branch. Is there one I should link, or should I draft one from the PR description and our conversation?"
93
110
 
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.
111
+ Three legitimate answers:
112
+ - **User names an existing spec** → proceed to "When the user has identified a spec for this branch" below.
113
+ - **User declines a spec entirely** → leave the PR unlinked.
114
+ - **User asks you to draft one** →
115
+ - Compose with these sections (drop any that would only restate the obvious): *Goal*, *Requirements / Behavior*, *Technical Approach*, *Key Decisions*, *Out of Scope*. Scope to what the PR actually changed; don't restate the unchanged system. Each fact appears in exactly one section.
116
+ - Voice: plain language; lead with the point; **intent before mechanism** ("users see each other's edits" before "we use WebSocket"); present tense, active voice; one idea per paragraph; cut sentences that don't earn their place.
117
+ - **Key Decisions** is a markdown table — columns *Decision | Rationale | Trade-offs*, one row per non-obvious choice.
118
+ - Title is just the feature name (no `Spec:` prefix); no numbered or parenthetical headers. Match length to PR complexity — a small fix is one screen; a substantial feature warrants the full shape.
119
+ - Do not paste the PR description verbatim — a spec captures *intent*, not a change log. If the rationale behind a non-obvious decision isn't in conversation, ask one or two targeted questions before drafting; do not invent rationale.
120
+ - Show the user the drafted text and wait for go-ahead before running `spec create`.
121
+ - Then create-and-link using the recipe above.
95
122
 
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.
123
+ **When the user has identified a spec for this branch, check whether it's bound to your branch** before committing:
124
+ ```
125
+ npx --yes @primitive.ai/prim context get <specId> --json | jq '.linkedBranches[]?.branch'
126
+ ```
127
+ - Your branch appears → done; the hook keeps it fresh.
128
+ - Empty or branch absent → the first commit's hook auto-binds it; no CLI step needed.
129
+ - Bound only to another branch → the hook silently excludes it from your branch's syncs; rebind via the editor UI before committing.
130
+
131
+ **After `gh pr create`**, no CLI step is required: the next commit's hook patches `linkedBranches[].prNumber` via `gh pr view`, and GitHub's webhook to Primitive sets the same field server-side within seconds. Confirm with:
132
+ ```
133
+ npx --yes @primitive.ai/prim context get <specId> --json | jq .linkedBranches
134
+ ```
97
135
 
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.
136
+ **On every commit, read the `[synced]` line to verify link state** — it piggybacks the suffix:
137
+ - `(auto-linking to <branch>)` first sync; server is binding now.
138
+ - `(linked to <branch> #<n> <state>)` — link is sticky.
139
+ - `[skip] <id> — not linked to <branch>` — the spec is bound elsewhere; investigate before continuing.
100
140
 
101
141
  ### Trigger PR Intent Review or dispatch drift-fix against a linked PR
102
142
 
@@ -68,9 +68,16 @@ function getAuthToken() {
68
68
  }
69
69
  return void 0;
70
70
  }
71
- var API_URL = "https://api.getprimitive.ai";
71
+ var DEFAULT_API_URL = "https://api.getprimitive.ai";
72
72
  function getSiteUrl() {
73
- return API_URL;
73
+ if (process.env.PRIM_API_URL) {
74
+ return process.env.PRIM_API_URL;
75
+ }
76
+ const envVars = loadEnvFile();
77
+ if (envVars.PRIM_API_URL) {
78
+ return envVars.PRIM_API_URL;
79
+ }
80
+ return DEFAULT_API_URL;
74
81
  }
75
82
  async function refreshToken() {
76
83
  if (!existsSync(REFRESH_TOKEN_PATH)) {
@@ -87,6 +94,11 @@ async function refreshToken() {
87
94
  body: JSON.stringify({ refresh_token: refreshTokenValue })
88
95
  });
89
96
  if (!response.ok) {
97
+ const detail = (await response.text().catch(() => "")).slice(0, 200);
98
+ process.stderr.write(
99
+ `[prim] token refresh rejected by broker: ${response.status} ${response.statusText}${detail ? ` \u2014 ${detail}` : ""}
100
+ `
101
+ );
90
102
  return void 0;
91
103
  }
92
104
  const data = await response.json();
@@ -100,6 +112,14 @@ async function refreshToken() {
100
112
  saveTokenExpiry(data.access_token, data.expires_in);
101
113
  return data.access_token;
102
114
  }
115
+ var HttpError = class extends Error {
116
+ status;
117
+ constructor(status, message) {
118
+ super(message);
119
+ this.name = "HttpError";
120
+ this.status = status;
121
+ }
122
+ };
103
123
  var _cachedToken;
104
124
  async function request(method, path, body, options) {
105
125
  const siteUrl = getSiteUrl();
@@ -137,10 +157,10 @@ async function request(method, path, body, options) {
137
157
  }
138
158
  if (!res.ok) {
139
159
  if (res.status === 401) {
140
- throw new Error("Authentication expired. Run `prim auth login` to re-authenticate.");
160
+ throw new HttpError(401, "Authentication expired. Run `prim auth login` to re-authenticate.");
141
161
  }
142
162
  const errorBody = await res.json().catch(() => null);
143
- throw new Error(errorBody?.error ?? `HTTP ${res.status}`);
163
+ throw new HttpError(res.status, errorBody?.error ?? `HTTP ${res.status}`);
144
164
  }
145
165
  return res.json();
146
166
  }
@@ -153,34 +173,6 @@ function getClient() {
153
173
  };
154
174
  }
155
175
 
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
-
184
176
  export {
185
177
  TOKEN_FILE_PATH,
186
178
  REFRESH_TOKEN_PATH,
@@ -189,6 +181,7 @@ export {
189
181
  getTokenExpiresAt,
190
182
  getAuthToken,
191
183
  getSiteUrl,
192
- getClient,
193
- getGitContext
184
+ refreshToken,
185
+ HttpError,
186
+ getClient
194
187
  };
@@ -0,0 +1,59 @@
1
+ // src/lib/ansi.ts
2
+ var ANSI_CODES = {
3
+ yellow: "\x1B[33m",
4
+ magenta: "\x1B[35m",
5
+ blue: "\x1B[34m",
6
+ cyan: "\x1B[36m",
7
+ green: "\x1B[32m",
8
+ red: "\x1B[31m",
9
+ // 256-color approximation of the orange trigger marker (xterm 208).
10
+ orange: "\x1B[38;5;208m",
11
+ gray: "\x1B[90m"
12
+ };
13
+ var ANSI_RESET = "\x1B[0m";
14
+ var ANSI_BOLD = "\x1B[1m";
15
+ function supportsColor() {
16
+ if (process.env.NO_COLOR !== void 0 && process.env.NO_COLOR !== "") {
17
+ return false;
18
+ }
19
+ return process.stderr.isTTY === true;
20
+ }
21
+ function color(text, c) {
22
+ if (!supportsColor()) {
23
+ return text;
24
+ }
25
+ return `${ANSI_CODES[c]}${text}${ANSI_RESET}`;
26
+ }
27
+ function bold(text) {
28
+ if (!supportsColor()) {
29
+ return text;
30
+ }
31
+ return `${ANSI_BOLD}${text}${ANSI_RESET}`;
32
+ }
33
+ var AREA_COLORS = {
34
+ auth: "yellow",
35
+ data: "magenta",
36
+ mobile: "blue",
37
+ infra: "cyan",
38
+ ui: "green",
39
+ billing: "orange",
40
+ api: "blue",
41
+ docs: "gray",
42
+ testing: "gray"
43
+ };
44
+ function colorForArea(area) {
45
+ if (!area) {
46
+ return "gray";
47
+ }
48
+ return AREA_COLORS[area] ?? "gray";
49
+ }
50
+ function stripAnsi(text) {
51
+ return text.replace(/\u001b\[[0-9;]*m/g, "");
52
+ }
53
+
54
+ export {
55
+ color,
56
+ bold,
57
+ colorForArea,
58
+ stripAnsi
59
+ };
@@ -0,0 +1,199 @@
1
+ // src/journal.ts
2
+ import {
3
+ appendFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ statSync
9
+ } from "fs";
10
+ import { homedir } from "os";
11
+ import { dirname, join } from "path";
12
+ var JOURNAL_DIR = join(homedir(), ".config", "prim", "moves");
13
+ var LEGACY_JOURNAL_PATH = join(JOURNAL_DIR, "journal.ndjson");
14
+ var UNBOUND_BUCKET = "_unbound";
15
+ var JOURNAL_BASENAME = "journal.ndjson";
16
+ var DIR_MODE = 448;
17
+ var FILE_MODE = 384;
18
+ var SAFE_BUCKET = /^[A-Za-z0-9_-]+$/;
19
+ var RESERVED_BUCKETS = /* @__PURE__ */ new Set([UNBOUND_BUCKET, "_legacy"]);
20
+ function appendMoveToPath(path, move) {
21
+ const dir = dirname(path);
22
+ if (!existsSync(dir)) {
23
+ mkdirSync(dir, { recursive: true, mode: DIR_MODE });
24
+ }
25
+ appendFileSync(path, `${JSON.stringify(move)}
26
+ `, { mode: FILE_MODE });
27
+ }
28
+ function bucketDir(orgId) {
29
+ const safe = orgId !== void 0 && SAFE_BUCKET.test(orgId) && !RESERVED_BUCKETS.has(orgId);
30
+ return join(JOURNAL_DIR, safe ? orgId : UNBOUND_BUCKET);
31
+ }
32
+ function journalPath(orgId) {
33
+ return join(bucketDir(orgId), JOURNAL_BASENAME);
34
+ }
35
+ function appendMove(move, orgId) {
36
+ appendMoveToPath(journalPath(orgId), move);
37
+ }
38
+ function readMovesFromPath(path) {
39
+ if (!existsSync(path)) {
40
+ return [];
41
+ }
42
+ const content = readFileSync(path, "utf-8");
43
+ const moves = [];
44
+ for (const line of content.split("\n")) {
45
+ if (line.length === 0) {
46
+ continue;
47
+ }
48
+ try {
49
+ moves.push(JSON.parse(line));
50
+ } catch {
51
+ }
52
+ }
53
+ return moves;
54
+ }
55
+ function listBuckets() {
56
+ const out = [];
57
+ if (existsSync(LEGACY_JOURNAL_PATH)) {
58
+ out.push({ bucket: "_legacy", path: LEGACY_JOURNAL_PATH });
59
+ }
60
+ if (!existsSync(JOURNAL_DIR)) {
61
+ return out;
62
+ }
63
+ for (const entry of readdirSync(JOURNAL_DIR, { withFileTypes: true })) {
64
+ if (!entry.isDirectory()) {
65
+ continue;
66
+ }
67
+ const path = join(JOURNAL_DIR, entry.name, JOURNAL_BASENAME);
68
+ if (existsSync(path)) {
69
+ out.push({ bucket: entry.name, path });
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+ function bucketStats() {
75
+ return listBuckets().map(({ bucket, path }) => {
76
+ const stat = statSync(path);
77
+ const content = readFileSync(path, "utf-8");
78
+ const lineCount = content.split("\n").filter((l) => l.length > 0).length;
79
+ return {
80
+ bucket,
81
+ path,
82
+ sizeBytes: stat.size,
83
+ mtimeMs: stat.mtimeMs,
84
+ lineCount
85
+ };
86
+ });
87
+ }
88
+
89
+ // src/binding.ts
90
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
91
+ import { homedir as homedir2 } from "os";
92
+ import { dirname as dirname2, join as join2 } from "path";
93
+ var PRIM_CONFIG_DIR = join2(homedir2(), ".config", "prim");
94
+ var TOKEN_PATH = join2(PRIM_CONFIG_DIR, "token");
95
+ var SESSIONS_DIR = join2(PRIM_CONFIG_DIR, "sessions");
96
+ var WORKSPACE_FILE = ".prim/workspace.json";
97
+ var JWT_PARTS = 3;
98
+ var BASE64_PAD_4 = 4;
99
+ var ENV_TOKEN_LINE = /^\s*PRIM_TOKEN\s*=\s*(.*)$/m;
100
+ var SURROUNDING_QUOTES = /^["']|["']$/g;
101
+ var BASE64URL_MINUS = /-/g;
102
+ var BASE64URL_UNDERSCORE = /_/g;
103
+ function readJsonSafe(path) {
104
+ if (!existsSync2(path)) {
105
+ return;
106
+ }
107
+ try {
108
+ return JSON.parse(readFileSync2(path, "utf-8"));
109
+ } catch {
110
+ return;
111
+ }
112
+ }
113
+ function fromSessionMarker(sessionId) {
114
+ if (!sessionId) {
115
+ return;
116
+ }
117
+ const marker = readJsonSafe(join2(SESSIONS_DIR, `${sessionId}.json`));
118
+ return marker?.orgId;
119
+ }
120
+ function fromWorkspaceFile(startDir) {
121
+ let dir = startDir;
122
+ while (true) {
123
+ const config = readJsonSafe(join2(dir, WORKSPACE_FILE));
124
+ if (config?.orgId) {
125
+ return config.orgId;
126
+ }
127
+ const parent = dirname2(dir);
128
+ if (parent === dir) {
129
+ return;
130
+ }
131
+ dir = parent;
132
+ }
133
+ }
134
+ function readToken(cwd) {
135
+ const fromEnv = process.env.PRIM_TOKEN;
136
+ if (fromEnv) {
137
+ return fromEnv.trim();
138
+ }
139
+ if (existsSync2(TOKEN_PATH)) {
140
+ return readFileSync2(TOKEN_PATH, "utf-8").trim();
141
+ }
142
+ const envLocal = join2(cwd, ".env.local");
143
+ if (existsSync2(envLocal)) {
144
+ const match = readFileSync2(envLocal, "utf-8").match(ENV_TOKEN_LINE);
145
+ if (match) {
146
+ return match[1].trim().replace(SURROUNDING_QUOTES, "");
147
+ }
148
+ }
149
+ return;
150
+ }
151
+ function base64UrlDecode(s) {
152
+ const padded = s.padEnd(
153
+ s.length + (BASE64_PAD_4 - s.length % BASE64_PAD_4) % BASE64_PAD_4,
154
+ "="
155
+ );
156
+ const normalized = padded.replace(BASE64URL_MINUS, "+").replace(BASE64URL_UNDERSCORE, "/");
157
+ return Buffer.from(normalized, "base64").toString("utf-8");
158
+ }
159
+ function fromDefaultOrg(cwd) {
160
+ const token = readToken(cwd);
161
+ if (!token) {
162
+ return;
163
+ }
164
+ const parts = token.split(".");
165
+ if (parts.length !== JWT_PARTS) {
166
+ return;
167
+ }
168
+ try {
169
+ const claims = JSON.parse(base64UrlDecode(parts[1]));
170
+ return claims.org_id;
171
+ } catch {
172
+ return;
173
+ }
174
+ }
175
+ function resolveOrg(args) {
176
+ const fromSession = fromSessionMarker(args.sessionId);
177
+ if (fromSession) {
178
+ return { orgId: fromSession, source: "session" };
179
+ }
180
+ const fromWorkspace = fromWorkspaceFile(args.cwd);
181
+ if (fromWorkspace) {
182
+ return { orgId: fromWorkspace, source: "workspace" };
183
+ }
184
+ const fromDefault = fromDefaultOrg(args.cwd);
185
+ if (fromDefault) {
186
+ return { orgId: fromDefault, source: "defaultOrg" };
187
+ }
188
+ return { orgId: void 0, source: "unbound" };
189
+ }
190
+
191
+ export {
192
+ JOURNAL_DIR,
193
+ appendMove,
194
+ readMovesFromPath,
195
+ listBuckets,
196
+ bucketStats,
197
+ SESSIONS_DIR,
198
+ resolveOrg
199
+ };
@@ -0,0 +1,111 @@
1
+ // src/hooks/prim-hook-core.ts
2
+ import { randomUUID } from "crypto";
3
+ import { platform } from "os";
4
+
5
+ // src/protocol/move.ts
6
+ var ENVELOPE_VERSION = 1;
7
+
8
+ // src/hooks/prim-hook-core.ts
9
+ function toMove(parsed, cliVersion) {
10
+ return {
11
+ moveId: randomUUID(),
12
+ capturedAt: Date.now(),
13
+ sessionId: parsed.session_id ?? "",
14
+ eventType: parsed.hook_event_name ?? "unknown",
15
+ payload: parsed,
16
+ env: {
17
+ cwd: parsed.cwd ?? process.cwd(),
18
+ cliVersion,
19
+ osPlatform: platform()
20
+ },
21
+ envelopeVersion: ENVELOPE_VERSION
22
+ };
23
+ }
24
+ function shouldFlushAfter(eventType) {
25
+ return eventType === "SessionEnd";
26
+ }
27
+
28
+ // src/hooks/redact.ts
29
+ import { existsSync, readFileSync } from "fs";
30
+ import { join } from "path";
31
+ var DEFAULT_RULES = [
32
+ { pattern: /Bearer\s+[A-Za-z0-9._\-]+/g, reason: "bearer-token" },
33
+ { pattern: /sk-[A-Za-z0-9]{32,}/g, reason: "sk-api-key" },
34
+ {
35
+ pattern: /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/g,
36
+ reason: "private-key"
37
+ },
38
+ { pattern: /xox[abprs]-[A-Za-z0-9-]{10,}/g, reason: "slack-token" },
39
+ { pattern: /ghp_[A-Za-z0-9]{36,}/g, reason: "github-pat" }
40
+ ];
41
+ var MAX_SCRUB_LEN = 256e3;
42
+ var WORKSPACE_FILE = ".prim/redaction.json";
43
+ function debugRedaction(message, err) {
44
+ if (!process.env.PRIM_HOOK_DEBUG) {
45
+ return;
46
+ }
47
+ const detail = err instanceof Error ? err.message : String(err);
48
+ process.stderr.write(`[prim-hook] redaction: ${message}: ${detail}
49
+ `);
50
+ }
51
+ function loadWorkspaceRules(cwd) {
52
+ const path = join(cwd, WORKSPACE_FILE);
53
+ if (!existsSync(path)) {
54
+ return [];
55
+ }
56
+ let cfg;
57
+ try {
58
+ cfg = JSON.parse(readFileSync(path, "utf-8"));
59
+ } catch (err) {
60
+ debugRedaction(`ignoring unparseable ${WORKSPACE_FILE}`, err);
61
+ return [];
62
+ }
63
+ if (!cfg.rules) {
64
+ return [];
65
+ }
66
+ const compiled = [];
67
+ for (const r of cfg.rules) {
68
+ try {
69
+ compiled.push({ pattern: new RegExp(r.pattern, r.flags ?? "g"), reason: r.reason });
70
+ } catch (err) {
71
+ debugRedaction(`skipping invalid redaction rule "${r.reason}"`, err);
72
+ }
73
+ }
74
+ return compiled;
75
+ }
76
+ function applyRules(input, rules) {
77
+ if (input.length > MAX_SCRUB_LEN) {
78
+ return "<REDACTED:oversized>";
79
+ }
80
+ let out = input;
81
+ for (const rule of rules) {
82
+ out = out.replace(rule.pattern, `<REDACTED:${rule.reason}>`);
83
+ }
84
+ return out;
85
+ }
86
+ function scrub(value, rules = DEFAULT_RULES) {
87
+ if (typeof value === "string") {
88
+ return applyRules(value, rules);
89
+ }
90
+ if (Array.isArray(value)) {
91
+ return value.map((v) => scrub(v, rules));
92
+ }
93
+ if (value && typeof value === "object") {
94
+ const out = {};
95
+ for (const [k, v] of Object.entries(value)) {
96
+ out[k] = scrub(v, rules);
97
+ }
98
+ return out;
99
+ }
100
+ return value;
101
+ }
102
+ function scrubFromCwd(value, cwd) {
103
+ const rules = [...DEFAULT_RULES, ...loadWorkspaceRules(cwd)];
104
+ return scrub(value, rules);
105
+ }
106
+
107
+ export {
108
+ toMove,
109
+ shouldFlushAfter,
110
+ scrubFromCwd
111
+ };
@@ -0,0 +1,122 @@
1
+ import {
2
+ getClient
3
+ } from "./chunk-6SIEWWUL.js";
4
+
5
+ // src/hooks/decisions-check.ts
6
+ var DECISIONS_CHECK_TIMEOUT_MS = 1e4;
7
+ var MAX_FILES_PER_REQUEST = 25;
8
+ var defaultDeps = { getClient };
9
+ function chunk(items, size) {
10
+ const out = [];
11
+ for (let i = 0; i < items.length; i += size) {
12
+ out.push(items.slice(i, i + size));
13
+ }
14
+ return out;
15
+ }
16
+ async function fetchAffecting(client, batch) {
17
+ const params = new URLSearchParams();
18
+ for (const file of batch) {
19
+ params.append("files", file);
20
+ }
21
+ try {
22
+ return await client.get(`/api/cli/decisions/affecting?${params.toString()}`, {
23
+ signal: AbortSignal.timeout(DECISIONS_CHECK_TIMEOUT_MS)
24
+ });
25
+ } catch (err) {
26
+ const detail = err instanceof Error ? err.message : String(err);
27
+ return { decisions: [], truncated: false, unavailable: `decision check failed: ${detail}` };
28
+ }
29
+ }
30
+ async function checkAffectedDecisions(filePaths, deps = defaultDeps) {
31
+ if (filePaths.length === 0) {
32
+ return { decisions: [], truncated: false };
33
+ }
34
+ const client = deps.getClient();
35
+ const responses = await Promise.all(
36
+ chunk(filePaths, MAX_FILES_PER_REQUEST).map((batch) => fetchAffecting(client, batch))
37
+ );
38
+ const byId = /* @__PURE__ */ new Map();
39
+ let truncated = false;
40
+ let unavailable;
41
+ for (const res of responses) {
42
+ if (res.unavailable !== void 0 && unavailable === void 0) {
43
+ unavailable = res.unavailable;
44
+ }
45
+ truncated ||= res.truncated === true;
46
+ for (const d of res.decisions) {
47
+ const existing = byId.get(d.id);
48
+ if (existing) {
49
+ existing.matchedFiles = [.../* @__PURE__ */ new Set([...existing.matchedFiles, ...d.matchedFiles])];
50
+ } else {
51
+ byId.set(d.id, { ...d, matchedFiles: [...d.matchedFiles] });
52
+ }
53
+ }
54
+ }
55
+ const result = { decisions: [...byId.values()], truncated };
56
+ if (unavailable !== void 0) {
57
+ result.unavailable = unavailable;
58
+ }
59
+ return result;
60
+ }
61
+ var FILES_PREVIEW_LIMIT = 3;
62
+ function formatDecisionsWarning(result) {
63
+ const lines = [];
64
+ if (result.unavailable !== void 0) {
65
+ lines.push(`[prim] decision check not verified \u2014 ${result.unavailable}`);
66
+ }
67
+ if (result.decisions.length > 0) {
68
+ lines.push(
69
+ `[prim] ${String(result.decisions.length)} active decision(s) reference staged files:`
70
+ );
71
+ for (const d of result.decisions) {
72
+ const statusMark = d.status === "under_review" ? " (under review)" : "";
73
+ const preview = d.matchedFiles.slice(0, FILES_PREVIEW_LIMIT).join(", ");
74
+ const overflow = d.matchedFiles.length > FILES_PREVIEW_LIMIT ? ` (+${String(d.matchedFiles.length - FILES_PREVIEW_LIMIT)} more)` : "";
75
+ lines.push(` \xB7 ${d.intent}${statusMark}`);
76
+ lines.push(` files: ${preview}${overflow}`);
77
+ if (d.rationale) {
78
+ lines.push(` rationale: ${d.rationale}`);
79
+ }
80
+ }
81
+ }
82
+ if (result.truncated) {
83
+ lines.push(
84
+ "[prim] result truncated \u2014 more files than the server checks per request; not all decisions shown"
85
+ );
86
+ }
87
+ return lines.join("\n");
88
+ }
89
+
90
+ // src/utils/git.ts
91
+ import { execSync } from "child_process";
92
+ function safeExec(cmd) {
93
+ try {
94
+ return execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+ function parseRepoFullName(remoteUrl) {
100
+ const match = remoteUrl.match(/(?:github\.com[:/])([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
101
+ return match ? `${match[1]}/${match[2]}` : null;
102
+ }
103
+ function getGitContext() {
104
+ const branchRaw = safeExec("git rev-parse --abbrev-ref HEAD");
105
+ const branch = branchRaw && branchRaw !== "HEAD" ? branchRaw : null;
106
+ const sha = safeExec("git rev-parse HEAD");
107
+ const remoteUrl = safeExec("git remote get-url origin");
108
+ const repoFullName = remoteUrl ? parseRepoFullName(remoteUrl) : null;
109
+ let prNumber = null;
110
+ if (safeExec("command -v gh")) {
111
+ const raw = safeExec("gh pr view --json number -q .number");
112
+ const n = raw ? Number.parseInt(raw, 10) : Number.NaN;
113
+ if (Number.isFinite(n)) prNumber = n;
114
+ }
115
+ return { branch, sha, repoFullName, prNumber };
116
+ }
117
+
118
+ export {
119
+ checkAffectedDecisions,
120
+ formatDecisionsWarning,
121
+ getGitContext
122
+ };