@falkenslab/agent-kit 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +3 -1
  2. package/assets/knowledge-plugin/.claude-plugin/plugin.json +1 -1
  3. package/assets/knowledge-plugin/commands/ingest.md +1 -1
  4. package/assets/knowledge-plugin/commands/lint.md +1 -1
  5. package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +13 -19
  6. package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +18 -14
  7. package/assets/knowledge-plugin/skills/knowledge-query/SKILL.md +5 -5
  8. package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +4 -0
  9. package/assets/knowledge-plugin-files/commands/ingest.md +7 -0
  10. package/assets/knowledge-plugin-files/commands/lint.md +5 -0
  11. package/assets/knowledge-plugin-files/commands/query.md +5 -0
  12. package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +46 -0
  13. package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +26 -0
  14. package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +14 -0
  15. package/dist/core/agentSpec.d.ts +27 -2
  16. package/dist/core/extractText.d.ts +13 -0
  17. package/dist/core/extractText.js +147 -0
  18. package/dist/core/fileKnowledgeStore.d.ts +10 -0
  19. package/dist/core/fileKnowledgeStore.js +395 -0
  20. package/dist/core/hooks/fileScopeGate.d.ts +8 -0
  21. package/dist/core/hooks/fileScopeGate.js +20 -0
  22. package/dist/core/hooks/humanInput.d.ts +18 -0
  23. package/dist/core/hooks/humanInput.js +30 -2
  24. package/dist/core/hooks/planGate.js +14 -5
  25. package/dist/core/hooks/stepGate.js +6 -0
  26. package/dist/core/interaction.d.ts +20 -0
  27. package/dist/core/knowledge.d.ts +14 -4
  28. package/dist/core/knowledge.js +36 -6
  29. package/dist/core/knowledgeStore.d.ts +139 -0
  30. package/dist/core/knowledgeStore.js +84 -0
  31. package/dist/core/messages/de.js +65 -1
  32. package/dist/core/messages/en.d.ts +39 -0
  33. package/dist/core/messages/en.js +65 -1
  34. package/dist/core/messages/es.js +65 -1
  35. package/dist/core/messages/fr.js +65 -1
  36. package/dist/core/modeControl.d.ts +36 -0
  37. package/dist/core/modeControl.js +61 -0
  38. package/dist/core/session.d.ts +6 -31
  39. package/dist/core/session.js +81 -71
  40. package/dist/core/sources.d.ts +122 -0
  41. package/dist/core/sources.js +312 -0
  42. package/dist/core/todos.d.ts +23 -0
  43. package/dist/core/todos.js +33 -0
  44. package/dist/core/toolLabels.d.ts +1 -1
  45. package/dist/core/toolLabels.js +42 -2
  46. package/dist/core/tools/humanApproval.d.ts +9 -5
  47. package/dist/core/tools/humanApproval.js +39 -7
  48. package/dist/core/tools/knowledgeTools.d.ts +16 -0
  49. package/dist/core/tools/knowledgeTools.js +168 -0
  50. package/dist/core/tools/saveToSources.d.ts +27 -8
  51. package/dist/core/tools/saveToSources.js +217 -55
  52. package/dist/core/tools/time.d.ts +29 -0
  53. package/dist/core/tools/time.js +129 -0
  54. package/dist/core/version.d.ts +5 -0
  55. package/dist/core/version.js +12 -0
  56. package/dist/index.d.ts +7 -3
  57. package/dist/index.js +5 -1
  58. package/dist/tui/chatTui.js +14 -1
  59. package/dist/tui/consoleRenderer.js +16 -2
  60. package/dist/tui/ink/SessionView.js +67 -17
  61. package/dist/tui/ink/inkInteraction.d.ts +4 -2
  62. package/dist/tui/ink/inkInteraction.js +20 -2
  63. package/dist/tui/ink/runChatInk.js +5 -0
  64. package/dist/tui/ink/sessionModel.d.ts +3 -0
  65. package/dist/tui/ink/sessionModel.js +14 -1
  66. package/dist/tui/terminalInteraction.js +2 -0
  67. package/package.json +31 -1
  68. /package/assets/{knowledge-plugin → knowledge-plugin-files}/skills/knowledge-pages/SKILL.md +0 -0
