akm-opencode 0.8.0-rc1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,18 +1,50 @@
1
1
  import { type Plugin, tool } from "@opencode-ai/plugin"
2
2
  import { execFileSync, execSync, spawn } from "node:child_process"
3
- import { readFileSync } from "node:fs"
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
4
+ import os from "node:os"
4
5
  import path from "node:path"
5
6
  import { fileURLToPath } from "node:url"
6
- import { classifyFeedbackSignal, shouldSubmitAutomaticFeedback } from "../shared/feedback-signals"
7
- import { appendCandidates, extractCandidatesFromText, getCandidateLogPath, readCandidates, updateCandidateStatus } from "../shared/memory-candidates"
8
- import { appendMemoryEvent, getEventLogPath, readJsonl, type AkmMemoryEvent } from "../shared/memory-events"
9
- import { shouldRecall } from "../shared/recall-policy"
10
- import { redactObject, redactSecrets } from "../shared/redaction"
7
+ import { classifyFeedbackSignal, shouldSubmitAutomaticFeedback } from "./shared/feedback-signals"
8
+ import { appendCandidates, extractCandidatesFromText, getCandidateLogPath, readCandidates, updateCandidateStatus } from "./shared/memory-candidates"
9
+ import { appendMemoryEvent, getEventLogPath, readJsonl, type AkmMemoryEvent } from "./shared/memory-events"
10
+ import { shouldRecall } from "./shared/recall-policy"
11
+ import { redactObject, redactSecrets } from "./shared/redaction"
12
+ import { extractAkmRefsFromString } from "./shared/ref-extraction"
13
+
14
+ // Quote-aware shell tokenizer. Splits on whitespace but respects single,
15
+ // double, and backtick quotes. Used by renderCommandTemplate() to fill
16
+ // `$1`/`$2` positional placeholders from raw `$ARGUMENTS` strings. Was
17
+ // previously co-located with the risky-command assessor in
18
+ // shared/risky-command.ts; inlined here when that gate was removed.
19
+ function splitArguments(raw: string): string[] {
20
+ if (!raw.trim()) return []
21
+ const args: string[] = []
22
+ const re = /"([^"]*)"|'([^']*)'|`([^`]*)`|(\S+)/g
23
+ let match: RegExpExecArray | null
24
+ while ((match = re.exec(raw)) !== null) {
25
+ args.push(match[1] ?? match[2] ?? match[3] ?? match[4] ?? "")
26
+ }
27
+ return args
28
+ }
11
29
 
12
30
  let resolvedAkmCommand = "akm"
13
- const autoInstallPackageRef = process.env.AKM_PACKAGE_REF ?? "akm-cli@latest"
31
+
32
+ // Test-only: reset the module-level resolved-CLI cache so each test resolves
33
+ // the akm command fresh under its own sandboxed env (HOME / AKM_OPENCODE_*).
34
+ // Without this, the first test to resolve pins `resolvedAkmCommand` for the
35
+ // rest of the process (resolveAkmCommand short-circuits on a still-valid
36
+ // cached command), making later resolution tests order-dependent.
37
+ export function __resetResolvedAkmForTests(): void {
38
+ resolvedAkmCommand = "akm"
39
+ }
40
+
14
41
  const moduleDir = path.dirname(fileURLToPath(import.meta.url))
15
42
  const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/
43
+ // Note: satisfiesAkmVersionRange() implements a custom matcher that accepts
44
+ // any 0.8.x release including prereleases (0.8.0-rc.5, 0.8.0-beta.2, etc.).
45
+ // Strict semver `^0.8.0` would NOT match prereleases, so the constant string
46
+ // is widened to honestly reflect what the matcher accepts.
47
+ const AKM_REQUIRED_VERSION_RANGE = "^0.8.0 || ^0.8.0-rc0"
16
48
 
17
49
  const AKM_AUTO_FEEDBACK = (process.env.AKM_AUTO_FEEDBACK ?? "1") !== "0"
18
50
  const AKM_AUTO_MEMORY = (process.env.AKM_AUTO_MEMORY ?? "1") !== "0"
@@ -42,10 +74,10 @@ const sessionWorkflow = new Map<string, string>()
42
74
  const sessionCuratorReport = new Map<string, string>()
43
75
  const sessionContextEpoch = new Map<string, number>()
44
76
  const sessionContextInjectedEpoch = new Map<string, number>()
77
+ const sessionCuratedFile = new Map<string, string>()
45
78
  const sessionCuratedVersion = new Map<string, number>()
46
79
  const sessionCuratedInjectedVersion = new Map<string, number>()
47
80
  const sessionRecallAudit = new Map<string, { shouldRecall: boolean; reason: string; query: string; injectedRefs: string[]; injectedChars: number; warnings: string[] }>()
