@brainervirus/workit-core 8.16.0 → 8.17.1
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 +1 -1
- package/skills/workit-fanout/SKILL.md +7 -7
- package/skills/workit-fanout/references/brief.md +3 -1
- package/skills/workit-implement/SKILL.md +8 -1
- package/skills/workit-review/SKILL.md +2 -1
- package/skills/workit-verify-app/SKILL.md +2 -0
- package/src/check-config.ts +21 -1
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +92 -0
- package/src/core/heavy.ts +542 -0
- package/src/core/resources.ts +32 -76
- package/src/core/workspaces.ts +22 -0
- package/src/fanout-brief.ts +2 -0
- package/src/fanout.ts +26 -2
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/descriptor.ts +2 -0
- package/src/hooks/handle.ts +32 -3
- package/src/hooks/heavy-gate.ts +91 -0
- package/src/hooks/hosts/claude-code.ts +34 -3
- package/src/hooks/hosts/codex.ts +6 -1
- package/src/hooks/hosts/cursor.ts +1 -1
- package/src/hooks/hosts/opencode.ts +1 -1
- package/src/hooks/hosts/pi.ts +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/protocol.ts +8 -0
- package/src/hooks/raw-git.ts +104 -2
- package/src/hooks/run.ts +34 -9
- package/src/hooks/write-gate.ts +42 -2
package/src/core/resources.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/core/workspaces.ts
CHANGED
|
@@ -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"]);
|
package/src/fanout-brief.ts
CHANGED
|
@@ -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
|
);
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// The hook's own deadline (issue #309). A host kills a hook past its timeout
|
|
2
|
+
// and discards its output; under machine load that silently drops the Workit
|
|
3
|
+
// context and gates. Past this deadline, below every host timeout, the hook
|
|
4
|
+
// answers a minimal valid result naming the skip and records it for doctor.
|
|
5
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { resolveStateDir } from "../core/logger";
|
|
8
|
+
import { dispatchHook } from "./handle";
|
|
9
|
+
import type { HookDecision, HookEventKind, HostAdapter, RenderedHook } from "./protocol";
|
|
10
|
+
|
|
11
|
+
export const HOOK_SKIP_TEXT = "workit context skipped: host under load";
|
|
12
|
+
|
|
13
|
+
/** For a host without its own row; Claude Code's prompt/tool/subagent value. */
|
|
14
|
+
export const HOOK_DEADLINE_MS = 8_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Per host, by native event ("" for the rest): a margin below the host's own
|
|
18
|
+
* timeout, left for a dispatch that started in time. Claude Code registers
|
|
19
|
+
* 15 s (20 s for SessionStart and Stop); Codex defaults to 600 s and Workit
|
|
20
|
+
* registers 20 s for Stop; Cursor defaults to 60 s.
|
|
21
|
+
*/
|
|
22
|
+
export const HOST_DEADLINES: Readonly<Record<string, Readonly<Record<string, number>>>> = {
|
|
23
|
+
claude_code: { "": 8_000, SessionStart: 16_000, Stop: 16_000 },
|
|
24
|
+
codex_cli: { "": 45_000, Stop: 16_000 },
|
|
25
|
+
codex_desktop: { "": 45_000, Stop: 16_000 },
|
|
26
|
+
cursor: { "": 45_000 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** One JSON HookSkip per line; `workit doctor` (hook_deadline) reads it. */
|
|
30
|
+
export const HOOK_SKIPS_FILE = "hook-skips.jsonl";
|
|
31
|
+
|
|
32
|
+
/** `skip`: the hook answered the minimal result; `late`: it answered in full, past the deadline. */
|
|
33
|
+
export type HookSkip = {
|
|
34
|
+
at: string;
|
|
35
|
+
host: string;
|
|
36
|
+
event: HookEventKind | null;
|
|
37
|
+
outcome: "skip" | "late";
|
|
38
|
+
stage: "input" | "dispatch" | "done";
|
|
39
|
+
elapsedMs: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type HookDeadline = {
|
|
43
|
+
/** Milliseconds from `startedAt`; by default WORKIT_HOOK_DEADLINE_MS, else HOST_DEADLINES. */
|
|
44
|
+
deadlineMs?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Epoch ms the budget counts from; the call by default. A hook-process
|
|
47
|
+
* entry passes PROCESS_START so its start-up and module load count too.
|
|
48
|
+
*/
|
|
49
|
+
startedAt?: number;
|
|
50
|
+
now?: () => number;
|
|
51
|
+
/** The handler step; dispatchHook unless a host handles the event itself. */
|
|
52
|
+
dispatch?: (
|
|
53
|
+
adapter: HostAdapter,
|
|
54
|
+
raw: unknown,
|
|
55
|
+
env: NodeJS.ProcessEnv,
|
|
56
|
+
) => (RenderedHook & { error: string | null }) | Promise<RenderedHook & { error: string | null }>;
|
|
57
|
+
record?: (skip: HookSkip) => void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type DeadlineResult = RenderedHook & { error: string | null; skipped: boolean };
|
|
61
|
+
|
|
62
|
+
const override = (env: NodeJS.ProcessEnv): number | null => {
|
|
63
|
+
const value = Number(env.WORKIT_HOOK_DEADLINE_MS);
|
|
64
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The deadline for one native event; `native` undefined gives the host's shortest. */
|
|
68
|
+
export const hookDeadlineMs = (env: NodeJS.ProcessEnv, host = "", native?: string): number => {
|
|
69
|
+
const row = HOST_DEADLINES[host];
|
|
70
|
+
return (
|
|
71
|
+
override(env) ??
|
|
72
|
+
(row
|
|
73
|
+
? native === undefined
|
|
74
|
+
? Math.min(...Object.values(row))
|
|
75
|
+
: (row[native] ?? row[""])
|
|
76
|
+
: HOOK_DEADLINE_MS)
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const skipsFile = (stateDir: string) => path.join(stateDir, HOOK_SKIPS_FILE);
|
|
81
|
+
|
|
82
|
+
/** Best effort: a read-only state dir never changes the hook's answer. */
|
|
83
|
+
export const recordHookSkip = (skip: HookSkip, stateDir: string = resolveStateDir()): void => {
|
|
84
|
+
try {
|
|
85
|
+
mkdirSync(stateDir, { recursive: true });
|
|
86
|
+
appendFileSync(skipsFile(stateDir), `${JSON.stringify(skip)}\n`);
|
|
87
|
+
} catch {
|
|
88
|
+
// The skip still reached the host; only the doctor count is lost.
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const QUIET: ReadonlySet<HookEventKind | null> = new Set([
|
|
93
|
+
"stop",
|
|
94
|
+
"subagent.stop",
|
|
95
|
+
"shell.post",
|
|
96
|
+
"compact.pre",
|
|
97
|
+
"prompt.submit",
|
|
98
|
+
null,
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
/** The minimal answer: the skip named where the event has a model channel, else no decision. */
|
|
102
|
+
const skipDecision = (event: HookEventKind | null): HookDecision =>
|
|
103
|
+
QUIET.has(event) ? { kind: "none" } : { kind: "context", text: HOOK_SKIP_TEXT };
|
|
104
|
+
|
|
105
|
+
const TIMED_OUT = Symbol("timed out");
|
|
106
|
+
|
|
107
|
+
/** Resolves to `work`'s value, or TIMED_OUT when `ms` passes first. */
|
|
108
|
+
export const within = async <T>(work: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> => {
|
|
109
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
110
|
+
const expired = new Promise<typeof TIMED_OUT>((resolve) => {
|
|
111
|
+
timer = setTimeout(() => resolve(TIMED_OUT), Math.max(0, ms));
|
|
112
|
+
timer.unref?.();
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
return await Promise.race([work, expired]);
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const isTimedOut = (value: unknown): value is typeof TIMED_OUT => value === TIMED_OUT;
|
|
122
|
+
|
|
123
|
+
type Budget = Required<Pick<HookDeadline, "now" | "record">> & {
|
|
124
|
+
startedAt: number;
|
|
125
|
+
/** The host's shortest until the payload names its event. */
|
|
126
|
+
deadlineMs: number;
|
|
127
|
+
/** The host whose HOST_DEADLINES row refines it per event; null when the caller fixed it. */
|
|
128
|
+
host: string | null;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Epoch ms this process started. */
|
|
132
|
+
export const PROCESS_START = performance.timeOrigin;
|
|
133
|
+
|
|
134
|
+
export const hookBudget = (
|
|
135
|
+
env: NodeJS.ProcessEnv,
|
|
136
|
+
options: HookDeadline = {},
|
|
137
|
+
host = "",
|
|
138
|
+
): Budget => {
|
|
139
|
+
const now = options.now ?? Date.now;
|
|
140
|
+
return {
|
|
141
|
+
now,
|
|
142
|
+
record: options.record ?? ((skip) => recordHookSkip(skip)),
|
|
143
|
+
startedAt: options.startedAt ?? now(),
|
|
144
|
+
deadlineMs: options.deadlineMs ?? hookDeadlineMs(env, host),
|
|
145
|
+
host: options.deadlineMs === undefined ? host : null,
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Reads the whole payload, or null when the deadline passes first. */
|
|
150
|
+
export const readWithin = async (
|
|
151
|
+
stdin: AsyncIterable<unknown> | Iterable<unknown>,
|
|
152
|
+
budget: Budget,
|
|
153
|
+
): Promise<string | null> => {
|
|
154
|
+
const read = (async () => {
|
|
155
|
+
let text = "";
|
|
156
|
+
for await (const chunk of stdin) text += String(chunk);
|
|
157
|
+
return text;
|
|
158
|
+
})();
|
|
159
|
+
const text = await within(read, budget.startedAt + budget.deadlineMs - budget.now());
|
|
160
|
+
return isTimedOut(text) ? null : text;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** Records one outcome; returns the parsed native event name for rendering. */
|
|
164
|
+
const note = (
|
|
165
|
+
adapter: HostAdapter,
|
|
166
|
+
raw: unknown,
|
|
167
|
+
env: NodeJS.ProcessEnv,
|
|
168
|
+
budget: Budget,
|
|
169
|
+
outcome: HookSkip["outcome"],
|
|
170
|
+
stage: HookSkip["stage"],
|
|
171
|
+
): { native: string | null; event: HookEventKind | null } => {
|
|
172
|
+
const parsed = raw === undefined ? null : adapter.parse(raw, env);
|
|
173
|
+
const event = parsed ? (parsed.ok ? parsed.input.event.kind : parsed.event) : null;
|
|
174
|
+
budget.record({
|
|
175
|
+
at: new Date(budget.now()).toISOString(),
|
|
176
|
+
host: adapter.descriptor.host,
|
|
177
|
+
event,
|
|
178
|
+
outcome,
|
|
179
|
+
stage,
|
|
180
|
+
elapsedMs: Math.round(budget.now() - budget.startedAt),
|
|
181
|
+
});
|
|
182
|
+
return { native: parsed?.native ?? null, event };
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** The minimal result for a skipped hook, rendered in the host's own shape. */
|
|
186
|
+
export const skippedHook = (
|
|
187
|
+
adapter: HostAdapter,
|
|
188
|
+
raw: unknown,
|
|
189
|
+
env: NodeJS.ProcessEnv,
|
|
190
|
+
budget: Budget,
|
|
191
|
+
stage: HookSkip["stage"],
|
|
192
|
+
): DeadlineResult => {
|
|
193
|
+
const { native, event } = note(adapter, raw, env, budget, "skip", stage);
|
|
194
|
+
return {
|
|
195
|
+
...adapter.render(skipDecision(event), native),
|
|
196
|
+
error: `${HOOK_SKIP_TEXT} (past the ${budget.deadlineMs} ms hook deadline)`,
|
|
197
|
+
skipped: true,
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* dispatchHook under the deadline: a hook already past it answers the
|
|
203
|
+
* minimal result without running the handler, and a handler that overruns
|
|
204
|
+
* it is abandoned. A synchronous handler cannot be interrupted once started;
|
|
205
|
+
* one that finishes late answers in full and is recorded as late.
|
|
206
|
+
*/
|
|
207
|
+
export const dispatchWithin = async (
|
|
208
|
+
adapter: HostAdapter,
|
|
209
|
+
raw: unknown,
|
|
210
|
+
env: NodeJS.ProcessEnv,
|
|
211
|
+
budget: Budget,
|
|
212
|
+
dispatch: NonNullable<HookDeadline["dispatch"]> = dispatchHook,
|
|
213
|
+
): Promise<DeadlineResult> => {
|
|
214
|
+
const native = (raw as { hook_event_name?: unknown } | null)?.hook_event_name;
|
|
215
|
+
if (budget.host !== null && typeof native === "string")
|
|
216
|
+
budget.deadlineMs = hookDeadlineMs(env, budget.host, native);
|
|
217
|
+
const remaining = () => budget.startedAt + budget.deadlineMs - budget.now();
|
|
218
|
+
const result =
|
|
219
|
+
remaining() > 0
|
|
220
|
+
? await within(
|
|
221
|
+
Promise.resolve().then(() => dispatch(adapter, raw, env)),
|
|
222
|
+
remaining(),
|
|
223
|
+
)
|
|
224
|
+
: TIMED_OUT;
|
|
225
|
+
if (isTimedOut(result)) return skippedHook(adapter, raw, env, budget, "dispatch");
|
|
226
|
+
if (remaining() < 0) note(adapter, raw, env, budget, "late", "done");
|
|
227
|
+
return { ...result, skipped: false };
|
|
228
|
+
};
|
package/src/hooks/descriptor.ts
CHANGED
|
@@ -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;
|
package/src/hooks/handle.ts
CHANGED
|
@@ -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
|
|
175
|
-
|
|
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
|
+
});
|