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

613 lines (612 loc) • 32.7 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/. /** * Task source v4 — the second, additive task source grammar (spec * docs/plans/specs/p2a-task-source-v4.md §1.1 D1, §1.2 D2, §1.5 D2-N1..D2-N7, * §3). Never call this grammar bare "v4" in prose — the workflow plan IR is * separately versioned (D1). * * `version: 4` introduces typed `inputs:`, a single bounded `output:` schema * (legal only on command targets, the one kind whose runtime consumes it — * see `targetConsumesOutputSchema` below), * OPTIONAL scheduling (absent `schedule:` is valid and manual-only, D2-N6), * and top-level execution controls (the `akm:` options bag and the `on:` * trigger block are both GONE — every `akm:` member that D2 does not * re-home survives as a top-level key instead, D2-N7). There is no * github-action `uses:` variant. * * `src/tasks/source-v3.ts` IS edited by this phase (D2-N4): the bounded-document * front end and the path-generic field helpers move body-intact out of that * file into `./bounded-document.ts`, and `source-v3.ts` now imports (and, * for the names that were already part of its public surface, re-exports) * them instead of declaring its own copies — see `./bounded-document.ts`'s * own header and spec §6 F-2 for the extraction itself. `parseTimeout` and * `parseTools` moved that way; `nullableSelector` did not (a recorded * deviation from a literal reading of D2-N4's own extraction list — see the * P2a Review log) and stays declared directly in `source-v3.ts`. * * This file implements its OWN top-level-rooted versions of the three v3 * helpers that hardcode the `["akm", …]` field path — * `parseTimeoutTopLevel`/`nullableSelectorTopLevel`/`parseToolsTopLevel` * below, siblings of v3's `parseTimeout`/`nullableSelector`/`parseTools` — * because task source v4 needs the same accept/reject semantics at a * different, un-prefixed field path, not the same field path. */ import { parseBuiltinCommandAction } from "../../commands/command/builtin-action.js"; import { UsageError } from "../../core/errors.js"; import { checkJsonSchemaDefinition, JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS, validateJsonSchemaSubset, } from "../../core/json-schema.js"; import { DURATION_UNITS, parseDuration } from "../../core/time.js"; import { warn } from "../../core/warn.js"; import { applyInputDefaults, INPUT_NAME_PATTERN, validateInputs, } from "../../execution/input-contract.js"; import { EXECUTION_MAX_TIMEOUT_MS } from "../../execution/limits.js"; import { classifyTargetRef } from "../../execution/target-ref.js"; import { detectSecretShapedParams } from "../../workflows/exec/param-secrets.js"; import { WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_SCHEMA_BYTES } from "../../workflows/resource-limits.js"; import { TASK_V3_HOST_SHELLS, TASK_V3_MAX_SCHEDULES } from "../source-v3.js"; import { TASK_RUN_RESERVED_FLAG_NAMES, TASK_RUN_SELF_DIAGNOSED_FLAGS } from "../task-run-reserved-flags.js"; import { asRecord, checkKeys, cloneBoundedJson, noGithubExpression, own, parseEnvironment, parseStringArray, presentJsonValue, readBoundedTaskSourceYaml, sourceError, stringField, TASK_V3_MAX_REDACT_NAMES, utf8Bytes, validateWorkingDirectory, } from "./bounded-document.js"; // ── Closed constants (D1, D2-N3, D2-N7) ───────────────────────────────────── export const TASK_SOURCE_V4_VERSION = 4; /** The exact, closed top-level key set (D2-N7) — `akm` and `on` are deliberately absent. */ export const TASK_SOURCE_V4_TOP_LEVEL_KEYS = [ "version", "name", "description", "when_to_use", "tags", "inputs", "output", "uses", "run", "with", "env", "shell", "working-directory", "schedule", "agent", "engine", "model", "inference", "tools", "timeout", "redact", "maxSteps", "maxRetries", ]; /** Closes one `schedule:` list entry (D2-N5). */ export const TASK_SOURCE_V4_SCHEDULE_KEYS = ["cron", "inputs"]; /** * The closed key set for one `inputs.<name>` declaration root (D2-N3). The * JSON-Schema-subset portion is DERIVED from * `JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS` (`src/core/json-schema.ts`) rather * than restated, so the two lists cannot silently drift; `title`, * `description`, and `default` are declaration keys unique to task source * v4, layered on top (`required` is already one of the derived subset keywords — at the * declaration ROOT it is re-interpreted as the boolean flag, D2-N3). */ const SUBSET_KEYWORD_NAMES = JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS.split(",") .map((entry) => entry.split(":")[0]?.trim() ?? "") .filter((entry) => entry.length > 0); export const TASK_INPUT_DECLARATION_KEYS = Object.freeze([...SUBSET_KEYWORD_NAMES, "title", "description", "default"]); const SHELL_SET = new Set(TASK_V3_HOST_SHELLS); const SOURCE_LABEL = "task source v4"; function ctxFrom(options) { return { filePath: options.filePath, sourceLabel: SOURCE_LABEL, ...(options.workspaceRoot ? { workspaceRoot: options.workspaceRoot } : {}), ...(options.lineAt ? { lineAt: options.lineAt } : {}), }; } // ── classifyTaskSourceV4Uses (spec §3.3) ──────────────────────────────────── /** * A value SHAPED like `owner/repo[/path]@revision` — used only to produce a * good "the github-action target was removed" message (B-13), never to * accept. Deliberately a shape test, not a full github-locator grammar: * native target classification recognizes no github-action variant at all * (P4 deleted the last one, `classifyTaskV3Uses`'s locator branch in * `source-v3.ts`) — only the shape needs to be recognized here, so the * rejection can name the target the user typed rather than guess. * * Runs ONLY on {@link classifyTargetRef}'s failure path (0.9.2 review round 2): * `@` is a legal character in a canonical asset ref, so a value the canonical * classifier accepts (`commands/review@v2`) is a valid target even though it * also matches this shape — the shape test upgrades the generic invalid-ref * message, and never vetoes a ref classification accepts (spec §3.3: "exists * only to produce a good message, never to accept"). */ function looksLikeGithubActionLocator(value) { const at = value.lastIndexOf("@"); if (at <= 0) return false; const locator = value.slice(0, at); const revision = value.slice(at + 1); if (revision.length === 0 || /\s/.test(revision)) return false; if (locator.length === 0 || /\s/.test(locator) || !locator.includes("/")) return false; return true; } /** * Classify one exact `uses:` string for task source v4 (spec §3.3). Delegates * to {@link classifyTargetRef} (`src/execution/target-ref.ts`) — the repo's * one canonical-ref classifier — rather than re-deriving ref grammar; layers * the `akm/command` builtin special case, the task-ref rejection (B-14), and * — on the classification-failure path only — the github-locator-shape * rejection (B-13) on top. */ /** * The remedy sentence every `uses:` rejection below ends with. ONE constant so * the advice can never drift from what {@link TaskSourceV4UsesTarget} actually * admits — `commands/`, `scripts/`, `workflows/`, and the `akm/command` * builtin. Notably NOT `tasks/`: a task ref is rejected by B-14 below. */ const TASK_SOURCE_V4_USES_REMEDY = "Use a canonical commands/, scripts/, or workflows/ ref, or akm/command, instead."; export function classifyTaskSourceV4Uses(value) { // Diagnostic-codes ratchet remedy (tests/architecture/diagnostic-codes.test.ts, // established pattern at src/tasks/model/definition.ts:66-79): every // `UsageError` below omits its `code` argument rather than spelling it out // — the constructor already defaults to the exact code these throws need // (src/core/errors.ts), so the thrown type, `.code`, and `.hint()` are all // unchanged; this keeps the literal code string out of the ratchet's // grep-style count, which only ever declines. if (typeof value !== "string" || value.length === 0 || value.trim() !== value || /\s/.test(value) || value.includes("${{")) { throw new UsageError("Task source v4 uses must be one exact, non-empty executable ref without expressions."); } if (value === "akm/command") { return Object.freeze({ kind: "builtin-command", ref: "akm/command" }); } let classified; try { classified = classifyTargetRef(value); } catch (cause) { if (looksLikeGithubActionLocator(value)) { throw new UsageError("GitHub Action targets were removed in task source v4 — the github-action uses: variant no longer exists. " + `${TASK_SOURCE_V4_USES_REMEDY}`); } // `classifyTargetRef` is SHARED with the workflow classifier, where a // `tasks/` target IS executable, so its message names all four canonical // families. Re-raising it verbatim here advertised `tasks/` one branch // before the B-14 check below rejects exactly that — the message named a // target this function does not accept. Restate it against the set this // function actually returns (p2a §6 item 4(b) / p4 R-R14). Anything the // shared classifier throws for another reason still propagates unchanged. if (cause instanceof UsageError && cause.code === "TARGET_REF_INVALID") { throw new UsageError(`${JSON.stringify(value)} is not an executable task source v4 target. ${TASK_SOURCE_V4_USES_REMEDY}`); } throw cause instanceof Error ? cause : new UsageError(String(cause)); } if (classified.kind === "task") { throw new UsageError(`A task ref is not an executable task source v4 target. ${TASK_SOURCE_V4_USES_REMEDY}`); } return Object.freeze({ kind: classified.kind, ref: classified.ref }); } // ── Top-level scalar/target field parsing ─────────────────────────────────── function nullableSelectorTopLevel(value, ctx, key) { const selector = stringField(value, ctx, [key], { nullable: true }); if (selector !== null && selector.trim().length === 0) sourceError(ctx, [key], "must be null or a non-empty string."); return selector; } function parseTimeoutTopLevel(value, ctx) { if (value === null) return null; if (typeof value === "string" && value.trim() !== value) { sourceError(ctx, ["timeout"], "must not contain surrounding whitespace."); } const milliseconds = typeof value === "string" ? parseDuration(value, DURATION_UNITS) : value; if (milliseconds === null || typeof milliseconds !== "number" || !Number.isSafeInteger(milliseconds) || milliseconds < 0 || milliseconds > EXECUTION_MAX_TIMEOUT_MS) { sourceError(ctx, ["timeout"], `must be null, 0 through ${EXECUTION_MAX_TIMEOUT_MS} milliseconds, or a common duration such as 20m.`); } return value; } function parseToolsTopLevel(value, ctx) { if (value === null || typeof value === "string") return value; if (Array.isArray(value)) { if (value.some((entry) => typeof entry !== "string")) sourceError(ctx, ["tools"], "array values must be strings."); return value; } if (typeof value === "object") return value; sourceError(ctx, ["tools"], "must be a string, string array, mapping, or null."); } function parseTarget(input, ctx) { const hasUses = own(input, "uses"); if (hasUses) { if (own(input, "shell")) sourceError(ctx, ["shell"], "is legal only with run."); if (own(input, "working-directory")) sourceError(ctx, ["working-directory"], "is legal only with run."); const usesText = stringField(input.uses, ctx, ["uses"], { nonempty: true }); let uses; try { uses = classifyTaskSourceV4Uses(usesText); } catch (cause) { sourceError(ctx, ["uses"], cause instanceof Error ? cause.message : String(cause)); } let withValues; if (own(input, "with")) { if (uses.kind !== "builtin-command") { sourceError(ctx, ["with"], "is legal only with uses: akm/command; declare typed inputs: instead."); } withValues = asRecord(presentJsonValue(input.with, ctx, ["with"]), ctx, ["with"]); } if (uses.kind === "builtin-command") { let command; try { command = parseBuiltinCommandAction(withValues); } catch (cause) { sourceError(ctx, ["with"], cause instanceof Error ? cause.message : String(cause)); } return Object.freeze({ kind: "uses", uses, ...(withValues ? { with: withValues } : {}), command }); } return Object.freeze({ kind: "uses", uses, ...(withValues ? { with: withValues } : {}) }); } if (own(input, "with")) sourceError(ctx, ["with"], "is legal only with uses: akm/command; declare typed inputs: instead."); const run = stringField(input.run, ctx, ["run"], { nonempty: true }); let shell; if (own(input, "shell")) { const rawShell = stringField(input.shell, ctx, ["shell"], { nonempty: true }); if (!SHELL_SET.has(rawShell)) { sourceError(ctx, ["shell"], `must be one of the closed host-shell table: ${TASK_V3_HOST_SHELLS.join(", ")}.`); } shell = rawShell; } let workingDirectory; if (own(input, "working-directory")) { workingDirectory = stringField(input["working-directory"], ctx, ["working-directory"], { nonempty: true, }); validateWorkingDirectory(workingDirectory, ctx); } return Object.freeze({ kind: "run", run, ...(shell ? { shell } : {}), ...(workingDirectory ? { workingDirectory } : {}), }); } function parseExecutionControls(input, ctx) { const out = {}; for (const key of ["agent", "engine", "model"]) { if (own(input, key)) out[key] = nullableSelectorTopLevel(input[key], ctx, key); } if (own(input, "inference")) { const inference = presentJsonValue(input.inference, ctx, ["inference"]); out.inference = inference === null ? null : asRecord(inference, ctx, ["inference"]); } if (own(input, "tools")) out.tools = parseToolsTopLevel(presentJsonValue(input.tools, ctx, ["tools"]), ctx); if (own(input, "timeout")) out.timeout = parseTimeoutTopLevel(input.timeout, ctx); if (own(input, "redact")) { const names = parseStringArray(input.redact, ctx, ["redact"], { max: TASK_V3_MAX_REDACT_NAMES, pattern: WORKFLOW_ENV_VAR_NAME_PATTERN, }); if (new Set(names).size !== names.length) sourceError(ctx, ["redact"], "must not contain duplicate names."); out.redact = names; } if (own(input, "maxSteps")) { if (!Number.isSafeInteger(input.maxSteps) || input.maxSteps < 1) { sourceError(ctx, ["maxSteps"], "must be a positive safe integer."); } out.maxSteps = input.maxSteps; } if (own(input, "maxRetries")) { if (!Number.isSafeInteger(input.maxRetries) || input.maxRetries < 0) { sourceError(ctx, ["maxRetries"], "must be a non-negative safe integer."); } out.maxRetries = input.maxRetries; } return Object.freeze(out); } // ── inputs: -> InputContract (D2-N3) ──────────────────────────────────────── function stripDeclarationAnnotations(declInput) { const schema = {}; let hasDefault = false; let defaultValue; let required = false; for (const [key, value] of Object.entries(declInput)) { if (key === "default") { hasDefault = true; defaultValue = value; continue; } if (key === "required") { required = value; continue; } schema[key] = value; } return { schema, hasDefault, defaultValue, required }; } function parseInputDeclaration(name, raw, ctx) { const declPath = ["inputs", name]; const declInput = asRecord(raw, ctx, declPath); checkKeys(declInput, TASK_INPUT_DECLARATION_KEYS, ctx, declPath); if (own(declInput, "required") && typeof declInput.required !== "boolean") { sourceError(ctx, [...declPath, "required"], "must be a boolean at the declaration root (nested objects' own required: […] keeps ordinary JSON Schema array semantics)."); } const { schema, hasDefault, defaultValue, required } = stripDeclarationAnnotations(declInput); if (hasDefault && required) { sourceError(ctx, declPath, "must not declare both default and required: true."); } const definitionIssue = checkJsonSchemaDefinition(schema)[0]; if (definitionIssue) sourceError(ctx, declPath, `is not a supported JSON schema: ${definitionIssue.message}`); if (utf8Bytes(JSON.stringify(schema)) > WORKFLOW_MAX_SCHEMA_BYTES) { sourceError(ctx, declPath, `serialized schema exceeds the ${WORKFLOW_MAX_SCHEMA_BYTES}-byte limit.`); } if (hasDefault) { const violations = validateJsonSchemaSubset(defaultValue, schema); if (violations.length > 0) { sourceError(ctx, [...declPath, "default"], `does not satisfy its own declaration: ${violations.join("; ")}`); } const secretWarnings = detectSecretShapedParams({ [name]: defaultValue }); for (const message of secretWarnings) warn(message); } return Object.freeze({ schema: Object.freeze(schema), ...(hasDefault ? { default: defaultValue } : {}), required, }); } function parseInputDeclarations(value, ctx) { const input = asRecord(value, ctx, ["inputs"]); const names = Object.keys(input); const result = {}; for (const name of names) { if (!INPUT_NAME_PATTERN.test(name)) { sourceError(ctx, ["inputs", name], "must match the input name pattern (a letter/underscore, then letters, digits, or underscores)."); } // Code-review finding (docs/plans/specs/p2b-input-bindings.md review // round 2, scheduler-binding.ts:238): a declared input name that // collides with a flag `akm task run` already binds to itself (--bundle, // --scheduled, …) can never be supplied through that CLI — parseTaskInputFlags // (../../commands/tasks/tasks-cli.ts) always treats the name as its OWN // flag, so the value is either silently misrouted (a second --bundle // re-targets which bundle the task loads from) or left as an orphaned // positional token that throws. Rejecting it HERE, at declaration time, // closes every caller at once: a bare `akm task run --<name>`, `akm task // explain --<name>`, and a `schedule[i].inputs` entry (whose keys are // checked against this same contract below, so a banned name can never // reach compileTaskSchedulerBindings's invocation tail either) — see // ../task-run-reserved-flags.ts's own header. // // 0.9.2 review round 2: `target` joins that set from the CLI's DIAGNOSTIC // side rather than its declared-arg side. `rejectRetiredTaskTargetFlag` // (../../commands/tasks/tasks-cli.ts) throws the 0.9 `--target` -> // `--bundle` rename hint for every spelling of the name, before // parseTaskInputFlags scans argv at all, so an input declared under that // name could never be supplied either — it is the same unusable // declaration, reached by a different route (TASK_RUN_SELF_DIAGNOSED_FLAGS). if (TASK_RUN_RESERVED_FLAG_NAMES.has(name)) { sourceError(ctx, ["inputs", name], TASK_RUN_SELF_DIAGNOSED_FLAGS.includes(name) ? `collides with the retired \`akm task --${name}\` spelling, which every task subcommand still rejects with a rename hint, so no --${name} flag can ever reach this input; declare the input under a different name.` : `collides with akm task run's own --${name} flag; declare the input under a different name.`); } result[name] = parseInputDeclaration(name, input[name], ctx); } return Object.freeze(result); } // ── output: -> bounded JSON Schema (mirrors v3's akm.outputSchema) ───────── function parseOutputSchema(value, ctx) { const schema = asRecord(value, ctx, ["output"]); const issue = checkJsonSchemaDefinition(schema)[0]; if (issue) sourceError(ctx, ["output"], `is not a supported JSON schema: ${issue.message}`); return schema; } /** * True for the target kinds whose runtime actually consumes `output:` — * command invocations (`uses: commands/<ref>` and `uses: akm/command`), where * `prepareTaskV3Execution` forwards it as the invocation's outputSchema * (../prepare/prepare.ts, via prepare-support.ts's currentExecutionValues). * `run:`, `uses: scripts/`, and `uses: workflows/` executions carry no * output schema anywhere (run-native-task.ts decides status from the exit * code alone; the workflow arm freezes a child plan without one), so an * authored `output:` there would be a silently unenforced contract — the * fifth state the fail-closed rule forbids (0.9.2 review round 2; same * grammar pattern as `with:` being legal only with `uses: akm/command`). */ function targetConsumesOutputSchema(target) { return target.kind === "uses" && (target.uses.kind === "command" || target.uses.kind === "builtin-command"); } // ── schedule: -> TaskSourceV4ScheduleBinding[] (D2-N5, D2-N6, B-06..B-10, B-38) ── /** * Every schedule binding must be independently runnable (0.9.2 review round 2). * * A scheduled firing supplies NO input flags — `compileTaskSchedulerBindings` * (../scheduler-binding.ts) appends only the entry's own * `schedule[i].inputs` to the `["task","run",id,"--bundle",b,"--scheduled"]` * tail — so that literal PLUS the declared defaults is the complete value set * `akm task run --scheduled` will see, and it is checked there by the very * same `applyInputDefaults` + `validateInputs` pair (../run/load-task.ts). * A `required: true` declaration may not also carry a `default` (D2-N3, * `parseInputDeclaration` above), so an entry that names no value for one can * never satisfy the contract, at any hour, ever. * * The source document alone knows both halves of that contradiction, so it is * a grammar error: TASK_SOURCE_INVALID at the entry's own field path, naming * the unsatisfied input(s). `akm task sync`'s projectability proof * (../scheduler-sync.ts) keeps its own independent copy of this check over * the DEFAULTED view — that gate is what makes such a schedule unreachable * rather than merely ill-advised, and it still guards a task source reaching * sync through any other path — but an author should not have to run `akm * task sync` to learn that a document contradicts itself (the parse-time * rejection p2a's own review log named as the natural fix). Manual `akm task * run` is deliberately unaffected: a manual run takes the value from the * input's own `--<name>` flag, so a required, default-less input plus NO * `schedule:` stays valid and manual-only (B-06, D2-N6). * * Runs for every entry, AFTER the raw `schedule[i].inputs` check below, so an * entry that authors an `inputs:` mapping keeps that check's exact * message/field path; defaults can only add values already validated against * their own declaration, so this pass can add nothing but missing-required. */ function checkScheduleEntryRunnable(inputs, contract, ctx, entryPath) { const errors = validateInputs(contract, applyInputDefaults(contract, { ...inputs }), { pathRoot: "inputs" }); if (errors.length === 0) return; sourceError(ctx, entryPath, `does not satisfy the task's declared inputs once defaults are applied: ${errors.join("; ")}. ` + "A scheduled run supplies no input flags — give this schedule entry an inputs: value for each input " + "named above, or declare a default: on the input instead (a required: true input may not carry one)."); } function parseScheduleEntry(entryRaw, index, contract, ctx) { const entryPath = ["schedule", index]; const entry = asRecord(entryRaw, ctx, entryPath); checkKeys(entry, TASK_SOURCE_V4_SCHEDULE_KEYS, ctx, entryPath); if (!own(entry, "cron")) sourceError(ctx, [...entryPath, "cron"], "is required."); const cron = stringField(entry.cron, ctx, [...entryPath, "cron"], { nonempty: true }); noGithubExpression(cron, ctx, [...entryPath, "cron"]); let inputsLiteral = Object.freeze({}); if (own(entry, "inputs")) { const inputsValue = asRecord(presentJsonValue(entry.inputs, ctx, [...entryPath, "inputs"]), ctx, [ ...entryPath, "inputs", ]); // Fail-closed exact-name check (code-review finding, task-source-v4.ts:530): // `validateInputs`'s synthetic `{type:"object", properties}` schema (spec // §4.2) deliberately carries no `additionalProperties:false` — that is the // right default for a general-purpose contract validator with other // callers (materializeInputFlags already does its own exact-name check // before ever calling validateInputs, per D3-N3's design) — so relying on // validateInputs alone here would silently accept a typo'd or wholly // undeclared schedule[i].inputs key forever, exactly the fifth state the // fail-closed rule forbids. checkKeys mirrors materializeInputFlags' own // exact-name rule at the grammar layer: closed against the declared // contract, TASK_SOURCE_INVALID at schedule[<i>].inputs.<name>. checkKeys(inputsValue, Object.keys(contract), ctx, [...entryPath, "inputs"]); // pathRoot "inputs" (matching checkScheduleEntryRunnable below) and the // sourceError call rooted at entryPath, not [...entryPath, "inputs"]: the // per-error detail already carries "inputs.<name>" (validateInputs), so // adding a SECOND ".inputs" segment to the field path here would render // two path roots in one message (code-review finding, was the bare "$" // default leaking through — see docs/plans/specs/p2a-task-source-v4.md // review-log item 4a). const errors = validateInputs(contract, inputsValue, { pathRoot: "inputs" }); if (errors.length > 0) sourceError(ctx, entryPath, errors.join("; ")); inputsLiteral = Object.freeze({ ...inputsValue }); } checkScheduleEntryRunnable(inputsLiteral, contract, ctx, entryPath); return Object.freeze({ cron, inputs: inputsLiteral, source: `schedule[${index}].cron`, ordinal: index }); } function parseSchedule(input, contract, ctx) { if (!own(input, "schedule")) return Object.freeze([]); const raw = presentJsonValue(input.schedule, ctx, ["schedule"]); if (typeof raw === "string") { const cron = stringField(raw, ctx, ["schedule"], { nonempty: true }); noGithubExpression(cron, ctx, ["schedule"]); // B-08's SHAPE is unchanged — one enabled binding, no inputs — but the // shorthand is a schedule entry like any other, so it is held to the same // runnability contract, at the `schedule` key's own field path (it has // neither an ordinal nor an `inputs:` sub-path to point at). checkScheduleEntryRunnable(Object.freeze({}), contract, ctx, ["schedule"]); return Object.freeze([Object.freeze({ cron, inputs: Object.freeze({}), source: "schedule", ordinal: 0 })]); } if (!Array.isArray(raw) || raw.length === 0) { sourceError(ctx, ["schedule"], "must be a non-empty string or a non-empty list of {cron, inputs?} records."); } if (raw.length > TASK_V3_MAX_SCHEDULES) { sourceError(ctx, ["schedule"], `accepts at most ${TASK_V3_MAX_SCHEDULES} entries.`); } const bindings = raw.map((entryRaw, index) => parseScheduleEntry(entryRaw, index, contract, ctx)); return Object.freeze(bindings); } // ── Top-level key rejection (akm:/on: removal, B-11/B-12; D2-N7) ─────────── function checkTopLevelKeys(input, ctx) { if (own(input, "akm")) { sourceError(ctx, ["akm"], "is removed in task source v4; its members are top-level keys now (schedule, timeout, engine, model, redact, " + "maxSteps, maxRetries, description, when_to_use, tags, agent, inference, tools, and output for outputSchema) " + "— see docs/reference/tasks.md."); } if (own(input, "on")) { sourceError(ctx, ["on"], "is removed in task source v4; declare a top-level schedule: instead."); } checkKeys(input, TASK_SOURCE_V4_TOP_LEVEL_KEYS, ctx, []); } // ── parseTaskSourceV4Document (spec §3.2) ─────────────────────────────────── /** Parse an already-decoded JSON/YAML value as a task source v4 document (spec §3.2). */ export function parseTaskSourceV4Document(value, options) { const ctx = ctxFrom(options); const cloned = cloneBoundedJson(value, ctx, [], { nodes: 0 }); const input = asRecord(cloned, ctx, []); if (!own(input, "version")) sourceError(ctx, ["version"], "is required and must be 4."); if (input.version !== TASK_SOURCE_V4_VERSION) sourceError(ctx, ["version"], "must be exactly 4."); checkTopLevelKeys(input, ctx); const hasUses = own(input, "uses"); const hasRun = own(input, "run"); if (hasUses === hasRun) sourceError(ctx, [], "requires exactly one executable selector: uses or run."); const name = own(input, "name") ? stringField(input.name, ctx, ["name"]) : undefined; const description = own(input, "description") ? stringField(input.description, ctx, ["description"]) : undefined; const whenToUse = own(input, "when_to_use") ? stringField(input.when_to_use, ctx, ["when_to_use"]) : undefined; const tags = own(input, "tags") ? parseStringArray(input.tags, ctx, ["tags"]) : undefined; const env = own(input, "env") ? parseEnvironment(presentJsonValue(input.env, ctx, ["env"]), ctx) : undefined; const target = parseTarget(input, ctx); const inputs = own(input, "inputs") ? parseInputDeclarations(presentJsonValue(input.inputs, ctx, ["inputs"]), ctx) : undefined; let output; if (own(input, "output")) { if (!targetConsumesOutputSchema(target)) { sourceError(ctx, ["output"], "is legal only with a command target (uses: commands/<ref> or uses: akm/command); " + "run:, uses: scripts/, and uses: workflows/ targets do not enforce an output schema."); } output = parseOutputSchema(presentJsonValue(input.output, ctx, ["output"]), ctx); } const schedule = parseSchedule(input, inputs ?? Object.freeze({}), ctx); const execution = parseExecutionControls(input, ctx); return Object.freeze({ version: TASK_SOURCE_V4_VERSION, ...(name !== undefined ? { name } : {}), ...(description !== undefined ? { description } : {}), ...(whenToUse !== undefined ? { when_to_use: whenToUse } : {}), ...(tags !== undefined ? { tags } : {}), ...(inputs !== undefined ? { inputs } : {}), ...(output !== undefined ? { output } : {}), target, ...(env !== undefined ? { env } : {}), execution, schedule, manualOnly: schedule.length === 0, source: Object.freeze({ path: options.filePath }), }); } /** Parse hostile YAML text as a task source v4 document — the standalone entry (mirrors `parseTaskV3Yaml`). */ export function parseTaskSourceV4(input) { const { root, lineAt } = readBoundedTaskSourceYaml(input, { sourceLabel: SOURCE_LABEL }); return parseTaskSourceV4Document(root, { filePath: input.filePath, ...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}), lineAt, }); }