48
- type ParsedSemver = { core: [number, number, number]; prerelease: Array<string | number> | null }
49
81
  type SessionBufferEntry = {
50
82
  timestamp: string
51
83
  kind: "memory-intent" | "tool-ref"
@@ -66,22 +98,22 @@ let agentSetupPromise: Promise<boolean> | null = null
66
98
  // Asset-ref grammar matching the stash skill: [origin//]type:name.
67
99
  // We validate normalized tokens individually instead of running a global regex
68
100
  // over arbitrary tool output to keep extraction predictable and ReDoS-safe.
69
- const AKM_REF_PATTERN = /^(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|vault|wiki|lesson):[A-Za-z0-9._/\-]+$/
101
+ const AKM_REF_PATTERN = /^(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|task|env|secret|wiki|lesson):[A-Za-z0-9._/\-]+$/
70
102
  const PROPOSED_QUALITY_WARNING = "Do not treat proposed assets as curated until accepted."
71
103
  const AKM_WORKFLOW_INSTRUCTION = [
72
- "# AKM workflow (v0.7.0)",
104
+ "# AKM workflow (v0.8.0)",
73
105
  "",
74
106
  "Use AKM as a reusable knowledge and workflow stash.",
75
107
  "",
76
108
  "Before writing from scratch:",
77
- "1. Use `akm_search` or `akm_curate`.",
109
+ "1. Use `akm_curate` with a query that includes the current project name/domain (primary discovery). Fall back to `akm_search` only when you already know an asset exists and need its exact ref.",
78
110
  "2. Use `akm_show <ref>` before relying on an asset.",
79
111
  "3. Record `akm_feedback` after the result is known.",
80
- "4. Use the dedicated v0.7.0 tools for the proposal flow: `akm_proposal` (list/show/diff/accept/reject), `akm_reflect`, `akm_propose`, and `akm_distill`. Fall back to `akm_help` for any verb without a dedicated tool.",
81
- "5. Treat `lesson:*` as first-class durable learning assets — they are produced by `akm_distill <ref>` as proposals and accepted via `akm_proposal action=accept`.",
112
+ "4. Use the dedicated v0.8.0 tools for the proposal flow: `akm_proposal` (list/show/diff/accept/reject/drain), `akm_improve`, and `akm_propose`. Fall back to `akm_help` for any verb without a dedicated tool.",
113
+ "5. Treat `lesson:*` as first-class durable learning assets — they are produced through `akm_improve` and accepted via `akm_proposal action=accept`.",
82
114
  "6. Use `akm_init` when you need to create the working stash or persist `stashDir`. Do not use `akm setup` from an agent; it is interactive and human-facing.",
83
115
  `7. ${PROPOSED_QUALITY_WARNING}`,
84
- "8. Never accept or reject proposals, push saves, remove sources, or access vault values without explicit user approval.",
116
+ "8. Never accept or reject proposals, push saves, remove sources, or access env values / secret material without explicit user approval.",
85
117
  ].join("\n")
86
118
 
87
119
  function readPackageVersion(): string {
@@ -127,14 +159,14 @@ Inputs you should inspect:
127
159
  Signals to act on:
128
160
  - Hot refs: assets repeatedly appearing in positive tool outcomes. Call akm_feedback <ref> positive --note "curator: consistently useful" to reinforce.
129
161
  - Cold refs: assets tied to failures or user complaints. Record akm_feedback <ref> negative --note "<excerpt>" and open the asset for review.
130
- - Lesson candidates: repeated memories or failures that should become a proposed lesson. Use akm_help topic="distill" before raw CLI distill commands.
162
+ - Lesson candidates: repeated memories or failures that should become a proposed lesson. Use akm_improve or akm_help topic="improve" before raw CLI improvement commands.
131
163
  - 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").
132
164
  - Pending proposals: list or diff them via akm_help topic="proposal" and recommend accept, reject, or revise. Never accept or reject without explicit user approval.
133
165
  - Duplicates / drift: near-identical descriptions or overlapping responsibilities. Propose a consolidation.
134
166
  - 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.
135
167
  - 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.
136
168
  - 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.
137
- - Never touch vaults: do not call akm_vault show or load unless the user explicitly asks. Vault values must never appear in reports.
169
+ - Never touch env or secret values: do not call akm_env run or akm_secret path unless the user explicitly asks. Env values and secret material must never appear in reports.
138
170
 
139
171
  Rules of engagement:
140
172
  - Never apply destructive changes without explicit user approval.
@@ -152,7 +184,7 @@ Output shape: end every run with a markdown report that has these sections:
152
184
  - <ref> — failure signal — proposed fix
153
185
 
154
186
  ## Lesson candidates
155
- - <theme> — evidence refs — distill or reflect command to run
187
+ - <theme> — evidence refs — improve or propose command to run
156
188
 
157
189
  ## Coverage gaps
158
190
  - <theme> — proposed asset (type, name, one-line description)
@@ -210,6 +242,9 @@ type CliLogMeta = {
210
242
  toolName: string
211
243
  directory?: string
212
244
  sessionID?: string
245
+ agent?: string
246
+ userID?: string
247
+ channel?: string
213
248
  }
214
249
 
215
250
  type SessionPromptBody = {
@@ -220,6 +255,12 @@ type SessionPromptBody = {
220
255
  tools?: Record<string, boolean>
221
256
  }
222
257
 
258
+ type DispatchTarget = {
259
+ agent: string
260
+ model?: { providerID: string; modelID: string }
261
+ requested: string
262
+ }
263
+
223
264
  function formatCliError(error: unknown): string {
224
265
  if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") {
225
266
  return "The 'akm' CLI was not found on PATH. Install it first from https://github.com/itlackey/akm."
@@ -234,7 +275,78 @@ function needsAgentSetup(message: string): boolean {
234
275
  function addAgentSetupGuidance(message: string): string {
235
276
  if (!needsAgentSetup(message)) return message
236
277
  if (/\bakm init\b|\bakm_init\b|\bakm setup\b/i.test(message)) return message
237
- return `${message}. Agents should not run akm setup because it is interactive. The plugin should set agent.default to the current platform when it is missing; if interactive configuration is still needed, ask the user to run akm setup manually. Use akm_init for agent-safe stash initialization.`
278
+ return `${message}. Agents should not run akm setup because it is interactive. The plugin should set the AKM default agent to the current platform when it is missing; if interactive configuration is still needed, ask the user to run akm setup manually. Use akm_init for agent-safe stash initialization.`
279
+ }
280
+
281
+ function getAkmConfigPath(): string {
282
+ const configHome = process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? ".", ".config")
283
+ return path.join(configHome, "akm", "config.json")
284
+ }
285
+
286
+ function readAkmConfig(): Record<string, unknown> {
287
+ try {
288
+ const raw = readFileSync(getAkmConfigPath(), "utf8")
289
+ const parsed = JSON.parse(raw)
290
+ return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {}
291
+ } catch {
292
+ return {}
293
+ }
294
+ }
295
+
296
+ function readConfiguredAgentDefault(): string {
297
+ const config = readAkmConfig()
298
+ // 0.8.0 canonical shape: defaults.agent. Fall back to the legacy agent.default
299
+ // slot when running against a pre-0.8 config that has not been migrated yet.
300
+ const defaults = config.defaults
301
+ if (defaults && typeof defaults === "object") {
302
+ const value = (defaults as Record<string, unknown>).agent
303
+ if (typeof value === "string" && value.trim()) return value.trim()
304
+ }
305
+ const agent = config.agent
306
+ if (agent && typeof agent === "object") {
307
+ const value = (agent as Record<string, unknown>).default
308
+ if (typeof value === "string" && value.trim()) return value.trim()
309
+ }
310
+ return ""
311
+ }
312
+
313
+ function writeConfiguredAgentDefault(platform: string): boolean {
314
+ if (!platform.trim()) return false
315
+ // #463: route through `akm config set` so akm's schema-walker / validator
316
+ // is the single source of truth for the on-disk shape. Direct JSON writes
317
+ // here would bypass strict-mode validation and the 5-backup ring buffer,
318
+ // and historically clobbered nearby keys when the legacy `agent.default`
319
+ // slot triggered an auto-migration.
320
+ const command = resolveAkmCommand()
321
+ if (typeof command === "object" && "ok" in command) return false
322
+ try {
323
+ // #463: --silent --layer user (akm-cli 0.8.0+) pins writes to the user
324
+ // config layer regardless of merged-read scope and silences hook-driven
325
+ // CLI output. Without them, hook writes could race with a project-layer
326
+ // override or pollute the parent process stdout.
327
+ execFileSync(
328
+ command.command,
329
+ [
330
+ ...command.argsPrefix,
331
+ "config",
332
+ "set",
333
+ "--silent",
334
+ "--layer",
335
+ "user",
336
+ `profiles.agent.${platform}`,
337
+ JSON.stringify({ platform }),
338
+ ],
339
+ { stdio: "ignore" },
340
+ )
341
+ execFileSync(
342
+ command.command,
343
+ [...command.argsPrefix, "config", "set", "--silent", "--layer", "user", "defaults.agent", platform],
344
+ { stdio: "ignore" },
345
+ )
346
+ return true
347
+ } catch {
348
+ return false
349
+ }
238
350
  }
239
351
 
240
352
  async function ensurePlatformAgentDefault(
@@ -242,7 +354,7 @@ async function ensurePlatformAgentDefault(
242
354
  meta: { directory?: string; sessionID?: string; trigger: string; platform: string },
243
355
  ): Promise<boolean> {
244
356
  const command = resolveAkmCommand()
245
- if (typeof command !== "string") {
357
+ if (typeof command === "object" && "ok" in command) {
246
358
  await writePluginLog(client, "warn", "AKM agent default check skipped", {
247
359
  subsystem: "akm",
248
360
  trigger: meta.trigger,
@@ -254,26 +366,12 @@ async function ensurePlatformAgentDefault(
254
366
  }
255
367
 
256
368
  try {
257
- const currentRaw = execFileSync(command, ["config", "get", "agent.default", "--format", "json"], {
258
- encoding: "utf8",
259
- timeout: 30_000,
260
- })
261
- const parsed = safeJsonParse<unknown>(currentRaw)
262
- const current = typeof parsed === "string"
263
- ? parsed.trim()
264
- : parsed && typeof parsed === "object"
265
- ? typeof (parsed as Record<string, unknown>).value === "string"
266
- ? ((parsed as Record<string, unknown>).value as string).trim()
267
- : typeof (parsed as Record<string, unknown>)["agent.default"] === "string"
268
- ? ((parsed as Record<string, unknown>)["agent.default"] as string).trim()
269
- : ""
270
- : ""
369
+ const current = readConfiguredAgentDefault()
271
370
  if (current) return true
272
371
 
273
- execFileSync(command, ["config", "set", "agent.default", meta.platform, "--format", "json"], {
274
- encoding: "utf8",
275
- timeout: 30_000,
276
- })
372
+ if (!writeConfiguredAgentDefault(meta.platform)) {
373
+ throw new Error(`Failed to write ${getAkmConfigPath()}`)
374
+ }
277
375
  await writePluginLog(client, "info", "AKM agent default initialized", {
278
376
  subsystem: "akm",
279
377
  trigger: meta.trigger,
@@ -364,6 +462,46 @@ function writeStructuredEvent(event: Omit<AkmMemoryEvent, "version" | "timestamp
364
462
  })
365
463
  }
366
464
 
465
+ const CURATED_DIR = path.join(os.tmpdir(), "akm-opencode", "curated")
466
+ mkdirSync(CURATED_DIR, { recursive: true })
467
+
468
+ function gatherCwdContext(directory: string): string {
469
+ const parts: string[] = []
470
+ const indicators: Array<{ file: string; label: string }> = [
471
+ { file: "package.json", label: "Node" },
472
+ { file: "Cargo.toml", label: "Rust" },
473
+ { file: "pyproject.toml", label: "Python" },
474
+ { file: "go.mod", label: "Go" },
475
+ { file: "Gemfile", label: "Ruby" },
476
+ { file: "Makefile", label: "Make" },
477
+ { file: "Dockerfile", label: "Docker" },
478
+ { file: "docker-compose.yml", label: "Docker Compose" },
479
+ { file: ".github/workflows", label: "GitHub Actions" },
480
+ { file: "composer.json", label: "PHP" },
481
+ ]
482
+ for (const indicator of indicators) {
483
+ try {
484
+ if (existsSync(path.join(directory, indicator.file))) parts.push(indicator.label)
485
+ } catch {}
486
+ }
487
+ try {
488
+ const pkgPath = path.join(directory, "package.json")
489
+ if (existsSync(pkgPath)) {
490
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
491
+ if (pkg.name) parts.push(pkg.name)
492
+ if (pkg.description) parts.push(String(pkg.description).slice(0, 120))
493
+ }
494
+ } catch {}
495
+ try {
496
+ const readme = path.join(directory, "README.md")
497
+ if (existsSync(readme)) {
498
+ const firstContent = readFileSync(readme, "utf8").split("\n").find((l) => l.trim() && !l.startsWith("#"))
499
+ if (firstContent) parts.push(firstContent.trim().slice(0, 100))
500
+ }
501
+ } catch {}
502
+ return parts.join(", ")
503
+ }
504
+
367
505
  async function logHookFailure(
368
506
  client: LogCapableClient,
369
507
  hook: string,
@@ -406,6 +544,17 @@ function bumpCuratedVersion(sessionID: string) {
406
544
  sessionCuratedVersion.set(sessionID, (sessionCuratedVersion.get(sessionID) ?? 0) + 1)
407
545
  }
408
546
 
547
+ function writeCuratedFile(sessionID: string, content: string): string {
548
+ const sanitized = sessionID.replace(/[^A-Za-z0-9._-]/g, "_")
549
+ const filePath = path.join(CURATED_DIR, `${sanitized}.md`)
550
+ try {
551
+ writeFileSync(filePath, content)
552
+ sessionCuratedFile.set(sessionID, filePath)
553
+ } catch {}
554
+ return filePath
555
+ }
556
+
557
+
409
558
  function isAkmRef(value: string): boolean {
410
559
  return AKM_REF_PATTERN.test(value)
411
560
  }
@@ -423,9 +572,9 @@ function parseMaybeJson(value: string): unknown {
423
572
  // stash never wedges the session loop.
424
573
  function runCliSyncRaw(args: string[], timeoutMs: number): { ok: true; stdout: string } | { ok: false; error: string } {
425
574
  const command = resolveAkmCommand()
426
- if (typeof command !== "string") return { ok: false, error: command.error }
575
+ if (typeof command === "object" && "ok" in command) return { ok: false, error: command.error }
427
576
  try {
428
- const stdout = execFileSync(command, args, {
577
+ const stdout = execResolvedAkm(command, args, {
429
578
  encoding: "utf8",
430
579
  timeout: timeoutMs,
431
580
  stdio: ["ignore", "pipe", "pipe"],
@@ -472,6 +621,125 @@ function buildScopedArgs(context: Record<string, unknown> | undefined): string[]
472
621
  return args
473
622
  }
474
623
 
624
+ function getHarnessStatePaths(): { stateDir: string; eventLog: string; candidateLog: string; opencodeLogDir: string } {
625
+ const stateDir = path.dirname(OPENCODE_EVENT_LOG)
626
+ return {
627
+ stateDir,
628
+ eventLog: OPENCODE_EVENT_LOG,
629
+ candidateLog: OPENCODE_CANDIDATE_LOG,
630
+ opencodeLogDir: path.join(process.env.XDG_DATA_HOME ?? path.join(process.env.HOME ?? ".", ".local", "share"), "opencode", "log"),
631
+ }
632
+ }
633
+
634
+ function formatPathBullet(label: string, filePath: string): string {
635
+ return `- ${label}: ${filePath}`
636
+ }
637
+
638
+ function countByValue(values: string[]): Array<[string, number]> {
639
+ const counts = new Map<string, number>()
640
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1)
641
+ return [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
642
+ }
643
+
644
+ function uniqueRecent<T>(values: T[], key: (value: T) => string, limit: number): T[] {
645
+ const selected: T[] = []
646
+ const seen = new Set<string>()
647
+ for (let index = values.length - 1; index >= 0 && selected.length < limit; index -= 1) {
648
+ const value = values[index]
649
+ const id = key(value)
650
+ if (!id || seen.has(id)) continue
651
+ seen.add(id)
652
+ selected.push(value)
653
+ }
654
+ return selected.reverse()
655
+ }
656
+
657
+ function sessionHasPendingCheckpointEvidence(sessionID: string | undefined): boolean {
658
+ if (!sessionID) return false
659
+ return (sessionBuffer.get(sessionID) ?? []).some((entry) => !entry.checkpointed)
660
+ }
661
+
662
+ async function ensureFreshProposalCheckpoint(
663
+ client: LogCapableClient,
664
+ context: { sessionID?: string; directory?: string; agent?: string },
665
+ reason: string,
666
+ ): Promise<string | null> {
667
+ if (!context.sessionID || !sessionHasPendingCheckpointEvidence(context.sessionID)) return null
668
+ const ref = captureSessionMemory(context.sessionID, reason, { checkpoint: true })
669
+ if (!ref) return null
670
+ await writePluginLog(client, "info", "AKM proposal checkpoint captured", {
671
+ subsystem: "memory",
672
+ actor: "system",
673
+ sessionID: context.sessionID,
674
+ directory: context.directory,
675
+ agent: context.agent,
676
+ reason,
677
+ ref,
678
+ })
679
+ const indexResult = runCliSyncRaw(["index"], AKM_CURATE_TIMEOUT_MS)
680
+ if (!indexResult.ok) {
681
+ await writePluginLog(client, "warn", "AKM proposal checkpoint indexing failed", {
682
+ subsystem: "memory",
683
+ actor: "system",
684
+ sessionID: context.sessionID,
685
+ directory: context.directory,
686
+ agent: context.agent,
687
+ reason,
688
+ ref,
689
+ error: indexResult.error,
690
+ })
691
+ }
692
+ return ref
693
+ }
694
+
695
+ function truncateLine(value: string, maxChars = 220): string {
696
+ return value.length <= maxChars ? value : `${value.slice(0, maxChars - 1)}...`
697
+ }
698
+
699
+ function formatEvidenceSummary(events: AkmMemoryEvent[], candidates: ReturnType<typeof readCandidates>, entries: SessionBufferEntry[]): string[] {
700
+ const lines: string[] = []
701
+ const toolRefs = entries.filter((entry) => entry.kind === "tool-ref" && entry.ref)
702
+ const touchedRefs = countByValue(toolRefs.map((entry) => entry.ref!))
703
+ const toolNames = countByValue(toolRefs.map((entry) => entry.toolName ?? "tool"))
704
+ const statuses = countByValue(toolRefs.map((entry) => entry.status ?? "unknown"))
705
+ const eventTypes = countByValue(events.map((event) => event.event))
706
+ const candidateTypes = countByValue(candidates.map((candidate) => candidate.type))
707
+
708
+ lines.push("## Evidence aggregates")
709
+ lines.push(`- buffered observations: ${entries.length}`)
710
+ if (toolRefs.length > 0) lines.push(`- asset-touch observations: ${toolRefs.length}`)
711
+ if (touchedRefs.length > 0) lines.push(`- top refs: ${touchedRefs.slice(0, 5).map(([ref, count]) => `${ref} (${count})`).join(", ")}`)
712
+ if (toolNames.length > 0) lines.push(`- tools involved: ${toolNames.slice(0, 5).map(([name, count]) => `${name} (${count})`).join(", ")}`)
713
+ if (statuses.length > 0) lines.push(`- statuses: ${statuses.map(([status, count]) => `${status} (${count})`).join(", ")}`)
714
+ if (eventTypes.length > 0) lines.push(`- event types: ${eventTypes.slice(0, 6).map(([event, count]) => `${event} (${count})`).join(", ")}`)
715
+ if (candidateTypes.length > 0) lines.push(`- candidate types: ${candidateTypes.map(([type, count]) => `${type} (${count})`).join(", ")}`)
716
+ lines.push("")
717
+
718
+ const notableEvents = uniqueRecent(events, (event) => `${event.timestamp}:${event.event}:${(event.refs ?? []).join(",")}`, 5)
719
+ if (notableEvents.length > 0) {
720
+ lines.push("## Notable recent events")
721
+ for (const event of notableEvents) {
722
+ const status = event.outcome?.status ?? "unknown"
723
+ const refs = Array.isArray(event.refs) && event.refs.length > 0 ? ` refs=${event.refs.join(", ")}` : ""
724
+ const warning = event.outcome?.warnings?.[0] ? ` warning=${truncateLine(event.outcome.warnings[0], 120)}` : ""
725
+ lines.push(`- ${event.timestamp} ${event.event} (${status})${refs}${warning}`)
726
+ }
727
+ lines.push("")
728
+ }
729
+
730
+ const notableCandidates = uniqueRecent(candidates, (candidate) => candidate.id, 5)
731
+ if (notableCandidates.length > 0) {
732
+ lines.push("## Candidate highlights")
733
+ for (const candidate of notableCandidates) {
734
+ const target = candidate.targetRef ? ` target=${candidate.targetRef}` : ""
735
+ lines.push(`- [${candidate.status}] ${candidate.type}/${candidate.scope}${target} :: ${truncateLine(candidate.content, 180)}`)
736
+ }
737
+ lines.push("")
738
+ }
739
+
740
+ return lines
741
+ }
742
+
475
743
  function runCurate(args: string[]): string | null {
476
744
  const result = runCliSyncRaw(args, AKM_CURATE_TIMEOUT_MS)
477
745
  if (!result.ok) return null
@@ -495,7 +763,7 @@ async function runCurateForPrompt(client: LogCapableClient, text: string, sessio
495
763
  return runCurateLogged(client,
496
764
  appendRunScopeArgs(
497
765
  [
498
- "--detail",
766
+ "--shape",
499
767
  "agent",
500
768
  "--format",
501
769
  "text",
@@ -511,21 +779,19 @@ async function runCurateForPrompt(client: LogCapableClient, text: string, sessio
511
779
  )
512
780
  }
513
781
 
514
- async function runCurateForSession(client: LogCapableClient, sessionID: string): Promise<string | null> {
782
+ async function runCurateForSession(client: LogCapableClient, sessionID: string, query?: string): Promise<string | null> {
783
+ const args = [
784
+ "--shape",
785
+ "agent",
786
+ "--format",
787
+ "text",
788
+ "-q",
789
+ "curate",
790
+ ]
791
+ if (query) args.push(query)
792
+ args.push("--limit", String(AKM_CURATE_LIMIT))
515
793
  return runCurateLogged(client,
516
- appendRunScopeArgs(
517
- [
518
- "--detail",
519
- "agent",
520
- "--format",
521
- "text",
522
- "-q",
523
- "curate",
524
- "--limit",
525
- String(AKM_CURATE_LIMIT),
526
- ],
527
- sessionID,
528
- ),
794
+ appendRunScopeArgs(args, sessionID),
529
795
  { toolName: "session.start", sessionID, operation: "session-curate" },
530
796
  )
531
797
  }
@@ -648,11 +914,12 @@ async function getAkmStashDir(client?: LogCapableClient): Promise<string | undef
648
914
 
649
915
  function warmIndexInBackground(): void {
650
916
  const command = resolveAkmCommand()
651
- if (typeof command !== "string") return
917
+ if (typeof command === "object" && "ok" in command) return
652
918
  try {
653
919
  // Fire and forget — execSync with a timeout would block, so spawn via the
654
920
  // shell and detach. Errors here are never surfaced to the session.
655
- execSync(`${JSON.stringify(command)} index >/dev/null 2>&1 &`, { timeout: 2_000 })
921
+ const shellArgs = [command.command, ...command.argsPrefix, "index"].map((part) => JSON.stringify(part)).join(" ")
922
+ execSync(`${shellArgs} >/dev/null 2>&1 &`, { timeout: 2_000 })
656
923
  } catch {
657
924
  // Intentionally ignore — warming is best-effort.
658
925
  }
@@ -724,9 +991,10 @@ async function getPendingProposalCount(client: LogCapableClient, sessionID?: str
724
991
  if (cached && cached.expiresAt > Date.now()) return cached
725
992
 
726
993
  const command = resolveAkmCommand()
727
- if (typeof command !== "string") return { count: 0, unsupported: true }
994
+ if (typeof command === "object" && "ok" in command) return { count: 0, unsupported: true }
728
995
  try {
729
- const stdout = execFileSync(command, ["proposal", "list", "--status", "pending", "--format", "json"], {
996
+ // 0.8.0 canonical proposal-queue listing path: `akm proposal list`.
997
+ const stdout = execResolvedAkm(command, ["proposal", "list", "--status", "pending", "--format", "json"], {
730
998
  encoding: "utf8",
731
999
  timeout: AKM_PENDING_PROPOSAL_TIMEOUT_MS,
732
1000
  })
@@ -763,7 +1031,7 @@ async function recordRetrospectiveFeedback(client: LogCapableClient, sessionID:
763
1031
  }
764
1032
 
765
1033
  const targetRef = recentRefs[recentRefs.length - 1]
766
- const raw = await runCli(client, ["feedback", targetRef, "--negative", "--note", text.slice(0, 280)], {
1034
+ const raw = await runCli(client, ["feedback", targetRef, "--negative", "--note", text.slice(0, 280), ...buildScopedArgs({ sessionID })], {
767
1035
  toolName: "akm_feedback",
768
1036
  sessionID,
769
1037
  })
@@ -780,53 +1048,6 @@ async function recordRetrospectiveFeedback(client: LogCapableClient, sessionID:
780
1048
  retrospectiveState.set(sessionID, { recentRefs })
781
1049
  }
782
1050
 
783
- type RiskyCommandAssessment = {
784
- category: string
785
- reason: string
786
- approval: string
787
- }
788
-
789
- function assessRiskyAkmCommand(command: string): RiskyCommandAssessment | undefined {
790
- const args = splitArguments(command)
791
- const akmIndex = args.findIndex((arg) => arg === "akm" || arg.endsWith("/akm") || arg.endsWith("\\akm.exe"))
792
- if (akmIndex === -1) return undefined
793
- const tokens = args.slice(akmIndex + 1)
794
- if (tokens[0] === "proposal" && tokens[1] === "accept") {
795
- return { category: "proposal-accept", reason: "Proposal acceptance changes curated AKM content.", approval: "Ask the user to approve `akm proposal accept <id>`." }
796
- }
797
- if (tokens[0] === "proposal" && tokens[1] === "reject") {
798
- return { category: "proposal-reject", reason: "Proposal rejection is a durable curation decision.", approval: "Ask the user to approve `akm proposal reject <id> --reason \"...\"`." }
799
- }
800
- if (tokens[0] === "save" && tokens.includes("--push")) {
801
- return { category: "save-push", reason: "Pushing stash changes must be explicitly approved.", approval: "Ask the user to approve `akm save --push`." }
802
- }
803
- if (tokens[0] === "remove") {
804
- return { category: "remove", reason: "Removing AKM sources is destructive.", approval: "Ask the user to approve the exact `akm remove ...` command." }
805
- }
806
- if (tokens[0] === "vault" && ["show", "load", "set", "unset"].includes(tokens[1] ?? "")) {
807
- return { category: `vault-${tokens[1]}`, reason: "Vault access or mutation is sensitive.", approval: `Ask the user to approve the exact \`akm vault ${tokens[1]} ...\` command.` }
808
- }
809
- if (tokens[0] === "config" && tokens[1] === "set" && (tokens[2]?.startsWith("llm.features.") ?? false)) {
810
- return { category: "config-llm-features", reason: "Changing AKM LLM feature flags alters autonomous behavior.", approval: "Ask the user to approve the exact `akm config set llm.features.* ...` command." }
811
- }
812
- if (tokens[0] === "update" && tokens.includes("--all")) {
813
- return { category: "update-all", reason: "Updating all AKM kits changes many assets at once.", approval: "Ask the user to approve `akm update --all`." }
814
- }
815
- if (tokens[0] === "upgrade") {
816
- return { category: "upgrade", reason: "Upgrading the AKM CLI changes the toolchain.", approval: "Ask the user to approve the exact `akm upgrade` command." }
817
- }
818
- return undefined
819
- }
820
-
821
- function blockedCommandMessage(command: string, assessment: RiskyCommandAssessment): string {
822
- return [
823
- `Blocked risky AKM command: ${command}`,
824
- assessment.reason,
825
- assessment.approval,
826
- "Retry only after explicit user approval in this conversation.",
827
- ].join("\n")
828
- }
829
-
830
1051
  function queueFeedback(
831
1052
  client: LogCapableClient,
832
1053
  ref: string,
@@ -840,7 +1061,7 @@ function queueFeedback(
840
1061
  dedupe?.add(dedupeKey)
841
1062
 
842
1063
  const command = resolveAkmCommand()
843
- if (typeof command !== "string") {
1064
+ if (typeof command === "object" && "ok" in command) {
844
1065
  void writePluginLog(client, "warn", "AKM auto-feedback skipped", {
845
1066
  subsystem: "feedback",
846
1067
  toolName: meta.toolName,
@@ -855,8 +1076,9 @@ function queueFeedback(
855
1076
 
856
1077
  try {
857
1078
  const child = spawn(
858
- command,
1079
+ command.command,
859
1080
  [
1081
+ ...command.argsPrefix,
860
1082
  "--format",
861
1083
  "json",
862
1084
  "-q",
@@ -865,6 +1087,13 @@ function queueFeedback(
865
1087
  sentiment === "positive" ? "--positive" : "--negative",
866
1088
  "--note",
867
1089
  note,
1090
+ ...buildScopedArgs({
1091
+ sessionID: meta.sessionID,
1092
+ directory: meta.directory,
1093
+ agent: meta.agent,
1094
+ userID: meta.userID,
1095
+ channel: meta.channel,
1096
+ }),
868
1097
  ],
869
1098
  {
870
1099
  detached: true,
@@ -913,11 +1142,11 @@ function queueFeedback(
913
1142
  }
914
1143
  }
915
1144
 
916
- function rememberTextAsMemory(name: string, body: string): string | null {
1145
+ function rememberTextAsMemory(name: string, body: string, context?: Record<string, unknown>): string | null {
917
1146
  const command = resolveAkmCommand()
918
- if (typeof command !== "string") return null
1147
+ if (typeof command === "object" && "ok" in command) return null
919
1148
  try {
920
- execFileSync(command, ["--format", "json", "-q", "remember", "--name", name, "--force"], {
1149
+ execResolvedAkm(command, ["--format", "json", "-q", "remember", "--name", name, "--force", ...buildScopedArgs(context)], {
921
1150
  encoding: "utf8",
922
1151
  timeout: AKM_CURATE_TIMEOUT_MS * 2,
923
1152
  input: redactSecrets(body).text,
@@ -931,7 +1160,7 @@ function rememberTextAsMemory(name: string, body: string): string | null {
931
1160
  function captureSessionMemory(
932
1161
  sessionID: string,
933
1162
  reason: string,
934
- options?: { checkpoint?: boolean },
1163
+ options?: { checkpoint?: boolean; directory?: string; agent?: string; channel?: string; userID?: string; user?: string },
935
1164
  ): string | null {
936
1165
  if (!AKM_AUTO_MEMORY) return null
937
1166
  if (!sessionID) return null
@@ -945,6 +1174,14 @@ function captureSessionMemory(
945
1174
  return null
946
1175
  }
947
1176
 
1177
+ const relatedEvents = readJsonl<AkmMemoryEvent>(OPENCODE_EVENT_LOG)
1178
+ .filter((event) => event.sessionId === sessionID)
1179
+ .slice(-12)
1180
+ const relatedCandidates = readCandidates(OPENCODE_CANDIDATE_LOG)
1181
+ .filter((candidate) => candidate.sessionId === sessionID)
1182
+ .slice(-8)
1183
+ const harnessPaths = getHarnessStatePaths()
1184
+
948
1185
  const lines: string[] = []
949
1186
  lines.push("---")
950
1187
  lines.push("akm_memory_kind: session_checkpoint")
@@ -957,6 +1194,15 @@ function captureSessionMemory(
957
1194
  lines.push(`Reason: ${reason}`)
958
1195
  lines.push(`Session: ${sessionID}`)
959
1196
  lines.push("")
1197
+ lines.push("## Full-detail evidence files")
1198
+ lines.push(formatPathBullet("OpenCode state dir", harnessPaths.stateDir))
1199
+ lines.push(formatPathBullet("Structured event log", harnessPaths.eventLog))
1200
+ lines.push(formatPathBullet("Memory candidate log", harnessPaths.candidateLog))
1201
+ lines.push(formatPathBullet("OpenCode host log dir", harnessPaths.opencodeLogDir))
1202
+ const harnessLog = process.env.AKM_EVAL_HARNESS_LOG?.trim()
1203
+ if (harnessLog) lines.push(formatPathBullet("Harness log", harnessLog))
1204
+ lines.push("")
1205
+ lines.push(...formatEvidenceSummary(relatedEvents, relatedCandidates, pendingEntries))
960
1206
  for (const entry of pendingEntries) {
961
1207
  if (entry.kind === "memory-intent") {
962
1208
  lines.push(`## ${entry.timestamp} — user memory intent`)
@@ -969,6 +1215,22 @@ function captureSessionMemory(
969
1215
  lines.push("")
970
1216
  }
971
1217
  }
1218
+ if (relatedEvents.length > 0) {
1219
+ lines.push("## Plugin event summary")
1220
+ for (const event of relatedEvents) {
1221
+ const status = event.outcome?.status ?? "unknown"
1222
+ const refs = Array.isArray(event.refs) && event.refs.length > 0 ? ` — refs: ${event.refs.join(", ")}` : ""
1223
+ lines.push(`- ${event.timestamp} — ${event.event} (${status})${refs}`)
1224
+ }
1225
+ lines.push("")
1226
+ }
1227
+ if (relatedCandidates.length > 0) {
1228
+ lines.push("## Memory candidates observed")
1229
+ for (const candidate of relatedCandidates) {
1230
+ lines.push(`- [${candidate.status}] ${candidate.type}/${candidate.scope}: ${candidate.content}`)
1231
+ }
1232
+ lines.push("")
1233
+ }
972
1234
  const body = lines.join("\n")
973
1235
 
974
1236
  const dateTag = buildDateTag({ includeTime: isCheckpoint })
@@ -977,7 +1239,15 @@ function captureSessionMemory(
977
1239
  ? `opencode-checkpoint-${dateTag}-${shortSid}`
978
1240
  : `opencode-session-${dateTag}-${shortSid}`
979
1241
 
980
- const ref = rememberTextAsMemory(name, body)
1242
+ const ref = rememberTextAsMemory(name, body, {
1243
+ sessionID,
1244
+ run: sessionID,
1245
+ directory: options?.directory,
1246
+ agent: options?.agent,
1247
+ channel: options?.channel,
1248
+ userID: options?.userID,
1249
+ user: options?.user,
1250
+ })
981
1251
  if (!ref) {
982
1252
  if (!isCheckpoint) {
983
1253
  sessionFinalMemoryCaptured.add(sessionID)
@@ -995,7 +1265,15 @@ function captureSessionMemory(
995
1265
  refs: [ref],
996
1266
  outcome: { status: "ok" },
997
1267
  })
998
- const candidates = extractCandidatesFromText({ harness: "opencode", sessionId: sessionID, text: body, evidence: [ref, reason] })
1268
+ const targetRefHints = pendingEntries.flatMap((entry) => entry.ref ? [entry.ref] : [])
1269
+ const candidates = extractCandidatesFromText({
1270
+ harness: "opencode",
1271
+ sessionId: sessionID,
1272
+ text: body,
1273
+ evidence: [ref, reason, ...targetRefHints],
1274
+ sourcePaths: [harnessPaths.eventLog, harnessPaths.candidateLog],
1275
+ targetRefHints,
1276
+ })
999
1277
  if (candidates.length > 0) {
1000
1278
  appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
1001
1279
  void writeStructuredEvent({
@@ -1025,7 +1303,15 @@ function captureSessionMemory(
1025
1303
  refs: [ref],
1026
1304
  outcome: { status: "ok" },
1027
1305
  })
1028
- const candidates = extractCandidatesFromText({ harness: "opencode", sessionId: sessionID, text: body, evidence: [ref, reason] })
1306
+ const targetRefHints = entries.flatMap((entry) => entry.ref ? [entry.ref] : [])
1307
+ const candidates = extractCandidatesFromText({
1308
+ harness: "opencode",
1309
+ sessionId: sessionID,
1310
+ text: body,
1311
+ evidence: [ref, reason, ...targetRefHints],
1312
+ sourcePaths: [harnessPaths.eventLog, harnessPaths.candidateLog],
1313
+ targetRefHints,
1314
+ })
1029
1315
  if (candidates.length > 0) {
1030
1316
  appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
1031
1317
  void writeStructuredEvent({
@@ -1040,10 +1326,13 @@ function captureSessionMemory(
1040
1326
  return ref
1041
1327
  }
1042
1328
 
1043
- function maybeCheckpointSessionMemory(sessionID: string): string | null {
1329
+ function maybeCheckpointSessionMemory(
1330
+ sessionID: string,
1331
+ options?: { directory?: string; agent?: string; channel?: string; userID?: string; user?: string },
1332
+ ): string | null {
1044
1333
  const count = sessionSuccessfulAssetTouchCount.get(sessionID) ?? 0
1045
1334
  if (count < AKM_MEMORY_CHECKPOINT_EVERY) return null
1046
- const captured = captureSessionMemory(sessionID, "checkpoint", { checkpoint: true })
1335
+ const captured = captureSessionMemory(sessionID, "checkpoint", { checkpoint: true, ...options })
1047
1336
  if (!captured) {
1048
1337
  sessionSuccessfulAssetTouchCount.set(sessionID, 0)
1049
1338
  }
@@ -1132,10 +1421,33 @@ function extractToolRefs(
1132
1421
  return { refs: [...refs], positiveOnlyRefs: [...positiveOnlyRefs] }
1133
1422
  }
1134
1423
 
1424
+ function extractAkmRefsFromAllArgs(args: Record<string, unknown>): string[] {
1425
+ if (!args || typeof args !== "object") return []
1426
+ const refs = new Set<string>()
1427
+ for (const value of Object.values(args)) {
1428
+ if (typeof value === "string") {
1429
+ for (const ref of extractAkmRefsFromString(value)) refs.add(ref)
1430
+ } else if (typeof value === "object" && value !== null) {
1431
+ const serialized = JSON.stringify(value)
1432
+ for (const ref of extractAkmRefsFromString(serialized)) refs.add(ref)
1433
+ }
1434
+ }
1435
+ return [...refs]
1436
+ }
1437
+
1135
1438
  const AKM_HINTS_PREFIX = [
1136
1439
  "# AKM is available in this session",
1137
1440
  "",
1138
- "You have an AKM stash on this machine. Before writing anything from scratch, call `akm_search` or `akm_curate` to see if the stash already covers it. Record `akm_feedback <ref> positive|negative` whenever an asset materially helps or misses, and use `akm_remember` to persist durable learnings so future sessions inherit them.",
1441
+ "You have an AKM stash on this machine. Before writing anything from scratch, call `akm_curate` with a task description to find relevant assets with LLM-reranked relevance scores.",
1442
+ "",
1443
+ "**Choosing the right lookup command:**",
1444
+ "",
1445
+ "- **`akm_curate`** — use this when starting any new task, looking for patterns, docs, skills, or workflows. This is the PRIMARY lookup command. v0.8.0 automatically boosts assets that match the current project (cwd-anchored project-context ranking), so an explicit project name in the query is no longer required for ranking — but it still helps the reranker frame intent.",
1446
+ ' - Good: `akm_curate("akm CLI improve command performance analysis")` (explicit framing, still ideal)',
1447
+ ' - Bad: `akm_curate("improve performance analysis")` (too generic — the reranker has less to work with even with auto-boost)',
1448
+ "- **`akm_search` (known name)** — use ONLY when you already know an asset exists (e.g. after `akm_show` returned \"not found\") and need to locate its exact ref. Do not use as a discovery tool.",
1449
+ "",
1450
+ "Record `akm_feedback <ref> positive|negative` whenever an asset materially helps or misses, and use `akm_remember` to persist durable learnings so future sessions inherit them.",
1139
1451
  "",
1140
1452
  AKM_WORKFLOW_INSTRUCTION,
1141
1453
  ].join("\n")
@@ -1196,15 +1508,22 @@ const AKM_HELP_QUICK_REFERENCE: readonly AkmHelpEntry[] = [
1196
1508
  keywords: ["proposal", "review proposals", "pending proposals", "accept proposal", "reject proposal"],
1197
1509
  },
1198
1510
  {
1199
- task: "Distill repeated evidence into a proposed lesson",
1200
- command: "akm distill <ref>",
1201
- notes: "Distill creates a proposal; proposed assets are not curated until accepted.",
1202
- keywords: ["distill", "lesson", "proposed lesson"],
1511
+ task: "Bulk-triage the standing pending proposal backlog by policy",
1512
+ command: "akm proposal drain --policy <personal-stash|conservative|manual> --dry-run",
1513
+ notes: "Mutating: promotes/rejects in bulk and commits to git (no batch revert). Preview with --dry-run, then --promote --yes after explicit approval. Supersedes the old manual proposal-management agent session; also runs as the processes.triage improve pre-pass.",
1514
+ keywords: ["proposal", "drain", "triage", "backlog", "bulk accept", "bulk reject"],
1203
1515
  },
1204
1516
  {
1205
- task: "Reflect on an existing asset after failure or drift",
1206
- command: "akm reflect <ref> --task \"...\"",
1207
- keywords: ["reflect", "drift", "failure"],
1517
+ task: "Improve existing assets or distill repeated evidence into proposals",
1518
+ command: "akm improve [<type>|<ref>] [--task \"...\"]",
1519
+ notes: "Improve owns the former reflect/distill flow; proposed assets are not curated until accepted. Profiles add a processes.triage pre-pass and end-of-run sync.",
1520
+ keywords: ["improve", "lesson", "reflect", "distill", "drift", "failure"],
1521
+ },
1522
+ {
1523
+ task: "Manage scheduled task assets via the OS scheduler",
1524
+ command: "akm tasks <add|list|show|remove|enable|disable|run|history|sync|doctor> ...",
1525
+ notes: "Tasks are first-class in v0.8.0 but remain a long-tail CLI surface in this plugin.",
1526
+ keywords: ["tasks", "scheduled task", "cron", "launchd", "schtasks"],
1208
1527
  },
1209
1528
  {
1210
1529
  task: "Create a proposed asset for a coverage gap",
@@ -1220,15 +1539,15 @@ const AKM_HELP_QUICK_REFERENCE: readonly AkmHelpEntry[] = [
1220
1539
  },
1221
1540
  {
1222
1541
  task: "Install a kit or register an external source (npm, GitHub, git, URL, local dir)",
1223
- command: "akm add <package-ref> [--name <n>] [--type wiki] [--writable] [--trust] [--provider <p>] [--max-pages N] [--max-depth N]",
1224
- notes: "Confirm with the user before passing --trust or registering a website crawler.",
1542
+ command: "akm add <package-ref> [--name <n>] [--type wiki] [--writable] [--provider <p>] [--max-pages N] [--max-depth N] [--allow-insecure]",
1543
+ notes: "Confirm with the user before registering a website crawler or passing --allow-insecure.",
1225
1544
  keywords: ["add", "install", "register", "kit", "source", "github", "npm"],
1226
1545
  },
1227
1546
  {
1228
- task: "Commit (and optionally push) pending stash changes",
1229
- command: "akm save [<source-name>] [-m <msg>] [--push]",
1230
- notes: "Add --push only when the stash is writable; review the diff first.",
1231
- keywords: ["save", "commit", "push", "publish", "git"],
1547
+ task: "Commit and push pending stash changes",
1548
+ command: "akm sync [<source-name>] [-m <msg>] [--no-push]",
1549
+ notes: "For writable git-backed sources, sync commits and pushes by default (pass --no-push to skip); review the diff first.",
1550
+ keywords: ["sync", "save", "commit", "push", "publish", "git"],
1232
1551
  },
1233
1552
  {
1234
1553
  task: "Import a file (or stdin) into the stash as a typed asset",
@@ -1270,6 +1589,12 @@ const AKM_HELP_QUICK_REFERENCE: readonly AkmHelpEntry[] = [
1270
1589
  notes: "Rarely needed — the index refreshes implicitly after writes.",
1271
1590
  keywords: ["index", "reindex", "rebuild"],
1272
1591
  },
1592
+ {
1593
+ task: "Manage whole-file secrets outside the chat-safe read surface",
1594
+ command: "akm secret <set|run|remove> ...",
1595
+ notes: "Use the first-class akm_secret tool for list/path. Secret values must never be pasted back into chat; `set` reads from stdin/--from-file/--from-env and `run` injects into a child process only.",
1596
+ keywords: ["secret", "docker secret", "pem", "token", "_FILE"],
1597
+ },
1273
1598
  {
1274
1599
  task: "View or update akm config (get/set/list/unset/path)",
1275
1600
  command: "akm config <action> [<key>] [<value>] [--all]",
@@ -1319,21 +1644,6 @@ function extractSessionIdFromEvent(payload: unknown): string | undefined {
1319
1644
  return undefined
1320
1645
  }
1321
1646
 
1322
- function getCommandStatus(command: string): "ok" | "missing" | "error" {
1323
- try {
1324
- execFileSync(command, ["--version"], {
1325
- encoding: "utf8",
1326
- timeout: 10_000,
1327
- })
1328
- return "ok"
1329
- } catch (error: unknown) {
1330
- if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") {
1331
- return "missing"
1332
- }
1333
- return "error"
1334
- }
1335
- }
1336
-
1337
1647
  function extractFirstSemverMatch(value: string): string | null {
1338
1648
  return value.match(SEMVER_PATTERN)?.[0] ?? null
1339
1649
  }
@@ -1350,208 +1660,268 @@ function getCommandVersion(command: string): string | null {
1350
1660
  }
1351
1661
  }
1352
1662
 
1353
- function parseSemver(version: string): ParsedSemver | null {
1354
- const normalized = extractFirstSemverMatch(version)
1355
- if (!normalized) return null
1663
+ type ResolvedAkmCommand = {
1664
+ command: string
1665
+ argsPrefix: string[]
1666
+ displayCommand: string
1667
+ }
1356
1668
 
1357
- const [withoutBuildMetadata] = normalized.split("+", 1)
1358
- const [release, prereleaseText] = withoutBuildMetadata.split("-", 2)
1359
- const parts = release.split(".").map((part) => Number(part))
1360
- if (parts.length !== 3 || parts.some((part) => !Number.isInteger(part) || part < 0)) {
1361
- return null
1362
- }
1363
- const core = [parts[0], parts[1], parts[2]] as [number, number, number]
1669
+ type CommandProbe = {
1670
+ command: string
1671
+ argsPrefix: string[]
1672
+ displayCommand: string
1673
+ exists: boolean
1674
+ version: string | null
1675
+ failureReason: string | null
1676
+ }
1364
1677
 
1678
+ // Like getCommandVersion, but distinguishes "binary not found on disk" from
1679
+ // "binary found but failed to produce a version" — the latter typically means
1680
+ // the binary is corrupt, wrong architecture, or has a runtime dependency missing.
1681
+ // Used by the diagnostic resolution trail so the consent banner can tell users
1682
+ // which failure mode they're hitting.
1683
+ function getLocalBuildAkmCommand(): ResolvedAkmCommand | null {
1684
+ const cliPath = process.env.AKM_LOCAL_BUILD_CLI?.trim()
1685
+ if (!cliPath) return null
1365
1686
  return {
1366
- core,
1367
- prerelease: prereleaseText
1368
- ? prereleaseText.split(".").map((part) => (/^\d+$/.test(part) ? Number(part) : part))
1369
- : null,
1687
+ command: process.env.BUN || "bun",
1688
+ argsPrefix: [cliPath],
1689
+ displayCommand: cliPath,
1370
1690
  }
1371
1691
  }
1372
1692
 
1373
- function compareSemver(left: string, right: string): number {
1374
- const leftParsed = parseSemver(left)
1375
- const rightParsed = parseSemver(right)
1376
- if (!leftParsed || !rightParsed) return left.localeCompare(right)
1693
+ function execResolvedAkm(command: ResolvedAkmCommand, args: string[], options?: Parameters<typeof execFileSync>[2]) {
1694
+ return execFileSync(command.command, [...command.argsPrefix, ...args], options)
1695
+ }
1377
1696
 
1378
- for (let index = 0; index < leftParsed.core.length; index += 1) {
1379
- const delta = leftParsed.core[index] - rightParsed.core[index]
1380
- if (delta !== 0) return delta
1697
+ function probeCommand(command: ResolvedAkmCommand): CommandProbe {
1698
+ const displayIsAbsolute = path.isAbsolute(command.displayCommand)
1699
+ const displayExists = displayIsAbsolute ? existsSync(command.displayCommand) : true
1700
+ if (displayIsAbsolute && !displayExists) {
1701
+ return { ...command, exists: false, version: null, failureReason: "not_on_disk" }
1381
1702
  }
1382
-
1383
- if (!leftParsed.prerelease && !rightParsed.prerelease) return 0
1384
- if (!leftParsed.prerelease) return 1
1385
- if (!rightParsed.prerelease) return -1
1386
-
1387
- const length = Math.max(leftParsed.prerelease.length, rightParsed.prerelease.length)
1388
- for (let index = 0; index < length; index += 1) {
1389
- const leftPart = leftParsed.prerelease[index]
1390
- const rightPart = rightParsed.prerelease[index]
1391
- if (leftPart === undefined) return -1
1392
- if (rightPart === undefined) return 1
1393
- if (leftPart === rightPart) continue
1394
- if (typeof leftPart === "number" && typeof rightPart === "number") return leftPart - rightPart
1395
- if (typeof leftPart === "number") return -1
1396
- if (typeof rightPart === "number") return 1
1397
- return leftPart.localeCompare(rightPart)
1703
+ const commandIsAbsolute = path.isAbsolute(command.command)
1704
+ const commandExists = commandIsAbsolute ? existsSync(command.command) : true
1705
+ if (commandIsAbsolute && !commandExists) {
1706
+ return { ...command, exists: false, version: null, failureReason: "command_not_on_disk" }
1707
+ }
1708
+ try {
1709
+ const version = execResolvedAkm(command, ["--version"], {
1710
+ encoding: "utf8",
1711
+ timeout: 10_000,
1712
+ })
1713
+ const parsed = extractFirstSemverMatch(version)
1714
+ if (!parsed) {
1715
+ return { ...command, exists: true, version: null, failureReason: "no_semver_in_output" }
1716
+ }
1717
+ return { ...command, exists: true, version: parsed, failureReason: null }
1718
+ } catch (err) {
1719
+ const code = (err as NodeJS.ErrnoException)?.code
1720
+ if (code === "ENOENT") {
1721
+ return { ...command, exists: false, version: null, failureReason: "not_on_path" }
1722
+ }
1723
+ return { ...command, exists: true, version: null, failureReason: code ?? "version_check_failed" }
1398
1724
  }
1725
+ }
1399
1726
 
1400
- return 0
1727
+ function satisfiesAkmVersionRange(version: string | null): boolean {
1728
+ if (typeof version !== "string") return false
1729
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/)
1730
+ if (!match) return false
1731
+ return Number(match[1]) === 0 && Number(match[2]) === 8
1401
1732
  }
1402
1733
 
1403
- function getBunGlobalAkmCommand(): string | null {
1734
+ function getBundledAkmCommand(): string | null {
1735
+ const packagePath = path.join(moduleDir, "node_modules", "akm-cli", "package.json")
1736
+ const fallback = path.join(moduleDir, "node_modules", ".bin", process.platform === "win32" ? "akm.cmd" : "akm")
1404
1737
  try {
1405
- const globalBin = execFileSync("bun", ["pm", "bin", "-g"], {
1406
- encoding: "utf8",
1407
- timeout: 10_000,
1408
- }).trim()
1409
- if (!globalBin || !path.isAbsolute(globalBin)) return null
1410
- return process.platform === "win32"
1411
- ? path.join(globalBin, "akm.exe")
1412
- : path.join(globalBin, "akm")
1738
+ const raw = readFileSync(packagePath, "utf8")
1739
+ const pkg = JSON.parse(raw) as { bin?: unknown }
1740
+ const bin = pkg.bin
1741
+ const relativeBin = typeof bin === "string"
1742
+ ? bin
1743
+ : bin && typeof bin === "object" && typeof (bin as Record<string, unknown>).akm === "string"
1744
+ ? (bin as Record<string, string>).akm
1745
+ : null
1746
+ if (!relativeBin) return existsSync(fallback) ? fallback : null
1747
+ const bundledCommand = path.join(moduleDir, "node_modules", "akm-cli", relativeBin)
1748
+ return existsSync(bundledCommand) ? bundledCommand : existsSync(fallback) ? fallback : null
1413
1749
  } catch {
1414
- return null
1750
+ return existsSync(fallback) ? fallback : null
1415
1751
  }
1416
1752
  }
1417
1753
 
1418
- function getInstalledAkmDetails(): { command: string; version: string } | null {
1419
- const candidates = [resolvedAkmCommand, getBunGlobalAkmCommand(), "akm"]
1420
- const seen = new Set<string>()
1421
- for (const candidate of candidates) {
1422
- if (!candidate || seen.has(candidate)) continue
1423
- seen.add(candidate)
1424
- const version = getCommandVersion(candidate)
1425
- if (version) return { command: candidate, version }
1754
+ function getConfigNodeModulesAkmCommand(): string | null {
1755
+ const homeDir = process.env.HOME || os.homedir()
1756
+ const configDir = process.env.XDG_CONFIG_HOME || path.join(homeDir, ".config")
1757
+ const configBin = path.join(configDir, "opencode", "node_modules", ".bin", process.platform === "win32" ? "akm.cmd" : "akm")
1758
+ return existsSync(configBin) ? configBin : null
1759
+ }
1760
+
1761
+ function getPathAkmCandidates(): string[] {
1762
+ const candidates: string[] = []
1763
+ const configNodeModules = getConfigNodeModulesAkmCommand()
1764
+ if (configNodeModules) candidates.push(configNodeModules)
1765
+ candidates.push("akm")
1766
+ // Honor an explicit $HOME override (standard POSIX, and matches
1767
+ // getConfigNodeModulesAkmCommand). os.homedir() snapshots HOME at process
1768
+ // start and ignores later changes, which made this path non-sandboxable.
1769
+ const home = process.env.HOME || os.homedir()
1770
+ if (home) {
1771
+ candidates.push(path.join(home, ".local", "bin", process.platform === "win32" ? "akm.cmd" : "akm"))
1426
1772
  }
1427
- return null
1773
+ return candidates
1428
1774
  }
1429
1775
 
1430
- async function getLatestNpmPackageVersion(packageName: string): Promise<string | null> {
1431
- if (typeof fetch !== "function") return null
1776
+ type AkmResolutionTrail = Array<{
1777
+ command: string
1778
+ source: "bundled" | "path" | "local_build"
1779
+ version: string | null
1780
+ outcome: "selected" | "version_out_of_range" | "missing" | "probe_failed"
1781
+ failureReason: string | null
1782
+ }>
1783
+
1784
+ let lastAkmResolutionTrail: AkmResolutionTrail = []
1785
+
1786
+ function getResolvedAkmDetails(): { command: string; argsPrefix: string[]; displayCommand: string; version: string; source: "bundled" | "path" | "local_build" } | null {
1787
+ const candidates: Array<{ command: string; argsPrefix: string[]; displayCommand: string; source: "bundled" | "path" | "local_build" }> = []
1788
+ // Opt-out (default off): force the plugin to ignore its bundled akm-cli and
1789
+ // fall through to AKM_LOCAL_BUILD_CLI / PATH. Used by the eval harness so a
1790
+ // deterministic fake `akm` on PATH wins over the real bundled dependency;
1791
+ // also a useful escape hatch when a bundled dep is broken.
1792
+ const ignoreBundled = process.env.AKM_OPENCODE_IGNORE_BUNDLED_CLI === "1"
1793
+ const bundled = ignoreBundled ? null : getBundledAkmCommand()
1794
+ if (bundled) candidates.push({ command: bundled, argsPrefix: [], displayCommand: bundled, source: "bundled" })
1795
+ const localBuild = getLocalBuildAkmCommand()
1796
+ if (localBuild) candidates.push({ ...localBuild, source: "local_build" })
1797
+ for (const command of getPathAkmCandidates()) {
1798
+ candidates.push({ command, argsPrefix: [], displayCommand: command, source: "path" })
1799
+ }
1432
1800
 
1433
- const controller = new AbortController()
1434
- const timeout = setTimeout(() => controller.abort(), 10_000)
1435
- try {
1436
- const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`, {
1437
- headers: { accept: "application/json" },
1438
- signal: controller.signal,
1439
- })
1440
- if (!response.ok) return null
1441
- const body = await response.json()
1442
- if (!body || typeof body !== "object") return null
1443
- return typeof body.version === "string" ? extractFirstSemverMatch(body.version) : null
1444
- } catch {
1445
- return null
1446
- } finally {
1447
- clearTimeout(timeout)
1801
+ const trail: AkmResolutionTrail = []
1802
+ const seen = new Set<string>()
1803
+ for (const candidate of candidates) {
1804
+ const cacheKey = `${candidate.command}::${candidate.argsPrefix.join(" ")}`
1805
+ if (!candidate.command || seen.has(cacheKey)) continue
1806
+ seen.add(cacheKey)
1807
+ const probe = probeCommand(candidate)
1808
+ if (!probe.exists) {
1809
+ trail.push({ command: candidate.displayCommand, source: candidate.source, version: null, outcome: "missing", failureReason: probe.failureReason })
1810
+ continue
1811
+ }
1812
+ if (probe.failureReason || !probe.version) {
1813
+ trail.push({ command: candidate.displayCommand, source: candidate.source, version: probe.version, outcome: "probe_failed", failureReason: probe.failureReason })
1814
+ continue
1815
+ }
1816
+ if (!satisfiesAkmVersionRange(probe.version)) {
1817
+ trail.push({ command: candidate.displayCommand, source: candidate.source, version: probe.version, outcome: "version_out_of_range", failureReason: null })
1818
+ continue
1819
+ }
1820
+ trail.push({ command: candidate.displayCommand, source: candidate.source, version: probe.version, outcome: "selected", failureReason: null })
1821
+ lastAkmResolutionTrail = trail
1822
+ return { ...candidate, version: probe.version }
1448
1823
  }
1824
+ lastAkmResolutionTrail = trail
1825
+ return null
1449
1826
  }
1450
1827
 
1451
- async function ensureLatestAkmInstalled(client: LogCapableClient): Promise<void> {
1452
- try {
1453
- execFileSync("bun", ["--version"], {
1454
- encoding: "utf8",
1455
- timeout: 10_000,
1456
- })
1457
- } catch (error: unknown) {
1458
- await writePluginLog(client, "warn", "AKM auto-install skipped", {
1828
+ // The OpenCode plugin has never silently auto-installed akm-cli — it relies on
1829
+ // the bundled binary that ships with the plugin, falling back to PATH. When
1830
+ // neither path produces a compatible akm we log a warn-level event to the host
1831
+ // AND write a stderr banner so the human running OpenCode actually sees the
1832
+ // problem. The banner mirrors the Claude plugin's wording: install must be
1833
+ // user-driven, never automatic. The recommended consent point is `akm setup`
1834
+ // (or the host-specific akm setup slash command if one exists).
1835
+ async function ensureSupportedAkmResolved(client: LogCapableClient): Promise<void> {
1836
+ const installedAkm = getResolvedAkmDetails()
1837
+ if (!installedAkm) {
1838
+ await writePluginLog(client, "warn", "AKM CLI resolution failed", {
1459
1839
  subsystem: "akm",
1460
- installer: "bun",
1461
- error: `Bun is not available: ${formatCliError(error)}`,
1840
+ requiredRange: AKM_REQUIRED_VERSION_RANGE,
1841
+ bundledCommand: getBundledAkmCommand(),
1842
+ pathCommand: "akm",
1843
+ reason: "no_supported_command",
1844
+ trail: lastAkmResolutionTrail,
1845
+ })
1846
+ writeAkmConsentBanner({
1847
+ detected: getCommandVersion("akm") ?? undefined,
1848
+ bundled: getBundledAkmCommand(),
1849
+ trail: lastAkmResolutionTrail,
1462
1850
  })
1463
1851
  return
1464
1852
  }
1465
1853
 
1466
- const installedAkm = getInstalledAkmDetails()
1467
- const latestStable = await getLatestNpmPackageVersion("akm-cli")
1468
-
1469
- if (installedAkm) {
1470
- if (!latestStable) {
1471
- resolvedAkmCommand = installedAkm.command
1472
- await writePluginLog(client, "info", "AKM auto-install skipped", {
1473
- subsystem: "akm",
1474
- installer: "bun",
1475
- package: autoInstallPackageRef,
1476
- command: resolvedAkmCommand,
1477
- installedVersion: installedAkm.version,
1478
- latestStable,
1479
- reason: "latest_version_unavailable",
1480
- })
1481
- return
1482
- }
1854
+ resolvedAkmCommand = installedAkm.command
1855
+ await writePluginLog(client, "info", "AKM CLI resolved", {
1856
+ subsystem: "akm",
1857
+ command: installedAkm.command,
1858
+ source: installedAkm.source,
1859
+ version: installedAkm.version,
1860
+ requiredRange: AKM_REQUIRED_VERSION_RANGE,
1861
+ })
1862
+ }
1483
1863
 
1484
- if (compareSemver(installedAkm.version, latestStable) >= 0) {
1485
- resolvedAkmCommand = installedAkm.command
1486
- await writePluginLog(client, "info", "AKM auto-install skipped", {
1487
- subsystem: "akm",
1488
- installer: "bun",
1489
- package: autoInstallPackageRef,
1490
- command: resolvedAkmCommand,
1491
- installedVersion: installedAkm.version,
1492
- latestStable,
1493
- reason: "installed_version_not_older",
1494
- })
1495
- return
1864
+ function writeAkmConsentBanner(info: { detected?: string; bundled?: string | null; trail?: AkmResolutionTrail }) {
1865
+ const detectedLabel = info.detected ?? "(not found on PATH)"
1866
+ const bundledLabel = info.bundled ?? "(none)"
1867
+ const trailLines: string[] = []
1868
+ if (info.trail && info.trail.length > 0) {
1869
+ trailLines.push("", " resolution trail (in order tried):")
1870
+ for (const entry of info.trail) {
1871
+ const versionLabel = entry.version ?? "no version"
1872
+ const reason = entry.failureReason ? ` reason=${entry.failureReason}` : ""
1873
+ trailLines.push(` [${entry.source}] ${entry.command} → ${entry.outcome} (${versionLabel})${reason}`)
1496
1874
  }
1497
1875
  }
1498
-
1876
+ const banner = [
1877
+ "─".repeat(60),
1878
+ "akm-opencode plugin: akm CLI not installed or wrong version",
1879
+ ` detected on PATH: ${detectedLabel}`,
1880
+ ` bundled fallback: ${bundledLabel}`,
1881
+ ` required: ${AKM_REQUIRED_VERSION_RANGE}`,
1882
+ ...trailLines,
1883
+ "",
1884
+ "Reinstall or update the akm-opencode plugin so OpenCode/Bun",
1885
+ "installs the dependency, or install akm-cli manually:",
1886
+ " bun install -g akm-cli@^0.8.0",
1887
+ " npm install -g akm-cli@^0.8.0",
1888
+ "Then run `akm setup` interactively to configure the stash.",
1889
+ "─".repeat(60),
1890
+ ].join("\n")
1499
1891
  try {
1500
- execFileSync("bun", ["install", "-g", autoInstallPackageRef], {
1501
- encoding: "utf8",
1502
- timeout: 120_000,
1503
- stdio: "pipe",
1504
- })
1505
-
1506
- const bunGlobalAkm = getBunGlobalAkmCommand()
1507
- if (bunGlobalAkm && getCommandStatus(bunGlobalAkm) === "ok") {
1508
- resolvedAkmCommand = bunGlobalAkm
1509
- } else if (getCommandStatus("akm") === "ok") {
1510
- resolvedAkmCommand = "akm"
1511
- }
1512
-
1513
- await writePluginLog(client, "info", "AKM CLI install check completed", {
1514
- subsystem: "akm",
1515
- installer: "bun",
1516
- package: autoInstallPackageRef,
1517
- command: resolvedAkmCommand,
1518
- installedVersion: installedAkm?.version ?? null,
1519
- latestStable,
1520
- })
1521
- } catch (error: unknown) {
1522
- await writePluginLog(client, "warn", "AKM auto-install failed", {
1523
- subsystem: "akm",
1524
- installer: "bun",
1525
- package: autoInstallPackageRef,
1526
- error: formatCliError(error),
1527
- })
1892
+ process.stderr.write(banner + "\n")
1893
+ } catch {
1894
+ // best-effort; never crash the plugin over a banner
1528
1895
  }
1529
1896
  }
1530
1897
 
1531
- function resolveAkmCommand(): string | CliError {
1532
- const currentStatus = getCommandStatus(resolvedAkmCommand)
1533
- if (currentStatus === "ok" || currentStatus === "error") return resolvedAkmCommand
1898
+ function resolveAkmCommand(): ResolvedAkmCommand | CliError {
1899
+ const localBuild = getLocalBuildAkmCommand()
1900
+ if (localBuild) {
1901
+ const probe = probeCommand(localBuild)
1902
+ if (probe.exists && satisfiesAkmVersionRange(probe.version)) return localBuild
1903
+ }
1534
1904
 
1535
- const bunGlobalAkm = getBunGlobalAkmCommand()
1536
- if (bunGlobalAkm && getCommandStatus(bunGlobalAkm) === "ok") {
1537
- resolvedAkmCommand = bunGlobalAkm
1538
- return resolvedAkmCommand
1905
+ const currentVersion = getCommandVersion(resolvedAkmCommand)
1906
+ if (satisfiesAkmVersionRange(currentVersion)) {
1907
+ return { command: resolvedAkmCommand, argsPrefix: [], displayCommand: resolvedAkmCommand }
1539
1908
  }
1540
1909
 
1541
- if (getCommandStatus("akm") === "ok") {
1542
- resolvedAkmCommand = "akm"
1543
- return resolvedAkmCommand
1910
+ const installedAkm = getResolvedAkmDetails()
1911
+ if (installedAkm) {
1912
+ resolvedAkmCommand = installedAkm.command
1913
+ return { command: installedAkm.command, argsPrefix: installedAkm.argsPrefix, displayCommand: installedAkm.displayCommand }
1544
1914
  }
1545
1915
 
1546
1916
  return {
1547
1917
  ok: false,
1548
- error: `The 'akm' CLI could not be resolved after attempting to install '${autoInstallPackageRef}' with Bun. Install akm from https://github.com/itlackey/akm.`,
1918
+ error: `AKM CLI ${AKM_REQUIRED_VERSION_RANGE} is required, but no compatible bundled or PATH 'akm' executable was found. Reinstall or update the akm-opencode plugin so OpenCode/Bun installs the dependency.`,
1549
1919
  }
1550
1920
  }
1551
1921
 
1552
1922
  async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta): Promise<string> {
1553
1923
  const command = resolveAkmCommand()
1554
- if (typeof command !== "string") {
1924
+ if (typeof command === "object" && "ok" in command) {
1555
1925
  await writePluginLog(client, "error", "AKM command resolution failed", {
1556
1926
  subsystem: "akm",
1557
1927
  toolName: meta.toolName,
@@ -1566,7 +1936,9 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
1566
1936
  return JSON.stringify(command)
1567
1937
  }
1568
1938
 
1569
- const fullArgs = args.includes("--format") ? [...args] : [...args, "--format", "json"]
1939
+ // `akm improve` hard-rejects --format in 0.8.0 (cli.ts:4131-4137); never auto-inject it there.
1940
+ const skipFormatInjection = args[0] === "improve"
1941
+ const fullArgs = skipFormatInjection || args.includes("--format") ? [...args] : [...args, "--format", "json"]
1570
1942
  const proposalId = args[0] === "proposal" && typeof args[2] === "string" ? args[2] : null
1571
1943
 
1572
1944
  const recordSuccess = async (stdout: string): Promise<string> => {
@@ -1625,8 +1997,8 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
1625
1997
  directory: meta.directory,
1626
1998
  })
1627
1999
  }
1628
- if (args[0] === "proposal" && ["accept", "reject"].includes(args[1] ?? "")) {
1629
- await emitWorkflowTelemetry(client, "info", args[1] === "accept" ? "akm.proposal.accept.requested" : "akm.proposal.reject.requested", {
2000
+ if (args[0] === "proposal" && ["accept", "reject", "drain"].includes(args[1] ?? "")) {
2001
+ await emitWorkflowTelemetry(client, "info", args[1] === "accept" ? "akm.proposal.accept.requested" : args[1] === "drain" ? "akm.proposal.drain.requested" : "akm.proposal.reject.requested", {
1630
2002
  sessionID: meta.sessionID,
1631
2003
  toolName: meta.toolName,
1632
2004
  proposalId,
@@ -1638,14 +2010,14 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
1638
2010
  }
1639
2011
 
1640
2012
  try {
1641
- const stdout = execFileSync(command, fullArgs, {
1642
- encoding: "utf8",
1643
- timeout: 60_000,
1644
- })
2013
+ const stdout = execResolvedAkm(command, fullArgs, {
2014
+ encoding: "utf8",
2015
+ timeout: 60_000,
2016
+ })
1645
2017
  return recordSuccess(stdout)
1646
2018
  } catch (error: unknown) {
1647
2019
  let message = formatCliError(error)
1648
- if (["akm_reflect", "akm_propose"].includes(meta.toolName) && needsAgentSetup(message)) {
2020
+ if (["akm_improve", "akm_propose"].includes(meta.toolName) && needsAgentSetup(message)) {
1649
2021
  message = addAgentSetupGuidance(message)
1650
2022
  }
1651
2023
  message = addAgentSetupGuidance(message)
@@ -1654,8 +2026,8 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
1654
2026
  toolName: meta.toolName,
1655
2027
  sessionID: meta.sessionID,
1656
2028
  directory: meta.directory,
1657
- command,
1658
- args: fullArgs,
2029
+ command: command.displayCommand,
2030
+ args: fullArgs,
1659
2031
  exitCode: getExecStatus(error),
1660
2032
  stdout: toLogString((error as { stdout?: unknown }).stdout) ?? "",
1661
2033
  stderr: toLogString((error as { stderr?: unknown }).stderr) ?? message,
@@ -1683,8 +2055,10 @@ type AssetType =
1683
2055
  | "memory"
1684
2056
  | "script"
1685
2057
  | "skill"
2058
+ | "task"
1686
2059
  | "workflow"
1687
- | "vault"
2060
+ | "env"
2061
+ | "secret"
1688
2062
  | "wiki"
1689
2063
 
1690
2064
  const ASSET_TYPES = [
@@ -1695,8 +2069,10 @@ const ASSET_TYPES = [
1695
2069
  "memory",
1696
2070
  "script",
1697
2071
  "skill",
2072
+ "task",
1698
2073
  "workflow",
1699
- "vault",
2074
+ "env",
2075
+ "secret",
1700
2076
  "wiki",
1701
2077
  "any",
1702
2078
  ] as const
@@ -1833,6 +2209,18 @@ function parseModelHint(modelHint: unknown): { providerID: string; modelID: stri
1833
2209
  return { providerID, modelID }
1834
2210
  }
1835
2211
 
2212
+ function resolveDispatchTarget(requested: string | undefined, fallbackAgent: string): DispatchTarget {
2213
+ const trimmed = requested?.trim()
2214
+ if (!trimmed) {
2215
+ return { agent: fallbackAgent, requested: fallbackAgent }
2216
+ }
2217
+ const model = parseModelHint(trimmed)
2218
+ if (model) {
2219
+ return { agent: fallbackAgent, model, requested: trimmed }
2220
+ }
2221
+ return { agent: trimmed, requested: trimmed }
2222
+ }
2223
+
1836
2224
  function parseToolPolicy(toolPolicy: unknown): Record<string, boolean> | undefined {
1837
2225
  const result: Record<string, boolean> = {}
1838
2226
 
@@ -2047,8 +2435,8 @@ async function promptTargetSession(input: {
2047
2435
  try {
2048
2436
  const promptResponse = await input.client.session.prompt({
2049
2437
  path: { id: input.targetSessionID },
2050
- body: input.promptBody,
2051
- })
2438
+ body: input.promptBody,
2439
+ })
2052
2440
 
2053
2441
  if (promptResponse.error || !promptResponse.data) {
2054
2442
  const reason = promptResponse.error ? JSON.stringify(promptResponse.error) : "empty response"
@@ -2059,6 +2447,7 @@ async function promptTargetSession(input: {
2059
2447
  directory: input.context.directory,
2060
2448
  targetSessionID: input.targetSessionID,
2061
2449
  dispatchAgent: input.promptBody.agent,
2450
+ dispatchModel: input.promptBody.model ?? null,
2062
2451
  ref: input.ref,
2063
2452
  error: reason,
2064
2453
  })
@@ -2073,10 +2462,11 @@ async function promptTargetSession(input: {
2073
2462
  toolName: input.toolName,
2074
2463
  sessionID: input.context.sessionID,
2075
2464
  directory: input.context.directory,
2076
- targetSessionID: input.targetSessionID,
2077
- dispatchAgent: input.promptBody.agent,
2078
- ref: input.ref,
2079
- })
2465
+ targetSessionID: input.targetSessionID,
2466
+ dispatchAgent: input.promptBody.agent,
2467
+ dispatchModel: input.promptBody.model ?? null,
2468
+ ref: input.ref,
2469
+ })
2080
2470
  return { ok: true, data: promptResponse.data }
2081
2471
  } catch (error: unknown) {
2082
2472
  const reason = error instanceof Error ? error.message : String(error)
@@ -2087,6 +2477,7 @@ async function promptTargetSession(input: {
2087
2477
  directory: input.context.directory,
2088
2478
  targetSessionID: input.targetSessionID,
2089
2479
  dispatchAgent: input.promptBody.agent,
2480
+ dispatchModel: input.promptBody.model ?? null,
2090
2481
  ref: input.ref,
2091
2482
  error: reason,
2092
2483
  })
@@ -2156,17 +2547,6 @@ async function getParentSessionID(
2156
2547
  }
2157
2548
  }
2158
2549
 
2159
- function splitArguments(raw: string): string[] {
2160
- if (!raw.trim()) return []
2161
- const args: string[] = []
2162
- const re = /"([^"]*)"|'([^']*)'|`([^`]*)`|(\S+)/g
2163
- let match: RegExpExecArray | null
2164
- while ((match = re.exec(raw)) !== null) {
2165
- args.push(match[1] ?? match[2] ?? match[3] ?? match[4] ?? "")
2166
- }
2167
- return args
2168
- }
2169
-
2170
2550
  function renderCommandTemplate(template: string, rawArguments: string): string {
2171
2551
  const args = splitArguments(rawArguments)
2172
2552
  return template
@@ -2225,7 +2605,7 @@ type PluginClient = {
2225
2605
  }
2226
2606
 
2227
2607
  export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2228
- await ensureLatestAkmInstalled(client as unknown as LogCapableClient)
2608
+ await ensureSupportedAkmResolved(client as unknown as LogCapableClient)
2229
2609
 
2230
2610
  const logClient = client as unknown as LogCapableClient
2231
2611
  const sdkClient = client as unknown as PluginClient
@@ -2258,10 +2638,12 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2258
2638
  })
2259
2639
  warmIndexInBackground()
2260
2640
  if (AKM_AUTO_CURATE && !sessionCurated.has(sid)) {
2261
- const curated = await runCurateForSession(logClient, sid)
2641
+ const cwdContext = gatherCwdContext(directory)
2642
+ const curated = await runCurateForSession(logClient, sid, cwdContext || undefined)
2262
2643
  if (curated) {
2263
2644
  bumpCuratedVersion(sid)
2264
2645
  sessionCurated.set(sid, curated)
2646
+ writeCuratedFile(sid, curated)
2265
2647
  }
2266
2648
  }
2267
2649
  }
@@ -2278,7 +2660,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2278
2660
  }
2279
2661
  } else if (type === "session.compacted" || type === "session.idle" || type === "session.deleted") {
2280
2662
  if (!sid) return
2281
- const captured = captureSessionMemory(sid, type)
2663
+ const captured = captureSessionMemory(sid, type, { directory })
2282
2664
  if (captured) {
2283
2665
  await writePluginLog(logClient, "info", "AKM session memory captured", {
2284
2666
  subsystem: "memory",
@@ -2288,6 +2670,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2288
2670
  ref: captured,
2289
2671
  })
2290
2672
  await maybeIndexSessionMemory(logClient, sid, type, captured)
2673
+ // Auto-signal so improve picks up this session memory on the next run.
2674
+ runCliSyncRaw(["feedback", captured, "--positive", "--note", "session checkpoint: auto-signal for improve eligibility"], AKM_CURATE_TIMEOUT_MS)
2291
2675
  }
2292
2676
  if (type === "session.compacted") {
2293
2677
  writeStructuredEvent({
@@ -2303,6 +2687,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2303
2687
  if (type === "session.deleted") {
2304
2688
  sessionHints.delete(sid)
2305
2689
  sessionCurated.delete(sid)
2690
+ sessionCuratedFile.delete(sid)
2306
2691
  sessionWorkflow.delete(sid)
2307
2692
  sessionCuratorReport.delete(sid)
2308
2693
  sessionContextEpoch.delete(sid)
@@ -2325,7 +2710,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2325
2710
  try {
2326
2711
  const sid = extractSessionIdFromEvent(input)
2327
2712
  if (!sid) return
2328
- const captured = captureSessionMemory(sid, "stop")
2713
+ const captured = captureSessionMemory(sid, "stop", { directory })
2329
2714
  if (captured) {
2330
2715
  await writePluginLog(logClient, "info", "AKM session memory captured", {
2331
2716
  subsystem: "memory",
@@ -2335,6 +2720,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2335
2720
  ref: captured,
2336
2721
  })
2337
2722
  await maybeIndexSessionMemory(logClient, sid, "stop", captured)
2723
+ // Auto-signal so improve picks up this session memory on the next run.
2724
+ runCliSyncRaw(["feedback", captured, "--positive", "--note", "session checkpoint: auto-signal for improve eligibility"], AKM_CURATE_TIMEOUT_MS)
2338
2725
  }
2339
2726
  } catch (error: unknown) {
2340
2727
  await logHookFailure(logClient, "stop", error)
@@ -2342,7 +2729,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2342
2729
  },
2343
2730
  // experimental.chat.system.transform is how OpenCode exposes the
2344
2731
  // additionalContext channel. We append the cached hints (once per session)
2345
- // and the curated assets (once per turn) so the next LLM call sees them.
2732
+ // and the curated file reference (once per turn) so the next LLM call
2733
+ // sees instructions to read the curated file instead of raw content.
2346
2734
  "experimental.chat.system.transform": async (
2347
2735
  input: { sessionID?: string; session_id?: string } | undefined,
2348
2736
  output: { system?: string[] } | undefined,
@@ -2353,9 +2741,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2353
2741
  const epoch = sessionContextEpoch.get(sid) ?? 0
2354
2742
  const injectedEpoch = sessionContextInjectedEpoch.get(sid)
2355
2743
  if (sid && injectedEpoch !== epoch) {
2744
+ const curatedFile = sessionCuratedFile.get(sid)
2745
+ const curatedBlock = curatedFile
2746
+ ? `AKM stash curation available at \`${curatedFile}\`. Read that file to discover assets relevant to this session. ${AKM_CURATED_TAIL}`
2747
+ : ""
2356
2748
  const blocks = [
2357
2749
  sessionHints.get(sid) ? `${AKM_HINTS_PREFIX}\n\n${sessionHints.get(sid)}` : "",
2358
- sessionCurated.get(sid) ? `${AKM_CURATED_HEADER}\n${sessionCurated.get(sid)}${AKM_CURATED_TAIL}` : "",
2750
+ curatedBlock,
2359
2751
  sessionWorkflow.get(sid) ? formatWorkflowContext(sessionWorkflow.get(sid)!) : "",
2360
2752
  (await getPendingProposalCount(logClient, sid)).count > 0 && !(await getPendingProposalCount(logClient, sid)).unsupported ? formatPendingProposalContext((await getPendingProposalCount(logClient, sid)).count) : "",
2361
2753
  sessionCuratorReport.get(sid) ? formatCuratorReportContext(sessionCuratorReport.get(sid)!) : "",
@@ -2363,8 +2755,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2363
2755
  output.system.push(...applyContextBudget(blocks))
2364
2756
  sessionContextInjectedEpoch.set(sid, epoch)
2365
2757
  if (sessionCurated.has(sid)) {
2366
- // Startup curation is already included in the epoch-scoped block,
2367
- // so mark that version as consumed to avoid a duplicate inject below.
2368
2758
  sessionCuratedInjectedVersion.set(sid, sessionCuratedVersion.get(sid) ?? 0)
2369
2759
  }
2370
2760
  }
@@ -2372,7 +2762,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2372
2762
  const curatedVersion = sessionCuratedVersion.get(sid) ?? 0
2373
2763
  if (curated) {
2374
2764
  if (sessionCuratedInjectedVersion.get(sid) !== curatedVersion) {
2375
- output.system.push(...applyContextBudget([`${AKM_CURATED_HEADER}\n${curated}${AKM_CURATED_TAIL}`]))
2765
+ const curatedFile = writeCuratedFile(sid, curated)
2766
+ output.system.push(...applyContextBudget([`AKM stash curation written to \`${curatedFile}\`. Read that file to discover assets relevant to the current task. ${AKM_CURATED_TAIL}`]))
2376
2767
  sessionCuratedInjectedVersion.set(sid, curatedVersion)
2377
2768
  }
2378
2769
  }
@@ -2385,10 +2776,17 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2385
2776
  if (!input.tool.startsWith("akm_")) return
2386
2777
  const args = output.args && typeof output.args === "object" ? output.args as Record<string, unknown> : {}
2387
2778
  const confirm = args.confirm === true
2388
- if (input.tool === "akm_vault" && (args.action === "show" || args.action === "unset") && !confirm) {
2779
+ if (input.tool === "akm_env" && (args.action === "path" || args.action === "run") && !confirm) {
2389
2780
  output.args = {
2390
2781
  ...args,
2391
- __akmBlocked: `akm_vault action='${String(args.action)}' requires confirm:true to avoid accidental secret exposure or deletion.`,
2782
+ __akmBlocked: `akm_env action='${String(args.action)}' requires confirm:true because env reads are sensitive.`,
2783
+ }
2784
+ return
2785
+ }
2786
+ if (input.tool === "akm_secret" && args.action === "path" && !confirm) {
2787
+ output.args = {
2788
+ ...args,
2789
+ __akmBlocked: "akm_secret action='path' requires confirm:true because secret paths are sensitive.",
2392
2790
  }
2393
2791
  return
2394
2792
  }
@@ -2399,6 +2797,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2399
2797
  }
2400
2798
  return
2401
2799
  }
2800
+ if (input.tool === "akm_proposal" && ["accept", "reject", "drain"].includes(String(args.action ?? "")) && !confirm) {
2801
+ output.args = {
2802
+ ...args,
2803
+ __akmBlocked: `akm_proposal action='${String(args.action)}' requires confirm:true because it mutates the proposal queue.`,
2804
+ }
2805
+ return
2806
+ }
2402
2807
  output.args = args
2403
2808
  } catch (error: unknown) {
2404
2809
  await logHookFailure(logClient, "tool.execute.before", error, {
@@ -2407,59 +2812,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2407
2812
  })
2408
2813
  }
2409
2814
  },
2410
- "permission.ask": async (input, output) => {
2411
- try {
2412
- const command = typeof input?.metadata?.command === "string"
2413
- ? input.metadata.command
2414
- : Array.isArray(input?.patterns)
2415
- ? input.patterns.join(" && ")
2416
- : ""
2417
- if (!command.includes("akm")) return
2418
- await emitWorkflowTelemetry(logClient, "info", "akm.raw_cli.invoked", {
2419
- sessionID: input.sessionID,
2420
- toolName: "bash",
2421
- outcome: "requested",
2422
- command,
2423
- })
2424
- const assessment = assessRiskyAkmCommand(command)
2425
- if (!assessment) return
2426
- output.status = "deny"
2427
- await emitWorkflowTelemetry(logClient, "warn", "akm.raw_cli.blocked", {
2428
- sessionID: input.sessionID,
2429
- toolName: "bash",
2430
- outcome: "blocked",
2431
- reason: assessment.reason,
2432
- command,
2433
- category: assessment.category,
2434
- })
2435
- } catch (error: unknown) {
2436
- await logHookFailure(logClient, "permission.ask", error, {
2437
- sessionID: input?.sessionID,
2438
- command: typeof input?.metadata?.command === "string" ? input.metadata.command : undefined,
2439
- })
2440
- }
2441
- },
2442
- "command.execute.before": async (input, output) => {
2443
- try {
2444
- const command = `${input.command ?? ""} ${input.arguments ?? ""}`.trim()
2445
- const assessment = assessRiskyAkmCommand(command)
2446
- if (!assessment) return
2447
- output.parts = [{ type: "text", text: blockedCommandMessage(command, assessment) }]
2448
- await emitWorkflowTelemetry(logClient, "warn", "akm.raw_cli.blocked", {
2449
- sessionID: input.sessionID,
2450
- toolName: String(input.command ?? "bash"),
2451
- outcome: "blocked",
2452
- reason: assessment.reason,
2453
- command,
2454
- category: assessment.category,
2455
- })
2456
- } catch (error: unknown) {
2457
- await logHookFailure(logClient, "command.execute.before", error, {
2458
- sessionID: input?.sessionID,
2459
- command: input?.command,
2460
- })
2461
- }
2462
- },
2463
2815
  "shell.env": async (_input, output) => {
2464
2816
  try {
2465
2817
  output.env.AKM_PROJECT = worktree
@@ -2486,28 +2838,63 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2486
2838
  if (AKM_AUTO_CURATE && input.sessionID) {
2487
2839
  const decision = shouldRecall(text, { activeWorkflow: !!sessionWorkflow.get(input.sessionID), recentAssetFailure: retrospectiveState.get(input.sessionID)?.lastNegativeSignalAt != null })
2488
2840
  if (decision.shouldRecall) {
2489
- const curated = await runCurateForPrompt(logClient, decision.query, input.sessionID)
2490
- const refs = [...new Set((curated ?? "").match(/(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|lesson|script|workflow|vault|wiki):[A-Za-z0-9._/-]+/g) ?? [])]
2491
- sessionRecallAudit.set(input.sessionID, {
2841
+ // Do NOT block the model on `akm curate` — previously this awaited
2842
+ // an 8s-timeout sync curate on every user message, adding up to 8s
2843
+ // to the time-to-first-token of every turn. Fire-and-forget the
2844
+ // curate; when it completes, store the result in `sessionCurated`
2845
+ // which gets picked up by `experimental.chat.system.transform` on
2846
+ // the NEXT message. The current message proceeds with whatever
2847
+ // curated context (if any) was cached from previous turns.
2848
+ const sessionID = input.sessionID
2849
+ const directorySnapshot = directory
2850
+ const agentSnapshot = input.agent
2851
+ const previewText = text
2852
+ // Record the audit row immediately so callers (akm_memory audit)
2853
+ // see the decision; the `injectedRefs` field is populated lazily
2854
+ // when the background curate resolves.
2855
+ sessionRecallAudit.set(sessionID, {
2492
2856
  shouldRecall: true,
2493
2857
  reason: decision.reason,
2494
2858
  query: decision.query,
2495
- injectedRefs: refs,
2496
- injectedChars: curated?.length ?? 0,
2497
- warnings: [],
2498
- })
2499
- writeStructuredEvent({
2500
- event: "prompt_recall",
2501
- sessionId: input.sessionID,
2502
- scope: buildEventScope(input.sessionID, directory, input.agent),
2503
- input: { promptPreview: text.slice(0, 280), query: decision.query, reason: decision.reason },
2504
- refs,
2505
- outcome: { status: curated ? "ok" : "skipped" },
2859
+ injectedRefs: [],
2860
+ injectedChars: 0,
2861
+ warnings: ["curate dispatched asynchronously; result injected on next message"],
2506
2862
  })
2507
- if (curated) {
2508
- sessionCurated.set(input.sessionID, curated)
2509
- bumpCuratedVersion(input.sessionID)
2510
- }
2863
+ void (async () => {
2864
+ try {
2865
+ const curated = await runCurateForPrompt(logClient, decision.query, sessionID)
2866
+ const refs = [...new Set((curated ?? "").match(/(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|lesson|script|workflow|task|env|secret|wiki):[A-Za-z0-9._/-]+/g) ?? [])]
2867
+ const prior = sessionRecallAudit.get(sessionID)
2868
+ if (prior) {
2869
+ sessionRecallAudit.set(sessionID, {
2870
+ ...prior,
2871
+ injectedRefs: refs,
2872
+ injectedChars: curated?.length ?? 0,
2873
+ warnings: prior.warnings.filter((warning) => !warning.startsWith("curate dispatched asynchronously")),
2874
+ })
2875
+ }
2876
+ writeStructuredEvent({
2877
+ event: "prompt_recall",
2878
+ sessionId: sessionID,
2879
+ scope: buildEventScope(sessionID, directorySnapshot, agentSnapshot),
2880
+ input: { promptPreview: previewText.slice(0, 280), query: decision.query, reason: decision.reason },
2881
+ refs,
2882
+ outcome: { status: curated ? "ok" : "skipped" },
2883
+ })
2884
+ if (curated) {
2885
+ sessionCurated.set(sessionID, curated)
2886
+ writeCuratedFile(sessionID, curated)
2887
+ bumpCuratedVersion(sessionID)
2888
+ }
2889
+ } catch (error: unknown) {
2890
+ await writePluginLog(logClient, "warn", "AKM background curate failed", {
2891
+ subsystem: "curation",
2892
+ sessionID,
2893
+ directory: directorySnapshot,
2894
+ error: formatCliError(error),
2895
+ })
2896
+ }
2897
+ })()
2511
2898
  } else {
2512
2899
  const hint = "Need more AKM context? Use `akm_search` or `akm_curate` before writing from scratch."
2513
2900
  sessionRecallAudit.set(input.sessionID, {
@@ -2527,7 +2914,9 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2527
2914
  })
2528
2915
  const current = sessionCurated.get(input.sessionID) ?? ""
2529
2916
  if (!current.includes(hint)) {
2530
- sessionCurated.set(input.sessionID, current ? `${current}\n\n${hint}` : hint)
2917
+ const updated = current ? `${current}\n\n${hint}` : hint
2918
+ sessionCurated.set(input.sessionID, updated)
2919
+ writeCuratedFile(input.sessionID, updated)
2531
2920
  bumpCuratedVersion(input.sessionID)
2532
2921
  }
2533
2922
  }
@@ -2542,17 +2931,42 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2542
2931
  })
2543
2932
  }
2544
2933
 
2545
- if (input.sessionID && AKM_AUTO_FEEDBACK && AKM_RETROSPECTIVE_FEEDBACK_RE.test(text)) {
2934
+ // Retrospective positive feedback: the loose "thanks|perfect|worked"
2935
+ // matcher previously fired on `thanks, but it didn't work` because it
2936
+ // only checked for a positive token without considering negation in
2937
+ // the same message. Gate the path with the same negative/correction
2938
+ // matchers used by `recordRetrospectiveFeedback` so a mixed-signal
2939
+ // message is treated as ambiguous (skip rather than misattribute).
2940
+ // Refs still flow through the shared confidence gate so Claude and
2941
+ // OpenCode produce parallel auto-feedback verdicts for the same
2942
+ // signal (README "Confidence-scored auto-feedback" parity claim).
2943
+ if (
2944
+ input.sessionID
2945
+ && AKM_AUTO_FEEDBACK
2946
+ && AKM_RETROSPECTIVE_FEEDBACK_RE.test(text)
2947
+ && !AKM_RETROSPECTIVE_NEGATIVE_RE.test(text)
2948
+ && !AKM_EXPLICIT_CORRECTION_RE.test(text)
2949
+ ) {
2546
2950
  const recentRefs = (sessionBuffer.get(input.sessionID) ?? [])
2547
2951
  .filter((entry) => entry.kind === "tool-ref" && !!entry.ref)
2548
2952
  .map((entry) => entry.ref!)
2549
- .filter((ref, index, refs) => !ref.startsWith("memory:") && !ref.startsWith("vault:") && refs.indexOf(ref) === index)
2953
+ .filter((ref, index, refs) => !ref.startsWith("memory:") && !ref.startsWith("env:") && !ref.startsWith("secret:") && refs.indexOf(ref) === index)
2550
2954
  .slice(-3)
2551
2955
  const dedupe = new Set<string>()
2552
2956
  for (const ref of recentRefs) {
2553
- queueFeedback(logClient, ref, "positive", "opencode retrospective: user confirmed it worked", {
2957
+ const signal = classifyFeedbackSignal({
2958
+ ref,
2959
+ polarity: "positive",
2960
+ harness: "opencode",
2961
+ sessionId: input.sessionID,
2962
+ retrospective: true,
2963
+ note: "opencode retrospective: user confirmed it worked",
2964
+ })
2965
+ if (!shouldSubmitAutomaticFeedback(signal)) continue
2966
+ queueFeedback(logClient, ref, "positive", signal.note, {
2554
2967
  toolName: "chat.message",
2555
2968
  sessionID: input.sessionID,
2969
+ agent: input.agent,
2556
2970
  }, dedupe)
2557
2971
  }
2558
2972
  }
@@ -2570,7 +2984,38 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2570
2984
  },
2571
2985
  "tool.execute.after": async (input, output) => {
2572
2986
  try {
2573
- if (!input.tool.startsWith("akm_")) return
2987
+ const isAkmTool = input.tool.startsWith("akm_")
2988
+ // The SDK type for `tool.execute.after` input does not expose `directory`,
2989
+ // but the OpenCode runtime does provide it for tool-scoped hooks. Read
2990
+ // it via a structural cast so we get the value when present without
2991
+ // accepting `any` everywhere it's used.
2992
+ const inputDirectory = (input as { directory?: unknown }).directory
2993
+ const directory = typeof inputDirectory === "string" ? inputDirectory : undefined
2994
+
2995
+ const allArgRefs = extractAkmRefsFromAllArgs(input.args as Record<string, unknown>)
2996
+ const allOutputRefs = extractAkmRefsFromString(output.output)
2997
+ const allRefs = [...new Set([...allArgRefs, ...allOutputRefs])]
2998
+
2999
+ if (allRefs.length > 0) {
3000
+ writeStructuredEvent({
3001
+ event: "tool_ref_observed",
3002
+ sessionId: input.sessionID,
3003
+ scope: buildEventScope(input.sessionID, directory, input.tool),
3004
+ input: { tool: input.tool, callID: input.callID },
3005
+ refs: allRefs,
3006
+ outcome: { status: "ok" },
3007
+ })
3008
+ for (const ref of allRefs) {
3009
+ addBufferEntry(input.sessionID, {
3010
+ kind: "tool-ref",
3011
+ toolName: input.tool,
3012
+ ref,
3013
+ status: "unknown",
3014
+ })
3015
+ }
3016
+ }
3017
+
3018
+ if (!isAkmTool) return
2574
3019
 
2575
3020
  const parsed = parseToolOutput(output.output)
2576
3021
  if (!parsed) return
@@ -2600,15 +3045,12 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2600
3045
  })
2601
3046
  }
2602
3047
 
2603
- // Auto-feedback + session buffering: record every asset ref the tool
2604
- // touched so the stash ranking improves over time and so Stop/Compact
2605
- // has material to flush into a session summary memory.
2606
3048
  const refResult = extractToolRefs(input.tool, input.args as Record<string, unknown>, parsed)
2607
3049
  noteRecentRefs(input.sessionID, refResult.refs)
2608
3050
  writeStructuredEvent({
2609
3051
  event: "tool_observation",
2610
3052
  sessionId: input.sessionID,
2611
- scope: buildEventScope(input.sessionID, input.directory, input.tool),
3053
+ scope: buildEventScope(input.sessionID, directory, input.tool),
2612
3054
  input: { tool: input.tool, callID: input.callID, args: input.args as Record<string, unknown>, output: parsed as Record<string, unknown> },
2613
3055
  refs: refResult.refs,
2614
3056
  outcome: { status: feedback === "negative" ? "failed" : "ok" },
@@ -2627,7 +3069,10 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2627
3069
  input.sessionID,
2628
3070
  (sessionSuccessfulAssetTouchCount.get(input.sessionID) ?? 0) + 1,
2629
3071
  )
2630
- const checkpointRef = maybeCheckpointSessionMemory(input.sessionID)
3072
+ const checkpointRef = maybeCheckpointSessionMemory(input.sessionID, {
3073
+ directory,
3074
+ agent: input.tool,
3075
+ })
2631
3076
  if (checkpointRef) {
2632
3077
  await writePluginLog(logClient, "info", "AKM checkpoint memory captured", {
2633
3078
  subsystem: "memory",
@@ -2654,10 +3099,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2654
3099
  ? `opencode auto: ${input.tool} succeeded`
2655
3100
  : `opencode auto: ${input.tool} failed`
2656
3101
  for (const ref of feedbackRefs) {
2657
- // Memories and vault refs are not first-class feedback targets —
2658
- // memories do not accept feedback, and vault values never surface in
2659
- // JSON so automatic usage signals would be misleading.
2660
- if (ref.startsWith("memory:") || ref.startsWith("vault:")) continue
3102
+ // Skip refs that should never receive auto-feedback. Matches the
3103
+ // claude-side hook: memory/env/secret/lesson are excluded, including
3104
+ // origin-qualified forms like `local//lesson:foo`. Lessons take
3105
+ // feedback through the proposal queue, not via direct akm feedback.
3106
+ if (/^(?:.*\/\/)?(?:memory|env|secret|lesson):/.test(ref)) continue
2661
3107
  const directInput = Object.values(input.args as Record<string, unknown>).some((value) => typeof value === "string" && value.includes(ref))
2662
3108
  const signal = classifyFeedbackSignal({
2663
3109
  ref,
@@ -2671,7 +3117,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2671
3117
  writeStructuredEvent({
2672
3118
  event: "feedback_recorded",
2673
3119
  sessionId: input.sessionID,
2674
- scope: buildEventScope(input.sessionID, input.directory, input.tool),
3120
+ scope: buildEventScope(input.sessionID, directory, input.tool),
2675
3121
  refs: [ref],
2676
3122
  input: { source: signal.source, confidence: signal.confidence, note: signal.note },
2677
3123
  outcome: { status: "skipped", warnings: ["confidence below automatic submission threshold"] },
@@ -2681,12 +3127,14 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2681
3127
  const ok = queueFeedback(logClient, ref, feedback, signal.note, {
2682
3128
  toolName: input.tool,
2683
3129
  sessionID: input.sessionID,
3130
+ directory,
3131
+ agent: input.tool,
2684
3132
  }, dedupe)
2685
3133
  if (ok) {
2686
3134
  writeStructuredEvent({
2687
3135
  event: "feedback_recorded",
2688
3136
  sessionId: input.sessionID,
2689
- scope: buildEventScope(input.sessionID, input.directory, input.tool),
3137
+ scope: buildEventScope(input.sessionID, directory, input.tool),
2690
3138
  refs: [ref],
2691
3139
  input: { source: signal.source, confidence: signal.confidence, note: signal.note },
2692
3140
  outcome: { status: "ok" },
@@ -2716,7 +3164,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2716
3164
  },
2717
3165
  }),
2718
3166
  akm_search: tool({
2719
- 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.",
3167
+ description: "Search your stash or the akm registry for scripts, skills, commands, agents, knowledge, memories, lessons, tasks, workflows, env configs, secrets, and wikis. Use source='registry' for installable community kits.",
2720
3168
  args: {
2721
3169
  query: tool.schema.string().describe("Case-insensitive substring search."),
2722
3170
  type: tool.schema
@@ -2872,7 +3320,14 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2872
3320
  return JSON.stringify({ ok: true, candidates })
2873
3321
  }
2874
3322
  if (input.action === "checkpoint") {
2875
- const ref = captureSessionMemory(context.sessionID, "manual-checkpoint", { checkpoint: true })
3323
+ const ref = captureSessionMemory(context.sessionID, "manual-checkpoint", {
3324
+ checkpoint: true,
3325
+ directory: context.directory,
3326
+ agent: context.agent,
3327
+ channel: (context as Record<string, unknown>).channel as string | undefined,
3328
+ userID: (context as Record<string, unknown>).userID as string | undefined,
3329
+ user: (context as Record<string, unknown>).user as string | undefined,
3330
+ })
2876
3331
  return JSON.stringify({ ok: true, ref })
2877
3332
  }
2878
3333
  if (input.action === "sync") {
@@ -2900,7 +3355,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2900
3355
  } else if (candidate.recommendedAction === "feedback" && candidate.targetRef) {
2901
3356
  rawResult = await runCli(client as unknown as LogCapableClient, ["feedback", candidate.targetRef, "--positive", "--note", candidate.content, ...buildScopedArgs(context as unknown as Record<string, unknown>)], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
2902
3357
  } else if (candidate.recommendedAction === "distill" && candidate.targetRef) {
2903
- rawResult = await runCli(client as unknown as LogCapableClient, ["distill", candidate.targetRef], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
3358
+ rawResult = await runCli(client as unknown as LogCapableClient, ["improve", candidate.targetRef], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
2904
3359
  } else if (candidate.recommendedAction === "propose") {
2905
3360
  rawResult = await runCli(client as unknown as LogCapableClient, ["propose", "knowledge", `candidate-${candidate.id}`, "--task", candidate.content], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
2906
3361
  } else {
@@ -2955,6 +3410,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2955
3410
  as_subtask: tool.schema.boolean().optional().describe("Run in a child session with parent context. Defaults to true."),
2956
3411
  },
2957
3412
  async execute({ focus, dispatch_agent, as_subtask }, context) {
3413
+ await ensureFreshProposalCheckpoint(logClient, {
3414
+ sessionID: context.sessionID,
3415
+ directory: context.directory,
3416
+ agent: context.agent,
3417
+ }, "pre-evolve")
2958
3418
  const useSubtask = as_subtask ?? true
2959
3419
  const requestedAgent = dispatch_agent ?? "akm-curator"
2960
3420
  const targetAgent = await resolveDispatchAgent(sdkClient, requestedAgent, context.directory)
@@ -2993,7 +3453,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2993
3453
  const dateTag = buildDateTag()
2994
3454
  const shortSid = context.sessionID.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 8) || "session"
2995
3455
  const curatorMemoryRef = fullText
2996
- ? rememberTextAsMemory(`akm-curator-${dateTag}-${shortSid}`, fullText)
3456
+ ? rememberTextAsMemory(`akm-curator-${dateTag}-${shortSid}`, fullText, context as unknown as Record<string, unknown>)
2997
3457
  : null
2998
3458
 
2999
3459
  return JSON.stringify({
@@ -3077,7 +3537,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3077
3537
  ref: tool.schema.string().optional().describe("Agent ref from akm_search (e.g. agent:my-agent.md)."),
3078
3538
  query: tool.schema.string().optional().describe("If ref is omitted, resolve best matching stash agent for this query."),
3079
3539
  task_prompt: tool.schema.string().describe("Task prompt sent to the dispatched OpenCode agent."),
3080
- dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the task with. Defaults to 'general'."),
3540
+ dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the task with, or a provider/model override like 'openai/gpt-5.3-codex'. Defaults to 'general'."),
3081
3541
  as_subtask: tool.schema.boolean().optional().describe("Run in child session with parent context. Defaults to true."),
3082
3542
  },
3083
3543
  async execute({ ref, query, task_prompt, dispatch_agent, as_subtask }, context) {
@@ -3110,10 +3570,10 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3110
3570
  }
3111
3571
 
3112
3572
  const useSubtask = as_subtask ?? true
3113
- const targetAgent = dispatch_agent ?? "general"
3114
- const model = parseModelHint(shown.modelHint)
3573
+ const dispatchTarget = resolveDispatchTarget(dispatch_agent, "general")
3574
+ const hintedModel = parseModelHint(shown.modelHint)
3575
+ const model = dispatchTarget.model ?? hintedModel
3115
3576
  const tools = parseToolPolicy(shown.toolPolicy)
3116
-
3117
3577
  writeStructuredEvent({
3118
3578
  event: "subagent_started",
3119
3579
  sessionId: context.sessionID,
@@ -3121,7 +3581,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3121
3581
  input: {
3122
3582
  ref: resolved.ref,
3123
3583
  stashAgent: shown.name,
3124
- dispatchAgent: targetAgent,
3584
+ dispatchAgent: dispatchTarget.agent,
3585
+ requestedDispatchTarget: dispatchTarget.requested,
3125
3586
  taskPrompt: task_prompt,
3126
3587
  advisoryToolPolicy: shown.toolPolicy ?? null,
3127
3588
  appliedToolPolicy: tools ?? null,
@@ -3143,7 +3604,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3143
3604
  if (!targetSession.ok) return JSON.stringify(targetSession)
3144
3605
 
3145
3606
  const promptBody: SessionPromptBody = {
3146
- agent: targetAgent,
3607
+ agent: dispatchTarget.agent,
3147
3608
  system: shown.prompt,
3148
3609
  parts: [{ type: "text", text: task_prompt }],
3149
3610
  }
@@ -3163,13 +3624,26 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3163
3624
  if (!promptResponse.ok) return JSON.stringify(promptResponse)
3164
3625
 
3165
3626
  const childText = extractText(promptResponse.data.parts)
3166
- const candidates = extractCandidatesFromText({ harness: "opencode", sessionId: targetSession.sessionID, text: childText, evidence: [resolved.ref, task_prompt] })
3627
+ const candidates = extractCandidatesFromText({
3628
+ harness: "opencode",
3629
+ sessionId: targetSession.sessionID,
3630
+ text: childText,
3631
+ evidence: [resolved.ref, task_prompt],
3632
+ sourcePaths: [OPENCODE_EVENT_LOG, OPENCODE_CANDIDATE_LOG],
3633
+ targetRefHints: [resolved.ref],
3634
+ })
3167
3635
  if (candidates.length > 0) appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
3168
3636
  writeStructuredEvent({
3169
3637
  event: "subagent_completed",
3170
3638
  sessionId: context.sessionID,
3171
3639
  scope: buildEventScope(context.sessionID, context.directory, context.agent),
3172
- input: { childSessionID: targetSession.sessionID, stashAgent: shown.name, dispatchAgent: targetAgent },
3640
+ input: {
3641
+ childSessionID: targetSession.sessionID,
3642
+ stashAgent: shown.name,
3643
+ dispatchAgent: dispatchTarget.agent,
3644
+ requestedDispatchTarget: dispatchTarget.requested,
3645
+ dispatchModel: model ?? null,
3646
+ },
3173
3647
  memory: { parentSessionID: context.sessionID, childSessionID: targetSession.sessionID, candidatesCreated: candidates.length },
3174
3648
  refs: [resolved.ref],
3175
3649
  outcome: { status: "ok" },
@@ -3179,7 +3653,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3179
3653
  ok: true,
3180
3654
  ref: resolved.ref,
3181
3655
  stashAgent: shown.name,
3182
- dispatchAgent: targetAgent,
3656
+ dispatchAgent: dispatchTarget.agent,
3657
+ requestedDispatchTarget: dispatchTarget.requested,
3183
3658
  usedSubtask: useSubtask,
3184
3659
  sessionID: targetSession.sessionID,
3185
3660
  model,
@@ -3194,7 +3669,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3194
3669
  ref: tool.schema.string().optional().describe("Command ref from akm_search (e.g. command:review.md)."),
3195
3670
  query: tool.schema.string().optional().describe("If ref is omitted, resolve best matching stash command for this query."),
3196
3671
  arguments: tool.schema.string().optional().describe("Command arguments used for $ARGUMENTS and positional placeholders ($1, $2, ...)."),
3197
- dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the rendered command. Defaults to current agent."),
3672
+ dispatch_agent: tool.schema.string().optional().describe("OpenCode agent to run the rendered command, or a provider/model override like 'openai/gpt-5.3-codex'. Defaults to current agent."),
3198
3673
  as_subtask: tool.schema.boolean().optional().describe("Run in child session with parent context. Defaults to false."),
3199
3674
  },
3200
3675
  async execute({ ref, query, arguments: commandArguments, dispatch_agent, as_subtask }, context) {
@@ -3221,7 +3696,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3221
3696
  const argsText = commandArguments ?? ""
3222
3697
  const rendered = renderCommandTemplate(template, argsText)
3223
3698
  const useSubtask = as_subtask ?? false
3224
- const targetAgent = dispatch_agent ?? context.agent
3699
+ const fallbackAgent = typeof context.agent === "string" && context.agent.trim() ? context.agent : "general"
3700
+ const dispatchTarget = resolveDispatchTarget(dispatch_agent, fallbackAgent)
3225
3701
 
3226
3702
  const targetSession = await ensureTargetSessionID({
3227
3703
  useSubtask,
@@ -3242,7 +3718,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3242
3718
  failureMessage: `Failed to execute command ${resolved.ref}`,
3243
3719
  ref: resolved.ref,
3244
3720
  promptBody: {
3245
- agent: targetAgent,
3721
+ agent: dispatchTarget.agent,
3722
+ model: dispatchTarget.model,
3246
3723
  parts: [{ type: "text", text: rendered }],
3247
3724
  },
3248
3725
  })
@@ -3252,75 +3729,84 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3252
3729
  ok: true,
3253
3730
  ref: resolved.ref,
3254
3731
  stashCommand: shown.name,
3255
- dispatchAgent: targetAgent,
3732
+ dispatchAgent: dispatchTarget.agent,
3733
+ requestedDispatchTarget: dispatchTarget.requested,
3256
3734
  usedSubtask: useSubtask,
3257
3735
  sessionID: targetSession.sessionID,
3736
+ model: dispatchTarget.model,
3258
3737
  arguments: argsText,
3259
3738
  renderedTemplate: rendered,
3260
3739
  text: extractText(promptResponse.data.parts),
3261
3740
  })
3262
3741
  },
3263
3742
  }),
3264
- akm_vault: tool({
3265
- 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.",
3743
+ akm_env: tool({
3744
+ description: "Access AKM env assets (.env files) without surfacing values. 'list' returns env refs. 'path' returns the on-disk file path. 'run' injects the env into a child command without values reaching stdout — the preferred agent-safe path. action='path' and action='run' require confirm:true.",
3266
3745
  args: {
3267
- 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."),
3268
- 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."),
3269
- name: tool.schema.string().optional().describe("Vault name when action is 'create' (e.g. 'prod' → vaults/prod.env)."),
3270
- key: tool.schema.string().optional().describe("Variable name for set/unset. May include '=' to pass KEY=VALUE in one field when value is omitted."),
3271
- value: tool.schema.string().optional().describe("Value to store. Never echoed back."),
3272
- comment: tool.schema.string().optional().describe("Optional inline '# comment' written above the key for 'set'."),
3273
- confirm: tool.schema.boolean().optional().describe("Must be true for sensitive actions like show and unset."),
3746
+ action: tool.schema.enum(["list", "path", "run"]).describe("Env subcommand. 'list' returns refs; 'path' returns the file path; 'run' injects env into a child command."),
3747
+ ref: tool.schema.string().optional().describe("Env ref such as env:prod or env:myapp. Required for path/run."),
3748
+ cmd: tool.schema.string().optional().describe("Command to run with env injected. Required for action='run'. Example: 'npm start'."),
3749
+ confirm: tool.schema.boolean().optional().describe("Must be true for path and run actions."),
3274
3750
  },
3275
3751
  async execute(input) {
3276
3752
  const blocked = blockedToolResponse(input as Record<string, unknown>)
3277
3753
  if (blocked) return blocked
3278
- const { action, ref, name, key, value, comment } = input
3279
- const logMeta = { toolName: "akm_vault" }
3754
+ const { action, ref, cmd, confirm } = input
3755
+ const logMeta = { toolName: "akm_env" }
3280
3756
  switch (action) {
3281
3757
  case "list": {
3282
- const args = ["vault", "list"]
3283
- if (ref) args.push(ref)
3284
- return runCli(client as unknown as LogCapableClient, args, logMeta)
3285
- }
3286
- case "show": {
3287
- if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='show'." })
3288
- return runCli(client as unknown as LogCapableClient, ["vault", "show", ref], logMeta)
3289
- }
3290
- case "create": {
3291
- if (!name) return JSON.stringify({ ok: false, error: "'name' is required for action='create'." })
3292
- return runCli(client as unknown as LogCapableClient, ["vault", "create", name], logMeta)
3758
+ return runCli(client as unknown as LogCapableClient, ["env", "list"], logMeta)
3293
3759
  }
3294
- case "set": {
3295
- if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='set'." })
3296
- if (!key) return JSON.stringify({ ok: false, error: "'key' is required for action='set'." })
3297
- const args = ["vault", "set", ref, key]
3298
- if (value != null) args.push(value)
3299
- if (comment) args.push("--comment", comment)
3300
- return runCli(client as unknown as LogCapableClient, args, logMeta)
3760
+ case "path": {
3761
+ if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_env action='path' requires confirm:true." })
3762
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='path'." })
3763
+ return runCli(client as unknown as LogCapableClient, ["env", "path", ref], logMeta)
3301
3764
  }
3302
- case "unset": {
3303
- if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='unset'." })
3304
- if (!key) return JSON.stringify({ ok: false, error: "'key' is required for action='unset'." })
3305
- return runCli(client as unknown as LogCapableClient, ["vault", "unset", ref, key], logMeta)
3765
+ case "run": {
3766
+ if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_env action='run' requires confirm:true." })
3767
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='run'." })
3768
+ if (!cmd) return JSON.stringify({ ok: false, error: "'cmd' is required for action='run'." })
3769
+ return runCli(client as unknown as LogCapableClient, ["env", "run", ref, "--", ...cmd.split(/\s+/)], logMeta)
3306
3770
  }
3307
- case "load": {
3308
- if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='load'." })
3309
- // `vault load` emits raw shell — not JSON. Return the snippet verbatim
3310
- // so the caller can hand it to a shell via eval. Never parse values.
3771
+ }
3772
+ },
3773
+ }),
3774
+ akm_secret: tool({
3775
+ description: "Inspect whole-file AKM secrets without surfacing contents. 'list' returns secret refs only. 'path' returns the on-disk file path for `_FILE`-style consumers. action='path' requires confirm:true.",
3776
+ args: {
3777
+ action: tool.schema.enum(["list", "path"]).describe("Secret subcommand. 'path' returns the absolute file path without reading or echoing the secret bytes."),
3778
+ ref: tool.schema.string().optional().describe("Secret ref such as secret:deploy-key. Required for action='path'."),
3779
+ confirm: tool.schema.boolean().optional().describe("Must be true for sensitive actions like path."),
3780
+ },
3781
+ async execute(input) {
3782
+ const blocked = blockedToolResponse(input as Record<string, unknown>)
3783
+ if (blocked) return blocked
3784
+ const { action, ref, confirm } = input
3785
+ const logMeta = { toolName: "akm_secret" }
3786
+ switch (action) {
3787
+ case "list":
3788
+ return runCli(client as unknown as LogCapableClient, ["secret", "list"], logMeta)
3789
+ case "path": {
3790
+ if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_secret action='path' requires confirm:true because secret paths are sensitive." })
3791
+ if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='path'." })
3311
3792
  const command = resolveAkmCommand()
3312
- if (typeof command !== "string") return JSON.stringify(command)
3793
+ if (typeof command === "object" && "ok" in command) return JSON.stringify(command)
3313
3794
  try {
3314
- const stdout = execFileSync(command, ["vault", "load", ref], {
3795
+ const filePath = execResolvedAkm(command, ["secret", "path", ref], {
3315
3796
  encoding: "utf8",
3316
3797
  timeout: 30_000,
3798
+ }).toString().trim()
3799
+ return JSON.stringify({
3800
+ ok: true,
3801
+ ref,
3802
+ filePath,
3803
+ usage: "Pass this file path to trusted `_FILE`-style consumers or shells. The plugin did not read or surface the secret contents.",
3317
3804
  })
3318
- return JSON.stringify({ ok: true, ref, shell: stdout.trim() })
3319
3805
  } catch (error: unknown) {
3320
- await writePluginLog(logClient, "error", "AKM vault load failed", {
3321
- subsystem: "vault",
3322
- toolName: "akm_vault",
3323
- action: "load",
3806
+ await writePluginLog(logClient, "error", "AKM secret path failed", {
3807
+ subsystem: "secret",
3808
+ toolName: "akm_secret",
3809
+ action: "path",
3324
3810
  ref,
3325
3811
  error: formatCliError(error),
3326
3812
  })
@@ -3347,8 +3833,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3347
3833
  ]).describe("Wiki subcommand."),
3348
3834
  name: tool.schema.string().optional().describe("Wiki name (required for every action except 'list')."),
3349
3835
  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."),
3350
- 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')."),
3351
- trust: tool.schema.boolean().optional().describe("Bypass install-audit blocking for this registration only."),
3836
+ writable: tool.schema.boolean().optional().describe("When registering a git-backed source, mark it as push-writable (used by `akm sync`; see akm_help topic='sync')."),
3352
3837
  max_pages: tool.schema.number().optional().describe("Crawler page cap when registering a website (default 50)."),
3353
3838
  max_depth: tool.schema.number().optional().describe("Crawler depth cap when registering a website (default 3)."),
3354
3839
  query: tool.schema.string().optional().describe("Query string for action='search'."),
@@ -3359,7 +3844,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3359
3844
  force: tool.schema.boolean().optional().describe("Required for action='remove'."),
3360
3845
  with_sources: tool.schema.boolean().optional().describe("When removing, also delete the raw/ sources (default false)."),
3361
3846
  },
3362
- async execute({ action, name, source_ref, writable, trust, max_pages, max_depth, query, limit, source, as_slug, content, force, with_sources }) {
3847
+ async execute({ action, name, source_ref, writable, max_pages, max_depth, query, limit, source, as_slug, content, force, with_sources }) {
3363
3848
  const logMeta = { toolName: "akm_wiki" }
3364
3849
  const requireName = () => {
3365
3850
  if (!name) return JSON.stringify({ ok: false, error: `'name' is required for action='${action}'.` })
@@ -3396,7 +3881,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3396
3881
  if (!source_ref) return JSON.stringify({ ok: false, error: "'source_ref' is required for action='register'." })
3397
3882
  const args = ["wiki", "register", name!, source_ref]
3398
3883
  if (writable) args.push("--writable")
3399
- if (trust) args.push("--trust")
3400
3884
  if (max_pages != null) args.push("--max-pages", String(max_pages))
3401
3885
  if (max_depth != null) args.push("--max-depth", String(max_depth))
3402
3886
  return runCli(client as unknown as LogCapableClient, args, logMeta)
@@ -3422,9 +3906,9 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3422
3906
  if (as_slug) args.push("--as", as_slug)
3423
3907
  if (source === "-" && content) {
3424
3908
  const command = resolveAkmCommand()
3425
- if (typeof command !== "string") return JSON.stringify(command)
3909
+ if (typeof command === "object" && "ok" in command) return JSON.stringify(command)
3426
3910
  try {
3427
- const stdout = execFileSync(command, [...args, "--format", "json"], {
3911
+ const stdout = execResolvedAkm(command, [...args, "--format", "json"], {
3428
3912
  encoding: "utf8",
3429
3913
  timeout: 60_000,
3430
3914
  input: content,
@@ -3552,9 +4036,9 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3552
4036
  case "template": {
3553
4037
  // The workflow template is emitted as raw markdown, not JSON.
3554
4038
  const command = resolveAkmCommand()
3555
- if (typeof command !== "string") return JSON.stringify(command)
4039
+ if (typeof command === "object" && "ok" in command) return JSON.stringify(command)
3556
4040
  try {
3557
- const stdout = execFileSync(command, ["workflow", "template"], {
4041
+ const stdout = execResolvedAkm(command, ["workflow", "template"], {
3558
4042
  encoding: "utf8",
3559
4043
  timeout: 30_000,
3560
4044
  })
@@ -3579,14 +4063,26 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3579
4063
  },
3580
4064
  }),
3581
4065
  akm_proposal: tool({
3582
- description: "Operate the AKM v0.7.0 proposal queue — list/show/diff/accept/reject pending drafts. All proposal-producing commands (reflect, propose, distill, plus plugin-emitted proposals) write through this queue. Acceptance runs full validation before promoting; rejection archives the draft. Always confirm with the user before action='accept' or 'reject'.",
4066
+ description: "Operate the AKM v0.8.0 proposal queue — list/show/diff/accept/reject pending drafts, or action='drain' for deterministic BULK triage of the whole backlog. All proposal-producing commands (improve, propose, plus plugin-emitted proposals) write through this queue. Acceptance runs full validation before promoting; rejection archives the draft. Always confirm with the user before action='accept', 'reject', or 'drain'. action='drain' clears the standing pending backlog by a deterministic policy (default queue/stage mode; pass promote:true to actually accept) — mutating, commits to git, no batch revert; always preview with dry_run:true first. This and the automatic improve `processes.triage` pre-pass supersede manual one-by-one queue management.",
3583
4067
  args: {
3584
- action: tool.schema.enum(["list", "show", "diff", "accept", "reject"]).describe("Proposal subcommand."),
4068
+ action: tool.schema.enum(["list", "show", "diff", "accept", "reject", "drain"]).describe("Proposal subcommand."),
3585
4069
  id: tool.schema.string().optional().describe("Proposal id. Required for show/diff/accept/reject."),
3586
4070
  status: tool.schema.enum(["pending", "accepted", "rejected"]).optional().describe("Filter for action='list'."),
3587
4071
  reason: tool.schema.string().optional().describe("Required for action='reject'. Recorded with the archived proposal."),
4072
+ confirm: tool.schema.boolean().optional().describe("Must be true for action='accept', 'reject', and 'drain'."),
4073
+ policy: tool.schema.string().optional().describe("action='drain' only: built-in preset (personal-stash|conservative|manual) or path to a policy file."),
4074
+ promote: tool.schema.boolean().optional().describe("action='drain' only: promote (accept) matching proposals. Default is queue/stage mode (no writes to assets)."),
4075
+ dry_run: tool.schema.boolean().optional().describe("action='drain' only: list the planned accept/reject/defer set without writing. Always preview first."),
4076
+ max_accepts: tool.schema.number().optional().describe("action='drain' only: hard per-run accept ceiling."),
4077
+ max_diff_lines: tool.schema.number().optional().describe("action='drain' only: defer accepts whose proposed content exceeds this many lines."),
4078
+ older_than: tool.schema.number().optional().describe("action='drain' only: only consider proposals created more than this many days ago."),
4079
+ judgment: tool.schema.boolean().optional().describe("action='drain' only: opt into the judgment tier for deferred items."),
4080
+ profile: tool.schema.string().optional().describe("action='drain' only: read the triage block (policy, applyMode, ceilings, judgment) from this improve profile."),
3588
4081
  },
3589
- async execute({ action, id, status, reason }) {
4082
+ async execute(input) {
4083
+ const blocked = blockedToolResponse(input as Record<string, unknown>)
4084
+ if (blocked) return blocked
4085
+ const { action, id, status, reason, confirm, policy, promote, dry_run, max_accepts, max_diff_lines, older_than, judgment, profile } = input
3590
4086
  const logMeta = { toolName: "akm_proposal" }
3591
4087
  switch (action) {
3592
4088
  case "list": {
@@ -3603,51 +4099,89 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3603
4099
  return runCli(client as unknown as LogCapableClient, ["proposal", "diff", id], logMeta)
3604
4100
  }
3605
4101
  case "accept": {
4102
+ if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_proposal action='accept' requires confirm:true because it mutates the proposal queue." })
3606
4103
  if (!id) return JSON.stringify({ ok: false, error: "'id' is required for action='accept'. Confirm with the user before accepting." })
3607
- return runCli(client as unknown as LogCapableClient, ["proposal", "accept", id], logMeta)
4104
+ const acceptResult = await runCli(client as unknown as LogCapableClient, ["proposal", "accept", id, "--yes"], logMeta)
4105
+ // Invalidate the proposal-count cache so the next getPendingProposalCount() call
4106
+ // reflects the updated queue immediately (WS-7a: no stale 60s TTL after mutations).
4107
+ pendingProposalSummaryCache.clear()
4108
+ return acceptResult
3608
4109
  }
3609
4110
  case "reject": {
4111
+ if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_proposal action='reject' requires confirm:true because it mutates the proposal queue." })
3610
4112
  if (!id) return JSON.stringify({ ok: false, error: "'id' is required for action='reject'. Confirm with the user before rejecting." })
3611
4113
  if (!reason || !reason.trim()) return JSON.stringify({ ok: false, error: "'reason' is required for action='reject'. Ask the user why the proposal is being rejected." })
3612
- return runCli(client as unknown as LogCapableClient, ["proposal", "reject", id, "--reason", reason], logMeta)
4114
+ const rejectResult = await runCli(client as unknown as LogCapableClient, ["proposal", "reject", id, "--reason", reason, "--yes"], logMeta)
4115
+ // Invalidate the proposal-count cache (WS-7a).
4116
+ pendingProposalSummaryCache.clear()
4117
+ return rejectResult
4118
+ }
4119
+ case "drain": {
4120
+ if (confirm !== true) return JSON.stringify({ ok: false, error: "akm_proposal action='drain' requires confirm:true because it bulk-mutates the proposal queue and commits to git." })
4121
+ const args = ["proposal", "drain"]
4122
+ if (policy) args.push("--policy", policy)
4123
+ if (promote) args.push("--promote")
4124
+ if (dry_run) args.push("--dry-run")
4125
+ else args.push("--yes")
4126
+ if (typeof max_accepts === "number") args.push("--max-accepts", String(max_accepts))
4127
+ if (typeof max_diff_lines === "number") args.push("--max-diff-lines", String(max_diff_lines))
4128
+ if (typeof older_than === "number") args.push("--older-than", String(older_than))
4129
+ if (judgment) args.push("--judgment")
4130
+ if (profile) args.push("--profile", profile)
4131
+ const drainResult = await runCli(client as unknown as LogCapableClient, args, logMeta)
4132
+ // A real drain (not a dry run) mutates the queue; invalidate the count cache (WS-7a).
4133
+ if (!dry_run) pendingProposalSummaryCache.clear()
4134
+ return drainResult
3613
4135
  }
3614
4136
  }
3615
4137
  },
3616
4138
  }),
3617
- akm_reflect: tool({
3618
- description: "Generate a reflection proposal for an AKM ref via the configured agent CLI. Output lands in the proposal queue only — never mutates live stash content. Requires `agent.default` to be set (run akm_setup first if missing).",
4139
+ akm_improve: tool({
4140
+ description: "Generate AKM improvement proposals for an existing ref, an asset type, or the broader stash. This is the v0.8.0 replacement for the old reflect/distill flow. Output lands in the proposal queue only — never mutates live stash content. Requires `defaults.agent` (with a matching `profiles.agent.<name>` entry) to be set for agent-backed proposal generation; the legacy `agent.default` shape is auto-migrated on load.",
3619
4141
  args: {
3620
- ref: tool.schema.string().optional().describe("[origin//]type:name ref to reflect on. Optional — when omitted the agent reflects on overall session signal."),
3621
- task: tool.schema.string().optional().describe("Free-form task description guiding the reflection."),
4142
+ scope: tool.schema.string().optional().describe("Optional asset type or [origin//]type:name ref to improve. When omitted, improves the current stash scope."),
4143
+ task: tool.schema.string().optional().describe("Optional extra guidance for this improvement pass."),
4144
+ dry_run: tool.schema.boolean().optional().describe("Show planned actions without generating proposals."),
3622
4145
  },
3623
- async execute({ ref, task }) {
3624
- const args = ["reflect"]
3625
- if (ref) args.push(ref)
4146
+ async execute({ scope, task, dry_run }, context) {
4147
+ if (!dry_run) {
4148
+ await ensureFreshProposalCheckpoint(logClient, {
4149
+ sessionID: context.sessionID,
4150
+ directory: context.directory,
4151
+ agent: context.agent,
4152
+ }, "pre-improve")
4153
+ }
4154
+ const args = ["improve"]
4155
+ if (scope) args.push(scope)
3626
4156
  if (task) args.push("--task", task)
3627
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_reflect" })
4157
+ if (dry_run) args.push("--dry-run")
4158
+ const improveResult = await runCli(client as unknown as LogCapableClient, args, { toolName: "akm_improve", sessionID: context.sessionID, directory: context.directory, agent: context.agent })
4159
+ // Invalidate the proposal-count cache after improve (may have added proposals) (WS-7a).
4160
+ if (!dry_run) pendingProposalSummaryCache.clear()
4161
+ return improveResult
3628
4162
  },
3629
4163
  }),
3630
4164
  akm_propose: tool({
3631
- description: "Generate a new-asset proposal via the configured agent CLI. The asset is drafted as `quality:\"proposed\"` and lands in the proposal queue — never directly into curated content. Requires `agent.default` (run akm_setup first if missing).",
4165
+ description: "Generate a new-asset proposal via the configured agent CLI. The asset is drafted as `quality:\"proposed\"` and lands in the proposal queue — never directly into curated content. Requires `defaults.agent` (with a matching `profiles.agent.<name>` entry; run akm_setup first if missing). The legacy `agent.default` shape is auto-migrated on load.",
3632
4166
  args: {
3633
4167
  type: tool.schema.enum(["skill", "command", "agent", "knowledge", "lesson", "script", "workflow", "wiki"]).describe("Asset type for the new proposal."),
3634
4168
  name: tool.schema.string().describe("Slug for the new asset (matches the standard ref grammar)."),
3635
- task: tool.schema.string().describe("Required. Describes what the asset should do."),
3636
- },
3637
- async execute({ type, name, task }) {
3638
- if (!task || !task.trim()) return JSON.stringify({ ok: false, error: "'task' is required for akm_propose." })
3639
- const args = ["propose", type, name, "--task", task]
3640
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_propose" })
4169
+ task: tool.schema.string().optional().describe("Optional inline task text describing what the asset should do."),
4170
+ file: tool.schema.string().optional().describe("Optional UTF-8 file path to read the task text from. Exactly one of task/file is required."),
3641
4171
  },
3642
- }),
3643
- akm_distill: tool({
3644
- description: "Distill an AKM ref into a proposed `lesson` using the bounded in-tree LLM. Gated by `llm.features.feedback_distillation` (default false); when the gate is off the call returns a fallback warning instead of a proposal. Lessons require `description` and `when_to_use` frontmatter and are stored under `lessons/<name>.md` after acceptance.",
3645
- args: {
3646
- ref: tool.schema.string().describe("[origin//]type:name ref to distill — most often memory:<name> or knowledge:<name>."),
3647
- },
3648
- async execute({ ref }) {
3649
- if (!ref || !ref.trim()) return JSON.stringify({ ok: false, error: "'ref' is required for akm_distill." })
3650
- return runCli(client as unknown as LogCapableClient, ["distill", ref], { toolName: "akm_distill" })
4172
+ async execute({ type, name, task, file }, context) {
4173
+ const hasTask = !!task?.trim()
4174
+ const hasFile = !!file?.trim()
4175
+ if (hasTask === hasFile) return JSON.stringify({ ok: false, error: "Exactly one of 'task' or 'file' is required for akm_propose." })
4176
+ await ensureFreshProposalCheckpoint(logClient, {
4177
+ sessionID: context.sessionID,
4178
+ directory: context.directory,
4179
+ agent: context.agent,
4180
+ }, "pre-propose")
4181
+ const args = ["propose", type, name]
4182
+ if (hasTask) args.push("--task", task!.trim())
4183
+ if (hasFile) args.push("--file", file!.trim())
4184
+ return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_propose", sessionID: context.sessionID, directory: context.directory, agent: context.agent })
3651
4185
  },
3652
4186
  }),
3653
4187
  akm_init: tool({
@@ -3665,13 +4199,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3665
4199
  },
3666
4200
  async execute({ topic, command }) {
3667
4201
  const cliCommand = resolveAkmCommand()
3668
- if (typeof cliCommand !== "string") return JSON.stringify(cliCommand)
4202
+ if (typeof cliCommand === "object" && "ok" in cliCommand) return JSON.stringify(cliCommand)
3669
4203
  const helpArgs = command && command.trim()
3670
4204
  ? [command.trim(), "--help"]
3671
4205
  : ["--help"]
3672
4206
  let helpText = ""
3673
4207
  try {
3674
- helpText = execFileSync(cliCommand, helpArgs, {
4208
+ helpText = execResolvedAkm(cliCommand, helpArgs, {
3675
4209
  encoding: "utf8",
3676
4210
  timeout: 30_000,
3677
4211
  }).toString().trim()
@@ -3694,13 +4228,14 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3694
4228
  help: helpText,
3695
4229
  workflowTopics: [
3696
4230
  "proposal",
3697
- "distill",
3698
- "reflect",
4231
+ "improve",
3699
4232
  "propose",
3700
4233
  "lesson",
4234
+ "tasks",
4235
+ "secret-safety",
3701
4236
  "include-proposed",
3702
4237
  "llm-features",
3703
- "vault-safety",
4238
+ "env-safety",
3704
4239
  ],
3705
4240
  })
3706
4241
  },
@@ -3708,6 +4243,6 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3708
4243
  },
3709
4244
  }
3710
4245
  }
3711
-
4246
+
3712
4247
  export const server = AkmPlugin
3713
- export default { server }
4248
+ export default { server, id: "akm-opencode" }