akm-cli 0.7.4 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/{CHANGELOG.md → .github/CHANGELOG.md} +33 -0
  2. package/.github/LICENSE +374 -0
  3. package/dist/cli.js +241 -170
  4. package/dist/commands/curate.js +1 -0
  5. package/dist/commands/distill.js +14 -4
  6. package/dist/commands/events.js +10 -1
  7. package/dist/commands/migration-help.js +2 -2
  8. package/dist/commands/propose.js +36 -16
  9. package/dist/commands/reflect.js +40 -14
  10. package/dist/commands/remember.js +1 -1
  11. package/dist/commands/show.js +19 -44
  12. package/dist/commands/vault.js +5 -10
  13. package/dist/core/asset-registry.js +1 -1
  14. package/dist/core/asset-spec.js +1 -1
  15. package/dist/core/config.js +13 -0
  16. package/dist/core/events.js +19 -2
  17. package/dist/indexer/db-search.js +35 -235
  18. package/dist/indexer/db.js +15 -5
  19. package/dist/indexer/ensure-index.js +72 -0
  20. package/dist/indexer/graph-extraction.js +10 -0
  21. package/dist/indexer/indexer.js +38 -22
  22. package/dist/integrations/agent/prompts.js +95 -15
  23. package/dist/integrations/agent/spawn.js +65 -12
  24. package/dist/llm/client.js +40 -2
  25. package/dist/llm/graph-extract.js +2 -4
  26. package/dist/llm/memory-infer.js +7 -4
  27. package/dist/output/cli-hints.js +17 -8
  28. package/dist/output/renderers.js +6 -1
  29. package/dist/output/shapes.js +8 -3
  30. package/dist/output/text.js +18 -19
  31. package/dist/sources/providers/git.js +43 -1
  32. package/dist/workflows/db.js +9 -0
  33. package/dist/workflows/runs.js +25 -8
  34. package/dist/workflows/scope-key.js +76 -0
  35. package/docs/migration/release-notes/0.7.4.md +1 -1
  36. package/docs/migration/release-notes/0.7.5.md +20 -0
  37. package/package.json +2 -2
@@ -47,16 +47,30 @@ function knownTypeList() {
47
47
  return Object.keys(TYPE_DIRS).sort().join(", ");
48
48
  }
49
49
  /**
50
- * Common envelope every prompt asks the agent to honour. The wrapper code
51
- * uses `JSON.parse(stdout)` to extract the payload — anything outside the
52
- * JSON object will be treated as a parse error.
50
+ * Common envelope every prompt asks the agent to honour when NO draft file
51
+ * path is available. The wrapper code uses `JSON.parse(stdout)` to extract
52
+ * the payload — anything outside the JSON object will be treated as a parse
53
+ * error.
53
54
  */
