akm-cli 0.9.0 → 0.9.1-beta.1
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 +707 -0
- package/README.md +28 -63
- package/STABILITY.md +4 -2
- package/dist/commands/agent/contribute-cli.js +1 -1
- 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/improve.js +126 -10
- package/dist/commands/improve/locks.js +7 -0
- package/dist/commands/improve/memory/memory-improve.js +8 -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 +200 -50
- package/dist/commands/lint/types.js +22 -1
- package/dist/commands/proposal/repository.js +17 -1
- package/dist/commands/sources/info.js +12 -2
- package/dist/commands/sources/installed-stashes.js +6 -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 +67 -5
- package/dist/core/adapter/adapters/akm-adapter.js +13 -10
- package/dist/core/adapter/adapters/akm-lint.js +72 -20
- package/dist/core/adapter/adapters/akm-task-adapter.js +40 -19
- package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
- package/dist/core/asset/frontmatter.js +4 -0
- package/dist/core/common.js +66 -6
- package/dist/core/concurrent.js +32 -0
- 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/file-lock.js +7 -1
- package/dist/core/improve-result.js +5 -0
- package/dist/core/json-schema.js +327 -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/redaction.js +64 -17
- package/dist/core/spawn-env.js +234 -0
- package/dist/core/state-db-scope.js +134 -0
- package/dist/core/subprocess.js +181 -37
- package/dist/core/write-provenance.js +85 -0
- package/dist/core/write-source.js +7 -0
- 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 +55 -15
- package/dist/indexer/passes/memory-inference.js +4 -0
- package/dist/indexer/search/db-search.js +8 -15
- 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/lockfile.js +45 -2
- package/dist/output/text/lint-format.js +17 -4
- package/dist/scripts/akm-migrate-node.js +1435 -753
- package/dist/scripts/akm-migrate.js +1403 -721
- package/dist/sources/providers/git-stash.js +19 -0
- package/dist/sources/providers/git.js +1 -1
- package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
- package/dist/storage/database.js +6 -1
- package/dist/storage/managed-db.js +19 -0
- package/dist/storage/repositories/index-connection.js +28 -3
- package/dist/storage/repositories/index-entries-repository.js +6 -1
- package/dist/storage/repositories/workflow-runs-repository.js +66 -13
- package/dist/tasks/log-redaction.js +156 -0
- package/dist/tasks/parser.js +82 -5
- package/dist/tasks/runner.js +139 -11
- package/dist/tasks/schema.js +65 -0
- 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 +457 -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 +496 -165
- 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 +216 -18
- 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 +8 -0
- package/schemas/akm-task.json +27 -5
- package/schemas/akm-workflow.json +85 -10
|
@@ -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,63 @@ 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
|
+
/** True when `SKILL.md` exists at `dir` or anywhere within {@link MAX_PACKAGE_PROBE_DEPTH} below it. */
|
|
142
|
+
async function subtreeHasManifest(dir, entries, ctx, depth) {
|
|
143
|
+
if (entries.includes(SKILL_MANIFEST))
|
|
144
|
+
return true;
|
|
145
|
+
if (depth >= MAX_PACKAGE_PROBE_DEPTH)
|
|
146
|
+
return false;
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
const child = `${dir}/${entry}`;
|
|
149
|
+
// `list` on a FILE yields `[]` (the read throws and is swallowed), so an
|
|
150
|
+
// empty listing is the "not a directory worth descending" signal — no
|
|
151
|
+
// separate stat is available on ValidateContext, and none is needed.
|
|
152
|
+
const childEntries = await ctx.list(child);
|
|
153
|
+
if (childEntries.length === 0)
|
|
154
|
+
continue;
|
|
155
|
+
if (await subtreeHasManifest(child, childEntries, ctx, depth + 1))
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The directory-level `missing-skill-md` check (issue #774).
|
|
162
|
+
*
|
|
163
|
+
* `validate` walks CHANGES, and a change is always a file — so a package
|
|
164
|
+
* directory carrying resources but no manifest contributes nothing the
|
|
165
|
+
* change-loop can see, and the check the spec (§4.5) and the lint golden's
|
|
166
|
+
* `missingSkillMd` edge case both name was unreachable. This scans the
|
|
167
|
+
* component root through {@link ValidateContext.list} instead, so the case is
|
|
168
|
+
* actually reported.
|
|
169
|
+
*
|
|
170
|
+
* Deliberately TOP-LEVEL only: a package's own resource dirs
|
|
171
|
+
* (`pdf-processing/reference/`) are part of the item, not candidate packages,
|
|
172
|
+
* and flagging them would turn every conformant bundle red. A top-level dir
|
|
173
|
+
* that holds a manifest ANYWHERE beneath it is a grouping dir, not a broken
|
|
174
|
+
* package, so it is left alone too.
|
|
175
|
+
*/
|
|
176
|
+
async function missingManifestDiagnostics(ctx) {
|
|
177
|
+
const diagnostics = [];
|
|
178
|
+
for (const name of await ctx.list(".")) {
|
|
179
|
+
if (name.startsWith("."))
|
|
180
|
+
continue; // .git, .github, … are not skill packages
|
|
181
|
+
const entries = await ctx.list(name);
|
|
182
|
+
if (entries.length === 0)
|
|
183
|
+
continue; // a root file (README.md), or an untrackable empty dir
|
|
184
|
+
if (await subtreeHasManifest(name, entries, ctx, 1))
|
|
185
|
+
continue;
|
|
186
|
+
diagnostics.push({ file: name, issue: "missing-skill-md", detail: `no SKILL.md in ${name}/`, fixed: false });
|
|
187
|
+
}
|
|
188
|
+
return diagnostics;
|
|
189
|
+
}
|
|
130
190
|
async function validate(_c, changes, ctx) {
|
|
131
191
|
const diagnostics = [];
|
|
132
192
|
const seenDirs = new Set();
|
|
@@ -142,10 +202,12 @@ async function validate(_c, changes, ctx) {
|
|
|
142
202
|
if (seenDirs.has(pkg.conceptId))
|
|
143
203
|
continue;
|
|
144
204
|
seenDirs.add(pkg.conceptId);
|
|
145
|
-
// missing-skill-md
|
|
146
|
-
// case is served by {@link
|
|
205
|
+
// `missing-skill-md` cannot fire here — the change IS a SKILL.md. The
|
|
206
|
+
// manifest-less package case is served by {@link missingManifestDiagnostics}
|
|
207
|
+
// below, which scans directories rather than changes.
|
|
147
208
|
diagnostics.push(...skillFieldDiagnostics(toPosix(change.path), pkg.dirName, parseFrontmatter(raw).data));
|
|
148
209
|
}
|
|
210
|
+
diagnostics.push(...(await missingManifestDiagnostics(ctx)));
|
|
149
211
|
return diagnostics;
|
|
150
212
|
}
|
|
151
213
|
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);
|
|
@@ -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 [];
|
|
@@ -300,51 +309,94 @@ export function matchWorkflowPlaceholder(body) {
|
|
|
300
309
|
}
|
|
301
310
|
/**
|
|
302
311
|
* WorkflowLinter's `invalid-workflow-structure` check (`workflow-linter.ts:48-77`):
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
* legacy behavior). NEVER writes.
|
|
312
|
+
* the ERROR half of {@link workflowFrontendDiagnostics}, for callers that only
|
|
313
|
+
* ever surface fatal findings — the read-only adapter `validate` path and
|
|
314
|
+
* `akm migrate`'s stale-workflow probe. A caller that ALSO surfaces the
|
|
315
|
+
* advisories must call {@link workflowFrontendDiagnostics} once instead of
|
|
316
|
+
* pairing this with a second view. NEVER writes.
|
|
309
317
|
*/
|
|
310
318
|
export function workflowStructureDiagnostics(relPath, raw, parsePath) {
|
|
319
|
+
return workflowFrontendDiagnostics(relPath, raw, parsePath).errors;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* The `Diagnostic.line` fragment for a line-anchored workflow finding. Every
|
|
323
|
+
* `WorkflowError` carries a 1-indexed `line`; this used to be DROPPED here, so
|
|
324
|
+
* an author linting a 300-line workflow got a message with no location while
|
|
325
|
+
* the same error rendered as `path:line — message` on the `workflow create`
|
|
326
|
+
* path. Spread (`...lineOf(err)`) rather than assigned, so a nonsense line
|
|
327
|
+
* never materializes the optional key on a whole-file finding.
|
|
328
|
+
*/
|
|
329
|
+
function lineOf(err) {
|
|
330
|
+
return typeof err.line === "number" && Number.isFinite(err.line) && err.line > 0 ? { line: err.line } : {};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* ONE parse+compile of a workflow through the unified frontend, returning both
|
|
334
|
+
* halves of what it produces: fatal `invalid-workflow-structure` findings, and
|
|
335
|
+
* `compileWorkflowPlan`'s non-fatal `workflow-warning` advisories (a step with
|
|
336
|
+
* no `output:` schema, a reference to an undeclared param). The read-only
|
|
337
|
+
* `/.cache/` + `/registry/` cached copies are skipped, and nothing is written.
|
|
338
|
+
*
|
|
339
|
+
* `parsePath` is the path handed to `parseWorkflow` (the adapter passes the
|
|
340
|
+
* change relPath, the CLI passes the absolute filePath — matching each
|
|
341
|
+
* caller's legacy behavior).
|
|
342
|
+
*
|
|
343
|
+
* A caller that surfaces BOTH halves must call this once and route the result
|
|
344
|
+
* itself. The frontend is expensive — instruction bodies reach
|
|
345
|
+
* `WORKFLOW_MAX_INSTRUCTION_BYTES` — so asking for each half through its own
|
|
346
|
+
* view parses and compiles every workflow in the stash twice.
|
|
347
|
+
*/
|
|
348
|
+
export function workflowFrontendDiagnostics(relPath, raw, parsePath) {
|
|
349
|
+
const none = { errors: [], warnings: [] };
|
|
311
350
|
if (parsePath.includes("/.cache/") || parsePath.includes("/registry/"))
|
|
312
|
-
return
|
|
313
|
-
const
|
|
351
|
+
return none;
|
|
352
|
+
const errors = [];
|
|
353
|
+
const warnings = [];
|
|
314
354
|
try {
|
|
315
355
|
const result = parseWorkflow(raw, { path: parsePath });
|
|
316
356
|
if (!result.ok) {
|
|
317
357
|
for (const err of result.errors ?? []) {
|
|
318
|
-
|
|
358
|
+
errors.push({
|
|
319
359
|
file: relPath,
|
|
320
360
|
issue: "invalid-workflow-structure",
|
|
321
361
|
detail: err.message ?? String(err),
|
|
322
362
|
fixed: false,
|
|
363
|
+
...lineOf(err),
|
|
323
364
|
});
|
|
324
365
|
}
|
|
325
|
-
return
|
|
366
|
+
return { errors, warnings };
|
|
326
367
|
}
|
|
327
368
|
const compiled = compileWorkflowPlan(result.document, path.basename(parsePath, path.extname(parsePath)));
|
|
328
369
|
if (!compiled.ok) {
|
|
329
370
|
for (const err of compiled.errors) {
|
|
330
|
-
|
|
371
|
+
errors.push({
|
|
331
372
|
file: relPath,
|
|
332
373
|
issue: "invalid-workflow-structure",
|
|
333
374
|
detail: err.message,
|
|
334
375
|
fixed: false,
|
|
376
|
+
...lineOf(err),
|
|
335
377
|
});
|
|
336
378
|
}
|
|
379
|
+
return { errors, warnings };
|
|
380
|
+
}
|
|
381
|
+
for (const warning of compiled.warnings) {
|
|
382
|
+
warnings.push({
|
|
383
|
+
file: relPath,
|
|
384
|
+
issue: "workflow-warning",
|
|
385
|
+
detail: warning.message,
|
|
386
|
+
fixed: false,
|
|
387
|
+
...lineOf(warning),
|
|
388
|
+
});
|
|
337
389
|
}
|
|
338
390
|
}
|
|
339
391
|
catch (e) {
|
|
340
|
-
|
|
392
|
+
errors.push({
|
|
341
393
|
file: relPath,
|
|
342
394
|
issue: "invalid-workflow-structure",
|
|
343
395
|
detail: `workflow parser error: ${e instanceof Error ? e.message : String(e)}`,
|
|
344
396
|
fixed: false,
|
|
345
397
|
});
|
|
346
398
|
}
|
|
347
|
-
return
|
|
399
|
+
return { errors, warnings };
|
|
348
400
|
}
|
|
349
401
|
/**
|
|
350
402
|
* 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 { 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
|
|
@@ -99,16 +90,46 @@ async function validate(_c, changes, ctx) {
|
|
|
99
90
|
const raw = change.after ?? (await ctx.readFile(change.path));
|
|
100
91
|
if (typeof raw !== "string")
|
|
101
92
|
continue;
|
|
102
|
-
|
|
93
|
+
const ext = path.extname(change.path).toLowerCase();
|
|
94
|
+
// `.yaml` is NOT a task extension — the file never indexes and never runs.
|
|
95
|
+
// It is validated here purely so the near miss is REPORTED rather than
|
|
96
|
+
// skipped the way every other extension is (issue #760).
|
|
97
|
+
if (ext !== TASK_EXT && ext !== TASK_NEAR_MISS_EXTENSION)
|
|
103
98
|
continue;
|
|
104
|
-
|
|
99
|
+
const relPath = toPosix(change.path);
|
|
100
|
+
if (ext === TASK_NEAR_MISS_EXTENSION) {
|
|
101
|
+
diagnostics.push({
|
|
102
|
+
file: relPath,
|
|
103
|
+
issue: "invalid-task-yaml",
|
|
104
|
+
detail: taskExtensionDetail(relPath),
|
|
105
|
+
fixed: false,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const parsed = parseTaskYaml(raw);
|
|
109
|
+
if (!parsed.ok) {
|
|
110
|
+
// Distinguish "unparseable" from "empty": `taskDiagnostics` returns []
|
|
111
|
+
// for an empty mapping, so collapsing a parse failure onto `{}` made a
|
|
112
|
+
// broken task file lint clean.
|
|
113
|
+
diagnostics.push({
|
|
114
|
+
file: relPath,
|
|
115
|
+
issue: "invalid-task-yaml",
|
|
116
|
+
detail: taskYamlParseDetail(parsed.error),
|
|
117
|
+
fixed: false,
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
diagnostics.push(...taskDiagnostics(relPath, parsed.data));
|
|
105
122
|
}
|
|
106
123
|
return diagnostics;
|
|
107
124
|
}
|
|
108
125
|
export const akmTaskAdapter = {
|
|
109
126
|
id: "akm-task",
|
|
110
127
|
version: "0.9.0",
|
|
111
|
-
|
|
128
|
+
// `.yaml` is listed as a COLLECTION hint only — `recognize` still gates on
|
|
129
|
+
// `.yml`, so a `.yaml` file is never indexed as a task. Listing it is what
|
|
130
|
+
// routes the near-miss file into `validate`, where it is reported instead of
|
|
131
|
+
// silently skipped (issue #760).
|
|
132
|
+
extensions: [TASK_EXT, TASK_NEAR_MISS_EXTENSION],
|
|
112
133
|
recognize,
|
|
113
134
|
validate,
|
|
114
135
|
/** A task places to `<conceptId>.yml`; an already-suffixed conceptId is idempotent. */
|
|
@@ -143,7 +164,7 @@ export const akmTaskAdapter = {
|
|
|
143
164
|
catch {
|
|
144
165
|
continue;
|
|
145
166
|
}
|
|
146
|
-
const data = parseTaskYaml(raw);
|
|
167
|
+
const { data } = parseTaskYaml(raw);
|
|
147
168
|
if (typeof data.schedule === "string" && data.schedule.trim() !== "")
|
|
148
169
|
return true;
|
|
149
170
|
}
|
|
@@ -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,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
13
|
+
import { recordWrittenPath } from "../write-provenance.js";
|
|
13
14
|
import { assembleAsset, serializeFrontmatter } from "./asset-serialize.js";
|
|
14
15
|
/**
|
|
15
16
|
* Parse YAML frontmatter from a Markdown (or similar) string.
|
|
@@ -143,6 +144,9 @@ export function mutateFrontmatter(filePath, mutator) {
|
|
|
143
144
|
? `---\n${serializeFrontmatter(nextFrontmatter)}\n---\n${parsed.content}`
|
|
144
145
|
: assembleAsset(nextFrontmatter, parsed.content);
|
|
145
146
|
fs.writeFileSync(filePath, next, "utf8");
|
|
147
|
+
// #652: in-place frontmatter stamps (belief state, contradiction markers,
|
|
148
|
+
// salience) are real asset mutations — journal them for the run's sync.
|
|
149
|
+
recordWrittenPath(filePath);
|
|
146
150
|
return true;
|
|
147
151
|
}
|
|
148
152
|
export function parseFrontmatterBlock(raw) {
|
package/dist/core/common.js
CHANGED
|
@@ -292,13 +292,49 @@ export function hasErrnoCode(error, code) {
|
|
|
292
292
|
return false;
|
|
293
293
|
return error.code === code;
|
|
294
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* True when `value` is a RELATIVE path that cannot leave its base directory:
|
|
297
|
+
* no absolute form (POSIX `/`, Windows `\` or a `C:` drive prefix), no `~`
|
|
298
|
+
* home expansion, and no `..` segment under either separator.
|
|
299
|
+
*
|
|
300
|
+
* This is the SYNTACTIC half of containment — cheap, string-only, usable at
|
|
301
|
+
* authoring time before any directory exists. It is deliberately paired with
|
|
302
|
+
* (never a substitute for) {@link isWithin}, which resolves symlinks against a
|
|
303
|
+
* real base at use time. Workflow `exec` units run both: the parser and the
|
|
304
|
+
* frozen-plan decoder reject uncontained spellings, and the executor re-checks
|
|
305
|
+
* the resolved path before spawning.
|
|
306
|
+
*/
|
|
307
|
+
export function isContainedRelativePath(value) {
|
|
308
|
+
if (value === "" || value.startsWith("/") || value.startsWith("\\") || value.startsWith("~"))
|
|
309
|
+
return false;
|
|
310
|
+
if (/^[A-Za-z]:/.test(value))
|
|
311
|
+
return false;
|
|
312
|
+
return !value.split(/[/\\]+/).includes("..");
|
|
313
|
+
}
|
|
295
314
|
export function isWithin(candidate, root) {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
315
|
+
return isContainedResolvedPath(safeRealpath(candidate), safeRealpath(root));
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* {@link isWithin} for callers that must not block the event loop (e.g. the
|
|
319
|
+
* workflow exec dispatch path, which runs once per fan-out unit). Same
|
|
320
|
+
* comparison, same normalization, same nearest-existing-ancestor fallback —
|
|
321
|
+
* only the realpath syscalls are awaited.
|
|
322
|
+
*/
|
|
323
|
+
export async function isWithinAsync(candidate, root) {
|
|
324
|
+
return isContainedResolvedPath(await safeRealpathAsync(candidate), await safeRealpathAsync(root));
|
|
325
|
+
}
|
|
326
|
+
/** The containment comparison shared by {@link isWithin} and {@link isWithinAsync}. */
|
|
327
|
+
function isContainedResolvedPath(resolvedCandidate, resolvedRoot) {
|
|
328
|
+
const rel = path.relative(normalizeFsPathForComparison(resolvedRoot), normalizeFsPathForComparison(resolvedCandidate));
|
|
329
|
+
if (rel === "")
|
|
330
|
+
return true;
|
|
331
|
+
if (path.isAbsolute(rel))
|
|
332
|
+
return false;
|
|
333
|
+
// Compare the first SEGMENT, not a string prefix: `..data` and `...v2` are
|
|
334
|
+
// legal directory names, and only a leading `..` segment means the candidate
|
|
335
|
+
// climbed out of the root. Both separators, because `path.relative` answers
|
|
336
|
+
// in the host's spelling while callers may hold either.
|
|
337
|
+
return rel.split(/[/\\]+/)[0] !== "..";
|
|
302
338
|
}
|
|
303
339
|
/**
|
|
304
340
|
* Resolve symlinks on `p`, walking up to the closest existing ancestor when
|
|
@@ -335,6 +371,30 @@ export function safeRealpath(p) {
|
|
|
335
371
|
}
|
|
336
372
|
}
|
|
337
373
|
}
|
|
374
|
+
/** {@link safeRealpath}'s async twin — awaited syscalls, identical walk-up. */
|
|
375
|
+
export async function safeRealpathAsync(p) {
|
|
376
|
+
const resolved = path.resolve(p);
|
|
377
|
+
try {
|
|
378
|
+
return await fs.promises.realpath(resolved);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
const suffix = [];
|
|
382
|
+
let current = resolved;
|
|
383
|
+
for (;;) {
|
|
384
|
+
const parent = path.dirname(current);
|
|
385
|
+
if (parent === current)
|
|
386
|
+
return resolved;
|
|
387
|
+
suffix.unshift(path.basename(current));
|
|
388
|
+
current = parent;
|
|
389
|
+
try {
|
|
390
|
+
return path.join(await fs.promises.realpath(current), ...suffix);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// parent also doesn't exist; keep walking up
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
338
398
|
function normalizeFsPathForComparison(value) {
|
|
339
399
|
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
340
400
|
}
|
package/dist/core/concurrent.js
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Serialize `fn` behind every task previously enqueued under `key`: an
|
|
6
|
+
* in-process keyed promise chain (Bun is single-threaded, so this is a
|
|
7
|
+
* sufficient — and free — admission control for per-key mutual exclusion).
|
|
8
|
+
*
|
|
9
|
+
* `chains` is the caller's own module-state map, so independent subsystems
|
|
10
|
+
* (the unit-writer's per-database write queue, the worktree module's per-repo
|
|
11
|
+
* git lock) never share chains. A failed task rejects its OWN caller but
|
|
12
|
+
* never wedges the chain, and a drained tail deletes its map entry so a
|
|
13
|
+
* long-lived process does not retain one settled promise per key it ever
|
|
14
|
+
* touched.
|
|
15
|
+
*/
|
|
16
|
+
export function serializeByKey(chains, key, fn) {
|
|
17
|
+
const tail = chains.get(key) ?? Promise.resolve();
|
|
18
|
+
const run = tail.then(() => fn());
|
|
19
|
+
// Keep the chain alive regardless of individual outcomes.
|
|
20
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
21
|
+
chains.set(key, settled);
|
|
22
|
+
// If another task was enqueued in the meantime the map now holds ITS tail,
|
|
23
|
+
// and this check leaves it alone.
|
|
24
|
+
void settled.then(() => {
|
|
25
|
+
if (chains.get(key) === settled)
|
|
26
|
+
chains.delete(key);
|
|
27
|
+
});
|
|
28
|
+
return run;
|
|
29
|
+
}
|
|
4
30
|
/**
|
|
5
31
|
* Maps over items concurrently with a pool size limit.
|
|
6
32
|
* Uses Promise.allSettled semantics — one failure does not cancel others.
|
|
@@ -11,6 +37,12 @@
|
|
|
11
37
|
* preempt those too). Unclaimed items stay `undefined` in the result,
|
|
12
38
|
* indistinguishable from individual failures by design: callers already
|
|
13
39
|
* treat `undefined` as "no result".
|
|
40
|
+
*
|
|
41
|
+
* A thrown `fn` is SWALLOWED (its slot stays `undefined`) — a caller that
|
|
42
|
+
* must report failure detail, or distinguish "threw" from "never claimed",
|
|
43
|
+
* catches inside `fn` and returns an explicit outcome value instead. This is
|
|
44
|
+
* the OPPOSITE of {@link serializeByKey} above, whose failures reject their
|
|
45
|
+
* own caller.
|
|
14
46
|
*/
|
|
15
47
|
export async function concurrentMap(items, fn, concurrency = 1, opts) {
|
|
16
48
|
const results = new Array(items.length).fill(undefined);
|
|
@@ -19,10 +19,21 @@ import { engineName, positiveInt } from "./primitives.js";
|
|
|
19
19
|
* `[1, WORKFLOW_MAX_CONCURRENCY_CEILING]` (64). Values above the ceiling
|
|
20
20
|
* are clamped, not rejected, so a config shared across machines with wildly
|
|
21
21
|
* different core counts never hard-fails validation.
|
|
22
|
+
*
|
|
23
|
+
* `defaultMapConcurrency` is the width a `map` step freezes when it declares no
|
|
24
|
+
* `concurrency:` of its own:
|
|
25
|
+
* - UNSET → `DEFAULT_MAP_CONCURRENCY` (4) from
|
|
26
|
+
* `src/workflows/concurrency-policy.ts`.
|
|
27
|
+
* - SET → the explicit positive integer, CLAMPED to `[1, 64]`. Setting it
|
|
28
|
+
* to `1` restores the pre-0.9.1 serial-by-default fan-out for every
|
|
29
|
+
* workflow on this install. It is a floor for authoring only: it never
|
|
30
|
+
* raises a step above `maxConcurrency`, the engine's concurrency, or the
|
|
31
|
+
* host CPU cap, and it never overrides an authored `map.concurrency`.
|
|
22
32
|
*/
|
|
23
33
|
export const WorkflowConfigSchema = z
|
|
24
34
|
.object({
|
|
25
35
|
maxConcurrency: positiveInt.optional(),
|
|
36
|
+
defaultMapConcurrency: positiveInt.optional(),
|
|
26
37
|
/** Named LLM or agent engine frozen into every criteria-bearing gate. */
|
|
27
38
|
judgeEngine: engineName.optional(),
|
|
28
39
|
})
|
package/dist/core/errors.js
CHANGED
|
@@ -9,6 +9,7 @@ const CONFIG_HINTS = {
|
|
|
9
9
|
STASH_DIR_NOT_FOUND: "Run `akm setup` to create and configure your bundle, or configure a defaultBundle path.",
|
|
10
10
|
STASH_DIR_NOT_A_DIRECTORY: "The configured default bundle path exists but isn't a directory. Update it to point at a folder.",
|
|
11
11
|
STASH_DIR_UNREADABLE: "Check the path exists and your user has read permission, or update the default bundle path.",
|
|
12
|
+
DATA_DIR_UNREADABLE: "The data directory is not readable by the user running akm. Check its owner and mode, or point AKM_DATA_DIR / XDG_DATA_HOME somewhere this user owns.",
|
|
12
13
|
EMBEDDING_NOT_CONFIGURED: 'Run `akm config set embedding \'{"endpoint":"...","model":"..."}\'` to enable embeddings.',
|
|
13
14
|
LLM_NOT_CONFIGURED: 'Run `akm setup` or configure an `engines` entry with `kind: "llm"`, then select it with `defaults.llmEngine`.',
|
|
14
15
|
TEST_ISOLATION_MISSING: "Under bun test, when AKM_BUNDLE_DIR is set you MUST also set XDG_DATA_HOME (or AKM_DATA_DIR) and XDG_STATE_HOME (or AKM_STATE_DIR) to temp directories so the test does not touch the developer's real ~/.local/share/akm or ~/.local/state/akm.",
|
|
@@ -131,3 +132,27 @@ export function rethrowIfTestIsolationError(err) {
|
|
|
131
132
|
throw err;
|
|
132
133
|
}
|
|
133
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Unreadable-data-dir guard helper — the #791 sibling of the test-isolation
|
|
137
|
+
* pair above, and it exists for the same reason.
|
|
138
|
+
*
|
|
139
|
+
* `DATA_DIR_UNREADABLE` says "this path is there and I am not allowed to read
|
|
140
|
+
* it". It is raised by `assertIndexPathReadable` and friends precisely so a
|
|
141
|
+
* permission fault stops being indistinguishable from "nothing indexed yet".
|
|
142
|
+
* That distinction is destroyed again the moment a best-effort `catch` around
|
|
143
|
+
* the open collapses it into the same `null`/`[]`/`0` the absent case returns —
|
|
144
|
+
* which is how `akm search` came to answer "No search index available. Run
|
|
145
|
+
* 'akm index'" at exit 0 for a populated index sitting right there on disk.
|
|
146
|
+
*
|
|
147
|
+
* Call `rethrowIfDataDirUnreadable(err)` from any catch block that returns a
|
|
148
|
+
* fallback value after touching a data-dir path. Absent stays absent; a fault
|
|
149
|
+
* the operator has to fix keeps travelling.
|
|
150
|
+
*/
|
|
151
|
+
export function isDataDirUnreadableError(err) {
|
|
152
|
+
return err instanceof ConfigError && err.code === "DATA_DIR_UNREADABLE";
|
|
153
|
+
}
|
|
154
|
+
export function rethrowIfDataDirUnreadable(err) {
|
|
155
|
+
if (isDataDirUnreadableError(err)) {
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|