akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
904 lines (903 loc) • 43.9 kB
JavaScript
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/**
* `akm task` — register, inspect, run, and remove scheduled task assets.
*
* Each handler exported here is a pure function that performs the real work;
* `src/cli.ts` wraps these in citty `defineCommand`s and shapes their return
* values via `output()`.
*
* Every command that writes the native scheduler (`add`, `enable`,
* `disable`, `sync`, `prune --yes`) runs under the one scheduler lock
* (`withSchedulerLock`): read the installed rows once, plan against the
* sources, then install, update, or remove row by row. One row that fails is
* reported and the rest still apply.
*/
import fs from "node:fs";
import path from "node:path";
import { stringify as yamlStringify } from "yaml";
import { detectAdapterId } from "../../core/adapter/detect-adapter.js";
import { assetPathForName } from "../../core/asset/asset-placement.js";
import { makeBundleRef, parseBundleRef } from "../../core/asset/asset-ref.js";
import { conceptIdFromTypeName, isFullRefInput } from "../../core/asset/resolve-ref.js";
import { isWithin, resolveStashDir } from "../../core/common.js";
import { loadConfig, mutateConfig, resetConfigCache } from "../../core/config/config.js";
import { bundleKeyForContentRoot, resolveActiveConfiguredSources, resolveConfiguredSources, } from "../../core/config/config-sources.js";
import { IMPROVE_AUTONOMY_CONFIG_KEY, isImproveAutonomyEnabled } from "../../core/config/experimental.js";
import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
import { getTaskHistoryDir, getTaskLogDir } from "../../core/paths.js";
import { warn } from "../../core/warn.js";
import { commitWriteTargetBoundary, isWriteCapableSourceKind, prepareWriteTargetForMutation, resolveWorkingStashTarget, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
import { withEngineFallback } from "../../integrations/agent/engine-fallback.js";
import { resolveAssetPath } from "../../sources/resolve.js";
import { enabledRefsFromInstalled, schedulerEnabledRefs, setSchedulerRefEnabled } from "../../tasks/activation-config.js";
import { backendNameForPlatform, selectBackend } from "../../tasks/backends/index.js";
import { prepareTaskV3Execution } from "../../tasks/prepare/prepare.js";
import { isCheckoutInvocation, resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
import { createExecutionProvenanceContext } from "../../tasks/run/provenance.js";
import { runTask } from "../../tasks/run/run-task.js";
import { readTaskHistory } from "../../tasks/run/task-history.js";
import { exitCodeForStatus } from "../../tasks/run/task-result.js";
import { parseSchedule, SCHEDULE_SUPPORTED_SUBSET_HINT } from "../../tasks/schedule.js";
import { compileTaskSchedulerBindings, schedulerBindingNativeId, } from "../../tasks/scheduler-binding.js";
import { readLegacySchedulerContext, scheduledRowEnvironment } from "../../tasks/scheduler-invocation.js";
import { withSchedulerLock } from "../../tasks/scheduler-lock.js";
import { compileSchedulerSources, installedRowNativeId, installedRowOwner, installedRowScope, planSchedulerSync, renderSchedulerPlanPreview, scheduledInvocationBundle, } from "../../tasks/scheduler-sync.js";
import { parseTaskSource } from "../../tasks/source/parse-task-source.js";
import { projectTaskSourceV4 } from "../../tasks/source/project-v4.js";
import { normaliseTaskConceptId, normaliseTaskId } from "../../tasks/task-id.js";
import { applyAutonomyGate, configuredDirectAutonomyLanes, describeGatedLanes } from "../improve/autonomy-gate.js";
import { resolveImproveStrategy } from "../improve/improve-strategies.js";
export async function akmTasksAdd(input, deps = {}) {
const id = normaliseTaskId(input.id);
assertTaskAddTargetShape(input);
// Validate the schedule for the active backend before writing anything. An
// injected backend (tests) carries its own name.
const backend = deps.backend?.name ?? backendNameForPlatform();
parseSchedule(input.schedule, backend);
const bundle = resolveTaskBundle(input.target);
const stashDir = bundle.stashDir;
const typeRoot = path.join(stashDir, "tasks");
const assetPath = assetPathForName("task", typeRoot, id);
if (!isWithin(assetPath, typeRoot)) {
throw new UsageError(`Resolved task path escapes the stash: "${id}".`, "PATH_ESCAPE_VIOLATION");
}
const yaml = renderTaskYaml({
id,
schedule: input.schedule,
workflow: input.workflow,
prompt: input.prompt,
command: input.command,
engine: input.engine,
model: input.model,
timeoutMs: input.timeoutMs,
params: input.params,
name: input.name,
description: input.description,
when_to_use: input.when_to_use,
tags: input.tags,
});
const parsedTask = parseTaskSource({ yaml, filePath: assetPath, workspaceRoot: stashDir });
const task = projectTaskSourceV4(parsedTask.v4);
const qualifiedRef = makeBundleRef(bundle.bundleName, `tasks/${id}`);
await prepareTaskV3Execution(task, {
taskId: id,
taskRef: qualifiedRef,
bundleName: bundle.bundleName,
bundleRoot: stashDir,
config: bundle.config,
resolveAsset: taskProjectionAssetResolver(bundle.config, bundle.bundleName, stashDir),
});
const bindings = compileTaskSchedulerBindings({
id,
qualifiedRef,
schedules: parsedTask.v4.schedule.map((schedule) => ({
cron: schedule.cron,
ordinal: schedule.ordinal,
source: schedule.source,
inputs: schedule.inputs,
})),
});
const first = bindings[0];
if (!first)
throw new UsageError(`Task "${id}" has no schedulable trigger.`, "INVALID_FLAG_VALUE");
const sched = deps.backend ?? selectBackend();
return withSchedulerLock(async () => {
if (fs.existsSync(assetPath) && !input.force) {
throw new UsageError(`Task "${id}" already exists. Pass --force to overwrite, or delete its file and run \`akm task sync\` first.`, "RESOURCE_ALREADY_EXISTS");
}
// Native ids are shared by every bundle and installation: refuse before
// writing anything when another one already schedules this id.
const scope = bundleScope(bundle.bundleName, stashDir);
const installed = await listInstalledRows(sched);
for (const binding of bindings) {
const row = installed.find((candidate) => installedRowNativeId(candidate) === schedulerBindingNativeId(binding));
if (row && !installedRowScope(row, [scope])) {
throw new UsageError(`Task id "${id}" is already scheduled from ${installedRowOwner(row)}; rename the task or disable the existing one first.`, "RESOURCE_ALREADY_EXISTS");
}
}
await ensureSchedulerChoice({ backend: sched });
if (input.disabled)
setSchedulerRefEnabled(qualifiedRef, false);
await (deps.writeAsset ?? writeAssetToSource)(bundle.resolved.source, bundle.resolved.config, taskAssetRef(id), yaml);
(deps.commitBoundary ?? commitWriteTargetBoundary)(bundle.resolved, `Update tasks/${id}`);
if (!input.disabled)
setSchedulerRefEnabled(qualifiedRef, true);
const sync = await akmTasksSync(deps, bundle.bundleName, { rebind: input.rebind === true });
for (const warning of sync.warnings ?? [])
warn(warning);
const failure = sync.failures.find((candidate) => candidate.ref === qualifiedRef);
if (failure) {
throw new ConfigError(`Task "${id}" was written to ${assetPath}${input.disabled ? "" : " and enabled"}, but it could not be scheduled: ${failure.reason}`, "INVALID_CONFIG_FILE", "Fix the cause and run `akm task sync`.");
}
return {
id,
ref: conceptIdFromTypeName("task", id),
path: assetPath,
bundleDir: stashDir,
schedule: first.cron,
enabled: input.disabled !== true,
backend,
target: task.target,
};
});
}
function assertTaskAddTargetShape(input) {
const hasCommand = input.command !== undefined &&
input.command !== null &&
!(typeof input.command === "string" && input.command.trim() === "") &&
!(Array.isArray(input.command) && input.command.length === 0);
const targetCount = [Boolean(input.workflow), Boolean(input.prompt), hasCommand].filter(Boolean).length;
if (targetCount !== 1) {
throw new UsageError("Pass exactly one of --workflow <ref>, --prompt <inline-text>, or --command <shell-command>.", "INVALID_FLAG_VALUE");
}
// `--timeout-ms` is the workflow's whole-run bound. Engine and model stay
// prompt-only because workflow engines come from the frozen plan.
if (input.workflow && (input.engine !== undefined || input.model !== undefined)) {
throw new UsageError("Workflow tasks accept --params and --timeout-ms; engine and model are prompt-task fields.", "INVALID_FLAG_VALUE");
}
if (hasCommand && (input.engine !== undefined || input.model !== undefined)) {
throw new UsageError("Command tasks accept --timeout-ms but not --engine or --model.", "INVALID_FLAG_VALUE");
}
if (input.prompt !== undefined)
assertInlineTaskPrompt(input.prompt);
}
export async function akmTasksRun(id, options = {}) {
const parsed = parseTaskRef(id);
const bundle = resolveTaskReadBundle(parsed.bundle, options.target);
const adapterId = bundle.source.adapterId ?? detectAdapterId(bundle.source.path);
const resolvedId = taskIdForAdapter(parsed.id, adapterId);
const scheduled = options.scheduled === true;
// D5 "Construction" (spec docs/plans/specs/p1b-model-extraction.md §1.2/
// §5.2): built ONCE at this invocation boundary. eventSource is "task"
// whether or not --scheduled was passed (§1.6 D5-N1) — scheduled stays a
// separate field carrying its own pre-existing meaning (activation policy,
// scheduler env), never selecting the event source.
const provenance = createExecutionProvenanceContext(scheduled);
// F-3 (spec §5.4): RunTaskOptions.stashDir renamed to bundleDir — VALUE-
// preserving, no CLI flag change.
//
// P2a Lane C (spec docs/plans/specs/p2a-task-source-v4.md §5.1): the raw,
// exact-name input flags `tasks-cli.ts`'s Stage 1 captures ride through
// unchanged to `runTask` -> `loadPreparedTask`'s Stage 2 materializer,
// which owns declaring `inputFlags` on `RunTaskOptions` and attaching the
// materialized literals to the constructed `TaskInvocation`. This is only
// the pass-through surface: a valid flag set stays byte-identical to the
// same run without flags (§0), and P2a delivers nothing to the target.
//
// No `as` cast (test-review finding, spec §6 F-5): `RunTaskOptions` (this
// literal's inferred type is checked directly against it, below) declares
// every one of these fields, so the compiler — not a suppressed excess-
// property check — enforces this seam. A future rename or removal on
// either side now fails `tsc`, not silently at the `runTask` boundary.
const runOptions = {
bundleDir: bundle.source.path,
bundleName: bundle.source.name,
adapterId,
scheduled,
provenance,
inputFlags: options.inputFlags,
};
// The runner owns the prepare-before-reserve boundary. Invalid source,
// projectability, and resolver failures therefore create no history row.
const result = await runTask(resolvedId, runOptions);
// C-7 (spec §5.6): after D8's result-vocabulary re-code, "command" means
// the agent/LLM arm — the native shell/script arm now reports "shell" /
// "script". Rewired in the SAME commit as the vocabulary re-code so a
// shell/script task's process exit 78 still passes through as CLI exit 78
// (documented behavior, src/assets/hints/cli-hints-short.md:95).
const exitCode = result.status === "failed" &&
(result.target.kind === "shell" || result.target.kind === "script") &&
result.detail?.exitCode === 78
? 78
: exitCodeForStatus(result.status);
return {
ok: result.status === "completed",
result,
exitCode,
};
}
function resolveTaskActivation(ref, target, requireSchedulable) {
const parsed = parseTaskRef(ref);
const bundle = resolveTaskReadBundle(parsed.bundle, target);
const adapterId = bundle.source.adapterId ?? detectAdapterId(bundle.source.path);
const id = taskIdForAdapter(parsed.id, adapterId);
const conceptId = adapterId === "akm" ? `tasks/${id}` : id;
const sourcePath = adapterId === "akm"
? path.join(bundle.source.path, "tasks", `${id}.yml`)
: path.join(bundle.source.path, `${id}.yml`);
if (requireSchedulable && !fs.existsSync(sourcePath)) {
throw new NotFoundError(`Task ${JSON.stringify(makeBundleRef(bundle.source.name, conceptId))} was not found.`);
}
if (requireSchedulable) {
const parsedSource = parseTaskSource({
yaml: fs.readFileSync(sourcePath, "utf8"),
filePath: sourcePath,
workspaceRoot: bundle.source.path,
});
if (parsedSource.v4.schedule.length === 0) {
throw new UsageError(`Task ${JSON.stringify(ref)} has no schedule to enable.`, "INVALID_FLAG_VALUE");
}
}
return { qualifiedRef: makeBundleRef(bundle.source.name, conceptId), sourcePath, bundleName: bundle.source.name };
}
/**
* A config that predates `scheduler.enabled` means "keep what is installed":
* write that as the host's explicit list before one ref is added or removed,
* so enabling or disabling a single task never silently drops the others.
*/
async function ensureSchedulerChoice(deps) {
const config = loadConfig();
if (schedulerEnabledRefs(config) !== undefined)
return;
initializeSchedulerChoice(await (deps.backend ?? selectBackend()).list(), config, { write: true });
}
function initializeSchedulerChoice(installed, config, options) {
const refs = enabledRefsFromInstalled(installed, config);
if (options.write) {
mutateConfig((current) => ({ ...current, scheduler: { ...current.scheduler, enabled: [...refs] } }));
resetConfigCache();
}
warn(`This host had no scheduler.enabled list; ${options.write ? "it was" : "it would be"} initialized from the ` +
`${refs.length} installed akm scheduler ${refs.length === 1 ? "binding" : "bindings"}` +
`${refs.length > 0 ? `: ${refs.join(", ")}` : ""}. Use \`akm task enable\`/\`akm task disable <ref>\` to change it.`);
return refs;
}
export async function akmTasksEnable(ref, options = {}, deps = {}) {
return setTaskActivation(resolveTaskActivation(ref, options.target, true), true, deps);
}
export async function akmTasksDisable(ref, options = {}, deps = {}) {
return setTaskActivation(resolveTaskActivation(ref, options.target, false), false, deps);
}
async function setTaskActivation(resolved, enabled, deps) {
return withSchedulerLock(async () => {
await ensureSchedulerChoice(deps);
const activation = setSchedulerRefEnabled(resolved.qualifiedRef, enabled);
const sync = await akmTasksSync(deps, resolved.bundleName);
return { ref: resolved.qualifiedRef, enabled, changed: activation.changed, sync };
});
}
export async function akmTasksHistory(input) {
const limit = input.limit !== undefined && input.limit > 0 ? input.limit : 50;
const parsed = input.id ? parseTaskRef(input.id) : undefined;
const bundle = resolveTaskReadBundle(parsed?.bundle, input.target);
const adapterId = bundle.source.adapterId ?? detectAdapterId(bundle.source.path);
const id = parsed ? taskIdForAdapter(parsed.id, adapterId) : undefined;
// History rows are keyed by task id in state.db, not per bundle.
return { rows: readTaskHistory({ id, limit }) };
}
/**
* Reconcile host-local scheduler activation with authored tasks/workflows.
*
* • without a bundle, every enabled configured filesystem/git bundle; with
* one, only that bundle
* • only refs listed in `scheduler.enabled` are read and scheduled
* • a row is installed when missing, rewritten when its rendered definition
* differs, and removed when its source is gone or no longer enabled
* • rows another bundle or installation owns are never touched, and a row
* whose source fails to compile is left as it is
* • a disabled bundle's rows are removed by an unscoped sync
*/
export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
return withSchedulerLock(async () => {
const { sched, plan, warnings } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
const failed = new Set();
const failures = [...plan.failures];
for (const operation of plan.operations) {
try {
if (operation.kind === "remove")
await sched.uninstall(operation.nativeId);
else
await sched.install(operation.binding, operation.options);
}
catch (cause) {
if (operation.kind === "remove") {
failed.add(operation.id);
failures.push({ path: operation.nativeId, reason: errorMessage(cause) });
}
else {
failed.add(operation.binding.id);
failures.push({
path: operation.binding.source,
ref: operation.binding.logicalSource.ref,
reason: errorMessage(cause),
});
}
}
}
const applied = (ids) => ids.filter((id) => !failed.has(id));
return {
installed: applied(plan.installed),
updated: applied(plan.updated),
removed: applied(plan.removed),
unchanged: [...plan.unchanged],
skipped: [],
backend: sched.name,
failures: failures.map((failure) => ({ ...failure })),
...(warnings.length > 0 ? { warnings } : {}),
};
});
}
/**
* `akm task sync --dry-run` (#849): the exact plan `akmTasksSync` would
* apply, previewed. It writes nothing: no config, no row.
*/
export async function akmTasksSyncPlan(deps = {}, bundleTarget, options = {}) {
const { sched, plan } = await buildSchedulerSyncPlan(deps, bundleTarget, { ...options, dryRun: true });
return renderSchedulerPlanPreview(sched.name, plan.operations, plan.unchanged, plan.failures);
}
async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
let config = loadConfig();
const sched = deps.backend ?? selectBackend();
const installed = await listInstalledRows(sched);
// A config that predates `scheduler.enabled` means "keep what is
// installed": the akm-written rows become the host's choice (written
// unless this is a dry run) before the desired set, which would otherwise
// remove them, is computed.
let enabledRefs = schedulerEnabledRefs(config);
if (enabledRefs === undefined) {
enabledRefs = initializeSchedulerChoice(installed, config, { write: options.dryRun !== true });
if (options.dryRun !== true)
config = loadConfig();
}
const activeSources = resolveActiveConfiguredSources(config);
if (bundleTarget) {
// Only filesystem and git bundles can carry scheduler state.
const targetSource = activeSources.find((source) => source.name === bundleTarget);
if (targetSource && !isWriteCapableSourceKind(targetSource.type)) {
throw new UsageError(`Bundle "${bundleTarget}" has kind "${targetSource.type}"; task scheduling is only supported for filesystem and git bundles.`, "INVALID_FLAG_VALUE");
}
}
const configuredSources = resolveConfiguredSources(config);
const schedulable = activeSources
.filter((source) => isWriteCapableSourceKind(source.type))
.map((source) => source.name);
// No configured bundle at all: the working stash (e.g. `AKM_BUNDLE_DIR`).
const selected = bundleTarget
? [bundleTarget]
: schedulable.length > 0 || configuredSources.length > 0
? schedulable
: [undefined];
const enabledRefSet = new Set(enabledRefs);
const desired = [];
const failures = [];
const keepRefs = new Set();
const scopes = [];
// Each bundle is its own item: one that cannot be read is reported and
// left untouched while the others reconcile. A scoped sync has only that
// bundle, so its failure is the whole operation's.
for (const name of selected) {
try {
const resolved = resolveTaskReadBundle(undefined, name);
const stashDir = resolved.source.path;
const adapterId = resolved.source.adapterId ?? detectAdapterId(stashDir);
const compiled = await compileSchedulerSources({
sourceRoot: stashDir,
adapterId,
bundleName: resolved.source.name,
backend: sched.name,
config,
resolveAsset: taskProjectionAssetResolver(config, resolved.source.name, stashDir),
enabledRefs: enabledRefSet,
});
desired.push(...compiled.desired);
failures.push(...compiled.failures);
for (const failure of compiled.failures)
if (failure.ref)
keepRefs.add(failure.ref);
scopes.push({ ...bundleScope(resolved.source.name, stashDir), adapterId });
}
catch (cause) {
if (bundleTarget)
throw cause;
failures.push({ path: name ?? "(default bundle)", reason: errorMessage(cause) });
}
}
// An enabled ref of a synced bundle that produced nothing names a source
// that is gone or has no schedule; its row, if any, is removed below.
const represented = new Set([...desired.map((binding) => binding.logicalSource.ref), ...keepRefs]);
const synced = new Set(scopes.map((scope) => scope.bundleName));
for (const ref of enabledRefs) {
const bundle = parseBundleRef(ref).bundle;
if (bundle !== undefined && synced.has(bundle) && !represented.has(ref)) {
failures.push({ path: ref, ref, reason: `Enabled ref ${JSON.stringify(ref)} was not found or has no schedule.` });
}
}
// A disabled bundle's rows go on an unscoped sync, without reading its content.
const inactive = new Set(configuredSources.filter((source) => source.enabled === false).map((source) => source.name));
const extraRemovals = bundleTarget
? []
: installed.filter((row) => {
const bundle = installedSchedulerBundle(config, row);
return bundle !== undefined && inactive.has(bundle);
});
const runtime = prepareSchedulerRuntime(deps);
const plan = planSchedulerSync({
desired,
installed,
scopes,
...(sched.expectedSignature ? { expectedSignature: sched.expectedSignature.bind(sched) } : {}),
installOptions: runtime.options,
rebind: options.rebind === true,
extraRemovals,
keepRefs,
});
const warnings = [];
const writesLauncher = options.rebind === true
? plan.operations.some((operation) => operation.kind !== "remove")
: plan.installed.length > 0;
if (runtime.via === "checkout" && writesLauncher) {
warnings.push(`Scheduled tasks now run akm from a source checkout (${runtime.options.binding?.join(" ")}); they run whatever the checkout holds when they fire. Install akm with \`npm install --global akm-cli\` or a standalone release, then run \`akm task sync --rebind\`.`);
}
return { sched, plan: { ...plan, failures: [...failures, ...plan.failures] }, warnings };
}
/**
* The launcher and environment rows are written with. The environment
* follows the current policy on every sync; a row that is already installed
* keeps its launcher unless `--rebind` (see `installOptionsFor`). An injected
* backend (tests) renders with its own launcher.
*/
function prepareSchedulerRuntime(deps) {
const environment = scheduledRowEnvironment();
if (deps.schedulerRuntime) {
const runtime = deps.schedulerRuntime();
return { options: { binding: runtime.binding, environment }, ...(runtime.via ? { via: runtime.via } : {}) };
}
if (deps.backend)
return { options: { environment } };
const invocation = resolveAkmInvocation();
return { options: { binding: invocation.argv, environment }, via: invocation.via };
}
/** One read of the akm-owned rows, each attributed to the bundle path it names (#846). */
async function listInstalledRows(sched) {
return (await sched.list()).map((row) => {
const ownerBundlePath = installedRowBundleDir(row);
return ownerBundlePath !== undefined ? { ...row, ownerBundlePath } : row;
});
}
/**
* The `AKM_BUNDLE_DIR` an installed row names: its inline one, or the one in
* the descriptor a row written before 0.9.17-alpha.7 references. Undefined
* when it names none, or its descriptor cannot be read — which never means
* "mine".
*/
function installedRowBundleDir(row) {
if (row.contextPath === undefined)
return row.environment?.AKM_BUNDLE_DIR;
try {
return readLegacySchedulerContext(row.contextPath).AKM_BUNDLE_DIR;
}
catch {
return undefined;
}
}
/** The primary bundle proves its rows by path (#846); any other bundle by its config name. */
function bundleScope(bundleName, stashDir) {
return isPrimaryStashPath(stashDir) ? { bundleName, bundlePath: path.resolve(stashDir) } : { bundleName };
}
function installedSchedulerBundle(config, row) {
const direct = row.target ?? scheduledInvocationBundle(row.invocation);
if (direct !== undefined)
return direct;
if (row.ownerBundlePath === undefined)
return undefined;
return bundleKeyForContentRoot(config, row.ownerBundlePath);
}
/**
* Why `akm task prune` (#851) would remove an installed row: a row written
* before 0.9.17-alpha.7 whose descriptor cannot be read (`invalid-context`),
* or one naming a bundle directory that is gone (`dead-bundle-path`). A row
* that still resolves to a live bundle is never a candidate.
*/
function classifyPruneCandidate(entry) {
let bundleDir;
if (entry.contextPath !== undefined) {
try {
bundleDir = readLegacySchedulerContext(entry.contextPath).AKM_BUNDLE_DIR;
}
catch {
return "invalid-context";
}
// Every descriptor akm wrote names the bundle; one that does not cannot be attributed.
if (bundleDir === undefined)
return "invalid-context";
}
else {
bundleDir = entry.environment?.AKM_BUNDLE_DIR;
}
if (bundleDir !== undefined && !fs.existsSync(bundleDir))
return "dead-bundle-path";
return undefined;
}
/**
* `akm task prune` (#851): remove installed rows `sync` can never reclaim
* because they no longer resolve to a live bundle. It scans
* every installed row, not one bundle's. Without `--yes` it only previews and
* writes nothing; `--id` narrows it to named candidates.
*/
export async function akmTasksPrune(deps = {}, options = {}) {
const prune = async () => {
const sched = deps.backend ?? selectBackend();
const operations = pruneOperations(await sched.list(), options.id ?? []);
const preview = renderSchedulerPlanPreview(sched.name, operations);
if (!options.yes)
return { backend: sched.name, dryRun: true, preview, removed: [] };
for (const operation of operations)
await sched.uninstall(operation.nativeId);
return { backend: sched.name, dryRun: false, preview, removed: operations.map((operation) => operation.id) };
};
return options.yes ? withSchedulerLock(prune) : prune();
}
function pruneOperations(installed, requestedIds) {
const candidates = installed.flatMap((row) => {
const reason = classifyPruneCandidate(row);
return reason ? [{ row, reason }] : [];
});
const ids = requestedIds.filter((id) => id.length > 0);
for (const id of ids) {
if (!candidates.some(({ row }) => row.id === id)) {
throw new UsageError(`Scheduler binding ${JSON.stringify(id)} is not an orphaned prune candidate ` +
"(either not installed, or it still resolves to a live bundle) — refusing to prune it.", "INVALID_FLAG_VALUE");
}
}
return candidates
.filter(({ row }) => ids.length === 0 || ids.includes(row.id))
.map(({ row, reason }) => ({ kind: "remove", id: row.id, nativeId: installedRowNativeId(row), reason }));
}
export async function akmTasksDoctor(deps = {}) {
const warnings = [];
let invocation = { argv: [], via: "unresolved" };
try {
const r = (deps.resolveInvocation ?? resolveAkmInvocation)();
invocation = { argv: r.argv, via: r.via };
}
catch (err) {
warnings.push(err instanceof Error ? err.message : String(err));
}
const skipNativeInspection = process.env.BUN_TEST === "1" && !deps.backend;
const sched = deps.backend ?? (skipNativeInspection ? undefined : selectBackend());
const backend = sched?.name ?? backendNameForPlatform();
let installed = [];
if (skipNativeInspection) {
warnings.push("Native scheduler inspection is skipped inside the bun test harness.");
}
else {
try {
installed = await sched.list();
}
catch (error) {
warnings.push(`Unable to inspect installed ${backend} definitions: ${error instanceof Error ? error.message : String(error)}`);
}
}
const bindings = groupInstalledBindings(installed);
// Report the EFFECTIVE engine view — the same one the runner resolves —
// so doctor never says "no engine" on an install where tasks actually run.
const { config } = withEngineFallback(loadConfig());
const defaultEngine = config.defaults?.engine;
const engines = Object.keys(config.engines ?? {});
// §6.1: surface the effective triage settings for the default improve
// strategy. The struct is a fixed shape, so this is a deliberate addition.
const improveStrategyName = typeof config.defaults?.improveStrategy === "string" ? config.defaults.improveStrategy : "default";
// D8 — report the EFFECTIVE strategy, not the raw one. Resolving the raw
// strategy here would report `applyMode: "promote"` for a promote strategy
// under a review-first config, while the run actually uses "queue" — a doctor
// command lying about the thing it exists to diagnose.
const rawStrategy = resolveImproveStrategy(config.defaults?.improveStrategy, config).config;
const { config: effectiveStrategy, gated } = applyAutonomyGate(rawStrategy, config);
const autonomyEnabled = isImproveAutonomyEnabled(config);
// Memory cleanup has no strategy flag to downgrade, so add that direct lane
// to the strategy-derived gate report.
const allGated = autonomyEnabled ? [] : [...gated, ...describeGatedLanes(configuredDirectAutonomyLanes())];
const improveAutonomy = {
enabled: autonomyEnabled,
configKey: IMPROVE_AUTONOMY_CONFIG_KEY,
gatedLanes: allGated.map((entry) => ({ lane: entry.lane, reason: entry.reason })),
};
const triage = effectiveStrategy.processes?.triage;
const improveTriage = triage
? {
defaultStrategy: improveStrategyName,
enabled: triage.enabled === true,
applyMode: triage.applyMode ?? "queue",
}
: undefined;
return {
backend,
akm: invocation,
caller: invocation,
bindings,
...(bindings.some((binding) => !binding.status.includes("ok"))
? { remediation: "akm task sync --rebind" }
: {}),
logDir: getTaskLogDir(),
historyDir: getTaskHistoryDir(),
engine: { defaultEngine, available: engines },
scheduleSubset: SCHEDULE_SUPPORTED_SUBSET_HINT,
warnings,
improveAutonomy,
...(improveTriage ? { improveTriage } : {}),
};
}
// ── helpers ─────────────────────────────────────────────────────────────────
function groupInstalledBindings(entries) {
const groups = new Map();
for (const entry of entries) {
const argv = [...entry.binding];
const status = inspectInstalledBinding(entry);
const key = JSON.stringify([argv, entry.contextPath, status]);
const existing = groups.get(key);
if (existing) {
existing.taskIds.push(entry.id);
continue;
}
groups.set(key, {
argv,
...(entry.contextPath !== undefined ? { contextPath: entry.contextPath } : {}),
taskIds: [entry.id],
status,
});
}
return [...groups.values()].map((group) => ({ ...group, taskIds: group.taskIds.sort() }));
}
function inspectInstalledBinding(entry) {
const status = [];
const binding = entry.binding;
if (isCheckoutInvocation(binding))
status.push("checkout");
if (binding.some((part) => part === "akm" || part === "bun" || part === "node"))
status.push("path-selected");
if (entry.contextPath !== undefined) {
try {
readLegacySchedulerContext(entry.contextPath);
}
catch {
status.push("invalid-context");
}
}
const absolutePaths = binding.filter((part) => path.isAbsolute(part));
if (absolutePaths.some((part) => !fs.existsSync(part)))
status.push("missing-path");
if (status.length === 0)
status.push("ok");
return status;
}
function taskAssetRef(id) {
return { type: "task", name: id };
}
/** The bundle `task add` writes into: its write target, config, stash path, and name. */
function resolveTaskBundle(target) {
const config = loadConfig();
const resolved = prepareWriteTargetForMutation(resolveWriteTarget(config, target, { requireWritable: true, flag: "--bundle" }));
return { resolved, config, stashDir: resolved.source.path, bundleName: resolved.source.name };
}
function taskProjectionAssetResolver(config, bundleName, bundleRoot) {
return async ({ bundle, type, name }) => {
if (bundle === bundleName) {
return { file: await resolveAssetPath(bundleRoot, type, name), bundleRoot };
}
// `bundle` is the qualifier of an asset ref in the task, not a flag.
const target = resolveWriteTarget(config, bundle, { requireWritable: false, flag: "The asset ref's bundle" });
return {
file: await resolveAssetPath(target.source.path, type, name),
bundleRoot: target.source.path,
};
};
}
/**
* Exported for `src/commands/tasks/explain.ts` (P2b Lane B, spec
* docs/plans/specs/p2b-input-bindings.md §4.5, B-N4): `akm task explain`
* resolves its `--bundle` axis identically to every other read-only task
* verb here (`akm task history`, `akm task run`) — the SAME resolver, not a
* second one.
*/
export function resolveTaskReadBundle(refBundle, flagBundle) {
if (refBundle && flagBundle && refBundle !== flagBundle) {
throw new UsageError(`Task ref selects bundle ${JSON.stringify(refBundle)}, but --bundle selects ${JSON.stringify(flagBundle)}.`, "INVALID_FLAG_VALUE");
}
const selector = flagBundle ?? refBundle;
const config = loadConfig();
let resolved;
if (!selector) {
resolved = resolveWorkingStashTarget(config, { requireWritable: false });
}
else {
const configured = resolveActiveConfiguredSources(config).some((source) => source.name === selector);
const implicit = configured ? undefined : resolveImplicitScheduledBundleTarget(config, selector);
resolved =
implicit ??
resolveWriteTarget(config, selector, {
requireWritable: false,
// The selector is `--bundle` when given, else the task ref's own bundle qualifier.
flag: flagBundle !== undefined ? "--bundle" : "The task ref's bundle",
});
}
if (refBundle && resolved.source.name !== refBundle) {
throw new UsageError(`Task ref bundle ${JSON.stringify(refBundle)} does not match the resolved source.`, "INVALID_FLAG_VALUE");
}
return resolved;
}
/**
* New scheduler bindings always carry a canonical `--bundle <owner>` token,
* including an env-selected working stash. That stash need not be persisted in
* config (CI, one-shot tools, and fresh installs commonly use only
* AKM_BUNDLE_DIR), so its scheduled child — every row sets AKM_BUNDLE_DIR to
* the working stash it was synced from — must accept precisely its derived
* owner name.
*
* This is intentionally narrower than an unknown-bundle fallback: a configured
* source always wins, and an unconfigured selector is accepted only when it is
* exactly the current env-selected working stash identity.
*/
function resolveImplicitScheduledBundleTarget(config, selector) {
if (!process.env.AKM_BUNDLE_DIR?.trim())
return undefined;
try {
const working = resolveWorkingStashTarget(config, { requireWritable: false });
return working.source.name === selector ? working : undefined;
}
catch {
return undefined;
}
}
/** True when `candidate` resolves to the same directory as the primary stash. */
function isPrimaryStashPath(candidate) {
let primary;
try {
primary = path.resolve(resolveStashDir());
}
catch {
return false;
}
return path.resolve(candidate) === primary;
}
function errorMessage(cause) {
return cause instanceof Error ? cause.message : String(cause);
}
/**
* Infer a JSON-Schema-subset `type` keyword from one `--params` value's
* runtime shape (spec docs/plans/specs/p4-deletions-closeout.md §3.2.6, row
* B-20). `null` is not one of the five runtime types the spec enumerates
* (string/number/boolean/object/array) but is a value JSON.parse can still
* produce for a param; `src/core/json-schema.ts`'s subset validator accepts
* `"null"` as a `type`, so it is handled rather than mis-typed.
*/
function jsonSchemaTypeOf(value) {
if (value === null)
return "null";
if (Array.isArray(value))
return "array";
return typeof value;
}
/**
* Render `--params` as typed `inputs:` declarations, one per key, each
* carrying a `default:` equal to the authored value and a `type:` inferred
* from its own JSON runtime shape (row B-20). Never emitted as `with:` —
* task source v4 accepts `with:` only on `uses: akm/command` (§3.2.6's
* `parseTarget`/`checkTopLevelKeys`), and a workflow target's declared
* inputs are what `load-task.ts`'s existing v4 delivery override binds into
* the child run's params.
*/
function renderInputsFromParams(params) {
const inputs = {};
for (const [name, value] of Object.entries(params)) {
inputs[name] = { type: jsonSchemaTypeOf(value), default: value };
}
return inputs;
}
function renderTaskYaml(input) {
const obj = { version: 4 };
if (input.workflow) {
obj.uses = input.workflow;
if (input.params)
obj.inputs = renderInputsFromParams(parseJsonObjectArg(input.params));
}
else if (input.prompt) {
obj.uses = "akm/command";
obj.with = { content: input.prompt };
}
else if (input.command !== undefined) {
if (Array.isArray(input.command)) {
throw new UsageError("--command accepts one shell string; argv arrays require manual migration.", "INVALID_FLAG_VALUE");
}
obj.run = input.command;
}
if (input.name)
obj.name = input.name;
if (input.description !== undefined)
obj.description = input.description;
if (input.when_to_use !== undefined)
obj.when_to_use = input.when_to_use;
if (input.tags && input.tags.length > 0)
obj.tags = input.tags;
if (input.engine !== undefined)
obj.engine = input.engine;
if (input.model !== undefined)
obj.model = input.model;
if (input.timeoutMs !== undefined)
obj.timeout = input.timeoutMs;
if (input.schedule.length > 0)
obj.schedule = input.schedule;
return yamlStringify(obj);
}
function assertInlineTaskPrompt(input) {
const value = input.trim();
const pathShaped = /^(?:\.{1,2}[\\/]|~[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(value) ||
(!/\s/.test(value) && /[\\/]/.test(value) && path.extname(value) !== "");
if (!isFullRefInput(value) && !pathShaped)
return;
warn(`--prompt "${input}" looks like an asset ref or file path; --prompt sends it as literal text, not a reference. Did you mean --workflow?`);
}
function parseJsonObjectArg(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
throw new UsageError("--params must be valid JSON.", "INVALID_JSON_ARGUMENT");
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new UsageError("--params must be a JSON object.", "INVALID_JSON_ARGUMENT");
}
return parsed;
}
// Re-exported so tests can verify the validator path directly.
// Re-export error classes consumed by callers that want to instanceof-check.
// Re-export this so the CLI can decide what process exit code to use after
// `akm task run` completes.
export { ConfigError, exitCodeForStatus, NotFoundError, UsageError };
// Parse only the bundle/ref syntax here. Whether a concept is a task is an
// adapter-specific decision made after resolving the selected bundle.
export function parseTaskRef(input) {
const trimmed = input.trim();
if (trimmed.includes("/")) {
try {
const parsed = parseBundleRef(trimmed);
if (parsed.fragment !== undefined)
throw new Error("task refs do not accept fragments");
return {
id: normaliseTaskConceptId(parsed.conceptId),
...(parsed.bundle ? { bundle: parsed.bundle } : {}),
};
}
catch {
// fall through to the shared error below
}
throw new UsageError(`Expected a syntactically valid task concept id, got "${input}".`, "INVALID_FLAG_VALUE");
}
return { id: normaliseTaskId(trimmed) };
}
/** Exported for `src/commands/tasks/explain.ts` — see {@link resolveTaskReadBundle}'s header. */
export function taskIdForAdapter(parsedId, adapterId) {
if (adapterId === "akm-task")
return normaliseTaskConceptId(parsedId);
if (adapterId === "akm") {
if (!parsedId.includes("/"))
return normaliseTaskId(parsedId);
if (parsedId.startsWith("tasks/") && !parsedId.slice("tasks/".length).includes("/")) {
return normaliseTaskId(parsedId.slice("tasks/".length));
}
throw new UsageError(`The native akm adapter accepts only a bare task id or tasks/<id>, got "${parsedId}".`, "INVALID_FLAG_VALUE");
}
throw new UsageError(`Bundle adapter "${adapterId}" does not define task runtime identity.`, "INVALID_FLAG_VALUE");
}