atom-agent 0.3.0 → 1.1.0

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 (61) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +83 -32
  3. package/dist/App.js +2178 -318
  4. package/dist/adapters.js +146 -15
  5. package/dist/agent/gates.js +153 -0
  6. package/dist/agent/loop-guard.js +184 -0
  7. package/dist/agent/loop.js +908 -0
  8. package/dist/agent/normalize.js +144 -0
  9. package/dist/agent/types.js +1 -0
  10. package/dist/auth.js +2 -1
  11. package/dist/cli.js +68 -6
  12. package/dist/compact.js +6 -48
  13. package/dist/config.js +171 -0
  14. package/dist/context-manager.js +564 -0
  15. package/dist/kilo.js +343 -0
  16. package/dist/local-discovery.js +308 -0
  17. package/dist/policy.js +286 -0
  18. package/dist/prompt-cache.js +99 -0
  19. package/dist/providers.js +183 -2
  20. package/dist/rollback.js +21 -0
  21. package/dist/scheduler.js +247 -0
  22. package/dist/session.js +35 -3
  23. package/dist/skills.js +214 -43
  24. package/dist/snapshots.js +57 -2
  25. package/dist/system.js +8 -1
  26. package/dist/telemetry-dashboard.js +589 -0
  27. package/dist/telemetry-server.js +301 -0
  28. package/dist/telemetry.js +1056 -0
  29. package/dist/tools/dir-cache.js +207 -0
  30. package/dist/tools/filesystem.js +149 -0
  31. package/dist/tools/fingerprints.js +33 -0
  32. package/dist/tools/overflow.js +76 -0
  33. package/dist/tools/read-cache.js +160 -0
  34. package/dist/tools/registry.js +802 -0
  35. package/dist/tools/search.js +242 -0
  36. package/dist/tools/shared.js +31 -0
  37. package/dist/tools/shell.js +273 -0
  38. package/dist/tools/todo.js +191 -0
  39. package/dist/tools/web.js +454 -0
  40. package/dist/tools.js +17 -1863
  41. package/dist/ui/activity.js +51 -0
  42. package/dist/ui/diff-panel.js +55 -0
  43. package/dist/ui/diff-view.js +112 -0
  44. package/dist/ui/diff.js +422 -0
  45. package/dist/ui/errors.js +129 -0
  46. package/dist/ui/highlight.js +120 -0
  47. package/dist/ui/input-model.js +115 -0
  48. package/dist/ui/input.js +40 -0
  49. package/dist/ui/live-tail.js +15 -0
  50. package/dist/ui/markdown.js +525 -0
  51. package/dist/ui/modals.js +47 -0
  52. package/dist/ui/palette.js +70 -0
  53. package/dist/ui/pickers.js +32 -0
  54. package/dist/ui/side-by-side.js +144 -0
  55. package/dist/ui/status-bar.js +75 -0
  56. package/dist/ui/theme.js +128 -0
  57. package/dist/ui/todo-panel.js +30 -0
  58. package/dist/ui/tool-inspector.js +59 -0
  59. package/dist/ui/transcript.js +128 -0
  60. package/dist/zen.js +145 -666
  61. package/package.json +1 -1
