@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,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
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The Workit verbs a host may run without asking (issue #297, decision
|
|
2
|
+
// mvk5q3io): delivery and state verbs whose own gates (grants, non-author
|
|
3
|
+
// verdicts, branch policy) decide. One list for every host's permission
|
|
4
|
+
// block: Claude Code `Bash(workit <verb> *)`, Codex `prefix_rule`, Cursor
|
|
5
|
+
// `Shell(workit <verb>)`, and the OpenCode plugin's ask-to-allow.
|
|
6
|
+
//
|
|
7
|
+
// Never listed: verbs that run an arbitrary command (`check -- <cmd>`,
|
|
8
|
+
// `lock run -- <cmd>`, `test-audit`, `fanout check`), change authority
|
|
9
|
+
// (`grant`), install or remove host config (`init`, `upgrade`, `setup`,
|
|
10
|
+
// `uninstall`, `launch`), delete data (`gc`), import state (`state`) or write
|
|
11
|
+
// outside the forge flow (`youtrack`, `changelog`, `knowledge`, `handoff`).
|
|
12
|
+
// Pure string work: the OpenCode plugin bundles it.
|
|
13
|
+
import { segmentsOf, type ShellDialect } from "./shell-words";
|
|
14
|
+
|
|
15
|
+
export const WORKIT_ALLOWED_VERBS = [
|
|
16
|
+
"git",
|
|
17
|
+
"pr",
|
|
18
|
+
"ci",
|
|
19
|
+
"stack",
|
|
20
|
+
"release",
|
|
21
|
+
"task",
|
|
22
|
+
"ledger",
|
|
23
|
+
"evidence",
|
|
24
|
+
"finding",
|
|
25
|
+
"decision",
|
|
26
|
+
"worker",
|
|
27
|
+
"verify-delivery",
|
|
28
|
+
"policy",
|
|
29
|
+
"doctor",
|
|
30
|
+
"help",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Flags that override a Workit gate: a host never allows them unasked.
|
|
35
|
+
* Workit has no `--admin` flag and refuses a bare `--force` push itself.
|
|
36
|
+
*/
|
|
37
|
+
export const WORKIT_BYPASS_FLAGS = ["--unverified", "--overwrite-unintegrated"] as const;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Calls only the user runs (issue #615). The CLI's TTY and agent-marker check
|
|
41
|
+
* deters a cooperating agent; the host's prompt is what stops a determined
|
|
42
|
+
* one, so no host rule or hook allow may cover these. `release` is an allowed
|
|
43
|
+
* verb and `release notes approve` is inside it, so each host also asks or
|
|
44
|
+
* denies these words explicitly.
|
|
45
|
+
*/
|
|
46
|
+
export const WORKIT_USER_ONLY: readonly (readonly string[])[] = [
|
|
47
|
+
["release", "notes", "approve"],
|
|
48
|
+
["grant", "set"],
|
|
49
|
+
["grant", "raise"],
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const ALLOWED = new Set<string>(WORKIT_ALLOWED_VERBS);
|
|
53
|
+
|
|
54
|
+
/** Do `needle`'s words appear in order in `words` (flags and values between them still match)? */
|
|
55
|
+
const hasWordsInOrder = (words: readonly string[], needle: readonly string[]): boolean => {
|
|
56
|
+
let at = 0;
|
|
57
|
+
for (const word of words) if (word === needle[at] && (at += 1) === needle.length) return true;
|
|
58
|
+
return false;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const isBypass = (word: string): boolean =>
|
|
62
|
+
WORKIT_BYPASS_FLAGS.some((flag) => word === flag || word.startsWith(`${flag}=`));
|
|
63
|
+
|
|
64
|
+
/** `workit`, `workit-cli`, or either by path. */
|
|
65
|
+
const isWorkitWord = (word: string): boolean => /^(?:.*[\\/])?workit(?:-cli)?$/u.test(word);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Is `words` one Workit call of an allowed verb? Global `--json` and
|
|
69
|
+
* `--cwd <dir>` may come first (they mean the same anywhere before `--`);
|
|
70
|
+
* `--session` may not (it changes the acting identity). A bypass flag before
|
|
71
|
+
* `--` disqualifies it, and so does a word the shell expands there (`expands`,
|
|
72
|
+
* indexes into `words`): `${F:---unverified}` could become one.
|
|
73
|
+
*/
|
|
74
|
+
export const isAllowedWorkitCall = (
|
|
75
|
+
words: readonly string[],
|
|
76
|
+
expands: readonly number[] = [],
|
|
77
|
+
): boolean => {
|
|
78
|
+
if (words[0] !== "workit") return false;
|
|
79
|
+
let at = 1;
|
|
80
|
+
for (;;) {
|
|
81
|
+
const word = words[at];
|
|
82
|
+
if (word === "--json") at += 1;
|
|
83
|
+
else if (word === "--cwd") at += 2;
|
|
84
|
+
else if (word?.startsWith("--cwd=")) at += 1;
|
|
85
|
+
else break;
|
|
86
|
+
}
|
|
87
|
+
const verb = words[at];
|
|
88
|
+
if (verb === undefined) return false;
|
|
89
|
+
if (verb === "--version") return at === words.length - 1;
|
|
90
|
+
if (!ALLOWED.has(verb)) return false;
|
|
91
|
+
const end = words.indexOf("--");
|
|
92
|
+
const limit = end < 0 ? words.length : end;
|
|
93
|
+
const call = words.slice(at, limit);
|
|
94
|
+
if (WORKIT_USER_ONLY.some((needle) => hasWordsInOrder(call, needle))) return false;
|
|
95
|
+
return !words.slice(0, limit).some(isBypass) && !expands.some((index) => index < limit);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const bypassReason = (flag: string) =>
|
|
99
|
+
`Workit: \`${flag}\` overrides a Workit gate, so the user decides this call (no allow rule covers it).`;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The ask reason for a Workit call in `command` that carries a bypass flag
|
|
103
|
+
* before its `--`, or null. A word the shell expands (`$F`, `${F:---unverified}`) is not read: its text is not what runs, and the
|
|
104
|
+
* merge grant and `--reason` stay the gate. Words are read after shell quoting, so `"--unverified"`,
|
|
105
|
+
* `--unverif''ied` or a tab-separated flag are found where a host's string
|
|
106
|
+
* rule could miss them. A command the tokenizer cannot analyze is judged on
|
|
107
|
+
* its text with quotes and backslashes removed.
|
|
108
|
+
*/
|
|
109
|
+
export const workitBypassFlag = (
|
|
110
|
+
command: string,
|
|
111
|
+
dialect: ShellDialect = "posix",
|
|
112
|
+
): string | null => {
|
|
113
|
+
for (const segment of segmentsOf(command, dialect)) {
|
|
114
|
+
if (segment.unanalyzable) {
|
|
115
|
+
const text = command.replaceAll(/['"\\`]/gu, "");
|
|
116
|
+
if (!/\bworkit\b/u.test(text)) return null;
|
|
117
|
+
const found = WORKIT_BYPASS_FLAGS.find((flag) => text.includes(flag));
|
|
118
|
+
return found ? bypassReason(found) : null;
|
|
119
|
+
}
|
|
120
|
+
const start = segment.words.findIndex(isWorkitWord);
|
|
121
|
+
if (start < 0) continue;
|
|
122
|
+
const words = segment.words.slice(start + 1);
|
|
123
|
+
const end = words.indexOf("--");
|
|
124
|
+
const flag = (end < 0 ? words : words.slice(0, end)).find(isBypass);
|
|
125
|
+
if (flag) return bypassReason(flag.split("=")[0] ?? flag);
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The user-only words (`WORKIT_USER_ONLY`) a Workit call in `command` runs,
|
|
132
|
+
* or null. Each shell segment is read like `workitBypassFlag` does, so global
|
|
133
|
+
* `--json`, `--cwd <dir>`, `--cwd=<dir>` and `--session <id>` may come before
|
|
134
|
+
* the verb, which stays anchored: `workit help grant set` is not a match.
|
|
135
|
+
*/
|
|
136
|
+
export const userOnlyWorkitCall = (
|
|
137
|
+
command: string,
|
|
138
|
+
dialect: ShellDialect = "posix",
|
|
139
|
+
): string[] | null => {
|
|
140
|
+
for (const segment of segmentsOf(command, dialect)) {
|
|
141
|
+
if (segment.unanalyzable) continue;
|
|
142
|
+
const start = segment.words.findIndex(isWorkitWord);
|
|
143
|
+
if (start < 0) continue;
|
|
144
|
+
const words = segment.words.slice(start + 1);
|
|
145
|
+
const end = words.indexOf("--");
|
|
146
|
+
const args = end < 0 ? words : words.slice(0, end);
|
|
147
|
+
let at = 0;
|
|
148
|
+
for (;;) {
|
|
149
|
+
const word = args[at];
|
|
150
|
+
if (word === "--json") at += 1;
|
|
151
|
+
else if (word === "--cwd" || word === "--session") at += 2;
|
|
152
|
+
else if (word?.startsWith("--cwd=")) at += 1;
|
|
153
|
+
else break;
|
|
154
|
+
}
|
|
155
|
+
const call = args.slice(at);
|
|
156
|
+
const found = WORKIT_USER_ONLY.find(
|
|
157
|
+
(needle) => call[0] === needle[0] && hasWordsInOrder(call, needle),
|
|
158
|
+
);
|
|
159
|
+
if (found) return [...found];
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Is the whole shell `command` allowed Workit calls only? Every segment must
|
|
166
|
+
* be one, with no substitution, redirect, group or heredoc anywhere.
|
|
167
|
+
*/
|
|
168
|
+
export const isAllowedWorkitCommand = (command: string): boolean => {
|
|
169
|
+
const segments = segmentsOf(command);
|
|
170
|
+
return (
|
|
171
|
+
segments.length > 0 &&
|
|
172
|
+
segments.every(
|
|
173
|
+
(s) =>
|
|
174
|
+
!s.unanalyzable &&
|
|
175
|
+
!s.group &&
|
|
176
|
+
!s.subs?.length &&
|
|
177
|
+
!s.stdin?.length &&
|
|
178
|
+
!s.redirects.length &&
|
|
179
|
+
isAllowedWorkitCall(s.words, s.expands),
|
|
180
|
+
)
|
|
181
|
+
);
|
|
182
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Per-host capability descriptor. Each host states what it supports, with a
|
|
2
|
+
// citation; workit derives engine capabilities from it. An `undocumented`
|
|
3
|
+
// axis is treated as `none`: it never backs a guarantee (fail-closed).
|
|
4
|
+
import type { Assurance, Capability } from "../core/task-contract";
|
|
5
|
+
import type { HookEventKind, HostId } from "./protocol";
|
|
6
|
+
|
|
7
|
+
export type Support = "native" | "partial" | "none" | "undocumented";
|
|
8
|
+
|
|
9
|
+
export type HostDescriptor = {
|
|
10
|
+
host: HostId;
|
|
11
|
+
/** Human label used in model-visible text, e.g. "Codex". */
|
|
12
|
+
label: string;
|
|
13
|
+
verifiedAgainst: string;
|
|
14
|
+
docs: string[];
|
|
15
|
+
transport: "hook-process" | "in-process-plugin";
|
|
16
|
+
events: Record<HookEventKind, { support: Support; native: string | null }>;
|
|
17
|
+
shellPolicy: {
|
|
18
|
+
deny: Support;
|
|
19
|
+
channel: "permissionDecision" | "exit2+json" | "effect" | "block" | null;
|
|
20
|
+
/** A parse or handler failure on a pre-tool gate denies instead of passing. */
|
|
21
|
+
failClosed: boolean;
|
|
22
|
+
/** The pre-shell hook can replace the command before it runs (the heavy-command lease). */
|
|
23
|
+
rewrite: Support;
|
|
24
|
+
};
|
|
25
|
+
context: {
|
|
26
|
+
sessionStart: Support;
|
|
27
|
+
perTurn: Support;
|
|
28
|
+
afterCompact: Support;
|
|
29
|
+
/** How the session's current task is chosen: by a session bound to the task
|
|
30
|
+
* record, or the workspace's single active task when sessions never bind. */
|
|
31
|
+
task: "session-bound" | "single-active";
|
|
32
|
+
/** `on-change`: the host keeps each injected context in the transcript, so
|
|
33
|
+
* the core resends per-turn context only when it changed; `every-turn`:
|
|
34
|
+
* the core sends it on every turn (an in-process system-prompt hook needs
|
|
35
|
+
* it each time, or the host plugin dedups itself); `per-session`: the
|
|
36
|
+
* host plugin sends it once per session and again after compaction. */
|
|
37
|
+
turnResend: "on-change" | "every-turn" | "per-session";
|
|
38
|
+
};
|
|
39
|
+
subagents: {
|
|
40
|
+
identity: Support;
|
|
41
|
+
parentBinding: Support;
|
|
42
|
+
blockStart: Support;
|
|
43
|
+
worktreeIsolation: Support;
|
|
44
|
+
maxConcurrency: number | "undocumented";
|
|
45
|
+
/** How a Workit agent type is spelled: plugin-namespaced (`workit:verifier`)
|
|
46
|
+
* on hosts that namespace plugin agents, else a plain `workit-verifier`. */
|
|
47
|
+
agentPrefix: "workit:" | "workit-";
|
|
48
|
+
};
|
|
49
|
+
provenance: { sessionId: Support; agentIdOnTool: Support; postToolObserve: Support };
|
|
50
|
+
/** Host-native interaction boundaries workit can observe. */
|
|
51
|
+
interaction: { questions: Support; writeBoundary: Support };
|
|
52
|
+
stopControl: Support;
|
|
53
|
+
shellAvailable: Support;
|
|
54
|
+
perEventCost: "low" | "npx-network";
|
|
55
|
+
capabilities: CapabilityRule[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Descriptor axes a capability can depend on. */
|
|
59
|
+
export type Axis =
|
|
60
|
+
| `event:${HookEventKind}`
|
|
61
|
+
| "shellPolicy.deny"
|
|
62
|
+
| `context.${"sessionStart" | "perTurn" | "afterCompact"}`
|
|
63
|
+
| `subagents.${"identity" | "parentBinding" | "blockStart" | "worktreeIsolation"}`
|
|
64
|
+
| `provenance.${"sessionId" | "agentIdOnTool" | "postToolObserve"}`
|
|
65
|
+
| `interaction.${"questions" | "writeBoundary"}`
|
|
66
|
+
| "stopControl"
|
|
67
|
+
| "shellAvailable";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One engine capability claim. `assurance` is granted only when every
|
|
71
|
+
* `requires` axis is supported and every `observed` runtime flag is true;
|
|
72
|
+
* `enforced` additionally needs at least one axis, all `native`, and degrades
|
|
73
|
+
* to `agent_guided` otherwise.
|
|
74
|
+
*/
|
|
75
|
+
export type CapabilityRule = {
|
|
76
|
+
name: string;
|
|
77
|
+
surface: string;
|
|
78
|
+
refs: string[];
|
|
79
|
+
requires: Axis[];
|
|
80
|
+
/** Runtime facts only the running dispatcher can attest (e.g. "subagent.start", "ui"). */
|
|
81
|
+
observed?: string[];
|
|
82
|
+
assurance: Assurance;
|
|
83
|
+
reason: string;
|
|
84
|
+
unavailableReason?: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const support = (descriptor: HostDescriptor, axis: Axis): Support => {
|
|
88
|
+
if (axis.startsWith("event:"))
|
|
89
|
+
return descriptor.events[axis.slice("event:".length) as HookEventKind].support;
|
|
90
|
+
if (axis === "shellPolicy.deny") return descriptor.shellPolicy.deny;
|
|
91
|
+
if (axis === "stopControl" || axis === "shellAvailable") return descriptor[axis];
|
|
92
|
+
const [group, key] = axis.split(".") as [
|
|
93
|
+
"context" | "subagents" | "provenance" | "interaction",
|
|
94
|
+
string,
|
|
95
|
+
];
|
|
96
|
+
return (descriptor[group] as Record<string, Support>)[key];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const usable = (value: Support) => value === "native" || value === "partial";
|
|
100
|
+
|
|
101
|
+
export const capabilitiesFor = (
|
|
102
|
+
descriptor: HostDescriptor,
|
|
103
|
+
observed: Partial<Record<string, boolean>> = {},
|
|
104
|
+
): Capability[] =>
|
|
105
|
+
descriptor.capabilities.map((rule) => {
|
|
106
|
+
const levels = rule.requires.map((axis) => support(descriptor, axis));
|
|
107
|
+
const available =
|
|
108
|
+
rule.assurance !== "unavailable" &&
|
|
109
|
+
levels.every(usable) &&
|
|
110
|
+
(rule.observed ?? []).every((flag) => observed[flag] === true);
|
|
111
|
+
const assurance: Assurance = !available
|
|
112
|
+
? "unavailable"
|
|
113
|
+
: rule.assurance === "enforced" &&
|
|
114
|
+
(levels.length === 0 || levels.some((level) => level !== "native"))
|
|
115
|
+
? "agent_guided"
|
|
116
|
+
: rule.assurance;
|
|
117
|
+
return {
|
|
118
|
+
name: rule.name,
|
|
119
|
+
surface: rule.surface,
|
|
120
|
+
assurance,
|
|
121
|
+
reason: assurance === "unavailable" ? (rule.unavailableReason ?? rule.reason) : rule.reason,
|
|
122
|
+
refs: rule.refs.map((handle) => ({ kind: "host" as const, host: descriptor.host, handle })),
|
|
123
|
+
};
|
|
124
|
+
});
|