akm-opencode 0.7.3 → 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 (2) hide show
  1. package/index.ts +354 -16
  2. package/package.json +1 -1
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
@@ -320,13 +328,14 @@ function getExecStatus(error: unknown): number | null {
320
328
 
321
329
  async function writePluginLog(client: LogCapableClient, level: LogLevel, message: string, extra: Record<string, unknown>) {
322
330
  try {
331
+ const redacted = redactObject(extra)
323
332
  await client.app.log({
324
- query: typeof extra.directory === "string" ? { directory: extra.directory } : undefined,
333
+ query: typeof redacted.value.directory === "string" ? { directory: redacted.value.directory as string } : undefined,
325
334
  body: {
326
335
  service: "akm-opencode",
327
336
  level,
328
337
  message,
329
- extra,
338
+ extra: redacted.value,
330
339
  },
331
340
  })
332
341
  } catch {
@@ -334,6 +343,27 @@ async function writePluginLog(client: LogCapableClient, level: LogLevel, message
334
343
  }
335
344
  }
336
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
+
337
367
  async function logHookFailure(
338
368
  client: LogCapableClient,
339
369
  hook: string,
@@ -637,6 +667,19 @@ function safeJsonParse<T>(raw: string): T | undefined {
637
667
  }
638
668
 
639
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
+ })
640
683
  return writePluginLog(client, level, eventType, {
641
684
  subsystem: "workflow-compliance",
642
685
  eventType,
@@ -877,7 +920,7 @@ function rememberTextAsMemory(name: string, body: string): string | null {
877
920
  execFileSync(command, ["--format", "json", "-q", "remember", "--name", name, "--force"], {
878
921
  encoding: "utf8",
879
922
  timeout: AKM_CURATE_TIMEOUT_MS * 2,
880
- input: body,
923
+ input: redactSecrets(body).text,
881
924
  })
882
925
  return `memory:${name}`
883
926
  } catch {
@@ -903,6 +946,13 @@ function captureSessionMemory(
903
946
  }
904
947
 
905
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("")
906
956
  lines.push(`# Session summary (${nowIso()})`)
907
957
  lines.push(`Reason: ${reason}`)
908
958
  lines.push(`Session: ${sessionID}`)
@@ -937,6 +987,26 @@ function captureSessionMemory(
937
987
  }
938
988
 
939
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
+ }
940
1010
  for (const entry of entries) {
941
1011
  if (!entry.checkpointed) entry.checkpointed = true
942
1012
  }
@@ -947,6 +1017,26 @@ function captureSessionMemory(
947
1017
 
948
1018
  sessionFinalMemoryCaptured.add(sessionID)
949
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
+ }
950
1040
  return ref
951
1041
  }
952
1042
 
@@ -2151,6 +2241,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2151
2241
  const sid = extractSessionIdFromEvent(event) ?? extractSessionIdFromEvent((event as { properties?: unknown }).properties)
2152
2242
  if (type === "session.created" || type === "session.updated") {
2153
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
+ })
2154
2251
  if (!sessionContextEpoch.has(sid)) sessionContextEpoch.set(sid, 0)
2155
2252
  if (type === "session.created") {
2156
2253
  await ensureAgentSetup(logClient, {
@@ -2192,6 +2289,15 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2192
2289
  })
2193
2290
  await maybeIndexSessionMemory(logClient, sid, type, captured)
2194
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
+ }
2195
2301
  // Drop per-session state so a re-created session does not inherit
2196
2302
  // stale hints/curation.
2197
2303
  if (type === "session.deleted") {
@@ -2286,6 +2392,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2286
2392
  }
2287
2393
  return
2288
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
+ }
2289
2402
  output.args = args