54
- const RESPONSE_CONTRACT = [
55
+ const RESPONSE_CONTRACT_JSON = [
55
56
  "Respond ONLY with a single JSON object. No prose before or after.",
56
57
  'Shape: {"ref": "<type>:<name>", "content": "<full file contents>", "frontmatter": {...}}',
57
58
  "`content` is the full file body that will be written if accepted.",
58
59
  "`frontmatter` is optional — include it if `content` starts with `---` so reviewers can sanity-check the keys.",
59
60
  ].join("\n");
61
+ /**
62
+ * Response contract used when a draft file path is available. Instructs the
63
+ * agent to write the improved asset content directly to the file using its
64
+ * native file-editing tools — no stdout JSON parsing required.
65
+ */
66
+ function fileWriteContract(draftFilePath) {
67
+ return [
68
+ `Write the complete improved asset content to: ${draftFilePath}`,
69
+ "Use your file-editing tools to create or overwrite that file.",
70
+ "Do NOT output JSON to stdout. Do NOT print the file contents. Just write the file.",
71
+ "When you are done writing the file, output a single line: DRAFT_WRITTEN",
72
+ ].join("\n");
73
+ }
60
74
  /**
61
75
  * Build the prompt for `akm reflect [ref]`. Asks the agent to review an
62
76
  * existing asset (plus any negative feedback / lint findings) and propose
@@ -105,7 +119,7 @@ export function buildReflectPrompt(input) {
105
119
  sections.push(`- ${line}`);
106
120
  }
107
121
  sections.push("Produce a single proposal that addresses the feedback and respects the asset-type contract.");
108
- sections.push(RESPONSE_CONTRACT);
122
+ sections.push(input.draftFilePath ? fileWriteContract(input.draftFilePath) : RESPONSE_CONTRACT_JSON);
109
123
  return sections.join("\n\n");
110
124
  }
111
125
  /**
@@ -124,19 +138,35 @@ export function buildProposePrompt(input) {
124
138
  sections.push(`- ${line}`);
125
139
  }
126
140
  sections.push("Produce a single proposal that, if accepted, would land as the asset described above.");
127
- sections.push(RESPONSE_CONTRACT);
141
+ sections.push(input.draftFilePath ? fileWriteContract(input.draftFilePath) : RESPONSE_CONTRACT_JSON);
128
142
  return sections.join("\n\n");
129
143
  }
130
144
  /**
131
145
  * Parse agent stdout into a proposal payload. The agent contract requires a
132
146
  * single JSON object; anything else is reported as a parse error so callers
133
147
  * can map to {@link AgentFailureReason} `parse_error`.
148
+ *
149
+ * Resilient to two common local-LLM failure modes:
150
+ * 1. `<think>…</think>` blocks emitted before the JSON (stripped by `stripJsonFences`).
151
+ * 2. Prose preamble / postamble around the JSON object (handled by `extractEmbeddedJson`).
134
152
  */
135
153
  export function parseAgentProposalPayload(stdout) {
136
154
  const trimmed = stripJsonFences(stdout).trim();
137
155
  if (!trimmed)
138
156
  throw new Error("agent produced empty output");
139
- const parsed = JSON.parse(trimmed);
157
+ let parsed;
158
+ try {
159
+ parsed = JSON.parse(trimmed);
160
+ }
161
+ catch (directErr) {
162
+ // Agent output contains prose before/after the JSON object (e.g. a local
163
+ // LLM that narrates before responding). Try extracting the first balanced
164
+ // top-level `{…}` from the text rather than failing immediately.
165
+ const embedded = extractEmbeddedJson(trimmed);
166
+ if (!embedded)
167
+ throw directErr;
168
+ parsed = embedded;
169
+ }
140
170
  if (typeof parsed.ref !== "string" || !parsed.ref.trim()) {
141
171
  throw new Error('agent response missing required string field "ref"');
142
172
  }
@@ -153,15 +183,65 @@ export function parseAgentProposalPayload(stdout) {
153
183
  return out;
154
184
  }
155
185
  /**
156
- * Strip `\`\`\`json \`\`\`` fences if the agent wrapped its JSON output.
157
- * Mirrors the same helper in `src/llm/client.ts` but kept local here so
158
- * `agent/` does not import from `llm/` (the boundary is one-way per
159
- * v1 spec §9.7 — agents are shell-out only).
186
+ * Extract the first balanced top-level `{…}` object from `text`. Used as a
187
+ * fallback when direct `JSON.parse` fails due to surrounding prose. Kept
188
+ * local to `agent/` (mirrors `parseEmbeddedJsonResponse` in `src/llm/client.ts`
189
+ * without importing across the one-way boundary — v1 spec §9.7).
190
+ */
191
+ function extractEmbeddedJson(text) {
192
+ for (let start = 0; start < text.length; start++) {
193
+ if (text[start] !== "{")
194
+ continue;
195
+ let depth = 0;
196
+ let inString = false;
197
+ let escaped = false;
198
+ for (let i = start; i < text.length; i++) {
199
+ const ch = text[i];
200
+ if (inString) {
201
+ if (escaped) {
202
+ escaped = false;
203
+ }
204
+ else if (ch === "\\") {
205
+ escaped = true;
206
+ }
207
+ else if (ch === '"') {
208
+ inString = false;
209
+ }
210
+ continue;
211
+ }
212
+ if (ch === '"') {
213
+ inString = true;
214
+ continue;
215
+ }
216
+ if (ch === "{")
217
+ depth++;
218
+ if (ch === "}") {
219
+ depth--;
220
+ if (depth === 0) {
221
+ try {
222
+ return JSON.parse(text.slice(start, i + 1));
223
+ }
224
+ catch {
225
+ break;
226
+ }
227
+ }
228
+ }
229
+ }
230
+ }
231
+ return undefined;
232
+ }
233
+ /**
234
+ * Strip `\`\`\`json … \`\`\`` fences and `<think>…</think>` reasoning blocks
235
+ * from agent output. Mirrors `client.ts` but kept local to `agent/` per v1
236
+ * spec §9.7 (one-way boundary — `agent/` does not import from `llm/`).
160
237
  */
161
238
  export function stripJsonFences(text) {
162
- const trimmed = text.trim();
163
- const fenced = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/);
239
+ const stripped = text
240
+ .trim()
241
+ .replace(/<think>[\s\S]*?<\/think>/gi, "")
242
+ .trim();
243
+ const fenced = stripped.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/);
164
244
  if (fenced)
165
- return fenced[1] ?? trimmed;
166
- return trimmed;
245
+ return fenced[1] ?? stripped;
246
+ return stripped;
167
247
  }
