@soleur/opencode 1.0.0 → 3.354.2

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 (92) hide show
  1. package/commands/go.md +6 -5
  2. package/commands/sync.md +2 -2
  3. package/install.js +6 -85
  4. package/lib/harness.ts +1 -1
  5. package/lib/heartbeat-manifest.ts +19 -0
  6. package/lib/pr-merge-poll.ts +2 -2
  7. package/lib/workflow-fidelity.ts +2 -2
  8. package/opencode/INSTRUCTIONS.md +19 -0
  9. package/opencode/plugin.ts +343 -18
  10. package/package.json +1 -1
  11. package/scripts/admin-merge-ready.sh +1 -1
  12. package/scripts/admin-merge-ready.test.sh +0 -0
  13. package/scripts/affected-tests.sh +0 -0
  14. package/scripts/alpha-metrics.sh +0 -0
  15. package/scripts/alpha-metrics.test.sh +0 -0
  16. package/scripts/check-red-on-main.sh +0 -0
  17. package/scripts/ci-head-verdict.sh +54 -6
  18. package/scripts/cloud-detect.sh +0 -0
  19. package/scripts/delegation-verify.sh +0 -0
  20. package/scripts/delegation-verify.test.sh +0 -0
  21. package/scripts/deploy-arm.sh +0 -0
  22. package/scripts/domain-model-drift.sh +0 -0
  23. package/scripts/emit-decision.sh +0 -0
  24. package/scripts/emit-decision.test.sh +0 -0
  25. package/scripts/ext-filter-divergence.sh +0 -0
  26. package/scripts/grok-fidelity-gate.sh +0 -0
  27. package/scripts/grok-pre-push-gate.sh +0 -0
  28. package/scripts/install-opencode-adapter.ts +118 -129
  29. package/scripts/lib/affected-tests/common.sh +0 -0
  30. package/scripts/lib/affected-tests/go.sh +0 -0
  31. package/scripts/lib/affected-tests/js.sh +0 -0
  32. package/scripts/lib/affected-tests/nx-turbo.sh +0 -0
  33. package/scripts/lib/affected-tests/python.sh +0 -0
  34. package/scripts/lib/session-state.sh +0 -0
  35. package/scripts/lib/tmp-classify.sh +0 -0
  36. package/scripts/monitor-pr-checks.sh +1 -1
  37. package/scripts/pipeline-tally.sh +0 -0
  38. package/scripts/plugin-version.sh +0 -0
  39. package/scripts/precommit-guard.sh +3 -1
  40. package/scripts/precommit-guard.test.sh +0 -0
  41. package/scripts/render-c4-model.sh +0 -0
  42. package/scripts/resolve-regenerable-conflicts.sh +0 -0
  43. package/scripts/run-in-pid-namespace.sh +0 -0
  44. package/scripts/setup-ralph-loop.sh +0 -0
  45. package/scripts/sync-npm-package.ts +74 -87
  46. package/scripts/sync-opencode-adapter.ts +19 -0
  47. package/scripts/sync-pr-behind.sh +183 -70
  48. package/scripts/taste-profile-update.sh +0 -0
  49. package/scripts/taste-profile-update.test.sh +0 -0
  50. package/scripts/write-budget-marker.sh +0 -0
  51. package/skills/deepen-plan/SKILL.md +1 -1
  52. package/skills/deploy/references/hetzner-setup.md +5 -3
  53. package/skills/drain-prs/SKILL.md +3 -2
  54. package/skills/fix-issue/SKILL.md +8 -8
  55. package/skills/fix-issue/references/agent-authored-exclusion.md +4 -2
  56. package/skills/fix-issue/references/exclude-label-jq-snippet.md +4 -3
  57. package/skills/merge-pr/SKILL.md +5 -3
  58. package/skills/model-launch-review/SKILL.md +4 -4
  59. package/skills/one-shot/SKILL.md +1 -1
  60. package/skills/plan/SKILL.md +1 -1
  61. package/skills/plan/references/plan-sharp-edges.md +7 -1
  62. package/skills/postmerge/SKILL.md +2 -2
  63. package/skills/postmerge/references/deploy-status-debugging.md +3 -0
  64. package/skills/review/SKILL.md +14 -15
  65. package/skills/review/references/review-evidence-comment.md +36 -0
  66. package/skills/review/references/risk-tier-and-fix-rounds.md +2 -0
  67. package/skills/ship/SKILL.md +14 -13
  68. package/skills/ship/references/cleanup-merged-archival.md +39 -0
  69. package/skills/ship/references/delegated-act-authoring-form.md +7 -0
  70. package/skills/ship/references/epic-close-sweep.md +62 -0
  71. package/skills/ship/references/merge-queue-dequeue.md +1 -1
  72. package/skills/ship/references/operator-holds.md +30 -0
  73. package/skills/ship/references/queue-mode.md +1 -1
  74. package/skills/ship/references/ready-run-wait.md +3 -3
  75. package/skills/test-fix-loop/SKILL.md +1 -1
  76. package/skills/verification-scaffold/SKILL.md +73 -0
  77. package/skills/verification-scaffold/references/gitleaks.toml.template +13 -0
  78. package/skills/verification-scaffold/references/guardrail-audit.md +156 -0
  79. package/skills/verification-scaffold/references/guards/block-stash-hook.sh.template +72 -0
  80. package/skills/verification-scaffold/references/guards/commit-on-main-hook.sh.template +82 -0
  81. package/skills/verification-scaffold/references/guards/orphan-test-check.sh.template +102 -0
  82. package/skills/verification-scaffold/references/guards/precommit-guard.sh.template +115 -0
  83. package/skills/verification-scaffold/references/guards/secret-scan-hook.sh.template +176 -0
  84. package/skills/verification-scaffold/references/guards/verification-gates.yml.template +49 -0
  85. package/skills/verification-scaffold/references/guards/wrapper-boundary.sh.template +47 -0
  86. package/skills/verification-scaffold/references/guards.tsv +21 -0
  87. package/skills/verification-scaffold/references/verification-readme.template +55 -0
  88. package/skills/verification-scaffold/scripts/verification-scaffold.sh +785 -0
  89. package/skills/verification-scaffold/test/bite-proof.test.sh +159 -0
  90. package/skills/verification-scaffold/test/generator.test.sh +319 -0
  91. package/skills/verification-scaffold/test/manifest.test.sh +144 -0
  92. package/skills/work/SKILL.md +2 -1
