@soleur/opencode 1.0.0 → 3.354.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/go.md +6 -5
- package/commands/sync.md +2 -2
- package/install.js +6 -85
- package/lib/harness.ts +1 -1
- package/lib/heartbeat-manifest.ts +19 -0
- package/lib/pr-merge-poll.ts +2 -2
- package/lib/workflow-fidelity.ts +2 -2
- package/opencode/INSTRUCTIONS.md +19 -0
- package/opencode/plugin.ts +343 -18
- package/package.json +1 -1
- package/scripts/admin-merge-ready.sh +1 -1
- package/scripts/admin-merge-ready.test.sh +0 -0
- package/scripts/affected-tests.sh +0 -0
- package/scripts/alpha-metrics.sh +0 -0
- package/scripts/alpha-metrics.test.sh +0 -0
- package/scripts/check-red-on-main.sh +0 -0
- package/scripts/ci-head-verdict.sh +54 -6
- package/scripts/cloud-detect.sh +0 -0
- package/scripts/delegation-verify.sh +0 -0
- package/scripts/delegation-verify.test.sh +0 -0
- package/scripts/deploy-arm.sh +0 -0
- package/scripts/domain-model-drift.sh +0 -0
- package/scripts/emit-decision.sh +0 -0
- package/scripts/emit-decision.test.sh +0 -0
- package/scripts/ext-filter-divergence.sh +0 -0
- package/scripts/grok-fidelity-gate.sh +0 -0
- package/scripts/grok-pre-push-gate.sh +0 -0
- package/scripts/install-opencode-adapter.ts +118 -129
- package/scripts/lib/affected-tests/common.sh +0 -0
- package/scripts/lib/affected-tests/go.sh +0 -0
- package/scripts/lib/affected-tests/js.sh +0 -0
- package/scripts/lib/affected-tests/nx-turbo.sh +0 -0
- package/scripts/lib/affected-tests/python.sh +0 -0
- package/scripts/lib/session-state.sh +0 -0
- package/scripts/lib/tmp-classify.sh +0 -0
- package/scripts/monitor-pr-checks.sh +1 -1
- package/scripts/pipeline-tally.sh +0 -0
- package/scripts/plugin-version.sh +0 -0
- package/scripts/precommit-guard.sh +3 -1
- package/scripts/precommit-guard.test.sh +0 -0
- package/scripts/render-c4-model.sh +0 -0
- package/scripts/resolve-regenerable-conflicts.sh +0 -0
- package/scripts/run-in-pid-namespace.sh +0 -0
- package/scripts/setup-ralph-loop.sh +0 -0
- package/scripts/sync-npm-package.ts +74 -87
- package/scripts/sync-opencode-adapter.ts +19 -0
- package/scripts/sync-pr-behind.sh +183 -70
- package/scripts/taste-profile-update.sh +0 -0
- package/scripts/taste-profile-update.test.sh +0 -0
- package/scripts/write-budget-marker.sh +0 -0
- package/skills/deepen-plan/SKILL.md +1 -1
- package/skills/deploy/references/hetzner-setup.md +5 -3
- package/skills/drain-prs/SKILL.md +3 -2
- package/skills/fix-issue/SKILL.md +8 -8
- package/skills/fix-issue/references/agent-authored-exclusion.md +4 -2
- package/skills/fix-issue/references/exclude-label-jq-snippet.md +4 -3
- package/skills/merge-pr/SKILL.md +5 -3
- package/skills/model-launch-review/SKILL.md +4 -4
- package/skills/one-shot/SKILL.md +1 -1
- package/skills/plan/SKILL.md +1 -1
- package/skills/plan/references/plan-sharp-edges.md +7 -1
- package/skills/postmerge/SKILL.md +2 -2
- package/skills/postmerge/references/deploy-status-debugging.md +3 -0
- package/skills/review/SKILL.md +14 -15
- package/skills/review/references/review-evidence-comment.md +36 -0
- package/skills/review/references/risk-tier-and-fix-rounds.md +2 -0
- package/skills/ship/SKILL.md +14 -13
- package/skills/ship/references/cleanup-merged-archival.md +39 -0
- package/skills/ship/references/delegated-act-authoring-form.md +7 -0
- package/skills/ship/references/epic-close-sweep.md +62 -0
- package/skills/ship/references/merge-queue-dequeue.md +1 -1
- package/skills/ship/references/operator-holds.md +30 -0
- package/skills/ship/references/queue-mode.md +1 -1
- package/skills/ship/references/ready-run-wait.md +3 -3
- package/skills/test-fix-loop/SKILL.md +1 -1
- package/skills/verification-scaffold/SKILL.md +73 -0
- package/skills/verification-scaffold/references/gitleaks.toml.template +13 -0
- package/skills/verification-scaffold/references/guardrail-audit.md +156 -0
- package/skills/verification-scaffold/references/guards/block-stash-hook.sh.template +72 -0
- package/skills/verification-scaffold/references/guards/commit-on-main-hook.sh.template +82 -0
- package/skills/verification-scaffold/references/guards/orphan-test-check.sh.template +102 -0
- package/skills/verification-scaffold/references/guards/precommit-guard.sh.template +115 -0
- package/skills/verification-scaffold/references/guards/secret-scan-hook.sh.template +176 -0
- package/skills/verification-scaffold/references/guards/verification-gates.yml.template +49 -0
- package/skills/verification-scaffold/references/guards/wrapper-boundary.sh.template +47 -0
- package/skills/verification-scaffold/references/guards.tsv +21 -0
- package/skills/verification-scaffold/references/verification-readme.template +55 -0
- package/skills/verification-scaffold/scripts/verification-scaffold.sh +785 -0
- package/skills/verification-scaffold/test/bite-proof.test.sh +159 -0
- package/skills/verification-scaffold/test/generator.test.sh +319 -0
- package/skills/verification-scaffold/test/manifest.test.sh +144 -0
- package/skills/work/SKILL.md +2 -1
package/opencode/plugin.ts
CHANGED
|
@@ -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
|
|
30
|
-
* permission hook, and its only refusal channel is a thrown error, so
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
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 =
|
|
392
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
@@ -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:
|
|
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
|
package/scripts/alpha-metrics.sh
CHANGED
|
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
|
|
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
|
-
|
|
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
|
}
|
package/scripts/cloud-detect.sh
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/scripts/deploy-arm.sh
CHANGED
|
File without changes
|
|
File without changes
|
package/scripts/emit-decision.sh
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|