@@ -252,21 +252,74 @@ export async function runAgent(profile, prompt, options = {}) {
252
252
  };
253
253
  }
254
254
  if (parseOutput === "json" && stdioMode === "captured") {
255
+ // Strip <think> blocks and code fences, then try direct parse with
256
+ // embedded-JSON fallback for local LLMs that emit prose around the payload.
257
+ const cleaned = stdout
258
+ .trim()
259
+ .replace(/<think>[\s\S]*?<\/think>/gi, "")
260
+ .trim()
261
+ .replace(/^```(?:json)?\s*\n?/, "")
262
+ .replace(/\n?```\s*$/, "")
263
+ .trim();
264
+ let parsed;
255
265
  try {
256
- const parsed = JSON.parse(stdout);
257
- return { ok: true, exitCode, stdout, stderr, durationMs, parsed };
266
+ parsed = JSON.parse(cleaned);
258
267
  }
259
- catch (err) {
260
- return {
261
- ok: false,
262
- exitCode,
263
- stdout,
264
- stderr,
265
- durationMs,
266
- reason: "parse_error",
267
- error: err instanceof Error ? err.message : String(err),
268
- };
268
+ catch {
269
+ // Fallback: extract the first balanced {…} from prose output.
270
+ let found;
271
+ for (let s = 0; s < cleaned.length; s++) {
272
+ if (cleaned[s] !== "{")
273
+ continue;
274
+ let depth = 0, inStr = false, esc = false;
275
+ for (let i = s; i < cleaned.length; i++) {
276
+ const c = cleaned[i];
277
+ if (inStr) {
278
+ if (esc) {
279
+ esc = false;
280
+ }
281
+ else if (c === "\\") {
282
+ esc = true;
283
+ }
284
+ else if (c === '"') {
285
+ inStr = false;
286
+ }
287
+ continue;
288
+ }
289
+ if (c === '"') {
290
+ inStr = true;
291
+ continue;
292
+ }
293
+ if (c === "{")
294
+ depth++;
295
+ if (c === "}") {
296
+ depth--;
297
+ if (depth === 0) {
298
+ try {
299
+ found = JSON.parse(cleaned.slice(s, i + 1));
300
+ }
301
+ catch { }
302
+ break;
303
+ }
304
+ }
305
+ }
306
+ if (found !== undefined)
307
+ break;
308
+ }
309
+ if (found === undefined) {
310
+ return {
311
+ ok: false,
312
+ exitCode,
313
+ stdout,
314
+ stderr,
315
+ durationMs,
316
+ reason: "parse_error",
317
+ error: "no JSON object found in agent output",
318
+ };
319
+ }
320
+ parsed = found;
269
321
  }
322
+ return { ok: true, exitCode, stdout, stderr, durationMs, parsed };
270
323
  }
271
324
  return { ok: true, exitCode, stdout, stderr, durationMs };
272
325
  }
@@ -39,6 +39,7 @@ export function redactErrorBody(input) {
39
39
  return out;
40
40
  }
