akm-cli 0.7.5 → 0.8.0-rc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/.github/CHANGELOG.md +1 -1
  2. package/dist/cli/parse-args.js +43 -0
  3. package/dist/cli.js +853 -479
  4. package/dist/commands/agent-dispatch.js +102 -0
  5. package/dist/commands/agent-support.js +62 -0
  6. package/dist/commands/config-cli.js +68 -84
  7. package/dist/commands/consolidate.js +823 -0
  8. package/dist/commands/distill-promotion-policy.js +658 -0
  9. package/dist/commands/distill.js +244 -52
  10. package/dist/commands/eval-cases.js +40 -0
  11. package/dist/commands/events.js +2 -23
  12. package/dist/commands/graph.js +222 -0
  13. package/dist/commands/health.js +376 -0
  14. package/dist/commands/help/help-accept.md +9 -0
  15. package/dist/commands/help/help-improve.md +53 -0
  16. package/dist/commands/help/help-proposals.md +15 -0
  17. package/dist/commands/help/help-propose.md +17 -0
  18. package/dist/commands/help/help-reject.md +8 -0
  19. package/dist/commands/history.js +3 -30
  20. package/dist/commands/improve.js +1170 -0
  21. package/dist/commands/info.js +2 -2
  22. package/dist/commands/init.js +2 -2
  23. package/dist/commands/install-audit.js +5 -1
  24. package/dist/commands/installed-stashes.js +118 -138
  25. package/dist/commands/knowledge.js +133 -0
  26. package/dist/commands/lint/agent-linter.js +46 -0
  27. package/dist/commands/lint/base-linter.js +285 -0
  28. package/dist/commands/lint/command-linter.js +46 -0
  29. package/dist/commands/lint/default-linter.js +13 -0
  30. package/dist/commands/lint/index.js +107 -0
  31. package/dist/commands/lint/knowledge-linter.js +13 -0
  32. package/dist/commands/lint/memory-linter.js +58 -0
  33. package/dist/commands/lint/registry.js +33 -0
  34. package/dist/commands/lint/skill-linter.js +42 -0
  35. package/dist/commands/lint/task-linter.js +47 -0
  36. package/dist/commands/lint/types.js +1 -0
  37. package/dist/commands/lint/workflow-linter.js +53 -0
  38. package/dist/commands/lint.js +1 -0
  39. package/dist/commands/proposal.js +8 -7
  40. package/dist/commands/propose.js +78 -28
  41. package/dist/commands/reflect.js +143 -35
  42. package/dist/commands/registry-search.js +2 -2
  43. package/dist/commands/remember.js +54 -0
  44. package/dist/commands/schema-repair.js +130 -0
  45. package/dist/commands/search.js +21 -5
  46. package/dist/commands/show.js +121 -17
  47. package/dist/commands/source-add.js +10 -10
  48. package/dist/commands/source-manage.js +11 -19
  49. package/dist/commands/tasks.js +385 -0
  50. package/dist/commands/url-checker.js +39 -0
  51. package/dist/commands/vault.js +8 -26
  52. package/dist/core/action-contributors.js +25 -0
  53. package/dist/core/asset-ref.js +4 -0
  54. package/dist/core/asset-registry.js +4 -16
  55. package/dist/core/asset-spec.js +10 -0
  56. package/dist/core/common.js +94 -0
  57. package/dist/core/concurrent.js +22 -0
  58. package/dist/core/config.js +222 -128
  59. package/dist/core/events.js +73 -126
  60. package/dist/core/frontmatter.js +3 -1
  61. package/dist/core/markdown.js +17 -0
  62. package/dist/core/memory-improve.js +678 -0
  63. package/dist/core/parse.js +155 -0
  64. package/dist/core/paths.js +101 -3
  65. package/dist/core/proposal-validators.js +61 -0
  66. package/dist/core/proposals.js +49 -38
  67. package/dist/core/state-db.js +775 -0
  68. package/dist/core/time.js +51 -0
  69. package/dist/core/warn.js +59 -1
  70. package/dist/indexer/db-search.js +52 -238
  71. package/dist/indexer/db.js +378 -1
  72. package/dist/indexer/ensure-index.js +61 -0
  73. package/dist/indexer/graph-boost.js +247 -94
  74. package/dist/indexer/graph-db.js +201 -0
  75. package/dist/indexer/graph-dedup.js +99 -0
  76. package/dist/indexer/graph-extraction.js +409 -76
  77. package/dist/indexer/index-context.js +10 -0
  78. package/dist/indexer/indexer.js +442 -290
  79. package/dist/indexer/llm-cache.js +47 -0
  80. package/dist/indexer/match-contributors.js +141 -0
  81. package/dist/indexer/matchers.js +24 -190
  82. package/dist/indexer/memory-inference.js +63 -29
  83. package/dist/indexer/metadata-contributors.js +26 -0
  84. package/dist/indexer/metadata.js +194 -175
  85. package/dist/indexer/path-resolver.js +89 -0
  86. package/dist/indexer/ranking-contributors.js +204 -0
  87. package/dist/indexer/ranking.js +74 -0
  88. package/dist/indexer/search-hit-enrichers.js +22 -0
  89. package/dist/indexer/search-source.js +24 -9
  90. package/dist/indexer/semantic-status.js +2 -16
  91. package/dist/indexer/walker.js +25 -0
  92. package/dist/integrations/agent/config.js +175 -3
  93. package/dist/integrations/agent/index.js +3 -1
  94. package/dist/integrations/agent/pipeline.js +39 -0
  95. package/dist/integrations/agent/profiles.js +67 -5
  96. package/dist/integrations/agent/prompts.js +77 -72
  97. package/dist/integrations/agent/runners.js +31 -0
  98. package/dist/integrations/agent/sdk-runner.js +120 -0
  99. package/dist/integrations/agent/spawn.js +71 -16
  100. package/dist/integrations/lockfile.js +10 -18
  101. package/dist/integrations/session-logs/index.js +65 -0
  102. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  103. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  104. package/dist/integrations/session-logs/types.js +1 -0
  105. package/dist/llm/call-ai.js +74 -0
  106. package/dist/llm/client.js +61 -122
  107. package/dist/llm/feature-gate.js +27 -16
  108. package/dist/llm/graph-extract.js +297 -62
  109. package/dist/llm/memory-infer.js +49 -71
  110. package/dist/llm/metadata-enhance.js +39 -22
  111. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  112. package/dist/output/cli-hints-full.md +277 -0
  113. package/dist/output/cli-hints-short.md +65 -0
  114. package/dist/output/cli-hints.js +2 -318
  115. package/dist/output/renderers.js +190 -123
  116. package/dist/output/shapes.js +33 -0
  117. package/dist/output/text.js +239 -2
  118. package/dist/registry/providers/skills-sh.js +61 -49
  119. package/dist/registry/providers/static-index.js +44 -48
  120. package/dist/setup/setup.js +510 -11
  121. package/dist/sources/provider-factory.js +2 -1
  122. package/dist/sources/providers/git.js +2 -2
  123. package/dist/sources/website-ingest.js +4 -0
  124. package/dist/tasks/backends/cron.js +200 -0
  125. package/dist/tasks/backends/exec-utils.js +25 -0
  126. package/dist/tasks/backends/index.js +32 -0
  127. package/dist/tasks/backends/launchd-template.xml +19 -0
  128. package/dist/tasks/backends/launchd.js +184 -0
  129. package/dist/tasks/backends/schtasks-template.xml +29 -0
  130. package/dist/tasks/backends/schtasks.js +212 -0
  131. package/dist/tasks/parser.js +198 -0
  132. package/dist/tasks/resolveAkmBin.js +84 -0
  133. package/dist/tasks/runner.js +432 -0
  134. package/dist/tasks/schedule.js +208 -0
  135. package/dist/tasks/schema.js +13 -0
  136. package/dist/tasks/validator.js +59 -0
  137. package/dist/wiki/index-template.md +12 -0
  138. package/dist/wiki/ingest-workflow-template.md +54 -0
  139. package/dist/wiki/log-template.md +8 -0
  140. package/dist/wiki/schema-template.md +61 -0
  141. package/dist/wiki/wiki-templates.js +12 -0
  142. package/dist/wiki/wiki.js +10 -61
  143. package/dist/workflows/authoring.js +5 -25
  144. package/dist/workflows/renderer.js +8 -3
  145. package/dist/workflows/runs.js +59 -91
  146. package/dist/workflows/validator.js +1 -1
  147. package/dist/workflows/workflow-template.md +24 -0
  148. package/docs/README.md +3 -0
  149. package/docs/migration/release-notes/0.7.0.md +1 -1
  150. package/docs/migration/release-notes/0.8.0.md +43 -0
  151. package/package.json +3 -2
  152. package/dist/templates/wiki-templates.js +0 -100
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Shared JSON parsing utilities for LLM and agent output.
3
+ *
4
+ * Lives in `src/core/` so that both `src/llm/` and `src/integrations/agent/`
5
+ * can import without crossing the one-way boundary defined by v1 spec §9.7
6
+ * (agent/ must not import from llm/).
7
+ *
8
+ * The canonical implementation is ported from `src/llm/client.ts` (most
9
+ * complete version):
10
+ * - Strips `<think>…</think>` reasoning blocks.
11
+ * - Strips markdown code fences (``` or ~~~, optional language tag, with
12
+ * trailing spaces on the fence line).
13
+ * - Escapes unescaped control characters (actual \n, \r, \t bytes) inside
14
+ * JSON string values so `JSON.parse` succeeds on outputs from local LLMs.
15
+ * - Balanced-brace scanner handles both `{…}` and `[…]` top-level
16
+ * structures (spawn.ts v0 only handled `{…}` — that was a bug).
17
+ */
18
+ /**
19
+ * Strips `<think>…</think>` blocks from LLM output (for reasoning-capable
20
+ * models). Also strips leading/trailing whitespace.
21
+ */
22
+ export function stripThinkBlocks(raw) {
23
+ return raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
24
+ }
25
+ /**
26
+ * Strips markdown code fences (``` or ~~~, with optional language tag).
27
+ * Handles fences with trailing spaces. Returns trimmed content.
28
+ */
29
+ export function stripCodeFences(raw) {
30
+ return raw
31
+ .trim()
32
+ .replace(/^```(?:json)?\s*\n?/i, "")
33
+ .replace(/\n?```\s*$/i, "")
34
+ .trim();
35
+ }
36
+ /**
37
+ * Escapes unescaped control characters (actual \n, \r, \t bytes) inside JSON
38
+ * string values. Prevents `JSON.parse` failures from embedded newlines in
39
+ * local-LLM output.
40
+ */
41
+ export function escapeJsonStringControls(raw) {
42
+ let out = "";
43
+ let inString = false;
44
+ let escaped = false;
45
+ for (let i = 0; i < raw.length; i++) {
46
+ const ch = raw[i];
47
+ if (escaped) {
48
+ out += ch;
49
+ escaped = false;
50
+ continue;
51
+ }
52
+ if (ch === "\\" && inString) {
53
+ out += ch;
54
+ escaped = true;
55
+ continue;
56
+ }
57
+ if (ch === '"') {
58
+ inString = !inString;
59
+ out += ch;
60
+ continue;
61
+ }
62
+ if (inString) {
63
+ if (ch === "\n") {
64
+ out += "\\n";
65
+ continue;
66
+ }
67
+ if (ch === "\r") {
68
+ out += "\\r";
69
+ continue;
70
+ }
71
+ if (ch === "\t") {
72
+ out += "\\t";
73
+ continue;
74
+ }
75
+ }
76
+ out += ch;
77
+ }
78
+ return out;
79
+ }
80
+ /**
81
+ * Full pipeline: stripThinkBlocks → stripCodeFences → escapeJsonStringControls
82
+ * → JSON.parse. Returns `undefined` on parse failure.
83
+ */
84
+ export function parseJsonResponse(raw) {
85
+ try {
86
+ const cleaned = escapeJsonStringControls(stripCodeFences(stripThinkBlocks(raw)));
87
+ return JSON.parse(cleaned);
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
93
+ /**
94
+ * Attempts `parseJsonResponse` first. On failure, scans for the first
95
+ * balanced `{ }` or `[ ]` structure in the text and attempts to parse that
96
+ * substring. Returns `undefined` if no valid JSON structure is found.
97
+ *
98
+ * Non-array results are preferred: if a `{…}` object is found first, it is
99
+ * returned immediately. Arrays (`[…]`) are captured as a fallback and
100
+ * returned only when no object was found.
101
+ */
102
+ export function parseEmbeddedJsonResponse(raw) {
103
+ const direct = parseJsonResponse(raw);
104
+ if (direct !== undefined)
105
+ return direct;
106
+ const text = escapeJsonStringControls(stripCodeFences(stripThinkBlocks(raw)));
107
+ let arrayFallback;
108
+ for (let start = 0; start < text.length; start++) {
109
+ const opener = text[start];
110
+ if (opener !== "{" && opener !== "[")
111
+ continue;
112
+ const closer = opener === "{" ? "}" : "]";
113
+ let depth = 0;
114
+ let inString = false;
115
+ let escaped = false;
116
+ for (let i = start; i < text.length; i++) {
117
+ const ch = text[i];
118
+ if (inString) {
119
+ if (escaped) {
120
+ escaped = false;
121
+ }
122
+ else if (ch === "\\") {
123
+ escaped = true;
124
+ }
125
+ else if (ch === '"') {
126
+ inString = false;
127
+ }
128
+ continue;
129
+ }
130
+ if (ch === '"') {
131
+ inString = true;
132
+ continue;
133
+ }
134
+ if (ch === opener)
135
+ depth += 1;
136
+ if (ch === closer) {
137
+ depth -= 1;
138
+ if (depth === 0) {
139
+ try {
140
+ const parsed = JSON.parse(text.slice(start, i + 1));
141
+ if (!Array.isArray(parsed)) {
142
+ return parsed;
143
+ }
144
+ arrayFallback ??= parsed;
145
+ break;
146
+ }
147
+ catch {
148
+ break;
149
+ }
150
+ }
151
+ }
152
+ }
153
+ }
154
+ return arrayFallback;
155
+ }
@@ -6,8 +6,8 @@
6
6
  * on Windows.
7
7
  */
8
8
  import path from "node:path";
9
+ import { IS_WINDOWS } from "./common";
9
10
  import { ConfigError } from "./errors";
10
- const IS_WINDOWS = process.platform === "win32";
11
11
  // ── Config directory ─────────────────────────────────────────────────────────
12
12
  export function getConfigDir(env = process.env, platform = process.platform) {
13
13
  const override = env.AKM_CONFIG_DIR?.trim();
@@ -65,11 +65,102 @@ export function getCacheDir() {
65
65
  return path.join("/tmp", "akm-cache");
66
66
  return path.join(home, ".cache", "akm");
67
67
  }
68
+ // ── Data directory ───────────────────────────────────────────────────────────
69
+ /**
70
+ * Returns the XDG data directory for akm (`~/.local/share/akm` on Linux/macOS,
71
+ * `%LOCALAPPDATA%\akm\data` on Windows).
72
+ *
73
+ * Holds durable, non-regenerable application data: SQLite databases
74
+ * (index.db, workflow.db, state.db), akm.lock, and config-backups.
75
+ * Losing this directory loses history and installed state.
76
+ *
77
+ * Env overrides (in priority order):
78
+ * AKM_DATA_DIR — point to any directory
79
+ * XDG_DATA_HOME — (Linux/macOS) override the XDG base; akm subdir is appended
80
+ */
81
+ export function getDataDir(env = process.env, platform = process.platform) {
82
+ const override = env.AKM_DATA_DIR?.trim();
83
+ if (override)
84
+ return override;
85
+ if (platform === "win32") {
86
+ const localAppData = env.LOCALAPPDATA?.trim();
87
+ if (localAppData)
88
+ return path.join(localAppData, "akm", "data");
89
+ const userProfile = env.USERPROFILE?.trim();
90
+ if (userProfile)
91
+ return path.join(userProfile, "AppData", "Local", "akm", "data");
92
+ const appData = env.APPDATA?.trim();
93
+ if (!appData) {
94
+ throw new ConfigError("Unable to determine data directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.", "CONFIG_DIR_UNRESOLVABLE");
95
+ }
96
+ return path.join(appData, "..", "Local", "akm", "data");
97
+ }
98
+ const xdgDataHome = env.XDG_DATA_HOME?.trim();
99
+ if (xdgDataHome)
100
+ return path.join(xdgDataHome, "akm");
101
+ const home = env.HOME?.trim();
102
+ if (!home)
103
+ return path.join("/tmp", "akm-data");
104
+ return path.join(home, ".local", "share", "akm");
105
+ }
106
+ // ── State directory ──────────────────────────────────────────────────────────
107
+ /**
108
+ * Returns the XDG state directory for akm (`~/.local/state/akm` on Linux/macOS,
109
+ * `%LOCALAPPDATA%\akm\state` on Windows).
110
+ *
111
+ * Holds runtime state and log-like files that persist across reboots but are
112
+ * less precious than $DATA: task history JSONL files, akm.lock.lck sentinel.
113
+ *
114
+ * Env overrides (in priority order):
115
+ * AKM_STATE_DIR — point to any directory
116
+ * XDG_STATE_HOME — (Linux/macOS) override the XDG base; akm subdir is appended
117
+ */
118
+ export function getStateDir(env = process.env, platform = process.platform) {
119
+ const override = env.AKM_STATE_DIR?.trim();
120
+ if (override)
121
+ return override;
122
+ if (platform === "win32") {
123
+ const localAppData = env.LOCALAPPDATA?.trim();
124
+ if (localAppData)
125
+ return path.join(localAppData, "akm", "state");
126
+ const userProfile = env.USERPROFILE?.trim();
127
+ if (userProfile)
128
+ return path.join(userProfile, "AppData", "Local", "akm", "state");
129
+ const appData = env.APPDATA?.trim();
130
+ if (!appData) {
131
+ throw new ConfigError("Unable to determine state directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.", "CONFIG_DIR_UNRESOLVABLE");
132
+ }
133
+ return path.join(appData, "..", "Local", "akm", "state");
134
+ }
135
+ const xdgStateHome = env.XDG_STATE_HOME?.trim();
136
+ if (xdgStateHome)
137
+ return path.join(xdgStateHome, "akm");
138
+ const home = env.HOME?.trim();
139
+ if (!home)
140
+ return path.join("/tmp", "akm-state");
141
+ return path.join(home, ".local", "state", "akm");
142
+ }
68
143
  export function getDbPath() {
69
- return path.join(getCacheDir(), "index.db");
144
+ return path.join(getDataDir(), "index.db");
70
145
  }
71
146
  export function getWorkflowDbPath() {
72
- return path.join(getCacheDir(), "workflow.db");
147
+ return path.join(getDataDir(), "workflow.db");
148
+ }
149
+ /** Path to the state.db file in $DATA. */
150
+ export function getStateDbPathInDataDir() {
151
+ return path.join(getDataDir(), "state.db");
152
+ }
153
+ /** Path for the task history directory in $STATE (v2 location). */
154
+ export function getTaskHistoryStateDir() {
155
+ return path.join(getStateDir(), "tasks", "history");
156
+ }
157
+ /** Path to the akm.lock file in $DATA. */
158
+ export function getLockfilePath() {
159
+ return path.join(getDataDir(), "akm.lock");
160
+ }
161
+ /** Path to the akm.lock.lck write-sentinel in $DATA. */
162
+ export function getLockfileLockPath() {
163
+ return path.join(getDataDir(), "akm.lock.lck");
73
164
  }
74
165
  export function getSemanticStatusPath() {
75
166
  return path.join(getCacheDir(), "semantic-status.json");
@@ -83,6 +174,13 @@ export function getRegistryIndexCacheDir() {
83
174
  export function getBinDir() {
84
175
  return path.join(getCacheDir(), "bin");
85
176
  }
177
+ // ── Scheduled-task runtime directories (logs + history) ──────────────────────
178
+ export function getTaskLogDir() {
179
+ return path.join(getCacheDir(), "tasks", "logs");
180
+ }
181
+ export function getTaskHistoryDir() {
182
+ return path.join(getCacheDir(), "tasks", "history");
183
+ }
86
184
  // ── Default stash directory ──────────────────────────────────────────────────
87
185
  export function getDefaultStashDir() {
88
186
  const override = process.env.AKM_STASH_DIR?.trim();
@@ -0,0 +1,61 @@
1
+ import { parseAssetRef } from "./asset-ref";
2
+ import { parseFrontmatter } from "./frontmatter";
3
+ import { lintLessonContent } from "./lesson-lint";
4
+ const genericProposalValidator = {
5
+ name: "generic-proposal-validator",
6
+ appliesTo: () => true,
7
+ validate(proposal, ctx) {
8
+ const findings = [];
9
+ if (!proposal.payload || typeof proposal.payload.content !== "string" || proposal.payload.content.trim() === "") {
10
+ findings.push({ kind: "empty-content", message: `Proposal ${proposal.id} has empty content.` });
11
+ }
12
+ try {
13
+ ctx.parsedRef = parseAssetRef(proposal.ref);
14
+ }
15
+ catch (err) {
16
+ findings.push({
17
+ kind: "invalid-ref",
18
+ message: `Proposal ${proposal.id} has invalid ref "${proposal.ref}": ${err.message}`,
19
+ });
20
+ ctx.stop = true;
21
+ return findings;
22
+ }
23
+ if (proposal.payload.content.startsWith("---")) {
24
+ try {
25
+ parseFrontmatter(proposal.payload.content);
26
+ }
27
+ catch (err) {
28
+ findings.push({
29
+ kind: "invalid-frontmatter",
30
+ message: `Proposal ${proposal.id} frontmatter could not be parsed: ${err.message}`,
31
+ });
32
+ }
33
+ }
34
+ return findings;
35
+ },
36
+ };
37
+ const lessonProposalValidator = {
38
+ name: "lesson-proposal-validator",
39
+ appliesTo(_proposal, ctx) {
40
+ return ctx.parsedRef?.type === "lesson";
41
+ },
42
+ validate(proposal) {
43
+ return lintLessonContent(proposal.payload.content, `proposal:${proposal.id}`).findings.map((finding) => ({
44
+ kind: finding.kind,
45
+ message: finding.message,
46
+ }));
47
+ },
48
+ };
49
+ export const defaultProposalValidators = [genericProposalValidator, lessonProposalValidator];
50
+ export function runProposalValidators(proposal, validators = defaultProposalValidators) {
51
+ const findings = [];
52
+ const ctx = {};
53
+ for (const validator of validators) {
54
+ if (!validator.appliesTo(proposal, ctx))
55
+ continue;
56
+ findings.push(...validator.validate(proposal, ctx));
57
+ if (ctx.stop)
58
+ break;
59
+ }
60
+ return { ok: findings.length === 0, findings };
61
+ }
@@ -39,8 +39,7 @@ import path from "node:path";
39
39
  import { makeAssetRef, parseAssetRef } from "./asset-ref";
40
40
  import { resolveAssetPathFromName, TYPE_DIRS } from "./asset-spec";
41
41
  import { NotFoundError, UsageError } from "./errors";
42
- import { parseFrontmatter } from "./frontmatter";
43
- import { lintLessonContent } from "./lesson-lint";
42
+ import { runProposalValidators } from "./proposal-validators";
44
43
  import { resolveWriteTarget, writeAssetToSource } from "./write-source";
45
44
  // ── Path helpers ────────────────────────────────────────────────────────────
46
45
  /**
@@ -190,6 +189,53 @@ export function getProposal(stashDir, id) {
190
189
  return readProposalFile(archivedPath);
191
190
  throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
192
191
  }
192
+ /**
193
+ * Resolve a proposal by full UUID, UUID prefix, or asset ref.
194
+ *
195
+ * Resolution order:
196
+ * 1. Exact UUID match (existing behaviour).
197
+ * 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
198
+ * that ref; falls back to archived if nothing is pending.
199
+ * 3. UUID prefix — matches any live proposal directory whose name starts
200
+ * with the given string; throws if ambiguous.
201
+ */
202
+ export function resolveProposalId(stashDir, idOrRef) {
203
+ // 1. Exact UUID.
204
+ try {
205
+ return getProposal(stashDir, idOrRef);
206
+ }
207
+ catch (e) {
208
+ if (!(e instanceof NotFoundError))
209
+ throw e;
210
+ }
211
+ // 2. Asset ref (e.g. "skill:akm-dream").
212
+ if (idOrRef.includes(":")) {
213
+ const pending = listProposals(stashDir, { ref: idOrRef });
214
+ if (pending.length > 0) {
215
+ return pending.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
216
+ }
217
+ const archived = listProposals(stashDir, { ref: idOrRef, includeArchive: true });
218
+ if (archived.length > 0) {
219
+ return archived.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
220
+ }
221
+ throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
222
+ }
223
+ // 3. UUID prefix.
224
+ const liveDir = getProposalsRoot(stashDir, false);
225
+ let prefixMatches = [];
226
+ try {
227
+ prefixMatches = fs.readdirSync(liveDir).filter((name) => name.startsWith(idOrRef));
228
+ }
229
+ catch {
230
+ /* live dir may not exist yet */
231
+ }
232
+ if (prefixMatches.length === 1)
233
+ return getProposal(stashDir, prefixMatches[0]);
234
+ if (prefixMatches.length > 1) {
235
+ throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
236
+ }
237
+ throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
238
+ }
193
239
  /**
194
240
  * Whether a proposal currently lives in the archive (used by callers that
195
241
  * need to know whether to look in the archive root for files / paths).
@@ -248,42 +294,7 @@ export function archiveProposal(stashDir, id, status, reason, ctx) {
248
294
  * here in the future without changing call sites.
249
295
  */
250
296
  export function validateProposal(proposal) {
251
- const findings = [];
252
- if (!proposal.payload || typeof proposal.payload.content !== "string" || proposal.payload.content.trim() === "") {
253
- findings.push({ kind: "empty-content", message: `Proposal ${proposal.id} has empty content.` });
254
- }
255
- let ref;
256
- try {
257
- ref = parseAssetRef(proposal.ref);
258
- }
259
- catch (err) {
260
- findings.push({
261
- kind: "invalid-ref",
262
- message: `Proposal ${proposal.id} has invalid ref "${proposal.ref}": ${err.message}`,
263
- });
264
- return { ok: false, findings };
265
- }
266
- // Generic frontmatter parse check for markdown-y types. If the content
267
- // *looks* like it has frontmatter (`---\n…\n---`) we ensure it parses.
268
- if (proposal.payload.content.startsWith("---")) {
269
- try {
270
- parseFrontmatter(proposal.payload.content);
271
- }
272
- catch (err) {
273
- findings.push({
274
- kind: "invalid-frontmatter",
275
- message: `Proposal ${proposal.id} frontmatter could not be parsed: ${err.message}`,
276
- });
277
- }
278
- }
279
- // Type-specific validators.
280
- if (ref.type === "lesson") {
281
- const lessonReport = lintLessonContent(proposal.payload.content, `proposal:${proposal.id}`);
282
- for (const finding of lessonReport.findings) {
283
- findings.push({ kind: finding.kind, message: finding.message });
284
- }
285
- }
286
- return { ok: findings.length === 0, findings };
297
+ return runProposalValidators(proposal);
287
298
  }
288
299
  /**
289
300
  * Validate a proposal, then promote it through the canonical