akm-opencode 0.7.2 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +4 -4
  2. package/index.ts +590 -149
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,7 +36,7 @@ The plugin exposes **20 high-value tools**. Long-tail verbs (`add`, `save`, `imp
36
36
  | `akm_reflect` | Generate a reflection proposal via the configured agent CLI; output lands in the proposal queue only |
37
37
  | `akm_propose` | Generate a new-asset proposal via the configured agent CLI; the result is `quality:"proposed"` until accepted |
38
38
  | `akm_distill` | Distill an AKM ref (typically `memory:*` or `knowledge:*`) into a proposed `lesson` (gated by `llm.features.feedback_distillation`) |
39
- | `akm_setup` | Detect installed agent CLIs (`opencode`, `claude`, `codex`, `gemini`, `aider`) and persist `agent.default`. Required once per machine for `akm_reflect` / `akm_propose` |
39
+ | `akm_init` | Initialize AKM's working stash directory and persist `stashDir` in config. This is the agent-safe initialization path; interactive `akm setup` is human-facing |
40
40
  | `akm_help` | Discover the right `akm` CLI invocation for non-first-class verbs. Returns a curated quick-reference table plus live `akm <subcommand> --help` output |
41
41
 
42
42
  ## Compound-engineering hooks
@@ -47,8 +47,8 @@ fails silently when `akm` is not on PATH — the TUI is never affected.
47
47
 
48
48
  | Event | What happens |
49
49
  | --- | --- |
50
- | **`session.created`** (event hook) | Warms the stash index in the background, caches `akm hints` plus active workflow status, and runs a scoped `akm curate --run <sessionID>` so fresh sessions see relevant stash context before the first user message. |
51
- | **`chat.message`** | Runs `akm curate "<prompt>" --run <sessionID>` on each user message (prompts shorter than `AKM_CURATE_MIN_CHARS` are skipped). The top matches are stored for injection. Memory intents (prompts mentioning "remember" / "memory") are tracked in the session buffer. |
50
+ | **`session.created`** (event hook) | Sets `agent.default` to `opencode` when missing, warms the stash index in the background, caches `akm hints` plus active workflow status, and runs a scoped `akm curate --run <sessionID>` so fresh sessions start with relevant stash context. |
51
+ | **`chat.message`** | Records user feedback/memory intent and appends a short reminder to use `akm_search` / `akm_curate` when more stash context is needed. It does not auto-run AKM CLI lookups on every message. |
52
52
  | **`experimental.chat.system.transform`** | Appends cached hints, active workflow state, pending proposal summaries, the last curator report, and the current prompt's curated context to the model's system prompt. Hints and workflow state are re-injected after transcript compaction. |
53
53
  | **`tool.execute.before`** (`akm_*` tools) | Blocks destructive or sensitive operations until `confirm:true` is provided. |
54
54
  | **`permission.ask`** / **`command.execute.before`** | Detects risky raw `akm` CLI commands executed through shell/commands and denies them until the user explicitly approves the exact operation. |
@@ -61,7 +61,7 @@ fails silently when `akm` is not on PATH — the TUI is never affected.
61
61
 
62
62
  | Variable | Default | Purpose |
63
63
  | --- | --- | --- |
64
- | `AKM_AUTO_CURATE` | `1` | Set to `0` to disable automatic `akm curate` on user messages. |
64
+ | `AKM_AUTO_CURATE` | `1` | Set to `0` to disable automatic `akm curate` on user messages. Session start no longer auto-curates. |
65
65
  | `AKM_AUTO_FEEDBACK` | `1` | Set to `0` to disable automatic `akm feedback` on tool success/failure. |
66
66
  | `AKM_AUTO_HINTS` | `1` | Set to `0` to skip injecting `akm hints` at session start. |
67
67
  | `AKM_AUTO_MEMORY` | `1` | Set to `0` to disable automatic session-summary memories. |
package/index.ts CHANGED
@@ -3,6 +3,11 @@ import { execFileSync, execSync, spawn } from "node:child_process"
3
3
  import { readFileSync } from "node:fs"
4
4
  import path from "node:path"
5
5
  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"
6
11
 
7
12
  let resolvedAkmCommand = "akm"
8
13
  const autoInstallPackageRef = process.env.AKM_PACKAGE_REF ?? "akm-cli@latest"
@@ -26,6 +31,8 @@ const AKM_RETROSPECTIVE_NEGATIVE_RE = createRetrospectiveNegativeRegex()
26
31
  const AKM_EXPLICIT_CORRECTION_RE = createExplicitCorrectionRegex()
27
32
  const PLUGIN_VERSION = readPackageVersion()
28
33
  const PLUGIN_INSTALL_LOCATION = moduleDir
34
+ const OPENCODE_EVENT_LOG = getEventLogPath("opencode")
35
+ const OPENCODE_CANDIDATE_LOG = getCandidateLogPath("opencode")
29
36
 
30
37
  // Per-session state that drives the compound-engineering loop.
31
38
  // These maps are keyed by OpenCode sessionID.
@@ -37,6 +44,7 @@ const sessionContextEpoch = new Map<string, number>()
37
44
  const sessionContextInjectedEpoch = new Map<string, number>()
38
45
  const sessionCuratedVersion = new Map<string, number>()
39
46
  const sessionCuratedInjectedVersion = new Map<string, number>()
47
+ const sessionRecallAudit = new Map<string, { shouldRecall: boolean; reason: string; query: string; injectedRefs: string[]; injectedChars: number; warnings: string[] }>()
40
48
  type ParsedSemver = { core: [number, number, number]; prerelease: Array<string | number> | null }
41
49
  type SessionBufferEntry = {
42
50
  timestamp: string
@@ -71,7 +79,7 @@ const AKM_WORKFLOW_INSTRUCTION = [
71
79
  "3. Record `akm_feedback` after the result is known.",
72
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.",
73
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`.",
74
- "6. Run `akm_setup` once on a fresh machine to detect installed agent CLIs and persist `agent.default`. Reflect/propose require it.",
82
+ "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.",
75
83
  `7. ${PROPOSED_QUALITY_WARNING}`,
76
84
  "8. Never accept or reject proposals, push saves, remove sources, or access vault values without explicit user approval.",
77
85
  ].join("\n")
