s-app-scoder-agent 0.5.0 → 0.7.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 (49) hide show
  1. package/.scoderrc.example.json +97 -0
  2. package/LICENSE +21 -21
  3. package/README.md +599 -435
  4. package/dist/agent.js +829 -168
  5. package/dist/agents.js +70 -0
  6. package/dist/compaction.js +114 -0
  7. package/dist/config.js +178 -12
  8. package/dist/context.js +119 -14
  9. package/dist/cost.js +56 -6
  10. package/dist/guardrails.js +409 -0
  11. package/dist/index.js +216 -45
  12. package/dist/json-schema.js +58 -0
  13. package/dist/llm.js +148 -11
  14. package/dist/mcp.js +397 -70
  15. package/dist/model-catalog.js +175 -0
  16. package/dist/permissions.js +328 -35
  17. package/dist/prompt-builder.js +47 -0
  18. package/dist/prompt.js +97 -0
  19. package/dist/provider-anthropic.js +113 -35
  20. package/dist/provider-registry.js +116 -0
  21. package/dist/retry.js +3 -2
  22. package/dist/sandbox.js +96 -0
  23. package/dist/server.js +28 -1
  24. package/dist/session.js +282 -11
  25. package/dist/shell-scanner.js +112 -0
  26. package/dist/skills.js +122 -0
  27. package/dist/snapshot.js +170 -0
  28. package/dist/steering.js +35 -0
  29. package/dist/tools.js +537 -24
  30. package/dist/tui/app.js +615 -0
  31. package/dist/tui/at-complete.js +93 -0
  32. package/dist/tui/chat-views.js +145 -0
  33. package/dist/tui/chrome.js +96 -0
  34. package/dist/tui/command-registry.js +189 -0
  35. package/dist/tui/commands.js +222 -0
  36. package/dist/tui/image.js +149 -0
  37. package/dist/tui/input.js +214 -0
  38. package/dist/tui/markdown.js +229 -0
  39. package/dist/tui/mouse.js +20 -0
  40. package/dist/tui/permission-modal.js +34 -0
  41. package/dist/tui/review.js +64 -0
  42. package/dist/tui/session-picker.js +68 -0
  43. package/dist/tui/sidebar.js +101 -0
  44. package/dist/tui/state.js +342 -0
  45. package/dist/tui/theme.js +90 -0
  46. package/dist/tui/vim.js +60 -0
  47. package/dist/ui.js +43 -3
  48. package/dist/watch.js +52 -0
  49. package/package.json +18 -5
package/dist/session.js CHANGED
@@ -1,8 +1,17 @@
1
1
  /**
2
- * Session persistence: save agent state (messages, steps, build) to
3
- * <workdir>/.scoder/sessions/<id>.json after every step so runs can be resumed.
2
+ * Session persistence v2: durable event log + projections.
3
+ *
4
+ * Source: anomalyco/opencode (packages/core/src/session/stored.ts, run-coordinator.ts;
5
+ * specs/v2/session.md) — pattern adapted: event-sourced JSONL log as the durable
6
+ * source, in-memory projection for messages, write-ahead execution claim for
7
+ * crash recovery. Falls back to reading v1 JSON snapshots transparently.
8
+ *
9
+ * Layout:
10
+ * <workdir>/.scoder/sessions/<id>.json — v1 snapshot (legacy, read-only)
11
+ * <workdir>/.scoder/sessions/<id>/events.jsonl — v2 durable event log
12
+ * <workdir>/.scoder/sessions/<id>/manifest.json — projection summary
4
13
  */
5
- import { mkdirSync, writeFileSync, renameSync, readFileSync, readdirSync, existsSync, rmSync, statSync } from "node:fs";
14
+ import { mkdirSync, writeFileSync, appendFileSync, renameSync, readFileSync, readdirSync, existsSync, rmSync, statSync } from "node:fs";
6
15
  import { join, resolve } from "node:path";
7
16
  const SESSIONS_DIR = ".scoder/sessions";