@@ -0,0 +1,247 @@
1
+ // Effect-aware tool scheduler: data-driven parallelism without Promise.all-
2
+ // ing every tool.
3
+ //
4
+ // The conservative contract (unchanged): batchable reads run concurrently,
5
+ // mutations stay serialized, results commit in original call order, cancel
6
+ // stops between batches, approval happens per call inside runOneTool. This
7
+ // module only PLANS batches; execution (zen.ts runLoopWithChat) is untouched.
8
+ //
9
+ // How it reasons (per tool, from the TOOL_EFFECTS table — the single
10
+ // coupling point; the algorithm below has no per-tool branches):
11
+ // - missing metadata → serial singleton (new tools fail safe, like the old
12
+ // allowlist-miss; current safe behavior is preserved when metadata is
13
+ // absent, not degraded).
14
+ // - unknown name / malformed JSON / failed validation / empty target →
15
+ // serial singleton (never batch what you cannot see; validation failures
16
+ // become inline-error singletons downstream, exactly as before).
17
+ // - interactive (ask_question) or exclusive (shared ambient state the effect
18
+ // model cannot see: todowrite/todo_update/todo_get) → serial singleton.
19
+ // - any filesystem/network WRITE, or any process SPAWN → serial singleton.
20
+ // Writes conflict GLOBALLY (not just same-target): a write splits the
21
+ // block and read-after-write stays ordered. Target-scoped write batching
22
+ // is a deliberate non-goal — correctness over theoretical parallelism.
23
+ // - reads (filesystem or network) batch with pairwise-disjoint keys, where
24
+ // the key is tool + target. Same tool + same target serializes (the old
25
+ // overlap rule, kept verbatim: e.g. two reads of one path). Reads never
26
+ // conflict across keys — an all-read batch cannot race a writer, because
27
+ // writers never join batches.
28
+ // - deterministic is declared per tool; its current enforcement is the
29
+ // same-key rule (a re-poll of the same background task, whose output can
30
+ // grow, never runs concurrently with itself).
31
+ //
32
+ // Pure module except the shared arg validators (same imports zen.ts already
33
+ // carries — no new coupling class). Covered by tests/scheduler.test.ts; the
34
+ // end-to-end ordering/cancel behavior stays pinned by
35
+ // tests/parallel-calls.test.ts.
36
+ import { toolNames, validateToolArgs } from "./tools.js";
37
+ const str = (v) => (typeof v === "string" ? v : "");
38
+ const targetOf = (key) => (args) => {
39
+ const t = str(args[key]);
40
+ return t.length > 0 ? t : null;
41
+ };
42
+ /** Effect metadata for every known tool. Missing name ⇒ serial singleton. */
43
+ export const TOOL_EFFECTS = {
44
+ read: {
45
+ filesystem: "read",
46
+ network: "none",
47
+ process: "none",
48
+ interactive: false,
49
+ exclusive: false,
50
+ deterministic: true,
51
+ target: targetOf("path"),
52
+ },
53
+ grep: {
54
+ filesystem: "read",
55
+ network: "none",
56
+ process: "none",
57
+ interactive: false,
58
+ exclusive: false,
59
+ deterministic: true,
60
+ target: targetOf("pattern"),
61
+ },
62
+ glob: {
63
+ filesystem: "read",
64
+ network: "none",
65
+ process: "none",
66
+ interactive: false,
67
+ exclusive: false,
68
+ deterministic: true,
69
+ target: targetOf("pattern"),
70
+ },
71
+ webfetch: {
72
+ filesystem: "none",
73
+ network: "read",
74
+ process: "none",
75
+ interactive: false,
76
+ exclusive: false,
77
+ deterministic: true,
78
+ target: targetOf("url"),
79
+ },
80
+ websearch: {
81
+ filesystem: "none",
82
+ network: "read",
83
+ process: "none",
84
+ interactive: false,
85
+ exclusive: false,
86
+ // Live search results vary call to call; same-query conflict is still
87
+ // serialized by the same-key rule.
88
+ deterministic: false,
89
+ target: targetOf("query"),
90
+ },
91
+ bash_output: {
92
+ // Reads background-task temp files scoped by taskId (not the repo).
93
+ filesystem: "read",
94
+ network: "none",
95
+ process: "none",
96
+ interactive: false,
97
+ exclusive: false,
98
+ // A running task's output grows between polls — same-task polls
99
+ // serialize via the same-key rule.
100
+ deterministic: false,
101
+ target: targetOf("taskId"),
102
+ },
103
+ write: {
104
+ filesystem: "write",
105
+ network: "none",
106
+ process: "none",
107
+ interactive: false,
108
+ exclusive: false,
109
+ deterministic: true,
110
+ target: targetOf("path"),
111
+ },
112
+ edit: {
113
+ filesystem: "write",
114
+ network: "none",
115
+ process: "none",
116
+ interactive: false,
117
+ exclusive: false,
118
+ deterministic: true,
119
+ target: targetOf("path"),
120
+ },
121
+ bash: {
122
+ // A command can touch anything (files, network, processes) with no
123
+ // statically visible footprint: global conflict, always singleton.
124
+ filesystem: "write",
125
+ network: "write",
126
+ process: "spawn",
127
+ interactive: false,
128
+ exclusive: false,
129
+ deterministic: false,
130
+ target: targetOf("command"),
131
+ },
132
+ ask_question: {
133
+ filesystem: "none",
134
+ network: "none",
135
+ process: "none",
136
+ interactive: true,
137
+ exclusive: false,
138
+ deterministic: false,
139
+ target: () => null,
140
+ },
141
+ todowrite: {
142
+ filesystem: "none",
143
+ network: "none",
144
+ process: "none",
145
+ interactive: false,
146
+ exclusive: true,
147
+ deterministic: false,
148
+ target: () => null,
149
+ },
150
+ todo_update: {
151
+ filesystem: "none",
152
+ network: "none",
153
+ process: "none",
154
+ interactive: false,
155
+ exclusive: true,
156
+ deterministic: false,
157
+ target: () => null,
158
+ },
159
+ todo_get: {
160
+ filesystem: "none",
161
+ network: "none",
162
+ process: "none",
163
+ interactive: false,
164
+ exclusive: true,
165
+ deterministic: true,
166
+ target: () => null,
167
+ },
168
+ };
169
+ // Partition one assistant message's tool_calls into commit batches,
170
+ // preserving program order: consecutive batchable calls with pairwise
171
+ // disjoint keys form one batch; any serial-only call — and any call whose
172
+ // key already appears in the open batch — closes the batch and runs as a
173
+ // strict serial singleton. A later batch never moves ahead of an earlier
174
+ // serial call (read-after-write stays ordered), and batches never span the
175
+ // block boundary.
176
+ export function planBatches(calls) {
177
+ const batches = [];
178
+ let open = [];
179
+ const keys = new Set();
180
+ const flush = () => {
181
+ if (open.length > 0) {
182
+ batches.push(open);
183
+ open = [];
184
+ keys.clear();
185
+ }
186
+ };
187
+ const singleton = (call, parsed) => {
188
+ flush();
189
+ batches.push([{ call, parsed, parallelKey: null }]);
190
+ };
191
+ for (const call of calls) {
192
+ let parsed;
193
+ let malformed = false;
194
+ try {
195
+ const raw = call?.function?.arguments ?? "{}";
196
+ const v = JSON.parse(typeof raw === "string" ? raw : "{}");
197
+ parsed = typeof v === "object" && v !== null ? v : {};
198
+ }
199
+ catch {
200
+ parsed = {};
201
+ malformed = true;
202
+ }
203
+ const name = typeof call?.function?.name === "string" ? call.function.name : "(unknown)";
204
+ // Missing metadata fails safe to serial (never batch the unknown).
205
+ const meta = TOOL_EFFECTS[name];
206
+ if (malformed || !meta || !toolNames().includes(name)) {
207
+ singleton(call, parsed);
208
+ continue;
209
+ }
210
+ // Failed validation becomes an inline-error singleton downstream.
211
+ if (validateToolArgs(name, parsed)) {
212
+ singleton(call, parsed);
213
+ continue;
214
+ }
215
+ // Interactive, ambient-state, mutating, or spawning calls serialize.
216
+ if (meta.interactive ||
217
+ meta.exclusive ||
218
+ meta.filesystem === "write" ||
219
+ meta.network === "write" ||
220
+ meta.process !== "none") {
221
+ singleton(call, parsed);
222
+ continue;
223
+ }
224
+ // Reads batch on disjoint tool+target keys; empty target = unknown
225
+ // footprint = serial.
226
+ let target = null;
227
+ try {
228
+ target = meta.target(parsed);
229
+ }
230
+ catch {
231
+ target = null;
232
+ }
233
+ if (!target) {
234
+ singleton(call, parsed);
235
+ continue;
236
+ }
237
+ const key = `${name} ${target}`;
238
+ if (keys.has(key)) {
239
+ singleton(call, parsed);
240
+ continue;
241
+ }
242
+ keys.add(key);
243
+ open.push({ call, parsed, parallelKey: key });
244
+ }
245
+ flush();
246
+ return batches;
247
+ }
package/dist/session.js CHANGED
@@ -16,8 +16,8 @@
16
16
  // outside the repo, so .gitignore needs no change).
