akm-opencode 0.4.3 → 0.5.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.
package/index.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import { type Plugin, tool } from "@opencode-ai/plugin"
2
- import { execFileSync, execSync } from "node:child_process"
2
+ import { execFileSync, execSync, spawn } from "node:child_process"
3
+ import { readFileSync } from "node:fs"
3
4
  import path from "node:path"
5
+ import { fileURLToPath } from "node:url"
4
6
 
5
7
  let resolvedAkmCommand = "akm"
6
8
  const autoInstallPackageRef = "akm-cli@latest"
9
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url))
7
10
 
8
11
  const AKM_AUTO_FEEDBACK = (process.env.AKM_AUTO_FEEDBACK ?? "1") !== "0"
9
12
  const AKM_AUTO_MEMORY = (process.env.AKM_AUTO_MEMORY ?? "1") !== "0"
@@ -12,11 +15,23 @@ const AKM_AUTO_HINTS = (process.env.AKM_AUTO_HINTS ?? "1") !== "0"
12
15
  const AKM_CURATE_LIMIT = Math.max(1, Number(process.env.AKM_CURATE_LIMIT ?? "5") || 5)
13
16
  const AKM_CURATE_MIN_CHARS = Math.max(1, Number(process.env.AKM_CURATE_MIN_CHARS ?? "16") || 16)
14
17
  const AKM_CURATE_TIMEOUT_MS = Math.max(1_000, (Number(process.env.AKM_CURATE_TIMEOUT ?? "8") || 8) * 1_000)
18
+ const AKM_MEMORY_CHECKPOINT_EVERY = Math.max(1, Number(process.env.AKM_MEMORY_CHECKPOINT_EVERY ?? "8") || 8)
19
+ const AKM_CURATOR_CONTEXT_MAX_CHARS = Math.max(500, Number(process.env.AKM_CURATOR_CONTEXT_MAX_CHARS ?? "4000") || 4000)
20
+ const SESSION_DATE_TAG_LENGTH = 8
21
+ const CHECKPOINT_DATE_TAG_LENGTH = 15
22
+ const AKM_RETROSPECTIVE_FEEDBACK_RE = createRetrospectiveFeedbackRegex()
23
+ const PLUGIN_VERSION = readPackageVersion()
15
24
 
16
25
  // Per-session state that drives the compound-engineering loop.
17
26
  // These maps are keyed by OpenCode sessionID.
18
27
  const sessionHints = new Map<string, string>()
19
28
  const sessionCurated = new Map<string, string>()
29
+ const sessionWorkflow = new Map<string, string>()
30
+ const sessionCuratorReport = new Map<string, string>()
31
+ const sessionContextEpoch = new Map<string, number>()
32
+ const sessionContextInjectedEpoch = new Map<string, number>()
33
+ const sessionCuratedVersion = new Map<string, number>()
34
+ const sessionCuratedInjectedVersion = new Map<string, number>()
20
35
  type SessionBufferEntry = {
21
36
  timestamp: string
22
37
  kind: "memory-intent" | "tool-ref"
@@ -24,32 +39,60 @@ type SessionBufferEntry = {
24
39
  ref?: string
25
40
  status?: "positive" | "negative" | "unknown"
26
41
  note?: string
42
+ checkpointed?: boolean
27
43
  }
28
44
  const sessionBuffer = new Map<string, SessionBufferEntry[]>()
29
- const sessionMemoryCaptured = new Set<string>()
45
+ const sessionFinalMemoryCaptured = new Set<string>()
46
+ const sessionSuccessfulAssetTouchCount = new Map<string, number>()
47
+ let cachedAkmStashDir: string | undefined
30
48
 
31
- // Asset-ref grammar matching the stash skill: [origin//]type:name
32
- const AKM_REF_PATTERN = /(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script):[A-Za-z0-9._/\-]+/g
49
+ // Asset-ref grammar matching the stash skill: [origin//]type:name.
50
+ // We validate normalized tokens individually instead of running a global regex
51
+ // over arbitrary tool output to keep extraction predictable and ReDoS-safe.
52
+ const AKM_REF_PATTERN = /^(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|vault|wiki):[A-Za-z0-9._/\-]+$/
33
53
 
34
- const CURATOR_AGENT_PROMPT = `You are the AKM curator — a compound-engineering agent that keeps the user's AKM stash improving every time the main agent finishes a task.
54
+ function readPackageVersion(): string {
55
+ try {
56
+ const raw = readFileSync(path.join(moduleDir, "package.json"), "utf8")
57
+ const parsed = JSON.parse(raw) as { version?: unknown }
58
+ return typeof parsed.version === "string" && parsed.version ? parsed.version : "0.0.0"
59
+ } catch {
60
+ return "0.0.0"
61
+ }
62
+ }
63
+
64
+ function createRetrospectiveFeedbackRegex(): RegExp {
65
+ const pattern = process.env.AKM_RETROSPECTIVE_FEEDBACK_PATTERN ?? "\\b(thanks|perfect|worked)\\b"
66
+ try {
67
+ return new RegExp(pattern, "i")
68
+ } catch {
69
+ return /\b(thanks|perfect|worked)\b/i
70
+ }
71
+ }
72
+
73
+ const CURATOR_AGENT_PROMPT_FALLBACK = `You are the AKM curator — a compound-engineering agent that keeps the user's AKM stash improving every time the main agent finishes a task.
35
74
 
36
75
  Inputs you should inspect:
37
76
  1. OpenCode app logs that include the "akm-opencode" service (feedback, memory, tool invocations).
38
77
  2. Session-summary memories named memory:opencode-session-*.
39
- 3. The live stash: call akm_list, akm_search "" --limit 50, and akm_show <ref>.
78
+ 3. The live stash: call akm_search "" --limit 50 (and akm_show <ref>) to enumerate assets; reach for akm_help topic="list sources" if you need the configured-sources view.
79
+ 4. Parent-session context via akm_parent_messages when this session was dispatched as a child.
40
80
 
41
81
  Signals to act on:
42
82
  - Hot refs: assets repeatedly appearing in positive tool outcomes. Call akm_feedback <ref> positive --note "curator: consistently useful" to reinforce.
43
83
  - Cold refs: assets tied to failures or user complaints. Record akm_feedback <ref> negative --note "<excerpt>" and open the asset for review.
44
- - Missing coverage: recurring user prompts with no matching asset. Draft a new skill, command, or knowledge doc in the working stash and reindex with akm_index.
84
+ - Missing coverage: recurring user prompts with no matching asset. Draft a new skill, command, knowledge doc, wiki page, or workflow in the working stash and reindex via the akm CLI (see akm_help topic="reindex").
45
85
  - Duplicates / drift: near-identical descriptions or overlapping responsibilities. Propose a consolidation.
46
- - Stale memories: session summaries that never get recalled. Propose akm_remove memory:<name> once distilled into a durable knowledge doc.
86
+ - Stale memories: session summaries that never get recalled. Propose removal (see akm_help topic="remove") once distilled into a durable knowledge doc or wiki page.
87
+ - Wiki hygiene: for each wiki returned by akm_wiki list, run akm_wiki lint <name> and report orphans, broken xrefs, uncited raws, and stale indexes as fix candidates.
88
+ - Stuck workflows: run akm_workflow list --active and surface any runs in blocked or failed state with their step ids. Propose whether to resume or escalate.
89
+ - Never touch vaults: do not call akm_vault show or load unless the user explicitly asks. Vault values must never appear in reports.
47
90
 
48
91
  Rules of engagement:
49
92
  - Never apply destructive changes without explicit user approval.
50
93
  - Report findings as a prioritized action list of concrete akm_* tool calls the user can run.
51
94
  - Prefer small, reversible edits: promote via positive feedback, draft a candidate skill, or clone and tweak.
52
- - When drafting new assets, write them into the working stash directory (akm_config get stashDir) under skills/, commands/, agents/, knowledge/, or scripts/. Call akm_index when finished.
95
+ - When drafting new assets, write them into the working stash directory under skills/, commands/, agents/, knowledge/, or scripts/. Use akm_help (topic="config" / topic="reindex") to look up the right CLI invocation when you need the stash path or want to force a reindex.
53
96
  - When finished, persist your own summary with akm_remember (name: curator-run-<timestamp>) so the next curator run can build on yours.
54
97
 
55
98
  Output shape: end every run with a markdown report that has these sections:
@@ -66,10 +109,33 @@ Output shape: end every run with a markdown report that has these sections:
66
109
  ## Duplicates / drift
67
110
  - <ref a> vs <ref b> — consolidation proposal
68
111
 
112
+ ## Wiki health
113
+ - <wiki> — lint findings (orphan, broken-xref, uncited-raw, stale-index) with suggested fix
114
+
115
+ ## Workflow health
116
+ - <workflow|runId> — blocked/failed state — resume or escalate
117
+
69
118
  ## Housekeeping
70
119
  - stale memories, reindex needs, config tweaks
71
120
  `
72
121
 
122
+ function loadCuratorAgentPrompt(): string {
123
+ try {
124
+ const raw = readFileSync(path.join(moduleDir, "agent", "akm-curator.md"), "utf8").trim()
125
+ let body = raw
126
+ const lines = raw.split(/\r?\n/)
127
+ if (lines[0] === "---") {
128
+ const closingIndex = lines.indexOf("---", 1)
129
+ if (closingIndex > 0) body = lines.slice(closingIndex + 1).join("\n").trim()
130
+ }
131
+ return body || CURATOR_AGENT_PROMPT_FALLBACK
132
+ } catch {
133
+ return CURATOR_AGENT_PROMPT_FALLBACK
134
+ }
135
+ }
136
+
137
+ const CURATOR_AGENT_PROMPT = loadCuratorAgentPrompt()
138
+
73
139
  type LogLevel = "debug" | "info" | "warn" | "error"
74
140
 
75
141
  type LogCapableClient = {
@@ -139,6 +205,11 @@ function nowIso(): string {
139
205
  return new Date().toISOString()
140
206
  }
141
207
 
208
+ function buildDateTag(options?: { includeTime?: boolean }): string {
209
+ const compactIso = new Date().toISOString().replace(/[-:]/g, "")
210
+ return compactIso.slice(0, options?.includeTime ? CHECKPOINT_DATE_TAG_LENGTH : SESSION_DATE_TAG_LENGTH)
211
+ }
212
+
142
213
  function addBufferEntry(sessionID: string | undefined, entry: Omit<SessionBufferEntry, "timestamp">) {
143
214
  if (!sessionID) return
144
215
  const buf = sessionBuffer.get(sessionID) ?? []
@@ -146,6 +217,26 @@ function addBufferEntry(sessionID: string | undefined, entry: Omit<SessionBuffer
146
217
  sessionBuffer.set(sessionID, buf)
147
218
  }
148
219
 
220
+ function markContextEpochDirty(sessionID: string) {
221
+ sessionContextEpoch.set(sessionID, (sessionContextEpoch.get(sessionID) ?? 0) + 1)
222
+ }
223
+
224
+ function bumpCuratedVersion(sessionID: string) {
225
+ sessionCuratedVersion.set(sessionID, (sessionCuratedVersion.get(sessionID) ?? 0) + 1)
226
+ }
227
+
228
+ function isAkmRef(value: string): boolean {
229
+ return AKM_REF_PATTERN.test(value)
230
+ }
231
+
232
+ function parseMaybeJson(value: string): unknown {
233
+ try {
234
+ return JSON.parse(value)
235
+ } catch {
236
+ return undefined
237
+ }
238
+ }
239
+
149
240
  // Synchronous CLI invocation used by the lifecycle hooks — the plugin host does
150
241
  // not await these in a hot path, but we still cap execution time so a slow
151
242
  // stash never wedges the session loop.
@@ -193,6 +284,92 @@ function runHintsForSession(): string | null {
193
284
  return body || null
194
285
  }
195
286
 
287
+ function summarizeWorkflowList(value: unknown): string | null {
288
+ if (Array.isArray(value)) {
289
+ const lines = value
290
+ .map((item) => {
291
+ if (!item || typeof item !== "object") return null
292
+ const record = item as Record<string, unknown>
293
+ const id = typeof record.runId === "string"
294
+ ? record.runId
295
+ : typeof record.id === "string"
296
+ ? record.id
297
+ : null
298
+ const ref = typeof record.ref === "string"
299
+ ? record.ref
300
+ : typeof record.workflowRef === "string"
301
+ ? record.workflowRef
302
+ : null
303
+ const state = typeof record.state === "string" ? record.state : typeof record.status === "string" ? record.status : null
304
+ const step = typeof record.step === "string"
305
+ ? record.step
306
+ : typeof record.currentStep === "string"
307
+ ? record.currentStep
308
+ : null
309
+ if (!id && !ref && !state && !step) return null
310
+ return `- ${ref ?? "workflow"} (${id ?? "run"})${state ? ` — ${state}` : ""}${step ? ` — next: ${step}` : ""}`
311
+ })
312
+ .filter((line): line is string => !!line)
313
+ return lines.length > 0 ? lines.join("\n") : null
314
+ }
315
+ return null
316
+ }
317
+
318
+ function runWorkflowSummaryForSession(): string | null {
319
+ const result = runCliSyncRaw(["--format", "json", "-q", "workflow", "list", "--active"], AKM_CURATE_TIMEOUT_MS)
320
+ if (!result.ok) return null
321
+ const parsed = parseMaybeJson(result.stdout)
322
+ const summary = summarizeWorkflowList(
323
+ Array.isArray(parsed)
324
+ ? parsed
325
+ : (parsed && typeof parsed === "object" && Array.isArray((parsed as { runs?: unknown }).runs))
326
+ ? (parsed as { runs: unknown[] }).runs
327
+ : (parsed && typeof parsed === "object" && Array.isArray((parsed as { items?: unknown }).items))
328
+ ? (parsed as { items: unknown[] }).items
329
+ : [],
330
+ )
331
+ return summary
332
+ }
333
+
334
+ function formatWorkflowContext(summary: string): string {
335
+ return `# AKM active workflows\n${summary}`
336
+ }
337
+
338
+ function formatCuratorReportContext(report: string): string {
339
+ return `# AKM curator report\n${report}`
340
+ }
341
+
342
+ function summarizeCuratorReportForContext(report: string): string {
343
+ if (report.length <= AKM_CURATOR_CONTEXT_MAX_CHARS) return report
344
+ return `${report.slice(0, AKM_CURATOR_CONTEXT_MAX_CHARS).trimEnd()}\n\n[truncated for context]`
345
+ }
346
+
347
+ function getAkmStashDir(): string | undefined {
348
+ if (cachedAkmStashDir !== undefined) return cachedAkmStashDir || undefined
349
+ const result = runCliSyncRaw(["--format", "json", "-q", "config", "get", "stashDir"], AKM_CURATE_TIMEOUT_MS)
350
+ if (!result.ok) {
351
+ cachedAkmStashDir = ""
352
+ return undefined
353
+ }
354
+ const parsed = parseMaybeJson(result.stdout)
355
+ if (typeof parsed === "string" && parsed.trim()) {
356
+ cachedAkmStashDir = parsed.trim()
357
+ return cachedAkmStashDir
358
+ }
359
+ if (parsed && typeof parsed === "object") {
360
+ for (const key of ["value", "path", "stashDir"]) {
361
+ const value = (parsed as Record<string, unknown>)[key]
362
+ if (typeof value === "string" && value.trim()) {
363
+ cachedAkmStashDir = value.trim()
364
+ return cachedAkmStashDir
365
+ }
366
+ }
367
+ }
368
+ const raw = result.stdout.trim()
369
+ cachedAkmStashDir = raw || ""
370
+ return cachedAkmStashDir || undefined
371
+ }
372
+
196
373
  function warmIndexInBackground(): void {
197
374
  const command = resolveAkmCommand()
198
375
  if (typeof command !== "string") return
@@ -205,31 +382,106 @@ function warmIndexInBackground(): void {
205
382
  }
206
383
  }
