akm-cli 0.7.5 → 0.8.0-rc.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 (155) hide show
  1. package/.github/CHANGELOG.md +1 -1
  2. package/dist/cli/parse-args.js +86 -0
  3. package/dist/cli.js +1023 -521
  4. package/dist/commands/agent-dispatch.js +107 -0
  5. package/dist/commands/agent-support.js +62 -0
  6. package/dist/commands/config-cli.js +68 -84
  7. package/dist/commands/consolidate.js +812 -0
  8. package/dist/commands/distill-promotion-policy.js +658 -0
  9. package/dist/commands/distill.js +218 -43
  10. package/dist/commands/eval-cases.js +40 -0
  11. package/dist/commands/events.js +2 -23
  12. package/dist/commands/graph.js +222 -0
  13. package/dist/commands/health.js +376 -0
  14. package/dist/commands/help/help-accept.md +9 -0
  15. package/dist/commands/help/help-improve.md +53 -0
  16. package/dist/commands/help/help-proposals.md +15 -0
  17. package/dist/commands/help/help-propose.md +17 -0
  18. package/dist/commands/help/help-reject.md +8 -0
  19. package/dist/commands/history.js +3 -30
  20. package/dist/commands/improve.js +1161 -0
  21. package/dist/commands/info.js +2 -2
  22. package/dist/commands/init.js +2 -2
  23. package/dist/commands/install-audit.js +5 -1
  24. package/dist/commands/installed-stashes.js +118 -138
  25. package/dist/commands/knowledge.js +133 -0
  26. package/dist/commands/lint/agent-linter.js +46 -0
  27. package/dist/commands/lint/base-linter.js +291 -0
  28. package/dist/commands/lint/command-linter.js +46 -0
  29. package/dist/commands/lint/default-linter.js +13 -0
  30. package/dist/commands/lint/index.js +145 -0
  31. package/dist/commands/lint/knowledge-linter.js +13 -0
  32. package/dist/commands/lint/memory-linter.js +58 -0
  33. package/dist/commands/lint/registry.js +33 -0
  34. package/dist/commands/lint/skill-linter.js +42 -0
  35. package/dist/commands/lint/task-linter.js +47 -0
  36. package/dist/commands/lint/types.js +1 -0
  37. package/dist/commands/lint/vault-key-rules.js +67 -0
  38. package/dist/commands/lint/workflow-linter.js +53 -0
  39. package/dist/commands/lint.js +1 -0
  40. package/dist/commands/proposal.js +8 -7
  41. package/dist/commands/propose.js +71 -28
  42. package/dist/commands/reflect.js +135 -35
  43. package/dist/commands/registry-search.js +2 -2
  44. package/dist/commands/remember.js +54 -0
  45. package/dist/commands/schema-repair.js +130 -0
  46. package/dist/commands/search.js +21 -5
  47. package/dist/commands/show.js +125 -20
  48. package/dist/commands/source-add.js +10 -10
  49. package/dist/commands/source-manage.js +11 -19
  50. package/dist/commands/tasks.js +385 -0
  51. package/dist/commands/url-checker.js +39 -0
  52. package/dist/commands/vault.js +168 -77
  53. package/dist/core/action-contributors.js +25 -0
  54. package/dist/core/asset-ref.js +4 -0
  55. package/dist/core/asset-registry.js +4 -16
  56. package/dist/core/asset-spec.js +10 -0
  57. package/dist/core/common.js +100 -0
  58. package/dist/core/concurrent.js +22 -0
  59. package/dist/core/config.js +233 -133
  60. package/dist/core/events.js +73 -126
  61. package/dist/core/frontmatter.js +0 -6
  62. package/dist/core/markdown.js +17 -0
  63. package/dist/core/memory-improve.js +678 -0
  64. package/dist/core/parse.js +155 -0
  65. package/dist/core/paths.js +101 -3
  66. package/dist/core/proposal-validators.js +61 -0
  67. package/dist/core/proposals.js +49 -38
  68. package/dist/core/state-db.js +731 -0
  69. package/dist/core/time.js +51 -0
  70. package/dist/core/warn.js +59 -1
  71. package/dist/indexer/db-search.js +52 -238
  72. package/dist/indexer/db.js +403 -54
  73. package/dist/indexer/ensure-index.js +61 -0
  74. package/dist/indexer/graph-boost.js +247 -94
  75. package/dist/indexer/graph-db.js +201 -0
  76. package/dist/indexer/graph-dedup.js +99 -0
  77. package/dist/indexer/graph-extraction.js +409 -76
  78. package/dist/indexer/index-context.js +10 -0
  79. package/dist/indexer/indexer.js +456 -290
  80. package/dist/indexer/llm-cache.js +47 -0
  81. package/dist/indexer/matchers.js +124 -160
  82. package/dist/indexer/memory-inference.js +63 -29
  83. package/dist/indexer/metadata-contributors.js +26 -0
  84. package/dist/indexer/metadata.js +196 -197
  85. package/dist/indexer/path-resolver.js +89 -0
  86. package/dist/indexer/ranking-contributors.js +204 -0
  87. package/dist/indexer/ranking.js +74 -0
  88. package/dist/indexer/search-hit-enrichers.js +22 -0
  89. package/dist/indexer/search-source.js +24 -9
  90. package/dist/indexer/semantic-status.js +2 -16
  91. package/dist/indexer/walker.js +25 -0
  92. package/dist/integrations/agent/builders.js +109 -0
  93. package/dist/integrations/agent/config.js +203 -3
  94. package/dist/integrations/agent/index.js +5 -2
  95. package/dist/integrations/agent/model-aliases.js +63 -0
  96. package/dist/integrations/agent/profiles.js +67 -5
  97. package/dist/integrations/agent/prompts.js +77 -72
  98. package/dist/integrations/agent/sdk-runner.js +120 -0
  99. package/dist/integrations/agent/spawn.js +93 -22
  100. package/dist/integrations/lockfile.js +10 -18
  101. package/dist/integrations/session-logs/index.js +65 -0
  102. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  103. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  104. package/dist/integrations/session-logs/types.js +1 -0
  105. package/dist/llm/call-ai.js +74 -0
  106. package/dist/llm/client.js +61 -122
  107. package/dist/llm/feature-gate.js +27 -16
  108. package/dist/llm/graph-extract.js +297 -62
  109. package/dist/llm/memory-infer.js +49 -71
  110. package/dist/llm/metadata-enhance.js +39 -22
  111. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  112. package/dist/output/cli-hints-full.md +277 -0
  113. package/dist/output/cli-hints-short.md +65 -0
  114. package/dist/output/cli-hints.js +2 -318
  115. package/dist/output/renderers.js +220 -256
  116. package/dist/output/shapes.js +101 -93
  117. package/dist/output/text.js +256 -17
  118. package/dist/registry/providers/skills-sh.js +61 -49
  119. package/dist/registry/providers/static-index.js +44 -48
  120. package/dist/registry/resolve.js +8 -16
  121. package/dist/setup/setup.js +510 -11
  122. package/dist/sources/provider-factory.js +2 -1
  123. package/dist/sources/providers/filesystem.js +16 -23
  124. package/dist/sources/providers/git.js +4 -5
  125. package/dist/sources/providers/website.js +15 -22
  126. package/dist/sources/website-ingest.js +4 -0
  127. package/dist/tasks/backends/cron.js +200 -0
  128. package/dist/tasks/backends/exec-utils.js +25 -0
  129. package/dist/tasks/backends/index.js +32 -0
  130. package/dist/tasks/backends/launchd-template.xml +19 -0
  131. package/dist/tasks/backends/launchd.js +184 -0
  132. package/dist/tasks/backends/schtasks-template.xml +29 -0
  133. package/dist/tasks/backends/schtasks.js +212 -0
  134. package/dist/tasks/parser.js +198 -0
  135. package/dist/tasks/resolveAkmBin.js +84 -0
  136. package/dist/tasks/runner.js +432 -0
  137. package/dist/tasks/schedule.js +208 -0
  138. package/dist/tasks/schema.js +13 -0
  139. package/dist/tasks/validator.js +59 -0
  140. package/dist/wiki/index-template.md +12 -0
  141. package/dist/wiki/ingest-workflow-template.md +54 -0
  142. package/dist/wiki/log-template.md +8 -0
  143. package/dist/wiki/schema-template.md +61 -0
  144. package/dist/wiki/wiki-templates.js +12 -0
  145. package/dist/wiki/wiki.js +10 -61
  146. package/dist/workflows/authoring.js +5 -25
  147. package/dist/workflows/renderer.js +8 -3
  148. package/dist/workflows/runs.js +59 -91
  149. package/dist/workflows/validator.js +1 -1
  150. package/dist/workflows/workflow-template.md +24 -0
  151. package/docs/README.md +5 -2
  152. package/docs/migration/release-notes/0.7.0.md +1 -1
  153. package/docs/migration/release-notes/0.8.0.md +43 -0
  154. package/package.json +3 -2
  155. package/dist/templates/wiki-templates.js +0 -100