@@ -225,42 +233,17 @@ function needsAgentSetup(message: string): boolean {
225
233
 
226
234
  function addAgentSetupGuidance(message: string): string {
227
235
  if (!needsAgentSetup(message)) return message
228
- if (/\bakm_setup\b|\bakm setup\b/i.test(message)) return message
229
- return `${message}. Run akm_setup to detect an installed agent CLI and persist agent.default. If none is installed yet, install one of: opencode, claude, codex, gemini, aider.`
236
+ 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.`
230
238
  }
231
239
 
232
- function toLogString(value: unknown): string | undefined {
233
- if (typeof value === "string") return value
234
- if (value instanceof Buffer) return value.toString("utf8")
235
- return undefined
236
- }
237
-
238
- function getExecStatus(error: unknown): number | null {
239
- if (!error || typeof error !== "object" || !("status" in error)) return null
240
- const status = (error as { status?: unknown }).status
241
- return typeof status === "number" ? status : null
242
- }
243
-
244
- async function writePluginLog(client: LogCapableClient, level: LogLevel, message: string, extra: Record<string, unknown>) {
245
- try {
246
- await client.app.log({
247
- query: typeof extra.directory === "string" ? { directory: extra.directory } : undefined,
248
- body: {
249
- service: "akm-opencode",
250
- level,
251
- message,
252
- extra,
253
- },
254
- })
255
- } catch {
256
- // Avoid breaking the TUI if logging itself fails.
257
- }
258
- }
259
-
260
- async function runAgentSetup(client: LogCapableClient, meta: { directory?: string; sessionID?: string; trigger: string; force?: boolean }): Promise<boolean> {
240
+ async function ensurePlatformAgentDefault(
241
+ client: LogCapableClient,
242
+ meta: { directory?: string; sessionID?: string; trigger: string; platform: string },
243
+ ): Promise<boolean> {
261
244
  const command = resolveAkmCommand()
262
245
  if (typeof command !== "string") {
263
- await writePluginLog(client, "warn", "AKM agent setup skipped", {
246
+ await writePluginLog(client, "warn", "AKM agent default check skipped", {
264
247
  subsystem: "akm",
265
248
  trigger: meta.trigger,
266
249
  sessionID: meta.sessionID,
@@ -270,59 +253,56 @@ async function runAgentSetup(client: LogCapableClient, meta: { directory?: strin
270
253
  return false
271
254
  }
272
255
 
273
- const args = ["setup"]
274
- if (meta.force) args.push("--force")
275
- args.push("--format", "json")
276
-
277
256
  try {
278
- const stdout = execFileSync(command, args, {
257
+ const currentRaw = execFileSync(command, ["config", "get", "agent.default", "--format", "json"], {
279
258
  encoding: "utf8",
280
259
  timeout: 30_000,
281
260
  })
282
- const parsed = safeJsonParse<{ ok?: boolean; error?: string }>(stdout)
283
- if (parsed?.ok === false) {
284
- await writePluginLog(client, "warn", "AKM agent setup reported an error", {
285
- subsystem: "akm",
286
- trigger: meta.trigger,
287
- sessionID: meta.sessionID,
288
- directory: meta.directory,
289
- command,
290
- args,
291
- stdout,
292
- error: parsed.error ?? "Unknown akm setup error",
293
- })
294
- return false
295
- }
296
- await writePluginLog(client, "info", "AKM agent setup completed", {
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
+ : ""
271
+ if (current) return true
272
+
273
+ execFileSync(command, ["config", "set", "agent.default", meta.platform, "--format", "json"], {
274
+ encoding: "utf8",
275
+ timeout: 30_000,
276
+ })
277
+ await writePluginLog(client, "info", "AKM agent default initialized", {
297
278
  subsystem: "akm",
298
279
  trigger: meta.trigger,
299
280
  sessionID: meta.sessionID,
300
281
  directory: meta.directory,
301
- command,
302
- args,
303
- stdout,
282
+ agentDefault: meta.platform,
304
283
  })
305
284
  return true
306
285
  } catch (error: unknown) {
307
- const message = addAgentSetupGuidance(formatCliError(error))
308
- await writePluginLog(client, "warn", "AKM agent setup failed", {
286
+ await writePluginLog(client, "warn", "AKM agent default initialization failed", {
309
287
  subsystem: "akm",
310
288
  trigger: meta.trigger,
311
289
  sessionID: meta.sessionID,
312
290
  directory: meta.directory,
313
- command,
314
- args,
315
- error: message,
291
+ platform: meta.platform,
292
+ error: addAgentSetupGuidance(formatCliError(error)),
316
293
  stdout: toLogString((error as { stdout?: unknown }).stdout) ?? "",
317
- stderr: toLogString((error as { stderr?: unknown }).stderr) ?? message,
294
+ stderr: toLogString((error as { stderr?: unknown }).stderr) ?? "",
318
295
  })
319
296
  return false
320
297
  }
321
298
  }
322
299
 
323
- async function ensureAgentSetup(client: LogCapableClient, meta: { directory?: string; sessionID?: string; trigger: string; force?: boolean }): Promise<boolean> {
300
+ async function ensureAgentSetup(
301
+ client: LogCapableClient,
302
+ meta: { directory?: string; sessionID?: string; trigger: string; platform: string },
303
+ ): Promise<boolean> {
324
304
  if (agentSetupPromise) return agentSetupPromise
325
- const promise = runAgentSetup(client, meta)
305
+ const promise = ensurePlatformAgentDefault(client, meta)
326
306
  const trackedPromise = promise.finally(() => {
327
307
  if (agentSetupPromise === trackedPromise) agentSetupPromise = null
328
308
  })
@@ -330,6 +310,74 @@ async function ensureAgentSetup(client: LogCapableClient, meta: { directory?: st
330
310
  return trackedPromise
331
311
  }
332
312
 
313
+ function isNotIndexedFeedbackError(message: string): boolean {
314
+ return /not in the current index|run ["'`]?akm index["'`]? and try again/i.test(message)
315
+ }
316
+
317
+ function toLogString(value: unknown): string | undefined {
318
+ if (typeof value === "string") return value
319
+ if (value instanceof Buffer) return value.toString("utf8")
320
+ return undefined
321
+ }
322
+
323
+ function getExecStatus(error: unknown): number | null {
324
+ if (!error || typeof error !== "object" || !("status" in error)) return null
325
+ const status = (error as { status?: unknown }).status
326
+ return typeof status === "number" ? status : null
327
+ }
328
+
329
+ async function writePluginLog(client: LogCapableClient, level: LogLevel, message: string, extra: Record<string, unknown>) {
330
+ try {
331
+ const redacted = redactObject(extra)
332
+ await client.app.log({
333
+ query: typeof redacted.value.directory === "string" ? { directory: redacted.value.directory as string } : undefined,
334
+ body: {
335
+ service: "akm-opencode",
336
+ level,
337
+ message,
338
+ extra: redacted.value,
339
+ },
340
+ })
341
+ } catch {
342
+ // Avoid breaking the TUI if logging itself fails.
343
+ }
344
+ }
345
+
346
+ function buildEventScope(sessionID?: string, directory?: string, agent?: string) {
347
+ return {
348
+ user: process.env.AKM_USER_ID,
349
+ agent,
350
+ run: sessionID,
351
+ channel: process.env.AKM_CHANNEL,
352
+ project: directory,
353
+ repo: process.env.AKM_REPO,
354
+ branch: process.env.AKM_BRANCH,
355
+ }
356
+ }
357
+
358
+ function writeStructuredEvent(event: Omit<AkmMemoryEvent, "version" | "timestamp" | "harness">) {
359
+ return appendMemoryEvent(OPENCODE_EVENT_LOG, {
360
+ version: 1,
361
+ timestamp: nowIso(),
362
+ harness: "opencode",
363
+ ...event,
364
+ })
365
+ }
366
+
367
+ async function logHookFailure(
368
+ client: LogCapableClient,
369
+ hook: string,
370
+ error: unknown,
371
+ extra?: Record<string, unknown>,
372
+ ) {
373
+ await writePluginLog(client, "error", `AKM ${hook} hook failed`, {
374
+ subsystem: "hook",
375
+ hook,
376
+ error: formatCliError(error),
377
+ ...extra,
378
+ })
379
+ }
380
+
333
381
  function nowIso(): string {
334
382
  return new Date().toISOString()
335
383
  }
@@ -431,9 +479,20 @@ function runCurate(args: string[]): string | null {
431
479
  return body || null
432
480
  }
433
481
 
434
- function runCurateForPrompt(text: string, sessionID?: string): string | null {
482
+ async function runCurateLogged(
483
+ client: LogCapableClient,
484
+ args: string[],
485
+ meta: CliLogMeta & { operation: string },
486
+ ): Promise<string | null> {
487
+ return runCliSyncBestEffort(client, args, AKM_CURATE_TIMEOUT_MS, {
488
+ ...meta,
489
+ subsystem: "curation",
490
+ })
491
+ }
492
+
493
+ async function runCurateForPrompt(client: LogCapableClient, text: string, sessionID?: string): Promise<string | null> {
435
494
  if (!text || text.length < AKM_CURATE_MIN_CHARS) return null
436
- return runCurate(
495
+ return runCurateLogged(client,
437
496
  appendRunScopeArgs(
438
497
  [
439
498
  "--detail",
@@ -448,11 +507,12 @@ function runCurateForPrompt(text: string, sessionID?: string): string | null {
448
507
  ],
449
508
  sessionID,
450
509
  ),
510
+ { toolName: "chat.message", sessionID, operation: "prompt-curate" },
451
511
  )
452
512
  }
453
513
 
454
- function runCurateForSession(sessionID: string): string | null {
455
- return runCurate(
514
+ async function runCurateForSession(client: LogCapableClient, sessionID: string): Promise<string | null> {
515
+ return runCurateLogged(client,
456
516
  appendRunScopeArgs(
457
517
  [
458
518
  "--detail",
@@ -466,14 +526,17 @@ function runCurateForSession(sessionID: string): string | null {
466
526
  ],
467
527
  sessionID,
468
528
  ),
529
+ { toolName: "session.start", sessionID, operation: "session-curate" },
469
530
  )
470
531
  }
471
532
 
472
- function runHintsForSession(): string | null {
473
- const result = runCliSyncRaw(["--format", "text", "-q", "hints"], AKM_CURATE_TIMEOUT_MS)
474
- if (!result.ok) return null
475
- const body = result.stdout.trim()
476
- return body || null
533
+ async function runHintsForSession(client: LogCapableClient, sessionID?: string): Promise<string | null> {
534
+ return runCliSyncBestEffort(client, ["--format", "text", "-q", "hints"], AKM_CURATE_TIMEOUT_MS, {
535
+ toolName: "session.start",
536
+ sessionID,
537
+ subsystem: "hints",
538
+ operation: "session-hints",
539
+ })
477
540
  }
478
541
 
479
542
  function summarizeWorkflowList(value: unknown): string | null {
@@ -507,10 +570,15 @@ function summarizeWorkflowList(value: unknown): string | null {
507
570
  return null
508
571
  }
509
572
 
510
- function runWorkflowSummaryForSession(): string | null {
511
- const result = runCliSyncRaw(["--format", "json", "-q", "workflow", "list", "--active"], AKM_CURATE_TIMEOUT_MS)
512
- if (!result.ok) return null
513
- const parsed = parseMaybeJson(result.stdout)
573
+ async function runWorkflowSummaryForSession(client: LogCapableClient, sessionID?: string): Promise<string | null> {
574
+ const raw = await runCliSyncBestEffort(client, ["--format", "json", "-q", "workflow", "list", "--active"], AKM_CURATE_TIMEOUT_MS, {
575
+ toolName: "session.start",
576
+ sessionID,
577
+ subsystem: "workflow",
578
+ operation: "active-workflow-summary",
579
+ })
580
+ if (!raw) return null
581
+ const parsed = parseMaybeJson(raw)
514
582
  const summary = summarizeWorkflowList(
515
583
  Array.isArray(parsed)
516
584
  ? parsed
@@ -547,14 +615,20 @@ function summarizeCuratorReportForContext(report: string): string {
547
615
  return `${report.slice(0, AKM_CURATOR_CONTEXT_MAX_CHARS).trimEnd()}\n\n[truncated for context]`
548
616
  }
549
617
 
550
- function getAkmStashDir(): string | undefined {
618
+ async function getAkmStashDir(client?: LogCapableClient): Promise<string | undefined> {
551
619
  if (cachedAkmStashDir !== undefined) return cachedAkmStashDir || undefined
552
- const result = runCliSyncRaw(["--format", "json", "-q", "config", "get", "stashDir"], AKM_CURATE_TIMEOUT_MS)
553
- if (!result.ok) {
620
+ const raw = client
621
+ ? await runCliSyncBestEffort(client, ["--format", "json", "-q", "config", "get", "stashDir"], AKM_CURATE_TIMEOUT_MS, {
622
+ toolName: "shell.env",
623
+ subsystem: "config",
624
+ operation: "get-stash-dir",
625
+ })
626
+ : runCurate(["--format", "json", "-q", "config", "get", "stashDir"])
627
+ if (!raw) {
554
628
  cachedAkmStashDir = ""
555
629
  return undefined
556
630
  }
557
- const parsed = parseMaybeJson(result.stdout)
631
+ const parsed = parseMaybeJson(raw)
558
632
  if (typeof parsed === "string" && parsed.trim()) {
559
633
  cachedAkmStashDir = parsed.trim()
560
634
  return cachedAkmStashDir
@@ -568,7 +642,6 @@ function getAkmStashDir(): string | undefined {
568
642
  }
569
643
  }
570
644
  }
571
- const raw = result.stdout.trim()
572
645
  cachedAkmStashDir = raw || ""
573
646
  return cachedAkmStashDir || undefined
574
647
  }
@@ -594,6 +667,19 @@ function safeJsonParse<T>(raw: string): T | undefined {
594
667
  }
595
668
 
596
669
  function emitWorkflowTelemetry(client: LogCapableClient, level: LogLevel, eventType: string, extra: Record<string, unknown>) {
670
+ const mappedEvent: AkmMemoryEvent["event"] = eventType.includes("workflow_")
671
+ ? eventType as AkmMemoryEvent["event"]
672
+ : eventType.includes("blocked")
673
+ ? "safety_blocked"
674
+ : "workflow_step"
675
+ void writeStructuredEvent({
676
+ event: mappedEvent,
677
+ sessionId: typeof extra.sessionID === "string" ? extra.sessionID : undefined,
678
+ workflowRunId: typeof extra.runId === "string" ? extra.runId : undefined,
679
+ scope: buildEventScope(typeof extra.sessionID === "string" ? extra.sessionID : undefined, typeof extra.directory === "string" ? extra.directory : undefined, typeof extra.toolName === "string" ? extra.toolName : undefined),
680
+ input: redactObject(extra).value as Record<string, unknown>,
681
+ outcome: { status: level === "error" ? "failed" : level === "warn" ? "blocked" : "ok", warnings: typeof extra.reason === "string" ? [extra.reason] : undefined },
682
+ })
597
683
  return writePluginLog(client, level, eventType, {
598
684
  subsystem: "workflow-compliance",
599
685
  eventType,
@@ -602,6 +688,29 @@ function emitWorkflowTelemetry(client: LogCapableClient, level: LogLevel, eventT
602
688
  })
603
689
  }
604
690
 
691
+ async function runCliSyncBestEffort(
692
+ client: LogCapableClient,
693
+ args: string[],
694
+ timeoutMs: number,
695
+ meta: CliLogMeta & { subsystem: string; operation: string },
696
+ ): Promise<string | null> {
697
+ const result = runCliSyncRaw(args, timeoutMs)
698
+ if (!result.ok) {
699
+ await writePluginLog(client, "warn", "AKM synchronous helper failed", {
700
+ subsystem: meta.subsystem,
701
+ operation: meta.operation,
702
+ toolName: meta.toolName,
703
+ sessionID: meta.sessionID,
704
+ directory: meta.directory,
705
+ args,
706
+ error: result.error,
707
+ })
708
+ return null
709
+ }
710
+ const body = result.stdout.trim()
711
+ return body || null
712
+ }
713
+
605
714
  function noteRecentRefs(sessionID: string | undefined, refs: string[]) {
606
715
  if (!sessionID || refs.length === 0) return
607
716
  const state = retrospectiveState.get(sessionID) ?? { recentRefs: [] }
@@ -659,7 +768,7 @@ async function recordRetrospectiveFeedback(client: LogCapableClient, sessionID:
659
768
  sessionID,
660
769
  })
661
770
  const parsed = safeJsonParse<{ ok?: boolean }>(raw)
662
- if (parsed?.ok !== false) {
771
+ if (parsed?.ok === true) {
663
772
  await emitWorkflowTelemetry(client, "info", "akm.feedback.recorded", {
664
773
  sessionID,
665
774
  toolName: "akm_feedback",
@@ -811,7 +920,7 @@ function rememberTextAsMemory(name: string, body: string): string | null {
811
920
  execFileSync(command, ["--format", "json", "-q", "remember", "--name", name, "--force"], {
812
921
  encoding: "utf8",
813
922
  timeout: AKM_CURATE_TIMEOUT_MS * 2,
814
- input: body,
923
+ input: redactSecrets(body).text,
815
924
  })
816
925
  return `memory:${name}`
817
926
  } catch {
@@ -837,6 +946,13 @@ function captureSessionMemory(
837
946
  }
838
947
 
839
948
  const lines: string[] = []
949
+ lines.push("---")
950
+ lines.push("akm_memory_kind: session_checkpoint")
951
+ lines.push("harness: opencode")
952
+ lines.push(`session_id: ${sessionID}`)
953
+ lines.push(`reason: ${reason}`)
954
+ lines.push("---")
955
+ lines.push("")
840
956
  lines.push(`# Session summary (${nowIso()})`)
841
957
  lines.push(`Reason: ${reason}`)
842
958
  lines.push(`Session: ${sessionID}`)
@@ -871,6 +987,26 @@ function captureSessionMemory(
871
987
  }
872
988
 
873
989
  if (isCheckpoint) {
990
+ void writeStructuredEvent({
991
+ event: "pre_compact_checkpoint",
992
+ sessionId: sessionID,
993
+ scope: buildEventScope(sessionID),
994
+ memory: { ref, reason, kind: "session_checkpoint" },
995
+ refs: [ref],
996
+ outcome: { status: "ok" },
997
+ })
998
+ const candidates = extractCandidatesFromText({ harness: "opencode", sessionId: sessionID, text: body, evidence: [ref, reason] })
999
+ if (candidates.length > 0) {
1000
+ appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
1001
+ void writeStructuredEvent({
1002
+ event: "candidate_extracted",
1003
+ sessionId: sessionID,
1004
+ scope: buildEventScope(sessionID),
1005
+ memory: { sourceRef: ref, count: candidates.length },
1006
+ refs: [ref],
1007
+ outcome: { status: "ok" },
1008
+ })
1009
+ }
874
1010
  for (const entry of entries) {
875
1011
  if (!entry.checkpointed) entry.checkpointed = true
876
1012
  }
@@ -881,6 +1017,26 @@ function captureSessionMemory(
881
1017
 
882
1018
  sessionFinalMemoryCaptured.add(sessionID)
883
1019
  sessionBuffer.delete(sessionID)
1020
+ void writeStructuredEvent({
1021
+ event: "session_ended",
1022
+ sessionId: sessionID,
1023
+ scope: buildEventScope(sessionID),
1024
+ memory: { ref, reason, kind: "session_checkpoint" },
1025
+ refs: [ref],
1026
+ outcome: { status: "ok" },
1027
+ })
1028
+ const candidates = extractCandidatesFromText({ harness: "opencode", sessionId: sessionID, text: body, evidence: [ref, reason] })
1029
+ if (candidates.length > 0) {
1030
+ appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
1031
+ void writeStructuredEvent({
1032
+ event: "candidate_extracted",
1033
+ sessionId: sessionID,
1034
+ scope: buildEventScope(sessionID),
1035
+ memory: { sourceRef: ref, count: candidates.length },
1036
+ refs: [ref],
1037
+ outcome: { status: "ok" },
1038
+ })
1039
+ }
884
1040
  return ref
885
1041
  }
886
1042
 
@@ -1490,32 +1646,7 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
1490
1646
  } catch (error: unknown) {
1491
1647
  let message = formatCliError(error)
1492
1648
  if (["akm_reflect", "akm_propose"].includes(meta.toolName) && needsAgentSetup(message)) {
1493
- const setupOk = await ensureAgentSetup(client, {
1494
- directory: meta.directory,
1495
- sessionID: meta.sessionID,
1496
- trigger: `${meta.toolName}:retry`,
1497
- force: true,
1498
- })
1499
- if (setupOk) {
1500
- try {
1501
- const stdout = execFileSync(command, fullArgs, {
1502
- encoding: "utf8",
1503
- timeout: 60_000,
1504
- })
1505
- await writePluginLog(client, "info", "AKM command recovered after setup", {
1506
- subsystem: "akm",
1507
- toolName: meta.toolName,
1508
- sessionID: meta.sessionID,
1509
- directory: meta.directory,
1510
- command,
1511
- args: fullArgs,
1512
- })
1513
- return recordSuccess(stdout)
1514
- } catch (retryError: unknown) {
1515
- error = retryError
1516
- message = formatCliError(retryError)
1517
- }
1518
- }
1649
+ message = addAgentSetupGuidance(message)
1519
1650
  }
1520
1651
  message = addAgentSetupGuidance(message)
1521
1652
  await writePluginLog(client, "error", "AKM command failed", {
@@ -2110,16 +2241,24 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2110
2241
  const sid = extractSessionIdFromEvent(event) ?? extractSessionIdFromEvent((event as { properties?: unknown }).properties)
2111
2242
  if (type === "session.created" || type === "session.updated") {
2112
2243
  if (!sid) return
2244
+ writeStructuredEvent({
2245
+ event: "session_started",
2246
+ sessionId: sid,
2247
+ scope: buildEventScope(sid, directory),
2248
+ input: { type },
2249
+ outcome: { status: "ok" },
2250
+ })
2113
2251
  if (!sessionContextEpoch.has(sid)) sessionContextEpoch.set(sid, 0)
2114
2252
  if (type === "session.created") {
2115
2253
  await ensureAgentSetup(logClient, {
2116
2254
  directory,
2117
2255
  sessionID: sid,
2118
2256
  trigger: "session.created",
2257
+ platform: "opencode",
2119
2258
  })
2120
2259
  warmIndexInBackground()
2121
2260
  if (AKM_AUTO_CURATE && !sessionCurated.has(sid)) {
2122
- const curated = runCurateForSession(sid)
2261
+ const curated = await runCurateForSession(logClient, sid)
2123
2262
  if (curated) {
2124
2263
  bumpCuratedVersion(sid)
2125
2264
  sessionCurated.set(sid, curated)
@@ -2127,11 +2266,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2127
2266
  }
2128
2267
  }
2129
2268
  if (AKM_AUTO_HINTS && !sessionHints.has(sid)) {
2130
- const hints = runHintsForSession()
2269
+ const hints = await runHintsForSession(logClient, sid)
2131
2270
  if (hints) sessionHints.set(sid, hints)
2132
2271
  }
2133
2272
  if (!sessionWorkflow.has(sid)) {
2134
- sessionWorkflow.set(sid, runWorkflowSummaryForSession() ?? "")
2273
+ sessionWorkflow.set(sid, await runWorkflowSummaryForSession(logClient, sid) ?? "")
2135
2274
  }
2136
2275
  const proposalSummary = await getPendingProposalCount(logClient, sid)
2137
2276
  if (!proposalSummary.unsupported && proposalSummary.count > 0) {
@@ -2150,6 +2289,15 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2150
2289
  })
2151
2290
  await maybeIndexSessionMemory(logClient, sid, type, captured)
2152
2291
  }
2292
+ if (type === "session.compacted") {
2293
+ writeStructuredEvent({
2294
+ event: "post_compact_summary",
2295
+ sessionId: sid,
2296
+ scope: buildEventScope(sid, directory),
2297
+ memory: { ref: captured ?? null, reason: type },
2298
+ outcome: { status: captured ? "ok" : "skipped" },
2299
+ })
2300
+ }
2153
2301
  // Drop per-session state so a re-created session does not inherit
2154
2302
  // stale hints/curation.
2155
2303
  if (type === "session.deleted") {
@@ -2166,8 +2314,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2166
2314
  sessionBuffer.delete(sid)
2167
2315
  }
2168
2316
  }
2169
- } catch {
2170
- // Lifecycle hooks must never throw into the TUI.
2317
+ } catch (error: unknown) {
2318
+ await logHookFailure(logClient, "event", error)
2171
2319
  }
2172
2320
  },
2173
2321
  // Stop is the closest analogue to Claude's Stop/SubagentStop — the user or
@@ -2188,8 +2336,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2188
2336
  })
2189
2337
  await maybeIndexSessionMemory(logClient, sid, "stop", captured)
2190
2338
  }
2191
- } catch {
2192
- // Best-effort only.
2339
+ } catch (error: unknown) {
2340
+ await logHookFailure(logClient, "stop", error)
2193
2341
  }
2194
2342
  },
2195
2343
  // experimental.chat.system.transform is how OpenCode exposes the
@@ -2228,8 +2376,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2228
2376
  sessionCuratedInjectedVersion.set(sid, curatedVersion)
2229
2377
  }
2230
2378
  }
2231
- } catch {
2232
- // Never break the turn because of a transform failure.
2379
+ } catch (error: unknown) {
2380
+ await logHookFailure(logClient, "experimental.chat.system.transform", error)
2233
2381
  }
2234
2382
  },
2235
2383
  "tool.execute.before": async (input, output) => {
@@ -2244,9 +2392,19 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2244
2392
  }
2245
2393
  return
2246
2394
  }
2395
+ if (input.tool === "akm_memory" && ["promote", "reject"].includes(String(args.action ?? "")) && !confirm) {
2396
+ output.args = {
2397
+ ...args,
2398
+ __akmBlocked: `akm_memory action='${String(args.action)}' requires confirm:true because it mutates candidate or memory state.`,
2399
+ }
2400
+ return
2401
+ }
2247
2402
  output.args = args
2248
- } catch {
2249
- // Never break tool execution from the pre-hook.
2403
+ } catch (error: unknown) {
2404
+ await logHookFailure(logClient, "tool.execute.before", error, {
2405
+ toolName: input?.tool,
2406
+ sessionID: input?.sessionID,
2407
+ })
2250
2408
  }
2251
2409
  },
2252
2410
  "permission.ask": async (input, output) => {
@@ -2274,8 +2432,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2274
2432
  command,
2275
2433
  category: assessment.category,
2276
2434
  })
2277
- } catch {
2278
- // Best-effort only.
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
+ })
2279
2440
  }
2280
2441
  },
2281
2442
  "command.execute.before": async (input, output) => {
@@ -2292,18 +2453,21 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2292
2453
  command,
2293
2454
  category: assessment.category,
2294
2455
  })
2295
- } catch {
2296
- // Best-effort only.
2456
+ } catch (error: unknown) {
2457
+ await logHookFailure(logClient, "command.execute.before", error, {
2458
+ sessionID: input?.sessionID,
2459
+ command: input?.command,
2460
+ })
2297
2461
  }
