akm-cli 0.7.5 → 0.8.0-rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/CHANGELOG.md +1 -1
- package/dist/cli/parse-args.js +43 -0
- package/dist/cli.js +804 -461
- package/dist/commands/agent-dispatch.js +102 -0
- package/dist/commands/agent-support.js +62 -0
- package/dist/commands/config-cli.js +68 -84
- package/dist/commands/consolidate.js +823 -0
- package/dist/commands/distill-promotion-policy.js +658 -0
- package/dist/commands/distill.js +244 -52
- package/dist/commands/eval-cases.js +40 -0
- package/dist/commands/events.js +2 -23
- 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 +1170 -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 +251 -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 +107 -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/workflow-linter.js +53 -0
- package/dist/commands/lint.js +1 -0
- package/dist/commands/proposal.js +8 -7
- package/dist/commands/propose.js +78 -28
- package/dist/commands/reflect.js +143 -35
- package/dist/commands/registry-search.js +2 -2
- package/dist/commands/remember.js +54 -0
- package/dist/commands/schema-repair.js +130 -0
- package/dist/commands/search.js +21 -5
- package/dist/commands/show.js +121 -17
- 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 +2 -23
- package/dist/core/action-contributors.js +25 -0
- package/dist/core/asset-registry.js +4 -16
- package/dist/core/asset-spec.js +10 -0
- package/dist/core/common.js +94 -0
- package/dist/core/concurrent.js +22 -0
- package/dist/core/config.js +222 -128
- package/dist/core/events.js +73 -126
- package/dist/core/frontmatter.js +3 -1
- 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 +775 -0
- package/dist/core/time.js +51 -0
- package/dist/core/warn.js +59 -1
- package/dist/indexer/db-search.js +52 -238
- package/dist/indexer/db.js +377 -1
- package/dist/indexer/ensure-index.js +61 -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 +409 -76
- package/dist/indexer/index-context.js +10 -0
- package/dist/indexer/indexer.js +442 -290
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/match-contributors.js +141 -0
- package/dist/indexer/matchers.js +24 -190
- package/dist/indexer/memory-inference.js +63 -29
- package/dist/indexer/metadata-contributors.js +26 -0
- package/dist/indexer/metadata.js +188 -175
- 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/config.js +175 -3
- package/dist/integrations/agent/index.js +3 -1
- package/dist/integrations/agent/pipeline.js +39 -0
- package/dist/integrations/agent/profiles.js +67 -5
- package/dist/integrations/agent/prompts.js +77 -72
- package/dist/integrations/agent/runners.js +31 -0
- package/dist/integrations/agent/sdk-runner.js +120 -0
- package/dist/integrations/agent/spawn.js +71 -16
- 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 +61 -122
- package/dist/llm/feature-gate.js +27 -16
- package/dist/llm/graph-extract.js +297 -62
- package/dist/llm/memory-infer.js +49 -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 -318
- package/dist/output/renderers.js +190 -123
- package/dist/output/shapes.js +33 -0
- package/dist/output/text.js +239 -2
- package/dist/registry/providers/skills-sh.js +61 -49
- package/dist/registry/providers/static-index.js +44 -48
- package/dist/setup/setup.js +510 -11
- package/dist/sources/provider-factory.js +2 -1
- package/dist/sources/providers/git.js +2 -2
- 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/renderer.js +8 -3
- package/dist/workflows/runs.js +59 -91
- package/dist/workflows/validator.js +1 -1
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +3 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.8.0.md +43 -0
- package/package.json +3 -2
- 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
|
+
}
|
package/dist/core/paths.js
CHANGED
|
@@ -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(
|
|
144
|
+
return path.join(getDataDir(), "index.db");
|
|
70
145
|
}
|
|
71
146
|
export function getWorkflowDbPath() {
|
|
72
|
-
return path.join(
|
|
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
|
+
}
|
package/dist/core/proposals.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|