@@ -0,0 +1,47 @@
1
+ import { BaseLinter } from "./base-linter";
2
+ /**
3
+ * Linter for `tasks/` assets.
4
+ *
5
+ * Tasks are `.md` files with YAML frontmatter. In addition to the base checks
6
+ * this linter validates the required task fields:
7
+ *
8
+ * - `schedule` (string, non-empty) — cron expression or `@`-alias
9
+ * - `enabled` (boolean)
10
+ * - At least one of: `prompt` or `workflow` field present
11
+ *
12
+ * All issues are reported as `invalid-task-frontmatter` and are **not**
13
+ * auto-fixable. Cron expression syntax validation is intentionally out of
14
+ * scope (that belongs to `parseSchedule()`).
15
+ */
16
+ export class TaskLinter extends BaseLinter {
17
+ types = ["tasks"];
18
+ lint(ctx) {
19
+ const issues = this.runBaseChecks(ctx);
20
+ // Only validate frontmatter fields when frontmatter is present.
21
+ if (ctx.frontmatter === null)
22
+ return issues;
23
+ const missing = [];
24
+ // schedule: must be present and non-empty
25
+ if (!("schedule" in ctx.data) || typeof ctx.data.schedule !== "string" || ctx.data.schedule.trim() === "") {
26
+ missing.push("schedule");
27
+ }
28
+ // enabled: must be present (boolean — value of false is valid)
29
+ if (!("enabled" in ctx.data)) {
30
+ missing.push("enabled");
31
+ }
32
+ // At least one of: prompt or workflow
33
+ const hasTarget = "prompt" in ctx.data || "workflow" in ctx.data;
34
+ if (!hasTarget) {
35
+ missing.push("prompt or workflow");
36
+ }
37
+ if (missing.length > 0) {
38
+ issues.push({
39
+ file: ctx.relPath,
40
+ issue: "invalid-task-frontmatter",
41
+ detail: `missing required fields: ${missing.join(", ")}`,
42
+ fixed: false,
43
+ });
44
+ }
45
+ return issues;
46
+ }
47
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Vault security lint rules — flags known-dangerous environment variable names.
3
+ *
4
+ * These env var names, when present as vault keys, indicate the vault can be
5
+ * used to hijack process execution via loader injection, path override, or
6
+ * shell/runtime startup hooks. The lint pass emits a warning-level finding;
7
+ * it does NOT block vault load or `akm add` installation.
8
+ */
9
+ import { listKeys } from "../vault";
10
+ // ── Dangerous key set ─────────────────────────────────────────────────────────
11
+ export const DANGEROUS_VAULT_KEYS = new Set([
12
+ // Dynamic linker hijacking (Linux)
13
+ "LD_PRELOAD",
14
+ "LD_LIBRARY_PATH",
15
+ "LD_AUDIT",
16
+ "LD_DEBUG",
17
+ // Dynamic linker hijacking (macOS)
18
+ "DYLD_INSERT_LIBRARIES",
19
+ "DYLD_LIBRARY_PATH",
20
+ "DYLD_FRAMEWORK_PATH",
21
+ // Shell and command resolution
22
+ "PATH",
23
+ "BASH_ENV",
24
+ "ENV",
25
+ "PROMPT_COMMAND",
26
+ "PS1",
27
+ "PS2",
28
+ // Language runtime hijacking
29
+ "NODE_OPTIONS",
30
+ "NODE_PATH",
31
+ "PYTHONSTARTUP",
32
+ "PYTHONPATH",
33
+ "PYTHONINSPECT",
34
+ "RUBYLIB",
35
+ "RUBYOPT",
36
+ "PERL5LIB",
37
+ "PERL5OPT",
38
+ "JAVA_TOOL_OPTIONS",
39
+ "JDK_JAVA_OPTIONS",
40
+ "_JAVA_OPTIONS",
41
+ ]);
42
+ // ── Checker ───────────────────────────────────────────────────────────────────
43
+ /**
44
+ * Inspect a vault `.env` file and return a lint finding for every key whose
45
+ * name appears in `DANGEROUS_VAULT_KEYS`.
46
+ *
47
+ * @param vaultPath Absolute path to the `.env` file.
48
+ * @param relPath Stash-relative path used as the `file` field in findings
49
+ * (e.g. `"vaults/prod.env"`).
50
+ * @param vaultRef Human-readable vault ref (e.g. `"vault:prod"`) shown in
51
+ * the finding message.
52
+ */
53
+ export function checkVaultForDangerousKeys(vaultPath, relPath, vaultRef) {
54
+ const { keys } = listKeys(vaultPath);
55
+ const issues = [];
56
+ for (const key of keys) {
57
+ if (!DANGEROUS_VAULT_KEYS.has(key))
58
+ continue;
59
+ issues.push({
60
+ file: relPath,
61
+ issue: "dangerous-vault-key",
62
+ detail: `Vault key \`${key}\` can be used to hijack process execution when injected via \`akm vault run\`. Vault ref: ${vaultRef}. Review this vault file before running \`akm vault run\` commands against untrusted stashes.`,
63
+ fixed: false,
64
+ });
65
+ }
66
+ return issues;
67
+ }
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import { BaseLinter } from "./base-linter";
3
+ const PLACEHOLDER_STRINGS = ["Describe what this workflow accomplishes", "Example Workflow"];
4
+ /**
5
+ * Linter for `workflows/` assets.
6
+ *
7
+ * Extra check beyond base:
8
+ * - `placeholder-stub`: body contains a known placeholder string.
9
+ * Fix: delete the file.
10
+ */
11
+ export class WorkflowLinter extends BaseLinter {
12
+ types = ["workflows"];
13
+ lint(ctx) {
14
+ const issues = this.runBaseChecks(ctx);
15
+ const placeholderMatch = this.#checkPlaceholderStub(ctx.body);
16
+ if (placeholderMatch) {
17
+ if (ctx.fix) {
18
+ try {
19
+ fs.unlinkSync(ctx.filePath);
20
+ issues.push({
21
+ file: ctx.relPath,
22
+ issue: "placeholder-stub",
23
+ detail: `deleted: found "${placeholderMatch}"`,
24
+ fixed: true,
25
+ });
26
+ }
27
+ catch (e) {
28
+ issues.push({
29
+ file: ctx.relPath,
30
+ issue: "placeholder-stub",
31
+ detail: `could not delete: ${e instanceof Error ? e.message : String(e)}`,
32
+ fixed: false,
33
+ });
34
+ }
35
+ return issues;
36
+ }
37
+ issues.push({
38
+ file: ctx.relPath,
39
+ issue: "placeholder-stub",
40
+ detail: `placeholder text: "${placeholderMatch}"`,
41
+ fixed: false,
42
+ });
43
+ }
44
+ return issues;
45
+ }
46
+ #checkPlaceholderStub(body) {
47
+ for (const placeholder of PLACEHOLDER_STRINGS) {
48
+ if (body.includes(placeholder))
49
+ return placeholder;
50
+ }
51
+ return null;
52
+ }
53
+ }
@@ -0,0 +1 @@
1
+ export { akmLint } from "./lint/index";
@@ -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
20
  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