2298
2462
  },
2299
2463
  "shell.env": async (_input, output) => {
2300
2464
  try {
2301
2465
  output.env.AKM_PROJECT = worktree
2302
2466
  output.env.AKM_PLUGIN_VERSION = PLUGIN_VERSION
2303
- const stashDir = getAkmStashDir()
2467
+ const stashDir = await getAkmStashDir(logClient)
2304
2468
  if (stashDir) output.env.AKM_STASH_DIR = stashDir
2305
- } catch {
2306
- // Best-effort only.
2469
+ } catch (error: unknown) {
2470
+ await logHookFailure(logClient, "shell.env", error)
2307
2471
  }
2308
2472
  },
2309
2473
  "chat.message": async (input, output) => {
@@ -2319,13 +2483,53 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2319
2483
  text: truncateLogText(text),
2320
2484
  })
2321
2485
 
2322
- // Compound-engineering loop: on every user message, curate the stash and
2323
- // stash the result so experimental.chat.system.transform can inject it.
2324
2486
  if (AKM_AUTO_CURATE && input.sessionID) {
2325
- const curated = runCurateForPrompt(text, input.sessionID)
2326
- if (curated) {
2327
- sessionCurated.set(input.sessionID, curated)
2328
- bumpCuratedVersion(input.sessionID)
2487
+ const decision = shouldRecall(text, { activeWorkflow: !!sessionWorkflow.get(input.sessionID), recentAssetFailure: retrospectiveState.get(input.sessionID)?.lastNegativeSignalAt != null })
2488
+ 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, {
2492
+ shouldRecall: true,
2493
+ reason: decision.reason,
2494
+ 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" },
2506
+ })
2507
+ if (curated) {
2508
+ sessionCurated.set(input.sessionID, curated)
2509
+ bumpCuratedVersion(input.sessionID)
2510
+ }
2511
+ } else {
2512
+ const hint = "Need more AKM context? Use `akm_search` or `akm_curate` before writing from scratch."
2513
+ sessionRecallAudit.set(input.sessionID, {
2514
+ shouldRecall: false,
2515
+ reason: decision.reason,
2516
+ query: decision.query,
2517
+ injectedRefs: [],
2518
+ injectedChars: 0,
2519
+ warnings: [],
2520
+ })
2521
+ writeStructuredEvent({
2522
+ event: "prompt_recall",
2523
+ sessionId: input.sessionID,
2524
+ scope: buildEventScope(input.sessionID, directory, input.agent),
2525
+ input: { promptPreview: text.slice(0, 280), query: decision.query, reason: decision.reason },
2526
+ outcome: { status: "skipped" },
2527
+ })
2528
+ const current = sessionCurated.get(input.sessionID) ?? ""
2529
+ if (!current.includes(hint)) {
2530
+ sessionCurated.set(input.sessionID, current ? `${current}\n\n${hint}` : hint)
2531
+ bumpCuratedVersion(input.sessionID)
2532
+ }
2329
2533
  }
