@zeroroot-ai/zerocool 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,8 +46,9 @@
46
46
  * GIBSON_CALLBACK_INSECURE "1" to dial the callback listener without TLS (kind)
47
47
  * GIBSON_MISSION_ID / GIBSON_MISSION_RUN_ID / GIBSON_AGENT_RUN_ID provenance
48
48
  * ZEROCOOL_WORKSPACE working directory (defaults to the cwd)
49
- * ZEROCOOL_TASK force a task kind ("source-analysis"); the Task's
50
- * own context `zerocool.task` is the normal way
49
+ * ZEROCOOL_TASK force a task kind ("source-analysis", "watch",
50
+ * "fix"); the Task's own context `zerocool.task`
51
+ * is the normal way
51
52
  * GIBSON_OPENCODE_SESSION_ID continue an earlier opencode session
52
53
  * GIBSON_TIMEOUT_MS hard deadline for the run, in ms
53
54
  *
@@ -57,7 +58,8 @@
57
58
  */
58
59
  import { type AgentOutcome, type TaskHarness } from "@zeroroot-ai/sdk";
59
60
  import { type FindingsBackend } from "./findings.js";
60
- import { type GitLabClient } from "./gitlab.js";
61
+ import { type FindingSource, type FindingStatusWriter, type FixPlanner, type Workspace } from "./fix.js";
62
+ import { type GitLabClient, type GitLabWriter } from "./gitlab.js";
61
63
  import { runOpencode } from "./opencode-run.js";
62
64
  import type { SemgrepRunner } from "./semgrep.js";
63
65
  import { type ScanLauncher, type WatchCheckpoints } from "./watch.js";
@@ -151,7 +153,7 @@ export declare function decodeTask(taskB64: string | undefined): DecodedTask;
151
153
  */
152
154
  export declare function typedValueString(val: unknown): string | undefined;
153
155
  /** The task kinds a sandboxed dispatch can serve. */
154
- export type DispatchTaskKind = "opencode" | "source-analysis" | "watch";
156
+ export type DispatchTaskKind = "opencode" | "source-analysis" | "watch" | "fix";
155
157
  /**
156
158
  * Which task this dispatch runs. The mission node says so in the Task's
157
159
  * context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
@@ -190,6 +192,29 @@ export interface DispatchDeps {
190
192
  taskKind?: DispatchTaskKind;
191
193
  /** Watch seams: no GitLab, no World and no child missions in a test. */
192
194
  watch?: WatchDeps;
195
+ /** Fix seams: no GitLab, no graph and no checkout in a test. */
196
+ fix?: FixDeps;
197
+ }
198
+ /**
199
+ * The fix task's injectable seams.
200
+ *
201
+ * `planner` and `workspace` have no default. The Fix decides what to change
202
+ * and runs the repository's own tests before anything is pushed, and neither
203
+ * is something this process can synthesise: a default planner would be one
204
+ * that changes nothing, and a default workspace would be one whose tests
205
+ * always pass. Both would turn a missing dependency into a Fix that reports
206
+ * success having done nothing, so a dispatch without them fails instead.
207
+ */
208
+ export interface FixDeps {
209
+ gitlab?: GitLabWriter;
210
+ findings?: FindingSource;
211
+ status?: FindingStatusWriter;
212
+ planner?: FixPlanner;
213
+ workspace?: Workspace;
214
+ /** Resolve the GitLab token. Defaults to the harness `GetCredential`. */
215
+ credential?: (name: string) => Promise<string>;
216
+ /** Cap on merge requests opened by one pass. */
217
+ maxMergeRequests?: number;
193
218
  }
194
219
  /** The watch task's injectable seams. Everything the loop reaches out through. */