@@ -26,11 +26,22 @@
26
26
  * - `effect = "ask"` + `message` ⇒ the tool stays `running` awaiting a decision. A real
27
27
  * prompt, so a guard's `ask` is delivered as an `ask` and NOT downgraded to a refusal.
28
28
  *
29
- * `ctx.tool.hook("execute.before")` is deliberately **not** bound. It fires *before* the
30
- * permission hook, and its only refusal channel is a thrown error, so binding it would let a
31
- * `deny`-or-`ask` both collapse into a throw and preempt the real ask behind it. It is a fine
32
- * surface (a throw there was measured to block the call and reach the model) and simply the
33
- * wrong one for a two-valued guard.
29
+ * `ctx.tool.hook("execute.before")` is bound **as a capture, never as a guard** (#10819). It fires
30
+ * *before* the permission hook, and its only refusal channel is a thrown error, so a `deny`-or-`ask`
31
+ * there would collapse into a throw and preempt the real ask behind it. What it *can* do without
32
+ * deciding anything is publish `input.workdir`, the one value the permission event does not carry:
33
+ * a `shell` call with `workdir=$HOME` was judged against the session directory, so `rm -rf *` was
34
+ * allowed that the destructive guard **denies** in the directory the command asked for. The
35
+ * permission hook consumes that capture by call key and passes it as the envelope `cwd`.
36
+ *
37
+ * INVARIANTS. (1) One predicate, `decidesBash`, answers "is this a call the Bash guards see" — and
38
+ * the envelope, the capture and the consume all ask it; a second copy of that question is how a
39
+ * wire-name change reopens the hole at one site only. (2) The capture runs for every Bash call,
40
+ * including one with no workdir, so a lookup miss means "the tool hook did not fire" rather than
41
+ * "no workdir". (3) A capture is never deleted on consume, so a second evaluation of the same call
42
+ * cannot fall back to the session directory. (4) A degraded correlation is announced; a captured
43
+ * workdir that cannot be used escalates to `ask` instead of judging somewhere the operator did not
44
+ * ask about.
34
45
  *
35
46
  * WHAT IT CANNOT DO (measurement §6.6). Every V2 hook surface communicates only by mutating
36
47
  * the event object it is handed, and `ctx.event.subscribe` is a one-way iterator with no
@@ -39,7 +50,7 @@
39
50
  */
40
51
 
41
52
  import { spawnSync } from "node:child_process";
42
- import { appendFileSync, existsSync, lstatSync, readFileSync } from "node:fs";
53
+ import { appendFileSync, existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
43
54
  import { join, resolve } from "node:path";
44
55
 
45
56
  /** Replaced in place by `install-opencode-adapter.ts` with the checkout path. */
@@ -113,6 +124,13 @@ export interface ScriptRun {
113
124
  status: number | null;
114
125
  stdout: string;
115
126
  stderr: string;
127
+ /**
128
+ * The spawn error's code, when the process could not be started at all (`ENOENT`, `EACCES`,
129
+ * `ENOTDIR`). `spawnSync` reports these as `status: null` with the reason only in `error`, so
130
+ * discarding it makes an unenterable working directory indistinguishable from a moved checkout
131
+ * and sends the operator to re-run an installer that will answer `ok`.
132
+ */
133
+ error?: string;
116
134
  }
117
135
 
118
136
  export type ScriptRunner = (script: string, stdin: string, cwd: string) => ScriptRun;
@@ -131,6 +149,7 @@ export function runScript(script: string, stdin: string, cwd: string): ScriptRun
131
149
  status: result.status,
132
150
  stdout: typeof result.stdout === "string" ? result.stdout : "",
133
151
  stderr: typeof result.stderr === "string" ? result.stderr : "",
152
+ ...(result.error === undefined ? {} : { error: result.error.code ?? result.error.message }),
134
153
  };
135
154
  }
136
155
 
@@ -174,6 +193,18 @@ export function claudeToolName(tool: string): string {
174
193
  return CLAUDE_TOOL_NAME[tool] ?? tool;
175
194
  }
176
195
 
