SLOPSHOPPER

workkit

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

newguardprocessagents
★ 4v0.82.0FSL-1.1-MITupdated 2026-10-24itw-creative-works/workkit
A shopper browsing a rack in a slop shop
README

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

Install

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.

First use

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.

How it works

Every piece of work travels one road, and each stop is a label on its GitHub issue (status:inbox, status:specced, and so on).

  1. Capture. An idea becomes an issue: from an issue form, a chat note, or workkit note "the thought" in any shell. It lands in the inbox.
  2. Triage. /workkit:triage routes each new issue: shape it now, keep it for later, or close it.
  3. Spec. You and Claude write down what done looks like. Accepting the spec is the go-ahead to build.
  4. Build. Your chat acts as a manager. It hands the work to helper agents: one writes the code, one writes the tests, and a third checks both without seeing how they were made.
  5. Check. The finished work waits, uncommitted, until you have tried it and said it is good.
  6. Ship. Say 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.

Seeing your board

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.

  • The central copy, out of the box. Open <https://itw-creative-works.github.io/workkit/> and paste a GitHub token once. That address is the kit's own GitHub Pages site for now, so it may move. The token stays in that browser. The site holds no data: it reads GitHub live, and finds your home repo (<login>/workkit) from the token.
  • The local tower. workkit tower runs it on your own machine, where it also shows the running agents, token spend and repo health.
  • Your own published copy. 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.

What is inside

PartCountWhat it does
Hooks29Run by themselves: at session start, before edits and commits, and when a reply ends. All of them run inside Claude Code as one module
Agents5The crew your chat delegates to, each briefed from a role template in briefs/
Skills9Procedures Claude follows when your words match, or when you type /workkit:<name>
Dashboard7 pagesworkkit tower opens a local view of every board, the running agents, token spend, and repo health
Daily brief1 jobA 9am summary of every repo, posted as a GitHub Discussion on your home repo

Skills

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.

Requirements

  • git
  • the GitHub CLI (gh), signed in with gh auth login
  • jq
  • Node.js with npm, 22.12 or newer (the root package.json names the floor)
  • the Claude Code CLI (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.

Docs

  • docs/setup.md: every install option, what setup does step by step, and the folder layout
  • docs/project-state.md: the rules: labels, capture and triage, specs, the proof, shipping
  • docs/agents.md: the crew, how big a crew each job gets, and how work is handed to it
  • docs/hooks.md: what each hook does, when it fires, and where it stands down
  • docs/tests.md: what a test must be worth and may cost, and the shape every repo's test runner keeps
  • workflow/README.md: the engine behind the workkit command
  • AGENTS.md: the architecture, for agent sessions

License

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

Source 46 files
hooks/register.js 618 lines
1/**
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}
618
hooks/mods/addon.js 30 lines
1/**
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}
30
hooks/mods/board-guard.js 55 lines
1/**
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}
55
hooks/mods/change-tracker.js 155 lines
1/**
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}
155
hooks/mods/changelog-guard.js 40 lines
1/**
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}
40
hooks/mods/checkpoint.js 33 lines
1/**
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}
33
hooks/mods/close-guard.js 84 lines
1/**
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}
84
hooks/mods/feature.js 40 lines
1/**
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}
40
hooks/mods/gate.js 31 lines
1/**
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}
31
hooks/mods/guards.js 161 lines
1/**
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}
161
hooks/mods/log.js 70 lines
1/**
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}
70
hooks/mods/manager.js 102 lines
1/**
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