8
17
  export function newSessionId(now = new Date()) {
@@ -19,12 +28,192 @@ export function newSessionId(now = new Date()) {
19
28
  function sessionsDir(workdir) {
20
29
  return resolve(workdir, SESSIONS_DIR);
21
30
  }
31
+ function eventLogPath(workdir, id) {
32
+ return join(sessionsDir(workdir), id, "events.jsonl");
33
+ }
34
+ function manifestPath(workdir, id) {
35
+ return join(sessionsDir(workdir), id, "manifest.json");
36
+ }
22
37
  /** Atomic write: write to .tmp then rename. */
23
38
  function atomicWrite(path, content) {
39
+ mkdirSync(resolve(path, ".."), { recursive: true });
24
40
  const tmp = `${path}.tmp`;
25
41
  writeFileSync(tmp, content, "utf8");
26
42
  renameSync(tmp, path);
27
43
  }
44
+ /** ---------------------------------------------------------------------
45
+ * Event-sourced store (v2)
46
+ * ------------------------------------------------------------------- */
47
+ export class EventSessionStore {
48
+ id;
49
+ enabled;
50
+ _workdir;
51
+ seq = 0;
52
+ createdAt;
53
+ logPath;
54
+ /** In-memory projection: message history (append-only). */
55
+ _messages = [];
56
+ _task = "";
57
+ _steps = 0;
58
+ _lastBuild = null;
59
+ _usage = undefined;
60
+ _finished = null;
61
+ constructor(workdir, enabled, id = newSessionId()) {
62
+ this.id = id;
63
+ this._workdir = workdir;
64
+ this.enabled = enabled;
65
+ this.createdAt = new Date().toISOString();
66
+ this.logPath = eventLogPath(workdir, id);
67
+ if (enabled) {
68
+ try {
69
+ mkdirSync(resolve(this.logPath, ".."), { recursive: true });
70
+ }
71
+ catch (err) {
72
+ // Never crash the agent because of a persistence failure
73
+ console.error(`⚠ session store init failed: ${err.message}`);
74
+ this.enabled = false;
75
+ }
76
+ }
77
+ }
78
+ get workdir() {
79
+ return this._workdir;
80
+ }
81
+ /** Append one durable event (fsync-free appendFileSync is atomic enough per line on POSIX). */
82
+ append(event) {
83
+ if (!this.enabled)
84
+ return;
85
+ try {
86
+ const full = {
87
+ ts: event.ts ?? new Date().toISOString(),
88
+ v: 1,
89
+ seq: ++this.seq,
90
+ ...event,
91
+ };
92
+ appendFileSync(this.logPath, JSON.stringify(full) + "\n", "utf8");
93
+ this.project(full);
94
+ }
95
+ catch (err) {
96
+ console.error(`⚠ event append failed: ${err.message}`);
97
+ }
98
+ }
99
+ /** Update the in-memory projection from an event. */
100
+ project(ev) {
101
+ switch (ev.type) {
102
+ case "run.started":
103
+ this._task = String(ev.task ?? "");
104
+ break;
105
+ case "message.added":
106
+ this._messages.push(ev.message);
107
+ break;
108
+ case "build.checked":
109
+ this._lastBuild = {
110
+ success: Boolean(ev.success),
111
+ command: String(ev.command ?? ""),
112
+ output: String(ev.output ?? "").slice(0, 10_000),
113
+ };
114
+ break;
115
+ case "finish":
116
+ this._finished = { success: Boolean(ev.success), summary: String(ev.summary ?? "") };
117
+ break;
118
+ default:
119
+ break; // tool/guardrail/etc. events are queryable from the raw log
120
+ }
121
+ }
122
+ /** Materialized message history (projection). */
123
+ get messages() {
124
+ return this._messages;
125
+ }
126
+ /** Write-ahead execution claim — stamped when a step begins executing. */
127
+ claimExecution(attempt) {
128
+ this.append({
129
+ type: "execution.claimed",
130
+ attempt,
131
+ pid: process.pid,
132
+ host: process.env.HOSTNAME ?? "localhost",
133
+ });
134
+ }
135
+ releaseExecution(attempt) {
136
+ this.append({ type: "execution.released", attempt });
137
+ }
138
+ /** Crash recovery: sessions with a stale claim (no matching release). */
139
+ static hasStaleClaim(workdir, id) {
140
+ const events = readEventLog(workdir, id);
141
+ let claimed = 0;
142
+ let released = 0;
143
+ for (const ev of events) {
144
+ if (ev.type === "execution.claimed")
145
+ claimed++;
146
+ if (ev.type === "execution.released")
147
+ released++;
148
+ }
149
+ return claimed > released;
150
+ }
151
+ /** Full projection → the v1 SessionFile shape (resume/read compatibility). */
152
+ toSessionFile() {
153
+ return {
154
+ id: this.id,
155
+ createdAt: this.createdAt,
156
+ updatedAt: new Date().toISOString(),
157
+ task: this._task,
158
+ model: "",
159
+ provider: "",
160
+ buildCmd: null,
161
+ steps: this._steps,
162
+ maxSteps: 0,
163
+ messages: this._messages,
164
+ lastBuild: this._lastBuild,
165
+ usage: this._usage,
166
+ };
167
+ }
168
+ get finished() {
169
+ return this._finished;
170
+ }
171
+ /** Persist a manifest (cheap summary for /sessions listing). */
172
+ writeManifest(meta) {
173
+ if (!this.enabled)
174
+ return;
175
+ try {
176
+ atomicWrite(manifestPath(this._workdir, this.id), JSON.stringify({
177
+ id: this.id,
178
+ createdAt: this.createdAt,
179
+ updatedAt: new Date().toISOString(),
180
+ lastBuild: this._lastBuild,
181
+ finished: this._finished,
182
+ eventCount: this.seq,
183
+ ...meta,
184
+ }, null, 2));
185
+ this._steps = meta.steps;
186
+ this._usage = meta.usage;
187
+ }
188
+ catch (err) {
189
+ console.error(`⚠ manifest write failed: ${err.message}`);
190
+ }
191
+ }
192
+ }
193
+ /** Read the raw event log of a session (empty array when missing/malformed lines). */
194
+ export function readEventLog(workdir, id) {
195
+ const p = eventLogPath(workdir, id);
196
+ if (!existsSync(p))
197
+ return [];
198
+ const out = [];
199
+ try {
200
+ const text = readFileSync(p, "utf8");
201
+ for (const line of text.split("\n")) {
202
+ if (!line.trim())
203
+ continue;
204
+ try {
205
+ out.push(JSON.parse(line));
206
+ }
207
+ catch {
208
+ // skip malformed line (torn write on crash) — remaining lines stay valid
209
+ }
210
+ }
211
+ }
212
+ catch {
213
+ return [];
214
+ }
215
+ return out;
216
+ }
28
217
  export class SessionStore {
29
218
  id;
30
219
  filePath;
@@ -60,6 +249,11 @@ export class SessionStore {
60
249
  // Load / list / prune
61
250
  // ---------------------------------------------------------------------------
62
251
  export function loadSession(workdir, id) {
252
+ // v2 event-sourced session directory first
253
+ const v2Dir = join(sessionsDir(workdir), id);
254
+ if (existsSync(v2Dir) && statSync(v2Dir).isDirectory()) {
255
+ return sessionFromEventLog(workdir, id);
256
+ }
63
257
  const path = join(sessionsDir(workdir), `${id}.json`);
64
258
  if (!existsSync(path))
65
259
  return null;
@@ -70,11 +264,69 @@ export function loadSession(workdir, id) {
70
264
  return null;
71
265
  }
72
266
  }
267
+ /** Rebuild a SessionFile projection from a v2 event log (null when no log). */
268
+ export function sessionFromEventLog(workdir, id) {
269
+ const events = readEventLog(workdir, id);
270
+ if (events.length === 0)
271
+ return null;
272
+ const messages = [];
273
+ let task = "";
274
+ let lastBuild = null;
275
+ let createdAt = "";
276
+ let updatedAt = "";
277
+ for (const ev of events) {
278
+ createdAt = createdAt || ev.ts;
279
+ updatedAt = ev.ts;
280
+ if (ev.type === "run.started")
281
+ task = String(ev.task ?? "");
282
+ else if (ev.type === "message.added")
283
+ messages.push(ev.message);
284
+ else if (ev.type === "build.checked") {
285
+ lastBuild = { success: Boolean(ev.success), command: String(ev.command ?? ""), output: String(ev.output ?? "") };
286
+ }
287
+ }
288
+ const manifest = existsSync(manifestPath(workdir, id))
289
+ ? JSON.parse(readFileSync(manifestPath(workdir, id), "utf8"))
290
+ : {};
291
+ return {
292
+ id,
293
+ createdAt,
294
+ updatedAt,
295
+ task,
296
+ model: manifest.model ?? "",
297
+ provider: manifest.provider ?? "",
298
+ buildCmd: manifest.buildCmd ?? null,
299
+ steps: manifest.steps ?? 0,
300
+ maxSteps: manifest.maxSteps ?? 0,
301
+ messages,
302
+ lastBuild,
303
+ usage: manifest.usage,
304
+ };
305
+ }
73
306
  export function listSessions(workdir) {
74
307
  const dir = sessionsDir(workdir);
75
308
  if (!existsSync(dir))
76
309
  return [];
77
310
  const out = [];
311
+ // v2 event-sourced sessions (directories with manifest.json), newest first
312
+ const v2 = [];
313
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
314
+ if (!e.isDirectory())
315
+ continue;
316
+ const mf = join(dir, e.name, "manifest.json");
317
+ if (!existsSync(mf))
318
+ continue;
319
+ try {
320
+ const m = JSON.parse(readFileSync(mf, "utf8"));
321
+ v2.push({ id: m.id, task: (m.task ?? "").slice(0, 60), steps: m.steps ?? 0, updatedAt: m.updatedAt ?? "" });
322
+ }
323
+ catch {
324
+ // skip malformed
325
+ }
326
+ }
327
+ v2.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.id.localeCompare(a.id));
328
+ out.push(...v2);
329
+ // v1 JSON snapshots (legacy, read-only)
78
330
  for (const f of readdirSync(dir).sort().reverse()) {
79
331
  if (!f.endsWith(".json"))
80
332
  continue;
@@ -95,9 +347,10 @@ export function listSessions(workdir) {
95
347
  }
96
348
  /**
97
349
  * Delete session files older than `days` (default 7).
98
- * Completed sessions (lastBuild.success === true) are removed; sessions that
99
- * did NOT finish successfully are kept longer — they may still be worth
100
- * resuming. Returns count removed.
350
+ * v2: removes the session DIRECTORY (events+manifest). v1: removes the .json.
351
+ * Completed sessions (finished.success) are removed; sessions that did NOT
352
+ * finish successfully are kept longer — they may still be worth resuming.
353
+ * Returns count removed.
101
354
  */
102
355
  export function pruneSessions(workdir, days = 7) {
103
356
  const dir = sessionsDir(workdir);
@@ -105,14 +358,32 @@ export function pruneSessions(workdir, days = 7) {
105
358
  return 0;
106
359
  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
107
360
  let removed = 0;
108
- for (const f of readdirSync(dir)) {
109
- if (!f.endsWith(".json"))
110
- continue;
111
- const full = join(dir, f);
361
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
362
+ const full = join(dir, e.name);
112
363
  try {
113
364
  const st = statSync(full);
114
365
  if (st.mtimeMs >= cutoff)
115
366
  continue;
367
+ if (e.isDirectory()) {
368
+ // v2 session dir — check manifest for finished status
369
+ const mf = join(full, "manifest.json");
370
+ let completed = false;
371
+ if (existsSync(mf)) {
372
+ try {
373
+ completed = JSON.parse(readFileSync(mf, "utf8")).finished?.success === true;
374
+ }
375
+ catch {
376
+ completed = false;
377
+ }
378
+ }
379
+ if (completed) {
380
+ rmSync(full, { recursive: true, force: true });
381
+ removed++;
382
+ }
383
+ continue;
384
+ }
385
+ if (!e.name.endsWith(".json"))
386
+ continue;
116
387
  const s = JSON.parse(readFileSync(full, "utf8"));
117
388
  const completed = s.lastBuild?.success === true;
118
389
  if (completed) {
@@ -123,7 +394,7 @@ export function pruneSessions(workdir, days = 7) {
123
394
  catch {
124
395
  // malformed JSON — prune it (unreadable session is worthless)
125
396
  try {
126
- rmSync(full);
397
+ rmSync(full, { recursive: true, force: true });
127
398
  removed++;
128
399
  }
129
400
  catch { /* ignore */ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Shell scanner — AST-aware permission analysis using tree-sitter-bash.
3
+ *
4
+ * Source: anomalyco/opencode (shell permission scanner, /v2/docs/permissions) —
5
+ * concept adapted to tree-sitter-bash on Node. Behavior contract:
6
+ * - A syntactically INVALID command (or dynamic evaluation the scanner
7
+ * cannot resolve) returns { ok: false, error } — the caller must treat it
8
+ * as "ask" (never execute unverified).
9
+ * - Compound commands produce one resource per subcommand; redirect targets
10
+ * are reported so callers can gate external writes.
11
+ * - `cd <dir>` targets are extracted for external_directory gating.
12
+ */
13
+ import Parser from "tree-sitter";
14
+ import Bash from "tree-sitter-bash";
15
+ let parser = null;
16
+ function getParser() {
17
+ if (!parser) {
18
+ parser = new Parser();
19
+ parser.setLanguage(Bash);
20
+ }
21
+ return parser;
22
+ }
23
+ function collect(root) {
24
+ const commandNames = [];
25
+ const commandLines = [];
26
+ const redirectTargets = [];
27
+ const cdTargets = [];
28
+ let hasCommandSubstitution = false;
29
+ let hasDynamicEval = false;
30
+ let hasError = root.hasError;
31
+ const walk = (n) => {
32
+ if (n.type === "ERROR")
33
+ hasError = true;
34
+ if (n.type === "command_substitution" || n.type === "process_substitution") {
35
+ hasCommandSubstitution = true;
36
+ }
37
+ if (n.type === "command") {
38
+ const nameNode = n.child(0);
39
+ const name = nameNode?.type === "command_name" ? nameNode.text : "";
40
+ if (name) {
41
+ commandNames.push(name);
42
+ commandLines.push(n.text);
43
+ if (name === "cd") {
44
+ const firstArg = n.child(1);
45
+ if (firstArg && firstArg.type !== "command_name")
46
+ cdTargets.push(firstArg.text);
47
+ }
48
+ if (name === "eval" || name === "source" || name === ".")
49
+ hasDynamicEval = true;
50
+ }
51
+ }
52
+ if (n.type === "file_redirect") {
53
+ // child(0) = operator (>, >>), child(1) = destination word
54
+ const dest = n.child(1)?.text ?? "";
55
+ if (dest)
56
+ redirectTargets.push(dest);
57
+ }
58
+ if (n.type === "heredoc_redirect") {
59
+ // heredoc body is safe to ignore, but the target word matters
60
+ const dest = n.child(0)?.text ?? "";
61
+ if (dest)
62
+ redirectTargets.push(dest);
63
+ }
64
+ for (let i = 0; i < n.childCount; i++) {
65
+ const c = n.child(i);
66
+ if (c)
67
+ walk(c);
68
+ }
69
+ };
70
+ walk(root);
71
+ return { commandNames, commandLines, redirectTargets, cdTargets, hasCommandSubstitution, hasDynamicEval, hasError };
72
+ }
73
+ /** Scan one shell command line (exactly what run_command would execute). */
74
+ export function scanCommand(command) {
75
+ const empty = {
76
+ ok: false,
77
+ error: "empty command",
78
+ commands: [],
79
+ commandLines: [],
80
+ redirectTargets: [],
81
+ cdTargets: [],
82
+ hasCommandSubstitution: false,
83
+ hasDynamicEval: false,
84
+ };
85
+ if (!command || !command.trim())
86
+ return empty;
87
+ let tree;
88
+ try {
89
+ tree = getParser().parse(command);
90
+ }
91
+ catch (err) {
92
+ return { ...empty, error: `scanner parse failure: ${err.message}` };
93
+ }
94
+ const r = collect(tree.rootNode);
95
+ if (r.hasError) {
96
+ return {
97
+ ...empty,
98
+ error: "command has invalid syntax or unresolvable constructs — approval required",
99
+ hasCommandSubstitution: r.hasCommandSubstitution,
100
+ hasDynamicEval: r.hasDynamicEval,
101
+ };
102
+ }
103
+ return {
104
+ ok: true,
105
+ commands: r.commandNames,
106
+ commandLines: r.commandLines,
107
+ redirectTargets: r.redirectTargets,
108
+ cdTargets: r.cdTargets,
109
+ hasCommandSubstitution: r.hasCommandSubstitution,
110
+ hasDynamicEval: r.hasDynamicEval,
111
+ };
112
+ }
package/dist/skills.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Skill discovery & loading for scoder.
3
+ *
4
+ * A skill is a directory containing a SKILL.md file with YAML-lite frontmatter:
5
+ *
6
+ * ---
7
+ * name: code-review
8
+ * description: Review code for bugs, security issues, and style
9
+ * ---
10
+ * (markdown instructions the agent follows)
11
+ *
12
+ * Discovery order (later wins on name conflict):
13
+ * 1. ~/.scoder/skills/<name>/SKILL.md (global, user-level)
14
+ * 2. <workdir>/.scoder/skills/<name>/SKILL.md (project-local)
15
+ *
16
+ * Skills are advertised to the model in the system prompt (name + description)
17
+ * so it can request one explicitly; users can also load one directly via
18
+ * `/skill <name>`. Inspired by the SKILL.md convention, implemented from
19
+ * scratch — no external dependencies.
20
+ */
21
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ const SKILL_FILE = "SKILL.md";
25
+ const MAX_SKILL_BODY_CHARS = 20_000;
26
+ /** M5: cảnh báo validate — consumer (Agent/TUI) hiển thị rồi clear. */
27
+ export const skillWarnings = [];
28
+ /** Parse a minimal YAML-lite frontmatter (key: value lines). */
29
+ export function parseFrontmatter(text) {
30
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
31
+ if (!m)
32
+ return { meta: {}, body: text };
33
+ const meta = {};
34
+ for (const line of m[1].split(/\r?\n/)) {
35
+ const kv = /^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/.exec(line.trim());
36
+ if (kv)
37
+ meta[kv[1].toLowerCase()] = kv[2].trim().replace(/^["']|["']$/g, "");
38
+ }
39
+ return { meta, body: m[2].trim() };
40
+ }
41
+ /** Read one SKILL.md into a Skill record (null when the file is malformed). */
42
+ export function loadSkillFile(path, scope) {
43
+ let text;
44
+ try {
45
+ text = readFileSync(path, "utf8");
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ const { meta, body } = parseFrontmatter(text);
51
+ const name = (meta.name || "").trim() || null;
52
+ if (!name)
53
+ return null; // a skill without a usable name is not discoverable
54
+ // M5: thiếu description → skip + warn (Anthropic convention: description
55
+ // là bắt buộc — model dùng nó để quyết định khi nào load skill).
56
+ const description = (meta.description || "").trim();
57
+ if (!description) {
58
+ skillWarnings.push({ path, message: `skill "${name}" thiếu description — bỏ qua (thêm description vào frontmatter)` });
59
+ return null;
60
+ }
61
+ const allowedToolsRaw = (meta["allowed-tools"] || "").trim();
62
+ return {
63
+ name,
64
+ description,
65
+ path,
66
+ scope,
67
+ body: body.length > MAX_SKILL_BODY_CHARS ? body.slice(0, MAX_SKILL_BODY_CHARS) + "\n… [truncated]" : body,
68
+ ...(allowedToolsRaw ? { allowedTools: allowedToolsRaw.split(",").map((t) => t.trim()).filter(Boolean) } : {}),
69
+ ...(meta.license ? { license: meta.license } : {}),
70
+ ...(meta["when-to-use"] ? { whenToUse: meta["when-to-use"] } : {}),
71
+ };
72
+ }
73
+ /** Collect SKILL.md files from one skills root (one level of directories). */
74
+ function scanRoot(root, scope) {
75
+ if (!existsSync(root))
76
+ return [];
77
+ const out = [];
78
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
79
+ if (!entry.isDirectory())
80
+ continue;
81
+ const file = join(root, entry.name, SKILL_FILE);
82
+ if (!existsSync(file))
83
+ continue;
84
+ const skill = loadSkillFile(file, scope);
85
+ if (skill)
86
+ out.push(skill);
87
+ }
88
+ return out;
89
+ }
90
+ /** Global skills root — overridable via SCODER_SKILLS for tests. */
91
+ function globalSkillsRoot() {
92
+ return process.env.SCODER_SKILLS || join(homedir(), ".scoder", "skills");
93
+ }
94
+ /** Discover all skills visible to a project: global first, project overrides. */
95
+ export function discoverSkills(workdir) {
96
+ const global = scanRoot(globalSkillsRoot(), "global");
97
+ const project = scanRoot(join(workdir, ".scoder", "skills"), "project");
98
+ const byName = new Map();
99
+ for (const s of [...global, ...project]) {
100
+ // M5: trùng name → warn (project wins per merge order).
101
+ if (byName.has(s.name)) {
102
+ skillWarnings.push({ path: s.path, message: `skill "${s.name}" trùng tên (${s.scope} overrides ${byName.get(s.name)?.scope})` });
103
+ }
104
+ byName.set(s.name, s); // project wins
105
+ }
106
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
107
+ }
108
+ /** Find one skill by (case-insensitive) name, or null. */
109
+ export function findSkill(workdir, name) {
110
+ const wanted = name.toLowerCase();
111
+ return discoverSkills(workdir).find((s) => s.name.toLowerCase() === wanted) ?? null;
112
+ }
113
+ /**
114
+ * One-line-per-skill advertisement for the system prompt — the model sees
115
+ * names + descriptions and can ask to load a skill, or the user invokes
116
+ * `/skill <name>` directly.
117
+ */
118
+ export function formatSkillIndex(skills) {
119
+ if (skills.length === 0)
120
+ return "";
121
+ return skills.map((s) => `- ${s.name}: ${s.description || "(no description)"}`).join("\n");
122
+ }