@scopebond/hook 0.8.0 → 0.9.0

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.
Files changed (102) hide show
  1. package/README.md +210 -0
  2. package/dist/budget-load.d.ts +68 -0
  3. package/dist/budget-load.d.ts.map +1 -0
  4. package/dist/budget-load.js +164 -0
  5. package/dist/budget-load.js.map +1 -0
  6. package/dist/capabilities.d.ts +103 -0
  7. package/dist/capabilities.d.ts.map +1 -0
  8. package/dist/capabilities.js +212 -0
  9. package/dist/capabilities.js.map +1 -0
  10. package/dist/classify.d.ts +17 -0
  11. package/dist/classify.d.ts.map +1 -0
  12. package/dist/classify.js +78 -0
  13. package/dist/classify.js.map +1 -0
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +546 -16
  16. package/dist/cli.js.map +1 -1
  17. package/dist/cloud.d.ts +8 -0
  18. package/dist/cloud.d.ts.map +1 -1
  19. package/dist/cloud.js.map +1 -1
  20. package/dist/dispatch-cli.d.ts +2 -0
  21. package/dist/dispatch-cli.d.ts.map +1 -0
  22. package/dist/dispatch-cli.js +128 -0
  23. package/dist/dispatch-cli.js.map +1 -0
  24. package/dist/explain.d.ts.map +1 -1
  25. package/dist/explain.js +2 -1
  26. package/dist/explain.js.map +1 -1
  27. package/dist/group.d.ts +12 -0
  28. package/dist/group.d.ts.map +1 -0
  29. package/dist/group.js +55 -0
  30. package/dist/group.js.map +1 -0
  31. package/dist/index.d.ts +25 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +17 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.d.ts +10 -0
  36. package/dist/install.d.ts.map +1 -1
  37. package/dist/install.js +56 -0
  38. package/dist/install.js.map +1 -1
  39. package/dist/map.d.ts +4 -0
  40. package/dist/map.d.ts.map +1 -1
  41. package/dist/map.js +29 -1
  42. package/dist/map.js.map +1 -1
  43. package/dist/obs-emitter.d.ts +132 -0
  44. package/dist/obs-emitter.d.ts.map +1 -0
  45. package/dist/obs-emitter.js +415 -0
  46. package/dist/obs-emitter.js.map +1 -0
  47. package/dist/obs-store.d.ts +160 -0
  48. package/dist/obs-store.d.ts.map +1 -0
  49. package/dist/obs-store.js +367 -0
  50. package/dist/obs-store.js.map +1 -0
  51. package/dist/obs-upload.d.ts +24 -0
  52. package/dist/obs-upload.d.ts.map +1 -0
  53. package/dist/obs-upload.js +231 -0
  54. package/dist/obs-upload.js.map +1 -0
  55. package/dist/observation.d.ts +232 -0
  56. package/dist/observation.d.ts.map +1 -0
  57. package/dist/observation.js +308 -0
  58. package/dist/observation.js.map +1 -0
  59. package/dist/paths.d.ts +15 -0
  60. package/dist/paths.d.ts.map +1 -0
  61. package/dist/paths.js +79 -0
  62. package/dist/paths.js.map +1 -0
  63. package/dist/policy-load.d.ts +60 -0
  64. package/dist/policy-load.d.ts.map +1 -0
  65. package/dist/policy-load.js +151 -0
  66. package/dist/policy-load.js.map +1 -0
  67. package/dist/proof.d.ts +30 -0
  68. package/dist/proof.d.ts.map +1 -0
  69. package/dist/proof.js +186 -0
  70. package/dist/proof.js.map +1 -0
  71. package/dist/rules.d.ts +20 -0
  72. package/dist/rules.d.ts.map +1 -1
  73. package/dist/rules.js +50 -0
  74. package/dist/rules.js.map +1 -1
  75. package/dist/runtime.d.ts +18 -1
  76. package/dist/runtime.d.ts.map +1 -1
  77. package/dist/runtime.js +74 -10
  78. package/dist/runtime.js.map +1 -1
  79. package/dist/scan.d.ts +7 -0
  80. package/dist/scan.d.ts.map +1 -0
  81. package/dist/scan.js +38 -0
  82. package/dist/scan.js.map +1 -0
  83. package/dist/shell.d.ts.map +1 -1
  84. package/dist/shell.js +12 -3
  85. package/dist/shell.js.map +1 -1
  86. package/dist/sql-classify.d.ts +11 -0
  87. package/dist/sql-classify.d.ts.map +1 -0
  88. package/dist/sql-classify.js +251 -0
  89. package/dist/sql-classify.js.map +1 -0
  90. package/dist/typed-infra.d.ts +73 -0
  91. package/dist/typed-infra.d.ts.map +1 -0
  92. package/dist/typed-infra.js +758 -0
  93. package/dist/typed-infra.js.map +1 -0
  94. package/dist/typed-ops.d.ts +140 -0
  95. package/dist/typed-ops.d.ts.map +1 -0
  96. package/dist/typed-ops.js +549 -0
  97. package/dist/typed-ops.js.map +1 -0
  98. package/dist/vectors.d.ts +40 -0
  99. package/dist/vectors.d.ts.map +1 -0
  100. package/dist/vectors.js +219 -0
  101. package/dist/vectors.js.map +1 -0
  102. package/package.json +4 -4
