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.
- package/.github/CHANGELOG.md +1 -1
- package/dist/cli/parse-args.js +43 -0
- package/dist/cli.js +853 -479
- 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 +285 -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 +8 -26
- package/dist/core/action-contributors.js +25 -0
- package/dist/core/asset-ref.js +4 -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 +378 -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 +194 -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,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schtasks.exe backend for `akm tasks` (Windows default).
|
|
3
|
+
*
|
|
4
|
+
* Each task is registered under the `\akm\` Task Scheduler folder so the
|
|
5
|
+
* backend never touches user-managed tasks. The full task definition is
|
|
6
|
+
* sent through `schtasks /Create /TN \akm\<id> /XML <path>` so we can
|
|
7
|
+
* express triggers/principals/actions without quoting hell.
|
|
8
|
+
*
|
|
9
|
+
* Platform notes:
|
|
10
|
+
* • `LogonType=InteractiveToken` means the task runs in the context of
|
|
11
|
+
* the registering user only when they are logged in — there is no
|
|
12
|
+
* stored password and the task will not fire at the lock screen.
|
|
13
|
+
* • `<Principal>` deliberately omits `<UserId>`; per the Task Scheduler
|
|
14
|
+
* 2.0 schema (`principalType.UserId` minOccurs=0) this is valid and
|
|
15
|
+
* defaults to the registering user.
|
|
16
|
+
* • `<DisallowStartIfOnBatteries>false</…>` and `<StopIfGoingOnBatteries>
|
|
17
|
+
* false</…>` allow the task to run on battery — utility tasks would
|
|
18
|
+
* otherwise be silently skipped on laptops.
|
|
19
|
+
* • `MultipleInstancesPolicy=IgnoreNew` makes overlapping triggers safe:
|
|
20
|
+
* while a task is still running, a new fire is dropped rather than
|
|
21
|
+
* queued or run in parallel.
|
|
22
|
+
* • `/Query /FO CSV /NH` (without `/V`) outputs three columns:
|
|
23
|
+
* `TaskName,Next Run Time,Status` — so the regex anchors on the task
|
|
24
|
+
* name as the leading quoted field. Adding `/V` would shift HostName
|
|
25
|
+
* into column 0; we deliberately don't.
|
|
26
|
+
*
|
|
27
|
+
* Tests inject a fake exec + filesystem.
|
|
28
|
+
*/
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import os from "node:os";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { ConfigError } from "../../core/errors";
|
|
33
|
+
import { getTaskLogDir } from "../../core/paths";
|
|
34
|
+
import { resolveAkmInvocation } from "../resolveAkmBin";
|
|
35
|
+
import { parseSchedule, translateToSchtasks } from "../schedule";
|
|
36
|
+
import { escapeXml, spawnCommand } from "./exec-utils";
|
|
37
|
+
import schtasksTemplate from "./schtasks-template.xml" with { type: "text" };
|
|
38
|
+
export const DEFAULT_FOLDER_PREFIX = "\\akm\\";
|
|
39
|
+
export function SCHTASKS_BACKEND(options = {}) {
|
|
40
|
+
const exec = options.exec ?? defaultSchtasksExec();
|
|
41
|
+
const fsLike = options.fs ?? defaultSchtasksFs();
|
|
42
|
+
const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
|
|
43
|
+
const logDir = options.logDir ?? getTaskLogDir();
|
|
44
|
+
const folder = options.folderPrefix ?? DEFAULT_FOLDER_PREFIX;
|
|
45
|
+
const taskName = (id) => `${folder}${id}`;
|
|
46
|
+
return {
|
|
47
|
+
name: "schtasks",
|
|
48
|
+
install(task) {
|
|
49
|
+
fsLike.ensureDir(logDir);
|
|
50
|
+
const xml = buildSchtasksXml(task, akmArgv, logDir, { folderPrefix: folder });
|
|
51
|
+
const tmpFile = path.join(fsLike.tmpdir(), `akm-task-${task.id}-${Date.now()}.xml`);
|
|
52
|
+
fsLike.writeFile(tmpFile, xml);
|
|
53
|
+
try {
|
|
54
|
+
// /F forces overwrite if a task with the same name exists.
|
|
55
|
+
const r = exec.run(["schtasks", "/Create", "/TN", taskName(task.id), "/XML", tmpFile, "/F"]);
|
|
56
|
+
if (r.status !== 0) {
|
|
57
|
+
throw new ConfigError(`schtasks /Create failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
58
|
+
}
|
|
59
|
+
if (!task.enabled) {
|
|
60
|
+
const dis = exec.run(["schtasks", "/Change", "/TN", taskName(task.id), "/DISABLE"]);
|
|
61
|
+
if (dis.status !== 0) {
|
|
62
|
+
throw new ConfigError(`schtasks /Change /DISABLE failed: ${dis.stderr || dis.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
fsLike.removeFile(tmpFile);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
uninstall(id) {
|
|
71
|
+
const r = exec.run(["schtasks", "/Delete", "/TN", taskName(id), "/F"]);
|
|
72
|
+
if (r.status !== 0 && !/cannot find/i.test(r.stderr ?? "")) {
|
|
73
|
+
throw new ConfigError(`schtasks /Delete failed: ${r.stderr || r.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
setEnabled(id, enabled) {
|
|
77
|
+
const flag = enabled ? "/ENABLE" : "/DISABLE";
|
|
78
|
+
const r = exec.run(["schtasks", "/Change", "/TN", taskName(id), flag]);
|
|
79
|
+
if (r.status !== 0) {
|
|
80
|
+
throw new ConfigError(`schtasks /Change ${flag} failed: ${r.stderr || r.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
list() {
|
|
84
|
+
const r = exec.run(["schtasks", "/Query", "/FO", "CSV", "/NH"]);
|
|
85
|
+
if (r.status !== 0)
|
|
86
|
+
return [];
|
|
87
|
+
const ids = [];
|
|
88
|
+
for (const line of (r.stdout ?? "").split(/\r?\n/)) {
|
|
89
|
+
const m = line.match(/^"([^"]+)",/);
|
|
90
|
+
if (!m)
|
|
91
|
+
continue;
|
|
92
|
+
const name = m[1];
|
|
93
|
+
if (name.startsWith(folder)) {
|
|
94
|
+
ids.push(name.slice(folder.length));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return ids.map((id) => ({ id }));
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function buildSchtasksXml(task, akmArgv, logDir, options = {}) {
|
|
102
|
+
const folder = options.folderPrefix ?? DEFAULT_FOLDER_PREFIX;
|
|
103
|
+
const now = options.now ? options.now() : new Date();
|
|
104
|
+
const startBoundary = formatStartBoundary(now);
|
|
105
|
+
const spec = parseSchedule(task.schedule, "schtasks");
|
|
106
|
+
const trigger = translateToSchtasks(spec);
|
|
107
|
+
const command = akmArgv[0];
|
|
108
|
+
const args = [...akmArgv.slice(1), "tasks", "run", task.id].map((a) => quoteArg(a)).join(" ");
|
|
109
|
+
const triggerXml = renderSchtasksTrigger(trigger, startBoundary);
|
|
110
|
+
const logPath = path.join(logDir, `${task.id}.log`);
|
|
111
|
+
return schtasksTemplate
|
|
112
|
+
.replaceAll("{{TASK_ID}}", escapeXml(task.id))
|
|
113
|
+
.replaceAll("{{FOLDER}}", escapeXml(folder))
|
|
114
|
+
.replace("{{TRIGGER_XML}}", triggerXml)
|
|
115
|
+
.replace("{{ENABLED}}", task.enabled ? "true" : "false")
|
|
116
|
+
.replace("{{COMMAND}}", escapeXml(command))
|
|
117
|
+
.replace("{{ARGS}}", escapeXml(args))
|
|
118
|
+
.replace("{{LOG_PATH}}", escapeXml(logPath));
|
|
119
|
+
}
|
|
120
|
+
function renderSchtasksTrigger(trigger, startBoundary) {
|
|
121
|
+
switch (trigger.kind) {
|
|
122
|
+
case "minute":
|
|
123
|
+
return ` <TimeTrigger>
|
|
124
|
+
<Repetition>
|
|
125
|
+
<Interval>PT${trigger.everyMinutes}M</Interval>
|
|
126
|
+
</Repetition>
|
|
127
|
+
<StartBoundary>${startBoundary}</StartBoundary>
|
|
128
|
+
<Enabled>true</Enabled>
|
|
129
|
+
</TimeTrigger>`;
|
|
130
|
+
case "hour":
|
|
131
|
+
return ` <TimeTrigger>
|
|
132
|
+
<Repetition>
|
|
133
|
+
<Interval>PT${trigger.everyHours}H</Interval>
|
|
134
|
+
</Repetition>
|
|
135
|
+
<StartBoundary>${startBoundary}</StartBoundary>
|
|
136
|
+
<Enabled>true</Enabled>
|
|
137
|
+
</TimeTrigger>`;
|
|
138
|
+
case "daily":
|
|
139
|
+
return ` <CalendarTrigger>
|
|
140
|
+
<StartBoundary>${pad(startBoundary, trigger.atHour, trigger.atMinute)}</StartBoundary>
|
|
141
|
+
<Enabled>true</Enabled>
|
|
142
|
+
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
|
|
143
|
+
</CalendarTrigger>`;
|
|
144
|
+
case "weekly": {
|
|
145
|
+
const dayMap = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
146
|
+
const days = trigger.daysOfWeek.map((d) => ` <${dayMap[d]} />`).join("\n");
|
|
147
|
+
return ` <CalendarTrigger>
|
|
148
|
+
<StartBoundary>${pad(startBoundary, trigger.atHour, trigger.atMinute)}</StartBoundary>
|
|
149
|
+
<Enabled>true</Enabled>
|
|
150
|
+
<ScheduleByWeek>
|
|
151
|
+
<DaysOfWeek>
|
|
152
|
+
${days}
|
|
153
|
+
</DaysOfWeek>
|
|
154
|
+
<WeeksInterval>1</WeeksInterval>
|
|
155
|
+
</ScheduleByWeek>
|
|
156
|
+
</CalendarTrigger>`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function pad(base, hour, minute) {
|
|
161
|
+
// Rewrite the time component of an ISO-8601 boundary while preserving
|
|
162
|
+
// the date so daily/weekly triggers fire at the configured wall-clock
|
|
163
|
+
// time rather than the install instant.
|
|
164
|
+
const hh = String(hour).padStart(2, "0");
|
|
165
|
+
const mm = String(minute).padStart(2, "0");
|
|
166
|
+
return base.replace(/T\d\d:\d\d:\d\d$/, `T${hh}:${mm}:00`);
|
|
167
|
+
}
|
|
168
|
+
function formatStartBoundary(d) {
|
|
169
|
+
// Local-time ISO-8601 (no zone suffix) — Task Scheduler interprets a
|
|
170
|
+
// bare boundary in the registering user's timezone, which matches what
|
|
171
|
+
// a user typing "0 9 * * *" intuitively means ("9am local").
|
|
172
|
+
const yyyy = d.getFullYear();
|
|
173
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
174
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
175
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
176
|
+
const mi = String(d.getMinutes()).padStart(2, "0");
|
|
177
|
+
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
178
|
+
return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}`;
|
|
179
|
+
}
|
|
180
|
+
function quoteArg(s) {
|
|
181
|
+
if (/^[A-Za-z0-9_\-./@:%=+,\\]+$/.test(s))
|
|
182
|
+
return s;
|
|
183
|
+
return `"${s.replace(/"/g, '\\"')}"`;
|
|
184
|
+
}
|
|
185
|
+
function defaultSchtasksExec() {
|
|
186
|
+
return {
|
|
187
|
+
run(args) {
|
|
188
|
+
return spawnCommand(args);
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function defaultSchtasksFs() {
|
|
193
|
+
return {
|
|
194
|
+
writeFile(file, content) {
|
|
195
|
+
fs.writeFileSync(file, content, { encoding: "utf8" });
|
|
196
|
+
},
|
|
197
|
+
removeFile(file) {
|
|
198
|
+
try {
|
|
199
|
+
fs.rmSync(file, { force: true });
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
/* ignore */
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
ensureDir(dir) {
|
|
206
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
207
|
+
},
|
|
208
|
+
tmpdir() {
|
|
209
|
+
return os.tmpdir();
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a task markdown file (frontmatter + body) into a {@link TaskDocument}.
|
|
3
|
+
*
|
|
4
|
+
* The on-disk shape is:
|
|
5
|
+
*
|
|
6
|
+
* ```markdown
|
|
7
|
+
* ---
|
|
8
|
+
* schedule: "0 9 * * *"
|
|
9
|
+
* # one of:
|
|
10
|
+
* workflow: workflow:daily-backup
|
|
11
|
+
* params:
|
|
12
|
+
* region: us-east-1
|
|
13
|
+
* # ...or:
|
|
14
|
+
* prompt: inline # body is the prompt
|
|
15
|
+
* profile: opencode # optional
|
|
16
|
+
* # ...or:
|
|
17
|
+
* prompt: agent:my-agent # asset ref
|
|
18
|
+
* # ...or:
|
|
19
|
+
* prompt: ./prompts/my-prompt.md # relative file path
|
|
20
|
+
* enabled: true # default true
|
|
21
|
+
* description: …
|
|
22
|
+
* tags: [scheduled, backup]
|
|
23
|
+
* ---
|
|
24
|
+
*
|
|
25
|
+
* # Task: Daily backup (optional notes; for prompt:inline this is the prompt)
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* Validation lives in {@link validateTaskDocument}. The parser only enforces
|
|
29
|
+
* shape; cron syntax, target reachability, and profile availability are
|
|
30
|
+
* checked separately so callers can choose how strictly to surface errors.
|
|
31
|
+
*/
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import { UsageError } from "../core/errors";
|
|
34
|
+
import { parseFrontmatter } from "../core/frontmatter";
|
|
35
|
+
import { TASK_SCHEMA_VERSION } from "./schema";
|
|
36
|
+
export function parseTaskDocument(input) {
|
|
37
|
+
const { markdown, filePath, id } = input;
|
|
38
|
+
const fm = parseFrontmatter(markdown);
|
|
39
|
+
const data = fm.data;
|
|
40
|
+
const schedule = readString(data.schedule, "schedule", filePath);
|
|
41
|
+
if (!schedule) {
|
|
42
|
+
throw new UsageError(`Task "${id}" is missing a schedule (frontmatter key "schedule"). File: ${filePath}`, "MISSING_REQUIRED_ARGUMENT");
|
|
43
|
+
}
|
|
44
|
+
const enabled = data.enabled === undefined ? true : data.enabled === true;
|
|
45
|
+
const description = readString(data.description, "description", filePath);
|
|
46
|
+
const tags = readStringArray(data.tags);
|
|
47
|
+
const hasWorkflow = "workflow" in data && data.workflow !== "";
|
|
48
|
+
const hasPrompt = "prompt" in data && data.prompt !== "";
|
|
49
|
+
const hasCommand = "command" in data && data.command !== "" && data.command !== null && data.command !== undefined;
|
|
50
|
+
const targetCount = [hasWorkflow, hasPrompt, hasCommand].filter(Boolean).length;
|
|
51
|
+
if (targetCount > 1) {
|
|
52
|
+
throw new UsageError(`Task "${id}" sets more than one of \`workflow\`, \`prompt\`, \`command\`; pick exactly one. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
53
|
+
}
|
|
54
|
+
if (targetCount === 0) {
|
|
55
|
+
throw new UsageError(`Task "${id}" must set one of \`workflow\`, \`prompt\`, or \`command\` in frontmatter. File: ${filePath}`, "MISSING_REQUIRED_ARGUMENT");
|
|
56
|
+
}
|
|
57
|
+
let target;
|
|
58
|
+
if (hasWorkflow) {
|
|
59
|
+
const ref = readString(data.workflow, "workflow", filePath);
|
|
60
|
+
if (!ref) {
|
|
61
|
+
throw new UsageError(`Task "${id}" has empty \`workflow\`. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
62
|
+
}
|
|
63
|
+
target = {
|
|
64
|
+
kind: "workflow",
|
|
65
|
+
ref,
|
|
66
|
+
params: readParams(data.params, filePath),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
else if (hasCommand) {
|
|
70
|
+
const cmd = readCommand(data.command, filePath, id);
|
|
71
|
+
target = { kind: "command", cmd };
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const promptRaw = readString(data.prompt, "prompt", filePath);
|
|
75
|
+
if (!promptRaw) {
|
|
76
|
+
throw new UsageError(`Task "${id}" has empty \`prompt\`. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
77
|
+
}
|
|
78
|
+
const profile = readString(data.profile, "profile", filePath);
|
|
79
|
+
target = {
|
|
80
|
+
kind: "prompt",
|
|
81
|
+
source: resolvePromptSource(promptRaw, fm.content, filePath, id),
|
|
82
|
+
profile: profile && profile.length > 0 ? profile : undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// null / 0 / negative → disabled (no timeout). Positive number → override.
|
|
86
|
+
// Omitted → undefined (inherits config.agent.timeoutMs).
|
|
87
|
+
let timeoutMs;
|
|
88
|
+
if ("timeoutMs" in data) {
|
|
89
|
+
const raw = data.timeoutMs;
|
|
90
|
+
if (raw === null || raw === "null" || raw === 0 || (typeof raw === "number" && raw < 0)) {
|
|
91
|
+
timeoutMs = null;
|
|
92
|
+
}
|
|
93
|
+
else if (typeof raw === "number" && raw > 0) {
|
|
94
|
+
timeoutMs = raw;
|
|
95
|
+
}
|
|
96
|
+
// non-numeric / unrecognised → leave as undefined (inherit)
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
schemaVersion: TASK_SCHEMA_VERSION,
|
|
100
|
+
id,
|
|
101
|
+
schedule,
|
|
102
|
+
enabled,
|
|
103
|
+
target,
|
|
104
|
+
description: description && description.length > 0 ? description : undefined,
|
|
105
|
+
tags: tags && tags.length > 0 ? tags : undefined,
|
|
106
|
+
source: { path: filePath },
|
|
107
|
+
timeoutMs,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Split `prompt:` frontmatter into one of {@link TaskPromptSource} variants.
|
|
112
|
+
*
|
|
113
|
+
* • "inline" → body is the prompt
|
|
114
|
+
* • "<type>:<name>" (asset ref) → asset
|
|
115
|
+
* • "./foo.md", "../foo.md", "/abs" → file
|
|
116
|
+
* • anything else → treated as inline prompt text
|
|
117
|
+
* (the value itself is the prompt)
|
|
118
|
+
*/
|
|
119
|
+
function resolvePromptSource(raw, body, filePath, id) {
|
|
120
|
+
const trimmed = raw.trim();
|
|
121
|
+
if (trimmed === "inline") {
|
|
122
|
+
const text = body.trim();
|
|
123
|
+
if (!text) {
|
|
124
|
+
throw new UsageError(`Task "${id}" sets \`prompt: inline\` but the markdown body is empty. File: ${filePath}`, "MISSING_REQUIRED_ARGUMENT");
|
|
125
|
+
}
|
|
126
|
+
return { kind: "inline", text };
|
|
127
|
+
}
|
|
128
|
+
if (trimmed.startsWith("./") || trimmed.startsWith("../") || path.isAbsolute(trimmed)) {
|
|
129
|
+
return { kind: "file", path: trimmed };
|
|
130
|
+
}
|
|
131
|
+
if (/^[A-Za-z]:[\\/]/.test(trimmed)) {
|
|
132
|
+
return { kind: "file", path: trimmed };
|
|
133
|
+
}
|
|
134
|
+
if (/^[a-z][a-z0-9_-]*:[^\s]/i.test(trimmed)) {
|
|
135
|
+
return { kind: "asset", ref: trimmed };
|
|
136
|
+
}
|
|
137
|
+
// Fallback: treat the literal value as the prompt text.
|
|
138
|
+
return { kind: "inline", text: trimmed };
|
|
139
|
+
}
|
|
140
|
+
function readString(value, key, filePath) {
|
|
141
|
+
if (value === undefined || value === null)
|
|
142
|
+
return undefined;
|
|
143
|
+
if (typeof value === "string")
|
|
144
|
+
return value.trim();
|
|
145
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
146
|
+
return String(value);
|
|
147
|
+
throw new UsageError(`Frontmatter key "${key}" must be a string. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
148
|
+
}
|
|
149
|
+
function readStringArray(value) {
|
|
150
|
+
if (value === undefined || value === null)
|
|
151
|
+
return undefined;
|
|
152
|
+
if (typeof value === "string") {
|
|
153
|
+
return value
|
|
154
|
+
.split(/[\s,]+/)
|
|
155
|
+
.map((s) => s.trim())
|
|
156
|
+
.filter(Boolean);
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(value)) {
|
|
159
|
+
return value.filter((v) => typeof v === "string" && v.trim().length > 0);
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
function readCommand(value, filePath, id) {
|
|
164
|
+
if (Array.isArray(value)) {
|
|
165
|
+
const parts = value.filter((v) => typeof v === "string" && v.trim().length > 0);
|
|
166
|
+
if (parts.length === 0) {
|
|
167
|
+
throw new UsageError(`Task "${id}" has empty \`command\` array. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
168
|
+
}
|
|
169
|
+
return parts;
|
|
170
|
+
}
|
|
171
|
+
if (typeof value === "string") {
|
|
172
|
+
const parts = value.trim().split(/\s+/).filter(Boolean);
|
|
173
|
+
if (parts.length === 0) {
|
|
174
|
+
throw new UsageError(`Task "${id}" has empty \`command\`. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
175
|
+
}
|
|
176
|
+
return parts;
|
|
177
|
+
}
|
|
178
|
+
throw new UsageError(`Frontmatter key "command" must be a string or array of strings. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
179
|
+
}
|
|
180
|
+
function readParams(value, filePath) {
|
|
181
|
+
if (value === undefined || value === null)
|
|
182
|
+
return {};
|
|
183
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
if (typeof value === "string" && value.trim()) {
|
|
187
|
+
try {
|
|
188
|
+
const parsed = JSON.parse(value);
|
|
189
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
190
|
+
return parsed;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// fall through
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
throw new UsageError(`Frontmatter key "params" must be a mapping or a JSON object. File: ${filePath}`, "INVALID_FLAG_VALUE");
|
|
198
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the absolute invocation that the OS scheduler should run.
|
|
3
|
+
*
|
|
4
|
+
* cron / launchd / schtasks all execute jobs with a stripped environment and
|
|
5
|
+
* a minimal PATH, so the registered command must be an absolute path.
|
|
6
|
+
*
|
|
7
|
+
* Resolution order:
|
|
8
|
+
*
|
|
9
|
+
* 1. `$AKM_BIN` (explicit override; takes precedence everywhere).
|
|
10
|
+
* 2. `process.execPath` + the resolved CLI script — works when running
|
|
11
|
+
* from a development checkout (`bun /repo/src/cli.ts`) and from a
|
|
12
|
+
* compiled install (`bun /opt/akm/dist/cli.js`).
|
|
13
|
+
* 3. `which akm` / `where akm` — last resort when the binary is on PATH
|
|
14
|
+
* but neither override applies.
|
|
15
|
+
*
|
|
16
|
+
* Returns the argv array the scheduler should execute (e.g.
|
|
17
|
+
* `["/usr/local/bin/bun", "/repo/dist/cli.js"]`). The caller appends
|
|
18
|
+
* subcommand args (`"tasks", "run", "<id>"`).
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { ConfigError } from "../core/errors";
|
|
25
|
+
export function resolveAkmInvocation(options = {}) {
|
|
26
|
+
const env = options.env ?? process.env;
|
|
27
|
+
const override = env.AKM_BIN?.trim();
|
|
28
|
+
if (override) {
|
|
29
|
+
return { argv: [override], via: "AKM_BIN" };
|
|
30
|
+
}
|
|
31
|
+
const cliPath = resolveCliEntry(options.cliEntryUrl ?? import.meta.url);
|
|
32
|
+
if (cliPath && process.execPath) {
|
|
33
|
+
return { argv: [process.execPath, cliPath], via: "execPath" };
|
|
34
|
+
}
|
|
35
|
+
const whichBin = findOnPath("akm", env);
|
|
36
|
+
if (whichBin) {
|
|
37
|
+
return { argv: [whichBin], via: "which" };
|
|
38
|
+
}
|
|
39
|
+
throw new ConfigError("Cannot resolve absolute path to the akm binary for scheduler registration.", "INVALID_CONFIG_FILE", "Set AKM_BIN to the absolute path of the akm binary, or ensure `akm` is on PATH.");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* From the URL of a module inside `src/tasks/` figure out the CLI entry.
|
|
43
|
+
*
|
|
44
|
+
* • dev `…/src/tasks/resolveAkmBin.ts` → `…/src/cli.ts`
|
|
45
|
+
* • build `…/dist/tasks/resolveAkmBin.js` → `…/dist/cli.js`
|
|
46
|
+
*/
|
|
47
|
+
function resolveCliEntry(moduleUrl) {
|
|
48
|
+
let modulePath;
|
|
49
|
+
try {
|
|
50
|
+
modulePath = fileURLToPath(moduleUrl);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const dir = path.dirname(modulePath); // .../tasks
|
|
56
|
+
const parent = path.dirname(dir); // .../src or .../dist
|
|
57
|
+
const ext = path.extname(modulePath); // .ts | .js
|
|
58
|
+
const candidate = path.join(parent, `cli${ext}`);
|
|
59
|
+
if (fs.existsSync(candidate))
|
|
60
|
+
return candidate;
|
|
61
|
+
// Fallback: try the other extension.
|
|
62
|
+
const alt = path.join(parent, ext === ".ts" ? "cli.js" : "cli.ts");
|
|
63
|
+
if (fs.existsSync(alt))
|
|
64
|
+
return alt;
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
function findOnPath(bin, env) {
|
|
68
|
+
const tool = process.platform === "win32" ? "where" : "which";
|
|
69
|
+
try {
|
|
70
|
+
const out = spawnSync(tool, [bin], { encoding: "utf8", env });
|
|
71
|
+
if (out.status === 0 && typeof out.stdout === "string") {
|
|
72
|
+
const first = out.stdout
|
|
73
|
+
.split(/\r?\n/)
|
|
74
|
+
.map((s) => s.trim())
|
|
75
|
+
.find(Boolean);
|
|
76
|
+
if (first && fs.existsSync(first))
|
|
77
|
+
return first;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// ignore — caller will throw a ConfigError
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|