@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,408 @@
1
+ // Stop control (ledger decision "stop control: option A, nudge once"): when
2
+ // the main agent ends its turn with an obligation the ledger or git can
3
+ // prove unmet, the host continues it once with a reason naming the
4
+ // obligation and the workit command that clears it. The obligations, for the
5
+ // session's branch (the checked-out branch, on which this session recorded a
6
+ // commit or opened a PR):
7
+ // (a) unpushed: a commit this session recorded, not on the push remote,
8
+ // while the effective endpoint is pr, green or merged;
9
+ // (b) checks: the branch's open PR has failing or pending checks recorded
10
+ // for its current head (`pr.status` rows) while the endpoint is green
11
+ // or merged; a live `workit ci wait` on that PR and head (ci-waiter.ts),
12
+ // often moved to the background, is in flight: the stop is allowed
13
+ // with a notice instead, and the wait re-invokes the lead when it ends;
14
+ // (c) verdict: the session opened the PR (or verified delivery) but the
15
+ // head lacks the verdict the policy asks for, while the endpoint is
16
+ // green or merged (a PR opens without one). Under `verification:
17
+ // proportional` (the default) the review lane of the head decides
18
+ // (core/policy/lane.ts): none owes nothing, self the author's own
19
+ // `--self` verdict, focused and full an accepted non-author verdict.
20
+ // Under `self`: the author's own at normal risk; at high risk or under
21
+ // `independent`: a non-author one, and not raised when the task's
22
+ // latest judgment records no behavior change at normal risk
23
+ // (policy/derive.ts asks for none either). Never raised while a
24
+ // verifier or reviewer this lead started still runs.
25
+ // Each obligation blocks once per head and session (a marker keyed by kind
26
+ // and head), not once per turn.
27
+ // A branch that landed owes nothing: a recorded merge (`workit pr merge`, or
28
+ // `pr status`/`ci wait` seeing the forge report it merged) of HEAD or of a
29
+ // head that contains this session's commits, or, with no merge recorded, an
30
+ // upstream that is gone. Its remote branch may be deleted, and an
31
+ // `--unverified` merge was the user's call.
32
+ // A stop is never blocked: in a subagent, outside a Workit workspace, when
33
+ // the host says this stop already follows a continuation (one continue per
34
+ // turn), or when the agent's last message asks the user a question. Reads
35
+ // the ledger, local git and the ci-wait marker only (no forge call), and
36
+ // every failure allows the stop.
37
+ import { spawnSync } from "node:child_process";
38
+ import { resolveAutonomy, type VerificationMode } from "../autonomy";
39
+ import { liveCiWaiter, type CiWaiter } from "../ci-waiter";
40
+ import { currentJudgment } from "../core/policy/current-judgment";
41
+ import { branchLane, laneNeed } from "../core/policy/lane";
42
+ import { currentBranch, pushRemoteName } from "../git/rev";
43
+ import { checkVerdicts, readLedger, type ReadRow } from "../ledger";
44
+ import type { HostDescriptor } from "./descriptor";
45
+ import type { HookDecision, HookInput } from "./protocol";
46
+ import { guardedBranch, inWorkitWorkspace } from "./raw-git";
47
+ import { readSessionMarker, sessionMarkerFile, updateSessionMarker } from "./session-marker";
48
+
49
+ const NONE: HookDecision = { kind: "none" };
50
+
51
+ export type StopObligation = {
52
+ kind: "unpushed" | "checks" | "verdict";
53
+ /** What is left, with the workit command that clears it. */
54
+ reason: string;
55
+ /** HEAD when it was found: with the kind, the once-per-head key. */
56
+ head?: string | null;
57
+ /** A live `workit ci wait` on the PR and head: the checks are in flight, not owed. */
58
+ waiter?: CiWaiter;
59
+ };
60
+
61
+ /** A question mark (Latin, full-width, Arabic) ending a sentence, or Spanish's opening one. */
62
+ const QUESTION = /[??؟](?=[\s*_`"')\]>]*(?:\s|$))|¿/;
63
+ /** An offer or a request for permission. */
64
+ const OFFER =
65
+ /\b(?:let me know|want me to|should I|shall I|would you like|do you want|if you(?:'d| would) like|may I|can I go ahead)\b/i;
66
+ /** A `[y/N]`, `(y/n)` or `[Y/n]` prompt. */
67
+ const YES_NO = /[[(]\s*y(?:es)?\s*\/\s*n(?:o)?\s*[\])]/i;
68
+ /** A list item: `- a`, `* a`, `1. a`, `2) a`, `a) a`. */
69
+ const LIST_ITEM = /^\s*(?:[-*+•]|\d+[.)]|[a-z][.)])\s+/i;
70
+
71
+ /**
72
+ * Does the message end by asking the user something? The last two
73
+ * paragraphs (past code blocks and a trailing option list) are read for a
74
+ * question mark, an offer ("want me to", "shall I", "let me know") or a
75
+ * yes/no prompt: a stop that waits on the user is never blocked, so this
76
+ * errs towards "yes".
77
+ */
78
+ export const asksUser = (message: string | null | undefined): boolean => {
79
+ if (!message) return false;
80
+ const paragraphs = message
81
+ .replace(/```[\s\S]*?(?:```|$)/g, "\n\n")
82
+ .split(/\n\s*\n/)
83
+ .map((paragraph) => paragraph.trim())
84
+ .filter(Boolean);
85
+ // A trailing option list belongs to the question before it.
86
+ while (
87
+ paragraphs.length > 1 &&
88
+ paragraphs
89
+ .at(-1)!
90
+ .split("\n")
91
+ .every((line) => LIST_ITEM.test(line))
92
+ )
93
+ paragraphs.pop();
94
+ const tail = paragraphs.slice(-2).join("\n");
95
+ return QUESTION.test(tail) || OFFER.test(tail) || YES_NO.test(tail);
96
+ };
97
+
98
+ const short = (sha: string | null | undefined) => (sha ?? "?").slice(0, 12);
99
+
100
+ /** A git command's output lines, or null when it fails. */
101
+ const gitList = (cwd: string, args: string[]): string[] | null => {
102
+ const run = spawnSync("git", args, {
103
+ cwd,
104
+ encoding: "utf8",
105
+ timeout: 3_000,
106
+ windowsHide: true,
107
+ env: {
108
+ ...process.env,
109
+ GIT_TERMINAL_PROMPT: "0",
110
+ GIT_OPTIONAL_LOCKS: "0",
111
+ LC_ALL: "C",
112
+ },
113
+ });
114
+ return run.status === 0 ? run.stdout.split("\n").filter(Boolean) : null;
115
+ };
116
+
117
+ const gitLine = (cwd: string, args: string[]): string | null => gitList(cwd, args)?.[0] ?? null;
118
+
119
+ const sessionOf = (row: ReadRow): string | null =>
120
+ (typeof row.session === "string" ? row.session : null) ?? row.actor.session;
121
+
122
+ /**
123
+ * The branch's open PR: the newest one the CLI opened or observed
124
+ * (`pr.created`, `pr.status`), unless it was merged since.
125
+ */
126
+ /** A `pr.status` row's PR state; rows written before it was recorded are open. */
127
+ const prState = (row: ReadRow): string => (typeof row.state === "string" ? row.state : "open");
128
+
129
+ /** A CLI row saying the PR ended: `workit pr merge`, or the forge reporting it merged or closed. */
130
+ const ended = (row: ReadRow): boolean =>
131
+ row.observer === "workit_cli" &&
132
+ (row.type === "pr.merged" || (row.type === "pr.status" && prState(row) !== "open"));
133
+
134
+ const openPr = (rows: readonly ReadRow[]): number | null => {
135
+ const seen = rows.findLast(
136
+ (row) =>
137
+ (row.type === "pr.created" || row.type === "pr.status") &&
138
+ row.observer === "workit_cli" &&
139
+ typeof row.pr === "number",
140
+ );
141
+ if (!seen || ended(seen)) return null;
142
+ const closed = rows.some((row) => ended(row) && row.pr === seen.pr && row.seq > seen.seq);
143
+ return closed ? null : (seen.pr as number);
144
+ };
145
+
146
+ /**
147
+ * The heads the ledger records as merged: `workit pr merge` (`pr.merged`), or
148
+ * the forge reporting the PR merged (`pr.status` state merged, as `workit pr
149
+ * status` and `workit ci wait` record a merge done in the forge UI). A PR
150
+ * closed without merging is not one. Newest last, at most three.
151
+ */
152
+ const mergedHeads = (rows: readonly ReadRow[]): string[] =>
153
+ [
154
+ ...new Set(
155
+ rows
156
+ .filter(
157
+ (row) =>
158
+ row.observer === "workit_cli" &&
159
+ typeof row.head === "string" &&
160
+ (row.type === "pr.merged" || (row.type === "pr.status" && prState(row) === "merged")),
161
+ )
162
+ .map((row) => row.head as string),
163
+ ),
164
+ ].slice(-3);
165
+
166
+ /** Is `sha` a commit in the local object store? */
167
+ const localCommit = (cwd: string, sha: string): boolean =>
168
+ /^[0-9a-f]{7,64}$/.test(sha) && gitList(cwd, ["cat-file", "-e", `${sha}^{commit}`]) !== null;
169
+
170
+ /** The branch's configured upstream was deleted on the remote and pruned. */
171
+ const upstreamGone = (cwd: string, branch: string): boolean =>
172
+ gitLine(cwd, ["for-each-ref", "--format=%(upstream:track)", `refs/heads/${branch}`]) === "[gone]";
173
+
174
+ /**
175
+ * Has the branch landed? A recorded merge (`mergedHeads`) of the current
176
+ * HEAD, or of a head that already contains every commit of this session on
177
+ * HEAD: the merge record and the branch both say done, so nothing is left to
178
+ * push (the remote branch may be deleted) or to verify (an `--unverified`
179
+ * merge was the user's call). With no merge recorded, an upstream that is
180
+ * gone (the forge deleted the merged branch and a fetch pruned it) says the
181
+ * same. New commits of this session after a recorded merge are not landed.
182
+ */
183
+ const landed = (
184
+ cwd: string,
185
+ branch: string,
186
+ rows: readonly ReadRow[],
187
+ head: string | null,
188
+ mine: ReadonlySet<string>,
189
+ ): boolean => {
190
+ if (!head) return false;
191
+ const merged = mergedHeads(rows);
192
+ if (merged.length === 0) return upstreamGone(cwd, branch);
193
+ if (merged.includes(head)) return true;
194
+ if (mine.size === 0) return false;
195
+ return merged.some((at) => {
196
+ // Commits on HEAD the merged head lacks; null when it is not a local commit.
197
+ const after = gitList(cwd, ["rev-list", `--max-count=${UNPUSHED_SCAN}`, "HEAD", "--not", at]);
198
+ return after !== null && !after.some((sha) => mine.has(sha));
199
+ });
200
+ };
201
+
202
+ /** How far back (a) looks for unpushed commits. */
203
+ const UNPUSHED_SCAN = 500;
204
+
205
+ /**
206
+ * (a) This session's recorded commits on HEAD that neither the push remote
207
+ * nor a recorded merge holds. Another session's or the user's commits never
208
+ * count, nor do commits already merged before the branch was reused.
209
+ */
210
+ const unpushed = (
211
+ cwd: string,
212
+ branch: string,
213
+ endpoint: string,
214
+ mine: ReadonlySet<string>,
215
+ merged: readonly string[],
216
+ ): StopObligation | null => {
217
+ const remote = pushRemoteName(cwd, branch);
218
+ if (!remote || mine.size === 0) return null;
219
+ const listed = gitList(cwd, [
220
+ "rev-list",
221
+ `--max-count=${UNPUSHED_SCAN}`,
222
+ "HEAD",
223
+ "--not",
224
+ `--remotes=${remote}`,
225
+ ...merged.filter((sha) => localCommit(cwd, sha)),
226
+ ]);
227
+ const count = (listed ?? []).filter((sha) => mine.has(sha)).length;
228
+ if (count === 0) return null;
229
+ return {
230
+ kind: "unpushed",
231
+ reason: `${branch} has ${count} commit(s) of this session not on ${remote}, and this workspace's endpoint is \`${endpoint}\` (\`workit grant show\`): push them with \`workit git push\`${endpoint === "pr" ? ", then open or update the PR (`workit pr create --fill`)" : ""}.`,
232
+ };
233
+ };
234
+
235
+ /** (b) The open PR's checks, as last recorded for the current head. */
236
+ const redChecks = (
237
+ cwd: string,
238
+ rows: readonly ReadRow[],
239
+ pr: number,
240
+ head: string,
241
+ endpoint: string,
242
+ ): StopObligation | null => {
243
+ const status = rows.findLast(
244
+ (row) => row.type === "pr.status" && row.observer === "workit_cli" && row.pr === pr,
245
+ );
246
+ const state = status && status.head === head && prState(status) === "open" ? status.checks : null;
247
+ if (state !== "failing" && state !== "pending") return null;
248
+ const waiter = liveCiWaiter(cwd, pr, head);
249
+ if (waiter)
250
+ return {
251
+ kind: "checks",
252
+ waiter,
253
+ reason: `a \`workit ci wait\` for PR #${pr} at ${short(waiter.head ?? head)} is in flight (pid ${waiter.pid}, until ${new Date(waiter.deadline).toISOString()}); its result re-invokes you when it ends, so do not start another wait.`,
254
+ };
255
+ return {
256
+ kind: "checks",
257
+ reason: `PR #${pr}'s checks were last seen ${state} at ${short(head)}, and this workspace's endpoint is \`${endpoint}\`: run \`workit ci wait --pr ${pr}\`${state === "failing" ? " after fixing the failures `workit pr status` lists (then push)" : ""}.`,
258
+ };
259
+ };
260
+
261
+ /**
262
+ * The base the branch's PR targets: the newest the forge reported (a
263
+ * `pr.status` row, so a PR retargeted on the forge is read against its
264
+ * current base), else the one it was opened with (`pr.created`), else null
265
+ * (the default base). Read from the ledger: the hook never calls the forge.
266
+ */
267
+ const prBase = (rows: readonly ReadRow[]): string | null => {
268
+ const seen = rows.findLast(
269
+ (row) =>
270
+ (row.type === "pr.status" || row.type === "pr.created") &&
271
+ row.observer === "workit_cli" &&
272
+ typeof row.base === "string",
273
+ );
274
+ return typeof seen?.base === "string" ? seen.base : null;
275
+ };
276
+
277
+ /**
278
+ * (c) Delivered by this session, yet the head lacks the verdict the policy
279
+ * asks for. Under `verification: proportional` the review lane of the head
280
+ * decides (core/policy/lane.ts): none owes nothing, self the author's own,
281
+ * focused and full a non-author one. Under `self`, the author's own at
282
+ * normal risk; under `independent` or at high risk, a non-author one.
283
+ */
284
+ const unverified = (
285
+ cwd: string,
286
+ branch: string,
287
+ all: readonly ReadRow[],
288
+ descriptor: HostDescriptor,
289
+ verification: VerificationMode,
290
+ head: string | null,
291
+ ): StopObligation | null => {
292
+ const check = checkVerdicts(cwd, branch, all);
293
+ if (check.accepted.accepted || check.accepted.reasons.includes("failing_verdict")) return null;
294
+ const judgment = currentJudgment(cwd);
295
+ const high = judgment?.riskTier === "high";
296
+ let independent: boolean;
297
+ if (verification === "proportional") {
298
+ const lane = branchLane(cwd, {
299
+ base: prBase(all.filter((row) => row.branch === branch)),
300
+ head,
301
+ judgment,
302
+ });
303
+ const need = laneNeed(lane.lane);
304
+ if (need === "none") return null;
305
+ independent = need === "non-author";
306
+ } else {
307
+ // Like policy/derive.ts: a verdict is asked for a behavior change or high
308
+ // risk. A recorded judgment with neither (docs only) owes none.
309
+ if (judgment && !judgment.behaviorChange && !high) return null;
310
+ independent = verification === "independent" || high;
311
+ }
312
+ if (!independent && check.review === "self-reviewed") return null;
313
+ const has = check.review === "self-reviewed" ? "only a self verdict" : "no current verdict";
314
+ const verifier = `${descriptor.subagents.agentPrefix}verifier`;
315
+ const how = independent
316
+ ? `hand it to a session that did not author it (the ${verifier} agent records \`workit ledger verdict <result> --branch ${branch} --how "<evidence>"\`), or tell the user it is unverified. Never record a verdict on your own work as independent.`
317
+ : `after an observed passing \`workit check test\`, record your own: \`workit ledger verdict tests-verified --self --branch ${branch} --how "<what you ran>"\` (shown as self-reviewed), or tell the user it is unverified.`;
318
+ return {
319
+ kind: "verdict",
320
+ reason: `the head ${short(check.head)} of ${branch}, which this session delivered, has ${has}, and this change needs ${independent ? "an independent verdict" : "a verdict (your own is enough)"}: ${how}`,
321
+ };
322
+ };
323
+
324
+ /** The first unmet obligation for the session's branch, or null. Never throws. */
325
+ export function stopObligation(
326
+ input: HookInput,
327
+ descriptor: HostDescriptor,
328
+ ): StopObligation | null {
329
+ try {
330
+ const session = input.session.id;
331
+ // Outside a Workit workspace there is no ledger either; the check only
332
+ // spares every other repository's Stop the git and ledger reads below.
333
+ if (!session || input.session.agentId || !inWorkitWorkspace(input.cwd)) return null;
334
+ const branch = currentBranch(input.cwd);
335
+ if (!branch || guardedBranch(input.cwd, branch)) return null;
336
+ // Read with the hash chain: a hand-appended row neither creates nor
337
+ // clears an obligation, and verdicts are judged with integrity (N1).
338
+ const ledger = readLedger(input.cwd, { integrity: true });
339
+ if (!ledger.ok) return null;
340
+ const all = ledger.value.rows.filter((row) => row.integrity !== "unverified");
341
+ const rows = all.filter((row) => row.branch === branch);
342
+ const mine = rows.filter((row) => sessionOf(row) === session);
343
+ const commits = new Set(
344
+ mine
345
+ .filter((row) => row.type === "commit.recorded")
346
+ .map((row) => (typeof row.sha === "string" ? row.sha : row.head))
347
+ .filter((sha): sha is string => typeof sha === "string"),
348
+ );
349
+ const delivered = mine.some(
350
+ (row) =>
351
+ row.observer === "workit_cli" &&
352
+ (row.type === "pr.created" || row.type === "delivery.verified"),
353
+ );
354
+ if (commits.size === 0 && !delivered) return null;
355
+ const head = gitLine(input.cwd, ["rev-parse", "HEAD"]);
356
+ if (landed(input.cwd, branch, rows, head, commits)) return null;
357
+ const autonomy = resolveAutonomy(input.cwd);
358
+ const endpoint = autonomy.effectiveEndpoint;
359
+ if (endpoint !== "commit") {
360
+ const left = unpushed(input.cwd, branch, endpoint, commits, mergedHeads(rows));
361
+ if (left) return { ...left, head };
362
+ }
363
+ const pr = openPr(rows);
364
+ if ((endpoint === "green" || endpoint === "merged") && pr !== null && head) {
365
+ const left = redChecks(input.cwd, rows, pr, head, endpoint);
366
+ if (left) return { ...left, head };
367
+ }
368
+ // A PR opens without a verdict: only green and merged need one.
369
+ if (!delivered || (endpoint !== "green" && endpoint !== "merged")) return null;
370
+ const left = unverified(input.cwd, branch, all, descriptor, autonomy.verification, head);
371
+ return left ? { ...left, head } : null;
372
+ } catch {
373
+ return null;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * The stop decision: continue once with the obligation, or allow. A stop
379
+ * that follows a continuation (`stopHookActive`), a subagent's stop, and a
380
+ * stop whose last message asks the user something are always allowed. So is
381
+ * a stop whose last message is unknown: it may be a question.
382
+ */
383
+ export function stopDecision(input: HookInput, descriptor: HostDescriptor): HookDecision {
384
+ const event = input.event;
385
+ if (event.kind !== "stop" || event.stopHookActive) return NONE;
386
+ if (!event.lastMessage?.trim() || asksUser(event.lastMessage)) return NONE;
387
+ const left = stopObligation(input, descriptor);
388
+ if (!left) return NONE;
389
+ if (left.waiter) return { kind: "notice", userMessage: `Workit: ${left.reason}` };
390
+ // Once per head and obligation for the session, and no verdict nag while a
391
+ // verifier or reviewer this lead started is still running.
392
+ const session = input.session.id;
393
+ const file = session ? sessionMarkerFile(input, session) : null;
394
+ const marker = file ? readSessionMarker(file) : null;
395
+ if (left.kind === "verdict" && marker && marker.judges.length > 0) return NONE;
396
+ const key = `${left.kind}:${left.head ?? ""}`;
397
+ if (file && marker) {
398
+ if (marker.stops.includes(key)) return NONE;
399
+ updateSessionMarker(file, (fresh) => ({
400
+ ...fresh,
401
+ stops: [...fresh.stops, key],
402
+ }));
403
+ }
404
+ return {
405
+ kind: "continue",
406
+ reason: `Workit: before stopping, ${left.reason} If this cannot be done now, say so to the user and stop.`,
407
+ };
408
+ }