2290
2403
  } catch (error: unknown) {
2291
2404
  await logHookFailure(logClient, "tool.execute.before", error, {
@@ -2370,14 +2483,53 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2370
2483
  text: truncateLogText(text),
2371
2484
  })
2372
2485
 
2373
- // Session-start curation already seeded context. On later user messages,
2374
- // inject only a small reminder instead of firing more automatic AKM CLI calls.
2375
2486
  if (AKM_AUTO_CURATE && input.sessionID) {
2376
- const hint = "Need more AKM context? Use `akm_search` or `akm_curate` before writing from scratch."
2377
- const current = sessionCurated.get(input.sessionID) ?? ""
2378
- if (!current.includes(hint)) {
2379
- sessionCurated.set(input.sessionID, current ? `${current}\n\n${hint}` : hint)
2380
- 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
+ }
2381
2533
  }
2382
2534
  }
2383
2535
 
@@ -2453,6 +2605,14 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2453
2605
  // has material to flush into a session summary memory.
2454
2606
  const refResult = extractToolRefs(input.tool, input.args as Record<string, unknown>, parsed)
2455
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
+ })
2456
2616
  if (refResult.refs.length > 0 && input.sessionID) {
2457
2617
  for (const ref of refResult.refs) {
2458
2618
  addBufferEntry(input.sessionID, {
@@ -2498,10 +2658,40 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2498
2658
  // memories do not accept feedback, and vault values never surface in
2499
2659
  // JSON so automatic usage signals would be misleading.
2500
2660
  if (ref.startsWith("memory:") || ref.startsWith("vault:")) continue
2501
- 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, {
2502
2682
  toolName: input.tool,
2503
2683
  sessionID: input.sessionID,
2504
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
+ }
2505
2695
  if (!ok) break
2506
2696
  }
2507
2697
  }
@@ -2641,6 +2831,108 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2641
2831
  return raw
2642
2832
  },
2643
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
+ }),
2644
2936
  akm_curate: tool({
2645
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.",
2646
2938
  args: {
@@ -2822,6 +3114,24 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2822
3114
  const model = parseModelHint(shown.modelHint)
2823
3115
  const tools = parseToolPolicy(shown.toolPolicy)
2824
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
+
2825
3135
  const targetSession = await ensureTargetSessionID({
2826
3136
  useSubtask,
2827
3137
  context: { sessionID: context.sessionID, directory: context.directory },
@@ -2852,6 +3162,19 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2852
3162
  })
2853
3163
  if (!promptResponse.ok) return JSON.stringify(promptResponse)
2854
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
+
2855
3178
  return JSON.stringify({
2856
3179
  ok: true,
2857
3180
  ref: resolved.ref,
@@ -2861,7 +3184,7 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2861
3184
  sessionID: targetSession.sessionID,
2862
3185
  model,
2863
3186
  tools,
2864
- text: extractText(promptResponse.data.parts),
3187
+ text: childText,
2865
3188
  })
2866
3189
  },
2867
3190
  }),
@@ -3176,14 +3499,18 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3176
3499
  if (!ref) return JSON.stringify({ ok: false, error: "'ref' is required for action='start'." })
3177
3500
  const args = ["workflow", "start", ref]
3178
3501
  if (params) args.push("--params", params)
3179
- 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
3180
3505
  }
3181
3506
  case "next": {
3182
3507
  const picked = target ?? run_id ?? ref
3183
3508
  if (!picked) return JSON.stringify({ ok: false, error: "'target', 'run_id', or 'ref' is required for action='next'." })
3184
3509
  const args = ["workflow", "next", picked]
3185
3510
  if (params) args.push("--params", params)
3186
- 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
3187
3514
  }
3188
3515
  case "complete": {
3189
3516
  if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='complete'." })
@@ -3192,7 +3519,16 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3192
3519
  if (state) args.push("--state", state)
3193
3520
  if (notes) args.push("--notes", notes)
3194
3521
  if (evidence) args.push("--evidence", evidence)
3195
- 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
3196
3532
  }
3197
3533
  case "status": {
3198
3534
  const picked = target ?? run_id ?? ref
@@ -3235,7 +3571,9 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3235
3571
  }
3236
3572
  case "resume": {
3237
3573
  if (!run_id) return JSON.stringify({ ok: false, error: "'run_id' is required for action='resume'." })
3238
- 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
3239
3577
  }
3240
3578
  }
3241
3579
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-opencode",
3
- "version": "0.7.3",
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": [