2330
2534
  }
2331
2535
 
@@ -2401,6 +2605,14 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2401
2605
  // has material to flush into a session summary memory.
2402
2606
  const refResult = extractToolRefs(input.tool, input.args as Record<string, unknown>, parsed)
2403
2607
  noteRecentRefs(input.sessionID, refResult.refs)
2608
+ writeStructuredEvent({
2609
+ event: "tool_observation",
2610
+ sessionId: input.sessionID,
2611
+ scope: buildEventScope(input.sessionID, input.directory, input.tool),
2612
+ input: { tool: input.tool, callID: input.callID, args: input.args as Record<string, unknown>, output: parsed as Record<string, unknown> },
2613
+ refs: refResult.refs,
2614
+ outcome: { status: feedback === "negative" ? "failed" : "ok" },
2615
+ })
2404
2616
  if (refResult.refs.length > 0 && input.sessionID) {
2405
2617
  for (const ref of refResult.refs) {
2406
2618
  addBufferEntry(input.sessionID, {
@@ -2446,10 +2658,40 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2446
2658
  // memories do not accept feedback, and vault values never surface in
2447
2659
  // JSON so automatic usage signals would be misleading.
2448
2660
  if (ref.startsWith("memory:") || ref.startsWith("vault:")) continue
2449
- const ok = queueFeedback(logClient, ref, feedback, note, {
2661
+ const directInput = Object.values(input.args as Record<string, unknown>).some((value) => typeof value === "string" && value.includes(ref))
2662
+ const signal = classifyFeedbackSignal({
2663
+ ref,
2664
+ polarity: feedback,
2665
+ harness: "opencode",
2666
+ sessionId: input.sessionID,
2667
+ directInput,
2668
+ note: `${note}; confidence=${directInput ? "0.65" : "0.25"}; source=${feedback === "positive" ? "tool_success" : "tool_failure"}`,
2669
+ })
2670
+ if (!shouldSubmitAutomaticFeedback(signal)) {
2671
+ writeStructuredEvent({
2672
+ event: "feedback_recorded",
2673
+ sessionId: input.sessionID,
2674
+ scope: buildEventScope(input.sessionID, input.directory, input.tool),
2675
+ refs: [ref],
2676
+ input: { source: signal.source, confidence: signal.confidence, note: signal.note },
2677
+ outcome: { status: "skipped", warnings: ["confidence below automatic submission threshold"] },
2678
+ })
2679
+ continue
2680
+ }
2681
+ const ok = queueFeedback(logClient, ref, feedback, signal.note, {
2450
2682
  toolName: input.tool,
2451
2683
  sessionID: input.sessionID,
2452
2684
  }, dedupe)
2685
+ if (ok) {
2686
+ writeStructuredEvent({
2687
+ event: "feedback_recorded",
2688
+ sessionId: input.sessionID,
2689
+ scope: buildEventScope(input.sessionID, input.directory, input.tool),
2690
+ refs: [ref],
2691
+ input: { source: signal.source, confidence: signal.confidence, note: signal.note },
2692
+ outcome: { status: "ok" },
2693
+ })
2694
+ }
2453
2695
  if (!ok) break
2454
2696
  }
2455
2697
  }
@@ -2552,6 +2794,32 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2552
2794
  if (note) args.push("--note", note)
2553
2795
  args.push(...buildScopedArgs(context as unknown as Record<string, unknown>))
2554
2796
  const raw = await runCli(client as unknown as LogCapableClient, args, { toolName: "akm_feedback", sessionID: context.sessionID, directory: context.directory })
2797
+ const parsed = safeJsonParse<{ ok?: boolean; error?: string }>(raw)
2798
+ if (parsed?.ok === false) {
2799
+ const error = parsed.error ?? "Unknown akm feedback error"
2800
+ if (isNotIndexedFeedbackError(error)) {
2801
+ await writePluginLog(logClient, "warn", "AKM feedback skipped", {
2802
+ subsystem: "feedback",
2803
+ toolName: "akm_feedback",
2804
+ sessionID: context.sessionID,
2805
+ directory: context.directory,
2806
+ ref,
2807
+ sentiment,
2808
+ reason: "ref_not_indexed",
2809
+ error,
2810
+ })
2811
+ await emitWorkflowTelemetry(logClient, "warn", "akm.feedback.skipped", {
2812
+ sessionID: context.sessionID,
2813
+ toolName: "akm_feedback",
2814
+ assetRef: ref,
2815
+ outcome: "skipped",
2816
+ reason: "ref not indexed",
2817
+ directory: context.directory,
2818
+ })
2819
+ return JSON.stringify({ ok: true, skipped: true, reason: "ref_not_indexed", ref, sentiment })
2820
+ }
2821
+ return raw
2822
+ }
2555
2823
  await emitWorkflowTelemetry(logClient, "info", "akm.feedback.recorded", {
2556
2824
  sessionID: context.sessionID,
2557
2825
  toolName: "akm_feedback",
@@ -2563,6 +2831,108 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2563
2831
  return raw
2564
2832
  },
2565
2833
  }),
2834
+ akm_memory: tool({
2835
+ description: "Audit AKM memory behavior, inspect pending candidates, and promote or reject candidates. Mutating actions require confirm:true.",
2836
+ args: {
2837
+ action: tool.schema.enum(["audit", "candidates", "promote", "reject", "checkpoint", "sync"]).describe("Memory action."),
2838
+ candidate_id: tool.schema.string().optional().describe("Candidate id for promote/reject."),
2839
+ reason: tool.schema.string().optional().describe("Reason for rejection."),
2840
+ scope: tool.schema.enum(["last-prompt", "session", "refs", "safety"]).optional().describe("Audit scope."),
2841
+ status: tool.schema.enum(["pending", "promoted", "rejected"]).optional().describe("Candidate status filter."),
2842
+ confirm: tool.schema.boolean().optional().describe("Must be true for mutate actions."),
2843
+ },
2844
+ async execute(input, context) {
2845
+ const blocked = blockedToolResponse(input as Record<string, unknown>)
2846
+ if (blocked) return blocked
2847
+ if (input.action === "audit") {
2848
+ const events = readJsonl<AkmMemoryEvent>(OPENCODE_EVENT_LOG)
2849
+ const sessionEvents = context.sessionID ? events.filter((event) => event.sessionId === context.sessionID) : events
2850
+ const recall = sessionRecallAudit.get(context.sessionID)
2851
+ const recentWrites = sessionEvents.filter((event) => ["durable_memory_written", "pre_compact_checkpoint", "session_ended"].includes(event.event)).slice(-5)
2852
+ const recentSafetyBlocks = sessionEvents.filter((event) => event.event === "safety_blocked").slice(-5)
2853
+ const refs = [...new Set(sessionEvents.flatMap((event) => event.refs ?? []))]
2854
+ const audit = input.scope === "last-prompt"
2855
+ ? { lastRecall: recall ?? null }
2856
+ : input.scope === "refs"
2857
+ ? { refs }
2858
+ : input.scope === "safety"
2859
+ ? { recentSafetyBlocks }
2860
+ : input.scope === "session"
2861
+ ? { lastRecall: recall ?? null, recentWrites, recentSafetyBlocks, refs }
2862
+ : { lastRecall: recall ?? null, recentWrites, recentSafetyBlocks, refs }
2863
+ return JSON.stringify({
2864
+ ok: true,
2865
+ audit,
2866
+ })
2867
+ }
2868
+ if (input.action === "candidates") {
2869
+ const candidates = readCandidates(OPENCODE_CANDIDATE_LOG)
2870
+ .filter((candidate) => !input.status || candidate.status === input.status)
2871
+ .filter((candidate) => !context.sessionID || candidate.sessionId === context.sessionID)
2872
+ return JSON.stringify({ ok: true, candidates })
2873
+ }
2874
+ if (input.action === "checkpoint") {
2875
+ const ref = captureSessionMemory(context.sessionID, "manual-checkpoint", { checkpoint: true })
2876
+ return JSON.stringify({ ok: true, ref })
2877
+ }
2878
+ if (input.action === "sync") {
2879
+ return JSON.stringify({ ok: true, synced: true, events: readJsonl<AkmMemoryEvent>(OPENCODE_EVENT_LOG).length, candidates: readCandidates(OPENCODE_CANDIDATE_LOG).length })
2880
+ }
2881
+ if (!input.candidate_id) return JSON.stringify({ ok: false, error: "'candidate_id' is required for promote/reject." })
2882
+ const candidates = readCandidates(OPENCODE_CANDIDATE_LOG)
2883
+ const candidate = candidates.find((entry) => entry.id === input.candidate_id)
2884
+ if (!candidate) return JSON.stringify({ ok: false, error: `Unknown candidate '${input.candidate_id}'.` })
2885
+ if (input.action === "reject") {
2886
+ const updated = updateCandidateStatus(OPENCODE_CANDIDATE_LOG, candidate.id, "rejected", input.reason)
2887
+ writeStructuredEvent({
2888
+ event: "candidate_rejected",
2889
+ sessionId: context.sessionID,
2890
+ scope: buildEventScope(context.sessionID, context.directory, context.agent),
2891
+ memory: { candidateId: candidate.id, reason: input.reason ?? null },
2892
+ outcome: { status: updated ? "ok" : "failed" },
2893
+ })
2894
+ return JSON.stringify({ ok: !!updated, candidate: updated ?? null })
2895
+ }
2896
+ if (input.action === "promote") {
2897
+ let rawResult: string | null = null
2898
+ if (candidate.recommendedAction === "remember") {
2899
+ rawResult = await runCli(client as unknown as LogCapableClient, ["remember", candidate.content, "--name", `candidate-${candidate.id}`, "--force", ...buildScopedArgs(context as unknown as Record<string, unknown>)], { toolName: "akm_memory", sessionID: context.sessionID, directory: context.directory })
2900
+ } else if (candidate.recommendedAction === "feedback" && candidate.targetRef) {
2901
+ 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
+ } 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 })
2904
+ } else if (candidate.recommendedAction === "propose") {
2905
+ 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
+ } else {
2907
+ return JSON.stringify({ ok: false, error: "Candidate recommendedAction is 'ignore'; reject it instead." })
2908
+ }
2909
+ const parsedResult = rawResult ? parseMaybeJson(rawResult) : null
2910
+ const resultOk = typeof parsedResult === "object" && parsedResult !== null && "ok" in parsedResult
2911
+ ? parsedResult.ok !== false
2912
+ : !!rawResult
2913
+ if (!resultOk) {
2914
+ writeStructuredEvent({
2915
+ event: "candidate_promoted",
2916
+ sessionId: context.sessionID,
2917
+ scope: buildEventScope(context.sessionID, context.directory, context.agent),
2918
+ memory: { candidateId: candidate.id, recommendedAction: candidate.recommendedAction },
2919
+ outcome: { status: "failed", warnings: ["candidate promotion command did not succeed"] },
2920
+ })
2921
+ return JSON.stringify({ ok: false, candidate, result: parsedResult ?? rawResult })
2922
+ }
2923
+ const updated = updateCandidateStatus(OPENCODE_CANDIDATE_LOG, candidate.id, "promoted")
2924
+ writeStructuredEvent({
2925
+ event: "candidate_promoted",
2926
+ sessionId: context.sessionID,
2927
+ scope: buildEventScope(context.sessionID, context.directory, context.agent),
2928
+ memory: { candidateId: candidate.id, recommendedAction: candidate.recommendedAction },
2929
+ outcome: { status: updated ? "ok" : "failed" },
2930
+ })
2931
+ return JSON.stringify({ ok: !!updated, candidate: updated ?? null, result: parsedResult ?? rawResult })
2932
+ }
2933
+ return JSON.stringify({ ok: false, error: `Unsupported action '${String(input.action)}'.` })
2934
+ },
2935
+ }),
2566
2936
  akm_curate: tool({
2567
2937
  description: "Curate stash assets for a task or topic. Returns the top matches as a ranked list so the agent can inspect and use them.",
2568
2938
  args: {
@@ -2744,6 +3114,24 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2744
3114
  const model = parseModelHint(shown.modelHint)
2745
3115
  const tools = parseToolPolicy(shown.toolPolicy)
2746
3116
 
3117
+ writeStructuredEvent({
3118
+ event: "subagent_started",
3119
+ sessionId: context.sessionID,
3120
+ scope: buildEventScope(context.sessionID, context.directory, context.agent),
3121
+ input: {
3122
+ ref: resolved.ref,
3123
+ stashAgent: shown.name,
3124
+ dispatchAgent: targetAgent,
3125
+ taskPrompt: task_prompt,
3126
+ advisoryToolPolicy: shown.toolPolicy ?? null,
3127
+ appliedToolPolicy: tools ?? null,
3128
+ modelHint: shown.modelHint ?? null,
3129
+ appliedModel: model ?? null,
3130
+ },
3131
+ refs: [resolved.ref],
3132
+ outcome: { status: "ok" },
3133
+ })
3134
+
2747
3135
  const targetSession = await ensureTargetSessionID({
2748
3136
  useSubtask,
2749
3137
  context: { sessionID: context.sessionID, directory: context.directory },
@@ -2774,6 +3162,19 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2774
3162
  })
2775
3163
  if (!promptResponse.ok) return JSON.stringify(promptResponse)
2776
3164
 
3165
+ const childText = extractText(promptResponse.data.parts)
3166
+ const candidates = extractCandidatesFromText({ harness: "opencode", sessionId: targetSession.sessionID, text: childText, evidence: [resolved.ref, task_prompt] })
3167
+ if (candidates.length > 0) appendCandidates(OPENCODE_CANDIDATE_LOG, candidates)
3168
+ writeStructuredEvent({
3169
+ event: "subagent_completed",
3170
+ sessionId: context.sessionID,
3171
+ scope: buildEventScope(context.sessionID, context.directory, context.agent),
3172
+ input: { childSessionID: targetSession.sessionID, stashAgent: shown.name, dispatchAgent: targetAgent },
3173
+ memory: { parentSessionID: context.sessionID, childSessionID: targetSession.sessionID, candidatesCreated: candidates.length },
3174
+ refs: [resolved.ref],
3175
+ outcome: { status: "ok" },
3176
+ })
3177
+
2777
3178
  return JSON.stringify({
2778
3179
  ok: true,
2779
3180
  ref: resolved.ref,
@@ -2783,7 +3184,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2783
3184
  sessionID: targetSession.sessionID,
2784
3185
  model,
2785
3186
  tools,
2786
- text: extractText(promptResponse.data.parts),
3187
+ text: childText,
2787
3188
  })
2788
3189
  },
2789
3190
  }),
