@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,115 @@
1
+ // A repeated identical failing Workit call (#435): the second failure in a row
2
+ // of the same tool and args (or the same `workit …` command) in a session ends
3
+ // with one line naming the exact corrected next call, instead of the same error.
4
+ import { createHash } from "node:crypto";
5
+
6
+ export const SAME_CALL_FAILED = "Same call failed twice:";
7
+
8
+ /** The last failing call's hash per session, in the host plugin's own state. */
9
+ export type FailureMemo = Map<string, string>;
10
+
11
+ const MAX_SESSIONS = 256;
12
+
13
+ const stable = (value: unknown): string =>
14
+ JSON.stringify(value, (_key, entry: unknown) =>
15
+ entry && typeof entry === "object" && !Array.isArray(entry)
16
+ ? Object.fromEntries(Object.entries(entry).toSorted(([a], [b]) => a.localeCompare(b)))
17
+ : entry,
18
+ ) ?? "null";
19
+
20
+ const hashOf = (call: string): string =>
21
+ createHash("sha256").update(call).digest("hex").slice(0, 32);
22
+
23
+ /**
24
+ * Record this call's outcome for the session. A success clears the memo; a
25
+ * failure returns the escalation line when the previous call failed the same
26
+ * way and a corrected call is known.
27
+ */
28
+ const record = (
29
+ memo: FailureMemo,
30
+ sessionId: string,
31
+ call: string,
32
+ failed: boolean,
33
+ next: string | null,
34
+ ): string | null => {
35
+ const previous = memo.get(sessionId);
36
+ memo.delete(sessionId);
37
+ if (!failed) return null;
38
+ const hash = hashOf(call);
39
+ memo.set(sessionId, hash);
40
+ if (memo.size > MAX_SESSIONS) memo.delete(memo.keys().next().value!);
41
+ return previous === hash && next ? `${SAME_CALL_FAILED} ${next}` : null;
42
+ };
43
+
44
+ type Failed = { ok: false; error: string; details?: { guidance?: unknown } };
45
+
46
+ const isFailure = (result: unknown): result is Failed =>
47
+ result !== null &&
48
+ typeof result === "object" &&
49
+ (result as { ok?: unknown }).ok === false &&
50
+ typeof (result as { error?: unknown }).error === "string";
51
+
52
+ /** A failed tool result's corrected payload: its `Example:` or, failing that, its guidance. */
53
+ const correctedPayload = (tool: string, result: Failed): string | null => {
54
+ const example = /Example: (\{.*\})\s*$/.exec(result.error)?.[1];
55
+ if (example) return `${tool} ${example}`;
56
+ const guidance = result.details?.guidance;
57
+ return typeof guidance === "string" && guidance ? guidance : null;
58
+ };
59
+
60
+ /**
61
+ * A Workit tool result, with the escalation line appended to its error when
62
+ * the same tool and args failed on the session's previous call too.
63
+ */
64
+ export const escalateToolFailure = <T>(
65
+ memo: FailureMemo,
66
+ sessionId: string,
67
+ tool: string,
68
+ input: unknown,
69
+ result: T,
70
+ ): T => {
71
+ const failed = isFailure(result);
72
+ const line = record(
73
+ memo,
74
+ sessionId,
75
+ `tool\0${tool}\0${stable(input)}`,
76
+ failed,
77
+ failed ? correctedPayload(tool, result) : null,
78
+ );
79
+ return line && failed ? { ...result, error: `${result.error}\n${line}` } : result;
80
+ };
81
+
82
+ const WORKIT_COMMAND = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*workit\s/;
83
+
84
+ const unblockOf = (output: string): string | null => {
85
+ const human = /^\s*unblock: (.+)$/m.exec(output)?.[1];
86
+ if (human) return human.trim();
87
+ const json = /"unblock":("(?:[^"\\]|\\.)*")/.exec(output)?.[1];
88
+ if (!json) return null;
89
+ try {
90
+ return JSON.parse(json) as string;
91
+ } catch {
92
+ return null;
93
+ }
94
+ };
95
+
96
+ /**
97
+ * The escalation line for a finished `workit …` shell command, or null: a
98
+ * failed run is one whose output carries the CLI's error envelope.
99
+ */
100
+ export const repeatedCliFailure = (
101
+ memo: FailureMemo,
102
+ sessionId: string,
103
+ command: string,
104
+ output: string,
105
+ ): string | null => {
106
+ if (!WORKIT_COMMAND.test(command)) return null;
107
+ const failed = /^workit: /m.test(output) || /"ok":\s*false/.test(output);
108
+ return record(
109
+ memo,
110
+ sessionId,
111
+ `cli\0${command.trim()}`,
112
+ failed,
113
+ failed ? unblockOf(output) : null,
114
+ );
115
+ };
@@ -0,0 +1,54 @@
1
+ // Hook-process entry by host name: maps each process host to its adapter and
2
+ // runs it (process.ts). Host bundles import process.ts with their own adapter,
3
+ // so they carry none of the others.
4
+ import { PROCESS_START, type HookDeadline } from "./deadline";
5
+ import { claudeCodeAdapter } from "./hosts/claude-code";
6
+ import { codexAdapter } from "./hosts/codex";
7
+ import { cursorAdapter } from "./hosts/cursor";
8
+ import { runAdapterHook } from "./process";
9
+ import type { HostAdapter, HostId } from "./protocol";
10
+
11
+ type ProcessHost = Exclude<HostId, "opencode" | "pi">;
12
+
13
+ export const HOOK_ADAPTERS: Record<ProcessHost, HostAdapter> = {
14
+ claude_code: claudeCodeAdapter,
15
+ codex_cli: codexAdapter,
16
+ codex_desktop: codexAdapter,
17
+ cursor: cursorAdapter,
18
+ };
19
+
20
+ type Sink = { write(chunk: string): unknown };
21
+
22
+ /** Runs one hook invocation and returns the process exit code. A broken
23
+ * payload never throws: the host's fail policy decides what is written. Past
24
+ * the deadline the host gets a minimal result naming the skip. */
25
+ export const runHookProcess = (
26
+ host: ProcessHost | HostAdapter,
27
+ stdin: AsyncIterable<unknown> | Iterable<unknown>,
28
+ stdout: Sink,
29
+ stderr: Sink = process.stderr,
30
+ env: NodeJS.ProcessEnv = process.env,
31
+ deadline: HookDeadline = {},
32
+ ): Promise<number> =>
33
+ typeof host === "string"
34
+ ? runAdapterHook(HOOK_ADAPTERS[host], stdin, stdout, stderr, env, deadline, host)
35
+ : runAdapterHook(host, stdin, stdout, stderr, env, deadline);
36
+
37
+ if (import.meta.main) {
38
+ const host = process.argv[2] as ProcessHost;
39
+ if (!Object.hasOwn(HOOK_ADAPTERS, host)) {
40
+ process.stderr.write(`usage: run.ts <${Object.keys(HOOK_ADAPTERS).join("|")}>\n`);
41
+ process.exitCode = 64;
42
+ } else {
43
+ process.exitCode = await runHookProcess(
44
+ host,
45
+ process.stdin,
46
+ process.stdout,
47
+ process.stderr,
48
+ process.env,
49
+ { startedAt: PROCESS_START },
50
+ );
51
+ // A payload that never ended must not keep the process alive past its answer.
52
+ process.stdin.destroy();
53
+ }
54
+ }
@@ -0,0 +1,151 @@
1
+ // The per-session nudge marker in the workspace store
2
+ // (`<store>/hooks/skills-<session hash>.json`): which skills a session was
3
+ // nudged about or loaded, and which raw-git nudge kinds it was shown, so each
4
+ // nudge fires once per session. Each hook is a fresh process, so the marker
5
+ // lives on disk. Every failure reads as an empty marker: hooks fail open.
6
+ import { createHash } from "node:crypto";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { resolveStore } from "../store/paths";
10
+ import type { HookInput } from "./protocol";
11
+
12
+ export type SessionMarker = {
13
+ nudged: string[];
14
+ loaded: string[];
15
+ raw: string[];
16
+ /** Stop obligations already raised, `<kind>:<head>`: each blocks once per head. */
17
+ stops: string[];
18
+ /** Verifier and reviewer subagents of this lead that started and have not stopped. */
19
+ judges: string[];
20
+ /** The last model the session reported (Codex #435): a change re-sends the contract. */
21
+ model: string | null;
22
+ };
23
+
24
+ /**
25
+ * The marker in the workspace's store (null outside one: nothing is nudged or
26
+ * recorded there). `store` names the store directly when the command acts in
27
+ * another checkout than the hook's cwd.
28
+ */
29
+ export const sessionMarkerFile = (
30
+ input: HookInput,
31
+ session: string,
32
+ store?: string,
33
+ ): string | null => {
34
+ let dir = store;
35
+ if (dir === undefined) {
36
+ const location = resolveStore(input.cwd);
37
+ if (location instanceof Error || !location.shared) return null;
38
+ dir = location.dir;
39
+ }
40
+ const name = createHash("sha256").update(`${input.host}\0${session}`).digest("hex").slice(0, 32);
41
+ return path.join(dir, "hooks", `skills-${name}.json`);
42
+ };
43
+
44
+ const list = (value: unknown): string[] =>
45
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
46
+
47
+ const EMPTY: SessionMarker = {
48
+ nudged: [],
49
+ loaded: [],
50
+ raw: [],
51
+ stops: [],
52
+ judges: [],
53
+ model: null,
54
+ };
55
+
56
+ const readRaw = (file: string): string => {
57
+ try {
58
+ return fs.readFileSync(file, "utf8");
59
+ } catch {
60
+ return "";
61
+ }
62
+ };
63
+
64
+ const parseMarker = (text: string): SessionMarker => {
65
+ try {
66
+ const value = JSON.parse(text) as Record<string, unknown>;
67
+ return {
68
+ nudged: list(value.nudged),
69
+ loaded: list(value.loaded),
70
+ raw: list(value.raw),
71
+ stops: list(value.stops),
72
+ judges: list(value.judges),
73
+ model: typeof value.model === "string" ? value.model : null,
74
+ };
75
+ } catch {
76
+ return { ...EMPTY };
77
+ }
78
+ };
79
+
80
+ export const readSessionMarker = (file: string): SessionMarker => parseMarker(readRaw(file));
81
+
82
+ /** Write through a temp file in the same directory and rename: a reader never sees half a marker. */
83
+ const writeAtomic = (file: string, marker: SessionMarker): void => {
84
+ fs.mkdirSync(path.dirname(file), { recursive: true });
85
+ const temp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
86
+ try {
87
+ fs.writeFileSync(temp, JSON.stringify(marker));
88
+ fs.renameSync(temp, file);
89
+ } catch (error) {
90
+ fs.rmSync(temp, { force: true });
91
+ throw error;
92
+ }
93
+ };
94
+
95
+ export const writeSessionMarker = (file: string, marker: SessionMarker): void => {
96
+ try {
97
+ writeAtomic(file, marker);
98
+ } catch {
99
+ // Fail open: without a marker a later nudge repeats.
100
+ }
101
+ };
102
+
103
+ /**
104
+ * Read-modify-write: `change` maps the marker as read to the marker to keep.
105
+ * Another hook's rename between the read and ours is detected (the file's
106
+ * text changed) and `change` runs once more on the fresh marker, so an entry
107
+ * of a concurrent hook is not lost. Fails open like the rest.
108
+ */
109
+ export const updateSessionMarker = (
110
+ file: string,
111
+ change: (marker: SessionMarker) => SessionMarker,
112
+ ): void => {
113
+ try {
114
+ for (let attempt = 0; attempt < 2; attempt++) {
115
+ const before = readRaw(file);
116
+ const next = change(parseMarker(before));
117
+ if (attempt === 0 && readRaw(file) !== before) continue;
118
+ writeAtomic(file, next);
119
+ return;
120
+ }
121
+ } catch {
122
+ // Fail open: without a marker a later nudge repeats.
123
+ }
124
+ };
125
+
126
+ /** Record that a verifier or reviewer subagent of this lead started or stopped. */
127
+ export const trackJudge = (input: HookInput, agentId: string | null, running: boolean): void => {
128
+ const session = input.session.id;
129
+ const file = session && agentId ? sessionMarkerFile(input, session) : null;
130
+ if (!file || !agentId) return;
131
+ updateSessionMarker(file, (marker) => {
132
+ const rest = marker.judges.filter((id) => id !== agentId);
133
+ return { ...marker, judges: running ? [...rest, agentId] : rest };
134
+ });
135
+ };
136
+
137
+ /**
138
+ * Record the session's model and say whether it differs from the last one
139
+ * recorded (the hook is a fresh process each time). The first model is a
140
+ * start, not a switch. Fails open: no store or no model reads as no switch.
141
+ */
142
+ export const modelSwitchedOnDisk = (input: HookInput): boolean => {
143
+ const session = input.session.id;
144
+ const model = input.model;
145
+ const file = session && model ? sessionMarkerFile(input, session) : null;
146
+ if (!file || !model) return false;
147
+ const previous = readSessionMarker(file).model;
148
+ if (previous === model) return false;
149
+ updateSessionMarker(file, (marker) => ({ ...marker, model }));
150
+ return previous !== null;
151
+ };