@iamem/amem 0.1.2 → 0.1.3

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.
package/README.md CHANGED
@@ -184,7 +184,8 @@ Tabs after setup:
184
184
 
185
185
  1. **Setup** — scan/select repos, platforms, login auto-start, bootstrap proposal
186
186
  2. **Memory** — facts by file, scored drafts (approve / replace older / dismiss / reject noisy), edit/pin/delete, search, recent hits/misses
187
- 3. **Stats** — estimated tokens saved per LLM, plus JSON / markdown / PDF export (proxies, not a bill)
187
+ 3. **Tasks** — per-project Kanban for deferred agent work (Backlog → Next → Doing → Blocked → Done). MCP: `amem_task_add` / `amem_task_update` / `amem_task_complete`. Open tasks appear in `amem_context`. Use Memory for durable facts; Tasks for “do later.”
188
+ 4. **Stats** — estimated tokens saved per LLM, plus JSON / markdown / PDF export (proxies, not a bill)
188
189
 
189
190
  Server-only (no browser open):
190
191
 
@@ -253,6 +254,8 @@ Memory is a small local graph in SQLite:
253
254
  | **Claim** | A durable fact with file anchors (may be `active` or `superseded`; optional pin) |
254
255
  | **Edge** | Links (claim → flow → component); `kind: "supersedes"` archives the target claim |
255
256
  | **Draft** | Pending session / miss→learn proposals waiting for Memory approve |
257
+ | **Task** | Deferred work on the project Kanban (Backlog / Next / Doing / Blocked / Done) — not a durable fact |
258
+ | **Skill** | A reusable multi-step procedure, stored as a `SKILL.md` file (see below) |
256
259
  | **Usage event** | Each `amem context` hit + token estimate |
257
260
 
258
261
  Claims are the retrieval unit. Ranking combines:
@@ -280,6 +283,60 @@ Example claim:
280
283
 
281
284
  ---
282
285
 
286
+ ## Skills (procedural memory)
287
+
288
+ Claims answer *what is true*. Skills answer *how we do this here* — a deploy sequence, a
289
+ migration dance, a debugging path someone already walked. They are too long to sit in every
290
+ prompt, so they load on demand.
291
+
292
+ Skills live as `SKILL.md` files under `~/.amem/skills/`, indexed in SQLite for ranking:
293
+
294
+ ```
295
+ ~/.amem/skills/deploy-staging/SKILL.md
296
+ ~/.amem/skills/deploy-staging/references/runbook.md
297
+ ```
298
+
299
+ **Progressive disclosure.** A context packet carries only names and descriptions. The agent
300
+ calls `amem_skill_view` to pull a body once it decides the procedure applies, so an unused
301
+ library of skills costs almost no tokens.
302
+
303
+ **The learning loop.** amem ships no model, so it never writes a skill itself. At session end
304
+ it looks for the shape of a hard-won procedure — enumerated steps or real commands, plus an
305
+ error it recovered from or a correction you gave. When several signals line up it queues one
306
+ suggestion, which reaches your agent as a nudge in the next context packet. The agent writes
307
+ the skill; you approve it. If a skill was loaded during a session that still went sideways,
308
+ amem queues a revision instead of a duplicate.
309
+
310
+ The bar is deliberately high, and a session can queue at most one suggestion.
311
+
312
+ ```bash
313
+ amem skills list # index of what is stored
314
+ amem skills show deploy-staging # full body
315
+ amem skills new deploy-staging --desc "Deploy staging and verify health"
316
+ amem skills import ./some-skill # bring in an agentskills.io skill
317
+ amem skills drafts # pending suggestions and staged writes
318
+ amem skills approve <draft-id>
319
+ ```
320
+
321
+ Skills are also a Skills tab in `amem ui`.
322
+
323
+ **Safety.** Skills are instructions an agent will follow, so content is scanned for
324
+ credentials and prompt-injection patterns before any write. Three policy keys control them:
325
+
326
+ | Key | Default | Effect |
327
+ | --- | --- | --- |
328
+ | `skills_enabled` | `true` | Master switch for storage, ranking, and injection |
329
+ | `skill_write_approval` | `false` | Stage agent writes for review instead of writing to disk |
330
+ | `skill_capture` | `true` | Allow session-end skill suggestions |
331
+
332
+ An unreadable `policy.toml` forces `skill_write_approval` on. `amem doctor --attest` reports
333
+ every installed skill with a content hash, so you can diff what agents are being told to do.
334
+
335
+ > Backups currently copy the database only — `~/.amem/skills/` is not included yet. Keep
336
+ > skills you care about in version control until that lands.
337
+
338
+ ---
339
+
283
340
  ## Token savings (estimates)
