opencode-harness-kit 0.0.0-stage → 0.2.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 (41) hide show
  1. package/GLOBAL.md +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +464 -2
  4. package/agents/build.md +66 -0
  5. package/agents/locate.md +48 -0
  6. package/agents/plan.md +61 -0
  7. package/agents/reviewer.md +62 -0
  8. package/agents/verify.md +54 -0
  9. package/bin/baseline.mjs +367 -0
  10. package/bin/git-hooks/pre-commit +30 -0
  11. package/bin/oc-harness.mjs +1025 -0
  12. package/dist/index.js +1038 -0
  13. package/docs/KNOWN-LIMITS.md +188 -0
  14. package/docs/RESEARCH.md +210 -0
  15. package/docs/demo/demo.gif +0 -0
  16. package/docs/demo/demo.mp4 +0 -0
  17. package/docs/demo/render.py +146 -0
  18. package/examples/muse-spark-1.3-contributor-free.json +41 -0
  19. package/package.json +81 -4
  20. package/profiles/deepseek-v4-flash-free.json +41 -0
  21. package/profiles/index.json +78 -0
  22. package/profiles/longcat-2.5-preview-free.json +41 -0
  23. package/profiles/muse-split-free.json +64 -0
  24. package/profiles/schema.json +161 -0
  25. package/profiles/space-bunny-free.json +42 -0
  26. package/profiles/x-preview-f-free.json +41 -0
  27. package/retention.yml +110 -0
  28. package/skills/space-build/SKILL.md +54 -0
  29. package/skills/space-finish/SKILL.md +63 -0
  30. package/skills/space-plan/SKILL.md +101 -0
  31. package/skills/space-plan/reference/task-template.md +47 -0
  32. package/skills/space-review/SKILL.md +53 -0
  33. package/src/config.js +358 -0
  34. package/src/effort.js +98 -0
  35. package/src/guard.ts +59 -0
  36. package/src/index.ts +80 -0
  37. package/src/profile.js +484 -0
  38. package/src/prune.ts +258 -0
  39. package/src/router.ts +192 -0
  40. package/src/rules.js +481 -0
  41. package/src/telemetry.ts +98 -0
