@brainervirus/workit-core 8.15.1 → 8.17.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.
@@ -20,6 +20,16 @@ import fs from "node:fs";
20
20
  import os from "node:os";
21
21
  import path from "node:path";
22
22
  import { tailLines, type CheckRun } from "../checks";
23
+ import { stateDir } from "./heavy";
24
+
25
+ export { stateDir };
26
+ export {
27
+ probeScope,
28
+ scopePrefix,
29
+ scopeSupport,
30
+ type ScopeProbe,
31
+ type ScopeSupport,
32
+ } from "./heavy-scope";
23
33
 
24
34
  /** Named checks that take the lease when nothing is configured. */
25
35
  export const DEFAULT_HEAVY_CHECKS = ["test", "build", "typecheck"];
@@ -183,17 +193,6 @@ export function resolveResourceSettings(
183
193
  // ---------------------------------------------------------------------------
184
194
  // the machine lease
185
195
 
186
- /** Workit's state dir for `env` (the logger's resolution, over an explicit env). */
187
- export function stateDir(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform) {
188
- if (env.WORKFLOW_TOOLKIT_STATE) return env.WORKFLOW_TOOLKIT_STATE;
189
- if (env.XDG_STATE_HOME) return path.join(env.XDG_STATE_HOME, "workit");
190
- const home = env.HOME || os.homedir();
191
- if (platform === "darwin") return path.join(home, "Library", "Application Support", "workit");
192
- if (platform === "win32")
193
- return path.join(env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "workit");
194
- return path.join(home, ".local", "state", "workit");
195
- }
196
-
197
196
  export const leaseDir = (env: NodeJS.ProcessEnv): string => path.join(stateDir(env), "leases");
198
197
 
199
198
  export type LeaseRecord = {
@@ -588,71 +587,6 @@ export function describeHolder(holder: LeaseHolder, now = Date.now()): string {
588
587
  return `${holder.check}, pid ${holder.pid}${holder.session ? `, session ${holder.session}` : ""}, ${age}, ${holder.cwd}`;
589
588
  }
590
589
 
591
- // ---------------------------------------------------------------------------
592
- // the systemd user scope
593
-
594
- export type ScopeSupport = { ok: true } | { ok: false; reason: string };
595
-
596
- const onPath = (command: string, env: NodeJS.ProcessEnv): boolean =>
597
- (env.PATH ?? "")
598
- .split(path.delimiter)
599
- .filter(Boolean)
600
- .some((dir) => {
601
- try {
602
- fs.accessSync(path.join(dir, command), fs.constants.X_OK);
603
- return true;
604
- } catch {
605
- return false;
606
- }
607
- });
608
-
609
- /**
610
- * Can heavy checks run in their own `systemd-run --user --scope`? Needs Linux,
611
- * `systemd-run` on PATH, a running user manager, and the memory and cpu
612
- * controllers delegated to it (cgroup v2).
613
- */
614
- export function scopeSupport(
615
- env: NodeJS.ProcessEnv,
616
- platform: NodeJS.Platform = process.platform,
617
- read: (file: string) => string | null = readText,
618
- ): ScopeSupport {
619
- if (platform !== "linux") return { ok: false, reason: `no systemd scopes on ${platform}` };
620
- if (!onPath("systemd-run", env)) return { ok: false, reason: "systemd-run is not on PATH" };
621
- const uid = process.getuid?.() ?? 0;
622
- const runtime = env.XDG_RUNTIME_DIR || `/run/user/${uid}`;
623
- if (!fs.existsSync(path.join(runtime, "systemd", "private")))
624
- return { ok: false, reason: "no user systemd manager is running" };
625
- const controllers = read(
626
- `/sys/fs/cgroup/user.slice/user-${uid}.slice/user@${uid}.service/cgroup.controllers`,
627
- );
628
- if (controllers === null)
629
- return { ok: false, reason: "cgroup v2 is not mounted for the user manager" };
630
- const missing = ["memory", "cpu"].filter((name) => !controllers.split(/\s+/u).includes(name));
631
- if (missing.length)
632
- return { ok: false, reason: `the user manager is not delegated ${missing.join(" and ")}` };
633
- return { ok: true };
634
- }
635
-
636
- /** The `systemd-run` prefix that runs a command in its own limited scope. */
637
- export function scopePrefix(
638
- unit: string,
639
- settings: Pick<ResourceSettings, "memoryMax" | "cpuQuota">,
640
- ) {
641
- return [
642
- "systemd-run",
643
- "--user",
644
- "--scope",
645
- "--quiet",
646
- "--collect",
647
- `--unit=${unit}`,
648
- "-p",
649
- `MemoryMax=${settings.memoryMax}`,
650
- "-p",
651
- `CPUQuota=${settings.cpuQuota}`,
652
- "--",
653
- ];
654
- }
655
-
656
590
  /** systemd-run's own failure before it ran the command (bus, unit, property). */
657
591
  const SCOPE_START_FAILURE =
658
592
  /^Failed to (?:connect to bus|create bus connection|start transient scope unit|parse \S+?=)/u;
@@ -745,3 +679,25 @@ export function firstNotice(
745
679
  }
746
680
  return true;
747
681
  }
682
+
683
+ export type SlotState = {
684
+ slot: number;
685
+ /** The lease in this slot, or null when it is free. */
686
+ holder: LeaseHolder | null;
687
+ /** Why the lease in it no longer holds (the next acquirer reclaims it). */
688
+ stale: string | null;
689
+ };
690
+
691
+ /** What each of `slots` lease slots holds right now. Read-only: nothing is reclaimed. */
692
+ export function leaseSlots(dir: string, slots: number, probe: LeaseProbe = {}): SlotState[] {
693
+ const states: SlotState[] = [];
694
+ for (let slot = 0; slot < slots; slot += 1) {
695
+ const lease = parseLease(readText(path.join(dir, `slot-${slot}.json`)));
696
+ states.push({
697
+ slot,
698
+ holder: lease ? holderOf(lease) : null,
699
+ stale: lease ? staleReason(lease, probe) : null,
700
+ });
701
+ }
702
+ return states;
703
+ }
@@ -823,6 +823,27 @@ export const taskSummarySchema = z
823
823
  })
