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
441 lines (440 loc) • 22.2 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/.
/**
* The bounded-document front end and field helpers both task source grammars
* share (spec docs/plans/specs/p2a-task-source-v4.md §1.5 D2-N4, §3.1).
*
* This is the actual D2-N4 move, not a parallel reimplementation: every
* helper below — including the `TASK_V3_MAX_*` resource bounds and
* `assertBoundedTaskYamlDocument` — is now OWNED here, body-intact from
* `src/tasks/source-v3.ts`. `source-v3.ts` imports every one of them (and
* re-exports `assertBoundedTaskYamlDocument` / the `TASK_V3_MAX_*` constants
* at their existing names, since those were already part of its public
* surface) instead of declaring its own copies. §9's acceptance criterion is
* structural: "src/tasks/source/bounded-document.ts exists and owns the
* D2-N4 helpers; src/tasks/source-v3.ts imports them and contains no copy of
* any of them" — `tests/tasks/bounded-document.test.ts`'s last describe
* block pins this with an AST scan of `source-v3.ts`, not just a runtime
* behavior check (a copy-instead-of-move implementation would pass every
* OTHER test in that file and in `tests/tasks/source-v3.test.ts`, because
* both files would still call functions they have in scope either way).
*
* This module deliberately imports NOTHING from `../source-v3` (not even a
* type) — `tests/architecture/import-cycle-ratchet.test.ts` is an ABSOLUTE
* no-cycle gate over all of `src/**` (its baseline emptied at chunk-8 and
* counts type-only imports as real graph edges), and `source-v3.ts` now
* imports from this file, so a reverse edge here would close a cycle. Where
* a moved helper's signature referenced a v3-only type
* (`TaskV3AkmOptions["tools"]`, `TaskV3Environment`) the return type is
* inlined structurally instead — invisible at runtime, and TypeScript's
* structural typing makes the inlined shape and the named alias
* interchangeable at every call site.
*
* `parseTimeout` and `parseTools` stay v3-only in the sense that they
* hardcode the `["akm", …]` field path (byte-identical to v3's existing
* behavior — that hardcoding is exactly what D2-N4's binding resolution asks
* to preserve). Task source v4's top-level `timeout:`/`tools:` fields have
* their own siblings in `src/tasks/source/task-source-v4.ts`
* (`parseTimeoutTopLevel`/`parseToolsTopLevel`) with the same accept/reject
* semantics at a different, un-prefixed field path — v3 imports the two
* below rather than declaring them itself, purely because D2-N4 homes every
* one of these named helpers here. `nullableSelector` is the same kind of
* `["akm", …]`-hardcoding helper but is NOT in D2-N4's named list and stays
* declared directly in `source-v3.ts`.
*/
import fs from "node:fs";
import path from "node:path";
import { types as utilTypes } from "node:util";
import { isAlias, isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
import { wellFormedUnicode } from "../../core/common.js";
import { UsageError } from "../../core/errors.js";
import { DURATION_UNITS, parseDuration } from "../../core/time.js";
import { EXECUTION_MAX_TIMEOUT_MS } from "../../execution/limits.js";
import { snapshotStrictRecord } from "../../execution/record.js";
import { WORKFLOW_ENV_VAR_NAME_PATTERN } from "../../workflows/resource-limits.js";
// ── Resource bounds (D2-N4: owned here, re-exported by source-v3.ts) ───────
export const TASK_V3_MAX_JSON_DEPTH = 64;
export const TASK_V3_MAX_JSON_NODES = 10_000;
export const TASK_V3_MAX_COLLECTION_ITEMS = 1024;
export const TASK_V3_MAX_OBJECT_KEYS = 256;
export const TASK_V3_MAX_STRING_BYTES = 256 * 1024;
export const TASK_V3_MAX_SCHEDULES = 64;
/** Max entries in a task's `redact:` env-name list. */
export const TASK_V3_MAX_REDACT_NAMES = 32;
export function own(value, key) {
return Object.hasOwn(value, key);
}
export function utf8Bytes(value) {
return new TextEncoder().encode(value).byteLength;
}
/** The one per-field error funnel both grammars render through, distinguished only by `ctx.sourceLabel` (D2-N4). */
export function sourceError(ctx, fieldPath, detail) {
const dotted = fieldPath.length === 0
? "$"
: fieldPath.reduce((display, segment) => typeof segment === "number"
? `${display}[${segment}]`
: display.length > 0
? `${display}.${segment}`
: segment, "");
const line = ctx.lineAt?.(fieldPath);
const location = `${ctx.filePath}${line === undefined ? "" : `:${line}`}`;
throw new UsageError(`Invalid ${ctx.sourceLabel} at ${location}: ${dotted} ${detail}`, "TASK_SOURCE_INVALID");
}
export function cloneBoundedJson(value, ctx, fieldPath, state, depth = 0, ancestors = new Set()) {
state.nodes += 1;
if (state.nodes > TASK_V3_MAX_JSON_NODES)
sourceError(ctx, fieldPath, `exceeds the ${TASK_V3_MAX_JSON_NODES}-node limit.`);
if (depth > TASK_V3_MAX_JSON_DEPTH)
sourceError(ctx, fieldPath, `exceeds the nesting depth of ${TASK_V3_MAX_JSON_DEPTH}.`);
if (value === null || typeof value === "boolean")
return value;
if (typeof value === "number") {
if (!Number.isFinite(value))
sourceError(ctx, fieldPath, "must be a finite JSON number.");
return value;
}
if (typeof value === "string") {
if (!wellFormedUnicode(value))
sourceError(ctx, fieldPath, "must contain well-formed Unicode.");
if (utf8Bytes(value) > TASK_V3_MAX_STRING_BYTES) {
sourceError(ctx, fieldPath, `exceeds the ${TASK_V3_MAX_STRING_BYTES}-byte string limit.`);
}
return value;
}
if (value === undefined)
sourceError(ctx, fieldPath, "must be omitted instead of set to undefined.");
if (typeof value !== "object")
sourceError(ctx, fieldPath, "must be JSON-safe.");
if (utilTypes.isProxy(value))
sourceError(ctx, fieldPath, "must not be a Proxy object.");
if (ancestors.has(value))
sourceError(ctx, fieldPath, "must not contain a cycle.");
const nextAncestors = new Set(ancestors).add(value);
if (Array.isArray(value)) {
if (Object.getPrototypeOf(value) !== Array.prototype)
sourceError(ctx, fieldPath, "array must use the standard prototype.");
const rawLength = Reflect.getOwnPropertyDescriptor(value, "length")?.value;
if (typeof rawLength !== "number" ||
!Number.isInteger(rawLength) ||
rawLength < 0 ||
rawLength > TASK_V3_MAX_COLLECTION_ITEMS) {
sourceError(ctx, fieldPath, `array exceeds the ${TASK_V3_MAX_COLLECTION_ITEMS}-item limit.`);
}
const length = rawLength;
const keys = Reflect.ownKeys(value);
if (keys.length !== length + 1)
sourceError(ctx, fieldPath, "array must be dense and contain no extra fields.");
const result = [];
for (let index = 0; index < length; index += 1) {
const descriptor = Reflect.getOwnPropertyDescriptor(value, String(index));
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
sourceError(ctx, [...fieldPath, index], "array item must be an enumerable data property in a dense array.");
}
result.push(cloneBoundedJson(descriptor.value, ctx, [...fieldPath, index], state, depth + 1, nextAncestors));
}
return Object.freeze(result);
}
let snapshot;
try {
snapshot = snapshotStrictRecord(value, fieldPath.map(String).join(".") || "task source");
}
catch (cause) {
sourceError(ctx, fieldPath, cause instanceof Error ? cause.message : String(cause));
}
const entries = Object.entries(snapshot);
if (entries.length > TASK_V3_MAX_OBJECT_KEYS) {
sourceError(ctx, fieldPath, `mapping exceeds the ${TASK_V3_MAX_OBJECT_KEYS}-key limit.`);
}
const result = Object.create(null);
for (const [key, child] of entries) {
if (!wellFormedUnicode(key))
sourceError(ctx, fieldPath, "contains a mapping key with malformed Unicode.");
if (utf8Bytes(key) > TASK_V3_MAX_STRING_BYTES) {
sourceError(ctx, fieldPath, `contains a mapping key exceeding the ${TASK_V3_MAX_STRING_BYTES}-byte string limit.`);
}
Object.defineProperty(result, key, {
value: cloneBoundedJson(child, ctx, [...fieldPath, key], state, depth + 1, nextAncestors),
enumerable: true,
configurable: false,
writable: false,
});
}
return Object.freeze(result);
}
export function asRecord(value, ctx, fieldPath) {
if (value === null || Array.isArray(value) || typeof value !== "object")
sourceError(ctx, fieldPath, "must be a mapping.");
return value;
}
export function checkKeys(value, allowed, ctx, fieldPath) {
const allow = new Set(allowed);
const firstUnknown = Object.keys(value).find((key) => !allow.has(key));
if (firstUnknown !== undefined)
sourceError(ctx, [...fieldPath, firstUnknown], "is an unsupported field.");
}
export function presentJsonValue(value, ctx, fieldPath) {
if (value === undefined)
sourceError(ctx, fieldPath, "must be omitted instead of set to undefined.");
return value;
}
export function stringField(value, ctx, fieldPath, options = {}) {
if (value === null && options.nullable)
return null;
if (typeof value !== "string")
sourceError(ctx, fieldPath, options.nullable ? "must be a string or null." : "must be a string.");
if (options.nonempty && value.trim().length === 0)
sourceError(ctx, fieldPath, "must be a non-empty string.");
return value;
}
export function noGithubExpression(value, ctx, fieldPath) {
if (value.includes("${{"))
sourceError(ctx, fieldPath, "contains an unsupported GitHub expression.");
}
/** `env:` is already a top-level field in both grammars — reused body-intact, field path unchanged. */
export function parseEnvironment(value, ctx) {
const environment = asRecord(value, ctx, ["env"]);
for (const [key, child] of Object.entries(environment)) {
if (!WORKFLOW_ENV_VAR_NAME_PATTERN.test(key))
sourceError(ctx, ["env", key], "has an invalid environment variable name.");
if (typeof child !== "string" && typeof child !== "number" && typeof child !== "boolean") {
sourceError(ctx, ["env", key], "must be a string, finite number, or boolean.");
}
}
return environment;
}
export function parseStringArray(value, ctx, fieldPath, options = {}) {
if (!Array.isArray(value))
sourceError(ctx, fieldPath, "must be an array of strings.");
if (options.max !== undefined && value.length > options.max)
sourceError(ctx, fieldPath, `accepts at most ${options.max} items.`);
const strings = [];
for (const [index, entry] of value.entries()) {
if (typeof entry !== "string" || entry.length === 0)
sourceError(ctx, [...fieldPath, index], "must be a non-empty string.");
if (options.pattern && !options.pattern.test(entry))
sourceError(ctx, [...fieldPath, index], "has an invalid value.");
strings.push(entry);
}
return Object.freeze(strings);
}
/**
* `akm.timeout` — v3-only (hardcodes the `["akm", "timeout"]` field path, see
* this module's header). Body-intact from `source-v3.ts`.
*/
export function parseTimeout(value, ctx) {
if (value === null)
return null;
if (typeof value === "string" && value.trim() !== value) {
sourceError(ctx, ["akm", "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, ["akm", "timeout"], `must be null, 0 through ${EXECUTION_MAX_TIMEOUT_MS} milliseconds, or a common duration such as 20m.`);
}
return value;
}
/**
* `akm.tools` — v3-only (hardcodes the `["akm", "tools"]` field path, see
* this module's header). Body-intact from `source-v3.ts`; the return type is
* inlined (was `TaskV3AkmOptions["tools"]`) so this module imports nothing
* from `../source-v3` (see header — the import-cycle ratchet is absolute).
*/
export function parseTools(value, ctx) {
if (value === null || typeof value === "string")
return value;
if (Array.isArray(value)) {
if (value.some((entry) => typeof entry !== "string"))
sourceError(ctx, ["akm", "tools"], "array values must be strings.");
return value;
}
if (typeof value === "object")
return value;
sourceError(ctx, ["akm", "tools"], "must be a string, string array, mapping, or null.");
}
/**
* `working-directory:` is already a top-level field in both grammars —
* reused body-intact, field path unchanged (`source-v3.ts:677-714`).
*/
export function validateWorkingDirectory(value, ctx) {
if (value.trim().length === 0 ||
value.includes("\0") ||
path.posix.isAbsolute(value.replaceAll("\\", "/")) ||
/^[A-Za-z]:[\\/]/.test(value) ||
value.startsWith("\\\\")) {
sourceError(ctx, ["working-directory"], "must be a non-empty relative path contained by the workspace root.");
}
const segments = value.replaceAll("\\", "/").split("/");
if (segments.some((segment) => segment === ".." || segment.length === 0)) {
sourceError(ctx, ["working-directory"], "must not contain empty or escaping path segments.");
}
if (!ctx.workspaceRoot) {
sourceError(ctx, ["working-directory"], "requires a workspace root so physical containment can be verified.");
}
let realRoot;
let realCandidate;
try {
realRoot = fs.realpathSync(ctx.workspaceRoot);
const candidate = path.resolve(realRoot, value);
const stat = fs.statSync(candidate);
if (!stat.isDirectory())
sourceError(ctx, ["working-directory"], "must resolve to a directory.");
realCandidate = fs.realpathSync(candidate);
}
catch (cause) {
if (cause instanceof UsageError)
throw cause;
sourceError(ctx, ["working-directory"], `cannot be physically verified: ${cause instanceof Error ? cause.message : String(cause)}.`);
}
const relative = path.relative(realRoot, realCandidate);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
sourceError(ctx, ["working-directory"], "resolves outside the workspace root and is not physically contained.");
}
}
export function yamlProblem(message) {
return message.split("\n")[0]?.trim() || "invalid YAML";
}
export function yamlAstError(options, node, detail) {
const range = node?.range;
const line = range && options.lineCounter ? options.lineCounter.linePos(range[0] ?? 0).line : undefined;
throw new UsageError(`Invalid ${options.sourceLabel} at ${options.filePath}${line === undefined ? "" : `:${line}`}: ${detail}`, "TASK_SOURCE_INVALID");
}
/**
* Bound and close the YAML AST before `toJS` can allocate or recurse through
* it. This is shared by the v3 parser, task source v4, and the explicit v2
* migration reader.
*/
export function assertBoundedTaskYamlDocument(document, options) {
const stack = [{ node: document.contents, depth: 0 }];
let nodes = 0;
while (stack.length > 0) {
const current = stack.pop();
if (!current)
break;
const node = current.node;
if (node === null || node === undefined)
continue;
nodes += 1;
if (nodes > TASK_V3_MAX_JSON_NODES) {
yamlAstError(options, node, `YAML exceeds the ${TASK_V3_MAX_JSON_NODES}-node limit.`);
}
if (current.depth > TASK_V3_MAX_JSON_DEPTH) {
yamlAstError(options, node, `YAML exceeds the nesting depth of ${TASK_V3_MAX_JSON_DEPTH}.`);
}
if (isAlias(node))
yamlAstError(options, node, "YAML aliases are unsupported.");
if (node.anchor !== undefined) {
yamlAstError(options, node, "YAML anchors are unsupported.");
}
if (node.tag) {
yamlAstError(options, node, "custom or explicit YAML tags are unsupported.");
}
if (isScalar(node))
continue;
if (isSeq(node)) {
if (node.items.length > TASK_V3_MAX_COLLECTION_ITEMS) {
yamlAstError(options, node, `YAML sequence exceeds the ${TASK_V3_MAX_COLLECTION_ITEMS}-item limit.`);
}
for (let index = node.items.length - 1; index >= 0; index -= 1) {
stack.push({ node: node.items[index], depth: current.depth + 1 });
}
continue;
}
if (isMap(node)) {
if (node.items.length > TASK_V3_MAX_OBJECT_KEYS) {
yamlAstError(options, node, `YAML mapping exceeds the ${TASK_V3_MAX_OBJECT_KEYS}-key limit.`);
}
for (let index = node.items.length - 1; index >= 0; index -= 1) {
const pair = node.items[index];
if (!pair)
yamlAstError(options, node, "sparse YAML mappings are unsupported.");
if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
yamlAstError(options, pair.key, "non-string YAML mapping keys are unsupported.");
}
if (!wellFormedUnicode(pair.key.value)) {
yamlAstError(options, pair.key, "YAML mapping key must contain well-formed Unicode.");
}
if (utf8Bytes(pair.key.value) > TASK_V3_MAX_STRING_BYTES) {
yamlAstError(options, pair.key, `YAML mapping key exceeds the ${TASK_V3_MAX_STRING_BYTES}-byte string limit.`);
}
if (pair.key.value === "<<")
yamlAstError(options, pair.key, "YAML merge keys are unsupported.");
stack.push({ node: pair.value, depth: current.depth + 1 });
stack.push({ node: pair.key, depth: current.depth + 1 });
}
continue;
}
yamlAstError(options, node, "unsupported YAML node kind.");
}
}
/**
* Parse hostile YAML without aliases/tags/merges, then hand back the bounded
* `{root, lineAt}` pair both grammars that read a task document consume —
* originally lifted from the now-deleted `parseTaskV3Yaml`
* (`source-v3.ts:894-952`), generalized only by `sourceLabel`.
*
* P4 (docs/plans/specs/p4-deletions-closeout.md §3.2) deleted task v3
* acceptance from `src` entirely — `parseTaskV3Yaml` and its `"task v3
* source"` label no longer exist here (the grammar survives only in the
* vendored, frozen `src/tasks/source/task-source-v3-frozen.ts`
* copy, which does not call this function). The two live `src` callers
* today: `parseTaskSourceV4`'s standalone YAML-string entry
* (`task-source-v4.ts:790`) passes `sourceLabel: "task source v4"`; the
* version router (`parse-task-source.ts:61`) calls this directly with
* `sourceLabel: "task source"` — not "task v3 source" — because its front
* end runs before `root.version` is even read, so it cannot yet know which
* schema version the document will turn out to be.
*/
export function readBoundedTaskSourceYaml(input, options) {
const sourceLabel = options.sourceLabel;
// P4 (docs/plans/specs/p4-deletions-closeout.md §5.2, row R-R8): these six
// throws used to omit their `code` argument and rely on the constructor's
// `INVALID_FLAG_VALUE` default (a pre-P4 ratchet-gaming trick that kept the
// literal string out of the grep-style count while the effective code
// stayed generic). §5.2's target table closes that gap: every pre-version
// failure in this bounded YAML front end (not a string, too large, YAML
// parse/expansion) is a task-source defect, not a flag-parsing one, so each
// now carries `TASK_SOURCE_INVALID` explicitly.
if (typeof input.yaml !== "string") {
throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: source must be a string.`, "TASK_SOURCE_INVALID");
}
const lineCounter = new LineCounter();
let document;
try {
document = parseDocument(input.yaml, { lineCounter, uniqueKeys: true });
}
catch (cause) {
throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: YAML parsing failed: ${cause instanceof Error ? cause.message : String(cause)}`, "TASK_SOURCE_INVALID");
}
const [problem] = document.errors;
if (problem) {
const offset = Array.isArray(problem.pos) ? problem.pos[0] : 0;
throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}:${lineCounter.linePos(offset).line}: ${yamlProblem(problem.message)}`, "TASK_SOURCE_INVALID");
}
const [warning] = document.warnings;
if (warning) {
throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: unsupported YAML construct: ${yamlProblem(warning.message)}`, "TASK_SOURCE_INVALID");
}
assertBoundedTaskYamlDocument(document, { filePath: input.filePath, sourceLabel, lineCounter });
let root;
try {
root = document.toJS({ maxAliasCount: 0 });
}
catch (cause) {
throw new UsageError(`Invalid ${sourceLabel} at ${input.filePath}: YAML expansion failed: ${cause instanceof Error ? cause.message : String(cause)}`, "TASK_SOURCE_INVALID");
}
const lineAt = (fieldPath) => {
for (let depth = fieldPath.length; depth >= 0; depth -= 1) {
const node = depth === 0 ? document.contents : document.getIn(fieldPath.slice(0, depth), true);
const range = node?.range;
if (range)
return lineCounter.linePos(range[0]).line;
}
return undefined;
};
return { root, lineAt };
}