- }
21
+ import { runAgentSdk } from "../integrations/agent/sdk-runner";
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)
@@ -91,14 +98,35 @@ export async function akmPropose(options) {
91
98
  // 4. Spawn the agent.
92
99
  // Real agent runs use interactive mode so file tools can write the draft.
93
100
  // Injected/custom spawns still need captured stdout for JSON payload tests.
94
- const runOptions = {
95
- stdio: options.runAgentOptions?.spawn ? "captured" : "interactive",
96
- parseOutput: "text",
97
- ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
98
- ...(options.runAgentOptions ?? {}),
99
- };
100
- const result = await runAgent(profile, prompt, runOptions);
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: dispatch directly to the appropriate runner.
116
+ const runOptions = {
117
+ stdio: resolvedDraftPath ? "interactive" : "captured",
118
+ parseOutput: "text",
119
+ ...(resolvedTimeoutMs !== undefined ? { timeoutMs: resolvedTimeoutMs } : {}),
120
+ };
121
+ result = profile.sdkMode
122
+ ? await runAgentSdk(profile, prompt ?? "", runOptions)
123
+ : await runAgent(profile, prompt, runOptions);
124
+ }
101
125
  if (!result.ok) {
126
+ // B3: ENOENT / not-found gives an actionable hint.
127
+ if (isEnoentFailure(result)) {
128
+ return { ...failureEnvelope(result, options.type, options.name), error: enoentHintMessage(profile.bin) };
129
+ }
102
130
  return failureEnvelope(result, options.type, options.name);
103
131
  }
