@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,486 @@
1
+ // `workit fanout brief <slice>`: the complete worker brief, rendered from the
2
+ // plan, the standing orders in force for the fanout (`workit ledger standing`)
3
+ // and the slice's scratch dir, so the lead passes it to the worker verbatim.
4
+ //
5
+ // MODE is `resume` when the branch already exists with work on it (a retry
6
+ // continues it), else `new`, also for a branch `fanout worktree create` made
7
+ // that still sits at its base SHA (#432); `--attempt <n>` renders one
8
+ // attempt of a race on its own branch `<branch>-try<n>` (refused when
9
+ // another slice has that branch), worktree `<worktree>+try<n>` and session
10
+ // `<lead>-w-<slice>+try<n>`: slice ids cannot contain `+`, so no attempt
11
+ // collides with another slice's path or session.
12
+ // The worker's host (`--host`, else the lead's: WORKIT_HOST or its own
13
+ // session variable) picks the model lines, the Co-Authored-By trailer and the
14
+ // identity rules: Claude Code workers use the WORKIT_SESSION_ID in their env (the
15
+ // lead's: SubagentStart issues ids only to judges); OpenCode, Pi and Codex
16
+ // workers export nothing (the host derives the id);
17
+ // Cursor, CI and plain terminals export `<lead>-w-<slice>`. Once
18
+ // `workit fanout worktree create` made the slice's worktree, WORKTREE and the
19
+ // RULES name it: every git and check command runs as `workit <verb> --cwd <worktree> …`.
20
+ // The lead must have a WORKIT_SESSION_ID: worker ids derive from it. A
21
+ // worker id longer than the ledger's 128 characters hashes its slice part. The fan-in line follows the plan's `fanIn`:
22
+ // one PR per slice forbids every merge; integration mode has the worker merge
23
+ // the integration tip before reporting, and nothing else. It only reads, apart
24
+ // from hiding `.workit-scratch/` in the repository's info/exclude.
25
+ import { createHash } from "node:crypto";
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+ import { gateCheckNames, loadCheckConfig } from "./check-config";
29
+ import { neededChecks } from "./core/policy/check-evidence";
30
+ import { claudeSpawnArgs, resolveRoleModel, type RoleSelector } from "./core/role-models";
31
+ import { activeStanding, readLedger, type LedgerActor } from "./ledger";
32
+ import { branchRef, fanoutFail, planLeads, type FanoutFile, type FanoutResult } from "./fanout";
33
+ import {
34
+ SCRATCH_DIR,
35
+ excludeScratch,
36
+ findSlice,
37
+ listWorktrees,
38
+ samePath,
39
+ slicePath,
40
+ } from "./fanout-worktree";
41
+
42
+ export type BriefMode = "new" | "resume";
43
+
44
+ export type BriefOutcome = {
45
+ fanout: string;
46
+ slice: string;
47
+ mode: BriefMode;
48
+ attempt: number | null;
49
+ branch: string;
50
+ base: string;
51
+ tier: string;
52
+ /** Issues the slice's PR links (the brief's ISSUE line). */
53
+ issues: string[];
54
+ /** The model `models.roles` (or `fanout.tierModels`) names for the tier on the lead's host, else null. */
55
+ model: string | null;
56
+ /** Its effort, when one is configured. */
57
+ effort: string | null;
58
+ /** The model and effort the lead spawns this slice's non-author verifier with, else null. */
59
+ verifier: { model: string; effort: string | null } | null;
60
+ /** Absolute when the slice's worktree exists, else relative to the worker's worktree root. */
61
+ scratch: string;
62
+ /** The host the brief was rendered for (`--host`, else the lead's), or null. */
63
+ host: string | null;
64
+ /** RULE 3's Co-Authored-By trailer for that host, or null when it has none. */
65
+ coAuthor: string | null;
66
+ /** The id a worker exports, or null when its host issues or derives it. */
67
+ session: string | null;
68
+ standing: Array<{ id: string; what: string }>;
69
+ text: string;
70
+ };
71
+
72
+ export const DEFAULT_TIMEBOX = "30 minutes";
73
+
74
+ /** The TIMEBOX line's tool-call budget when the slice names none (hooks/budget-signal.ts). */
75
+ export const DEFAULT_TOOL_CALLS = 120;
76
+
77
+ /** A hard slice's default budget: three hard slices hit 120 before their gates (retro W2.18). */
78
+ export const HARD_TOOL_CALLS = 180;
79
+
80
+ /** The share of the budget by which a worker has run its full gates, leaving the rest to fix what they find. */
81
+ const GATES_BY = 0.6;
82
+
83
+ /**
84
+ * The slice's TIMEBOX text plus a tool-call budget unless it already names
85
+ * one (hard slices get more), and the call by which the full gates have run.
86
+ */
87
+ const timeboxText = (timebox: string | null, tier: string): string => {
88
+ const text = timebox ?? DEFAULT_TIMEBOX;
89
+ const named = /\b(\d+)\s+tool calls\b/u.exec(text);
90
+ const budget = named ? Number(named[1]) : tier === "hard" ? HARD_TOOL_CALLS : DEFAULT_TOOL_CALLS;
91
+ const withBudget = named ? text : `${text}, ${budget} tool calls`;
92
+ return `${withBudget} (run your full VERIFY gates by call ${Math.round(budget * GATES_BY)})`;
93
+ };
94
+
95
+ const REPORT = [
96
+ "REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,",
97
+ " each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),",
98
+ " anything out of scope as a follow-up, not a diff. Report heads when pushed; CI is waited on once, at the end.",
99
+ " A PR body goes through `--body-file -` (stdin), never a shared scratch path.",
100
+ ];
101
+
102
+ /**
103
+ * How a worker's identity is settled on the lead's host (host-session.ts,
104
+ * docs/agents/hosts.md):
105
+ * - `issued`: Claude Code. SubagentStart issues ids only to verifiers and
106
+ * reviewers, so an implementer keeps the lead's WORKIT_SESSION_ID from its
107
+ * env and never mints an id;
108
+ * - `derived`: OpenCode, Pi, Codex. The worker's shell carries the host's own
109
+ * session id, which exporting WORKIT_SESSION_ID cannot replace;
110
+ * - `declared`: Cursor, CI, a plain terminal. Nothing vouches for an id, so
111
+ * the brief declares `<lead>-w-<slice>`.
112
+ */
113
+ export type BriefIdentity = "issued" | "derived" | "declared";
114
+
115
+ /** The hosts a brief renders for, as `--host` spells them. */
116
+ export const BRIEF_HOSTS = ["claude_code", "codex", "opencode", "cursor", "pi"] as const;
117
+
118
+ const familyOf = (host: string | null): string => host?.replace(/_(?:cli|desktop)$/, "") ?? "";
119
+
120
+ /**
121
+ * A known worker host for `host` (`codex` names Codex's CLI; `claude-code`
122
+ * and `_cli`/`_desktop` spellings are accepted), else null: unset, `cli` (a
123
+ * plain terminal) or a typo.
124
+ */
125
+ export const briefHost = (host: string | null): string | null => {
126
+ const spelled = host?.trim().toLowerCase().replaceAll("-", "_") ?? "";
127
+ if (spelled === "codex") return "codex_cli";
128
+ return (BRIEF_HOSTS as readonly string[]).includes(familyOf(spelled)) ? spelled : null;
129
+ };
130
+
131
+ /**
132
+ * The Co-Authored-By trailer a host's agents sign commits with, or null for
133
+ * a host with no convention (Pi) or no host. Claude Code names the model
134
+ * family when the brief's model is a Claude alias (`opus` -> Claude Opus).
135
+ */
136
+ export const coAuthorTrailer = (host: string | null, model: string | null): string | null => {
137
+ const family = familyOf(host);
138
+ if (family === "claude_code") {
139
+ const named = /\b(opus|sonnet|haiku|fable)\b/iu.exec(model ?? "")?.[1].toLowerCase();
140
+ const who = named ? `Claude ${named[0].toUpperCase()}${named.slice(1)}` : "Claude";
141
+ return `Co-Authored-By: ${who} <noreply@anthropic.com>`;
142
+ }
143
+ if (family === "cursor") return "Co-Authored-By: Cursor Agent <cursoragent@cursor.com>";
144
+ return null;
145
+ };
146
+
147
+ const identityOf = (host: string | null): BriefIdentity => {
148
+ const family = familyOf(host);
149
+ if (family === "claude_code") return "issued";
150
+ if (family === "opencode" || family === "pi" || family === "codex") return "derived";
151
+ return "declared";
152
+ };
153
+
154
+ const identityLines = (identity: BriefIdentity, session: string): string[] => {
155
+ if (identity === "issued")
156
+ return [
157
+ " Session: your env already has WORKIT_SESSION_ID (the lead's id; SubagentStart issues ids only to verifiers and reviewers). Use it as is: workit commands read it and `workit git commit` adds the Workit-Session trailer; never export or hand-mint one.",
158
+ ];
159
+ if (identity === "derived")
160
+ return [
161
+ " Session: your host already sets WORKIT_SESSION_ID in your shell; export nothing and never hand-mint one.",
162
+ ];
163
+ return [` export WORKIT_SESSION_ID=${session}`];
164
+ };
165
+
166
+ /**
167
+ * RULES. With the slice's worktree made (`worktree`), every git and check
168
+ * command names it (`workit <verb> --cwd <worktree> …`, verb first so host
169
+ * allow rules match, one verb per call) and it is already on the branch;
170
+ * Workit's hooks keep an implementer inside it (#310).
171
+ */
172
+ const rules = (
173
+ mode: BriefMode,
174
+ branch: string,
175
+ base: string,
176
+ worktree: string | null,
177
+ trailer: string | null,
178
+ host: string | null,
179
+ ): string[] => {
180
+ const workit = (verb: string) =>
181
+ worktree ? `workit ${verb} --cwd ${worktree}` : `workit ${verb}`;
182
+ let first = ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`;
183
+ if (worktree)
184
+ first = ` 1. Work only in WORKTREE, already on ${branch}${mode === "resume" ? " at its head" : ""}: edit files under it and run each git and check command against it, one verb per call (\`${workit("git")} commit …\`, \`${workit("check")} <name>\`), never \`cd … &&\` chains or a shell variable in a path (paste the absolute path above). An implementer's change outside it is denied.`;
185
+ else if (mode === "resume")
186
+ first = ` 1. First command: \`git switch ${branch}\`, then continue from its head.`;
187
+ return [
188
+ "RULES:",
189
+ first,
190
+ ' 2. Decide ambiguities yourself: `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`; stop only for an irreversible, security-sensitive or out-of-worktree action.',
191
+ ` 3. Commit with \`${workit("git")} commit -m <msg>${trailer ? ` --trailer "${trailer}"` : ""} -- <paths in SCOPE>\`${trailer ? " (Co-Authored-By rides in the same commit, never an amend)" : ""}; push only if this brief says so.${familyOf(host) === "claude_code" ? " Only a Claude Code host-isolated worktree (`.claude/worktrees/agent-<id>`) takes plain git instead, as its SubagentStart text says: that isolation refuses a git wrapper." : ""}`,
192
+ " 4. Never record a verdict on your own work.",
193
+ " 5. Run tests, lint, typecheck and builds only through `workit check`: a named check (`workit check <name>`), or an ad-hoc light command (a formatter, one linter, one test file) as `workit check --name <n> -- <cmd>`, which takes no lease. `--heavy` is only for full suites, packaging, builds and knip: one machine-wide lease queues every agent, so never a hand-made flock; `workit lock status` shows who holds it.",
194
+ " 6. A failed review gets one fix round and one recheck. Still failing: stop and report the open finding, never a third round (the lead spawns a decider). A product choice is the user's: report it, do not decide it.",
195
+ " 7. Before your final report, wait for or stop every background command you started; never report with one still running.",
196
+ ];
197
+ };
198
+
199
+ /** This repository's skill gates, named only where all of them exist (retro W2.36). */
200
+ const WORKIT_SKILL_TESTS = [
201
+ "test/workit-core/skill-set.test.ts",
202
+ "test/workit-core/methods.test.ts",
203
+ "test/behavior/context-budget.test.ts",
204
+ "test/workit-core/generated-copies.test.ts",
205
+ ];
206
+
207
+ /**
208
+ * Does a SCOPE entry reach skills or agent definitions (`**\/skills/**`,
209
+ * `**\/agents/**`)? Not under a `docs` dir: `docs/agents/*.md` is prose
210
+ * about agents, not a definition (retro W2.41).
211
+ */
212
+ const touchesSkills = (scope: readonly string[]): boolean =>
213
+ scope.some((entry) => {
214
+ const segments = entry.split(/[\\/]/u);
215
+ const hit = segments.findIndex((segment) => segment === "skills" || segment === "agents");
216
+ return hit >= 0 && !segments.slice(0, hit).includes("docs");
217
+ });
218
+
219
+ /**
220
+ * The extra VERIFY line for a slice whose SCOPE touches skills or agents:
221
+ * targeted tests miss the skill cap, pinned phrases and context budget, which
222
+ * fail only in CI. A `skills` check or group in the checks config is the
223
+ * portable form; else this repository's test files, else a generic rule.
224
+ * `root` is the slice's worktree once made: the worker runs the checks its
225
+ * branch defines, not those of the lead's checkout, which may be behind
226
+ * (retro W2.41: a stale main had no `skills` check).
227
+ */
228
+ function skillsVerify(root: string, scope: readonly string[], verify: readonly string[]) {
229
+ if (!touchesSkills(scope)) return [];
230
+ const config = loadCheckConfig(root);
231
+ const named =
232
+ !config.error &&
233
+ (config.checks.some((check) => check.name === "skills") || "skills" in config.groups);
234
+ let command = "run the repo's skill and context-budget checks";
235
+ if (named) command = "workit check skills";
236
+ else if (WORKIT_SKILL_TESTS.every((file) => fs.existsSync(path.join(config.root, file))))
237
+ command = `workit check --name unit -- bun test ${WORKIT_SKILL_TESTS.join(" ")}`;
238
+ if (verify.some((line) => line.includes(command))) return [];
239
+ return [
240
+ ` skills: ${command} (SCOPE touches skills or agents; targeted tests miss these gates)`,
241
+ ];
242
+ }
243
+
244
+ /**
245
+ * The VERIFY line naming each check a verdict on this slice needs (the
246
+ * repo's verification gate) that its VERIFY lines do not already run by
247
+ * name: a check run ad hoc leaves it missing on the verdict (retro 2026-11-07
248
+ * R5).
249
+ */
250
+ function configuredVerify(root: string, lines: readonly string[]): string[] {
251
+ const config = loadCheckConfig(root);
252
+ if (config.error) return [];
253
+ const named = new Set(
254
+ lines.flatMap((line) =>
255
+ [...line.matchAll(/\bworkit check (?:--cwd \S+ )?([\w.-]+)/gu)].map((match) => match[1]),
256
+ ),
257
+ );
258
+ const missing = neededChecks("full", gateCheckNames(config, "verification")).filter(
259
+ (name) => !named.has(name),
260
+ );
261
+ return missing.length
262
+ ? [
263
+ ` checks: ${missing.map((name) => `workit check ${name}`).join("; ")} (the configured checks a verdict records: run each by name, never ad hoc)`,
264
+ ]
265
+ : [];
266
+ }
267
+
268
+ /**
269
+ * The SPAWN lines a Claude Code lead copies into its Agent calls: the exact
270
+ * args its spawn hook passes as written (retro 2026-11-07 R1: a spawn the hook
271
+ * rewrote is refused in auto mode, and a re-issue dropped run_in_background).
272
+ */
273
+ function spawnLines(
274
+ cwd: string,
275
+ host: string | null,
276
+ slice: string,
277
+ tier: RoleSelector["tier"],
278
+ ): string[] {
279
+ if (familyOf(host) !== "claude_code") return [];
280
+ const line = (label: string, type: string, selector: RoleSelector, prompt: string) => {
281
+ const args = claudeSpawnArgs(cwd, selector);
282
+ const fields = [
283
+ `subagent_type: "${type}"`,
284
+ ...(args ? [`model: "${args.model}"`] : []),
285
+ ...(args?.effort ? [`effort: "${args.effort}"`] : []),
286
+ "run_in_background: true",
287
+ `description: "${slice}"`,
288
+ ];
289
+ return `${label}: Agent ${fields.join(", ")}, prompt: ${prompt}`;
290
+ };
291
+ return [
292
+ line(
293
+ "SPAWN",
294
+ "workit:implementer",
295
+ { role: "implementer", tier },
296
+ `this brief, or its WORKER, TIER, TIMEBOX and WORKTREE lines plus "Run \`workit fanout brief ${slice}\` and follow it exactly."`,
297
+ ),
298
+ line(
299
+ " verifier SPAWN",
300
+ "workit:verifier",
301
+ { role: "verifier" },
302
+ "the candidate branch with this slice's ACCEPTANCE and VERIFY",
303
+ ),
304
+ ];
305
+ }
306
+
307
+ /** VERIFY's extra lines: the skill gates, then the configured checks neither names. */
308
+ function verifyLines(root: string, scope: readonly string[], verify: readonly string[]): string[] {
309
+ const skills = skillsVerify(root, scope, verify);
310
+ return [...skills, ...configuredVerify(root, [...verify, ...skills])];
311
+ }
312
+
313
+ /** A session id fragment that the ledger accepts ([A-Za-z0-9_.:@/+-]). */
314
+ const sessionSafe = (text: string): string => text.replace(/[^A-Za-z0-9_.:@/+-]/gu, "-");
315
+
316
+ /** The ledger's session id limit. */
317
+ const SESSION_MAX = 128;
318
+
319
+ /** `<lead>-w-<slice>[+try<n>]`, with the slice part hashed when the whole would be too long. */
320
+ function workerSession(lead: string, slice: string, attempt: number | null): string {
321
+ const tail = attempt === null ? "" : `+try${attempt}`;
322
+ const full = `${lead}-w-${slice}${tail}`;
323
+ if (full.length <= SESSION_MAX) return full;
324
+ const digest = createHash("sha256").update(slice).digest("hex").slice(0, 12);
325
+ return `${lead.slice(0, SESSION_MAX - 3 - digest.length - tail.length)}-w-${digest}${tail}`;
326
+ }
327
+
328
+ export function renderBrief(
329
+ cwd: string,
330
+ plan: FanoutFile,
331
+ input: {
332
+ slice: string;
333
+ mode: BriefMode | null;
334
+ attempt: number | null;
335
+ actor: LedgerActor;
336
+ /** The worker's host (`--host`, normalized by briefHost), else the lead's. */
337
+ host?: string | null;
338
+ },
339
+ ): FanoutResult<BriefOutcome> {
340
+ const found = findSlice(plan, input.slice);
341
+ if (!found.ok) return found;
342
+ const slice = found.data;
343
+ const ledger = readLedger(cwd);
344
+ if (!ledger.ok) return fanoutFail(ledger.code, ledger.error, ledger.unblock);
345
+ if (!input.actor.session)
346
+ return fanoutFail(
347
+ "blocked",
348
+ "WORKIT_SESSION_ID is not set: worker session ids derive from the lead's",
349
+ "export WORKIT_SESSION_ID=<your session id>, then render the brief again",
350
+ );
351
+ const branch = input.attempt === null ? slice.branch : `${slice.branch}-try${input.attempt}`;
352
+ const taken = plan.slices.find((other) => other.id !== slice.id && other.branch === branch);
353
+ if (taken)
354
+ return fanoutFail(
355
+ "invalid_input",
356
+ `attempt branch ${branch} is slice ${taken.id}'s branch`,
357
+ "pick another --attempt number, or rename one of the branches in the plan",
358
+ );
359
+ const head = branchRef(cwd, branch);
360
+ const planned = `${slicePath(cwd, slice)}${input.attempt === null ? "" : `+try${input.attempt}`}`;
361
+ const made = ledger.value.rows.findLast(
362
+ (row) =>
363
+ row.type === "fanout.worktree.created" &&
364
+ row.fanout === plan.name &&
365
+ row.slice === slice.id &&
366
+ typeof row.path === "string" &&
367
+ samePath(row.path, planned),
368
+ );
369
+ const fresh = Boolean(head) && made?.baseSha === head;
370
+ // A dependent slice made after its dependencies landed starts on the trunk (#506).
371
+ const sliceBase = typeof made?.startBase === "string" ? made.startBase : slice.base;
372
+ const mode: BriefMode = input.mode ?? (head && !fresh ? "resume" : "new");
373
+ if (mode === "resume" && !head)
374
+ return fanoutFail(
375
+ "not_found",
376
+ `MODE resume needs the branch ${branch}, which does not exist here`,
377
+ `workit fanout brief ${slice.id} --mode new`,
378
+ );
379
+
380
+ const exists = listWorktrees(cwd).some((entry) => samePath(entry.path, planned));
381
+ excludeScratch(cwd);
382
+ const scratch = exists ? `${planned}/${SCRATCH_DIR}` : SCRATCH_DIR;
383
+ const scratchLine = exists
384
+ ? `SCRATCH: ${scratch} (yours alone; temp files go here, never a shared path)`
385
+ : `SCRATCH: ${SCRATCH_DIR}/ at your worktree root (mkdir -p it; git ignores it; never a shared path)`;
386
+
387
+ const standing = activeStanding(ledger.value.rows, plan.name, planLeads(plan)).map((row) => ({
388
+ id: row.id,
389
+ what: String(row.what),
390
+ }));
391
+ const host = input.host ?? input.actor.host;
392
+ const identity = identityOf(host);
393
+ const model = resolveRoleModel(cwd, { role: "implementer", tier: slice.tier }, host);
394
+ const verifier = resolveRoleModel(cwd, { role: "verifier" }, host);
395
+ const coAuthor = coAuthorTrailer(host, model?.model ?? null);
396
+ const minted = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
397
+ const session = identity === "declared" ? minted : null;
398
+ const fanIn =
399
+ plan.fanIn === "integration"
400
+ ? [
401
+ `FAN-IN: integration branch ${plan.trunk}. Before you report, merge its tip into your branch`,
402
+ ` (\`git merge --no-edit ${plan.trunk}\`), resolve conflicts inside SCOPE and re-run VERIFY, so the`,
403
+ " lead's merge is a fast-forward. No other merge, and no rebase, retarget or force-push.",
404
+ ]
405
+ : [
406
+ "FAN-IN: one PR per slice. Never rebase, retarget, merge or force-push: the lead owns topology.",
407
+ ];
408
+
409
+ const lines = [
410
+ `MODE: ${mode}${input.attempt === null ? "" : ` (race attempt ${input.attempt}: the lead keeps the attempt with the best verdict)`}`,
411
+ `WORKER: ${slice.id}${input.attempt === null ? "" : `+try${input.attempt}`}`,
412
+ `GOAL: ${slice.goal}`,
413
+ `SCOPE: ${slice.scope.join(", ")}`,
414
+ ...(slice.owns.length ? [` owns: ${slice.owns.join(", ")}`] : []),
415
+ ` branch: ${branch} base: ${sliceBase}`,
416
+ `CONTEXT: ${slice.context ?? "none given: read the files in SCOPE and the neighbours they import"}`,
417
+ ...(slice.issues.length
418
+ ? [
419
+ `ISSUE: ${slice.issues.join(", ")} (\`workit pr create\` on ${branch} links ${slice.issues.length === 1 ? "it" : "them"} by itself; no --issue needed)`,
420
+ ]
421
+ : []),
422
+ ...(mode === "resume" && head
423
+ ? [
424
+ ` resume: continue ${branch} from ${head.slice(0, 12)}; read \`git log ${sliceBase}..${branch}\` and \`workit ledger list --branch ${branch}\` (the last report, verdicts, rulings)`,
425
+ ]
426
+ : []),
427
+ "ACCEPTANCE:",
428
+ ...slice.acceptance.map((line) => ` - ${line}`),
429
+ `VERIFY: ${slice.verify.join("; ")}`,
430
+ ...verifyLines(exists ? planned : cwd, slice.scope, slice.verify),
431
+ ...(slice.reason ? [`REASON: ${slice.reason}`] : []),
432
+ `TIER: ${slice.tier}`,
433
+ ...(model
434
+ ? [
435
+ ` model: ${model.model}${model.effort ? ` effort: ${model.effort}` : ""} (${model.source})`,
436
+ ]
437
+ : []),
438
+ ...(verifier
439
+ ? [
440
+ ` verifier: model ${verifier.model}${verifier.effort ? ` effort ${verifier.effort}` : ""} (${verifier.source})`,
441
+ ]
442
+ : []),
443
+ ...spawnLines(cwd, host, slice.id, slice.tier),
444
+ `TIMEBOX: ${timeboxText(slice.timebox, slice.tier)}; past it without a new commit you will be replaced`,
445
+ scratchLine,
446
+ `FORBIDDEN: ${slice.forbidden.join("; ")}`,
447
+ ...fanIn,
448
+ ...REPORT,
449
+ "STANDING:",
450
+ ...(standing.length
451
+ ? standing.map((order) => ` - ${order.what}`)
452
+ : [" - none recorded beyond this brief"]),
453
+ exists
454
+ ? `WORKTREE: ${planned} (made by \`workit fanout worktree create ${slice.id}\`; yours alone)`
455
+ : `WORKTREE: ${planned} (not made yet: the lead runs \`workit fanout worktree create ${slice.id}\` before spawning, then renders this brief again)`,
456
+ ...(exists &&
457
+ typeof made?.install === "string" &&
458
+ !fs.existsSync(path.join(planned, "node_modules"))
459
+ ? [`INSTALL: \`${made.install}\` first: the main checkout's node_modules was not linked here`]
460
+ : []),
461
+ ...identityLines(identity, minted),
462
+ ...rules(mode, branch, sliceBase, exists ? planned : null, coAuthor, host),
463
+ ];
464
+ return {
465
+ ok: true,
466
+ data: {
467
+ fanout: plan.name,
468
+ slice: slice.id,
469
+ mode,
470
+ attempt: input.attempt,
471
+ branch,
472
+ base: sliceBase,
473
+ tier: slice.tier,
474
+ issues: slice.issues,
475
+ model: model?.model ?? null,
476
+ effort: model?.effort ?? null,
477
+ verifier: verifier ? { model: verifier.model, effort: verifier.effort } : null,
478
+ scratch,
479
+ host,
480
+ coAuthor,
481
+ session,
482
+ standing,
483
+ text: `${lines.join("\n")}\n`,
484
+ },
485
+ };
486
+ }