41
41
  export async function chatCompletion(config, messages, options) {
42
+ const timeoutMs = options?.timeoutMs ?? config.timeoutMs ?? 120_000;
42
43
  const headers = { "Content-Type": "application/json" };
43
44
  if (config.apiKey) {
44
45
  headers.Authorization = `Bearer ${config.apiKey}`;
@@ -53,7 +54,7 @@ export async function chatCompletion(config, messages, options) {
53
54
  max_tokens: options?.maxTokens ?? config.maxTokens ?? 512,
54
55
  ...config.extraParams,
55
56
  }),
56
- }, 30_000, options?.signal);
57
+ }, timeoutMs, options?.signal);
57
58
  if (!response.ok) {
58
59
  const rawBody = await response.text().catch(() => "");
59
60
  const safeBody = redactErrorBody(rawBody);
@@ -64,12 +65,49 @@ export async function chatCompletion(config, messages, options) {
64
65
  }
65
66
  /** Strip leading/trailing markdown code fences from an LLM response. */
66
67
  export function stripJsonFences(raw) {
67
- return raw
68
+ const repaired = raw
68
69
  .trim()
69
70
  .replace(/<think>[\s\S]*?<\/think>/gi, "")
70
71
  .replace(/^```(?:json)?\s*\n?/i, "")
71
72
  .replace(/\n?```\s*$/i, "")
72
73
  .trim();
74
+ let out = "";
75
+ let inString = false;
76
+ let escaped = false;
77
+ for (let i = 0; i < repaired.length; i++) {
78
+ const ch = repaired[i];
79
+ if (escaped) {
80
+ out += ch;
81
+ escaped = false;
82
+ continue;
83
+ }
84
+ if (ch === "\\" && inString) {
85
+ out += ch;
86
+ escaped = true;
87
+ continue;
88
+ }
89
+ if (ch === '"') {
90
+ inString = !inString;
91
+ out += ch;
92
+ continue;
93
+ }
94
+ if (inString) {
95
+ if (ch === "\n") {
96
+ out += "\\n";
97
+ continue;
98
+ }
99
+ if (ch === "\r") {
100
+ out += "\\r";
101
+ continue;
102
+ }
103
+ if (ch === "\t") {
104
+ out += "\\t";
105
+ continue;
106
+ }
107
+ }
108
+ out += ch;
109
+ }
110
+ return out;
73
111
  }
74
112
  /** Parse a possibly-fenced JSON response. Returns undefined if invalid. */
75
113
  export function parseJsonResponse(raw) {
@@ -26,8 +26,6 @@ const MAX_BODY_CHARS = 4000;
26
26
  const MAX_ENTITIES_PER_ASSET = 32;
27
27
  /** Hard cap on relations returned per asset. */
28
28
  const MAX_RELATIONS_PER_ASSET = 32;
29
- /** Hard timeout for the LLM call; an `akm index` run must not hang on a misbehaving endpoint. */
30
- const LLM_TIMEOUT_MS = 30_000;
31
29
  const SYSTEM_PROMPT = "You extract a knowledge graph from developer notes. Return only valid JSON. " + "No prose, no markdown fences.";
32
30
  const USER_PROMPT_PREFIX = `Extract entities and relations from the asset body below.
33
31
 
@@ -61,9 +59,9 @@ export async function extractGraphFromBody(llmConfig, body, signal) {
61
59
  chatCompletion(llmConfig, [
62
60
  { role: "system", content: SYSTEM_PROMPT },
63
61
  { role: "user", content: userPrompt },
64
- ], { maxTokens: 1024, temperature: 0.1, signal }),
62
+ ], { maxTokens: 1024, temperature: 0.1, timeoutMs: llmConfig.timeoutMs ?? 120_000, signal }),
65
63
  new Promise((_, reject) => {
66
- timeoutHandle = setTimeout(() => reject(new Error("graph extraction timed out")), LLM_TIMEOUT_MS);
64
+ timeoutHandle = setTimeout(() => reject(new Error("graph extraction timed out")), llmConfig.timeoutMs ?? 120_000);
67
65
  }),
68
66
  ]);
69
67
  if (!raw)
@@ -20,8 +20,6 @@ import { warn } from "../core/warn";
20
20
  import { chatCompletion, parseEmbeddedJsonResponse } from "./client";
21
21
  /** Hard cap on body chars sent to the model — pragmatic and matches `runLlmEnrich`. */
22
22
  const MAX_BODY_CHARS = 4000;
23
- /** Hard timeout for the LLM call. The index run must not hang on a misbehaving endpoint. */
24
- const LLM_TIMEOUT_MS = 30_000;
25
23
  const SYSTEM_PROMPT = "You compress a developer memory into one high-signal derived memory for later retrieval. " +
26
24
  "Return only valid JSON. No prose outside the JSON object. No markdown fences.";
27
25
  const USER_PROMPT_PREFIX = `Compress the memory below into one concise, information-dense derived memory.
@@ -68,9 +66,14 @@ export async function compressMemoryToDerivedMemory(llmConfig, body, signal) {
68
66
  chatCompletion(llmConfig, [
69
67
  { role: "system", content: SYSTEM_PROMPT },
70
68
  { role: "user", content: userPrompt },
71
- ], { maxTokens: 768, temperature: 0.1, signal }),
69
+ ], {
70
+ maxTokens: llmConfig.maxTokens ?? 4096,
71
+ temperature: 0.1,
72
+ timeoutMs: llmConfig.timeoutMs ?? 120_000,
73
+ signal,
74
+ }),
72
75
  new Promise((_, reject) => {
73
- timeoutHandle = setTimeout(() => reject(new Error("memory inference timed out")), LLM_TIMEOUT_MS);
76
+ timeoutHandle = setTimeout(() => reject(new Error("memory inference timed out")), llmConfig.timeoutMs ?? 120_000);
74
77
  }),
75
78
  ]);