104
132
  // 5. Resolve the proposal content.
@@ -115,6 +143,21 @@ export async function akmPropose(options) {
115
143
  };
116
144
  }
117
145
  else {
146
+ // B1: When interactive mode was used and stdout is empty, the agent did not
147
+ // write the draft file and stdout was not captured — surface an actionable error.
148
+ const stdioWasInteractive = !useCustomSpawn;
149
+ if (stdioWasInteractive && (result.stdout ?? "") === "") {
150
+ return {
151
+ schemaVersion: 1,
152
+ ok: false,
153
+ reason: "parse_error",
154
+ 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'.",
155
+ type: options.type,
156
+ name: options.name,
157
+ exitCode: result.exitCode,
158
+ ...(result.stderr ? { stderr: result.stderr } : {}),
159
+ };
160
+ }
118
161
  try {
119
162
  payload = parseAgentProposalPayload(result.stdout ?? "");
120
163
  }
@@ -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";
31
34
  import { buildReflectPrompt, parseAgentProposalPayload } from "../integrations/agent/prompts";
35
+ import { runAgentSdk } from "../integrations/agent/sdk-runner";
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,45 +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
  }
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);
81
+ }
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
+ }
71
137
  function fallbackPayloadFromRawContent(stdout, ref) {
72
138
  if (!ref)
73
139
  return undefined;
74
- const trimmed = stripMarkdownFence(stdout).trim();
140
+ const trimmed = stripMarkdownFences(stdout).trim();
75
141
  if (!trimmed)
76
142
  return undefined;
77
143
  if (!looksLikeAssetContent(trimmed))
78
144
  return undefined;
79
145
  return { ref, content: trimmed };
80
146
  }