284
341
 
285
342
  Every `amem context` logs a usage event. The UI **Stats** tab breaks this down by platform (`cursor`, `claude`, …).
@@ -380,6 +437,9 @@ MCP tools (stdio or HTTP):
380
437
  | `amem_context` | Ranked memory packet for the current question |
381
438
  | `amem_remember` | Store a durable fact after an outcome |
382
439
  | `amem_recipe` | Generic read-then-write contract (any MCP host) |
440
+ | `amem_skill_list` | Cheap index of stored procedures (names + descriptions only) |
441
+ | `amem_skill_view` | Load one skill body, after the index says it applies |
442
+ | `amem_skill_save` | Store a multi-step procedure you just worked out |
383
443
  | `amem_repos` | What is monitored (git repos + named workspaces) |
384
444
  | `amem_stats` | Lookup time, estimated tokens/ms saved, hit rate |
385
445
  | `amem_graph` | Claims / components / flows stored for a workspace or repo |
@@ -400,6 +460,8 @@ amem doctor [--attest] [--json]
400
460
  amem context "<query>" [--workspace <name>] [--platform …]
401
461
  amem remember "<text>" [--workspace <name>] [--kind …] [--anchor <path>]
402
462
  amem recipe [--json]
463
+ amem skills list|show <name>|new <name> [--desc <text>]|rm <name>|sync|import <path>
464
+ amem skills drafts|approve <draft-id>|dismiss <draft-id>
403
465
  amem mcp [--print-config] [--workspace <name>]
404
466
  amem propose validate|diff|apply <file.json>
405
467
  amem export [--out <file.json>]
@@ -428,6 +490,7 @@ amem service install|uninstall|status
428
490
  | `context` | Retrieve a Markdown packet; log usage |
429
491
  | `remember` | Store one local fact |
430
492
  | `mcp` | Stdio MCP tools; HTTP MCP at `http://127.0.0.1:7843/mcp` while UI runs |
493
+ | `skills` | Manage procedural memory (`list`, `show`, `new`, `import`, `drafts`, `approve`) |
431
494
  | `propose diff` | Preview claim/component/flow changes before apply |
432
495
  | `propose apply` | Upsert structured memory locally |
433
496
  | `lock` / `unlock` | Optional AES-256-GCM encrypt-at-rest for `graph.db` |
@@ -3,12 +3,29 @@ import { resolve, join } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { estimateUsdSaved, metricsFromPacket, USD_PER_MILLION_INPUT_TOKENS } from "../estimate.js";
5
5
  import { buildActivityGraph, speedForEvent } from "../activity.js";
6
- import { getRepoByCwd, getRepoById, getRepoByName, renameWorkspace, getSetupState, insertUsageEvent, listClaims, listClaimsAll, listComponents, listComponentsAll, listEdges, listEdgesAll, listFlows, listFlowsAll, listRepos, listSessions, listSessionsAll, listUsageEvents, listProposalDrafts, listProposalDraftsAll, countProposalDrafts, countProposalDraftsAll, getProposalDraft, setProposalDraftStatus, updateClaim, setClaimPinned, deleteClaim, setReportedOnLatest, setReportedTokensSaved, upsertRepo, upsertSetupState, wipeRepo, openDb, closeDb, } from "../db.js";
6
+ import { getRepoByCwd, getRepoById, getRepoByName, renameWorkspace, getSetupState, insertUsageEvent, listClaims, listClaimsAll, listComponents, listComponentsAll, listEdges, listEdgesAll, listFlows, listFlowsAll, listRepos, listSessions, listSessionsAll, listUsageEvents, listProposalDrafts, listProposalDraftsAll, countProposalDrafts, countProposalDraftsAll, getProposalDraft, setProposalDraftStatus, listTasks, listTasksAll, getSkillDraft, insertSkillDraft, listSkillDrafts, recordSkillUse, setSkillDraftStatus, setSkillRepo, findTaskAnyRepo, getTask, insertTask, updateTask, completeTask, deleteTask, countTasks, countTasksAll, normalizeTaskStatus, updateClaim, setClaimPinned, deleteClaim, setReportedOnLatest, setReportedTokensSaved, upsertRepo, upsertSetupState, wipeRepo, openDb, closeDb, } from "../db.js";
7
+ import { deleteSkill, findSkillOnDisk, isValidSkillName, listIndexedSkills, listSkillAssets, rankSkills, readSkillAsset, readSkillBody, renderSkillMarkdown, scanSkillContent, skillsDir, slugifySkillName, syncSkillIndex, writeSkill, } from "../skills.js";
7
8
  import { buildContext, buildRetrievalShowdown, decorateUsageEvents, renderContextMarkdown } from "../context.js";