76
79
  if (!raw)
@@ -24,6 +24,10 @@ For workflow tasks:
24
24
  2. Do the step work in your workspace
25
25
  3. \`akm workflow complete <run-id> --step <step-id>\` — mark done, get next step
26
26
 
27
+ Workflow runs are scoped to your current project/worktree/directory. Ref-based
28
+ commands like \`workflow next workflow:<name>\`, \`workflow status workflow:<name>\`,
29
+ and \`workflow list\` operate within the current scope only.
30
+
27
31
  ## Quick Reference
28
32
 
29
33
  \`\`\`sh
@@ -55,7 +59,7 @@ akm registry search "<query>" # Search all registries
55
59
  | knowledge | A reference doc (use \`toc\` or \`section "..."\` to navigate) |
56
60
  | workflow | Parsed steps plus workflow-specific execution commands |
57
61
  | memory | Recalled context (read the content for background information) |
58
- | vault | Key names only; use vault commands to inspect or load values safely |
62
+ | vault | Key names only; use \`akm vault path\` or \`akm vault run\` to use values safely |
59
63
  | wiki | A page in a multi-wiki knowledge base. For any wiki task, start with \`akm wiki list\`, then \`akm wiki ingest <name>\` for the workflow. Run \`akm wiki -h\` for the full surface. |
60
64
 
61
65
  When an asset meaningfully helps or fails, record that with \`akm feedback\` so
@@ -186,24 +190,29 @@ akm vault create prod # Create a new vault
186
190
  akm vault set prod DB_URL postgres://... # Set a key (or KEY=VALUE combined form)
187
191
  akm vault set prod DB_URL=postgres://... # Combined KEY=VALUE form also works
188
192
  akm vault unset prod DB_URL # Remove a key
189
- akm vault list vault:prod # List key names (no values)
190
- akm vault show vault:prod # Same as list (alias)
191
- akm vault load vault:prod # Print export statements to source
193
+ akm vault list # List all vaults across all stashes with key names
194
+ akm vault path vault:prod # Print the vault file path for shell loading
195
+ akm vault run vault:prod -- env # Run one command with all vault vars injected
196
+ akm vault run vault:prod/DB_URL -- printenv DB_URL # Inject one key for one command
192
197
  \`\`\`
193
198
 
194
199
  ## Workflows
195
200
 
196
201
  Step-based workflows stored as \`<stashDir>/workflows/<name>.md\`.
197
202
 
203
+ Ref-based workflow commands are scoped to the current project/worktree/directory,
204
+ so one active run does not block unrelated directories from starting the same
205
+ workflow. Direct run-id commands still target the exact run.
206
+
198
207
  \`\`\`sh
199
208
  akm workflow template # Print a starter workflow template
200
209
  akm workflow create ship-release # Scaffold a new workflow asset
201
- akm workflow start workflow:ship-release # Start a new run
202
- akm workflow next workflow:ship-release # Advance to the next step (or auto-start)
210
+ akm workflow start workflow:ship-release # Start a new run in the current scope
211
+ akm workflow next workflow:ship-release # Advance to the next step (or auto-start) in the current scope
203
212
  akm workflow complete <run-id> # Mark a step complete and advance
204
- akm workflow status <run-id> # Show current run status
213
+ akm workflow status <run-id> # Show the exact run by id
205
214
  akm workflow resume <run-id> # Resume a blocked or failed run
206
- akm workflow list # List all workflow runs
215
+ akm workflow list # List workflow runs in the current scope
207
216
  \`\`\`
208
217
 
209
218
  ## Clone
@@ -563,12 +563,17 @@ const vaultEnvRenderer = {
563
563
  type: "vault",
564
564
  name,
565
565
  path: ctx.absPath,
566
- action: 'Vault — keys + comments only. Use `eval "$(akm vault load <ref>)"` to load values into the current shell. Values stay on disk and are never written to akm\'s stdout.',
566
+ action: 'Vault — keys + comments only. Use `source "$(akm vault path <ref>)"` to load values into the current shell, or `akm vault run <ref[/KEY]> -- <command>` to run with injected env. Values stay on disk and are never written to akm\'s stdout.',
567
567
  description: comments.length > 0 ? comments.join("\n") : undefined,
568
568
  keys,
569
569
  comments,
570
570
  };
571
571
  },
572
+ enrichSearchHit(hit, _stashDir) {
573
+ const { keys } = listVaultKeys(hit.path);
574
+ if (keys.length > 0)
575
+ hit.keys = keys;
576
+ },
572
577
  extractMetadata(entry, ctx) {
573
578
  // Re-derive from the file directly to guarantee no value ever transits
574
579
  // through any other code path. Caller already short-circuits in
@@ -407,19 +407,22 @@ export function shapeSearchHit(hit, detail) {
407
407
  // `ref` is included at `brief` so agents can run `akm show <ref>` without
408
408
  // needing --detail full or --for-agent (REC-03).
409
409
  if (detail === "brief")
410
- return pickFields(hit, ["type", "name", "ref", "action", "estimatedTokens"]);
410
+ return pickFields(hit, ["type", "name", "ref", "action", "estimatedTokens", "keys"]);
411
411
  if (detail === "normal") {
412
412
  // `warnings` is projected at `normal` so non-fatal hit-level issues are
413
413
  // visible without forcing callers up to `--detail full`. Optional
414
414
  // `quality` (v1 spec §4.2) is also surfaced when present so callers
415
415
  // can see why a `proposed` entry showed up under `--include-proposed`.
416
- return capDescription(pickFields(hit, ["type", "name", "description", "action", "score", "estimatedTokens", "warnings", "quality"]), NORMAL_DESCRIPTION_LIMIT);
416
+ const shaped = capDescription(pickFields(hit, ["type", "name", "description", "action", "score", "estimatedTokens", "warnings", "quality"]), NORMAL_DESCRIPTION_LIMIT);
417
+ if (Array.isArray(hit.keys) && hit.keys.length > 0)
418
+ shaped.keys = hit.keys;
419
+ return shaped;
417
420
  }
418
421
  return hit;
419
422
  }
420
423
  /** Agent-optimized search hit: only fields an LLM agent needs to decide and act */
421
424
  export function shapeSearchHitForAgent(hit) {
422
- const picked = pickFields(hit, ["name", "ref", "type", "description", "action", "score", "estimatedTokens"]);
425
+ const picked = pickFields(hit, ["name", "ref", "type", "description", "action", "score", "estimatedTokens", "keys"]);
423
426
  return capDescription(picked, NORMAL_DESCRIPTION_LIMIT);
424
427
  }
425
428
  export function capDescription(hit, limit) {
@@ -449,6 +452,7 @@ export function shapeShowOutput(result, detail, forAgent = false) {
449
452
  "run",
450
453
  "setup",
451
454
  "cwd",
455
+ "activeRun",
452
456
  "toolPolicy",
453
457
  "modelHint",
454
458
  "agent",
@@ -495,6 +499,7 @@ export function shapeShowOutput(result, detail, forAgent = false) {
495
499
  "run",
496
500
  "setup",
497
501
  "cwd",
502
+ "activeRun",
498
503
  "keys",
499
504
  "comments",
500
505
  // path and editable are always projected so JSON consumers can locate and
@@ -404,22 +404,7 @@ export function formatRegistryBuildIndexPlain(r) {
404
404
  return `Wrote registry index ${version} (${total} kits) → ${outPath}`.replace(/\s+/g, " ").trim();
405
405
  }
406
406
  export function formatVaultListPlain(r) {
407
- // Single-vault listing: { ref, path, entries: [{ key, comment? }, ...] }
408
- if (typeof r.ref === "string" && Array.isArray(r.entries)) {
409
- const ref = r.ref;
410
- const entries = r.entries;
411
- if (entries.length === 0) {
412
- return `No keys in ${ref}. Set one with \`akm vault set ${ref} KEY=VALUE\`.`;
413
- }
414
- const lines = [ref];
415
- for (const e of entries) {
416
- const key = String(e.key ?? "?");
417
- const comment = typeof e.comment === "string" && e.comment ? ` # ${e.comment}` : "";
418
- lines.push(` ${key}${comment}`);
419
- }
420
- return lines.join("\n");
421
- }
422
- // Multi-vault listing: { vaults: [{ ref, path, keyCount }, ...] }
407
+ // Multi-vault listing: { vaults: [{ ref, path, keys }, ...] }
423
408
  const vaults = Array.isArray(r.vaults) ? r.vaults : [];