196
+ /**
197
+ * Does a call with this wire name reach the Bash guards?
198
+ *
199
+ * One answer for three askers — the envelope constructor, the `execute.before` capture and the
200
+ * `permission.evaluate` consume. Two copies of this question is the rot that would let the
201
+ * measured wire name (`shell`) or its V1 spelling (`bash`) be handled at one site only, which is
202
+ * silently the pre-fix behaviour: capture with no consume, or consume with no capture.
203
+ */
204
+ export function decidesBash(tool: string): boolean {
205
+ return claudeToolName(tool) === "Bash";
206
+ }
207
+
177
208
  /**
178
209
  * The Claude PreToolUse envelope the guards parse. Returns `undefined` for any tool the guards
179
210
  * cannot decide, so no process is spawned for a call no guard would look at. `tool_input` is
@@ -182,12 +213,64 @@ export function claudeToolName(tool: string): string {
182
213
  * turn that ask into an allow.
183
214
  */
184
215
  export function guardEnvelope(tool: string, input: unknown, cwd: string): string | undefined {
216
+ if (!decidesBash(tool)) return undefined;
185
217
  const name = claudeToolName(tool);
186
- if (name !== "Bash") return undefined;
187
218
  const toolInput = input !== null && typeof input === "object" && !Array.isArray(input) ? input : {};
188
219
  return JSON.stringify({ hook_event_name: "PreToolUse", tool_name: name, tool_input: toolInput, cwd });
189
220
  }
190
221
 