@@ -2916,6 +3317,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2916
3317
  })
2917
3318
  return JSON.stringify({ ok: true, ref, shell: stdout.trim() })
2918
3319
  } catch (error: unknown) {
3320
+ await writePluginLog(logClient, "error", "AKM vault load failed", {
3321
+ subsystem: "vault",
3322
+ toolName: "akm_vault",
3323
+ action: "load",
3324
+ ref,
3325
+ error: formatCliError(error),
3326
+ })
2919
3327
  return JSON.stringify({ ok: false, error: formatCliError(error) })
2920
3328
  }
2921
3329
  }
@@ -3023,6 +3431,15 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3023
3431
  })
3024
3432
  return stdout
3025
3433
  } catch (error: unknown) {
3434
+ await writePluginLog(logClient, "error", "AKM wiki stash failed", {
3435
+ subsystem: "wiki",
3436
+ toolName: "akm_wiki",
3437
+ action: "stash",
3438
+ name,
3439
+ source,
3440
+ as_slug,
3441
+ error: formatCliError(error),
3442
+ })
3026
3443
  return JSON.stringify({ ok: false, error: formatCliError(error) })
3027
3444
  }
3028
3445
  }
@@ -3082,14 +3499,18 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3082
3499
  if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='start'." })
3083
3500
  const args = ["workflow", "start", ref]
