The issue-pipeline workflow system: standards heal, manager crew, guard hooks, and the workflow skills, for any repo.

<h1 align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/assets/mark-dark.svg"><img src="docs/assets/mark.svg" height="28" alt=""></picture> workkit</h1>
workkit is a plugin for Claude Code, Anthropic's coding agent for the terminal.
claude plugin marketplace add ITW-Creative-Works/workkit
claude plugin install workkit@workkit
Then start Claude Code (claude) in any folder. The first session sees that setup has not run yet and Claude runs it for you, handing you only what you must do yourself.
Setup prints one line per step, so you can see what it did. It asks before anything big: creating your home repo (a private GitHub repo for work that belongs to no single project), minting a Claude token, publishing the dashboard, and turning workkit on for the repo you are standing in. It is safe to run again: every step checks first and only fixes what is missing.
When it finishes, the workkit command is in ~/.local/bin (setup prints the PATH line to add if that folder is not on it). Setup also links a gh shim there: inside a Claude session it posts the agent's issue bookkeeping as workkit-agent[bot], and everywhere else gh is yours as before. For that you sign in once per machine (setup asks, opens GitHub in your browser and finishes the sign-in itself; workkit setup --login does it again, and a bookkeeping call at a terminal signs you in itself), and the App must be installed once per account or org on the repos workkit works in, from <https://github.com/apps/workkit-agent> (setup offers that page for the repo you are standing in); without either, the agent's bookkeeping stops and tells you which (docs/setup.md § The bot identity). workkit doctor shows what is set up, and workkit help lists every command. Start a new Claude Code session so the plugin loads. The skills run bash ~/.claude/workkit/..., node ~/.claude/workkit/... and gh issue ... often; to skip most of those permission prompts, add Bash(bash ~/.claude/workkit/*), Bash(node ~/.claude/workkit/*) and Bash(gh issue *) to permissions.allow in your ~/.claude/settings.json (workkit writes nothing there). The review-marker calls spell the path through CLAUDE_PLUGIN_ROOT, so those still ask once per batch.
To work on workkit itself, install from a clone instead; the clone then outranks the plugin copy:
git clone https://github.com/ITW-Creative-Works/workkit.git
cd workkit
./workflow/workkit.sh setup
Every setup step, and how to turn workkit on in another repo: docs/setup.md.
Turn workkit on in a project of yours, then open Claude Code there:
cd <your-project> && workkit enable
claude
Type /workkit:status for a plain-language list of the open issues and what each one is waiting on. No issues yet? Tell Claude what you want built, and it files the first one.
Then type work on #12, with a real issue number. Claude checks the issue has an accepted spec, and interviews you to write one if not. Then it builds the change with tests, has it reviewed, and leaves it in your working tree for you to try.
When you are happy, say ship.
Every piece of work travels one road, and each stop is a label on its GitHub issue (status:inbox, status:specced, and so on).
workkit note "the thought" in any shell. It lands in the inbox./workkit:triage routes each new issue: shape it now, keep it for later, or close it.ship. Claude writes the changelog entry, commits, releases, and closes the issue.Hooks guard each step on their own. For example, no code commits until the full test suite has passed on exactly what is being committed. A repo that has not turned workkit on gets none of its hooks. The rules for every stop: docs/project-state.md.
The dashboard shows the board of every repo in one place, and it comes in three tiers. Use whichever suits you, and switch any time.
<login>/workkit) from the token.workkit tower runs it on your own machine, where it also shows the running agents, token spend and repo health.workkit publish puts it on your home repo's GitHub Pages, for a custom domain or full control.The token, and what each tier needs: docs/setup.md.
| Part | Count | What it does |
|---|---|---|
| Hooks | 29 | Run by themselves: at session start, before edits and commits, and when a reply ends. All of them run inside Claude Code as one module |
| Agents | 5 | The crew your chat delegates to, each briefed from a role template in briefs/ |
| Skills | 9 | Procedures Claude follows when your words match, or when you type /workkit:<name> |
| Dashboard | 7 pages | workkit tower opens a local view of every board, the running agents, token spend, and repo health |
| Daily brief | 1 job | A 9am summary of every repo, posted as a GitHub Discussion on your home repo |
workkit:feature · workkit:interview · workkit:diagnose · workkit:review · workkit:triage · workkit:status · workkit:checkpoint · workkit:migrate · workkit:ship. Each is one SKILL.md of bullets.
gh), signed in with gh auth loginpackage.json names the floor)claude)It runs on macOS, on Linux, and on Windows under Git Bash. The 9am schedule on your own machine is macOS only; everywhere else the same brief runs in the cloud from your home repo.
workkit commandFunctional Source License, Version 1.1, MIT Future License (FSL-1.1-MIT). Use it, change it, share it, and run it inside your own work freely; do not offer it as a competing product or service. Each release becomes MIT two years after it ships.
hooks/register.js 618 lines1/**
2 * hooks/register.js: the workkit hooks module, the twenty-nine in-process
3 * hooks on nine events (docs/hooks.md § The hooks module). The one file that
4 * touches `$`: each hook's logic is plain functions in hooks/mods/<name>.js,
5 * and this file gathers their facts (or hands a mods gatherer the engine calls
6 * as plain functions), gates them, logs each fire and answers.
7 */
8
9import { addonContext, addonPath } from './mods/addon.js';
10import { boardGuardVerdict } from './mods/board-guard.js';
11import { changeTrackerGather } from './mods/change-tracker.js';
12import { changelogGuardVerdict, isChangelog, linterPath } from './mods/changelog-guard.js';
13import { checkpointContext } from './mods/checkpoint.js';
14import { closeGuardGather } from './mods/close-guard.js';
15import { featureContext, featureMatch } from './mods/feature.js';
16import { isExempt } from './mods/gate.js';
17import { BASH_GUARDS, FILE_GUARDS, GUARDS_FAILED, POST_BASH_GUARDS, READ_GUARDS, folderOpted, runChain } from './mods/guards.js';
18import { appendLog, homeOf, logLine, pad, userDirOf } from './mods/log.js';
19import { crewClass, isAdvise, managerConfig } from './mods/manager.js';
20import { sha1Hex, shellEnvOf, windowsKit } from './mods/markers.js';
21import { profileContext } from './mods/profile.js';
22import { reloadFingerprint, reloadGuardContext, surfacePaths } from './mods/reload-guard.js';
23import { resolverAdvice, resolverModel } from './mods/resolver.js';
24import { sessionGather } from './mods/session.js';
25import { sessionGuardVerdict } from './mods/session-guard.js';
26import { spawnGuardWarning } from './mods/spawn-guard.js';
27import { standardsGather } from './mods/standards.js';
28import { inboxCount, stateCheckContext } from './mods/state-check.js';
29import { testBarContext } from './mods/test-bar.js';
30import { isCodePath, isTestPath, testReminderContext } from './mods/test-reminder.js';
31
32// Each hook as settings and the hooks log name it.
33const HOOK = {
34 boardGuard: 'docs:board-guard',
35 changelogGuard: 'docs:changelog-guard',
36 sessionGuard: 'docs:session-guard',
37 testReminder: 'safety:test-reminder',
38 testBar: 'safety:test-bar',
39 stateCheck: 'docs:state-check',
40 session: 'docs:session',
41 standards: 'workflow:standards',
42 reloadGuard: 'workflow:reload-guard',
43 profile: 'manager:profile',
44 checkpoint: 'docs:checkpoint',
45 feature: 'workflow:feature',
46 addon: 'manager:addon',
47 resolver: 'manager:resolver',
48 spawnGuard: 'manager:spawn-guard',
49 closeGuard: 'manager:close-guard',
50 changeTracker: 'docs:change-tracker',
51};
52
53const GH_TIMEOUT_MS = 5000;
54const INBOX_CACHE_MS = 30 * 60 * 1000;
55
56// A path's folder; a drive root keeps its slash (`C:/`), so git never runs drive-relative.
57const dirOf = (path) => {
58 const cut = path.lastIndexOf('/');
59 if (cut <= 0) return '/';
60 return /^[A-Za-z]:$/.test(path.slice(0, cut)) ? path.slice(0, cut + 1) : path.slice(0, cut);
61};
62const verdictOf = (hook, opted) => (isExempt(hook) || opted ? 'run' : 'stood-down');
63const storeKey = (sid, name) => `workkit:${sid}:${name}`;
64const failedRun = (err) => ({ exitCode: 1, stdout: '', stderr: String(err?.message ?? err), isStarted: false });
65const quiet = (promise) => promise.catch(() => undefined);
66
67// The written file, absolute and in one spelling, every backslash a slash (as
68// optin.js folds it), so the mods see `C:/...` on Windows too; a
69// relative one is the session directory's.
70const filePathOf = (e) => {
71 const path = e.tool_input?.file_path?.replace(/\\/g, '/');
72 if (!path) return null;
73 return path.startsWith('/') || /^[A-Za-z]:\//.test(path) ? path : `${String(e.cwd ?? '').replace(/\\/g, '/')}/${path}`;
74};
75
76// Our contexts lead what the hooks beneath added; the result passes on whole.
77const withContext = (result, contexts) => (contexts.length === 0
78 ? result
79 : { ...result, additionalContext: [...contexts, ...(result?.additionalContext ?? [])] });
80
81// Every variable the module reads, once per dispatch, each folder the shell
82// spelled translated to the one it names; an unreadable one is unset.
83async function envOf($) {
84 const [
85 HOOK_DISABLE, WORKFLOW_HOME, HOME, USERPROFILE, TMPDIR, TMP, TEMP, LOCALAPPDATA, WORKFLOW_DIR, WORKFLOW_CLAUDE_HOME,
86 STATE_CHECK_CACHE, WORKFLOW_STANDARDS_CACHE, RELOAD_GUARD_ROOT,
87 MANAGER_LADDER, MANAGER_USER_SETTINGS, MANAGER_DEBUG, MANAGER_CLOSE_EDITS,
88 ] = await Promise.all([
89 quiet($.env.get('HOOK_DISABLE')), quiet($.env.get('WORKFLOW_HOME')), quiet($.env.get('HOME')),
90 quiet($.env.get('USERPROFILE')), quiet($.env.get('TMPDIR')), quiet($.env.get('TMP')),
91 quiet($.env.get('TEMP')), quiet($.env.get('LOCALAPPDATA')), quiet($.env.get('WORKFLOW_DIR')),
92 quiet($.env.get('WORKFLOW_CLAUDE_HOME')), quiet($.env.get('STATE_CHECK_CACHE')),
93 quiet($.env.get('WORKFLOW_STANDARDS_CACHE')), quiet($.env.get('RELOAD_GUARD_ROOT')),
94 quiet($.env.get('MANAGER_LADDER')), quiet($.env.get('MANAGER_USER_SETTINGS')),
95 quiet($.env.get('MANAGER_DEBUG')), quiet($.env.get('MANAGER_CLOSE_EDITS')),
96 ]);
97 return shellEnvOf({
98 HOOK_DISABLE, WORKFLOW_HOME, HOME, USERPROFILE, TMPDIR, TMP, TEMP, LOCALAPPDATA, WORKFLOW_DIR, WORKFLOW_CLAUDE_HOME,
99 STATE_CHECK_CACHE, WORKFLOW_STANDARDS_CACHE, RELOAD_GUARD_ROOT,
100 MANAGER_LADDER, MANAGER_USER_SETTINGS, MANAGER_DEBUG, MANAGER_CLOSE_EDITS,
101 }, windowsKit($.plugin.root) ? 'win32' : 'posix');
102}
103
104async function readText($, path) {
105 try {
106 return await $.fs.read(path);
107 } catch {
108 return null;
109 }
110}
111
112async function exists($, path) {
113 try {
114 return await $.fs.exists(path);
115 } catch {
116 return false;
117 }
118}
119
120// The written file where it really is: a
121// linked file names its target's repo; a path not there yet keeps its spelling.
122async function physicalPath($, file) {
123 if (!file) return null;
124 const real = await realPath($, file);
125 return real ? real.replace(/\\/g, '/') : file;
126}
127
128async function realPath($, path) {
129 try {
130 return (await $.fs.stat(path, { resolve: true })).realPath ?? null;
131 } catch {
132 return null;
133 }
134}
135
136// A command that cannot start, or outlives its timeout, reads as a failed run.
137async function run($, argv, init) {
138 try {
139 return await $.process.run(argv, init);
140 } catch (err) {
141 return failedRun(err);
142 }
143}
144
145async function gitTop($, cwd) {
146 if (!cwd) return null;
147 const top = await run($, ['git', 'rev-parse', '--show-toplevel'], { cwd });
148 return top.exitCode === 0 ? top.stdout.trim() || null : null;
149}
150
151async function now($) {
152 try {
153 return await $.clock.now();
154 } catch {
155 return Date.now();
156 }
157}
158
159async function sessionModel($) {
160 try {
161 return await $.session.model();
162 } catch {
163 return undefined;
164 }
165}
166
167// The session's own folder, '' when the engine cannot say: the quiet form, for
168// where no folder is a stand-down (the hooks log, agent.spawn, the Agent tool's
169// PreToolUse, test-reminder). The tool.call chains ask $.session.cwd() bare, so
170// a throw there fails them closed.
171async function sessionCwd($) {
172 try {
173 return await $.session.cwd();
174 } catch {
175 return '';
176 }
177}
178
179async function sessionId($, e) {
180 if (e.session_id || e.sessionId) return e.session_id || e.sessionId;
181 try {
182 return await $.session.id();
183 } catch {
184 return '';
185 }
186}
187
188async function storeGet($, sid, name) {
189 if (!sid) return undefined;
190 try {
191 return await $.store.get(storeKey(sid, name));
192 } catch {
193 return undefined;
194 }
195}
196
197async function storeKeys($) {
198 try {
199 return await $.store.keys();
200 } catch {
201 return [];
202 }
203}
204
205async function storeDrop($, key) {
206 try {
207 await $.store.delete(key);
208 } catch {
209 // A key left behind is dropped by nothing else; it costs only its bytes.
210 }
211}
212
213async function storeSet($, sid, name, value) {
214 if (!sid) return;
215 try {
216 await $.store.set(storeKey(sid, name), value);
217 } catch {
218 // A lost marker costs one repeated line, never the hook.
219 }
220}
221
222// Each hook fails open on its own: the error goes to the debug log and the
223// hook says nothing.
224async function caught($, hook, work) {
225 try {
226 return await work;
227 } catch (err) {
228 $.ui.log(`${hook}: ${err?.message ?? err}`, { to: 'debug' });
229 return null;
230 }
231}
232
233// The classic events whose input carries the session's folder as `cwd`.
234const CARRIES_CWD = new Set(['PostToolUse', 'SessionStart', 'UserPromptSubmit', 'SubagentStart', 'Stop']);
235
236// Every fire of a dispatch in the hooks log, the one writer every handler
237// calls: one line per fire (log.js's line and cap), the session and folder
238// asked once, then one read and one whole-file write, so two sessions writing
239// at once may lose a line (docs/hooks.md § The hooks log).
240async function logFires($, env, event, e, fires) {
241 if (fires.length === 0) return;
242 try {
243 const folder = CARRIES_CWD.has(event) ? e.cwd ?? '' : sessionCwd($);
244 const [sid, cwd, at] = await Promise.all([sessionId($, e), folder, now($)]);
245 const iso = new Date(at).toISOString().replace(/\.\d{3}Z$/, 'Z');
246 const path = `${userDirOf(env)}/hooks.log`;
247 const lines = fires.map(([hook, verdict]) => logLine(iso, hook, event, sid, cwd, verdict)).join('');
248 await $.fs.write(path, appendLog((await readText($, path)) ?? '', lines));
249 } catch {
250 // A write that fails never stops the hook.
251 }
252}
253
254async function configOf($, env, cwd) {
255 const ladder = env.MANAGER_LADDER || `${$.plugin.root}/hooks/manager/resources/ladder.json`;
256 const user = env.MANAGER_USER_SETTINGS || `${userDirOf(env)}/settings.json`;
257 const root = await gitTop($, cwd);
258 const [ladderText, userText, repoText] = await Promise.all([
259 readText($, ladder), readText($, user), root ? readText($, `${root}/.workkit/settings.json`) : null,
260 ]);
261 return managerConfig(ladderText, userText, repoText);
262}
263
264// The engine calls a mods gatherer makes, as plain functions: `$` stays here.
265const ioOf = ($, env) => ({
266 pluginRoot: $.plugin.root,
267 top: (cwd) => gitTop($, cwd), read: (path) => readText($, path), now: () => now($),
268 exists: (path) => exists($, path), real: (path) => realPath($, path), run: (argv, init) => run($, argv, init),
269 list: (path) => $.fs.list(path).then((entries) => entries.map((f) => f.name), () => []),
270 write: (path, text) => quiet($.fs.write(path, text)), sha1: sha1Hex, stat: (path) => $.fs.stat(path).catch(() => null),
271 config: (cwd) => configOf($, env, cwd), model: () => sessionModel($),
272 messages: () => $.session.messages(), log: (text) => $.ui.log(text),
273});
274
275async function changelogGuardHook($, env, file, text) {
276 if (!isChangelog(file) || text === null) return null;
277 const linter = linterPath(env, $.plugin.root);
278 if (!(await exists($, linter))) return null;
279 const lint = await run($, ['node', linter, file, '--added-only'], { cwd: dirOf(file) });
280 // No node to run the linter, or a run past its timeout: the guard stands aside.
281 return lint.isStarted === false ? null : changelogGuardVerdict(file, lint);
282}
283
284// A written file in the session's own repo as `{ root, rel }`, else null: the
285// gate test-reminder and test-bar share.
286async function repoRel($, e, file) {
287 if (!file || !(await exists($, file))) return null;
288 const dir = dirOf(file);
289 const [root, fileRoot] = await Promise.all([gitTop($, e.cwd || (await sessionCwd($))), gitTop($, dir)]);
290 if (!root || root !== fileRoot) return null;
291 const prefix = await run($, ['git', 'rev-parse', '--show-prefix'], { cwd: dir });
292 if (prefix.exitCode !== 0) return null;
293 return { root, rel: `${prefix.stdout.trim()}${file.split('/').pop()}` };
294}
295
296async function testReminderHook($, e, where) {
297 if (!where) return null;
298 const { root, rel } = where;
299 const seen = (await storeGet($, e.session_id, 'test-reminder')) ?? [];
300 if (!isCodePath(rel) || seen.includes(`${root}/${rel}`)) return null;
301 const listing = await run($, ['git', '-c', 'core.quotePath=false', 'ls-files', '--cached', '--others', '--exclude-standard'], { cwd: root });
302 const files = listing.exitCode === 0 ? listing.stdout.split('\n').filter(Boolean) : [];
303 const tests = {};
304 await Promise.all(files.filter(isTestPath).map(async (path) => {
305 tests[path] = (await readText($, `${root}/${path}`)) ?? '';
306 }));
307 const context = testReminderContext({ root, rel, files, tests }, seen);
308 if (context) await storeSet($, e.session_id, 'test-reminder', [...seen, `${root}/${rel}`]);
309 return context;
310}
311
312async function testBarHook(where, text) {
313 return where ? testBarContext({ ...where, text }) : null;
314}
315
316// The open inbox count; only silence is cached, about 30 minutes per repo,
317// since a count worth announcing can be drained at any moment.
318async function inboxOf($, env, cwd) {
319 if ((await run($, ['git', 'rev-parse', '--is-inside-work-tree'], { cwd })).exitCode !== 0) return 0;
320 const cache = `${env.STATE_CHECK_CACHE || `${homeOf(env)}/.claude/logs/state-check`}/${await sha1Hex(cwd)}`;
321 try {
322 if ((await now($)) - (await $.fs.stat(cache)).mtimeMs < INBOX_CACHE_MS && (await readText($, cache)) === '0') return 0;
323 } catch {
324 // No cache yet: ask.
325 }
326 const gh = await run($, ['gh', 'issue', 'list', '--state', 'open', '--label', 'status:inbox', '--json', 'number', '--limit', '1000'], { cwd, timeoutMs: GH_TIMEOUT_MS });
327 if (gh.exitCode !== 0 || !gh.stdout.trim()) return 0;
328 const count = inboxCount(gh.stdout);
329 try {
330 await $.fs.write(cache, count === 0 ? '0' : '');
331 } catch {
332 // An unwritten cache asks again next session.
333 }
334 return count;
335}
336
337async function stateCheckHook($, env, e) {
338 const { cwd } = e;
339 if (!cwd) return null;
340 const [inbox, capture, claude, agents] = await Promise.all([
341 inboxOf($, env, cwd), readText($, `${cwd}/.workkit/capture.md`),
342 readText($, `${cwd}/CLAUDE.md`), readText($, `${cwd}/AGENTS.md`),
343 ]);
344 const here = claude === null ? null : await realPath($, cwd);
345 const isUserClaude = here !== null && here === (await realPath($, `${homeOf(env)}/.claude`));
346 return stateCheckContext({ cwd, inbox, capture, claude, agents, isUserClaude });
347}
348
349// The load-time surfaces' fingerprint, digested: the files a session reads
350// once, this module's own among them.
351async function fingerprintOf($, env) {
352 const root = env.RELOAD_GUARD_ROOT || $.plugin.root;
353 const listed = async (dir) => {
354 try {
355 return await $.fs.list(`${root}/${dir}`);
356 } catch {
357 return [];
358 }
359 };
360 const [paths, hooksJson] = await Promise.all([surfacePaths(listed), readText($, `${root}/hooks/hooks.json`)]);
361 const surfaces = await Promise.all(paths.map(async (path) => {
362 try {
363 return { path, mtimeMs: (await $.fs.stat(`${root}/${path}`)).mtimeMs };
364 } catch {
365 return null;
366 }
367 }));
368 return sha1Hex(reloadFingerprint(hooksJson ?? '', surfaces.filter(Boolean)));
369}
370
371async function reloadStampHook($, env, e) {
372 if (!e.session_id) return null;
373 await storeSet($, e.session_id, 'reload-guard', { stamp: await fingerprintOf($, env) });
374 return null;
375}
376
377async function reloadPromptHook($, env, e) {
378 if (!e.session_id) return null;
379 const [held, fingerprint] = await Promise.all([storeGet($, e.session_id, 'reload-guard'), fingerprintOf($, env)]);
380 if (!held?.stamp) {
381 await storeSet($, e.session_id, 'reload-guard', { stamp: fingerprint });
382 return null;
383 }
384 const context = reloadGuardContext(held, fingerprint);
385 if (context) await storeSet($, e.session_id, 'reload-guard', { ...held, notified: fingerprint });
386 return context;
387}
388
389async function profileHook($, env, e) {
390 return profileContext(await configOf($, env, e.cwd), await sessionModel($));
391}
392
393async function checkpointHook($, e) {
394 const context = checkpointContext(e.prompt, await storeGet($, e.session_id, 'checkpoint'));
395 if (context) await storeSet($, e.session_id, 'checkpoint', await now($));
396 return context;
397}
398
399// Only a strong match spends the session's one load, so a false weak fire
400// (a port, a year) cannot silence a later `work on #N`.
401async function featureHook($, e) {
402 const context = featureContext(e.prompt, await storeGet($, e.session_id, 'feature'));
403 if (context && featureMatch(e.prompt) === 'strong') await storeSet($, e.session_id, 'feature', true);
404 return context;
405}
406
407async function addonHook($, env, e) {
408 const path = addonPath(userDirOf(env), e.agent_type);
409 if (!path || !(await exists($, path))) return null;
410 const text = await readText($, path);
411 if (text === null) {
412 $.ui.log(`manager:addon: ${path} exists but cannot be read; fix its permissions or remove it`);
413 return null;
414 }
415 return addonContext(text, e.agent_type, path);
416}
417
418async function spawnGuardHook($, env, e, cwd) {
419 const warning = spawnGuardWarning(await configOf($, env, cwd), e.subagent_type, e.model, await sessionModel($));
420 if (warning) $.ui.log(warning);
421 return warning;
422}
423
424async function resolverHook($, env, e, cwd) {
425 const config = await configOf($, env, cwd);
426 const model = resolverModel(config, e.subagentType, e.parentModel, e.model);
427 if (!model) return null;
428 if (env.MANAGER_DEBUG === '1') {
429 const at = new Date(await now($));
430 const line = `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())} class=${crewClass(e.subagentType)} mode=${isAdvise(config) ? 'advise' : 'rewrite'} model=${model} session=${e.parentModel || '?'}\n`;
431 const path = `${env.TMPDIR}/claude-manager-resolver.log`;
432 await quiet($.fs.write(path, `${(await readText($, path)) ?? ''}${line}`));
433 }
434 return { model, isAdvise: isAdvise(config) };
435}
436
437async function dropSessionKeys($, sid) {
438 if (!sid) return null;
439 const keys = await storeKeys($);
440 await Promise.all(keys.filter((key) => key.startsWith(storeKey(sid, ''))).map((key) => storeDrop($, key)));
441 return null;
442}
443
444// A hook that never refuses passes the event on when it fails.
445const passOn = ($, e, next) => next(e);
446const NOT_INPUT = new Set(['tool', 'tool_use_id', 'agentId']);
447// A guard chain that failed before its answer refuses, naming the failure's kind.
448const failClosed = ($, e, next) => (next.called ? next(e) : { deny: `${GUARDS_FAILED}${next.error.kind}` });
449
450// One guard chain (guards.js) over the facts `facts()` gathers: every fire
451// logged, a guard's throw rethrown for the .catch, the first deny answered,
452// else the event passed on with the notices as one entry and one owner line.
453async function guardChain($, e, next, event, guards, facts) {
454 const env = await envOf($);
455 if (env.HOOK_DISABLE === '1') return next(e);
456 const chain = await runChain(ioOf($, env), env, guards, await facts());
457 await logFires($, env, event === 'tool.call' ? 'PreToolUse' : event, e, chain.fires);
458 if (chain.error) throw chain.error;
459 if (chain.deny) return { deny: chain.deny };
460 if (!chain.notice) return next(e);
461 $.ui.log(chain.notice);
462 const result = await next(e);
463 return event === 'tool.call' ? { ...result, context: [chain.notice, ...(result?.context ?? [])] } : withContext(result, [chain.notice]);
464}
465
466// A file guard's facts. `tool.call` carries the input flat beside `tool`,
467// `tool_use_id` and `agentId`, and no folder: the cwd is the session's. An
468// Edit or Write is gated by the file's repo, a Read or Grep by the cwd's.
469// `file` is where the file really is, `typed` the path as the input spells it.
470async function fileFacts($, e) {
471 const input = Object.fromEntries(Object.entries(e).filter(([key]) => !NOT_INPUT.has(key)));
472 const cwd = await $.session.cwd();
473 const file = await physicalPath($, filePathOf({ tool_input: input, cwd }));
474 const dir = file ? dirOf(file) : cwd;
475 return { tool: e.tool, input, cwd, file, typed: filePathOf({ tool_input: input, cwd }), dir, gateDir: e.tool === 'Read' || e.tool === 'Grep' ? cwd : dir };
476}
477
478export function register(on) {
479 on('classic.PostToolUse', { tool_name: ['Edit', 'Write'] }, async ($, e, next) => {
480 const env = await envOf($);
481 if (env.HOOK_DISABLE === '1') return next(e);
482 const file = await physicalPath($, filePathOf(e));
483 const opted = await folderOpted(ioOf($, env), file ? dirOf(file) : e.cwd);
484 const fires = [];
485 let block = null;
486 const contexts = [];
487 if (!opted) {
488 for (const hook of [HOOK.boardGuard, HOOK.changelogGuard, HOOK.sessionGuard, HOOK.testReminder, HOOK.testBar]) fires.push([hook, 'stood-down']);
489 } else {
490 // In order: the first block wins and the hooks after it do not run.
491 const text = file ? await readText($, file) : null;
492 fires.push([HOOK.boardGuard, 'run']);
493 block = boardGuardVerdict(file, text, `${$.plugin.root}/docs/project-state.md`);
494 if (!block) {
495 fires.push([HOOK.changelogGuard, 'run']);
496 block = await caught($, HOOK.changelogGuard, changelogGuardHook($, env, file, text));
497 }
498 if (!block) {
499 fires.push([HOOK.sessionGuard, 'run']);
500 block = sessionGuardVerdict(file, text);
501 }
502 if (!block) {
503 // The two nudges share one repo read; neither blocks.
504 const where = await caught($, `${HOOK.testReminder}, ${HOOK.testBar} (repo read)`, repoRel($, e, file));
505 fires.push([HOOK.testReminder, 'run']);
506 contexts.push(await caught($, HOOK.testReminder, testReminderHook($, e, where)));
507 fires.push([HOOK.testBar, 'run']);
508 contexts.push(await caught($, HOOK.testBar, testBarHook(where, text)));
509 }
510 }
511 await logFires($, env, 'PostToolUse', e, fires);
512 const result = await next(e);
513 if (block) return { ...result, block: [block, result?.block].filter(Boolean).join('\n\n') };
514 return withContext(result, contexts.filter(Boolean));
515 }).catch(($, e, next) => (next.called
516 ? next(e)
517 : { block: 'workkit: the PostToolUse guards failed before they could judge this write; check the file by hand.' }));
518
519 on('classic.SessionStart', async ($, e, next) => {
520 const env = await envOf($);
521 if (env.HOOK_DISABLE === '1') return next(e);
522 const opted = await folderOpted(ioOf($, env), e.cwd);
523 // The reload stamp is taken only where the session loads its surfaces.
524 const stamps = e.source === 'startup' || e.source === 'resume';
525 const fires = [[HOOK.stateCheck, verdictOf(HOOK.stateCheck, opted)], [HOOK.standards, 'run']];
526 if (stamps) fires.push([HOOK.reloadGuard, 'run']);
527 fires.push([HOOK.session, verdictOf(HOOK.session, opted)]);
528 const contexts = await Promise.all([
529 opted ? caught($, HOOK.stateCheck, stateCheckHook($, env, e)) : null,
530 caught($, HOOK.standards, standardsGather(ioOf($, env), env, e)),
531 stamps ? caught($, HOOK.reloadGuard, reloadStampHook($, env, e)) : null,
532 opted ? caught($, HOOK.session, sessionGather(ioOf($, env), env, e)) : null,
533 ]);
534 await logFires($, env, 'SessionStart', e, fires);
535 return withContext(await next(e), contexts.filter(Boolean));
536 }).catch(passOn);
537
538 on('classic.UserPromptSubmit', async ($, e, next) => {
539 const env = await envOf($);
540 if (env.HOOK_DISABLE === '1') return next(e);
541 const opted = await folderOpted(ioOf($, env), e.cwd);
542 const fires = [HOOK.profile, HOOK.reloadGuard, HOOK.checkpoint, HOOK.feature].map((hook) => [hook, verdictOf(hook, opted)]);
543 const contexts = await Promise.all([
544 opted ? caught($, HOOK.profile, profileHook($, env, e)) : null,
545 caught($, HOOK.reloadGuard, reloadPromptHook($, env, e)),
546 opted ? caught($, HOOK.checkpoint, checkpointHook($, e)) : null,
547 opted ? caught($, HOOK.feature, featureHook($, e)) : null,
548 ]);
549 await logFires($, env, 'UserPromptSubmit', e, fires);
550 return withContext(await next(e), contexts.filter(Boolean));
551 }).catch(passOn);
552
553 on('classic.SubagentStart', { agent_type: /^workkit:/ }, async ($, e, next) => {
554 const env = await envOf($);
555 if (env.HOOK_DISABLE === '1') return next(e);
556 const opted = await folderOpted(ioOf($, env), e.cwd);
557 const context = opted ? await caught($, HOOK.addon, addonHook($, env, e)) : null;
558 await logFires($, env, 'SubagentStart', e, [[HOOK.addon, verdictOf(HOOK.addon, opted)]]);
559 return withContext(await next(e), context ? [context] : []);
560 }).catch(passOn);
561
562 on('classic.PreToolUse', { tool: ['Task', 'Agent'] }, async ($, e, next) => {
563 const env = await envOf($);
564 if (env.HOOK_DISABLE === '1') return next(e);
565 const cwd = await sessionCwd($);
566 const opted = await folderOpted(ioOf($, env), cwd);
567 const warning = opted ? await caught($, HOOK.spawnGuard, spawnGuardHook($, env, e, cwd)) : null;
568 await logFires($, env, 'PreToolUse', e, [[HOOK.spawnGuard, verdictOf(HOOK.spawnGuard, opted)]]);
569 return withContext(await next(e), warning ? [warning] : []);
570 }).catch(passOn);
571
572 // tool.call carries no cwd: the folder is the session's, or the subagent's in a subagent.
573 on('tool.call', { tool: 'Bash' }, ($, e, next) => guardChain($, e, next, 'tool.call', BASH_GUARDS,
574 async () => ({ command: e.command ?? '', cwd: await $.session.cwd() }))).catch(failClosed);
575
576 on('tool.call', { tool: ['Edit', 'Write'] }, ($, e, next) => guardChain($, e, next, 'tool.call', FILE_GUARDS, () => fileFacts($, e))).catch(failClosed);
577
578 on('tool.call', { tool: ['Read', 'Grep'] }, ($, e, next) => guardChain($, e, next, 'tool.call', READ_GUARDS, () => fileFacts($, e))).catch(failClosed);
579
580 // Never a block: a hook here acts after the command ran.
581 on('classic.PostToolUse', { tool_name: 'Bash' }, ($, e, next) => guardChain($, e, next, 'PostToolUse', POST_BASH_GUARDS,
582 async () => ({ command: e.tool_input?.command ?? '', cwd: e.cwd ?? '' }))).catch(passOn);
583
584 on('agent.spawn', async ($, e, next) => {
585 const env = await envOf($);
586 if (env.HOOK_DISABLE === '1') return next(e);
587 const cwd = await sessionCwd($);
588 const opted = await folderOpted(ioOf($, env), cwd);
589 await logFires($, env, 'agent.spawn', e, [[HOOK.resolver, verdictOf(HOOK.resolver, opted)]]);
590 // A resumed run or a fork keeps the model it has.
591 if (!opted || e.resume || e.fork) return next(e);
592 const decision = await caught($, HOOK.resolver, resolverHook($, env, e, cwd));
593 if (!decision) return next(e);
594 if (decision.isAdvise) return { deny: resolverAdvice(e.subagentType, decision.model) };
595 return next({ ...e, model: decision.model });
596 }).catch(($, e, next) => (next.called
597 ? next(e)
598 : { deny: 'manager:resolver failed before this spawn started; re-issue the Agent call.' }));
599
600 // close-guard's warning reaches the owner alone, since a Stop context would
601 // continue the turn; the change tracker's nag is a block, continuing it on purpose.
602 on('classic.Stop', async ($, e, next) => {
603 const env = await envOf($);
604 if (env.HOOK_DISABLE === '1') return next(e);
605 const opted = await folderOpted(ioOf($, env), e.cwd);
606 if (opted) await caught($, HOOK.closeGuard, closeGuardGather(ioOf($, env), env, e));
607 const nag = opted ? await caught($, HOOK.changeTracker, changeTrackerGather(ioOf($, env), env, e)) : null;
608 await logFires($, env, 'Stop', e, [HOOK.closeGuard, HOOK.changeTracker].map((hook) => [hook, verdictOf(hook, opted)]));
609 return nag ? next(e).then((r) => ({ ...r, block: [nag.block, r?.block].filter(Boolean).join('\n\n') })) : next(e);
610 }).catch(passOn);
611
612 on('session.end', async ($, e, next) => {
613 if ((await envOf($)).HOOK_DISABLE === '1') return next(e);
614 await caught($, 'session.end', dropSessionKeys($, e.sessionId));
615 return next(e);
616 });
617}
618hooks/mods/addon.js 30 lines1/**
2 * hooks/mods/addon.js: manager:addon's personal add-on, the text of the
3 * machine folder's agents/<name>.md stacked onto a `workkit:<name>` spawn
4 * (docs/agents.md § Personal add-ons).
5 */
6
7/**
8 * Where a spawn's add-on lives, or null for a type that is not `workkit:<name>`.
9 * @param {string} userDir the machine folder (userDir in ./log.js)
10 * @param {string} agentType the spawn's agent type
11 * @returns {string|null}
12 */
13export function addonPath(userDir, agentType) {
14 const match = /^workkit:(.+)$/.exec(String(agentType ?? ''));
15 return match ? `${userDir}/agents/${match[1]}.md` : null;
16}
17
18/**
19 * The added context for an add-on's text under its one-line header, or null
20 * for a missing or blank file, the normal case.
21 * @param {string|null|undefined} text the add-on file's text
22 * @param {string} agentType the spawn's agent type
23 * @param {string} path the add-on file's path
24 * @returns {string|null}
25 */
26export function addonContext(text, agentType, path) {
27 if (typeof text !== 'string' || text.trim() === '') return null;
28 return `Personal add-on for ${agentType} (from ${path}):\n${text.replace(/\n+$/, '')}`;
29}
30hooks/mods/board-guard.js 55 lines1/**
2 * hooks/mods/board-guard.js: docs:board-guard's verdict on an AGENTS.md write,
3 * and the budget measurement docs:state-check announces from too
4 * (docs/hooks.md § docs:board-guard).
5 */
6
7import { rowsOf } from './session-guard.js';
8
9export const AGENTS_MAX_LINES = 250;
10export const AGENTS_MAX_BYTES = 400;
11
12const encoder = new TextEncoder();
13// Either separator: a Windows path's backslashes read as slashes.
14const baseName = (path) => String(path ?? '').split(/[\\/]/).pop();
15
16/**
17 * An AGENTS.md text against its budget, counted as awk counts records and in
18 * BYTES, so one rule judges every platform alike.
19 * @param {string} text the file's text
20 * @returns {{lines: number, dense: number, named: string}} the line count, the
21 * lines over AGENTS_MAX_BYTES, and the first three as `line N (M bytes)`
22 */
23export function agentsBudget(text) {
24 const rows = rowsOf(text);
25 const offenders = [];
26 rows.forEach((row, i) => {
27 const bytes = encoder.encode(row).length;
28 if (bytes > AGENTS_MAX_BYTES) offenders.push(`line ${i + 1} (${bytes} bytes)`);
29 });
30 const more = offenders.length > 3 ? `, and ${offenders.length - 3} more` : '';
31 return { lines: rows.length, dense: offenders.length, named: `${offenders.slice(0, 3).join(', ')}${more}` };
32}
33
34/**
35 * The bounce for a write that leaves AGENTS.md past its budget, or null.
36 * @param {string} path the written file's path
37 * @param {string|null|undefined} text the file's text, null when it is gone
38 * @param {string} [spec] the spec the bounce names
39 * @returns {string|null}
40 */
41export function boardGuardVerdict(path, text, spec = 'docs/project-state.md') {
42 if (baseName(path) !== 'AGENTS.md' || typeof text !== 'string') return null;
43 const { lines, dense, named } = agentsBudget(text);
44 const violations = [];
45 if (lines > AGENTS_MAX_LINES) {
46 violations.push(`AGENTS BUDGET: ${lines} lines (max ${AGENTS_MAX_LINES}). AGENTS.md is the architectural overview; deep references move to docs/<topic>.md and AGENTS.md keeps a pointer line.`);
47 }
48 if (dense > 0) {
49 violations.push(`AGENTS DENSITY: ${named}. No line may exceed ${AGENTS_MAX_BYTES} bytes. A markdown paragraph is one source line, so AGENTS.md passes the ${AGENTS_MAX_LINES}-line budget while carrying a book. Bulletize those lines, or move the detail to docs/<topic>.md and keep a pointer here.`);
50 }
51 if (violations.length === 0) return null;
52 const list = violations.map((line) => ` - ${line}`).join('\n');
53 return `board-guard: AGENTS.md violates the document rules of the project-state spec v4 (spec: ${spec}). Fix these NOW, before any other work:\n${list}`;
54}
55hooks/mods/change-tracker.js 155 lines1/**
2 * hooks/mods/change-tracker.js: docs:change-tracker, the Stop nag that keeps
3 * the work item true, promotes durable findings out of `.workkit/` and checks
4 * doc parity while code sits uncommitted or captures sit unfiled; once per
5 * change, remembered by fingerprint in `.workkit/agents/.change-tracker`
6 * (docs/hooks.md § docs:change-tracker). It reads files and git, never gh.
7 */
8
9import { sha1Hex } from './markers.js';
10import { rowsOf } from './session-guard.js';
11import { findingsOn, settingsLayers } from './settings.js';
12import { CODE_EXT } from './test-reminder.js';
13
14const STATE_FILE = '.workkit/agents/.change-tracker';
15const PROMPT = 'hooks/mods/change-tracker/prompt.md';
16
17const FINDINGS_ON = 'FINDINGS: findings.file is on. File each finding outside the issue in hand as a `status:inbox` issue and print the `Filed:` trail for what you filed. If GitHub cannot be reached, put the finding in chat and stop there. The owner decides.';
18const FINDINGS_OFF = 'FINDINGS: findings.file is off. Build a finding that belongs to the issue in hand into it; say every other finding in chat under **🧭 Findings**, one bullet each in the cold-reader line, for the owner to answer file or drop. File nothing on your own.';
19const BOARD = "BOARD: PROGRESS.md still exists in this repo. Migrate it to issues (recipe: the workkit plugin's docs/project-state.md § Migration), then delete the file. Until that happens, keep it true this turn.";
20
21// What a command substitution keeps of a text: every trailing newline dropped.
22const chomp = (text) => text.replace(/\n+$/, '');
23
24/**
25 * The unfiled entries of a capture surface: lines that are not a heading, a
26 * quote or blank; 0 for a file that is not there.
27 * @param {string|null} text the file's text
28 * @returns {number}
29 */
30export function unfiledCount(text) {
31 return typeof text === 'string' ? rowsOf(text).filter((row) => !/^#|^>|^\s*$/.test(row)).length : 0;
32}
33
34// A porcelain line names code: a docs path is docs unless a code extension
35// wins; `.workkit/` and the docs basenames are docs wherever they sit.
36function namesCode(line) {
37 let path = line.slice(3);
38 if (path.includes(' -> ')) path = path.slice(path.lastIndexOf(' -> ') + 4);
39 path = path.replace(/"$/, '').replace(/^"/, '');
40 const base = path.split('/').filter(Boolean).pop() ?? '';
41 if (path === '.workkit' || path.startsWith('.workkit/')) return false;
42 if (/\.md$|^CHANGELOG$|^CHANGELOG\.|^LICENSE$|^LICENSE\./.test(base)) return false;
43 return CODE_EXT.test(base) || !(path.startsWith('docs/') || path.includes('/docs/'));
44}
45
46/**
47 * The block's reason when there is something to nag about (uncommitted code,
48 * unfiled entries, or both), else null: the gather asks it before it reads
49 * the diff, so a clean tree costs two reads.
50 * @param {string} porcelain `git status --porcelain`'s output
51 * @param {number} inbox INBOX.md's unfiled entries
52 * @param {number} capture `.workkit/capture.md`'s unfiled entries
53 * @returns {string|null}
54 */
55function nagReason(porcelain, inbox, capture) {
56 const code = chomp(porcelain).split('\n').some((line) => line !== '' && namesCode(line));
57 const unfiled = inbox > 0 || capture > 0;
58 if (code && unfiled) return 'Change tracker: uncommitted code changes + unfiled entries detected.';
59 if (code) return 'Change tracker: uncommitted code changes detected.';
60 return unfiled ? 'Change tracker: unfiled entries detected.' : null;
61}
62
63/**
64 * The change tracker's verdict on one Stop: the reason, the nag and the
65 * fingerprint of what it nags about, or null when there is nothing new.
66 * @param {object} facts `stopHookActive`; `porcelain` (`git status
67 * --porcelain`); `diff` (`git diff HEAD`, `git diff` with no HEAD);
68 * `untracked` (`[{ path, text }]`); `inbox` and `capture` (the unfiled
69 * counts); `previous` (the remembered fingerprint or null); `prompt` (the
70 * nag text); and for the lines after it `cwd`, `findings` (findings.file on),
71 * `board` (a PROGRESS.md left), `surfaces` (INBOX.md's then capture.md's text)
72 * @returns {Promise<{block: string, context: string, fingerprint: string}|null>}
73 */
74export async function changeTrackerVerdict(facts) {
75 const { stopHookActive, porcelain, diff, untracked, inbox, capture, previous, prompt } = facts;
76 if (stopHookActive) return null;
77 const block = nagReason(porcelain, inbox, capture);
78 if (!block) return null;
79 const sorted = [...untracked].sort((a, b) => (a.path < b.path ? -1 : Number(a.path > b.path)));
80 const fingerprint = await sha1Hex(`${chomp(porcelain)}\n${diff}${sorted.map((f) => f.text).join('')}${facts.surfaces ?? ''}`);
81 if (previous !== null && previous === fingerprint) return null;
82 const lines = [chomp(prompt), facts.findings ? FINDINGS_ON : FINDINGS_OFF];
83 if (facts.board) lines.push(BOARD);
84 if (inbox > 0) {
85 lines.push(`INBOX: ${inbox} unfiled entries in ${facts.cwd}/INBOX.md. Surface the count to the user and offer the workkit:triage skill. Filing stays deliberate: never empty the inbox as a side effect.`);
86 }
87 if (capture > 0) {
88 lines.push(`SCRATCH: ${capture} unfiled entries in ${facts.cwd}/.workkit/capture.md. Surface the count and offer the workkit:triage skill. Filing stays deliberate: never drain the local capture file as a side effect.`);
89 }
90 return { block, context: lines.join('\n\n'), fingerprint };
91}
92
93// Each untracked file's text, `git ls-files` relative to the folder; an unreadable one is left out.
94async function untrackedOf(io, cwd) {
95 const listing = await io.run(['git', 'ls-files', '--others', '--exclude-standard', '-z'], { cwd });
96 const paths = listing.exitCode === 0 ? listing.stdout.split('\0').filter(Boolean) : [];
97 const files = await Promise.all(paths.map(async (path) => ({ path, text: await io.read(`${cwd}/${path}`) })));
98 return files.filter((file) => file.text !== null);
99}
100
101// Whether this repo keeps the memory: a `.workkit/` folder that gitignores the
102// state file, so it is never a file the repo would be asked to commit.
103async function keepsMemory(io, cwd) {
104 if ((await io.stat(`${cwd}/.workkit`))?.kind !== 'dir') return false;
105 return (await io.run(['git', 'check-ignore', '-q', STATE_FILE], { cwd })).exitCode === 0;
106}
107
108/**
109 * The change tracker's Stop: gathers the tree, asks the verdict, writes the
110 * new fingerprint whole where the repo keeps the memory, and answers the
111 * block carrying the reason and the nag.
112 * @param {object} io the engine calls register.js hands over (`ioOf`)
113 * @param {object} env the variables the module read
114 * @param {object} e the Stop event's input
115 * @returns {Promise<{block: string}|null>}
116 */
117export async function changeTrackerGather(io, env, e) {
118 const { cwd } = e;
119 if (String(e.stop_hook_active) === 'true' || !cwd) return null;
120 const git = (args) => io.run(['git', ...args], { cwd });
121 if ((await git(['rev-parse', '--is-inside-work-tree'])).exitCode !== 0) return null;
122 const [status, inboxText, captureText] = await Promise.all([
123 git(['status', '--porcelain']), io.read(`${cwd}/INBOX.md`), io.read(`${cwd}/.workkit/capture.md`),
124 ]);
125 const porcelain = status.exitCode === 0 ? status.stdout : '';
126 const [inbox, capture] = [unfiledCount(inboxText), unfiledCount(captureText)];
127 if (!nagReason(porcelain, inbox, capture)) return null;
128 const promptPath = `${io.pluginRoot}/${PROMPT}`;
129 const prompt = await io.read(promptPath);
130 // The prompt is the plugin's own file: missing is a broken install, never a built-in string.
131 if (prompt === null) {
132 io.log(`change-tracker: prompt missing at ${promptPath}`);
133 return null;
134 }
135 const head = await git(['diff', 'HEAD']);
136 const memory = await keepsMemory(io, cwd);
137 const [diff, untracked, previous, board, layers] = await Promise.all([
138 head.exitCode === 0 ? head.stdout : git(['diff']).then((r) => (r.exitCode === 0 ? r.stdout : '')),
139 untrackedOf(io, cwd),
140 memory ? io.read(`${cwd}/${STATE_FILE}`).then((text) => (text === null ? null : chomp(text))) : null,
141 io.exists(`${cwd}/PROGRESS.md`),
142 settingsLayers(cwd, env, io),
143 ]);
144 const verdict = await changeTrackerVerdict({
145 stopHookActive: false, porcelain, diff, untracked, inbox, capture, previous, prompt,
146 cwd, findings: findingsOn(...layers), board, surfaces: `${inboxText ?? ''}${captureText ?? ''}`,
147 });
148 if (!verdict) return null;
149 if (memory) {
150 if ((await io.stat(`${cwd}/.workkit/agents`))?.kind !== 'dir') await io.run(['mkdir', '-p', `${cwd}/.workkit/agents`]);
151 await io.write(`${cwd}/${STATE_FILE}`, `${verdict.fingerprint}\n`);
152 }
153 return { block: `${verdict.block}\n\n${verdict.context}` };
154}
155hooks/mods/changelog-guard.js 40 lines1/**
2 * hooks/mods/changelog-guard.js: docs:changelog-guard's verdict on a CHANGELOG
3 * write, from the linter (workflow/changelog/changelog.js, run with
4 * `--added-only`), and the two reads safety:commit-gate check 3 shares: the
5 * CHANGELOG path test and the linter's path.
6 */
7
8/**
9 * Whether a path names a CHANGELOG.md, at the root or below.
10 * @param {string} path the path
11 * @returns {boolean}
12 */
13export function isChangelog(path) {
14 return /(^|[\\/])CHANGELOG\.md$/.test(String(path ?? ''));
15}
16
17/**
18 * The CHANGELOG linter: `<engine>/changelog/changelog.js`, the engine folder
19 * `WORKFLOW_DIR` names, else the plugin's own `workflow/`.
20 * @param {object} env the variables the module read
21 * @param {string} pluginRoot the plugin's root
22 * @returns {string}
23 */
24export function linterPath(env, pluginRoot) {
25 return `${env.WORKFLOW_DIR || `${pluginRoot}/workflow`}/changelog/changelog.js`;
26}
27
28/**
29 * The bounce for a write that added an entry out of format, or null: another
30 * file, no linter run (no node), or a clean lint.
31 * @param {string} path the written file's path
32 * @param {{exitCode: number, stderr?: string}|null|undefined} lintResult the
33 * linter's run, its findings on stderr
34 * @returns {string|null}
35 */
36export function changelogGuardVerdict(path, lintResult) {
37 if (!isChangelog(path) || !lintResult || lintResult.exitCode === 0) return null;
38 return `changelog-guard: fix the entry before any other work.\n${String(lintResult.stderr ?? '').replace(/\n+$/, '')}`;
39}
40hooks/mods/checkpoint.js 33 lines1/**
2 * hooks/mods/checkpoint.js: docs:checkpoint's instruction, fired by a line
3 * about compacting, clearing or restarting the chat, a delta after the first
4 * fire in a session (docs/hooks.md § docs:checkpoint).
5 */
6
7import { isSystemPrompt } from './frame.js';
8import { pad } from './log.js';
9
10// `compact` is a substring on purpose (`compaction`, `/compact`); `context`
11// fires only beside a follower on the same line, each bounded by a non-letter.
12const PATTERN = /compact|clear the chat|clear this chat|new chat|fresh session|start over|context.*(^|[^a-z])(full|low|running out|window)([^a-z]|$)|(running out of|out of|low on) context/;
13
14const FIRST = '[Compaction line detected (#238): run the workkit:checkpoint skill before anything else in this turn, then answer the line. It files every verdict, decision and question from this chat onto its issue and trims session.md, so nothing is lost when the context is compacted.]';
15
16/**
17 * The instruction for a prompt, or null when it is no compaction line or
18 * Claude Code delivered it itself.
19 * @param {string} prompt the prompt
20 * @param {number|string|Date|null|undefined} marker when the instruction was
21 * last issued this session, absent before the first fire
22 * @returns {string|null}
23 */
24export function checkpointContext(prompt, marker) {
25 if (!prompt || isSystemPrompt(prompt)) return null;
26 // Line by line, as grep reads it, so `context` and its follower share a line.
27 if (!String(prompt).toLowerCase().split('\n').some((line) => PATTERN.test(line))) return null;
28 if (marker === undefined || marker === null) return FIRST;
29 const at = new Date(marker);
30 const stamp = `${pad(at.getHours())}:${pad(at.getMinutes())}`;
31 return `[Compaction line detected (#238): the workkit:checkpoint instruction was already issued this session at ${stamp}. Run the skill again only for verdicts, decisions and questions spoken since then, as a delta, then answer the line.]`;
32}
33hooks/mods/close-guard.js 84 lines1/**
2 * hooks/mods/close-guard.js: manager:close-guard's warning, never a block,
3 * over the turn that just ended: rule 3, a frontier session making the edits
4 * itself with no worker; rule 4, a worker spawned with no verifier
5 * (docs/hooks.md § manager:close-guard).
6 */
7
8import { isSystemPrompt } from './frame.js';
9import { DEFAULT_TIERS, modelTier } from './manager.js';
10
11const DEFAULT_CLOSE_EDITS = 5;
12const EDITS = new Set(['Edit', 'Write']);
13const SPAWNS = new Set(['Task', 'Agent']);
14
15// An owner prompt: a user entry with text, carrying no tool results, that
16// Claude Code did not deliver itself.
17const isOwnerPrompt = (message, isSystemPrompt) => message.role === 'user'
18 && Boolean(message.text)
19 && !(message.toolResults?.length > 0)
20 && !isSystemPrompt(message.text);
21
22/**
23 * The turn's counts: every entry after the last owner prompt; none in view is
24 * an empty turn.
25 * @param {object[]} messages the transcript, the session's SessionMessage rows
26 * @param {(text: string) => boolean} isSystemPrompt the frame test (mods/frame.js)
27 * @returns {{edits: number, workers: number, verifiers: number}}
28 */
29export function turnFacts(messages, isSystemPrompt) {
30 const facts = { edits: 0, workers: 0, verifiers: 0 };
31 const start = messages.findLastIndex((message) => isOwnerPrompt(message, isSystemPrompt));
32 if (start === -1) return facts;
33 for (const message of messages.slice(start + 1)) {
34 for (const use of message.toolUses ?? []) {
35 if (EDITS.has(use.tool)) facts.edits += 1;
36 if (!SPAWNS.has(use.tool)) continue;
37 const type = String(use.input?.subagent_type ?? '?').replace(/^workkit:/, '');
38 if (type === 'worker') facts.workers += 1;
39 if (type === 'verifier') facts.verifiers += 1;
40 }
41 }
42 return facts;
43}
44
45/**
46 * The warning for a turn, or null when there is nothing to say.
47 * @param {{edits: number, workers: number, verifiers: number}} facts the turn's counts (turnFacts)
48 * @param {object} options the session's reading
49 * @param {string|null} options.tier the session model's family (modelTier)
50 * @param {string} [options.frontier] the frontier rung, `fable` when unset
51 * @param {number|string} [options.closeEdits] MANAGER_CLOSE_EDITS; anything but digits reads 5
52 * @returns {string|null}
53 */
54export function closeGuardWarning(facts, { tier, frontier, closeEdits }) {
55 const bar = /^\d+$/.test(String(closeEdits ?? '')) ? Number(closeEdits) : DEFAULT_CLOSE_EDITS;
56 const warnings = [];
57 if (tier && tier === (frontier || DEFAULT_TIERS.frontier) && facts.edits >= bar && facts.workers === 0) {
58 warnings.push(`the frontier model made ${facts.edits} edits itself this turn with no worker spawn: implementation belongs with workkit:worker`);
59 }
60 if (facts.workers >= 1 && facts.verifiers === 0) {
61 warnings.push(`${facts.workers} worker spawn(s) this turn and no verifier: consider a verifier pass over the build`);
62 }
63 return warnings.length > 0 ? `manager:close-guard: ${warnings.join('; ')}.` : null;
64}
65
66/**
67 * The hook: the turn read from the session and its warning logged, never a
68 * block or a context. A stop already continuing for a Stop hook was judged once.
69 * @param {object} io the engine calls as plain functions: `config(cwd)` (the
70 * effective manager config), `model()`, `messages()`, `log(text)`
71 * @param {object} env the variables the module read, for MANAGER_CLOSE_EDITS
72 * @param {object} e the Stop input
73 * @returns {Promise<void>}
74 */
75export async function closeGuardGather(io, env, e) {
76 if (e.stop_hook_active) return;
77 const config = await io.config(e.cwd);
78 if (config.enabled === false) return;
79 const [model, messages] = await Promise.all([io.model(), io.messages()]);
80 const facts = turnFacts(messages, isSystemPrompt);
81 const warning = closeGuardWarning(facts, { tier: modelTier(model), frontier: config.tiers?.frontier, closeEdits: env.MANAGER_CLOSE_EDITS });
82 if (warning) io.log(warning);
83}
84hooks/mods/feature.js 40 lines1/**
2 * hooks/mods/feature.js: workflow:feature's instruction, fired by a line about
3 * working an issue, once per session after a strong match
4 * (docs/hooks.md § workflow:feature).
5 */
6
7import { isSystemPrompt } from './frame.js';
8
9// STRONG: `#N` or a whole-word queue phrase (`presume` is not `resume`). WEAK:
10// a bare 2 to 4 digits bounded by non-alphanumerics, maybe a port or a year.
11const STRONG = /#[0-9]+|(^|[^a-z])(work on|work the|do issue|the issues|next issue|what next|what'?s next|what'?s left|continue|resume|keep going|what now|next one|go now|lets do|let's do|finish up|task list|the queue|the batch|open items|the board)([^a-z]|$)/;
12const WEAK = /(^|[^a-z0-9])[0-9]{2,4}([^a-z0-9]|$)/;
13
14const CONTEXT = '[Issue work detected (#374): load the workkit:feature skill before working the issue.]';
15
16/**
17 * How strongly a prompt reads as issue work: `strong`, `weak`, or null for no
18 * match and for a prompt Claude Code delivered itself.
19 * @param {string} prompt the prompt
20 * @returns {'strong'|'weak'|null}
21 */
22export function featureMatch(prompt) {
23 if (!prompt || isSystemPrompt(prompt)) return null;
24 const lines = String(prompt).replaceAll('’', "'").toLowerCase().split('\n');
25 if (lines.some((line) => STRONG.test(line))) return 'strong';
26 return lines.some((line) => WEAK.test(line)) ? 'weak' : null;
27}
28
29/**
30 * The instruction for a prompt, or null for no match, or once a strong match
31 * has set the session's marker.
32 * @param {string} prompt the prompt
33 * @param {boolean|undefined} marker whether a strong match fired this session
34 * @returns {string|null}
35 */
36export function featureContext(prompt, marker) {
37 if (marker) return null;
38 return featureMatch(prompt) ? CONTEXT : null;
39}
40hooks/mods/gate.js 31 lines1/**
2 * hooks/mods/gate.js: the opt-in rule the module's gate applies: a repo opted
3 * in when its .workkit/settings.json exists and does not hold
4 * `"enabled": false`: the engine's wk_settings_declined, turned round.
5 */
6
7// The hooks that run in every repo, opted in or not: each speaks to the
8// machine (the setup reminder, the restart notice), never to a repo.
9const EXEMPT = new Set(['workflow:standards', 'workflow:reload-guard']);
10
11/**
12 * Whether a repo's settings text opts it in; a file with no `enabled` key is
13 * the legacy yes, so a declined file is the only no.
14 * The module's one opt-in reader, the engine's no-jq text read (wk_settings_declined).
15 * @param {string|null|undefined} text the settings file's text, null when absent
16 * @returns {boolean}
17 */
18export function settingsOptedIn(text) {
19 if (typeof text !== 'string') return false;
20 return !/"enabled"[^\S\n]*:[^\S\n]*false/.test(text);
21}
22
23/**
24 * Whether a hook runs whatever the repo decided.
25 * @param {string} hook the hook as settings name it (`workflow:standards`)
26 * @returns {boolean}
27 */
28export function isExempt(hook) {
29 return EXEMPT.has(hook);
30}
31hooks/mods/guards.js 161 lines1/**
2 * hooks/mods/guards.js: the guard chains the hooks module runs, each an
3 * ordered list of `{ hook, run }`, `run(facts)` async answering null,
4 * `{ deny }` or `{ context }`, and the one runner every chain shares: the
5 * gate, each guard in order, the first deny ending the chain, the notices
6 * joined (docs/hooks.md § The hooks module). It gathers through the engine
7 * calls register.js hands it as plain functions.
8 */
9
10import { captureGuardGuard } from './capture-guard.js';
11import { originless, repoSlug } from './command.js';
12import { commitGateGuard } from './commit-gate.js';
13import { commitLanguageGuard } from './commit-language.js';
14import { findingsGuardGuard } from './findings-guard.js';
15import { settingsOptedIn } from './gate.js';
16import { issueGuardGuard } from './issue-guard.js';
17import { shellEnvOf, windowsKit } from './markers.js';
18import { isOptinExempt, optinRepo, rootOf, rosterOf } from './optin.js';
19import { proofGuardGuard } from './proof-guard.js';
20import { releaseTakenGuard } from './release-taken.js';
21import { snapshotGuard } from './snapshot.js';
22import { specGuardGuard } from './spec-guard.js';
23import { suiteGuardGuard } from './suite-guard.js';
24import { treeGuardGuard } from './tree-guard.js';
25import { vendorGuardGuard } from './vendor-guard.js';
26
27/** The Bash guards on `tool.call`, run in the order listed here. */
28export const BASH_GUARDS = [
29 { hook: 'safety:commit-gate', run: commitGateGuard },
30 { hook: 'safety:commit-language', run: commitLanguageGuard },
31 { hook: 'safety:release-taken', run: releaseTakenGuard },
32 { hook: 'safety:tree-guard', run: treeGuardGuard },
33 { hook: 'safety:issue-guard', run: issueGuardGuard },
34 { hook: 'safety:proof-guard', run: proofGuardGuard },
35 { hook: 'safety:spec-guard', run: specGuardGuard },
36 { hook: 'safety:suite-guard', run: suiteGuardGuard },
37 { hook: 'safety:capture-guard', run: captureGuardGuard },
38 { hook: 'safety:findings-guard', run: findingsGuardGuard },
39];
40
41/** The guards on `tool.call` for Edit and Write. */
42export const FILE_GUARDS = [
43 { hook: 'safety:vendor-guard', run: vendorGuardGuard },
44 { hook: 'safety:capture-guard', run: captureGuardGuard },
45];
46
47/** The guards on `tool.call` for Read and Grep. */
48export const READ_GUARDS = [
49 { hook: 'safety:capture-guard', run: captureGuardGuard },
50];
51
52/** The hooks on `classic.PostToolUse` for Bash; they never block. */
53export const POST_BASH_GUARDS = [
54 { hook: 'workflow:snapshot', run: snapshotGuard },
55];
56
57/** The refusal a chain that failed before its answer leads with; the failure's kind follows. */
58export const GUARDS_FAILED = 'workkit: the guards failed before they could judge this call, so it did not run: ';
59
60// The opt-in gate for a Bash command (optin.js), its reads made through io.
61async function bashOpted(io, env, { command, cwd }) {
62 const { opted } = await optinRepo({
63 command,
64 cwd,
65 roster: await rosterOf(env, io.read),
66 toplevel: io.top,
67 settingsOf: (root) => io.read(`${root}/.workkit/settings.json`),
68 slugOf: (dir) => repoSlug(dir, io.run),
69 exists: io.exists,
70 });
71 return opted;
72}
73
74/**
75 * The opt-in gate for a folder: the repo holding it, climbed to the nearest
76 * folder that exists for a path not made yet (rootOf), judged by
77 * settingsOptedIn. The file chains and every other handler gate through it.
78 * @param {object} io the engine calls register.js hands over (`ioOf`)
79 * @param {string|undefined} dir the folder; none is a no
80 * @returns {Promise<boolean>}
81 */
82export async function folderOpted(io, dir) {
83 if (!dir) return false;
84 const root = await rootOf(dir, io.top, io.exists);
85 return root !== null && settingsOptedIn(await io.read(`${root}/.workkit/settings.json`));
86}
87
88// The variables as a guard reads them: each folder the one Git Bash means, so
89// every marker read and every machine-folder read lands where the shell wrote.
90const guardEnv = (io, env) => shellEnvOf(env, windowsKit(io.pluginRoot) ? 'win32' : 'posix');
91
92/**
93 * The Bash guards' facts: the command and its folder, the variables, the
94 * reads, a scratch write and the kit's folder; the origin is asked once, and
95 * only by a guard that needs it.
96 * @param {object} io the engine calls register.js hands over (`ioOf`)
97 * @param {object} env the variables the module read
98 * @param {{command: string, cwd: string}} call the command and its folder
99 * @returns {object}
100 */
101export function bashFacts(io, env, { command, cwd }) {
102 let origin = null;
103 return {
104 command,
105 cwd,
106 env: guardEnv(io, env),
107 originless: () => (origin ??= originless(cwd, io.run)),
108 exists: (rel) => io.exists(`${cwd}/${rel}`),
109 top: io.top,
110 read: io.read,
111 list: io.list,
112 run: io.run,
113 pluginRoot: io.pluginRoot,
114 stat: io.stat,
115 real: io.real,
116 write: io.write,
117 now: io.now,
118 };
119}
120
121/**
122 * Runs one chain over its facts: a Bash chain (facts carrying `command`) is
123 * gated by optinRepo, a file chain by the repo holding `facts.gateDir`, climbed
124 * to the nearest folder that exists. A guard
125 * outside an opted-in repo stands down unless it is exempt; a guard that
126 * throws ends the chain with the error, for the handler to rethrow.
127 * @param {object} io the engine calls register.js hands over (`ioOf`)
128 * @param {object} env the variables the module read
129 * @param {Array<{hook: string, run: function}>} guards the chain, in order
130 * @param {object} facts `{ command, cwd }` for Bash, `{ tool, input, cwd, file, typed, dir, gateDir }` for a file
131 * @returns {Promise<{fires: Array<[string, string]>, deny: string|null, notice: string|null, error: *}>}
132 * each fire's hook and verdict for the hooks log, the first deny, the notices as one entry, a throw
133 */
134export async function runChain(io, env, guards, facts) {
135 const chain = { fires: [], deny: null, notice: null, error: null };
136 if (guards.length === 0) return chain;
137 const isBash = typeof facts.command === 'string';
138 const opted = isBash ? await bashOpted(io, env, facts) : await folderOpted(io, facts.gateDir);
139 const gathered = isBash ? bashFacts(io, env, facts) : {
140 ...facts, env: guardEnv(io, env), read: io.read, run: io.run, top: io.top, stat: io.stat, now: io.now,
141 };
142 const notices = [];
143 for (const { hook, run } of guards) {
144 if (!opted && !isOptinExempt(hook)) {
145 chain.fires.push([hook, 'stood-down']);
146 continue;
147 }
148 let verdict;
149 try {
150 verdict = await run(gathered);
151 } catch (error) {
152 chain.fires.push([hook, 'crashed']);
153 return { ...chain, error };
154 }
155 chain.fires.push([hook, 'run']);
156 if (verdict?.deny) return { ...chain, deny: verdict.deny };
157 if (verdict?.context) notices.push(verdict.context);
158 }
159 return { ...chain, notice: notices.length > 0 ? notices.join('\n') : null };
160}
161hooks/mods/log.js 70 lines1/**
2 * hooks/mods/log.js: the hooks log line and its cap (docs/hooks.md § The
3 * hooks log), and the machine folder it lives in.
4 */
5
6const LOG_MAX_LINES = 10000;
7const LOG_KEEP_LINES = 5000;
8
9/**
10 * A clock field two digits wide (`07`), the shape every stamp here writes.
11 * @param {number} n the hour, minute, second, month or day
12 * @returns {string}
13 */
14export function pad(n) {
15 return String(n).padStart(2, '0');
16}
17
18/**
19 * The home folder: HOME, else USERPROFILE (Windows), else nothing.
20 * @param {object} env the variables the module read
21 * @returns {string}
22 */
23export function homeOf(env) {
24 return env.HOME || env.USERPROFILE || '';
25}
26
27/**
28 * The machine folder, wk_user_dir's rule: WORKFLOW_HOME, else ~/.workkit.
29 * @param {string|undefined} workflowHome WORKFLOW_HOME
30 * @param {string|undefined} home the home folder
31 * @returns {string}
32 */
33export function userDir(workflowHome, home) {
34 return workflowHome || `${home ?? ''}/.workkit`;
35}
36
37/**
38 * The machine folder for the variables the module read: userDir over
39 * WORKFLOW_HOME and homeOf.
40 * @param {object} env the variables the module read
41 * @returns {string}
42 */
43export function userDirOf(env) {
44 return userDir(env.WORKFLOW_HOME, homeOf(env));
45}
46
47/**
48 * One fire's line: `<iso>\t<hook>\t<event>\t<session_id>\t<cwd>\t<verdict>\n`.
49 * @param {string} iso the time, UTC, to the second
50 * @returns {string}
51 */
52export function logLine(iso, hook, event, sid, cwd, verdict) {
53 return `${iso}\t${hook}\t${event}\t${sid ?? ''}\t${cwd ?? ''}\t${verdict}\n`;
54}
55
56/**
57 * The log after `lines` are added: a log past 10,000 lines is first cut to its
58 * last 5,000.
59 * @param {string} text the log as it stands, '' when there is none
60 * @param {string} lines the lines to add, each ending in a newline
61 * @returns {string}
62 */
63export function appendLog(text, lines) {
64 const count = (text.match(/\n/g) ?? []).length;
65 if (count <= LOG_MAX_LINES) return `${text}${lines}`;
66 const rows = text.split('\n');
67 if (rows.at(-1) === '') rows.pop();
68 return `${rows.slice(-LOG_KEEP_LINES).join('\n')}\n${lines}`;
69}
70hooks/mods/manager.js 102 lines1/**
2 * hooks/mods/manager.js: the manager system's reads: the family a model id
3 * belongs to, a crew class by either spelling, and the effective config, three
4 * layers deep-merged (docs/hooks.md § manager:resolver).
5 */
6
7import { isPlain, parseJson } from './settings.js';
8
9const CLASSES = new Set(['scout', 'worker', 'verifier', 'advisor']);
10
11// The rungs a ladder with no tiers falls back to, as the shell reads default.
12export const DEFAULT_TIERS = { frontier: 'fable', workhorse: 'opus' };
13
14// A layer as the hook reads it: the parsed file, or its text; anything else,
15// or text that does not parse, adds nothing.
16const parsed = (layer) => (typeof layer === 'string' ? parseJson(layer) : layer);
17
18// jq's `*`: objects merge key by key, recursively; any other value replaces.
19const deepMerge = (base, over) => {
20 const out = { ...base };
21 for (const [key, value] of Object.entries(over)) {
22 out[key] = isPlain(out[key]) && isPlain(value) ? deepMerge(out[key], value) : value;
23 }
24 return out;
25};
26
27// The overridable slice of a settings file's `manager` block: `mode`,
28// `enabled` and the three tiers; `classes` and `ladder` stay global.
29const managerLayer = (settings) => {
30 const file = parsed(settings);
31 if (!isPlain(file)) return {};
32 const block = file.manager ?? {};
33 if (!isPlain(block)) return {};
34 const given = block.tiers ?? {};
35 if (!isPlain(given)) return {};
36 const tiers = {};
37 for (const name of ['frontier', 'workhorse', 'fast']) {
38 if (given[name] !== undefined && given[name] !== null) tiers[name] = given[name];
39 }
40 const layer = {};
41 if (block.mode !== undefined && block.mode !== null) layer.mode = block.mode;
42 if (block.enabled !== undefined && block.enabled !== null) layer.enabled = block.enabled;
43 if (Object.keys(tiers).length > 0) layer.tiers = tiers;
44 return layer;
45};
46
47/**
48 * The family of a model id (`fable`, `opus`, `sonnet`, `haiku`), a context
49 * suffix such as `[1m]` ignored.
50 * @param {string|undefined} id a model id or alias
51 * @returns {string|null} the family, null when unknown
52 */
53export function modelTier(id) {
54 const base = String(id ?? '').split('[')[0].toLowerCase();
55 return ['fable', 'opus', 'sonnet', 'haiku'].find((family) => base.includes(family)) ?? null;
56}
57
58/**
59 * The crew class a subagent type names, bare or `workkit:`-prefixed.
60 * @param {string|undefined} subagentType the spawn's agent type
61 * @returns {string|null} `scout`, `worker`, `verifier` or `advisor`, else null
62 */
63export function crewClass(subagentType) {
64 const name = String(subagentType ?? '').replace(/^workkit:/, '');
65 return CLASSES.has(name) ? name : null;
66}
67
68/**
69 * The effective config: the ladder, then the user's `manager` block, then the
70 * repo's, each beating the one before. A layer that cannot be read adds
71 * nothing; `enabled: false` stays in the result for the readers to honour.
72 * @param {object|string|null} ladder the ladder file, parsed or as text
73 * @param {object|string|null} userSettings the user settings file, parsed or as text
74 * @param {object|string|null} repoSettings the repo settings file, parsed or as text
75 * @returns {object}
76 */
77export function managerConfig(ladder, userSettings, repoSettings) {
78 const global = parsed(ladder);
79 const base = isPlain(global) ? global : {};
80 return deepMerge(deepMerge(base, managerLayer(userSettings)), managerLayer(repoSettings));
81}
82
83/**
84 * A rung's place in the ladder's key order, 0 the strongest.
85 * @param {object} config the effective config
86 * @param {string} rung the rung's name
87 * @returns {number} the place, -1 for a rung the ladder does not carry
88 */
89export function rungRank(config, rung) {
90 const ladder = isPlain(config.ladder) ? config.ladder : {};
91 return Object.keys(ladder).indexOf(rung);
92}
93
94/**
95 * Whether the config reads the advise mode; anything else is rewrite.
96 * @param {object} config the effective config
97 * @returns {boolean}
98 */
99export function isAdvise(config) {
100 return config.mode === 'advise';
101}
102