UNPKG

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
// 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"); }