195
220
  export interface WatchDeps {
@@ -242,6 +267,38 @@ export declare function runSourceAnalysisDispatch(ctx: DispatchContext, deps?: D
242
267
  * `watch.interval_ms` poll cadence; defaults to 30s
243
268
  */
244
269
  export declare function runWatchDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
270
+ /**
271
+ * The fix task, sandboxed (zerocool-plugins#89, finished in #96).
272
+ *
273
+ * What the always-on agent does after a Scan mission lands: read the
274
+ * Application's Findings over the task grant, work them in priority order,
275
+ * and for each one it can act on rewrite the repository, run the repository's
276
+ * own tests, and only then open a merge request that merges itself on a green
277
+ * pipeline. See `fix.ts` for why the order is tests-then-push and why this
278
+ * never marks a Finding `verified`.
279
+ *
280
+ * THE READ AND THE STATUS WRITE TRAVEL ON THE DISPATCH GRANT, not on a
281
+ * component identity — this process has none. `applicationFindings` and
282
+ * `observe` both reach the daemon as the task, so the run holds exactly the
283
+ * authority its dispatch granted.
284
+ *
285
+ * A REJECTED READ FAILS THE RUN. `harnessFindingSource` does not catch it, and
286
+ * neither does this: an unreachable graph reported as an empty backlog is a
287
+ * Fix that says the Application is clean while the backlog is live, and it
288
+ * reads identically to health.
289
+ *
290
+ * Task context keys it reads:
291
+ * `zerocool.task` selector, "fix"
292
+ * `application` the Application whose Findings are worked (required)
293
+ * `gitlab.project` `group/project` on the instance (required)
294
+ * `gitlab.url` instance base url; defaults to gitlab.com
295
+ * `gitlab.ref` branch merge requests target; defaults to `main`
296
+ * `gitlab.credential` tenant secret holding the project access token
297
+ * `repository.commit` the commit the Scan ran against — what the status lands on
298
+ * `pipeline.url` the pipeline page, linked from the commit status
299
+ * `fix.max_merge_requests` cap on merge requests opened in one pass
300
+ */
301
+ export declare function runFixDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
245
302
  /**
246
303
  * Resolve a tenant secret through the harness on the dispatch grant. The value
247
304
  * is returned to the caller and never logged: it is the GitLab token, and the
package/dist/dispatch.js CHANGED
@@ -46,8 +46,9 @@
46
46
  * GIBSON_CALLBACK_INSECURE "1" to dial the callback listener without TLS (kind)
47
47
  * GIBSON_MISSION_ID / GIBSON_MISSION_RUN_ID / GIBSON_AGENT_RUN_ID provenance
48
48
  * ZEROCOOL_WORKSPACE working directory (defaults to the cwd)
49
- * ZEROCOOL_TASK force a task kind ("source-analysis"); the Task's
50
- * own context `zerocool.task` is the normal way
49
+ * ZEROCOOL_TASK force a task kind ("source-analysis", "watch",
50
+ * "fix"); the Task's own context `zerocool.task`
51
+ * is the normal way
51
52
  * GIBSON_OPENCODE_SESSION_ID continue an earlier opencode session
52
53
  * GIBSON_TIMEOUT_MS hard deadline for the run, in ms
53
54
  *
@@ -55,10 +56,11 @@
55
56
  * {@link dispatchChildEnv} — that is the opencode plugin's own contract, fed from
56
57
  * the GIBSON_CG_JWT this process reads.
57
58
  */
58
- import { openTaskHarness } from "@zeroroot-ai/sdk";
59
+ import { observe, openTaskHarness, taskKnowledge } from "@zeroroot-ai/sdk";
59
60
  import { join } from "node:path";
60
61
  import { taskFindingsBackend } from "./findings.js";
61
- import { gitlabRest } from "./gitlab.js";
62
+ import { harnessFindingSource, harnessFindingStatus, runFix, } from "./fix.js";
63
+ import { gitlabRest, gitlabRestWriter } from "./gitlab.js";
62
64
  import { runOpencode } from "./opencode-run.js";
63
65
  import { DEFAULT_REF, harnessScanLauncher, runWatchLoop, worldCheckpoints, } from "./watch.js";
64
66
  import { formatSourceAnalysis, harnessTriageModel, runSourceAnalysis, } from "./source-analysis.js";
@@ -183,6 +185,8 @@ export function dispatchTaskKind(ctx, env = {}) {
183
185
  return "source-analysis";
184
186
  if (kind === "watch")
185
187
  return "watch";
188
+ if (kind === "fix")
189
+ return "fix";
186
190
  return "opencode";
187
191
  }
188
192
  /**
@@ -245,6 +249,9 @@ export async function runDispatch(ctx, deps = {}) {
245
249
  if (kind === "watch") {
246
250
  return runWatchDispatch(ctx, deps);
247
251
  }
252
+ if (kind === "fix") {
253
+ return runFixDispatch(ctx, deps);
254
+ }
248
255
  const run = deps.run ?? runOpencode;
249
256
  const result = await run({
250
257
  goal: ctx.goal,
@@ -412,6 +419,118 @@ export async function runWatchDispatch(ctx, deps = {}) {
412
419
  harness?.stop();
413
420
  }
414
421
  }
422
+ /**
423
+ * The fix task, sandboxed (zerocool-plugins#89, finished in #96).
424
+ *
425
+ * What the always-on agent does after a Scan mission lands: read the
426
+ * Application's Findings over the task grant, work them in priority order,
427
+ * and for each one it can act on rewrite the repository, run the repository's
428
+ * own tests, and only then open a merge request that merges itself on a green
429
+ * pipeline. See `fix.ts` for why the order is tests-then-push and why this
430
+ * never marks a Finding `verified`.
431
+ *
432
+ * THE READ AND THE STATUS WRITE TRAVEL ON THE DISPATCH GRANT, not on a
433
+ * component identity — this process has none. `applicationFindings` and
434
+ * `observe` both reach the daemon as the task, so the run holds exactly the
435
+ * authority its dispatch granted.
436
+ *
437
+ * A REJECTED READ FAILS THE RUN. `harnessFindingSource` does not catch it, and
438
+ * neither does this: an unreachable graph reported as an empty backlog is a
439
+ * Fix that says the Application is clean while the backlog is live, and it
440
+ * reads identically to health.
441
+ *
442
+ * Task context keys it reads:
443
+ * `zerocool.task` selector, "fix"
444
+ * `application` the Application whose Findings are worked (required)
445
+ * `gitlab.project` `group/project` on the instance (required)
446
+ * `gitlab.url` instance base url; defaults to gitlab.com
447
+ * `gitlab.ref` branch merge requests target; defaults to `main`
448
+ * `gitlab.credential` tenant secret holding the project access token
449
+ * `repository.commit` the commit the Scan ran against — what the status lands on
450
+ * `pipeline.url` the pipeline page, linked from the commit status
451
+ * `fix.max_merge_requests` cap on merge requests opened in one pass
452
+ */
453
+ export async function runFixDispatch(ctx, deps = {}) {
454
+ const f = deps.fix ?? {};
455
+ const application = ctx.taskContext.application || "";
456
+ const projectPath = ctx.taskContext["gitlab.project"] || "";
457
+ const targetRef = ctx.taskContext["gitlab.ref"] || DEFAULT_REF;
458
+ const commit = ctx.taskContext["repository.commit"] || "";
459
+ const credentialName = ctx.taskContext["gitlab.credential"] || "gitlab-token";
460
+ if (!application) {
461
+ throw new Error("fix: the mission node set no `application` in the task context. The Fix reads one " +
462
+ "Application's Findings and writes back to them; without one it cannot tell which " +
463
+ "backlog it is working.");
464
+ }
465
+ if (!f.gitlab && !projectPath) {
466
+ throw new Error("fix: the mission node set no `gitlab.project` in the task context, so there is no " +
467
+ "project to open a merge request against.");
468
+ }
469
+ // No default planner and no default workspace: see FixDeps. A Fix that
470
+ // cannot change anything or cannot test what it changed must not report a
471
+ // clean pass over a backlog it never touched.
472
+ if (!f.planner) {
473
+ throw new Error("fix: no planner was supplied, so this dispatch could only report every finding unfixed");
474
+ }
475
+ if (!f.workspace) {
476
+ throw new Error("fix: no workspace was supplied, so no change could be made or tested");
477
+ }
478
+ const needsHarness = !f.gitlab || !f.findings || !f.status || !f.credential;
479
+ const opened = needsHarness && !deps.harness;
480
+ const harness = needsHarness
481
+ ? (deps.harness ??
482
+ ((c) => openTaskHarness({ endpoint: c.callbackEndpoint, token: c.callbackToken, insecure: c.insecure ?? false })))(ctx)
483
+ : undefined;
484
+ try {
485
+ const gitlab = f.gitlab ??
486
+ gitlabRestWriter({
487
+ projectPath,
488
+ token: await (f.credential ?? harnessCredential(harness))(credentialName),
489
+ ...(ctx.taskContext["gitlab.url"] ? { baseUrl: ctx.taskContext["gitlab.url"] } : {}),
490
+ });
491
+ const findings = f.findings ?? harnessFindingSource(taskKnowledge(harness));
492
+ const status = f.status ??
493
+ harnessFindingStatus((entity) => observe(harness, { lifecycleEntity: entity }));
494
+ const cap = Number(ctx.taskContext["fix.max_merge_requests"] || Number.NaN);
495
+ const summary = await runFix({
496
+ application,
497
+ commit,
498
+ targetRef,
499
+ findings,
500
+ status,
501
+ planner: f.planner,
502
+ workspace: f.workspace,
503
+ gitlab,
504
+ ...(ctx.taskContext["pipeline.url"] ? { pipelineUrl: ctx.taskContext["pipeline.url"] } : {}),
505
+ ...(f.maxMergeRequests !== undefined
506
+ ? { maxMergeRequests: f.maxMergeRequests }
507
+ : Number.isFinite(cap) && cap > 0
508
+ ? { maxMergeRequests: cap }
509
+ : {}),
510
+ ...(deps.onEvent ? { onEvent: (e) => deps.onEvent?.(JSON.stringify(e)) } : {}),
511
+ });
512
+ const fixing = summary.outcomes.filter((o) => o.result === "fixing").length;
513
+ const merged = summary.outcomes.filter((o) => o.result === "merged").length;
514
+ const unfixed = summary.outcomes.filter((o) => o.result === "unfixed").length;
515
+ return {
516
+ output: `fix ${application}@${commit.slice(0, 12) || "unknown"}: ${fixing} merge request(s) opened, ` +
517
+ `${merged} merged, ${unfixed} left for a human`,
518
+ success: true,
519
+ metadata: {
520
+ task: "fix",
521
+ application,
522
+ commit,
523
+ fixing: String(fixing),
524
+ merged: String(merged),
525
+ unfixed: String(unfixed),
526
+ },
527
+ };
528
+ }
529
+ finally {
530
+ if (opened)
531
+ harness?.stop();
532
+ }
533
+ }
415
534
  /**
416
535
  * Resolve a tenant secret through the harness on the dispatch grant. The value
417
536
  * is returned to the caller and never logged: it is the GitLab token, and the
package/dist/fix.d.ts ADDED
@@ -0,0 +1,268 @@
1
+ import type { FileChange, GitLabWriter, MergeRequest } from "./gitlab.js";
2
+ /**
3
+ * The Fix — what the always-on agent does with a Finding (zerocool-plugins#89).
4
+ *
5
+ * After a Scan mission lands, the agent works the Application's open Findings
6
+ * in priority order. For each one it can act on it rewrites the repository,
7
+ * runs the repository's own tests, and only then opens a merge request set to
8
+ * merge itself when the pipeline succeeds. It records the scan verdict on the
9
+ * commit as `gibson/scan` and posts one note saying what it fixed, what it did
10
+ * not, and why.
11
+ *
12
+ * THE ORDER IS TESTS, THEN PUSH. A fix that breaks the build is worse than the
13
+ * Finding: it stops every other fix behind it. So the change is applied to a
14
+ * workspace, the tests run there, and a failure resets the workspace and leaves
15
+ * the Finding `open` with its reason recorded. Nothing unproven reaches GitLab.
16
+ *
17
+ * THE FIX NEVER MARKS A FINDING `verified`. A merge is not evidence that a
18
+ * rescan did not see the weakness again — only the rescan is, and absence is
19
+ * not an observation, so it is reconciled once at a scan's completion
20
+ * (gibson#1686). The Fix moves a Finding `open` → `fixing` when its merge
21
+ * request opens and `fixing` → `fixed` when GitLab reports that request merged.
22
+ *
23
+ * THE TOKEN NEVER APPEARS IN ANYTHING THIS WRITES. It reaches GitLab only as a
24
+ * `PRIVATE-TOKEN` header inside {@link gitlabRestWriter}. No branch name, merge
25
+ * request title, description, note or status description is built from it, and
26
+ * `fix.test.ts` proves that with a sentinel that would show up in every
27
+ * recorded output if it ever did.
28
+ *
29
+ * THE TWO PLATFORM SEAMS ARE LIVE. Reading an Application's Findings and
30
+ * writing a Finding's status arrived on the TypeScript wire in
31
+ * `@zeroroot-ai/sdk@0.10.0` (sdk-ts#51), so {@link harnessFindingSource} and
32
+ * {@link harnessFindingStatus} below back {@link FindingSource} and
33
+ * {@link FindingStatusWriter} on the per-dispatch task grant. They stayed
34
+ * seams rather than becoming direct calls, which is what let this file be
35
+ * driven by stubs before the wire existed and still be driven by them in
36
+ * tests now that it does.
37
+ */
38
+ /** Severities, worst first. Used for the note's counts and for a stable order. */
39
+ export declare const SEVERITIES: readonly ["critical", "high", "medium", "low", "info"];
40
+ /** A Finding as the Fix needs it — the read half of sdk-ts#51. */
41
+ export interface FixableFinding {
42
+ /** The graph's `brain_id`, so a write lands on the node that was read. */
43
+ id: string;
44
+ /** `open`, `fixing`, `fixed` or `verified`. */
45
+ status: string;
46
+ severity: string;
47
+ /** The CVE, GHSA or platform id this Finding is an instance of. */
48
+ vulnerabilityId: string;
49
+ /** Where it is: a `Package`, a `Repository` file, or a `Service`. */
50
+ placeLabel: string;
51
+ placeKey: string;
52
+ /**
53
+ * `P1`..`P4`, what a triage pass decided (gibson#1684). **Absent means no
54
+ * pass has decided yet, never "unimportant"** — see {@link byWorkOrder}.
55
+ */
56
+ priority?: string;
57
+ /** The merge request already opened for this Finding, when it is `fixing`. */
58
+ mergeRequestIid?: number;
59
+ }
60
+ /** Reads an Application's Findings. Backed by {@link harnessFindingSource}. */
61
+ export interface FindingSource {
62
+ /**
63
+ * The Application's Findings in the given statuses, **in the order they
64
+ * should be worked**. Rejects when the graph is unreachable; it must never
65
+ * resolve empty to signal failure, because an empty list reads as a healthy
66
+ * Application and the Fix would report success over a live backlog.
67
+ */
68
+ findings(application: string, statuses: string[]): Promise<FixableFinding[]>;
69
+ }
70
+ /** Moves a Finding through its statuses. Backed by {@link harnessFindingStatus}. */
71
+ export interface FindingStatusWriter {
72
+ /** Record that a merge request now proposes this Finding's fix. */
73
+ markFixing(findingId: string, mr: MergeRequest): Promise<void>;
74
+ /** Record that the merge request merged. Never `verified` — the rescan does that. */
75
+ markFixed(findingId: string): Promise<void>;
76
+ }
77
+ /** A checkout the Fix can change and test before anything is pushed. */
78
+ export interface Workspace {
79
+ /** File contents, or `undefined` when the path does not exist. */
80
+ read(path: string): Promise<string | undefined>;
81
+ /** Stage a rewrite in the workspace. Not a push. */
82
+ write(path: string, content: string): Promise<void>;
83
+ /** Run the repository's own tests. `output` is quoted in the note on failure. */
84
+ test(): Promise<{
85
+ ok: boolean;
86
+ output: string;
87
+ }>;
88
+ /** Discard staged rewrites, so a failed fix does not leak into the next one. */
89
+ reset(): Promise<void>;
90
+ /** What was staged, in commit order. */
91
+ staged(): FileChange[];
92
+ }
93
+ /** What the agent proposes to do about one Finding. */
94
+ export interface FixPlan {
95
+ /** Which of the three the owner's scope named. Reported in the note. */
96
+ kind: "dependency" | "source" | "config";
97
+ /** One line, for the merge request title. */
98
+ summary: string;
99
+ /** The commit message body. */
100
+ detail: string;
101
+ }
102
+ /**
103
+ * Decides what to change for a Finding, and stages it in the workspace.
104
+ *
105
+ * Returning `undefined` means "no strategy for this Finding" — reported in the
106
+ * note as unfixed with that reason, never silently skipped. A planner that
107
+ * throws is treated the same way, with its message as the reason: one Finding
108
+ * with no strategy must not stop the ones behind it.
109
+ */
110
+ export interface FixPlanner {
111
+ plan(finding: FixableFinding, ws: Workspace): Promise<FixPlan | undefined>;
112
+ }
113
+ /** What happened to one Finding in a Fix pass. */
114
+ export interface FixOutcome {
115
+ finding: FixableFinding;
116
+ /** `fixing` when a merge request opened, `merged` when one already had, else `unfixed`. */
117
+ result: "fixing" | "merged" | "unfixed";
118
+ /** Why it is unfixed. Empty otherwise. Quoted verbatim in the note. */
119
+ reason: string;
120
+ mergeRequest?: MergeRequest;
121
+ plan?: FixPlan;
122
+ }
123
+ /** One line of the console stream, so the ops wall shows the Fix working. */
124
+ export type FixEvent = {
125
+ type: "fix.start";
126
+ application: string;
127
+ commit: string;
128
+ open: number;
129
+ fixing: number;
130
+ } | {
131
+ type: "fix.plan";
132
+ findingId: string;
133
+ kind: FixPlan["kind"];
134
+ summary: string;
135
+ } | {
136
+ type: "fix.tests";
137
+ findingId: string;
138
+ ok: boolean;
139
+ } | {
140
+ type: "fix.mr";
141
+ findingId: string;
142
+ iid: number;
143
+ webUrl: string;
144
+ } | {
145
+ type: "fix.merged";
146
+ findingId: string;
147
+ iid: number;
148
+ } | {
149
+ type: "fix.skip";
150
+ findingId: string;
151
+ reason: string;
152
+ } | {
153
+ type: "fix.error";
154
+ stage: "read" | "status" | "note" | "plan";
155
+ message: string;
156
+ };
157
+ export interface FixSummary {
158
+ application: string;
159
+ commit: string;
160
+ outcomes: FixOutcome[];
161
+ /** True when every Finding that had a strategy ended `fixing` or `merged`. */
162
+ clean: boolean;
163
+ }
164
+ export interface RunFixOptions {
165
+ application: string;
166
+ /** The commit the Scan mission ran against — what the status is set on. */
167
+ commit: string;
168
+ /** The branch merge requests target. */
169
+ targetRef: string;
170
+ findings: FindingSource;
171
+ status: FindingStatusWriter;
172
+ planner: FixPlanner;
173
+ workspace: Workspace;
174
+ gitlab: GitLabWriter;
175
+ /** The pipeline page, linked from the commit status. */
176
+ pipelineUrl?: string;
177
+ /** Cap on merge requests opened in one pass, so a backlog cannot flood review. */
178
+ maxMergeRequests?: number;
179
+ onEvent?: (e: FixEvent) => void;
180
+ }
181
+ /** Default ceiling on merge requests opened by one pass. */
182
+ export declare const DEFAULT_MAX_MERGE_REQUESTS = 10;
183
+ /**
184
+ * Run one Fix pass over an Application.
185
+ *
186
+ * Reconcile first, then fix. A Finding already `fixing` whose merge request has
187
+ * merged becomes `fixed` before anything new is opened, so a pass never opens a
188
+ * second merge request for a Finding the previous pass already resolved.
189
+ */
190
+ export declare function runFix(opts: RunFixOptions): Promise<FixSummary>;
191
+ /**
192
+ * The branch one Finding's fix lands on. Stable, so a retry reuses it rather
193
+ * than opening a second merge request for the same Finding.
194
+ *
195
+ * The slug keeps only `[a-z0-9-]`. Dots are dropped rather than preserved:
196
+ * git refuses a ref containing `..`, and a vulnerability id is attacker-
197
+ * adjacent text that ends up interpolated into a URL path, so a dot run is
198
+ * exactly the shape not to carry through. The Finding id is appended so two
199
+ * occurrences of one CVE get two branches.
200
+ */
201
+ export declare function fixBranch(finding: FixableFinding): string;
202
+ /** Counts by severity, worst first, for the note and the status line. */
203
+ export declare function countsBySeverity(findings: FixableFinding[]): Record<string, number>;
204
+ /** The one-line verdict GitLab shows beside the commit. */
205
+ export declare function statusDescription(summary: FixSummary): string;
206
+ /** The merge request description. Names the Finding it closes and how. */
207
+ export declare function mergeRequestBody(finding: FixableFinding, plan: FixPlan): string;
208
+ /** The note posted on each merge request: what this pass did, in full. */
209
+ export declare function noteBody(summary: FixSummary): string;
210
+ /**
211
+ * The order the Fix works Findings in: priority first, severity beneath it,
212
+ * then the Finding id so a pass over unchanged input is deterministic.
213
+ *
214
+ * Deterministic order matters beyond tidiness: the merge-request cap means the
215
+ * tail of this list is what gets deferred to the next pass, so an unstable
216
+ * sort would defer a different Finding each run and none would ever be worked.
217
+ */
218
+ export declare function byWorkOrder(a: FixableFinding, b: FixableFinding): number;
219
+ /**
220
+ * Read an Application's Findings over the task grant (sdk-ts#51).
221
+ *
222
+ * The rejection is deliberately NOT caught. `applicationFindings` throws when
223
+ * the graph is unreachable, and swallowing that into an empty list is the one
224
+ * failure this whole seam exists to prevent: the Fix would report a clean
225
+ * Application over a live backlog, and it would look exactly like health.
226
+ */
227
+ export declare function harnessFindingSource(knowledge: KnowledgeReadsFindings): FindingSource;
228
+ /** The single read the Fix needs from a knowledge source. */
229
+ export interface KnowledgeReadsFindings {
230
+ applicationFindings(opts: {
231
+ application: string;
232
+ statuses?: string[];
233
+ limit?: number;
234
+ }): Promise<ReadFinding[]>;
235
+ }
236
+ /** The fields of an `ApplicationFinding` the Fix reads. */
237
+ export interface ReadFinding {
238
+ findingId: string;
239
+ status: string;
240
+ severity: string;
241
+ vulnerabilityId: string;
242
+ placeLabel: string;
243
+ placeKey: string;
244
+ priority: string;
245
+ }
246
+ /** Writes one lifecycle sighting. Satisfied by the SDK's `observe`. */
247
+ export type ObserveLifecycle = (entity: {
248
+ label: string;
249
+ idProperties: Record<string, string>;
250
+ properties?: Record<string, string>;
251
+ }) => Promise<void>;
252
+ /**
253
+ * Move a Finding through its statuses over the task grant (sdk-ts#51).
254
+ *
255
+ * The Finding is named by its `brain_id` in `idProperties`, so the write lands
256
+ * on the node the scan raised rather than creating a second one beside it.
257
+ *
258
+ * Only the properties this transition decides are sent. A sighting that omits
259
+ * a property leaves whatever an earlier one established; one that sets it to
260
+ * `""` erases it. The Fix decides a status and, when it has one, the merge
261
+ * request — it decides nothing about severity, priority or reason, so it sends
262
+ * none of them and cannot blank what the scan or a triage pass recorded.
263
+ *
264
+ * There is no `markVerified`, and there is no branch here that could reach it.
265
+ * `verified` follows a rescan not seeing the weakness again, and absence is not
266
+ * an observation this process can make (gibson#1686).
267
+ */
268
+ export declare function harnessFindingStatus(observe: ObserveLifecycle): FindingStatusWriter;
package/dist/fix.js ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * The Fix — what the always-on agent does with a Finding (zerocool-plugins#89).
3
+ *
4
+ * After a Scan mission lands, the agent works the Application's open Findings
5
+ * in priority order. For each one it can act on it rewrites the repository,
6
+ * runs the repository's own tests, and only then opens a merge request set to
7
+ * merge itself when the pipeline succeeds. It records the scan verdict on the
8
+ * commit as `gibson/scan` and posts one note saying what it fixed, what it did
9
+ * not, and why.
10
+ *
11
+ * THE ORDER IS TESTS, THEN PUSH. A fix that breaks the build is worse than the
12
+ * Finding: it stops every other fix behind it. So the change is applied to a
13
+ * workspace, the tests run there, and a failure resets the workspace and leaves
14
+ * the Finding `open` with its reason recorded. Nothing unproven reaches GitLab.
15
+ *
16
+ * THE FIX NEVER MARKS A FINDING `verified`. A merge is not evidence that a
17
+ * rescan did not see the weakness again — only the rescan is, and absence is
18
+ * not an observation, so it is reconciled once at a scan's completion
19
+ * (gibson#1686). The Fix moves a Finding `open` → `fixing` when its merge
20
+ * request opens and `fixing` → `fixed` when GitLab reports that request merged.
21
+ *
22
+ * THE TOKEN NEVER APPEARS IN ANYTHING THIS WRITES. It reaches GitLab only as a
23
+ * `PRIVATE-TOKEN` header inside {@link gitlabRestWriter}. No branch name, merge
24
+ * request title, description, note or status description is built from it, and
25
+ * `fix.test.ts` proves that with a sentinel that would show up in every
26
+ * recorded output if it ever did.
27
+ *
28
+ * THE TWO PLATFORM SEAMS ARE LIVE. Reading an Application's Findings and
29
+ * writing a Finding's status arrived on the TypeScript wire in
30
+ * `@zeroroot-ai/sdk@0.10.0` (sdk-ts#51), so {@link harnessFindingSource} and
31
+ * {@link harnessFindingStatus} below back {@link FindingSource} and
32
+ * {@link FindingStatusWriter} on the per-dispatch task grant. They stayed
33
+ * seams rather than becoming direct calls, which is what let this file be
34
+ * driven by stubs before the wire existed and still be driven by them in
35
+ * tests now that it does.
36
+ */
37
+ /** Severities, worst first. Used for the note's counts and for a stable order. */
38
+ export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
39
+ /** Default ceiling on merge requests opened by one pass. */
40
+ export const DEFAULT_MAX_MERGE_REQUESTS = 10;
41
+ /**
42
+ * Run one Fix pass over an Application.
43
+ *
44
+ * Reconcile first, then fix. A Finding already `fixing` whose merge request has
45
+ * merged becomes `fixed` before anything new is opened, so a pass never opens a
46
+ * second merge request for a Finding the previous pass already resolved.
47
+ */
48
+ export async function runFix(opts) {
49
+ const emit = (e) => opts.onEvent?.(e);
50
+ const cap = opts.maxMergeRequests ?? DEFAULT_MAX_MERGE_REQUESTS;
51
+ const outcomes = [];
52
+ const all = await opts.findings.findings(opts.application, ["open", "fixing"]);
53
+ const fixing = all.filter((f) => f.status === "fixing");
54
+ const open = all.filter((f) => f.status === "open");
55
+ emit({ type: "fix.start", application: opts.application, commit: opts.commit, open: open.length, fixing: fixing.length });
56
+ // Reconcile: a merge request that merged moves its Finding to `fixed`.
57
+ for (const finding of fixing) {
58
+ if (finding.mergeRequestIid === undefined)
59
+ continue;
60
+ try {
61
+ const mr = await opts.gitlab.mergeRequestState(finding.mergeRequestIid);
62
+ if (mr.state !== "merged")
63
+ continue;
64
+ await opts.status.markFixed(finding.id);
65
+ emit({ type: "fix.merged", findingId: finding.id, iid: mr.iid });
66
+ outcomes.push({ finding, result: "merged", reason: "", mergeRequest: mr });
67
+ }
68
+ catch (e) {
69
+ emit({ type: "fix.error", stage: "status", message: message(e) });
70
+ }
71
+ }
72
+ let opened = 0;
73
+ for (const finding of open) {
74
+ if (opened >= cap) {
75
+ outcomes.push({ finding, result: "unfixed", reason: `merge request cap of ${cap} reached in this pass` });
76
+ emit({ type: "fix.skip", findingId: finding.id, reason: "cap reached" });
77
+ continue;
78
+ }
79
+ let plan;
80
+ try {
81
+ plan = await opts.planner.plan(finding, opts.workspace);
82
+ }
83
+ catch (e) {
84
+ await safeReset(opts.workspace);
85
+ outcomes.push({ finding, result: "unfixed", reason: `planning failed: ${message(e)}` });
86
+ emit({ type: "fix.error", stage: "plan", message: message(e) });
87
+ continue;
88
+ }
89
+ if (!plan) {
90
+ await safeReset(opts.workspace);
91
+ outcomes.push({ finding, result: "unfixed", reason: "no fix strategy for this finding" });
92
+ emit({ type: "fix.skip", findingId: finding.id, reason: "no fix strategy" });
93
+ continue;
94
+ }
95
+ const changes = opts.workspace.staged();
96
+ if (changes.length === 0) {
97
+ await safeReset(opts.workspace);
98
+ outcomes.push({ finding, result: "unfixed", reason: "the planner changed nothing" });
99
+ emit({ type: "fix.skip", findingId: finding.id, reason: "no change staged" });
100
+ continue;
101
+ }
102
+ emit({ type: "fix.plan", findingId: finding.id, kind: plan.kind, summary: plan.summary });
103
+ const tests = await opts.workspace.test();
104
+ emit({ type: "fix.tests", findingId: finding.id, ok: tests.ok });
105
+ if (!tests.ok) {
106
+ await safeReset(opts.workspace);
107
+ outcomes.push({ finding, result: "unfixed", plan, reason: `the repository's tests failed: ${firstLines(tests.output)}` });
108
+ continue;
109
+ }
110
+ try {
111
+ const branch = fixBranch(finding);
112
+ await opts.gitlab.commitToBranch(branch, opts.targetRef, `${plan.summary}\n\n${plan.detail}`, changes);
113
+ const mr = await opts.gitlab.openMergeRequest(branch, opts.targetRef, plan.summary, mergeRequestBody(finding, plan));
114
+ emit({ type: "fix.mr", findingId: finding.id, iid: mr.iid, webUrl: mr.webUrl });
115
+ opened++;
116
+ await opts.status.markFixing(finding.id, mr);
117
+ outcomes.push({ finding, result: "fixing", reason: "", mergeRequest: mr, plan });
118
+ }
119
+ catch (e) {
120
+ outcomes.push({ finding, result: "unfixed", plan, reason: `GitLab refused the change: ${message(e)}` });
121
+ emit({ type: "fix.error", stage: "status", message: message(e) });
122
+ }
123
+ finally {
124
+ await safeReset(opts.workspace);
125
+ }
126
+ }
127
+ const summary = {
128
+ application: opts.application,
129
+ commit: opts.commit,
130
+ outcomes,
131
+ clean: outcomes.every((o) => o.result !== "unfixed"),
132
+ };
133
+ // The verdict lands on the commit that was scanned, whatever happened above.
134
+ // A pass that could not reach GitLab for the status still reports its work
135
+ // through the return value, so the caller is never told the pass did nothing.
136
+ try {
137
+ await opts.gitlab.setCommitStatus(opts.commit, summary.clean ? "success" : "failed", statusDescription(summary), opts.pipelineUrl);
138
+ }
139
+ catch (e) {
140
+ emit({ type: "fix.error", stage: "note", message: message(e) });
141
+ }
142
+ for (const outcome of outcomes) {
143
+ if (outcome.result !== "fixing" || !outcome.mergeRequest)
144
+ continue;
145
+ try {
146
+ await opts.gitlab.comment(outcome.mergeRequest.iid, noteBody(summary));
147
+ }
148
+ catch (e) {
149
+ emit({ type: "fix.error", stage: "note", message: message(e) });
150
+ }
151
+ }
152
+ return summary;
153
+ }
154
+ /**
155
+ * The branch one Finding's fix lands on. Stable, so a retry reuses it rather
156
+ * than opening a second merge request for the same Finding.
157
+ *
158
+ * The slug keeps only `[a-z0-9-]`. Dots are dropped rather than preserved:
159
+ * git refuses a ref containing `..`, and a vulnerability id is attacker-
160
+ * adjacent text that ends up interpolated into a URL path, so a dot run is
161
+ * exactly the shape not to carry through. The Finding id is appended so two
162
+ * occurrences of one CVE get two branches.
163
+ */
164
+ export function fixBranch(finding) {
165
+ const slug = `${finding.vulnerabilityId || finding.id}`
166
+ .toLowerCase()
167
+ .replace(/[^a-z0-9]+/g, "-")
168
+ .replace(/^-+|-+$/g, "");
169
+ return `gibson/fix-${slug || "unclassified"}-${finding.id.slice(0, 8)}`;
170
+ }
171
+ /** Counts by severity, worst first, for the note and the status line. */
172
+ export function countsBySeverity(findings) {
173
+ const out = {};
174
+ for (const s of SEVERITIES) {
175
+ const n = findings.filter((f) => f.severity.toLowerCase() === s).length;
176
+ if (n > 0)
177
+ out[s] = n;
178
+ }
179
+ const known = new Set(SEVERITIES);
180
+ const other = findings.filter((f) => !known.has(f.severity.toLowerCase())).length;
181
+ if (other > 0)
182
+ out.other = other;
183
+ return out;
184
+ }
185
+ /** The one-line verdict GitLab shows beside the commit. */
186
+ export function statusDescription(summary) {
187
+ const fixing = summary.outcomes.filter((o) => o.result === "fixing").length;
188
+ const merged = summary.outcomes.filter((o) => o.result === "merged").length;
189
+ const unfixed = summary.outcomes.filter((o) => o.result === "unfixed").length;
190
+ if (summary.outcomes.length === 0)
191
+ return "no open findings";
192
+ return `${fixing} merge request(s) opened, ${merged} merged, ${unfixed} left for a human`;
193
+ }
194
+ /** The merge request description. Names the Finding it closes and how. */
195
+ export function mergeRequestBody(finding, plan) {
196
+ return [
197
+ plan.detail,
198
+ "",
199
+ `Finding: \`${finding.id}\``,
200
+ `Vulnerability: \`${finding.vulnerabilityId || "unclassified"}\``,
201
+ `Where: ${finding.placeLabel} \`${finding.placeKey}\``,
202
+ `Severity: ${finding.severity}${finding.priority ? ` · priority ${finding.priority}` : ""}`,
203
+ "",
204
+ "Opened by the Gibson always-on agent. It merges itself when the pipeline",
205
+ "succeeds. The finding is marked fixed on merge and verified only by a",
206
+ "later rescan.",
207
+ ].join("\n");
208
+ }
209
+ /** The note posted on each merge request: what this pass did, in full. */
210
+ export function noteBody(summary) {
211
+ const lines = [
212
+ `### Gibson scan of \`${summary.commit.slice(0, 12)}\` on ${summary.application}`,
213
+ "",
214
+ ];
215
+ const counts = countsBySeverity(summary.outcomes.map((o) => o.finding));
216
+ const rendered = Object.entries(counts)
217
+ .map(([k, v]) => `${v} ${k}`)
218
+ .join(", ");
219
+ lines.push(rendered ? `Findings this pass: ${rendered}.` : "No open findings this pass.");
220
+ lines.push("");
221
+ const fixed = summary.outcomes.filter((o) => o.result === "fixing" || o.result === "merged");
222
+ if (fixed.length > 0) {
223
+ lines.push("**Fixed**");
224
+ for (const o of fixed) {
225
+ const where = `${o.finding.placeLabel} \`${o.finding.placeKey}\``;
226
+ const link = o.mergeRequest?.webUrl ? ` — ${o.mergeRequest.webUrl}` : "";
227
+ lines.push(`- ${o.finding.vulnerabilityId || o.finding.id} in ${where}${link}`);
228
+ }
229
+ lines.push("");
230
+ }
231
+ const unfixed = summary.outcomes.filter((o) => o.result === "unfixed");
232
+ if (unfixed.length > 0) {
233
+ lines.push("**Left for a human**");
234
+ for (const o of unfixed) {
235
+ const where = `${o.finding.placeLabel} \`${o.finding.placeKey}\``;
236
+ lines.push(`- ${o.finding.vulnerabilityId || o.finding.id} in ${where} — ${o.reason}`);
237
+ }
238
+ }
239
+ return lines.join("\n");
240
+ }
241
+ async function safeReset(ws) {
242
+ try {
243
+ await ws.reset();
244
+ }
245
+ catch {
246
+ // A reset that fails is reported by the next planner call changing nothing.
247
+ }
248
+ }
249
+ function firstLines(output, n = 3) {
250
+ return output.split("\n").slice(0, n).join(" ").trim();
251
+ }
252
+ function message(e) {
253
+ return e instanceof Error ? e.message : String(e);
254
+ }
255
+ // --------------------------------------------------------------------------
256
+ // the platform seams, backed by the task grant (zerocool-plugins#96)
257
+ // --------------------------------------------------------------------------
258
+ /**
259
+ * Priority order, worst first. `P1` is the most urgent thing a triage pass
260
+ * found; anything it has not ranked sorts AFTER everything it has.
261
+ *
262
+ * An unranked Finding is not a low-priority one — it is one no pass has
263
+ * decided about yet (gibson#1684). Ranking it as `P4` would be inventing a
264
+ * decision nobody took, and the triage agent's keep-previous rule would then
265
+ * preserve that invention on the next pass. So unranked sorts last among
266
+ * peers rather than being scored, and severity breaks the tie beneath it, so
267
+ * a critical unranked Finding still outranks a low unranked one.
268
+ */
269
+ const PRIORITY_ORDER = ["p1", "p2", "p3", "p4"];
270
+ function priorityRank(priority) {
271
+ const i = PRIORITY_ORDER.indexOf((priority ?? "").toLowerCase());
272
+ return i === -1 ? PRIORITY_ORDER.length : i;
273
+ }
274
+ function severityRank(severity) {
275
+ const i = SEVERITIES.indexOf(severity.toLowerCase());
276
+ return i === -1 ? SEVERITIES.length : i;
277
+ }
278
+ /**
279
+ * The order the Fix works Findings in: priority first, severity beneath it,
280
+ * then the Finding id so a pass over unchanged input is deterministic.
281
+ *
282
+ * Deterministic order matters beyond tidiness: the merge-request cap means the
283
+ * tail of this list is what gets deferred to the next pass, so an unstable
284
+ * sort would defer a different Finding each run and none would ever be worked.
285
+ */
286
+ export function byWorkOrder(a, b) {
287
+ const p = priorityRank(a.priority) - priorityRank(b.priority);
288
+ if (p !== 0)
289
+ return p;
290
+ const s = severityRank(a.severity) - severityRank(b.severity);
291
+ if (s !== 0)
292
+ return s;
293
+ return a.id.localeCompare(b.id);
294
+ }
295
+ /**
296
+ * Read an Application's Findings over the task grant (sdk-ts#51).
297
+ *
298
+ * The rejection is deliberately NOT caught. `applicationFindings` throws when
299
+ * the graph is unreachable, and swallowing that into an empty list is the one
300
+ * failure this whole seam exists to prevent: the Fix would report a clean
301
+ * Application over a live backlog, and it would look exactly like health.
302
+ */
303
+ export function harnessFindingSource(knowledge) {
304
+ return {
305
+ async findings(application, statuses) {
306
+ const found = await knowledge.applicationFindings({ application, statuses });
307
+ return found
308
+ .map((f) => ({
309
+ id: f.findingId,
310
+ status: f.status,
311
+ severity: f.severity,
312
+ vulnerabilityId: f.vulnerabilityId,
313
+ placeLabel: f.placeLabel,
314
+ placeKey: f.placeKey,
315
+ // Omitted rather than defaulted: empty means no pass has decided.
316
+ ...(f.priority ? { priority: f.priority } : {}),
317
+ }))
318
+ .sort(byWorkOrder);
319
+ },
320
+ };
321
+ }
322
+ /**
323
+ * Move a Finding through its statuses over the task grant (sdk-ts#51).
324
+ *
325
+ * The Finding is named by its `brain_id` in `idProperties`, so the write lands
326
+ * on the node the scan raised rather than creating a second one beside it.
327
+ *
328
+ * Only the properties this transition decides are sent. A sighting that omits
329
+ * a property leaves whatever an earlier one established; one that sets it to
330
+ * `""` erases it. The Fix decides a status and, when it has one, the merge
331
+ * request — it decides nothing about severity, priority or reason, so it sends
332
+ * none of them and cannot blank what the scan or a triage pass recorded.
333
+ *
334
+ * There is no `markVerified`, and there is no branch here that could reach it.
335
+ * `verified` follows a rescan not seeing the weakness again, and absence is not
336
+ * an observation this process can make (gibson#1686).
337
+ */
338
+ export function harnessFindingStatus(observe) {
339
+ const write = (findingId, properties) => observe({ label: "Finding", idProperties: { brain_id: findingId }, properties });
340
+ return {
341
+ async markFixing(findingId, mr) {
342
+ await write(findingId, {
343
+ status: "fixing",
344
+ merge_request_iid: String(mr.iid),
345
+ merge_request_url: mr.webUrl,
346
+ });
347
+ },
348
+ async markFixed(findingId) {
349
+ await write(findingId, { status: "fixed" });
350
+ },
351
+ };
352
+ }
package/dist/gitlab.d.ts CHANGED
@@ -72,3 +72,47 @@ export declare function parsePipeline(raw: unknown): Pipeline | undefined;
72
72
  * request.
73
73
  */
74
74
  export declare function gitlabRest(opts: GitLabRestOptions): GitLabClient;
75
+ /** The `gibson/scan` commit status states GitLab accepts, narrowed to what we set. */
76
+ export type CommitStatusState = "success" | "failed" | "running";
77
+ /** The context name the scan status is reported under, named once. */
78
+ export declare const SCAN_STATUS_CONTEXT = "gibson/scan";
79
+ /** A merge request the Fix opened, reduced to what the Fix and a human need. */
80
+ export interface MergeRequest {
81
+ /** The project-scoped iid, which is what every later GitLab call takes. */
82
+ iid: number;
83
+ /** The page a human opens. Also what the Finding records. */
84
+ webUrl: string;
85
+ /** GitLab's own state: `opened`, `merged`, `closed`, `locked`. */
86
+ state: string;
87
+ }
88
+ /** One file the Fix rewrote, as the commit API wants it. */
89
+ export interface FileChange {
90
+ path: string;
91
+ content: string;
92
+ }
93
+ /** The GitLab writes the Fix makes. Kept behind an interface so a test needs no network. */
94
+ export interface GitLabWriter {
95
+ /**
96
+ * Commit `changes` onto a new branch off `startRef`. Returns the commit sha.
97
+ * The branch is created by the same call, so a half-made branch with no
98
+ * commit is not a state this can leave behind.
99
+ */
100
+ commitToBranch(branch: string, startRef: string, message: string, changes: FileChange[]): Promise<string>;
101
+ /** Open a merge request set to merge itself when its pipeline succeeds. */
102
+ openMergeRequest(branch: string, targetRef: string, title: string, description: string): Promise<MergeRequest>;
103
+ /** The current state of a merge request, so a later pass can see it merged. */
104
+ mergeRequestState(iid: number): Promise<MergeRequest>;
105
+ /** Post a note on a merge request. */
106
+ comment(iid: number, body: string): Promise<void>;
107
+ /** Set the `gibson/scan` status on a commit. */
108
+ setCommitStatus(sha: string, state: CommitStatusState, description: string, targetUrl?: string): Promise<void>;
109
+ }
110
+ /**
111
+ * A {@link GitLabWriter} over GitLab's REST API.
112
+ *
113
+ * Every request carries the token in `PRIVATE-TOKEN` and never in a URL or a
114
+ * body, so a failure message that quotes the request cannot carry it into the
115
+ * console stream. The error text names the project, the branch and the status
116
+ * code — never a header.
117
+ */
118
+ export declare function gitlabRestWriter(opts: GitLabRestOptions): GitLabWriter;
package/dist/gitlab.js CHANGED
@@ -80,3 +80,97 @@ export function gitlabRest(opts) {
80
80
  },
81
81
  };
82
82
  }
83
+ /** The context name the scan status is reported under, named once. */
84
+ export const SCAN_STATUS_CONTEXT = "gibson/scan";
85
+ /**
86
+ * A {@link GitLabWriter} over GitLab's REST API.
87
+ *
88
+ * Every request carries the token in `PRIVATE-TOKEN` and never in a URL or a
89
+ * body, so a failure message that quotes the request cannot carry it into the
90
+ * console stream. The error text names the project, the branch and the status
91
+ * code — never a header.
92
+ */
93
+ export function gitlabRestWriter(opts) {
94
+ const base = (opts.baseUrl || GITLAB_DEFAULT_URL).replace(/\/+$/, "");
95
+ const project = encodeURIComponent(opts.projectPath);
96
+ const doFetch = opts.fetch ?? globalThis.fetch;
97
+ const api = `${base}/api/v4/projects/${project}`;
98
+ async function call(method, path, body) {
99
+ const res = await doFetch(`${api}${path}`, {
100
+ method,
101
+ headers: {
102
+ "PRIVATE-TOKEN": opts.token,
103
+ Accept: "application/json",
104
+ ...(body === undefined ? {} : { "Content-Type": "application/json" }),
105
+ },
106
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
107
+ });
108
+ if (!res.ok) {
109
+ throw new Error(`GitLab ${method} ${path} on ${opts.projectPath} returned ${res.status} ${res.statusText}`);
110
+ }
111
+ return res.json();
112
+ }
113
+ const asMergeRequest = (raw, branch) => {
114
+ const mr = (raw ?? {});
115
+ const iid = typeof mr.iid === "number" ? mr.iid : Number.NaN;
116
+ if (!Number.isInteger(iid) || iid <= 0) {
117
+ throw new Error(`GitLab returned a merge request for ${branch} with no iid`);
118
+ }
119
+ return {
120
+ iid,
121
+ webUrl: typeof mr.web_url === "string" ? mr.web_url : "",
122
+ state: typeof mr.state === "string" ? mr.state : "",
123
+ };
124
+ };
125
+ return {
126
+ async commitToBranch(branch, startRef, message, changes) {
127
+ if (changes.length === 0) {
128
+ throw new Error(`refusing to commit an empty change set to ${branch}`);
129
+ }
130
+ const raw = await call("POST", "/repository/commits", {
131
+ branch,
132
+ start_branch: startRef,
133
+ commit_message: message,
134
+ actions: changes.map((c) => ({ action: "update", file_path: c.path, content: c.content })),
135
+ });
136
+ const sha = raw.id;
137
+ if (typeof sha !== "string" || !sha) {
138
+ throw new Error(`GitLab accepted the commit on ${branch} but returned no sha`);
139
+ }
140
+ return sha;
141
+ },
142
+ async openMergeRequest(branch, targetRef, title, description) {
143
+ const created = asMergeRequest(await call("POST", "/merge_requests", {
144
+ source_branch: branch,
145
+ target_branch: targetRef,
146
+ title,
147
+ description,
148
+ remove_source_branch: true,
149
+ }), branch);
150
+ // Auto-merge is a separate call, and a refusal is not fatal: the merge
151
+ // request is open and correct either way, and a human can merge it. A
152
+ // thrown error here would lose a good merge request over a race with the
153
+ // pipeline that has not started yet.
154
+ try {
155
+ await call("PUT", `/merge_requests/${created.iid}/merge`, { merge_when_pipeline_succeeds: true });
156
+ }
157
+ catch {
158
+ // Reported by the caller through the note, not swallowed silently.
159
+ }
160
+ return created;
161
+ },
162
+ mergeRequestState: async (iid) => asMergeRequest(await call("GET", `/merge_requests/${iid}`), `!${iid}`),
163
+ comment: async (iid, body) => {
164
+ await call("POST", `/merge_requests/${iid}/notes`, { body });
165
+ },
166
+ setCommitStatus: async (sha, state, description, targetUrl) => {
167
+ await call("POST", `/statuses/${encodeURIComponent(sha)}`, {
168
+ state,
169
+ name: SCAN_STATUS_CONTEXT,
170
+ context: SCAN_STATUS_CONTEXT,
171
+ description,
172
+ ...(targetUrl ? { target_url: targetUrl } : {}),
173
+ });
174
+ },
175
+ };
176
+ }
package/dist/watch.d.ts CHANGED
@@ -170,13 +170,18 @@ export interface ScanMissionInputs {
170
170
  /**
171
171
  * The Scan mission definition, as canonical proto JSON.
172
172
  *
173
- * A PLACEHOLDER WITH A REAL SHAPE. gibson#1660 owns the Scan mission proper —
174
- * Trivy on the image, source analysis on the commit, and the runtime tools
175
- * against the target, fanned out and joined. Until it lands, this originates
176
- * the one node that exists today: zerocool's source-analysis task
177
- * (zerocool-plugins#87) on the pipeline's commit. Everything the full fan-out
178
- * needs is already carried in the node's task context, so gibson#1660 replaces
179
- * the body of this one function and nothing else in the loop changes.
173
+ * STILL BUILT HERE, AND IT SHOULD NOT BE. gibson#1685 landed the Scan mission
174
+ * as a checked-in definition in gibson's `missioncatalog`, and ADR-0018 makes
175
+ * that copy authoritative: this function is meant to become a mission name plus
176
+ * seven parameters, not a second description of the same graph.
177
+ *
178
+ * It cannot yet. `missioncatalog.Render` has no production caller, and
179
+ * `CreateMission` carries only `mission_definition_json` — there is no field
180
+ * naming a checked-in mission and none carrying its parameters, so an agent has
181
+ * no way to ask for the catalog's copy. That is gibson#1688. Until it lands the
182
+ * only way to originate a Scan is to send a graph, so this one stays, with the
183
+ * shape the catalog definition expects: everything the fan-out needs is carried
184
+ * in the node's task context, and the swap is this function's body alone.
180
185
  *
181
186
  * The definition is built as protojson by hand rather than through the
182
187
  * generated schema because the SDK's package exports only its index, and the
package/dist/watch.js CHANGED
@@ -225,13 +225,18 @@ export function checkpointQueryText(application) {
225
225
  /**
226
226
  * The Scan mission definition, as canonical proto JSON.
227
227
  *
228
- * A PLACEHOLDER WITH A REAL SHAPE. gibson#1660 owns the Scan mission proper —
229
- * Trivy on the image, source analysis on the commit, and the runtime tools
230
- * against the target, fanned out and joined. Until it lands, this originates
231
- * the one node that exists today: zerocool's source-analysis task
232
- * (zerocool-plugins#87) on the pipeline's commit. Everything the full fan-out
233
- * needs is already carried in the node's task context, so gibson#1660 replaces
234
- * the body of this one function and nothing else in the loop changes.
228
+ * STILL BUILT HERE, AND IT SHOULD NOT BE. gibson#1685 landed the Scan mission
229
+ * as a checked-in definition in gibson's `missioncatalog`, and ADR-0018 makes
230
+ * that copy authoritative: this function is meant to become a mission name plus
231
+ * seven parameters, not a second description of the same graph.
232
+ *
233
+ * It cannot yet. `missioncatalog.Render` has no production caller, and
234
+ * `CreateMission` carries only `mission_definition_json` — there is no field
235
+ * naming a checked-in mission and none carrying its parameters, so an agent has
236
+ * no way to ask for the catalog's copy. That is gibson#1688. Until it lands the
237
+ * only way to originate a Scan is to send a graph, so this one stays, with the
238
+ * shape the catalog definition expects: everything the fan-out needs is carried
239
+ * in the node's task context, and the swap is this function's body alone.
235
240
  *
236
241
  * The definition is built as protojson by hand rather than through the
237
242
  * generated schema because the SDK's package exports only its index, and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeroroot-ai/zerocool",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "opencode plugin: Gibson platform integration (zerocool). Zero-config LLM, tools, findings, knowledge, delegate/missions.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,7 +23,7 @@
23
23
  "test": "tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\""
24
24
  },
25
25
  "dependencies": {
26
- "@zeroroot-ai/sdk": "^0.9.0"
26
+ "@zeroroot-ai/sdk": "^0.10.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "@opencode-ai/plugin": "*"