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
864 lines (863 loc) • 37.1 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/.
/**
* Pure, byte-producing task-source migration planner: a v2, v3, or v4 task
* file straight to task source v4 (spec docs/plans/specs/p2b-input-bindings.md
* §1.3, §1.7 C-N1, §5). One planner, one outcome per file — the former
* two-generation chain (legacy task to v3, then v3 to v4, composed by
* `scripts/akm-migrate/migrate/task-files.ts`) is gone: a v2 file is read
* once and converted directly, with no intermediate v3 file ever written to
* disk or reported as its own outcome.
*
* A v3 document — real, or the v3-shape record a v2 file converts to in
* memory — is read as a raw record by a vendored bounded-YAML reader, never
* the typed `parseTaskV3Yaml`, which would normalize away exactly the value
* bytes this migrator must preserve (a duration string like "5m", or a bare
* numeric `timeout`, would be converted to milliseconds by the real parser).
* The one exception is a pre-validation gate: every v3-versioned document —
* real, or freshly built from v2 — is first checked against the REAL typed
* `parseTaskV3Yaml`, exactly as the prior two-generation chain did at each of
* its hops, so every blocked reason only the typed parser catches (an
* escaping `working-directory` symlink, a GitHub-expression schedule, an
* invalid builtin-command `with:` shape, and so on) still blocks here, with
* the same reason. The OUTPUT side is validated through the REAL
* `parseTaskSourceV4` before a "changed" outcome is ever handed back (C-N1,
* B-71).
*
* `inputs:` is never invented — the migrator translates structure, not
* intent (spec §5.3).
*/
import crypto from "node:crypto";
import path from "node:path";
import { isMap, isSeq, LineCounter, parseDocument, stringify as stringifyYaml } from "yaml";
import { bundleRefToString, parseBundleRef } from "../../core/asset/asset-ref.js";
import { formatExtraParamsIssue, validateExtraParams } from "../../core/extra-params.js";
import { WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_TIMEOUT_MS } from "../../workflows/resource-limits.js";
import { validateTaskId } from "../task-id.js";
import { assertBoundedTaskYamlDocument, TASK_V3_MAX_REDACT_NAMES } from "./bounded-document.js";
import { classifyTaskV3Uses, parseTaskV3Yaml } from "./task-source-v3-frozen.js";
import { parseTaskSourceV4 } from "./task-source-v4.js";
// ── v3 grammar (real v3 input, and the v3-shape record a v2 file builds) ────
/** The closed v3 top-level key set (`src/tasks/source-v3.ts`'s own, vendored — not exported there). */
const V3_TOP_LEVEL_KEYS = new Set([
"version",
"name",
"uses",
"run",
"with",
"env",
"shell",
"working-directory",
"akm",
"on",
]);
/** The closed v3 `akm.*` key set, vendored from `src/tasks/source-v3.ts`. */
const V3_AKM_KEYS = new Set([
"schedule",
"enabled",
"description",
"when_to_use",
"tags",
"agent",
"engine",
"model",
"inference",
"outputSchema",
"tools",
"timeout",
"redact",
"maxSteps",
"maxRetries",
]);
/** The closed v3 `on.*` key set, vendored from `src/tasks/source-v3.ts`. */
const V3_ON_KEYS = new Set(["schedule", "workflow_dispatch"]);
/** `akm.*` keys hoisted verbatim to the identical top-level v4 key (schedule/enabled handled separately). */
const AKM_HOIST_KEYS = [
"description",
"when_to_use",
"tags",
"agent",
"engine",
"model",
"inference",
"tools",
"timeout",
"redact",
"maxSteps",
"maxRetries",
];
// ── v2 grammar ────────────────────────────────────────────────────────────────
const V2_KEYS = new Set([
"version",
"name",
"description",
"when_to_use",
"tags",
"schedule",
"enabled",
"workflow",
"prompt",
"command",
"params",
"engine",
"model",
"timeoutMs",
"maxSteps",
"maxRetries",
"llm",
"redact",
]);
const V2_SHARED_KEYS = new Set([
"version",
"name",
"description",
"when_to_use",
"tags",
"schedule",
"enabled",
"redact",
]);
const V2_LLM_KEYS = new Set([
"temperature",
"maxTokens",
"supportsJsonSchema",
"extraParams",
"contextLength",
"enableThinking",
"reasoningEffort",
]);
const SAFE_V2_COMMAND_TOKEN = /^[A-Za-z0-9_./:=+,-]+$/;
const SHELL_ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
/**
* V2 executed argv directly, while v3 `run:` enters a host shell. An explicit
* path bypasses shell aliases/builtins; `akm` is the one bare executable whose
* v3 runtime resolution is contractually pinned to the current installation.
*/
function shellStableV2Executable(executable) {
return executable === "akm" || executable.includes("/");
}
/**
* `env NAME=value... cmd args...` is env(1) itself resolving and exec'ing
* `cmd` via its own PATH search — that lookup happens inside env's execvp()
* regardless of whether env was launched by direct execve (v2) or by a host
* shell (v3 `run:`). The shell-vs-argv divergence `shellStableV2Executable`
* guards against (bare names shadowed by shell aliases/builtins/functions)
* therefore does not apply to whatever env ultimately invokes, so skip past
* a leading `env` and its `NAME=value` assignments to find the real target.
* Returns the original tokens, unchanged, when there is no such target
* (e.g. `env` with nothing after its assignments).
*/
function skipEnvAssignmentPrefix(tokens) {
if (tokens[0] !== "env")
return { tokens, envWrapped: false };
let index = 1;
while (index < tokens.length && SHELL_ASSIGNMENT_WORD.test(tokens[index]))
index += 1;
if (index >= tokens.length)
return { tokens, envWrapped: false };
return { tokens: tokens.slice(index), envWrapped: true };
}
const KNOWN_PROMPT_REF_FAMILIES = new Set([
"agents",
"commands",
"env",
"facts",
"instructions",
"knowledge",
"lessons",
"memories",
"scripts",
"secrets",
"sessions",
"skills",
"tasks",
"workflows",
]);
/**
* #902: the one blocker with an unambiguous remedy. The sibling shell-safety
* reasons need a case-by-case judgement and stay reason-only.
*/
const ARGV_ARRAY_BLOCK_DETAIL = "Manual conversion required: an array `command:` has no safe v3 `run:` string. Rewrite it by hand as " +
"`run:` (string) plus `shell:` — see docs/migration/v0.9.1-to-v0.9.2.md for the full v2 to v4 field mapping.";
// ── shared helpers ────────────────────────────────────────────────────────────
function hash(bytes) {
return crypto.createHash("sha256").update(bytes).digest("hex");
}
function causeMessage(cause) {
return cause instanceof Error ? cause.message : String(cause);
}
function base(input) {
return {
filePath: input.filePath,
before: Buffer.from(input.bytes),
beforeHash: hash(input.bytes),
mode: input.mode,
writable: input.writable,
...(input.onDiskWritable !== undefined ? { onDiskWritable: input.onDiskWritable } : {}),
...(input.containmentRoot ? { containmentRoot: input.containmentRoot } : {}),
};
}
function blocked(input, reason, detail) {
return Object.freeze({ status: "blocked", ...base(input), reason, ...(detail ? { detail } : {}) });
}
function plainRecord(value, label) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be a mapping`);
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new Error(`${label} must use a plain or null prototype`);
}
return value;
}
function exactString(value, label, nonempty = false) {
if (typeof value !== "string" || (nonempty && value.trim().length === 0)) {
throw new Error(`${label} must be ${nonempty ? "a non-empty " : "a "}string`);
}
return value;
}
function optionalString(value, label) {
if (value === undefined || value === null)
return undefined;
return exactString(value, label);
}
/**
* Bounded-YAML raw-record reader shared by every version and every
* generation (v2 grammar, real v3, and the v3-shape record a v2 file
* builds): reading the RAW decoded record — rather than a typed parser —
* keeps every field's original value bytes (a duration string, a bare
* millisecond integer, an env value's exact type) intact for verbatim
* re-emission; a typed parse would normalize several of these away (C-N1).
* Which grammar applies to the result is entirely up to the caller, decided
* after `data.version` is known.
*/
function parseRawTaskYaml(input) {
let source;
try {
source = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(input.bytes);
}
catch {
throw new Error("task YAML contains invalid UTF-8 bytes");
}
const lineCounter = new LineCounter();
let document;
try {
document = parseDocument(source, { lineCounter, uniqueKeys: true });
}
catch (cause) {
throw new Error(`invalid YAML: ${causeMessage(cause)}`);
}
const [parseError] = document.errors;
if (parseError)
throw new Error(`invalid YAML: ${parseError.message.split("\n")[0]}`);
const [parseWarning] = document.warnings;
if (parseWarning)
throw new Error(`unsupported YAML construct: ${parseWarning.message}`);
assertBoundedTaskYamlDocument(document, {
filePath: input.filePath,
sourceLabel: "task migration source",
lineCounter,
});
return { data: plainRecord(document.toJS({ maxAliasCount: 0 }), "task YAML"), source };
}
/** Convert one already-validated v3 raw record (real or v2-derived) to final task source v4 bytes. */
function planV3DataToV4(input, data) {
const unknownTop = Object.keys(data).filter((key) => !V3_TOP_LEVEL_KEYS.has(key));
if (unknownTop.length > 0) {
return blocked(input, "invalid-v3-task", `unknown v3 field(s): ${unknownTop.join(", ")}`);
}
const hasUses = Object.hasOwn(data, "uses");
const hasRun = Object.hasOwn(data, "run");
if (hasUses === hasRun) {
return blocked(input, "invalid-v3-task", "requires exactly one executable selector: uses or run");
}
let akm;
if (Object.hasOwn(data, "akm")) {
try {
akm = plainRecord(data.akm, "akm");
}
catch (cause) {
return blocked(input, "invalid-v3-task", causeMessage(cause));
}
const unknownAkm = Object.keys(akm).filter((key) => !V3_AKM_KEYS.has(key));
if (unknownAkm.length > 0) {
return blocked(input, "unrecognized-akm-member", `akm has unknown field(s): ${unknownAkm.join(", ")}`);
}
if (Object.hasOwn(akm, "enabled") && typeof akm.enabled !== "boolean") {
return blocked(input, "invalid-v3-task", "akm.enabled must be a boolean");
}
if (Object.hasOwn(akm, "schedule") && typeof akm.schedule !== "string") {
return blocked(input, "invalid-v3-task", "akm.schedule must be a string");
}
}
const hasOn = Object.hasOwn(data, "on");
let onRecord;
if (hasOn) {
try {
onRecord = plainRecord(data.on, "on");
}
catch (cause) {
return blocked(input, "invalid-v3-task", causeMessage(cause));
}
// Mirrors the frozen v3 reader's own `parseOn` gates exactly
// (task-source-v3-frozen.ts:395-427): an empty `on: {}` declares no
// trigger at all, and `on.workflow_dispatch` accepts only null or an
// empty mapping (inputs are unsupported in v3). The frozen parser
// rejects both shapes outright, so this migrator must too — translating
// a document the v3 oracle would refuse to parse into runnable v4 bytes
// would launder invalid input into a valid, schedule-less task.
if (Object.keys(onRecord).length === 0) {
return blocked(input, "invalid-v3-task", "on must declare schedule and/or workflow_dispatch.");
}
const unknownOn = Object.keys(onRecord).filter((key) => !V3_ON_KEYS.has(key));
if (unknownOn.length > 0) {
return blocked(input, "invalid-v3-task", `on has unknown field(s): ${unknownOn.join(", ")}`);
}
if (Object.hasOwn(onRecord, "workflow_dispatch") && onRecord.workflow_dispatch !== null) {
let dispatchMapping;
try {
dispatchMapping = plainRecord(onRecord.workflow_dispatch, "on.workflow_dispatch");
}
catch (cause) {
return blocked(input, "invalid-v3-task", causeMessage(cause));
}
if (Object.keys(dispatchMapping).length > 0) {
return blocked(input, "invalid-v3-task", "on.workflow_dispatch must be null or an empty mapping; inputs are unsupported.");
}
}
}
const hasAkmSchedule = akm !== undefined && Object.hasOwn(akm, "schedule");
if (hasAkmSchedule && hasOn) {
return blocked(input, "ambiguous-scheduling-source", "declares both akm.schedule and on:; task v3 requires exactly one scheduling source and the migrator will not guess which one wins.");
}
if (!hasAkmSchedule && !hasOn) {
return blocked(input, "invalid-v3-task", "requires exactly one scheduling source: akm.schedule or on.");
}
const hasWith = Object.hasOwn(data, "with");
let usesTarget;
if (hasUses) {
let usesValue;
try {
usesValue = exactString(data.uses, "uses", true);
}
catch (cause) {
return blocked(input, "invalid-v3-task", causeMessage(cause));
}
try {
usesTarget = classifyTaskV3Uses(usesValue);
}
catch (cause) {
return blocked(input, "invalid-v3-task", causeMessage(cause));
}
if (usesTarget.kind === "github-action") {
return blocked(input, "github-action-target-removed", `"${usesValue}" is a github-action target; the github-action uses: variant was removed in task source v4. Use commands/, scripts/, workflows/, or akm/command instead.`);
}
if (hasWith && usesTarget.kind !== "builtin-command") {
return blocked(input, "with-on-non-command-target", `a with: block on "${usesValue}" (a non-akm/command target) has no task source v4 equivalent; task-call inputs are declared and bound separately.`);
}
}
else if (hasWith) {
return blocked(input, "invalid-v3-task", "with is legal only with uses");
}
if (!input.writable || input.onDiskWritable === false) {
return blocked(input, "read-only-source", !input.writable ? "the owning source is not writable" : "the source file or publication directory is read-only");
}
let scheduleField;
// Several independent translation facts can need reporting on the SAME
// file (a manual-only trigger AND a dropped output schema, say), so
// notices accumulate and are joined into the single `notice` string the
// outcome carries.
const notices = [];
if (hasAkmSchedule) {
const cron = akm.schedule;
scheduleField = cron;
}
else {
const rawSchedule = onRecord !== undefined && Object.hasOwn(onRecord, "schedule") ? onRecord.schedule : undefined;
if (rawSchedule !== undefined) {
if (!Array.isArray(rawSchedule) || rawSchedule.length === 0) {
return blocked(input, "invalid-v3-task", "on.schedule must be a non-empty list of {cron} records");
}
const crons = [];
for (const entry of rawSchedule) {
let record;
try {
record = plainRecord(entry, "on.schedule[]");
}
catch (cause) {
return blocked(input, "invalid-v3-task", causeMessage(cause));
}
const keys = Object.keys(record);
if (keys.length !== 1 || keys[0] !== "cron" || typeof record.cron !== "string" || record.cron.length === 0) {
return blocked(input, "invalid-v3-task", "each on.schedule entry must be exactly {cron: <non-empty string>}");
}
crons.push(record.cron);
}
scheduleField = crons.map((cron) => ({ cron }));
}
else {
notices.push("schedule: is absent from the migrated document — the source's only trigger was on.workflow_dispatch (manual dispatch); task source v4 tasks are always runnable manually via `akm task run`, so no schedule: entry was emitted.");
}
}
const out = { version: 4 };
if (Object.hasOwn(data, "name"))
out.name = data.name;
if (hasUses)
out.uses = data.uses;
else
out.run = data.run;
if (Object.hasOwn(data, "shell"))
out.shell = data.shell;
if (hasWith)
out.with = data.with;
if (Object.hasOwn(data, "env"))
out.env = data.env;
if (Object.hasOwn(data, "working-directory"))
out["working-directory"] = data["working-directory"];
if (scheduleField !== undefined)
out.schedule = scheduleField;
if (akm) {
for (const key of AKM_HOIST_KEYS) {
if (Object.hasOwn(akm, key))
out[key] = akm[key];
}
// v3's `akm.outputSchema: null` means "no schema" (accepted verbatim by
// the frozen v3 reader, task-source-v3-frozen.ts:256-258); v4's
// `output:` has no null form (parseOutputSchema always requires a
// mapping). Omitting the key is the faithful v4 equivalent of an
// explicit v3 null — emitting `output: null` would fail the real
// parseTaskSourceV4 validation below and block the whole file.
if (Object.hasOwn(akm, "outputSchema") && akm.outputSchema !== null) {
// v4 accepts `output:` ONLY on a command target — `uses: commands/<ref>`
// or `uses: akm/command` (src/tasks/source/task-source-v4.ts's
// `targetConsumesOutputSchema`). v3 enforced no such rule: the frozen v3
// reader accepts `akm.outputSchema` on ANY target kind
// (task-source-v3-frozen.ts:256-263), and on `run:`/`uses: scripts/`/
// `uses: workflows/` it was equally inert there — nothing ever consumed
// it. Hoisting it unconditionally would therefore emit bytes the real
// parseTaskSourceV4 below rejects, blocking a valid, previously-runnable
// v3 file from `akm migrate apply`
// (scripts/akm-migrate/migrate/task-files.ts). Dropping an
// already-inert field and SAYING SO is the faithful translation, and
// keeps spec row B-66 / §5.3's `changed` guarantee intact.
if (usesTarget !== undefined && (usesTarget.kind === "command" || usesTarget.kind === "builtin-command")) {
out.output = akm.outputSchema;
}
else {
const targetLabel = usesTarget === undefined ? "a run: target" : `the "${usesTarget.ref}" target`;
notices.push(`akm.outputSchema was dropped rather than hoisted to output: — task source v4 accepts output: only with a command target (uses: commands/<ref> or uses: akm/command), and ${targetLabel} never consumed the schema in v3 either, so nothing enforceable was lost.`);
}
}
}
const afterYaml = stringifyYaml(out);
const after = Buffer.from(afterYaml, "utf8");
try {
parseTaskSourceV4({
yaml: afterYaml,
filePath: input.filePath,
...(input.containmentRoot ? { workspaceRoot: input.containmentRoot } : {}),
});
}
catch (cause) {
return blocked(input, "generated-v4-validation-failed", causeMessage(cause));
}
const notice = notices.join(" ");
return Object.freeze({
status: "changed",
...base(input),
reason: "task-converted",
after,
afterHash: hash(after),
...(notice ? { notice } : {}),
});
}
// ── v2 → v3-shape record ───────────────────────────────────────────────────
function validateCommonV2(data) {
const unknown = Object.keys(data).filter((key) => !V2_KEYS.has(key));
if (unknown.length > 0)
throw new Error(`unknown v2 field(s): ${unknown.join(", ")}`);
exactString(data.schedule, "schedule", true);
if (data.enabled !== undefined && typeof data.enabled !== "boolean")
throw new Error("enabled must be a boolean");
for (const key of ["name", "description", "when_to_use"])
optionalString(data[key], key);
if (data.tags !== undefined && data.tags !== null) {
if (!Array.isArray(data.tags) || data.tags.some((entry) => typeof entry !== "string" || entry.length === 0)) {
throw new Error("tags must be an array of non-empty strings");
}
}
if (data.timeoutMs !== undefined && data.timeoutMs !== null) {
if (!Number.isInteger(data.timeoutMs) ||
data.timeoutMs < 1 ||
data.timeoutMs > WORKFLOW_MAX_TIMEOUT_MS) {
throw new Error(`timeoutMs must be null or an integer from 1 through ${WORKFLOW_MAX_TIMEOUT_MS}`);
}
}
if (data.redact !== undefined && data.redact !== null) {
if (!Array.isArray(data.redact) ||
data.redact.length > TASK_V3_MAX_REDACT_NAMES ||
data.redact.some((entry) => typeof entry !== "string" || !WORKFLOW_ENV_VAR_NAME_PATTERN.test(entry))) {
throw new Error("redact must contain only bounded environment variable names");
}
}
}
function validateTargetFields(data, allowed) {
const targetFields = new Set([...allowed, "workflow", "prompt", "command"]);
const invalid = Object.keys(data).filter((key) => !V2_SHARED_KEYS.has(key) && !targetFields.has(key));
if (invalid.length > 0)
throw new Error(`field(s) not valid for this target: ${invalid.join(", ")}`);
}
function validateV2Llm(value) {
if (value === undefined)
return undefined;
const llm = plainRecord(value, "llm");
const unknown = Object.keys(llm).filter((key) => !V2_LLM_KEYS.has(key));
if (unknown.length > 0)
throw new Error(`llm has unknown field(s): ${unknown.join(", ")}`);
if (llm.temperature !== undefined && (typeof llm.temperature !== "number" || !Number.isFinite(llm.temperature))) {
throw new Error("llm.temperature must be a finite number");
}
for (const key of ["maxTokens", "contextLength"]) {
if (llm[key] !== undefined && (!Number.isInteger(llm[key]) || llm[key] <= 0)) {
throw new Error(`llm.${key} must be a positive integer`);
}
}
for (const key of ["supportsJsonSchema", "enableThinking"]) {
if (llm[key] !== undefined && typeof llm[key] !== "boolean")
throw new Error(`llm.${key} must be a boolean`);
}
if (llm.reasoningEffort !== undefined && (typeof llm.reasoningEffort !== "string" || !llm.reasoningEffort.trim())) {
throw new Error("llm.reasoningEffort must be a non-empty string");
}
if (llm.extraParams !== undefined) {
const issue = validateExtraParams(llm.extraParams)[0];
if (issue)
throw new Error(formatExtraParamsIssue("llm.extraParams", issue));
}
return llm;
}
function commonAkm(data) {
const akm = {
schedule: exactString(data.schedule, "schedule", true),
enabled: data.enabled === undefined ? true : data.enabled,
};
for (const key of ["description", "when_to_use", "tags"]) {
if (data[key] !== undefined && data[key] !== null)
akm[key] = data[key];
}
return akm;
}
function addRuntimeOverrides(data, akm) {
for (const key of ["engine", "model"]) {
const value = optionalString(data[key], key);
if (value)
akm[key] = value;
}
const llm = validateV2Llm(data.llm);
if (llm !== undefined)
akm.inference = llm;
if (data.timeoutMs !== undefined)
akm.timeout = data.timeoutMs;
if (data.redact !== undefined && data.redact !== null) {
akm.redact = [...new Set(data.redact)];
}
}
function addSharedNonPromptOverrides(data, akm) {
if (data.timeoutMs !== undefined)
akm.timeout = data.timeoutMs;
if (data.redact !== undefined && data.redact !== null) {
akm.redact = [...new Set(data.redact)];
}
}
function promptSourceKind(raw) {
const trimmed = raw.trim();
if (trimmed.startsWith("./") ||
trimmed.startsWith("../") ||
path.isAbsolute(trimmed) ||
/^[A-Za-z]:[\\/]/.test(trimmed)) {
return "file";
}
try {
const parsed = parseBundleRef(trimmed);
const family = parsed.conceptId.split("/", 1)[0] ?? "";
if (bundleRefToString(parsed) !== trimmed || !parsed.conceptId.includes("/")) {
return "inline";
}
if (!KNOWN_PROMPT_REF_FAMILIES.has(family))
return parsed.bundle === undefined ? "inline" : "other-ref";
if (family === "agents")
return "agent";
if (family === "commands")
return "command";
return "other-ref";
}
catch {
return "inline";
}
}
/**
* Convert one already-normalized legacy record to a v3-shape record, in
* memory — never written to disk, never reported as its own outcome. Returns
* a blocked reason string in place of the record when the v2 document has no
* safe v4 representation.
*/
function migratedObject(data) {
validateCommonV2(data);
const targets = ["workflow", "prompt", "command"].filter((key) => Object.hasOwn(data, key) && data[key] !== null && data[key] !== "");
if (targets.length !== 1)
throw new Error("v2 task must declare exactly one of workflow, prompt, or command");
const output = { version: 3 };
if (data.name !== undefined && data.name !== null)
output.name = data.name;
const akm = commonAkm(data);
if (targets[0] === "workflow") {
validateTargetFields(data, ["params", "timeoutMs", "maxSteps", "maxRetries"]);
const ref = exactString(data.workflow, "workflow", true).trim();
let target;
try {
target = classifyTaskV3Uses(ref);
}
catch {
throw new Error("workflow is not a canonical v3 asset ref");
}
if (target.kind !== "workflow")
throw new Error("workflow target is not a workflows/ ref");
output.uses = ref;
if (data.params !== undefined && data.params !== null) {
const params = plainRecord(data.params, "params");
output.with = params;
}
if (data.maxSteps !== undefined && data.maxSteps !== null) {
if (!Number.isSafeInteger(data.maxSteps) || data.maxSteps < 1)
throw new Error("maxSteps must be positive");
akm.maxSteps = data.maxSteps;
}
if (data.maxRetries !== undefined && data.maxRetries !== null) {
if (!Number.isSafeInteger(data.maxRetries) || data.maxRetries < 0) {
throw new Error("maxRetries must be a non-negative integer");
}
akm.maxRetries = data.maxRetries;
}
addSharedNonPromptOverrides(data, akm);
}
else if (targets[0] === "prompt") {
validateTargetFields(data, ["engine", "model", "timeoutMs", "llm"]);
const prompt = exactString(data.prompt, "prompt", true).trim();
const kind = promptSourceKind(prompt);
if (kind === "file")
return "dynamic-file-read-cannot-be-inlined-without-changing-semantics";
if (kind === "agent")
return "agent-ref-has-persona-but-no-command-work";
if (kind === "other-ref")
return "non-command-asset-has-no-v3-command-ref-equivalent";
if (kind === "command")
output.uses = prompt;
else {
output.uses = "akm/command";
output.with = { content: prompt };
}
addRuntimeOverrides(data, akm);
}
else {
validateTargetFields(data, ["timeoutMs"]);
if (Array.isArray(data.command))
return "argv-array-has-no-portable-shell-string";
const command = exactString(data.command, "command", true).trim();
if (/['"\\]/.test(command))
return "shell-quoting-changes-v2-whitespace-split-semantics";
const tokens = command.split(/\s+/).filter(Boolean);
if (tokens.length === 0 || tokens.some((token) => !SAFE_V2_COMMAND_TOKEN.test(token))) {
return "shell-operators-change-v2-literal-argv-semantics";
}
const { tokens: targetTokens, envWrapped } = skipEnvAssignmentPrefix(tokens);
const executable = targetTokens[0];
if (SHELL_ASSIGNMENT_WORD.test(executable) || (!envWrapped && !shellStableV2Executable(executable))) {
return "shell-command-resolution-changes-v2-literal-argv-semantics";
}
output.run = tokens.join(" ");
addSharedNonPromptOverrides(data, akm);
}
output.akm = akm;
return output;
}
function isReason(value) {
return typeof value === "string";
}
/**
* v2 straight to v4: build the v3-shape record in memory, validate it
* through the REAL typed v3 parser exactly as the prior v2-to-v3 generation
* did (the same safety net, now inline), then hoist it to v4 through the
* same `planV3DataToV4` every real v3 file goes through. `before`/
* `beforeHash` on the result are always the original v2 bytes (`base(input)`,
* computed from the untouched `input`).
*/
function planV2DataToV4(input, data) {
if (!input.writable || input.onDiskWritable === false) {
return blocked(input, "read-only-source", !input.writable ? "the owning source is not writable" : "the source file or publication directory is read-only");
}
try {
validateTaskId(path.basename(input.filePath, ".yml"));
}
catch (cause) {
return blocked(input, "invalid-v2-task", causeMessage(cause));
}
let migrated;
try {
migrated = migratedObject(data);
}
catch (cause) {
return blocked(input, "invalid-v2-task", causeMessage(cause));
}
if (isReason(migrated)) {
const detail = migrated === "argv-array-has-no-portable-shell-string" ? ARGV_ARRAY_BLOCK_DETAIL : undefined;
return blocked(input, migrated, detail);
}
const v3Yaml = stringifyYaml(migrated);
try {
parseTaskV3Yaml({
yaml: v3Yaml,
filePath: input.filePath,
...(input.containmentRoot ? { workspaceRoot: input.containmentRoot } : {}),
});
}
catch (cause) {
return blocked(input, "generated-v3-validation-failed", causeMessage(cause));
}
let v3Data;
try {
({ data: v3Data } = parseRawTaskYaml({ ...input, bytes: Buffer.from(v3Yaml, "utf8") }));
}
catch (cause) {
return blocked(input, "invalid-task-yaml", causeMessage(cause));
}
const v4Outcome = planV3DataToV4(input, v3Data);
return v4Outcome.status === "changed" ? { ...v4Outcome, reason: "task-converted" } : v4Outcome;
}
// ── v4 (cleanup only) ───────────────────────────────────────────────────────
/** A v4 document: strip 0.9.15's retired per-schedule `enabled`, if present; otherwise already current. */
function planV4Cleanup(input, source) {
const document = parseDocument(source, { uniqueKeys: true });
const schedule = document.get("schedule", true);
let removed = false;
if (isSeq(schedule)) {
for (const entry of schedule.items) {
if (!isMap(entry) || !entry.has("enabled"))
continue;
entry.delete("enabled");
removed = true;
}
}
if (removed) {
if (!input.writable || input.onDiskWritable === false) {
return blocked(input, "read-only-source", !input.writable ? "the owning source is not writable" : "the source file or publication directory is read-only");
}
const after = Buffer.from(document.toString(), "utf8");
try {
parseTaskSourceV4({
yaml: after.toString("utf8"),
filePath: input.filePath,
...(input.containmentRoot ? { workspaceRoot: input.containmentRoot } : {}),
});
}
catch (cause) {
return blocked(input, "generated-v4-validation-failed", causeMessage(cause));
}
return Object.freeze({
status: "changed",
...base(input),
reason: "source-enablement-removed",
after,
afterHash: hash(after),
notice: "Removed source-owned schedule enablement; scheduler activation is now host-local config.",
});
}
try {
parseTaskSourceV4({
yaml: source,
filePath: input.filePath,
...(input.containmentRoot ? { workspaceRoot: input.containmentRoot } : {}),
});
return Object.freeze({ status: "skipped", ...base(input), reason: "already-v4" });
}
catch (cause) {
return blocked(input, "invalid-v4-task", causeMessage(cause));
}
}
// ── dispatcher ───────────────────────────────────────────────────────────────
/** Plan exactly one source file — v2, v3, or v4 — straight to v4, without touching disk. */
export function planTaskToV4File(input) {
let data;
let source;
try {
({ data, source } = parseRawTaskYaml(input));
}
catch (cause) {
return blocked(input, "invalid-task-yaml", causeMessage(cause));
}
if (data.version === 4) {
return planV4Cleanup(input, source);
}
if (data.version === 3) {
try {
parseTaskV3Yaml({
yaml: source,
filePath: input.filePath,
...(input.containmentRoot ? { workspaceRoot: input.containmentRoot } : {}),
});
}
catch (cause) {
return blocked(input, "invalid-v3-task", causeMessage(cause));
}
return planV3DataToV4(input, data);
}
if (data.version === 2) {
return planV2DataToV4(input, data);
}
return blocked(input, "unsupported-task-version", `expected version 2, 3, or 4, got ${String(data.version)}`);
}
function generationFor(files) {
const digest = crypto.createHash("sha256");
digest.update("akm-task-to-v4-plan-v1\0");
for (const file of files) {
digest.update(file.filePath);
digest.update("\0");
digest.update(file.status);
digest.update("\0");
digest.update(file.reason);
digest.update("\0");
digest.update(String(file.mode));
digest.update("\0");
digest.update(file.writable ? "writable" : "read-only");
digest.update("\0");
digest.update(file.onDiskWritable === false ? "disk-read-only" : "disk-writable-or-unspecified");
digest.update("\0");
if (file.containmentRoot)
digest.update(file.containmentRoot);
digest.update("\0");
digest.update(file.beforeHash);
digest.update("\0");
if (file.status === "changed")
digest.update(file.afterHash);
digest.update("\0");
if (file.detail)
digest.update(file.detail);
digest.update("\0");
if (file.status === "changed" && file.notice)
digest.update(file.notice);
digest.update("\0");
}
return digest.digest("hex");
}
/** Build/fingerprint a plan from already-derived immutable outcomes. */
export function taskToV4PlanFromOutcomes(outcomes) {
const files = [...outcomes].sort((left, right) => left.filePath < right.filePath ? -1 : left.filePath > right.filePath ? 1 : 0);
for (let index = 1; index < files.length; index += 1) {
const previous = files[index - 1];
const current = files[index];
if (previous && current && path.resolve(previous.filePath) === path.resolve(current.filePath)) {
throw new Error(`duplicate task migration file path: ${current.filePath}`);
}
}
return Object.freeze({ schemaVersion: 1, generation: generationFor(files), files: Object.freeze(files) });
}