8
9
  import { installClaude, claudeInstallHealth } from "../install/claude.js";
9
10
  import { installCursor, cursorInstallHealth } from "../install/cursor.js";
10
11
  import { hostInstallHealth, installHost } from "../install/hosts.js";
11
12
  import { decorateDraft, decorateDrafts } from "../draft-quality.js";
13
+ /**
14
+ * Which repo owns a task. Scoped requests may only touch the current repo; an
15
+ * all-memory request resolves the task's real owner so a board that shows every
16
+ * memory can also edit what it shows.
17
+ */
18
+ function taskOwnerRepoId(id, currentRepoId, all) {
19
+ if (currentRepoId) {
20
+ const direct = getTask(currentRepoId, id);
21
+ if (direct)
22
+ return currentRepoId;
23
+ }
24
+ if (all) {
25
+ return findTaskAnyRepo(id)?.repo_id ?? null;
26
+ }
27
+ return null;
28
+ }
12
29
  import { isUsefulRememberText } from "../capture.js";
13
30
  import { buildSavingsExport, formatSavingsMarkdown, savingsPdf, } from "../savings-export.js";
14
31
  import { assertPlatformAllowed, assertRemoteAllowed, loadPolicy, } from "../policy.js";
@@ -58,6 +75,40 @@ function ok(body) {
58
75
  function err(status, message) {
59
76
  return { status, body: { error: message } };
60
77
  }
78
+ function safeJsonArray(raw) {
79
+ try {
80
+ const list = JSON.parse(raw);
81
+ return Array.isArray(list) ? list.filter((v) => typeof v === "string") : [];
82
+ }
83
+ catch {
84
+ return [];
85
+ }
86
+ }
87
+ /** Once a skill is saved, the nudge that prompted it has done its job. */
88
+ function resolveSuggestionFor(repoId, sessionId) {
89
+ if (!repoId)
90
+ return;
91
+ for (const draft of listSkillDrafts({ status: "pending", repoId, limit: 20 })) {
92
+ if (draft.kind !== "suggestion")
93
+ continue;
94
+ if (sessionId && draft.session_id && draft.session_id !== sessionId)
95
+ continue;
96
+ setSkillDraftStatus(draft.id, "applied");
97
+ return;
98
+ }
99
+ }
100
+ /** Level-0 view of a skill: enough to decide whether to load it, without the body. */
101
+ function skillSummary(skill) {
102
+ return {
103
+ name: skill.name,
104
+ description: skill.description,
105
+ version: skill.version,
106
+ tags: skill.tags,
107
+ source: skill.source,
108
+ repo_id: skill.repoId ?? null,
109
+ uses: skill.uses ?? 0,
110
+ };
111
+ }
61
112
  function bodyField(body, key) {
62
113
  if (!body || typeof body !== "object")
63
114
  return undefined;
@@ -1242,6 +1293,285 @@ export function handleApi(req) {
1242
1293
  return err(404, "Claim not found");
1243
1294
  return ok({ deleted: id });
1244
1295
  }
1296
+ // Skills are a global library, not repo-scoped like claims and tasks — no `repo` guard.
1297
+ if (method === "GET" && pathname === "/api/skills") {
1298
+ const query = searchParams.get("q") || "";
1299
+ const skills = listIndexedSkills();
1300
+ const ranked = query ? rankSkills(skills, query, Number(searchParams.get("limit") || 10)) : [];
1301
+ return ok({
1302
+ skills: skills.map(skillSummary),
1303
+ matches: ranked.map((s) => ({ ...skillSummary(s), score: s.score, reasons: s.reasons })),
1304
+ dir: skillsDir(),
1305
+ });
1306
+ }
1307
+ if (method === "GET" && pathname === "/api/skills/view") {
1308
+ const name = searchParams.get("name");
1309
+ if (!name)
1310
+ return err(400, "name required");
1311
+ const file = searchParams.get("file");
1312
+ if (file) {
1313
+ const asset = readSkillAsset(name, file);
1314
+ if (asset === null)
1315
+ return err(404, "Skill file not found");
1316
+ return ok({ name, file, content: asset });
1317
+ }
1318
+ const meta = findSkillOnDisk(name);
1319
+ if (!meta)
1320
+ return err(404, "Skill not found");
1321
+ const content = readSkillBody(name);
1322
+ if (content === null)
1323
+ return err(404, "Skill not found");
1324
+ // A skill can be viewed before anything indexed it, and the usage counter lives in
1325
+ // the index — reconcile first or the increment silently updates zero rows.
1326
+ syncSkillIndex();
1327
+ recordSkillUse(meta.name, {
1328
+ repoId: repo ? repo.id : null,
1329
+ sessionId: searchParams.get("session_id"),
1330
+ });
1331
+ return ok({ ...skillSummary(meta), content, files: listSkillAssets(meta.name) });
1332
+ }
1333
+ if (method === "POST" && pathname === "/api/skills") {
1334
+ const name = bodyField(body, "name");
1335
+ if (!name)
1336
+ return err(400, "name required");
1337
+ const slug = slugifySkillName(name);
1338
+ if (!isValidSkillName(slug))
1339
+ return err(400, "Invalid skill name");
1340
+ const content = bodyField(body, "content");
1341
+ const description = bodyField(body, "description") || "";
1342
+ // Accept either a full SKILL.md or the parts, so agents can save without templating.
1343
+ const markdown = content && content.includes("---")
1344
+ ? content
1345
+ : renderSkillMarkdown({
1346
+ name: slug,
1347
+ description,
1348
+ body: content || "",
1349
+ version: bodyField(body, "version"),
1350
+ });
1351
+ const scan = scanSkillContent(markdown);
1352
+ if (!scan.ok)
1353
+ return err(400, `Rejected: ${scan.reason}`);
1354
+ const policy = loadPolicy().policy;
1355
+ if (!policy.skills_enabled)
1356
+ return err(403, "Skills are disabled by policy");
1357
+ // A SKILL.md is too long to review inline, so an approval gate stages rather than
1358
+ // blocks — the agent keeps working and a human decides later.
1359
+ if (policy.skill_write_approval) {
1360
+ const draft = insertSkillDraft({
1361
+ repoId: repo ? repo.id : null,
1362
+ name: slug,
1363
+ title: description || slug,
1364
+ summary: description,
1365
+ content: markdown,
1366
+ kind: findSkillOnDisk(slug) ? "revision" : "create",
1367
+ targetSkill: findSkillOnDisk(slug) ? slug : null,
1368
+ source: `agent-save:${slug}:${Date.now()}`,
1369
+ sessionId: bodyField(body, "session_id") ?? null,
1370
+ reasons: ["staged by skill_write_approval"],
1371
+ });
1372
+ return ok({
1373
+ staged: draft.id,
1374
+ name: slug,
1375
+ pending: true,
1376
+ message: "Skill staged for review — approve it in the Skills tab or `amem skills drafts`.",
1377
+ });
1378
+ }
1379
+ const written = writeSkill(slug, markdown);
1380
+ syncSkillIndex();
1381
+ const repoId = bodyField(body, "repo_id") ?? (repo ? repo.id : null);
1382
+ if (repoId)
1383
+ setSkillRepo(slug, repoId);
1384
+ resolveSuggestionFor(repo?.id, bodyField(body, "session_id"));
1385
+ return ok({ saved: written.name, path: written.path, hash: written.hash });
1386
+ }
1387
+ if (method === "GET" && pathname === "/api/skills/drafts") {
1388
+ const drafts = listSkillDrafts({
1389
+ status: searchParams.get("status") || "pending",
1390
+ limit: Number(searchParams.get("limit") || 50),
1391
+ });
1392
+ const repoNames = new Map(listRepos().map((r) => [r.id, r.repo_name]));
1393
+ return ok({
1394
+ drafts: drafts.map((d) => ({
1395
+ ...d,
1396
+ reasons: safeJsonArray(d.reasons),
1397
+ repo_name: d.repo_id ? (repoNames.get(d.repo_id) ?? null) : null,
1398
+ // A suggestion has no content yet — only an agent can write the body.
1399
+ has_content: Boolean(d.content),
1400
+ })),
1401
+ counts: { pending: listSkillDrafts({ status: "pending", limit: 200 }).length },
1402
+ });
1403
+ }
1404
+ if (method === "POST" && pathname === "/api/skills/drafts/apply") {
1405
+ const id = bodyField(body, "id");
1406
+ if (!id)
1407
+ return err(400, "id required");
1408
+ const draft = getSkillDraft(id);
1409
+ if (!draft)
1410
+ return err(404, "Draft not found");
1411
+ if (!draft.content || !draft.name) {
1412
+ return err(400, "This is a suggestion, not a staged skill — an agent must write it first");
1413
+ }
1414
+ const scan = scanSkillContent(draft.content);
1415
+ if (!scan.ok)
1416
+ return err(400, `Rejected: ${scan.reason}`);
1417
+ const written = writeSkill(draft.name, draft.content);
1418
+ syncSkillIndex();
1419
+ if (draft.repo_id)
1420
+ setSkillRepo(draft.name, draft.repo_id);
1421
+ setSkillDraftStatus(id, "applied");
1422
+ return ok({ applied: id, name: written.name, path: written.path });
1423
+ }
1424
+ if (method === "POST" && pathname === "/api/skills/drafts/dismiss") {
1425
+ const id = bodyField(body, "id");
1426
+ if (!id)
1427
+ return err(400, "id required");
1428
+ if (!getSkillDraft(id))
1429
+ return err(404, "Draft not found");
1430
+ setSkillDraftStatus(id, "dismissed");
1431
+ return ok({ dismissed: id });
1432
+ }
1433
+ if (method === "DELETE" && pathname === "/api/skills") {
1434
+ const name = searchParams.get("name") || bodyField(body, "name");
1435
+ if (!name)
1436
+ return err(400, "name required");
1437
+ const removed = deleteSkill(name);
1438
+ if (!removed)
1439
+ return err(404, "Skill not found");
1440
+ syncSkillIndex();
1441
+ return ok({ deleted: slugifySkillName(name) });
1442
+ }
1443
+ if (method === "GET" && pathname === "/api/tasks") {
1444
+ const all = searchParams.get("scope") === "all" ||
1445
+ bodyField(body, "scope") === "all" ||
1446
+ searchParams.get("repo") === "all";
1447
+ if (!all && !repo)
1448
+ return err(400, "Repo not initialized");
1449
+ const statusRaw = searchParams.get("status") || bodyField(body, "status");
1450
+ const status = statusRaw ? normalizeTaskStatus(statusRaw) : null;
1451
+ if (statusRaw && !status)
1452
+ return err(400, "invalid status");
1453
+ const includeDone = searchParams.get("include_done") === "1" ||
1454
+ searchParams.get("include_done") === "true" ||
1455
+ bodyField(body, "include_done") === "1" ||
1456
+ bodyField(body, "include_done") === "true";
1457
+ const listOpts = {
1458
+ status: status || undefined,
1459
+ includeDone: includeDone || Boolean(status === "done"),
1460
+ limit: Number(searchParams.get("limit") || (all ? 200 : 100)),
1461
+ };
1462
+ const tasks = all ? listTasksAll(listOpts) : listTasks(repo.id, listOpts);
1463
+ const count = (o) => all ? countTasksAll(o) : countTasks(repo.id, o);
1464
+ // Name the owning memory so an all-memory board can say where each task lives.
1465
+ const repoNames = all
1466
+ ? new Map(listRepos().map((r) => [r.id, r.repo_name]))
1467
+ : new Map();
1468
+ return ok({
1469
+ scope: all ? "all" : "current",
1470
+ tasks: all
1471
+ ? tasks.map((t) => ({ ...t, repo_name: repoNames.get(t.repo_id) ?? null }))
1472
+ : tasks,
1473
+ counts: {
1474
+ open: count({ openOnly: true }),
1475
+ backlog: count({ status: "backlog" }),
1476
+ next: count({ status: "next" }),
1477
+ doing: count({ status: "doing" }),
1478
+ blocked: count({ status: "blocked" }),
1479
+ done: count({ status: "done" }),
1480
+ },
1481
+ });
1482
+ }
1483
+ if (method === "POST" && pathname === "/api/tasks") {
1484
+ const targetRepo = repo || ensurePersonalWorkspace();
1485
+ const payload = body && typeof body === "object" ? body : {};
1486
+ const title = typeof payload.title === "string" ? payload.title : "";
1487
+ if (!title.trim())
1488
+ return err(400, "title required");
1489
+ let anchors;
1490
+ if (Array.isArray(payload.anchors)) {
1491
+ anchors = payload.anchors.filter((a) => typeof a === "string");
1492
+ }
1493
+ try {
1494
+ const task = insertTask({
1495
+ repoId: targetRepo.id,
1496
+ title,
1497
+ body: typeof payload.body === "string" ? payload.body : "",
1498
+ status: typeof payload.status === "string" ? payload.status : "backlog",
1499
+ anchors,
1500
+ source: typeof payload.source === "string" ? payload.source : "ui",
1501
+ });
1502
+ return ok({ task });
1503
+ }
1504
+ catch (error) {
1505
+ return err(400, error instanceof Error ? error.message : String(error));
1506
+ }
1507
+ }
1508
+ if (method === "PATCH" && pathname === "/api/tasks") {
1509
+ const all = searchParams.get("scope") === "all" ||
1510
+ bodyField(body, "scope") === "all" ||
1511
+ searchParams.get("repo") === "all";
1512
+ if (!all && !repo)
1513
+ return err(400, "Repo not initialized");
1514
+ const payload = body && typeof body === "object" ? body : {};
1515
+ const id = bodyField(body, "id");
1516
+ if (!id)
1517
+ return err(400, "id required");
1518
+ // In all-memory scope the card may belong to any repo, so find its real owner.
1519
+ const ownerId = taskOwnerRepoId(id, repo?.id, all);
1520
+ if (!ownerId)
1521
+ return err(404, "Task not found");
1522
+ let anchors;
1523
+ if (Array.isArray(payload.anchors)) {
1524
+ anchors = payload.anchors.filter((a) => typeof a === "string");
1525
+ }
1526
+ try {
1527
+ const task = updateTask(ownerId, id, {
1528
+ title: typeof payload.title === "string" ? payload.title : undefined,
1529
+ body: typeof payload.body === "string" ? payload.body : undefined,
1530
+ status: typeof payload.status === "string" ? payload.status : undefined,
1531
+ anchors,
1532
+ });
1533
+ if (!task)
1534
+ return err(404, "Task not found");
1535
+ return ok({ task });
1536
+ }
1537
+ catch (error) {
1538
+ return err(400, error instanceof Error ? error.message : String(error));
1539
+ }
1540
+ }
1541
+ if (method === "POST" && pathname === "/api/tasks/complete") {
1542
+ const all = searchParams.get("scope") === "all" ||
1543
+ bodyField(body, "scope") === "all" ||
1544
+ searchParams.get("repo") === "all";
1545
+ if (!all && !repo)
1546
+ return err(400, "Repo not initialized");
1547
+ const id = bodyField(body, "id");
1548
+ if (!id)
1549
+ return err(400, "id required");
1550
+ const ownerId = taskOwnerRepoId(id, repo?.id, all);
1551
+ if (!ownerId)
1552
+ return err(404, "Task not found");
1553
+ const task = completeTask(ownerId, id);
1554
+ if (!task)
1555
+ return err(404, "Task not found");
1556
+ return ok({ task });
1557
+ }
1558
+ if (method === "DELETE" && pathname === "/api/tasks") {
1559
+ const all = searchParams.get("scope") === "all" ||
1560
+ bodyField(body, "scope") === "all" ||
1561
+ searchParams.get("repo") === "all";
1562
+ if (!all && !repo)
1563
+ return err(400, "Repo not initialized");
1564
+ const id = searchParams.get("id") || bodyField(body, "id");
1565
+ if (!id)
1566
+ return err(400, "id required");
1567
+ const ownerId = taskOwnerRepoId(id, repo?.id, all);
1568
+ if (!ownerId)
1569
+ return err(404, "Task not found");
1570
+ const removed = deleteTask(ownerId, id);
1571
+ if (!removed)
1572
+ return err(404, "Task not found");
1573
+ return ok({ deleted: id });
1574
+ }
1245
1575
  if (method === "GET" && pathname === "/api/usage/export") {
1246
1576
  const scope = searchParams.get("scope") ?? "current";
1247
1577
  const days = Number(searchParams.get("days") ?? "30");
package/dist/attest.d.ts CHANGED
@@ -38,6 +38,19 @@ export type AttestReport = {
38
38
  };
39
39
  license: ReturnType<typeof licenseStatus>;
40
40
  embed: ReturnType<typeof embedStatus>;
41
+ /** Procedural memory an auditor should be able to review: what agents may be told to do. */
42
+ skills: {
43
+ dir: string;
44
+ enabled: boolean;
45
+ write_approval: boolean;
46
+ pending_drafts: number;
47
+ installed: Array<{
48
+ name: string;
49
+ description: string;
50
+ source: string;
51
+ hash: string;
52
+ }>;
53
+ };
41
54
  sku?: {
42
55
  tier: string;
43
56
  airgap: true;
package/dist/attest.js CHANGED
@@ -12,6 +12,8 @@ import { FEATURE_ATTEST_SKU, hasFeature, licenseStatus } from "./license.js";
12
12
  import { embedIndexIssues, embedStatus } from "./embed.js";
13
13
  import { vaultStatus } from "./vault.js";
14
14
  import { hostInstallHealth } from "./install/hosts.js";
15
+ import { listSkillDrafts } from "./db.js";
16
+ import { scanSkills, skillsDir } from "./skills.js";
15
17
  function packageRoot() {
16
18
  const here = fileURLToPath(new URL(".", import.meta.url));
17
19
  // dist/ -> package root
@@ -104,6 +106,8 @@ export function buildAttestReport(cwd = process.cwd()) {
104
106
  catch {
105
107
  // Locked vault: the vault section already reports that.
106
108
  }
109
+ const skills = skillsAttestSection();
110
+ issues.push(...skillIssues(skills));
107
111
  const pkgPath = join(packageRoot(), "package.json");
108
112
  const sku = hasFeature(FEATURE_ATTEST_SKU)
109
113
  ? {
@@ -154,11 +158,51 @@ export function buildAttestReport(cwd = process.cwd()) {
154
158
  },
155
159
  license,
156
160
  embed,
161
+ skills,
157
162
  sku,
158
163
  ok: issues.length === 0,
159
164
  issues,
160
165
  };
161
166
  }
167
+ /**
168
+ * Inventory of procedural memory. Hashes let an auditor diff what agents are being told to
169
+ * do between two machines, which a bare list of names would not support.
170
+ */
171
+ function skillsAttestSection() {
172
+ const policy = loadPolicy().policy;
173
+ const base = {
174
+ dir: skillsDir(),
175
+ enabled: policy.skills_enabled,
176
+ write_approval: policy.skill_write_approval,
177
+ };
178
+ try {
179
+ return {
180
+ ...base,
181
+ pending_drafts: listSkillDrafts({ status: "pending", limit: 200 }).length,
182
+ installed: scanSkills().map((s) => ({
183
+ name: s.name,
184
+ description: s.description,
185
+ source: s.source,
186
+ hash: s.hash,
187
+ })),
188
+ };
189
+ }
190
+ catch {
191
+ return { ...base, pending_drafts: 0, installed: [] };
192
+ }
193
+ }
194
+ function skillIssues(skills) {
195
+ const issues = [];
196
+ for (const skill of skills.installed) {
197
+ if (!skill.description.trim()) {
198
+ issues.push(`skill ${skill.name} has no description — agents cannot rank it`);
199
+ }
200
+ }
201
+ if (skills.pending_drafts > 0 && skills.write_approval) {
202
+ issues.push(`${skills.pending_drafts} skill write(s) awaiting approval`);
203
+ }
204
+ return issues;
205
+ }
162
206
  export function formatAttestHuman(report) {
163
207
  const lines = [
164
208
  `amem attest ${report.ok ? "OK" : "ISSUES"} · v${report.version}`,