424
409
  if (vaults.length === 0) {
425
410
  return "No vaults. Create one with `akm vault create <name>` then `akm vault set vault:<name> KEY=VALUE`.";
@@ -427,8 +412,17 @@ export function formatVaultListPlain(r) {
427
412
  const lines = [];
428
413
  for (const v of vaults) {
429
414
  const ref = String(v.ref ?? "?");
430
- const keyCount = typeof v.keyCount === "number" ? v.keyCount : 0;
431
- lines.push(`${ref}\t${keyCount} key(s)`);
415
+ const keys = Array.isArray(v.keys) ? v.keys.map(String) : [];
416
+ if (lines.length > 0)
417
+ lines.push("");
418
+ lines.push(`## ${ref}`);
419
+ if (keys.length === 0) {
420
+ lines.push("- (no keys)");
421
+ continue;
422
+ }
423
+ for (const key of keys) {
424
+ lines.push(`- ${key}`);
425
+ }
432
426
  }
433
427
  return lines.join("\n");
434
428
  }
@@ -795,7 +789,7 @@ function isCommandOutputSkill(lines) {
795
789
  export function formatWorkflowListPlain(result) {
796
790
  const runs = Array.isArray(result.runs) ? result.runs : [];
797
791
  if (runs.length === 0) {
798
- return "No workflow runs. Start one with `akm workflow next workflow:<name>` or author one with `akm workflow create <name>`.";
792
+ return "No workflow runs in the current working scope. Start one with `akm workflow next workflow:<name>` or author one with `akm workflow create <name>`.";
799
793
  }
800
794
  return runs
801
795
  .map((run) => {
@@ -906,6 +900,8 @@ export function formatSearchPlain(r, detail) {
906
900
  lines.push(` ref: ${String(hit.ref)}`);
907
901
  if (hit.origin !== undefined)
908
902
  lines.push(` origin: ${String(hit.origin)}`);
903
+ if (Array.isArray(hit.keys) && hit.keys.length > 0)
904
+ lines.push(` keys: ${hit.keys.join(", ")}`);
909
905
  if (hit.size)
910
906
  lines.push(` size: ${String(hit.size)}`);
911
907
  if (hit.action)
@@ -1088,6 +1084,9 @@ export function formatCuratePlain(r, detail) {
1088
1084
  lines.push(` ref: ${String(item.ref)}`);
1089
1085
  if (item.id)
1090
1086
  lines.push(` id: ${String(item.id)}`);
1087
+ if (Array.isArray(item.keys) && item.keys.length > 0) {
1088
+ lines.push(` keys: ${item.keys.join(", ")}`);
1089
+ }
1091
1090
  if (Array.isArray(item.parameters) && item.parameters.length > 0) {
1092
1091
  lines.push(` parameters: ${item.parameters.join(", ")}`);
1093
1092
  }
@@ -407,7 +407,7 @@ export function saveGitStash(name, message, writableOverride) {
407
407
  let writable = false;
408
408
  if (name) {
409
409
  const config = loadConfig();
410
- const stash = (config.sources ?? config.stashes ?? []).find((s) => s.name === name || s.url === name);
410
+ const stash = findGitStashByTarget(config.sources ?? config.stashes ?? [], name);
411
411
  if (!stash)
412
412
  throw new UsageError(`No git stash found with name "${name}"`);
413
413
  if (!GIT_STASH_TYPES.has(stash.type)) {
@@ -468,5 +468,47 @@ export function saveGitStash(name, message, writableOverride) {
468
468
  output: (commitResult.stdout + pushResult.stdout).trim() || "changes committed and pushed",
469
469
  };
470
470
  }
471
+ function findGitStashByTarget(stashes, target) {
472
+ return stashes.find((stash) => matchesGitStashTarget(stash, target));
473
+ }
474
+ function matchesGitStashTarget(stash, target) {
475
+ if (!GIT_STASH_TYPES.has(stash.type))
476
+ return false;
477
+ if (stash.name === target || stash.url === target)
478
+ return true;
479
+ if (!stash.url)
480
+ return false;
481
+ try {
482
+ const repo = parseGitRepoUrl(stash.url);
483
+ if (repo.canonicalUrl === target)
484
+ return true;
485
+ return buildGithubTargetAliases(repo.canonicalUrl).has(target);
486
+ }
487
+ catch {
488
+ return false;
489
+ }
490
+ }
491
+ function buildGithubTargetAliases(canonicalUrl) {
492
+ try {
493
+ const parsed = new URL(canonicalUrl);
494
+ if (parsed.hostname !== "github.com")
495
+ return new Set();
496
+ const segments = parsed.pathname.split("/").filter(Boolean);
497
+ if (segments.length < 2)
498
+ return new Set();
499
+ const owner = segments[0];
500
+ const repo = segments[1];
501
+ const aliases = new Set([`${owner}/${repo}`, `github:${owner}/${repo}`]);
502
+ if (segments[2] === "tree" && segments.length >= 4) {
503
+ const ref = segments.slice(3).join("/");
504
+ aliases.add(`${owner}/${repo}#${ref}`);
505
+ aliases.add(`github:${owner}/${repo}#${ref}`);
506
+ }
507
+ return aliases;
508
+ }
509
+ catch {
510
+ return new Set();
511
+ }
512
+ }
471
513
  // ── Exports ─────────────────────────────────────────────────────────────────
472
514
  export { ensureGitMirror, GitSourceProvider, getCachePaths, parseGitRepoUrl };
@@ -21,6 +21,7 @@ function ensureWorkflowSchema(db) {
21
21
  CREATE TABLE IF NOT EXISTS workflow_runs (
22
22
  id TEXT PRIMARY KEY,
23
23
  workflow_ref TEXT NOT NULL,
24
+ scope_key TEXT,
24
25
  workflow_entry_id INTEGER,
25
26
  workflow_title TEXT NOT NULL,
26
27
  status TEXT NOT NULL CHECK (status IN ('active', 'completed', 'blocked', 'failed')),
@@ -52,4 +53,12 @@ function ensureWorkflowSchema(db) {
52
53
  CREATE INDEX IF NOT EXISTS idx_workflow_run_steps_run_sequence
53
54
  ON workflow_run_steps(run_id, sequence_index);
54
55
  `);
56
+ const columns = db
57
+ .query("PRAGMA table_info(workflow_runs)")
58
+ .all()
59
+ .map((column) => column.name);
60
+ if (!columns.includes("scope_key")) {
61
+ db.exec("ALTER TABLE workflow_runs ADD COLUMN scope_key TEXT");
62
+ }
63
+ db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_runs_scope_ref_status ON workflow_runs(scope_key, workflow_ref, status)");
55
64
  }