@workit-dev/core 0.0.1 → 1.0.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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Role models on a Claude Code subagent spawn (#420, #654): a spawn runs on
|
|
2
|
+
// its role's model (and effort) from the role config. A model the spawn names
|
|
3
|
+
// wins over a built-in default, and over a configured one only with
|
|
4
|
+
// `models.allowLeadOverride`. A spawn that lacks its role's values is filled
|
|
5
|
+
// through PreToolUse `updatedInput` with a note naming them for next time,
|
|
6
|
+
// except under auto mode, which refuses a hook-rewritten call (retro
|
|
7
|
+
// 2026-11-07 R1): there it is denied once with the exact Agent args to pass.
|
|
8
|
+
// A type with no role, or a value the Agent tool cannot take, runs as written.
|
|
9
|
+
// Before choosing, the hook reads this project's recent transcripts for a
|
|
10
|
+
// usage limit (model-limit.ts, #655): a role whose list names a model of
|
|
11
|
+
// another provider then runs on it, and the lead learns which worker to
|
|
12
|
+
// re-dispatch.
|
|
13
|
+
import {
|
|
14
|
+
CLAUDE_EFFORTS,
|
|
15
|
+
chooseSpawnModel,
|
|
16
|
+
claudeAgentModel,
|
|
17
|
+
roleForAgentType,
|
|
18
|
+
} from "../core/role-models";
|
|
19
|
+
import { scanClaudeTranscripts } from "./model-limit";
|
|
20
|
+
import type { HookDecision } from "./protocol";
|
|
21
|
+
|
|
22
|
+
const given = (value: unknown): boolean => typeof value === "string" && value.trim() !== "";
|
|
23
|
+
|
|
24
|
+
/** Keys a respawn lists first; `description` and `prompt` stay as written and are not repeated. */
|
|
25
|
+
const LEADING = ["subagent_type", "model", "effort", "run_in_background"];
|
|
26
|
+
const UNLISTED = new Set(["description", "prompt"]);
|
|
27
|
+
|
|
28
|
+
/** The Agent args to pass, as `key: value` pairs in a fixed order, `description` and `prompt` aside. */
|
|
29
|
+
const spawnArgsText = (input: Record<string, unknown>): string =>
|
|
30
|
+
[...LEADING.filter((key) => Object.hasOwn(input, key)), ...Object.keys(input)]
|
|
31
|
+
.filter(
|
|
32
|
+
(key, at, keys) => !UNLISTED.has(key) && input[key] !== undefined && keys.indexOf(key) === at,
|
|
33
|
+
)
|
|
34
|
+
.map((key) => `${key}: ${JSON.stringify(input[key])}`)
|
|
35
|
+
.join(", ");
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What an earlier gate changed on the spawn: the keys it removed, why (for a
|
|
39
|
+
* deny) and the note for a rewrite.
|
|
40
|
+
*/
|
|
41
|
+
export type SpawnChange = { dropped: string[]; reasons: string[]; notes: string[] };
|
|
42
|
+
|
|
43
|
+
const NO_CHANGE: SpawnChange = { dropped: [], reasons: [], notes: [] };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One deny with the exact args of the spawn Workit would run, the same text
|
|
47
|
+
* every time, so the lead retries once and the retry passes as written.
|
|
48
|
+
*/
|
|
49
|
+
const respawn = (
|
|
50
|
+
input: Record<string, unknown>,
|
|
51
|
+
reasons: string[],
|
|
52
|
+
dropped: string[],
|
|
53
|
+
limits: string,
|
|
54
|
+
): HookDecision => {
|
|
55
|
+
const args = spawnArgsText(input);
|
|
56
|
+
const without = dropped.length ? ` and no ${dropped.join(" or ")}` : "";
|
|
57
|
+
return {
|
|
58
|
+
kind: "deny",
|
|
59
|
+
reason: [
|
|
60
|
+
`workit spawn args: spawn again with exactly ${args} (description and prompt as written${without}).`,
|
|
61
|
+
...reasons,
|
|
62
|
+
"Workit denies instead of rewriting the spawn: Claude Code's auto mode refuses a hook-rewritten spawn.",
|
|
63
|
+
limits,
|
|
64
|
+
]
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.join(" "),
|
|
67
|
+
unblock: args,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The exact values the lead's next spawn should pass (W2.21), so it runs as written. */
|
|
72
|
+
const withNextSpawn = (text: string, input: Record<string, unknown>): string => {
|
|
73
|
+
if (text.includes("Next time pass")) return text;
|
|
74
|
+
const values = [
|
|
75
|
+
given(input.model) ? `model: ${String(input.model)}` : null,
|
|
76
|
+
given(input.effort) ? `effort: ${String(input.effort)}` : null,
|
|
77
|
+
].filter(Boolean);
|
|
78
|
+
return values.length
|
|
79
|
+
? `${text} Next time pass ${values.join(", ")} on the spawn, so it runs as written.`
|
|
80
|
+
: text;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type SpawnOptions = {
|
|
84
|
+
env?: NodeJS.ProcessEnv;
|
|
85
|
+
/** What the spawn gate already changed (isolation). */
|
|
86
|
+
change?: SpawnChange;
|
|
87
|
+
/** The session's permission mode (Claude Code `permission_mode`). */
|
|
88
|
+
permissionMode?: string | null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A spawn missing its role's model or effort: under auto mode a deny naming
|
|
93
|
+
* the exact args (retro 2026-11-07 R1: auto mode refuses an `updatedInput`
|
|
94
|
+
* rewrite), in any other mode an `updatedInput` fill with a note naming them.
|
|
95
|
+
*/
|
|
96
|
+
export const agentModelDecision = (
|
|
97
|
+
cwd: string,
|
|
98
|
+
tool: Record<string, unknown>,
|
|
99
|
+
options: SpawnOptions = {},
|
|
100
|
+
): HookDecision => {
|
|
101
|
+
const env = options.env ?? process.env;
|
|
102
|
+
const change = options.change ?? NO_CHANGE;
|
|
103
|
+
const auto = options.permissionMode === "auto";
|
|
104
|
+
const limits = scanClaudeTranscripts(cwd, env).join("\n");
|
|
105
|
+
const changed = change.dropped.length > 0;
|
|
106
|
+
const asWritten = (note: string | null): HookDecision => {
|
|
107
|
+
if (changed && auto) return respawn(tool, change.reasons, change.dropped, limits);
|
|
108
|
+
const text = [...change.notes, note, limits].filter(Boolean).join("\n");
|
|
109
|
+
if (changed) return { kind: "update-input", input: tool, text };
|
|
110
|
+
return text ? { kind: "context", text } : { kind: "none" };
|
|
111
|
+
};
|
|
112
|
+
// The host fixes the model itself: nothing to fill, and `model` is not in its schema.
|
|
113
|
+
if (env.CLAUDE_CODE_SUBAGENT_MODEL_FORCE) return asWritten(null);
|
|
114
|
+
const selector = roleForAgentType(tool.subagent_type, tool.prompt);
|
|
115
|
+
if (!selector) return asWritten(null);
|
|
116
|
+
const choice = chooseSpawnModel(cwd, selector, "claude_code", tool, claudeAgentModel);
|
|
117
|
+
const effort = choice.effort?.toLowerCase() ?? null;
|
|
118
|
+
const input: Record<string, unknown> = { ...tool };
|
|
119
|
+
if (choice.model) input.model = choice.model;
|
|
120
|
+
if (effort && CLAUDE_EFFORTS.has(effort) && !given(tool.effort)) input.effort = effort;
|
|
121
|
+
const needs = [
|
|
122
|
+
input.model === tool.model ? null : `model ${String(input.model)}`,
|
|
123
|
+
input.effort === tool.effort ? null : `effort ${String(input.effort)}`,
|
|
124
|
+
].filter(Boolean);
|
|
125
|
+
if (!needs.length) return asWritten(choice.note);
|
|
126
|
+
if (!auto) {
|
|
127
|
+
const note =
|
|
128
|
+
choice.note ??
|
|
129
|
+
`Workit set effort ${String(input.effort)} on this spawn: its role's configured effort.`;
|
|
130
|
+
return {
|
|
131
|
+
kind: "update-input",
|
|
132
|
+
input,
|
|
133
|
+
text: [...change.notes, withNextSpawn(note, input), limits].filter(Boolean).join("\n"),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const replaced = given(tool.model) && input.model !== tool.model;
|
|
137
|
+
return respawn(
|
|
138
|
+
input,
|
|
139
|
+
[
|
|
140
|
+
...change.reasons,
|
|
141
|
+
`It needs ${needs.join(" and ")}: ${choice.basis ?? "its role"}.`,
|
|
142
|
+
replaced
|
|
143
|
+
? `That replaces model ${String(tool.model)}: a configured role model wins unless "allowLeadOverride": true is set under models in workspaces.json.`
|
|
144
|
+
: "",
|
|
145
|
+
].filter(Boolean),
|
|
146
|
+
change.dropped,
|
|
147
|
+
limits,
|
|
148
|
+
);
|
|
149
|
+
};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// The worker tool-call budget (retro P9, ruling mvrhg6lc). A brief's TIMEBOX
|
|
2
|
+
// names `N tool calls`; the spawn that carries it records N for the next
|
|
3
|
+
// subagent of that type (`<store>/budget/spawns-<lead>.jsonl`), SubagentStart
|
|
4
|
+
// binds it to the agent (`<store>/budget/<agent>.json`: limit and the last
|
|
5
|
+
// multiple told), and every pre-tool hook of that agent appends one byte to
|
|
6
|
+
// `<store>/budget/<agent>.calls`. At N calls (and 2N, 3N…) one line rides the
|
|
7
|
+
// standing-order channel; the call is never denied. An agent without a
|
|
8
|
+
// budget file is never counted.
|
|
9
|
+
// fs, path and crypto only: the Codex launcher imports this before the
|
|
10
|
+
// runtime, and the Claude Code launcher (bin/workit-hook.mjs) keeps a copy of
|
|
11
|
+
// spawnBudget, noteSpawnBudget and tickBudget that
|
|
12
|
+
// test/workit-core/hooks/budget-signal.test.ts holds equal.
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { gitStoreDir } from "./standing-signal";
|
|
17
|
+
import type { HookInput } from "./protocol";
|
|
18
|
+
|
|
19
|
+
const hash = (text: string): string => createHash("sha256").update(text).digest("hex").slice(0, 32);
|
|
20
|
+
|
|
21
|
+
const budgetDir = (store: string): string => path.join(store, "budget");
|
|
22
|
+
|
|
23
|
+
/** The same key as the standing seen file: one per host session or subagent. */
|
|
24
|
+
const agentBase = (store: string, session: string, agentId: string | null): string =>
|
|
25
|
+
path.join(budgetDir(store), hash(`${session}\0${agentId ?? ""}`));
|
|
26
|
+
|
|
27
|
+
const spawnsFile = (store: string, session: string): string =>
|
|
28
|
+
path.join(budgetDir(store), `spawns-${hash(session)}.jsonl`);
|
|
29
|
+
|
|
30
|
+
/** `N tool calls` on a brief's TIMEBOX line, else null. */
|
|
31
|
+
export const spawnBudget = (prompt: string): number | null => {
|
|
32
|
+
const found = /^TIMEBOX:[^\n]*?\b(\d+)\s+tool calls\b/mu.exec(prompt);
|
|
33
|
+
const n = found ? Number(found[1]) : NaN;
|
|
34
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A lead's spawn that will run: queue its budget, or an explicit none, for
|
|
39
|
+
* the next subagent of `type`. Every spawn of a type queues one entry, so the
|
|
40
|
+
* FIFO stays aligned with the SubagentStart order. Call it only once the
|
|
41
|
+
* spawn is allowed: a denied spawn must leave no entry.
|
|
42
|
+
*/
|
|
43
|
+
export const noteSpawnBudget = (
|
|
44
|
+
cwd: string,
|
|
45
|
+
session: string,
|
|
46
|
+
type: string | null,
|
|
47
|
+
prompt: string,
|
|
48
|
+
): void => {
|
|
49
|
+
try {
|
|
50
|
+
const store = session ? gitStoreDir(cwd) : null;
|
|
51
|
+
if (!store) return;
|
|
52
|
+
fs.mkdirSync(budgetDir(store), { recursive: true });
|
|
53
|
+
fs.appendFileSync(
|
|
54
|
+
spawnsFile(store, session),
|
|
55
|
+
`${JSON.stringify({ type: type ?? "", n: spawnBudget(prompt) })}\n`,
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
// Without the note the worker runs unbudgeted.
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type Budget = { limit: number; told: number };
|
|
63
|
+
|
|
64
|
+
/** Small files written whole; a torn read fails open as "no budget". */
|
|
65
|
+
const readBudget = (file: string): Budget | null => {
|
|
66
|
+
try {
|
|
67
|
+
const { limit, told } = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<Budget>;
|
|
68
|
+
return typeof limit === "number" && limit > 0 ? { limit, told: Number(told) || 0 } : null;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* SubagentStart: take the head entry queued for this agent's type and bind
|
|
76
|
+
* its budget to the agent. Returns the limit, or null when the entry says
|
|
77
|
+
* none or nothing was queued.
|
|
78
|
+
*/
|
|
79
|
+
export const claimSpawnBudget = (
|
|
80
|
+
cwd: string,
|
|
81
|
+
session: string,
|
|
82
|
+
agentId: string,
|
|
83
|
+
agentType: string | null,
|
|
84
|
+
): number | null => {
|
|
85
|
+
try {
|
|
86
|
+
const store = session ? gitStoreDir(cwd) : null;
|
|
87
|
+
if (!store) return null;
|
|
88
|
+
const file = spawnsFile(store, session);
|
|
89
|
+
const entries = fs
|
|
90
|
+
.readFileSync(file, "utf8")
|
|
91
|
+
.split("\n")
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.map((line) => JSON.parse(line) as { type?: unknown; n?: unknown });
|
|
94
|
+
const at = entries.findIndex((entry) => entry.type === (agentType ?? ""));
|
|
95
|
+
if (at < 0) return null;
|
|
96
|
+
const [{ n }] = entries.splice(at, 1);
|
|
97
|
+
fs.writeFileSync(file, entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""));
|
|
98
|
+
if (typeof n !== "number" || n <= 0) return null;
|
|
99
|
+
startBudget(store, session, agentId, n);
|
|
100
|
+
return n;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Starts a budget; first ages out files of agents that never stopped, older than 7 days. */
|
|
107
|
+
const startBudget = (store: string, session: string, agentId: string | null, n: number): void => {
|
|
108
|
+
const dir = budgetDir(store);
|
|
109
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
110
|
+
const cutoff = Date.now() - 6048e5;
|
|
111
|
+
for (const name of fs.readdirSync(dir))
|
|
112
|
+
try {
|
|
113
|
+
if (fs.statSync(path.join(dir, name)).mtimeMs < cutoff) fs.rmSync(path.join(dir, name));
|
|
114
|
+
} catch {
|
|
115
|
+
// Another hook removed it first.
|
|
116
|
+
}
|
|
117
|
+
const base = agentBase(store, session, agentId);
|
|
118
|
+
fs.writeFileSync(`${base}.json`, JSON.stringify({ limit: n, told: 0 }));
|
|
119
|
+
fs.rmSync(`${base}.calls`, { force: true });
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A host that launches the worker itself (Pi) binds the budget its prompt
|
|
124
|
+
* names straight to the worker's session. Returns the limit, else null.
|
|
125
|
+
*/
|
|
126
|
+
export const bindBudget = (
|
|
127
|
+
cwd: string,
|
|
128
|
+
session: string,
|
|
129
|
+
agentId: string | null,
|
|
130
|
+
prompt: string,
|
|
131
|
+
): number | null => {
|
|
132
|
+
try {
|
|
133
|
+
const n = spawnBudget(prompt);
|
|
134
|
+
const store = n && session ? gitStoreDir(cwd) : null;
|
|
135
|
+
if (!n || !store) return null;
|
|
136
|
+
startBudget(store, session, agentId, n);
|
|
137
|
+
return n;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Count one tool call of a budgeted agent. True when the count reached the
|
|
145
|
+
* next untold multiple of the budget, so the runtime must load to tell it.
|
|
146
|
+
*/
|
|
147
|
+
export const tickBudget = (cwd: string, session: string, agentId: string | null): boolean => {
|
|
148
|
+
try {
|
|
149
|
+
const store = session ? gitStoreDir(cwd) : null;
|
|
150
|
+
if (!store) return false;
|
|
151
|
+
const base = agentBase(store, session, agentId);
|
|
152
|
+
const budget = readBudget(`${base}.json`);
|
|
153
|
+
if (!budget) return false;
|
|
154
|
+
fs.appendFileSync(`${base}.calls`, ".");
|
|
155
|
+
return fs.statSync(`${base}.calls`).size >= budget.limit * (budget.told + 1);
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const lineFor = (calls: number): string =>
|
|
162
|
+
`Tool-call budget reached (${calls}): commit your work, write your report, stop.`;
|
|
163
|
+
|
|
164
|
+
/** The agent's base path, its budget, and how many multiples of it its calls reached. */
|
|
165
|
+
const reachedOf = (cwd: string, session: string, agentId: string | null) => {
|
|
166
|
+
const store = session ? gitStoreDir(cwd) : null;
|
|
167
|
+
const base = store && agentBase(store, session, agentId);
|
|
168
|
+
const budget = base && readBudget(`${base}.json`);
|
|
169
|
+
if (!budget) return null;
|
|
170
|
+
return { base, budget, reached: Math.floor(fs.statSync(`${base}.calls`).size / budget.limit) };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** SubagentStop: the agent's budget and counter go. */
|
|
174
|
+
export const dropBudget = (cwd: string, session: string, agentId: string | null): void => {
|
|
175
|
+
const store = session ? gitStoreDir(cwd) : null;
|
|
176
|
+
if (store)
|
|
177
|
+
for (const ext of [".json", ".calls"])
|
|
178
|
+
fs.rmSync(agentBase(store, session, agentId) + ext, { force: true });
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** The one line for a budget reached and not yet told (marked told), else null. */
|
|
182
|
+
export const budgetLine = (cwd: string, session: string, agentId: string | null): string | null => {
|
|
183
|
+
try {
|
|
184
|
+
const at = reachedOf(cwd, session, agentId);
|
|
185
|
+
if (!at || at.reached <= at.budget.told) return null;
|
|
186
|
+
fs.writeFileSync(`${at.base}.json`, JSON.stringify({ ...at.budget, told: at.reached }));
|
|
187
|
+
return lineFor(at.budget.limit * at.reached);
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* For a host that rebuilds its context on every model call (OpenCode): the
|
|
195
|
+
* line while the budget stands reached, else null. Never marks it told.
|
|
196
|
+
*/
|
|
197
|
+
export const budgetStanding = (
|
|
198
|
+
cwd: string,
|
|
199
|
+
session: string,
|
|
200
|
+
agentId: string | null,
|
|
201
|
+
): string | null => {
|
|
202
|
+
try {
|
|
203
|
+
const at = reachedOf(cwd, session, agentId);
|
|
204
|
+
return at && at.reached > 0 ? lineFor(at.budget.limit * at.reached) : null;
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** Codex tools Workit may deny; any other PreToolUse is never denied there. */
|
|
211
|
+
const CODEX_GATED = new Set(["bash", "unifiedexec", "shell", "applypatch", "edit", "write"]);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One native Codex PreToolUse payload: a lead's spawn (a tool input naming an
|
|
215
|
+
* agent type, or carrying a brief) queues its budget or an explicit none, and
|
|
216
|
+
* a budgeted subagent's call is counted. A spawn is a tool Workit never denies
|
|
217
|
+
* on Codex, so queueing here cannot outlive a denied call. True when the
|
|
218
|
+
* count reached the budget, so the runtime tells it.
|
|
219
|
+
*/
|
|
220
|
+
export const observeHookBudget = (payload: string): boolean => {
|
|
221
|
+
try {
|
|
222
|
+
const value = JSON.parse(payload) as Record<string, unknown>;
|
|
223
|
+
if (value.hook_event_name !== "PreToolUse" || typeof value.session_id !== "string")
|
|
224
|
+
return false;
|
|
225
|
+
const cwd = typeof value.cwd === "string" ? value.cwd : ".";
|
|
226
|
+
const agentId =
|
|
227
|
+
typeof value.agent_id === "string" && value.agent_id.trim() ? value.agent_id : null;
|
|
228
|
+
if (agentId) return tickBudget(cwd, value.session_id, agentId);
|
|
229
|
+
const tool = typeof value.tool_name === "string" ? value.tool_name : "";
|
|
230
|
+
const spawn = value.tool_input;
|
|
231
|
+
if (
|
|
232
|
+
CODEX_GATED.has(tool.toLowerCase().replace(/[_-]/g, "")) ||
|
|
233
|
+
!spawn ||
|
|
234
|
+
typeof spawn !== "object"
|
|
235
|
+
)
|
|
236
|
+
return false;
|
|
237
|
+
const fields = spawn as Record<string, unknown>;
|
|
238
|
+
const prompt = Object.values(fields).find(
|
|
239
|
+
(field): field is string => typeof field === "string" && field.includes("TIMEBOX:"),
|
|
240
|
+
);
|
|
241
|
+
const type = [fields.agent_type, fields.subagent_type].find(
|
|
242
|
+
(field): field is string => typeof field === "string",
|
|
243
|
+
);
|
|
244
|
+
if (prompt !== undefined || type !== undefined)
|
|
245
|
+
noteSpawnBudget(cwd, value.session_id, type ?? null, prompt ?? "");
|
|
246
|
+
return false;
|
|
247
|
+
} catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** What handleHook does with a budget, for the hosts whose entry passes it (HookDeps.budget). */
|
|
253
|
+
export type BudgetHooks = {
|
|
254
|
+
/** A pre-tool answer that lets the call run: the line once reached. */
|
|
255
|
+
line: (input: HookInput) => string | null;
|
|
256
|
+
/** SubagentStart: bind the queued budget. */
|
|
257
|
+
start: (input: HookInput, agentId: string, agentType: string) => void;
|
|
258
|
+
/** A lead's spawn the gate let through (Claude Code: untyped is general-purpose). */
|
|
259
|
+
spawn: (input: HookInput, spawn: Record<string, unknown>) => void;
|
|
260
|
+
/** SubagentStop: drop the agent's files. */
|
|
261
|
+
stop: (input: HookInput, agentId: string) => void;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export const budgetHooks: BudgetHooks = {
|
|
265
|
+
line: (input) => budgetLine(input.cwd, input.session.id ?? "", input.session.agentId),
|
|
266
|
+
start: (input, agentId, agentType) =>
|
|
267
|
+
void claimSpawnBudget(input.cwd, input.session.id ?? "", agentId, agentType),
|
|
268
|
+
spawn: (input, spawn) => {
|
|
269
|
+
if (!input.session.agentId)
|
|
270
|
+
noteSpawnBudget(
|
|
271
|
+
input.cwd,
|
|
272
|
+
input.session.id ?? "",
|
|
273
|
+
String(spawn.subagent_type || "general-purpose"),
|
|
274
|
+
String(spawn.prompt ?? ""),
|
|
275
|
+
);
|
|
276
|
+
},
|
|
277
|
+
stop: (input, agentId) => {
|
|
278
|
+
try {
|
|
279
|
+
dropBudget(input.cwd, input.session.id ?? "", agentId);
|
|
280
|
+
} catch {
|
|
281
|
+
// Aged out by the next startBudget.
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
};
|