@@ -0,0 +1,549 @@
1
+ // Typed operations derived from the ACTUAL request the hook is about to let through: the
2
+ // shell command line or the tool call input, never a model-written summary. Each function
3
+ // returns a closed operation (git, github_resource, package) or null when the request cannot be
4
+ // described honestly. A fact that cannot be read stays unknown: an unresolved ref or remote
5
+ // is reported as `resolution: "unresolved"`, an unknown package fact is omitted or `unknown`,
6
+ // and nothing here claims a complete picture (a bare `npm install` names no packages, so it
7
+ // has no package operation at all rather than an empty or guessed one).
8
+ //
9
+ // Nothing raw leaves this module: remotes, refs, repositories, pull requests and packages
10
+ // are reduced to installation-keyed opaque ids, or to public package coordinates (name,
11
+ // version, registry host) which are the point of a dependency record. Credentials embedded in
12
+ // a URL are stripped before anything is keyed or recorded.
13
+ //
14
+ // The git and package derivations read only the local working tree (no network, no
15
+ // process other than a short `git` call). GitHub pull request updates and merges name the
16
+ // pull request only by number, so their head and base commits come from a platform read-back
17
+ // that this hook does not perform by default: they are typed only when the caller supplies a
18
+ // resolver, and are otherwise left as plain shell actions.
19
+ import { execFileSync } from "node:child_process";
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { parseMcpName } from "./map.js";
23
+ import { decomposeShell, gitArgs, canonProgram } from "./shell.js";
24
+ import { scrubParam } from "./minimize.js";
25
+ import { scan, flagValue } from "./scan.js";
26
+ import { deriveInfraOperation, fetchOperation, parseDestination } from "./typed-infra.js";
27
+ const SHA = /^([0-9a-f]{40}|[0-9a-f]{64})$/;
28
+ function git(cwd, args) {
29
+ try {
30
+ const out = execFileSync("git", ["-C", cwd, ...args], {
31
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
32
+ }).trim();
33
+ return out === "" ? null : out;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ export const systemGit = {
40
+ head: (cwd) => { const s = git(cwd, ["rev-parse", "HEAD"]); return s && SHA.test(s) ? s : null; },
41
+ branch: (cwd) => git(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
42
+ remoteUrl: (cwd, remote) => git(cwd, ["remote", "get-url", "--push", remote]),
43
+ defaultRemote: (cwd, branch) => (branch && git(cwd, ["config", "--get", `branch.${branch}.pushRemote`])) || (branch && git(cwd, ["config", "--get", `branch.${branch}.remote`])) || "origin",
44
+ revParse: (cwd, rev) => { if (rev.startsWith("-"))
45
+ return null; const s = git(cwd, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]); return s && SHA.test(s) ? s : null; },
46
+ };
47
+ export const UNBOUND = "unbound";
48
+ /** The opaque id this installation gives a value, so a reference set (protected refs, approved
49
+ * remotes and repositories) can be written in the ids observations carry. Null for a value
50
+ * that cannot be read as that kind. Kinds: ref, remote, ghrepo, repo (a workspace path as the
51
+ * hook sees it), mcp (server, tool) and mcp-resource (kind, value), net-dest (host, port), cf (resource kind, name) and database (pg or sqlite, key). */
52
+ export function keyedIdFor(key, kind, values) {
53
+ const [a, b] = values;
54
+ if (kind === "ref" && a)
55
+ return key.resourceId("ref", a.replace(/^refs\/heads\//, ""));
56
+ if (kind === "remote" && a) {
57
+ const n = normalizeRemote(a);
58
+ return n ? key.resourceId("remote", remoteKey(n)) : null;
59
+ }
60
+ if (kind === "ghrepo" && a) {
61
+ const r = parseRepoFlag(a) ?? githubRepoOf(normalizeRemote(a));
62
+ return r ? key.resourceId("ghrepo", githubRepoKey(r)) : null;
63
+ }
64
+ if (kind === "repo" && a)
65
+ return key.resourceId("repo", a);
66
+ if (kind === "mcp" && a && b)
67
+ return key.resourceId("mcp", `${a}\0${b}`);
68
+ if (kind === "mcp-resource" && a && b)
69
+ return key.resourceId(`mcp:${a}`, b);
70
+ // Destinations and Cloudflare resources, as the network, cloudflare_resource and database operations key them.
71
+ if (kind === "net-dest" && a && b && /^[0-9]{1,5}$/.test(b)) {
72
+ const d = parseDestination(`https://${a}`, false);
73
+ return d ? key.resourceId("net-dest", `${d.host}:${b}`) : null;
74
+ }
75
+ if (kind === "cf" && a && b && ["worker", "pages_project", "pages_deployment", "d1_database", "r2_bucket", "r2_object"].includes(a))
76
+ return key.resourceId(`cf:${a}`, b);
77
+ if (kind === "database" && a && b && (a === "pg" || a === "sqlite"))
78
+ return key.resourceId(a, b);
79
+ return null;
80
+ }
81
+ const defaultProtected = (ref) => /^(main|master)$|^release\//i.test(ref);
82
+ const probeOf = (ctx) => ctx.probe ?? systemGit;
83
+ function common(ctx, request) {
84
+ return {
85
+ environment_class: "unknown", reference_set_version: ctx.referenceSetVersion,
86
+ request_digest: ctx.key.requestDigest(request), digest_key_generation: ctx.key.generation,
87
+ };
88
+ }
89
+ // ---- remotes and repositories ---------------------------------------------------------------------
90
+ /** A remote reduced to `host` and `path`, with credentials, query, fragment and a trailing
91
+ * `.git` removed. Null for anything that is not readable as a remote (an unexpanded variable,
92
+ * an empty string). Local paths keep their path and use the host `local`. */
93
+ export function normalizeRemote(url) {
94
+ const text = url.trim();
95
+ if (text === "" || /[$`\s]/.test(text))
96
+ return null;
97
+ // Slashes are trimmed by scanning, and the query is cut at its first marker, so no
98
+ // backtracking pattern runs over a remote a caller supplied.
99
+ const strip = (p) => {
100
+ const cut = p.search(/[?#]/);
101
+ const s = (cut === -1 ? p : p.slice(0, cut)).replace(/\\/g, "/");
102
+ let end = s.length;
103
+ while (end > 0 && s.charCodeAt(end - 1) === 47)
104
+ end--;
105
+ let start = 0;
106
+ const body = s.slice(0, end).replace(/\.git$/, "");
107
+ while (start < body.length && body.charCodeAt(start) === 47)
108
+ start++;
109
+ return body.slice(start);
110
+ };
111
+ let m = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(text);
112
+ if (m && !/^file$/i.test(text.slice(0, 4)))
113
+ return { host: m[1].toLowerCase(), path: strip(m[2]) };
114
+ m = /^(?:[^@/\s]+@)?([^/:\s]+):(?!\/\/)([^\s]+)$/.exec(text);
115
+ if (m && !/^[A-Za-z]$/.test(m[1]))
116
+ return { host: m[1].toLowerCase(), path: strip(m[2]) };
117
+ // A local path: a drive-letter or POSIX path, absolute or relative.
118
+ const local = strip(text.replace(/^file:\/\/\/?/i, ""));
119
+ if (local === "")
120
+ return null;
121
+ return { host: "local", path: /^[A-Za-z]:/.test(local) ? local.toLowerCase() : local };
122
+ }
123
+ const remoteKey = (r) => `${r.host}/${r.host === "github.com" ? r.path.toLowerCase() : r.path}`;
124
+ function githubRepoOf(remote) {
125
+ if (!remote || remote.host === "local")
126
+ return null;
127
+ const parts = remote.path.split("/");
128
+ if (parts.length !== 2 || !parts[0] || !parts[1])
129
+ return null;
130
+ return { host: remote.host, owner: parts[0], repo: parts[1] };
131
+ }
132
+ const githubRepoKey = (r) => `${r.host}/${r.owner.toLowerCase()}/${r.repo.toLowerCase()}`;
133
+ /** `OWNER/REPO` or `HOST/OWNER/REPO` as `gh --repo` takes it. */
134
+ function parseRepoFlag(value) {
135
+ const parts = value.replace(/\.git$/, "").split("/");
136
+ if (parts.some((p) => p === "" || /[\s$`]/.test(p)))
137
+ return null;
138
+ if (parts.length === 2)
139
+ return { host: "github.com", owner: parts[0], repo: parts[1] };
140
+ if (parts.length === 3)
141
+ return { host: parts[0].toLowerCase(), owner: parts[1], repo: parts[2] };
142
+ return null;
143
+ }
144
+ // ---- git -----------------------------------------------------------------------------------------------
145
+ /** The `git` operation for one dispatched push, from the parameters the hook evaluated
146
+ * (`ref`, `remote`, `force`, `delete`, `all`) and the local repository. */
147
+ export function gitPushOperation(params, ctx, headSha, request) {
148
+ const probe = probeOf(ctx);
149
+ const head = headSha ?? probe.head(ctx.cwd);
150
+ if (!head || !SHA.test(head))
151
+ return null;
152
+ const ref = typeof params.ref === "string" ? params.ref : "";
153
+ const mirror = ref === "--mirror";
154
+ const isDelete = params.delete === true && !mirror;
155
+ const namedRef = ref !== "" && !ref.startsWith("-");
156
+ // Resolve the remote: a name through the local config, a URL directly.
157
+ const remoteArg = typeof params.remote === "string" && params.remote !== "" ? params.remote : undefined;
158
+ const remoteName = remoteArg ?? probe.defaultRemote(ctx.cwd, probe.branch(ctx.cwd));
159
+ const url = /[:/\\]/.test(remoteName) ? remoteName : probe.remoteUrl(ctx.cwd, remoteName);
160
+ const normalized = url ? normalizeRemote(url) : null;
161
+ const remoteId = normalized ? ctx.key.resourceId("remote", remoteKey(normalized)) : ctx.key.resourceId("remote-name", remoteName);
162
+ const isProtected = ctx.isProtectedRef ?? defaultProtected;
163
+ const resolved = namedRef && normalized !== null && !mirror && params.all !== true;
164
+ return {
165
+ type: "git", resource_id: ctx.repositoryId, ...common(ctx, request ?? { action_type: "git.push", params }),
166
+ verb: mirror ? "mirror" : isDelete ? "delete" : "push", repository_id: ctx.repositoryId,
167
+ refs: namedRef ? [{ ref_id: ctx.key.resourceId("ref", ref), protected: isProtected(ref) }] : [],
168
+ force: params.force === true, head_sha: head, resolution: resolved ? "resolved" : "unresolved", remote_id: remoteId,
169
+ };
170
+ }
171
+ /** `git commit`: the branch it lands on and the HEAD it starts from. */
172
+ function gitCommitOperation(sc, ctx, request) {
173
+ const g = gitArgs(sc);
174
+ if (!g || g.sub !== "commit" || g.configs.some((c) => /^alias\./i.test(c)))
175
+ return null;
176
+ if (sc.argv.some((a) => a === "-C" || a === "--git-dir" || a === "--work-tree" || a.startsWith("--git-dir=") || a.startsWith("--work-tree=")))
177
+ return null;
178
+ if (g.args.includes("--dry-run"))
179
+ return null;
180
+ const probe = probeOf(ctx);
181
+ const head = probe.head(ctx.cwd);
182
+ if (!head)
183
+ return null;
184
+ const branch = probe.branch(ctx.cwd);
185
+ const isProtected = ctx.isProtectedRef ?? defaultProtected;
186
+ return {
187
+ type: "git", resource_id: ctx.repositoryId, ...common(ctx, request), verb: "commit", repository_id: ctx.repositoryId,
188
+ refs: branch ? [{ ref_id: ctx.key.resourceId("ref", branch), protected: isProtected(branch) }] : [],
189
+ force: false, head_sha: head, resolution: branch ? "resolved" : "unresolved",
190
+ };
191
+ }
192
+ const PR_URL = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/;
193
+ /** A `gh` command reduced to what it asks for. Only the four typed verbs; anything else,
194
+ * including `gh api`, is not described. */
195
+ export function parseGh(sc) {
196
+ if (canonProgram(sc.program) !== "gh")
197
+ return null;
198
+ const [area, action, ...rest] = sc.argv;
199
+ const s = scan(rest, ["-R", "--repo", "-B", "--base", "-H", "--head", "--match-head-commit", "--target", "-t", "--title", "-b", "--body", "-F", "--body-file", "-a", "--assignee", "-l", "--label", "-r", "--reviewer", "-m", "--milestone", "-n", "--notes", "-d", "--discussion-category", "--notes-file", "--notes-start-tag", "-p", "--project", "--add-label", "--remove-label"]);
200
+ const repoFlag = flagValue(s, "-R", "--repo");
201
+ const request = {
202
+ verb: "pr_create",
203
+ ...(repoFlag ? { repo: parseRepoFlag(repoFlag) ?? undefined } : {}),
204
+ ...(flagValue(s, "-H", "--head") ? { head: flagValue(s, "-H", "--head") } : {}),
205
+ ...(flagValue(s, "-B", "--base") ? { base: flagValue(s, "-B", "--base") } : {}),
206
+ };
207
+ if (area === "pr" && action === "create")
208
+ return request;
209
+ if (area === "pr" && (action === "edit" || action === "merge")) {
210
+ request.verb = action === "edit" ? "pr_update" : "pr_merge";
211
+ const selector = s.positionals[0];
212
+ if (selector !== undefined) {
213
+ const url = PR_URL.exec(selector);
214
+ if (url) {
215
+ request.repo = { host: url[1].toLowerCase(), owner: url[2], repo: url[3] };
216
+ request.pr = url[4];
217
+ }
218
+ else if (/^\d+$/.test(selector))
219
+ request.pr = selector;
220
+ }
221
+ const match = flagValue(s, "--match-head-commit");
222
+ if (match)
223
+ request.matchHead = match;
224
+ return request;
225
+ }
226
+ if (area === "release" && action === "create") {
227
+ request.verb = "release_create";
228
+ if (s.positionals[0])
229
+ request.tag = s.positionals[0];
230
+ const target = flagValue(s, "--target");
231
+ if (target)
232
+ request.target = target;
233
+ return request;
234
+ }
235
+ return null;
236
+ }
237
+ /** The known GitHub MCP tools, from the tool call input. Tool names are the ones the GitHub
238
+ * MCP server publishes; a different server keeps its plain MCP operation. */
239
+ export function parseGithubMcp(server, tool, input) {
240
+ if (server !== "github")
241
+ return null;
242
+ const str = (k) => (typeof input[k] === "string" && input[k] !== "" ? input[k] : undefined);
243
+ const owner = str("owner");
244
+ const repo = str("repo");
245
+ const number = typeof input.pullNumber === "number" ? String(input.pullNumber) : str("pullNumber");
246
+ const base = { verb: "pr_create", ...(owner && repo ? { repo: { host: "github.com", owner, repo } } : {}) };
247
+ switch (tool) {
248
+ case "create_pull_request": return { ...base, ...(str("head") ? { head: str("head") } : {}), ...(str("base") ? { base: str("base") } : {}) };
249
+ case "update_pull_request": return { ...base, verb: "pr_update", ...(number && /^\d+$/.test(number) ? { pr: number } : {}) };
250
+ case "merge_pull_request": return { ...base, verb: "pr_merge", ...(number && /^\d+$/.test(number) ? { pr: number } : {}), ...(str("sha") ? { matchHead: str("sha") } : {}) };
251
+ default: return null;
252
+ }
253
+ }
254
+ /** The `github_resource` operation for a request, or null when a field the closed schema
255
+ * requires cannot be read from the request and the local repository. */
256
+ export function githubOperation(req, ctx, request) {
257
+ const probe = probeOf(ctx);
258
+ const workspaceRemote = normalizeRemote(probe.remoteUrl(ctx.cwd, probe.defaultRemote(ctx.cwd, probe.branch(ctx.cwd))) ?? "");
259
+ const workspaceRepo = githubRepoOf(workspaceRemote);
260
+ const repo = req.repo ?? workspaceRepo;
261
+ if (!repo)
262
+ return null;
263
+ // Local commit ids describe the workspace repository only.
264
+ const isWorkspace = workspaceRepo !== null && githubRepoKey(workspaceRepo) === githubRepoKey(repo);
265
+ const repositoryId = ctx.key.resourceId("ghrepo", githubRepoKey(repo));
266
+ const policyVersion = ctx.requiredCheckPolicyVersion ?? UNBOUND;
267
+ const base = { type: "github_resource", resource_id: repositoryId, ...common(ctx, request), verb: req.verb, repository_id: repositoryId };
268
+ if (req.verb === "pr_create") {
269
+ if (!isWorkspace)
270
+ return null;
271
+ const branch = (req.head ?? probe.branch(ctx.cwd) ?? "").replace(/^[^:]*:/, "");
272
+ if (branch === "" || (req.head?.includes(":") ?? false))
273
+ return null;
274
+ const headSha = probe.revParse(ctx.cwd, `refs/heads/${branch}`);
275
+ const baseSha = req.base ? probe.revParse(ctx.cwd, `refs/remotes/origin/${req.base}`) : probe.revParse(ctx.cwd, "refs/remotes/origin/HEAD");
276
+ return headSha && baseSha ? { ...base, base_sha: baseSha, head_sha: headSha } : null;
277
+ }
278
+ if (req.verb === "release_create") {
279
+ if (!isWorkspace)
280
+ return null;
281
+ const target = req.tag ? probe.revParse(ctx.cwd, `refs/tags/${req.tag}`) ?? (req.target ? (SHA.test(req.target) ? req.target : probe.revParse(ctx.cwd, req.target)) : null) : null;
282
+ return target ? { ...base, release_digest: target, required_check_policy_version: policyVersion } : null;
283
+ }
284
+ // pr_update / pr_merge: the request names a pull request by number only.
285
+ if (!req.pr)
286
+ return null;
287
+ const resolved = ctx.resolvePullRequest?.(repo, req.pr) ?? null;
288
+ if (!resolved || !SHA.test(resolved.head_sha) || !SHA.test(resolved.base_sha))
289
+ return null;
290
+ if (req.matchHead && SHA.test(req.matchHead) && req.matchHead !== resolved.head_sha)
291
+ return null;
292
+ return {
293
+ ...base, pr_id: ctx.key.resourceId("pr", `${githubRepoKey(repo)}#${req.pr}`), base_sha: resolved.base_sha, head_sha: resolved.head_sha,
294
+ ...(req.verb === "pr_merge" ? { required_check_policy_version: policyVersion } : {}),
295
+ };
296
+ }
297
+ const EXACT_VERSION = /^\d+(?:\.\d+){0,3}(?:[-+.][0-9A-Za-z.+-]+)?$/;
298
+ const HOST = /^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/;
299
+ const JS_VALUED = ["--registry", "--prefix", "--workspace", "--tag", "--cache", "--userconfig", "--loglevel", "-F", "--filter", "-C", "--dir", "--cwd", "--reporter", "--fetch-timeout", "--store-dir", "--network-concurrency"];
300
+ const PY_VALUED = ["-r", "--requirement", "--requirements", "-e", "--editable", "-i", "--index-url", "--extra-index-url", "-f", "--find-links", "-t", "--target", "--prefix", "--root", "--python", "-p", "--platform", "--abi", "--python-version", "--implementation", "--only-binary", "--no-binary", "-c", "--constraint", "--progress-bar", "--proxy", "--cert", "--client-cert", "--retries", "--timeout", "--trusted-host", "--config-settings", "-C", "--global-option", "--with", "--group", "--extra", "--index", "--default-index", "--directory", "--project", "--resolution", "--prerelease", "--exclude-newer"];
301
+ /** The registry host a spec or index URL points to, or undefined. */
302
+ function hostOf(url) {
303
+ if (!url)
304
+ return undefined;
305
+ const n = normalizeRemote(/^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : `https://${url}`);
306
+ return n && n.host !== "local" && HOST.test(n.host) ? n.host : undefined;
307
+ }
308
+ const clip = (s) => (s.length > 200 ? s.slice(0, 200) : s);
309
+ function jsPackage(spec) {
310
+ if (spec === "" || spec === "." || spec === "..")
311
+ return { name: "local:project" };
312
+ if (/^(?:https?|git\+https?|git\+ssh|git|ssh|git\+file|file):/i.test(spec)) {
313
+ if (/^file:/i.test(spec))
314
+ return { name: clip(`local:${spec.replace(/^file:/i, "").replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "project"}`) };
315
+ const n = normalizeRemote(spec.replace(/^git\+/i, ""));
316
+ return n ? { name: clip(`url:${n.host}/${n.path}`), host: n.host } : null;
317
+ }
318
+ if (/^(?:github|gitlab|bitbucket):/i.test(spec)) {
319
+ const [kind, path] = spec.split(":", 2);
320
+ return { name: clip(`git:${kind.toLowerCase()}.${kind.toLowerCase() === "bitbucket" ? "org" : "com"}/${path.replace(/#.*$/, "")}`) };
321
+ }
322
+ if (/^(?:\.{1,2}[\\/]|\/|~|[A-Za-z]:[\\/])/.test(spec))
323
+ return { name: clip(`local:${spec.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "project"}`) };
324
+ if (/^[\w.-]+\/[\w.-]+(?:#.*)?$/.test(spec) && !spec.startsWith("@"))
325
+ return { name: clip(`git:github.com/${spec.replace(/#.*$/, "")}`) };
326
+ const m = /^((?:@[A-Za-z0-9._~-]+\/)?[A-Za-z0-9._~-]+)(?:@(.+))?$/.exec(spec);
327
+ if (!m)
328
+ return null;
329
+ return { name: m[1], ...(m[2] && EXACT_VERSION.test(m[2]) ? { version: m[2] } : {}) };
330
+ }
331
+ function pyPackage(spec) {
332
+ if (spec === "." || spec === ".." || /^(?:\.{1,2}[\\/]|\/|~|[A-Za-z]:[\\/])/.test(spec))
333
+ return { name: clip(`local:${spec === "." || spec === ".." ? "project" : spec.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "project"}`) };
334
+ if (/^(?:https?|git\+https?|git\+ssh|git\+file|file):/i.test(spec)) {
335
+ const n = normalizeRemote(spec.replace(/^git\+/i, ""));
336
+ return n ? { name: clip(`url:${n.host}/${n.path}`), host: n.host } : null;
337
+ }
338
+ const m = /^([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)(?:\[[^\]]*\])?\s*(?:(===?)\s*([0-9A-Za-z.+!*-]+))?(?:\s*[;<>!~,@].*)?$/.exec(spec);
339
+ if (!m)
340
+ return null;
341
+ const name = m[1].toLowerCase().replace(/[._]+/g, "-");
342
+ return { name, ...(m[2] && m[3] && !m[3].includes("*") ? { version: m[3] } : {}) };
343
+ }
344
+ function readPackageManager(cwd, manager) {
345
+ try {
346
+ const file = join(cwd, "package.json");
347
+ if (!existsSync(file))
348
+ return undefined;
349
+ const field = JSON.parse(readFileSync(file, "utf8")).packageManager;
350
+ if (typeof field !== "string")
351
+ return undefined;
352
+ const m = /^([a-z]+)@(\d[0-9A-Za-z.+-]*?)(?:\+.*)?$/.exec(field);
353
+ return m && m[1] === manager ? m[2] : undefined;
354
+ }
355
+ catch {
356
+ return undefined;
357
+ }
358
+ }
359
+ /** `python -m pip …` and `uv pip …` read as the pip they run. */
360
+ function managerCommand(sc) {
361
+ const program = canonProgram(sc.program);
362
+ if (program === "npm" || program === "pnpm" || program === "yarn")
363
+ return { manager: program, argv: sc.argv };
364
+ if (program === "pip" || program === "pip3")
365
+ return { manager: "pip", argv: sc.argv };
366
+ if (program === "uv")
367
+ return { manager: "uv", argv: sc.argv };
368
+ if (/^(?:python[0-9.]*|py)$/.test(program)) {
369
+ const at = sc.argv.indexOf("-m");
370
+ const mod = at >= 0 ? sc.argv[at + 1] : undefined;
371
+ if (mod === "pip")
372
+ return { manager: "pip", argv: sc.argv.slice(at + 2) };
373
+ if (mod === "uv")
374
+ return { manager: "uv", argv: sc.argv.slice(at + 2) };
375
+ }
376
+ return null;
377
+ }
378
+ /** The `package` operation for an install, add or update that names its packages. A command
379
+ * that installs whatever a lockfile or requirements file lists (`npm ci`, `pnpm install`,
380
+ * `pip install -r …`, `uv sync`) has no package operation: its package set is not in the
381
+ * request, and an empty or guessed list would read as a complete one. */
382
+ export function packageOperation(sc, ctx, request) {
383
+ const found = managerCommand(sc);
384
+ if (!found)
385
+ return null;
386
+ const { manager } = found;
387
+ const js = manager === "npm" || manager === "pnpm" || manager === "yarn";
388
+ const s = scan(found.argv, js ? (manager === "npm" ? [...JS_VALUED, "-w"] : JS_VALUED) : PY_VALUED);
389
+ const [sub, second, ...others] = s.positionals;
390
+ let verb;
391
+ let specs;
392
+ if (js) {
393
+ const map = { install: "install", i: "install", add: "add", update: "update", up: "update", upgrade: "update" };
394
+ if (sub === undefined || !Object.hasOwn(map, sub))
395
+ return null;
396
+ verb = map[sub];
397
+ specs = [second, ...others].filter((x) => x !== undefined);
398
+ }
399
+ else if (manager === "pip") {
400
+ if (sub !== "install")
401
+ return null;
402
+ verb = s.flags.has("-U") || s.flags.has("--upgrade") ? "update" : "install";
403
+ specs = [second, ...others].filter((x) => x !== undefined);
404
+ }
405
+ else if (sub === "add") {
406
+ verb = "add";
407
+ specs = [second, ...others].filter((x) => x !== undefined);
408
+ }
409
+ else if (sub === "pip" && second === "install") {
410
+ verb = s.flags.has("-U") || s.flags.has("--upgrade") ? "update" : "install";
411
+ specs = others;
412
+ }
413
+ else
414
+ return null;
415
+ // Packages taken from a file or an editable path are not named in the request.
416
+ if (!js && ["-r", "--requirement", "--requirements", "-e", "--editable"].some((f) => s.flags.has(f)))
417
+ return null;
418
+ if (specs.length === 0)
419
+ return null;
420
+ const registry = js ? hostOf(flagValue(s, "--registry")) : hostOf(flagValue(s, "-i", "--index-url", "--default-index"));
421
+ const ignore = s.flags.get("--ignore-scripts");
422
+ const noScripts = ignore === true || ignore === "true";
423
+ const lifecycle = js ? (noScripts ? "blocked" : "unknown") : "not_supported";
424
+ const packages = [];
425
+ for (const spec of specs.slice(0, 100)) {
426
+ const parsed = js ? jsPackage(spec) : pyPackage(spec);
427
+ if (!parsed || scrubParam(parsed.name) !== parsed.name)
428
+ return null;
429
+ const host = parsed.host ?? (parsed.name.startsWith("local:") || parsed.name.startsWith("git:") ? undefined : registry);
430
+ packages.push({ name: parsed.name, integrity_status: "unknown", ...(parsed.version ? { resolved_version: parsed.version } : {}), ...(host ? { registry_host: host } : {}) });
431
+ }
432
+ if (specs.length > 100)
433
+ return null; // a larger install is not truncated; it stays a shell action
434
+ const names = packages.map((p) => String(p.name)).sort();
435
+ const versionOf = ctx.packageManagerVersion ?? readPackageManager;
436
+ return {
437
+ type: "package", resource_id: ctx.key.resourceId("package-set", names.join("\0")), ...common(ctx, request),
438
+ manager, manager_version: versionOf(ctx.cwd, manager) ?? "unknown", verb, packages, lifecycle_scripts: lifecycle,
439
+ };
440
+ }
441
+ /** The tool call as the harness sent it, for typed operations: a shell tool's raw command
442
+ * (Claude Code, Codex and Cursor each name it differently) or an MCP tool's actual input. */
443
+ export function callRequestOf(input) {
444
+ const name = typeof input.tool_name === "string" ? input.tool_name : "";
445
+ const ti = (input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {});
446
+ const text = (v) => (typeof v === "string" ? v : undefined);
447
+ if (name === "PowerShell") {
448
+ const command = text(ti.command ?? ti.cmd);
449
+ return command === undefined ? undefined : { command, dialect: "powershell" };
450
+ }
451
+ if (name === "Bash" || name === "Shell" || name === "exec_command" || name === "unified_exec") {
452
+ const command = text(ti.command ?? ti.cmd);
453
+ return command === undefined ? undefined : { command };
454
+ }
455
+ if (name === "WebFetch") {
456
+ const url = text(ti.url);
457
+ return url === undefined ? undefined : { fetch: { url } };
458
+ }
459
+ const mcp = parseMcpName(name);
460
+ if (mcp)
461
+ return { mcp: { ...mcp, input: ti } };
462
+ // Cursor's shell hook carries the command at the top level.
463
+ if (name === "" && typeof input.command === "string")
464
+ return { command: input.command };
465
+ // Cursor's MCP hook names the server and tool and carries the arguments (an object, or JSON text).
466
+ if (name === "" && typeof (input.server ?? input.server_name) === "string" && typeof (input.tool ?? input.tool_name) === "string") {
467
+ const raw = input.args ?? input.arguments ?? input.tool_input;
468
+ let parsed = raw;
469
+ if (typeof raw === "string") {
470
+ try {
471
+ parsed = JSON.parse(raw);
472
+ }
473
+ catch {
474
+ parsed = {};
475
+ }
476
+ }
477
+ return { mcp: { server: String(input.server ?? input.server_name), tool: String(input.tool ?? input.tool_name), input: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {} } };
478
+ }
479
+ return undefined;
480
+ }
481
+ /**
482
+ * Typed operations for the dispatched items of one tool call, by item index. A shell item is
483
+ * matched to its simple command by the same redacted text the mapper recorded, so an operation
484
+ * is always about the command that was evaluated. A `cd` earlier in the same command line moves
485
+ * later commands away from the workspace, so repository-dependent operations are skipped after
486
+ * one (they stay plain shell actions).
487
+ */
488
+ export function deriveTypedOperations(input, ctx) {
489
+ const out = new Map();
490
+ if (input.mcp) {
491
+ const at = input.dispatched.findIndex((d) => d.action.action_type === "mcp.tool.call");
492
+ const req = parseGithubMcp(input.mcp.server, input.mcp.tool, input.mcp.input);
493
+ if (at >= 0 && req) {
494
+ const op = githubOperation(req, ctx, { action_type: "mcp.tool.call", params: { server: input.mcp.server, tool: input.mcp.tool, arguments: input.mcp.input } });
495
+ if (op)
496
+ out.set(at, op);
497
+ }
498
+ return out;
499
+ }
500
+ if (input.fetch) {
501
+ const at = input.dispatched.findIndex((d) => d.action.action_type === "net.fetch");
502
+ if (at >= 0) {
503
+ const op = fetchOperation(input.fetch.url, { key: ctx.key, cwd: ctx.cwd, referenceSetVersion: ctx.referenceSetVersion }, { action_type: "net.fetch", params: { url: input.fetch.url, method: "GET" } });
504
+ if (op)
505
+ out.set(at, op);
506
+ }
507
+ return out;
508
+ }
509
+ if (input.command === undefined)
510
+ return out;
511
+ const src = input.dialect === "powershell" ? input.command.replace(/`(.)/g, "$1").replace(/\\/g, "/") : input.command;
512
+ const commands = decomposeShell(src);
513
+ const used = new Set();
514
+ let moved = false;
515
+ for (const sc of commands) {
516
+ if (sc.opaque)
517
+ continue;
518
+ const program = canonProgram(sc.program);
519
+ if (/^(?:cd|pushd|chdir|set-location|sl)$/.test(program)) {
520
+ moved = true;
521
+ continue;
522
+ }
523
+ // After a `cd` the workspace is not where later commands run: only the destination of a fetch, which does not
524
+ // depend on it, is still read.
525
+ const redacted = input.redact(sc.raw);
526
+ const at = input.dispatched.findIndex((d, i) => !used.has(i) && d.action.action_type === "shell.exec" && d.action.params.command === redacted);
527
+ if (at < 0)
528
+ continue;
529
+ const request = { action_type: "shell.exec", params: { command: sc.raw, cwd: ctx.cwd } };
530
+ const gh = parseGh(sc);
531
+ const op = (moved ? null : (gh ? githubOperation(gh, ctx, request) : null) ?? gitCommitOperation(sc, ctx, request) ?? packageOperation(sc, ctx, request))
532
+ ?? deriveInfraOperation(sc, { key: ctx.key, cwd: ctx.cwd, referenceSetVersion: ctx.referenceSetVersion, ...(ctx.files ? { files: ctx.files } : {}), ...(ctx.env ? { env: ctx.env } : {}) }, request, input.dialect ?? "posix", moved);
533
+ if (op) {
534
+ out.set(at, op);
535
+ used.add(at);
536
+ }
537
+ }
538
+ return out;
539
+ }
540
+ /** A repository the fixtures describe: deterministic, no git process, no network. */
541
+ export const fixtureProbe = {
542
+ head: () => "1".repeat(40), branch: () => "feature/fixture",
543
+ remoteUrl: () => "https://github.com/example/fixture.git", defaultRemote: () => "origin",
544
+ revParse: (_cwd, rev) => (rev === "refs/heads/feature/fixture" ? "1".repeat(40) : rev.startsWith("refs/remotes/origin/") ? "2".repeat(40) : null),
545
+ };
546
+ /** Capability cells for typed operations. These describe observations, not receipts, so a
547
+ * proof for one has no receipt of its own action type to name and is never sent to a workspace. */
548
+ export const TYPED_ACTION_TYPES = new Set(["git.commit", "package.install", "github.resource", "github.pr_change", "deploy.run", "network.request", "cloudflare.resource", "database.exec", "browser.action", "communication.send", "visibility.change"]);
549
+ //# sourceMappingURL=typed-ops.js.map