Test harness for this plugin's mods: `claude plugin test mods`. Never installed; the plugin loads mods/hooks/register.ts through hooks/claude.json.

<img src="assets/kevin-avatar.jpg" alt="Kevin" width="180" />
Your personal AI assistant, as a Claude Code or Codex plugin. One markdown folder, one plugin, a brain that learns who you are session after session.
<a href="https://agentlayer.one/docs"><img src="https://img.shields.io/badge/Docs-agentlayer.one-5EFFA1.svg" alt="Documentation"/></a> <a href="./LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg" alt="License"/></a> <a href="https://docs.claude.com/en/docs/claude-code"><img src="https://img.shields.io/badge/Claude_Code-plugin-orange.svg" alt="Claude Code plugin"/></a> <a href="https://developers.openai.com/codex"><img src="https://img.shields.io/badge/Codex-plugin-black.svg" alt="Codex plugin"/></a> <a href="https://agentlayer.one/docs/about/platforms"><img src="https://img.shields.io/badge/macOS-tested-success.svg" alt="macOS tested"/></a> <a href="https://agentlayer.one"><img src="https://img.shields.io/badge/Made_by-AgentLayer-blueviolet.svg" alt="Made by AgentLayer"/></a>
Kevin is a portable, file-based personal AI assistant that plugs into the agent CLI you already use, Claude Code or OpenAI Codex. Everything that makes Kevin Kevin (personality, memory, knowledge, projects, tasks) lives in your own directory as plain markdown. Any AI can read it. You can browse it in Obsidian or Finder. If you ever want to leave, you take the folder and go.
It is not a chat wrapper. It is an operating system for personal AI:
graph LR
A[Sessions] -->|capture| B[Knowledge]
B -->|informs| C[Projects]
C -->|generate| D[Results]
D -->|feed back into| A
Every session is captured on exit. Captured sessions compile into a wiki. The wiki loads before you type a word in the next session. That loop is the whole product.
Kevin is named after the loyal minion. Helpful, enthusiastic, a little nerdy.
You need Bun ≥ 1.1, Git, and a host: Claude Code or Codex. Full prerequisites, the local clone install, and Windows notes are in Install.
Pick a home for the brain, then install the plugin from it:
mkdir -p ~/Documents/Agents/Kevin && cd ~/Documents/Agents/Kevin
| Claude Code | Codex |
|---|---|
claude, then /plugin marketplace add AgentLayer1/agentlayer-agent-marketplace and /plugin install agent-kevin@agentlayer | codex plugin marketplace add AgentLayer1/agentlayer-agent-marketplace then codex plugin add agent-kevin@agentlayer |
Relaunch and run /agent-kevin:init | Create the home from Claude Code, then open it with codex and run $upgrade once |
Skills: /agent-kevin:<skill> | Skills: $<skill> |
Five minutes of questions later you have a home. See Onboarding and Hosts.
Want a head start? The wizard turns eleven prompts about your company into a seed bundle; hand the zip to init and the agent wakes up named, characterised, and briefed. A teammate's seed export does the same from an existing agent. → Seed bundles
Always launch from the agent home. The plugin loads only for sessions started there, and that is also what keeps several agents on one machine apart. Reach your code through permissions.additionalDirectories in the home's .claude/settings.local.json (init writes it there, since the path is this machine's), not by launching from a repo.
This README is the short version. Everything lives at agentlayer.one/docs, also served as llms-full.txt for models.
| Section | Start with |
|---|---|
| Getting started | Install · Onboarding · Your first session · Updating |
| Dashboard | Today · Tasks and projects · Sessions · Brain · Reports and scheduler · Capabilities · Persona and system |
| Platform | The agent home · The brain · Capture · Sync · History · Self-evolution · Seed bundles · Multiple agents |
| Agent | Hosts · Claude Code · Codex · Hooks · Configuration · Tasks · Daily rhythm · Architecture |
| Modules | Plan and run · Build and ship · Reach and see · Brain and memory · Skills · MCP tools · Second-model review · Browser · SEO · Accounts |
| Engineering | The engineer skill · Principles · Design and review · Pull requests · Worktrees · Specs and plans · Coding rules · Verification · API collections · Releases |
| Reference | CLI · Upgrades · Naming · Changelog |
| Workstation | The rig: Ghostty · cmux · editor and tools |
| About | Privacy · Platforms · FAQ · History · Contributing |
<img src="assets/dashboard.png" alt="Kevin Agent OS dashboard" width="720" />
knowledge-compile skill distils them into user facets, concept articles, and active memory that load next launch. → The brainfocus skill keeps today to three, shows what carried over and what the roadmap has in flight, and the goals skill sets the day, week, month and year with you; one page for all your work and one per project. → Daily rhythmsync skill runs compile → lint → flywheel → dashboards and ends with a next move. → Synchistory skill turns on local version history for the home in one question, no git knowledge needed; sync saves a snapshot each run. A home in iCloud, Dropbox or OneDrive keeps its history in ~/.local/state (%LOCALAPPDATA% on Windows), where syncing can't damage it. → Historyengineer skill routes code work to a playbook (bug fix, feature, refactor, performance, forensics, prototype, and more) backed by 23 named principles, proves the result on the running artifact, and strips comments before you see the diff. → EngineeringPull the new plugin version on your host (/plugin update agent-kevin@agentlayer in Claude Code; remove and re-add in Codex), then run the upgrade skill. The plugin update refreshes code; upgrade reconciles your home from the CHANGELOG's Upgrade blocks, backing up first. → Updating · Upgrades and releases
Pull requests welcome: new opt-in packs, read-mostly MCP tools, platform hardening, docs. Open an issue first for architectural changes; Kevin's contract with the markdown home is intentional. See CONTRIBUTING.md.
Licensed under the Apache License, Version 2.0. agent-kevin is © AgentLayer · agentlayer.one. See NOTICE for the attribution stanza. Third-party skill libraries installed via the configure-skills skill are not bundled and carry their own licenses.
<a href="https://agentlayer.one"><img src="assets/agentlayer-logo.png" alt="AgentLayer" height="40" /></a>
Built by AgentLayer · agentic infrastructure for AI-native operations
hooks/register.ts 14 lines1import type { Register } from 'claude-code';
2
3import { registerCommands } from '../commands/commands';
4import { registerManuals } from '../manuals/manuals';
5import { registerNotices } from '../notices/notices';
6import { registerSync } from '../sync/tracker';
7
8export const register: Register = (on) => {
9 registerManuals(on);
10 registerSync(on);
11 registerCommands(on);
12 registerNotices(on);
13};
14commands/commands.tsx 145 lines1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, On } from 'claude-code';
3
4import type { AgendaItem, TodayView } from '../types';
5
6import { COMMANDS_FEATURE } from '../shared/catalog';
7import { cliArgv, cliError } from '../shared/cli';
8import type { TaskFrontmatter } from './today';
9import { STALE_SYNC_HOURS, buildToday, formatAge, parseEmoji, summarize } from './today';
10
11const todayViews = atom({ plugin: 'agent-kevin', key: 'todayViews' } as const, {});
12
13const VIEWS_KEPT = 10;
14const PRIORITY_COLORS: Record<string, string> = { P0: 'red', P1: 'yellow' };
15
16// The session root, not its cwd: a shell `cd` in a turn moves the cwd out of the home.
17async function runCli($: EngineInterface, args: readonly string[]): Promise<string> {
18 const { exitCode, stdout, stderr } = await $.process.run(cliArgv($.plugin.root, $.plugin.name, args), {
19 cwd: await $.session.root()
20 });
21 if (exitCode !== 0) {
22 throw cliError(stderr, args, exitCode);
23 }
24 return stdout;
25}
26
27async function captureAs($: EngineInterface, kind: 'inbox' | 'feedback', text: string) {
28 if (!text.trim()) {
29 return { text: `Usage: /${kind === 'inbox' ? 'capture' : 'lesson'} <text>` };
30 }
31 const result = JSON.parse(await runCli($, ['capture', text, `--kind=${kind}`])) as {
32 relPath: string;
33 duplicate: boolean;
34 };
35 return { text: result.duplicate ? `Already captured: ${result.relPath}` : `Saved to ${result.relPath}` };
36}
37
38async function todayView($: EngineInterface): Promise<TodayView> {
39 const { home, data, timezone } = JSON.parse(await runCli($, ['ping'])) as {
40 home: string;
41 data: string;
42 timezone: string;
43 };
44 const [tasks, syncedAt, identityText, now] = await Promise.all([
45 runCli($, ['task', 'query']).then((text) => JSON.parse(text) as TaskFrontmatter[]),
46 $.fs
47 .read(`${data}/cadence.json`)
48 .then((text) => (JSON.parse(text) as { sync?: string }).sync)
49 .catch(() => undefined),
50 $.fs.read(`${home}/IDENTITY.md`).catch(() => ''),
51 $.clock.now()
52 ]);
53 return buildToday({ emoji: parseEmoji(identityText), timezone, tasks, syncedAt, now });
54}
55
56export const registerCommands = (on: On): void => {
57 on('session.start', async ($, e, next) => {
58 await Promise.all(COMMANDS_FEATURE.commands.map((command) => $.command.register(command)));
59 return next(e);
60 });
61
62 on('command.run', { command: 'capture' }, ($, e) => captureAs($, 'inbox', e.args));
63
64 on('command.run', { command: 'lesson' }, ($, e) => captureAs($, 'feedback', e.args));
65
66 on('command.run', { command: 'done' }, async ($, e) => {
67 const id = e.args.trim();
68 if (!id) {
69 return { text: 'Usage: /done <task-id>' };
70 }
71 await runCli($, ['task', 'close', id]);
72 return { text: `Closed ${id}` };
73 });
74
75 on('command.run', { command: 'today' }, async ($) => {
76 const view = await todayView($).catch((error: unknown) => (error instanceof Error ? error.message : String(error)));
77 if (typeof view === 'string') {
78 return { text: `Couldn't read today's state: ${view}` };
79 }
80 const text = summarize(view);
81 await update($, todayViews, (views) =>
82 Object.fromEntries([...Object.entries(views), [text, view]].slice(-VIEWS_KEPT))
83 );
84 return { text };
85 });
86
87 // Claude reads the one-line summary; the transcript draws the agenda stored under it.
88 on('ui.render', { component: 'CommandOutput', props: { command: 'today' } }, async ($, e, next) => {
89 const view = (await read($, todayViews))[e.props.text];
90 if (view === undefined) {
91 return next(e);
92 }
93 const { Box, Text } = $.ui.resolve(e);
94 const isStale = view.syncAgeHours === null || view.syncAgeHours >= STALE_SYNC_HOURS;
95 const idWidth = Math.max(0, ...[...view.overdue, ...view.dueToday].map((item) => item.id.length));
96 const row = (item: AgendaItem, isLate: boolean) => (
97 <Box key={item.id} paddingLeft={3}>
98 <Text dimColor>▸ </Text>
99 <Text color={PRIORITY_COLORS[item.priority]} dimColor={!PRIORITY_COLORS[item.priority]}>
100 {item.priority.padEnd(4)}
101 </Text>
102 <Text dimColor>{item.id.padEnd(idWidth + 2)}</Text>
103 <Box flexGrow={1} flexShrink={1}>
104 <Text wrap="truncate-end">{item.title}</Text>
105 </Box>
106 {isLate ? <Text color="red">{` ${item.daysLate}d late`}</Text> : null}
107 </Box>
108 );
109 const section = (title: string, items: readonly AgendaItem[], isLate: boolean) =>
110 items.length ? (
111 <Box key={title} flexDirection="column" marginTop={1}>
112 <Box paddingLeft={2}>
113 <Text bold>{title}</Text>
114 <Text dimColor>{` ${items.length}`}</Text>
115 </Box>
116 {items.map((item) => row(item, isLate))}
117 </Box>
118 ) : null;
119 return (
120 <Box flexDirection="column">
121 <Box>
122 <Text>{view.emoji} </Text>
123 <Text bold>Today</Text>
124 <Text dimColor>
125 {' '}
126 · {view.dateLabel} · {view.hijriLabel}
127 </Text>
128 </Box>
129 {section('Overdue', view.overdue, true)}
130 {section('Due today', view.dueToday, false)}
131 {view.overdue.length + view.dueToday.length === 0 ? (
132 <Box marginTop={1} paddingLeft={2}>
133 <Text color="green">✓ nothing overdue or due today</Text>
134 </Box>
135 ) : null}
136 <Box marginTop={1} paddingLeft={2}>
137 <Text color={isStale ? 'yellow' : undefined} dimColor={!isStale}>
138 ⟳ {view.syncAgeHours === null ? 'never synced' : `last sync ${formatAge(view.syncAgeHours)}`}
139 </Text>
140 </Box>
141 </Box>
142 );
143 });
144};
145manuals/manuals.tsx 288 lines1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, On, ToolCallResult, TurnCompleteReason } from 'claude-code';
3
4import { MANUALS_FEATURE } from '../shared/catalog';
5import { cliArgv } from '../shared/cli';
6import {
7 CLAUDE_FILES,
8 HOME_MARKERS,
9 MAX_IMPORT_HOPS,
10 dataDirNameIn,
11 expandHome,
12 foldersUpTo,
13 grantFor,
14 hasOwnContent,
15 importsIn,
16 isUnder,
17 manualContext,
18 normalize,
19 parentOf,
20 pathsInCommand,
21 resolveImport,
22 shortName
23} from './repo';
24
25// `${loop}:${path}` for each file attached and `${loop}:${folder}/` for each folder already read.
26const delivered = atom({ plugin: 'agent-kevin', key: 'delivered' } as const, []);
27// Files the current turn attached, shown in the band until the turn ends.
28const thisTurn = atom({ plugin: 'agent-kevin', key: 'manualsThisTurn' } as const, []);
29
30const MAIN = 'main';
31const TURN_REASONS: readonly TurnCompleteReason[] = ['answer', 'aborted', 'refusal', 'error'];
32
33// Read once per module load; a reload picks up changed grants.
34let grantsCache: string[] | undefined;
35let dataDirCache: string | undefined;
36
37interface Manual {
38 path: string;
39 text: string;
40}
41
42const loopOf = (agentId: string | undefined): string => agentId ?? MAIN;
43
44const asPaths = (field: unknown): string[] => (typeof field === 'string' ? [field] : []);
45
46async function exists($: EngineInterface, path: string): Promise<boolean> {
47 return $.fs.stat(path).then(
48 () => true,
49 () => false
50 );
51}
52
53async function grants($: EngineInterface): Promise<string[]> {
54 if (grantsCache) {
55 return grantsCache;
56 }
57 const permissions = (await $.settings.read()).permissions;
58 const listed =
59 typeof permissions === 'object' && permissions !== null && 'additionalDirectories' in permissions
60 ? permissions.additionalDirectories
61 : [];
62 const home = await $.env.get('HOME');
63 grantsCache = (Array.isArray(listed) ? listed : [])
64 .filter((entry): entry is string => typeof entry === 'string')
65 .map((entry) => expandHome(entry, home));
66 return grantsCache;
67}
68
69/**
70 * The plugin's data dir name (`.state`), from the CLI that owns it; '' when it can't be read.
71 */
72async function dataDirName($: EngineInterface): Promise<string> {
73 if (dataDirCache === undefined) {
74 const { exitCode, stdout } = await $.process.run(cliArgv($.plugin.root, $.plugin.name, ['ping']), {
75 cwd: await $.session.root()
76 });
77 dataDirCache = exitCode === 0 ? dataDirNameIn(stdout) : '';
78 }
79 return dataDirCache;
80}
81
82/**
83 * An agent home loads its own instructions through its bridge; it is marked, as the runtime marks
84 * one, by a data dir holding a marker file.
85 */
86async function isAgentHome($: EngineInterface, folder: string): Promise<boolean> {
87 const name = await dataDirName($);
88 if (!name) {
89 return false;
90 }
91 const marked = await Promise.all(HOME_MARKERS.map((marker) => exists($, `${folder}/${name}/${marker}`)));
92 return marked.includes(true);
93}
94
95async function findRepoRoot($: EngineInterface, folders: readonly string[]): Promise<string | undefined> {
96 const [first, ...rest] = folders;
97 if (first === undefined) {
98 return undefined;
99 }
100 return (await exists($, `${first}/.git`)) ? first : findRepoRoot($, rest);
101}
102
103/**
104 * The repo folders from the root down to `path`'s folder that this loop hasn't read yet, or none
105 * outside a granted repo and inside an agent home.
106 */
107async function foldersFor(
108 $: EngineInterface,
109 path: string,
110 mayBeFolder: boolean,
111 done: ReadonlySet<string>
112): Promise<string[]> {
113 const grant = grantFor(path, await grants($), await $.session.root());
114 if (grant === undefined) {
115 return [];
116 }
117 const isFolder =
118 mayBeFolder &&
119 (await $.fs.stat(path).then(
120 (stat) => stat.kind === 'dir',
121 () => false
122 ));
123 const folder = isFolder ? path : parentOf(path);
124 // Handling a folder marks every folder from its repo root down, so a repeat touch stops here.
125 if (done.has(`${folder}/`)) {
126 return [];
127 }
128 const upward = foldersUpTo(folder, grant);
129 const repoRoot = await findRepoRoot($, upward);
130 if (repoRoot === undefined || (await isAgentHome($, repoRoot))) {
131 return [];
132 }
133 return upward.slice(0, upward.indexOf(repoRoot) + 1).reverse();
134}
135
136/**
137 * A folder's Claude files, or its AGENTS.md when it has none: Claude Code's default rule.
138 */
139async function instructionFiles($: EngineInterface, folder: string): Promise<string[]> {
140 const present = async (paths: readonly string[]) =>
141 (await Promise.all(paths.map(async (path) => ((await exists($, path)) ? [path] : [])))).flat();
142 const claude = await present(CLAUDE_FILES.map((name) => `${folder}/${name}`));
143 return claude.length ? claude : present([`${folder}/AGENTS.md`]);
144}
145
146/**
147 * Each file then its imports, depth-first, skipping anything already seen and any import
148 * outside the grants (the mod reads files the Read tool's sandbox would refuse).
149 */
150async function expand(
151 $: EngineInterface,
152 files: readonly string[],
153 hops: number,
154 walk: { allowed: readonly string[]; home: string | undefined; seen: Set<string> }
155): Promise<Manual[]> {
156 const [file, ...rest] = files;
157 if (file === undefined) {
158 return [];
159 }
160 if (walk.seen.has(file)) {
161 return expand($, rest, hops, walk);
162 }
163 walk.seen.add(file);
164 const text = await $.fs.read(file).catch(() => undefined);
165 if (typeof text !== 'string') {
166 return expand($, rest, hops, walk);
167 }
168 const imports =
169 hops >= MAX_IMPORT_HOPS
170 ? []
171 : importsIn(text)
172 .map((ref) => resolveImport(file, ref, walk.home))
173 .filter((path) => walk.allowed.some((grant) => isUnder(path, grant)));
174 const nested = await expand($, imports, hops + 1, walk);
175 return [{ path: file, text }, ...nested, ...(await expand($, rest, hops, walk))];
176}
177
178async function withManuals(
179 $: EngineInterface,
180 result: ToolCallResult,
181 spelled: readonly string[],
182 mayBeFolder: boolean,
183 agentId: string | undefined
184): Promise<ToolCallResult> {
185 const touched = spelled.map(normalize);
186 if (result.deny !== undefined || touched.length === 0) {
187 return result;
188 }
189 const loop = loopOf(agentId);
190 const done = new Set(
191 (await read($, delivered)).filter((key) => key.startsWith(`${loop}:`)).map((key) => key.slice(loop.length + 1))
192 );
193 const folders = [
194 ...new Set((await Promise.all(touched.map((path) => foldersFor($, path, mayBeFolder, done)))).flat())
195 ].filter((folder) => !done.has(`${folder}/`));
196 if (folders.length === 0) {
197 return result;
198 }
199 const files = (await Promise.all(folders.map((folder) => instructionFiles($, folder)))).flat();
200 const manuals = await expand($, files, 0, {
201 allowed: await grants($),
202 home: await $.env.get('HOME'),
203 seen: new Set([...done, ...touched])
204 });
205 const shown = manuals.filter((manual) => hasOwnContent(manual.text));
206 await update($, delivered, (list) => [
207 ...list,
208 ...folders.map((folder) => `${loop}:${folder}/`),
209 ...shown.map((manual) => `${loop}:${manual.path}`)
210 ]);
211 if (loop === MAIN && shown.length) {
212 await update($, thisTurn, (list) => [...list, ...shown.map((manual) => manual.path)]);
213 }
214 return shown.length
215 ? {
216 ...result,
217 context: [...(result.context ?? []), ...shown.map((manual) => manualContext(manual.path, manual.text))]
218 }
219 : result;
220}
221
222export const registerManuals = (on: On): void => {
223 // Edit refuses a file the conversation hasn't Read, so the Read already attached its manual.
224 on('tool.call', { tool: ['Read', 'Write'] }, async ($, e, next) =>
225 withManuals($, await next(e), asPaths(e.file_path), false, e.agentId)
226 );
227
228 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
229 const result = await next(e);
230 return withManuals($, result, pathsInCommand(asPaths(e.command)[0] ?? '', await grants($)), true, e.agentId);
231 });
232
233 // A precompute only prepares a summary and a vetoed one changes nothing: the manuals stay in context.
234 on('session.compact', { trigger: ['manual', 'auto', 'plugin'] }, async ($, e, next) => {
235 const result = await next(e);
236 if (result.skip === undefined) {
237 const prefix = `${loopOf(e.agentId)}:`;
238 await update($, delivered, (list) => list.filter((key) => !key.startsWith(prefix)));
239 }
240 return result;
241 });
242
243 // A /clear or a resume starts another conversation in the same process.
244 on('session.end', { reason: ['clear', 'resume'] }, async ($, e, next) => {
245 await update($, delivered, () => []);
246 return next(e);
247 });
248
249 // Sync owns the unmatched turn.complete; every reason is listed so this one sees each turn too.
250 on('turn.complete', { reason: TURN_REASONS }, async ($, e, next) => {
251 if (e.agentId === undefined) {
252 await update($, thisTurn, () => []);
253 }
254 return next(e);
255 });
256
257 on('session.start', { isInteractive: true }, async ($, e, next) => {
258 await Promise.all(MANUALS_FEATURE.commands.map((command) => $.command.register(command)));
259 return next(e);
260 });
261
262 on('command.run', { command: 'manuals' }, async ($) => {
263 const allowed = await grants($);
264 const files = (await read($, delivered))
265 .filter((key) => key.startsWith(`${MAIN}:`) && !key.endsWith('/'))
266 .map((key) => shortName(key.slice(MAIN.length + 1), allowed));
267 return { text: files.length ? `In context: ${files.join(', ')}` : 'No repo instructions attached yet.' };
268 });
269
270 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
271 const below = await next(e);
272 const attached = await read($, thisTurn);
273 if (attached.length === 0 || e.props.hasSurvey) {
274 return below;
275 }
276 const allowed = await grants($);
277 const { Box, Text } = $.ui.resolve(e);
278 return (
279 <Box flexDirection="column">
280 <Text dimColor wrap="truncate-end">
281 📎 {attached.map((path) => shortName(path, allowed)).join(' · ')} attached
282 </Text>
283 {below}
284 </Box>
285 );
286 });
287};
288notices/notices.tsx 204 lines1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, On, Timer } from 'claude-code';
3
4import { cliArgv, cliError } from '../shared/cli';
5import { SYNC_HISTORY_KEY, formatDuration, parseHistory, typicalMs } from '../sync/stats';
6import type { Notice, NoticeActing } from '../types';
7
8const notices = atom({ plugin: 'agent-kevin', key: 'notices' } as const, []);
9// The notice whose command was pressed or started; the row steps aside until that command's turn ends.
10const acting = atom({ plugin: 'agent-kevin', key: 'noticeActing' } as const, null);
11
12const REFRESH_MS = 30 * 60_000;
13const TOAST_MS = 8000;
14
15const LEVEL_COLORS = { hint: 'cyan', nudge: 'yellow', alert: 'red' } as const;
16const TONE_COLORS = { accent: 'cyan', warn: 'yellow' } as const;
17
18// Module state: a reload drops the timer and the next session start makes a new one.
19let refresher: Timer | undefined;
20// Claimed before the press's first await, so a second press in the same tick finds it.
21let isPressing = false;
22
23const actsOn = (notice: Notice, skill: string): boolean =>
24 notice.command === skill || notice.command.endsWith(`:${skill}`);
25
26const isString = (data: unknown): data is string => typeof data === 'string';
27
28const isNotice = (data: unknown): data is Notice => {
29 if (typeof data !== 'object' || data === null) {
30 return false;
31 }
32 const item = data as Record<string, unknown>;
33 return (
34 [item.id, item.icon, item.title, item.command, item.actionLabel].every(isString) &&
35 ['hint', 'nudge', 'alert'].includes(String(item.level)) &&
36 Array.isArray(item.facts) &&
37 item.facts.every(
38 (fact: unknown) => typeof fact === 'object' && fact !== null && isString((fact as { text?: unknown }).text)
39 )
40 );
41};
42
43// The CLI is this plugin's own, but a list that isn't one keeps the last good list rather than breaking the band.
44const parseNotices = (text: string): Notice[] | null => {
45 const data: unknown = JSON.parse(text);
46 return Array.isArray(data) && data.every(isNotice) ? data : null;
47};
48
49// The session root, not its cwd: a shell `cd` in a turn moves the cwd out of the home.
50async function runCli($: EngineInterface, args: readonly string[]): Promise<string> {
51 const { exitCode, stdout, stderr } = await $.process.run(cliArgv($.plugin.root, $.plugin.name, args), {
52 cwd: await $.session.root()
53 });
54 if (exitCode !== 0) {
55 throw cliError(stderr, args, exitCode);
56 }
57 return stdout;
58}
59
60/**
61 * Stores the list a `notices` call prints, the sync notice carrying this machine's typical run time.
62 */
63async function load($: EngineInterface, args: readonly string[]): Promise<Notice[]> {
64 const fresh = await runCli($, args)
65 .then(parseNotices)
66 .catch(() => null);
67 if (fresh === null) {
68 return read($, notices);
69 }
70 const typical = typicalMs(parseHistory(await $.store.get(SYNC_HISTORY_KEY)));
71 const shown =
72 typical === null
73 ? fresh
74 : fresh.map((notice) =>
75 notice.id === 'sync'
76 ? { ...notice, facts: [...notice.facts, { text: `~${formatDuration(typical)}` }] }
77 : notice
78 );
79 await update($, notices, () => shown);
80 return shown;
81}
82
83async function announce($: EngineInterface): Promise<void> {
84 const [lead] = await load($, ['notices', '--row']);
85 if (lead === undefined) {
86 return;
87 }
88 void $.prompt.suggest({ text: `/${lead.command}` });
89 if (lead.level === 'alert') {
90 $.ui.toast(`${lead.icon} ${lead.title}. Tab to ${lead.actionLabel.toLowerCase()}.`, { timeoutMs: TOAST_MS });
91 }
92}
93
94async function act($: EngineInterface, notice: Notice): Promise<void> {
95 if (isPressing) {
96 return;
97 }
98 isPressing = true;
99 try {
100 if ((await read($, acting)) !== null) {
101 return;
102 }
103 const at = await $.clock.now();
104 await update($, acting, () => ({ id: notice.id, isStarted: false, at }));
105 } finally {
106 isPressing = false;
107 }
108 // Queued, not awaited: the run outlives the press. Should a host refuse the name, the typed form does the same.
109 void $.command
110 .run({ command: notice.command })
111 .catch(() => $.prompt.submit({ text: `/${notice.command}` }))
112 .catch(() => update($, acting, (current) => (current?.isStarted ? current : null)));
113}
114
115async function snooze($: EngineInterface, notice: Notice): Promise<void> {
116 await load($, ['notices', 'record', notice.id, '--outcome=snoozed']);
117}
118
119async function settle($: EngineInterface): Promise<void> {
120 await load($, ['notices']);
121 await update($, acting, () => null);
122}
123
124// A press whose command never started (the queue dropped it) stops hiding the row once it is a refresh old.
125async function refresh($: EngineInterface): Promise<void> {
126 await load($, ['notices']);
127 const now = await $.clock.now();
128 await update($, acting, (current: NoticeActing | null) =>
129 current && !current.isStarted && now - current.at >= REFRESH_MS ? null : current
130 );
131}
132
133export const registerNotices = (on: On): void => {
134 on('session.start', { isInteractive: true }, async ($, e, next) => {
135 const result = await next(e);
136 // After the session is up: the CLI call is a process spawn the prompt shouldn't wait on.
137 $.clock.after(0, () => void announce($));
138 refresher?.cancel();
139 refresher = $.clock.every(REFRESH_MS, () => void refresh($));
140 return result;
141 });
142
143 // Typing the command by hand counts as acting on it, the same as the button.
144 on('skill.prompt', { skill: /./ }, async ($, e, next) => {
145 const notice = (await read($, notices)).find((item) => actsOn(item, e.skill));
146 if (notice) {
147 const at = await $.clock.now();
148 await update($, acting, () => ({ id: notice.id, isStarted: true, at }));
149 void runCli($, ['notices', 'record', notice.id, '--outcome=acted']).catch(() => undefined);
150 }
151 return next(e);
152 });
153
154 // The command's skill started inside the running turn, so the next main turn to end is the command's own, however it ended.
155 on('turn.complete', { reason: ['answer', 'aborted', 'refusal', 'error'] }, async ($, e, next) => {
156 const result = await next(e);
157 if (e.agentId === undefined && (await read($, acting))?.isStarted) {
158 $.clock.after(0, () => void settle($));
159 }
160 return result;
161 });
162
163 // Stacks above whatever renders beneath, so other features' rows share the band.
164 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
165 const below = await next(e);
166 if (e.props.hasSurvey || (await read($, acting)) !== null) {
167 return below;
168 }
169 const [top, ...waiting] = await read($, notices);
170 if (top === undefined || top.level === 'hint') {
171 return below;
172 }
173 const { Box, Button, Text } = $.ui.resolve(e);
174 return (
175 <Box flexDirection="column">
176 <Box gap={1}>
177 {/* The trailing space survives a font that draws the glyph wider than its one cell. */}
178 <Text bold color={LEVEL_COLORS[top.level]}>{`${top.icon} `}</Text>
179 <Text bold>{top.title}</Text>
180 {top.facts.map((fact) => (
181 <Box key={fact.text} gap={1}>
182 <Text dimColor>·</Text>
183 <Text color={fact.tone ? TONE_COLORS[fact.tone] : undefined} dimColor={!fact.tone}>
184 {fact.text}
185 </Text>
186 </Box>
187 ))}
188 <Box gap={1} marginLeft={2}>
189 <Button
190 key={`notice:${top.id}:act`}
191 variant="primary"
192 label={top.actionLabel}
193 onPress={() => act($, top)}
194 />
195 <Button key={`notice:${top.id}:snooze`} dimColor label="Tomorrow" onPress={() => snooze($, top)} />
196 </Box>
197 {waiting.length ? <Text dimColor>{`+${waiting.length} more`}</Text> : null}
198 </Box>
199 {below}
200 </Box>
201 );
202 });
203};
204sync/tracker.tsx 269 lines1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, On, Timer } from 'claude-code';
3
4import { SYNC_FEATURE } from '../shared/catalog';
5import { cliArgv } from '../shared/cli';
6import type { SyncSnapshot } from '../types';
7import { PHASES, bareToolName, classify } from './phases';
8import {
9 actionNotes,
10 appendHistory,
11 finish,
12 isOver,
13 formatDuration,
14 markWaiting,
15 parseBrain,
16 parseCompile,
17 parseHistory,
18 parseTasks,
19 progressOf,
20 recordCall,
21 remeasured,
22 startRun,
23 statsInstruction,
24 statsPayload,
25 SYNC_HISTORY_KEY,
26 toHistory,
27 typicalMs
28} from './stats';
29
30const syncRun = atom({ plugin: 'agent-kevin', key: 'syncRun' } as const, null);
31// Bumped every second while a run is live; only the band reads it, so only the band redraws.
32const syncTick = atom({ plugin: 'agent-kevin', key: 'syncTick' } as const, 0);
33
34const [STATS_TOOL] = SYNC_FEATURE.tools;
35const SYNC_SKILL = /(^|:)sync$/;
36const TICK_MS = 1000;
37
38const LOOKS = {
39 running: { icon: '⟳', color: 'cyan', status: 'running' },
40 waiting: { icon: '⏸', color: 'yellow', status: 'waiting on you' },
41 done: { icon: '✓', color: 'green', status: 'done' },
42 stopped: { icon: '■', color: 'red', status: 'stopped' }
43} as const;
44
45// Module state: a reload drops the timer and the next run starts a new one.
46let ticker: Timer | undefined;
47
48async function runQuiet($: EngineInterface, argv: readonly string[]): Promise<string> {
49 const { exitCode, stdout } = await $.process.run(argv, { cwd: await $.session.root() });
50 return exitCode === 0 ? stdout : '';
51}
52
53async function measureCompile($: EngineInterface) {
54 return parseCompile(await runQuiet($, cliArgv($.plugin.root, $.plugin.name, ['compile', 'status'])));
55}
56
57async function snapshot($: EngineInterface): Promise<SyncSnapshot> {
58 const [compile, tasks, brain] = await Promise.all([
59 measureCompile($),
60 runQuiet($, cliArgv($.plugin.root, $.plugin.name, ['task', 'scan'])),
61 runQuiet($, ['bun', `${$.plugin.root}/skills/self-review/scripts/brain-audit.ts`])
62 ]);
63 return { compile, tasks: parseTasks(tasks), brain: parseBrain(brain) };
64}
65
66/**
67 * Re-measures in the background so the counters move during the run; a late answer after the
68 * run ended is dropped.
69 */
70async function refresh($: EngineInterface, fresh: Promise<Partial<SyncSnapshot>>): Promise<void> {
71 const measured = await fresh.catch(() => ({}));
72 await update($, syncRun, (run) => (run && !isOver(run) ? remeasured(run, measured) : run));
73}
74
75async function history($: EngineInterface) {
76 return parseHistory(await $.store.get(SYNC_HISTORY_KEY));
77}
78
79export const registerSync = (on: On): void => {
80 on('session.start', { isInteractive: true }, async ($, e, next) => {
81 await $.tool.register({
82 name: STATS_TOOL,
83 description:
84 "Counted numbers for the sync that is running: compile backlog, task health and brain counts measured at the run's start and now, actions tallied from tool results, and phase timings. Call once before writing sync's output block.",
85 inputSchema: { type: 'object', properties: {} }
86 });
87 return next(e);
88 });
89
90 on('skill.prompt', { skill: SYNC_SKILL }, async ($, e, next) => {
91 const result = await next(e);
92 const now = await $.clock.now();
93 const before = await snapshot($).catch(() => null);
94 await update($, syncRun, () => startRun(now, before));
95 ticker?.cancel();
96 ticker = $.clock.every(TICK_MS, () => void update($, syncTick, (tick) => tick + 1));
97 const tools = await $.tool.list();
98 const statsTool = tools.find((tool) => tool.name.endsWith(`__${STATS_TOOL}`));
99 return statsTool ? { text: result.text + statsInstruction(statsTool.name) } : result;
100 });
101
102 on('tool.call', async ($, e, next) => {
103 const before = await read($, syncRun);
104 if (before === null || isOver(before) || e.agentId !== undefined) {
105 return next(e);
106 }
107 const phase = classify({ tool: e.tool, input: e }, $.plugin.name);
108 if (e.tool === 'AskUserQuestion') {
109 const asked = await $.clock.now();
110 await update($, syncRun, (run) => (run ? markWaiting(run, phase, asked) : run));
111 }
112 const result = await next(e);
113 const now = await $.clock.now();
114 const call = {
115 phase,
116 name: bareToolName(e.tool, $.plugin.name),
117 resultText: result.text ?? '',
118 isError: result.isError === true
119 };
120 await update($, syncRun, (run) => (run ? recordCall(run, call, now) : run));
121 if (call.phase !== undefined && call.phase > before.phase) {
122 $.clock.after(0, () => void refresh($, snapshot($)));
123 } else if (call.name === 'compile_write') {
124 $.clock.after(
125 0,
126 () =>
127 void refresh(
128 $,
129 measureCompile($).then((compile) => ({ compile }))
130 )
131 );
132 }
133 return result;
134 });
135
136 on('tool.call', { tool: /__sync_stats$/ }, async ($) => {
137 const run = await read($, syncRun);
138 if (run === null) {
139 return { result: 'No sync is running in this session.' };
140 }
141 const after = await snapshot($).catch(() => null);
142 await update($, syncRun, (current) => (current ? { ...current, after } : current));
143 const now = await $.clock.now();
144 // A tool result is text or content blocks, never an object.
145 return { result: JSON.stringify(statsPayload({ ...run, after }, now, await history($))) };
146 });
147
148 on('turn.complete', async ($, e, next) => {
149 const result = await next(e);
150 const run = await read($, syncRun);
151 if (run === null || isOver(run) || e.agentId !== undefined) {
152 return result;
153 }
154 ticker?.cancel();
155 ticker = undefined;
156 const now = await $.clock.now();
157 const ended = finish(run, now, await snapshot($).catch(() => null), e.isAborted);
158 await update($, syncRun, () => ended);
159 // A stopped run would drag the average down.
160 if (!e.isAborted) {
161 await $.store.set(SYNC_HISTORY_KEY, appendHistory(await history($), toHistory(ended)));
162 }
163 return result;
164 });
165
166 on('prompt.submit', async ($, e, next) => {
167 const run = await read($, syncRun);
168 if (run && isOver(run)) {
169 await update($, syncRun, () => null);
170 }
171 return next(e);
172 });
173
174 // Stacks above whatever renders beneath, so other features' rows share the band.
175 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
176 const below = await next(e);
177 const run = await read($, syncRun);
178 if (run === null || e.props.hasSurvey) {
179 return below;
180 }
181 const { Box, Text } = $.ui.resolve(e);
182 const now = await $.clock.now();
183 const look = LOOKS[run.status];
184 const isEnded = isOver(run);
185 const isComplete = run.status === 'done';
186 await read($, syncTick);
187 const past = isComplete ? await history($) : [];
188 const average = typicalMs(past);
189 const elapsed = formatDuration((run.endedAt ?? now) - run.startedAt);
190 const notes = actionNotes(run.actions);
191
192 const header = (
193 <Box>
194 <Text color={look.color}>{look.icon} </Text>
195 <Text bold>sync</Text>
196 <Text dimColor>
197 {' '}
198 · {look.status} · {elapsed}
199 {average === null ? '' : ` (avg ${formatDuration(average)})`}
200 </Text>
201 </Box>
202 );
203
204 const rail = (
205 <Box>
206 {PHASES.map((phase, index) => (
207 <Box key={phase.id}>
208 {index > 0 ? <Text dimColor>─</Text> : null}
209 {isComplete || index < run.phase ? (
210 <Text color="green">●</Text>
211 ) : index === run.phase ? (
212 <Text color={look.color}>◉</Text>
213 ) : (
214 <Text dimColor>○</Text>
215 )}
216 </Box>
217 ))}
218 {!isComplete && PHASES[run.phase] ? (
219 <Box>
220 <Text>
221 {' '}
222 {PHASES[run.phase]?.id}{' '}
223 </Text>
224 <Text dimColor>{`${run.phase + 1}/${PHASES.length}`}</Text>
225 </Box>
226 ) : null}
227 </Box>
228 );
229
230 const counters = (
231 <Box gap={3}>
232 {progressOf(run).map((item) => (
233 <Box key={item.label}>
234 <Text dimColor>{item.label} </Text>
235 <Text color={item.cleared && item.cleared === item.total ? 'green' : undefined}>
236 {item.cleared}/{item.total}
237 </Text>
238 {item.grew ? <Text color="red"> +{item.grew}</Text> : null}
239 </Box>
240 ))}
241 {notes.map((note) => (
242 <Box key={note.label}>
243 <Text dimColor>{note.label} </Text>
244 <Text>{note.count}</Text>
245 </Box>
246 ))}
247 </Box>
248 );
249
250 return isEnded ? (
251 <Box flexDirection="column">
252 <Box gap={2}>
253 {header}
254 {rail}
255 {counters}
256 </Box>
257 {below}
258 </Box>
259 ) : (
260 <Box flexDirection="column">
261 {header}
262 <Box paddingLeft={2}>{rail}</Box>
263 <Box paddingLeft={2}>{counters}</Box>
264 {below}
265 </Box>
266 );
267 });
268};
269shared/catalog.ts 76 lines1// Pure data, no 'claude-code' import: the features register their commands from here, and the
2// dashboard's Capabilities page reads the same list, so the two can't drift.
3
4export interface ModCommand {
5 name: string;
6 description: string;
7 argumentHint?: string;
8 immediate?: boolean;
9}
10
11export interface ModFeature {
12 /** The feature's folder under mods/, which register.ts imports. */
13 id: string;
14 title: string;
15 summary: string;
16 where: string;
17 commands: readonly ModCommand[];
18 tools: readonly string[];
19}
20
21export const COMMANDS_FEATURE = {
22 id: 'commands',
23 title: 'Instant commands',
24 summary: 'Capture a thought, log a lesson, close a task or see today, answered at once without a model turn.',
25 where: 'slash commands',
26 commands: [
27 {
28 name: 'capture',
29 description: 'Drop a thought into the knowledge inbox (no Claude turn)',
30 argumentHint: '<text>',
31 immediate: true
32 },
33 {
34 name: 'lesson',
35 description: 'Log a correction to the feedback log (no Claude turn)',
36 argumentHint: '<text>',
37 immediate: true
38 },
39 { name: 'done', description: 'Close a task by id (no Claude turn)', argumentHint: '<task-id>', immediate: true },
40 { name: 'today', description: "Overdue work, what's due today and the last sync (no Claude turn)" }
41 ],
42 tools: []
43} as const satisfies ModFeature;
44
45export const MANUALS_FEATURE = {
46 id: 'manuals',
47 title: 'Repo manuals',
48 summary:
49 "The first file read or written in a granted code repo, or named by absolute path in a shell command, attaches that repo's CLAUDE.md or AGENTS.md, once, and again after a compaction or /clear.",
50 where: 'tool results · a 📎 line above the prompt',
51 commands: [{ name: 'manuals', description: 'Repo instructions attached to this conversation (no Claude turn)' }],
52 tools: []
53} as const satisfies ModFeature;
54
55export const SYNC_FEATURE = {
56 id: 'sync',
57 title: 'Sync progress',
58 summary:
59 "Sync's step, elapsed time and what it is clearing, live while it runs; its report quotes counted numbers instead of a tally.",
60 where: 'above the prompt',
61 commands: [],
62 tools: ['sync_stats']
63} as const satisfies ModFeature;
64
65export const NOTICES_FEATURE = {
66 id: 'notices',
67 title: 'Notices',
68 summary:
69 'The top upgrade or sync nudge, with a button that runs it and one that snoozes it until tomorrow; also the Tab suggestion.',
70 where: 'above the prompt · toast for an alert',
71 commands: [],
72 tools: []
73} as const satisfies ModFeature;
74
75export const MOD_FEATURES: readonly ModFeature[] = [COMMANDS_FEATURE, MANUALS_FEATURE, SYNC_FEATURE, NOTICES_FEATURE];
76shared/cli.ts 22 lines1// Pure helpers only: the engine follows `$` into functions of the calling file, never across
2// an import, so each feature file makes its own `$` calls and shares just these.
3
4const LOG_LINE = /^\d{4}-\d{2}-\d{2}T/;
5
6/**
7 * The plugin's CLI is `bin/<plugin name minus "agent-">`, the same rule its manifests pin.
8 */
9export const cliArgv = (pluginRoot: string, pluginName: string, args: readonly string[]): string[] => [
10 'bun',
11 `${pluginRoot}/bin/${pluginName.replace(/^agent-/, '')}`,
12 ...args
13];
14
15/**
16 * The CLI's first stderr line that isn't a timestamped log line, else a generic exit message.
17 */
18export const cliError = (stderr: string, args: readonly string[], exitCode: number): Error =>
19 new Error(
20 stderr.split('\n').find((line) => line.trim() && !LOG_LINE.test(line)) ?? `${args.join(' ')} exited ${exitCode}`
21 );
22commands/today.ts 99 lines1import type { AgendaItem, TodayView } from '../types';
2
3const OPEN_STATUSES = ['open', 'active'];
4const PRIORITIES = ['P0', 'P1', 'P2', 'P3'];
5const DAY_MS = 86_400_000;
6const HOUR_MS = 3_600_000;
7
8export interface TaskFrontmatter {
9 id: string;
10 title: string;
11 status: string;
12 priority?: string;
13 due?: string;
14}
15
16interface TodayInputs {
17 emoji: string;
18 timezone: string;
19 tasks: readonly TaskFrontmatter[];
20 syncedAt: string | undefined;
21 now: number;
22}
23
24export const STALE_SYNC_HOURS = 72;
25
26export const parseEmoji = (identity: string): string => /\*\*Emoji:\*\*\s*(\S+)/.exec(identity)?.[1] ?? '🤖';
27
28const isoDate = (now: number, timeZone: string): string =>
29 new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
30
31const hijriDate = (now: number, timeZone: string): string => {
32 const parts = new Intl.DateTimeFormat('en-u-ca-islamic-umalqura', {
33 timeZone,
34 day: 'numeric',
35 month: 'long',
36 year: 'numeric'
37 }).formatToParts(now);
38 const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((item) => item.type === type)?.value ?? '';
39 return `${part('day')} ${part('month')} ${part('year')}`;
40};
41
42const priorityRank = (priority: string): number => {
43 const rank = PRIORITIES.indexOf(priority);
44 return rank === -1 ? PRIORITIES.length : rank;
45};
46
47/**
48 * Most urgent first: priority, then the latest.
49 */
50const byUrgency = (left: AgendaItem, right: AgendaItem): number =>
51 priorityRank(left.priority) - priorityRank(right.priority) || right.daysLate - left.daysLate;
52
53const toItem = (task: TaskFrontmatter, date: string): AgendaItem => ({
54 id: task.id,
55 priority: task.priority ?? '',
56 title: task.title,
57 daysLate: Math.round((Date.parse(date) - Date.parse(task.due ?? date)) / DAY_MS)
58});
59
60export const buildToday = ({ emoji, timezone, tasks, syncedAt, now }: TodayInputs): TodayView => {
61 const date = isoDate(now, timezone);
62 const open = tasks.filter((task) => OPEN_STATUSES.includes(task.status) && task.due);
63 const syncedMs = syncedAt === undefined ? Number.NaN : Date.parse(syncedAt);
64 return {
65 emoji,
66 dateLabel: new Intl.DateTimeFormat('en-GB', {
67 timeZone: timezone,
68 weekday: 'short',
69 day: 'numeric',
70 month: 'short'
71 }).format(now),
72 hijriLabel: hijriDate(now, timezone),
73 overdue: open
74 .filter((task) => (task.due ?? '') < date)
75 .map((task) => toItem(task, date))
76 .sort(byUrgency),
77 dueToday: open
78 .filter((task) => task.due === date)
79 .map((task) => toItem(task, date))
80 .sort(byUrgency),
81 syncAgeHours: Number.isNaN(syncedMs) ? null : Math.floor((now - syncedMs) / HOUR_MS)
82 };
83};
84
85export const formatAge = (hours: number): string =>
86 hours < 1 ? 'just now' : hours < 48 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`;
87
88/**
89 * The one line Claude reads; the transcript draws the agenda from the stored view.
90 */
91export const summarize = (view: TodayView): string => {
92 const ids = (list: readonly AgendaItem[]) => (list.length ? ` (${list.map((item) => item.id).join(', ')})` : '');
93 return [
94 `${view.emoji} ${view.overdue.length} overdue${ids(view.overdue)}`,
95 `${view.dueToday.length} due today${ids(view.dueToday)}`,
96 view.syncAgeHours === null ? 'never synced' : `synced ${formatAge(view.syncAgeHours)}`
97 ].join(' · ');
98};
99manuals/repo.ts 119 lines1// Pure path logic. The mod runtime has no node:path, so these work on POSIX absolute paths
2// (macOS-first). TODO(windows): drive letters and backslashes are not handled.
3
4/**
5 * A folder's Claude instruction files, in the order Claude Code reads them; AGENTS.md is read
6 * only when a folder has none of these.
7 */
8export const CLAUDE_FILES = ['CLAUDE.md', '.claude/CLAUDE.md', 'CLAUDE.local.md'];
9
10/**
11 * Imports nest at most this deep, as in Claude Code's own loader.
12 */
13export const MAX_IMPORT_HOPS = 4;
14
15/**
16 * The files whose presence in a data dir marks an agent home: the runtime's own
17 * `HOME_MARKER_FILES` (mcp-server/src/shared/naming.ts), which a test keeps equal.
18 */
19export const HOME_MARKERS = ['version.json', 'knowledge.json'];
20
21const CODE = /```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`/g;
22const IMPORT = /(?:^|\s)@(\S+)/g;
23
24const trimSlash = (path: string): string => (path.length > 1 ? path.replace(/\/+$/, '') : path);
25
26export const parentOf = (path: string): string => {
27 const trimmed = trimSlash(path);
28 const cut = trimmed.lastIndexOf('/');
29 return cut <= 0 ? '/' : trimmed.slice(0, cut);
30};
31
32/**
33 * The data dir's folder name (`.state`) from the CLI's `ping` output, or '' when it can't be read.
34 */
35export const dataDirNameIn = (ping: string): string => {
36 try {
37 const data: unknown = (JSON.parse(ping) as { data?: unknown }).data;
38 return typeof data === 'string' ? data.slice(trimSlash(data).lastIndexOf('/') + 1) : '';
39 } catch {
40 return '';
41 }
42};
43
44export const isUnder = (path: string, folder: string): boolean => {
45 const base = trimSlash(folder);
46 return path === base || path.startsWith(`${base}/`);
47};
48
49export const expandHome = (path: string, home: string | undefined): string =>
50 home !== undefined && (path === '~' || path.startsWith('~/')) ? `${home}${path.slice(1)}` : path;
51
52/**
53 * An absolute path with `.` and `..` resolved and repeat slashes dropped, so one file has one spelling.
54 */
55export const normalize = (path: string): string =>
56 `/${path
57 .split('/')
58 .reduce<string[]>(
59 (parts, segment) =>
60 segment === '' || segment === '.' ? parts : segment === '..' ? parts.slice(0, -1) : [...parts, segment],
61 []
62 )
63 .join('/')}`;
64
65/**
66 * The granted folder holding `path`, skipping anything under the session root (the engine's own
67 * instruction loading covers that tree).
68 */
69export const grantFor = (path: string, grants: readonly string[], sessionRoot: string): string | undefined =>
70 isUnder(path, sessionRoot) ? undefined : grants.find((grant) => isUnder(path, grant));
71
72/**
73 * Folders from `dir` up to and including `stop`, nearest first.
74 */
75export const foldersUpTo = (dir: string, stop: string): string[] =>
76 dir === trimSlash(stop) || !isUnder(dir, stop) || dir === '/'
77 ? [trimSlash(stop)]
78 : [dir, ...foldersUpTo(parentOf(dir), stop)];
79
80/**
81 * Where `@ref` in `fromFile` points: relative to that file's folder, `~/` from home, or absolute.
82 */
83export const resolveImport = (fromFile: string, ref: string, home: string | undefined): string =>
84 normalize(ref.startsWith('/') || ref.startsWith('~/') ? expandHome(ref, home) : `${parentOf(fromFile)}/${ref}`);
85
86/**
87 * The `@path` imports in a file, skipping code spans and fenced blocks as Claude Code does.
88 */
89export const importsIn = (text: string): string[] =>
90 [...text.replace(CODE, ' ').matchAll(IMPORT)]
91 .map((match) => (match[1] ?? '').replace(/[.,;:!?)\]]+$/, ''))
92 .filter(Boolean);
93
94/**
95 * False for a file that holds nothing but `@` imports (a bridge): its imports carry the content.
96 */
97export const hasOwnContent = (text: string): boolean =>
98 text
99 .split('\n')
100 .map((line) => line.trim())
101 .some((line) => line !== '' && !line.startsWith('@') && !line.startsWith('<!--'));
102
103/**
104 * Absolute paths in a shell command that fall under a grant.
105 */
106export const pathsInCommand = (command: string, grants: readonly string[]): string[] =>
107 (command.match(/\/[^\s'"`;&|<>()]+/g) ?? []).filter((path) => grants.some((grant) => isUnder(path, grant)));
108
109/**
110 * A short name for the band and /manuals: the path from the grant's parent folder down.
111 */
112export const shortName = (path: string, grants: readonly string[]): string => {
113 const grant = grants.find((candidate) => isUnder(path, candidate));
114 return grant === undefined ? path : path.slice(parentOf(grant).length + 1);
115};
116
117export const manualContext = (path: string, text: string): string =>
118 `Contents of ${path} (this repo's agent instructions, attached the first time the session touched it):\n\n${text}`;
119sync/stats.ts 280 lines1import type {
2 BrainCounts,
3 CompileBacklog,
4 SyncActions,
5 SyncHistoryEntry,
6 SyncRun,
7 SyncSnapshot,
8 TaskHealth
9} from '../types';
10import { PHASES } from './phases';
11
12const HISTORY_LIMIT = 20;
13
14const NO_ACTIONS: SyncActions = {
15 reposUpdated: 0,
16 compiled: 0,
17 lintFixed: 0,
18 lintErrors: 0,
19 tasksClosed: 0,
20 tasksUpdated: 0,
21 threads: 0,
22 tasksCreated: 0
23};
24
25const parseJson = (text: string): unknown => {
26 try {
27 return JSON.parse(text);
28 } catch {
29 return undefined;
30 }
31};
32
33const isRecord = (data: unknown): data is Record<string, unknown> => typeof data === 'object' && data !== null;
34
35const count = (data: unknown): number => (Array.isArray(data) ? data.length : typeof data === 'number' ? data : 0);
36
37export const parseCompile = (text: string): CompileBacklog | null => {
38 const data = parseJson(text);
39 if (!isRecord(data) || !isRecord(data.pending)) {
40 return null;
41 }
42 return {
43 sessions: count(data.pending.sessions),
44 feedback: count(data.pending.feedback),
45 inbox: count(data.pending.inbox)
46 };
47};
48
49export const parseTasks = (text: string): TaskHealth | null => {
50 const data = parseJson(text);
51 if (!isRecord(data)) {
52 return null;
53 }
54 return { overdue: count(data.overdue), stale: count(data.stale), dueSoon: count(data.dueSoon) };
55};
56
57export const parseBrain = (text: string): BrainCounts | null => {
58 const data = parseJson(text);
59 if (!isRecord(data) || !isRecord(data.counts)) {
60 return null;
61 }
62 const counts = data.counts;
63 return {
64 staleTasks: count(counts.staleTasks),
65 dormantTasks: count(counts.dormantTasks),
66 activeOld: count(counts.activeOld),
67 dormantProjects: count(counts.dormantProjects),
68 memoryLines: count(counts.memoryLines),
69 decisions: count(counts.decisions),
70 staleArticles: count(counts.staleArticles),
71 oldCaptureFiles: count(counts.oldCaptureFiles),
72 oldestWaiting: typeof counts.oldestWaiting === 'string' ? counts.oldestWaiting : null
73 };
74};
75
76/**
77 * Adds what one finished tool call did to the run's tally.
78 */
79const tallyAction = (actions: SyncActions, name: string, resultText: string): SyncActions => {
80 const data = parseJson(resultText);
81 switch (name) {
82 case 'github_fast_forward':
83 return { ...actions, reposUpdated: actions.reposUpdated + (resultText.match(/"UPDATED"/g) ?? []).length };
84 case 'compile_write':
85 return { ...actions, compiled: actions.compiled + 1 };
86 case 'knowledge_lint':
87 return isRecord(data)
88 ? { ...actions, lintFixed: actions.lintFixed + count(data.fixed), lintErrors: count(data.errors) }
89 : actions;
90 case 'task_close':
91 return { ...actions, tasksClosed: actions.tasksClosed + 1 };
92 case 'task_update':
93 return { ...actions, tasksUpdated: actions.tasksUpdated + 1 };
94 case 'task_thread':
95 return { ...actions, threads: actions.threads + 1 };
96 case 'task_create':
97 return { ...actions, tasksCreated: actions.tasksCreated + 1 };
98 default:
99 return actions;
100 }
101};
102
103export const startRun = (now: number, before: SyncSnapshot | null): SyncRun => ({
104 status: 'running',
105 startedAt: now,
106 phase: -1,
107 phaseStartedAt: now,
108 timings: [],
109 actions: NO_ACTIONS,
110 before,
111 after: null,
112 endedAt: null
113});
114
115const closePhase = (run: SyncRun, now: number): SyncRun =>
116 run.phase < 0
117 ? run
118 : {
119 ...run,
120 timings: [...run.timings, { phase: PHASES[run.phase]?.id ?? 'unknown', ms: now - run.phaseStartedAt }]
121 };
122
123/**
124 * Moves the run forward to `phase`; a call from an earlier phase never moves it back.
125 */
126const advance = (run: SyncRun, phase: number | undefined, now: number): SyncRun =>
127 phase === undefined || phase <= run.phase ? run : { ...closePhase(run, now), phase, phaseStartedAt: now };
128
129/**
130 * A question is open: the run waits on the person, at the question's own phase.
131 */
132export const markWaiting = (run: SyncRun, phase: number | undefined, now: number): SyncRun => ({
133 ...advance(run, phase, now),
134 status: 'waiting'
135});
136
137/**
138 * Applies one finished call: the phase it belongs to, and what it did unless it errored.
139 */
140export const recordCall = (
141 run: SyncRun,
142 call: { phase: number | undefined; name: string; resultText: string; isError: boolean },
143 now: number
144): SyncRun => ({
145 ...advance(run, call.phase, now),
146 status: 'running',
147 actions: call.isError ? run.actions : tallyAction(run.actions, call.name, call.resultText)
148});
149
150/**
151 * Ends the run with its final measurement; an interrupted run is `stopped`, not `done`.
152 */
153export const finish = (run: SyncRun, now: number, after: SyncSnapshot | null, isAborted: boolean): SyncRun => ({
154 ...closePhase(run, now),
155 status: isAborted ? 'stopped' : 'done',
156 after: after ?? run.after,
157 endedAt: now
158});
159
160export const isOver = (run: SyncRun): boolean => run.endedAt !== null;
161
162export const toHistory = (run: SyncRun): SyncHistoryEntry => ({
163 endedAt: run.endedAt ?? run.startedAt,
164 totalMs: (run.endedAt ?? run.startedAt) - run.startedAt,
165 after: run.after
166});
167
168export const appendHistory = (history: readonly SyncHistoryEntry[], entry: SyncHistoryEntry): SyncHistoryEntry[] =>
169 [...history, entry].slice(-HISTORY_LIMIT);
170
171export const parseHistory = (stored: unknown): SyncHistoryEntry[] =>
172 Array.isArray(stored)
173 ? stored.filter((entry): entry is SyncHistoryEntry => isRecord(entry) && 'totalMs' in entry)
174 : [];
175
176export const averageMs = (history: readonly SyncHistoryEntry[]): number | null =>
177 history.length ? Math.round(history.reduce((sum, entry) => sum + entry.totalMs, 0) / history.length) : null;
178
179/**
180 * The store key the finished runs are kept under, read by any feature that quotes a typical sync.
181 */
182export const SYNC_HISTORY_KEY = 'sync-history';
183
184/**
185 * One run is a sample, not a typical time.
186 */
187export const typicalMs = (history: readonly SyncHistoryEntry[]): number | null =>
188 history.length > 1 ? averageMs(history) : null;
189
190const backlogTotal = (backlog: CompileBacklog | null): number | null =>
191 backlog === null ? null : backlog.sessions + backlog.feedback + backlog.inbox;
192
193/**
194 * What the `sync_stats` tool answers: counted numbers the report quotes instead of a tally.
195 */
196export const statsPayload = (run: SyncRun, now: number, history: readonly SyncHistoryEntry[]) => {
197 const last = history.at(-1);
198 return {
199 elapsedMs: now - run.startedAt,
200 phase: PHASES[run.phase]?.id ?? null,
201 phaseTimings: run.timings,
202 actions: run.actions,
203 compileBacklog: {
204 before: backlogTotal(run.before?.compile ?? null),
205 after: backlogTotal(run.after?.compile ?? null)
206 },
207 tasks: { before: run.before?.tasks ?? null, after: run.after?.tasks ?? null },
208 brain: { before: run.before?.brain ?? null, after: run.after?.brain ?? null },
209 lastRun: last
210 ? { endedAt: new Date(last.endedAt).toISOString(), totalMs: last.totalMs, brain: last.after?.brain ?? null }
211 : null,
212 averageMs: averageMs(history)
213 };
214};
215
216export const formatDuration = (ms: number): string => {
217 const seconds = Math.max(0, Math.round(ms / 1000));
218 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`;
219};
220
221/**
222 * How much of a starting count the run has cleared so far, and how far it grew if it did.
223 */
224export interface SubProgress {
225 label: string;
226 cleared: number;
227 total: number;
228 grew: number;
229}
230
231const subProgress = (label: string, before: number | null | undefined, now: number | null | undefined) => {
232 if (before === null || before === undefined) {
233 return null;
234 }
235 const latest = now ?? before;
236 return { label, cleared: Math.max(0, before - latest), total: before, grew: Math.max(0, latest - before) };
237};
238
239/**
240 * The band's counters, each measured against the run's start; counts that began at zero and
241 * stayed there are left out.
242 */
243export const progressOf = (run: SyncRun): SubProgress[] =>
244 [
245 subProgress('sessions', run.before?.compile?.sessions, run.after?.compile?.sessions),
246 subProgress('feedback', run.before?.compile?.feedback, run.after?.compile?.feedback),
247 subProgress('inbox', run.before?.compile?.inbox, run.after?.compile?.inbox),
248 subProgress('stale', run.before?.brain?.staleTasks, run.after?.brain?.staleTasks),
249 subProgress('overdue', run.before?.tasks?.overdue, run.after?.tasks?.overdue)
250 ].filter((item): item is SubProgress => item !== null && (item.total > 0 || item.grew > 0));
251
252/**
253 * What the run did, for the band's tail.
254 */
255export const actionNotes = (actions: SyncActions): { label: string; count: number }[] =>
256 [
257 { label: 'compiled', count: actions.compiled },
258 { label: 'lint fixed', count: actions.lintFixed },
259 { label: 'closed', count: actions.tasksClosed }
260 ].filter((item) => item.count > 0);
261
262/**
263 * A fresh partial measurement laid over the latest one.
264 */
265export const remeasured = (run: SyncRun, fresh: Partial<SyncSnapshot>): SyncRun => ({
266 ...run,
267 after: { compile: null, tasks: null, brain: null, ...(run.after ?? run.before), ...fresh }
268});
269
270/**
271 * The paragraph appended to sync's prompt when the stats tool is registered.
272 */
273export const statsInstruction = (toolName: string): string =>
274 [
275 '',
276 '## Counted numbers (this session)',
277 '',
278 `Before writing the output block, call \`${toolName}\` once and quote its numbers for the compile backlog, the 🧹 Brain counts, task health and timing, instead of tallying them yourself. Its \`before\`/\`after\` pairs are measured at the start of this run and at the call.`
279 ].join('\n');
280sync/phases.ts 97 lines1// Pure data and reducers, no 'claude-code' import: mcp-server's bun suite imports this file to
2// check the table against skills/sync/SKILL.md.
3
4interface Phase {
5 id: string;
6 tools: readonly string[];
7 scripts: readonly string[];
8 skills: readonly string[];
9 reports: readonly ReportMatch[];
10}
11
12interface ReportMatch {
13 category: string;
14 skill?: string;
15}
16
17interface ToolCall {
18 tool: string;
19 input: Record<string, unknown>;
20}
21
22const phase = (id: string, parts: Partial<Omit<Phase, 'id'>>): Phase => ({
23 id,
24 tools: parts.tools ?? [],
25 scripts: parts.scripts ?? [],
26 skills: parts.skills ?? [],
27 reports: parts.reports ?? []
28});
29
30/**
31 * Sync's steps in order, keyed by the tools and scripts each one calls.
32 */
33export const PHASES: readonly Phase[] = [
34 phase('code', { tools: ['github_fast_forward'] }),
35 phase('compile', { tools: ['compile_status', 'compile_next', 'compile_write'] }),
36 phase('lint', { tools: ['knowledge_lint'] }),
37 phase('prune', { tools: ['memory_prune'] }),
38 phase('links', { tools: ['links_rewrite'] }),
39 phase('flywheel', {
40 tools: ['task_query', 'task_get', 'task_update', 'task_thread', 'task_close', 'task_create'],
41 reports: [{ category: 'briefings', skill: 'flywheel' }]
42 }),
43 phase('attention', { tools: ['task_scan'], scripts: ['cadence.ts', 'brain-audit.ts'] }),
44 phase('briefing', {
45 tools: ['focus_write', 'web_search', 'WebSearch'],
46 reports: [{ category: 'briefings' }]
47 }),
48 phase('radar', { skills: ['focus'], reports: [{ category: 'radar' }] }),
49 phase('dashboard', { tools: ['dashboard'] }),
50 phase('history', { scripts: ['commit-brain.ts'] }),
51 phase('next', {
52 tools: ['AskUserQuestion'],
53 scripts: ['review-defer.ts'],
54 skills: ['goals', 'roadmap', 'self-review']
55 })
56];
57
58/**
59 * A call's bare tool name: the part after `__` for this plugin's MCP tools, the name itself otherwise.
60 */
61export const bareToolName = (tool: string, pluginName: string): string =>
62 tool.startsWith(`mcp__plugin_${pluginName}_`) ? tool.slice(tool.lastIndexOf('__') + 2) : tool;
63
64const scriptIn = (command: unknown): string | undefined =>
65 typeof command === 'string' ? /\/scripts\/([\w-]+\.ts)/.exec(command)?.[1] : undefined;
66
67const skillIn = (skill: unknown): string | undefined =>
68 typeof skill === 'string' ? skill.slice(skill.indexOf(':') + 1) : undefined;
69
70const reportMatches = (match: ReportMatch, input: Record<string, unknown>): boolean =>
71 input.category === match.category && (match.skill === undefined || input.skill === match.skill);
72
73const phaseMatches = (candidate: Phase, name: string, input: Record<string, unknown>): boolean => {
74 if (name === 'Bash') {
75 const script = scriptIn(input.command);
76 return script !== undefined && candidate.scripts.includes(script);
77 }
78 if (name === 'Skill') {
79 const skill = skillIn(input.skill);
80 return skill !== undefined && candidate.skills.includes(skill);
81 }
82 if (name === 'report_write') {
83 return candidate.reports.some((match) => reportMatches(match, input));
84 }
85 return candidate.tools.includes(name);
86};
87
88/**
89 * The first phase whose signals match the call, or undefined. The flywheel's report names its
90 * skill and comes first, so it is never read as the briefing.
91 */
92export const classify = (call: ToolCall, pluginName: string): number | undefined => {
93 const name = bareToolName(call.tool, pluginName);
94 const index = PHASES.findIndex((candidate) => phaseMatches(candidate, name, call.input));
95 return index === -1 ? undefined : index;
96};
97types/index.d.ts 123 lines1export interface AgendaItem {
2 id: string;
3 priority: string;
4 title: string;
5 daysLate: number;
6}
7
8export interface TodayView {
9 emoji: string;
10 dateLabel: string;
11 hijriLabel: string;
12 overdue: AgendaItem[];
13 dueToday: AgendaItem[];
14 syncAgeHours: number | null;
15}
16
17export interface CompileBacklog {
18 sessions: number;
19 feedback: number;
20 inbox: number;
21}
22
23export interface TaskHealth {
24 overdue: number;
25 stale: number;
26 dueSoon: number;
27}
28
29export interface BrainCounts {
30 staleTasks: number;
31 dormantTasks: number;
32 activeOld: number;
33 dormantProjects: number;
34 memoryLines: number;
35 decisions: number;
36 staleArticles: number;
37 oldCaptureFiles: number;
38 oldestWaiting: string | null;
39}
40
41export interface SyncSnapshot {
42 compile: CompileBacklog | null;
43 tasks: TaskHealth | null;
44 brain: BrainCounts | null;
45}
46
47export interface SyncActions {
48 reposUpdated: number;
49 compiled: number;
50 lintFixed: number;
51 lintErrors: number;
52 tasksClosed: number;
53 tasksUpdated: number;
54 threads: number;
55 tasksCreated: number;
56}
57
58export interface PhaseTiming {
59 phase: string;
60 ms: number;
61}
62
63export interface SyncRun {
64 status: 'running' | 'waiting' | 'done' | 'stopped';
65 startedAt: number;
66 phase: number;
67 phaseStartedAt: number;
68 timings: PhaseTiming[];
69 actions: SyncActions;
70 before: SyncSnapshot | null;
71 after: SyncSnapshot | null;
72 endedAt: number | null;
73}
74
75export interface SyncHistoryEntry {
76 endedAt: number;
77 totalMs: number;
78 after: SyncSnapshot | null;
79}
80
81/**
82 * One entry the CLI's `notices` prints, mirrored from mcp-server/src/notices/notices.ts (a mod can't import Node code).
83 */
84export interface NoticeFact {
85 text: string;
86 tone?: 'accent' | 'warn';
87}
88
89export interface Notice {
90 id: string;
91 level: 'hint' | 'nudge' | 'alert';
92 pinned: boolean;
93 icon: string;
94 label: string;
95 title: string;
96 facts: NoticeFact[];
97 command: string;
98 actionLabel: string;
99}
100
101/**
102 * A notice whose command was pressed (`isStarted` false until its skill starts) or is running.
103 */
104export interface NoticeActing {
105 id: string;
106 isStarted: boolean;
107 at: number;
108}
109
110declare module 'claude-code' {
111 interface PluginState {
112 'agent-kevin': {
113 syncRun: SyncRun | null;
114 delivered: string[];
115 manualsThisTurn: string[];
116 todayViews: Record<string, TodayView>;
117 syncTick: number;
118 notices: Notice[];
119 noticeActing: NoticeActing | null;
120 };
121 }
122}
123