3084
3501
  if (params) args.push("--params", params)
3085
- return runCli(client as unknown as LogCapableClient, args, logMeta)
3502
+ const raw = await runCli(client as unknown as LogCapableClient, args, logMeta)
3503
+ void emitWorkflowTelemetry(logClient, "info", "workflow_started", { toolName: "akm_workflow", ref, params: params ?? null })
3504
+ return raw
3086
3505
  }
3087
3506
  case "next": {
3088
3507
  const picked = target ?? run_id ?? ref
3089
3508
  if (!picked) return JSON.stringify({ ok: false, error: "'target', 'run_id', or 'ref' is required for action='next'." })
3090
3509
  const args = ["workflow", "next", picked]
3091
3510
  if (params) args.push("--params", params)
3092
- return runCli(client as unknown as LogCapableClient, args, logMeta)
3511
+ const raw = await runCli(client as unknown as LogCapableClient, args, logMeta)
3512
+ void emitWorkflowTelemetry(logClient, "info", "workflow_next_loaded", { toolName: "akm_workflow", target: picked, params: params ?? null })
3513
+ return raw
3093
3514
  }
3094
3515
  case "complete": {
3095
3516
  if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='complete'." })
@@ -3098,7 +3519,16 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3098
3519
  if (state) args.push("--state", state)
3099
3520
  if (notes) args.push("--notes", notes)
3100
3521
  if (evidence) args.push("--evidence", evidence)
3101
- return runCli(client as unknown as LogCapableClient, args, logMeta)
3522
+ const raw = await runCli(client as unknown as LogCapableClient, args, logMeta)
3523
+ const eventType = state === "blocked"
3524
+ ? "workflow_step_blocked"
3525
+ : state === "failed"
3526
+ ? "workflow_step_failed"
3527
+ : state === "skipped"
3528
+ ? "workflow_step_skipped"
3529
+ : "workflow_step_completed"
3530
+ void emitWorkflowTelemetry(logClient, "info", eventType, { toolName: "akm_workflow", runId: run_id, step, notes: notes ?? null, evidence: evidence ?? null })
3531
+ return raw
3102
3532
  }
