Delivers this session's own CI and reap reports into it mid-session (#849)

A mod that brings a report this session's own start produced into the session while it runs, rather than leaving it for the next session start (#849), a release request against a file it holds at its next turn, rather than at its next write to that file (#855), and a sweep verdict that changes mid-session within about ten minutes of the run finishing (#856).
Every 30 s it stats the notify-reports store, the request register and the claim store. Only when a stat changes does it run scripts/deliver-to-session.js with that source's flag: --reports for the store, --requests for the other two. The script owns every rule of what is due; a request is told in the arbiter's own wording and once with its write path, within the hour. Every 10 minutes, beginning 10 minutes in, it runs --ci: one gh call for the newest run carrying a verdict, and only where that run differs from the last one this session saw, the full CI check with --recheck, which fires no sweep and leaves the delivery stamps to the session-start route. Non-empty output is appended once as a user row the model reads, written to the debug log, and toasted where a surface draws. It hooks session.start alone and never submits a prompt, so it skips no guard and wakes no idle session. The session-start replay is untouched: a report delivered early still shows at the next session start.
Tested on Claude Code 2.1.294. Floor: 2.1.289. Below it the mod starts nothing and says so once in the debug log.
wfc-delivery@skills-dir, in any session started at the repo's root.scripts/deliver-to-session.js the plugin ships, not one in the adopter, with the session's folder as its working folder, so the script finds the adopter's own stores. Where the session's folder also carries .claude/skills/wfc-delivery/, this load starts nothing and says so in the debug log, so the mod never runs twice.At session start it checks, in order, the Claude Code floor, then that double-load guard. Each one that stops it writes one debug-log line naming why.
No repo can switch it off. It tells the model what the session must act on, and CLAUDE.md's rule that a holder reads a release request at its next turn depends on it, so the committed mods key has no wfc-delivery switch.
The emergency off switch, for a mod that breaks a session, is one seat's own:
.claude/settings.local.json turns the stand-alone load off: { "enabledPlugins": { "wfc-delivery@skills-dir": false } }
.claude/skills/wfc-delivery/ folder stops the hosted load alone, through the double-load guard above, and git never commits an empty folder.claude plugin test .claude/skills/wfc-delivery: the mod's own tests. The ordinary vitest suite never collects them.claude plugin validate .claude/skills/wfc-delivery: must list session.start as the only hook. scripts/wfc-mods-validate.test.ts asserts it.npm run typecheck: its third pass type-checks the module and tests.The engine's types are an untracked copy (#1960): Claude Code's declarations carry no grant to redistribute them. The engine writes no .claude-plugin/types/ for a @skills-dir mod (#851, check (e)), so npm run typecheck copies the newest unpacked plugin-authoring skill's types/claude-code.d.ts to the gitignored .claude-plugin/types/claude-code/index.d.ts, and skips the mod's pass where no session has unpacked that skill.
The tests need Claude Code 2.1.293 or later (#896), above the mod's own floor. The pre-push gate (scripts/run-mod-tests.js) reads its floor from this bolded sentence and, below it, warns and skips the tests rather than refusing the push (#901); reword it and the gate loses the floor.
hooks/register.ts 237 lines1/*
2 * register.ts, the wfc-delivery mod — brings a report this session's own start produced into the
3 * session while it runs, rather than at the next session start (#849, #854),
4 * a release request against a file it holds at its next turn rather than
5 * its next write to that file (#855), and a sweep verdict that changes while
6 * it runs within about ten minutes (#856).
7 *
8 * A thin loop over scripts/deliver-to-session.js, which owns every rule of
9 * what is due: owner, allow-list, once per session, the arbiter's wording.
10 *
11 * Hooks `session.start` alone and never calls `$.prompt.submit`: it can
12 * neither skip a guard nor wake an idle session (#849 §Guard safety).
13 *
14 * EVERYTHING LIVES INSIDE `register`. The tests stand for the load through
15 * the workflow-core plugin by loading `register` again as an inline plugin,
16 * which carries the function's own text and nothing around it (#944).
17 */
18
19import type { Register } from "claude-code";
20
21export const register: Register = (on) => {
22 /* The build #851's go checks passed on; below it the mod is unproven and
23 * starts nothing (#849 user story 19). */
24 const versionFloor = [2, 1, 289];
25 // #849 §Guard safety.
26 const tickMs = 30_000;
27 /* "A few seconds" (#849 §Guard safety), a guess never measured: `--reports`
28 * reads files, and `--requests` bounds its arbiter run at 4 s. */
29 const runTimeoutMs = 5000;
30 /* #849 §CI re-check mid-session. The first run is 10 minutes in: the
31 * session-start check covers the minutes before. */
32 const ciIntervalMs = 10 * 60_000;
33 /* Above the script's own bounds, 8 s on its gate and 15 s on the check, with
34 * room for two node starts; a guess. */
35 const ciRunTimeoutMs = 30_000;
36 // A guess: enough to ride out a transient rejection, never measured.
37 const maxOffers = 3;
38 const modName = "wfc-delivery";
39 const scriptRelPath = "scripts/deliver-to-session.js";
40 const deliveryHeader =
41 "Delivered by the wfc-delivery mod: a report this session's own start produced, a release request against a file it holds, or a sweep verdict that changed since (#849).";
42
43 /* The claim store's directory changes whenever any session takes or gives up
44 * a claim, this one's among them; its claims are read only then (#855). */
45 const sources = [
46 { flag: "--reports", watch: ["notifyReports"] },
47 { flag: "--requests", watch: ["requests", "locks"] },
48 ] as const;
49 type Paths = Record<(typeof sources)[number]["watch"][number], string>;
50
51 /* The helpers COPIED names in scripts/wfc-mods-copies.test.ts are copied in
52 * the liveness mod, not shared: the tests' inline load, standing for the
53 * plugin's, carries `register`'s own text alone (#944), and the engine
54 * follows `$` across no import (#903 F3). That test fails where the copies
55 * differ; a helper copied later is held only once it joins COPIED. */
56
57 /** True when `version` is at or above versionFloor; an unparseable one is below. */
58 const isAtFloor = (version: string): boolean => {
59 const parts = /^(\d+)\.(\d+)\.(\d+)/.exec(version)?.slice(1).map(Number);
60 if (!parts) return false;
61 for (let i = 0; i < versionFloor.length; i++) {
62 const have = parts[i] ?? 0;
63 const need = versionFloor[i] ?? 0;
64 if (have !== need) return have > need;
65 }
66 return true;
67 };
68
69 const messageOf = (err: unknown) =>
70 err instanceof Error ? err.message : String(err);
71
72 // Matches every cwd, so one module may hook `session.start` twice (#903 F2).
73 on("session.start", { cwd: /./ }, async ($, e, next) => {
74 /* Closures over `$`, never helpers taking it: the engine follows `$`
75 * into a function declared at the top of the file alone. */
76 const debug = (text: string) =>
77 $.ui.log(`${modName}: ${text}`, { to: "debug" });
78
79 /** Why this load starts nothing — the floor, then the double-load guard
80 * (#944) — or undefined where it starts. The guard reads the folder
81 * alone, so a repo that also loads the plugin and turns the stand-alone
82 * copy off in its settings runs neither load. No repo-wide switch, since
83 * the mod tells the model what it must act on (#1467); an adopter's seat
84 * stops the hosted load only with an empty .claude/skills/wfc-delivery/. */
85 const whyNotStarted = async () => {
86 const { version } = await $.session.version();
87 if (!isAtFloor(version))
88 return `Claude Code ${version} is below ${versionFloor.join(".")}; not started`;
89 if (
90 $.plugin.name !== modName &&
91 (await $.fs.exists(`${e.cwd}/.claude/skills/${modName}`))
92 )
93 return `loaded through ${$.plugin.name}, but .claude/skills/${modName} here loads it stand-alone; not started`;
94 };
95
96 try {
97 const why = await whyNotStarted();
98 if (why !== undefined) {
99 debug(why);
100 return next(e);
101 }
102 // Hosted, the script is the one the host plugin ships (#933).
103 const root = $.plugin.name === modName ? e.cwd : $.plugin.root;
104 const script = `${root}/${scriptRelPath}`;
105 let paths: Paths | undefined;
106 // Per source, absent until its first stat, so the first tick forks each.
107 const stamps = new Map<string, string>();
108 /* Printed text not yet appended. The script's ledger already holds it,
109 * so it is offered from here, never printed again; dropped after
110 * maxOffers rejections, or lost to a reload or the session's end. A
111 * report is shown again by the next session start's replay (#854); a
112 * release request only once its said-once marker is swept, about an
113 * hour on; a CI re-check never this session (#891). */
114 let pending = "";
115 let offers = 0;
116 let isBusy = false;
117 /* Set by the CI interval, run by the next tick, so every fork and the
118 * pending row stay with one loop. */
119 let isCiDue = false;
120
121 const run = async (args: string[], timeoutMs = runTimeoutMs) => {
122 const ran = await $.process.run(["node", script, ...args], {
123 cwd: e.cwd,
124 env: { CLAUDE_CODE_SESSION_ID: await $.session.id() },
125 timeoutMs,
126 });
127 if (ran.exitCode !== 0)
128 throw new Error(
129 `${scriptRelPath} ${args.join(" ")} exited ${ran.exitCode}: ${ran.stderr.trim()}`,
130 );
131 return ran.stdout;
132 };
133
134 const storePaths = async (): Promise<Paths> => {
135 const printed = JSON.parse(await run(["--paths"]));
136 for (const { watch } of sources)
137 for (const key of watch)
138 if (typeof printed[key] !== "string")
139 throw new Error(`${scriptRelPath} --paths named no ${key}`);
140 return printed;
141 };
142
143 const deliver = async () => {
144 const names = (
145 pending.match(/^▸ \S+/gm)?.map((n) => n.slice(2)) ?? ["a report"]
146 ).join(", ");
147 offers++;
148 const appended = await $.session
149 .append({
150 message: {
151 type: "user",
152 content: [
153 { type: "text", text: `${deliveryHeader}\n\n${pending}` },
154 ],
155 },
156 })
157 .catch((err: unknown) => {
158 if (offers >= maxOffers) pending = "";
159 throw new Error(
160 `append of ${names} rejected (offer ${offers} of ${maxOffers}): ${messageOf(err)}`,
161 );
162 });
163 pending = "";
164 /* A plugin above refused the row: a ruling, so it is dropped rather
165 * than offered again (#854). */
166 if (appended.deny !== undefined) {
167 debug(`append of ${names} refused, dropped: ${appended.deny}`);
168 return;
169 }
170 debug(`delivered ${names}`);
171 if ((await $.session.surfaces()).length > 0)
172 $.ui.toast(`${names} delivered to this session`);
173 };
174
175 // A held row never stops a newer report being fetched.
176 const fetch = async ({ flag, watch }: (typeof sources)[number]) => {
177 paths ??= await storePaths();
178 const known = paths;
179 // -1 for a missing directory: nothing has written there yet.
180 const stamp = (
181 await Promise.all(
182 watch.map((key) =>
183 $.fs.stat(known[key]).then(
184 (s) => s.mtimeMs,
185 () => -1,
186 ),
187 ),
188 )
189 ).join();
190 if (stamp === stamps.get(flag)) return;
191 const out = (await run([flag])).trim();
192 stamps.set(flag, stamp);
193 hold(out);
194 };
195
196 const hold = (out: string) => {
197 if (!out) return;
198 pending = pending ? `${pending}\n\n${out}` : out;
199 offers = 0;
200 };
201
202 const failed = (err: unknown) =>
203 debug(`tick failed, retried next tick: ${messageOf(err)}`);
204
205 const tick = async () => {
206 if (isBusy) return;
207 isBusy = true;
208 // One source failing never starves the other.
209 for (const source of sources) await fetch(source).catch(failed);
210 /* Cleared first: a failed or timed-out run delivers nothing and waits
211 * for the next interval (#856). */
212 if (isCiDue) {
213 isCiDue = false;
214 await run(["--ci"], ciRunTimeoutMs)
215 .then((out) => hold(out.trim()))
216 .catch((err: unknown) =>
217 debug(
218 `CI re-check failed, retried next interval: ${messageOf(err)}`,
219 ),
220 );
221 }
222 if (pending) await deliver().catch(failed);
223 isBusy = false;
224 };
225
226 // `tick` catches its own failures, so nothing it returns is dropped.
227 $.clock.every(tickMs, tick);
228 $.clock.every(ciIntervalMs, () => {
229 isCiDue = true;
230 });
231 } catch (err) {
232 debug(`not started: ${messageOf(err)}`);
233 }
234 return next(e);
235 });
236};
237