81
- function stripMarkdownFence(stdout) {
82
- const trimmed = stdout.trim();
83
- const match = trimmed.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i);
84
- return match?.[1] ?? trimmed;
85
- }
86
147
  function looksLikeAssetContent(value) {
87
148
  return value.startsWith("#") || value.startsWith("---");
88
149
  }
89
- function loadAgentConfigFromDisk() {
90
- const config = loadConfig();
91
- return parseAgentConfig(config.agent);
92
- }
93
- function resolveProfile(options) {
94
- if (options.agentProfile)
95
- return options.agentProfile;
96
- const agent = options.agentConfig ?? loadAgentConfigFromDisk();
97
- return requireAgentProfile(agent, options.profile);
98
- }
99
150
  function failureEnvelope(result, ref, fallbackReason = "non_zero_exit") {
100
- const reason = result.reason ?? fallbackReason;
101
151
  return {
102
- schemaVersion: 1,
103
- ok: false,
104
- reason,
105
- error: result.error ?? `agent failure (${reason})`,
152
+ ...baseFailureFields(result, fallbackReason),
106
153
  ...(ref ? { ref } : {}),
107
- exitCode: result.exitCode,
108
- ...(result.stdout ? { stdout: result.stdout } : {}),
109
- ...(result.stderr ? { stderr: result.stderr } : {}),
110
154
  };
111
155
  }
