opencode-harness-kit 0.1.0 → 0.2.1

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.
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ // `oc-harness run` -- drive the space-plan -> build -> review -> finish loop
3
+ // without a human at the keyboard.
4
+ //
5
+ // The four skills already know how to do their own job. What was missing was
6
+ // the thing that decides *which* one runs next and *when to stop*. That is
7
+ // here, and it is deliberately small: it reads `.space/tasks.md`, hands one
8
+ // task at a time to `opencode run --agent <role>`, records the real verify
9
+ // output, and stops.
10
+ //
11
+ // What it will not do, ever:
12
+ // - push, open a PR, or cut a release. Publishing is a human decision, and an
13
+ // unattended loop that can do it is an unattended loop that will.
14
+ // - retry a task it already blocked, or start a task with no verify command.
15
+ // A task it cannot check is a task it cannot claim is done.
16
+ // - loop forever. Every stop condition is explicit and every one of them is
17
+ // reported, so a run that ends is never a mystery.
18
+
19
+ import { spawnSync } from "node:child_process"
20
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
21
+ import { join } from "node:path"
22
+
23
+ import {
24
+ parseTasks,
25
+ nextPending,
26
+ markTask,
27
+ taskPrompt,
28
+ summarize,
29
+ isBlocked,
30
+ } from "../tasks.js"
31
+
32
+ const TASKS_PATH = join(".space", "tasks.md")
33
+
34
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`
35
+ const stamp = (s) => `\x1b[2m${s}\x1b[0m`
36
+ const green = (s) => `\x1b[32m${s}\x1b[0m`
37
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`
38
+ const red = (s) => `\x1b[31m${s}\x1b[0m`
39
+
40
+ /**
41
+ * Run one command to completion and report what happened.
42
+ *
43
+ * Never throws: the caller has to be able to write the failure into the task
44
+ * file, which is the only record anyone gets of why a task did not land.
45
+ */
46
+ export function sh(command, { cwd, timeoutMs = 900_000 } = {}) {
47
+ const started = Date.now()
48
+ const res = spawnSync(command, {
49
+ shell: true,
50
+ cwd,
51
+ timeout: timeoutMs,
52
+ encoding: "utf8",
53
+ maxBuffer: 32 * 1024 * 1024,
54
+ env: { ...process.env, NO_COLOR: process.env.NO_COLOR ?? "" },
55
+ })
56
+ const stdout = `${res.stdout ?? ""}${res.stderr ?? ""}`.trimEnd()
57
+ return {
58
+ ok: res.status === 0,
59
+ status: res.status,
60
+ // A spawn that never started has no stderr; say so instead of implying the
61
+ // command ran and said nothing.
62
+ output: stdout || (res.error ? String(res.error.message) : ""),
63
+ seconds: Math.round((Date.now() - started) / 1000),
64
+ }
65
+ }
66
+
67
+ /** The tail of a verify run -- the last few lines are the verdict. */
68
+ export function tail(text, n = 12) {
69
+ const lines = String(text).split("\n").filter((l) => l.trim())
70
+ return lines.slice(-n).join("\n")
71
+ }
72
+
73
+ /**
74
+ * Ask `opencode` to do the task. Returns the transcript so a human can read what
75
+ * the agent actually did rather than being told about it.
76
+ */
77
+ export function invokeAgent(role, prompt, { cwd, timeoutMs }) {
78
+ const res = spawnSync(
79
+ "opencode",
80
+ ["run", "--agent", role, "--format", "json", prompt],
81
+ {
82
+ cwd,
83
+ encoding: "utf8",
84
+ timeout: timeoutMs,
85
+ maxBuffer: 64 * 1024 * 1024,
86
+ },
87
+ )
88
+ return {
89
+ ok: res.status === 0,
90
+ status: res.status,
91
+ output: `${res.stdout ?? ""}${res.stderr ?? ""}`.trimEnd(),
92
+ error: res.error ? String(res.error.message) : "",
93
+ }
94
+ }
95
+
96
+ /** The agent's own instructions for one task, plus what the loop expects back. */
97
+ export function buildPrompt(task) {
98
+ return [
99
+ `You are the space-build agent. Do exactly this one task and stop.`,
100
+ ``,
101
+ taskPrompt(task),
102
+ ``,
103
+ task.verify
104
+ ? `When the change is done, run this command yourself: ${task.verify}`
105
+ : `There is no verify command for this task. Do the work, say what you changed, and stop.`,
106
+ ``,
107
+ `Rules for this run:`,
108
+ ` - Change only the files named above. If the task is wrong or blocked, say so and stop.`,
109
+ ` - Never run git push, never create a PR, never publish. A human does that.`,
110
+ ` - Report the real output of the verify command. Never invent a passing result.`,
111
+ ].join("\n")
112
+ }
113
+
114
+ /**
115
+ * Run the loop.
116
+ *
117
+ * Stops at the first of: nothing pending, nothing verifiable, a blocked task, a
118
+ * failing verify, the round cap, or the time budget. Whichever it was gets
119
+ * printed, so a run that stops early always says why.
120
+ */
121
+ export function runLoop({
122
+ cwd = process.cwd(),
123
+ role = "build",
124
+ maxRounds = 20,
125
+ budgetMinutes = 120,
126
+ runAgent = invokeAgent,
127
+ runVerify = sh,
128
+ } = {}) {
129
+ const started = Date.now()
130
+ const budgetMs = budgetMinutes * 60_000
131
+ const log = []
132
+
133
+ if (!existsSync(join(cwd, TASKS_PATH))) {
134
+ return stop(log, {
135
+ reason: `no task file at ${TASKS_PATH}. Run space-plan first -- this loop needs a plan to walk.`,
136
+ code: 1,
137
+ })
138
+ }
139
+
140
+ let text = readFileSync(join(cwd, TASKS_PATH), "utf8")
141
+ let rounds = 0
142
+
143
+ for (;;) {
144
+ const parsed = parseTasks(text)
145
+ const task = nextPending(parsed)
146
+
147
+ if (!task) {
148
+ const s = summarize(parsed)
149
+ return stop(log, {
150
+ reason:
151
+ s.blocked > 0
152
+ ? `nothing left to run: ${s.done} done, ${s.blocked} blocked.`
153
+ : `every task is done (${s.done}/${s.total}).`,
154
+ code: s.blocked > 0 ? 2 : 0,
155
+ summary: s,
156
+ })
157
+ }
158
+
159
+ if (isBlocked(task)) {
160
+ return stop(log, { reason: `task is blocked and needs a person: ${task.title}`, code: 2 })
161
+ }
162
+
163
+ if (!task.verify) {
164
+ text = markTask(text, task, {
165
+ status: "blocked",
166
+ verifyOutput: "blocked by the loop: this task has no verify command, so nothing could prove it finished.",
167
+ })
168
+ writeFileSync(join(cwd, TASKS_PATH), text)
169
+ return stop(log, {
170
+ reason: `"${task.title}" has no verify command. It cannot be checked, so it cannot be run unattended. Add one, or do it yourself.`,
171
+ code: 2,
172
+ })
173
+ }
174
+
175
+ rounds++
176
+ if (rounds > maxRounds) {
177
+ return stop(log, { reason: `stopped at the round cap (${maxRounds}). Raise it with --max-rounds, or run fewer tasks.`, code: 2 })
178
+ }
179
+
180
+ const elapsed = Date.now() - started
181
+ if (elapsed >= budgetMs) {
182
+ return stop(log, { reason: `stopped at the time budget (${budgetMinutes} min, ${Math.round(elapsed / 60000)} min used).`, code: 2 })
183
+ }
184
+
185
+ console.log(`\n ${bold(`[${rounds}]`)} ${task.title}`)
186
+ if (task.files) console.log(` ${stamp(task.files)}`)
187
+
188
+ const agent = runAgent(role, buildPrompt(task), {
189
+ cwd,
190
+ timeoutMs: Math.max(60_000, budgetMs - (Date.now() - started)),
191
+ })
192
+ if (!agent.ok) {
193
+ const why = agent.error || agent.output || `exit ${agent.status}`
194
+ text = markTask(text, task, {
195
+ status: "blocked",
196
+ verifyOutput: `blocked by the loop: the ${role} agent failed.\n${tail(why)}`,
197
+ })
198
+ writeFileSync(join(cwd, TASKS_PATH), text)
199
+ return stop(log, {
200
+ reason: `the ${role} agent failed on "${task.title}": ${tail(why, 3).split("\n")[0]}`,
201
+ code: 3,
202
+ })
203
+ }
204
+
205
+ // The agent's claim is not the evidence. Re-run the verify here, because a
206
+ // loop that trusts the agent's own word about whether it passed is a loop
207
+ // that reports success it never checked.
208
+ console.log(` ${stamp(`verify: ${task.verify}`)}`)
209
+ const check = runVerify(task.verify, { cwd, timeoutMs: 1800_000 })
210
+ log.push({ round: rounds, task: task.title, ok: check.ok, seconds: check.seconds })
211
+
212
+ if (!check.ok) {
213
+ text = markTask(text, task, {
214
+ status: "blocked",
215
+ verifyOutput: `verify failed (exit ${check.status}, ${check.seconds}s)\n${tail(check.output)}`,
216
+ })
217
+ writeFileSync(join(cwd, TASKS_PATH), text)
218
+ return stop(log, {
219
+ reason: `verify failed on "${task.title}". Marked blocked -- the real output is in ${TASKS_PATH}.`,
220
+ code: 4,
221
+ })
222
+ }
223
+
224
+ text = markTask(text, task, {
225
+ status: "done",
226
+ verifyOutput: `exit ${check.status} in ${check.seconds}s\n${tail(check.output)}`,
227
+ })
228
+ writeFileSync(join(cwd, TASKS_PATH), text)
229
+ console.log(` ${green("done")} ${stamp(`${check.seconds}s`)}`)
230
+ }
231
+ }
232
+
233
+ function stop(log, { reason, code, summary }) {
234
+ return { reason, code: code ?? 0, rounds: log.length, log, summary }
235
+ }
236
+
237
+ export { TASKS_PATH }
package/src/profile.js CHANGED
@@ -17,6 +17,7 @@ export const ROOT = resolve(HERE, "..")
17
17
  /**
18
18
  * @typedef {"zero-retention" | "may-train" | "unknown"} Retention
19
19
  * @typedef {string | null} EffortRequest
20
+ * @typedef {{ model: string, levels?: string[] }} RoleModel
20
21
  * @typedef {object} Profile
21
22
  * @property {string} id
22
23
  * @property {string} [title]
@@ -31,6 +32,7 @@ export const ROOT = resolve(HERE, "..")
31
32
  * structuredOutput?: boolean, attachment?: boolean }} [supports]
32
33
  * @property {{ levels?: string[], default?: string | null, reserveShare?: number }} [effort]
33
34
  * @property {Record<string, EffortRequest>} [roles]
35
+ * @property {Record<string, RoleModel>} [roleModels]
34
36
  * @property {{ enabled?: boolean, keepRecentToolCalls?: number,
35
37
  * keepRecentTurns?: number, keepUserMessages?: boolean }} [prune]
36
38
  * @property {string[]} [fallback]
@@ -135,6 +137,35 @@ export function validate(p, path = "<memory>") {
135
137
  `${where}: effort.reserveShare must be between 0 and 0.5`,
136
138
  )
137
139
  }
140
+ for (const [role, rm] of Object.entries(p.roleModels ?? {})) {
141
+ if (!SPLITTABLE_ROLES.includes(role)) {
142
+ throw new ProfileError(
143
+ `${where}: roleModels.${role} is not one of ${SPLITTABLE_ROLES.join(", ")}`,
144
+ )
145
+ }
146
+ // resolveRoles walks `roles`, so a split for a role nobody asks for never
147
+ // reaches an agent. That reads as "split installed" while doing nothing.
148
+ if (!(role in (p.roles ?? {}))) {
149
+ throw new ProfileError(
150
+ `${where}: roleModels.${role} has no matching entry in roles, so it would never apply`,
151
+ )
152
+ }
153
+ if (!rm?.model || typeof rm.model !== "string") {
154
+ throw new ProfileError(`${where}: roleModels.${role} needs a model`)
155
+ }
156
+ if (rm.levels !== undefined && !Array.isArray(rm.levels)) {
157
+ throw new ProfileError(
158
+ `${where}: roleModels.${role}.levels must be an array`,
159
+ )
160
+ }
161
+ // retention.yml is the only place retention is decided, so a role model
162
+ // missing from it is a profile we cannot honestly write a header for.
163
+ if (!retentionFor(rm.model)) {
164
+ throw new ProfileError(
165
+ `${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`,
166
+ )
167
+ }
168
+ }
138
169
  }
139
170
 
140
171
  // Canonical ordering for reasoning effort, used to snap a requested level onto
@@ -178,15 +209,45 @@ export function resolveEffort(levels, requested) {
178
209
  return best ?? levels[levels.length - 1] ?? null
179
210
  }
180
211
 
212
+ /**
213
+ * Model and effort levels for one role, or null when the role stays on the
214
+ * profile's base model.
215
+ *
216
+ * @param {Profile} p
217
+ * @param {string} role
218
+ * @returns {{ model: string, levels: string[] } | null}
219
+ */
220
+ export function resolveRoleModel(p, role) {
221
+ const rm = p.roleModels?.[role]
222
+ if (!rm) return null
223
+ return { model: rm.model, levels: rm.levels ?? [] }
224
+ }
225
+
226
+ /**
227
+ * Effort levels a role's own model offers. Falls back to the profile's when the
228
+ * role is not split out, so an unsplit profile behaves exactly as before.
229
+ *
230
+ * @param {Profile} p
231
+ * @param {string} role
232
+ * @returns {string[]}
233
+ */
234
+ function levelsForRole(p, role) {
235
+ const rm = resolveRoleModel(p, role)
236
+ if (rm) return rm.levels
237
+ return p.effort?.levels ?? []
238
+ }
239
+
181
240
  /**
182
241
  * @param {Profile} p
183
242
  * @returns {Record<string, string | null>}
184
243
  */
185
244
  export function resolveRoles(p) {
186
- const levels = p.effort?.levels ?? []
187
245
  const out = /** @type {Record<string, string | null>} */ ({})
188
246
  for (const [role, want] of Object.entries(p.roles ?? {})) {
189
- out[role] = resolveEffort(levels, want)
247
+ // Resolve against the levels of the model this role actually runs on. A
248
+ // role asking for `max` on a model topping out at `xhigh` gets `xhigh`,
249
+ // which is only correct if the profile knows the role's real model.
250
+ out[role] = resolveEffort(levelsForRole(p, role), want)
190
251
  }
191
252
  return out
192
253
  }
@@ -201,10 +262,39 @@ export function resolveDefaultVariant(p) {
201
262
  * tool output. Scaled from the model, never hardcoded.
202
263
  * @param {Profile} p @returns {number}
203
264
  */
265
+ // The roles a profile may split onto another model. Kept in step with
266
+ // `roleModels` in profiles/schema.json, which nothing validates at runtime.
267
+ const SPLITTABLE_ROLES = ["plan", "build", "reviewer", "locate", "verify"]
268
+
269
+ /**
270
+ * The model whose limits govern context management: the one with the widest
271
+ * input cap, not necessarily the base model.
272
+ *
273
+ * A split profile derives `reserved` and `prune` from a single `limits`, but
274
+ * the roles do not share it. Sizing for the narrowest input cap silently
275
+ * throttles every wider role to under half its real window, which is the whole
276
+ * point of running them on a bigger model. Sizing for the widest means no role
277
+ * can be under-reserved, which is the direction that has to be safe.
278
+ *
279
+ * @param {Profile} p
280
+ * @returns {number}
281
+ */
282
+ export function inputCap(p) {
283
+ let cap = p.limits.input ?? p.limits.context
284
+ for (const s of roleSplitModels(p)) {
285
+ const other = s.limits?.input ?? s.limits?.context
286
+ if (typeof other === "number" && other > cap) cap = other
287
+ }
288
+ return cap
289
+ }
290
+
291
+ /**
292
+ * @param {Profile} p
293
+ * @returns {number}
294
+ */
204
295
  export function reservedTokens(p) {
205
- const input = p.limits.input ?? p.limits.context
206
296
  const share = p.effort?.reserveShare ?? 0.12
207
- return Math.max(8000, Math.round(input * share))
297
+ return Math.max(8000, Math.round(inputCap(p) * share))
208
298
  }
209
299
 
210
300
  /**
@@ -212,7 +302,7 @@ export function reservedTokens(p) {
212
302
  * @param {Profile} p @returns {number}
213
303
  */
214
304
  export function pruneThreshold(p) {
215
- return (p.limits.input ?? p.limits.context) - reservedTokens(p)
305
+ return inputCap(p) - reservedTokens(p)
216
306
  }
217
307
 
218
308
  /**
@@ -296,6 +386,67 @@ export function retentionFor(id) {
296
386
  return table[id] ?? table[id.includes("/") ? id : `opencode/${id}`]
297
387
  }
298
388
 
389
+ /**
390
+ * Every model a profile can put behind the user, base plus any split roles.
391
+ *
392
+ * @param {Profile} p
393
+ * @returns {string[]}
394
+ */
395
+ export function modelsInPlay(p) {
396
+ const out = [p.model]
397
+ for (const rm of Object.values(p.roleModels ?? {})) {
398
+ if (!out.includes(rm.model)) out.push(rm.model)
399
+ }
400
+ return out
401
+ }
402
+
403
+ /**
404
+ * The split roles, each with the limits of the model that role actually runs
405
+ * on. A role on a different model has a different output cap, and the header
406
+ * has to say so: reading "output cap 524,288" and assuming `build` can spend
407
+ * all of it is wrong whenever build is split onto a smaller model.
408
+ *
409
+ * @param {Profile} p
410
+ * @returns {{ role: string, model: string, levels: string[], limits: Profile["limits"] | null }[]}
411
+ */
412
+ export function roleSplitModels(p) {
413
+ return Object.entries(p.roleModels ?? {}).map(([role, rm]) => {
414
+ let limits = null
415
+ try {
416
+ limits = loadProfile(rm.model)?.limits ?? null
417
+ } catch {
418
+ limits = null
419
+ }
420
+ return { role, model: rm.model, levels: rm.levels ?? [], limits }
421
+ })
422
+ }
423
+
424
+ // Ordered safest-first: a profile is only as private as its least private model.
425
+ const RETENTION_SEVERITY = ["zero-retention", "unknown", "may-train"]
426
+
427
+ /**
428
+ * The retention that actually applies once every role is considered, which is
429
+ * what a config header has to print. `retention.yml` is the source of truth.
430
+ *
431
+ * @param {Profile} p
432
+ * @returns {{ retention: string, worst: string | null }}
433
+ */
434
+ export function effectiveRetention(p) {
435
+ let worst = null
436
+ let retention = "zero-retention"
437
+ for (const id of modelsInPlay(p)) {
438
+ const declared = retentionFor(id)?.retention ?? "unknown"
439
+ if (declared === "may-train") {
440
+ return { retention: "may-train", worst: id }
441
+ }
442
+ if (!worst || RETENTION_SEVERITY.indexOf(declared) > RETENTION_SEVERITY.indexOf(worst)) {
443
+ worst = declared
444
+ }
445
+ retention = worst
446
+ }
447
+ return { retention, worst }
448
+ }
449
+
299
450
  /** Where opencode keeps its config, honouring the same env var it does. */
300
451
  export function configFilePath() {
301
452
  const dir =
@@ -304,14 +455,118 @@ export function configFilePath() {
304
455
  return join(dir, "opencode.jsonc")
305
456
  }
306
457
 
458
+ /**
459
+ * Parse JSONC the way OpenCode does: comments and trailing commas are legal.
460
+ *
461
+ * A regex that strips `//` cannot be used here. The generated config carries
462
+ * `"$schema": "https://opencode.ai/config.json"`, so a naive strip corrupts the
463
+ * URL; and a trailing comma -- ordinary in a hand-edited `.jsonc` -- makes
464
+ * `JSON.parse` throw, which used to surface as an empty object and silently
465
+ * dropped every hand-tuned key in `oc-harness use`. This walks the text once,
466
+ * tracking string state, so both are handled correctly.
467
+ *
468
+ * @param {string} text
469
+ * @returns {Record<string, unknown> | null} null when the text is not valid JSONC
470
+ */
471
+ export function parseJsonc(text) {
472
+ let out = ""
473
+ let i = 0
474
+ let inString = false
475
+ let escaped = false
476
+ /** True once a `,` has been emitted that a `}` or `]` may turn out to cancel. */
477
+ let pendingComma = false
478
+
479
+ const n = text.length
480
+ while (i < n) {
481
+ const ch = text[i]
482
+
483
+ if (inString) {
484
+ out += ch
485
+ if (escaped) escaped = false
486
+ else if (ch === "\\") escaped = true
487
+ else if (ch === '"') inString = false
488
+ i++
489
+ continue
490
+ }
491
+
492
+ if (ch === '"') {
493
+ if (pendingComma) {
494
+ out += ","
495
+ pendingComma = false
496
+ }
497
+ out += ch
498
+ inString = true
499
+ i++
500
+ continue
501
+ }
502
+
503
+ if (ch === "/" && text[i + 1] === "/") {
504
+ while (i < n && text[i] !== "\n") i++
505
+ continue
506
+ }
507
+
508
+ if (ch === "/" && text[i + 1] === "*") {
509
+ i += 2
510
+ while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++
511
+ i += 2
512
+ continue
513
+ }
514
+
515
+ if (ch === ",") {
516
+ // A second comma before any value is malformed. Repairing `,,,` into one
517
+ // separator would mean guessing at a file we are about to rewrite, which
518
+ // is the exact failure this parser exists to prevent.
519
+ if (pendingComma) return null
520
+ // Hold the comma back: if the next significant character closes an
521
+ // object or array, this comma was illegal trailing punctuation.
522
+ pendingComma = true
523
+ i++
524
+ continue
525
+ }
526
+
527
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
528
+ out += ch
529
+ i++
530
+ continue
531
+ }
532
+
533
+ if (ch === "}" || ch === "]") {
534
+ // A closer cancels any comma held back before it.
535
+ pendingComma = false
536
+ out += ch
537
+ i++
538
+ continue
539
+ }
540
+
541
+ // Every other significant character settles a comma held back across
542
+ // whitespace or a comment. Miss this on the string branch below and a
543
+ // comment after `1, // note` swallows the separator entirely.
544
+ if (pendingComma) {
545
+ out += ","
546
+ pendingComma = false
547
+ }
548
+ out += ch
549
+ i++
550
+ }
551
+
552
+ try {
553
+ const parsed = JSON.parse(out)
554
+ return parsed && typeof parsed === "object" ? parsed : null
555
+ } catch {
556
+ // An empty or comments-only file is not malformed JSONC, it is an empty
557
+ // config, and `oc-harness use` has to be able to fill one in. Only report
558
+ // a parse failure when there was actually something to parse.
559
+ if (!out.trim()) return {}
560
+ return null
561
+ }
562
+ }
563
+
307
564
  /**
308
565
  * Read the active profile id from the config file on disk.
309
566
  *
310
- * This exists because the `config` hook cannot be the source. OpenCode's
311
- * Config schema sets `additionalProperties: false`, so a custom `harness` key
312
- * written into the file is stripped from the object handed to the hook. The
313
- * file survives; the in-memory config does not. Verified against the 1.18.35
314
- * schema.
567
+ * Returns null when the file is absent, unreadable, or not valid JSONC. Callers
568
+ * fall back to the profile named in the `config` hook, so a null here degrades
569
+ * to the default profile rather than failing.
315
570
  *
316
571
  * @returns {string | null}
317
572
  */
@@ -322,10 +577,10 @@ export function profileIdFromConfig() {
322
577
  const file = configFilePath()
323
578
  if (!existsSync(file)) return null
324
579
  try {
325
- const text = readFileSync(file, "utf8")
326
- const json = text.slice(text.indexOf("{"))
327
- const parsed = JSON.parse(json)
328
- const id = parsed?.harness?.profile
580
+ const parsed = parseJsonc(readFileSync(file, "utf8"))
581
+ const harness = parsed?.harness
582
+ const id =
583
+ harness && typeof harness === "object" ? /** @type {{profile?: unknown}} */ (harness).profile : null
329
584
  return typeof id === "string" && id ? id : null
330
585
  } catch {
331
586
  return null
package/src/rules.js CHANGED
@@ -349,24 +349,9 @@ export const BASH_RULES = [
349
349
  test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*)\b/i,
350
350
  why: "downloads and executes a script in one step",
351
351
  },
352
- {
353
- name: "git push with force",
354
- scope: "segment",
355
- // A dry run publishes nothing, so it is not the thing being gated.
356
- check: (seg) =>
357
- gitSubcommand(seg) === "push" &&
358
- tokenize(seg).some((w) => w === "--force" || w === "-f" || w.startsWith("--force")),
359
- why: "overwrites published history",
360
- },
361
- {
362
- name: "git push",
363
- scope: "segment",
364
- // Flags are matched as whole words: a `-f` in `my-feature-f` is a branch
365
- // name, not a force flag.
366
- check: (seg) =>
367
- gitSubcommand(seg) === "push" && !tokenize(seg).some((w) => w === "--dry-run" || w === "-n"),
368
- why: "publishing is a human decision",
369
- },
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.
370
355
  {
371
356
  name: "recursive delete",
372
357
  scope: "segment",