17
17
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
18
18
  import * as path from "node:path";
19
- import { atomDir } from "./auth.js";
20
- import { isProviderId } from "./providers.js";
19
+ import { atomDir, getStoredBaseURL, loadAuth, resolveApiKey } from "./auth.js";
20
+ import { chatEndpointFor, isProviderId, openaiCompatibleChatEndpoint, } from "./providers.js";
21
21
  import { EFFORT_OPTIONS, } from "./zen.js";
22
22
  export const SESSION_VERSION = 1;
23
23
  export const SESSION_FILENAME = "session.json";
@@ -32,6 +32,32 @@ export function sessionExists(home) {
32
32
  return false;
33
33
  }
34
34
  }
35
+ export function loadPrefs(home, zenEndpoint) {
36
+ try {
37
+ const loaded = loadSession(home);
38
+ if (loaded.status !== "ok")
39
+ return null;
40
+ const s = loaded.session;
41
+ const auth = loadAuth(home);
42
+ const key = resolveApiKey(s.provider, auth);
43
+ // Kilo serves anonymous free models, so a saved kilo session restores
44
+ // keyless; every other keyed provider still needs a resolvable key.
45
+ if (!key && s.provider !== "kilo")
46
+ return null;
47
+ const baseURL = getStoredBaseURL(auth, s.provider);
48
+ if (s.provider === "openai-compatible" && !baseURL)
49
+ return null;
50
+ const endpoint = s.provider === "opencode-zen"
51
+ ? zenEndpoint
52
+ : s.provider === "openai-compatible"
53
+ ? openaiCompatibleChatEndpoint(baseURL)
54
+ : chatEndpointFor(s.provider, baseURL);
55
+ return { provider: s.provider, model: s.model, effort: s.effort, apiKey: key, endpoint };
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
35
61
  // Atomic save: write temp + rename, 0600 POSIX (best-effort Windows).
36
62
  // Never throws for missing dirs (mkdir -p); disk errors propagate to the
37
63
  // caller, which ignores them (in-memory session still applies).
@@ -95,7 +121,13 @@ function validateUsageTotals(value) {
95
121
  if (!isRecord(value))
96
122
  return null;
97
123
  const out = {};
98
- for (const key of ["prompt_tokens", "completion_tokens", "total_tokens"]) {
124
+ for (const key of [
125
+ "prompt_tokens",
126
+ "completion_tokens",
127
+ "total_tokens",
128
+ "cacheReadTokens",
129
+ "cacheWriteTokens",
130
+ ]) {
99
131
  const v = value[key];
100
132
  if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
101
133
  out[key] = Math.floor(v);
package/dist/skills.js CHANGED
@@ -5,6 +5,12 @@
5
5
  // the TUI listing. Levels and precedence (ticket 05), invocation (tickets
6
6
  // 03/04), and tool grants (ticket 06) build on this registry.
7
7
  //
8
+ // Roots (scanned in order, every call — no cache): project .claude/skills,
9
+ // project .agents/skills (skills.sh installs here), global ~/.claude/skills,
10
+ // global ~/.agents/skills. Same-level name clashes keep the first with a
11
+ // note (see resolveSkills); .claude sorts before .agents so a skill present
12
+ // in both keeps its .claude copy.
13
+ //
8
14
  // Node builtins only. Discovery never throws: per-skill failures come back
9
15
  // as warning strings, and a missing skills directory is normal (silent).
10
16
  import { promises as fsp } from "node:fs";
@@ -99,7 +105,9 @@ export async function discoverSkills(opts) {
99
105
  const seenBases = new Set();
100
106
  const roots = [
101
107
  { base: path.join(projectDir, ".claude", "skills"), source: "project" },
108
+ { base: path.join(projectDir, ".agents", "skills"), source: "project" },
102
109
  { base: path.join(homeDir, ".claude", "skills"), source: "global" },
110
+ { base: path.join(homeDir, ".agents", "skills"), source: "global" },
103
111
  ];
104
112
  for (const { base, source } of roots) {
105
113
  const resolved = path.resolve(base);
@@ -117,73 +125,102 @@ export async function discoverSkills(opts) {
117
125
  .filter((e) => e.isDirectory())
118
126
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
119
127
  for (const e of dirs) {
120
- const dir = path.join(base, e.name);
121
- let text;
122
- try {
123
- text = await fsp.readFile(path.join(dir, "SKILL.md"), "utf8");
124
- }
125
- catch {
126
- warnings.push(`skill "${e.name}" (${source}): cannot read SKILL.md — skipped`);
127
- continue;
128
- }
129
- const { front, body, unclosed } = splitFrontmatter(text);
130
- if (unclosed) {
131
- warnings.push(`skill "${e.name}" (${source}): unclosed frontmatter — skipped`);
132
- continue;
133
- }
134
- const fmName = front ? frontField(front, "name") : undefined;
135
- const fmDesc = front ? frontField(front, "description") : undefined;
136
- const name = fmName && fmName.length > 0 ? fmName : e.name;
137
- const description = fmDesc && fmDesc.length > 0 ? fmDesc : firstParagraph(body);
138
- if (!description) {
139
- warnings.push(`skill "${name}" (${source}): no description and empty body — skipped`);
140
- continue;
141
- }
142
- const noModel = front ? frontBool(front, "disable-model-invocation") : undefined;
143
- const userOnly = front ? frontBool(front, "user-invocable") : undefined;
144
- skills.push({
145
- name,
146
- description,
147
- dir,
148
- source,
149
- userInvocable: userOnly ?? true,
150
- modelInvocable: !(noModel ?? false),
151
- allowedTools: front ? parseAllowedTools(frontField(front, "allowed-tools")) : [],
152
- });
128
+ const parsed = await parseSkillDir(base, source, e.name);
129
+ if (parsed.info)
130
+ skills.push(parsed.info);
131
+ else if (parsed.warning)
132
+ warnings.push(parsed.warning);
153
133
  }
154
134
  }
155
135
  return { skills, warnings };
156
136
  }
157
- // One-shot TUI listing for /skills. Name clashes resolve with personal
158
- // (global) winning (resolveSkills); model-only skills show with an
159
- // [auto-only] tag instead of hiding discoverable, but not invocable.
160
- // The header always renders so the command is self-explanatory when empty;
161
- // warnings ride along visibly.
137
+ // Parse one skill directory's SKILL.md into metadata (Tier 1) or a warning.
138
+ // Shared by the uncached discoverSkills above and the SkillRegistry below so
139
+ // both paths parse byte-identically. Reads exactly one file; bodies and
140
+ // references stay lazy (see loadSkillBody).
141
+ async function parseSkillDir(base, source, dirname) {
142
+ const dir = path.join(base, dirname);
143
+ let text;
144
+ try {
145
+ text = await fsp.readFile(path.join(dir, "SKILL.md"), "utf8");
146
+ }
147
+ catch {
148
+ return { info: null, warning: `skill "${dirname}" (${source}): cannot read SKILL.md — skipped` };
149
+ }
150
+ const { front, body, unclosed } = splitFrontmatter(text);
151
+ if (unclosed) {
152
+ return { info: null, warning: `skill "${dirname}" (${source}): unclosed frontmatter — skipped` };
153
+ }
154
+ const fmName = front ? frontField(front, "name") : undefined;
155
+ const fmDesc = front ? frontField(front, "description") : undefined;
156
+ const name = fmName && fmName.length > 0 ? fmName : dirname;
157
+ const description = fmDesc && fmDesc.length > 0 ? fmDesc : firstParagraph(body);
158
+ if (!description) {
159
+ return { info: null, warning: `skill "${name}" (${source}): no description and empty body — skipped` };
160
+ }
161
+ const noModel = front ? frontBool(front, "disable-model-invocation") : undefined;
162
+ const userOnly = front ? frontBool(front, "user-invocable") : undefined;
163
+ return {
164
+ info: {
165
+ name,
166
+ description,
167
+ dir,
168
+ source,
169
+ userInvocable: userOnly ?? true,
170
+ modelInvocable: !(noModel ?? false),
171
+ allowedTools: front ? parseAllowedTools(frontField(front, "allowed-tools")) : [],
172
+ },
173
+ warning: null,
174
+ };
175
+ }
176
+ // One-shot listing for copy/paste and headless use. Rows are names only
177
+ // (`/skill:name`, directly runnable) plus source — descriptions stay out of
178
+ // the TUI; the picker and descriptions live in SKILL.md files. Name clashes
179
+ // resolve with personal (global) winning (resolveSkills); model-only skills
180
+ // show with an [auto-only] tag instead of hiding. The header always renders
181
+ // so the command is self-explanatory when empty; warnings ride along visibly.
162
182
  export async function skillsListText(projectDir, homeDir) {
163
183
  const found = await discoverSkills({ projectDir, homeDir });
164
184
  const { skills, notes } = resolveSkills(found.skills);
165
185
  const out = [skills.length === 1 ? "Skills (1):" : `Skills (${skills.length}):`];
166
186
  for (const s of skills) {
167
- out.push(`/${s.name} — ${s.description} [${s.source}]${s.userInvocable ? "" : " [auto-only]"}`);
187
+ out.push(`/skill:${s.name} [${s.source}]${s.userInvocable ? "" : " [auto-only]"}`);
168
188
  }
169
189
  for (const n of notes)
170
190
  out.push(`note: ${n}`);
171
191
  for (const w of found.warnings)
172
192
  out.push(`⚠ ${w}`);
173
193
  if (skills.length === 0 && found.warnings.length === 0) {
174
- out.push("(no skills installed — add SKILL.md skills under .claude/skills/ or ~/.claude/skills/)");
194
+ out.push("(no skills installed — add SKILL.md skills under .claude/skills/, .agents/skills/, ~/.claude/skills/, or ~/.agents/skills/)");
175
195
  }
176
196
  return out.join("\n");
177
197
  }
178
198
  const SKILL_FILE_CAP = 8 * 1024;
179
199
  const SKILL_INCLUDE_MAX = 3;
200
+ // Auto-invoke context cap (Tier 2): an auto-loaded skill body is truncated
201
+ // here with a pointer the model can follow via read — auto-activation must
202
+ // never flood the window the way an explicit manual load may.
203
+ export const AUTO_SKILL_BODY_CAP = 12 * 1024;
204
+ export function capSkillBodyForAuto(text, skillDir) {
205
+ if (text.length <= AUTO_SKILL_BODY_CAP)
206
+ return text;
207
+ return (text.slice(0, AUTO_SKILL_BODY_CAP) +
208
+ `\n[truncated: auto-loaded skill body exceeded 12KB — read ${skillDir}/SKILL.md for the rest]`);
209
+ }
180
210
  // references/<path> + scripts/<path> mentions inside the skill body.
181
211
  const SKILL_MENTION_RE = /\b(references|scripts)\/[A-Za-z0-9_.\-/@]+/g;
182
212
  // Load a skill's body plus the support files its body references (on
183
213
  // demand, capped — never the whole directory). Mentioned-but-missing files
184
214
  // are skipped silently; path escapes outside the skill dir are dropped.
185
215
  // Never throws: an unreadable SKILL.md yields empty text.
186
- export async function loadSkillBody(skill) {
216
+ //
217
+ // Progressive-disclosure tiers (Claude-Code-style): pass { inlineRefs: false }
218
+ // to load Tier 2 only (body, no inlined references — the model reads
219
+ // references/<…> via the read tool when it actually needs them). Tier 3
220
+ // (references) stays on demand. Manual invocation keeps the default
221
+ // (inline) since the user explicitly asked for the whole skill.
222
+ export async function loadSkillBody(skill, opts) {
223
+ const inlineRefs = opts?.inlineRefs ?? true;
187
224
  let raw;
188
225
  try {
189
226
  raw = await fsp.readFile(path.join(skill.dir, "SKILL.md"), "utf8");
@@ -192,6 +229,8 @@ export async function loadSkillBody(skill) {
192
229
  return { info: skill, text: "", included: [] };
193
230
  }
194
231
  const { body } = splitFrontmatter(raw);
232
+ if (!inlineRefs)
233
+ return { info: skill, text: body, included: [] };
195
234
  const seen = new Set();
196
235
  const mentions = [];
197
236
  for (const m of body.match(SKILL_MENTION_RE) ?? []) {
@@ -232,20 +271,31 @@ function contentWords(s) {
232
271
  }
233
272
  // Deterministic description match for auto-invoke: distinct
234
273
  // name+description words (len ≥ 3, stopwords dropped) hitting the
235
- // message as substrings, at least minHits (default 2), best score first,
274
+ // message, at least minHits (default 2), best score first,
236
275
  // capped at max (default 2) skills per turn. Skills with
237
276
  // disable-model-invocation never match. Pure function — no I/O.
277
+ //
278
+ // Matching modes: substring (default — `test` hits `testing`, kept for
279
+ // backward compatibility) or wholeWords (a skill word must appear as a whole
280
+ // message word — kills the biggest false-positive class (`test` inside
281
+ // `latest`, `commit` inside `committed`). The App's auto-invoke path uses
282
+ // wholeWords with a higher bar; the defaults stay for manual tooling.
238
283
  export function matchSkills(message, skills, opts) {
239
284
  const max = opts?.max ?? 2;
240
285
  const minHits = opts?.minHits ?? 2;
286
+ const wholeWords = opts?.wholeWords ?? false;
241
287
  const text = message.toLowerCase();
288
+ const messageWords = wholeWords ?
289
+ new Set(text.split(/[^a-z0-9]+/).filter((w) => w.length > 0))
290
+ : null;
242
291
  const scored = [];
243
292
  for (const s of skills) {
244
293
  if (!s.modelInvocable)
245
294
  continue;
246
295
  let hits = 0;
247
296
  for (const w of contentWords(`${s.name} ${s.description}`)) {
248
- if (text.includes(w))
297
+ const hit = messageWords ? messageWords.has(w) : text.includes(w);
298
+ if (hit)
249
299
  hits += 1;
250
300
  }
251
301
  if (hits >= minHits)
@@ -281,3 +331,124 @@ export function resolveSkills(skills) {
281
331
  }
282
332
  return { skills: order.map((n) => byName.get(n)), notes };
283
333
  }
334
+ export function createSkillRegistry(opts) {
335
+ const projectDir = opts?.projectDir ?? process.cwd();
336
+ const homeDir = opts?.homeDir ?? os.homedir();
337
+ // Resolved root base -> dirname -> cached parse. Instance-local: no
338
+ // cross-talk between Apps, tests, or headless users.
339
+ const rootState = new Map();
340
+ let last = { skills: [], warnings: [] };
341
+ function roots() {
342
+ return [
343
+ { base: path.join(projectDir, ".claude", "skills"), source: "project" },
344
+ { base: path.join(projectDir, ".agents", "skills"), source: "project" },
345
+ { base: path.join(homeDir, ".claude", "skills"), source: "global" },
346
+ { base: path.join(homeDir, ".agents", "skills"), source: "global" },
347
+ ];
348
+ }
349
+ async function refresh() {
350
+ const skills = [];
351
+ const warnings = [];
352
+ const stats = {
353
+ roots: 0,
354
+ entries: 0,
355
+ reused: 0,
356
+ reloaded: 0,
357
+ added: 0,
358
+ removed: 0,
359
+ };
360
+ const seenBases = new Set();
361
+ for (const { base, source } of roots()) {
362
+ const resolved = path.resolve(base);
363
+ if (seenBases.has(resolved))
364
+ continue; // e.g. repo rooted at $HOME
365
+ seenBases.add(resolved);
366
+ let entries;
367
+ try {
368
+ entries = await fsp.readdir(base, { withFileTypes: true });
369
+ }
370
+ catch {
371
+ // Root vanished: drop its cached entries silently (same as discovery).
372
+ const prev = rootState.get(resolved);
373
+ if (prev) {
374
+ stats.removed += prev.size;
375
+ rootState.delete(resolved);
376
+ }
377
+ continue;
378
+ }
379
+ stats.roots += 1;
380
+ const dirs = entries
381
+ .filter((e) => e.isDirectory())
382
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
383
+ const prev = rootState.get(resolved) ?? new Map();
384
+ const next = new Map();
385
+ for (const e of dirs) {
386
+ stats.entries += 1;
387
+ const skillFile = path.join(base, e.name, "SKILL.md");
388
+ let fingerprint = null;
389
+ try {
390
+ const st = await fsp.stat(skillFile);
391
+ if (st.isFile())
392
+ fingerprint = { mtimeMs: st.mtimeMs, size: st.size };
393
+ }
394
+ catch {
395
+ fingerprint = null;
396
+ }
397
+ const cached = prev.get(e.name);
398
+ // Reuse on fingerprint match. Entries that previously failed to stat
399
+ // carry the (0, 0) sentinel and can never match a real file, so a
400
+ // vanished file that reappears is always re-parsed — never stuck.
401
+ if (cached &&
402
+ fingerprint &&
403
+ cached.mtimeMs > 0 &&
404
+ fingerprint.mtimeMs === cached.mtimeMs &&
405
+ fingerprint.size === cached.size) {
406
+ next.set(e.name, cached);
407
+ stats.reused += 1;
408
+ if (cached.info)
409
+ skills.push(cached.info);
410
+ else if (cached.warning)
411
+ warnings.push(cached.warning);
412
+ continue;
413
+ }
414
+ if (fingerprint === null) {
415
+ // Matches discovery's read-failure warning byte-for-byte. Re-checked
416
+ // (not reloaded) every refresh — a reappearing file is re-parsed via
417
+ // the path below, never stuck warned.
418
+ const warning = `skill "${e.name}" (${source}): cannot read SKILL.md — skipped`;
419
+ next.set(e.name, { mtimeMs: 0, size: 0, info: null, warning });
420
+ warnings.push(warning);
421
+ if (!cached)
422
+ stats.added += 1;
423
+ continue;
424
+ }
425
+ const parsed = await parseSkillDir(base, source, e.name);
426
+ next.set(e.name, {
427
+ mtimeMs: fingerprint.mtimeMs,
428
+ size: fingerprint.size,
429
+ info: parsed.info,
430
+ warning: parsed.warning,
431
+ });
432
+ if (parsed.info)
433
+ skills.push(parsed.info);
434
+ else if (parsed.warning)
435
+ warnings.push(parsed.warning);
436
+ if (cached)
437
+ stats.reloaded += 1;
438
+ else
439
+ stats.added += 1;
440
+ }
441
+ for (const name of prev.keys()) {
442
+ if (!next.has(name))
443
+ stats.removed += 1;
444
+ }
445
+ rootState.set(resolved, next);
446
+ }
447
+ last = { skills, warnings };
448
+ return { skills, warnings, stats };
449
+ }
450
+ function snapshot() {
451
+ return { skills: [...last.skills], warnings: [...last.warnings] };
452
+ }
453
+ return { refresh, snapshot };
454
+ }