824
824
  .strict();
825
825
  export type TaskSummary = z.infer<typeof taskSummarySchema>;
826
+ /** Bounded `inspect` projection (`view: "summary"`): under 4 KB however long the task history is. */
827
+ export type TaskBrief = {
828
+ id: string;
829
+ revision: string;
830
+ workspaceRevision: string;
831
+ updatedAt: string;
832
+ objective: string;
833
+ status: TaskSummary["status"];
834
+ closure: { outcome: Closure["outcome"]; at: string; summary: string } | null;
835
+ progress: {
836
+ summary: string;
837
+ nextAction: string | null;
838
+ blockers: { reason: string; dependentAction: string }[];
839
+ };
840
+ requirements: {
841
+ total: number;
842
+ unmet: number;
843
+ open: { dimension: Dimension; before: string; status: RequirementEvaluation["status"] }[];
844
+ };
845
+ truncated: boolean;
846
+ };
826
847
  export const taskListItemSchema = taskSummarySchema
827
848
  .omit({ policy: true, requirements: true })
828
849
  .extend({ source: provenanceSchema.pick({ host: true, kind: true }) })
@@ -35,6 +35,7 @@ import {
35
35
  type Result,
36
36
  type Scope,
37
37
  type TaskRecord,
38
+ type TaskBrief,
38
39
  type TaskListItem,
39
40
  type TaskSummary,
40
41
  type TaskView,
@@ -81,6 +82,7 @@ import {
81
82
  type NativeWorkerVerifier,
82
83
  } from "./workers";
83
84
  import {
85
+ boundTaskSummary,
84
86
  captureCandidate,
85
87
  checkPin,
86
88
  signalFreshness,
@@ -115,6 +117,8 @@ export type OperationContext = {
115
117
  now: Utc | (() => Utc);
116
118
  nativeWorker?: NativeWorkerVerifier;
117
119
  workerId?: string | null;
120
+ /** A child session with no worker record: it may only read the bounded task summary. */
121
+ readOnlyChild?: boolean;
118
122
  };
119
123
 
120
124
  export type ObserveCheckRequest = {
@@ -872,19 +876,27 @@ export class WorkitCore {
872
876
  return failure("invalid_input", "unknown state action");
873
877
  }
874
878
 
875
- task(request: unknown): Result<TaskSummary | TaskListItem[] | TaskView> {
879
+ task(request: unknown): Result<TaskSummary | TaskBrief | TaskListItem[] | TaskView> {
876
880
  const resolved = this.implicitTask("task", request);
877
881
  if (!resolved.ok) return resolved;
878
882
  const value = resolved.data;
879
883
  return this.retryOmittedRevisions(value, () => this.taskOnce(value));
880
884
  }
881
885
 
882
- private taskOnce(request: unknown): Result<TaskSummary | TaskListItem[] | TaskView> {
886
+ private taskOnce(request: unknown): Result<TaskSummary | TaskBrief | TaskListItem[] | TaskView> {
883
887
  const root = this.contextRootError();
884
888
  if (!root.ok) return root;
885
889
  const parsed = parseOperation("task", request);
886
890
  if (!parsed.ok) return parsed;
887
891
  const input = parsed.data;
892
+ if (this.context.readOnlyChild) {
893
+ if (input.action !== "inspect")
894
+ return failure("permission_denied", "child sessions can only inspect the task summary");
895
+ const task = this.store.readTask(input.taskId);
896
+ if (!task.ok) return task;
897
+ const summary = this.summary(task.data);
898
+ return summary.ok ? { ...summary, data: boundTaskSummary(summary.data) } : summary;
899
+ }
888
900
  if ((this.context.workerId ?? null) !== null) {
889
901
  if (input.action !== "inspect" && input.action !== "list")
890
902
  return failure("permission_denied", "helpers cannot control task lifecycle or scope");
@@ -18,6 +18,8 @@ import {
18
18
  type EvidenceEvaluation,
19
19
  type Outcome,
20
20
  type Requirement,
21
+ type TaskBrief,
22
+ type TaskSummary,
21
23
  type Result,
22
24
  type Scope,
23
25
  type TaskRecord,
@@ -1074,3 +1076,72 @@ export function evaluateClosure(
1074
1076
  requirementIds: evaluations.map((item) => item.requirementId),
1075
1077
  });
1076
1078
  }
1079
+
1080
+ export const TASK_BRIEF_MAX_BYTES = 4096;
1081
+ const BRIEF_TEXT = 250;
1082
+ const BRIEF_BLOCKERS = 3;
1083
+ const BRIEF_OPEN = 8;
1084
+
1085
+ /** Cut `value` until its JSON-escaped form fits `BRIEF_TEXT` bytes. */
1086
+ const clip = (value: string): string => {
1087
+ const fits = (text: string) => Buffer.byteLength(JSON.stringify(text)) <= BRIEF_TEXT;
1088
+ if (fits(value)) return value;
1089
+ let length = Math.min(value.length, BRIEF_TEXT);
1090
+ while (length > 0 && !fits(`${value.slice(0, length)}…`)) length -= 1;
1091
+ return `${value.slice(0, length)}…`;
1092
+ };
1093
+
1094
+ /**
1095
+ * Bound a task summary for `inspect --view summary`: text is clipped, lists
1096
+ * capped and the policy reduced to the requirements still owed, so the JSON
1097
+ * stays under {@link TASK_BRIEF_MAX_BYTES} however long the task history is.
1098
+ */
1099
+ export function boundTaskSummary(summary: TaskSummary): TaskBrief {
1100
+ const rules = new Map((summary.policy?.requirements ?? []).map((item) => [item.id, item]));
1101
+ const unmet = summary.requirements.filter(
1102
+ (item) => item.status === "unsatisfied" || item.status === "unavailable",
1103
+ );
1104
+ const { progress, closure } = summary;
1105
+ const next = progress.nextAction;
1106
+ const open = unmet.slice(0, BRIEF_OPEN).map((item) => ({
1107
+ dimension: rules.get(item.requirementId)?.dimension ?? "verification",
1108
+ before: rules.get(item.requirementId)?.before ?? "close",
1109
+ status: item.status,
1110
+ }));
1111
+ const brief: TaskBrief = {
1112
+ id: summary.id,
1113
+ revision: summary.revision,
1114
+ workspaceRevision: summary.workspaceRevision,
1115
+ updatedAt: summary.updatedAt,
1116
+ objective: clip(summary.objective),
1117
+ status: summary.status,
1118
+ closure: closure && {
1119
+ outcome: closure.outcome,
1120
+ at: closure.at,
1121
+ summary: clip(closure.summary),
1122
+ },
1123
+ progress: {
1124
+ summary: clip(progress.summary),
1125
+ nextAction: next && clip(next),
1126
+ blockers: progress.blockers.slice(0, BRIEF_BLOCKERS).map((item) => ({
1127
+ reason: clip(item.reason),
1128
+ dependentAction: clip(item.dependentAction),
1129
+ })),
1130
+ },
1131
+ requirements: { total: summary.requirements.length, unmet: unmet.length, open },
1132
+ truncated: false,
1133
+ };
1134
+ brief.truncated =
1135
+ JSON.stringify(brief).includes("…") ||
1136
+ progress.blockers.length > BRIEF_BLOCKERS ||
1137
+ unmet.length > BRIEF_OPEN;
1138
+ return brief;
1139
+ }
1140
+
1141
+ /** For a child or unattested caller: `task inspect` with the summary view returns the brief. */
1142
+ export function briefInspectResult(request: unknown, result: Result<unknown>): Result<unknown> {
1143
+ const { action, view } = (request ?? {}) as { action?: unknown; view?: unknown };
1144
+ return result.ok && action === "inspect" && (view ?? "summary") === "summary"
1145
+ ? { ...result, data: boundTaskSummary(result.data as TaskSummary) }
1146
+ : result;
1147
+ }
@@ -143,6 +143,13 @@ const workspaceConfigSchema = z
143
143
  defaultEndpoint: z.unknown().optional(),
144
144
  /** How a normal-risk behavior change is verified (S17); same rules as grants. */
145
145
  verification: z.enum(["self", "independent"]).optional(),
146
+ /**
147
+ * Who does the work (skills and `fanout plan` read it): `inline-first`
148
+ * (default) keeps the lead working itself and refuses a slice without a
149
+ * reason to delegate; `lead-orchestrates` allows slices without one.
150
+ * Read leniently (workspaceDelegation): an unknown value reads as unset.
151
+ */
152
+ delegation: z.unknown().optional(),
146
153
  /**
147
154
  * Whether commits get the `Workit-Session:` trailer (default true). With
148
155
  * `false`, `workit git commit` and the session hook write none and
@@ -766,6 +773,21 @@ export const workspaceLiveSessionGuard = (cwd: string, dir?: string): boolean =>
766
773
  inheritedSetting(cwd, dir, (entry) => typeof entry.liveSessionGuard === "boolean")?.source
767
774
  ?.liveSessionGuard !== false;
768
775
 
776
+ export const DELEGATION_MODES = ["inline-first", "lead-orchestrates"] as const;
777
+ export type DelegationMode = (typeof DELEGATION_MODES)[number];
778
+
779
+ const isDelegation = (value: unknown): value is DelegationMode =>
780
+ (DELEGATION_MODES as readonly unknown[]).includes(value);
781
+
782
+ /**
783
+ * The workspace's `delegation` mode, default `inline-first`; inherited like
784
+ * `trailers`. A value this version does not know reads as unset.
785
+ */
786
+ export const workspaceDelegation = (cwd: string, dir?: string): DelegationMode => {
787
+ const source = inheritedSetting(cwd, dir, (entry) => isDelegation(entry.delegation))?.source;
788
+ return isDelegation(source?.delegation) ? source.delegation : "inline-first";
789
+ };
790
+
769
791
  const hostFamily = (host: string): string => host.replace(/_(?:cli|desktop)$/u, "");
770
792
 
771
793
  const TIER_KEYS = new Set(["mundane", "standard", "hard"]);
@@ -112,6 +112,7 @@ const rules = (
112
112
  ? ' 3. Commit with plain `git commit -m <msg> --trailer "Workit-Session: <the WORKIT_SESSION_ID in your env>" --trailer "<your host attribution trailer>" -- <paths in SCOPE>`; push only if this brief says so.'
113
113
  : " 3. Commit with `workit git commit -m <msg> -- <paths in SCOPE>`; push only if this brief says so.",
114
114
  " 4. Never record a verdict on your own work.",
115
+ " 5. Run tests, lint, typecheck and builds only through `workit check` (a named check, or `workit check --heavy -- <cmd>`): one machine-wide lease queues every agent, so never a hand-made flock; `workit lock status` shows who holds it.",
115
116
  ];
116
117
  };
117
118
 
@@ -210,6 +211,7 @@ export function renderBrief(
210
211
  "ACCEPTANCE:",
211
212
  ...slice.acceptance.map((line) => ` - ${line}`),
212
213
  `VERIFY: ${slice.verify.join("; ")}`,
214
+ ...(slice.reason ? [`REASON: ${slice.reason}`] : []),
213
215
  `TIER: ${slice.tier}`,
214
216
  ...(model
215
217
  ? [` model: ${model.model} (fanout.tierModels in workspace ${model.workspace})`]
package/src/fanout.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  //
21
21
  // Each slice's `hash` covers what it is (goal, acceptance, verify, forbidden,
22
22
  // scope, owns, branch, base, dependencies), not how it is briefed (tier,
23
- // timebox, context, issues) or where its worktree goes. A re-plan that changes a
23
+ // reason, timebox, context, issues) or where its worktree goes. A re-plan that changes a
24
24
  // slice starts a new run for that slice alone: its `hashSince` moves to now,
25
25
  // and only ledger rows recorded under its hash (worktree rows carry
26
26
  // `sliceHash`) or since then link a gone branch's merged PR to it or count as
@@ -45,6 +45,7 @@ import {
45
45
  storeRoot,
46
46
  type LedgerActor,
47
47
  } from "./ledger";
48
+ import { workspaceDelegation, type DelegationMode } from "./core/workspaces";
48
49
  import { stackFileName } from "./stack";
49
50
 
50
51
  export const FANOUT_VERSION = 1;
@@ -58,6 +59,8 @@ export type Slice = {
58
59
  base: string;
59
60
  worktree: string;
60
61
  tier: Tier;
62
+ /** Why this work is delegated; required under `delegation: inline-first`. */
63
+ reason: Reason | null;
61
64
  dependsOn: string[];
62
65
  /** The file-scope manifest: globs (`*`, `**`, `?`, `{a,b}`) relative to the repo root. */
63
66
  scope: string[];
@@ -77,6 +80,10 @@ export type Slice = {
77
80
  hashSince: string;
78
81
  };
79
82
 
83
+ /** Why a slice is delegated at all (the brief's REASON line). */
84
+ export const REASONS = ["explore", "parallel", "backstop", "high-risk-verify"] as const;
85
+ export type Reason = (typeof REASONS)[number];
86
+
80
87
  export const FAN_IN_MODES = ["prs", "integration"] as const;
81
88
  export type FanInMode = (typeof FAN_IN_MODES)[number];
82
89
 
@@ -321,6 +328,8 @@ export type PlanDefaults = {
321
328
  root: string;
322
329
  /** Where default worktrees go: `<repo>-wt/` next to the main checkout. */
323
330
  worktreeRoot: string;
331
+ /** Under inline-first (the default when absent), a slice needs a reason. */
332
+ delegation?: DelegationMode;
324
333
  };
325
334
 
326
335
  type Parsed = { ok: true; plan: FanoutFile } | { ok: false; problems: string[] };
@@ -405,6 +414,15 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
405
414
  const tier = item.tier;
406
415
  if (typeof tier !== "string" || !(TIERS as readonly string[]).includes(tier))
407
416
  problems.push(`${where}: tier must be one of ${TIERS.join(", ")}`);
417
+ const rawReason = typeof item.reason === "string" ? item.reason.trim() : item.reason;
418
+ let reason: Reason | null = null;
419
+ if (rawReason === undefined || rawReason === null || rawReason === "") {
420
+ if ((defaults.delegation ?? "inline-first") === "inline-first")
421
+ problems.push(
422
+ `${where}: reason is empty; delegation is inline-first, so name why this is delegated: ${REASONS.join(", ")} (or set delegation to lead-orchestrates in the workspace)`,
423
+ );
424
+ } else if ((REASONS as readonly unknown[]).includes(rawReason)) reason = rawReason as Reason;
425
+ else problems.push(`${where}: reason must be one of ${REASONS.join(", ")}`);
408
426
  const dependsOn = textList(item.dependsOn ?? []);
409
427
  if (dependsOn === null) problems.push(`${where}: dependsOn must be a list of slice ids`);
410
428
  const issues = textList(item.issue ?? []);
@@ -420,6 +438,7 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
420
438
  optional("worktree") ?? path.join(defaults.worktreeRoot, id),
421
439
  ),
422
440
  tier: tier as Tier,
441
+ reason,
423
442
  dependsOn: [...new Set(dependsOn ?? [])],
424
443
  scope,
425
444
  owns,
@@ -782,7 +801,7 @@ export const planLeads = (
782
801
 
783
802
  /**
784
803
  * sha256 (16 hex) of the slice's definition. Brief-only fields (tier,
785
- * timebox, context, issues) and the worktree path are left out, so fixing a typo in
804
+ * reason, timebox, context, issues) and the worktree path are left out, so fixing a typo in
786
805
  * them never re-dispatches landed work.
787
806
  */
788
807
  export function sliceHash(slice: Slice): string {
@@ -791,6 +810,7 @@ export function sliceHash(slice: Slice): string {
791
810
  hash: _hash,
792
811
  hashSince: _since,
793
812
  tier: _tier,
813
+ reason: _reason,
794
814
  timebox: _timebox,
795
815
  context: _context,
796
816
  issues: _issues,
@@ -839,6 +859,9 @@ function parseStored(raw: unknown): FanoutFile | null {
839
859
  base: text(item.base) || raw.trunk,
840
860
  worktree: text(item.worktree),
841
861
  tier: (TIERS as readonly unknown[]).includes(item.tier) ? (item.tier as Tier) : "standard",
862
+ reason: (REASONS as readonly unknown[]).includes(item.reason)
863
+ ? (item.reason as Reason)
864
+ : null,
842
865
  dependsOn: list(item.dependsOn),
843
866
  scope: globs(item.scope),
844
867
  owns: globs(item.owns),
@@ -1043,6 +1066,7 @@ export function planFanout(
1043
1066
  trunk: input.trunk ?? defaultTrunk(cwd),
1044
1067
  root: mainCheckout(cwd),
1045
1068
  worktreeRoot: worktreeRoot(cwd),
1069
+ delegation: workspaceDelegation(cwd),
1046
1070
  },
1047
1071
  input.name,
1048
1072
  );
@@ -19,6 +19,8 @@ export type HostDescriptor = {
19
19
  channel: "permissionDecision" | "exit2+json" | "effect" | "block" | null;
20
20
  /** A parse or handler failure on a pre-tool gate denies instead of passing. */
21
21
  failClosed: boolean;
22
+ /** The pre-shell hook can replace the command before it runs (the heavy-command lease). */
23
+ rewrite: Support;
22
24
  };
23
25
  context: {
24
26
  sessionStart: Support;
@@ -9,6 +9,7 @@ import type { HostDescriptor, Support } from "./descriptor";
9
9
  import { branchGuidance } from "../core/branch";
10
10
  import { workspaceTrailers } from "../core/workspaces";
11
11
  import { recordIssuedSession } from "../host-session";
12
+ import { heavyDenial, heavyGate, rewriteNote, splitDenial } from "./heavy-gate";
12
13
  import { shellPolicy } from "./policy";
13
14
  import {
14
15
  callKey,
@@ -162,6 +163,19 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
162
163
  ? shellGate(input.cwd, event.command, liveOwner(input, descriptor.transport), event.dialect)
163
164
  : NONE;
164
165
  if (gate.kind === "deny") return gate;
166
+ // A heavy command runs under the machine lease: rewritten where the
167
+ // host can replace the command, else denied with the exact rewrite.
168
+ // Every gate above passed on the original command (ruling mvid5onk).
169
+ const plan = heavyGate(input.cwd, event.command, {
170
+ host: input.host,
171
+ dialect: event.dialect,
172
+ permissionMode: input.permissionMode,
173
+ });
174
+ if (plan.kind === "split" && canDeny) return splitDenial(plan);
175
+ const rewrites = usable(descriptor.shellPolicy.rewrite);
176
+ if (plan.kind === "rewrite" && !rewrites && canDeny) return heavyDenial(event.command, plan);
177
+ const lease = plan.kind === "rewrite" && rewrites ? plan : null;
178
+ const aside = plan.kind === "passthrough" ? plan.note : null;
165
179
  // The command will run: note HEAD so the post-tool hook (or the next
166
180
  // command, without one) can tell whether a raw commit moved it.
167
181
  noteRawCommit(
@@ -169,13 +183,28 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
169
183
  event.command,
170
184
  postTool ? callKey(event.toolUseId, event.command) : NEXT_COMMAND,
171
185
  );
186
+ const rewritten = (text: string | null): HookDecision =>
187
+ lease
188
+ ? {
189
+ kind: "rewrite",
190
+ command: lease.command,
191
+ input: event.toolInput ? { ...event.toolInput, command: lease.command } : null,
192
+ text,
193
+ }
194
+ : NONE;
172
195
  // An in-process plugin shows nudges on the tool result (shellNudge):
173
196
  // spending the once-per-session nudge here would lose it.
174
- if (descriptor.transport !== "hook-process") return NONE;
175
- return withContextLine(
176
- canDeny ? rawGitPre(input, event.command) : NONE,
197
+ if (descriptor.transport !== "hook-process") return rewritten(null);
198
+ const nudged = withContextLine(
199
+ withContextLine(canDeny ? rawGitPre(input, event.command) : NONE, aside),
177
200
  shipNudge(input, event.command),
178
201
  );
202
+ if (!lease) return nudged;
203
+ return rewritten(
204
+ [rewriteNote(lease), nudged.kind === "context" ? nudged.text : null]
205
+ .filter(Boolean)
206
+ .join("\n"),
207
+ );
179
208
  }
180
209
  case "shell.post":
181
210
  if (usable(descriptor.events["shell.post"].support))
@@ -0,0 +1,91 @@
1
+ // The heavy-command gate (issue #325, ruling mvid5onk): after every Workit
2
+ // gate passed on the original command, a raw heavy command (tests, builds,
3
+ // lint, typecheck, installs; core/heavy.ts) runs under the machine-wide
4
+ // heavy-check lease:
5
+ // - one simple heavy command: rewritten to `workit check --heavy -- <cmd>`
6
+ // where the host can replace it (Claude Code, OpenCode, Pi), else denied
7
+ // with that exact command (Codex, Cursor);
8
+ // - a chain with heavy parts: denied, naming each part's lease command
9
+ // (never `bash -c`);
10
+ // - a command a host permission rule names, or one whose wrapper only an
11
+ // allow for `workit` would let through: left alone, with a note.
12
+ import {
13
+ claudeRules,
14
+ codexRules,
15
+ cursorRules,
16
+ opencodeRules,
17
+ type RuleRead,
18
+ } from "../core/heavy-rules";
19
+ import { heavyPlan, workitOnPath, type HeavyPlan } from "../core/heavy";
20
+ import { maybeHeavy } from "../core/heavy-words";
21
+ import type { HookDecision, HostId } from "./protocol";
22
+ import type { ShellDialect } from "./shell-words";
23
+
24
+ /** The host's readable permission rules (null where it keeps none the hook could read). */
25
+ const rulesFor = (host: HostId, cwd: string, env: NodeJS.ProcessEnv): RuleRead | null => {
26
+ switch (host) {
27
+ case "claude_code":
28
+ return claudeRules(cwd, env);
29
+ case "opencode":
30
+ return opencodeRules(cwd, env);
31
+ case "codex_cli":
32
+ case "codex_desktop":
33
+ return codexRules(cwd, env);
34
+ case "cursor":
35
+ return cursorRules(cwd, env);
36
+ case "pi":
37
+ return null;
38
+ }
39
+ };
40
+
41
+ export type GateOptions = {
42
+ host: HostId;
43
+ dialect?: ShellDialect;
44
+ /** Claude `permission_mode`: bypassPermissions skips the allowlist check. */
45
+ permissionMode?: string | null;
46
+ env?: NodeJS.ProcessEnv;
47
+ };
48
+
49
+ /**
50
+ * The gate's plan for `command`, run after the Workit gates allowed it.
51
+ * Nothing when no `workit` is on PATH (the wrapper could not run) or the
52
+ * host sets WORKIT_HEAVY_GATE=0. Never throws.
53
+ */
54
+ export function heavyGate(cwd: string, command: string, options: GateOptions): HeavyPlan {
55
+ const env = options.env ?? process.env;
56
+ try {
57
+ // The launchers' cheap word check first: most commands stop here.
58
+ if (env.WORKIT_HEAVY_GATE === "0" || !maybeHeavy(command, cwd, env) || !workitOnPath(env))
59
+ return { kind: "none" };
60
+ return heavyPlan(command, {
61
+ cwd,
62
+ env,
63
+ dialect: options.dialect ?? "posix",
64
+ rules: rulesFor(options.host, cwd, env),
65
+ bypass: options.permissionMode === "bypassPermissions",
66
+ });
67
+ } catch {
68
+ return { kind: "none" };
69
+ }
70
+ }
71
+
72
+ /** The model-visible line after a rewrite. */
73
+ export const rewriteNote = (plan: Extract<HeavyPlan, { kind: "rewrite" }>): string =>
74
+ `workit: this heavy command (${plan.reason}) runs under the machine-wide heavy-check lease as \`${plan.command}\`: it waits while another heavy check holds the lease (\`workit lock status\` shows who). Run heavy commands that way yourself; \`workit help lock\` explains the lease.`;
75
+
76
+ /** The deny on hosts that cannot rewrite: the exact command to run instead. */
77
+ export const heavyDenial = (
78
+ original: string,
79
+ plan: Extract<HeavyPlan, { kind: "rewrite" }>,
80
+ ): HookDecision => ({
81
+ kind: "deny",
82
+ reason: `heavy_command: \`${original.trim()}\` is heavy (${plan.reason}) and must run under the machine-wide heavy-check lease, so parallel agents cannot exhaust this machine's memory and CPU. Run exactly: ${plan.command}`,
83
+ unblock: plan.command,
84
+ });
85
+
86
+ /** The deny for a chain with heavy parts. */
87
+ export const splitDenial = (plan: Extract<HeavyPlan, { kind: "split" }>): HookDecision => ({
88
+ kind: "deny",
89
+ reason: plan.reason,
90
+ unblock: plan.unblock,
91
+ });
@@ -54,7 +54,12 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
54
54
  // `stop_hook_active` and `last_assistant_message` (claude 2.1.295).
55
55
  stop: { support: "native", native: "Stop" },
56
56
  },
57
- shellPolicy: { deny: "native", channel: "permissionDecision", failClosed: false },
57
+ shellPolicy: {
58
+ deny: "native",
59
+ channel: "permissionDecision",
60
+ failClosed: false,
61
+ rewrite: "native",
62
+ },
58
63
  context: {
59
64
  sessionStart: "native",
60
65
  perTurn: "native",
@@ -185,7 +190,16 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
185
190
  return { ok: true, event: { kind: "tool.pre", tool: value.tool_name, toolUseId } };
186
191
  const command = toolCommand(value);
187
192
  return command
188
- ? { ok: true, event: { kind: "shell.pre", command, toolUseId, dialect: dialectOf(value) } }
193
+ ? {
194
+ ok: true,
195
+ event: {
196
+ kind: "shell.pre",
197
+ command,
198
+ toolUseId,
199
+ dialect: dialectOf(value),
200
+ toolInput: value.tool_input as Record<string, unknown>,
201
+ },
202
+ }
189
203
  : { ok: false, error: `tool_input.command is required for ${value.tool_name}` };
190
204
  }
191
205
  case "PostToolUse": {
@@ -254,8 +268,25 @@ const CONTEXT_EVENTS = new Set([
254
268
  "PostToolUse",
255
269
  ]);
256
270
 
257
- /** Never emits `allow`: that would bypass the user's own permission prompt. */
271
+ /**
272
+ * Never emits `allow`: that would bypass the user's own permission prompt. A
273
+ * rewrite is `updatedInput` with no permissionDecision: Claude Code applies it
274
+ * and runs its normal permission flow on the rewritten command (claude
275
+ * 2.1.306: a hook result with updatedInput and no permissionBehavior is
276
+ * yielded as the input to run).
277
+ */
258
278
  const render = (decision: HookDecision, native: string | null) => {
279
+ if (decision.kind === "rewrite" && native === "PreToolUse" && decision.input)
280
+ return {
281
+ json: {
282
+ hookSpecificOutput: {
283
+ hookEventName: "PreToolUse",
284
+ updatedInput: decision.input,
285
+ ...(decision.text ? { additionalContext: decision.text } : {}),
286
+ },
287
+ },
288
+ exitCode: 0,
289
+ };
259
290
  if (decision.kind === "deny" && native === "PreToolUse")
260
291
  return {
261
292
  json: {