112
156
  export async function akmReflect(options = {}) {
@@ -138,9 +182,32 @@ export async function akmReflect(options = {}) {
138
182
  }
139
183
  // 3. Resolve agent profile. ConfigError surfaces as a thrown error so the
140
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.
141
189
  let profile;
190
+ let resolvedTimeoutMs = options.timeoutMs;
142
191
  try {
143
- 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
+ }
144
211
  }
145
212
  catch (err) {
146
213
  if (err instanceof ConfigError || err instanceof UsageError)
@@ -153,6 +220,7 @@ export async function akmReflect(options = {}) {
153
220
  // local opencode models and caused proposal generation failures.
154
221
  const feedback = readRecentFeedback(options.ref);
155
222
  const schemaHints = buildSchemaHints(parsedRef?.type ?? "", assetContent);
223
+ const relatedLessons = options.ref && parsedRef ? await readRelatedLessons(stash, options.ref, parsedRef) : [];
156
224
  const prompt = buildReflectPrompt({
157
225
  ...(options.ref ? { ref: options.ref } : {}),
158
226
  ...(parsedRef?.type ? { type: parsedRef.type } : {}),
@@ -160,17 +228,40 @@ export async function akmReflect(options = {}) {
160
228
  ...(assetContent !== undefined ? { assetContent } : {}),
161
229
  ...(feedback.length > 0 ? { feedback } : {}),
162
230
  ...(schemaHints.length > 0 ? { schemaHints } : {}),
231
+ ...(relatedLessons.length > 0 ? { relatedLessons } : {}),
163
232
  ...(options.task ? { task: options.task } : {}),
233
+ ...(options.avoidPatterns && options.avoidPatterns.length > 0 ? { avoidPatterns: options.avoidPatterns } : {}),
164
234
  });
165
235
  // 5. Spawn the agent.
166
- const runOptions = {
167
- stdio: "captured",
168
- parseOutput: "text",
169
- ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
170
- ...(options.runAgentOptions ?? {}),
171
- };
172
- const result = await runAgent(profile, prompt, runOptions);
236
+ // Fall back to raw runAgent when a custom spawn function is injected (test seam).
237
+ // Production path dispatches directly to runAgentSdk or runAgent.
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: dispatch directly to the appropriate runner.
251
+ const runOptions = {
252
+ stdio: "captured",
253
+ parseOutput: "text",
254
+ ...(resolvedTimeoutMs !== undefined ? { timeoutMs: resolvedTimeoutMs } : {}),
255
+ };
256
+ result = profile.sdkMode
257
+ ? await runAgentSdk(profile, prompt ?? "", runOptions)
258
+ : await runAgent(profile, prompt, runOptions);
259
+ }
173
260
  if (!result.ok) {
261
+ // B3: ENOENT / not-found gives an actionable hint.
262
+ if (isEnoentFailure(result)) {
263
+ return { ...failureEnvelope(result, options.ref), error: enoentHintMessage(profile.bin) };
264
+ }
174
265
  return failureEnvelope(result, options.ref);
175
266
  }
176
267
  // 6. Resolve the proposal content from stdout JSON.
@@ -208,6 +299,15 @@ export async function akmReflect(options = {}) {
208
299
  },
209
300
  };
210
301
  const proposal = createProposal(stash, createInput, options.ctx);
302
+ appendEvent({
303
+ eventType: "reflect_completed",
304
+ ref: proposal.ref,
305
+ metadata: {
306
+ proposalId: proposal.id,
307
+ source: "reflect",
308
+ agentProfile: profile.name,
309
+ },
310
+ });
211
311
  return {
212
312
  schemaVersion: 1,
213
313
  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 ─────────────────────────────────────────────────────