akm-opencode 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +4 -4
  2. package/index.ts +473 -188
  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
@@ -53,6 +53,7 @@ const sessionSuccessfulAssetTouchCount = new Map<string, number>()
53
53
  const pendingProposalSummaryCache = new Map<string, { count: number; expiresAt: number; unsupported?: boolean }>()
54
54
  const retrospectiveState = new Map<string, { recentRefs: string[]; lastNegativeSignalAt?: number }>()
55
55
  let cachedAkmStashDir: string | undefined
56
+ let agentSetupPromise: Promise<boolean> | null = null
56
57
 
57
58
  // Asset-ref grammar matching the stash skill: [origin//]type:name.
58
59
  // We validate normalized tokens individually instead of running a global regex
@@ -70,7 +71,7 @@ const AKM_WORKFLOW_INSTRUCTION = [
70
71
  "3. Record `akm_feedback` after the result is known.",
71
72
  "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.",
72
73
  "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`.",
73
- "6. Run `akm_setup` once on a fresh machine to detect installed agent CLIs and persist `agent.default`. Reflect/propose require it.",
74
+ "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.",
74
75
  `7. ${PROPOSED_QUALITY_WARNING}`,
75
76
  "8. Never accept or reject proposals, push saves, remove sources, or access vault values without explicit user approval.",
76
77
  ].join("\n")
@@ -218,6 +219,93 @@ function formatCliError(error: unknown): string {
218
219
  return error instanceof Error ? error.message : String(error)
219
220
  }
220
221
 
222
+ function needsAgentSetup(message: string): boolean {
223
+ return /(agent commands are disabled|agent not configured|no [`'"]?agent[`'"]? block in config\.json)/i.test(message)
224
+ }
225
+
226
+ function addAgentSetupGuidance(message: string): string {
227
+ if (!needsAgentSetup(message)) return message
228
+ if (/\bakm init\b|\bakm_init\b|\bakm setup\b/i.test(message)) return message
229
+ 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
+ }
231
+
232
+ async function ensurePlatformAgentDefault(
233
+ client: LogCapableClient,
234
+ meta: { directory?: string; sessionID?: string; trigger: string; platform: string },
235
+ ): Promise<boolean> {
236
+ const command = resolveAkmCommand()
237
+ if (typeof command !== "string") {
238
+ await writePluginLog(client, "warn", "AKM agent default check skipped", {
239
+ subsystem: "akm",
240
+ trigger: meta.trigger,
241
+ sessionID: meta.sessionID,
242
+ directory: meta.directory,
243
+ error: command.error,
244
+ })
245
+ return false
246
+ }
247
+
248
+ try {
249
+ const currentRaw = execFileSync(command, ["config", "get", "agent.default", "--format", "json"], {
250
+ encoding: "utf8",
251
+ timeout: 30_000,
252
+ })
253
+ const parsed = safeJsonParse<unknown>(currentRaw)
254
+ const current = typeof parsed === "string"
255
+ ? parsed.trim()
256
+ : parsed && typeof parsed === "object"
257
+ ? typeof (parsed as Record<string, unknown>).value === "string"
258
+ ? ((parsed as Record<string, unknown>).value as string).trim()
259
+ : typeof (parsed as Record<string, unknown>)["agent.default"] === "string"
260
+ ? ((parsed as Record<string, unknown>)["agent.default"] as string).trim()
261
+ : ""
262
+ : ""
263
+ if (current) return true
264
+
265
+ execFileSync(command, ["config", "set", "agent.default", meta.platform, "--format", "json"], {
266
+ encoding: "utf8",
267
+ timeout: 30_000,
268
+ })
269
+ await writePluginLog(client, "info", "AKM agent default initialized", {
270
+ subsystem: "akm",
271
+ trigger: meta.trigger,
272
+ sessionID: meta.sessionID,
273
+ directory: meta.directory,
274
+ agentDefault: meta.platform,
275
+ })
276
+ return true
277
+ } catch (error: unknown) {
278
+ await writePluginLog(client, "warn", "AKM agent default initialization failed", {
279
+ subsystem: "akm",
280
+ trigger: meta.trigger,
281
+ sessionID: meta.sessionID,
282
+ directory: meta.directory,
283
+ platform: meta.platform,
284
+ error: addAgentSetupGuidance(formatCliError(error)),
285
+ stdout: toLogString((error as { stdout?: unknown }).stdout) ?? "",
286
+ stderr: toLogString((error as { stderr?: unknown }).stderr) ?? "",
287
+ })
288
+ return false
289
+ }
290
+ }
291
+
292
+ async function ensureAgentSetup(
293
+ client: LogCapableClient,
294
+ meta: { directory?: string; sessionID?: string; trigger: string; platform: string },
295
+ ): Promise<boolean> {
296
+ if (agentSetupPromise) return agentSetupPromise
297
+ const promise = ensurePlatformAgentDefault(client, meta)
298
+ const trackedPromise = promise.finally(() => {
299
+ if (agentSetupPromise === trackedPromise) agentSetupPromise = null
300
+ })
301
+ agentSetupPromise = trackedPromise
302
+ return trackedPromise
303
+ }
304
+
305
+ function isNotIndexedFeedbackError(message: string): boolean {
306
+ return /not in the current index|run ["'`]?akm index["'`]? and try again/i.test(message)
307
+ }
308
+
221
309
  function toLogString(value: unknown): string | undefined {
222
310
  if (typeof value === "string") return value
223
311
  if (value instanceof Buffer) return value.toString("utf8")
@@ -246,6 +334,20 @@ async function writePluginLog(client: LogCapableClient, level: LogLevel, message
246
334
  }
247
335
  }
248
336
 
337
+ async function logHookFailure(
338
+ client: LogCapableClient,
339
+ hook: string,
340
+ error: unknown,
341
+ extra?: Record<string, unknown>,
342
+ ) {
343
+ await writePluginLog(client, "error", `AKM ${hook} hook failed`, {
344
+ subsystem: "hook",
345
+ hook,
346
+ error: formatCliError(error),
347
+ ...extra,
348
+ })
349
+ }
350
+
249
351
  function nowIso(): string {
250
352
  return new Date().toISOString()
251
353
  }
@@ -347,9 +449,20 @@ function runCurate(args: string[]): string | null {
347
449
  return body || null
348
450
  }
349
451
 
350
- function runCurateForPrompt(text: string, sessionID?: string): string | null {
452
+ async function runCurateLogged(
453
+ client: LogCapableClient,
454
+ args: string[],
455
+ meta: CliLogMeta & { operation: string },
456
+ ): Promise<string | null> {
457
+ return runCliSyncBestEffort(client, args, AKM_CURATE_TIMEOUT_MS, {
458
+ ...meta,
459
+ subsystem: "curation",
460
+ })
461
+ }
462
+
463
+ async function runCurateForPrompt(client: LogCapableClient, text: string, sessionID?: string): Promise<string | null> {
351
464
  if (!text || text.length < AKM_CURATE_MIN_CHARS) return null
352
- return runCurate(
465
+ return runCurateLogged(client,
353
466
  appendRunScopeArgs(
354
467
  [
355
468
  "--detail",
@@ -364,11 +477,12 @@ function runCurateForPrompt(text: string, sessionID?: string): string | null {
364
477
  ],
365
478
  sessionID,
366
479
  ),
480
+ { toolName: "chat.message", sessionID, operation: "prompt-curate" },
367
481
  )
368
482
  }
369
483
 
370
- function runCurateForSession(sessionID: string): string | null {
371
- return runCurate(
484
+ async function runCurateForSession(client: LogCapableClient, sessionID: string): Promise<string | null> {
485
+ return runCurateLogged(client,
372
486
  appendRunScopeArgs(
373
487
  [
374
488
  "--detail",
@@ -382,14 +496,17 @@ function runCurateForSession(sessionID: string): string | null {
382
496
  ],
383
497
  sessionID,
384
498
  ),
499
+ { toolName: "session.start", sessionID, operation: "session-curate" },
385
500
  )
386
501
  }
387
502
 
388
- function runHintsForSession(): string | null {
389
- const result = runCliSyncRaw(["--format", "text", "-q", "hints"], AKM_CURATE_TIMEOUT_MS)
390
- if (!result.ok) return null
391
- const body = result.stdout.trim()
392
- return body || null
503
+ async function runHintsForSession(client: LogCapableClient, sessionID?: string): Promise<string | null> {
504
+ return runCliSyncBestEffort(client, ["--format", "text", "-q", "hints"], AKM_CURATE_TIMEOUT_MS, {
505
+ toolName: "session.start",
506
+ sessionID,
507
+ subsystem: "hints",
508
+ operation: "session-hints",
509
+ })
393
510
  }
394
511
 
395
512
  function summarizeWorkflowList(value: unknown): string | null {
@@ -423,10 +540,15 @@ function summarizeWorkflowList(value: unknown): string | null {
423
540
  return null
424
541
  }
425
542
 
426
- function runWorkflowSummaryForSession(): string | null {
427
- const result = runCliSyncRaw(["--format", "json", "-q", "workflow", "list", "--active"], AKM_CURATE_TIMEOUT_MS)
428
- if (!result.ok) return null
429
- const parsed = parseMaybeJson(result.stdout)
543
+ async function runWorkflowSummaryForSession(client: LogCapableClient, sessionID?: string): Promise<string | null> {
544
+ const raw = await runCliSyncBestEffort(client, ["--format", "json", "-q", "workflow", "list", "--active"], AKM_CURATE_TIMEOUT_MS, {
545
+ toolName: "session.start",
546
+ sessionID,
547
+ subsystem: "workflow",
548
+ operation: "active-workflow-summary",
549
+ })
550
+ if (!raw) return null
551
+ const parsed = parseMaybeJson(raw)
430
552
  const summary = summarizeWorkflowList(
431
553
  Array.isArray(parsed)
432
554
  ? parsed
@@ -463,14 +585,20 @@ function summarizeCuratorReportForContext(report: string): string {
463
585
  return `${report.slice(0, AKM_CURATOR_CONTEXT_MAX_CHARS).trimEnd()}\n\n[truncated for context]`
464
586
  }
465
587
 
466
- function getAkmStashDir(): string | undefined {
588
+ async function getAkmStashDir(client?: LogCapableClient): Promise<string | undefined> {
467
589
  if (cachedAkmStashDir !== undefined) return cachedAkmStashDir || undefined
468
- const result = runCliSyncRaw(["--format", "json", "-q", "config", "get", "stashDir"], AKM_CURATE_TIMEOUT_MS)
469
- if (!result.ok) {
590
+ const raw = client
591
+ ? await runCliSyncBestEffort(client, ["--format", "json", "-q", "config", "get", "stashDir"], AKM_CURATE_TIMEOUT_MS, {
592
+ toolName: "shell.env",
593
+ subsystem: "config",
594
+ operation: "get-stash-dir",
595
+ })
596
+ : runCurate(["--format", "json", "-q", "config", "get", "stashDir"])
597
+ if (!raw) {
470
598
  cachedAkmStashDir = ""
471
599
  return undefined
472
600
  }
473
- const parsed = parseMaybeJson(result.stdout)
601
+ const parsed = parseMaybeJson(raw)
474
602
  if (typeof parsed === "string" && parsed.trim()) {
475
603
  cachedAkmStashDir = parsed.trim()
476
604
  return cachedAkmStashDir
@@ -484,7 +612,6 @@ function getAkmStashDir(): string | undefined {
484
612
  }
485
613
  }
486
614
  }
487
- const raw = result.stdout.trim()
488
615
  cachedAkmStashDir = raw || ""
489
616
  return cachedAkmStashDir || undefined
490
617
  }
@@ -518,6 +645,29 @@ function emitWorkflowTelemetry(client: LogCapableClient, level: LogLevel, eventT
518
645
  })
519
646
  }
520
647
 
648
+ async function runCliSyncBestEffort(
649
+ client: LogCapableClient,
650
+ args: string[],
651
+ timeoutMs: number,
652
+ meta: CliLogMeta & { subsystem: string; operation: string },
653
+ ): Promise<string | null> {
654
+ const result = runCliSyncRaw(args, timeoutMs)
655
+ if (!result.ok) {
656
+ await writePluginLog(client, "warn", "AKM synchronous helper failed", {
657
+ subsystem: meta.subsystem,
658
+ operation: meta.operation,
659
+ toolName: meta.toolName,
660
+ sessionID: meta.sessionID,
661
+ directory: meta.directory,
662
+ args,
663
+ error: result.error,
664
+ })
665
+ return null
666
+ }
667
+ const body = result.stdout.trim()
668
+ return body || null
669
+ }
670
+
521
671
  function noteRecentRefs(sessionID: string | undefined, refs: string[]) {
522
672
  if (!sessionID || refs.length === 0) return
523
673
  const state = retrospectiveState.get(sessionID) ?? { recentRefs: [] }
@@ -575,7 +725,7 @@ async function recordRetrospectiveFeedback(client: LogCapableClient, sessionID:
575
725
  sessionID,
576
726
  })
577
727
  const parsed = safeJsonParse<{ ok?: boolean }>(raw)
578
- if (parsed?.ok !== false) {
728
+ if (parsed?.ok === true) {
579
729
  await emitWorkflowTelemetry(client, "info", "akm.feedback.recorded", {
580
730
  sessionID,
581
731
  toolName: "akm_feedback",
@@ -678,6 +828,21 @@ function queueFeedback(
678
828
  stdio: "ignore",
679
829
  },
680
830
  )
831
+ child.on("exit", (code, signal) => {
832
+ if ((typeof code === "number" && code !== 0) || signal) {
833
+ void writePluginLog(client, "warn", "AKM auto-feedback failed", {
834
+ subsystem: "feedback",
835
+ toolName: meta.toolName,
836
+ sessionID: meta.sessionID,
837
+ directory: meta.directory,
838
+ ref,
839
+ sentiment,
840
+ error: signal
841
+ ? `akm feedback exited via signal ${signal}`
842
+ : `akm feedback exited with code ${code}`,
843
+ })
844
+ }
845
+ })
681
846
  child.on("error", (error) => {
682
847
  void writePluginLog(client, "warn", "AKM auto-feedback failed", {
683
848
  subsystem: "feedback",
@@ -1314,11 +1479,7 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
1314
1479
  const fullArgs = args.includes("--format") ? [...args] : [...args, "--format", "json"]
1315
1480
  const proposalId = args[0] === "proposal" && typeof args[2] === "string" ? args[2] : null
1316
1481
 
1317
- try {
1318
- const stdout = execFileSync(command, fullArgs, {
1319
- encoding: "utf8",
1320
- timeout: 60_000,
1321
- })
1482
+ const recordSuccess = async (stdout: string): Promise<string> => {
1322
1483
  await writePluginLog(client, "info", "AKM command completed", {
1323
1484
  subsystem: "akm",
1324
1485
  toolName: meta.toolName,
@@ -1384,8 +1545,20 @@ async function runCli(client: LogCapableClient, args: string[], meta: CliLogMeta
1384
1545
  })
1385
1546
  }
1386
1547
  return stdout
1548
+ }
1549
+
1550
+ try {
1551
+ const stdout = execFileSync(command, fullArgs, {
1552
+ encoding: "utf8",
1553
+ timeout: 60_000,
1554
+ })
1555
+ return recordSuccess(stdout)
1387
1556
  } catch (error: unknown) {
1388
- const message = formatCliError(error)
1557
+ let message = formatCliError(error)
1558
+ if (["akm_reflect", "akm_propose"].includes(meta.toolName) && needsAgentSetup(message)) {
1559
+ message = addAgentSetupGuidance(message)
1560
+ }
1561
+ message = addAgentSetupGuidance(message)
1389
1562
  await writePluginLog(client, "error", "AKM command failed", {
1390
1563
  subsystem: "akm",
1391
1564
  toolName: meta.toolName,
@@ -1980,9 +2153,15 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
1980
2153
  if (!sid) return
1981
2154
  if (!sessionContextEpoch.has(sid)) sessionContextEpoch.set(sid, 0)
1982
2155
  if (type === "session.created") {
2156
+ await ensureAgentSetup(logClient, {
2157
+ directory,
2158
+ sessionID: sid,
2159
+ trigger: "session.created",
2160
+ platform: "opencode",
2161
+ })
1983
2162
  warmIndexInBackground()
1984
2163
  if (AKM_AUTO_CURATE && !sessionCurated.has(sid)) {
1985
- const curated = runCurateForSession(sid)
2164
+ const curated = await runCurateForSession(logClient, sid)
1986
2165
  if (curated) {
1987
2166
  bumpCuratedVersion(sid)
1988
2167
  sessionCurated.set(sid, curated)
@@ -1990,11 +2169,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
1990
2169
  }
1991
2170
  }
1992
2171
  if (AKM_AUTO_HINTS && !sessionHints.has(sid)) {
1993
- const hints = runHintsForSession()
2172
+ const hints = await runHintsForSession(logClient, sid)
1994
2173
  if (hints) sessionHints.set(sid, hints)
1995
2174
  }
1996
2175
  if (!sessionWorkflow.has(sid)) {
1997
- sessionWorkflow.set(sid, runWorkflowSummaryForSession() ?? "")
2176
+ sessionWorkflow.set(sid, await runWorkflowSummaryForSession(logClient, sid) ?? "")
1998
2177
  }
1999
2178
  const proposalSummary = await getPendingProposalCount(logClient, sid)
2000
2179
  if (!proposalSummary.unsupported && proposalSummary.count > 0) {
@@ -2029,8 +2208,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2029
2208
  sessionBuffer.delete(sid)
2030
2209
  }
2031
2210
  }
2032
- } catch {
2033
- // Lifecycle hooks must never throw into the TUI.
2211
+ } catch (error: unknown) {
2212
+ await logHookFailure(logClient, "event", error)
2034
2213
  }
2035
2214
  },
2036
2215
  // Stop is the closest analogue to Claude's Stop/SubagentStop — the user or
@@ -2051,8 +2230,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2051
2230
  })
2052
2231
  await maybeIndexSessionMemory(logClient, sid, "stop", captured)
2053
2232
  }
2054
- } catch {
2055
- // Best-effort only.
2233
+ } catch (error: unknown) {
2234
+ await logHookFailure(logClient, "stop", error)
2056
2235
  }
2057
2236
  },
2058
2237
  // experimental.chat.system.transform is how OpenCode exposes the
@@ -2091,8 +2270,8 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2091
2270
  sessionCuratedInjectedVersion.set(sid, curatedVersion)
2092
2271
  }
2093
2272
  }
2094
- } catch {
2095
- // Never break the turn because of a transform failure.
2273
+ } catch (error: unknown) {
2274
+ await logHookFailure(logClient, "experimental.chat.system.transform", error)
2096
2275
  }
2097
2276
  },
2098
2277
  "tool.execute.before": async (input, output) => {
@@ -2108,8 +2287,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2108
2287
  return
2109
2288
  }
2110
2289
  output.args = args
2111
- } catch {
2112
- // Never break tool execution from the pre-hook.
2290
+ } catch (error: unknown) {
2291
+ await logHookFailure(logClient, "tool.execute.before", error, {
2292
+ toolName: input?.tool,
2293
+ sessionID: input?.sessionID,
2294
+ })
2113
2295
  }
2114
2296
  },
2115
2297
  "permission.ask": async (input, output) => {
@@ -2137,8 +2319,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2137
2319
  command,
2138
2320
  category: assessment.category,
2139
2321
  })
2140
- } catch {
2141
- // Best-effort only.
2322
+ } catch (error: unknown) {
2323
+ await logHookFailure(logClient, "permission.ask", error, {
2324
+ sessionID: input?.sessionID,
2325
+ command: typeof input?.metadata?.command === "string" ? input.metadata.command : undefined,
2326
+ })
2142
2327
  }
2143
2328
  },
2144
2329
  "command.execute.before": async (input, output) => {
@@ -2155,154 +2340,180 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2155
2340
  command,
2156
2341
  category: assessment.category,
2157
2342
  })
2158
- } catch {
2159
- // Best-effort only.
2343
+ } catch (error: unknown) {
2344
+ await logHookFailure(logClient, "command.execute.before", error, {
2345
+ sessionID: input?.sessionID,
2346
+ command: input?.command,
2347
+ })
2160
2348
  }
2161
2349
  },
2162
2350
  "shell.env": async (_input, output) => {
2163
2351
  try {
2164
2352
  output.env.AKM_PROJECT = worktree
2165
2353
  output.env.AKM_PLUGIN_VERSION = PLUGIN_VERSION
2166
- const stashDir = getAkmStashDir()
2354
+ const stashDir = await getAkmStashDir(logClient)
2167
2355
  if (stashDir) output.env.AKM_STASH_DIR = stashDir
2168
- } catch {
2169
- // Best-effort only.
2356
+ } catch (error: unknown) {
2357
+ await logHookFailure(logClient, "shell.env", error)
2170
2358
  }
2171
2359
  },
2172
2360
  "chat.message": async (input, output) => {
2173
- const text = extractText(output.parts).trim()
2174
- if (!text) return
2175
- await writePluginLog(logClient, "info", "AKM user feedback recorded", {
2176
- subsystem: "feedback",
2177
- actor: "user",
2178
- sessionID: input.sessionID,
2179
- messageID: input.messageID,
2180
- agent: input.agent,
2181
- text: truncateLogText(text),
2182
- })
2361
+ try {
2362
+ const text = extractText(output.parts).trim()
2363
+ if (!text) return
2364
+ await writePluginLog(logClient, "info", "AKM user feedback recorded", {
2365
+ subsystem: "feedback",
2366
+ actor: "user",
2367
+ sessionID: input.sessionID,
2368
+ messageID: input.messageID,
2369
+ agent: input.agent,
2370
+ text: truncateLogText(text),
2371
+ })
2183
2372
 
2184
- // Compound-engineering loop: on every user message, curate the stash and
2185
- // stash the result so experimental.chat.system.transform can inject it.
2186
- if (AKM_AUTO_CURATE && input.sessionID) {
2187
- const curated = runCurateForPrompt(text, input.sessionID)
2188
- if (curated) {
2189
- sessionCurated.set(input.sessionID, curated)
2190
- bumpCuratedVersion(input.sessionID)
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
+ 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)
2381
+ }
2191
2382
  }
2192
- }
2193
2383
 
2194
- // Track explicit memory intents so capture-memory has something durable
2195
- // to flush when the session ends.
2196
- if (/\b(remember|memory|memories)\b/i.test(text)) {
2197
- addBufferEntry(input.sessionID, {
2198
- kind: "memory-intent",
2199
- note: truncateLogText(text, 500),
2200
- })
2201
- }
2384
+ // Track explicit memory intents so capture-memory has something durable
2385
+ // to flush when the session ends.
2386
+ if (/\b(remember|memory|memories)\b/i.test(text)) {
2387
+ addBufferEntry(input.sessionID, {
2388
+ kind: "memory-intent",
2389
+ note: truncateLogText(text, 500),
2390
+ })
2391
+ }
2202
2392
 
2203
- if (input.sessionID && AKM_AUTO_FEEDBACK && AKM_RETROSPECTIVE_FEEDBACK_RE.test(text)) {
2204
- const recentRefs = (sessionBuffer.get(input.sessionID) ?? [])
2205
- .filter((entry) => entry.kind === "tool-ref" && !!entry.ref)
2206
- .map((entry) => entry.ref!)
2207
- .filter((ref, index, refs) => !ref.startsWith("memory:") && !ref.startsWith("vault:") && refs.indexOf(ref) === index)
2208
- .slice(-3)
2209
- const dedupe = new Set<string>()
2210
- for (const ref of recentRefs) {
2211
- queueFeedback(logClient, ref, "positive", "opencode retrospective: user confirmed it worked", {
2212
- toolName: "chat.message",
2213
- sessionID: input.sessionID,
2214
- }, dedupe)
2393
+ if (input.sessionID && AKM_AUTO_FEEDBACK && AKM_RETROSPECTIVE_FEEDBACK_RE.test(text)) {
2394
+ const recentRefs = (sessionBuffer.get(input.sessionID) ?? [])
2395
+ .filter((entry) => entry.kind === "tool-ref" && !!entry.ref)
2396
+ .map((entry) => entry.ref!)
2397
+ .filter((ref, index, refs) => !ref.startsWith("memory:") && !ref.startsWith("vault:") && refs.indexOf(ref) === index)
2398
+ .slice(-3)
2399
+ const dedupe = new Set<string>()
2400
+ for (const ref of recentRefs) {
2401
+ queueFeedback(logClient, ref, "positive", "opencode retrospective: user confirmed it worked", {
2402
+ toolName: "chat.message",
2403
+ sessionID: input.sessionID,
2404
+ }, dedupe)
2405
+ }
2215
2406
  }
2407
+ await recordRetrospectiveFeedback(logClient, input.sessionID, text)
2408
+ } catch (error: unknown) {
2409
+ await writePluginLog(logClient, "error", "AKM chat.message hook failed", {
2410
+ subsystem: "hook",
2411
+ hook: "chat.message",
2412
+ sessionID: input?.sessionID,
2413
+ messageID: input?.messageID,
2414
+ agent: input?.agent,
2415
+ error: formatCliError(error),
2416
+ })
2216
2417
  }
2217
- await recordRetrospectiveFeedback(logClient, input.sessionID, text)
2218
2418
  },
2219
2419
  "tool.execute.after": async (input, output) => {
2220
- if (!input.tool.startsWith("akm_")) return
2420
+ try {
2421
+ if (!input.tool.startsWith("akm_")) return
2221
2422
 
2222
- const parsed = parseToolOutput(output.output)
2223
- if (!parsed) return
2423
+ const parsed = parseToolOutput(output.output)
2424
+ if (!parsed) return
2224
2425
 
2225
- const feedback = classifyToolFeedback(parsed)
2226
- if (feedback) {
2227
- await writePluginLog(logClient, feedback === "negative" ? "warn" : "info", "AKM system feedback recorded", {
2228
- subsystem: "feedback",
2229
- actor: "system",
2230
- feedback,
2231
- toolName: input.tool,
2232
- sessionID: input.sessionID,
2233
- callID: input.callID,
2234
- title: output.title,
2235
- error: typeof (parsed as { error?: unknown }).error === "string" ? (parsed as { error?: string }).error : undefined,
2236
- })
2237
- }
2426
+ const feedback = classifyToolFeedback(parsed)
2427
+ if (feedback) {
2428
+ await writePluginLog(logClient, feedback === "negative" ? "warn" : "info", "AKM system feedback recorded", {
2429
+ subsystem: "feedback",
2430
+ actor: "system",
2431
+ feedback,
2432
+ toolName: input.tool,
2433
+ sessionID: input.sessionID,
2434
+ callID: input.callID,
2435
+ title: output.title,
2436
+ error: typeof (parsed as { error?: unknown }).error === "string" ? (parsed as { error?: string }).error : undefined,
2437
+ })
2438
+ }
2238
2439
 
2239
- const memoryRefs = extractMemoryRefs(input.tool, input.args as Record<string, unknown>, parsed)
2240
- if (memoryRefs.length > 0) {
2241
- await writePluginLog(logClient, "info", "AKM memory usage recorded", {
2242
- subsystem: "memory",
2243
- toolName: input.tool,
2244
- sessionID: input.sessionID,
2245
- callID: input.callID,
2246
- refs: memoryRefs,
2247
- })
2248
- }
2440
+ const memoryRefs = extractMemoryRefs(input.tool, input.args as Record<string, unknown>, parsed)
2441
+ if (memoryRefs.length > 0) {
2442
+ await writePluginLog(logClient, "info", "AKM memory usage recorded", {
2443
+ subsystem: "memory",
2444
+ toolName: input.tool,
2445
+ sessionID: input.sessionID,
2446
+ callID: input.callID,
2447
+ refs: memoryRefs,
2448
+ })
2449
+ }
2249
2450
 
2250
- // Auto-feedback + session buffering: record every asset ref the tool
2251
- // touched so the stash ranking improves over time and so Stop/Compact
2252
- // has material to flush into a session summary memory.
2451
+ // Auto-feedback + session buffering: record every asset ref the tool
2452
+ // touched so the stash ranking improves over time and so Stop/Compact
2453
+ // has material to flush into a session summary memory.
2253
2454
  const refResult = extractToolRefs(input.tool, input.args as Record<string, unknown>, parsed)
2254
2455
  noteRecentRefs(input.sessionID, refResult.refs)
2255
2456
  if (refResult.refs.length > 0 && input.sessionID) {
2256
- for (const ref of refResult.refs) {
2257
- addBufferEntry(input.sessionID, {
2258
- kind: "tool-ref",
2259
- toolName: input.tool,
2260
- ref,
2261
- status: feedback ?? "unknown",
2262
- })
2263
- }
2264
- if (feedback === "positive") {
2265
- sessionSuccessfulAssetTouchCount.set(
2266
- input.sessionID,
2267
- (sessionSuccessfulAssetTouchCount.get(input.sessionID) ?? 0) + 1,
2268
- )
2269
- const checkpointRef = maybeCheckpointSessionMemory(input.sessionID)
2270
- if (checkpointRef) {
2271
- await writePluginLog(logClient, "info", "AKM checkpoint memory captured", {
2272
- subsystem: "memory",
2273
- actor: "system",
2274
- sessionID: input.sessionID,
2275
- reason: "checkpoint",
2276
- ref: checkpointRef,
2457
+ for (const ref of refResult.refs) {
2458
+ addBufferEntry(input.sessionID, {
2459
+ kind: "tool-ref",
2460
+ toolName: input.tool,
2461
+ ref,
2462
+ status: feedback ?? "unknown",
2277
2463
  })
2278
2464
  }
2465
+ if (feedback === "positive") {
2466
+ sessionSuccessfulAssetTouchCount.set(
2467
+ input.sessionID,
2468
+ (sessionSuccessfulAssetTouchCount.get(input.sessionID) ?? 0) + 1,
2469
+ )
2470
+ const checkpointRef = maybeCheckpointSessionMemory(input.sessionID)
2471
+ if (checkpointRef) {
2472
+ await writePluginLog(logClient, "info", "AKM checkpoint memory captured", {
2473
+ subsystem: "memory",
2474
+ actor: "system",
2475
+ sessionID: input.sessionID,
2476
+ reason: "checkpoint",
2477
+ ref: checkpointRef,
2478
+ })
2479
+ }
2480
+ }
2279
2481
  }
2280
- }
2281
2482
 
2282
- if (
2283
- AKM_AUTO_FEEDBACK
2284
- && feedback
2285
- && input.tool !== "akm_feedback"
2286
- && refResult.refs.length > 0
2287
- ) {
2288
- const dedupe = new Set<string>()
2289
- const feedbackRefs = feedback === "positive"
2290
- ? refResult.refs
2291
- : refResult.refs.filter((ref) => !refResult.positiveOnlyRefs.includes(ref))
2292
- const note = feedback === "positive"
2293
- ? `opencode auto: ${input.tool} succeeded`
2294
- : `opencode auto: ${input.tool} failed`
2295
- for (const ref of feedbackRefs) {
2296
- // Memories and vault refs are not first-class feedback targets —
2297
- // memories do not accept feedback, and vault values never surface in
2298
- // JSON so automatic usage signals would be misleading.
2299
- if (ref.startsWith("memory:") || ref.startsWith("vault:")) continue
2300
- const ok = queueFeedback(logClient, ref, feedback, note, {
2301
- toolName: input.tool,
2302
- sessionID: input.sessionID,
2303
- }, dedupe)
2304
- if (!ok) break
2483
+ if (
2484
+ AKM_AUTO_FEEDBACK
2485
+ && feedback
2486
+ && input.tool !== "akm_feedback"
2487
+ && refResult.refs.length > 0
2488
+ ) {
2489
+ const dedupe = new Set<string>()
2490
+ const feedbackRefs = feedback === "positive"
2491
+ ? refResult.refs
2492
+ : refResult.refs.filter((ref) => !refResult.positiveOnlyRefs.includes(ref))
2493
+ const note = feedback === "positive"
2494
+ ? `opencode auto: ${input.tool} succeeded`
2495
+ : `opencode auto: ${input.tool} failed`
2496
+ for (const ref of feedbackRefs) {
2497
+ // Memories and vault refs are not first-class feedback targets —
2498
+ // memories do not accept feedback, and vault values never surface in
2499
+ // JSON so automatic usage signals would be misleading.
2500
+ if (ref.startsWith("memory:") || ref.startsWith("vault:")) continue
2501
+ const ok = queueFeedback(logClient, ref, feedback, note, {
2502
+ toolName: input.tool,
2503
+ sessionID: input.sessionID,
2504
+ }, dedupe)
2505
+ if (!ok) break
2506
+ }
2305
2507
  }
2508
+ } catch (error: unknown) {
2509
+ await writePluginLog(logClient, "error", "AKM tool.execute.after hook failed", {
2510
+ subsystem: "hook",
2511
+ hook: "tool.execute.after",
2512
+ toolName: input?.tool,
2513
+ sessionID: input?.sessionID,
2514
+ callID: input?.callID,
2515
+ error: formatCliError(error),
2516
+ })
2306
2517
  }
2307
2518
  },
2308
2519
  tool: {
@@ -2393,6 +2604,32 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2393
2604
  if (note) args.push("--note", note)
2394
2605
  args.push(...buildScopedArgs(context as unknown as Record<string, unknown>))
2395
2606
  const raw = await runCli(client as unknown as LogCapableClient, args, { toolName: "akm_feedback", sessionID: context.sessionID, directory: context.directory })
2607
+ const parsed = safeJsonParse<{ ok?: boolean; error?: string }>(raw)
2608
+ if (parsed?.ok === false) {
2609
+ const error = parsed.error ?? "Unknown akm feedback error"
2610
+ if (isNotIndexedFeedbackError(error)) {
2611
+ await writePluginLog(logClient, "warn", "AKM feedback skipped", {
2612
+ subsystem: "feedback",
2613
+ toolName: "akm_feedback",
2614
+ sessionID: context.sessionID,
2615
+ directory: context.directory,
2616
+ ref,
2617
+ sentiment,
2618
+ reason: "ref_not_indexed",
2619
+ error,
2620
+ })
2621
+ await emitWorkflowTelemetry(logClient, "warn", "akm.feedback.skipped", {
2622
+ sessionID: context.sessionID,
2623
+ toolName: "akm_feedback",
2624
+ assetRef: ref,
2625
+ outcome: "skipped",
2626
+ reason: "ref not indexed",
2627
+ directory: context.directory,
2628
+ })
2629
+ return JSON.stringify({ ok: true, skipped: true, reason: "ref_not_indexed", ref, sentiment })
2630
+ }
2631
+ return raw
2632
+ }
2396
2633
  await emitWorkflowTelemetry(logClient, "info", "akm.feedback.recorded", {
2397
2634
  sessionID: context.sessionID,
2398
2635
  toolName: "akm_feedback",
@@ -2482,16 +2719,27 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2482
2719
  description: "Read compact text summaries of the parent session's messages so a dispatched AKM subagent can inherit upstream context.",
2483
2720
  args: {},
2484
2721
  async execute(_input, context) {
2485
- const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
2486
- if (!parent.ok) return JSON.stringify(parent)
2487
- const messages = await sdkClient.session.messages({
2488
- path: { id: parent.parentID },
2489
- query: { directory: context.directory },
2490
- })
2491
- if (messages.error || !messages.data) {
2722
+ try {
2723
+ const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
2724
+ if (!parent.ok) return JSON.stringify(parent)
2725
+ const messages = await sdkClient.session.messages({
2726
+ path: { id: parent.parentID },
2727
+ query: { directory: context.directory },
2728
+ })
2729
+ if (messages.error || !messages.data) {
2730
+ return JSON.stringify({ ok: false, error: "Failed to read parent session messages." })
2731
+ }
2732
+ return JSON.stringify(summarizeSessionMessages(parent.parentID, messages.data))
2733
+ } catch (error: unknown) {
2734
+ await writePluginLog(logClient, "error", "AKM parent session read failed", {
2735
+ subsystem: "session",
2736
+ toolName: "akm_parent_messages",
2737
+ sessionID: context.sessionID,
2738
+ directory: context.directory,
2739
+ error: formatCliError(error),
2740
+ })
2492
2741
  return JSON.stringify({ ok: false, error: "Failed to read parent session messages." })
2493
2742
  }
2494
- return JSON.stringify(summarizeSessionMessages(parent.parentID, messages.data))
2495
2743
  },
2496
2744
  }),
2497
2745
  akm_session_messages: tool({
@@ -2500,23 +2748,35 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2500
2748
  session_id: tool.schema.string().describe("OpenCode session ID to inspect."),
2501
2749
  },
2502
2750
  async execute({ session_id }, context) {
2503
- const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
2504
- const allowedSessionIDs = new Set<string>([context.sessionID])
2505
- if (parent.ok) allowedSessionIDs.add(parent.parentID)
2506
- if (context.agent !== "akm-curator" && !allowedSessionIDs.has(session_id)) {
2507
- return JSON.stringify({
2508
- ok: false,
2509
- error: "akm_session_messages only allows arbitrary session IDs for the akm-curator agent. Use akm_parent_messages for parent context.",
2751
+ try {
2752
+ const parent = await getParentSessionID(sdkClient, context.sessionID, context.directory)
2753
+ const allowedSessionIDs = new Set<string>([context.sessionID])
2754
+ if (parent.ok) allowedSessionIDs.add(parent.parentID)
2755
+ if (context.agent !== "akm-curator" && !allowedSessionIDs.has(session_id)) {
2756
+ return JSON.stringify({
2757
+ ok: false,
2758
+ error: "akm_session_messages only allows arbitrary session IDs for the akm-curator agent. Use akm_parent_messages for parent context.",
2759
+ })
2760
+ }
2761
+ const messages = await sdkClient.session.messages({
2762
+ path: { id: session_id },
2763
+ query: { directory: context.directory },
2764
+ })
2765
+ if (messages.error || !messages.data) {
2766
+ return JSON.stringify({ ok: false, error: `Failed to read messages for session '${session_id}'.` })
2767
+ }
2768
+ return JSON.stringify(summarizeSessionMessages(session_id, messages.data))
2769
+ } catch (error: unknown) {
2770
+ await writePluginLog(logClient, "error", "AKM session messages read failed", {
2771
+ subsystem: "session",
2772
+ toolName: "akm_session_messages",
2773
+ sessionID: context.sessionID,
2774
+ directory: context.directory,
2775
+ targetSessionID: session_id,
2776
+ error: formatCliError(error),
2510
2777
  })
2511
- }
2512
- const messages = await sdkClient.session.messages({
2513
- path: { id: session_id },
2514
- query: { directory: context.directory },
2515
- })
2516
- if (messages.error || !messages.data) {
2517
2778
  return JSON.stringify({ ok: false, error: `Failed to read messages for session '${session_id}'.` })
2518
2779
  }
2519
- return JSON.stringify(summarizeSessionMessages(session_id, messages.data))
2520
2780
  },
2521
2781
  }),
2522
2782
  akm_agent: tool({
@@ -2734,6 +2994,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2734
2994
  })
2735
2995
  return JSON.stringify({ ok: true, ref, shell: stdout.trim() })
2736
2996
  } catch (error: unknown) {
2997
+ await writePluginLog(logClient, "error", "AKM vault load failed", {
2998
+ subsystem: "vault",
2999
+ toolName: "akm_vault",
3000
+ action: "load",
3001
+ ref,
3002
+ error: formatCliError(error),
3003
+ })
2737
3004
  return JSON.stringify({ ok: false, error: formatCliError(error) })
2738
3005
  }
2739
3006
  }
@@ -2841,6 +3108,15 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2841
3108
  })
2842
3109
  return stdout
2843
3110
  } catch (error: unknown) {
3111
+ await writePluginLog(logClient, "error", "AKM wiki stash failed", {
3112
+ subsystem: "wiki",
3113
+ toolName: "akm_wiki",
3114
+ action: "stash",
3115
+ name,
3116
+ source,
3117
+ as_slug,
3118
+ error: formatCliError(error),
3119
+ })
2844
3120
  return JSON.stringify({ ok: false, error: formatCliError(error) })
2845
3121
  }
2846
3122
  }
@@ -2948,6 +3224,12 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
2948
3224
  })
2949
3225
  return JSON.stringify({ ok: true, template: stdout })
2950
3226
  } catch (error: unknown) {
3227
+ await writePluginLog(logClient, "error", "AKM workflow template failed", {
3228
+ subsystem: "workflow",
3229
+ toolName: "akm_workflow",
3230
+ action: "template",
3231
+ error: formatCliError(error),
3232
+ })
2951
3233
  return JSON.stringify({ ok: false, error: formatCliError(error) })
2952
3234
  }
2953
3235
  }
@@ -3030,15 +3312,11 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3030
3312
  return runCli(client as unknown as LogCapableClient, ["distill", ref], { toolName: "akm_distill" })
3031
3313
  },
3032
3314
  }),
3033
- akm_setup: tool({
3034
- 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.",
3035
- args: {
3036
- force: tool.schema.boolean().optional().describe("Re-run detection even when agent.default is already configured."),
3037
- },
3038
- async execute({ force }) {
3039
- const args = ["setup"]
3040
- if (force) args.push("--force")
3041
- return runCli(client as unknown as LogCapableClient, args, { toolName: "akm_setup" })
3315
+ akm_init: tool({
3316
+ 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.",
3317
+ args: {},
3318
+ async execute() {
3319
+ return runCli(client as unknown as LogCapableClient, ["init"], { toolName: "akm_init" })
3042
3320
  },
3043
3321
  }),
3044
3322
  akm_help: tool({
@@ -3060,6 +3338,13 @@ export const AkmPlugin: Plugin = async ({ client, worktree, directory }) => {
3060
3338
  timeout: 30_000,
3061
3339
  }).toString().trim()
3062
3340
  } catch (error: unknown) {
3341
+ await writePluginLog(logClient, "error", "AKM help command failed", {
3342
+ subsystem: "help",
3343
+ toolName: "akm_help",
3344
+ command: cliCommand,
3345
+ args: helpArgs,
3346
+ error: formatCliError(error),
3347
+ })
3063
3348
  return JSON.stringify({ ok: false, error: formatCliError(error) })
3064
3349
  }
3065
3350
  return JSON.stringify({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-opencode",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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": [