akm-cli 0.9.0 → 0.9.1-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +724 -0
- package/README.md +28 -63
- package/STABILITY.md +4 -2
- package/dist/cli/parse-args.js +7 -1
- package/dist/commands/agent/contribute-cli.js +1 -1
- package/dist/commands/env/child-env.js +14 -0
- package/dist/commands/feedback-cli.js +7 -1
- package/dist/commands/health/llm-usage.js +2 -1
- package/dist/commands/health/surfaces.js +4 -77
- package/dist/commands/health.js +65 -11
- package/dist/commands/improve/distill/quality-gate.js +6 -1
- package/dist/commands/improve/eligibility.js +7 -1
- package/dist/commands/improve/eval-cases.js +2 -0
- package/dist/commands/improve/improve.js +126 -10
- package/dist/commands/improve/locks.js +7 -0
- package/dist/commands/improve/memory/memory-improve.js +9 -0
- package/dist/commands/improve/run-context.js +5 -0
- package/dist/commands/improve/session-asset.js +4 -0
- package/dist/commands/lint/base-linter.js +31 -7
- package/dist/commands/lint/index.js +205 -51
- package/dist/commands/lint/types.js +22 -1
- package/dist/commands/proposal/repository.js +17 -1
- package/dist/commands/sources/add-cli.js +8 -2
- package/dist/commands/sources/info.js +12 -2
- package/dist/commands/sources/installed-stashes.js +6 -1
- package/dist/commands/sources/migration-help.js +12 -3
- package/dist/commands/sources/self-update.js +9 -1
- package/dist/commands/tasks/tasks.js +8 -2
- package/dist/commands/workflow-cli.js +17 -11
- package/dist/core/abort-deadline.js +28 -0
- package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
- package/dist/core/adapter/adapters/akm-adapter.js +13 -10
- package/dist/core/adapter/adapters/akm-lint.js +78 -22
- package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
- package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
- package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
- package/dist/core/asset/frontmatter.js +10 -1
- package/dist/core/common.js +147 -9
- package/dist/core/concurrent.js +32 -0
- package/dist/core/config/config-io.js +5 -45
- package/dist/core/config/schema/engines.js +14 -3
- package/dist/core/config/schema/workflow.js +11 -0
- package/dist/core/errors.js +25 -0
- package/dist/core/events.js +30 -24
- package/dist/core/extra-params.js +11 -0
- package/dist/core/file-lock.js +7 -1
- package/dist/core/fs-txn.js +15 -2
- package/dist/core/improve-result.js +5 -0
- package/dist/core/json-schema.js +344 -9
- package/dist/core/loopback.js +89 -0
- package/dist/core/migration-operation.js +17 -2
- package/dist/core/path-access.js +107 -0
- package/dist/core/paths.js +16 -2
- package/dist/core/redaction.js +86 -18
- package/dist/core/spawn-env.js +234 -0
- package/dist/core/state-db-scope.js +134 -0
- package/dist/core/state-db.js +1 -0
- package/dist/core/subprocess.js +181 -37
- package/dist/core/write-provenance.js +85 -0
- package/dist/core/write-source.js +33 -2
- package/dist/indexer/db/graph-db.js +17 -6
- package/dist/indexer/ensure-index.js +10 -3
- package/dist/indexer/index-written-assets.js +17 -2
- package/dist/indexer/indexer.js +86 -21
- package/dist/indexer/passes/memory-inference.js +4 -0
- package/dist/indexer/search/db-search.js +25 -17
- package/dist/indexer/walk/walker.js +6 -1
- package/dist/integrations/agent/detect.js +13 -1
- package/dist/integrations/agent/engine-resolution.js +24 -11
- package/dist/integrations/agent/model-aliases.js +1 -1
- package/dist/integrations/agent/profiles.js +9 -1
- package/dist/integrations/agent/spawn.js +15 -87
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
- package/dist/integrations/lockfile.js +55 -2
- package/dist/llm/client.js +14 -19
- package/dist/llm/embedder.js +23 -3
- package/dist/llm/embedders/remote.js +27 -2
- package/dist/output/html-render.js +40 -1
- package/dist/output/text/lint-format.js +17 -4
- package/dist/runtime.js +23 -1
- package/dist/scripts/akm-migrate-node.js +1714 -836
- package/dist/scripts/akm-migrate.js +1682 -804
- package/dist/setup/setup.js +22 -7
- package/dist/sources/providers/git-install.js +25 -2
- package/dist/sources/providers/git-stash.js +19 -0
- package/dist/sources/providers/git.js +1 -1
- package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
- package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
- package/dist/storage/database.js +71 -7
- package/dist/storage/engines/sqlite-migrations.js +61 -2
- package/dist/storage/managed-db.js +19 -0
- package/dist/storage/repositories/index-connection.js +39 -4
- package/dist/storage/repositories/index-entries-repository.js +6 -1
- package/dist/storage/repositories/index-meta-repository.js +11 -0
- package/dist/storage/repositories/index-schema.js +17 -2
- package/dist/storage/repositories/index-vec-repository.js +43 -5
- package/dist/storage/repositories/workflow-runs-repository.js +66 -13
- package/dist/storage/sqlite-pragmas.js +12 -1
- package/dist/tasks/log-redaction.js +156 -0
- package/dist/tasks/parser.js +82 -5
- package/dist/tasks/runner.js +222 -17
- package/dist/tasks/scheduler-invocation.js +19 -0
- package/dist/tasks/schema.js +86 -1
- package/dist/text-import-hook.mjs +1 -1
- package/dist/workflows/concurrency-policy.js +95 -1
- package/dist/workflows/exec/dispatch-redaction.js +114 -0
- package/dist/workflows/exec/exec-unit.js +542 -0
- package/dist/workflows/exec/frozen-judge.js +114 -42
- package/dist/workflows/exec/native-executor.js +465 -238
- package/dist/workflows/exec/param-secrets.js +4 -3
- package/dist/workflows/exec/run-workflow.js +424 -219
- package/dist/workflows/exec/step-work.js +506 -167
- package/dist/workflows/exec/unit-dispatch.js +31 -1
- package/dist/workflows/exec/unit-writer.js +53 -13
- package/dist/workflows/exec/worktree.js +454 -41
- package/dist/workflows/ir/compile.js +26 -2
- package/dist/workflows/ir/freeze.js +82 -15
- package/dist/workflows/ir/schema.js +105 -20
- package/dist/workflows/parser.js +242 -19
- package/dist/workflows/program/schema.js +24 -0
- package/dist/workflows/renderer.js +32 -4
- package/dist/workflows/resource-limits.js +182 -0
- package/dist/workflows/runtime/runs.js +146 -6
- package/dist/workflows/validate-summary.js +17 -2
- package/docs/README.md +74 -32
- package/docs/migration/release-notes/0.9.0.md +2 -1
- package/docs/migration/v0.7-to-v0.8.md +2 -1
- package/docs/migration/v0.8-to-v0.9.md +3 -1
- package/docs/reference/README.md +11 -4
- package/docs/reference/bundle-types.md +19 -0
- package/docs/reference/cli.md +105 -16
- package/docs/reference/configuration.md +15 -2
- package/docs/reference/data-and-telemetry.md +30 -10
- package/docs/reference/supported-formats.md +50 -0
- package/docs/reference/workflow-schema.md +1014 -0
- package/docs/reference/workflows.md +37 -633
- package/package.json +13 -6
- package/schemas/akm-config.json +18 -5
- package/schemas/akm-task.json +27 -5
- package/schemas/akm-workflow.json +92 -13
|
@@ -40,8 +40,12 @@ export async function akmTasksAdd(input, deps = {}) {
|
|
|
40
40
|
if (targetCount !== 1) {
|
|
41
41
|
throw new UsageError("Pass exactly one of --workflow <ref>, --prompt <asset-ref|./file.md|text>, or --command <shell-command>.", "INVALID_FLAG_VALUE");
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// `--timeout-ms` IS valid on a workflow task: it is the whole-run bound the
|
|
44
|
+
// task runner turns into an abort signal (issue 11), the same one
|
|
45
|
+
// `akm workflow run --timeout` applies interactively. Engine and model stay
|
|
46
|
+
// prompt-only — a workflow's engines come from its frozen plan.
|
|
47
|
+
if (input.workflow && (input.engine !== undefined || input.model !== undefined)) {
|
|
48
|
+
throw new UsageError("Workflow tasks accept --params and --timeout-ms; engine and model are prompt-task fields.", "INVALID_FLAG_VALUE");
|
|
45
49
|
}
|
|
46
50
|
if (hasCommand && (input.engine !== undefined || input.model !== undefined)) {
|
|
47
51
|
throw new UsageError("Command tasks accept --timeout-ms but not --engine or --model.", "INVALID_FLAG_VALUE");
|
|
@@ -597,6 +601,8 @@ function renderTaskYaml(input) {
|
|
|
597
601
|
if (input.params) {
|
|
598
602
|
obj.params = parseJsonObjectArg(input.params);
|
|
599
603
|
}
|
|
604
|
+
if (input.timeoutMs !== undefined)
|
|
605
|
+
obj.timeoutMs = input.timeoutMs;
|
|
600
606
|
}
|
|
601
607
|
else if (input.prompt) {
|
|
602
608
|
obj.prompt = input.prompt;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { getStringArg } from "../cli/parse-args.js";
|
|
11
11
|
import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output } from "../cli/shared.js";
|
|
12
|
+
import { armAbortDeadline } from "../core/abort-deadline.js";
|
|
12
13
|
import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "../core/asset/asset-create.js";
|
|
13
14
|
import { NotFoundError, UsageError } from "../core/errors.js";
|
|
14
15
|
import { akmIndex } from "../indexer/indexer.js";
|
|
@@ -152,7 +153,6 @@ const workflowRunCommand = defineJsonCommand({
|
|
|
152
153
|
const maxRetries = parseIntegerFlag(getStringArg(args, "max-retries"), "--max-retries", 0, WORKFLOW_MAX_RETRIES);
|
|
153
154
|
const timeoutMs = parseWorkflowTimeout(getStringArg(args, "timeout"));
|
|
154
155
|
const controller = new AbortController();
|
|
155
|
-
let timedOut = false;
|
|
156
156
|
let signalExitCode;
|
|
157
157
|
const interrupt = (signal) => {
|
|
158
158
|
signalExitCode = signal === "SIGINT" ? 130 : 143;
|
|
@@ -162,13 +162,12 @@ const workflowRunCommand = defineJsonCommand({
|
|
|
162
162
|
const onSigterm = () => interrupt("SIGTERM");
|
|
163
163
|
process.once("SIGINT", onSigint);
|
|
164
164
|
process.once("SIGTERM", onSigterm);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
timer?.unref?.();
|
|
165
|
+
// The same deadline a scheduled workflow task arms (`tasks/runner.ts`),
|
|
166
|
+
// sharing this controller with the signal handlers above.
|
|
167
|
+
const deadline = armAbortDeadline(controller, {
|
|
168
|
+
timeoutMs,
|
|
169
|
+
reason: `Workflow run timed out after ${timeoutMs}ms.`,
|
|
170
|
+
});
|
|
172
171
|
try {
|
|
173
172
|
const result = await runWorkflowSteps({
|
|
174
173
|
target: args.target,
|
|
@@ -177,15 +176,22 @@ const workflowRunCommand = defineJsonCommand({
|
|
|
177
176
|
...(maxRetries !== undefined ? { maxRetries } : {}),
|
|
178
177
|
signal: controller.signal,
|
|
179
178
|
});
|
|
179
|
+
// The abort is observed between steps, so a deadline landing in the run's
|
|
180
|
+
// final bookkeeping fires on a run that then finishes. Reporting that as
|
|
181
|
+
// timed out would send an operator to resume a run with nothing left to
|
|
182
|
+
// resume — `tasks/runner.ts` suppresses the same case.
|
|
183
|
+
const timedOut = deadline.timedOut() && result.run.status !== "completed";
|
|
180
184
|
const rendered = { ...result, ...(timedOut ? { timedOut: true } : {}) };
|
|
181
185
|
output("workflow-run", rendered);
|
|
182
|
-
|
|
186
|
+
// `blocked` is a stopped, unverified run — a verification-judge failure
|
|
187
|
+
// leaves it there for `akm workflow resume` — so it must not exit 0 and
|
|
188
|
+
// read as success to a script (it maps to 1 for scheduled tasks too).
|
|
189
|
+
if (result.run.status === "failed" || result.run.status === "blocked" || result.gateRejection || result.aborted) {
|
|
183
190
|
process.exitCode = signalExitCode ?? EXIT_CODES.GENERAL;
|
|
184
191
|
}
|
|
185
192
|
}
|
|
186
193
|
finally {
|
|
187
|
-
|
|
188
|
-
clearTimeout(timer);
|
|
194
|
+
deadline.disarm();
|
|
189
195
|
process.off("SIGINT", onSigint);
|
|
190
196
|
process.off("SIGTERM", onSigterm);
|
|
191
197
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
export function armAbortDeadline(controller, options) {
|
|
5
|
+
const { timeoutMs, reason } = options;
|
|
6
|
+
if (timeoutMs === null || timeoutMs === undefined) {
|
|
7
|
+
return { disarm: () => { }, timedOut: () => false };
|
|
8
|
+
}
|
|
9
|
+
const setTimeoutImpl = options.setTimeoutFn ?? setTimeout;
|
|
10
|
+
const clearTimeoutImpl = options.clearTimeoutFn ?? clearTimeout;
|
|
11
|
+
let fired = false;
|
|
12
|
+
let timer = setTimeoutImpl(() => {
|
|
13
|
+
timer = undefined;
|
|
14
|
+
fired = true;
|
|
15
|
+
controller.abort(new Error(reason));
|
|
16
|
+
}, timeoutMs);
|
|
17
|
+
// A pending deadline must never be the reason the process stays alive.
|
|
18
|
+
timer?.unref?.();
|
|
19
|
+
return {
|
|
20
|
+
disarm: () => {
|
|
21
|
+
if (timer !== undefined) {
|
|
22
|
+
clearTimeoutImpl(timer);
|
|
23
|
+
timer = undefined;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
timedOut: () => fired,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -27,10 +27,13 @@
|
|
|
27
27
|
* - `skill-description-too-long` — description must be 1-1024 chars.
|
|
28
28
|
* - `missing-skill-md` — a package dir with no SKILL.md (edge case; git cannot
|
|
29
29
|
* commit an empty dir, so it is covered by a directory-level check, not a
|
|
30
|
-
* fixture).
|
|
30
|
+
* fixture). Implemented by {@link missingManifestDiagnostics}, which scans
|
|
31
|
+
* the component root through `ValidateContext.list` — the change-set loop
|
|
32
|
+
* cannot reach it, because a change is always a file and a manifest-less
|
|
33
|
+
* package contributes no SKILL.md change (issue #774).
|
|
31
34
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
35
|
+
* The two field codes are APPROVED-BUT-NOT-YET-CODED elsewhere and are
|
|
36
|
+
* implemented here. Base checks are NOT run:
|
|
34
37
|
* a SKILL.md carries no `updated` field, so `missing-updated` would fire on
|
|
35
38
|
* every conformant skill and contradict the lint golden.
|
|
36
39
|
*
|
|
@@ -127,6 +130,79 @@ function skillFieldDiagnostics(relPath, dirName, data) {
|
|
|
127
130
|
}
|
|
128
131
|
return diagnostics;
|
|
129
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* How deep below a candidate package directory the manifest probe looks. Agent
|
|
135
|
+
* Skills packages sit at the component root (`<name>/SKILL.md`), occasionally
|
|
136
|
+
* one group level down (`<group>/<name>/SKILL.md`) — this bound keeps a deep
|
|
137
|
+
* resource tree (a package's `reference/`, `assets/`, …) from turning the lint
|
|
138
|
+
* sweep into a full recursive walk.
|
|
139
|
+
*/
|
|
140
|
+
const MAX_PACKAGE_PROBE_DEPTH = 3;
|
|
141
|
+
function missingManifestDiagnostic(dir) {
|
|
142
|
+
return { file: dir, issue: "missing-skill-md", detail: `no SKILL.md in ${dir}/`, fixed: false };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Classify one directory as a package, a grouping directory, or one broken
|
|
146
|
+
* package candidate. Once a real package root is found, its resource
|
|
147
|
+
* directories are never descended into. If manifests exist only below this
|
|
148
|
+
* directory, it is a group and each sibling candidate is checked independently.
|
|
149
|
+
*/
|
|
150
|
+
async function scanPackageCandidate(dir, entries, ctx, depth) {
|
|
151
|
+
if (entries.includes(SKILL_MANIFEST))
|
|
152
|
+
return { containsManifest: true, diagnostics: [] };
|
|
153
|
+
if (depth >= MAX_PACKAGE_PROBE_DEPTH) {
|
|
154
|
+
return { containsManifest: false, diagnostics: [missingManifestDiagnostic(dir)] };
|
|
155
|
+
}
|
|
156
|
+
const children = [];
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
if (entry.startsWith("."))
|
|
159
|
+
continue;
|
|
160
|
+
const child = `${dir}/${entry}`;
|
|
161
|
+
// `list` on a FILE yields `[]` (the read throws and is swallowed), so an
|
|
162
|
+
// empty listing is the "not a directory worth descending" signal — no
|
|
163
|
+
// separate stat is available on ValidateContext, and none is needed.
|
|
164
|
+
const childEntries = await ctx.list(child);
|
|
165
|
+
if (childEntries.length === 0)
|
|
166
|
+
continue;
|
|
167
|
+
children.push(await scanPackageCandidate(child, childEntries, ctx, depth + 1));
|
|
168
|
+
}
|
|
169
|
+
if (children.some((child) => child.containsManifest)) {
|
|
170
|
+
return {
|
|
171
|
+
containsManifest: true,
|
|
172
|
+
diagnostics: children.flatMap((child) => child.diagnostics),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
// No descendant establishes this as a grouping directory. Diagnose the
|
|
176
|
+
// candidate itself, not its resource subdirectories.
|
|
177
|
+
return { containsManifest: false, diagnostics: [missingManifestDiagnostic(dir)] };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The directory-level `missing-skill-md` check (issue #774).
|
|
181
|
+
*
|
|
182
|
+
* `validate` walks CHANGES, and a change is always a file — so a package
|
|
183
|
+
* directory carrying resources but no manifest contributes nothing the
|
|
184
|
+
* change-loop can see, and the check the spec (§4.5) and the lint golden's
|
|
185
|
+
* `missingSkillMd` edge case both name was unreachable. This scans the
|
|
186
|
+
* component root through {@link ValidateContext.list} instead, so the case is
|
|
187
|
+
* actually reported.
|
|
188
|
+
*
|
|
189
|
+
* A package's own resource dirs (`pdf-processing/reference/`) are part of the
|
|
190
|
+
* item, not candidate packages. Grouping directories are supported too, but
|
|
191
|
+
* their children are classified independently so one valid package cannot hide
|
|
192
|
+
* a manifest-less sibling.
|
|
193
|
+
*/
|
|
194
|
+
async function missingManifestDiagnostics(ctx) {
|
|
195
|
+
const diagnostics = [];
|
|
196
|
+
for (const name of await ctx.list(".")) {
|
|
197
|
+
if (name.startsWith("."))
|
|
198
|
+
continue; // .git, .github, … are not skill packages
|
|
199
|
+
const entries = await ctx.list(name);
|
|
200
|
+
if (entries.length === 0)
|
|
201
|
+
continue; // a root file (README.md), or an untrackable empty dir
|
|
202
|
+
diagnostics.push(...(await scanPackageCandidate(name, entries, ctx, 1)).diagnostics);
|
|
203
|
+
}
|
|
204
|
+
return diagnostics;
|
|
205
|
+
}
|
|
130
206
|
async function validate(_c, changes, ctx) {
|
|
131
207
|
const diagnostics = [];
|
|
132
208
|
const seenDirs = new Set();
|
|
@@ -142,10 +218,12 @@ async function validate(_c, changes, ctx) {
|
|
|
142
218
|
if (seenDirs.has(pkg.conceptId))
|
|
143
219
|
continue;
|
|
144
220
|
seenDirs.add(pkg.conceptId);
|
|
145
|
-
// missing-skill-md
|
|
146
|
-
// case is served by {@link
|
|
221
|
+
// `missing-skill-md` cannot fire here — the change IS a SKILL.md. The
|
|
222
|
+
// manifest-less package case is served by {@link missingManifestDiagnostics}
|
|
223
|
+
// below, which scans directories rather than changes.
|
|
147
224
|
diagnostics.push(...skillFieldDiagnostics(toPosix(change.path), pkg.dirName, parseFrontmatter(raw).data));
|
|
148
225
|
}
|
|
226
|
+
diagnostics.push(...(await missingManifestDiagnostics(ctx)));
|
|
149
227
|
return diagnostics;
|
|
150
228
|
}
|
|
151
229
|
export const agentSkillsAdapter = {
|
|
@@ -80,8 +80,8 @@
|
|
|
80
80
|
*/
|
|
81
81
|
import fs from "node:fs";
|
|
82
82
|
import path from "node:path";
|
|
83
|
-
import { parse as parseYaml } from "yaml";
|
|
84
83
|
import { applyPostContributorFields, applyPreContributorFields, extractPackageMetadata, } from "../../../indexer/passes/metadata.js";
|
|
84
|
+
import { parseTaskYaml, taskYamlParseDetail } from "../../../tasks/schema.js";
|
|
85
85
|
import { assetPathForName, deriveCanonicalAssetNameFromStashRoot, placementTypes, stashDirFor, stashDirNames, } from "../../asset/asset-placement.js";
|
|
86
86
|
import { parseFrontmatter } from "../../asset/frontmatter.js";
|
|
87
87
|
import { recognizeMatch } from "../recognize-match.js";
|
|
@@ -380,16 +380,19 @@ async function validate(c, changes, ctx) {
|
|
|
380
380
|
// everything else → `parseFrontmatter`.
|
|
381
381
|
let parsed;
|
|
382
382
|
if (type === "task") {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
383
|
+
const task = parseTaskYaml(raw);
|
|
384
|
+
// A parse failure is its OWN finding: every task rule short-circuits on
|
|
385
|
+
// an empty mapping, so collapsing "unparseable" onto `{}` made a broken
|
|
386
|
+
// task file validate clean (issue #760). Mirrors the CLI sweep.
|
|
387
|
+
if (!task.ok) {
|
|
388
|
+
diagnostics.push({
|
|
389
|
+
file: change.path,
|
|
390
|
+
issue: "invalid-task-yaml",
|
|
391
|
+
detail: taskYamlParseDetail(task.error),
|
|
392
|
+
fixed: false,
|
|
393
|
+
});
|
|
388
394
|
}
|
|
389
|
-
|
|
390
|
-
data = {};
|
|
391
|
-
}
|
|
392
|
-
parsed = { data, content: raw, frontmatter: null };
|
|
395
|
+
parsed = { data: task.data, content: raw, frontmatter: null };
|
|
393
396
|
}
|
|
394
397
|
else {
|
|
395
398
|
const p = parseFrontmatter(raw);
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
*/
|
|
53
53
|
import path from "node:path";
|
|
54
54
|
import { isDangerousEnvKey } from "../../../commands/lint/env-key-rules.js";
|
|
55
|
-
import { taskFieldProblems } from "../../../tasks/schema.js";
|
|
55
|
+
import { isPresentTarget, taskFieldProblems } from "../../../tasks/schema.js";
|
|
56
56
|
import { compileWorkflowPlan } from "../../../workflows/ir/compile.js";
|
|
57
57
|
import { parseWorkflow } from "../../../workflows/parser.js";
|
|
58
58
|
import { conceptIdForStashFile } from "../../asset/resolve-ref.js";
|
|
@@ -187,20 +187,29 @@ export function dangerousEnvKeyDiagnostics(type, relPath, raw) {
|
|
|
187
187
|
return diagnostics;
|
|
188
188
|
}
|
|
189
189
|
// ── skill directory check (SkillLinter.lintDirectory) ────────────────────────
|
|
190
|
+
/** The akm-native skill placement dir — the default gate for {@link skillDirectoryDiagnostics}. */
|
|
191
|
+
const AKM_SKILL_DIRS = new Set(["skills"]);
|
|
190
192
|
/**
|
|
191
193
|
* Reproduce `SkillLinter.lintDirectory` (`skill-linter.ts:31-45`) in the
|
|
192
|
-
* change-set model: for a change under
|
|
193
|
-
* when
|
|
194
|
-
* dir with multiple changed files reports once
|
|
195
|
-
* `file`/`detail` mirror the live check exactly
|
|
194
|
+
* change-set model: for a change under `<skillDir>/<name>/…`, emit
|
|
195
|
+
* `missing-skill-md` when `<skillDir>/<name>/SKILL.md` is absent from the
|
|
196
|
+
* overlay. `seen` dedups so a dir with multiple changed files reports once
|
|
197
|
+
* (matching the per-subdir call). `file`/`detail` mirror the live check exactly
|
|
198
|
+
* (relDir + `no SKILL.md in <relDir>/`).
|
|
199
|
+
*
|
|
200
|
+
* `skillDirs` defaults to the akm-native `skills/` placement dir. The tool-dir
|
|
201
|
+
* adapters pass their OWN accepted spellings, because opencode also accepts the
|
|
202
|
+
* singular `skill/` alias on read (`opencode-adapter.ts` LAYOUT) — with the
|
|
203
|
+
* gate hardcoded to `"skills"`, an identical manifest-less package went flagged
|
|
204
|
+
* under `skills/` and unflagged under `skill/` (issue #774).
|
|
196
205
|
*/
|
|
197
|
-
export async function skillDirectoryDiagnostics(relPath, seen, ctx) {
|
|
206
|
+
export async function skillDirectoryDiagnostics(relPath, seen, ctx, skillDirs = AKM_SKILL_DIRS) {
|
|
198
207
|
const segments = relPath
|
|
199
208
|
.replace(/\\/g, "/")
|
|
200
209
|
.split("/")
|
|
201
210
|
.filter((s) => s.length > 0);
|
|
202
|
-
if (segments.length < 3 || segments[0]
|
|
203
|
-
return []; // must be
|
|
211
|
+
if (segments.length < 3 || !skillDirs.has(segments[0]))
|
|
212
|
+
return []; // must be <skillDir>/<name>/<file…>
|
|
204
213
|
const skillDir = `${segments[0]}/${segments[1]}`;
|
|
205
214
|
if (seen.has(skillDir))
|
|
206
215
|
return [];
|
|
@@ -274,7 +283,11 @@ export function taskDiagnostics(relPath, data) {
|
|
|
274
283
|
if (data === null || Object.keys(data).length === 0)
|
|
275
284
|
return [];
|
|
276
285
|
const missing = taskFieldProblems(data);
|
|
277
|
-
|
|
286
|
+
// Presence, matching the runtime parser's rule (src/tasks/parser.ts): an
|
|
287
|
+
// empty string is NOT a target there, so a `workflow: ""` that linted clean
|
|
288
|
+
// here failed at run time with MISSING_REQUIRED_ARGUMENT — a file the linter
|
|
289
|
+
// called valid but that could never run.
|
|
290
|
+
const hasTarget = ["prompt", "workflow", "command"].some((key) => isPresentTarget(data[key]));
|
|
278
291
|
if (!hasTarget)
|
|
279
292
|
missing.push("prompt, workflow, or command");
|
|
280
293
|
if (missing.length > 0) {
|
|
@@ -300,51 +313,94 @@ export function matchWorkflowPlaceholder(body) {
|
|
|
300
313
|
}
|
|
301
314
|
/**
|
|
302
315
|
* WorkflowLinter's `invalid-workflow-structure` check (`workflow-linter.ts:48-77`):
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
* legacy behavior). NEVER writes.
|
|
316
|
+
* the ERROR half of {@link workflowFrontendDiagnostics}, for callers that only
|
|
317
|
+
* ever surface fatal findings — the read-only adapter `validate` path and
|
|
318
|
+
* `akm migrate`'s stale-workflow probe. A caller that ALSO surfaces the
|
|
319
|
+
* advisories must call {@link workflowFrontendDiagnostics} once instead of
|
|
320
|
+
* pairing this with a second view. NEVER writes.
|
|
309
321
|
*/
|
|
310
322
|
export function workflowStructureDiagnostics(relPath, raw, parsePath) {
|
|
323
|
+
return workflowFrontendDiagnostics(relPath, raw, parsePath).errors;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The `Diagnostic.line` fragment for a line-anchored workflow finding. Every
|
|
327
|
+
* `WorkflowError` carries a 1-indexed `line`; this used to be DROPPED here, so
|
|
328
|
+
* an author linting a 300-line workflow got a message with no location while
|
|
329
|
+
* the same error rendered as `path:line — message` on the `workflow create`
|
|
330
|
+
* path. Spread (`...lineOf(err)`) rather than assigned, so a nonsense line
|
|
331
|
+
* never materializes the optional key on a whole-file finding.
|
|
332
|
+
*/
|
|
333
|
+
function lineOf(err) {
|
|
334
|
+
return typeof err.line === "number" && Number.isFinite(err.line) && err.line > 0 ? { line: err.line } : {};
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* ONE parse+compile of a workflow through the unified frontend, returning both
|
|
338
|
+
* halves of what it produces: fatal `invalid-workflow-structure` findings, and
|
|
339
|
+
* `compileWorkflowPlan`'s non-fatal `workflow-warning` advisories (a step with
|
|
340
|
+
* no `output:` schema, a reference to an undeclared param). The read-only
|
|
341
|
+
* `/.cache/` + `/registry/` cached copies are skipped, and nothing is written.
|
|
342
|
+
*
|
|
343
|
+
* `parsePath` is the path handed to `parseWorkflow` (the adapter passes the
|
|
344
|
+
* change relPath, the CLI passes the absolute filePath — matching each
|
|
345
|
+
* caller's legacy behavior).
|
|
346
|
+
*
|
|
347
|
+
* A caller that surfaces BOTH halves must call this once and route the result
|
|
348
|
+
* itself. The frontend is expensive — instruction bodies reach
|
|
349
|
+
* `WORKFLOW_MAX_INSTRUCTION_BYTES` — so asking for each half through its own
|
|
350
|
+
* view parses and compiles every workflow in the stash twice.
|
|
351
|
+
*/
|
|
352
|
+
export function workflowFrontendDiagnostics(relPath, raw, parsePath) {
|
|
353
|
+
const none = { errors: [], warnings: [] };
|
|
311
354
|
if (parsePath.includes("/.cache/") || parsePath.includes("/registry/"))
|
|
312
|
-
return
|
|
313
|
-
const
|
|
355
|
+
return none;
|
|
356
|
+
const errors = [];
|
|
357
|
+
const warnings = [];
|
|
314
358
|
try {
|
|
315
359
|
const result = parseWorkflow(raw, { path: parsePath });
|
|
316
360
|
if (!result.ok) {
|
|
317
361
|
for (const err of result.errors ?? []) {
|
|
318
|
-
|
|
362
|
+
errors.push({
|
|
319
363
|
file: relPath,
|
|
320
364
|
issue: "invalid-workflow-structure",
|
|
321
365
|
detail: err.message ?? String(err),
|
|
322
366
|
fixed: false,
|
|
367
|
+
...lineOf(err),
|
|
323
368
|
});
|
|
324
369
|
}
|
|
325
|
-
return
|
|
370
|
+
return { errors, warnings };
|
|
326
371
|
}
|
|
327
372
|
const compiled = compileWorkflowPlan(result.document, path.basename(parsePath, path.extname(parsePath)));
|
|
328
373
|
if (!compiled.ok) {
|
|
329
374
|
for (const err of compiled.errors) {
|
|
330
|
-
|
|
375
|
+
errors.push({
|
|
331
376
|
file: relPath,
|
|
332
377
|
issue: "invalid-workflow-structure",
|
|
333
378
|
detail: err.message,
|
|
334
379
|
fixed: false,
|
|
380
|
+
...lineOf(err),
|
|
335
381
|
});
|
|
336
382
|
}
|
|
383
|
+
return { errors, warnings };
|
|
384
|
+
}
|
|
385
|
+
for (const warning of compiled.warnings) {
|
|
386
|
+
warnings.push({
|
|
387
|
+
file: relPath,
|
|
388
|
+
issue: "workflow-warning",
|
|
389
|
+
detail: warning.message,
|
|
390
|
+
fixed: false,
|
|
391
|
+
...lineOf(warning),
|
|
392
|
+
});
|
|
337
393
|
}
|
|
338
394
|
}
|
|
339
395
|
catch (e) {
|
|
340
|
-
|
|
396
|
+
errors.push({
|
|
341
397
|
file: relPath,
|
|
342
398
|
issue: "invalid-workflow-structure",
|
|
343
399
|
detail: `workflow parser error: ${e instanceof Error ? e.message : String(e)}`,
|
|
344
400
|
fixed: false,
|
|
345
401
|
});
|
|
346
402
|
}
|
|
347
|
-
return
|
|
403
|
+
return { errors, warnings };
|
|
348
404
|
}
|
|
349
405
|
/**
|
|
350
406
|
* WorkflowLinter extra checks (`workflow-linter.ts:22-79`), READ-ONLY:
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* invalid task (e.g. two targets) is still RECOGNIZED; the `invalid-task-yaml`
|
|
11
11
|
* violation surfaces only in `validate`.
|
|
12
12
|
*
|
|
13
|
+
* `.yaml` is the one extension `validate` inspects but `recognize` refuses: it
|
|
14
|
+
* is not a task spelling (nothing indexes or schedules it), so it is reported
|
|
15
|
+
* as `invalid-task-yaml` rather than silently skipped (issue #760).
|
|
16
|
+
*
|
|
13
17
|
* ── validate (spec §6 task validation column) ──
|
|
14
18
|
*
|
|
15
19
|
* A task must declare `version: 2`, a `schedule`, and EXACTLY ONE target
|
|
@@ -26,13 +30,12 @@
|
|
|
26
30
|
*/
|
|
27
31
|
import fs from "node:fs";
|
|
28
32
|
import path from "node:path";
|
|
29
|
-
import {
|
|
30
|
-
import { taskFieldProblems } from "../../../tasks/schema.js";
|
|
33
|
+
import { isPresentTarget, parseTaskYaml, TASK_EXTENSION, TASK_NEAR_MISS_EXTENSION, taskExtensionDetail, taskFieldProblems, taskYamlParseDetail, } from "../../../tasks/schema.js";
|
|
31
34
|
import { hashContent } from "./shared.js";
|
|
32
35
|
/** A native task bundle is single-component; its one component is `main`. */
|
|
33
36
|
const COMPONENT_ID = "main";
|
|
34
37
|
/** The task YAML extension (spec §6 task row). */
|
|
35
|
-
const TASK_EXT =
|
|
38
|
+
const TASK_EXT = TASK_EXTENSION;
|
|
36
39
|
/** The mutually-exclusive task target keys (exactly one required). */
|
|
37
40
|
const TARGET_KEYS = ["prompt", "workflow", "command"];
|
|
38
41
|
/** Upper bound on the bounded `content` FTS field (mirrors okf-adapter). */
|
|
@@ -59,18 +62,6 @@ function recognize(c, file) {
|
|
|
59
62
|
content: raw.length > MAX_CONTENT_CHARS ? raw.slice(0, MAX_CONTENT_CHARS) : raw,
|
|
60
63
|
};
|
|
61
64
|
}
|
|
62
|
-
/** Parse a task YAML into a plain record (tolerant: malformed / non-mapping → {}). */
|
|
63
|
-
function parseTaskYaml(raw) {
|
|
64
|
-
try {
|
|
65
|
-
const doc = parseYaml(raw);
|
|
66
|
-
if (doc && typeof doc === "object" && !Array.isArray(doc))
|
|
67
|
-
return doc;
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
// malformed YAML
|
|
71
|
-
}
|
|
72
|
-
return {};
|
|
73
|
-
}
|
|
74
65
|
/**
|
|
75
66
|
* The native `invalid-task-yaml` check: the shared field rules
|
|
76
67
|
* ({@link taskFieldProblems} — see its doc for the lint-vs-parser
|
|
@@ -80,7 +71,9 @@ function taskDiagnostics(relPath, data) {
|
|
|
80
71
|
if (Object.keys(data).length === 0)
|
|
81
72
|
return [];
|
|
82
73
|
const problems = taskFieldProblems(data);
|
|
83
|
-
|
|
74
|
+
// Shared presence rule (src/tasks/schema.ts): an empty string or empty array
|
|
75
|
+
// is not a target, matching the runtime parser.
|
|
76
|
+
const targets = TARGET_KEYS.filter((k) => isPresentTarget(data[k]));
|
|
84
77
|
if (targets.length === 0)
|
|
85
78
|
problems.push("exactly one target (prompt, workflow, or command)");
|
|
86
79
|
else if (targets.length > 1)
|
|
@@ -99,16 +92,46 @@ async function validate(_c, changes, ctx) {
|
|
|
99
92
|
const raw = change.after ?? (await ctx.readFile(change.path));
|
|
100
93
|
if (typeof raw !== "string")
|
|
101
94
|
continue;
|
|
102
|
-
|
|
95
|
+
const ext = path.extname(change.path).toLowerCase();
|
|
96
|
+
// `.yaml` is NOT a task extension — the file never indexes and never runs.
|
|
97
|
+
// It is validated here purely so the near miss is REPORTED rather than
|
|
98
|
+
// skipped the way every other extension is (issue #760).
|
|
99
|
+
if (ext !== TASK_EXT && ext !== TASK_NEAR_MISS_EXTENSION)
|
|
103
100
|
continue;
|
|
104
|
-
|
|
101
|
+
const relPath = toPosix(change.path);
|
|
102
|
+
if (ext === TASK_NEAR_MISS_EXTENSION) {
|
|
103
|
+
diagnostics.push({
|
|
104
|
+
file: relPath,
|
|
105
|
+
issue: "invalid-task-yaml",
|
|
106
|
+
detail: taskExtensionDetail(relPath),
|
|
107
|
+
fixed: false,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const parsed = parseTaskYaml(raw);
|
|
111
|
+
if (!parsed.ok) {
|
|
112
|
+
// Distinguish "unparseable" from "empty": `taskDiagnostics` returns []
|
|
113
|
+
// for an empty mapping, so collapsing a parse failure onto `{}` made a
|
|
114
|
+
// broken task file lint clean.
|
|
115
|
+
diagnostics.push({
|
|
116
|
+
file: relPath,
|
|
117
|
+
issue: "invalid-task-yaml",
|
|
118
|
+
detail: taskYamlParseDetail(parsed.error),
|
|
119
|
+
fixed: false,
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
diagnostics.push(...taskDiagnostics(relPath, parsed.data));
|
|
105
124
|
}
|
|
106
125
|
return diagnostics;
|
|
107
126
|
}
|
|
108
127
|
export const akmTaskAdapter = {
|
|
109
128
|
id: "akm-task",
|
|
110
129
|
version: "0.9.0",
|
|
111
|
-
|
|
130
|
+
// `.yaml` is listed as a COLLECTION hint only — `recognize` still gates on
|
|
131
|
+
// `.yml`, so a `.yaml` file is never indexed as a task. Listing it is what
|
|
132
|
+
// routes the near-miss file into `validate`, where it is reported instead of
|
|
133
|
+
// silently skipped (issue #760).
|
|
134
|
+
extensions: [TASK_EXT, TASK_NEAR_MISS_EXTENSION],
|
|
112
135
|
recognize,
|
|
113
136
|
validate,
|
|
114
137
|
/** A task places to `<conceptId>.yml`; an already-suffixed conceptId is idempotent. */
|
|
@@ -143,7 +166,7 @@ export const akmTaskAdapter = {
|
|
|
143
166
|
catch {
|
|
144
167
|
continue;
|
|
145
168
|
}
|
|
146
|
-
const data = parseTaskYaml(raw);
|
|
169
|
+
const { data } = parseTaskYaml(raw);
|
|
147
170
|
if (typeof data.schedule === "string" && data.schedule.trim() !== "")
|
|
148
171
|
return true;
|
|
149
172
|
}
|
|
@@ -61,6 +61,25 @@ function classify(relPath) {
|
|
|
61
61
|
}
|
|
62
62
|
return null;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* True when `--sensitive` marked this asset, via the sibling marker file that
|
|
66
|
+
* `akm env create --sensitive` / `akm secret create --sensitive` writes:
|
|
67
|
+
* `env/<name>.sensitive` for `env/<name>.env`, `secrets/<name>.sensitive` for
|
|
68
|
+
* `secrets/<name>`.
|
|
69
|
+
*
|
|
70
|
+
* The flag documents itself as excluding the asset from BOTH `env list` output
|
|
71
|
+
* and the search index. Indexing filters are adapter-owned (the walk no longer
|
|
72
|
+
* pre-filters), and the akm adapter abstains on the marker — but this adapter
|
|
73
|
+
* only skipped files whose OWN name ended in `.sensitive`. A dotenv bundle is a
|
|
74
|
+
* legal env/secret write target, so a marked `env/prod.env` there was still
|
|
75
|
+
* indexed with every KEY NAME as a hint and a marked secret still indexed by
|
|
76
|
+
* name, while `env list` / `secret list` correctly hid them. The two surfaces
|
|
77
|
+
* disagreed about a documented promise.
|
|
78
|
+
*/
|
|
79
|
+
function hasSensitiveMarker(absPath, type) {
|
|
80
|
+
const marker = type === "env" ? absPath.replace(/\.env$/i, ".sensitive") : `${absPath}.sensitive`;
|
|
81
|
+
return marker !== absPath && fs.existsSync(marker);
|
|
82
|
+
}
|
|
64
83
|
/** Extract KEY NAMES (never values) from an env file's raw content, first-appearance order, deduped. */
|
|
65
84
|
function scanKeyNames(raw) {
|
|
66
85
|
const keys = [];
|
|
@@ -81,6 +100,8 @@ function recognize(c, file) {
|
|
|
81
100
|
const type = classify(file.relPath);
|
|
82
101
|
if (type === null)
|
|
83
102
|
return null;
|
|
103
|
+
if (hasSensitiveMarker(file.absPath, type))
|
|
104
|
+
return null;
|
|
84
105
|
const posix = toPosix(file.relPath);
|
|
85
106
|
const raw = file.content();
|
|
86
107
|
if (type === "env") {
|
|
@@ -185,9 +185,11 @@ export async function validateToolDir(layout, c, changes, ctx) {
|
|
|
185
185
|
continue;
|
|
186
186
|
const relPath = toPosix(change.path);
|
|
187
187
|
// The one coded skill check (missing-skill-md) fires on ANY change under a
|
|
188
|
-
//
|
|
189
|
-
// mirrors the akm adapter's per-change SkillLinter.lintDirectory
|
|
190
|
-
|
|
188
|
+
// `<skillDir>/<name>/…` package (self-gated + deduped), even a bundled
|
|
189
|
+
// resource — mirrors the akm adapter's per-change SkillLinter.lintDirectory
|
|
190
|
+
// pass. `layout.skillDirs` is passed so opencode's singular `skill/` alias
|
|
191
|
+
// is checked identically to `skills/` (issue #774).
|
|
192
|
+
diagnostics.push(...(await skillDirectoryDiagnostics(relPath, seenSkillDirs, ctx, layout.skillDirs)));
|
|
191
193
|
const cls = classify(change.path, layout);
|
|
192
194
|
if (cls === null)
|
|
193
195
|
continue;
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
13
|
+
import { existingFileMode, writeFileAtomic } from "../common.js";
|
|
14
|
+
import { recordWrittenPath } from "../write-provenance.js";
|
|
13
15
|
import { assembleAsset, serializeFrontmatter } from "./asset-serialize.js";
|
|
14
16
|
/**
|
|
15
17
|
* Parse YAML frontmatter from a Markdown (or similar) string.
|
|
@@ -142,7 +144,14 @@ export function mutateFrontmatter(filePath, mutator) {
|
|
|
142
144
|
const next = parsed.frontmatter !== null
|
|
143
145
|
? `---\n${serializeFrontmatter(nextFrontmatter)}\n---\n${parsed.content}`
|
|
144
146
|
: assembleAsset(nextFrontmatter, parsed.content);
|
|
145
|
-
|
|
147
|
+
// Atomic, like the canonical asset write: this rewrites a file the user
|
|
148
|
+
// authored, and a truncate-in-place left a window where a crash or a
|
|
149
|
+
// concurrent reader saw a half-written or empty asset. The existing mode is
|
|
150
|
+
// preserved so stamping frontmatter never changes an asset's permissions.
|
|
151
|
+
writeFileAtomic(filePath, next, existingFileMode(filePath));
|
|
152
|
+
// #652: in-place frontmatter stamps (belief state, contradiction markers,
|
|
153
|
+
// salience) are real asset mutations — journal them for the run's sync.
|
|
154
|
+
recordWrittenPath(filePath);
|
|
146
155
|
return true;
|
|
147
156
|
}
|
|
148
157
|
export function parseFrontmatterBlock(raw) {
|