@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,60 @@
|
|
|
1
|
+
// Shared host-hook protocol. Hook bundles import this, not the core barrel.
|
|
2
|
+
export * from "./protocol";
|
|
3
|
+
export {
|
|
4
|
+
capabilitiesFor,
|
|
5
|
+
support,
|
|
6
|
+
type Axis,
|
|
7
|
+
type CapabilityRule,
|
|
8
|
+
type HostDescriptor,
|
|
9
|
+
type Support,
|
|
10
|
+
} from "./descriptor";
|
|
11
|
+
export {
|
|
12
|
+
changedTurnContext,
|
|
13
|
+
currentTaskEntry,
|
|
14
|
+
seedTurnContext,
|
|
15
|
+
sessionCompactContext,
|
|
16
|
+
sessionContextText,
|
|
17
|
+
turnContextText,
|
|
18
|
+
unfinishedTaskOffer,
|
|
19
|
+
type SessionHandle,
|
|
20
|
+
} from "./context";
|
|
21
|
+
export { shellPolicy } from "./policy";
|
|
22
|
+
export {
|
|
23
|
+
isAllowedWorkitCall,
|
|
24
|
+
isAllowedWorkitCommand,
|
|
25
|
+
WORKIT_ALLOWED_VERBS,
|
|
26
|
+
WORKIT_BYPASS_FLAGS,
|
|
27
|
+
} from "./delivery-verbs";
|
|
28
|
+
export { heavyDenial, heavyGate, splitDenial } from "./heavy-gate";
|
|
29
|
+
export { shellGate, shellWrites, writeGate } from "./write-gate";
|
|
30
|
+
export { liveOwner } from "../core/live-session";
|
|
31
|
+
export { asksUser, stopDecision, stopObligation, type StopObligation } from "./stop";
|
|
32
|
+
export { callKey, noteRawCommit, rawGitPost, rawGitPre } from "./raw-git";
|
|
33
|
+
export { promptNudge, recordSkillLoad, shellNudge, skillFileIn } from "./skill-nudge";
|
|
34
|
+
export { isWriteTool, writePaths } from "./hosts/fields";
|
|
35
|
+
export { seedStanding, standingDelta, standingSinceStart } from "./standing";
|
|
36
|
+
export { dispatchHook, failureDecision, handleHook, type HookDeps } from "./handle";
|
|
37
|
+
export { runAdapterHook } from "./process";
|
|
38
|
+
export { HOOK_ADAPTERS, runHookProcess } from "./run";
|
|
39
|
+
export { CLAUDE_CODE_DESCRIPTOR, claudeCodeAdapter } from "./hosts/claude-code";
|
|
40
|
+
export {
|
|
41
|
+
CODEX_DESCRIPTOR,
|
|
42
|
+
codexAdapter,
|
|
43
|
+
codexDescriptor,
|
|
44
|
+
detectCodexSurface,
|
|
45
|
+
parseCodexHookInput,
|
|
46
|
+
type CodexHookEvent,
|
|
47
|
+
type CodexHookInput,
|
|
48
|
+
type CodexHost,
|
|
49
|
+
} from "./hosts/codex";
|
|
50
|
+
export {
|
|
51
|
+
CURSOR_DESCRIPTOR,
|
|
52
|
+
cursorAdapter,
|
|
53
|
+
cursorDeny,
|
|
54
|
+
cursorWorkspaceRoot,
|
|
55
|
+
parseCursorHookInput,
|
|
56
|
+
type CursorHookEvent,
|
|
57
|
+
type CursorHookInput,
|
|
58
|
+
} from "./hosts/cursor";
|
|
59
|
+
export { OPENCODE_DESCRIPTOR } from "./hosts/opencode";
|
|
60
|
+
export { PI_DESCRIPTOR } from "./hosts/pi";
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// Usage limits seen by a host hook (#655). A tool or assistant error that
|
|
2
|
+
// names a provider usage limit (Claude Code's `You've hit your weekly limit ·
|
|
3
|
+
// resets 7pm (America/Santiago)`, its 5-hour and per-model limits, OpenAI's
|
|
4
|
+
// usage limit, a 429 quota or a long retry-after) marks the provider
|
|
5
|
+
// exhausted until the parsed reset, else until `models.limitWindow` (default
|
|
6
|
+
// 5h) has passed. A short 429 rate limit is not a usage limit.
|
|
7
|
+
//
|
|
8
|
+
// Claude Code: the Agent hook (agent-model.ts) reads the tails of this
|
|
9
|
+
// project's recent transcripts, where a dead worker's last assistant entry is
|
|
10
|
+
// the limit text. OpenCode: the plugin's subagent execute.after hands the
|
|
11
|
+
// result or error text here.
|
|
12
|
+
import { closeSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
claimRedispatch,
|
|
17
|
+
firstSighting,
|
|
18
|
+
limitWindowMs,
|
|
19
|
+
markExhausted,
|
|
20
|
+
promptKey,
|
|
21
|
+
providerOf,
|
|
22
|
+
shortTime,
|
|
23
|
+
} from "../core/model-fallback";
|
|
24
|
+
|
|
25
|
+
export type UsageLimit = {
|
|
26
|
+
/** The provider the text names, else null (the caller knows the model). */
|
|
27
|
+
provider: string | null;
|
|
28
|
+
/** A per-model limit (Claude's Opus or Sonnet limit), else null. */
|
|
29
|
+
family: string | null;
|
|
30
|
+
/** The reset the text names, else null. */
|
|
31
|
+
resetAt: Date | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
35
|
+
const WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
|
36
|
+
/** A time word after "resets" or "try again": "in 2h", "at 3pm", "3am", "later", "Nov 5". */
|
|
37
|
+
const TIME_WORD = String.raw`(?:\d|(?:in|at|on|later|tomorrow|soon)\b|(?:${[...WEEKDAYS, ...MONTHS].join("|")})[a-z]*\b)`;
|
|
38
|
+
/**
|
|
39
|
+
* What follows a provider's "… limit reached": the end of the line, an epoch
|
|
40
|
+
* (`|<seconds>`), or its reset with a time word, after an optional separator.
|
|
41
|
+
* A report quoting "the weekly limit reached, resets handling" is none.
|
|
42
|
+
*/
|
|
43
|
+
const LIMIT_END = String.raw`(?=\s*(?:\|\d{10}\b|(?:[∙·•|,:\-–—.]\s*)?(?:$|\(?(?:resets?|try again)\s+${TIME_WORD})))`;
|
|
44
|
+
/** Provider error shapes, never a bare phrase: prose about limits is not one. */
|
|
45
|
+
const LIMIT_PATTERNS = [
|
|
46
|
+
// Claude Code and Codex: "You've hit your weekly limit · resets 7pm", "You've hit your usage limit."
|
|
47
|
+
/\byou(?:'|\u2019)?ve hit your\b[^.\n]{0,40}\blimit\b/iu,
|
|
48
|
+
// "5-hour limit reached ∙ resets 3am", "Claude AI usage limit reached|<epoch>": the phrase ends
|
|
49
|
+
// the message or runs into its reset, so a report quoting "the 5-hour limit reached message" is none.
|
|
50
|
+
new RegExp(String.raw`\bclaude(?: ai)? usage limit reached${LIMIT_END}`, "imu"),
|
|
51
|
+
new RegExp(
|
|
52
|
+
String.raw`\b(?:5-hour|five-hour|weekly|daily|session|opus|sonnet|fable|haiku) limit reached${LIMIT_END}`,
|
|
53
|
+
"imu",
|
|
54
|
+
),
|
|
55
|
+
/\busage limit\b[^\n]{0,200}\b(?:try again|resets?) (?:in|at)\b/iu,
|
|
56
|
+
/\byou exceeded your current quota\b|\binsufficient_quota\b/iu,
|
|
57
|
+
];
|
|
58
|
+
/** A 429 or rate_limit counts only with a quota word or a long wait. */
|
|
59
|
+
const RATE_LIMIT =
|
|
60
|
+
/\brate_limit(?:_error)?\b|"type":\s*"rate_limit|\bstatus(?:code)?["':\s]+429\b|^\s*(?:\w+Error:\s*)?429\b/imu;
|
|
61
|
+
const LONG_WAIT_MS = 15 * 60_000;
|
|
62
|
+
|
|
63
|
+
const AT_TIME = new RegExp(
|
|
64
|
+
String.raw`\b(?:resets?|reset at|try again at|available again at|retry at)\s+(?:at\s+|on\s+)?` +
|
|
65
|
+
String.raw`(?:(?:${WEEKDAYS.join("|")})[a-z]*,?\s+)?` +
|
|
66
|
+
String.raw`(?:(${MONTHS.join("|")})[a-z]*\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(?:at\s+)?)?` +
|
|
67
|
+
String.raw`(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*\(([A-Za-z_]+(?:/[A-Za-z_+-]+)*)\))?`,
|
|
68
|
+
"iu",
|
|
69
|
+
);
|
|
70
|
+
const IN_DURATION =
|
|
71
|
+
/\b(?:try again|resets?|retry|available again)\s+in\s+((?:\d+(?:\.\d+)?\s*(?:days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\b[\s,]*(?:and\s+)?)+)/iu;
|
|
72
|
+
const RETRY_AFTER = /\bretry[- ]after\b["':\s]+(\d+)/iu;
|
|
73
|
+
const EPOCH = /\|(\d{10})\b/u;
|
|
74
|
+
const UNIT_MS: Array<[RegExp, number]> = [
|
|
75
|
+
[/^d/u, 86_400_000],
|
|
76
|
+
[/^h/u, 3_600_000],
|
|
77
|
+
[/^m/u, 60_000],
|
|
78
|
+
[/^s/u, 1000],
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const durationMs = (text: string): number => {
|
|
82
|
+
let total = 0;
|
|
83
|
+
for (const [, amount, unit] of text.matchAll(/(\d+(?:\.\d+)?)\s*([a-z]+)/giu))
|
|
84
|
+
total += Number(amount) * (UNIT_MS.find(([re]) => re.test(unit.toLowerCase()))?.[1] ?? 0);
|
|
85
|
+
return total;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Milliseconds a zone is ahead of UTC at `at`. */
|
|
89
|
+
const zoneOffset = (zone: string, at: Date): number => {
|
|
90
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
91
|
+
timeZone: zone,
|
|
92
|
+
hourCycle: "h23",
|
|
93
|
+
year: "numeric",
|
|
94
|
+
month: "numeric",
|
|
95
|
+
day: "numeric",
|
|
96
|
+
hour: "numeric",
|
|
97
|
+
minute: "numeric",
|
|
98
|
+
second: "numeric",
|
|
99
|
+
}).formatToParts(at);
|
|
100
|
+
const get = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0);
|
|
101
|
+
const local = Date.UTC(
|
|
102
|
+
get("year"),
|
|
103
|
+
get("month") - 1,
|
|
104
|
+
get("day"),
|
|
105
|
+
get("hour") % 24,
|
|
106
|
+
get("minute"),
|
|
107
|
+
get("second"),
|
|
108
|
+
);
|
|
109
|
+
return local - (at.getTime() - at.getUTCMilliseconds());
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const validZone = (zone: string | undefined): string => {
|
|
113
|
+
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
114
|
+
if (!zone) return fallback;
|
|
115
|
+
try {
|
|
116
|
+
return new Intl.DateTimeFormat("en-US", { timeZone: zone }).resolvedOptions().timeZone;
|
|
117
|
+
} catch {
|
|
118
|
+
return fallback;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** The next `hour:minute` in `zone` after `now` (on `month`/`day` when given). */
|
|
123
|
+
const nextWallTime = (
|
|
124
|
+
now: Date,
|
|
125
|
+
zone: string,
|
|
126
|
+
hour: number,
|
|
127
|
+
minute: number,
|
|
128
|
+
date: { month: number; day: number } | null,
|
|
129
|
+
): Date => {
|
|
130
|
+
const local = new Date(now.getTime() + zoneOffset(zone, now));
|
|
131
|
+
const year = local.getUTCFullYear();
|
|
132
|
+
let wall = date
|
|
133
|
+
? Date.UTC(year, date.month, date.day, hour, minute)
|
|
134
|
+
: Date.UTC(year, local.getUTCMonth(), local.getUTCDate(), hour, minute);
|
|
135
|
+
if (wall <= local.getTime())
|
|
136
|
+
wall = date ? Date.UTC(year + 1, date.month, date.day, hour, minute) : wall + 86_400_000;
|
|
137
|
+
const guess = wall - zoneOffset(zone, new Date(wall));
|
|
138
|
+
return new Date(wall - zoneOffset(zone, new Date(guess)));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const resetFrom = (text: string, now: Date): Date | null => {
|
|
142
|
+
const epoch = EPOCH.exec(text);
|
|
143
|
+
if (epoch) return new Date(Number(epoch[1]) * 1000);
|
|
144
|
+
const at = AT_TIME.exec(text);
|
|
145
|
+
if (at && (at[5] || at[4] || at[6])) {
|
|
146
|
+
const [, month, day, hourText, minuteText, meridiem, zone] = at;
|
|
147
|
+
let hour = Number(hourText);
|
|
148
|
+
if (hour > 23 || (meridiem && hour > 12)) return null;
|
|
149
|
+
if (meridiem?.toLowerCase() === "pm" && hour < 12) hour += 12;
|
|
150
|
+
if (meridiem?.toLowerCase() === "am" && hour === 12) hour = 0;
|
|
151
|
+
const date = month
|
|
152
|
+
? {
|
|
153
|
+
month: MONTHS.indexOf(month.toLowerCase().slice(0, 3)),
|
|
154
|
+
day: Number(day),
|
|
155
|
+
}
|
|
156
|
+
: null;
|
|
157
|
+
return nextWallTime(now, validZone(zone), hour, Number(minuteText ?? 0), date);
|
|
158
|
+
}
|
|
159
|
+
const span = IN_DURATION.exec(text);
|
|
160
|
+
if (span) {
|
|
161
|
+
const ms = durationMs(span[1]);
|
|
162
|
+
if (ms > 0) return new Date(now.getTime() + ms);
|
|
163
|
+
}
|
|
164
|
+
const retry = RETRY_AFTER.exec(text);
|
|
165
|
+
if (retry) return new Date(now.getTime() + Number(retry[1]) * 1000);
|
|
166
|
+
return null;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const providerIn = (text: string): string | null => {
|
|
170
|
+
if (/\b(?:claude|anthropic)\b/iu.test(text)) return "anthropic";
|
|
171
|
+
if (/\b(?:openai|chatgpt|codex)\b|\bgpt-/iu.test(text)) return "openai";
|
|
172
|
+
return null;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The usage limit `text` reports, or null: a short rate limit, an unrelated
|
|
177
|
+
* error or ordinary prose is none.
|
|
178
|
+
*/
|
|
179
|
+
export const parseUsageLimit = (text: string, now: Date = new Date()): UsageLimit | null => {
|
|
180
|
+
if (!text || text.length > 20_000) return null;
|
|
181
|
+
const resetAt = resetFrom(text, now);
|
|
182
|
+
const limit = LIMIT_PATTERNS.some((pattern) => pattern.test(text));
|
|
183
|
+
const longRateLimit =
|
|
184
|
+
RATE_LIMIT.test(text) && resetAt !== null && resetAt.getTime() - now.getTime() >= LONG_WAIT_MS;
|
|
185
|
+
if (!limit && !longRateLimit) return null;
|
|
186
|
+
const family = /\b(opus|sonnet|fable|haiku) limit\b/iu.exec(text)?.[1]?.toLowerCase() ?? null;
|
|
187
|
+
return { provider: providerIn(text), family, resetAt };
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export type LimitSeen = {
|
|
191
|
+
provider: string;
|
|
192
|
+
key: string;
|
|
193
|
+
until: string;
|
|
194
|
+
reason: string;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Mark the provider of a usage limit in `text` exhausted. `model` is the model
|
|
199
|
+
* that ran (its provider wins over one the text names); `host` resolves a bare
|
|
200
|
+
* model name. Null when `text` is no usage limit or names no provider.
|
|
201
|
+
*/
|
|
202
|
+
export const recordUsageLimit = (
|
|
203
|
+
text: string,
|
|
204
|
+
context: { cwd: string; host: string; model?: string | null; now?: Date },
|
|
205
|
+
): LimitSeen | null => {
|
|
206
|
+
const now = context.now ?? new Date();
|
|
207
|
+
const limit = parseUsageLimit(text, now);
|
|
208
|
+
if (!limit) return null;
|
|
209
|
+
const provider =
|
|
210
|
+
(context.model ? providerOf(context.model, context.host) : null) ??
|
|
211
|
+
limit.provider ??
|
|
212
|
+
providerOf("", context.host);
|
|
213
|
+
if (!provider) return null;
|
|
214
|
+
const family = limit.family ?? null;
|
|
215
|
+
const key = family && provider === "anthropic" ? `${provider}/${family}` : provider;
|
|
216
|
+
const until =
|
|
217
|
+
limit.resetAt && limit.resetAt.getTime() > now.getTime()
|
|
218
|
+
? limit.resetAt
|
|
219
|
+
: new Date(now.getTime() + limitWindowMs(context.cwd));
|
|
220
|
+
const reason = text.trim().split("\n")[0] ?? "";
|
|
221
|
+
const entry = markExhausted(key, until, reason, now);
|
|
222
|
+
return { provider, key, until: entry.until, reason };
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The note for the lead when a worker died on a usage limit: re-dispatch it
|
|
227
|
+
* once with the same prompt (its role now runs on the next model), or, when
|
|
228
|
+
* that prompt was already re-dispatched, not again.
|
|
229
|
+
*/
|
|
230
|
+
export const redispatchNote = (
|
|
231
|
+
seen: LimitSeen,
|
|
232
|
+
worker: { label: string; prompt: string | null },
|
|
233
|
+
now: Date = new Date(),
|
|
234
|
+
): string => {
|
|
235
|
+
const opening = `Workit: ${worker.label} died on a usage limit (${seen.reason || seen.provider}); ${seen.key} is marked exhausted until ${shortTime(seen.until)}.`;
|
|
236
|
+
if (worker.prompt && !claimRedispatch(promptKey(worker.prompt), now))
|
|
237
|
+
return `${opening} It was already re-dispatched once after a limit: do not re-dispatch it again; check \`workit models show\` and wait for the reset or add a model of another provider to its role.`;
|
|
238
|
+
return `${opening} Re-dispatch it once with the same prompt: its role now runs on the next model in its list (\`workit models show\` names it).`;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const TAIL_BYTES = 16_384;
|
|
242
|
+
|
|
243
|
+
const tail = (file: string, size: number): string => {
|
|
244
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
245
|
+
const buffer = Buffer.alloc(length);
|
|
246
|
+
const fd = openSync(file, "r");
|
|
247
|
+
try {
|
|
248
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
249
|
+
} finally {
|
|
250
|
+
closeSync(fd);
|
|
251
|
+
}
|
|
252
|
+
return buffer.toString("utf8");
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const start = (file: string): string => {
|
|
256
|
+
const buffer = Buffer.alloc(TAIL_BYTES);
|
|
257
|
+
const fd = openSync(file, "r");
|
|
258
|
+
try {
|
|
259
|
+
return buffer.subarray(0, readSync(fd, buffer, 0, TAIL_BYTES, 0)).toString("utf8");
|
|
260
|
+
} finally {
|
|
261
|
+
closeSync(fd);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
type Entry = Record<string, unknown>;
|
|
266
|
+
const parse = (line: string): Entry | null => {
|
|
267
|
+
try {
|
|
268
|
+
const value: unknown = JSON.parse(line);
|
|
269
|
+
return value && typeof value === "object" ? (value as Entry) : null;
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const textOf = (message: unknown): string => {
|
|
276
|
+
const content = (message as { content?: unknown } | undefined)?.content;
|
|
277
|
+
if (typeof content === "string") return content;
|
|
278
|
+
if (!Array.isArray(content)) return "";
|
|
279
|
+
return content
|
|
280
|
+
.map((part: { type?: unknown; text?: unknown; content?: unknown }) =>
|
|
281
|
+
typeof part?.text === "string"
|
|
282
|
+
? part.text
|
|
283
|
+
: typeof part?.content === "string"
|
|
284
|
+
? part.content
|
|
285
|
+
: "",
|
|
286
|
+
)
|
|
287
|
+
.join("\n");
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The last assistant entry of a transcript tail when it is an API error
|
|
292
|
+
* (Claude Code writes one as a synthetic assistant message), else null: a
|
|
293
|
+
* worker's own report that mentions a limit is not one.
|
|
294
|
+
*/
|
|
295
|
+
const lastApiError = (text: string): string | null => {
|
|
296
|
+
const lines = text.split("\n").filter(Boolean).toReversed();
|
|
297
|
+
for (const line of lines) {
|
|
298
|
+
const entry = parse(line);
|
|
299
|
+
if (entry?.type !== "assistant") continue;
|
|
300
|
+
const model = (entry.message as { model?: unknown } | undefined)?.model;
|
|
301
|
+
const apiError = entry.isApiErrorMessage === true || !!entry.error || model === "<synthetic>";
|
|
302
|
+
return apiError ? textOf(entry.message) : null;
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** The first user prompt of a subagent transcript (its spawn prompt), or null. */
|
|
308
|
+
const firstPrompt = (file: string): string | null => {
|
|
309
|
+
for (const line of start(file).split("\n")) {
|
|
310
|
+
const entry = parse(line);
|
|
311
|
+
if (entry?.type === "user") return textOf(entry.message) || null;
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/** Claude Code's project directory name for a cwd. */
|
|
317
|
+
export const claudeProjectSlug = (cwd: string): string => cwd.replace(/[^A-Za-z0-9]/gu, "-");
|
|
318
|
+
|
|
319
|
+
const listDir = (dir: string): string[] => {
|
|
320
|
+
try {
|
|
321
|
+
return readdirSync(dir).map((name) => path.join(dir, name));
|
|
322
|
+
} catch {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const SCAN_MS = 30 * 60_000;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Claude Code (#655): read this project's transcripts written in the last 30
|
|
331
|
+
* minutes; each whose last assistant entry is a usage limit marks Anthropic
|
|
332
|
+
* (or the model family) exhausted, once per transcript state. Returns a note
|
|
333
|
+
* per worker that died on it and a switch notice when the lead itself did.
|
|
334
|
+
*/
|
|
335
|
+
export const scanClaudeTranscripts = (
|
|
336
|
+
cwd: string,
|
|
337
|
+
env: NodeJS.ProcessEnv,
|
|
338
|
+
now: Date = new Date(),
|
|
339
|
+
): string[] => {
|
|
340
|
+
const projects = path.join(
|
|
341
|
+
env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), ".claude"),
|
|
342
|
+
"projects",
|
|
343
|
+
);
|
|
344
|
+
const project = path.join(projects, claudeProjectSlug(cwd));
|
|
345
|
+
const notes: string[] = [];
|
|
346
|
+
const files = listDir(project).flatMap((entry) =>
|
|
347
|
+
entry.endsWith(".jsonl")
|
|
348
|
+
? [{ file: entry, worker: false }]
|
|
349
|
+
: listDir(path.join(entry, "subagents"))
|
|
350
|
+
.filter((file) => file.endsWith(".jsonl"))
|
|
351
|
+
.map((file) => ({ file, worker: true })),
|
|
352
|
+
);
|
|
353
|
+
for (const { file, worker } of files)
|
|
354
|
+
try {
|
|
355
|
+
const stat = statSync(file);
|
|
356
|
+
if (now.getTime() - stat.mtimeMs > SCAN_MS) continue;
|
|
357
|
+
const last = lastApiError(tail(file, stat.size));
|
|
358
|
+
if (!last || !parseUsageLimit(last, now)) continue;
|
|
359
|
+
if (!firstSighting(`${file}:${stat.size}`, now)) continue;
|
|
360
|
+
const seen = recordUsageLimit(last, { cwd, host: "claude_code", now });
|
|
361
|
+
if (!seen) continue;
|
|
362
|
+
notes.push(
|
|
363
|
+
worker
|
|
364
|
+
? redispatchNote(
|
|
365
|
+
seen,
|
|
366
|
+
{
|
|
367
|
+
label: `worker ${path.basename(file, ".jsonl")}`,
|
|
368
|
+
prompt: firstPrompt(file),
|
|
369
|
+
},
|
|
370
|
+
now,
|
|
371
|
+
)
|
|
372
|
+
: `Workit: this session hit a usage limit (${seen.reason}); ${seen.key} is marked exhausted until ${shortTime(seen.until)}. Claude Code runs only Anthropic models: Workit cannot switch this session, so continue on another host or after the reset.`,
|
|
373
|
+
);
|
|
374
|
+
} catch {
|
|
375
|
+
// An unreadable transcript is skipped.
|
|
376
|
+
}
|
|
377
|
+
return notes;
|
|
378
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// A mid-session model switch (#435): the new model inherits a long context
|
|
2
|
+
// whose Workit contract sits far up, so the host re-sends the compact contract
|
|
3
|
+
// and the current turn context once per change, never on every turn.
|
|
4
|
+
|
|
5
|
+
/** The last model seen per session, in the host plugin's own state. */
|
|
6
|
+
export type SessionModels = Map<string, string>;
|
|
7
|
+
|
|
8
|
+
const MAX_SESSIONS = 256;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Record `model` for the session and say whether it differs from the last one
|
|
12
|
+
* seen. The first model a session reports is a start, not a switch; an unknown
|
|
13
|
+
* model (null) changes nothing.
|
|
14
|
+
*/
|
|
15
|
+
export const modelSwitched = (
|
|
16
|
+
models: SessionModels,
|
|
17
|
+
sessionId: string,
|
|
18
|
+
model: string | null,
|
|
19
|
+
): boolean => {
|
|
20
|
+
if (!model) return false;
|
|
21
|
+
const previous = models.get(sessionId);
|
|
22
|
+
models.delete(sessionId);
|
|
23
|
+
models.set(sessionId, model);
|
|
24
|
+
if (models.size > MAX_SESSIONS) models.delete(models.keys().next().value!);
|
|
25
|
+
return previous !== undefined && previous !== model;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** `provider/id` from a host's model reference, or null when it names none. */
|
|
29
|
+
export const modelKey = (model: unknown): string | null => {
|
|
30
|
+
if (model === null || typeof model !== "object") return null;
|
|
31
|
+
const { id, providerID, provider } = model as Record<string, unknown>;
|
|
32
|
+
if (typeof id !== "string" || !id) return null;
|
|
33
|
+
const source = typeof providerID === "string" ? providerID : provider;
|
|
34
|
+
return typeof source === "string" && source ? `${source}/${id}` : id;
|
|
35
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { shellBranchPolicyViolation } from "../core/route-intent";
|
|
2
|
+
import type { HookDecision } from "./protocol";
|
|
3
|
+
import { analysisDenial } from "./raw-git";
|
|
4
|
+
import { ShellAnalysisError, type ShellDialect } from "./shell-words";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Branch policy for one shell command: a raw branch creation (`git switch -c`,
|
|
8
|
+
* `checkout -b`, `branch`, `worktree add -b`, in any chain, directory or
|
|
9
|
+
* wrapper) onto a noncompliant name is denied. Every other command keeps the
|
|
10
|
+
* host's own permission decision. The reason carries the policy's correction.
|
|
11
|
+
* A command the hook cannot analyze is denied with how to split it; any other
|
|
12
|
+
* failure propagates to the host's fail policy.
|
|
13
|
+
*/
|
|
14
|
+
export const shellPolicy = (
|
|
15
|
+
cwd: string,
|
|
16
|
+
command: string,
|
|
17
|
+
dialect: ShellDialect = "posix",
|
|
18
|
+
): HookDecision => {
|
|
19
|
+
let policy: ReturnType<typeof shellBranchPolicyViolation>;
|
|
20
|
+
try {
|
|
21
|
+
policy = shellBranchPolicyViolation(cwd, command, dialect);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof ShellAnalysisError) return analysisDenial(error);
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
return policy && !policy.ok
|
|
27
|
+
? { kind: "deny", reason: `branch_policy_denied: ${policy.error}`, unblock: null }
|
|
28
|
+
: { kind: "none" };
|
|
29
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Hook-process runner for one adapter: stdin JSON → parse → handle → render →
|
|
2
|
+
// stdout, under the hook's own deadline (deadline.ts). It names no host, so a
|
|
3
|
+
// host bundle that imports it carries only its own adapter (run.ts maps host
|
|
4
|
+
// names to every adapter).
|
|
5
|
+
import { dispatchWithin, hookBudget, readWithin, skippedHook, type HookDeadline } from "./deadline";
|
|
6
|
+
import { recordHookRun } from "./hook-runs";
|
|
7
|
+
import type { HostAdapter } from "./protocol";
|
|
8
|
+
import { migrateLegacyDirs } from "../core/workit-paths.mjs";
|
|
9
|
+
|
|
10
|
+
type Sink = { write(chunk: string): unknown };
|
|
11
|
+
|
|
12
|
+
/** Runs one hook invocation and returns the process exit code. A broken
|
|
13
|
+
* payload never throws: the host's fail policy decides what is written. Past
|
|
14
|
+
* the deadline the host gets a minimal result naming the skip. `host` is the
|
|
15
|
+
* name the run is recorded under (the adapter's own host by default). */
|
|
16
|
+
export async function runAdapterHook(
|
|
17
|
+
adapter: HostAdapter,
|
|
18
|
+
stdin: AsyncIterable<unknown> | Iterable<unknown>,
|
|
19
|
+
stdout: Sink,
|
|
20
|
+
stderr: Sink = process.stderr,
|
|
21
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
22
|
+
deadline: HookDeadline = {},
|
|
23
|
+
host: string = adapter.descriptor.host,
|
|
24
|
+
): Promise<number> {
|
|
25
|
+
// One-time move of a pre-XDG state or config dir, before the run is recorded there.
|
|
26
|
+
migrateLegacyDirs({ env });
|
|
27
|
+
const budget = hookBudget(env, deadline, adapter.descriptor.host);
|
|
28
|
+
const text = await readWithin(stdin, budget);
|
|
29
|
+
let raw: unknown;
|
|
30
|
+
try {
|
|
31
|
+
raw = text === null ? undefined : JSON.parse(text || "{}");
|
|
32
|
+
} catch {
|
|
33
|
+
raw = undefined;
|
|
34
|
+
}
|
|
35
|
+
const native = (raw as { hook_event_name?: unknown } | null | undefined)?.hook_event_name;
|
|
36
|
+
recordHookRun(host, typeof native === "string" ? native : null, env);
|
|
37
|
+
const result =
|
|
38
|
+
text === null
|
|
39
|
+
? skippedHook(adapter, undefined, env, budget, "input")
|
|
40
|
+
: await dispatchWithin(adapter, raw, env, budget, deadline.dispatch);
|
|
41
|
+
if (result.skipped) stderr.write(`[workit] ${result.error}\n`);
|
|
42
|
+
else if (raw === undefined || result.error)
|
|
43
|
+
stderr.write(
|
|
44
|
+
`[workit] hook input rejected: ${raw === undefined ? "invalid JSON hook input" : result.error}\n`,
|
|
45
|
+
);
|
|
46
|
+
stdout.write(`${JSON.stringify(result.json)}\n`);
|
|
47
|
+
return result.exitCode;
|
|
48
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The shared host-hook protocol: every host adapter parses its native payload
|
|
2
|
+
// into a HookInput and renders a HookDecision back. Types only, no I/O.
|
|
3
|
+
import type { HostDescriptor } from "./descriptor";
|
|
4
|
+
|
|
5
|
+
export type HostId = "claude_code" | "opencode" | "codex_cli" | "codex_desktop" | "cursor" | "pi";
|
|
6
|
+
|
|
7
|
+
export type Session = {
|
|
8
|
+
id: string;
|
|
9
|
+
agentId: string | null;
|
|
10
|
+
agentType: string | null;
|
|
11
|
+
parentId: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SessionSource = "startup" | "resume" | "clear" | "compact" | "fork";
|
|
15
|
+
|
|
16
|
+
export type HookEvent =
|
|
17
|
+
| { kind: "session.start"; source: SessionSource }
|
|
18
|
+
/** Per-turn injection (OpenCode session context, Pi before_agent_start, Claude UserPromptSubmit). */
|
|
19
|
+
| {
|
|
20
|
+
kind: "context.turn";
|
|
21
|
+
/** The submitted prompt, on hosts whose per-turn event carries it. */
|
|
22
|
+
prompt?: string | null;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
kind: "shell.pre";
|
|
26
|
+
command: string;
|
|
27
|
+
toolUseId: string | null;
|
|
28
|
+
/** The shell that runs `command`; posix when absent. */
|
|
29
|
+
dialect?: "posix" | "powershell";
|
|
30
|
+
/** The host's whole tool input, where a rewrite must hand it back (Claude Code). */
|
|
31
|
+
toolInput?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
/** A pre-tool gate for a non-shell tool. Host permission policy owns these. */
|
|
34
|
+
| {
|
|
35
|
+
kind: "tool.pre";
|
|
36
|
+
tool: string;
|
|
37
|
+
toolUseId: string | null;
|
|
38
|
+
/** The skill a skill-loading tool loads (Claude Code's Skill tool). */
|
|
39
|
+
skill?: string | null;
|
|
40
|
+
/** A subagent spawn's whole tool input (Claude Code's Agent tool), where a default is filled in. */
|
|
41
|
+
agentInput?: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
/** A file-writing tool (Edit, Write, apply_patch…): the before-write gate (S17). */
|
|
44
|
+
| {
|
|
45
|
+
kind: "write.pre";
|
|
46
|
+
tool: string;
|
|
47
|
+
/** The files written; null when they cannot be read (unknown targets, gated). */
|
|
48
|
+
paths: string[] | null;
|
|
49
|
+
toolUseId: string | null;
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
kind: "shell.post";
|
|
53
|
+
command: string;
|
|
54
|
+
stdout: string;
|
|
55
|
+
exitCode: number | null;
|
|
56
|
+
toolUseId: string | null;
|
|
57
|
+
dialect?: "posix" | "powershell";
|
|
58
|
+
}
|
|
59
|
+
| { kind: "subagent.start"; agentId: string; agentType: string; task: string | null }
|
|
60
|
+
| {
|
|
61
|
+
kind: "subagent.stop";
|
|
62
|
+
agentId: string | null;
|
|
63
|
+
agentType: string | null;
|
|
64
|
+
lastMessage: string | null;
|
|
65
|
+
stopHookActive: boolean;
|
|
66
|
+
}
|
|
67
|
+
| { kind: "prompt.submit"; prompt: string; source: string | null }
|
|
68
|
+
| { kind: "compact.pre"; trigger: "manual" | "auto" }
|
|
69
|
+
| { kind: "stop"; lastMessage: string | null; stopHookActive: boolean };
|
|
70
|
+
|
|
71
|
+
export type HookEventKind = HookEvent["kind"];
|
|
72
|
+
|
|
73
|
+
export const HOOK_EVENT_KINDS = [
|
|
74
|
+
"session.start",
|
|
75
|
+
"context.turn",
|
|
76
|
+
"shell.pre",
|
|
77
|
+
"tool.pre",
|
|
78
|
+
"write.pre",
|
|
79
|
+
"shell.post",
|
|
80
|
+
"subagent.start",
|
|
81
|
+
"subagent.stop",
|
|
82
|
+
"prompt.submit",
|
|
83
|
+
"compact.pre",
|
|
84
|
+
"stop",
|
|
85
|
+
] as const satisfies readonly HookEventKind[];
|
|
86
|
+
|
|
87
|
+
export type HookInput = {
|
|
88
|
+
host: HostId;
|
|
89
|
+
cwd: string;
|
|
90
|
+
session: Session;
|
|
91
|
+
permissionMode: string | null;
|
|
92
|
+
transcriptPath: string | null;
|
|
93
|
+
/** The host's current model, where every hook input carries it (Codex). */
|
|
94
|
+
model?: string | null;
|
|
95
|
+
event: HookEvent;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type HookDecision =
|
|
99
|
+
| { kind: "none" }
|
|
100
|
+
/** Model-visible additional context. */
|
|
101
|
+
| { kind: "context"; text: string }
|
|
102
|
+
/** The reason text always carries its own correction (principle 3). */
|
|
103
|
+
| { kind: "deny"; reason: string; unblock: string | null }
|
|
104
|
+
/** shell.pre: let the user decide, whatever the host's rules allow. Never an allow. */
|
|
105
|
+
| { kind: "ask"; reason: string }
|
|
106
|
+
/**
|
|
107
|
+
* shell.pre: run `command` instead (the heavy-command lease rewrite).
|
|
108
|
+
* `input` is the host's tool input with the command replaced, where the
|
|
109
|
+
* host takes the whole input back; `text` is model-visible context.
|
|
110
|
+
*/
|
|
111
|
+
| { kind: "rewrite"; command: string; input: Record<string, unknown> | null; text: string | null }
|
|
112
|
+
/**
|
|
113
|
+
* tool.pre: run the tool with `input` (the host's whole tool input, with
|
|
114
|
+
* defaults filled in) instead; `text` is model-visible context. Never a
|
|
115
|
+
* permission decision.
|
|
116
|
+
*/
|
|
117
|
+
| { kind: "update-input"; input: Record<string, unknown>; text: string | null }
|
|
118
|
+
/** Stop/SubagentStop: keep working. */
|
|
119
|
+
| { kind: "continue"; reason: string }
|
|
120
|
+
| { kind: "notice"; userMessage: string };
|
|
121
|
+
|
|
122
|
+
/** A hook-process parse: the native event name is kept for rendering. */
|
|
123
|
+
export type ParsedHook =
|
|
124
|
+
| { ok: true; input: HookInput; native: string }
|
|
125
|
+
| { ok: false; error: string; native: string | null; event: HookEventKind | null };
|
|
126
|
+
|
|
127
|
+
export type RenderedHook = { json: Record<string, unknown>; exitCode: number };
|
|
128
|
+
|
|
129
|
+
export type HostAdapter = {
|
|
130
|
+
descriptor: HostDescriptor;
|
|
131
|
+
parse(raw: unknown, env: NodeJS.ProcessEnv): ParsedHook;
|
|
132
|
+
render(decision: HookDecision, native: string | null): RenderedHook;
|
|
133
|
+
/** Host-specific lines appended inside the session-start contract. */
|
|
134
|
+
addendum?(input: HookInput): string | null;
|
|
135
|
+
};
|