@brainervirus/workit-core 8.33.0 → 8.35.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.33.0",
3
+ "version": "8.35.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -29,6 +29,8 @@ export const PR_STATUS_QUERY = `query($owner: String!, $name: String!, $number:
29
29
 
30
30
  export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state baseRefName headRefName headRefOid headRepositoryOwner { login } } } } }`;
31
31
 
32
+ export const BRANCH_CHECKS_QUERY = `query($owner: String!, $name: String!, $ref: String!, $contexts: String) { repository(owner: $owner, name: $name) { object(expression: $ref) { ... on Commit { oid statusCheckRollup { contexts(first: 100, after: $contexts) { pageInfo { hasNextPage endCursor } nodes { __typename ... on CheckRun { name status conclusion detailsUrl databaseId checkSuite { workflowRun { databaseId workflow { name } } } } ... on StatusContext { context state targetUrl } } } } } } } }`;
33
+
32
34
  const READY_MUTATION = `mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
33
35
  const DRAFT_MUTATION = `mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
34
36
  const REPLY_MUTATION = `mutation($thread: ID!, $body: String!) { addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $thread, body: $body }) { comment { url } } }`;
@@ -113,6 +115,12 @@ type GqlPr = {
113
115
  };
114
116
  };
115
117
 
118
+ type BranchChecksData = {
119
+ repository: {
120
+ object: { oid?: string; statusCheckRollup: { contexts: Page<GqlContext> } | null } | null;
121
+ } | null;
122
+ };
123
+
116
124
  type GqlResponse<T> = { data?: T; errors?: Array<{ message?: string; type?: string }> };
117
125
 
118
126
  const prState = (value: string): PrState =>
@@ -451,6 +459,36 @@ export function createGitHubForge(options: {
451
459
  return success(status);
452
460
  },
453
461
 
462
+ branchChecks(branch) {
463
+ const contexts: GqlContext[] = [];
464
+ let sha: string | null = null;
465
+ let cursor: string | null = null;
466
+ for (let page = 1; page <= maxPages; page += 1) {
467
+ const data: ForgeResult<BranchChecksData> = graphql<BranchChecksData>(
468
+ BRANCH_CHECKS_QUERY,
469
+ { owner, name, ref: `refs/heads/${branch}`, contexts: cursor },
470
+ `checks on ${branch}`,
471
+ );
472
+ if (!data.ok) return data;
473
+ const commit: NonNullable<BranchChecksData["repository"]>["object"] =
474
+ data.data.repository?.object ?? null;
475
+ if (!commit) return failure("not_found", `branch ${branch} was not found`);
476
+ sha = commit.oid ?? sha;
477
+ const rollup: { contexts: Page<GqlContext> } | null = commit.statusCheckRollup;
478
+ if (!rollup) break;
479
+ contexts.push(...rollup.contexts.nodes);
480
+ if (!rollup.contexts.pageInfo.hasNextPage) break;
481
+ cursor = rollup.contexts.pageInfo.endCursor;
482
+ }
483
+ const required = requiredContexts(branch);
484
+ const checks = latestPerName(
485
+ contexts.map(checkFromContext).filter((check): check is ForgeCheck => check !== null),
486
+ ).map((check) =>
487
+ required ? { ...check, required: required.includes(check.context) } : check,
488
+ );
489
+ return success({ sha, checks, requiredKnown: required !== null });
490
+ },
491
+
454
492
  jobLogTail(job, lines) {
455
493
  const text = apiText(
456
494
  runner,
@@ -465,6 +465,54 @@ export function createGitLabForge(options: {
465
465
  return success(status);
466
466
  },
467
467
 
468
+ branchChecks(branch) {
469
+ const tip = baseTip(branch);
470
+ if (!tip) return failure("not_found", `branch ${branch} was not found`);
471
+ const latest = apiJson<
472
+ Array<{ id: number; project_id?: number; status: string; web_url?: string }>
473
+ >(
474
+ runner,
475
+ "glab",
476
+ apiHost,
477
+ api(
478
+ `${project}/pipelines?ref=${encodeURIComponent(branch)}&sha=${tip}&order_by=id&sort=desc&per_page=1`,
479
+ ),
480
+ `pipelines on ${branch}`,
481
+ );
482
+ if (!latest.ok) return latest;
483
+ const pipeline = Array.isArray(latest.data) ? latest.data[0] : undefined;
484
+ if (!pipeline) return success({ sha: tip, checks: [], requiredKnown: true });
485
+ const scope = String(pipeline.project_id ?? encodeURIComponent(repo));
486
+ const jobs = pages(
487
+ `projects/${scope}/pipelines/${pipeline.id}/jobs`,
488
+ `pipeline ${pipeline.id} jobs`,
489
+ );
490
+ if (!jobs.ok) return jobs;
491
+ const bridges = pages(
492
+ `projects/${scope}/pipelines/${pipeline.id}/bridges`,
493
+ `pipeline ${pipeline.id} bridges`,
494
+ );
495
+ if (!bridges.ok) return bridges;
496
+ const checks = [
497
+ ...(jobs.data.items as GlJob[]).map((job) => checkFromJob(job, pipeline.id, scope)),
498
+ ...(bridges.data.items as GlBridge[]).map((bridge) =>
499
+ checkFromBridge(bridge, pipeline.id, scope),
500
+ ),
501
+ ];
502
+ if (
503
+ stateOf(pipeline.status, false) === "failing" &&
504
+ !checks.some((check) => check.state === "failing")
505
+ )
506
+ checks.push(
507
+ pipelineCheck("failing", pipeline.status, {
508
+ id: pipeline.id,
509
+ project_id: pipeline.project_id ?? 0,
510
+ web_url: pipeline.web_url,
511
+ }),
512
+ );
513
+ return success({ sha: tip, checks, requiredKnown: true });
514
+ },
515
+
468
516
  jobLogTail(job, lines) {
469
517
  const scope = job.scope ?? encodeURIComponent(repo);
470
518
  const text = apiText(
@@ -46,7 +46,14 @@ import type { LaneNeed, ReviewLane } from "../core/policy/lane";
46
46
  import { laneLabel, prLaneGate } from "../core/policy/pr-lane";
47
47
  import { buildStatusDoc, selectPr, type PrStatusDoc } from "./report";
48
48
  import type { ResolvedForge } from "./resolve";
49
- import { failure, success, type ForgeResult, type MergeMethod, type PrRef } from "./types";
49
+ import {
50
+ failure,
51
+ success,
52
+ type ForgeCheck,
53
+ type ForgeResult,
54
+ type MergeMethod,
55
+ type PrRef,
56
+ } from "./types";
50
57
 
51
58
  export type Sleep = (ms: number) => Promise<void>;
52
59
 
@@ -358,6 +365,8 @@ export type MergeInput = {
358
365
  expect?: { base: string; branch: string };
359
366
  /** Merge without a verdict (`merge: true` only); the reason is recorded. */
360
367
  unverified?: { reason: string };
368
+ /** Merge onto a base whose latest CI is red (`--base-red-ok`); the reason is recorded. */
369
+ baseRedOk?: { reason: string };
361
370
  /** Squash commit subject and body (`--subject`, `--body`); unset parts use the defaults. */
362
371
  subject?: string;
363
372
  body?: string;
@@ -393,6 +402,8 @@ export type MergeOutcome = {
393
402
  };
394
403
  /** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
395
404
  unverified: { reason: string; recorded: string } | null;
405
+ /** The base branch's CI at its head: a one-line note when it was not green, and the override used. */
406
+ baseCi: { note: string | null; override: { reason: string } | null };
396
407
  grant: { source: AutonomySource };
397
408
  /** `skipped`: the implicit default found a reason to keep the branch; the merge is unaffected. */
398
409
  deletedBranch: boolean | { error: string } | { skipped: string };
@@ -454,6 +465,7 @@ export type MergeRefusal = {
454
465
  | "closed"
455
466
  | "protected_branch"
456
467
  | "base_mismatch"
468
+ | "base_red"
457
469
  | "ledger_tampered"
458
470
  | "intent_unrecorded";
459
471
  next?: string;
@@ -632,6 +644,41 @@ function implicitDeleteSkip(
632
644
  return null;
633
645
  }
634
646
 
647
+ type BaseCi =
648
+ | { ok: true; note: string | null }
649
+ | { ok: false; failing: ForgeCheck[]; sha: string | null; requiredKnown: boolean };
650
+
651
+ /**
652
+ * The base branch head's CI before a merge lands on it: red (a failing check)
653
+ * refuses, pending or unreadable proceeds with a note, green or no checks
654
+ * proceeds quietly. Only a protected target is guarded: a stack's lower
655
+ * branch is its own PR's business.
656
+ */
657
+ function baseCiState(cwd: string, resolved: ResolvedForge, base: string): BaseCi {
658
+ if (!isProtectedTarget(cwd, base)) return { ok: true, note: null };
659
+ const read = resolved.forge.branchChecks(base);
660
+ if (!read.ok)
661
+ return { ok: true, note: `base ${base} CI not read (${read.error}); merging anyway` };
662
+ // Only checks branch protection requires gate; with the required set
663
+ // unreadable, count hard failures not known to be optional.
664
+ const { requiredKnown } = read.data;
665
+ const failing = read.data.checks.filter(
666
+ (check) =>
667
+ check.state === "failing" &&
668
+ (requiredKnown
669
+ ? check.required === true
670
+ : check.required !== false && check.conclusion === "failure"),
671
+ );
672
+ if (failing.length) return { ok: false, failing, sha: read.data.sha, requiredKnown };
673
+ const pending = read.data.checks.filter((check) => check.state === "pending");
674
+ return {
675
+ ok: true,
676
+ note: pending.length
677
+ ? `base ${base} CI is still running (${pending.map((check) => check.name).join(", ")}); merging without waiting`
678
+ : null,
679
+ };
680
+ }
681
+
635
682
  export async function mergePullRequest(
636
683
  cwd: string,
637
684
  resolved: ResolvedForge,
@@ -650,6 +697,13 @@ export async function mergePullRequest(
650
697
  { reason: "unverified_refused" },
651
698
  );
652
699
 
700
+ const baseRed = input.baseRedOk ?? null;
701
+ if (baseRed && !baseRed.reason.trim())
702
+ return failure(
703
+ "invalid_input",
704
+ "--base-red-ok needs --reason <why merging onto a red base is fine>",
705
+ );
706
+
653
707
  const number = selectPr(cwd, resolved, { pr: input.pr });
654
708
  if (!number.ok) return number;
655
709
  const status = resolved.forge.prStatus(number.data);
@@ -789,6 +843,20 @@ export async function mergePullRequest(
789
843
  );
790
844
  }
791
845
 
846
+ const baseCi = baseCiState(cwd, resolved, doc.base);
847
+ let baseRedOverride: { reason: string } | null = null;
848
+ if (!baseCi.ok) {
849
+ const names = baseCi.failing.map((failed) => failed.name).join(", ");
850
+ const url = baseCi.failing.find((failed) => failed.url)?.url ?? null;
851
+ if (!baseRed)
852
+ return refuse(
853
+ `base_red: ${doc.base} at ${baseCi.sha?.slice(0, 12) ?? "its head"} has failing CI (${names}${baseCi.requiredKnown ? "" : "; the required checks could not be read, so every failed check counts"})${url ? `: ${url}` : ""}; merging ${label} onto it would land on a red base`,
854
+ `fix ${doc.base} first, or, if the user accepts it: workit pr merge${input.pr ? ` --pr ${doc.number}` : ""} --base-red-ok --reason "<why>"`,
855
+ { reason: "base_red", blockers: baseCi.failing.map((failed) => failed.name) },
856
+ );
857
+ baseRedOverride = baseRed;
858
+ }
859
+
792
860
  const squashMessage = squashMessageFor(cwd, resolved, input, doc);
793
861
  if (squashMessage && !squashMessage.ok) return squashMessage;
794
862
  // The intent goes in before the forge write: a merge call that errors after
@@ -806,6 +874,7 @@ export async function mergePullRequest(
806
874
  review: verdict.review,
807
875
  lane: verdict.lane,
808
876
  ...(unverified ? { unverified: unverified.recorded } : {}),
877
+ ...(baseRedOverride ? { baseRedOk: baseRedOverride.reason } : {}),
809
878
  grant: grant.source,
810
879
  });
811
880
  if (!intent.ok)
@@ -841,6 +910,7 @@ export async function mergePullRequest(
841
910
  review: verdict.review,
842
911
  lane: verdict.lane,
843
912
  ...(unverified ? { unverified: unverified.recorded } : {}),
913
+ ...(baseRedOverride ? { baseRedOk: baseRedOverride.reason } : {}),
844
914
  grant: grant.source,
845
915
  intent: intentId,
846
916
  verified: false,
@@ -909,6 +979,14 @@ export async function mergePullRequest(
909
979
  mergeSha: merged.data.mergeSha,
910
980
  verdict,
911
981
  unverified,
982
+ baseCi: {
983
+ note: baseRedOverride
984
+ ? `base ${doc.base} CI is red; merged anyway (--base-red-ok: ${baseRedOverride.reason})`
985
+ : baseCi.ok
986
+ ? baseCi.note
987
+ : null,
988
+ override: baseRedOverride,
989
+ },
912
990
  grant: { source: grant.source },
913
991
  deletedBranch,
914
992
  recorded,
@@ -110,6 +110,16 @@ export type ForgePrStatus = {
110
110
  truncated: boolean;
111
111
  };
112
112
 
113
+ /** The checks on the latest commit of a branch (the merge guard reads the base's). */
114
+ export type RefChecks = {
115
+ /** The commit the checks belong to; null when the forge did not say. */
116
+ sha: string | null;
117
+ /** `required` is set from branch protection when `requiredKnown`; else it is null on GitHub. */
118
+ checks: ForgeCheck[];
119
+ /** True when the branch's required checks could be read (GitLab gates on the whole pipeline). */
120
+ requiredKnown: boolean;
121
+ };
122
+
113
123
  export type JobRef = { jobId: number; scope: string | null };
114
124
 
115
125
  export type RerunTarget =
@@ -196,6 +206,8 @@ export interface Forge {
196
206
  repoInfo(repo: string): ForgeResult<RepoInfo>;
197
207
  findPr(head: string, match: PrMatch): ForgeResult<PrRef | null>;
198
208
  prStatus(n: number): ForgeResult<ForgePrStatus>;
209
+ /** The checks on the head commit of branch `branch`; empty when CI has not reported for it. */
210
+ branchChecks(branch: string): ForgeResult<RefChecks>;
199
211
  jobLogTail(job: JobRef, lines: number): ForgeResult<string[]>;
200
212
  rerun(target: RerunTarget): ForgeResult<void>;
201
213
  /** Open a PR/MR; `headSha` is the head the forge reports right after creation. */
@@ -0,0 +1,96 @@
1
+ // A tiny record of which hook events each host ran (#433): without it "zero
2
+ // hook events" cannot tell a host that never fires its hooks from one that
3
+ // fires them silently. One small file per host and native event in the state
4
+ // dir (`hook-runs/<host>__<event>.json`: last seen time and a count), written
5
+ // best effort: one read and one atomic-enough write, no spawn, and a
6
+ // read-only state dir never changes the hook's answer. `workit doctor`
7
+ // (`hook_runs`) reads it. Light imports only: the Codex launcher's quiet path
8
+ // calls it before the runtime loads.
9
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { stateDir } from "../core/heavy-words";
12
+
13
+ export const HOOK_RUNS_DIR = "hook-runs";
14
+
15
+ export type HookRun = {
16
+ host: string;
17
+ event: string;
18
+ at: string;
19
+ count: number;
20
+ };
21
+
22
+ const safe = (part: string): string => part.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64);
23
+
24
+ const runFile = (dir: string, host: string, event: string): string =>
25
+ path.join(dir, HOOK_RUNS_DIR, `${safe(host)}__${safe(event)}.json`);
26
+
27
+ /** Counts one run of the native `event` on `host`; never throws. */
28
+ export const recordHookRun = (
29
+ host: string,
30
+ event: string | null | undefined,
31
+ env: NodeJS.ProcessEnv = process.env,
32
+ now: () => number = Date.now,
33
+ ): void => {
34
+ if (!event) return;
35
+ try {
36
+ const dir = stateDir(env);
37
+ const file = runFile(dir, host, event);
38
+ let count = 0;
39
+ try {
40
+ const previous = JSON.parse(readFileSync(file, "utf8")) as {
41
+ count?: unknown;
42
+ };
43
+ if (typeof previous.count === "number" && Number.isFinite(previous.count))
44
+ count = previous.count;
45
+ } catch {
46
+ // First run, or a torn file: start again.
47
+ }
48
+ const row: HookRun = {
49
+ host,
50
+ event,
51
+ at: new Date(now()).toISOString(),
52
+ count: count + 1,
53
+ };
54
+ try {
55
+ writeFileSync(file, JSON.stringify(row));
56
+ } catch {
57
+ mkdirSync(path.dirname(file), { recursive: true });
58
+ writeFileSync(file, JSON.stringify(row));
59
+ }
60
+ } catch {
61
+ // The hook still answers the host; only the doctor line is lost.
62
+ }
63
+ };
64
+
65
+ /** Every recorded run under `stateDir`; unreadable or malformed files are skipped. */
66
+ export const readHookRuns = (dir: string): HookRun[] => {
67
+ const runs: HookRun[] = [];
68
+ let names: string[] = [];
69
+ try {
70
+ names = readdirSync(path.join(dir, HOOK_RUNS_DIR));
71
+ } catch {
72
+ return runs;
73
+ }
74
+ for (const name of names) {
75
+ try {
76
+ const row = JSON.parse(
77
+ readFileSync(path.join(dir, HOOK_RUNS_DIR, name), "utf8"),
78
+ ) as Partial<HookRun>;
79
+ if (
80
+ typeof row.host === "string" &&
81
+ typeof row.event === "string" &&
82
+ typeof row.at === "string" &&
83
+ typeof row.count === "number"
84
+ )
85
+ runs.push({
86
+ host: row.host,
87
+ event: row.event,
88
+ at: row.at,
89
+ count: row.count,
90
+ });
91
+ } catch {
92
+ // Not a run record.
93
+ }
94
+ }
95
+ return runs;
96
+ };
package/src/hooks/run.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import { claudeCodeAdapter } from "./hosts/claude-code";
12
12
  import { codexAdapter } from "./hosts/codex";
13
13
  import { cursorAdapter } from "./hosts/cursor";
14
+ import { recordHookRun } from "./hook-runs";
14
15
  import type { HostAdapter, HostId } from "./protocol";
15
16
 
16
17
  type ProcessHost = Exclude<HostId, "opencode" | "pi">;
@@ -44,6 +45,12 @@ export async function runHookProcess(
44
45
  } catch {
45
46
  raw = undefined;
46
47
  }
48
+ const native = (raw as { hook_event_name?: unknown } | null | undefined)?.hook_event_name;
49
+ recordHookRun(
50
+ typeof host === "string" ? host : adapter.descriptor.host,
51
+ typeof native === "string" ? native : null,
52
+ env,
53
+ );
47
54
  const result =
48
55
  text === null
49
56
  ? skippedHook(adapter, undefined, env, budget, "input")