SLOPSHOPPER

wfc-delivery

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

newtoastprocesstimer
A shopper browsing a rack in a slop shop
README

wfc-delivery

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.

How it loads

  • In workflow-core, as a stand-alone mod, wfc-delivery@skills-dir, in any session started at the repo's root.
  • In an adopter, through the workflow-core plugin's one entry module (#933), in any session started at the adopter's root that loads the plugin. Loaded that way, it runs the 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.

Switching it off

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:

  • In workflow-core, this line in .claude/settings.local.json turns the stand-alone load off:
  { "enabledPlugins": { "wfc-delivery@skills-dir": false } }
  • In an adopter, the mod loads with the plugin's guards, so the plugin's own line would turn those off too. An empty .claude/skills/wfc-delivery/ folder stops the hosted load alone, through the double-load guard above, and git never commits an empty folder.

Checking it

  • 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.

Source 1 files
hooks/register.ts 237 lines
1/*
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