@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.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. 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
+ });