akm-cli 0.7.4 → 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.
- package/{CHANGELOG.md → .github/CHANGELOG.md} +34 -1
- package/.github/LICENSE +374 -0
- package/dist/cli/parse-args.js +86 -0
- package/dist/cli.js +1223 -650
- package/dist/commands/agent-dispatch.js +107 -0
- package/dist/commands/agent-support.js +62 -0
- package/dist/commands/config-cli.js +68 -84
- package/dist/commands/consolidate.js +812 -0
- package/dist/commands/curate.js +1 -0
- package/dist/commands/distill-promotion-policy.js +658 -0
- package/dist/commands/distill.js +224 -39
- package/dist/commands/eval-cases.js +40 -0
- package/dist/commands/events.js +12 -24
- package/dist/commands/graph.js +222 -0
- package/dist/commands/health.js +376 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +53 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +3 -30
- package/dist/commands/improve.js +1161 -0
- package/dist/commands/info.js +2 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/install-audit.js +5 -1
- package/dist/commands/installed-stashes.js +118 -138
- package/dist/commands/knowledge.js +133 -0
- package/dist/commands/lint/agent-linter.js +46 -0
- package/dist/commands/lint/base-linter.js +291 -0
- package/dist/commands/lint/command-linter.js +46 -0
- package/dist/commands/lint/default-linter.js +13 -0
- package/dist/commands/lint/index.js +145 -0
- package/dist/commands/lint/knowledge-linter.js +13 -0
- package/dist/commands/lint/memory-linter.js +58 -0
- package/dist/commands/lint/registry.js +33 -0
- package/dist/commands/lint/skill-linter.js +42 -0
- package/dist/commands/lint/task-linter.js +47 -0
- package/dist/commands/lint/types.js +1 -0
- package/dist/commands/lint/vault-key-rules.js +67 -0
- package/dist/commands/lint/workflow-linter.js +53 -0
- package/dist/commands/lint.js +1 -0
- package/dist/commands/migration-help.js +2 -2
- package/dist/commands/proposal.js +8 -7
- package/dist/commands/propose.js +106 -43
- package/dist/commands/reflect.js +167 -41
- package/dist/commands/registry-search.js +2 -2
- package/dist/commands/remember.js +55 -1
- package/dist/commands/schema-repair.js +130 -0
- package/dist/commands/search.js +21 -5
- package/dist/commands/show.js +135 -55
- package/dist/commands/source-add.js +10 -10
- package/dist/commands/source-manage.js +11 -19
- package/dist/commands/tasks.js +385 -0
- package/dist/commands/url-checker.js +39 -0
- package/dist/commands/vault.js +173 -87
- package/dist/core/action-contributors.js +25 -0
- package/dist/core/asset-ref.js +4 -0
- package/dist/core/asset-registry.js +5 -17
- package/dist/core/asset-spec.js +11 -1
- package/dist/core/common.js +100 -0
- package/dist/core/concurrent.js +22 -0
- package/dist/core/config.js +240 -127
- package/dist/core/events.js +87 -123
- package/dist/core/frontmatter.js +0 -6
- package/dist/core/markdown.js +17 -0
- package/dist/core/memory-improve.js +678 -0
- package/dist/core/parse.js +155 -0
- package/dist/core/paths.js +101 -3
- package/dist/core/proposal-validators.js +61 -0
- package/dist/core/proposals.js +49 -38
- package/dist/core/state-db.js +731 -0
- package/dist/core/time.js +51 -0
- package/dist/core/warn.js +59 -1
- package/dist/indexer/db-search.js +86 -472
- package/dist/indexer/db.js +418 -59
- package/dist/indexer/ensure-index.js +133 -0
- package/dist/indexer/graph-boost.js +247 -94
- package/dist/indexer/graph-db.js +201 -0
- package/dist/indexer/graph-dedup.js +99 -0
- package/dist/indexer/graph-extraction.js +417 -74
- package/dist/indexer/index-context.js +10 -0
- package/dist/indexer/indexer.js +480 -298
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/matchers.js +124 -160
- package/dist/indexer/memory-inference.js +63 -29
- package/dist/indexer/metadata-contributors.js +26 -0
- package/dist/indexer/metadata.js +196 -197
- package/dist/indexer/path-resolver.js +89 -0
- package/dist/indexer/ranking-contributors.js +204 -0
- package/dist/indexer/ranking.js +74 -0
- package/dist/indexer/search-hit-enrichers.js +22 -0
- package/dist/indexer/search-source.js +24 -9
- package/dist/indexer/semantic-status.js +2 -16
- package/dist/indexer/walker.js +25 -0
- package/dist/integrations/agent/builders.js +109 -0
- package/dist/integrations/agent/config.js +203 -3
- package/dist/integrations/agent/index.js +5 -2
- package/dist/integrations/agent/model-aliases.js +63 -0
- package/dist/integrations/agent/profiles.js +67 -5
- package/dist/integrations/agent/prompts.js +114 -29
- package/dist/integrations/agent/sdk-runner.js +120 -0
- package/dist/integrations/agent/spawn.js +158 -34
- package/dist/integrations/lockfile.js +10 -18
- package/dist/integrations/session-logs/index.js +65 -0
- package/dist/integrations/session-logs/providers/claude-code.js +56 -0
- package/dist/integrations/session-logs/providers/opencode.js +52 -0
- package/dist/integrations/session-logs/types.js +1 -0
- package/dist/llm/call-ai.js +74 -0
- package/dist/llm/client.js +63 -86
- package/dist/llm/feature-gate.js +27 -16
- package/dist/llm/graph-extract.js +297 -64
- package/dist/llm/memory-infer.js +52 -71
- package/dist/llm/metadata-enhance.js +39 -22
- package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
- package/dist/output/cli-hints-full.md +277 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +2 -309
- package/dist/output/renderers.js +226 -257
- package/dist/output/shapes.js +109 -96
- package/dist/output/text.js +274 -36
- package/dist/registry/providers/skills-sh.js +61 -49
- package/dist/registry/providers/static-index.js +44 -48
- package/dist/registry/resolve.js +8 -16
- package/dist/setup/setup.js +510 -11
- package/dist/sources/provider-factory.js +2 -1
- package/dist/sources/providers/filesystem.js +16 -23
- package/dist/sources/providers/git.js +45 -4
- package/dist/sources/providers/website.js +15 -22
- package/dist/sources/website-ingest.js +4 -0
- package/dist/tasks/backends/cron.js +200 -0
- package/dist/tasks/backends/exec-utils.js +25 -0
- package/dist/tasks/backends/index.js +32 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +184 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +212 -0
- package/dist/tasks/parser.js +198 -0
- package/dist/tasks/resolveAkmBin.js +84 -0
- package/dist/tasks/runner.js +432 -0
- package/dist/tasks/schedule.js +208 -0
- package/dist/tasks/schema.js +13 -0
- package/dist/tasks/validator.js +59 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +12 -0
- package/dist/wiki/wiki.js +10 -61
- package/dist/workflows/authoring.js +5 -25
- package/dist/workflows/db.js +9 -0
- package/dist/workflows/renderer.js +8 -3
- package/dist/workflows/runs.js +73 -88
- package/dist/workflows/scope-key.js +76 -0
- package/dist/workflows/validator.js +1 -1
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +5 -2
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.7.4.md +1 -1
- package/docs/migration/release-notes/0.7.5.md +20 -0
- package/docs/migration/release-notes/0.8.0.md +43 -0
- package/package.json +4 -3
- package/dist/templates/wiki-templates.js +0 -100
package/dist/cli.js
CHANGED
|
@@ -1,39 +1,46 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import * as p from "@clack/prompts";
|
|
5
6
|
import { defineCommand, runMain } from "citty";
|
|
7
|
+
import { getStringArg, hasSubcommand, parseNonNegativeIntFlag, parsePositiveIntFlag } from "./cli/parse-args";
|
|
8
|
+
import { akmAgentDispatch } from "./commands/agent-dispatch";
|
|
6
9
|
import { generateBashCompletions, installBashCompletions } from "./commands/completions";
|
|
7
10
|
import { getConfigValue, listConfig, setConfigValue, unsetConfigValue } from "./commands/config-cli";
|
|
8
11
|
import { akmCurate } from "./commands/curate";
|
|
9
|
-
import { akmDistill } from "./commands/distill";
|
|
10
12
|
import { akmEventsList, akmEventsTail } from "./commands/events";
|
|
13
|
+
import { akmGraphEntities, akmGraphExport, akmGraphRelated, akmGraphRelations, akmGraphSummary, } from "./commands/graph";
|
|
14
|
+
import { akmHealth } from "./commands/health";
|
|
11
15
|
import { akmHistory } from "./commands/history";
|
|
16
|
+
import { akmImprove } from "./commands/improve";
|
|
12
17
|
import { assembleInfo } from "./commands/info";
|
|
13
18
|
import { akmInit } from "./commands/init";
|
|
14
19
|
import { akmListSources, akmRemove, akmUpdate } from "./commands/installed-stashes";
|
|
20
|
+
import { readKnowledgeInput, writeMarkdownAsset } from "./commands/knowledge";
|
|
21
|
+
import { akmLint } from "./commands/lint";
|
|
15
22
|
import { renderMigrationHelp } from "./commands/migration-help";
|
|
16
23
|
import { akmProposalAccept, akmProposalDiff, akmProposalList, akmProposalReject, akmProposalShow, } from "./commands/proposal";
|
|
17
24
|
import { akmPropose } from "./commands/propose";
|
|
18
|
-
import { akmReflect } from "./commands/reflect";
|
|
19
25
|
import { searchRegistry } from "./commands/registry-search";
|
|
20
|
-
import { buildMemoryFrontmatter, parseDuration, readMemoryContent, runAutoHeuristics, runLlmEnrich, } from "./commands/remember";
|
|
21
|
-
import { akmSearch, parseScopeFilterFlags, parseSearchSource } from "./commands/search";
|
|
26
|
+
import { buildMemoryFrontmatter, parseDuration, readMemoryContent, resolveRememberContentArg, runAutoHeuristics, runLlmEnrich, } from "./commands/remember";
|
|
27
|
+
import { akmSearch, parseBeliefFilterMode, parseScopeFilterFlags, parseSearchSource } from "./commands/search";
|
|
22
28
|
import { checkForUpdate, performUpgrade } from "./commands/self-update";
|
|
23
|
-
import { akmShowUnified } from "./commands/show";
|
|
29
|
+
import { akmShowUnified, normalizeShowArgv } from "./commands/show";
|
|
24
30
|
import { akmAdd } from "./commands/source-add";
|
|
25
31
|
import { akmClone } from "./commands/source-clone";
|
|
26
32
|
import { addStash } from "./commands/source-manage";
|
|
33
|
+
import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksList, akmTasksRemove, akmTasksRun, akmTasksSetEnabled, akmTasksShow, akmTasksSync, parseTaskRef, } from "./commands/tasks";
|
|
27
34
|
import { parseAssetRef } from "./core/asset-ref";
|
|
28
35
|
import { deriveCanonicalAssetName, resolveAssetPathFromName } from "./core/asset-spec";
|
|
29
|
-
import { isHttpUrl, isWithin, resolveStashDir
|
|
30
|
-
import { DEFAULT_CONFIG,
|
|
36
|
+
import { isHttpUrl, isWithin, resolveStashDir } from "./core/common";
|
|
37
|
+
import { DEFAULT_CONFIG, loadConfig, loadUserConfig, resolveConfiguredSources, saveConfig } from "./core/config";
|
|
31
38
|
import { ConfigError, NotFoundError, UsageError } from "./core/errors";
|
|
32
39
|
import { appendEvent } from "./core/events";
|
|
33
|
-
import { getCacheDir, getDbPath, getDefaultStashDir } from "./core/paths";
|
|
34
|
-
import { setQuiet, setVerbose, warn } from "./core/warn";
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
40
|
+
import { getCacheDir, getConfigPath, getDbPath, getDefaultStashDir } from "./core/paths";
|
|
41
|
+
import { clearLogFile, info, setLogFile, setQuiet, setVerbose, warn } from "./core/warn";
|
|
42
|
+
import { applyFeedbackToUtilityScore, closeDatabase, findEntryIdByRef, openExistingDatabase } from "./indexer/db";
|
|
43
|
+
import { ensureIndex } from "./indexer/ensure-index";
|
|
37
44
|
import { akmIndex } from "./indexer/indexer";
|
|
38
45
|
import { resolveSourceEntries } from "./indexer/search-source";
|
|
39
46
|
import { insertUsageEvent } from "./indexer/usage-events";
|
|
@@ -45,16 +52,22 @@ import { buildRegistryIndex, writeRegistryIndex } from "./registry/build-index";
|
|
|
45
52
|
import { resolveSourcesForOrigin } from "./registry/origin-resolve";
|
|
46
53
|
import { saveGitStash } from "./sources/providers/git";
|
|
47
54
|
import { resolveAssetPath } from "./sources/resolve";
|
|
48
|
-
import { fetchWebsiteMarkdownSnapshot } from "./sources/website-ingest";
|
|
49
55
|
import { pkgVersion } from "./version";
|
|
50
56
|
import { createWorkflowAsset, formatWorkflowErrors, getWorkflowTemplate, validateWorkflowSource, } from "./workflows/authoring";
|
|
51
57
|
import { hasWorkflowSubcommand, parseWorkflowJsonObject, parseWorkflowStepState, WORKFLOW_STEP_STATES, } from "./workflows/cli";
|
|
52
58
|
import { completeWorkflowStep, getNextWorkflowStep, getWorkflowStatus, listWorkflowRuns, resumeWorkflowRun, startWorkflowRun, } from "./workflows/runs";
|
|
53
|
-
const MAX_CAPTURED_ASSET_SLUG_LENGTH = 64;
|
|
54
59
|
const SKILLS_SH_NAME = "skills.sh";
|
|
55
60
|
const SKILLS_SH_URL = "https://skills.sh";
|
|
56
61
|
const SKILLS_SH_PROVIDER = "skills-sh";
|
|
57
62
|
import { stringify as yamlStringify } from "yaml";
|
|
63
|
+
function applyEarlyStderrFlags(argv) {
|
|
64
|
+
if (argv.includes("--quiet") || argv.includes("-q")) {
|
|
65
|
+
setQuiet(true);
|
|
66
|
+
}
|
|
67
|
+
if (argv.includes("--verbose")) {
|
|
68
|
+
setVerbose(true);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
58
71
|
/**
|
|
59
72
|
* Collect all occurrences of a repeatable flag from process.argv.
|
|
60
73
|
* Citty's StringArgDef only exposes the last value when a flag is repeated,
|
|
@@ -78,6 +91,43 @@ function parseAllFlagValues(flag) {
|
|
|
78
91
|
}
|
|
79
92
|
return values;
|
|
80
93
|
}
|
|
94
|
+
function resolveHelpMigrateVersionArg(version) {
|
|
95
|
+
if (version === undefined)
|
|
96
|
+
return undefined;
|
|
97
|
+
const parsedFormat = parseFlagValue(process.argv, "--format");
|
|
98
|
+
if (parsedFormat !== undefined &&
|
|
99
|
+
version === parsedFormat &&
|
|
100
|
+
wasHelpMigrateFlagValueConsumedAsVersion(version, parsedFormat, "--format")) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const parsedDetail = parseFlagValue(process.argv, "--detail");
|
|
104
|
+
if (parsedDetail !== undefined &&
|
|
105
|
+
version === parsedDetail &&
|
|
106
|
+
wasHelpMigrateFlagValueConsumedAsVersion(version, parsedDetail, "--detail")) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
return version;
|
|
110
|
+
}
|
|
111
|
+
function wasHelpMigrateFlagValueConsumedAsVersion(version, flagValue, flagName) {
|
|
112
|
+
const argv = process.argv.slice(2);
|
|
113
|
+
const helpIndex = argv.indexOf("help");
|
|
114
|
+
const tokens = helpIndex >= 0 ? argv.slice(helpIndex + 1) : argv;
|
|
115
|
+
const migrateIndex = tokens.indexOf("migrate");
|
|
116
|
+
const relevant = migrateIndex >= 0 ? tokens.slice(migrateIndex + 1) : tokens;
|
|
117
|
+
let flagIndex = -1;
|
|
118
|
+
for (let i = 0; i < relevant.length; i += 1) {
|
|
119
|
+
const token = relevant[i];
|
|
120
|
+
if (token === flagName || token === `${flagName}=${flagValue}`) {
|
|
121
|
+
flagIndex = i;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (flagIndex === -1)
|
|
126
|
+
return false;
|
|
127
|
+
if (relevant.slice(0, flagIndex).includes(version))
|
|
128
|
+
return false;
|
|
129
|
+
return relevant[flagIndex] === flagName ? relevant[flagIndex + 1] === version : true;
|
|
130
|
+
}
|
|
81
131
|
function output(command, result) {
|
|
82
132
|
const mode = getOutputMode();
|
|
83
133
|
const shaped = shapeForCommand(command, result, mode.detail, mode.forAgent);
|
|
@@ -109,12 +159,57 @@ function output(command, result) {
|
|
|
109
159
|
const setupCommand = defineCommand({
|
|
110
160
|
meta: {
|
|
111
161
|
name: "setup",
|
|
112
|
-
description: "Interactive configuration wizard
|
|
162
|
+
description: "Interactive configuration wizard. Configures embeddings/LLM connections (for indexing/enrichment), agent profiles (CLI agent, embedded SDK, or none), sources, and registries. Shows which features are enabled at the end. Use --config <json> or --yes for non-interactive/scripting mode.",
|
|
113
163
|
},
|
|
114
|
-
|
|
164
|
+
args: {
|
|
165
|
+
config: {
|
|
166
|
+
type: "string",
|
|
167
|
+
description: 'Config JSON to apply non-interactively, e.g. \'{"llm":{"endpoint":"...","model":"..."}}\'',
|
|
168
|
+
},
|
|
169
|
+
yes: {
|
|
170
|
+
type: "boolean",
|
|
171
|
+
default: false,
|
|
172
|
+
description: "Accept all defaults, skip all prompts. Idempotent — safe to run in CI.",
|
|
173
|
+
},
|
|
174
|
+
dir: {
|
|
175
|
+
type: "string",
|
|
176
|
+
description: "Stash directory path (overrides stashDir in config or --config JSON)",
|
|
177
|
+
},
|
|
178
|
+
probe: {
|
|
179
|
+
type: "boolean",
|
|
180
|
+
default: false,
|
|
181
|
+
description: "Probe LLM/embedding endpoints after writing config to verify connectivity",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
async run({ args }) {
|
|
115
185
|
await runWithJsonErrors(async () => {
|
|
116
|
-
const
|
|
117
|
-
|
|
186
|
+
const noInit = getHyphenatedBoolean(args, "no-init");
|
|
187
|
+
if (args.config) {
|
|
188
|
+
// Non-interactive config mode
|
|
189
|
+
const { runSetupFromConfig } = await import("./setup/setup");
|
|
190
|
+
const result = await runSetupFromConfig({
|
|
191
|
+
configJson: args.config,
|
|
192
|
+
dir: args.dir,
|
|
193
|
+
noInit,
|
|
194
|
+
probe: args.probe,
|
|
195
|
+
});
|
|
196
|
+
output("setup", result);
|
|
197
|
+
}
|
|
198
|
+
else if (args.yes) {
|
|
199
|
+
// Defaults mode — no prompts
|
|
200
|
+
const { runSetupWithDefaults } = await import("./setup/setup");
|
|
201
|
+
const result = await runSetupWithDefaults({
|
|
202
|
+
dir: args.dir,
|
|
203
|
+
noInit,
|
|
204
|
+
probe: args.probe,
|
|
205
|
+
});
|
|
206
|
+
output("setup", result);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
// Interactive wizard
|
|
210
|
+
const { runSetupWizard } = await import("./setup/setup");
|
|
211
|
+
await runSetupWizard({ dir: args.dir, noInit });
|
|
212
|
+
}
|
|
118
213
|
});
|
|
119
214
|
},
|
|
120
215
|
});
|
|
@@ -140,16 +235,23 @@ const indexCommand = defineCommand({
|
|
|
140
235
|
meta: { name: "index", description: "Build search index (incremental by default; --full forces full reindex)" },
|
|
141
236
|
args: {
|
|
142
237
|
full: { type: "boolean", description: "Force full reindex", default: false },
|
|
143
|
-
enrich: { type: "boolean", description: "Enable LLM inference and enrichment passes", default: false },
|
|
144
238
|
verbose: { type: "boolean", description: "Print phase-by-phase indexing progress to stderr", default: false },
|
|
145
239
|
},
|
|
146
240
|
async run({ args }) {
|
|
147
241
|
await runWithJsonErrors(async () => {
|
|
242
|
+
if (getHyphenatedBoolean(args, "enrich") || parseFlagValue(process.argv, "--enrich") !== undefined) {
|
|
243
|
+
throw new UsageError("`akm index --enrich` has been removed. Plain `akm index` now performs metadata enrichment by default.");
|
|
244
|
+
}
|
|
245
|
+
if (getHyphenatedBoolean(args, "re-enrich") || parseFlagValue(process.argv, "--re-enrich") !== undefined) {
|
|
246
|
+
throw new UsageError("`akm index --re-enrich` has been removed. Re-enrichment of index-time LLM passes is not exposed in this slice.");
|
|
247
|
+
}
|
|
148
248
|
const outputMode = getOutputMode();
|
|
149
249
|
const controller = new AbortController();
|
|
150
250
|
const abort = () => controller.abort(new Error("index interrupted"));
|
|
151
251
|
process.once("SIGINT", abort);
|
|
152
252
|
process.once("SIGTERM", abort);
|
|
253
|
+
const indexLogFile = path.join(getCacheDir(), "logs", "index", `${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
|
|
254
|
+
setLogFile(indexLogFile);
|
|
153
255
|
const spin = !args.verbose && outputMode.format === "text" ? p.spinner() : null;
|
|
154
256
|
if (spin) {
|
|
155
257
|
spin.start(`Building search index${args.full ? " (full rebuild)" : ""}...`);
|
|
@@ -158,12 +260,11 @@ const indexCommand = defineCommand({
|
|
|
158
260
|
try {
|
|
159
261
|
const result = await akmIndex({
|
|
160
262
|
full: args.full,
|
|
161
|
-
|
|
162
|
-
onProgress: ({ message, processed, total }) => {
|
|
263
|
+
onProgress: ({ phase, message, processed, total }) => {
|
|
163
264
|
latestMessage = message;
|
|
164
265
|
const progressPrefix = processed !== undefined && total !== undefined ? `[${processed}/${total}] ` : "";
|
|
165
266
|
if (args.verbose) {
|
|
166
|
-
|
|
267
|
+
info(`[index:${phase}] ${progressPrefix}${message}`);
|
|
167
268
|
}
|
|
168
269
|
else if (spin) {
|
|
169
270
|
spin.stop(`${progressPrefix}${message}`);
|
|
@@ -184,6 +285,7 @@ const indexCommand = defineCommand({
|
|
|
184
285
|
throw error;
|
|
185
286
|
}
|
|
186
287
|
finally {
|
|
288
|
+
clearLogFile();
|
|
187
289
|
process.off("SIGINT", abort);
|
|
188
290
|
process.off("SIGTERM", abort);
|
|
189
291
|
}
|
|
@@ -199,6 +301,102 @@ const infoCommand = defineCommand({
|
|
|
199
301
|
});
|
|
200
302
|
},
|
|
201
303
|
});
|
|
304
|
+
const healthCommand = defineCommand({
|
|
305
|
+
meta: { name: "health", description: "Check akm runtime health, artifacts, and improve metrics" },
|
|
306
|
+
args: {
|
|
307
|
+
since: {
|
|
308
|
+
type: "string",
|
|
309
|
+
description: "Rolling window start (ISO timestamp, date, epoch ms, or shorthand like 24h / 7d)",
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
run({ args }) {
|
|
313
|
+
return runWithJsonErrors(() => {
|
|
314
|
+
const result = akmHealth({ since: args.since });
|
|
315
|
+
output("health", result);
|
|
316
|
+
});
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
const graphCommand = defineCommand({
|
|
320
|
+
meta: { name: "graph", description: "Inspect the indexed entity graph stored in SQLite" },
|
|
321
|
+
subCommands: {
|
|
322
|
+
summary: defineCommand({
|
|
323
|
+
meta: { name: "summary", description: "Show entity-graph counts and quality telemetry" },
|
|
324
|
+
args: {
|
|
325
|
+
source: { type: "string", description: "Source name/path (default: primary stash source)" },
|
|
326
|
+
},
|
|
327
|
+
run({ args }) {
|
|
328
|
+
return runWithJsonErrors(() => {
|
|
329
|
+
output("graph-summary", akmGraphSummary({ source: args.source }));
|
|
330
|
+
});
|
|
331
|
+
},
|
|
332
|
+
}),
|
|
333
|
+
entities: defineCommand({
|
|
334
|
+
meta: { name: "entities", description: "List entities with per-file occurrence counts" },
|
|
335
|
+
args: {
|
|
336
|
+
source: { type: "string", description: "Source name/path (default: primary stash source)" },
|
|
337
|
+
limit: { type: "string", description: "Maximum entities to return" },
|
|
338
|
+
},
|
|
339
|
+
run({ args }) {
|
|
340
|
+
return runWithJsonErrors(() => {
|
|
341
|
+
output("graph-entities", akmGraphEntities({ source: args.source, limit: parsePositiveIntFlag(args.limit ?? undefined) }));
|
|
342
|
+
});
|
|
343
|
+
},
|
|
344
|
+
}),
|
|
345
|
+
relations: defineCommand({
|
|
346
|
+
meta: { name: "relations", description: "List relations with occurrence counts" },
|
|
347
|
+
args: {
|
|
348
|
+
source: { type: "string", description: "Source name/path (default: primary stash source)" },
|
|
349
|
+
limit: { type: "string", description: "Maximum relations to return" },
|
|
350
|
+
},
|
|
351
|
+
run({ args }) {
|
|
352
|
+
return runWithJsonErrors(() => {
|
|
353
|
+
output("graph-relations", akmGraphRelations({ source: args.source, limit: parsePositiveIntFlag(args.limit ?? undefined) }));
|
|
354
|
+
});
|
|
355
|
+
},
|
|
356
|
+
}),
|
|
357
|
+
related: defineCommand({
|
|
358
|
+
meta: { name: "related", description: "Show graph-related neighboring assets for a ref" },
|
|
359
|
+
args: {
|
|
360
|
+
ref: { type: "positional", description: "Asset ref", required: true },
|
|
361
|
+
source: { type: "string", description: "Source name/path (default: primary stash source)" },
|
|
362
|
+
limit: { type: "string", description: "Maximum related assets to return" },
|
|
363
|
+
},
|
|
364
|
+
async run({ args }) {
|
|
365
|
+
return runWithJsonErrors(async () => {
|
|
366
|
+
output("graph-related", await akmGraphRelated({
|
|
367
|
+
ref: args.ref ?? "",
|
|
368
|
+
source: args.source,
|
|
369
|
+
limit: parsePositiveIntFlag(args.limit ?? undefined),
|
|
370
|
+
}));
|
|
371
|
+
});
|
|
372
|
+
},
|
|
373
|
+
}),
|
|
374
|
+
export: defineCommand({
|
|
375
|
+
meta: { name: "export", description: "Export graph artifact as JSON or JSONL" },
|
|
376
|
+
args: {
|
|
377
|
+
source: { type: "string", description: "Source name/path (default: primary stash source)" },
|
|
378
|
+
out: { type: "string", description: "Output path" },
|
|
379
|
+
format: { type: "string", description: "Export format (json|jsonl)", default: "json" },
|
|
380
|
+
},
|
|
381
|
+
run({ args }) {
|
|
382
|
+
return runWithJsonErrors(() => {
|
|
383
|
+
output("graph-export", akmGraphExport({
|
|
384
|
+
source: args.source,
|
|
385
|
+
out: args.out ?? "",
|
|
386
|
+
format: args.format,
|
|
387
|
+
}));
|
|
388
|
+
});
|
|
389
|
+
},
|
|
390
|
+
}),
|
|
391
|
+
},
|
|
392
|
+
run({ args }) {
|
|
393
|
+
return runWithJsonErrors(() => {
|
|
394
|
+
if (hasSubcommand(args, GRAPH_SUBCOMMAND_SET))
|
|
395
|
+
return;
|
|
396
|
+
output("graph-summary", akmGraphSummary());
|
|
397
|
+
});
|
|
398
|
+
},
|
|
399
|
+
});
|
|
202
400
|
const searchCommand = defineCommand({
|
|
203
401
|
meta: { name: "search", description: "Search the stash" },
|
|
204
402
|
args: {
|
|
@@ -218,29 +416,30 @@ const searchCommand = defineCommand({
|
|
|
218
416
|
description: 'Include entries with quality:"proposed" in the result set. Excluded by default (v1 spec §4.2).',
|
|
219
417
|
default: false,
|
|
220
418
|
},
|
|
419
|
+
belief: {
|
|
420
|
+
type: "string",
|
|
421
|
+
description: "Memory belief filter: all|current|historical. current keeps active memory beliefs; historical keeps contradicted/superseded/archived memory beliefs.",
|
|
422
|
+
default: "all",
|
|
423
|
+
},
|
|
221
424
|
format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
|
|
222
425
|
detail: { type: "string", description: "Detail level (brief|normal|full|summary|agent)" },
|
|
223
426
|
},
|
|
224
427
|
async run({ args }) {
|
|
225
428
|
await runWithJsonErrors(async () => {
|
|
226
|
-
// An empty query enumerates all indexed assets (list mode).
|
|
227
|
-
// The guard that rejected empty queries was removed; akmSearch handles
|
|
228
|
-
// empty strings end-to-end via getAllEntries (DB path) and the
|
|
229
|
-
// substring-search fallback's query-less branch.
|
|
230
429
|
const query = (args.query ?? "").trim();
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (limitRaw !== undefined && Number.isNaN(limitRaw)) {
|
|
234
|
-
throw new UsageError(`Invalid --limit value: "${args.limit}". Must be a positive integer.`);
|
|
430
|
+
if (!query) {
|
|
431
|
+
throw new UsageError('A search query is required. Usage: akm search "<query>" [--type <type>] [--limit <n>]', "MISSING_REQUIRED_ARGUMENT", 'Pass a query like `akm search "docker"` or `akm search "code review" --type skill`.');
|
|
235
432
|
}
|
|
236
|
-
const
|
|
433
|
+
const type = args.type;
|
|
434
|
+
const limit = parsePositiveIntFlag(args.limit ?? undefined);
|
|
237
435
|
const source = parseSearchSource(args.source);
|
|
238
436
|
// Repeatable; citty exposes only the last `--filter` value, so read all
|
|
239
437
|
// occurrences directly from argv (same pattern as `--tag`).
|
|
240
438
|
const filterTokens = parseAllFlagValues("--filter");
|
|
241
439
|
const filters = parseScopeFilterFlags(filterTokens, "--filter");
|
|
242
440
|
const includeProposed = args["include-proposed"] === true;
|
|
243
|
-
const
|
|
441
|
+
const belief = parseBeliefFilterMode(typeof args.belief === "string" ? args.belief : undefined);
|
|
442
|
+
const result = await akmSearch({ query, type, limit, source, filters, includeProposed, belief });
|
|
244
443
|
output("search", result);
|
|
245
444
|
});
|
|
246
445
|
},
|
|
@@ -265,11 +464,8 @@ const curateCommand = defineCommand({
|
|
|
265
464
|
throw new UsageError('A curate query is required. Usage: akm curate "<task or prompt>" [--type <type>] [--limit <n>]', "MISSING_REQUIRED_ARGUMENT", 'Describe the task you want assets for, e.g. `akm curate "deploy to prod"`.');
|
|
266
465
|
}
|
|
267
466
|
const type = args.type;
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
throw new UsageError(`Invalid --limit value: "${args.limit}". Must be a positive integer.`);
|
|
271
|
-
}
|
|
272
|
-
const limit = limitRaw && limitRaw > 0 ? limitRaw : 4;
|
|
467
|
+
const limitParsed = parsePositiveIntFlag(args.limit ?? undefined);
|
|
468
|
+
const limit = limitParsed && limitParsed > 0 ? limitParsed : 4;
|
|
273
469
|
const source = parseSearchSource(args.source ?? "stash");
|
|
274
470
|
const curated = await akmCurate({ query: args.query, type, limit, source });
|
|
275
471
|
output("curate", curated);
|
|
@@ -308,7 +504,7 @@ const addCommand = defineCommand({
|
|
|
308
504
|
"max-depth": { type: "string", description: "Maximum crawl depth for website sources (default: 3)" },
|
|
309
505
|
"allow-insecure": {
|
|
310
506
|
type: "boolean",
|
|
311
|
-
description: "Allow a plain HTTP source URL
|
|
507
|
+
description: "Allow a plain HTTP source URL and skip confirmation for dangerous vault keys (e.g. LD_PRELOAD, PATH). Use only after explicitly reviewing the stash.",
|
|
312
508
|
default: false,
|
|
313
509
|
},
|
|
314
510
|
},
|
|
@@ -316,6 +512,7 @@ const addCommand = defineCommand({
|
|
|
316
512
|
await runWithJsonErrors(async () => {
|
|
317
513
|
const ref = args.ref.trim();
|
|
318
514
|
const allowInsecure = getHyphenatedBoolean(args, "allow-insecure");
|
|
515
|
+
const allowDangerousKeys = allowInsecure;
|
|
319
516
|
// URL with --provider → stash source (remote or git provider)
|
|
320
517
|
if (args.provider) {
|
|
321
518
|
if (shouldWarnOnPlainHttp(ref)) {
|
|
@@ -395,6 +592,118 @@ const addCommand = defineCommand({
|
|
|
395
592
|
writable: args.writable === true,
|
|
396
593
|
},
|
|
397
594
|
});
|
|
595
|
+
// ── Post-install vault key audit ────────────────────────────────────────
|
|
596
|
+
// Resolve the stash root from the install result and scan any vault files
|
|
597
|
+
// for dangerous env var keys. When findings are present the install is
|
|
598
|
+
// gated: TTY → interactive confirmation prompt; non-TTY without
|
|
599
|
+
// --allow-insecure → hard failure (exit 1). Pass
|
|
600
|
+
// --allow-insecure to skip the prompt non-interactively.
|
|
601
|
+
try {
|
|
602
|
+
const installedStashRoot = result.installed?.stashRoot ??
|
|
603
|
+
(result.sourceAdded && "stashRoot" in result.sourceAdded ? result.sourceAdded.stashRoot : undefined);
|
|
604
|
+
if (installedStashRoot) {
|
|
605
|
+
const { checkVaultForDangerousKeys } = await import("./commands/lint/vault-key-rules.js");
|
|
606
|
+
const vaultsDir = path.join(installedStashRoot, "vaults");
|
|
607
|
+
if (fs.existsSync(vaultsDir)) {
|
|
608
|
+
const envFiles = fs.readdirSync(vaultsDir).filter((f) => f.endsWith(".env"));
|
|
609
|
+
// Collect all dangerous-key findings across every vault file.
|
|
610
|
+
const allFindings = [];
|
|
611
|
+
for (const envFile of envFiles) {
|
|
612
|
+
const vaultPath = path.join(vaultsDir, envFile);
|
|
613
|
+
const baseName = path.basename(envFile, ".env");
|
|
614
|
+
const vaultRef = baseName === "" ? "vault:default" : `vault:${baseName}`;
|
|
615
|
+
const relPath = path.join("vaults", envFile);
|
|
616
|
+
const findings = checkVaultForDangerousKeys(vaultPath, relPath, vaultRef);
|
|
617
|
+
for (const finding of findings) {
|
|
618
|
+
// Extract the key name from the detail string for the summary line.
|
|
619
|
+
const keyMatch = finding.detail.match(/Vault key `([^`]+)`/);
|
|
620
|
+
const keyName = keyMatch ? keyMatch[1] : finding.file;
|
|
621
|
+
allFindings.push({ vaultRef, keyName, relPath });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (allFindings.length > 0) {
|
|
625
|
+
if (allowDangerousKeys) {
|
|
626
|
+
// Operator has explicitly accepted the risk — warn and continue.
|
|
627
|
+
for (const f of allFindings) {
|
|
628
|
+
warn(`[dangerous-vault-key] ${f.relPath}: key \`${f.keyName}\` in ${f.vaultRef} can hijack process execution via \`akm vault run\`. Proceeding because --allow-insecure was set.`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
else if (process.stdout.isTTY) {
|
|
632
|
+
// Interactive path: show findings and ask the user to confirm.
|
|
633
|
+
const stashLabel = ref;
|
|
634
|
+
const groupedByVault = new Map();
|
|
635
|
+
for (const f of allFindings) {
|
|
636
|
+
const existing = groupedByVault.get(f.vaultRef) ?? [];
|
|
637
|
+
existing.push(f.keyName);
|
|
638
|
+
groupedByVault.set(f.vaultRef, existing);
|
|
639
|
+
}
|
|
640
|
+
for (const [vaultRef, keys] of groupedByVault) {
|
|
641
|
+
warn(`[warn] Vault "${vaultRef}" in stash "${stashLabel}" contains potentially dangerous keys:`);
|
|
642
|
+
for (const key of keys) {
|
|
643
|
+
warn(` - ${key}: can hijack process execution via \`akm vault run\``);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const confirmed = await p.confirm({
|
|
647
|
+
message: "Install anyway?",
|
|
648
|
+
initialValue: false,
|
|
649
|
+
});
|
|
650
|
+
if (p.isCancel(confirmed) || confirmed !== true) {
|
|
651
|
+
// Roll back the install before aborting.
|
|
652
|
+
// Use the canonical installed id (most reliably resolved by akmRemove) rather
|
|
653
|
+
// than the raw user-supplied ref which may not match after URL normalisation.
|
|
654
|
+
const rollbackTarget = result.installed?.id ?? result.sourceAdded?.stashRoot ?? ref;
|
|
655
|
+
let rollbackWarning;
|
|
656
|
+
try {
|
|
657
|
+
await akmRemove({ target: rollbackTarget });
|
|
658
|
+
}
|
|
659
|
+
catch (_rollbackErr) {
|
|
660
|
+
rollbackWarning =
|
|
661
|
+
`Rollback failed — stash may still be installed at ${installedStashRoot}. ` +
|
|
662
|
+
`Remove it manually with: akm remove ${rollbackTarget}`;
|
|
663
|
+
}
|
|
664
|
+
console.error(JSON.stringify({
|
|
665
|
+
ok: false,
|
|
666
|
+
error: "Install aborted: stash contains dangerous vault keys. Remove the keys or re-run with --allow-insecure to bypass.",
|
|
667
|
+
code: "DANGEROUS_VAULT_KEY",
|
|
668
|
+
...(rollbackWarning ? { rollbackWarning } : {}),
|
|
669
|
+
}, null, 2));
|
|
670
|
+
process.exit(1);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
// Non-interactive path without bypass flag: fail hard.
|
|
675
|
+
// Roll back the install before exiting.
|
|
676
|
+
// Use the canonical installed id (most reliably resolved by akmRemove) rather
|
|
677
|
+
// than the raw user-supplied ref which may not match after URL normalisation.
|
|
678
|
+
const rollbackTarget = result.installed?.id ?? result.sourceAdded?.stashRoot ?? ref;
|
|
679
|
+
let rollbackWarning;
|
|
680
|
+
try {
|
|
681
|
+
await akmRemove({ target: rollbackTarget });
|
|
682
|
+
}
|
|
683
|
+
catch (_rollbackErr) {
|
|
684
|
+
rollbackWarning =
|
|
685
|
+
`Rollback failed — stash may still be installed at ${installedStashRoot}. ` +
|
|
686
|
+
`Remove it manually with: akm remove ${rollbackTarget}`;
|
|
687
|
+
}
|
|
688
|
+
const keyList = allFindings.map((f) => ` - ${f.keyName} (${f.vaultRef})`).join("\n");
|
|
689
|
+
console.error(JSON.stringify({
|
|
690
|
+
ok: false,
|
|
691
|
+
error: `Install blocked: stash "${ref}" contains dangerous vault keys that can hijack process execution via \`akm vault run\`:\n${keyList}\nRe-run with --allow-insecure to bypass this check after reviewing the vault.`,
|
|
692
|
+
code: "DANGEROUS_VAULT_KEY",
|
|
693
|
+
...(rollbackWarning ? { rollbackWarning } : {}),
|
|
694
|
+
}, null, 2));
|
|
695
|
+
process.exit(1);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch (auditErr) {
|
|
702
|
+
// Only swallow errors that are NOT our intentional process.exit calls.
|
|
703
|
+
if (auditErr instanceof Error && auditErr.message === "process.exit called")
|
|
704
|
+
throw auditErr;
|
|
705
|
+
// Vault key audit is best-effort; never fail the install on unexpected audit errors.
|
|
706
|
+
}
|
|
398
707
|
output("add", result);
|
|
399
708
|
});
|
|
400
709
|
},
|
|
@@ -543,15 +852,17 @@ const showCommand = defineCommand({
|
|
|
543
852
|
},
|
|
544
853
|
async run({ args }) {
|
|
545
854
|
await runWithJsonErrors(async () => {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
throw new UsageError(error.message, "INVALID_FLAG_VALUE", error.hint());
|
|
855
|
+
const subcommand = Array.isArray(args._) ? args._[0] : undefined;
|
|
856
|
+
if (subcommand === "proposal") {
|
|
857
|
+
const proposalId = Array.isArray(args._) ? args._[1] : undefined;
|
|
858
|
+
if (typeof proposalId !== "string" || !proposalId.trim()) {
|
|
859
|
+
throw new UsageError("Usage: akm show proposal <id>", "MISSING_REQUIRED_ARGUMENT");
|
|
552
860
|
}
|
|
553
|
-
|
|
861
|
+
const result = akmProposalShow({ id: proposalId.trim() });
|
|
862
|
+
output("proposal-show", result);
|
|
863
|
+
return;
|
|
554
864
|
}
|
|
865
|
+
parseAssetRef(args.ref);
|
|
555
866
|
// The knowledge-view positional syntax (`akm show knowledge:foo section "Auth"`)
|
|
556
867
|
// is rewritten to `--akmView` / `--akmHeading` / `--akmStart` / `--akmEnd`
|
|
557
868
|
// by `normalizeShowArgv` before citty parses argv. We read those values
|
|
@@ -640,6 +951,14 @@ const configCommand = defineCommand({
|
|
|
640
951
|
});
|
|
641
952
|
},
|
|
642
953
|
}),
|
|
954
|
+
show: defineCommand({
|
|
955
|
+
meta: { name: "show", description: "Alias for `akm config list` — list current configuration" },
|
|
956
|
+
run() {
|
|
957
|
+
return runWithJsonErrors(() => {
|
|
958
|
+
output("config", listConfig(loadConfig()));
|
|
959
|
+
});
|
|
960
|
+
},
|
|
961
|
+
}),
|
|
643
962
|
get: defineCommand({
|
|
644
963
|
meta: { name: "get", description: "Get a configuration value by key" },
|
|
645
964
|
args: {
|
|
@@ -681,7 +1000,7 @@ const configCommand = defineCommand({
|
|
|
681
1000
|
},
|
|
682
1001
|
run({ args }) {
|
|
683
1002
|
return runWithJsonErrors(() => {
|
|
684
|
-
if (
|
|
1003
|
+
if (hasSubcommand(args, CONFIG_SUBCOMMAND_SET))
|
|
685
1004
|
return;
|
|
686
1005
|
if (args.list) {
|
|
687
1006
|
output("config", listConfig(loadConfig()));
|
|
@@ -725,7 +1044,7 @@ const saveCommand = defineCommand({
|
|
|
725
1044
|
? undefined
|
|
726
1045
|
: args.name;
|
|
727
1046
|
let writable;
|
|
728
|
-
if (
|
|
1047
|
+
if (effectiveName === undefined) {
|
|
729
1048
|
// Primary stash — honour the root-level writable flag from config.
|
|
730
1049
|
const cfg = loadConfig();
|
|
731
1050
|
writable = cfg.writable === true ? true : undefined;
|
|
@@ -901,10 +1220,7 @@ const registryCommand = defineCommand({
|
|
|
901
1220
|
},
|
|
902
1221
|
async run({ args }) {
|
|
903
1222
|
await runWithJsonErrors(async () => {
|
|
904
|
-
const limitRaw =
|
|
905
|
-
if (limitRaw !== undefined && Number.isNaN(limitRaw)) {
|
|
906
|
-
throw new UsageError(`Invalid --limit value: "${args.limit}". Must be a positive integer.`);
|
|
907
|
-
}
|
|
1223
|
+
const limitRaw = parsePositiveIntFlag(args.limit ?? undefined);
|
|
908
1224
|
const result = await searchRegistry(args.query, { limit: limitRaw, includeAssets: args.assets });
|
|
909
1225
|
output("registry-search", result);
|
|
910
1226
|
});
|
|
@@ -939,13 +1255,37 @@ const registryCommand = defineCommand({
|
|
|
939
1255
|
}),
|
|
940
1256
|
},
|
|
941
1257
|
});
|
|
1258
|
+
const TAG_KEY_RE = /^[a-z_][a-z0-9_]*$/;
|
|
1259
|
+
const MAX_FEEDBACK_TAGS = 10;
|
|
1260
|
+
function validateFeedbackTags(raw) {
|
|
1261
|
+
const seen = new Set();
|
|
1262
|
+
const out = [];
|
|
1263
|
+
for (const tag of raw) {
|
|
1264
|
+
const parts = tag.split(":");
|
|
1265
|
+
if (parts.length < 2 || parts[0] === "" || parts.slice(1).join("") === "") {
|
|
1266
|
+
throw new UsageError(`Invalid tag "${tag}". Tags must be in key:value format where key matches [a-z_][a-z0-9_]* and value is non-empty.`, "INVALID_FLAG_VALUE");
|
|
1267
|
+
}
|
|
1268
|
+
const key = parts[0];
|
|
1269
|
+
if (!TAG_KEY_RE.test(key)) {
|
|
1270
|
+
throw new UsageError(`Invalid tag key "${key}" in "${tag}". Key must match [a-z_][a-z0-9_]*.`, "INVALID_FLAG_VALUE");
|
|
1271
|
+
}
|
|
1272
|
+
if (seen.has(tag))
|
|
1273
|
+
continue;
|
|
1274
|
+
seen.add(tag);
|
|
1275
|
+
out.push(tag);
|
|
1276
|
+
}
|
|
1277
|
+
if (out.length > MAX_FEEDBACK_TAGS) {
|
|
1278
|
+
throw new UsageError(`Too many tags: ${out.length}. Maximum is ${MAX_FEEDBACK_TAGS}.`, "INVALID_FLAG_VALUE");
|
|
1279
|
+
}
|
|
1280
|
+
return out;
|
|
1281
|
+
}
|
|
942
1282
|
const feedbackCommand = defineCommand({
|
|
943
1283
|
meta: {
|
|
944
1284
|
name: "feedback",
|
|
945
1285
|
description: "Record positive or negative feedback for any indexed stash asset.\n\n" +
|
|
946
1286
|
"Positive feedback boosts an asset's EMA utility score, making it rank higher\n" +
|
|
947
1287
|
"in future searches without requiring a full reindex.\n\n" +
|
|
948
|
-
"Negative feedback records a negative signal in usage_events and events
|
|
1288
|
+
"Negative feedback records a negative signal in usage_events and state.db events.\n" +
|
|
949
1289
|
"It does NOT immediately lower the asset's ranking — the EMA utility score is\n" +
|
|
950
1290
|
"updated the next time `akm index` runs (incremental or full). Run `akm index`\n" +
|
|
951
1291
|
"after recording negative feedback to have it reflected in search results.",
|
|
@@ -962,10 +1302,18 @@ const feedbackCommand = defineCommand({
|
|
|
962
1302
|
"Reindexing is required for the signal to affect search results.",
|
|
963
1303
|
default: false,
|
|
964
1304
|
},
|
|
965
|
-
|
|
1305
|
+
reason: {
|
|
1306
|
+
type: "string",
|
|
1307
|
+
description: "Reason for the feedback (recommended for negative feedback, used by distillation)",
|
|
1308
|
+
},
|
|
1309
|
+
note: { type: "string", description: "Alias for --reason (backward-compatible, prefer --reason)" },
|
|
1310
|
+
tag: {
|
|
1311
|
+
type: "string",
|
|
1312
|
+
description: "Tag to attach to the feedback (repeatable, e.g. --tag slice:train --tag team:platform)",
|
|
1313
|
+
},
|
|
966
1314
|
},
|
|
967
1315
|
run({ args }) {
|
|
968
|
-
return runWithJsonErrors(() => {
|
|
1316
|
+
return runWithJsonErrors(async () => {
|
|
969
1317
|
const ref = (args.ref ?? "").trim();
|
|
970
1318
|
if (!ref) {
|
|
971
1319
|
throw new UsageError("Asset ref is required. Usage: akm feedback <ref> --positive|--negative", "MISSING_REQUIRED_ARGUMENT", "Pass a ref like `skill:deploy` and either --positive or --negative.");
|
|
@@ -978,12 +1326,35 @@ const feedbackCommand = defineCommand({
|
|
|
978
1326
|
throw new UsageError("Specify --positive or --negative.");
|
|
979
1327
|
}
|
|
980
1328
|
const signal = args.positive ? "positive" : "negative";
|
|
981
|
-
const
|
|
1329
|
+
const reason = args.reason ?? args.note;
|
|
1330
|
+
if (args.negative === true && !reason?.trim()) {
|
|
1331
|
+
const cfg = loadConfig();
|
|
1332
|
+
if (cfg.feedback?.requireReason === true) {
|
|
1333
|
+
throw new UsageError("Negative feedback requires --reason (feedback.requireReason is enabled).", "MISSING_REQUIRED_ARGUMENT");
|
|
1334
|
+
}
|
|
1335
|
+
else {
|
|
1336
|
+
warn("Warning: negative feedback without --reason provides less distillation signal.");
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
const rawTags = parseAllFlagValues("--tag");
|
|
1340
|
+
const validatedTags = validateFeedbackTags(rawTags);
|
|
1341
|
+
const metadataObj = {
|
|
1342
|
+
signal,
|
|
1343
|
+
...(reason?.trim() ? { reason: reason.trim() } : {}),
|
|
1344
|
+
...(validatedTags.length > 0 ? { tags: validatedTags } : {}),
|
|
1345
|
+
};
|
|
1346
|
+
const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
|
|
1347
|
+
// Auto-index when stale so the index is current before recording feedback.
|
|
1348
|
+
const sources = resolveSourceEntries();
|
|
1349
|
+
if (sources.length > 0) {
|
|
1350
|
+
await ensureIndex(sources[0].path);
|
|
1351
|
+
}
|
|
982
1352
|
const db = openExistingDatabase();
|
|
983
1353
|
try {
|
|
984
1354
|
const entryId = findEntryIdByRef(db, ref);
|
|
985
1355
|
if (entryId === undefined) {
|
|
986
|
-
throw new UsageError(`Ref "${ref}" is not in the
|
|
1356
|
+
throw new UsageError(`Ref "${ref}" is not in the index. ` +
|
|
1357
|
+
"Run 'akm search' to verify the asset exists, then 'akm index' if it was recently added.");
|
|
987
1358
|
}
|
|
988
1359
|
// Persist the feedback signal into usage_events. For positive signals,
|
|
989
1360
|
// the EMA utility score is updated immediately on the next read path.
|
|
@@ -995,8 +1366,27 @@ const feedbackCommand = defineCommand({
|
|
|
995
1366
|
entry_ref: ref,
|
|
996
1367
|
entry_id: entryId,
|
|
997
1368
|
signal,
|
|
998
|
-
metadata,
|
|
1369
|
+
metadata: metadataStr,
|
|
999
1370
|
});
|
|
1371
|
+
// Apply feedback-derived utility score adjustment immediately so that
|
|
1372
|
+
// positive/negative signals influence search ranking without requiring
|
|
1373
|
+
// a full reindex. We query the total accumulated feedback counts from
|
|
1374
|
+
// usage_events so the delta reflects the entire signal history.
|
|
1375
|
+
try {
|
|
1376
|
+
const counts = db
|
|
1377
|
+
.prepare(`SELECT
|
|
1378
|
+
SUM(CASE WHEN signal = 'positive' THEN 1 ELSE 0 END) AS pos,
|
|
1379
|
+
SUM(CASE WHEN signal = 'negative' THEN 1 ELSE 0 END) AS neg
|
|
1380
|
+
FROM usage_events
|
|
1381
|
+
WHERE event_type = 'feedback' AND entry_id = ?`)
|
|
1382
|
+
.get(entryId);
|
|
1383
|
+
const pos = counts?.pos ?? 0;
|
|
1384
|
+
const neg = counts?.neg ?? 0;
|
|
1385
|
+
applyFeedbackToUtilityScore(db, entryId, pos, neg);
|
|
1386
|
+
}
|
|
1387
|
+
catch {
|
|
1388
|
+
// best-effort — feedback recording succeeds even if utility update fails
|
|
1389
|
+
}
|
|
1000
1390
|
}
|
|
1001
1391
|
finally {
|
|
1002
1392
|
closeDatabase(db);
|
|
@@ -1004,9 +1394,9 @@ const feedbackCommand = defineCommand({
|
|
|
1004
1394
|
appendEvent({
|
|
1005
1395
|
eventType: "feedback",
|
|
1006
1396
|
ref,
|
|
1007
|
-
metadata:
|
|
1397
|
+
metadata: metadataObj,
|
|
1008
1398
|
});
|
|
1009
|
-
output("feedback", { ok: true, ref, signal,
|
|
1399
|
+
output("feedback", { ok: true, ref, signal, reason: reason?.trim() ?? null, tags: validatedTags });
|
|
1010
1400
|
});
|
|
1011
1401
|
},
|
|
1012
1402
|
});
|
|
@@ -1016,8 +1406,8 @@ const historyCommand = defineCommand({
|
|
|
1016
1406
|
description: "Show mutation/usage history for a single asset (--ref) or stash-wide.\n\n" +
|
|
1017
1407
|
"Event sources:\n" +
|
|
1018
1408
|
" usage_events (default): search, show, and feedback events from the local index.\n" +
|
|
1019
|
-
"
|
|
1020
|
-
" emitted by `akm
|
|
1409
|
+
" state.db events (--include-proposals): proposal lifecycle events (promoted, rejected)\n" +
|
|
1410
|
+
" emitted by `akm accept` / `akm reject`.\n\n" +
|
|
1021
1411
|
"Results from all active sources are merged and sorted chronologically.",
|
|
1022
1412
|
},
|
|
1023
1413
|
args: {
|
|
@@ -1025,7 +1415,7 @@ const historyCommand = defineCommand({
|
|
|
1025
1415
|
since: { type: "string", description: "ISO timestamp or epoch ms — only events on/after this time" },
|
|
1026
1416
|
"include-proposals": {
|
|
1027
1417
|
type: "boolean",
|
|
1028
|
-
description: "Also include proposal lifecycle events (promoted, rejected) from events.
|
|
1418
|
+
description: "Also include proposal lifecycle events (promoted, rejected) from state.db events. " +
|
|
1029
1419
|
"Default: false (usage_events only).",
|
|
1030
1420
|
default: false,
|
|
1031
1421
|
},
|
|
@@ -1042,97 +1432,10 @@ const historyCommand = defineCommand({
|
|
|
1042
1432
|
});
|
|
1043
1433
|
},
|
|
1044
1434
|
});
|
|
1045
|
-
function normalizeMarkdownAssetName(name, fallback) {
|
|
1046
|
-
const trimmed = (name ?? fallback)
|
|
1047
|
-
.trim()
|
|
1048
|
-
.replace(/\\/g, "/")
|
|
1049
|
-
.replace(/^\/+|\/+$/g, "")
|
|
1050
|
-
.replace(/\.md$/i, "");
|
|
1051
|
-
if (!trimmed)
|
|
1052
|
-
throw new UsageError("Asset name cannot be empty.");
|
|
1053
|
-
const segments = trimmed.split("/");
|
|
1054
|
-
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
|
1055
|
-
throw new UsageError("Asset name must be a relative path without '.' or '..' segments.");
|
|
1056
|
-
}
|
|
1057
|
-
return trimmed;
|
|
1058
|
-
}
|
|
1059
|
-
function slugifyAssetName(value, fallbackPrefix) {
|
|
1060
|
-
const slug = value
|
|
1061
|
-
.toLowerCase()
|
|
1062
|
-
.replace(/^[#>\-\s]+/, "")
|
|
1063
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
1064
|
-
.replace(/^-+|-+$/g, "")
|
|
1065
|
-
.slice(0, MAX_CAPTURED_ASSET_SLUG_LENGTH);
|
|
1066
|
-
return slug || `${fallbackPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
1067
|
-
}
|
|
1068
|
-
function inferAssetName(content, fallbackPrefix, preferred) {
|
|
1069
|
-
const firstNonEmptyLine = content
|
|
1070
|
-
.split(/\r?\n/)
|
|
1071
|
-
.map((line) => line.trim())
|
|
1072
|
-
.find((line) => line.length > 0);
|
|
1073
|
-
const basis = preferred?.trim() || firstNonEmptyLine || fallbackPrefix;
|
|
1074
|
-
return slugifyAssetName(basis, fallbackPrefix);
|
|
1075
|
-
}
|
|
1076
|
-
function readKnowledgeContent(source) {
|
|
1077
|
-
if (source === "-") {
|
|
1078
|
-
const content = tryReadStdinText();
|
|
1079
|
-
if (!content?.trim()) {
|
|
1080
|
-
throw new UsageError("No stdin content received. Pipe a document into stdin or pass a file path.");
|
|
1081
|
-
}
|
|
1082
|
-
return { content };
|
|
1083
|
-
}
|
|
1084
|
-
const resolvedSource = path.resolve(source);
|
|
1085
|
-
let stat;
|
|
1086
|
-
try {
|
|
1087
|
-
stat = fs.statSync(resolvedSource);
|
|
1088
|
-
}
|
|
1089
|
-
catch {
|
|
1090
|
-
throw new UsageError(`Knowledge source not found: "${source}". Pass a readable file path or "-" for stdin.`);
|
|
1091
|
-
}
|
|
1092
|
-
if (!stat.isFile()) {
|
|
1093
|
-
throw new UsageError(`Knowledge source must be a file: "${source}".`);
|
|
1094
|
-
}
|
|
1095
|
-
return {
|
|
1096
|
-
content: fs.readFileSync(resolvedSource, "utf8"),
|
|
1097
|
-
preferredName: path.basename(resolvedSource, path.extname(resolvedSource)),
|
|
1098
|
-
};
|
|
1099
|
-
}
|
|
1100
|
-
async function readKnowledgeInput(source) {
|
|
1101
|
-
if (!isHttpUrl(source))
|
|
1102
|
-
return readKnowledgeContent(source);
|
|
1103
|
-
const snapshot = await fetchWebsiteMarkdownSnapshot(source);
|
|
1104
|
-
return { content: snapshot.content, preferredName: snapshot.preferredName };
|
|
1105
|
-
}
|
|
1106
|
-
async function writeMarkdownAsset(options) {
|
|
1107
|
-
// Resolve write target via the v1 precedence chain (`--target` →
|
|
1108
|
-
// `defaultWriteTarget` → working stash). Per spec §10 step 5, this is the
|
|
1109
|
-
// single dispatch point — `core/write-source.ts` owns all kind-branching.
|
|
1110
|
-
const cfg = loadConfig();
|
|
1111
|
-
const { source, config } = resolveWriteTarget(cfg, options.target);
|
|
1112
|
-
const typeRoot = path.join(source.path, options.type === "knowledge" ? "knowledge" : "memories");
|
|
1113
|
-
const normalizedName = normalizeMarkdownAssetName(options.name, inferAssetName(options.content, options.fallbackPrefix, options.preferredName));
|
|
1114
|
-
// Pre-flight: existence + force semantics. The helper itself overwrites
|
|
1115
|
-
// unconditionally; the CLI surfaces a friendlier UsageError before any
|
|
1116
|
-
// disk activity when --force is absent.
|
|
1117
|
-
const assetPath = resolveAssetPathFromName(options.type, typeRoot, normalizedName);
|
|
1118
|
-
if (!isWithin(assetPath, typeRoot)) {
|
|
1119
|
-
throw new UsageError(`Resolved ${options.type} path escapes the stash: "${normalizedName}"`);
|
|
1120
|
-
}
|
|
1121
|
-
if (fs.existsSync(assetPath) && !options.force) {
|
|
1122
|
-
throw new UsageError(`${options.type === "knowledge" ? "Knowledge" : "Memory"} "${normalizedName}" already exists. Re-run with --force to overwrite it.`, "RESOURCE_ALREADY_EXISTS");
|
|
1123
|
-
}
|
|
1124
|
-
// Delegate the actual write (and optional git commit/push) to the helper.
|
|
1125
|
-
const result = await writeAssetToSource(source, config, { type: options.type, name: normalizedName }, options.content);
|
|
1126
|
-
return {
|
|
1127
|
-
ref: result.ref,
|
|
1128
|
-
path: result.path,
|
|
1129
|
-
stashDir: source.path,
|
|
1130
|
-
};
|
|
1131
|
-
}
|
|
1132
1435
|
const workflowStartCommand = defineCommand({
|
|
1133
1436
|
meta: {
|
|
1134
1437
|
name: "start",
|
|
1135
|
-
description: "Start a new workflow run",
|
|
1438
|
+
description: "Start a new workflow run in the current working scope",
|
|
1136
1439
|
},
|
|
1137
1440
|
args: {
|
|
1138
1441
|
ref: { type: "positional", description: "Workflow ref (workflow:<name>)", required: true },
|
|
@@ -1148,22 +1451,25 @@ const workflowStartCommand = defineCommand({
|
|
|
1148
1451
|
const workflowNextCommand = defineCommand({
|
|
1149
1452
|
meta: {
|
|
1150
1453
|
name: "next",
|
|
1151
|
-
description: "Show the next actionable workflow step, auto-starting a run when passed a workflow ref",
|
|
1454
|
+
description: "Show the next actionable workflow step in the current scope, auto-starting a run when passed a workflow ref",
|
|
1152
1455
|
},
|
|
1153
1456
|
args: {
|
|
1154
1457
|
target: { type: "positional", description: "Workflow run id or workflow ref", required: true },
|
|
1155
1458
|
params: { type: "string", description: "Workflow parameters as a JSON object (only for auto-started runs)" },
|
|
1459
|
+
"dry-run": { type: "boolean", description: "Not supported — rejected with an error", default: false },
|
|
1156
1460
|
},
|
|
1157
1461
|
async run({ args }) {
|
|
1158
1462
|
await runWithJsonErrors(async () => {
|
|
1463
|
+
if (getHyphenatedBoolean(args, "dry-run")) {
|
|
1464
|
+
throw new UsageError("`akm workflow next` does not support --dry-run. Remove the flag to start or resume a run.", "INVALID_FLAG_VALUE");
|
|
1465
|
+
}
|
|
1159
1466
|
const parsedParams = args.params ? parseWorkflowJsonObject(args.params, "--params") : undefined;
|
|
1160
1467
|
// If the target looks like a UUID-style run id (no `:` and matches the
|
|
1161
1468
|
// run-id shape), short-circuit with a structured WORKFLOW_NOT_FOUND
|
|
1162
1469
|
// error before parseAssetRef gets to throw an unhelpful ref-parse error.
|
|
1163
1470
|
if (looksLikeWorkflowRunId(args.target)) {
|
|
1164
|
-
const {
|
|
1165
|
-
|
|
1166
|
-
if (!existingRuns.some((r) => r.id === args.target)) {
|
|
1471
|
+
const { hasWorkflowRun } = await import("./workflows/runs.js");
|
|
1472
|
+
if (!(await hasWorkflowRun(args.target))) {
|
|
1167
1473
|
throw new NotFoundError(`Workflow run "${args.target}" not found.`, "WORKFLOW_NOT_FOUND", "Run `akm workflow list --active` to see runs.");
|
|
1168
1474
|
}
|
|
1169
1475
|
}
|
|
@@ -1208,7 +1514,7 @@ const workflowCompleteCommand = defineCommand({
|
|
|
1208
1514
|
},
|
|
1209
1515
|
async run({ args }) {
|
|
1210
1516
|
await runWithJsonErrors(async () => {
|
|
1211
|
-
const result = completeWorkflowStep({
|
|
1517
|
+
const result = await completeWorkflowStep({
|
|
1212
1518
|
runId: args.runId,
|
|
1213
1519
|
stepId: args.step,
|
|
1214
1520
|
status: parseWorkflowStepState(args.state),
|
|
@@ -1222,13 +1528,13 @@ const workflowCompleteCommand = defineCommand({
|
|
|
1222
1528
|
const workflowStatusCommand = defineCommand({
|
|
1223
1529
|
meta: {
|
|
1224
1530
|
name: "status",
|
|
1225
|
-
description: "Show full workflow run state for review or resume",
|
|
1531
|
+
description: "Show full workflow run state for review or resume; workflow refs resolve within the current scope",
|
|
1226
1532
|
},
|
|
1227
1533
|
args: {
|
|
1228
1534
|
target: { type: "positional", description: "Workflow run id or workflow ref (workflow:<name>)", required: true },
|
|
1229
1535
|
},
|
|
1230
1536
|
run({ args }) {
|
|
1231
|
-
return runWithJsonErrors(() => {
|
|
1537
|
+
return runWithJsonErrors(async () => {
|
|
1232
1538
|
const target = args.target;
|
|
1233
1539
|
// Check if target looks like a workflow ref
|
|
1234
1540
|
const parsed = (() => {
|
|
@@ -1241,18 +1547,18 @@ const workflowStatusCommand = defineCommand({
|
|
|
1241
1547
|
})();
|
|
1242
1548
|
if (parsed?.type === "workflow") {
|
|
1243
1549
|
const ref = `${parsed.origin ? `${parsed.origin}//` : ""}workflow:${parsed.name}`;
|
|
1244
|
-
const { runs } = listWorkflowRuns({ workflowRef: ref });
|
|
1550
|
+
const { runs } = await listWorkflowRuns({ workflowRef: ref });
|
|
1245
1551
|
if (runs.length === 0) {
|
|
1246
1552
|
throw new NotFoundError(`No workflow runs found for ${ref}`, "WORKFLOW_NOT_FOUND");
|
|
1247
1553
|
}
|
|
1248
1554
|
const mostRecent = runs[0];
|
|
1249
1555
|
if (!mostRecent)
|
|
1250
1556
|
throw new NotFoundError(`No workflow runs found for ${ref}`, "WORKFLOW_NOT_FOUND");
|
|
1251
|
-
const result = getWorkflowStatus(mostRecent.id);
|
|
1557
|
+
const result = await getWorkflowStatus(mostRecent.id);
|
|
1252
1558
|
output("workflow-status", result);
|
|
1253
1559
|
}
|
|
1254
1560
|
else {
|
|
1255
|
-
const result = getWorkflowStatus(target);
|
|
1561
|
+
const result = await getWorkflowStatus(target);
|
|
1256
1562
|
output("workflow-status", result);
|
|
1257
1563
|
}
|
|
1258
1564
|
});
|
|
@@ -1261,15 +1567,15 @@ const workflowStatusCommand = defineCommand({
|
|
|
1261
1567
|
const workflowListCommand = defineCommand({
|
|
1262
1568
|
meta: {
|
|
1263
1569
|
name: "list",
|
|
1264
|
-
description: "List workflow runs",
|
|
1570
|
+
description: "List workflow runs in the current working scope",
|
|
1265
1571
|
},
|
|
1266
1572
|
args: {
|
|
1267
1573
|
ref: { type: "string", description: "Filter to one workflow ref" },
|
|
1268
1574
|
active: { type: "boolean", description: "Only show active runs", default: false },
|
|
1269
1575
|
},
|
|
1270
1576
|
run({ args }) {
|
|
1271
|
-
return runWithJsonErrors(() => {
|
|
1272
|
-
const result = listWorkflowRuns({ workflowRef: args.ref, activeOnly: args.active });
|
|
1577
|
+
return runWithJsonErrors(async () => {
|
|
1578
|
+
const result = await listWorkflowRuns({ workflowRef: args.ref, activeOnly: args.active });
|
|
1273
1579
|
output("workflow-list", result);
|
|
1274
1580
|
});
|
|
1275
1581
|
},
|
|
@@ -1382,8 +1688,8 @@ const workflowResumeCommand = defineCommand({
|
|
|
1382
1688
|
runId: { type: "positional", description: "Workflow run id", required: true },
|
|
1383
1689
|
},
|
|
1384
1690
|
run({ args }) {
|
|
1385
|
-
return runWithJsonErrors(() => {
|
|
1386
|
-
const result = resumeWorkflowRun(args.runId);
|
|
1691
|
+
return runWithJsonErrors(async () => {
|
|
1692
|
+
const result = await resumeWorkflowRun(args.runId);
|
|
1387
1693
|
output("workflow-resume", result);
|
|
1388
1694
|
});
|
|
1389
1695
|
},
|
|
@@ -1405,10 +1711,10 @@ const workflowCommand = defineCommand({
|
|
|
1405
1711
|
validate: workflowValidateCommand,
|
|
1406
1712
|
},
|
|
1407
1713
|
run({ args }) {
|
|
1408
|
-
return runWithJsonErrors(() => {
|
|
1714
|
+
return runWithJsonErrors(async () => {
|
|
1409
1715
|
if (hasWorkflowSubcommand(args))
|
|
1410
1716
|
return;
|
|
1411
|
-
output("workflow-list", listWorkflowRuns({ activeOnly: true }));
|
|
1717
|
+
output("workflow-list", await listWorkflowRuns({ activeOnly: true }));
|
|
1412
1718
|
});
|
|
1413
1719
|
},
|
|
1414
1720
|
});
|
|
@@ -1478,6 +1784,10 @@ const rememberCommand = defineCommand({
|
|
|
1478
1784
|
type: "string",
|
|
1479
1785
|
description: "Scope this memory to a channel name (persisted as `scope_channel` frontmatter)",
|
|
1480
1786
|
},
|
|
1787
|
+
showSimilar: {
|
|
1788
|
+
type: "boolean",
|
|
1789
|
+
description: "Return top-3 similar existing memories in output (opt-in)",
|
|
1790
|
+
},
|
|
1481
1791
|
},
|
|
1482
1792
|
async run({ args }) {
|
|
1483
1793
|
return runWithJsonErrors(async () => {
|
|
@@ -1499,7 +1809,7 @@ const rememberCommand = defineCommand({
|
|
|
1499
1809
|
if (typeof args.channel === "string" && args.channel.trim())
|
|
1500
1810
|
scopeFields.channel = args.channel.trim();
|
|
1501
1811
|
const hasScope = Object.keys(scopeFields).length > 0;
|
|
1502
|
-
const hasTagRequiringArgs = rawTags.length > 0 || !!args.expires || !!args.source || !!args.description
|
|
1812
|
+
const hasTagRequiringArgs = rawTags.length > 0 || !!args.expires || !!args.source || !!args.description;
|
|
1503
1813
|
const hasStructuredArgs = hasTagRequiringArgs || hasScope || args.auto;
|
|
1504
1814
|
if (!hasStructuredArgs) {
|
|
1505
1815
|
const result = await writeMarkdownAsset({
|
|
@@ -1515,7 +1825,13 @@ const rememberCommand = defineCommand({
|
|
|
1515
1825
|
ref: result.ref,
|
|
1516
1826
|
metadata: { path: result.path, force: args.force === true },
|
|
1517
1827
|
});
|
|
1518
|
-
|
|
1828
|
+
if (args.showSimilar) {
|
|
1829
|
+
const similar = await fetchSimilarMemories(body.slice(0, 500), result.ref);
|
|
1830
|
+
output("remember", { ok: true, ...result, similar });
|
|
1831
|
+
}
|
|
1832
|
+
else {
|
|
1833
|
+
output("remember", { ok: true, ...result });
|
|
1834
|
+
}
|
|
1519
1835
|
return;
|
|
1520
1836
|
}
|
|
1521
1837
|
// ── Accumulate metadata from all three modes ──────────────────────────
|
|
@@ -1604,53 +1920,31 @@ const rememberCommand = defineCommand({
|
|
|
1604
1920
|
...(hasScope ? { scope: scopeFields } : {}),
|
|
1605
1921
|
},
|
|
1606
1922
|
});
|
|
1607
|
-
|
|
1923
|
+
if (args.showSimilar) {
|
|
1924
|
+
const similar = await fetchSimilarMemories((body ?? args.content ?? "").slice(0, 500), result.ref);
|
|
1925
|
+
output("remember", { ok: true, ...result, similar });
|
|
1926
|
+
}
|
|
1927
|
+
else {
|
|
1928
|
+
output("remember", { ok: true, ...result });
|
|
1929
|
+
}
|
|
1608
1930
|
});
|
|
1609
1931
|
},
|
|
1610
1932
|
});
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
return
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
content === parsedDetail &&
|
|
1623
|
-
wasRememberFlagValueConsumedAsContent(content, parsedDetail, "--detail")) {
|
|
1624
|
-
return undefined;
|
|
1933
|
+
/**
|
|
1934
|
+
* Best-effort top-3 similar memory search for `--show-similar`.
|
|
1935
|
+
* Scoped to memory: type; excludes the just-written ref.
|
|
1936
|
+
*/
|
|
1937
|
+
async function fetchSimilarMemories(query, excludeRef) {
|
|
1938
|
+
try {
|
|
1939
|
+
const result = await akmSearch({ query, type: "memory", limit: 4 });
|
|
1940
|
+
return (result.hits ?? [])
|
|
1941
|
+
.filter((h) => "ref" in h && h.ref !== excludeRef)
|
|
1942
|
+
.slice(0, 3)
|
|
1943
|
+
.map((h) => ({ ref: h.ref, ...(h.name ? { title: h.name } : {}) }));
|
|
1625
1944
|
}
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
function wasRememberFlagValueConsumedAsContent(content, flagValue, flagName) {
|
|
1629
|
-
const argv = process.argv.slice(2);
|
|
1630
|
-
const rememberIndex = argv.indexOf("remember");
|
|
1631
|
-
const tokens = rememberIndex >= 0 ? argv.slice(rememberIndex + 1) : argv;
|
|
1632
|
-
let flagIndex = -1;
|
|
1633
|
-
let flagConsumesNextToken = false;
|
|
1634
|
-
for (let i = 0; i < tokens.length; i += 1) {
|
|
1635
|
-
const token = tokens[i];
|
|
1636
|
-
if (token === flagName) {
|
|
1637
|
-
flagIndex = i;
|
|
1638
|
-
flagConsumesNextToken = true;
|
|
1639
|
-
break;
|
|
1640
|
-
}
|
|
1641
|
-
if (token === `${flagName}=${flagValue}`) {
|
|
1642
|
-
flagIndex = i;
|
|
1643
|
-
break;
|
|
1644
|
-
}
|
|
1945
|
+
catch {
|
|
1946
|
+
return [];
|
|
1645
1947
|
}
|
|
1646
|
-
if (flagIndex === -1)
|
|
1647
|
-
return false;
|
|
1648
|
-
if (tokens.slice(0, flagIndex).includes(content))
|
|
1649
|
-
return false;
|
|
1650
|
-
const firstTokenAfterFlag = flagIndex + (flagConsumesNextToken ? 2 : 1);
|
|
1651
|
-
if (tokens.slice(firstTokenAfterFlag).includes(content))
|
|
1652
|
-
return false;
|
|
1653
|
-
return true;
|
|
1654
1948
|
}
|
|
1655
1949
|
const importKnowledgeCommand = defineCommand({
|
|
1656
1950
|
meta: {
|
|
@@ -1740,10 +2034,11 @@ const helpCommand = defineCommand({
|
|
|
1740
2034
|
},
|
|
1741
2035
|
run({ args }) {
|
|
1742
2036
|
return runWithJsonErrors(() => {
|
|
1743
|
-
|
|
2037
|
+
const version = resolveHelpMigrateVersionArg(typeof args.version === "string" ? args.version : undefined);
|
|
2038
|
+
if (!version?.trim()) {
|
|
1744
2039
|
throw new UsageError("Usage: akm help migrate <version>.", "MISSING_REQUIRED_ARGUMENT", "Pass a version like `0.6.0`, `v0.6.0`, `0.6.0-rc1`, or `latest`.");
|
|
1745
2040
|
}
|
|
1746
|
-
process.stdout.write(renderMigrationHelp(
|
|
2041
|
+
process.stdout.write(renderMigrationHelp(version));
|
|
1747
2042
|
});
|
|
1748
2043
|
},
|
|
1749
2044
|
}),
|
|
@@ -1773,8 +2068,8 @@ const completionsCommand = defineCommand({
|
|
|
1773
2068
|
const script = generateBashCompletions(main);
|
|
1774
2069
|
if (args.install) {
|
|
1775
2070
|
const dest = installBashCompletions(script);
|
|
1776
|
-
|
|
1777
|
-
|
|
2071
|
+
info(`Completions installed to ${dest}`);
|
|
2072
|
+
info(`Restart your shell or run: source ${dest}`);
|
|
1778
2073
|
}
|
|
1779
2074
|
else {
|
|
1780
2075
|
process.stdout.write(script);
|
|
@@ -1841,21 +2136,43 @@ const disableCommand = defineCommand({
|
|
|
1841
2136
|
});
|
|
1842
2137
|
// ── vault ───────────────────────────────────────────────────────────────────
|
|
1843
2138
|
//
|
|
1844
|
-
// `akm vault` manages secrets stored in `.env` files under
|
|
1845
|
-
//
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2139
|
+
// `akm vault` manages secrets stored in `.env` files under each stash's
|
|
2140
|
+
// vaults/ directory. Values are NEVER written to stdout or structured output.
|
|
2141
|
+
function parseVaultRef(ref) {
|
|
2142
|
+
return parseAssetRef(ref.includes(":") ? ref : `vault:${ref}`);
|
|
2143
|
+
}
|
|
2144
|
+
function findVaultSource(origin) {
|
|
2145
|
+
const sources = resolveSourceEntries(undefined, loadConfig());
|
|
2146
|
+
if (sources.length === 0) {
|
|
2147
|
+
throw new UsageError("No stashes configured. Run `akm init` to create your working stash.");
|
|
2148
|
+
}
|
|
2149
|
+
if (!origin || origin === "local")
|
|
2150
|
+
return sources[0];
|
|
2151
|
+
const named = sources.find((source) => source.registryId === origin);
|
|
2152
|
+
if (!named) {
|
|
2153
|
+
throw new NotFoundError(`Source not found for origin: ${origin}`);
|
|
2154
|
+
}
|
|
2155
|
+
return named;
|
|
2156
|
+
}
|
|
2157
|
+
function makeVaultRef(name, source) {
|
|
2158
|
+
return source?.registryId ? `${source.registryId}//vault:${name}` : `vault:${name}`;
|
|
2159
|
+
}
|
|
1850
2160
|
function resolveVaultPath(ref) {
|
|
1851
|
-
const
|
|
1852
|
-
const parsed = parseAssetRef(ref.includes(":") ? ref : `vault:${ref}`);
|
|
2161
|
+
const parsed = parseVaultRef(ref);
|
|
1853
2162
|
if (parsed.type !== "vault") {
|
|
1854
2163
|
throw new UsageError(`Expected a vault ref (vault:<name>); got "${ref}".`);
|
|
1855
2164
|
}
|
|
1856
|
-
const
|
|
2165
|
+
const source = findVaultSource(parsed.origin);
|
|
2166
|
+
const typeRoot = path.join(source.path, "vaults");
|
|
1857
2167
|
const absPath = resolveAssetPathFromName("vault", typeRoot, parsed.name);
|
|
1858
|
-
|
|
2168
|
+
// Defense-in-depth: ensure the resolved path stays inside the vaults directory.
|
|
2169
|
+
// validateName already rejects traversal patterns like "../../foo", but an
|
|
2170
|
+
// absolute-path override or symlink-based attack could still escape without
|
|
2171
|
+
// this second check.
|
|
2172
|
+
if (!isWithin(absPath, typeRoot)) {
|
|
2173
|
+
throw new UsageError(`Vault name "${parsed.name}" escapes the vault directory.`);
|
|
2174
|
+
}
|
|
2175
|
+
return { name: parsed.name, absPath, source, parsedRef: parsed };
|
|
1859
2176
|
}
|
|
1860
2177
|
/**
|
|
1861
2178
|
* Walk `vaults/` recursively and return one entry per `.env` file, using the
|
|
@@ -1864,97 +2181,65 @@ function resolveVaultPath(ref) {
|
|
|
1864
2181
|
* `vault:team/prod`, `vaults/team/.env` → `vault:team/default`).
|
|
1865
2182
|
*/
|
|
1866
2183
|
function listVaultsRecursive(listKeysFn) {
|
|
1867
|
-
const stashDir = resolveStashDir({ readOnly: true });
|
|
1868
|
-
const vaultsDir = path.join(stashDir, "vaults");
|
|
1869
2184
|
const result = [];
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
2185
|
+
for (const source of resolveSourceEntries(undefined, loadConfig())) {
|
|
2186
|
+
const vaultsDir = path.join(source.path, "vaults");
|
|
2187
|
+
if (!fs.existsSync(vaultsDir))
|
|
2188
|
+
continue;
|
|
2189
|
+
const walk = (dir) => {
|
|
2190
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
2191
|
+
const full = path.join(dir, entry.name);
|
|
2192
|
+
if (entry.isDirectory()) {
|
|
2193
|
+
walk(full);
|
|
2194
|
+
continue;
|
|
2195
|
+
}
|
|
2196
|
+
if (!entry.isFile())
|
|
2197
|
+
continue;
|
|
2198
|
+
if (entry.name !== ".env" && !entry.name.endsWith(".env"))
|
|
2199
|
+
continue;
|
|
2200
|
+
const canonical = deriveCanonicalAssetName("vault", vaultsDir, full);
|
|
2201
|
+
if (!canonical)
|
|
2202
|
+
continue;
|
|
2203
|
+
// Skip sensitive vaults: presence of a sibling .sensitive marker file suppresses listing.
|
|
2204
|
+
const markerPath = full.replace(/\.env$/, ".sensitive");
|
|
2205
|
+
if (fs.existsSync(markerPath))
|
|
2206
|
+
continue;
|
|
2207
|
+
const { keys } = listKeysFn(full);
|
|
2208
|
+
result.push({ ref: makeVaultRef(canonical, source), path: full, keys });
|
|
2209
|
+
}
|
|
2210
|
+
};
|
|
2211
|
+
walk(vaultsDir);
|
|
2212
|
+
}
|
|
1891
2213
|
return result;
|
|
1892
2214
|
}
|
|
1893
|
-
function
|
|
1894
|
-
const
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
const tokens = listIndex >= 0 ? argv.slice(listIndex + 1) : argv;
|
|
1898
|
-
let flagIndex = -1;
|
|
1899
|
-
let flagConsumesNextToken = false;
|
|
1900
|
-
for (let i = 0; i < tokens.length; i += 1) {
|
|
1901
|
-
const token = tokens[i];
|
|
1902
|
-
if (token === flag) {
|
|
1903
|
-
flagIndex = i;
|
|
1904
|
-
flagConsumesNextToken = true;
|
|
1905
|
-
break;
|
|
1906
|
-
}
|
|
1907
|
-
if (token === `${flag}=${flagValue}`) {
|
|
1908
|
-
flagIndex = i;
|
|
1909
|
-
break;
|
|
1910
|
-
}
|
|
2215
|
+
function splitVaultRunTarget(target) {
|
|
2216
|
+
const full = resolveVaultPath(target);
|
|
2217
|
+
if (fs.existsSync(full.absPath)) {
|
|
2218
|
+
return { ref: makeVaultRef(full.name, full.source) };
|
|
1911
2219
|
}
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
// as the positional ref and it was not consumed by the output flag.
|
|
1916
|
-
if (tokens.slice(0, flagIndex).includes(ref))
|
|
1917
|
-
return false;
|
|
1918
|
-
// Skip past either `--flag value` (2 tokens) or `--flag=value` (1 token)
|
|
1919
|
-
// before checking whether the ref appears elsewhere as a real positional.
|
|
1920
|
-
const TOKENS_AFTER_SPACE_FLAG = 2;
|
|
1921
|
-
const TOKENS_AFTER_EQUALS_FLAG = 1;
|
|
1922
|
-
const firstTokenAfterFlag = flagIndex + (flagConsumesNextToken ? TOKENS_AFTER_SPACE_FLAG : TOKENS_AFTER_EQUALS_FLAG);
|
|
1923
|
-
if (tokens.slice(firstTokenAfterFlag).includes(ref))
|
|
1924
|
-
return false;
|
|
1925
|
-
return true;
|
|
1926
|
-
}
|
|
1927
|
-
function resolveVaultListRef(ref) {
|
|
1928
|
-
if (ref === undefined)
|
|
1929
|
-
return undefined;
|
|
1930
|
-
const parsedFormat = parseFlagValue(process.argv, "--format");
|
|
1931
|
-
if (parsedFormat !== undefined && ref === parsedFormat && wasRefMisparsedAsFlagValue(ref, "--format", parsedFormat)) {
|
|
1932
|
-
return undefined;
|
|
2220
|
+
const slashIndex = target.lastIndexOf("/");
|
|
2221
|
+
if (slashIndex <= 0) {
|
|
2222
|
+
throw new NotFoundError(`Vault not found: ${target.includes(":") ? target : `vault:${target}`}`);
|
|
1933
2223
|
}
|
|
1934
|
-
const
|
|
1935
|
-
|
|
1936
|
-
|
|
2224
|
+
const refPart = target.slice(0, slashIndex);
|
|
2225
|
+
const key = target.slice(slashIndex + 1).trim();
|
|
2226
|
+
if (!key) {
|
|
2227
|
+
throw new UsageError("Expected vault run target in the form <ref> or <ref/KEY>.");
|
|
1937
2228
|
}
|
|
1938
|
-
|
|
2229
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
2230
|
+
throw new UsageError(`"${key}" is not a valid environment variable name.`, "INVALID_FLAG_VALUE");
|
|
2231
|
+
}
|
|
2232
|
+
const resolved = resolveVaultPath(refPart);
|
|
2233
|
+
if (!fs.existsSync(resolved.absPath)) {
|
|
2234
|
+
throw new NotFoundError(`Vault not found: ${makeVaultRef(resolved.name, resolved.source)}`);
|
|
2235
|
+
}
|
|
2236
|
+
return { ref: makeVaultRef(resolved.name, resolved.source), key };
|
|
1939
2237
|
}
|
|
1940
2238
|
const vaultListCommand = defineCommand({
|
|
1941
|
-
meta: { name: "list", description: "List vaults
|
|
1942
|
-
|
|
1943
|
-
ref: { type: "positional", description: "Optional vault ref (e.g. vault:prod or just prod)", required: false },
|
|
1944
|
-
},
|
|
1945
|
-
run({ args }) {
|
|
2239
|
+
meta: { name: "list", description: "List all vaults across all stashes with their available key names (no values)" },
|
|
2240
|
+
run() {
|
|
1946
2241
|
return runWithJsonErrors(async () => {
|
|
1947
|
-
const { listKeys
|
|
1948
|
-
const effectiveRef = resolveVaultListRef(args.ref);
|
|
1949
|
-
if (effectiveRef) {
|
|
1950
|
-
const { name, absPath } = resolveVaultPath(effectiveRef);
|
|
1951
|
-
if (!fs.existsSync(absPath)) {
|
|
1952
|
-
throw new NotFoundError(`Vault not found: vault:${name}`);
|
|
1953
|
-
}
|
|
1954
|
-
const entries = listEntries(absPath);
|
|
1955
|
-
output("vault-list", { ref: `vault:${name}`, path: absPath, entries });
|
|
1956
|
-
return;
|
|
1957
|
-
}
|
|
2242
|
+
const { listKeys } = await import("./commands/vault.js");
|
|
1958
2243
|
const vaults = listVaultsRecursive(listKeys);
|
|
1959
2244
|
output("vault-list", { vaults });
|
|
1960
2245
|
});
|
|
@@ -1964,48 +2249,69 @@ const vaultCreateCommand = defineCommand({
|
|
|
1964
2249
|
meta: { name: "create", description: "Create an empty vault file (no-op if it already exists)" },
|
|
1965
2250
|
args: {
|
|
1966
2251
|
name: { type: "positional", description: "Vault name (e.g. prod) — file becomes <name>.env", required: true },
|
|
2252
|
+
sensitive: {
|
|
2253
|
+
type: "boolean",
|
|
2254
|
+
description: "Exclude this vault from vault list output and the search index",
|
|
2255
|
+
default: false,
|
|
2256
|
+
},
|
|
1967
2257
|
},
|
|
1968
2258
|
run({ args }) {
|
|
1969
2259
|
return runWithJsonErrors(async () => {
|
|
1970
2260
|
const { createVault } = await import("./commands/vault.js");
|
|
1971
|
-
const { name, absPath } = resolveVaultPath(args.name);
|
|
2261
|
+
const { name, absPath, source } = resolveVaultPath(args.name);
|
|
1972
2262
|
createVault(absPath);
|
|
1973
|
-
|
|
2263
|
+
if (args.sensitive) {
|
|
2264
|
+
const markerPath = absPath.replace(/\.env$/, ".sensitive");
|
|
2265
|
+
if (!fs.existsSync(markerPath)) {
|
|
2266
|
+
fs.writeFileSync(markerPath, "", { mode: 0o600 });
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
output("vault-create", { ref: makeVaultRef(name, source) });
|
|
1974
2270
|
});
|
|
1975
2271
|
},
|
|
1976
2272
|
});
|
|
1977
2273
|
const vaultSetCommand = defineCommand({
|
|
1978
2274
|
meta: {
|
|
1979
2275
|
name: "set",
|
|
1980
|
-
description: 'Set a key in a vault. Value is
|
|
2276
|
+
description: 'Set a key in a vault. Value is read from stdin by default (never via argv, avoiding /proc/cmdline exposure). Use --from-env <VAR> to read from an environment variable instead. Optionally attach a comment with --comment "description".',
|
|
1981
2277
|
},
|
|
1982
2278
|
args: {
|
|
1983
2279
|
ref: { type: "positional", description: "Vault ref (e.g. vault:prod or just prod)", required: true },
|
|
1984
|
-
key: { type: "positional", description: "Key name (e.g. DB_URL)
|
|
1985
|
-
value: {
|
|
1986
|
-
type: "positional",
|
|
1987
|
-
description: "Value to store (omit when using KEY=VALUE combined form)",
|
|
1988
|
-
required: false,
|
|
1989
|
-
},
|
|
2280
|
+
key: { type: "positional", description: "Key name (e.g. DB_URL)", required: true },
|
|
1990
2281
|
comment: { type: "string", description: "Optional comment written above the key line", required: false },
|
|
2282
|
+
"from-env": {
|
|
2283
|
+
type: "string",
|
|
2284
|
+
description: "Read value from the named environment variable instead of stdin",
|
|
2285
|
+
},
|
|
1991
2286
|
},
|
|
1992
2287
|
run({ args }) {
|
|
1993
2288
|
return runWithJsonErrors(async () => {
|
|
1994
2289
|
const { setKey } = await import("./commands/vault.js");
|
|
1995
|
-
const { name, absPath } = resolveVaultPath(args.ref);
|
|
1996
|
-
|
|
2290
|
+
const { name, absPath, source } = resolveVaultPath(args.ref);
|
|
2291
|
+
const fromEnv = getHyphenatedArg(args, "from-env");
|
|
1997
2292
|
let realValue;
|
|
1998
|
-
if (
|
|
1999
|
-
const
|
|
2000
|
-
|
|
2001
|
-
|
|
2293
|
+
if (fromEnv !== undefined) {
|
|
2294
|
+
const envVal = process.env[fromEnv];
|
|
2295
|
+
if (envVal === undefined) {
|
|
2296
|
+
throw new UsageError(`Environment variable "${fromEnv}" is not set.`, "INVALID_FLAG_VALUE");
|
|
2297
|
+
}
|
|
2298
|
+
realValue = envVal;
|
|
2002
2299
|
}
|
|
2003
2300
|
else {
|
|
2004
|
-
|
|
2005
|
-
|
|
2301
|
+
const MAX_VAULT_VALUE_BYTES = 1024 * 1024; // 1 MB
|
|
2302
|
+
let totalBytes = 0;
|
|
2303
|
+
const chunks = [];
|
|
2304
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
2305
|
+
totalBytes += chunk.byteLength;
|
|
2306
|
+
if (totalBytes > MAX_VAULT_VALUE_BYTES) {
|
|
2307
|
+
throw new UsageError("Vault value exceeds 1 MB limit. Values must be provided via stdin.");
|
|
2308
|
+
}
|
|
2309
|
+
chunks.push(chunk);
|
|
2310
|
+
}
|
|
2311
|
+
realValue = Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
|
|
2006
2312
|
}
|
|
2007
|
-
setKey(absPath,
|
|
2008
|
-
output("vault-set", { ref:
|
|
2313
|
+
setKey(absPath, args.key, realValue, args.comment);
|
|
2314
|
+
output("vault-set", { ref: makeVaultRef(name, source), key: args.key });
|
|
2009
2315
|
});
|
|
2010
2316
|
},
|
|
2011
2317
|
});
|
|
@@ -2018,104 +2324,102 @@ const vaultUnsetCommand = defineCommand({
|
|
|
2018
2324
|
run({ args }) {
|
|
2019
2325
|
return runWithJsonErrors(async () => {
|
|
2020
2326
|
const { unsetKey } = await import("./commands/vault.js");
|
|
2021
|
-
const { name, absPath } = resolveVaultPath(args.ref);
|
|
2327
|
+
const { name, absPath, source } = resolveVaultPath(args.ref);
|
|
2022
2328
|
if (!fs.existsSync(absPath)) {
|
|
2023
|
-
throw new NotFoundError(`Vault not found:
|
|
2329
|
+
throw new NotFoundError(`Vault not found: ${makeVaultRef(name, source)}`);
|
|
2024
2330
|
}
|
|
2025
2331
|
const removed = unsetKey(absPath, args.key);
|
|
2026
|
-
output("vault-unset", { ref:
|
|
2332
|
+
output("vault-unset", { ref: makeVaultRef(name, source), key: args.key, removed });
|
|
2027
2333
|
});
|
|
2028
2334
|
},
|
|
2029
2335
|
});
|
|
2030
|
-
const
|
|
2336
|
+
const vaultPathCommand = defineCommand({
|
|
2031
2337
|
meta: {
|
|
2032
|
-
name: "
|
|
2033
|
-
description: '
|
|
2338
|
+
name: "path",
|
|
2339
|
+
description: 'Print the absolute vault file path so you can load it directly, e.g. `source "$(akm vault path vault:prod)"`.',
|
|
2034
2340
|
},
|
|
2035
2341
|
args: {
|
|
2036
2342
|
ref: { type: "positional", description: "Vault ref", required: true },
|
|
2037
2343
|
},
|
|
2038
|
-
|
|
2344
|
+
run({ args }) {
|
|
2039
2345
|
return runWithJsonErrors(async () => {
|
|
2040
|
-
|
|
2041
|
-
// is a shell snippet intended for `eval`, not structured output.
|
|
2042
|
-
const { name, absPath } = resolveVaultPath(args.ref);
|
|
2346
|
+
const { name, absPath, source } = resolveVaultPath(args.ref);
|
|
2043
2347
|
if (!fs.existsSync(absPath)) {
|
|
2044
|
-
throw new NotFoundError(`Vault not found:
|
|
2045
|
-
}
|
|
2046
|
-
const { buildShellExportScript } = await import("./commands/vault.js");
|
|
2047
|
-
const crypto = await import("node:crypto");
|
|
2048
|
-
const os = await import("node:os");
|
|
2049
|
-
// Parse via dotenv (no expansion, no code execution) and build a
|
|
2050
|
-
// script of literal `export KEY='value'` lines with `'\''` escaping.
|
|
2051
|
-
// Sourcing this is safe even if the raw vault file contained shell
|
|
2052
|
-
// metacharacters like $, backticks, or $(...).
|
|
2053
|
-
const script = buildShellExportScript(absPath);
|
|
2054
|
-
// Write to a mode-0600 temp file the shell can source.
|
|
2055
|
-
//
|
|
2056
|
-
// INTENTIONAL: this site uses `os.tmpdir()` (i.e. `/tmp` on Unix)
|
|
2057
|
-
// rather than `${getCacheDir()}/vault/`. The temp file is written
|
|
2058
|
-
// mode-0600, sourced by the parent shell via `eval`, and immediately
|
|
2059
|
-
// `rm -f`'d on the same line of the emitted snippet. `/tmp` is the
|
|
2060
|
-
// conventional location for short-lived shell-eval scratch files and
|
|
2061
|
-
// benefits from tmp-cleanup-on-reboot semantics, which operators
|
|
2062
|
-
// expect for ephemeral secret material. Moving to `~/.cache/akm/`
|
|
2063
|
-
// would surprise those operators and also persist the file across
|
|
2064
|
-
// reboots if the eval is interrupted before the inline `rm -f` runs.
|
|
2065
|
-
// The bench/registry-build rationale (#276/#284) — orphan dirs
|
|
2066
|
-
// accumulating under `/tmp` from long-running builds — does not
|
|
2067
|
-
// apply here: the file is single-shot, a few hundred bytes, and
|
|
2068
|
-
// removed by the same shell command that sources it.
|
|
2069
|
-
// Regression test: tests/vault-load-error.test.ts verifies the
|
|
2070
|
-
// emitted snippet contains both `. <path>` and `rm -f <path>`.
|
|
2071
|
-
const tmpPath = path.join(os.tmpdir(), `akm-vault-${crypto.randomBytes(12).toString("hex")}.sh`);
|
|
2072
|
-
fs.writeFileSync(tmpPath, script, { mode: 0o600, encoding: "utf8" });
|
|
2073
|
-
try {
|
|
2074
|
-
fs.chmodSync(tmpPath, 0o600);
|
|
2348
|
+
throw new NotFoundError(`Vault not found: ${makeVaultRef(name, source)}`);
|
|
2075
2349
|
}
|
|
2076
|
-
|
|
2077
|
-
/* best-effort on platforms without chmod */
|
|
2078
|
-
}
|
|
2079
|
-
const quotedTmp = `'${tmpPath.replace(/'/g, "'\\''")}'`;
|
|
2080
|
-
// Emit: source the temp file, then remove it — values reach bash only
|
|
2081
|
-
// via the temp file (mode 0600), never via akm's stdout.
|
|
2082
|
-
process.stdout.write(`. ${quotedTmp}; rm -f ${quotedTmp}\n`);
|
|
2350
|
+
process.stdout.write(`${absPath}\n`);
|
|
2083
2351
|
});
|
|
2084
2352
|
},
|
|
2085
2353
|
});
|
|
2086
|
-
const
|
|
2087
|
-
meta: {
|
|
2354
|
+
const vaultRunCommand = defineCommand({
|
|
2355
|
+
meta: {
|
|
2356
|
+
name: "run",
|
|
2357
|
+
description: "Run a command with env injected from a vault or a single vault key: `akm vault run <ref[/KEY]> -- <command>`",
|
|
2358
|
+
},
|
|
2088
2359
|
args: {
|
|
2089
|
-
|
|
2360
|
+
target: { type: "positional", description: "Vault ref or ref/key target", required: true },
|
|
2090
2361
|
},
|
|
2091
2362
|
run({ args }) {
|
|
2092
2363
|
return runWithJsonErrors(async () => {
|
|
2093
|
-
const
|
|
2094
|
-
|
|
2364
|
+
const dashIndex = process.argv.indexOf("--");
|
|
2365
|
+
if (dashIndex < 0 || dashIndex === process.argv.length - 1) {
|
|
2366
|
+
throw new UsageError("Missing command. Usage: akm vault run <ref[/KEY]> -- <command>");
|
|
2367
|
+
}
|
|
2368
|
+
const command = process.argv.slice(dashIndex + 1);
|
|
2369
|
+
const { loadEnv } = await import("./commands/vault.js");
|
|
2370
|
+
const { ref, key } = splitVaultRunTarget(args.target);
|
|
2371
|
+
const { name, absPath, source } = resolveVaultPath(ref);
|
|
2095
2372
|
if (!fs.existsSync(absPath)) {
|
|
2096
|
-
throw new NotFoundError(`Vault not found:
|
|
2373
|
+
throw new NotFoundError(`Vault not found: ${makeVaultRef(name, source)}`);
|
|
2097
2374
|
}
|
|
2098
|
-
const
|
|
2099
|
-
|
|
2375
|
+
const envValues = loadEnv(absPath);
|
|
2376
|
+
const mergedEnv = { ...process.env };
|
|
2377
|
+
if (key) {
|
|
2378
|
+
if (!(key in envValues)) {
|
|
2379
|
+
throw new NotFoundError(`Key not found in ${makeVaultRef(name, source)}: ${key}`);
|
|
2380
|
+
}
|
|
2381
|
+
mergedEnv[key] = envValues[key];
|
|
2382
|
+
}
|
|
2383
|
+
else {
|
|
2384
|
+
for (const [envKey, envValue] of Object.entries(envValues)) {
|
|
2385
|
+
mergedEnv[envKey] = envValue;
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
// Emit vault access event (keys only, no values) for audit trail.
|
|
2389
|
+
// Best-effort: never block vault run on event write failure.
|
|
2390
|
+
appendEvent({
|
|
2391
|
+
eventType: "vault_access",
|
|
2392
|
+
ref: makeVaultRef(name, source),
|
|
2393
|
+
metadata: {
|
|
2394
|
+
keys: key ? [key] : Object.keys(envValues),
|
|
2395
|
+
},
|
|
2396
|
+
});
|
|
2397
|
+
const result = spawnSync(command[0], command.slice(1), {
|
|
2398
|
+
stdio: "inherit",
|
|
2399
|
+
env: mergedEnv,
|
|
2400
|
+
});
|
|
2401
|
+
if (result.error)
|
|
2402
|
+
throw result.error;
|
|
2403
|
+
process.exit(result.status ?? 0);
|
|
2100
2404
|
});
|
|
2101
2405
|
},
|
|
2102
2406
|
});
|
|
2103
2407
|
const vaultCommand = defineCommand({
|
|
2104
2408
|
meta: {
|
|
2105
2409
|
name: "vault",
|
|
2106
|
-
description: "Manage secret vaults (.env files).
|
|
2410
|
+
description: "Manage secret vaults (.env files). Keys are visible, values stay on disk and never appear in structured output.",
|
|
2107
2411
|
},
|
|
2108
2412
|
subCommands: {
|
|
2109
2413
|
list: vaultListCommand,
|
|
2110
|
-
|
|
2414
|
+
path: vaultPathCommand,
|
|
2415
|
+
run: vaultRunCommand,
|
|
2111
2416
|
create: vaultCreateCommand,
|
|
2112
2417
|
set: vaultSetCommand,
|
|
2113
2418
|
unset: vaultUnsetCommand,
|
|
2114
|
-
load: vaultLoadCommand,
|
|
2115
2419
|
},
|
|
2116
2420
|
run({ args }) {
|
|
2117
2421
|
return runWithJsonErrors(async () => {
|
|
2118
|
-
if (
|
|
2422
|
+
if (hasSubcommand(args, VAULT_SUBCOMMAND_SET))
|
|
2119
2423
|
return;
|
|
2120
2424
|
// Default action: list all vaults
|
|
2121
2425
|
const { listKeys } = await import("./commands/vault.js");
|
|
@@ -2279,12 +2583,39 @@ const wikiStashCommand = defineCommand({
|
|
|
2279
2583
|
name: { type: "positional", description: "Wiki name", required: true },
|
|
2280
2584
|
source: { type: "positional", description: "Source file path, URL, or '-' to read from stdin", required: true },
|
|
2281
2585
|
as: { type: "string", description: "Preferred slug base (defaults to source filename or first-line slug)" },
|
|
2586
|
+
target: {
|
|
2587
|
+
type: "string",
|
|
2588
|
+
description: "Name of a writable stash source to write into instead of the default stash. Must match a configured source name (run `akm list` to see sources).",
|
|
2589
|
+
},
|
|
2282
2590
|
},
|
|
2283
2591
|
run({ args }) {
|
|
2284
2592
|
return runWithJsonErrors(async () => {
|
|
2285
2593
|
const { stashRaw } = await import("./wiki/wiki.js");
|
|
2286
|
-
const { content, preferredName } = await
|
|
2287
|
-
|
|
2594
|
+
const { content, preferredName } = await (async () => {
|
|
2595
|
+
if (!isHttpUrl(args.source))
|
|
2596
|
+
return readKnowledgeInput(args.source);
|
|
2597
|
+
const { fetchWebsiteMarkdownSnapshot } = await import("./sources/website-ingest");
|
|
2598
|
+
const snapshot = await fetchWebsiteMarkdownSnapshot(args.source);
|
|
2599
|
+
return { content: snapshot.content, preferredName: args.as ?? snapshot.preferredName };
|
|
2600
|
+
})();
|
|
2601
|
+
let stashDir;
|
|
2602
|
+
if (args.target) {
|
|
2603
|
+
// Resolve the named source to its filesystem path.
|
|
2604
|
+
const cfg = loadConfig();
|
|
2605
|
+
const sources = resolveConfiguredSources(cfg);
|
|
2606
|
+
const match = sources.find((s) => s.name === args.target);
|
|
2607
|
+
if (!match) {
|
|
2608
|
+
throw new UsageError(`--target must reference a configured source name. No source named "${args.target}" found. Run \`akm list\` to see available sources.`, "INVALID_FLAG_VALUE");
|
|
2609
|
+
}
|
|
2610
|
+
const spec = match.source;
|
|
2611
|
+
if (spec.type !== "filesystem" && spec.type !== "local") {
|
|
2612
|
+
throw new ConfigError(`Source "${args.target}" is not a filesystem source and cannot be used as a wiki stash target.`, "INVALID_CONFIG_FILE", `Use a source with type "filesystem" or "local", or omit --target to use the default stash.`);
|
|
2613
|
+
}
|
|
2614
|
+
stashDir = spec.path;
|
|
2615
|
+
}
|
|
2616
|
+
else {
|
|
2617
|
+
stashDir = resolveStashDir();
|
|
2618
|
+
}
|
|
2288
2619
|
const result = stashRaw({
|
|
2289
2620
|
stashDir,
|
|
2290
2621
|
wikiName: args.name,
|
|
@@ -2353,7 +2684,7 @@ const wikiCommand = defineCommand({
|
|
|
2353
2684
|
},
|
|
2354
2685
|
run({ args }) {
|
|
2355
2686
|
return runWithJsonErrors(async () => {
|
|
2356
|
-
if (
|
|
2687
|
+
if (hasSubcommand(args, WIKI_SUBCOMMAND_SET))
|
|
2357
2688
|
return;
|
|
2358
2689
|
// Default action: list wikis
|
|
2359
2690
|
const { listWikis } = await import("./wiki/wiki.js");
|
|
@@ -2362,50 +2693,76 @@ const wikiCommand = defineCommand({
|
|
|
2362
2693
|
},
|
|
2363
2694
|
});
|
|
2364
2695
|
// ── `akm events` ────────────────────────────────────────────────────────────
|
|
2365
|
-
// Append-only events stream surface (#204). `list` reads
|
|
2366
|
-
// with optional --since/--type/--ref filters; `tail` follows the
|
|
2696
|
+
// Append-only events stream surface (#204). `list` reads state.db events
|
|
2697
|
+
// with optional --since/--type/--ref filters; `tail` follows the table via
|
|
2367
2698
|
// a polling loop and prints each event as a single JSONL line.
|
|
2368
2699
|
const eventsListCommand = defineCommand({
|
|
2369
|
-
meta: { name: "list", description: "List events from the append-only
|
|
2700
|
+
meta: { name: "list", description: "List events from the append-only state.db events stream" },
|
|
2370
2701
|
args: {
|
|
2371
2702
|
since: {
|
|
2372
2703
|
type: "string",
|
|
2373
|
-
description: "ISO timestamp / epoch ms, OR `@offset:<
|
|
2704
|
+
description: "ISO timestamp / epoch ms, OR `@offset:<id>` for a durable row-id cursor (resume across processes)",
|
|
2374
2705
|
},
|
|
2375
2706
|
type: { type: "string", description: "Filter by event type (add, remove, remember, feedback, ...)" },
|
|
2376
2707
|
ref: { type: "string", description: "Filter by asset ref (type:name)" },
|
|
2708
|
+
"exclude-tags": {
|
|
2709
|
+
type: "string",
|
|
2710
|
+
description: "Exclude events matching these tags (repeatable)",
|
|
2711
|
+
},
|
|
2712
|
+
"include-tags": {
|
|
2713
|
+
type: "string",
|
|
2714
|
+
description: "Only include events with ALL these tags (repeatable)",
|
|
2715
|
+
},
|
|
2377
2716
|
},
|
|
2378
2717
|
run({ args }) {
|
|
2379
2718
|
return runWithJsonErrors(() => {
|
|
2380
|
-
const
|
|
2719
|
+
const excludeTags = parseAllFlagValues("--exclude-tags");
|
|
2720
|
+
const includeTags = parseAllFlagValues("--include-tags");
|
|
2721
|
+
const result = akmEventsList({
|
|
2722
|
+
since: args.since,
|
|
2723
|
+
type: args.type,
|
|
2724
|
+
ref: args.ref,
|
|
2725
|
+
...(excludeTags.length > 0 ? { excludeTags } : {}),
|
|
2726
|
+
...(includeTags.length > 0 ? { includeTags } : {}),
|
|
2727
|
+
});
|
|
2381
2728
|
output("events-list", result);
|
|
2382
2729
|
});
|
|
2383
2730
|
},
|
|
2384
2731
|
});
|
|
2385
2732
|
const eventsTailCommand = defineCommand({
|
|
2386
|
-
meta: { name: "tail", description: "Follow the append-only
|
|
2733
|
+
meta: { name: "tail", description: "Follow the append-only state.db events stream (polling)" },
|
|
2387
2734
|
args: {
|
|
2388
2735
|
since: {
|
|
2389
2736
|
type: "string",
|
|
2390
|
-
description: "ISO timestamp / epoch ms, OR `@offset:<
|
|
2737
|
+
description: "ISO timestamp / epoch ms, OR `@offset:<id>` for a durable row-id cursor (resume across processes)",
|
|
2391
2738
|
},
|
|
2392
2739
|
type: { type: "string", description: "Filter by event type" },
|
|
2393
2740
|
ref: { type: "string", description: "Filter by asset ref (type:name)" },
|
|
2394
2741
|
"interval-ms": { type: "string", description: "Polling interval in ms (default: 75)" },
|
|
2395
2742
|
"max-duration-ms": { type: "string", description: "Stop after this many ms (default: never)" },
|
|
2396
2743
|
"max-events": { type: "string", description: "Stop after observing this many events" },
|
|
2744
|
+
"exclude-tags": {
|
|
2745
|
+
type: "string",
|
|
2746
|
+
description: "Exclude events matching these tags (repeatable)",
|
|
2747
|
+
},
|
|
2748
|
+
"include-tags": {
|
|
2749
|
+
type: "string",
|
|
2750
|
+
description: "Only include events with ALL these tags (repeatable)",
|
|
2751
|
+
},
|
|
2397
2752
|
},
|
|
2398
2753
|
async run({ args }) {
|
|
2399
2754
|
await runWithJsonErrors(async () => {
|
|
2400
|
-
const intervalMs =
|
|
2401
|
-
const maxDurationMs =
|
|
2402
|
-
const maxEvents =
|
|
2755
|
+
const intervalMs = parsePositiveIntFlag(getHyphenatedArg(args, "interval-ms"), "--interval-ms");
|
|
2756
|
+
const maxDurationMs = parsePositiveIntFlag(getHyphenatedArg(args, "max-duration-ms"), "--max-duration-ms");
|
|
2757
|
+
const maxEvents = parsePositiveIntFlag(getHyphenatedArg(args, "max-events"), "--max-events");
|
|
2403
2758
|
const mode = getOutputMode();
|
|
2404
2759
|
// In streaming text mode we want each event to print as soon as it
|
|
2405
2760
|
// arrives. The polling loop emits via `onEvent`; the final result is
|
|
2406
2761
|
// also rendered through the standard output() pipeline so JSON
|
|
2407
2762
|
// consumers always get the canonical envelope.
|
|
2408
2763
|
const stream = mode.format === "text" || mode.format === "jsonl";
|
|
2764
|
+
const excludeTags = parseAllFlagValues("--exclude-tags");
|
|
2765
|
+
const includeTags = parseAllFlagValues("--include-tags");
|
|
2409
2766
|
const result = await akmEventsTail({
|
|
2410
2767
|
since: args.since,
|
|
2411
2768
|
type: args.type,
|
|
@@ -2413,6 +2770,8 @@ const eventsTailCommand = defineCommand({
|
|
|
2413
2770
|
intervalMs,
|
|
2414
2771
|
maxDurationMs,
|
|
2415
2772
|
maxEvents,
|
|
2773
|
+
...(excludeTags.length > 0 ? { excludeTags } : {}),
|
|
2774
|
+
...(includeTags.length > 0 ? { includeTags } : {}),
|
|
2416
2775
|
onEvent: stream
|
|
2417
2776
|
? (event) => {
|
|
2418
2777
|
if (mode.format === "jsonl") {
|
|
@@ -2449,22 +2808,10 @@ const eventsTailCommand = defineCommand({
|
|
|
2449
2808
|
});
|
|
2450
2809
|
},
|
|
2451
2810
|
});
|
|
2452
|
-
function parsePositiveInt(raw, flag) {
|
|
2453
|
-
if (raw === undefined)
|
|
2454
|
-
return undefined;
|
|
2455
|
-
const trimmed = raw.trim();
|
|
2456
|
-
if (!trimmed)
|
|
2457
|
-
return undefined;
|
|
2458
|
-
const value = Number.parseInt(trimmed, 10);
|
|
2459
|
-
if (Number.isNaN(value) || value <= 0) {
|
|
2460
|
-
throw new UsageError(`Invalid ${flag} value: "${raw}". Must be a positive integer.`, "INVALID_FLAG_VALUE");
|
|
2461
|
-
}
|
|
2462
|
-
return value;
|
|
2463
|
-
}
|
|
2464
2811
|
const eventsCommand = defineCommand({
|
|
2465
2812
|
meta: {
|
|
2466
2813
|
name: "events",
|
|
2467
|
-
description: "Read or follow the append-only
|
|
2814
|
+
description: "Read or follow the append-only state.db events stream (mutations, feedback, indexing)",
|
|
2468
2815
|
},
|
|
2469
2816
|
subCommands: {
|
|
2470
2817
|
list: eventsListCommand,
|
|
@@ -2472,16 +2819,12 @@ const eventsCommand = defineCommand({
|
|
|
2472
2819
|
},
|
|
2473
2820
|
});
|
|
2474
2821
|
// ── proposal substrate (#225) ────────────────────────────────────────────────
|
|
2475
|
-
const
|
|
2476
|
-
meta: { name: "
|
|
2822
|
+
const proposalsCommand = defineCommand({
|
|
2823
|
+
meta: { name: "proposals", description: "List proposal queue entries" },
|
|
2477
2824
|
args: {
|
|
2478
2825
|
status: { type: "string", description: "Filter by status (pending|accepted|rejected)" },
|
|
2479
2826
|
ref: { type: "string", description: "Filter by asset ref (type:name)" },
|
|
2480
|
-
"
|
|
2481
|
-
type: "boolean",
|
|
2482
|
-
description: "Include accepted/rejected proposals from the archive",
|
|
2483
|
-
default: false,
|
|
2484
|
-
},
|
|
2827
|
+
type: { type: "string", description: "Filter by asset type" },
|
|
2485
2828
|
},
|
|
2486
2829
|
run({ args }) {
|
|
2487
2830
|
return runWithJsonErrors(() => {
|
|
@@ -2489,28 +2832,20 @@ const proposalListCommand = defineCommand({
|
|
|
2489
2832
|
const result = akmProposalList({
|
|
2490
2833
|
status,
|
|
2491
2834
|
ref: args.ref,
|
|
2492
|
-
includeArchive:
|
|
2835
|
+
includeArchive: status === "accepted" || status === "rejected",
|
|
2493
2836
|
});
|
|
2494
2837
|
output("proposal-list", result);
|
|
2495
2838
|
});
|
|
2496
2839
|
},
|
|
2497
2840
|
});
|
|
2498
|
-
const
|
|
2499
|
-
meta: { name: "
|
|
2500
|
-
args: {
|
|
2501
|
-
id: { type: "positional", description: "Proposal id (uuid)", required: true },
|
|
2502
|
-
},
|
|
2503
|
-
run({ args }) {
|
|
2504
|
-
return runWithJsonErrors(() => {
|
|
2505
|
-
const result = akmProposalShow({ id: args.id });
|
|
2506
|
-
output("proposal-show", result);
|
|
2507
|
-
});
|
|
2508
|
-
},
|
|
2509
|
-
});
|
|
2510
|
-
const proposalAcceptCommand = defineCommand({
|
|
2511
|
-
meta: { name: "accept", description: "Validate and promote a proposal to a real asset" },
|
|
2841
|
+
const acceptCommand = defineCommand({
|
|
2842
|
+
meta: { name: "accept", description: "Accept a proposal and promote it into the stash" },
|
|
2512
2843
|
args: {
|
|
2513
|
-
id: {
|
|
2844
|
+
id: {
|
|
2845
|
+
type: "positional",
|
|
2846
|
+
description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream)",
|
|
2847
|
+
required: true,
|
|
2848
|
+
},
|
|
2514
2849
|
target: { type: "string", description: "Override the write target by source name" },
|
|
2515
2850
|
},
|
|
2516
2851
|
async run({ args }) {
|
|
@@ -2520,23 +2855,34 @@ const proposalAcceptCommand = defineCommand({
|
|
|
2520
2855
|
});
|
|
2521
2856
|
},
|
|
2522
2857
|
});
|
|
2523
|
-
const
|
|
2524
|
-
meta: { name: "reject", description: "
|
|
2858
|
+
const rejectCommand = defineCommand({
|
|
2859
|
+
meta: { name: "reject", description: "Reject a proposal and record the reason" },
|
|
2525
2860
|
args: {
|
|
2526
|
-
id: {
|
|
2527
|
-
|
|
2861
|
+
id: {
|
|
2862
|
+
type: "positional",
|
|
2863
|
+
description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream)",
|
|
2864
|
+
required: true,
|
|
2865
|
+
},
|
|
2866
|
+
reason: { type: "string", description: "Reason for rejection (required)" },
|
|
2528
2867
|
},
|
|
2529
2868
|
run({ args }) {
|
|
2530
2869
|
return runWithJsonErrors(() => {
|
|
2870
|
+
if (!args.reason || !String(args.reason).trim()) {
|
|
2871
|
+
throw new UsageError("Usage: akm reject <id> --reason '<reason>'", "MISSING_REQUIRED_ARGUMENT");
|
|
2872
|
+
}
|
|
2531
2873
|
const result = akmProposalReject({ id: args.id, reason: args.reason });
|
|
2532
2874
|
output("proposal-reject", result);
|
|
2533
2875
|
});
|
|
2534
2876
|
},
|
|
2535
2877
|
});
|
|
2536
|
-
const
|
|
2537
|
-
meta: { name: "diff", description: "Show the diff
|
|
2878
|
+
const diffCommand = defineCommand({
|
|
2879
|
+
meta: { name: "diff", description: "Show the diff for a proposal (accepts full UUID, UUID prefix, or asset ref)" },
|
|
2538
2880
|
args: {
|
|
2539
|
-
id: {
|
|
2881
|
+
id: {
|
|
2882
|
+
type: "positional",
|
|
2883
|
+
description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream)",
|
|
2884
|
+
required: true,
|
|
2885
|
+
},
|
|
2540
2886
|
target: { type: "string", description: "Override the write target by source name" },
|
|
2541
2887
|
},
|
|
2542
2888
|
run({ args }) {
|
|
@@ -2546,76 +2892,7 @@ const proposalDiffCommand = defineCommand({
|
|
|
2546
2892
|
});
|
|
2547
2893
|
},
|
|
2548
2894
|
});
|
|
2549
|
-
const proposalCommand = defineCommand({
|
|
2550
|
-
meta: {
|
|
2551
|
-
name: "proposal",
|
|
2552
|
-
description: "Review and promote queued asset proposals (durable storage under .akm/proposals/)",
|
|
2553
|
-
},
|
|
2554
|
-
subCommands: {
|
|
2555
|
-
list: proposalListCommand,
|
|
2556
|
-
show: proposalShowCommand,
|
|
2557
|
-
accept: proposalAcceptCommand,
|
|
2558
|
-
reject: proposalRejectCommand,
|
|
2559
|
-
diff: proposalDiffCommand,
|
|
2560
|
-
},
|
|
2561
|
-
});
|
|
2562
2895
|
// ── distill (#228) ──────────────────────────────────────────────────────────
|
|
2563
|
-
const distillCommand = defineCommand({
|
|
2564
|
-
meta: {
|
|
2565
|
-
name: "distill",
|
|
2566
|
-
description: "Distil feedback for an asset into a queued lesson proposal (gated on llm.features.feedback_distillation)",
|
|
2567
|
-
},
|
|
2568
|
-
args: {
|
|
2569
|
-
ref: { type: "positional", description: "Asset ref (type:name) to distil from", required: true },
|
|
2570
|
-
"source-run": {
|
|
2571
|
-
type: "string",
|
|
2572
|
-
description: "Optional run id propagated onto the queued proposal for traceability",
|
|
2573
|
-
},
|
|
2574
|
-
"exclude-feedback-from": {
|
|
2575
|
-
type: "string",
|
|
2576
|
-
description: "Comma-separated asset refs whose feedback events MUST be filtered out before the LLM input is built. Falls back to AKM_DISTILL_EXCLUDE_FEEDBACK_FROM when omitted.",
|
|
2577
|
-
},
|
|
2578
|
-
},
|
|
2579
|
-
async run({ args }) {
|
|
2580
|
-
await runWithJsonErrors(async () => {
|
|
2581
|
-
const excludeFlag = getHyphenatedArg(args, "exclude-feedback-from");
|
|
2582
|
-
const excludeEnv = process.env.AKM_DISTILL_EXCLUDE_FEEDBACK_FROM;
|
|
2583
|
-
// CLI flag takes precedence over the env var when both are present.
|
|
2584
|
-
const excludeRaw = excludeFlag ?? excludeEnv;
|
|
2585
|
-
const excludeFeedbackFromRefs = parseExcludeFeedbackFromRefs(excludeRaw);
|
|
2586
|
-
const result = await akmDistill({
|
|
2587
|
-
ref: args.ref,
|
|
2588
|
-
sourceRun: getHyphenatedArg(args, "source-run"),
|
|
2589
|
-
...(excludeFeedbackFromRefs.length > 0 ? { excludeFeedbackFromRefs } : {}),
|
|
2590
|
-
});
|
|
2591
|
-
output("distill", result);
|
|
2592
|
-
});
|
|
2593
|
-
},
|
|
2594
|
-
});
|
|
2595
|
-
/**
|
|
2596
|
-
* Parse a comma-separated list of asset refs (#267 — `--exclude-feedback-from`
|
|
2597
|
-
* and `AKM_DISTILL_EXCLUDE_FEEDBACK_FROM`). Each entry is validated against
|
|
2598
|
-
* the canonical `[origin//]type:name` grammar via `parseAssetRef`; an
|
|
2599
|
-
* invalid entry surfaces as a UsageError → exit 2.
|
|
2600
|
-
*/
|
|
2601
|
-
function parseExcludeFeedbackFromRefs(raw) {
|
|
2602
|
-
if (raw === undefined || raw.trim() === "")
|
|
2603
|
-
return [];
|
|
2604
|
-
const refs = raw
|
|
2605
|
-
.split(",")
|
|
2606
|
-
.map((part) => part.trim())
|
|
2607
|
-
.filter((part) => part.length > 0);
|
|
2608
|
-
for (const ref of refs) {
|
|
2609
|
-
try {
|
|
2610
|
-
parseAssetRef(ref);
|
|
2611
|
-
}
|
|
2612
|
-
catch (err) {
|
|
2613
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
2614
|
-
throw new UsageError(`Invalid --exclude-feedback-from ref "${ref}": ${message}`, "INVALID_FLAG_VALUE", "Each ref must match `[origin//]type:name`, e.g. skill:deploy or team//memory:auth-tips.");
|
|
2615
|
-
}
|
|
2616
|
-
}
|
|
2617
|
-
return refs;
|
|
2618
|
-
}
|
|
2619
2896
|
function parseProposalStatus(raw) {
|
|
2620
2897
|
if (raw === undefined)
|
|
2621
2898
|
return undefined;
|
|
@@ -2626,39 +2903,232 @@ function parseProposalStatus(raw) {
|
|
|
2626
2903
|
return trimmed;
|
|
2627
2904
|
throw new UsageError(`Invalid --status value: "${raw}". Expected one of: pending, accepted, rejected.`, "INVALID_FLAG_VALUE");
|
|
2628
2905
|
}
|
|
2629
|
-
|
|
2630
|
-
const reflectCommand = defineCommand({
|
|
2906
|
+
const agentCommand = defineCommand({
|
|
2631
2907
|
meta: {
|
|
2632
|
-
name: "
|
|
2633
|
-
description: "
|
|
2908
|
+
name: "agent",
|
|
2909
|
+
description: "Dispatch an agent CLI (opencode, claude, …) with an optional agent asset that provides the system prompt, model, and tool policy. Use <agent-ref> to embody a stash agent, --model to override the model, and --prompt/--command/--workflow to provide the task.",
|
|
2634
2910
|
},
|
|
2635
2911
|
args: {
|
|
2636
|
-
|
|
2912
|
+
profile: {
|
|
2637
2913
|
type: "positional",
|
|
2638
|
-
description: "
|
|
2914
|
+
description: "Agent profile / platform to use (opencode, claude, …)",
|
|
2639
2915
|
required: false,
|
|
2640
2916
|
},
|
|
2641
|
-
|
|
2642
|
-
|
|
2917
|
+
"agent-ref": {
|
|
2918
|
+
type: "positional",
|
|
2919
|
+
description: "Optional agent asset ref (e.g. agent:code-reviewer). Loads system prompt, model, and tool policy from the stash asset.",
|
|
2920
|
+
required: false,
|
|
2921
|
+
},
|
|
2922
|
+
prompt: { type: "string", description: "Task prompt to pass to the agent" },
|
|
2923
|
+
command: { type: "string", description: "Load prompt from a command: asset" },
|
|
2924
|
+
workflow: { type: "string", description: "Load prompt from a workflow: asset" },
|
|
2925
|
+
model: {
|
|
2926
|
+
type: "string",
|
|
2927
|
+
description: "Model override — accepts aliases (opus, sonnet, haiku) or exact platform model IDs. Overrides the model specified in the agent asset.",
|
|
2928
|
+
},
|
|
2643
2929
|
"timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds" },
|
|
2644
2930
|
},
|
|
2645
2931
|
async run({ args }) {
|
|
2646
2932
|
await runWithJsonErrors(async () => {
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2933
|
+
if (!args.profile) {
|
|
2934
|
+
throw new UsageError("Usage: akm agent <profile> [<agent-ref>] [--prompt <text>] [--model <model>]", "MISSING_REQUIRED_ARGUMENT", "Provide the agent profile name. Available profiles are listed in config.agent.profiles.");
|
|
2935
|
+
}
|
|
2936
|
+
const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
|
|
2937
|
+
const config = loadConfig();
|
|
2938
|
+
const { parseAgentConfig } = await import("./integrations/agent/config.js");
|
|
2939
|
+
const agentConfig = parseAgentConfig(config.agent);
|
|
2940
|
+
// Resolve agent asset ref → extract system prompt, model, and tool policy.
|
|
2941
|
+
const agentRef = getStringArg(args, "agent-ref");
|
|
2942
|
+
let systemPrompt;
|
|
2943
|
+
let assetModel;
|
|
2944
|
+
let assetTools;
|
|
2945
|
+
if (agentRef) {
|
|
2946
|
+
const { akmShowUnified } = await import("./commands/show.js");
|
|
2947
|
+
const asset = await akmShowUnified({ ref: agentRef, detail: "full" });
|
|
2948
|
+
systemPrompt = typeof asset.content === "string" ? asset.content : undefined;
|
|
2949
|
+
assetModel = typeof asset.modelHint === "string" ? asset.modelHint : undefined;
|
|
2950
|
+
assetTools = asset.toolPolicy;
|
|
2951
|
+
}
|
|
2952
|
+
// --model flag wins over the asset's modelHint.
|
|
2953
|
+
const model = getStringArg(args, "model") ?? assetModel;
|
|
2954
|
+
const promptText = getStringArg(args, "prompt");
|
|
2955
|
+
const commandRef = getStringArg(args, "command");
|
|
2956
|
+
const workflowRef = getStringArg(args, "workflow");
|
|
2957
|
+
// Only build a dispatch request when there is something to dispatch — a
|
|
2958
|
+
// prompt, an agent asset, or a model override. When none of these are
|
|
2959
|
+
// present the agent is launched interactively (no injected prompt, no
|
|
2960
|
+
// platform-specific flags beyond the profile's base args).
|
|
2961
|
+
const hasDispatchContent = !!(promptText ?? commandRef ?? workflowRef ?? systemPrompt ?? model ?? assetTools);
|
|
2962
|
+
const result = await akmAgentDispatch({
|
|
2963
|
+
profileName: String(args.profile),
|
|
2964
|
+
prompt: promptText,
|
|
2965
|
+
commandRef,
|
|
2966
|
+
workflowRef,
|
|
2967
|
+
agentConfig,
|
|
2968
|
+
llmConfig: config.llm,
|
|
2969
|
+
...(hasDispatchContent
|
|
2970
|
+
? {
|
|
2971
|
+
dispatch: {
|
|
2972
|
+
prompt: promptText ?? "",
|
|
2973
|
+
systemPrompt,
|
|
2974
|
+
model,
|
|
2975
|
+
tools: assetTools,
|
|
2976
|
+
},
|
|
2977
|
+
}
|
|
2978
|
+
: {}),
|
|
2653
2979
|
...(timeoutMs !== undefined && Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
|
|
2654
2980
|
});
|
|
2655
|
-
output("
|
|
2656
|
-
if (result.ok
|
|
2981
|
+
output("agent-result", result);
|
|
2982
|
+
if (!result.ok) {
|
|
2657
2983
|
process.exit(EXIT_GENERAL);
|
|
2658
2984
|
}
|
|
2659
2985
|
});
|
|
2660
2986
|
},
|
|
2661
2987
|
});
|
|
2988
|
+
const lintCommand = defineCommand({
|
|
2989
|
+
meta: {
|
|
2990
|
+
name: "lint",
|
|
2991
|
+
description: "Scan stash .md files for structural issues (unquoted colons, missing updated field, orphaned stubs, placeholder stubs, missing name/type, stale paths). Use --fix to auto-fix Tier 1 issues.",
|
|
2992
|
+
},
|
|
2993
|
+
args: {
|
|
2994
|
+
fix: { type: "boolean", description: "Apply auto-fixes in place", default: false },
|
|
2995
|
+
dir: { type: "string", description: "Override stash root directory (default: from config)" },
|
|
2996
|
+
},
|
|
2997
|
+
async run({ args }) {
|
|
2998
|
+
await runWithJsonErrors(async () => {
|
|
2999
|
+
const result = akmLint({
|
|
3000
|
+
fix: args.fix ?? false,
|
|
3001
|
+
dir: getStringArg(args, "dir"),
|
|
3002
|
+
});
|
|
3003
|
+
output("lint", result);
|
|
3004
|
+
if (!result.ok)
|
|
3005
|
+
process.exit(EXIT_GENERAL);
|
|
3006
|
+
});
|
|
3007
|
+
},
|
|
3008
|
+
});
|
|
3009
|
+
const improveCommand = defineCommand({
|
|
3010
|
+
meta: {
|
|
3011
|
+
name: "improve",
|
|
3012
|
+
description: "Analyze existing AKM assets and generate improvement proposals; also consolidates memories when llm.features.memory_consolidation is enabled",
|
|
3013
|
+
},
|
|
3014
|
+
args: {
|
|
3015
|
+
scope: {
|
|
3016
|
+
type: "positional",
|
|
3017
|
+
description: "Optional asset type or asset ref to improve",
|
|
3018
|
+
required: false,
|
|
3019
|
+
},
|
|
3020
|
+
task: { type: "string", description: "Add extra guidance for this improvement pass" },
|
|
3021
|
+
"dry-run": { type: "boolean", description: "Show planned actions without writing", default: false },
|
|
3022
|
+
target: { type: "string", description: "Override the write target for accepted proposals" },
|
|
3023
|
+
"auto-accept": {
|
|
3024
|
+
type: "string",
|
|
3025
|
+
description: "Automatically accept low-risk proposals (only 'safe' is supported)",
|
|
3026
|
+
},
|
|
3027
|
+
limit: { type: "string", description: "Maximum number of assets to process (highest utility first)" },
|
|
3028
|
+
"timeout-ms": {
|
|
3029
|
+
type: "string",
|
|
3030
|
+
description: "Wall-clock budget for the entire run in milliseconds (default: 7200000 = 2 hours)",
|
|
3031
|
+
},
|
|
3032
|
+
"ignore-cooldown": {
|
|
3033
|
+
type: "boolean",
|
|
3034
|
+
description: "Ignore all cooldown periods (equivalent to --reflect-cooldown-days 0 --distill-cooldown-days 0 --consolidate-cooldown-days 0)",
|
|
3035
|
+
default: false,
|
|
3036
|
+
},
|
|
3037
|
+
"reflect-cooldown-days": {
|
|
3038
|
+
type: "string",
|
|
3039
|
+
description: "Override reflect cooldown for this run only (default: 7, 0 to disable)",
|
|
3040
|
+
},
|
|
3041
|
+
"distill-cooldown-days": {
|
|
3042
|
+
type: "string",
|
|
3043
|
+
description: "Override distill cooldown for this run only (default: 30, 0 to disable)",
|
|
3044
|
+
},
|
|
3045
|
+
"consolidate-cooldown-days": {
|
|
3046
|
+
type: "string",
|
|
3047
|
+
description: "Override consolidate cooldown for this run only (default: 14, 0 to disable)",
|
|
3048
|
+
},
|
|
3049
|
+
"consolidate-recovery": {
|
|
3050
|
+
type: "string",
|
|
3051
|
+
description: "How to handle stale/incomplete consolidation journals: abort (default) or clean (remove stale journal artifacts)",
|
|
3052
|
+
},
|
|
3053
|
+
"require-feedback-signal": {
|
|
3054
|
+
type: "boolean",
|
|
3055
|
+
description: "Only process assets with recent feedback signals (disables retrieval fallback)",
|
|
3056
|
+
default: false,
|
|
3057
|
+
},
|
|
3058
|
+
"min-retrieval-count": {
|
|
3059
|
+
type: "string",
|
|
3060
|
+
description: "Minimum retrieval count for zero-feedback fallback eligibility (default: 5)",
|
|
3061
|
+
},
|
|
3062
|
+
},
|
|
3063
|
+
async run({ args }) {
|
|
3064
|
+
await runWithJsonErrors(async () => {
|
|
3065
|
+
const autoAcceptRaw = getHyphenatedArg(args, "auto-accept");
|
|
3066
|
+
if (autoAcceptRaw !== undefined && autoAcceptRaw !== "safe") {
|
|
3067
|
+
throw new UsageError("--auto-accept only supports the value 'safe'.", "INVALID_FLAG_VALUE");
|
|
3068
|
+
}
|
|
3069
|
+
const targetArg = getStringArg(args, "target");
|
|
3070
|
+
const taskArg = getStringArg(args, "task");
|
|
3071
|
+
const dryRun = getHyphenatedBoolean(args, "dry-run");
|
|
3072
|
+
const autoAccept = autoAcceptRaw === "safe" ? "safe" : undefined;
|
|
3073
|
+
const limitRaw = parsePositiveIntFlag(args.limit ?? undefined);
|
|
3074
|
+
const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
|
|
3075
|
+
const ignoreCooldown = getHyphenatedBoolean(args, "ignore-cooldown");
|
|
3076
|
+
const reflectCooldownRaw = getHyphenatedArg(args, "reflect-cooldown-days");
|
|
3077
|
+
const reflectCooldownDays = ignoreCooldown
|
|
3078
|
+
? 0
|
|
3079
|
+
: parseNonNegativeIntFlag(reflectCooldownRaw, "--reflect-cooldown-days");
|
|
3080
|
+
const distillCooldownRaw = getHyphenatedArg(args, "distill-cooldown-days");
|
|
3081
|
+
const distillCooldownDays = ignoreCooldown
|
|
3082
|
+
? 0
|
|
3083
|
+
: parseNonNegativeIntFlag(distillCooldownRaw, "--distill-cooldown-days");
|
|
3084
|
+
const consolidateCooldownRaw = getHyphenatedArg(args, "consolidate-cooldown-days");
|
|
3085
|
+
const consolidateCooldownDays = ignoreCooldown
|
|
3086
|
+
? 0
|
|
3087
|
+
: parseNonNegativeIntFlag(consolidateCooldownRaw, "--consolidate-cooldown-days");
|
|
3088
|
+
const consolidateRecoveryRaw = getHyphenatedArg(args, "consolidate-recovery");
|
|
3089
|
+
const consolidateRecovery = consolidateRecoveryRaw === undefined
|
|
3090
|
+
? undefined
|
|
3091
|
+
: consolidateRecoveryRaw.trim().toLowerCase();
|
|
3092
|
+
if (consolidateRecovery !== undefined && consolidateRecovery !== "abort" && consolidateRecovery !== "clean") {
|
|
3093
|
+
throw new UsageError(`Invalid --consolidate-recovery value: "${consolidateRecoveryRaw}". Must be one of: abort, clean.`, "INVALID_FLAG_VALUE");
|
|
3094
|
+
}
|
|
3095
|
+
const minRetrievalCountRaw = getHyphenatedArg(args, "min-retrieval-count");
|
|
3096
|
+
const minRetrievalCount = parseNonNegativeIntFlag(minRetrievalCountRaw, "--min-retrieval-count");
|
|
3097
|
+
const requireFeedbackSignal = getHyphenatedBoolean(args, "require-feedback-signal");
|
|
3098
|
+
const improveLogFile = path.join(getCacheDir(), "logs", "improve", `${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
|
|
3099
|
+
setLogFile(improveLogFile);
|
|
3100
|
+
let improveResult;
|
|
3101
|
+
try {
|
|
3102
|
+
improveResult = await akmImprove({
|
|
3103
|
+
scope: getStringArg(args, "scope"),
|
|
3104
|
+
task: taskArg,
|
|
3105
|
+
dryRun,
|
|
3106
|
+
target: targetArg,
|
|
3107
|
+
autoAccept,
|
|
3108
|
+
...(limitRaw !== undefined ? { limit: limitRaw } : {}),
|
|
3109
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
3110
|
+
...(reflectCooldownDays !== undefined ? { reflectCooldownDays } : {}),
|
|
3111
|
+
...(distillCooldownDays !== undefined ? { distillCooldownDays } : {}),
|
|
3112
|
+
...(consolidateCooldownDays !== undefined ? { consolidateCooldownDays } : {}),
|
|
3113
|
+
...(minRetrievalCount !== undefined ? { minRetrievalCount } : {}),
|
|
3114
|
+
...(requireFeedbackSignal ? { requireFeedbackSignal } : {}),
|
|
3115
|
+
consolidateOptions: {
|
|
3116
|
+
target: targetArg,
|
|
3117
|
+
dryRun,
|
|
3118
|
+
autoAccept,
|
|
3119
|
+
task: taskArg,
|
|
3120
|
+
...(consolidateRecovery !== undefined ? { recoveryMode: consolidateRecovery } : {}),
|
|
3121
|
+
},
|
|
3122
|
+
});
|
|
3123
|
+
}
|
|
3124
|
+
finally {
|
|
3125
|
+
clearLogFile();
|
|
3126
|
+
}
|
|
3127
|
+
output("improve", improveResult);
|
|
3128
|
+
process.exit(0);
|
|
3129
|
+
});
|
|
3130
|
+
},
|
|
3131
|
+
});
|
|
2662
3132
|
const proposeCommand = defineCommand({
|
|
2663
3133
|
meta: {
|
|
2664
3134
|
name: "propose",
|
|
@@ -2671,6 +3141,7 @@ const proposeCommand = defineCommand({
|
|
|
2671
3141
|
type: { type: "positional", description: "Asset type (skill, command, knowledge, lesson, ...)", required: false },
|
|
2672
3142
|
name: { type: "positional", description: "Asset name (slug or path under the type dir)", required: false },
|
|
2673
3143
|
task: { type: "string", description: "Task description for the agent (what should the asset do?)" },
|
|
3144
|
+
file: { type: "string", description: "Read the task or prompt text from a UTF-8 file" },
|
|
2674
3145
|
profile: { type: "string", description: "Override the agent profile (defaults to agent.default)" },
|
|
2675
3146
|
"timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds" },
|
|
2676
3147
|
},
|
|
@@ -2679,17 +3150,22 @@ const proposeCommand = defineCommand({
|
|
|
2679
3150
|
// citty silently shows help and exits 0 when required positionals are
|
|
2680
3151
|
// omitted. Re-validate explicitly so the exit code is 2 (USAGE) and a
|
|
2681
3152
|
// structured JSON error reaches scripted callers.
|
|
2682
|
-
|
|
2683
|
-
|
|
3153
|
+
const taskFromFlag = typeof args.task === "string" ? args.task : undefined;
|
|
3154
|
+
const fileFromFlag = typeof args.file === "string" ? args.file : undefined;
|
|
3155
|
+
if (!args.type || !args.name || (!taskFromFlag && !fileFromFlag)) {
|
|
3156
|
+
throw new UsageError("Usage: akm propose <type> <name> (--task '<task>' | --file <path>).", "MISSING_REQUIRED_ARGUMENT", "Provide the asset type, name, and exactly one of --task or --file.");
|
|
2684
3157
|
}
|
|
2685
|
-
|
|
2686
|
-
|
|
3158
|
+
if (taskFromFlag && fileFromFlag) {
|
|
3159
|
+
throw new UsageError("Pass exactly one of --task or --file.", "INVALID_FLAG_VALUE");
|
|
3160
|
+
}
|
|
3161
|
+
const taskText = fileFromFlag ? fs.readFileSync(path.resolve(fileFromFlag), "utf8") : (taskFromFlag ?? "");
|
|
3162
|
+
const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
|
|
2687
3163
|
const result = await akmPropose({
|
|
2688
3164
|
type: String(args.type),
|
|
2689
3165
|
name: String(args.name),
|
|
2690
|
-
task:
|
|
2691
|
-
profile:
|
|
2692
|
-
...(timeoutMs !== undefined
|
|
3166
|
+
task: taskText,
|
|
3167
|
+
profile: getStringArg(args, "profile"),
|
|
3168
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
2693
3169
|
});
|
|
2694
3170
|
output("propose", result);
|
|
2695
3171
|
if (result.ok === false) {
|
|
@@ -2698,6 +3174,189 @@ const proposeCommand = defineCommand({
|
|
|
2698
3174
|
});
|
|
2699
3175
|
},
|
|
2700
3176
|
});
|
|
3177
|
+
const TASKS_SUBCOMMAND_SET = new Set([
|
|
3178
|
+
"add",
|
|
3179
|
+
"list",
|
|
3180
|
+
"show",
|
|
3181
|
+
"remove",
|
|
3182
|
+
"enable",
|
|
3183
|
+
"disable",
|
|
3184
|
+
"run",
|
|
3185
|
+
"history",
|
|
3186
|
+
"sync",
|
|
3187
|
+
"doctor",
|
|
3188
|
+
]);
|
|
3189
|
+
const GRAPH_SUBCOMMAND_SET = new Set(["summary", "entities", "relations", "related", "export"]);
|
|
3190
|
+
const tasksAddCommand = defineCommand({
|
|
3191
|
+
meta: { name: "add", description: "Register a new scheduled task and install it in the OS scheduler" },
|
|
3192
|
+
args: {
|
|
3193
|
+
id: { type: "positional", description: "Task id (used as filename and scheduler entry)", required: true },
|
|
3194
|
+
schedule: { type: "string", description: 'Cron-style schedule, e.g. "0 9 * * *" or "@daily"', required: true },
|
|
3195
|
+
workflow: { type: "string", description: "Workflow ref to invoke (e.g. workflow:my-flow)" },
|
|
3196
|
+
prompt: {
|
|
3197
|
+
type: "string",
|
|
3198
|
+
description: "Prompt for the configured agent harness — inline text, an asset ref like agent:foo, or ./path.md",
|
|
3199
|
+
},
|
|
3200
|
+
profile: { type: "string", description: "Agent profile to use for prompt targets (default: config.agent.default)" },
|
|
3201
|
+
params: { type: "string", description: "Workflow params as a JSON object" },
|
|
3202
|
+
description: { type: "string", description: "Human-readable description" },
|
|
3203
|
+
tags: { type: "string", description: "Comma-separated tags" },
|
|
3204
|
+
disabled: { type: "boolean", description: "Register but leave disabled in the OS scheduler", default: false },
|
|
3205
|
+
force: { type: "boolean", description: "Overwrite an existing task with the same id", default: false },
|
|
3206
|
+
},
|
|
3207
|
+
async run({ args }) {
|
|
3208
|
+
await runWithJsonErrors(async () => {
|
|
3209
|
+
const result = await akmTasksAdd({
|
|
3210
|
+
id: args.id,
|
|
3211
|
+
schedule: args.schedule,
|
|
3212
|
+
workflow: args.workflow,
|
|
3213
|
+
prompt: args.prompt,
|
|
3214
|
+
profile: args.profile,
|
|
3215
|
+
params: args.params,
|
|
3216
|
+
description: args.description,
|
|
3217
|
+
tags: args.tags
|
|
3218
|
+
? args.tags
|
|
3219
|
+
.split(/[\s,]+/)
|
|
3220
|
+
.map((s) => s.trim())
|
|
3221
|
+
.filter(Boolean)
|
|
3222
|
+
: undefined,
|
|
3223
|
+
disabled: args.disabled === true,
|
|
3224
|
+
force: args.force === true,
|
|
3225
|
+
});
|
|
3226
|
+
output("tasks-add", result);
|
|
3227
|
+
});
|
|
3228
|
+
},
|
|
3229
|
+
});
|
|
3230
|
+
const tasksListCommand = defineCommand({
|
|
3231
|
+
meta: { name: "list", description: "List scheduled tasks in the stash" },
|
|
3232
|
+
async run() {
|
|
3233
|
+
await runWithJsonErrors(async () => {
|
|
3234
|
+
const result = await akmTasksList();
|
|
3235
|
+
output("tasks-list", result);
|
|
3236
|
+
});
|
|
3237
|
+
},
|
|
3238
|
+
});
|
|
3239
|
+
const tasksShowCommand = defineCommand({
|
|
3240
|
+
meta: { name: "show", description: "Show a parsed task definition" },
|
|
3241
|
+
args: { id: { type: "positional", description: "Task id or task:<id>", required: true } },
|
|
3242
|
+
async run({ args }) {
|
|
3243
|
+
await runWithJsonErrors(async () => {
|
|
3244
|
+
const { id } = parseTaskRef(args.id);
|
|
3245
|
+
const result = await akmTasksShow(id);
|
|
3246
|
+
output("tasks-show", result);
|
|
3247
|
+
});
|
|
3248
|
+
},
|
|
3249
|
+
});
|
|
3250
|
+
const tasksRemoveCommand = defineCommand({
|
|
3251
|
+
meta: { name: "remove", description: "Delete a task file and uninstall it from the OS scheduler" },
|
|
3252
|
+
args: { id: { type: "positional", description: "Task id", required: true } },
|
|
3253
|
+
async run({ args }) {
|
|
3254
|
+
await runWithJsonErrors(async () => {
|
|
3255
|
+
const { id } = parseTaskRef(args.id);
|
|
3256
|
+
const result = await akmTasksRemove(id);
|
|
3257
|
+
output("tasks-remove", result);
|
|
3258
|
+
});
|
|
3259
|
+
},
|
|
3260
|
+
});
|
|
3261
|
+
function makeTasksToggleCommand(enabled) {
|
|
3262
|
+
const verb = enabled ? "enable" : "disable";
|
|
3263
|
+
const description = enabled
|
|
3264
|
+
? "Enable a previously-disabled task"
|
|
3265
|
+
: "Disable a task in the OS scheduler without removing the file";
|
|
3266
|
+
return defineCommand({
|
|
3267
|
+
meta: { name: verb, description },
|
|
3268
|
+
args: { id: { type: "positional", description: "Task id", required: true } },
|
|
3269
|
+
async run({ args }) {
|
|
3270
|
+
await runWithJsonErrors(async () => {
|
|
3271
|
+
const { id } = parseTaskRef(args.id);
|
|
3272
|
+
const result = await akmTasksSetEnabled(id, enabled);
|
|
3273
|
+
output(`tasks-${verb}`, result);
|
|
3274
|
+
});
|
|
3275
|
+
},
|
|
3276
|
+
});
|
|
3277
|
+
}
|
|
3278
|
+
const tasksEnableCommand = makeTasksToggleCommand(true);
|
|
3279
|
+
const tasksDisableCommand = makeTasksToggleCommand(false);
|
|
3280
|
+
const tasksRunCommand = defineCommand({
|
|
3281
|
+
meta: {
|
|
3282
|
+
name: "run",
|
|
3283
|
+
description: "Execute a task now (this is what cron / launchd / schtasks invoke at the scheduled time)",
|
|
3284
|
+
},
|
|
3285
|
+
args: { id: { type: "positional", description: "Task id", required: true } },
|
|
3286
|
+
async run({ args }) {
|
|
3287
|
+
await runWithJsonErrors(async () => {
|
|
3288
|
+
const { id } = parseTaskRef(args.id);
|
|
3289
|
+
const envelope = await akmTasksRun(id);
|
|
3290
|
+
output("tasks-run", envelope);
|
|
3291
|
+
if (envelope.exitCode !== 0)
|
|
3292
|
+
process.exit(envelope.exitCode);
|
|
3293
|
+
});
|
|
3294
|
+
},
|
|
3295
|
+
});
|
|
3296
|
+
const tasksHistoryCommand = defineCommand({
|
|
3297
|
+
meta: { name: "history", description: "Show recent task run history" },
|
|
3298
|
+
args: {
|
|
3299
|
+
id: { type: "string", description: "Filter to one task id" },
|
|
3300
|
+
limit: { type: "string", description: "Maximum rows to return (default 50)" },
|
|
3301
|
+
},
|
|
3302
|
+
async run({ args }) {
|
|
3303
|
+
await runWithJsonErrors(async () => {
|
|
3304
|
+
const limit = parsePositiveIntFlag(args.limit ?? undefined);
|
|
3305
|
+
const result = await akmTasksHistory({ id: args.id, limit });
|
|
3306
|
+
output("tasks-history", result);
|
|
3307
|
+
});
|
|
3308
|
+
},
|
|
3309
|
+
});
|
|
3310
|
+
const tasksSyncCommand = defineCommand({
|
|
3311
|
+
meta: {
|
|
3312
|
+
name: "sync",
|
|
3313
|
+
description: "Reconcile the on-disk task files with the OS scheduler",
|
|
3314
|
+
},
|
|
3315
|
+
async run() {
|
|
3316
|
+
await runWithJsonErrors(async () => {
|
|
3317
|
+
const result = await akmTasksSync();
|
|
3318
|
+
output("tasks-sync", result);
|
|
3319
|
+
});
|
|
3320
|
+
},
|
|
3321
|
+
});
|
|
3322
|
+
const tasksDoctorCommand = defineCommand({
|
|
3323
|
+
meta: {
|
|
3324
|
+
name: "doctor",
|
|
3325
|
+
description: "Report the active scheduler backend, akm bin path, log dir, and supported schedule subset",
|
|
3326
|
+
},
|
|
3327
|
+
async run() {
|
|
3328
|
+
await runWithJsonErrors(async () => {
|
|
3329
|
+
const result = await akmTasksDoctor();
|
|
3330
|
+
output("tasks-doctor", result);
|
|
3331
|
+
});
|
|
3332
|
+
},
|
|
3333
|
+
});
|
|
3334
|
+
const tasksCommand = defineCommand({
|
|
3335
|
+
meta: {
|
|
3336
|
+
name: "tasks",
|
|
3337
|
+
description: "Schedule workflows or prompts via the OS-native scheduler (cron / launchd / schtasks)",
|
|
3338
|
+
},
|
|
3339
|
+
subCommands: {
|
|
3340
|
+
add: tasksAddCommand,
|
|
3341
|
+
list: tasksListCommand,
|
|
3342
|
+
show: tasksShowCommand,
|
|
3343
|
+
remove: tasksRemoveCommand,
|
|
3344
|
+
enable: tasksEnableCommand,
|
|
3345
|
+
disable: tasksDisableCommand,
|
|
3346
|
+
run: tasksRunCommand,
|
|
3347
|
+
history: tasksHistoryCommand,
|
|
3348
|
+
sync: tasksSyncCommand,
|
|
3349
|
+
doctor: tasksDoctorCommand,
|
|
3350
|
+
},
|
|
3351
|
+
run({ args }) {
|
|
3352
|
+
return runWithJsonErrors(async () => {
|
|
3353
|
+
if (hasSubcommand(args, TASKS_SUBCOMMAND_SET))
|
|
3354
|
+
return;
|
|
3355
|
+
const result = await akmTasksList();
|
|
3356
|
+
output("tasks-list", result);
|
|
3357
|
+
});
|
|
3358
|
+
},
|
|
3359
|
+
});
|
|
2701
3360
|
const main = defineCommand({
|
|
2702
3361
|
meta: {
|
|
2703
3362
|
name: "akm",
|
|
@@ -2718,7 +3377,9 @@ const main = defineCommand({
|
|
|
2718
3377
|
setup: setupCommand,
|
|
2719
3378
|
init: initCommand,
|
|
2720
3379
|
index: indexCommand,
|
|
3380
|
+
health: healthCommand,
|
|
2721
3381
|
info: infoCommand,
|
|
3382
|
+
graph: graphCommand,
|
|
2722
3383
|
add: addCommand,
|
|
2723
3384
|
list: listCommand,
|
|
2724
3385
|
remove: removeCommand,
|
|
@@ -2739,19 +3400,24 @@ const main = defineCommand({
|
|
|
2739
3400
|
feedback: feedbackCommand,
|
|
2740
3401
|
history: historyCommand,
|
|
2741
3402
|
events: eventsCommand,
|
|
2742
|
-
|
|
2743
|
-
|
|
3403
|
+
agent: agentCommand,
|
|
3404
|
+
lint: lintCommand,
|
|
3405
|
+
improve: improveCommand,
|
|
2744
3406
|
propose: proposeCommand,
|
|
2745
|
-
|
|
3407
|
+
proposals: proposalsCommand,
|
|
3408
|
+
accept: acceptCommand,
|
|
3409
|
+
reject: rejectCommand,
|
|
3410
|
+
diff: diffCommand,
|
|
2746
3411
|
help: helpCommand,
|
|
2747
3412
|
hints: hintsCommand,
|
|
2748
3413
|
completions: completionsCommand,
|
|
2749
3414
|
vault: vaultCommand,
|
|
2750
3415
|
wiki: wikiCommand,
|
|
3416
|
+
tasks: tasksCommand,
|
|
2751
3417
|
},
|
|
2752
3418
|
});
|
|
2753
|
-
const CONFIG_SUBCOMMAND_SET = new Set(["path", "list", "get", "set", "unset"]);
|
|
2754
|
-
const VAULT_SUBCOMMAND_SET = new Set(["list", "
|
|
3419
|
+
const CONFIG_SUBCOMMAND_SET = new Set(["path", "list", "show", "get", "set", "unset"]);
|
|
3420
|
+
const VAULT_SUBCOMMAND_SET = new Set(["list", "path", "run", "create", "set", "unset"]);
|
|
2755
3421
|
const WIKI_SUBCOMMAND_SET = new Set([
|
|
2756
3422
|
"create",
|
|
2757
3423
|
"register",
|
|
@@ -2764,7 +3430,6 @@ const WIKI_SUBCOMMAND_SET = new Set([
|
|
|
2764
3430
|
"lint",
|
|
2765
3431
|
"ingest",
|
|
2766
3432
|
]);
|
|
2767
|
-
const SHOW_VIEW_MODES = new Set(["toc", "frontmatter", "full", "section", "lines"]);
|
|
2768
3433
|
// ── Exit codes ──────────────────────────────────────────────────────────────
|
|
2769
3434
|
const EXIT_GENERAL = 1;
|
|
2770
3435
|
const EXIT_USAGE = 2;
|
|
@@ -2778,17 +3443,11 @@ process.argv = normalizeShowArgv(process.argv);
|
|
|
2778
3443
|
// invalid; surface it through the same JSON-error path the rest of the CLI uses
|
|
2779
3444
|
// rather than letting the raw exception escape with a stack trace.
|
|
2780
3445
|
try {
|
|
3446
|
+
applyEarlyStderrFlags(process.argv);
|
|
2781
3447
|
initOutputMode(process.argv, loadConfig().output ?? {});
|
|
2782
3448
|
}
|
|
2783
3449
|
catch (error) {
|
|
2784
|
-
|
|
2785
|
-
const hint = extractHint(error);
|
|
2786
|
-
const exitCode = classifyExitCode(error);
|
|
2787
|
-
const code = error instanceof UsageError || error instanceof ConfigError || error instanceof NotFoundError
|
|
2788
|
-
? error.code
|
|
2789
|
-
: undefined;
|
|
2790
|
-
console.error(JSON.stringify({ ok: false, error: message, ...(code ? { code } : {}), hint }, null, 2));
|
|
2791
|
-
process.exit(exitCode);
|
|
3450
|
+
emitJsonError(error);
|
|
2792
3451
|
}
|
|
2793
3452
|
runMain(main);
|
|
2794
3453
|
function classifyExitCode(error) {
|
|
@@ -2800,33 +3459,6 @@ function classifyExitCode(error) {
|
|
|
2800
3459
|
return EXIT_GENERAL;
|
|
2801
3460
|
return EXIT_GENERAL;
|
|
2802
3461
|
}
|
|
2803
|
-
async function runWithJsonErrors(fn) {
|
|
2804
|
-
try {
|
|
2805
|
-
// Apply --quiet flag early so warnings inside the command are suppressed
|
|
2806
|
-
if (process.argv.includes("--quiet") || process.argv.includes("-q")) {
|
|
2807
|
-
setQuiet(true);
|
|
2808
|
-
}
|
|
2809
|
-
// Apply --verbose flag early so per-spec diagnostics (gated behind
|
|
2810
|
-
// `isVerbose()` in src/core/warn.ts) are restored. The `AKM_VERBOSE`
|
|
2811
|
-
// env var still wins regardless — see warn.ts for the precedence rule.
|
|
2812
|
-
if (process.argv.includes("--verbose")) {
|
|
2813
|
-
setVerbose(true);
|
|
2814
|
-
}
|
|
2815
|
-
await fn();
|
|
2816
|
-
}
|
|
2817
|
-
catch (error) {
|
|
2818
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2819
|
-
const hint = extractHint(error);
|
|
2820
|
-
const exitCode = classifyExitCode(error);
|
|
2821
|
-
// Surface machine-readable error code from typed errors when present so
|
|
2822
|
-
// scripts can branch on `.code` instead of message-string matching.
|
|
2823
|
-
const code = error instanceof UsageError || error instanceof ConfigError || error instanceof NotFoundError
|
|
2824
|
-
? error.code
|
|
2825
|
-
: undefined;
|
|
2826
|
-
console.error(JSON.stringify({ ok: false, error: message, ...(code ? { code } : {}), hint }, null, 2));
|
|
2827
|
-
process.exit(exitCode);
|
|
2828
|
-
}
|
|
2829
|
-
}
|
|
2830
3462
|
/**
|
|
2831
3463
|
* Extract an actionable hint from an error instance. Hints live on the error
|
|
2832
3464
|
* classes themselves (see src/errors.ts) — either supplied explicitly at the
|
|
@@ -2838,86 +3470,27 @@ function extractHint(error) {
|
|
|
2838
3470
|
}
|
|
2839
3471
|
return undefined;
|
|
2840
3472
|
}
|
|
2841
|
-
function hasConfigSubcommand(args) {
|
|
2842
|
-
const command = Array.isArray(args._) ? args._[0] : undefined;
|
|
2843
|
-
return typeof command === "string" && CONFIG_SUBCOMMAND_SET.has(command);
|
|
2844
|
-
}
|
|
2845
|
-
function hasVaultSubcommand(args) {
|
|
2846
|
-
const command = Array.isArray(args._) ? args._[0] : undefined;
|
|
2847
|
-
return typeof command === "string" && VAULT_SUBCOMMAND_SET.has(command);
|
|
2848
|
-
}
|
|
2849
|
-
function hasWikiSubcommand(args) {
|
|
2850
|
-
const command = Array.isArray(args._) ? args._[0] : undefined;
|
|
2851
|
-
return typeof command === "string" && WIKI_SUBCOMMAND_SET.has(command);
|
|
2852
|
-
}
|
|
2853
3473
|
/**
|
|
2854
|
-
*
|
|
2855
|
-
*
|
|
2856
|
-
*
|
|
2857
|
-
* Converts:
|
|
2858
|
-
* akm show knowledge:guide.md toc → akm show knowledge:guide.md --akmView toc
|
|
2859
|
-
* akm show knowledge:guide.md section Auth → akm show knowledge:guide.md --akmView section --akmHeading Auth
|
|
2860
|
-
* akm show knowledge:guide.md lines 1 50 → akm show knowledge:guide.md --akmView lines --akmStart 1 --akmEnd 50
|
|
2861
|
-
*
|
|
2862
|
-
* Legacy `--view` is intentionally unsupported.
|
|
2863
|
-
* Returns a new array; the input is never modified.
|
|
3474
|
+
* Serialize an error to the standard JSON envelope and exit.
|
|
3475
|
+
* Used in both the startup try/catch and `runWithJsonErrors`.
|
|
2864
3476
|
*/
|
|
2865
|
-
function
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
const arg = rest[i];
|
|
2879
|
-
if (arg === "--quiet" || arg === "-q" || arg === "--for-agent" || arg === "--for-agent=true") {
|
|
2880
|
-
globalFlags.push(arg);
|
|
2881
|
-
continue;
|
|
2882
|
-
}
|
|
2883
|
-
if (arg.startsWith("--format=") || arg.startsWith("--detail=")) {
|
|
2884
|
-
globalFlags.push(arg);
|
|
2885
|
-
continue;
|
|
2886
|
-
}
|
|
2887
|
-
if (arg === "--format" || arg === "--detail") {
|
|
2888
|
-
globalFlags.push(arg);
|
|
2889
|
-
if (rest[i + 1] !== undefined) {
|
|
2890
|
-
globalFlags.push(rest[i + 1]);
|
|
2891
|
-
i++;
|
|
2892
|
-
}
|
|
2893
|
-
continue;
|
|
2894
|
-
}
|
|
2895
|
-
showArgs.push(arg);
|
|
2896
|
-
}
|
|
2897
|
-
// showArgs[0] = ref, showArgs[1] = potential view mode, showArgs[2..] = view params
|
|
2898
|
-
const ref = showArgs[0];
|
|
2899
|
-
const viewMode = showArgs[1];
|
|
2900
|
-
if (!ref || !viewMode || !SHOW_VIEW_MODES.has(viewMode)) {
|
|
2901
|
-
return argv;
|
|
2902
|
-
}
|
|
2903
|
-
const result = [...prefix, ref, "--akmView", viewMode];
|
|
2904
|
-
if (viewMode === "section") {
|
|
2905
|
-
// Next arg is the heading name; pass empty string when missing so the
|
|
2906
|
-
// show handler can produce a clear "section not found" error.
|
|
2907
|
-
const heading = showArgs[2] ?? "";
|
|
2908
|
-
result.push("--akmHeading", heading);
|
|
3477
|
+
function emitJsonError(error) {
|
|
3478
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3479
|
+
const hint = extractHint(error);
|
|
3480
|
+
const exitCode = classifyExitCode(error);
|
|
3481
|
+
const code = error instanceof UsageError || error instanceof ConfigError || error instanceof NotFoundError
|
|
3482
|
+
? error.code
|
|
3483
|
+
: undefined;
|
|
3484
|
+
console.error(JSON.stringify({ ok: false, error: message, ...(code ? { code } : {}), hint }, null, 2));
|
|
3485
|
+
process.exit(exitCode);
|
|
3486
|
+
}
|
|
3487
|
+
async function runWithJsonErrors(fn) {
|
|
3488
|
+
try {
|
|
3489
|
+
await fn();
|
|
2909
3490
|
}
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
const start = showArgs[2];
|
|
2913
|
-
const end = showArgs[3];
|
|
2914
|
-
if (start)
|
|
2915
|
-
result.push("--akmStart", start);
|
|
2916
|
-
if (end)
|
|
2917
|
-
result.push("--akmEnd", end);
|
|
3491
|
+
catch (error) {
|
|
3492
|
+
emitJsonError(error);
|
|
2918
3493
|
}
|
|
2919
|
-
result.push(...globalFlags);
|
|
2920
|
-
return result;
|
|
2921
3494
|
}
|
|
2922
3495
|
// ── Hints (embedded AGENTS.md) ──────────────────────────────────────────────
|
|
2923
3496
|
function loadHints(detail = "normal") {
|