package/src/rules.js ADDED
@@ -0,0 +1,481 @@
1
+ // Pure guard logic, separate from the hook so it can be tested without a
2
+ // TypeScript runtime and reused by other tools.
3
+
4
+ import { homedir, tmpdir } from "node:os"
5
+ import { resolve as resolvePath, sep } from "node:path"
6
+
7
+ // Two ideas here:
8
+ //
9
+ // 1. Commands are split on shell separators before matching. Matching a raw
10
+ // string is what lets `cd repo && git push` through, which is the obvious
11
+ // hole in every regex guardrail. This is the "syntax-aware command
12
+ // permissioning" pattern.
13
+ //
14
+ // 2. Rules are scoped. A rule that spans two commands (a pipe into a shell, a
15
+ // fetch assigned to a credential name) has to see the whole string; a rule
16
+ // about one command must only see one command, or `git log --grep push`
17
+ // trips a push rule.
18
+ //
19
+ // 3. Splitting and matching are both quote-aware. Splitting naively on `;` and
20
+ // `&&` invents commands that are really one argument, and anchoring a rule
21
+ // at `^git` is defeated by any prefix (`env`, `sudo`, `git -C`). So the
22
+ // splitter walks the string tracking quotes, git rules look up the resolved
23
+ // subcommand, and every anchored rule is matched against the segment with
24
+ // its prefixes stripped.
25
+
26
+ /**
27
+ * macOS and Windows compare paths case-insensitively by default, so a scope
28
+ * check that compared case-sensitively would let `/Users/Admin/...` past a
29
+ * `/users/admin` root.
30
+ */
31
+ const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32"
32
+
33
+ /**
34
+ * @param {string} p
35
+ * @returns {string}
36
+ */
37
+ function expandHome(p) {
38
+ if (p === "~") return homedir()
39
+ if (p.startsWith("~/")) return resolvePath(homedir(), p.slice(2))
40
+ return p
41
+ }
42
+
43
+ /**
44
+ * Whether a write target stays inside the project. A relative path is resolved
45
+ * against the project root, which is where the agent's working directory is.
46
+ * @param {string} target
47
+ * @param {string} root
48
+ * @returns {boolean}
49
+ */
50
+ export function insideRoot(target, root) {
51
+ if (!root) return true
52
+ const absTarget = resolvePath(root, expandHome(target))
53
+ const absRoot = resolvePath(root)
54
+ if (absTarget === absRoot) return true
55
+ const a = CASE_INSENSITIVE_FS ? absTarget.toLowerCase() : absTarget
56
+ const b = CASE_INSENSITIVE_FS ? absRoot.toLowerCase() : absRoot
57
+ return a.startsWith(b.endsWith(sep) ? b : b + sep)
58
+ }
59
+
60
+ /**
61
+ * @typedef {{ name: string, test?: RegExp, check?: (text: string) => boolean,
62
+ * why: string, scope?: "segment" | "command", unless?: RegExp }} Rule
63
+ * @typedef {{ name: string, why: string, segment: string }} Hit
64
+ */
65
+
66
+ const HEREDOC = /^<<(-)?(?:'([A-Za-z_][A-Za-z0-9_]*)'|"([A-Za-z_][A-Za-z0-9_]*)"|([A-Za-z_][A-Za-z0-9_]*))/
67
+
68
+ /**
69
+ * The index just past a heredoc body that starts at `at`, or -1. A heredoc
70
+ * body is data handed to a command, never commands, so it must not be split.
71
+ * An agent writing a commit message that quotes the command it fixed would
72
+ * otherwise be blocked for describing the very thing it was asked to remove.
73
+ * @param {string} command
74
+ * @param {number} at
75
+ * @returns {number}
76
+ */
77
+ function heredocEnd(command, at) {
78
+ if (command[at] !== "<" || command[at + 1] !== "<") return -1
79
+ if (command[at + 2] === "<") return -1 // <<< is a herestring, whose value is an argument
80
+ const m = HEREDOC.exec(command.slice(at))
81
+ if (!m) return -1
82
+ const delim = m[2] ?? m[3] ?? m[4]
83
+ const nl = command.indexOf("\n", at + m[0].length)
84
+ if (nl === -1) return -1
85
+ for (let p = nl + 1; ; ) {
86
+ const end = command.indexOf("\n", p)
87
+ const line = command.slice(p, end === -1 ? command.length : end)
88
+ if (line.trim() === delim) return end === -1 ? command.length : end
89
+ if (end === -1) return -1
90
+ p = end + 1
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Split a shell command into the individual commands it runs. Separators
96
+ * inside quotes, and heredoc bodies, are not command boundaries.
97
+ * @param {string} command
98
+ * @returns {string[]}
99
+ */
100
+ export function segments(command) {
101
+ /** @type {string[]} */
102
+ const parts = []
103
+ let cur = ""
104
+ let quote = null
105
+ const flush = () => {
106
+ const t = cur.trim()
107
+ if (t) parts.push(t)
108
+ cur = ""
109
+ }
110
+ for (let i = 0; i < command.length; i++) {
111
+ const ch = command[i]
112
+ if (quote) {
113
+ cur += ch
114
+ if (ch === "\\" && quote === '"') {
115
+ if (i + 1 < command.length) cur += command[++i]
116
+ continue
117
+ }
118
+ if (ch === quote) quote = null
119
+ continue
120
+ }
121
+ if (ch === "\\") {
122
+ cur += ch
123
+ if (i + 1 < command.length) cur += command[++i]
124
+ continue
125
+ }
126
+ if (ch === "'" || ch === '"') {
127
+ quote = ch
128
+ cur += ch
129
+ continue
130
+ }
131
+ // Consume a heredoc body whole, separators and all, before any of its
132
+ // text can be mistaken for a command.
133
+ if (ch === "<" && command[i + 1] === "<") {
134
+ const end = heredocEnd(command, i)
135
+ if (end > i) {
136
+ cur += command.slice(i, end)
137
+ i = end - 1
138
+ continue
139
+ }
140
+ }
141
+ if (ch === "\n" || ch === ";") {
142
+ flush()
143
+ continue
144
+ }
145
+ if (ch === "|") {
146
+ flush()
147
+ if (command[i + 1] === "|") i++
148
+ continue
149
+ }
150
+ if (ch === "&" && command[i + 1] === "&") {
151
+ flush()
152
+ i++
153
+ continue
154
+ }
155
+ cur += ch
156
+ }
157
+ flush()
158
+ return parts
159
+ }
160
+
161
+ /**
162
+ * Split one segment into words, honouring quotes so that a quoted phrase is
163
+ * one argument rather than several.
164
+ * @param {string} segment
165
+ * @returns {string[]}
166
+ */
167
+ export function tokenize(segment) {
168
+ /** @type {string[]} */
169
+ const tokens = []
170
+ let cur = ""
171
+ let quote = null
172
+ let started = false
173
+ const flush = () => {
174
+ if (cur || started) tokens.push(cur)
175
+ cur = ""
176
+ started = false
177
+ }
178
+ for (let i = 0; i < segment.length; i++) {
179
+ const ch = segment[i]
180
+ if (quote) {
181
+ if (ch === "\\" && quote === '"') {
182
+ if (i + 1 < segment.length) cur += segment[++i]
183
+ continue
184
+ }
185
+ if (ch === quote) {
186
+ quote = null
187
+ continue
188
+ }
189
+ cur += ch
190
+ continue
191
+ }
192
+ if (ch === "\\" && i + 1 < segment.length) {
193
+ cur += segment[++i]
194
+ started = true
195
+ continue
196
+ }
197
+ if (ch === "'" || ch === '"') {
198
+ quote = ch
199
+ started = true
200
+ continue
201
+ }
202
+ if (/\s/.test(ch)) {
203
+ flush()
204
+ continue
205
+ }
206
+ cur += ch
207
+ }
208
+ flush()
209
+ return tokens
210
+ }
211
+
212
+ const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/
213
+ const GIT_WRAPPERS = new Set(["env", "sudo", "doas", "nohup", "time", "command", "exec", "xargs"])
214
+ // Wrappers whose flag consumes the next word, e.g. `sudo -u deploy git push`.
215
+ const WRAPPER_VALUE_FLAGS = new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"])
216
+ // git options that take a value, so the subcommand is the word after them.
217
+ const GIT_VALUE_FLAGS = new Set([
218
+ "-C",
219
+ "-c",
220
+ "--git-dir",
221
+ "--work-tree",
222
+ "--namespace",
223
+ "--super-prefix",
224
+ "--exec-path",
225
+ "--config-env",
226
+ ])
227
+ const GIT_GLOBAL_FLAGS = new Set([
228
+ "--bare",
229
+ "--no-pager",
230
+ "--paginate",
231
+ "--no-replace-objects",
232
+ "--no-optional-locks",
233
+ "--literal-pathspecs",
234
+ "--glob-pathspecs",
235
+ "--icase-pathspecs",
236
+ ])
237
+
238
+ /**
239
+ * Skip the wrappers and environment assignments in front of a real command.
240
+ * `env FOO=1 sudo -u deploy git -C /repo push` resolves at index 4.
241
+ * @param {string[]} tokens
242
+ * @returns {number} index of the first real command word, or tokens.length
243
+ */
244
+ function skipPrefixes(tokens) {
245
+ let i = 0
246
+ for (;;) {
247
+ const word = tokens[i]
248
+ if (word === undefined) return tokens.length
249
+ if (ENV_ASSIGN.test(word)) {
250
+ i++
251
+ continue
252
+ }
253
+ if (GIT_WRAPPERS.has(word)) {
254
+ i++
255
+ while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
256
+ const flag = tokens[i]
257
+ i++
258
+ if (WRAPPER_VALUE_FLAGS.has(flag) && i < tokens.length) i++
259
+ }
260
+ continue
261
+ }
262
+ return i
263
+ }
264
+ }
265
+
266
+ /**
267
+ * A segment with its prefixes removed, so a rule anchored at `^` sees the
268
+ * command it is actually about: `sudo rm -rf /` becomes `rm -rf /`.
269
+ * Segment rules are matched against this rather than the raw text, because
270
+ * without it every anchored rule is one `sudo` away from being decorative.
271
+ * @param {string} segment
272
+ * @returns {string} empty when the segment names no command
273
+ */
274
+ export function stripPrefixes(segment) {
275
+ const tokens = tokenize(segment)
276
+ const i = skipPrefixes(tokens)
277
+ if (i >= tokens.length) return ""
278
+ const rest = tokens.slice(i)
279
+ const slash = Math.max(rest[0].lastIndexOf("/"), rest[0].lastIndexOf("\\"))
280
+ if (slash !== -1) rest[0] = rest[0].slice(slash + 1)
281
+ return rest.join(" ")
282
+ }
283
+
284
+ /**
285
+ * Resolve the git subcommand a segment actually runs, or null if the segment
286
+ * does not run git.
287
+ * @param {string} segment
288
+ * @returns {string | null}
289
+ */
290
+ export function gitSubcommand(segment) {
291
+ const tokens = tokenize(segment)
292
+ let i = skipPrefixes(tokens)
293
+ if (i >= tokens.length) return null
294
+ if (!/(^|[\\/])git(\.exe)?$/i.test(tokens[i])) return null
295
+ i++
296
+ // Skip only git's own global options. A flag that belongs to the
297
+ // subcommand (`git log --grep`) ends the search, or `git log --grep push`
298
+ // would resolve to `push`.
299
+ while (i < tokens.length) {
300
+ const flag = tokens[i]
301
+ if (GIT_VALUE_FLAGS.has(flag)) {
302
+ i += 2
303
+ continue
304
+ }
305
+ if (/^--[A-Za-z][\w-]*=/.test(flag) || GIT_GLOBAL_FLAGS.has(flag)) {
306
+ i++
307
+ continue
308
+ }
309
+ break
310
+ }
311
+ return tokens[i] ?? null
312
+ }
313
+
314
+ const SHELL_BIN = /(^|[\\/])(sh|bash|zsh|dash|ksh)(\.exe)?$/i
315
+ const MAX_SHELL_DEPTH = 2
316
+
317
+ /**
318
+ * The command line hidden inside `sh -c "..."` or `eval "..."`.
319
+ * @param {string} segment
320
+ * @returns {string | null}
321
+ */
322
+ export function shellPayload(segment) {
323
+ const tokens = tokenize(segment)
324
+ if (tokens.length < 2) return null
325
+ if (tokens[0] === "eval") return tokens.slice(1).join(" ") || null
326
+ if (!SHELL_BIN.test(tokens[0])) return null
327
+ const c = tokens.findIndex((t) => /^-[a-zA-Z]*c$/.test(t))
328
+ if (c === -1 || c === tokens.length - 1) return null
329
+ return tokens.slice(c + 1).join(" ") || null
330
+ }
331
+
332
+ /** @type {Rule[]} */
333
+ export const BASH_RULES = [
334
+ {
335
+ name: "secret exfiltration via shell",
336
+ scope: "command",
337
+ test: /\b(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
338
+ why: "assigns a fetched value into a credential-named variable",
339
+ },
340
+ {
341
+ name: "remote delete over ssh",
342
+ scope: "command",
343
+ test: /\bssh\b[^|;&]*\brm\s+-[a-zA-Z]*[rf]/i,
344
+ why: "destructive command executed on another host",
345
+ },
346
+ {
347
+ name: "pipe remote script into a shell",
348
+ scope: "command",
349
+ test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*)\b/i,
350
+ why: "downloads and executes a script in one step",
351
+ },
352
+ // `git push` is ask-gated via permissions (`*` ask in the generated
353
+ // config), not guard-blocked. Publishing needs human approval, but the
354
+ // guard lets it through so opencode can prompt first.
355
+ {
356
+ name: "recursive delete",
357
+ scope: "segment",
358
+ test: /^rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*)\s+/i,
359
+ why: "rm with a recursive or force flag",
360
+ },
361
+ {
362
+ name: "chmod 777",
363
+ scope: "segment",
364
+ test: /^chmod\s+(-[a-zA-Z]+\s+)*777\b/,
365
+ why: "opens permissions on everything",
366
+ },
367
+ {
368
+ name: "history rewrite",
369
+ scope: "segment",
370
+ test: /^git\s+(reset\s+--hard|clean\s+-[a-zA-Z]*f|rebase\s+.*--force)/i,
371
+ why: "destroys work that only exists locally",
372
+ },
373
+ ]
374
+
375
+ /** @type {Rule[]} */
376
+ export const WRITE_RULES = [
377
+ {
378
+ // `unless` exists because the safe variants are the same shape as the
379
+ // dangerous ones: `.env.example` is a template meant to be committed, and
380
+ // protecting it would block ordinary work.
381
+ name: "write to a dotenv file",
382
+ test: /(^|[\\/])\.env(\.|$)/i,
383
+ unless: /(^|[\\/])\.env\.(example|sample|template|defaults?)\b/i,
384
+ why: "dotenv files hold live credentials",
385
+ },
386
+ {
387
+ name: "write to a credentials file",
388
+ test: /(^|[\\/])\.(npmrc|pypirc|netrc|git-credentials)(\.|$)/i,
389
+ unless: /(^|[\\/])\.(npmrc|pypirc)\.example$/i,
390
+ why: "registry and git credentials",
391
+ },
392
+ {
393
+ name: "write to a private key",
394
+ test: /(^|[\\/])id_(rsa|ed25519|ecdsa|dsa)\b/i,
395
+ why: "private key material",
396
+ },
397
+ ]
398
+
399
+ /**
400
+ * @param {Rule} rule
401
+ * @param {string} text
402
+ * @returns {boolean}
403
+ */
404
+ function matches(rule, text) {
405
+ if (rule.check) return rule.check(text)
406
+ if (!rule.test?.test(text)) return false
407
+ return !rule.unless?.test(text)
408
+ }
409
+
410
+ /**
411
+ * @param {string} command
412
+ * @param {Rule[]} [rules]
413
+ * @param {number} [depth]
414
+ * @returns {Hit | null}
415
+ */
416
+ export function checkBash(command, rules = BASH_RULES, depth = 0) {
417
+ if (!command || !command.trim()) return null
418
+ const parts = segments(command)
419
+ // Segment rules see the segment with its prefixes stripped, so `sudo rm -rf`
420
+ // and `env X=1 git reset --hard` are judged as the commands they run. The
421
+ // reported segment stays raw, because that is what the agent needs to see.
422
+ const normalized = parts.map((seg) => stripPrefixes(seg))
423
+ for (const rule of rules) {
424
+ if (rule.scope === "command") {
425
+ if (matches(rule, command)) return { name: rule.name, why: rule.why, segment: command }
426
+ continue
427
+ }
428
+ for (let i = 0; i < parts.length; i++) {
429
+ if (matches(rule, normalized[i])) return { name: rule.name, why: rule.why, segment: parts[i] }
430
+ }
431
+ }
432
+ // `bash -c "git push"` hides an entire command line behind one argument, so
433
+ // the rules have to be run again over the payload.
434
+ if (depth < MAX_SHELL_DEPTH) {
435
+ for (const seg of parts) {
436
+ const payload = shellPayload(seg)
437
+ if (!payload) continue
438
+ const hit = checkBash(payload, rules, depth + 1)
439
+ if (hit) return hit
440
+ }
441
+ }
442
+ return null
443
+ }
444
+
445
+ /**
446
+ * @param {string} target
447
+ * @param {Rule[]} [rules]
448
+ * @param {string} [root] the project directory, when the host knows it
449
+ * @returns {Hit | null}
450
+ */
451
+ export function checkWrite(target, rules = WRITE_RULES, root = "") {
452
+ if (!target) return null
453
+ for (const rule of rules) {
454
+ if (!matches(rule, target)) continue
455
+ return { name: rule.name, why: rule.why, segment: target }
456
+ }
457
+ // Scope check, last: a path outside the project is still fine if it is a
458
+ // secret file we are refusing for a stronger reason.
459
+ //
460
+ // The OS temp directory is allowed. This kit tells agents to put scratch
461
+ // files in a temp dir outside the workspace, so forbidding every path outside
462
+ // the project would block a convention the harness itself sets.
463
+ //
464
+ // An unknown root is allowed through rather than blocked. This rule is a
465
+ // scope guard, not a safety guard -- blocking every write when the host did
466
+ // not tell us the worktree would make the tool unusable, and the host
467
+ // (opencode) always passes one.
468
+ if (root && !insideRoot(target, root) && !insideRoot(target, tmpdir())) {
469
+ return {
470
+ name: "write outside the project",
471
+ why: `this project's files are the ones you were asked to change, and ${root} is where they live`,
472
+ segment: target,
473
+ }
474
+ }
475
+ return null
476
+ }
477
+
478
+ /** Names of every rule, for `oc-harness show` and documentation. */
479
+ export function ruleNames() {
480
+ return [...BASH_RULES, ...WRITE_RULES].map((r) => r.name)
481
+ }
@@ -0,0 +1,98 @@
1
+ // Local telemetry.
2
+ //
3
+ // Purpose is comparison, not surveillance: `oc-harness baseline` reads the
4
+ // opencode database for history, this records what the kit itself changes so
5
+ // the two can be diffed. It is a JSONL file in the project, never a network
6
+ // call, and it is gitignored.
7
+ //
8
+ // What is worth recording: context size per request (to see pruning working),
9
+ // latency by role and effort (to see tiering paying off), tool denials (to see
10
+ // the guard being useful rather than annoying), and provider rate limits.
11
+
12
+ import type { Plugin } from "@opencode-ai/plugin"
13
+ import { appendFileSync, mkdirSync, existsSync, statSync, renameSync } from "node:fs"
14
+ import { join } from "node:path"
15
+
16
+ const MAX_BYTES = 4 * 1024 * 1024
17
+
18
+ function logPath(dir: string): string {
19
+ return join(dir, "telemetry.jsonl")
20
+ }
21
+
22
+ /** Rotate by size so a long-running project cannot fill its disk. */
23
+ function rotate(file: string) {
24
+ try {
25
+ if (existsSync(file) && statSync(file).size > MAX_BYTES) {
26
+ renameSync(file, `${file}.1`)
27
+ }
28
+ } catch {
29
+ // Best effort.
30
+ }
31
+ }
32
+
33
+ function write(dir: string, record: Record<string, unknown>) {
34
+ try {
35
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
36
+ const file = logPath(dir)
37
+ rotate(file)
38
+ appendFileSync(file, JSON.stringify({ t: new Date().toISOString(), ...record }) + "\n")
39
+ } catch {
40
+ // Telemetry must never be load-bearing.
41
+ }
42
+ }
43
+
44
+ export const TelemetryPlugin: Plugin = async ({ worktree }) => {
45
+ const dir = join(worktree, ".space")
46
+ const started = Date.now()
47
+
48
+ return {
49
+ event: async ({ event }) => {
50
+ // Events are provider-specific and opencode may add shapes we have never
51
+ // seen. Anything unrecognised is ignored rather than trusted.
52
+ const type = (event as { type?: unknown } | undefined)?.type
53
+ if (typeof type !== "string") return
54
+
55
+ // Provider rate limits are the one signal that changes behaviour, so
56
+ // they get logged even though the event shape is provider-specific.
57
+ if (/error|rate|limit|quota/i.test(type)) {
58
+ write(dir, { kind: "provider-event", type, detail: truncate(safeStringify(event)) })
59
+ }
60
+ },
61
+
62
+ "tool.execute.after": async (input, output) => {
63
+ const text = typeof output?.output === "string" ? output.output : ""
64
+ if (text.includes("[opencode-harness-kit] blocked")) {
65
+ write(dir, { kind: "guard", tool: input.tool, rule: extractRule(text) })
66
+ return
67
+ }
68
+ const command = String((input.args as { command?: unknown })?.command ?? "")
69
+ if (input.tool === "bash" && /git push|rm -rf|chmod 777/.test(command)) {
70
+ write(dir, { kind: "guard-attempt", tool: input.tool, command: truncate(command) })
71
+ }
72
+ },
73
+
74
+ dispose: async () => {
75
+ write(dir, { kind: "session", uptimeMs: Date.now() - started })
76
+ },
77
+ }
78
+ }
79
+
80
+ function extractRule(output: string): string {
81
+ const m = output.match(/blocked:\s*([^\n]+)/)
82
+ return m ? m[1].trim() : "unknown"
83
+ }
84
+
85
+ function truncate(s: string, n = 300): string {
86
+ return s.length > n ? `${s.slice(0, n)}...` : s
87
+ }
88
+
89
+ /** Stringify without throwing on a cycle or a BigInt. */
90
+ function safeStringify(value: unknown): string {
91
+ try {
92
+ return JSON.stringify(value) ?? String(value)
93
+ } catch {
94
+ return String(value)
95
+ }
96
+ }
97
+
98
+ export default TelemetryPlugin