207
384
 
208
- function recordFeedbackSync(ref: string, sentiment: "positive" | "negative", note: string): boolean {
209
- const result = runCliSyncRaw(
210
- [
211
- "--format",
212
- "json",
213
- "-q",
214
- "feedback",
385
+ function queueFeedback(
386
+ client: LogCapableClient,
387
+ ref: string,
388
+ sentiment: "positive" | "negative",
389
+ note: string,
390
+ meta: CliLogMeta,
391
+ dedupe?: Set<string>,
392
+ ): boolean {
393
+ const dedupeKey = `${ref}:${sentiment}`
394
+ if (dedupe?.has(dedupeKey)) return true
395
+ dedupe?.add(dedupeKey)
396
+
397
+ const command = resolveAkmCommand()
398
+ if (typeof command !== "string") {
399
+ void writePluginLog(client, "warn", "AKM auto-feedback skipped", {
400
+ subsystem: "feedback",
401
+ toolName: meta.toolName,
402
+ sessionID: meta.sessionID,
403
+ directory: meta.directory,
215
404
  ref,
216
- sentiment === "positive" ? "--positive" : "--negative",
217
- "--note",
218
- note,
219
- ],
220
- AKM_CURATE_TIMEOUT_MS,
221
- )
222
- return result.ok
405
+ sentiment,
406
+ error: command.error,
407
+ })
408
+ return false
409
+ }
410
+
411
+ try {
412
+ const child = spawn(
413
+ command,
414
+ [
415
+ "--format",
416
+ "json",
417
+ "-q",
418
+ "feedback",
419
+ ref,
420
+ sentiment === "positive" ? "--positive" : "--negative",
421
+ "--note",
422
+ note,
423
+ ],
424
+ {
425
+ detached: true,
426
+ stdio: "ignore",
427
+ },
428
+ )
429
+ child.on("error", (error) => {
430
+ void writePluginLog(client, "warn", "AKM auto-feedback failed", {
431
+ subsystem: "feedback",
432
+ toolName: meta.toolName,
433
+ sessionID: meta.sessionID,
434
+ directory: meta.directory,
435
+ ref,
436
+ sentiment,
437
+ error: formatCliError(error),
438
+ })
439
+ })
440
+ child.unref()
441
+ return true
442
+ } catch (error: unknown) {
443
+ void writePluginLog(client, "warn", "AKM auto-feedback failed", {
444
+ subsystem: "feedback",
445
+ toolName: meta.toolName,
446
+ sessionID: meta.sessionID,
447
+ directory: meta.directory,
448
+ ref,
449
+ sentiment,
450
+ error: formatCliError(error),
451
+ })
452
+ return false
453
+ }
223
454
  }
224
455
 
225
- function captureSessionMemory(sessionID: string, reason: string): string | null {
456
+ function rememberTextAsMemory(name: string, body: string): string | null {
457
+ const command = resolveAkmCommand()
458
+ if (typeof command !== "string") return null
459
+ try {
460
+ execFileSync(command, ["--format", "json", "-q", "remember", "--name", name, "--force"], {
461
+ encoding: "utf8",
462
+ timeout: AKM_CURATE_TIMEOUT_MS * 2,
463
+ input: body,
464
+ })
465
+ return `memory:${name}`
466
+ } catch {
467
+ return null
468
+ }
469
+ }
470
+
471
+ function captureSessionMemory(
472
+ sessionID: string,
473
+ reason: string,
474
+ options?: { checkpoint?: boolean },
475
+ ): string | null {
226
476
  if (!AKM_AUTO_MEMORY) return null
227
477
  if (!sessionID) return null
228
- if (sessionMemoryCaptured.has(sessionID)) return null
478
+ const isCheckpoint = options?.checkpoint === true
479
+ if (!isCheckpoint && sessionFinalMemoryCaptured.has(sessionID)) return null
229
480
  const entries = sessionBuffer.get(sessionID) ?? []
481
+ const pendingEntries = isCheckpoint ? entries.filter((entry) => !entry.checkpointed) : entries
230
482
  // Require at least two observations before persisting — single events are noise.
231
- if (entries.length < 2) {
232
- sessionBuffer.delete(sessionID)
483
+ if (pendingEntries.length < 2) {
484
+ if (!isCheckpoint) sessionBuffer.delete(sessionID)
233
485
  return null
234
486
  }
235
487
 
@@ -238,7 +490,7 @@ function captureSessionMemory(sessionID: string, reason: string): string | null
238
490
  lines.push(`Reason: ${reason}`)
239
491
  lines.push(`Session: ${sessionID}`)
240
492
  lines.push("")
241
- for (const entry of entries) {
493
+ for (const entry of pendingEntries) {
242
494
  if (entry.kind === "memory-intent") {
243
495
  lines.push(`## ${entry.timestamp} — user memory intent`)
244
496
  if (entry.note) lines.push(entry.note)
@@ -252,38 +504,74 @@ function captureSessionMemory(sessionID: string, reason: string): string | null
252
504
  }
253
505
  const body = lines.join("\n")
254
506
 
255
- const dateTag = new Date().toISOString().replace(/[-:]/g, "").slice(0, 8)
507
+ const dateTag = buildDateTag({ includeTime: isCheckpoint })
256
508
  const shortSid = sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
257
- const name = `opencode-session-${dateTag}-${shortSid}`
509
+ const name = isCheckpoint
510
+ ? `opencode-checkpoint-${dateTag}-${shortSid}`
511
+ : `opencode-session-${dateTag}-${shortSid}`
258
512
 
259
- const command = resolveAkmCommand()
260
- if (typeof command !== "string") {
261
- sessionMemoryCaptured.add(sessionID)
262
- sessionBuffer.delete(sessionID)
513
+ const ref = rememberTextAsMemory(name, body)
514
+ if (!ref) {
515
+ if (!isCheckpoint) {
516
+ sessionFinalMemoryCaptured.add(sessionID)
517
+ sessionBuffer.delete(sessionID)
518
+ }
263
519
  return null
264
520
  }
265
- try {
266
- execFileSync(command, ["--format", "json", "-q", "remember", "--name", name, "--force"], {
267
- encoding: "utf8",
268
- timeout: AKM_CURATE_TIMEOUT_MS * 2,
269
- input: body,
270
- })
271
- sessionMemoryCaptured.add(sessionID)
272
- sessionBuffer.delete(sessionID)
273
- return `memory:${name}`
274
- } catch {
275
- sessionMemoryCaptured.add(sessionID)
276
- sessionBuffer.delete(sessionID)
277
- return null
521
+
522
+ if (isCheckpoint) {
523
+ for (const entry of entries) {
524
+ if (!entry.checkpointed) entry.checkpointed = true
525
+ }
526
+ sessionSuccessfulAssetTouchCount.set(sessionID, 0)
527
+ sessionBuffer.set(sessionID, entries)
528
+ return ref
278
529
  }
530
+
531
+ sessionFinalMemoryCaptured.add(sessionID)
532
+ sessionBuffer.delete(sessionID)
533
+ return ref
279
534
  }
280
535
 
281
- function extractToolRefs(toolName: string, args: Record<string, unknown>, output: unknown): string[] {
536
+ function maybeCheckpointSessionMemory(sessionID: string): string | null {
537
+ const count = sessionSuccessfulAssetTouchCount.get(sessionID) ?? 0
538
+ if (count < AKM_MEMORY_CHECKPOINT_EVERY) return null
539
+ const captured = captureSessionMemory(sessionID, "checkpoint", { checkpoint: true })
540
+ if (!captured) {
541
+ sessionSuccessfulAssetTouchCount.set(sessionID, 0)
542
+ }
543
+ return captured
544
+ }
545
+
546
+ const AKM_REF_EDGE_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?", "(", ")", "[", "]", "{", "}", "'", "\"", "`"])
547
+
548
+ function normalizeExtractedRef(ref: string): string {
549
+ let start = 0
550
+ let end = ref.length
551
+ while (start < end && AKM_REF_EDGE_PUNCTUATION.has(ref[start] ?? "")) start += 1
552
+ while (end > start && AKM_REF_EDGE_PUNCTUATION.has(ref[end - 1] ?? "")) end -= 1
553
+ return ref.slice(start, end)
554
+ }
555
+
556
+ function extractRefsFromText(value: string): string[] {
282
557
  const refs = new Set<string>()
558
+ for (const token of value.split(/\s+/)) {
559
+ const normalized = normalizeExtractedRef(token)
560
+ if (normalized && isAkmRef(normalized)) refs.add(normalized)
561
+ }
562
+ return [...refs]
563
+ }
564
+
565
+ function extractToolRefs(
566
+ toolName: string,
567
+ args: Record<string, unknown>,
568
+ output: unknown,
569
+ ): { refs: string[]; positiveOnlyRefs: string[] } {
570
+ const refs = new Set<string>()
571
+ const positiveOnlyRefs = new Set<string>()
283
572
  const addMatches = (value: unknown) => {
284
573
  if (typeof value !== "string") return
285
- const matches = value.match(AKM_REF_PATTERN)
286
- if (matches) for (const ref of matches) refs.add(ref)
574
+ for (const ref of extractRefsFromText(value)) refs.add(ref)
287
575
  }
288
576
 
289
577
  for (const key of ["ref", "package_ref"]) {
@@ -304,9 +592,18 @@ function extractToolRefs(toolName: string, args: Record<string, unknown>, output
304
592
  }
305
593
  }
306
594
  if (toolName === "akm_remember" && typeof o.ref === "string") addMatches(o.ref)
595
+ if (
596
+ (toolName === "akm_agent" || toolName === "akm_cmd" || toolName === "akm_evolve")
597
+ && typeof o.text === "string"
598
+ ) {
599
+ for (const ref of extractRefsFromText(o.text)) {
600
+ refs.add(ref)
601
+ positiveOnlyRefs.add(ref)
602
+ }
603
+ }
307
604
  }
308
605
 
309
- return [...refs]
606
+ return { refs: [...refs], positiveOnlyRefs: [...positiveOnlyRefs] }
310
607
  }
311
608
 
312
609
  const AKM_HINTS_PREFIX = [
@@ -318,6 +615,98 @@ const AKM_HINTS_PREFIX = [
318
615
  const AKM_CURATED_HEADER = "# AKM stash — assets relevant to this prompt"
319
616
  const AKM_CURATED_TAIL = "\n\nTip: call `akm_show <ref>` to fetch full content, and record `akm_feedback <ref> positive|negative` once you know whether the asset helped."
320
617
 
618
+ // Curated quick-reference for the long-tail of `akm` CLI verbs that no longer
619
+ // have a dedicated tool wrapper. Surfaced through akm_help so agents can
620
+ // always find the right invocation without polluting default context.
621
+ type AkmHelpEntry = {
622
+ task: string
623
+ command: string
624
+ notes?: string
625
+ keywords: string[]
626
+ }
627
+
628
+ const AKM_HELP_QUICK_REFERENCE: readonly AkmHelpEntry[] = [
629
+ {
630
+ task: "Install a kit or register an external source (npm, GitHub, git, URL, local dir)",
631
+ command: "akm add <package-ref> [--name <n>] [--type wiki] [--writable] [--trust] [--provider <p>] [--max-pages N] [--max-depth N]",
632
+ notes: "Confirm with the user before passing --trust or registering a website crawler.",
633
+ keywords: ["add", "install", "register", "kit", "source", "github", "npm"],
634
+ },
635
+ {
636
+ task: "Commit (and optionally push) pending stash changes",
637
+ command: "akm save [<source-name>] [-m <msg>] [--push]",
638
+ notes: "Add --push only when the stash is writable; review the diff first.",
639
+ keywords: ["save", "commit", "push", "publish", "git"],
640
+ },
641
+ {
642
+ task: "Import a file (or stdin) into the stash as a typed asset",
643
+ command: "akm import <path|-> [--name <name>] [--force]",
644
+ notes: "Use `-` and pipe content via stdin to import a string.",
645
+ keywords: ["import", "ingest", "upload", "stdin"],
646
+ },
647
+ {
648
+ task: "Clone an asset from any source for editing",
649
+ command: "akm clone <ref> [--name <new>] [--dest <dir>] [--force]",
650
+ notes: "Type subdirectory is appended automatically; ref may include origin (e.g. npm:@scope/pkg//script:foo).",
651
+ keywords: ["clone", "copy", "fork", "edit"],
652
+ },
653
+ {
654
+ task: "Update a managed source (or all of them)",
655
+ command: "akm update [<package_ref>|--all] [--force]",
656
+ keywords: ["update", "upgrade kit", "refresh", "pull"],
657
+ },
658
+ {
659
+ task: "Remove a configured source and reindex",
660
+ command: "akm remove <id|ref|path|url|name>",
661
+ notes: "Destructive — confirm intent before running.",
662
+ keywords: ["remove", "uninstall", "delete source"],
663
+ },
664
+ {
665
+ task: "List configured sources (local dirs, kits, remotes)",
666
+ command: "akm list",
667
+ keywords: ["list", "sources", "kits", "show sources"],
668
+ },
669
+ {
670
+ task: "Search the registry only (skip local stash)",
671
+ command: "akm registry search <query> [--limit N] [--assets]",
672
+ notes: "akm_search with source='registry' covers most cases; this is the explicit form.",
673
+ keywords: ["registry", "search registry", "installable", "discover kit"],
674
+ },
675
+ {
676
+ task: "Build or rebuild the stash search index",
677
+ command: "akm index",
678
+ notes: "Rarely needed — the index refreshes implicitly after writes.",
679
+ keywords: ["index", "reindex", "rebuild"],
680
+ },
681
+ {
682
+ task: "View or update akm config (get/set/list/unset/path)",
683
+ command: "akm config <action> [<key>] [<value>] [--all]",
684
+ notes: "`akm config path --all` prints config, stash, cache, and index paths.",
685
+ keywords: ["config", "settings", "configure", "path"],
686
+ },
687
+ {
688
+ task: "Check for or install an akm CLI update",
689
+ command: "akm upgrade [--check] [--force]",
690
+ keywords: ["upgrade cli", "update cli", "self-upgrade"],
691
+ },
692
+ {
693
+ task: "Run a stash script end-to-end (resolve → show → run)",
694
+ command: "akm show <script-ref> # then exec the printed `run` command",
695
+ notes: "Or `akm --format json -q show <ref>` and pipe `.run` into your shell.",
696
+ keywords: ["run", "execute", "script", "exec"],
697
+ },
698
+ ]
699
+
700
+ function lookupAkmHelpHint(topic: string): AkmHelpEntry[] {
701
+ const needle = topic.toLowerCase().trim()
702
+ if (!needle) return []
703
+ return AKM_HELP_QUICK_REFERENCE.filter((entry) =>
704
+ entry.keywords.some((kw) => needle.includes(kw))
705
+ || entry.task.toLowerCase().includes(needle)
706
+ || entry.command.toLowerCase().includes(needle),
707
+ )
708
+ }
709
+
321
710
  function extractSessionIdFromEvent(payload: unknown): string | undefined {
322
711
  if (!payload || typeof payload !== "object") return undefined
323
712
  const p = payload as Record<string, unknown>
@@ -451,7 +840,7 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
451
840
  return JSON.stringify(command)
452
841
  }
453
842
 
454
- const fullArgs = [...args, "--format", "json"]
843
+ const fullArgs = args.includes("--format") ? [...args] : [...args, "--format", "json"]
455
844
 
456
845
  try {
457
846
  const stdout = execFileSync(command, fullArgs, {
@@ -488,7 +877,29 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
488
877
  }
489
878
 
490
879
  type CliError = { ok: false; error: string }
491
- type AssetType = "agent" | "command" | "knowledge" | "memory" | "script" | "skill"
880
+ type AssetType =
881
+ | "agent"
882
+ | "command"
883
+ | "knowledge"
884
+ | "memory"
885
+ | "script"
886
+ | "skill"
887
+ | "workflow"
888
+ | "vault"
889
+ | "wiki"
890
+
891
+ const ASSET_TYPES = [
892
+ "agent",
893
+ "command",
894
+ "knowledge",
895
+ "memory",
896
+ "script",
897
+ "skill",
898
+ "workflow",
899
+ "vault",
900
+ "wiki",
901
+ "any",
902
+ ] as const
492
903
 
493
904
  type ShowAgentResponse = {
494
905
  type: "agent"
@@ -588,6 +999,12 @@ function parseCliJson<T>(raw: string): T | CliError {
588
999
  }
589
1000
  }
590
1001
 
1002
+ function blockedToolResponse(args: Record<string, unknown>): string | null {
1003
+ return typeof args.__akmBlocked === "string"
1004
+ ? JSON.stringify({ ok: false, error: args.__akmBlocked })
1005
+ : null
1006
+ }
1007
+
591
1008
  function isCliError(value: unknown): value is CliError {
592
1009
  return !!value
593
1010
  && typeof value === "object"
@@ -705,33 +1122,45 @@ function truncateLogText(value: string, limit = 1_000): string {
705
1122
  return value.length > limit ? `${value.slice(0, limit)}…` : value
706
1123
  }
707
1124
 
708
- async function resolveRefInput(
1125
+ async function searchRef(
709
1126
  client: LogCapableClient,
710
- input: { ref?: string; query?: string },
711
- type: AssetType,
1127
+ query: string,
1128
+ type: AssetType | "any",
712
1129
  meta: CliLogMeta,
713
1130
  ): Promise<{ ok: true; ref: string } | CliError> {
714
- if (input.ref && input.ref.trim()) {
715
- return { ok: true, ref: input.ref.trim() }
716
- }
717
-
718
- const query = input.query?.trim()
719
- if (!query) {
720
- return { ok: false, error: "Provide either 'ref' or 'query'." }
721
- }
722
-
723
- const raw = await runCli(client, ["search", query, "--type", type, "--limit", "1", "--detail", "normal", "--source", "stash"], meta)
1131
+ const raw = await runCli(
1132
+ client,
1133
+ ["search", query, "--type", type, "--limit", "1", "--detail", "normal", "--source", "stash"],
1134
+ meta,
1135
+ )
724
1136
  const parsed = parseCliJson<SearchResponse>(raw)
725
1137
  if (isCliError(parsed)) return parsed
726
-
727
1138
  const ref = parsed.hits?.[0]?.ref
728
1139
  if (!ref) {
729
- return { ok: false, error: `No ${type} match found for query '${query}'.` }
1140
+ return {
1141
+ ok: false,
1142
+ error: `No stash ref matched '${query}'. Use akm_search to disambiguate, then retry with an exact ref.`,
1143
+ }
730
1144
  }
731
-
732
1145
  return { ok: true, ref }
733
1146
  }
734
1147
 
1148
+ async function resolveRefOrQueryInput(
1149
+ client: LogCapableClient,
1150
+ input: { ref?: string; query?: string },
1151
+ type: AssetType | "any",
1152
+ meta: CliLogMeta,
1153
+ ): Promise<{ ok: true; ref: string } | CliError> {
1154
+ const explicitRef = input.ref?.trim()
1155
+ if (explicitRef) return { ok: true, ref: explicitRef }
1156
+
1157
+ const query = input.query?.trim()
1158
+ if (!query) {
1159
+ return { ok: false, error: "Provide either 'ref' or 'query'." }
1160
+ }
1161
+ return searchRef(client, query, type, meta)
1162
+ }
1163
+
735
1164
  async function ensureTargetSessionID(input: {
736
1165
  useSubtask: boolean
737
1166
  context: { sessionID: string; directory: string }
@@ -844,6 +1273,65 @@ async function promptTargetSession(input: {
844
1273
  }
845
1274
  }
846
1275
 
1276
+ async function resolveDispatchAgent(
1277
+ client: PluginClient,
1278
+ requestedAgent: string,
1279
+ directory: string,
1280
+ ): Promise<string> {
1281
+ if (requestedAgent !== "akm-curator") return requestedAgent
1282
+ try {
1283
+ const agents = await client.app.agents({ query: { directory } })
1284
+ if (agents.error) return "general"
1285
+ const hasCurator = (agents.data ?? []).some((agent) => agent?.name === "akm-curator")
1286
+ return hasCurator ? "akm-curator" : "general"
1287
+ } catch {
1288
+ return "general"
1289
+ }
1290
+ }
1291
+
1292
+ function summarizeSessionMessages(
1293
+ sessionID: string,
1294
+ messages: Array<{ info?: Record<string, unknown>; parts?: unknown }>,
1295
+ ) {
1296
+ return {
1297
+ ok: true,
1298
+ sessionID,
1299
+ messages: messages.map((message) => {
1300
+ const info = message.info ?? {}
1301
+ const role = typeof info.role === "string" ? info.role : "unknown"
1302
+ const agent = typeof info.agent === "string"
1303
+ ? info.agent
1304
+ : typeof info.mode === "string"
1305
+ ? info.mode
1306
+ : null
1307
+ return {
1308
+ role,
1309
+ agent,
1310
+ text: extractText(message.parts),
1311
+ }
1312
+ }),
1313
+ }
1314
+ }
1315
+
1316
+ async function getParentSessionID(
1317
+ client: PluginClient,
1318
+ sessionID: string,
1319
+ directory: string,
1320
+ ): Promise<{ ok: true; parentID: string } | CliError> {
1321
+ try {
1322
+ const result = await client.session.get({
1323
+ path: { id: sessionID },
1324
+ query: { directory },
1325
+ })
1326
+ if (result.error || !result.data?.parentID) {
1327
+ return { ok: false, error: "This session does not have a parent session." }
1328
+ }
1329
+ return { ok: true, parentID: result.data.parentID }
1330
+ } catch (error: unknown) {
1331
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
1332
+ }
1333
+ }
1334
+
847
1335
  function splitArguments(raw: string): string[] {
848
1336
  if (!raw.trim()) return []
849
1337
  const args: string[] = []
@@ -868,7 +1356,7 @@ function normalizeSearchSource(source: "local" | "stash" | "registry" | "both"):
868
1356
 
869
1357
  function createSearchArgs(input: {
870
1358
  query: string
871
- type?: AssetType | "any"
1359
+ type?: AssetType | "any" | string
872
1360
  limit?: number
873
1361
  source?: "local" | "stash" | "registry" | "both"
874
1362
  defaultSource?: "local" | "stash" | "registry" | "both"
@@ -890,17 +1378,31 @@ type PluginClient = {
890
1378
  create: (input: {
891
1379
  body: { parentID: string; title: string }
892
1380
  }) => Promise<{ data?: { id?: string }; error?: unknown }>
1381
+ get: (input: {
1382
+ path: { id: string }
1383
+ query?: { directory?: string }
1384
+ }) => Promise<{ data?: { id?: string; parentID?: string }; error?: unknown }>
1385
+ messages: (input: {
1386
+ path: { id: string }
1387
+ query?: { directory?: string }
1388
+ }) => Promise<{ data?: Array<{ info?: Record<string, unknown>; parts?: unknown }>; error?: unknown }>
893
1389
  prompt: (input: {
894
1390
  path: { id: string }
895
1391
  body: SessionPromptBody
896
1392
  }) => Promise<{ data?: { parts?: unknown }; error?: unknown }>
897
1393
  }
1394
+ app: {
1395
+ agents: (input?: {
1396
+ query?: { directory?: string }
1397
+ }) => Promise<{ data?: Array<{ name?: string }>; error?: unknown }>
1398
+ }
898
1399
  }
899
1400
 
900
- export const AkmPlugin: Plugin = async ({ client }) => {
1401
+ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
901
1402
  await ensureLatestAkmInstalled(client as unknown as LogCapableClient)
902
1403
 
903
1404
  const logClient = client as unknown as LogCapableClient
1405
+ const sdkClient = client as unknown as PluginClient
904
1406
 
905
1407
  return {
906
1408
  // Events cover the lifecycle boundaries that Claude Code exposes as
@@ -913,11 +1415,15 @@ export const AkmPlugin: Plugin = async ({ client }) => {
913
1415
  const sid = extractSessionIdFromEvent(event) ?? extractSessionIdFromEvent((event as { properties?: unknown }).properties)
914
1416
  if (type === "session.created" || type === "session.updated") {
915
1417
  if (!sid) return
916
- if (!AKM_AUTO_HINTS) return
917
- if (sessionHints.has(sid)) return
918
- warmIndexInBackground()
919
- const hints = runHintsForSession()
920
- if (hints) sessionHints.set(sid, hints)
1418
+ if (!sessionContextEpoch.has(sid)) sessionContextEpoch.set(sid, 0)
1419
+ if (type === "session.created") warmIndexInBackground()
1420
+ if (AKM_AUTO_HINTS && !sessionHints.has(sid)) {
1421
+ const hints = runHintsForSession()
1422
+ if (hints) sessionHints.set(sid, hints)
1423
+ }
1424
+ if (!sessionWorkflow.has(sid)) {
1425
+ sessionWorkflow.set(sid, runWorkflowSummaryForSession() ?? "")
1426
+ }
921
1427
  } else if (type === "session.compacted" || type === "session.idle" || type === "session.deleted") {
922
1428
  if (!sid) return
923
1429
  const captured = captureSessionMemory(sid, type)
@@ -935,7 +1441,14 @@ export const AkmPlugin: Plugin = async ({ client }) => {
935
1441
  if (type === "session.deleted") {
936
1442
  sessionHints.delete(sid)
937
1443
  sessionCurated.delete(sid)
938
- sessionMemoryCaptured.delete(sid)
1444
+ sessionWorkflow.delete(sid)
1445
+ sessionCuratorReport.delete(sid)
1446
+ sessionContextEpoch.delete(sid)
1447
+ sessionContextInjectedEpoch.delete(sid)
1448
+ sessionCuratedVersion.delete(sid)
1449
+ sessionCuratedInjectedVersion.delete(sid)
1450
+ sessionFinalMemoryCaptured.delete(sid)
1451
+ sessionSuccessfulAssetTouchCount.delete(sid)
939
1452
  sessionBuffer.delete(sid)
940
1453
  }
941
1454
  }
@@ -964,6 +1477,24 @@ export const AkmPlugin: Plugin = async ({ client }) => {
964
1477
  // Best-effort only.
965
1478
  }
966
1479
  },
1480
+ "experimental.session.compacting": async (input, output) => {
1481
+ try {
1482
+ const sid = input.sessionID
1483
+ if (!sid) return
1484
+ if (!Array.isArray(output.context)) return
1485
+ markContextEpochDirty(sid)
1486
+ const hints = sessionHints.get(sid)
1487
+ if (hints) output.context.push(`${AKM_HINTS_PREFIX}\n\n${hints}`)
1488
+ const curated = sessionCurated.get(sid)
1489
+ if (curated) output.context.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
1490
+ const workflow = sessionWorkflow.get(sid)
1491
+ if (workflow) output.context.push(formatWorkflowContext(workflow))
1492
+ const curatorReport = sessionCuratorReport.get(sid)
1493
+ if (curatorReport) output.context.push(formatCuratorReportContext(curatorReport))
1494
+ } catch {
1495
+ // Never break compaction because of plugin context.
1496
+ }
1497
+ },
967
1498
  // experimental.chat.system.transform is how OpenCode exposes the
968
1499
  // additionalContext channel. We append the cached hints (once per session)
969
1500
  // and the curated assets (once per turn) so the next LLM call sees them.
@@ -974,21 +1505,56 @@ export const AkmPlugin: Plugin = async ({ client }) => {
974
1505
  try {
975
1506
  if (!output || !Array.isArray(output.system)) return
976
1507
  const sid = extractSessionIdFromEvent(input) ?? ""
977
- const hints = sid ? sessionHints.get(sid) : undefined
978
- if (hints) {
979
- output.system.push(`${AKM_HINTS_PREFIX}\n\n${hints}`)
980
- // Only inject hints on the first transform of the session.
981
- sessionHints.delete(sid)
1508
+ const epoch = sessionContextEpoch.get(sid) ?? 0
1509
+ const injectedEpoch = sessionContextInjectedEpoch.get(sid)
1510
+ if (sid && injectedEpoch !== epoch) {
1511
+ const hints = sessionHints.get(sid)
1512
+ if (hints) output.system.push(`${AKM_HINTS_PREFIX}\n\n${hints}`)
1513
+ const workflow = sessionWorkflow.get(sid)
1514
+ if (workflow) output.system.push(formatWorkflowContext(workflow))
1515
+ const curatorReport = sessionCuratorReport.get(sid)
1516
+ if (curatorReport) output.system.push(formatCuratorReportContext(curatorReport))
1517
+ sessionContextInjectedEpoch.set(sid, epoch)
982
1518
  }
983
1519
  const curated = sid ? sessionCurated.get(sid) : undefined
1520
+ const curatedVersion = sessionCuratedVersion.get(sid) ?? 0
984
1521
  if (curated) {
985
- output.system.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
986
- sessionCurated.delete(sid)
1522
+ if (sessionCuratedInjectedVersion.get(sid) !== curatedVersion) {
1523
+ output.system.push(`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`)
1524
+ sessionCuratedInjectedVersion.set(sid, curatedVersion)
1525
+ }
987
1526
  }
988
1527
  } catch {
989
1528
  // Never break the turn because of a transform failure.
990
1529
  }
991
1530
  },
1531
+ "tool.execute.before": async (input, output) => {
1532
+ try {
1533
+ if (!input.tool.startsWith("akm_")) return
1534
+ const args = output.args && typeof output.args === "object" ? output.args as Record<string, unknown> : {}
1535
+ const confirm = args.confirm === true
1536
+ if (input.tool === "akm_vault" && (args.action === "show" || args.action === "unset") && !confirm) {
1537
+ output.args = {
1538
+ ...args,
1539
+ __akmBlocked: `akm_vault action='${String(args.action)}' requires confirm:true to avoid accidental secret exposure or deletion.`,
1540
+ }
1541
+ return
1542
+ }
1543
+ output.args = args
1544
+ } catch {
1545
+ // Never break tool execution from the pre-hook.
1546
+ }
1547
+ },
1548
+ "shell.env": async (_input, output) => {
1549
+ try {
1550
+ output.env.AKM_PROJECT = worktree
1551
+ output.env.AKM_PLUGIN_VERSION = PLUGIN_VERSION
1552
+ const stashDir = getAkmStashDir()
1553
+ if (stashDir) output.env.AKM_STASH_DIR = stashDir
1554
+ } catch {
1555
+ // Best-effort only.
1556
+ }
1557
+ },
992
1558
  "chat.message": async (input, output) => {
993
1559
  const text = extractText(output.parts).trim()
994
1560
  if (!text) return
@@ -1005,7 +1571,10 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1005
1571
  // stash the result so experimental.chat.system.transform can inject it.
1006
1572
  if (AKM_AUTO_CURATE && input.sessionID) {
1007
1573
  const curated = runCurateForPrompt(text)
1008
- if (curated) sessionCurated.set(input.sessionID, curated)
1574
+ if (curated) {
1575
+ sessionCurated.set(input.sessionID, curated)
1576
+ bumpCuratedVersion(input.sessionID)
1577
+ }
1009
1578
  }
1010
1579
 
1011
1580
  // Track explicit memory intents so capture-memory has something durable
@@ -1016,6 +1585,21 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1016
1585
  note: truncateLogText(text, 500),
1017
1586
  })
1018
1587
  }
1588
+
1589
+ if (input.sessionID && AKM_AUTO_FEEDBACK && AKM_RETROSPECTIVE_FEEDBACK_RE.test(text)) {
1590
+ const recentRefs = (sessionBuffer.get(input.sessionID) ?? [])
1591
+ .filter((entry) => entry.kind === "tool-ref" && !!entry.ref)
1592
+ .map((entry) => entry.ref!)
1593
+ .filter((ref, index, refs) => !ref.startsWith("memory:") && !ref.startsWith("vault:") && refs.indexOf(ref) === index)
1594
+ .slice(-3)
1595
+ const dedupe = new Set<string>()
1596
+ for (const ref of recentRefs) {
1597
+ queueFeedback(logClient, ref, "positive", "opencode retrospective: user confirmed it worked", {
1598
+ toolName: "chat.message",
1599
+ sessionID: input.sessionID,
1600
+ }, dedupe)
1601
+ }
1602
+ }
1019
1603
  },
1020
1604
  "tool.execute.after": async (input, output) => {
1021
1605
  if (!input.tool.startsWith("akm_")) return
@@ -1051,9 +1635,9 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1051
1635
  // Auto-feedback + session buffering: record every asset ref the tool
1052
1636
  // touched so the stash ranking improves over time and so Stop/Compact
1053
1637
  // has material to flush into a session summary memory.
1054
- const allRefs = extractToolRefs(input.tool, input.args as Record<string, unknown>, parsed)
1055
- if (allRefs.length > 0 && input.sessionID) {
1056
- for (const ref of allRefs) {
1638
+ const refResult = extractToolRefs(input.tool, input.args as Record<string, unknown>, parsed)
1639
+ if (refResult.refs.length > 0 && input.sessionID) {
1640
+ for (const ref of refResult.refs) {
1057
1641
  addBufferEntry(input.sessionID, {
1058
1642
  kind: "tool-ref",
1059
1643
  toolName: input.tool,
@@ -1061,32 +1645,57 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1061
1645
  status: feedback ?? "unknown",
1062
1646
  })
1063
1647
  }
1648
+ if (feedback === "positive") {
1649
+ sessionSuccessfulAssetTouchCount.set(
1650
+ input.sessionID,
1651
+ (sessionSuccessfulAssetTouchCount.get(input.sessionID) ?? 0) + 1,
1652
+ )
1653
+ const checkpointRef = maybeCheckpointSessionMemory(input.sessionID)
1654
+ if (checkpointRef) {
1655
+ await writePluginLog(logClient, "info", "AKM checkpoint memory captured", {
1656
+ subsystem: "memory",
1657
+ actor: "system",
1658
+ sessionID: input.sessionID,
1659
+ reason: "checkpoint",
1660
+ ref: checkpointRef,
1661
+ })
1662
+ }
1663
+ }
1064
1664
  }
1065
1665
 
1066
1666
  if (
1067
1667
  AKM_AUTO_FEEDBACK
1068
1668
  && feedback
1069
1669
  && input.tool !== "akm_feedback"
1070
- && allRefs.length > 0
1670
+ && refResult.refs.length > 0
1071
1671
  ) {
1672
+ const dedupe = new Set<string>()
1673
+ const feedbackRefs = feedback === "positive"
1674
+ ? refResult.refs
1675
+ : refResult.refs.filter((ref) => !refResult.positiveOnlyRefs.includes(ref))
1072
1676
  const note = feedback === "positive"
1073
1677
  ? `opencode auto: ${input.tool} succeeded`
1074
1678
  : `opencode auto: ${input.tool} failed`
1075
- for (const ref of allRefs) {
1076
- // Memories do not accept feedback in the current CLI.
1077
- if (ref.startsWith("memory:")) continue
1078
- const ok = recordFeedbackSync(ref, feedback, note)
1679
+ for (const ref of feedbackRefs) {
1680
+ // Memories and vault refs are not first-class feedback targets —
1681
+ // memories do not accept feedback, and vault values never surface in
1682
+ // JSON so automatic usage signals would be misleading.
1683
+ if (ref.startsWith("memory:") || ref.startsWith("vault:")) continue
1684
+ const ok = queueFeedback(logClient, ref, feedback, note, {
1685
+ toolName: input.tool,
1686
+ sessionID: input.sessionID,
1687
+ }, dedupe)
1079
1688
  if (!ok) break
1080
1689
  }
1081
1690
  }
1082
1691
  },
1083
1692
  tool: {
1084
1693
  akm_search: tool({
1085
- description: "Search your stash or the akm registry for scripts, skills, commands, agents, knowledge, and memories. Use source='registry' or akm_registry_search for installable community kits.",
1694
+ description: "Search your stash or the akm registry for scripts, skills, commands, agents, knowledge, memories, workflows, vaults, and wikis. Use source='registry' for installable community kits.",
1086
1695
  args: {
1087
1696
  query: tool.schema.string().describe("Case-insensitive substring search."),
1088
1697
  type: tool.schema
1089
- .enum(["agent", "command", "knowledge", "memory", "script", "skill", "any"])
1698
+ .enum(ASSET_TYPES as unknown as [string, ...string[]])
1090
1699
  .optional()
1091
1700
  .describe("Optional type filter. Defaults to 'any'."),
1092
1701
  limit: tool.schema.number().optional().describe("Maximum number of hits to return. Defaults to 20."),
@@ -1099,41 +1708,6 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1099
1708
  return runCli(client as unknown as LogCapableClient, createSearchArgs({ query, type, limit, source }), { toolName: "akm_search" })
1100
1709
  },
1101
1710
  }),
1102
- akm_registry_search: tool({
1103
- description: "Search configured akm registries only. Use this when you want installable kits without mixing in local stash results.",
1104
- args: {
1105
- query: tool.schema.string().describe("Search query for installable registry kits."),
1106
- type: tool.schema
1107
- .enum(["agent", "command", "knowledge", "memory", "script", "skill", "any"])
1108
- .optional()
1109
- .describe("Optional asset type filter. Defaults to 'any'."),
1110
- limit: tool.schema.number().optional().describe("Maximum number of registry hits to return. Defaults to 20."),
1111
- assets: tool.schema.boolean().optional().describe("Include asset-level results from registry index v2 payloads."),
1112
- },
1113
- async execute({ query, type, limit, assets }) {
1114
- const args = ["registry", "search", query]
1115
- if (limit) args.push("--limit", String(limit))
1116
- const assetTypeFilter = type && type !== "any" ? type : undefined
1117
- if (assets || assetTypeFilter) args.push("--assets")
1118
-
1119
- const raw = await runCli(client as unknown as LogCapableClient, args, { toolName: "akm_registry_search" })
1120
- if (!assetTypeFilter) return raw
1121
-
1122
- const parsed = parseCliJson<{
1123
- hits?: SearchHit[]
1124
- assetHits?: Array<SearchHit & { assetType?: AssetType }>
1125
- warnings?: string[]
1126
- query?: string
1127
- }>(raw)
1128
- if (isCliError(parsed)) return JSON.stringify(parsed)
1129
-
1130
- return JSON.stringify({
1131
- ...parsed,
1132
- hits: [],
1133
- assetHits: (parsed.assetHits ?? []).filter((hit) => hit.assetType === assetTypeFilter),
1134
- })
1135
- },
1136
- }),
1137
1711
  akm_show: tool({
1138
1712
  description: "Show a stash asset by ref. For knowledge assets, use view_mode to retrieve specific content (toc, section, lines, frontmatter).",
1139
1713
  args: {
@@ -1162,75 +1736,6 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1162
1736
  return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_show" })
1163
1737
  },
1164
1738
  }),
1165
- akm_index: tool({
1166
- description: "Build or rebuild the akm stash index. Scans stash directories, generates missing .stash.json metadata, and builds a semantic search index.",
1167
- args: {},
1168
- async execute() {
1169
- return runCli(client as unknown as LogCapableClient, ["index"], { toolName: "akm_index" })
1170
- },
1171
- }),
1172
- akm_add: tool({
1173
- description: "Install a kit from npm, GitHub, another git host, or a local directory. Installed kits become searchable alongside local assets.",
1174
- args: {
1175
- package_ref: tool.schema.string().describe("Package reference such as npm:@scope/kit, github:<owner>/<repo>, git+https://host/repo, or ./local/kit."),
1176
- },
1177
- async execute({ package_ref }) {
1178
- return runCli(client as unknown as LogCapableClient, ["add", package_ref], { toolName: "akm_add" })
1179
- },
1180
- }),
1181
- akm_list: tool({
1182
- description: "List all configured AKM sources, including local directories, managed kits, and remote providers.",
1183
- args: {},
1184
- async execute() {
1185
- return runCli(client as unknown as LogCapableClient, ["list"], { toolName: "akm_list" })
1186
- },
1187
- }),
1188
- akm_remove: tool({
1189
- description: "Remove a configured AKM source by id, ref, path, URL, or name and reindex the stash.",
1190
- args: {
1191
- package_ref: tool.schema.string().describe("Source id, ref, path, URL, or name, such as npm:@scope/kit, owner/repo, or ~/.claude/skills."),
1192
- },
1193
- async execute({ package_ref }) {
1194
- return runCli(client as unknown as LogCapableClient, ["remove", package_ref], { toolName: "akm_remove" })
1195
- },
1196
- }),
1197
- akm_update: tool({
1198
- description: "Update one managed AKM source or all managed sources to the latest available version.",
1199
- args: {
1200
- package_ref: tool.schema.string().optional().describe("Managed source id or ref to update."),
1201
- all: tool.schema.boolean().optional().describe("Update all installed kits."),
1202
- force: tool.schema.boolean().optional().describe("Force a fresh download even if the version is unchanged."),
1203
- },
1204
- async execute({ package_ref, all, force }) {
1205
- const args = ["update"]
1206
- const packageRef = package_ref?.trim()
1207
- if (all) {
1208
- args.push("--all")
1209
- } else if (packageRef) {
1210
- args.push(packageRef)
1211
- } else {
1212
- return JSON.stringify({ ok: false, error: "Provide 'package_ref' or set 'all' to true." })
1213
- }
1214
- if (force) args.push("--force")
1215
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_update" })
1216
- },
1217
- }),
1218
- akm_clone: tool({
1219
- description: "Clone an asset from any source into the working stash or a custom destination for editing.",
1220
- args: {
1221
- ref: tool.schema.string().describe("Asset ref to clone, including optional origin such as npm:@scope/pkg//script:deploy.sh."),
1222
- name: tool.schema.string().optional().describe("Optional new asset name."),
1223
- dest: tool.schema.string().optional().describe("Optional destination directory. The type subdirectory is appended automatically by akm."),
1224
- force: tool.schema.boolean().optional().describe("Overwrite the destination if it already exists."),
1225
- },
1226
- async execute({ ref, name, dest, force }) {
1227
- const args = ["clone", ref]
1228
- if (name) args.push("--name", name)
1229
- if (dest) args.push("--dest", dest)
1230
- if (force) args.push("--force")
1231
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_clone" })
1232
- },
1233
- }),
1234
1739
  akm_remember: tool({
1235
1740
  description: "Record a memory in the default AKM stash so it can be searched and shown later.",
1236
1741
  args: {
@@ -1282,20 +1787,21 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1282
1787
  },
1283
1788
  }),
1284
1789
  akm_evolve: tool({
1285
- description: "Dispatch the AKM curator agent to review recent session activity and propose stash improvements (promote hot assets, flag cold ones, draft missing coverage).",
1790
+ description: "Dispatch the AKM curator agent to review recent session activity and propose stash improvements (promote hot assets, flag cold ones, draft missing coverage). Persists the report as a memory and seeds the curator-context cache so it survives compaction.",
1286
1791
  args: {
1287
1792
  focus: tool.schema.string().optional().describe("Optional focus area or theme to weight the review toward."),
1288
- dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the curator with. Defaults to 'general'."),
1793
+ dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the curator with. Defaults to 'akm-curator', or falls back to 'general' when that agent is unavailable."),
1289
1794
  as_subtask: tool.schema.boolean().optional().describe("Run in a child session with parent context. Defaults to true."),
1290
1795
  },
1291
1796
  async execute({ focus, dispatch_agent, as_subtask }, context) {
1292
1797
  const useSubtask = as_subtask ?? true
1293
- const targetAgent = dispatch_agent ?? "general"
1798
+ const requestedAgent = dispatch_agent ?? "akm-curator"
1799
+ const targetAgent = await resolveDispatchAgent(sdkClient, requestedAgent, context.directory)
1294
1800
  const targetSession = await ensureTargetSessionID({
1295
1801
  useSubtask,
1296
1802
  context: { sessionID: context.sessionID, directory: context.directory },
1297
1803
  title: "akm:curator",
1298
- client: client as unknown as PluginClient,
1804
+ client: sdkClient,
1299
1805
  logClient,
1300
1806
  toolName: "akm_evolve",
1301
1807
  })
@@ -1306,7 +1812,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1306
1812
  : "Review recent AKM activity and produce the prioritized action list described in the system prompt."
1307
1813
 
1308
1814
  const promptResponse = await promptTargetSession({
1309
- client: client as unknown as PluginClient,
1815
+ client: sdkClient,
1310
1816
  logClient,
1311
1817
  toolName: "akm_evolve",
1312
1818
  context: { sessionID: context.sessionID, directory: context.directory },
@@ -1314,20 +1820,71 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1314
1820
  failureMessage: "Failed to dispatch curator",
1315
1821
  promptBody: {
1316
1822
  agent: targetAgent,
1317
- system: CURATOR_AGENT_PROMPT,
1823
+ system: targetAgent === "akm-curator" ? undefined : CURATOR_AGENT_PROMPT,
1318
1824
  parts: [{ type: "text", text: task }],
1319
1825
  },
1320
1826
  })
1321
1827
  if (!promptResponse.ok) return JSON.stringify(promptResponse)
1322
1828
 
1829
+ const fullText = extractText(promptResponse.data.parts)
1830
+ sessionCuratorReport.set(context.sessionID, summarizeCuratorReportForContext(fullText))
1831
+ markContextEpochDirty(context.sessionID)
1832
+ const dateTag = buildDateTag()
1833
+ const shortSid = context.sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
1834
+ const curatorMemoryRef = fullText
1835
+ ? rememberTextAsMemory(`akm-curator-${dateTag}-${shortSid}`, fullText)
1836
+ : null
1837
+
1323
1838
  return JSON.stringify({
1324
1839
  ok: true,
1325
1840
  dispatchAgent: targetAgent,
1326
1841
  usedSubtask: useSubtask,
1327
1842
  sessionID: targetSession.sessionID,
1328
1843
  focus: focus ?? null,
1329
- text: extractText(promptResponse.data.parts),
1844
+ curatorMemoryRef,
1845
+ text: fullText,
1846
+ })
1847
+ },
1848
+ }),
1849
+ akm_parent_messages: tool({
1850
+ description: "Read compact text summaries of the parent session's messages so a dispatched AKM subagent can inherit upstream context.",
1851
+ args: {},
1852
+ async execute(_input, context) {
1853
+ const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
1854
+ if (!parent.ok) return JSON.stringify(parent)
1855
+ const messages = await sdkClient.session.messages({
1856
+ path: { id: parent.parentID },
1857
+ query: { directory: context.directory },
1858
+ })
1859
+ if (messages.error || !messages.data) {
1860
+ return JSON.stringify({ ok: false, error: "Failed to read parent session messages." })
1861
+ }
1862
+ return JSON.stringify(summarizeSessionMessages(parent.parentID, messages.data))
1863
+ },
1864
+ }),
1865
+ akm_session_messages: tool({
1866
+ description: "Read compact text summaries for a specific OpenCode session. Arbitrary session IDs are restricted to the akm-curator agent; other agents may read only their current or parent session.",
1867
+ args: {
1868
+ session_id: tool.schema.string().describe("OpenCode session ID to inspect."),
1869
+ },
1870
+ async execute({ session_id }, context) {
1871
+ const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
1872
+ const allowedSessionIDs = new Set<string>([context.sessionID])
1873
+ if (parent.ok) allowedSessionIDs.add(parent.parentID)
1874
+ if (context.agent !== "akm-curator" && !allowedSessionIDs.has(session_id)) {
1875
+ return JSON.stringify({
1876
+ ok: false,
1877
+ error: "akm_session_messages only allows arbitrary session IDs for the akm-curator agent. Use akm_parent_messages for parent context.",
1878
+ })
1879
+ }
1880
+ const messages = await sdkClient.session.messages({
1881
+ path: { id: session_id },
1882
+ query: { directory: context.directory },
1330
1883
  })
1884
+ if (messages.error || !messages.data) {
1885
+ return JSON.stringify({ ok: false, error: `Failed to read messages for session '${session_id}'.` })
1886
+ }
1887
+ return JSON.stringify(summarizeSessionMessages(session_id, messages.data))
1331
1888
  },
1332
1889
  }),
1333
1890
  akm_agent: tool({
@@ -1345,7 +1902,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1345
1902
  directory: context.directory,
1346
1903
  sessionID: context.sessionID,
1347
1904
  }
1348
- const resolved = await resolveRefInput(client as unknown as LogCapableClient, { ref, query }, "agent", logMeta)
1905
+ const resolved = await resolveRefOrQueryInput(client as unknown as LogCapableClient, { ref, query }, "agent", logMeta)
1349
1906
  if (!resolved.ok) return JSON.stringify(resolved)
1350
1907
 
1351
1908
  const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], logMeta)
@@ -1431,7 +1988,7 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1431
1988
  directory: context.directory,
1432
1989
  sessionID: context.sessionID,
1433
1990
  }
1434
- const resolved = await resolveRefInput(client as unknown as LogCapableClient, { ref, query }, "command", logMeta)
1991
+ const resolved = await resolveRefOrQueryInput(client as unknown as LogCapableClient, { ref, query }, "command", logMeta)
1435
1992
  if (!resolved.ok) return JSON.stringify(resolved)
1436
1993
 
1437
1994
  const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], logMeta)
@@ -1489,95 +2046,315 @@ export const AkmPlugin: Plugin = async ({ client }) => {
1489
2046
  })
1490
2047
  },
1491
2048
  }),
1492
- akm_config: tool({
1493
- description: "View or update akm configuration settings.",
2049
+ akm_vault: tool({
2050
+ description: "Manage encrypted-at-rest vaults of KEY=VALUE pairs. Values never surface in any output channel — 'show'/'list' return key names only, 'set'/'unset' never echo the value. Use 'load' to get a shell-eval snippet that loads values into the process. action='show' and action='unset' require confirm:true.",
1494
2051
  args: {
1495
- action: tool.schema.enum(["get", "set", "list", "unset", "path"]).describe("Config action: get, set, list, unset, or path."),
1496
- key: tool.schema.string().optional().describe("Config key (required for get/set)."),
1497
- value: tool.schema.string().optional().describe("Config value (required for set)."),
1498
- all: tool.schema.boolean().optional().describe("When action is 'path', include config, stash, cache, and index paths."),
2052
+ action: tool.schema.enum(["list", "show", "create", "set", "unset", "load"]).describe("Vault subcommand. 'load' wraps `akm vault load` — treat its output as opaque shell text meant for eval."),
2053
+ ref: tool.schema.string().optional().describe("Vault ref such as vault:prod or vault:team/prod. Required for show/set/unset/load; optional for list."),
2054
+ name: tool.schema.string().optional().describe("Vault name when action is 'create' (e.g. 'prod' → vaults/prod.env)."),
2055
+ key: tool.schema.string().optional().describe("Variable name for set/unset. May include '=' to pass KEY=VALUE in one field when value is omitted."),
2056
+ value: tool.schema.string().optional().describe("Value to store. Never echoed back."),
2057
+ comment: tool.schema.string().optional().describe("Optional inline '# comment' written above the key for 'set'."),
2058
+ confirm: tool.schema.boolean().optional().describe("Must be true for sensitive actions like show and unset."),
1499
2059
  },
1500
- async execute({ action, key, value, all }) {
1501
- const args = ["config", action]
1502
- if (key) args.push(key)
1503
- if (value) args.push(value)
1504
- if (action === "path" && all) args.push("--all")
1505
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_config" })
2060
+ async execute(input) {
2061
+ const blocked = blockedToolResponse(input as Record<string, unknown>)
2062
+ if (blocked) return blocked
2063
+ const { action, ref, name, key, value, comment } = input
2064
+ const logMeta = { toolName: "akm_vault" }
2065
+ switch (action) {
2066
+ case "list": {
2067
+ const args = ["vault", "list"]
2068
+ if (ref) args.push(ref)
2069
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
2070
+ }
2071
+ case "show": {
2072
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='show'." })
2073
+ return runCli(client as unknown as LogCapableClient, ["vault", "show", ref], logMeta)
2074
+ }
2075
+ case "create": {
2076
+ if (!name) return JSON.stringify({ ok: false, error: "'name' is required for action='create'." })
2077
+ return runCli(client as unknown as LogCapableClient, ["vault", "create", name], logMeta)
2078
+ }
2079
+ case "set": {
2080
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='set'." })
2081
+ if (!key) return JSON.stringify({ ok: false, error: "'key' is required for action='set'." })
2082
+ const args = ["vault", "set", ref, key]
2083
+ if (value != null) args.push(value)
2084
+ if (comment) args.push("--comment", comment)
2085
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
2086
+ }
2087
+ case "unset": {
2088
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='unset'." })
2089
+ if (!key) return JSON.stringify({ ok: false, error: "'key' is required for action='unset'." })
2090
+ return runCli(client as unknown as LogCapableClient, ["vault", "unset", ref, key], logMeta)
2091
+ }
2092
+ case "load": {
2093
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='load'." })
2094
+ // `vault load` emits raw shell — not JSON. Return the snippet verbatim
2095
+ // so the caller can hand it to a shell via eval. Never parse values.
2096
+ const command = resolveAkmCommand()
2097
+ if (typeof command !== "string") return JSON.stringify(command)
2098
+ try {
2099
+ const stdout = execFileSync(command, ["vault", "load", ref], {
2100
+ encoding: "utf8",
2101
+ timeout: 30_000,
2102
+ })
2103
+ return JSON.stringify({ ok: true, ref, shell: stdout.trim() })
2104
+ } catch (error: unknown) {
2105
+ return JSON.stringify({ ok: false, error: formatCliError(error) })
2106
+ }
2107
+ }
2108
+ }
1506
2109
  },
1507
2110
  }),
1508
- akm_run: tool({
1509
- description: "Execute a stash script by ref. Resolves via search, fetches metadata via show, and runs the run command.",
2111
+ akm_wiki: tool({
2112
+ description: "Manage AKM wikis — multi-wiki knowledge bases under <stashDir>/wikis/<name>/. Supports scaffolding, registering external sources, listing pages, scoped search, stashing raw sources, lint, and ingest workflow.",
1510
2113
  args: {
1511
- ref: tool.schema.string().optional().describe("Script ref from akm_search (e.g. script:deploy.sh)."),
1512
- query: tool.schema.string().optional().describe("If ref is omitted, resolve best matching stash script for this query."),
1513
- args: tool.schema.string().optional().describe("Arguments to append to the run command."),
2114
+ action: tool.schema.enum([
2115
+ "create",
2116
+ "register",
2117
+ "list",
2118
+ "show",
2119
+ "remove",
2120
+ "pages",
2121
+ "search",
2122
+ "stash",
2123
+ "lint",
2124
+ "ingest",
2125
+ ]).describe("Wiki subcommand."),
2126
+ name: tool.schema.string().optional().describe("Wiki name (required for every action except 'list')."),
2127
+ source_ref: tool.schema.string().optional().describe("Source ref to register (required for action='register'). Accepts directory paths, git URLs, github owner/repo, or https:// website roots."),
2128
+ writable: tool.schema.boolean().optional().describe("When registering a git-backed source, mark it as push-writable (used by `akm save`; see akm_help topic='save')."),
2129
+ trust: tool.schema.boolean().optional().describe("Bypass install-audit blocking for this registration only."),
2130
+ max_pages: tool.schema.number().optional().describe("Crawler page cap when registering a website (default 50)."),
2131
+ max_depth: tool.schema.number().optional().describe("Crawler depth cap when registering a website (default 3)."),
2132
+ query: tool.schema.string().optional().describe("Query string for action='search'."),
2133
+ limit: tool.schema.number().optional().describe("Result cap for action='search'."),
2134
+ source: tool.schema.string().optional().describe("Source path (or '-' for stdin) for action='stash'."),
2135
+ as_slug: tool.schema.string().optional().describe("Explicit slug for action='stash' (defaults to derived from source)."),
2136
+ content: tool.schema.string().optional().describe("Raw content to feed stdin when stashing with source='-'."),
2137
+ force: tool.schema.boolean().optional().describe("Required for action='remove'."),
2138
+ with_sources: tool.schema.boolean().optional().describe("When removing, also delete the raw/ sources (default false)."),
1514
2139
  },
1515
- async execute({ ref, query, args: runArgs }) {
1516
- const resolved = await resolveRefInput(client as unknown as LogCapableClient, { ref, query }, "script", { toolName: "akm_run" })
1517
- if (!resolved.ok) return JSON.stringify(resolved)
1518
-
1519
- const shownRaw = await runCli(client as unknown as LogCapableClient, ["show", resolved.ref], { toolName: "akm_run" })
1520
- const shown = parseCliJson<ShowToolResponse | { type: string }>(shownRaw)
1521
- if (isCliError(shown)) return JSON.stringify(shown)
1522
-
1523
- if (!isShowToolResponse(shown)) {
1524
- return JSON.stringify({
1525
- ok: false,
1526
- error: `Ref ${resolved.ref} is not a script payload from akm_show.`,
1527
- })
1528
- }
1529
-
1530
- if (!shown.run || !shown.run.trim()) {
1531
- return JSON.stringify({
1532
- ok: false,
1533
- error: `Script ${shown.name} is missing run command.`,
1534
- })
2140
+ async execute({ action, name, source_ref, writable, trust, max_pages, max_depth, query, limit, source, as_slug, content, force, with_sources }) {
2141
+ const logMeta = { toolName: "akm_wiki" }
2142
+ const requireName = () => {
2143
+ if (!name) return JSON.stringify({ ok: false, error: `'name' is required for action='${action}'.` })
2144
+ return null
1535
2145
  }
1536
-
1537
- let cmd = shown.run
1538
- if (runArgs && runArgs.trim()) {
1539
- cmd = `${cmd} ${runArgs.trim()}`
1540
- }
1541
-
1542
- try {
1543
- const output = execSync(cmd, {
1544
- encoding: "utf8",
1545
- timeout: 120_000,
1546
- })
1547
- return JSON.stringify({
1548
- ok: true,
1549
- ref: resolved.ref,
1550
- script: shown.name,
1551
- run: cmd,
1552
- output,
1553
- })
1554
- } catch (error: unknown) {
1555
- const message = error instanceof Error ? error.message : String(error)
1556
- return JSON.stringify({
1557
- ok: false,
1558
- error: `Failed to execute run command for ${shown.name}: ${message}`,
1559
- })
2146
+ switch (action) {
2147
+ case "list":
2148
+ return runCli(client as unknown as LogCapableClient, ["wiki", "list"], logMeta)
2149
+ case "create": {
2150
+ const err = requireName(); if (err) return err
2151
+ return runCli(client as unknown as LogCapableClient, ["wiki", "create", name!], logMeta)
2152
+ }
2153
+ case "show": {
2154
+ const err = requireName(); if (err) return err
2155
+ return runCli(client as unknown as LogCapableClient, ["wiki", "show", name!], logMeta)
2156
+ }
2157
+ case "pages": {
2158
+ const err = requireName(); if (err) return err
2159
+ return runCli(client as unknown as LogCapableClient, ["wiki", "pages", name!], logMeta)
2160
+ }
2161
+ case "ingest": {
2162
+ const err = requireName(); if (err) return err
2163
+ return runCli(client as unknown as LogCapableClient, ["wiki", "ingest", name!], logMeta)
2164
+ }
2165
+ case "lint": {
2166
+ const err = requireName(); if (err) return err
2167
+ // `wiki lint` exits 1 when findings exist, which runCli surfaces as
2168
+ // an error envelope. That is still useful output — the JSON body is
2169
+ // the lint report. Pass through either way.
2170
+ return runCli(client as unknown as LogCapableClient, ["wiki", "lint", name!], logMeta)
2171
+ }
2172
+ case "register": {
2173
+ const err = requireName(); if (err) return err
2174
+ if (!source_ref) return JSON.stringify({ ok: false, error: "'source_ref' is required for action='register'." })
2175
+ const args = ["wiki", "register", name!, source_ref]
2176
+ if (writable) args.push("--writable")
2177
+ if (trust) args.push("--trust")
2178
+ if (max_pages != null) args.push("--max-pages", String(max_pages))
2179
+ if (max_depth != null) args.push("--max-depth", String(max_depth))
2180
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
2181
+ }
2182
+ case "remove": {
2183
+ const err = requireName(); if (err) return err
2184
+ if (!force) return JSON.stringify({ ok: false, error: "'force' must be true to remove a wiki." })
2185
+ const args = ["wiki", "remove", name!, "--force"]
2186
+ if (with_sources) args.push("--with-sources")
2187
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
2188
+ }
2189
+ case "search": {
2190
+ const err = requireName(); if (err) return err
2191
+ if (!query) return JSON.stringify({ ok: false, error: "'query' is required for action='search'." })
2192
+ const args = ["wiki", "search", name!, query]
2193
+ if (limit != null) args.push("--limit", String(limit))
2194
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
2195
+ }
2196
+ case "stash": {
2197
+ const err = requireName(); if (err) return err
2198
+ if (!source) return JSON.stringify({ ok: false, error: "'source' is required for action='stash'." })
2199
+ const args = ["wiki", "stash", name!, source]
2200
+ if (as_slug) args.push("--as", as_slug)
2201
+ if (source === "-" && content) {
2202
+ const command = resolveAkmCommand()
2203
+ if (typeof command !== "string") return JSON.stringify(command)
2204
+ try {
2205
+ const stdout = execFileSync(command, [...args, "--format", "json"], {
2206
+ encoding: "utf8",
2207
+ timeout: 60_000,
2208
+ input: content,
2209
+ })
2210
+ return stdout
2211
+ } catch (error: unknown) {
2212
+ return JSON.stringify({ ok: false, error: formatCliError(error) })
2213
+ }
2214
+ }
2215
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
2216
+ }
1560
2217
  }
1561
2218
  },
1562
2219
  }),
1563
- akm_sources: tool({
1564
- description: "List all configured AKM sources. Kept as a backward-compatible alias for the older sources command.",
1565
- args: {},
1566
- async execute() {
1567
- return runCli(client as unknown as LogCapableClient, ["list"], { toolName: "akm_sources" })
2220
+ akm_workflow: tool({
2221
+ description: "Manage AKM workflow runs — stateful multi-step procedures defined as workflow:<name> assets. Use start/next/complete/resume to drive a run, status/list to inspect, create/template to author.",
2222
+ args: {
2223
+ action: tool.schema.enum([
2224
+ "start",
2225
+ "next",
2226
+ "complete",
2227
+ "status",
2228
+ "list",
2229
+ "create",
2230
+ "template",
2231
+ "resume",
2232
+ ]).describe("Workflow subcommand."),
2233
+ ref: tool.schema.string().optional().describe("Workflow ref (e.g. workflow:release). Required for start; accepted by next/status as a target."),
2234
+ target: tool.schema.string().optional().describe("Run id or workflow ref for next/status. When a workflow ref is passed to 'next', a new run is auto-started."),
2235
+ run_id: tool.schema.string().optional().describe("Workflow run id. Required for complete and resume."),
2236
+ params: tool.schema.string().optional().describe("JSON object string of parameters for start/next."),
2237
+ step: tool.schema.string().optional().describe("Step id to transition (required for action='complete')."),
2238
+ state: tool.schema.enum(["completed", "blocked", "failed", "skipped"]).optional().describe("Step state for 'complete'. Defaults to 'completed'."),
2239
+ notes: tool.schema.string().optional().describe("Freeform notes attached to the step transition."),
2240
+ evidence: tool.schema.string().optional().describe("JSON object string of evidence attached to the step transition."),
2241
+ name: tool.schema.string().optional().describe("Workflow name for action='create'."),
2242
+ from: tool.schema.string().optional().describe("Path to a markdown template for action='create'."),
2243
+ force: tool.schema.boolean().optional().describe("Overwrite an existing workflow on create (requires --from or --reset)."),
2244
+ reset: tool.schema.boolean().optional().describe("Reset to the built-in template for action='create'."),
2245
+ filter_ref: tool.schema.string().optional().describe("Restrict action='list' to runs of this workflow ref."),
2246
+ active_only: tool.schema.boolean().optional().describe("Restrict action='list' to active (non-terminal) runs."),
2247
+ },
2248
+ async execute({
2249
+ action,
2250
+ ref,
2251
+ target,
2252
+ run_id,
2253
+ params,
2254
+ step,
2255
+ state,
2256
+ notes,
2257
+ evidence,
2258
+ name,
2259
+ from,
2260
+ force,
2261
+ reset,
2262
+ filter_ref,
2263
+ active_only,
2264
+ }) {
2265
+ const logMeta = { toolName: "akm_workflow" }
2266
+ switch (action) {
2267
+ case "start": {
2268
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='start'." })
2269
+ const args = ["workflow", "start", ref]
2270
+ if (params) args.push("--params", params)
2271
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
2272
+ }
2273
+ case "next": {
2274
+ const picked = target ?? run_id ?? ref
2275
+ if (!picked) return JSON.stringify({ ok: false, error: "'target', 'run_id', or 'ref' is required for action='next'." })
2276
+ const args = ["workflow", "next", picked]
2277
+ if (params) args.push("--params", params)
2278
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
2279
+ }
2280
+ case "complete": {
2281
+ if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='complete'." })
2282
+ if (!step) return JSON.stringify({ ok: false, error: "'step' is required for action='complete'." })
2283
+ const args = ["workflow", "complete", run_id, "--step", step]
2284
+ if (state) args.push("--state", state)
2285
+ if (notes) args.push("--notes", notes)
2286
+ if (evidence) args.push("--evidence", evidence)
2287
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
2288
+ }
2289
+ case "status": {
2290
+ const picked = target ?? run_id ?? ref
2291
+ if (!picked) return JSON.stringify({ ok: false, error: "'target', 'run_id', or 'ref' is required for action='status'." })
2292
+ return runCli(client as unknown as LogCapableClient, ["workflow", "status", picked], logMeta)
2293
+ }
2294
+ case "list": {
2295
+ const args = ["workflow", "list"]
2296
+ if (filter_ref) args.push("--ref", filter_ref)
2297
+ if (active_only) args.push("--active")
2298
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
2299
+ }
2300
+ case "create": {
2301
+ if (!name) return JSON.stringify({ ok: false, error: "'name' is required for action='create'." })
2302
+ const args = ["workflow", "create", name]
2303
+ if (from) args.push("--from", from)
2304
+ if (force) args.push("--force")
2305
+ if (reset) args.push("--reset")
2306
+ return runCli(client as unknown as LogCapableClient, args, logMeta)
2307
+ }
2308
+ case "template": {
2309
+ // The workflow template is emitted as raw markdown, not JSON.
2310
+ const command = resolveAkmCommand()
2311
+ if (typeof command !== "string") return JSON.stringify(command)
2312
+ try {
2313
+ const stdout = execFileSync(command, ["workflow", "template"], {
2314
+ encoding: "utf8",
2315
+ timeout: 30_000,
2316
+ })
2317
+ return JSON.stringify({ ok: true, template: stdout })
2318
+ } catch (error: unknown) {
2319
+ return JSON.stringify({ ok: false, error: formatCliError(error) })
2320
+ }
2321
+ }
2322
+ case "resume": {
2323
+ if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='resume'." })
2324
+ return runCli(client as unknown as LogCapableClient, ["workflow", "resume", run_id], logMeta)
2325
+ }
2326
+ }
1568
2327
  },
1569
2328
  }),
1570
- akm_upgrade: tool({
1571
- description: "Check for or install akm CLI updates.",
2329
+ akm_help: tool({
2330
+ description: "Discover the right `akm` CLI command and args for tasks not covered by a first-class tool — e.g. save/push, import, clone, update, remove, list sources, registry search, reindex, config, CLI upgrade, run script. Returns a curated quick-reference plus live `akm --help` output. Pass `command` to drill into a specific subcommand.",
1572
2331
  args: {
1573
- check: tool.schema.boolean().optional().describe("Only check for updates without installing."),
1574
- force: tool.schema.boolean().optional().describe("Force upgrade even if already on latest version."),
2332
+ topic: tool.schema.string().optional().describe("Natural-language description of the task (e.g. 'commit and push my stash', 'install a kit from github'). Returns curated hints if any keywords match."),
2333
+ command: tool.schema.string().optional().describe("Specific akm subcommand to inspect (e.g. 'save', 'clone', 'config'). Runs `akm <command> --help` and returns the output verbatim."),
1575
2334
  },
1576
- async execute({ check, force }) {
1577
- const args = ["upgrade"]
1578
- if (check) args.push("--check")
1579
- if (force) args.push("--force")
1580
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_upgrade" })
2335
+ async execute({ topic, command }) {
2336
+ const cliCommand = resolveAkmCommand()
2337
+ if (typeof cliCommand !== "string") return JSON.stringify(cliCommand)
2338
+ const helpArgs = command && command.trim()
2339
+ ? [command.trim(), "--help"]
2340
+ : ["--help"]
2341
+ let helpText = ""
2342
+ try {
2343
+ helpText = execFileSync(cliCommand, helpArgs, {
2344
+ encoding: "utf8",
2345
+ timeout: 30_000,
2346
+ }).toString().trim()
2347
+ } catch (error: unknown) {
2348
+ return JSON.stringify({ ok: false, error: formatCliError(error) })
2349
+ }
2350
+ return JSON.stringify({
2351
+ ok: true,
2352
+ command: command ?? null,
2353
+ topic: topic ?? null,
2354
+ hints: topic ? lookupAkmHelpHint(topic) : [],
2355
+ quickReference: AKM_HELP_QUICK_REFERENCE,
2356
+ help: helpText,
2357
+ })
1581
2358
  },
1582
2359
  }),
1583
2360
  },