3103
3533
  case "status": {
3104
3534
  const picked = target ?? run_id ?? ref
@@ -3130,12 +3560,20 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3130
3560
  })
3131
3561
  return JSON.stringify({ ok: true, template: stdout })
3132
3562
  } catch (error: unknown) {
3563
+ await writePluginLog(logClient, "error", "AKM workflow template failed", {
3564
+ subsystem: "workflow",
3565
+ toolName: "akm_workflow",
3566
+ action: "template",
3567
+ error: formatCliError(error),
3568
+ })
3133
3569
  return JSON.stringify({ ok: false, error: formatCliError(error) })
3134
3570
  }
3135
3571
  }
3136
3572
  case "resume": {
3137
3573
  if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='resume'." })
3138
- return runCli(client as unknown as LogCapableClient, ["workflow", "resume", run_id], logMeta)
3574
+ const raw = await runCli(client as unknown as LogCapableClient, ["workflow", "resume", run_id], logMeta)
3575
+ void emitWorkflowTelemetry(logClient, "info", "workflow_resumed", { toolName: "akm_workflow", runId: run_id })
3576
+ return raw
3139
3577
  }
3140
3578
  }
3141
3579
  },
@@ -3212,15 +3650,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3212
3650
  return runCli(client as unknown as LogCapableClient, ["distill", ref], { toolName: "akm_distill" })