222
+ /**
223
+ * What a `tool.execute.before` event said about the directory its command will start in.
224
+ *
225
+ * `absent` and `unusable` are deliberately different: the first is the normal case (no `workdir`
226
+ * was supplied, so the session directory is correct), the second means the operator asked for a
227
+ * directory this plugin cannot vouch for — and judging in the session directory instead is exactly
228
+ * the hole #10819 reports, one order of magnitude rarer.
229
+ */
230
+ export type WorkdirCapture =
231
+ | { readonly kind: "absent" }
232
+ | { readonly kind: "usable"; readonly path: string }
233
+ | { readonly kind: "unusable"; readonly raw: string };
234
+
235
+ /**
236
+ * Resolve the `workdir` a tool call carries. Total by construction: it never throws and never
237
+ * returns a path it has not confirmed is a directory.
238
+ *
239
+ * A relative value is resolved against `baseDir` (the session directory) — measured, not assumed:
240
+ * with the session moved to a worktree while the OpenCode process kept its launch cwd in the bare
241
+ * checkout, `workdir: "plugins/soleur"` ran in the worktree. `realpathSync` then yields the physical
242
+ * directory the shell's `chdir` reaches, so `path.resolve`'s lexical answer for a symlinked or
243
+ * `..`-containing value cannot disagree with where the command actually runs.
244
+ */
245
+ export function resolveWorkdir(input: unknown, baseDir: string): WorkdirCapture {
246
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return { kind: "absent" };
247
+ const raw = (input as Record<string, unknown>)["workdir"];
248
+ if (raw === undefined || raw === null) return { kind: "absent" };
249
+ // A present but non-string value is a *shape* violation, not "no workdir was given": treating it
250
+ // as absent would judge the call in the session directory without saying so, which is #10819's
251
+ // silence one shape over. `null`/absent stay the normal case because a runtime may send either.
252
+ if (typeof raw !== "string") return { kind: "unusable", raw: clip(String(raw)) };
253
+ if (raw === "") return { kind: "absent" };
254
+ try {
255
+ // `resolve` already returns an absolute argument unchanged, and `statSync` throws for a missing
256
+ // path, so neither `isAbsolute` nor `existsSync` earns a branch here.
257
+ const absolute = resolve(baseDir, raw);
258
+ if (!statSync(absolute).isDirectory()) return { kind: "unusable", raw: clip(raw) };
259
+ return { kind: "usable", path: realpathSync(absolute) };
260
+ } catch {
261
+ return { kind: "unusable", raw: clip(raw) };
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Clip wire-controlled text to a length an operator can read, after stripping what would forge a
267
+ * record: `field()` removes the control characters, and the bound removes a megabyte of it.
268
+ */
269
+ export function clip(value: string, max = 160): string {
270
+ const flat = value.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "_");
271
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
272
+ }
273
+
191
274
  export interface GuardDecision {
192
275
  guard: string;
193
276
  decision: "deny" | "ask";
@@ -234,7 +317,24 @@ export function decideBash(
234
317
  const parsed = parseGuardOutput(run.stdout);
235
318
  if (parsed !== undefined) return { guard: guard.id, ...parsed };
236
319
  if (run.status !== 0) {
237
- const how = run.status === null ? "timed out or could not be spawned" : `exited ${run.status}`;
320
+ // A directory that cannot be entered and a guard that could not start share `status: null`, so
321
+ // the error code decides which sentence the operator reads. Only a *directory* fault gets the
322
+ // directory sentence: `ETIMEDOUT`/`ENOBUFS` are the guard's own limits, and `ENOENT` with an
323
+ // existing cwd is a missing interpreter or script — telling that operator to create a
324
+ // directory would send them to the wrong fix.
325
+ if (run.error !== undefined && (run.error === "EACCES" || run.error === "ENOTDIR" || !existsSync(cwd))) {
326
+ return {
327
+ guard: GUARD_RUN_FAILURE,
328
+ decision: "deny",
329
+ reason: `the guard could not be started in \`${clip(cwd)}\` (${run.error}): ${script}. The working directory this call asked for is not enterable, so the command was NOT judged and NOT run. Create that directory and re-issue, or drop the \`workdir\` to be judged in the session directory.`,
330
+ };
331
+ }
332
+ const how =
333
+ run.status === null
334
+ ? run.error === undefined
335
+ ? "timed out or could not be spawned"
336
+ : `could not be spawned (${run.error})`
337
+ : `exited ${run.status}`;
238
338
  return {
239
339
  guard: GUARD_RUN_FAILURE,
240
340
  decision: "deny",
@@ -342,14 +442,50 @@ export function compactionDirective(): string {
342
442
  /** Optional observation target. Unset means the completion observer writes nothing. */
343
443
  const EVENT_LOG = process.env["SOLEUR_OPENCODE_EVENT_LOG"];
344
444
 
445
+ /**
446
+ * The default writer. A failed append is announced once and then shrugged off: an unwritable log
447
+ * path (a parent directory that does not exist is the common shape of that mistake) must not make
448
+ * `setup` reject, because a rejected setup leaves **no hook bound at all** — no guards, no capture,
449
+ * nothing — to protect a diagnostic channel.
450
+ */
451
+ const defaultWriter = (() => {
452
+ let broken = false;
453
+ return (line: string): void => {
454
+ if (EVENT_LOG === undefined) return;
455
+ try {
456
+ appendFileSync(EVENT_LOG, `${line}\n`);
457
+ } catch (error) {
458
+ if (broken) return;
459
+ broken = true;
460
+ console.error(
461
+ `[soleur] the event log \`${clip(String(EVENT_LOG))}\` is not writable (${clip(String(error))}); continuing without the durable record. The log's parent directory must exist.`,
462
+ );
463
+ }
464
+ };
465
+ })();
466
+
345
467
  export interface PermissionEvaluateEvent {
346
468
  readonly action?: unknown;
347
469
  readonly resources?: readonly unknown[];
348
470
  readonly agent?: unknown;
471
+ /**
472
+ * Where the decision came from. Measured (§12.2): a `shell` call carries
473
+ * `{type: "tool", messageID, id}` and that `id` equals the `tool.execute.before` event's `id`,
474
+ * which is the only channel through which the per-call `workdir` can reach this event.
475
+ */
476
+ readonly source?: { readonly type?: unknown; readonly id?: unknown; readonly messageID?: unknown };
349
477
  effect: "allow" | "ask" | "deny";
350
478
  message?: string;
351
479
  }
352
480
 
481
+ /** A `tool.execute.before` event. Measured keys: `tool, sessionID, agent, messageID, id, input`. */
482
+ export interface ToolBeforeEvent {
483
+ readonly tool?: unknown;
484
+ readonly id?: unknown;
485
+ readonly messageID?: unknown;
486
+ readonly input?: unknown;
487
+ }
488
+
353
489
  export interface SetupContext {
354
490
  location?: { directory?: string };
355
491
  permission: {
@@ -358,6 +494,17 @@ export interface SetupContext {
358
494
  handler: (event: PermissionEvaluateEvent) => Promise<void> | void,
359
495
  ): Promise<unknown>;
360
496
  };
497
+ /**
498
+ * The capture surface (#10819). Optional because a runtime that does not offer it must still
499
+ * load; absence, and a registration the runtime rejects, are both announced at setup rather than
500
+ * ignored, because every decision then silently reverts to the session directory.
501
+ */
502
+ tool?: {
503
+ hook(
504
+ name: "execute.before",
505
+ handler: (event: ToolBeforeEvent) => Promise<void> | void,
506
+ ): Promise<unknown>;
507
+ };
361
508
  session: {
362
509
  hook(
363
510
  name: string,
@@ -381,6 +528,31 @@ interface SessionState {
381
528
  */
382
529
  const MAX_SESSIONS = 500;
383
530
 
531
+ /**
532
+ * Cap on captured tool calls. One entry per Bash call, keyed by `(messageID, id)`; a long-lived
533
+ * `opencode serve` never tells the plugin a call is finished, so the map is bounded here.
534
+ *
535
+ * Eviction is **silent**, unlike `remember`'s. A capture that is lost is announced where it could
536
+ * change a verdict — at the decision, as `workdir-uncorrelated cause=no-capture` — so an eviction
537
+ * notice would be a second report of the same fact, emitted for calls whose evaluation never
538
+ * arrived at all (cancelled, auto-allowed by the ruleset). Loudness belongs at the decision.
539
+ */
540
+ const MAX_CALLS = 500;
541
+
542
+ /**
543
+ * The key a call is captured and consumed under.
544
+ *
545
+ * `id` alone was the obvious choice and the wrong one: the measured id (`call_00_s3uv…`) reads as a
546
+ * per-message counter, and one plugin instance is shared by every session an `opencode serve`
547
+ * process hosts. `messageID` is measured on both events, so the pair makes a colliding suffix
548
+ * harmless. Both ids are **required**: defaulting a missing one to `""` would collapse the key back
549
+ * to the bare counter and hand one call's workdir to another, so an absent id is uncorrelated
550
+ * (loud, session directory) rather than correlated on half a key.
551
+ */
552
+ function callKey(messageID: string, id: string): string {
553
+ return `${messageID}\u0000${id}`;
554
+ }
555
+
384
556
  /**
385
557
  * Build `setup`. The hooks are returned bound to injected collaborators so the unit tests can
386
558
  * drive them with a fake context and a fake script runner.
@@ -388,15 +560,38 @@ const MAX_SESSIONS = 500;
388
560
  export function createSetup(
389
561
  runner: ScriptRunner = runScript,
390
562
  pluginRoot: string = PLUGIN_ROOT,
391
- write: (line: string) => void = (line) => {
392
- if (EVENT_LOG !== undefined) appendFileSync(EVENT_LOG, `${line}\n`);
393
- },
563
+ write: (line: string) => void = defaultWriter,
564
+ report: (line: string) => void = (line) => console.error(line),
394
565
  ): (ctx: SetupContext) => Promise<void> {
395
566
  const sessions = new Map<string, SessionState>();
567
+ const calls = new Map<string, WorkdirCapture>();
396
568
 
397
569
  /** Wire-controlled text before it goes into a TSV line: no C0/C1, no line/paragraph separators. */
398
570
  const field = (value: unknown): string => String(value).replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, "_");
399
571
 
572
+ /**
573
+ * Announce a degraded correlation on both channels. The log file is off by default
574
+ * (`SOLEUR_OPENCODE_EVENT_LOG` unset), so a log-only claim of loudness is false in the default
575
+ * configuration; stderr visibility in the TUI is not measured, so neither channel alone is
576
+ * enough. The sentence is for the operator, the TSV token for the record.
577
+ */
578
+ const announce = (token: string, detail: string, sentence: string): void => {
579
+ report(`[soleur] ${sentence}`);
580
+ // `detail` is fielded here as well as at the call sites: this is the single line assembler, so a
581
+ // future caller that passes a raw path cannot break the column count.
582
+ write(`soleur-opencode\t${token}\t${field(detail)}`);
583
+ };
584
+
585
+ /** Record a capture, keeping the map bounded. Write-once keys: no recency re-ordering. */
586
+ const recordCall = (key: string, capture: WorkdirCapture): void => {
587
+ calls.set(key, capture);
588
+ while (calls.size > MAX_CALLS) {
589
+ const oldest = calls.keys().next();
590
+ if (oldest.done === true) break;
591
+ calls.delete(oldest.value);
592
+ }
593
+ };
594
+
400
595
  /** Remember a session's state, keeping the map bounded and the most recent entry last. */
401
596
  const remember = (id: string, state: SessionState): void => {
402
597
  sessions.delete(id);
@@ -411,24 +606,154 @@ export function createSetup(
411
606
  return async function setup(ctx: SetupContext): Promise<void> {
412
607
  const sessionCwd = ctx.location?.directory ?? process.cwd();
413
608
 
414
- // The only blocking surface bound here. A guard's decision replaces the ruleset's own verdict
415
- // — except in one direction: an arriving `deny` is never relaxed to `ask`. Only `allow`
416
- // arrivals were measured (§12.2), and relaxing an unmeasured deny is the silent-enforcement-loss
417
- // direction.
418
- await ctx.permission.hook("evaluate", (event) => {
419
- const decision = decidePermission(event.action, event.resources ?? [], sessionCwd, runner, pluginRoot);
609
+ /**
610
+ * The one directory a `shell` decision is judged against, or the escalation to make instead.
611
+ *
612
+ * `ask` is returned only when a workdir **was** captured and cannot be used. Falling back to the
613
+ * session directory there is defect #10819 applied to the one case where the operator
614
+ * explicitly asked for a different directory — and `effect = "ask"` is a measured channel
615
+ * (§12.2), so the plugin can say "I cannot check this" instead of guessing.
616
+ */
617
+ const decisionCwd = (
618
+ event: PermissionEvaluateEvent,
619
+ ): { kind: "decide"; cwd: string } | { kind: "unusable"; raw: string; message: string } => {
620
+ const source = event.source;
621
+ const id = source?.id;
622
+ const idText = typeof id === "string" && id !== "" ? field(id) : "absent";
623
+ // `source.type` is checked as well as the ids: a rule or config source whose id happened to
624
+ // collide with a call id would otherwise apply a stale workdir and log success. A missing
625
+ // message id is uncorrelatable rather than defaulted — with `""` on both sides a per-message
626
+ // counter would collide across concurrent messages and hand call A's workdir to call B.
627
+ const cause =
628
+ source?.type !== "tool"
629
+ ? "source-not-tool"
630
+ : typeof id !== "string" || id === ""
631
+ ? "id-absent"
632
+ : typeof source.messageID !== "string"
633
+ ? "message-id-absent"
634
+ : undefined;
635
+ if (cause !== undefined) {
636
+ announce(
637
+ "workdir-uncorrelated",
638
+ `id=${idText}\tcause=${cause}`,
639
+ `a shell call's permission event does not carry the ids the capture is keyed by (${cause}), so any per-call workdir cannot be correlated — this command is judged against the session directory instead. If this repeats, the runtime changed the event shape; check the OpenCode version.`,
640
+ );
641
+ return { kind: "decide", cwd: sessionCwd };
642
+ }
643
+ const capture = calls.get(callKey(source.messageID as string, id as string));
644
+ if (capture === undefined) {
645
+ announce(
646
+ "workdir-uncorrelated",
647
+ `id=${idText}\tcause=no-capture`,
648
+ `the tool hook recorded nothing for shell call ${idText}, so any workdir it carried is unknown — this command is judged against the session directory instead. The tool hook did not fire; check the OpenCode version, then re-run \`install-opencode-adapter.ts --check\`.`,
649
+ );
650
+ return { kind: "decide", cwd: sessionCwd };
651
+ }
652
+ if (capture.kind === "absent") return { kind: "decide", cwd: sessionCwd };
653
+ if (capture.kind === "usable") {
654
+ write(`soleur-opencode\tworkdir-correlated\tcwd=${field(capture.path)}`);
655
+ return { kind: "decide", cwd: capture.path };
656
+ }
657
+ announce(
658
+ "workdir-unusable",
659
+ `cwd=${field(capture.raw)}`,
660
+ `this shell call asked to run in \`${capture.raw}\`, which is not a directory this plugin can use. The guards were run against the session directory instead and declined, so the command is not checked against the directory it asked for.`,
661
+ );
662
+ return {
663
+ kind: "unusable",
664
+ raw: capture.raw,
665
+ message: `Soleur: this shell call asked for \`${capture.raw}\`, which is not a usable directory, so it could not be checked against the directory it asked for — the guards ran against the session directory and declined. Re-issue it without \`workdir\` to be checked here, or create that directory first.`,
666
+ };
667
+ };
668
+
669
+ /** Deliver a guard decision onto the event, never relaxing an arriving `deny` into an `ask`. */
670
+ const applyDecision = (event: PermissionEvaluateEvent, decision: GuardDecision | undefined): void => {
420
671
  if (decision === undefined) return;
672
+ // Only `allow` arrivals were measured (§12.2); relaxing an unmeasured deny is the
673
+ // silent-enforcement-loss direction.
421
674
  if (event.effect === "deny" && decision.decision === "ask") return;
422
675
  event.effect = decision.decision;
423
676
  event.message = decisionMessage(decision);
424
677
  // A guard that could not run is an operator-visible fault, so it is announced even when the
425
678
  // observation channel was never armed.
426
679
  if (decision.guard === GUARD_RUN_FAILURE) {
427
- console.error(`[soleur] ${decision.reason}`);
680
+ report(`[soleur] ${decision.reason}`);
428
681
  write(`soleur-opencode\tguard-run-failure\teffect=deny\t${field(decision.reason)}`);
429
682
  }
683
+ };
684
+
685
+ await ctx.permission.hook("evaluate", (event) => {
686
+ const action = event.action;
687
+ // ONE predicate for both sides (#10819). A non-Bash action returns before the map is touched:
688
+ // the measured `external_directory` event fires FIRST with the same call id, and consuming it
689
+ // there would lose the workdir before the decision that needs it.
690
+ if (typeof action !== "string" || !decidesBash(action)) return;
691
+ const target = decisionCwd(event);
692
+ if (target.kind === "decide") {
693
+ applyDecision(event, decidePermission(action, event.resources ?? [], target.cwd, runner, pluginRoot));
694
+ return;
695
+ }
696
+ // A captured workdir that cannot be used does NOT skip the guards: their cwd-independent
697
+ // classes (a home wipe, an infra destroy, a credential-bearing snapshot, a forced push to the
698
+ // default branch) are the ones that must survive, and running them costs one prompt at most.
699
+ // Only when they decline does the unresolvable directory become the decision — and an
700
+ // arriving `deny` is still never relaxed into that prompt.
701
+ applyDecision(event, decidePermission(action, event.resources ?? [], sessionCwd, runner, pluginRoot));
702
+ if (event.effect !== "allow") return;
703
+ event.effect = "ask";
704
+ event.message = target.message;
430
705
  });
431
706
 
707
+ /**
708
+ * Capture-only (#10819): publish `input.workdir` for the decision above. Bound **after** the
709
+ * decision surface, so a runtime that rejects this registration cannot leave the guards
710
+ * unbound; wrapped, because optional chaining covers an absent `tool` and not a rejecting
711
+ * `hook()`. Never decides, never throws — a throw here would preempt the real ask behind it.
712
+ */
713
+ const capture = (event: ToolBeforeEvent): void => {
714
+ try {
715
+ const tool = event.tool;
716
+ if (typeof tool !== "string" || !decidesBash(tool)) return;
717
+ const id = event.id;
718
+ const hasId = typeof id === "string" && id !== "";
719
+ if (!hasId || typeof event.messageID !== "string") {
720
+ announce(
721
+ "workdir-uncapturable",
722
+ `id=${hasId ? field(id) : "absent"}\treason=${hasId ? "message-id-absent" : "id-absent"}`,
723
+ "a shell call reached the tool hook without the ids its permission event is keyed by, so its workdir cannot be published — the decision that follows is judged against the session directory. Check the OpenCode version.",
724
+ );
725
+ return;
726
+ }
727
+ recordCall(callKey(event.messageID, id), resolveWorkdir(event.input, sessionCwd));
728
+ } catch (error) {
729
+ // Containment for the "never throw" promise: with total helpers this is unreachable, and it
730
+ // must stay that way rather than becoming the one path that preempts the real ask.
731
+ announce(
732
+ "workdir-capture-error",
733
+ `reason=${field(String(error))}`,
734
+ `the workdir capture failed (${clip(String(error))}); this shell call is judged against the session directory and the guards still run.`,
735
+ );
736
+ }
737
+ };
738
+
739
+ const noToolSurface = (reason: string): void => {
740
+ announce(
741
+ "no-tool-hook-surface",
742
+ `workdir-unavailable\treason=${field(reason)}`,
743
+ `this runtime offers no usable \`tool.execute.before\` surface (${clip(reason)}), so a shell call's \`workdir\` cannot be captured — every command is judged against the session directory, as before this fix. The permission guards remain bound.`,
744
+ );
745
+ };
746
+
747
+ if (ctx.tool === undefined) {
748
+ noToolSurface("the surface is absent");
749
+ } else {
750
+ try {
751
+ await ctx.tool.hook("execute.before", capture);
752
+ } catch (error) {
753
+ noToolSurface(String(error));
754
+ }
755
+ }
756
+
432
757
  await ctx.session.hook("context", (event) => {
433
758
  const cwd = typeof event["cwd"] === "string" ? event["cwd"] : sessionCwd;
434
759
  const rawSessionID = event["sessionID"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soleur/opencode",
3
- "version": "1.0.0",
3
+ "version": "3.354.2",
4
4
  "description": "Soleur AI organization plugin for OpenCode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -262,7 +262,7 @@ soften_test_row() {
262
262
  | .lines |= map(if startswith("FAILED test (") then "PENDING test (awaiting-approval: a maintainer must approve the fork workflow run)" else . end)' <<<"$out" > "$WORK/soft.json" ;;
263
263
  stalled)
264
264
  REASON_OVERRIDE="stalled"
265
- MSG="STALLED: this PR was marked ready but no full CI run decided ($V_WHY: the stall window counts from the ready event). Recovery: $RECOVERY"
265
+ MSG="STALLED: this PR was marked ready but no full CI run decided ($V_WHY: see ci-head-verdict.sh --help for the stall clock). Recovery: $RECOVERY"
266
266
  jq -c '.failed -= ["test"] | .absent += ["test"]
267
267
  | .lines |= map(if startswith("FAILED test (") then "ABSENT test (stalled: no full CI run decided after the ready event)" else . end)' <<<"$out" > "$WORK/soft.json" ;;
268
268
  full-decided)
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -41,8 +41,20 @@
41
41
  # no kept run -> no-run
42
42
  # 5. (a conflicting PR, mergeable_state dirty, with no run stays no-run reason=conflicting: GitHub builds no merge ref
43
43
  # for it, so no run can ever appear and an undo/ready would not help)
44
- # pending-full and no-run become `stalled` once more than N=120 minutes have passed since T (above the declared
45
- # 73-minute path plus a ~14-minute p90 queue wait; a run still in flight at N is not a healthy slow run)
44
+ # pending-full and no-run become `stalled` once more than N=120 minutes have passed since the LATER of T and A (above
45
+ # the declared 73-minute path plus a ~14-minute p90 queue wait; a run still in flight at N is not a healthy slow run).
46
+ # A is where the head first appeared in CI, because a head pushed long after T must not read stalled at once (the
47
+ # consumers answer stalled with `gh pr ready --undo` and a ready, which would cancel a healthy fresh run): when a run is
48
+ # kept, A is that run's own created_at (no extra read); when none is, A is the earliest check suite's created_at at the
49
+ # head (the server-assigned moment CI first saw it), read only when T alone already says stalled. An A at or before T
50
+ # leaves the clock at T, as before A existed.
51
+ # A failed read, a suite for another head, or an EMPTY suite list is `state=error` (reasons api-error and
52
+ # no-check-suites), never a fallback to T. The empty list means CI never saw this head (nothing ran on it): do not
53
+ # fix-loop; the recovery is the stalled one, `gh pr ready --undo` then ready under a user token. The consumers drop
54
+ # stderr and keep their own reading on state=error, so the reason is on the marker line, not in a message.
55
+ # Residuals, accepted: a manual re-run of an old run keeps its original created_at and does not restart the clock; a
56
+ # force-push back to a sha pushed earlier, or a sha also pushed on another branch, reuses that sha's old suites (A is
57
+ # old, so the clock falls back to T); a head with no kept run AND no visible suite reads state=error, not stalled.
46
58
  # `run=` is the DECIDING run: a reader judging `test` must use that run's rows. A newer `test` row that belongs to a
47
59
  # different run (a manual re-run of a pre-T run) is stale; the row's run id is the `/actions/runs/<id>/` segment of
48
60
  # its details_url. Every time comes from the SERVER (the PR read's Date header, the timeline, the runs API), never
@@ -85,7 +97,9 @@ usage: ci-head-verdict.sh verdict <pr> [--repo OWNER/REPO]
85
97
 
86
98
  verdict: last stdout line
87
99
  SOLEUR_CI_HEAD_VERDICT state=<n/a|full-decided|pending-full|no-run|stalled|awaiting-approval> pr=<N> sha=<H> run=<id|none> reason=<token>
88
- (exit 3: state=error, outside that set)
100
+ (exit 3: state=error, outside that set; reason api-error = retry, no-check-suites = CI never saw this head)
101
+ stalled = pending-full or no-run for more than 120 minutes since the later of the ready event and the head's first
102
+ appearance in CI (the kept run's created_at, else the earliest check suite; a conflicting PR stays no-run); the full rule is in this script's header
89
103
  wait-ready-run: last stdout line
90
104
  SOLEUR_CI_WAIT_READY_RUN result=<ok|fail> pr=<N> sha=<H|unknown> run=<id|none> reason=<run-created|not-applicable|no-ready-event|no-run|awaiting-approval|api-error>
91
105
  exit 0 ok | 1 wait failed (do NOT arm auto-merge) | 2 usage | 3 gh/jq/API error (ready state unknown: re-run verdict)
@@ -187,7 +201,7 @@ read_test_row() {
187
201
 
188
202
  # The newest ci.yml pull_request run at <sha> created at or after <floor-epoch>: RUN_ID / RUN_STATUS / RUN_CONCLUSION
189
203
  # (RUN_ID empty when none). Looked up by head_sha, never through a run's pull_requests[].
190
- RUN_ID=""; RUN_STATUS=""; RUN_CONCLUSION=""
204
+ RUN_ID=""; RUN_STATUS=""; RUN_CONCLUSION=""; RUN_EPOCH=""
191
205
  read_newest_run() { # <sha> <floor-epoch>
192
206
  gh api --paginate --slurp "repos/{owner}/{repo}/actions/workflows/ci.yml/runs?event=pull_request&head_sha=$1&per_page=100" > "$WORK/runs.json" 2> "$WORK/err" \
193
207
  || { ERRMSG="listing ci.yml runs at $1 failed: $(head -c 200 "$WORK/err" | tr '\n' ' ')"; return 1; }
@@ -202,6 +216,30 @@ read_newest_run() { # <sha> <floor-epoch>
202
216
  [[ -z "$RUN_ID" || "$RUN_ID" =~ ^[0-9]+$ ]] || { ERRMSG="non-numeric run id in the ci.yml run list at $1"; return 1; }
203
217
  RUN_STATUS="$(jq -r '.status // ""' "$WORK/newest.json")"
204
218
  RUN_CONCLUSION="$(jq -r '.conclusion // ""' "$WORK/newest.json")"
219
+ RUN_EPOCH="$(jq -r 'if .ts == null then "" else (.ts | tostring) end' "$WORK/newest.json")"
220
+ }
221
+
222
+ # The earliest check suite at <sha>: ANCHOR_EPOCH (epoch seconds), the server-assigned moment CI first saw this head. A
223
+ # failed or malformed read, a suite for another head, or an empty list returns 1 with ERRMSG set and ANCHOR_REASON naming the
224
+ # cause; the caller turns that into state=error and never falls back to a guess.
225
+ ANCHOR_EPOCH=""; ANCHOR_REASON="api-error"
226
+ read_head_anchor() { # <sha>
227
+ local out
228
+ gh api --paginate --slurp "repos/{owner}/{repo}/commits/$1/check-suites?per_page=100" > "$WORK/suites.json" 2> "$WORK/err" \
229
+ || { ERRMSG="reading check suites at $1 failed: $(head -c 200 "$WORK/err" | tr '\n' ' ')"; return 1; }
230
+ jq -e --arg h "$1" 'type == "array" and all(.[]; type == "object" and (.check_suites | type) == "array"
231
+ and all(.check_suites[]; type == "object" and .head_sha == $h and (.created_at | type) == "string"))' \
232
+ "$WORK/suites.json" >/dev/null 2>&1 || { ERRMSG="unparseable check-suites response at $1"; return 1; }
233
+ # `min` of an empty array is null with exit 0, which bash arithmetic would read as 0: the empty list is its own answer.
234
+ out="$(jq -r '[.[].check_suites[] | .created_at | fromdateiso8601] | if length == 0 then "none" else (min | tostring) end' "$WORK/suites.json" 2>/dev/null)" \
235
+ || { ERRMSG="unparseable created_at in the check-suites response at $1"; return 1; }
236
+ if [[ "$out" == none ]]; then
237
+ ANCHOR_REASON="no-check-suites"
238
+ ERRMSG="no check suites visible at $1; cannot place the stall clock (CI never saw this head: recover as for stalled, gh pr ready --undo then ready under a user token)"
239
+ return 1
240
+ fi
241
+ [[ "$out" =~ ^[0-9]+$ ]] || { ERRMSG="unusable created_at in the check-suites response at $1"; return 1; }
242
+ ANCHOR_EPOCH="$out"
205
243
  }
206
244
 
207
245
  # Does this repo carry the mechanism? True when the DEFAULT branch's ci.yml has a `draft-light` job. APPLICABLE is
@@ -259,7 +297,7 @@ emit() { # <state> <run|""> <reason>
259
297
 
260
298
  # ── verdict ───────────────────────────────────────────────────────────────────────────────────
261
299
  cmd_verdict() {
262
- local t_epoch st rs
300
+ local t_epoch st rs anchor_epoch
263
301
  read_pr || fail_api "$ERRMSG"
264
302
  SHA_OUT="$PR_SHA"
265
303
  NOW_EPOCH=$(server_now) || fail_api "no usable server time (Date header absent or unparseable)" no-server-time
@@ -286,7 +324,17 @@ cmd_verdict() {
286
324
  # the conflict's doing, not a lost ready run, and `stalled` would advise an undo/ready that cannot help: it stays no-run.
287
325
  if [[ "$st" == no-run && "$rs" == no-run-after-ready && "$PR_MSTATE" == dirty ]]; then rs="conflicting"
288
326
  elif [[ "$st" == pending-full || "$st" == no-run ]] && (( NOW_EPOCH - t_epoch > STALL_MINUTES * 60 )); then
289
- st=stalled; rs="undecided-after-${STALL_MINUTES}m"
327
+ # T alone says stalled; the clock rule (anchor A, residuals, error semantics) is defined once, in the header. The suite
328
+ # read is lazy (only here) and a failed or empty one is state=error, never a fallback to T.
329
+ if [[ -n "$RUN_ID" ]]; then
330
+ anchor_epoch="$RUN_EPOCH"
331
+ else
332
+ read_head_anchor "$PR_SHA" || fail_api "$ERRMSG" "$ANCHOR_REASON"
333
+ anchor_epoch="$ANCHOR_EPOCH"
334
+ fi
335
+ if (( NOW_EPOCH - anchor_epoch > STALL_MINUTES * 60 )); then
336
+ st=stalled; rs="undecided-after-${STALL_MINUTES}m"
337
+ fi
290
338
  fi
291
339
  emit "$st" "$RUN_ID" "$rs"
292
340
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes