akm-cli 0.7.4 → 0.8.0-rc1

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 (158) hide show
  1. package/{CHANGELOG.md → .github/CHANGELOG.md} +34 -1
  2. package/.github/LICENSE +374 -0
  3. package/dist/cli/parse-args.js +43 -0
  4. package/dist/cli.js +1007 -593
  5. package/dist/commands/agent-dispatch.js +102 -0
  6. package/dist/commands/agent-support.js +62 -0
  7. package/dist/commands/config-cli.js +68 -84
  8. package/dist/commands/consolidate.js +823 -0
  9. package/dist/commands/curate.js +1 -0
  10. package/dist/commands/distill-promotion-policy.js +658 -0
  11. package/dist/commands/distill.js +250 -48
  12. package/dist/commands/eval-cases.js +40 -0
  13. package/dist/commands/events.js +12 -24
  14. package/dist/commands/graph.js +222 -0
  15. package/dist/commands/health.js +376 -0
  16. package/dist/commands/help/help-accept.md +9 -0
  17. package/dist/commands/help/help-improve.md +53 -0
  18. package/dist/commands/help/help-proposals.md +15 -0
  19. package/dist/commands/help/help-propose.md +17 -0
  20. package/dist/commands/help/help-reject.md +8 -0
  21. package/dist/commands/history.js +3 -30
  22. package/dist/commands/improve.js +1170 -0
  23. package/dist/commands/info.js +2 -2
  24. package/dist/commands/init.js +2 -2
  25. package/dist/commands/install-audit.js +5 -1
  26. package/dist/commands/installed-stashes.js +118 -138
  27. package/dist/commands/knowledge.js +133 -0
  28. package/dist/commands/lint/agent-linter.js +46 -0
  29. package/dist/commands/lint/base-linter.js +251 -0
  30. package/dist/commands/lint/command-linter.js +46 -0
  31. package/dist/commands/lint/default-linter.js +13 -0
  32. package/dist/commands/lint/index.js +107 -0
  33. package/dist/commands/lint/knowledge-linter.js +13 -0
  34. package/dist/commands/lint/memory-linter.js +58 -0
  35. package/dist/commands/lint/registry.js +33 -0
  36. package/dist/commands/lint/skill-linter.js +42 -0
  37. package/dist/commands/lint/task-linter.js +47 -0
  38. package/dist/commands/lint/types.js +1 -0
  39. package/dist/commands/lint/workflow-linter.js +53 -0
  40. package/dist/commands/lint.js +1 -0
  41. package/dist/commands/migration-help.js +2 -2
  42. package/dist/commands/proposal.js +8 -7
  43. package/dist/commands/propose.js +113 -43
  44. package/dist/commands/reflect.js +175 -41
  45. package/dist/commands/registry-search.js +2 -2
  46. package/dist/commands/remember.js +55 -1
  47. package/dist/commands/schema-repair.js +130 -0
  48. package/dist/commands/search.js +21 -5
  49. package/dist/commands/show.js +131 -52
  50. package/dist/commands/source-add.js +10 -10
  51. package/dist/commands/source-manage.js +11 -19
  52. package/dist/commands/tasks.js +385 -0
  53. package/dist/commands/url-checker.js +39 -0
  54. package/dist/commands/vault.js +7 -33
  55. package/dist/core/action-contributors.js +25 -0
  56. package/dist/core/asset-registry.js +5 -17
  57. package/dist/core/asset-spec.js +11 -1
  58. package/dist/core/common.js +94 -0
  59. package/dist/core/concurrent.js +22 -0
  60. package/dist/core/config.js +229 -122
  61. package/dist/core/events.js +87 -123
  62. package/dist/core/frontmatter.js +3 -1
  63. package/dist/core/markdown.js +17 -0
  64. package/dist/core/memory-improve.js +678 -0
  65. package/dist/core/parse.js +155 -0
  66. package/dist/core/paths.js +101 -3
  67. package/dist/core/proposal-validators.js +61 -0
  68. package/dist/core/proposals.js +49 -38
  69. package/dist/core/state-db.js +775 -0
  70. package/dist/core/time.js +51 -0
  71. package/dist/core/warn.js +59 -1
  72. package/dist/indexer/db-search.js +86 -472
  73. package/dist/indexer/db.js +392 -6
  74. package/dist/indexer/ensure-index.js +133 -0
  75. package/dist/indexer/graph-boost.js +247 -94
  76. package/dist/indexer/graph-db.js +201 -0
  77. package/dist/indexer/graph-dedup.js +99 -0
  78. package/dist/indexer/graph-extraction.js +417 -74
  79. package/dist/indexer/index-context.js +10 -0
  80. package/dist/indexer/indexer.js +466 -298
  81. package/dist/indexer/llm-cache.js +47 -0
  82. package/dist/indexer/match-contributors.js +141 -0
  83. package/dist/indexer/matchers.js +24 -190
  84. package/dist/indexer/memory-inference.js +63 -29
  85. package/dist/indexer/metadata-contributors.js +26 -0
  86. package/dist/indexer/metadata.js +188 -175
  87. package/dist/indexer/path-resolver.js +89 -0
  88. package/dist/indexer/ranking-contributors.js +204 -0
  89. package/dist/indexer/ranking.js +74 -0
  90. package/dist/indexer/search-hit-enrichers.js +22 -0
  91. package/dist/indexer/search-source.js +24 -9
  92. package/dist/indexer/semantic-status.js +2 -16
  93. package/dist/indexer/walker.js +25 -0
  94. package/dist/integrations/agent/config.js +175 -3
  95. package/dist/integrations/agent/index.js +3 -1
  96. package/dist/integrations/agent/pipeline.js +39 -0
  97. package/dist/integrations/agent/profiles.js +67 -5
  98. package/dist/integrations/agent/prompts.js +114 -29
  99. package/dist/integrations/agent/runners.js +31 -0
  100. package/dist/integrations/agent/sdk-runner.js +120 -0
  101. package/dist/integrations/agent/spawn.js +136 -28
  102. package/dist/integrations/lockfile.js +10 -18
  103. package/dist/integrations/session-logs/index.js +65 -0
  104. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  105. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  106. package/dist/integrations/session-logs/types.js +1 -0
  107. package/dist/llm/call-ai.js +74 -0
  108. package/dist/llm/client.js +63 -86
  109. package/dist/llm/feature-gate.js +27 -16
  110. package/dist/llm/graph-extract.js +297 -64
  111. package/dist/llm/memory-infer.js +52 -71
  112. package/dist/llm/metadata-enhance.js +39 -22
  113. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  114. package/dist/output/cli-hints-full.md +277 -0
  115. package/dist/output/cli-hints-short.md +65 -0
  116. package/dist/output/cli-hints.js +2 -309
  117. package/dist/output/renderers.js +196 -124
  118. package/dist/output/shapes.js +41 -3
  119. package/dist/output/text.js +257 -21
  120. package/dist/registry/providers/skills-sh.js +61 -49
  121. package/dist/registry/providers/static-index.js +44 -48
  122. package/dist/setup/setup.js +510 -11
  123. package/dist/sources/provider-factory.js +2 -1
  124. package/dist/sources/providers/git.js +44 -2
  125. package/dist/sources/website-ingest.js +4 -0
  126. package/dist/tasks/backends/cron.js +200 -0
  127. package/dist/tasks/backends/exec-utils.js +25 -0
  128. package/dist/tasks/backends/index.js +32 -0
  129. package/dist/tasks/backends/launchd-template.xml +19 -0
  130. package/dist/tasks/backends/launchd.js +184 -0
  131. package/dist/tasks/backends/schtasks-template.xml +29 -0
  132. package/dist/tasks/backends/schtasks.js +212 -0
  133. package/dist/tasks/parser.js +198 -0
  134. package/dist/tasks/resolveAkmBin.js +84 -0
  135. package/dist/tasks/runner.js +432 -0
  136. package/dist/tasks/schedule.js +208 -0
  137. package/dist/tasks/schema.js +13 -0
  138. package/dist/tasks/validator.js +59 -0
  139. package/dist/wiki/index-template.md +12 -0
  140. package/dist/wiki/ingest-workflow-template.md +54 -0
  141. package/dist/wiki/log-template.md +8 -0
  142. package/dist/wiki/schema-template.md +61 -0
  143. package/dist/wiki/wiki-templates.js +12 -0
  144. package/dist/wiki/wiki.js +10 -61
  145. package/dist/workflows/authoring.js +5 -25
  146. package/dist/workflows/db.js +9 -0
  147. package/dist/workflows/renderer.js +8 -3
  148. package/dist/workflows/runs.js +73 -88
  149. package/dist/workflows/scope-key.js +76 -0
  150. package/dist/workflows/validator.js +1 -1
  151. package/dist/workflows/workflow-template.md +24 -0
  152. package/docs/README.md +3 -0
  153. package/docs/migration/release-notes/0.7.0.md +1 -1
  154. package/docs/migration/release-notes/0.7.4.md +1 -1
  155. package/docs/migration/release-notes/0.7.5.md +20 -0
  156. package/docs/migration/release-notes/0.8.0.md +43 -0
  157. package/package.json +4 -3
  158. package/dist/templates/wiki-templates.js +0 -100
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- const CHANGELOG_URL = "https://github.com/itlackey/akm/blob/main/CHANGELOG.md";
3
+ const CHANGELOG_URL = "https://github.com/itlackey/akm/blob/main/.github/CHANGELOG.md";
4
4
  const MIGRATION_DOC_URL = "https://github.com/itlackey/akm/blob/main/docs/migration/v0.5-to-v0.6.md";
5
5
  /**
6
6
  * Directory containing per-version release notes. Resolved relative to
@@ -14,7 +14,7 @@ function releaseNotesDir() {
14
14
  }
15
15
  function loadChangelog() {
16
16
  try {
17
- const changelogPath = path.resolve(import.meta.dir, "../../CHANGELOG.md");
17
+ const changelogPath = path.resolve(import.meta.dir, "../../.github/CHANGELOG.md");
18
18
  if (fs.existsSync(changelogPath)) {
19
19
  return fs.readFileSync(changelogPath, "utf8");
20
20
  }
@@ -12,7 +12,7 @@ import { resolveStashDir } from "../core/common";
12
12
  import { loadConfig } from "../core/config";
13
13
  import { UsageError } from "../core/errors";
14
14
  import { appendEvent } from "../core/events";
15
- import { archiveProposal, createProposal, diffProposal, getProposal, listProposals, promoteProposal, validateProposal, } from "../core/proposals";
15
+ import { archiveProposal, createProposal, diffProposal, getProposal, listProposals, promoteProposal, resolveProposalId, validateProposal, } from "../core/proposals";
16
16
  // ── Shared helpers ──────────────────────────────────────────────────────────
17
17
  function resolveStash(stashDir) {
18
18
  if (stashDir)
@@ -43,7 +43,8 @@ export function akmProposalShow(options) {
43
43
  export async function akmProposalAccept(options) {
44
44
  const stash = resolveStash(options.stashDir);
45
45
  const config = options.config ?? loadConfig();
46
- const result = await promoteProposal(stash, config, options.id, { target: options.target }, options.ctx);
46
+ const resolvedId = resolveProposalId(stash, options.id).id;
47
+ const result = await promoteProposal(stash, config, resolvedId, { target: options.target }, options.ctx);
47
48
  // Emit `promoted` to the events stream so observers (audit, dashboards,
48
49
  // sync) see the accept happen. Only emit on the happy path — promotion
49
50
  // throws on validation failure, so reaching this point means the asset
@@ -69,11 +70,11 @@ export async function akmProposalAccept(options) {
69
70
  }
70
71
  export function akmProposalReject(options) {
71
72
  const stash = resolveStash(options.stashDir);
72
- const existing = getProposal(stash, options.id);
73
+ const existing = resolveProposalId(stash, options.id);
73
74
  if (existing.status !== "pending") {
74
- throw new UsageError(`Proposal ${options.id} is not pending (current status: ${existing.status}). Only pending proposals can be rejected.`, "INVALID_FLAG_VALUE");
75
+ throw new UsageError(`Proposal ${existing.id} is not pending (current status: ${existing.status}). Only pending proposals can be rejected.`, "INVALID_FLAG_VALUE");
75
76
  }
76
- const updated = archiveProposal(stash, options.id, "rejected", options.reason, options.ctx);
77
+ const updated = archiveProposal(stash, existing.id, "rejected", options.reason, options.ctx);
77
78
  appendEvent({
78
79
  eventType: "rejected",
79
80
  ref: updated.ref,
@@ -96,8 +97,8 @@ export function akmProposalReject(options) {
96
97
  export function akmProposalDiff(options) {
97
98
  const stash = resolveStash(options.stashDir);
98
99
  const config = options.config ?? loadConfig();
99
- const proposal = getProposal(stash, options.id);
100
- const diff = diffProposal(stash, config, options.id, { target: options.target });
100
+ const proposal = resolveProposalId(stash, options.id);
101
+ const diff = diffProposal(stash, config, proposal.id, { target: options.target });
101
102
  return {
102
103
  schemaVersion: 1,
103
104
  id: proposal.id,
@@ -12,34 +12,19 @@
12
12
  import { parseAssetRef } from "../core/asset-ref";
13
13
  import { TYPE_DIRS } from "../core/asset-spec";
14
14
  import { resolveStashDir } from "../core/common";
15
- import { loadConfig } from "../core/config";
16
15
  import { ConfigError, UsageError } from "../core/errors";
17
16
  import { appendEvent } from "../core/events";
18
17
  import { createProposal } from "../core/proposals";
19
- import { parseAgentConfig, requireAgentProfile, runAgent, } from "../integrations/agent";
18
+ import { runAgent, } from "../integrations/agent";
19
+ import { resolveProcessAgentProfile } from "../integrations/agent/config";
20
+ import { runProposalAgentPipeline } from "../integrations/agent/pipeline";
20
21
  import { buildProposePrompt, parseAgentProposalPayload } from "../integrations/agent/prompts";
21
- function loadAgentConfigFromDisk() {
22
- const config = loadConfig();
23
- return parseAgentConfig(config.agent);
24
- }
25
- function resolveProfile(options) {
26
- if (options.agentProfile)
27
- return options.agentProfile;
28
- const agent = options.agentConfig ?? loadAgentConfigFromDisk();
29
- return requireAgentProfile(agent, options.profile);
30
- }
22
+ import { baseFailureFields, enoentHintMessage, isEnoentFailure, loadAgentConfigFromDisk, resolveAgentProfile, } from "./agent-support";
31
23
  function failureEnvelope(result, type, name, fallbackReason = "non_zero_exit") {
32
- const reason = result.reason ?? fallbackReason;
33
24
  return {
34
- schemaVersion: 1,
35
- ok: false,
36
- reason,
37
- error: result.error ?? `agent failure (${reason})`,
25
+ ...baseFailureFields(result, fallbackReason),
38
26
  type,
39
27
  name,
40
- exitCode: result.exitCode,
41
- ...(result.stdout ? { stdout: result.stdout } : {}),
42
- ...(result.stderr ? { stderr: result.stderr } : {}),
43
28
  };
44
29
  }
45
30
  export async function akmPropose(options) {
@@ -68,9 +53,31 @@ export async function akmPropose(options) {
68
53
  },
69
54
  });
70
55
  // 2. Resolve profile.
56
+ // When an explicit --profile flag is given, honour it directly (existing
57
+ // behaviour). Otherwise use resolveProcessAgentProfile so that per-process
58
+ // agent config (agent.processes["propose"]) is picked up automatically.
71
59
  let profile;
60
+ let resolvedTimeoutMs = options.timeoutMs;
72
61
  try {
73
- profile = resolveProfile(options);
62
+ if (options.agentProfile) {
63
+ // Test seam: injected profile bypasses all config.
64
+ profile = options.agentProfile;
65
+ }
66
+ else if (options.profile) {
67
+ // Explicit --profile flag wins over process config.
68
+ profile = resolveAgentProfile(options);
69
+ }
70
+ else {
71
+ // Use per-process config resolution (falls back to agent.default).
72
+ const agent = options.agentConfig ?? loadAgentConfigFromDisk();
73
+ const processName = options.agentProcess ?? "propose";
74
+ const resolved = resolveProcessAgentProfile(processName, agent);
75
+ profile = resolved.profile;
76
+ // Only apply process-resolved timeoutMs when caller didn't supply one.
77
+ if (resolvedTimeoutMs === undefined) {
78
+ resolvedTimeoutMs = resolved.timeoutMs;
79
+ }
80
+ }
74
81
  }
75
82
  catch (err) {
76
83
  if (err instanceof ConfigError || err instanceof UsageError)
@@ -78,40 +85,103 @@ export async function akmPropose(options) {
78
85
  throw err;
79
86
  }
80
87
  // 3. Build prompt.
88
+ // Synthesize a temp draft path so opencode can write the asset content
89
+ // directly using its file tools rather than returning JSON via stdout.
90
+ const draftFilePath = import("node:os").then((os) => import("node:path").then((path) => path.join(os.tmpdir(), `akm-propose-${options.type}-${options.name.replace(/[^a-z0-9_-]/gi, "_")}-${Date.now()}.md`)));
91
+ const resolvedDraftPath = await draftFilePath;
81
92
  const prompt = buildProposePrompt({
82
93
  type: options.type,
83
94
  name: options.name,
84
95
  task: options.task,
96
+ draftFilePath: resolvedDraftPath,
85
97
  });
86
98
  // 4. Spawn the agent.
87
- const runOptions = {
88
- stdio: "captured",
89
- parseOutput: "text",
90
- ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
91
- ...(options.runAgentOptions ?? {}),
92
- };
93
- const result = await runAgent(profile, prompt, runOptions);
99
+ // Real agent runs use interactive mode so file tools can write the draft.
100
+ // Injected/custom spawns still need captured stdout for JSON payload tests.
101
+ // Use callAi for the unified AI dispatch path (agent CLI preferred, LLM HTTP fallback).
102
+ const useCustomSpawn = Boolean(options.runAgentOptions?.spawn);
103
+ let result;
104
+ if (useCustomSpawn) {
105
+ // Test seam: use raw runAgent with injected spawn so tests remain deterministic.
106
+ const runOptions = {
107
+ stdio: "captured",
108
+ parseOutput: "text",
109
+ ...(resolvedTimeoutMs !== undefined ? { timeoutMs: resolvedTimeoutMs } : {}),
110
+ ...(options.runAgentOptions ?? {}),
111
+ };
112
+ result = await runAgent(profile, prompt, runOptions);
113
+ }
114
+ else {
115
+ // Production path: route through runProposalAgentPipeline (shared logic).
116
+ const pipelineResult = await runProposalAgentPipeline({
117
+ profile,
118
+ prompt,
119
+ draftFilePath: resolvedDraftPath,
120
+ timeoutMs: resolvedTimeoutMs,
121
+ });
122
+ result = {
123
+ ok: pipelineResult.ok,
124
+ exitCode: pipelineResult.exitCode,
125
+ stdout: pipelineResult.stdout,
126
+ stderr: pipelineResult.stderr,
127
+ durationMs: pipelineResult.durationMs,
128
+ error: pipelineResult.error,
129
+ reason: pipelineResult.reason,
130
+ };
131
+ }
94
132
  if (!result.ok) {
133
+ // B3: ENOENT / not-found gives an actionable hint.
134
+ if (isEnoentFailure(result)) {
135
+ return { ...failureEnvelope(result, options.type, options.name), error: enoentHintMessage(profile.bin) };
136
+ }
95
137
  return failureEnvelope(result, options.type, options.name);
96
138
  }
97
- // 5. Parse the structured response.
139
+ // 5. Resolve the proposal content.
140
+ // Path A: opencode wrote the draft file — read it directly (no stdout parse).
141
+ // Path B: fallback to stdout JSON parse for non-file-writing agents.
142
+ const fs = await import("node:fs");
98
143
  let payload;
99
- try {
100
- payload = parseAgentProposalPayload(result.stdout);
101
- }
102
- catch (err) {
103
- return {
104
- schemaVersion: 1,
105
- ok: false,
106
- reason: "parse_error",
107
- error: err instanceof Error ? err.message : String(err),
108
- type: options.type,
109
- name: options.name,
110
- exitCode: result.exitCode,
111
- stdout: result.stdout,
112
- ...(result.stderr ? { stderr: result.stderr } : {}),
144
+ if (fs.existsSync(resolvedDraftPath)) {
145
+ const draftContent = fs.readFileSync(resolvedDraftPath, "utf8");
146
+ fs.unlinkSync(resolvedDraftPath);
147
+ payload = {
148
+ ref: `${options.type}:${options.name}`,
149
+ content: draftContent,
113
150
  };
114
151
  }
152
+ else {
153
+ // B1: When interactive mode was used and stdout is empty, the agent did not
154
+ // write the draft file and stdout was not captured — surface an actionable error.
155
+ const stdioWasInteractive = !useCustomSpawn;
156
+ if (stdioWasInteractive && (result.stdout ?? "") === "") {
157
+ return {
158
+ schemaVersion: 1,
159
+ ok: false,
160
+ reason: "parse_error",
161
+ error: "Agent did not write draft file and stdout was not captured (interactive mode). Check that the agent CLI understood the file-write instruction, or configure a headless profile with stdio: 'captured'.",
162
+ type: options.type,
163
+ name: options.name,
164
+ exitCode: result.exitCode,
165
+ ...(result.stderr ? { stderr: result.stderr } : {}),
166
+ };
167
+ }
168
+ try {
169
+ payload = parseAgentProposalPayload(result.stdout ?? "");
170
+ }
171
+ catch (err) {
172
+ return {
173
+ schemaVersion: 1,
174
+ ok: false,
175
+ reason: "parse_error",
176
+ error: err instanceof Error ? err.message : String(err),
177
+ type: options.type,
178
+ name: options.name,
179
+ exitCode: result.exitCode,
180
+ stdout: result.stdout,
181
+ ...(result.stderr ? { stderr: result.stderr } : {}),
182
+ };
183
+ }
184
+ }
115
185
  // 6. Insert the proposal. Note: we allow the agent's `ref` to normalise the
116
186
  // asset name (e.g. path-cleanup), but only after validating that the ref is
117
187
  // well-formed and the type still matches the requested type.
@@ -19,16 +19,22 @@
19
19
  * a committed asset, and the `accept` flow is the bridge.
20
20
  */
21
21
  import fs from "node:fs";
22
+ import path from "node:path";
22
23
  import { parseAssetRef } from "../core/asset-ref";
23
24
  import { resolveStashDir } from "../core/common";
24
- import { loadConfig } from "../core/config";
25
25
  import { ConfigError, UsageError } from "../core/errors";
26
26
  import { appendEvent, readEvents } from "../core/events";
27
+ import { parseFrontmatter } from "../core/frontmatter";
27
28
  import { lintLessonContent } from "../core/lesson-lint";
29
+ import { stripMarkdownFences } from "../core/markdown";
28
30
  import { createProposal } from "../core/proposals";
29
31
  import { lookup } from "../indexer/indexer";
30
- import { parseAgentConfig, requireAgentProfile, runAgent, } from "../integrations/agent";
32
+ import { runAgent, } from "../integrations/agent";
33
+ import { resolveProcessAgentProfile } from "../integrations/agent/config";
34
+ import { runProposalAgentPipeline } from "../integrations/agent/pipeline";
31
35
  import { buildReflectPrompt, parseAgentProposalPayload } from "../integrations/agent/prompts";
36
+ import { baseFailureFields, enoentHintMessage, isEnoentFailure, loadAgentConfigFromDisk, resolveAgentProfile, } from "./agent-support";
37
+ import { deriveLessonRef } from "./distill";
32
38
  const MAX_FEEDBACK_LINES = 10;
33
39
  const MAX_GLOBAL_FEEDBACK_LINES = 20;
34
40
  /**
@@ -45,7 +51,7 @@ function readRecentFeedback(ref) {
45
51
  for (const event of result.events.slice(-limit)) {
46
52
  const md = (event.metadata ?? {});
47
53
  const signal = typeof md.signal === "string" ? md.signal : "?";
48
- const note = typeof md.note === "string" ? md.note : typeof md.reason === "string" ? md.reason : "";
54
+ const note = typeof md.reason === "string" ? md.reason : typeof md.note === "string" ? md.note : "";
49
55
  const details = note ? `[${signal}] ${note}` : `[${signal}]`;
50
56
  lines.push(!ref && event.ref ? `${event.ref} ${details}` : details);
51
57
  }
@@ -68,27 +74,83 @@ function buildSchemaHints(type, content) {
68
74
  const report = lintLessonContent(content, "reflect");
69
75
  return report.findings.map((f) => `[${f.kind}] ${f.message}`);
70
76
  }
71
- function loadAgentConfigFromDisk() {
72
- const config = loadConfig();
73
- return parseAgentConfig(config.agent);
77
+ function hasRelatedSkillSource(content, skillRef) {
78
+ const parsed = parseFrontmatter(content);
79
+ const sources = parsed.data.sources;
80
+ return Array.isArray(sources) && sources.some((source) => typeof source === "string" && source.trim() === skillRef);
74
81
  }
75
- function resolveProfile(options) {
76
- if (options.agentProfile)
77
- return options.agentProfile;
78
- const agent = options.agentConfig ?? loadAgentConfigFromDisk();
79
- return requireAgentProfile(agent, options.profile);
82
+ async function readRelatedLessons(stash, ref, parsedRef) {
83
+ if (parsedRef.type !== "skill")
84
+ return [];
85
+ const related = new Map();
86
+ const derivedLessonRef = deriveLessonRef(ref);
87
+ const candidateRefs = new Set([derivedLessonRef]);
88
+ const derivedLessonPath = path.join(stash, "lessons", `${derivedLessonRef.slice("lesson:".length)}.md`);
89
+ if (fs.existsSync(derivedLessonPath)) {
90
+ related.set(derivedLessonRef, { ref: derivedLessonRef, content: fs.readFileSync(derivedLessonPath, "utf8") });
91
+ }
92
+ try {
93
+ const feedbackEvents = readEvents({ type: "distill_invoked", ref }).events;
94
+ for (const event of feedbackEvents) {
95
+ const lessonRef = typeof event.metadata?.lessonRef === "string" ? event.metadata.lessonRef : undefined;
96
+ if (lessonRef?.startsWith("lesson:"))
97
+ candidateRefs.add(lessonRef);
98
+ }
99
+ }
100
+ catch {
101
+ // Best effort only.
102
+ }
103
+ for (const candidateRef of candidateRefs) {
104
+ try {
105
+ const entry = await lookup(parseAssetRef(candidateRef));
106
+ if (!entry?.filePath || !fs.existsSync(entry.filePath))
107
+ continue;
108
+ const content = fs.readFileSync(entry.filePath, "utf8");
109
+ related.set(candidateRef, { ref: candidateRef, content });
110
+ }
111
+ catch {
112
+ // Index miss is non-fatal.
113
+ }
114
+ }
115
+ try {
116
+ const lessonsDir = path.join(stash, "lessons");
117
+ if (fs.existsSync(lessonsDir)) {
118
+ for (const fileName of fs.readdirSync(lessonsDir)) {
119
+ if (!fileName.endsWith(".md"))
120
+ continue;
121
+ const content = fs.readFileSync(path.join(lessonsDir, fileName), "utf8");
122
+ if (!hasRelatedSkillSource(content, ref))
123
+ continue;
124
+ const lessonName = fileName.slice(0, -3);
125
+ const lessonRef = `lesson:${lessonName}`;
126
+ if (!related.has(lessonRef)) {
127
+ related.set(lessonRef, { ref: lessonRef, content });
128
+ }
129
+ }
130
+ }
131
+ }
132
+ catch {
133
+ // Best effort only.
134
+ }
135
+ return [...related.values()];
136
+ }
137
+ function fallbackPayloadFromRawContent(stdout, ref) {
138
+ if (!ref)
139
+ return undefined;
140
+ const trimmed = stripMarkdownFences(stdout).trim();
141
+ if (!trimmed)
142
+ return undefined;
143
+ if (!looksLikeAssetContent(trimmed))
144
+ return undefined;
145
+ return { ref, content: trimmed };
146
+ }
147
+ function looksLikeAssetContent(value) {
148
+ return value.startsWith("#") || value.startsWith("---");
80
149
  }
81
150
  function failureEnvelope(result, ref, fallbackReason = "non_zero_exit") {
82
- const reason = result.reason ?? fallbackReason;
83
151
  return {
84
- schemaVersion: 1,
85
- ok: false,
86
- reason,
87
- error: result.error ?? `agent failure (${reason})`,
152
+ ...baseFailureFields(result, fallbackReason),
88
153
  ...(ref ? { ref } : {}),
89
- exitCode: result.exitCode,
90
- ...(result.stdout ? { stdout: result.stdout } : {}),
91
- ...(result.stderr ? { stderr: result.stderr } : {}),
92
154
  };
93
155
  }
94
156
  export async function akmReflect(options = {}) {
@@ -120,9 +182,32 @@ export async function akmReflect(options = {}) {
120
182
  }
121
183
  // 3. Resolve agent profile. ConfigError surfaces as a thrown error so the
122
184
  // CLI dispatcher renders the standard envelope.
185
+ //
186
+ // When an explicit --profile flag is given, honour it directly (existing
187
+ // behaviour). Otherwise use resolveProcessAgentProfile so that per-process
188
+ // agent config (agent.processes["reflect"]) is picked up automatically.
123
189
  let profile;
190
+ let resolvedTimeoutMs = options.timeoutMs;
124
191
  try {
125
- profile = resolveProfile(options);
192
+ if (options.agentProfile) {
193
+ // Test seam: injected profile bypasses all config.
194
+ profile = options.agentProfile;
195
+ }
196
+ else if (options.profile) {
197
+ // Explicit --profile flag wins over process config.
198
+ profile = resolveAgentProfile(options);
199
+ }
200
+ else {
201
+ // Use per-process config resolution (falls back to agent.default).
202
+ const agent = options.agentConfig ?? loadAgentConfigFromDisk();
203
+ const processName = options.agentProcess ?? "reflect";
204
+ const resolved = resolveProcessAgentProfile(processName, agent);
205
+ profile = resolved.profile;
206
+ // Only apply process-resolved timeoutMs when caller didn't supply one.
207
+ if (resolvedTimeoutMs === undefined) {
208
+ resolvedTimeoutMs = resolved.timeoutMs;
209
+ }
210
+ }
126
211
  }
127
212
  catch (err) {
128
213
  if (err instanceof ConfigError || err instanceof UsageError)
@@ -130,8 +215,12 @@ export async function akmReflect(options = {}) {
130
215
  throw err;
131
216
  }
132
217
  // 4. Build the prompt.
218
+ // Keep reflect on the same captured JSON path the bench harness already
219
+ // uses successfully. The draft-file interactive path proved brittle with
220
+ // local opencode models and caused proposal generation failures.
133
221
  const feedback = readRecentFeedback(options.ref);
134
222
  const schemaHints = buildSchemaHints(parsedRef?.type ?? "", assetContent);
223
+ const relatedLessons = options.ref && parsedRef ? await readRelatedLessons(stash, options.ref, parsedRef) : [];
135
224
  const prompt = buildReflectPrompt({
136
225
  ...(options.ref ? { ref: options.ref } : {}),
137
226
  ...(parsedRef?.type ? { type: parsedRef.type } : {}),
@@ -139,36 +228,72 @@ export async function akmReflect(options = {}) {
139
228
  ...(assetContent !== undefined ? { assetContent } : {}),
140
229
  ...(feedback.length > 0 ? { feedback } : {}),
141
230
  ...(schemaHints.length > 0 ? { schemaHints } : {}),
231
+ ...(relatedLessons.length > 0 ? { relatedLessons } : {}),
142
232
  ...(options.task ? { task: options.task } : {}),
233
+ ...(options.avoidPatterns && options.avoidPatterns.length > 0 ? { avoidPatterns: options.avoidPatterns } : {}),
143
234
  });
144
- // 5. Spawn the agent. Force captured stdio + JSON parse so we can extract
145
- // the structured payload without confusing terminal control codes.
146
- const runOptions = {
147
- stdio: "captured",
148
- parseOutput: "text",
149
- ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
150
- ...(options.runAgentOptions ?? {}),
151
- };
152
- const result = await runAgent(profile, prompt, runOptions);
235
+ // 5. Spawn the agent.
236
+ // Use runProposalAgentPipeline for the shared spawn step, but fall back to
237
+ // raw runAgent when a custom spawn function is injected (test seam).
238
+ let result;
239
+ if (options.runAgentOptions?.spawn) {
240
+ // Test seam: use raw runAgent with injected spawn so tests remain deterministic.
241
+ const runOptions = {
242
+ stdio: "captured",
243
+ parseOutput: "text",
244
+ ...(resolvedTimeoutMs !== undefined ? { timeoutMs: resolvedTimeoutMs } : {}),
245
+ ...(options.runAgentOptions ?? {}),
246
+ };
247
+ result = await runAgent(profile, prompt, runOptions);
248
+ }
249
+ else {
250
+ // Production path: route through runProposalAgentPipeline (shared logic).
251
+ const pipelineResult = await runProposalAgentPipeline({
252
+ profile,
253
+ prompt,
254
+ // reflect always uses captured stdout (no draft file path).
255
+ draftFilePath: undefined,
256
+ timeoutMs: resolvedTimeoutMs,
257
+ });
258
+ result = {
259
+ ok: pipelineResult.ok,
260
+ exitCode: pipelineResult.exitCode,
261
+ stdout: pipelineResult.stdout,
262
+ stderr: pipelineResult.stderr,
263
+ durationMs: pipelineResult.durationMs,
264
+ error: pipelineResult.error,
265
+ reason: pipelineResult.reason,
266
+ };
267
+ }
153
268
  if (!result.ok) {
269
+ // B3: ENOENT / not-found gives an actionable hint.
270
+ if (isEnoentFailure(result)) {
271
+ return { ...failureEnvelope(result, options.ref), error: enoentHintMessage(profile.bin) };
272
+ }
154
273
  return failureEnvelope(result, options.ref);
155
274
  }
156
- // 6. Parse stdout into a proposal payload.
275
+ // 6. Resolve the proposal content from stdout JSON.
157
276
  let payload;
158
277
  try {
159
- payload = parseAgentProposalPayload(result.stdout);
278
+ payload = parseAgentProposalPayload(result.stdout ?? "");
160
279
  }
161
280
  catch (err) {
162
- return {
163
- schemaVersion: 1,
164
- ok: false,
165
- reason: "parse_error",
166
- error: err instanceof Error ? err.message : String(err),
167
- ...(options.ref ? { ref: options.ref } : {}),
168
- exitCode: result.exitCode,
169
- stdout: result.stdout,
170
- ...(result.stderr ? { stderr: result.stderr } : {}),
171
- };
281
+ const fallback = fallbackPayloadFromRawContent(result.stdout ?? "", options.ref);
282
+ if (fallback) {
283
+ payload = fallback;
284
+ }
285
+ else {
286
+ return {
287
+ schemaVersion: 1,
288
+ ok: false,
289
+ reason: "parse_error",
290
+ error: err instanceof Error ? err.message : String(err),
291
+ ...(options.ref ? { ref: options.ref } : {}),
292
+ exitCode: result.exitCode,
293
+ stdout: result.stdout,
294
+ ...(result.stderr ? { stderr: result.stderr } : {}),
295
+ };
296
+ }
172
297
  }
173
298
  // 7. Create the proposal. The proposal queue is the ONLY thing reflect
174
299
  // writes — promotion to a real asset is gated by `akm proposal accept`.
@@ -182,6 +307,15 @@ export async function akmReflect(options = {}) {
182
307
  },
183
308
  };
184
309
  const proposal = createProposal(stash, createInput, options.ctx);
310
+ appendEvent({
311
+ eventType: "reflect_completed",
312
+ ref: proposal.ref,
313
+ metadata: {
314
+ proposalId: proposal.id,
315
+ source: "reflect",
316
+ agentProfile: profile.name,
317
+ },
318
+ });
185
319
  return {
186
320
  schemaVersion: 1,
187
321
  ok: true,
@@ -1,5 +1,5 @@
1
1
  import { toErrorMessage } from "../core/common";
2
- import { DEFAULT_CONFIG, loadConfig } from "../core/config";
2
+ import { DEFAULT_CONFIG } from "../core/config";
3
3
  import { warn } from "../core/warn";
4
4
  import { resolveProviderFactory } from "../registry/factory";
5
5
  // ── Eagerly import providers to trigger self-registration ───────────────────
@@ -133,7 +133,7 @@ export function resolveRegistries(configRegistries) {
133
133
  }
134
134
  return entries;
135
135
  }
136
- const registries = configRegistries ?? loadConfig().registries ?? DEFAULT_CONFIG.registries ?? [];
136
+ const registries = configRegistries ?? DEFAULT_CONFIG.registries ?? [];
137
137
  return registries.filter((r) => r.enabled !== false);
138
138
  }
139
139
  // ── Provider resolution ─────────────────────────────────────────────────────
@@ -11,6 +11,7 @@ import { loadConfig } from "../core/config";
11
11
  import { UsageError } from "../core/errors";
12
12
  import { warn } from "../core/warn";
13
13
  import { SCOPE_KEYS } from "../indexer/metadata";
14
+ import { parseFlagValue } from "../output/context";
14
15
  /**
15
16
  * Parse a shorthand duration string to a number of milliseconds.
16
17
  * Supports: `30d` (days), `12h` (hours), `6m` (months, approximated as 30d).
@@ -138,7 +139,7 @@ export async function runLlmEnrich(body) {
138
139
  return { tags: [] };
139
140
  }
140
141
  const llmConfig = config.llm;
141
- const { chatCompletion, parseJsonResponse } = await import("../llm/client");
142
+ const { chatCompletion, parseEmbeddedJsonResponse: parseJsonResponse } = await import("../llm/client");
142
143
  const prompt = `You are a memory tagger for a developer knowledge base.
143
144
  Given the memory text below, return ONLY a JSON object with these fields:
144
145
  - "tags": array of 1-5 short lowercase keyword tags
@@ -188,3 +189,56 @@ Return ONLY the JSON object, no prose, no markdown fences.`;
188
189
  return { tags: [] };
189
190
  }
190
191
  }
192
+ // ── Content-arg disambiguation ───────────────────────────────────────────────
193
+ /**
194
+ * Guard against citty consuming a global flag value as the `content` positional.
195
+ *
196
+ * When the user runs `akm remember --format json` without a content argument,
197
+ * citty may assign `"json"` to the `content` positional because of how it
198
+ * handles flag order. This helper detects that case and returns `undefined`
199
+ * so `readMemoryContent` falls through to stdin.
200
+ */
201
+ export function resolveRememberContentArg(content) {
202
+ if (content === undefined)
203
+ return undefined;
204
+ const parsedFormat = parseFlagValue(process.argv, "--format");
205
+ if (parsedFormat !== undefined &&
206
+ content === parsedFormat &&
207
+ wasRememberFlagValueConsumedAsContent(content, parsedFormat, "--format")) {
208
+ return undefined;
209
+ }
210
+ const parsedDetail = parseFlagValue(process.argv, "--detail");
211
+ if (parsedDetail !== undefined &&
212
+ content === parsedDetail &&
213
+ wasRememberFlagValueConsumedAsContent(content, parsedDetail, "--detail")) {
214
+ return undefined;
215
+ }
216
+ return content;
217
+ }
218
+ function wasRememberFlagValueConsumedAsContent(content, flagValue, flagName) {
219
+ const argv = process.argv.slice(2);
220
+ const rememberIndex = argv.indexOf("remember");
221
+ const tokens = rememberIndex >= 0 ? argv.slice(rememberIndex + 1) : argv;
222
+ let flagIndex = -1;
223
+ let flagConsumesNextToken = false;
224
+ for (let i = 0; i < tokens.length; i += 1) {
225
+ const token = tokens[i];
226
+ if (token === flagName) {
227
+ flagIndex = i;
228
+ flagConsumesNextToken = true;
229
+ break;
230
+ }
231
+ if (token === `${flagName}=${flagValue}`) {
232
+ flagIndex = i;
233
+ break;
234
+ }
235
+ }
236
+ if (flagIndex === -1)
237
+ return false;
238
+ if (tokens.slice(0, flagIndex).includes(content))
239
+ return false;
240
+ const firstTokenAfterFlag = flagIndex + (flagConsumesNextToken ? 2 : 1);
241
+ if (tokens.slice(firstTokenAfterFlag).includes(content))
242
+ return false;
243
+ return true;
244
+ }