3213
3651
  },
3214
3652
  }),
3215
- akm_setup: tool({
3216
- description: "Detect installed agent CLIs (opencode, claude, codex, gemini, aider) and persist `agent.default`. Required once per machine before akm_reflect / akm_propose can shell out. Idempotent — pass force=true to re-run detection even when agent.default is already set.",
3217
- args: {
3218
- force: tool.schema.boolean().optional().describe("Re-run detection even when agent.default is already configured."),
3219
- },
3220
- async execute({ force }) {
3221
- const args = ["setup"]
3222
- if (force) args.push("--force")
3223
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_setup" })
3653
+ akm_init: tool({
3654
+ description: "Initialize AKM's working stash directory and persist `stashDir` in config. This is the agent-safe initialization path; do not use interactive `akm setup` from tools.",
3655
+ args: {},
3656
+ async execute() {
3657
+ return runCli(client as unknown as LogCapableClient, ["init"], { toolName: "akm_init" })
3224
3658
  },
3225
3659
  }),
3226
3660
  akm_help: tool({
@@ -3242,6 +3676,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3242
3676
  timeout: 30_000,
3243
3677
  }).toString().trim()
3244
3678
  } catch (error: unknown) {
3679
+ await writePluginLog(logClient, "error", "AKM help command failed", {
3680
+ subsystem: "help",
3681
+ toolName: "akm_help",
3682
+ command: cliCommand,
3683
+ args: helpArgs,
3684
+ error: formatCliError(error),
3685
+ })
3245
3686
  return JSON.stringify({ ok: false, error: formatCliError(error) })
3246
3687
  }
3247
3688
  return JSON.stringify({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-opencode",
3
- "version": "0.7.2",
3
+ "version": "0.7.5",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for AKM v0.7.0 - search, show, and manage extension assets via the akm CLI, including vaults, wikis, workflows, the proposal queue, lesson assets, and reflect/propose/distill commands, with agentic hooks that auto-load relevant stash assets, record feedback, and harvest session memories so the stash improves every session.",
6
6
  "keywords": [