@@ -0,0 +1,312 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { copyFile, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ /**
6
+ * The sources folder's bookkeeping: originals stay files (ADR-024), and the kit keeps a
7
+ * manifest next to them (`.agent-kit/sources.json`, inside `sourcesDir`, where the agent's
8
+ * file tools can't write) with each original's hash, size and provenance, the version it
9
+ * replaces, and the hash it had when a summary page first pointed to it. From that it tells
10
+ * new, ingested, changed and missing originals apart, refuses duplicates, and retires an
11
+ * original into `.agent-kit/retired/` instead of deleting it.
12
+ */
13
+ export const KIT_DIR = ".agent-kit";
14
+ const MANIFEST = "sources.json";
15
+ const RETIRED_DIR = "retired";
16
+ const posix = (p) => p.split(path.sep).join("/");
17
+ // The model can call several tools at once: everything that reads and writes a folder's
18
+ // manifest runs one at a time per folder, or a parallel call would lose another's entry.
19
+ const locks = new Map();
20
+ function serialized(sourcesDir, work) {
21
+ const key = path.resolve(sourcesDir);
22
+ const result = (locks.get(key) ?? Promise.resolve()).then(work, work);
23
+ locks.set(key, result.catch(() => undefined));
24
+ return result;
25
+ }
26
+ const now = () => new Date().toISOString();
27
+ export async function hashFile(file) {
28
+ return createHash("sha256").update(await readFile(file)).digest("hex");
29
+ }
30
+ /** Resolves `candidate` against `base`; the absolute path only if it stays inside it. */
31
+ export function resolveWithin(base, candidate) {
32
+ const resolved = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(base, candidate);
33
+ const relative = path.relative(base, resolved);
34
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved : undefined;
35
+ }
36
+ export async function loadManifest(sourcesDir) {
37
+ try {
38
+ const parsed = JSON.parse(await readFile(path.join(sourcesDir, KIT_DIR, MANIFEST), "utf8"));
39
+ return { version: 1, files: parsed.files ?? {}, retired: parsed.retired ?? [] };
40
+ }
41
+ catch {
42
+ return { version: 1, files: {}, retired: [] };
43
+ }
44
+ }
45
+ export async function saveManifest(sourcesDir, manifest) {
46
+ await mkdir(path.join(sourcesDir, KIT_DIR), { recursive: true });
47
+ await writeFile(path.join(sourcesDir, KIT_DIR, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
48
+ }
49
+ /** Every original under `sourcesDir` (paths relative to it), leaving out hidden files and folders (the kit's own among them). */
50
+ export async function scanSources(sourcesDir) {
51
+ const found = [];
52
+ async function walk(dir) {
53
+ let entries;
54
+ try {
55
+ entries = await readdir(dir, { withFileTypes: true });
56
+ }
57
+ catch {
58
+ return;
59
+ }
60
+ for (const entry of entries) {
61
+ if (entry.name.startsWith("."))
62
+ continue;
63
+ const full = path.join(dir, entry.name);
64
+ if (entry.isDirectory())
65
+ await walk(full);
66
+ else if (entry.isFile())
67
+ found.push(posix(path.relative(sourcesDir, full)));
68
+ }
69
+ }
70
+ await walk(sourcesDir);
71
+ return found.sort();
72
+ }
73
+ /** An entry for `file`, reusing the recorded hash when its size and modification time haven't changed. */
74
+ async function freshEntry(file, known, origin) {
75
+ const info = await stat(file);
76
+ if (known && known.size === info.size && known.mtimeMs === info.mtimeMs)
77
+ return known;
78
+ return { ...(known ?? { addedAt: now(), origin }), hash: await hashFile(file), size: info.size, mtimeMs: info.mtimeMs };
79
+ }
80
+ /** The original a frontmatter `file:` value points to (a relative path or a markdown link), resolved from the page's folder. */
81
+ function linkedFile(value, pageDir) {
82
+ const link = /\]\(([^)]+)\)/.exec(value)?.[1] ?? value.trim().replace(/^["']|["']$/g, "");
83
+ if (!link || /^[a-z]+:\/\//i.test(link))
84
+ return undefined;
85
+ return path.resolve(pageDir, decodeURI(link));
86
+ }
87
+ /** The summary pages (knowledge base layout: `summaries/*.md`) by the absolute path of the original their frontmatter `file:` points to. */
88
+ export async function summariesByOriginal(knowledgeDir) {
89
+ const byOriginal = new Map();
90
+ const dir = path.join(knowledgeDir, "summaries");
91
+ let names;
92
+ try {
93
+ names = await readdir(dir);
94
+ }
95
+ catch {
96
+ return byOriginal;
97
+ }
98
+ for (const name of names.filter((n) => n.endsWith(".md"))) {
99
+ const page = path.join(dir, name);
100
+ const text = await readFile(page, "utf8");
101
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? "";
102
+ const value = /^file:\s*(.+)$/m.exec(frontmatter)?.[1];
103
+ const original = value ? linkedFile(value, dir) : undefined;
104
+ if (original)
105
+ byOriginal.set(path.normalize(original), [...(byOriginal.get(path.normalize(original)) ?? []), page]);
106
+ }
107
+ return byOriginal;
108
+ }
109
+ /** Pages of a PDF, read from its page tree without a parser; undefined when it can't tell (e.g. compressed object streams). */
110
+ export function pdfPageCount(bytes) {
111
+ const text = bytes.toString("latin1");
112
+ const counts = [...text.matchAll(/\/Type\s*\/Pages\b[^>]*?\/Count\s+(\d+)/g)].map((m) => Number(m[1]));
113
+ const reversed = [...text.matchAll(/\/Count\s+(\d+)[^>]*?\/Type\s*\/Pages\b/g)].map((m) => Number(m[1]));
114
+ const fromTree = Math.max(0, ...counts, ...reversed);
115
+ if (fromTree > 0)
116
+ return fromTree;
117
+ const pages = text.match(/\/Type\s*\/Page(?![s\w])/g)?.length ?? 0;
118
+ return pages > 0 ? pages : undefined;
119
+ }
120
+ /** Slides of a PPTX, from the file names in the zip (stored uncompressed). */
121
+ export function pptxSlideCount(bytes) {
122
+ const names = new Set(bytes.toString("latin1").match(/ppt\/slides\/slide\d+\.xml/g) ?? []);
123
+ return names.size > 0 ? names.size : undefined;
124
+ }
125
+ const typeOf = (file) => path.extname(file).slice(1).toLowerCase() || "file";
126
+ /**
127
+ * Every original with its status, recording in the manifest the ones copied in by hand and,
128
+ * the first time a summary page points to one, its hash at ingest. Without a knowledge
129
+ * folder there's no ingest to compare with: originals are "present".
130
+ */
131
+ export function listSources(sourcesDir, knowledgeDir, projectDir) {
132
+ return serialized(sourcesDir, () => listSourcesNow(sourcesDir, knowledgeDir, projectDir));
133
+ }
134
+ async function listSourcesNow(sourcesDir, knowledgeDir, projectDir) {
135
+ const manifest = await loadManifest(sourcesDir);
136
+ const summaries = knowledgeDir ? await summariesByOriginal(knowledgeDir) : new Map();
137
+ const files = await scanSources(sourcesDir);
138
+ const listing = [];
139
+ for (const rel of files) {
140
+ const full = path.join(sourcesDir, rel);
141
+ const entry = await freshEntry(full, manifest.files[rel], { kind: "manual" });
142
+ const pagesOf = summaries.get(path.normalize(full)) ?? [];
143
+ let status = "present";
144
+ if (knowledgeDir) {
145
+ if (pagesOf.length === 0)
146
+ status = "new";
147
+ else {
148
+ entry.ingestedHash ??= entry.hash;
149
+ status = entry.ingestedHash === entry.hash ? "ingested" : "changed";
150
+ }
151
+ }
152
+ manifest.files[rel] = entry;
153
+ const type = typeOf(rel);
154
+ const counts = type === "pdf" || type === "pptx"
155
+ ? await readFile(full).then((bytes) => (type === "pdf" ? { pages: pdfPageCount(bytes) } : { slides: pptxSlideCount(bytes) }))
156
+ : {};
157
+ listing.push({
158
+ path: rel,
159
+ status,
160
+ type,
161
+ size: entry.size,
162
+ ...counts,
163
+ origin: entry.origin,
164
+ ...(entry.replaces ? { replaces: entry.replaces } : {}),
165
+ summaries: pagesOf.map((page) => posix(path.relative(projectDir, page))),
166
+ });
167
+ }
168
+ for (const [rel, entry] of Object.entries(manifest.files)) {
169
+ if (!files.includes(rel))
170
+ listing.push({ path: rel, status: "missing", type: typeOf(rel), origin: entry.origin, summaries: [] });
171
+ }
172
+ await saveManifest(sourcesDir, manifest);
173
+ return listing;
174
+ }
175
+ /**
176
+ * Adds `file` (an absolute path) to `sourcesDir` at `destination` (a folder: under `extra.name`,
177
+ * or the file's own name): never overwriting, and
178
+ * not at all if an identical file is already there (its path is returned). `replaces`
179
+ * records it as a new version of an original already in the folder.
180
+ */
181
+ export function addSource(sourcesDir, file, destination, origin, extra = {}) {
182
+ return serialized(sourcesDir, () => addSourceNow(sourcesDir, file, destination, origin, extra));
183
+ }
184
+ async function addSourceNow(sourcesDir, file, destination, origin, extra) {
185
+ // A folder ("books/", or one that exists) means: inside it, under the file's own name.
186
+ const isFolder = /[/\\]$/.test(destination) || (await stat(path.join(sourcesDir, destination)).catch(() => null))?.isDirectory();
187
+ if (isFolder)
188
+ destination = path.join(destination, extra.name ?? path.basename(file));
189
+ const target = resolveWithin(sourcesDir, destination);
190
+ if (!target || posix(path.relative(sourcesDir, target)).split("/").some((part) => part.startsWith("."))) {
191
+ throw new Error(`"${destination}" isn't a path inside sources/ (hidden folders are the kit's).`);
192
+ }
193
+ const manifest = await loadManifest(sourcesDir);
194
+ if (extra.replaces) {
195
+ const old = resolveWithin(sourcesDir, extra.replaces);
196
+ if (!old || !(await stat(old).catch(() => null)))
197
+ throw new Error(`"${extra.replaces}" isn't an original in sources/, so it can't be replaced.`);
198
+ }
199
+ const hash = await hashFile(file);
200
+ for (const rel of await scanSources(sourcesDir)) {
201
+ const entry = await freshEntry(path.join(sourcesDir, rel), manifest.files[rel], { kind: "manual" });
202
+ manifest.files[rel] = entry;
203
+ if (entry.hash === hash) {
204
+ await saveManifest(sourcesDir, manifest);
205
+ return { path: rel, duplicateOf: rel };
206
+ }
207
+ }
208
+ await mkdir(path.dirname(target), { recursive: true });
209
+ try {
210
+ await copyFile(file, target, constants.COPYFILE_EXCL);
211
+ }
212
+ catch (error) {
213
+ if (error.code === "EEXIST") {
214
+ throw new Error(`"${destination}" already exists in sources/: originals are never overwritten. Pick another name${extra.replaces ? "" : ", or pass `replaces` for a new version"}.`, {
215
+ cause: error,
216
+ });
217
+ }
218
+ throw error;
219
+ }
220
+ const rel = posix(path.relative(sourcesDir, target));
221
+ const info = await stat(target);
222
+ manifest.files[rel] = {
223
+ hash,
224
+ size: info.size,
225
+ mtimeMs: info.mtimeMs,
226
+ addedAt: now(),
227
+ origin,
228
+ ...(extra.replaces ? { replaces: posix(path.relative(sourcesDir, resolveWithin(sourcesDir, extra.replaces))) } : {}),
229
+ ...(extra.derivedFrom ? { derivedFrom: extra.derivedFrom } : {}),
230
+ };
231
+ await saveManifest(sourcesDir, manifest);
232
+ return { path: rel };
233
+ }
234
+ /** Adds frontmatter fields and a notice under the frontmatter of a markdown page. */
235
+ async function markPage(page, fields, notice) {
236
+ const text = await readFile(page, "utf8");
237
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
238
+ const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`);
239
+ const updated = match
240
+ ? `---\n${match[1].split(/\r?\n/).filter((line) => !Object.keys(fields).some((key) => line.startsWith(`${key}:`))).concat(lines).join("\n")}\n---\n\n> ${notice}\n\n${text.slice(match[0].length).replace(/^\s+/, "")}`
241
+ : `---\n${lines.join("\n")}\n---\n\n> ${notice}\n\n${text}`;
242
+ await writeFile(page, updated);
243
+ }
244
+ /**
245
+ * Moves an original to `.agent-kit/retired/` (never deleted: the person empties that folder)
246
+ * and records why. Its summary pages are marked: retired if the original was wrong (what it
247
+ * taught was wrong), superseded if it was replaced, pointing to the replacement's summary
248
+ * when there is one.
249
+ */
250
+ export function retireSource(sourcesDir, knowledgeDir, projectDir, source, why, reason, replacedBy) {
251
+ return serialized(sourcesDir, () => retireSourceNow(sourcesDir, knowledgeDir, projectDir, source, why, reason, replacedBy));
252
+ }
253
+ async function retireSourceNow(sourcesDir, knowledgeDir, projectDir, source, why, reason, replacedBy) {
254
+ const full = resolveWithin(sourcesDir, source);
255
+ if (!full || !(await stat(full).catch(() => null))?.isFile())
256
+ throw new Error(`"${source}" isn't an original in sources/.`);
257
+ const rel = posix(path.relative(sourcesDir, full));
258
+ if (rel.split("/").some((part) => part.startsWith(".")))
259
+ throw new Error(`"${source}" is one of the kit's own files.`);
260
+ const replacement = replacedBy ? resolveWithin(sourcesDir, replacedBy) : undefined;
261
+ if (replacedBy && (!replacement || !(await stat(replacement).catch(() => null))))
262
+ throw new Error(`"${replacedBy}" isn't an original in sources/.`);
263
+ const summaries = knowledgeDir ? await summariesByOriginal(knowledgeDir) : new Map();
264
+ const manifest = await loadManifest(sourcesDir);
265
+ const stamp = now();
266
+ const movedTo = posix(path.join(KIT_DIR, RETIRED_DIR, `${stamp.replace(/[:.]/g, "-")}-${path.basename(rel)}`));
267
+ await mkdir(path.join(sourcesDir, KIT_DIR, RETIRED_DIR), { recursive: true });
268
+ await rename(full, path.join(sourcesDir, movedTo));
269
+ manifest.retired.push({
270
+ path: rel,
271
+ movedTo,
272
+ retiredAt: stamp,
273
+ why,
274
+ reason,
275
+ ...(replacement ? { replacedBy: posix(path.relative(sourcesDir, replacement)) } : {}),
276
+ ...(manifest.files[rel] ? { entry: manifest.files[rel] } : {}),
277
+ });
278
+ delete manifest.files[rel];
279
+ await saveManifest(sourcesDir, manifest);
280
+ const date = stamp.slice(0, 10);
281
+ const replacementSummary = replacement ? summaries.get(path.normalize(replacement))?.[0] : undefined;
282
+ const result = { movedTo, summaries: [] };
283
+ for (const page of summaries.get(path.normalize(full)) ?? []) {
284
+ if (why === "wrong") {
285
+ await markPage(page, { status: "retired", retired: date }, `Retired on ${date}: its original was wrong (${reason}). Don't rely on what it says.`);
286
+ result.summaries.push({ page: posix(path.relative(projectDir, page)), marked: "retired" });
287
+ }
288
+ else {
289
+ const link = replacementSummary ? posix(path.relative(path.dirname(page), replacementSummary)) : undefined;
290
+ await markPage(page, { status: "superseded", superseded: date, ...(link ? { superseded_by: link } : {}) }, `Superseded on ${date}: its original was replaced (${reason})${link ? `; see [the new summary](${link})` : ""}.`);
291
+ result.summaries.push({ page: posix(path.relative(projectDir, page)), marked: "superseded", ...(replacementSummary ? { supersededBy: posix(path.relative(projectDir, replacementSummary)) } : {}) });
292
+ }
293
+ }
294
+ return result;
295
+ }
296
+ /**
297
+ * Records `source` (relative to `sourcesDir`) as ingested now, with its current hash: a
298
+ * summary page was just written from it, so "changed" means changed after this.
299
+ */
300
+ export function recordIngest(sourcesDir, source) {
301
+ return serialized(sourcesDir, async () => {
302
+ const full = resolveWithin(sourcesDir, source);
303
+ if (!full || !(await stat(full).catch(() => null))?.isFile())
304
+ return;
305
+ const rel = posix(path.relative(sourcesDir, full));
306
+ const manifest = await loadManifest(sourcesDir);
307
+ const entry = await freshEntry(full, manifest.files[rel], { kind: "manual" });
308
+ entry.ingestedHash = entry.hash;
309
+ manifest.files[rel] = entry;
310
+ await saveManifest(sourcesDir, manifest);
311
+ });
312
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The SDK's `TodoWrite` tool: the model keeps a task list on long jobs, sending the whole
3
+ * list with every call (confirmed empirically: an `action` event whose input is
4
+ * `{ todos: [{ content, status, activeForm }] }`, and a result that only says it was saved).
5
+ * The chats show the list instead of the calls.
6
+ */
7
+ export type TodoStatus = "pending" | "in_progress" | "completed";
8
+ /** One task: what to do, in the imperative ("Prepare the slides"), and while it's done ("Preparing the slides"). */
9
+ export interface Todo {
10
+ content: string;
11
+ status: TodoStatus;
12
+ activeForm: string;
13
+ }
14
+ export declare const TODO_TOOL = "TodoWrite";
15
+ /** The list in a `TodoWrite` call's input, or `null` if it isn't one. */
16
+ export declare function parseTodos(input: unknown): Todo[] | null;
17
+ /** Whether any task is still to do. */
18
+ export declare function hasOpenTodos(todos: readonly Todo[] | null): boolean;
19
+ /** The tasks that started and the ones finished from `before` to `after`, matched by their text. */
20
+ export declare function todoChanges(before: readonly Todo[], after: readonly Todo[]): {
21
+ started: Todo[];
22
+ completed: Todo[];
23
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The SDK's `TodoWrite` tool: the model keeps a task list on long jobs, sending the whole
3
+ * list with every call (confirmed empirically: an `action` event whose input is
4
+ * `{ todos: [{ content, status, activeForm }] }`, and a result that only says it was saved).
5
+ * The chats show the list instead of the calls.
6
+ */
7
+ export const TODO_TOOL = "TodoWrite";
8
+ const STATUSES = new Set(["pending", "in_progress", "completed"]);
9
+ /** The list in a `TodoWrite` call's input, or `null` if it isn't one. */
10
+ export function parseTodos(input) {
11
+ const todos = input?.todos;
12
+ if (!Array.isArray(todos))
13
+ return null;
14
+ return todos
15
+ .filter((todo) => Boolean(todo) && typeof todo === "object")
16
+ .map((todo) => ({
17
+ content: String(todo.content ?? ""),
18
+ status: (STATUSES.has(String(todo.status)) ? todo.status : "pending"),
19
+ activeForm: String(todo.activeForm ?? todo.content ?? ""),
20
+ }));
21
+ }
22
+ /** Whether any task is still to do. */
23
+ export function hasOpenTodos(todos) {
24
+ return Boolean(todos?.some((todo) => todo.status !== "completed"));
25
+ }
26
+ /** The tasks that started and the ones finished from `before` to `after`, matched by their text. */
27
+ export function todoChanges(before, after) {
28
+ const previous = new Map(before.map((todo) => [todo.content, todo.status]));
29
+ return {
30
+ started: after.filter((todo) => todo.status === "in_progress" && previous.get(todo.content) !== "in_progress"),
31
+ completed: after.filter((todo) => todo.status === "completed" && previous.get(todo.content) !== "completed"),
32
+ };
33
+ }
@@ -21,7 +21,7 @@ export type ToolDescriber = (shortName: string, input: Record<string, unknown>)
21
21
  * Builds a `friendlyToolLabel(toolName, toolInput)` — `describe` lets the host agent
22
22
  * layer its own domain-specific cases (e.g. Playwright's browser_* tools) on top of this
23
23
  * kit's generic ones; `extraLocalServers` names any *additional* MCP server (beyond this
24
- * kit's own "approvals"/"manualLogin"/"sourceFiles") whose tools should be unwrapped
24
+ * kit's own "approvals"/"manualLogin"/"sourceFiles"/"time") whose tools should be unwrapped
25
25
  * without a "[server] " prefix, e.g. "playwright".
26
26
  */
27
27
  export declare function createFriendlyToolLabel(options?: {
@@ -27,8 +27,46 @@ function describeCore(shortName, input) {
27
27
  switch (shortName) {
28
28
  case "request_human_approval":
29
29
  return labels.askingApproval(truncate(text(input.summary, ""), 120));
30
+ case "ask_human":
31
+ return labels.askingHuman(truncate(text(input.question, ""), 100));
32
+ case "present_plan":
33
+ return labels.presentingPlan;
30
34
  case "request_manual_login":
31
35
  return labels.waitingManual;
36
+ case "current_time":
37
+ return labels.checkingTime;
38
+ case "date_math":
39
+ return labels.calculatingDates;
40
+ case "knowledge_index":
41
+ return labels.knowledgeIndex;
42
+ case "knowledge_search":
43
+ return labels.knowledgeSearch(truncate(text(input.query, ""), 60));
44
+ case "knowledge_read":
45
+ return labels.knowledgeRead(text(input.page, labels.aPage));
46
+ case "knowledge_create":
47
+ return labels.knowledgeCreate(`${text(input.type, "")}/${text(input.slug, "")}`);
48
+ case "knowledge_edit":
49
+ return labels.knowledgeEdit(text(input.page, labels.aPage));
50
+ case "knowledge_rewrite":
51
+ return labels.knowledgeRewrite(text(input.page, labels.aPage));
52
+ case "knowledge_supersede":
53
+ return labels.knowledgeSupersede(text(input.page, labels.aPage));
54
+ case "knowledge_retire":
55
+ return labels.knowledgeRetire(text(input.page, labels.aPage));
56
+ case "knowledge_log":
57
+ return labels.knowledgeLog;
58
+ case "knowledge_check":
59
+ return labels.knowledgeCheck;
60
+ case "list_sources":
61
+ return labels.listingSources;
62
+ case "extract_text":
63
+ return labels.extractingText(truncatePath(text(input.source, labels.aFile), 70));
64
+ case "download_to_sources":
65
+ return labels.downloading(truncate(text(input.url, labels.aPage), 80));
66
+ case "request_file":
67
+ return labels.requestingFile(truncate(text(input.description, ""), 100));
68
+ case "retire_source":
69
+ return labels.retiringSource(truncatePath(text(input.source, ""), 70));
32
70
  case "save_to_sources":
33
71
  return labels.savingToSources(truncatePath(text(input.destination, ""), 70));
34
72
  case "Read":
@@ -59,6 +97,8 @@ function describeCore(shortName, input) {
59
97
  // explicitly in `tools`). Confirmed empirically: its input is `{ skill: "name" }`.
60
98
  case "Skill":
61
99
  return labels.applyingSkill(text(input.skill, labels.aSkill));
100
+ case "TodoWrite":
101
+ return labels.updatingTasks;
62
102
  default:
63
103
  return undefined;
64
104
  }
@@ -67,11 +107,11 @@ function describeCore(shortName, input) {
67
107
  * Builds a `friendlyToolLabel(toolName, toolInput)` — `describe` lets the host agent
68
108
  * layer its own domain-specific cases (e.g. Playwright's browser_* tools) on top of this
69
109
  * kit's generic ones; `extraLocalServers` names any *additional* MCP server (beyond this
70
- * kit's own "approvals"/"manualLogin"/"sourceFiles") whose tools should be unwrapped
110
+ * kit's own "approvals"/"manualLogin"/"sourceFiles"/"time") whose tools should be unwrapped
71
111
  * without a "[server] " prefix, e.g. "playwright".
72
112
  */
73
113
  export function createFriendlyToolLabel(options = {}) {
74
- const localServers = new Set(["approvals", "manualLogin", "sourceFiles", ...(options.extraLocalServers ?? [])]);
114
+ const localServers = new Set(["approvals", "manualLogin", "sourceFiles", "time", "knowledge", ...(options.extraLocalServers ?? [])]);
75
115
  const describe = (shortName, input) => options.describe?.(shortName, input) ?? describeCore(shortName, input) ?? shortName.replace(/_/g, " ");
76
116
  return (toolName, toolInput) => {
77
117
  const input = toolInput && typeof toolInput === "object" ? toolInput : {};
@@ -1,3 +1,4 @@
1
+ import { type ModeControl } from "../modeControl.js";
1
2
  /** The approval tool's texts: its description (when the model should call it) and what it returns when approved or rejected. */
2
3
  export interface HumanApprovalTexts {
3
4
  description: string;
@@ -7,9 +8,12 @@ export interface HumanApprovalTexts {
7
8
  /** Domain-neutral defaults — a concrete agent should normally override these via `AgentSpec.humanApprovalTexts` with wording specific to what "publishing" means in its domain. */
8
9
  export declare const DEFAULT_HUMAN_APPROVAL_TEXTS: HumanApprovalTexts;
9
10
  /**
10
- * Checkpoint tool for "guided" mode: the agent explores freely, but
11
- * must call this tool and wait for human approval right before any action that publishes
12
- * something visible to others and that's hard to naturally undo. Everything else —
13
- * browsing and reading — doesn't go through this.
11
+ * The tools that ask the person, in every mode but autonomous: `request_human_approval`
12
+ * (right before an action that publishes something visible to others and that's hard to
13
+ * undo), `ask_human` (a choice between options, in a panel, within the turn) and, when the
14
+ * session can be in plan mode (`modeControl`), `present_plan`, the kit's own way out of plan
15
+ * mode (ADR-023): the person runs the plan, keeps planning or cancels.
14
16
  */
15
- export declare function createHumanApprovalServer(runDir: string, texts?: HumanApprovalTexts): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
17
+ export declare function createHumanApprovalServer(runDir: string, texts?: HumanApprovalTexts, options?: {
18
+ modeControl?: ModeControl;
19
+ }): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
@@ -1,7 +1,8 @@
1
1
  import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { z } from "zod";
3
- import { askForDecision } from "../hooks/humanInput.js";
3
+ import { askForChoice, askForDecision, askForText } from "../hooks/humanInput.js";
4
4
  import { t } from "../messages/index.js";
5
+ import { togglePlanMode } from "../modeControl.js";
5
6
  const APPROVED_ANSWERS = new Set(["", "y", "yes"]);
6
7
  /** Domain-neutral defaults — a concrete agent should normally override these via `AgentSpec.humanApprovalTexts` with wording specific to what "publishing" means in its domain. */
7
8
  export const DEFAULT_HUMAN_APPROVAL_TEXTS = {
@@ -11,13 +12,15 @@ export const DEFAULT_HUMAN_APPROVAL_TEXTS = {
11
12
  approved: "Approved by the human. You may continue.",
12
13
  rejected: "Rejected by the human. Don't proceed with that; ask what to do differently.",
13
14
  };
15
+ const ok = (text) => ({ content: [{ type: "text", text }] });
14
16
  /**
15
- * Checkpoint tool for "guided" mode: the agent explores freely, but
16
- * must call this tool and wait for human approval right before any action that publishes
17
- * something visible to others and that's hard to naturally undo. Everything else —
18
- * browsing and reading — doesn't go through this.
17
+ * The tools that ask the person, in every mode but autonomous: `request_human_approval`
18
+ * (right before an action that publishes something visible to others and that's hard to
19
+ * undo), `ask_human` (a choice between options, in a panel, within the turn) and, when the
20
+ * session can be in plan mode (`modeControl`), `present_plan`, the kit's own way out of plan
21
+ * mode (ADR-023): the person runs the plan, keeps planning or cancels.
19
22
  */
20
- export function createHumanApprovalServer(runDir, texts = DEFAULT_HUMAN_APPROVAL_TEXTS) {
23
+ export function createHumanApprovalServer(runDir, texts = DEFAULT_HUMAN_APPROVAL_TEXTS, options = {}) {
21
24
  const requestHumanApproval = tool("request_human_approval", texts.description, {
22
25
  summary: z.string().describe("Brief summary of what you're about to submit or publish"),
23
26
  }, async (args) => {
@@ -30,9 +33,38 @@ export function createHumanApprovalServer(runDir, texts = DEFAULT_HUMAN_APPROVAL
30
33
  content: [{ type: "text", text: approved ? texts.approved : texts.rejected }],
31
34
  };
32
35
  });
36
+ const askHuman = tool("ask_human", "Ask the person to choose between options, in a panel, and get the answer in this same turn, instead of ending your turn with a written question. Use it when you need their decision to go on (which approach, which of several items). They can also type their own answer.", {
37
+ question: z.string().describe("The question, in one sentence, in the person's language"),
38
+ options: z.array(z.string()).min(2).max(8).describe("The options, short, in the person's language (2 to 8; an \"Other\" for their own answer is added)"),
39
+ multiple: z.boolean().optional().describe("Whether they can pick several (default: one)"),
40
+ }, async (args) => {
41
+ const answer = await askForChoice(runDir, { title: t().questionTitle, lines: [args.question] }, args.options, args.multiple ?? false);
42
+ const parts = [
43
+ ...(answer.chosen.length ? [`The person chose: ${answer.chosen.join("; ")}.`] : []),
44
+ ...(answer.other ? [`Their own answer: ${answer.other}`] : []),
45
+ ];
46
+ return ok(parts.length ? parts.join("\n") : "The person didn't answer. Go on with what you can, or ask in your reply.");
47
+ });
48
+ const control = options.modeControl;
49
+ const presentPlan = tool("present_plan", "In plan mode, when the plan is ready: show it to the person, who decides to run it (plan mode ends and you carry it out right away, in this turn), to keep planning (with a comment on what to change) or to cancel. Outside plan mode it's refused.", { plan: z.string().describe("The whole plan, in markdown, in the person's language: steps, what changes, what to check") }, async (args) => {
50
+ if (!control || control.mode !== "plan")
51
+ return { ...ok("present_plan only works in plan mode: you aren't in it, so just go ahead."), isError: true };
52
+ const choices = [t().runPlan, t().keepPlanning, t().cancelPlan];
53
+ const answer = await askForChoice(runDir, { title: t().planTitle, lines: args.plan.split("\n"), markdown: true }, choices);
54
+ const picked = answer.chosen[0];
55
+ if (picked === t().runPlan) {
56
+ const mode = togglePlanMode(control) ?? control.mode;
57
+ control.takeNotice?.(); // told here, not again with the next message
58
+ return ok(`The person approved the plan. Plan mode is off (now ${mode} mode): carry the plan out now, step by step.${answer.other ? ` They added: ${answer.other}` : ""}`);
59
+ }
60
+ if (picked === t().cancelPlan)
61
+ return ok("The person cancelled the plan. Stop, and ask them what they want instead.");
62
+ const comment = answer.other ?? (await askForText(runDir, { title: t().planTitle, lines: [], question: t().planCommentQuestion }));
63
+ return ok(`The person wants to keep planning${comment ? `: ${comment}` : ""}. You're still in plan mode: revise the plan and present it again.`);
64
+ });
33
65
  return createSdkMcpServer({
34
66
  name: "approvals",
35
67
  version: "1.0.0",
36
- tools: [requestHumanApproval],
68
+ tools: [requestHumanApproval, askHuman, ...(control?.switchable.includes("plan") ? [presentPlan] : [])],
37
69
  });
38
70
  }
@@ -0,0 +1,16 @@
1
+ import type { KnowledgeStore } from "../knowledgeStore.js";
2
+ /** Options of `createKnowledgeServer()`. */
3
+ export interface KnowledgeToolsOptions {
4
+ /** This run's folder (for the response file of `knowledge_retire`'s approval). */
5
+ runDir: string;
6
+ /** A person can be asked (not autonomous): adds `knowledge_retire`, which asks first. */
7
+ interactive?: boolean;
8
+ /** The sources folder: summaries record their original's hash, and `knowledge_check` reports new, changed and missing originals. */
9
+ sourcesDir?: string;
10
+ /** The project root, for `list_sources`' paths. */
11
+ projectDir?: string;
12
+ /** The knowledge folder, for `list_sources`' ingest status. */
13
+ knowledgeDir?: string;
14
+ }
15
+ /** The `knowledge` MCP server over `store`. */
16
+ export declare function createKnowledgeServer(store: KnowledgeStore, options: KnowledgeToolsOptions): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;