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
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import {
|
|
7
|
-
import { factDiagnostics, matchWorkflowPlaceholder, memoryOrphanStubApplies, nameOrTypeDiagnostics, ORPHANED_STUB_DETAIL, taskDiagnostics, workflowStructureDiagnostics, } from "../../core/adapter/adapters/akm-lint.js";
|
|
6
|
+
import { factDiagnostics, matchWorkflowPlaceholder, memoryOrphanStubApplies, nameOrTypeDiagnostics, ORPHANED_STUB_DETAIL, taskDiagnostics, workflowFrontendDiagnostics, } from "../../core/adapter/adapters/akm-lint.js";
|
|
8
7
|
import { detectAdapterId } from "../../core/adapter/detect-adapter.js";
|
|
9
8
|
import { adapterForId } from "../../core/adapter/registry.js";
|
|
10
9
|
import { createValidateContext } from "../../core/adapter/validate-context.js";
|
|
@@ -15,9 +14,12 @@ import { deriveBundleIds } from "../../core/bundle-id.js";
|
|
|
15
14
|
import { resolveStashDir } from "../../core/common.js";
|
|
16
15
|
import { loadConfig, primaryBundlePath } from "../../core/config/config.js";
|
|
17
16
|
import { UsageError } from "../../core/errors.js";
|
|
17
|
+
import { warn } from "../../core/warn.js";
|
|
18
18
|
import { resolveSourceEntries } from "../../indexer/search/search-source.js";
|
|
19
|
+
import { parseTaskYaml, TASK_EXTENSION, TASK_NEAR_MISS_EXTENSION, taskExtensionDetail, taskYamlParseDetail, } from "../../tasks/schema.js";
|
|
19
20
|
import { runBaseChecks } from "./base-linter.js";
|
|
20
21
|
import { checkEnvForDangerousKeys } from "./env-key-rules.js";
|
|
22
|
+
import { isAdvisoryLintIssue } from "./types.js";
|
|
21
23
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
22
24
|
const STASH_SUBDIRS = [
|
|
23
25
|
"agents",
|
|
@@ -31,21 +33,34 @@ const STASH_SUBDIRS = [
|
|
|
31
33
|
"facts",
|
|
32
34
|
];
|
|
33
35
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
34
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Every task-shaped file under `tasks/`: the recognized `.yml` spelling AND the
|
|
38
|
+
* `.yaml` near-miss. A `.yaml` file is not a runnable task — it is invisible to
|
|
39
|
+
* the indexer's `tasks` matcher — but collecting it here is what lets the sweep
|
|
40
|
+
* SAY so (`invalid-task-yaml`, see {@link taskExtensionDetail}) instead of
|
|
41
|
+
* walking past it and reporting a clean scan (issue #760).
|
|
42
|
+
*/
|
|
43
|
+
function collectTaskFiles(dir) {
|
|
35
44
|
if (!fs.existsSync(dir))
|
|
36
45
|
return [];
|
|
37
46
|
const results = [];
|
|
38
47
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
39
48
|
const full = path.join(dir, entry.name);
|
|
40
49
|
if (entry.isDirectory()) {
|
|
41
|
-
results.push(...
|
|
50
|
+
results.push(...collectTaskFiles(full));
|
|
42
51
|
}
|
|
43
|
-
else if (entry.isFile() && entry.name
|
|
52
|
+
else if (entry.isFile() && (isTaskFileName(entry.name) || isNearMissTaskFileName(entry.name))) {
|
|
44
53
|
results.push(full);
|
|
45
54
|
}
|
|
46
55
|
}
|
|
47
56
|
return results;
|
|
48
57
|
}
|
|
58
|
+
function isTaskFileName(fileName) {
|
|
59
|
+
return fileName.toLowerCase().endsWith(TASK_EXTENSION);
|
|
60
|
+
}
|
|
61
|
+
function isNearMissTaskFileName(fileName) {
|
|
62
|
+
return fileName.toLowerCase().endsWith(TASK_NEAR_MISS_EXTENSION);
|
|
63
|
+
}
|
|
49
64
|
function collectMarkdownFiles(dir, caseInsensitive = false) {
|
|
50
65
|
if (!fs.existsSync(dir))
|
|
51
66
|
return [];
|
|
@@ -141,18 +156,32 @@ const KNOWN_ADAPTER_ISSUE_TYPES = new Set([
|
|
|
141
156
|
"missing-type",
|
|
142
157
|
"missing-name-or-type",
|
|
143
158
|
"missing-skill-md",
|
|
159
|
+
// The `akm-task` adapter's own code. Now reachable from `akm lint` for a
|
|
160
|
+
// malformed or misnamed task file (issue #760); without it here, a genuine
|
|
161
|
+
// task finding would arrive folded onto `adapter-diagnostic`.
|
|
162
|
+
"invalid-task-yaml",
|
|
144
163
|
"dangerous-env-key",
|
|
145
164
|
"uncited-raw",
|
|
146
165
|
"missing-description",
|
|
147
166
|
"broken-xref",
|
|
148
167
|
"broken-source",
|
|
168
|
+
"workflow-warning",
|
|
149
169
|
]);
|
|
150
170
|
/** Map one adapter {@link Diagnostic} onto a {@link LintIssue} — see `types.ts`'s `"adapter-diagnostic"` doc comment for the open→closed reconciliation. */
|
|
151
171
|
export function diagnosticToLintIssue(diag) {
|
|
172
|
+
// `line` is optional on both shapes: carry it only when the adapter set one,
|
|
173
|
+
// so whole-file findings keep their exact existing serialization.
|
|
174
|
+
const location = typeof diag.line === "number" ? { line: diag.line } : {};
|
|
152
175
|
if (KNOWN_ADAPTER_ISSUE_TYPES.has(diag.issue)) {
|
|
153
|
-
return { file: diag.file, issue: diag.issue, detail: diag.detail, fixed: diag.fixed };
|
|
176
|
+
return { file: diag.file, issue: diag.issue, detail: diag.detail, fixed: diag.fixed, ...location };
|
|
154
177
|
}
|
|
155
|
-
return {
|
|
178
|
+
return {
|
|
179
|
+
file: diag.file,
|
|
180
|
+
issue: "adapter-diagnostic",
|
|
181
|
+
detail: `[${diag.issue}] ${diag.detail}`,
|
|
182
|
+
fixed: diag.fixed,
|
|
183
|
+
...location,
|
|
184
|
+
};
|
|
156
185
|
}
|
|
157
186
|
/**
|
|
158
187
|
* Lint a bundle through its OWN adapter's `validate()` (spec §12.1): the
|
|
@@ -169,6 +198,17 @@ async function lintViaAdapter(adapterId, stashRoot, extraStashRoots, sources, cf
|
|
|
169
198
|
// always applied to a bundle it can't otherwise place.
|
|
170
199
|
if (!adapter)
|
|
171
200
|
return lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options);
|
|
201
|
+
// `--type` names an AKM stash subdir; every other adapter has its own type
|
|
202
|
+
// vocabulary and `validate()` sees the whole bundle regardless. That is not a
|
|
203
|
+
// correctness problem (full-bundle validation is a superset of the requested
|
|
204
|
+
// scope), but a user narrowing a run deserves to hear the flag did nothing
|
|
205
|
+
// rather than infer it from identical output (issue #762). Warn, don't throw:
|
|
206
|
+
// a hard error would break scripts passing one `--type` across mixed-adapter
|
|
207
|
+
// bundle sets.
|
|
208
|
+
if (options.typeFilter) {
|
|
209
|
+
warn(`Warning: lint --type "${options.typeFilter}" is not supported for the "${adapterId}" adapter — ` +
|
|
210
|
+
"type scoping applies to akm bundles only; the whole bundle was validated.");
|
|
211
|
+
}
|
|
172
212
|
const files = collectAdapterFiles(stashRoot, adapter.extensions);
|
|
173
213
|
const changes = files.map((filePath) => ({
|
|
174
214
|
path: path.relative(stashRoot, filePath).replace(/\\/g, "/"),
|
|
@@ -179,7 +219,13 @@ async function lintViaAdapter(adapterId, stashRoot, extraStashRoots, sources, cf
|
|
|
179
219
|
const componentId = sourceIndex >= 0 ? ids[sourceIndex] : stashRoot;
|
|
180
220
|
const ctx = createValidateContext({ root: stashRoot, extraRoots: extraStashRoots });
|
|
181
221
|
const diagnostics = await adapter.validate({ id: componentId, adapter: adapterId, root: stashRoot, writable: true }, changes, ctx);
|
|
182
|
-
const
|
|
222
|
+
const mapped = diagnostics.map(diagnosticToLintIssue);
|
|
223
|
+
// Advisory diagnostics travel in their own channel — never `flagged`, so a
|
|
224
|
+
// `--fail-on-flagged` gate is not tripped by a non-fatal warning. Classified
|
|
225
|
+
// by the shared `ADVISORY_LINT_ISSUES` set rather than a code spelled out
|
|
226
|
+
// here, so this and the sweep below can never disagree about a code.
|
|
227
|
+
const warnings = mapped.filter(isAdvisoryLintIssue);
|
|
228
|
+
const flagged = mapped.filter((issue) => !isAdvisoryLintIssue(issue));
|
|
183
229
|
// The cross-bundle env dangerous-key sweep (see `runEnvDangerousKeyPass`'s
|
|
184
230
|
// doc comment) ran for every non-akm adapter via the STASH_SUBDIRS
|
|
185
231
|
// fallthrough this dispatch replaces — EXCEPT `okf`, which the old code
|
|
@@ -199,7 +245,13 @@ async function lintViaAdapter(adapterId, stashRoot, extraStashRoots, sources, cf
|
|
|
199
245
|
flagged.push(issue);
|
|
200
246
|
}
|
|
201
247
|
}
|
|
202
|
-
return {
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
fixed: [],
|
|
251
|
+
flagged,
|
|
252
|
+
warnings,
|
|
253
|
+
summary: { fixed: 0, flagged: flagged.length, warnings: warnings.length },
|
|
254
|
+
};
|
|
203
255
|
}
|
|
204
256
|
function lintIssueDedupeKey(issue) {
|
|
205
257
|
return `${issue.file} ${issue.issue} ${issue.detail}`;
|
|
@@ -259,6 +311,29 @@ function runEnvDangerousKeyPass(stashRoot, extraStashRoots, sources, cfg) {
|
|
|
259
311
|
}
|
|
260
312
|
return flagged;
|
|
261
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* Refuse `--fix` against a bundle the config marks `writable: false`, BEFORE
|
|
316
|
+
* the sweep touches a single file (issue #761).
|
|
317
|
+
*
|
|
318
|
+
* Every other mutating command routes through `core/write-source.ts`'s
|
|
319
|
+
* `ensureWritable`/`resolveWritable` pair; `akm lint --fix` writes and deletes
|
|
320
|
+
* directly and never consulted the flag, so it happily rewrote frontmatter in a
|
|
321
|
+
* bundle explicitly configured read-only. `SearchSource.writable` is already the
|
|
322
|
+
* EFFECTIVE policy (`resolveWritable` applied — see `resolveSourceEntries`), so
|
|
323
|
+
* this reads the same answer the write path would, without a second resolver.
|
|
324
|
+
*
|
|
325
|
+
* A root that is not a configured source at all (an ad-hoc `--dir`) carries no
|
|
326
|
+
* policy and stays fixable, exactly as today.
|
|
327
|
+
*/
|
|
328
|
+
function assertFixTargetWritable(stashRoot, sources) {
|
|
329
|
+
const target = sources.find((source) => path.resolve(source.path) === path.resolve(stashRoot));
|
|
330
|
+
if (target?.writable !== false)
|
|
331
|
+
return;
|
|
332
|
+
// Same error kind and code `write-source.ts#ensureWritable` raises for the
|
|
333
|
+
// identical refusal, so a scripted caller classifies both the same way.
|
|
334
|
+
throw new UsageError(`lint --fix: bundle "${stashRoot}" is configured \`writable: false\`; refusing to modify it. ` +
|
|
335
|
+
"Run `akm lint` without --fix to report findings, or set `writable: true` on the bundle.", "INVALID_FLAG_VALUE");
|
|
336
|
+
}
|
|
262
337
|
/** True when the issue represents a file deletion that was successfully applied. */
|
|
263
338
|
function isFileDeletion(issue) {
|
|
264
339
|
return issue.fixed === true && (issue.issue === "orphaned-stub" || issue.issue === "placeholder-stub");
|
|
@@ -308,46 +383,53 @@ function appendMemoryStubIssue(ctx, issues) {
|
|
|
308
383
|
}
|
|
309
384
|
issues.push({ file: ctx.relPath, issue: "orphaned-stub", detail: ORPHANED_STUB_DETAIL, fixed: false });
|
|
310
385
|
}
|
|
311
|
-
/**
|
|
312
|
-
|
|
386
|
+
/**
|
|
387
|
+
* WorkflowLinter's `placeholder-stub` check WITH its `--fix` delete
|
|
388
|
+
* (workflow-linter.ts:22-79). Its sibling `invalid-workflow-structure` check is
|
|
389
|
+
* deliberately NOT here: parse+compile is a single pass shared with the
|
|
390
|
+
* advisory channel, so {@link lintAkmSweep} runs it once per file and routes
|
|
391
|
+
* both halves.
|
|
392
|
+
*/
|
|
393
|
+
function appendWorkflowStubIssue(ctx, issues) {
|
|
313
394
|
const placeholder = matchWorkflowPlaceholder(ctx.body);
|
|
314
|
-
if (placeholder)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
catch (e) {
|
|
326
|
-
issues.push({
|
|
327
|
-
file: ctx.relPath,
|
|
328
|
-
issue: "placeholder-stub",
|
|
329
|
-
detail: `could not delete: ${e instanceof Error ? e.message : String(e)}`,
|
|
330
|
-
fixed: "failed",
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
return; // WorkflowLinter returns before the structure check once a stub is fixed.
|
|
395
|
+
if (!placeholder)
|
|
396
|
+
return;
|
|
397
|
+
if (ctx.fix) {
|
|
398
|
+
try {
|
|
399
|
+
fs.unlinkSync(ctx.filePath);
|
|
400
|
+
issues.push({
|
|
401
|
+
file: ctx.relPath,
|
|
402
|
+
issue: "placeholder-stub",
|
|
403
|
+
detail: `deleted: found "${placeholder}"`,
|
|
404
|
+
fixed: true,
|
|
405
|
+
});
|
|
334
406
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
407
|
+
catch (e) {
|
|
408
|
+
issues.push({
|
|
409
|
+
file: ctx.relPath,
|
|
410
|
+
issue: "placeholder-stub",
|
|
411
|
+
detail: `could not delete: ${e instanceof Error ? e.message : String(e)}`,
|
|
412
|
+
fixed: "failed",
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
341
416
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
417
|
+
issues.push({
|
|
418
|
+
file: ctx.relPath,
|
|
419
|
+
issue: "placeholder-stub",
|
|
420
|
+
detail: `placeholder text: "${placeholder}"`,
|
|
421
|
+
fixed: false,
|
|
422
|
+
});
|
|
345
423
|
}
|
|
346
424
|
/**
|
|
347
425
|
* Lint ONE asset file: the shared base checks, then the winning stash subdir's
|
|
348
426
|
* per-`type` extra rules. Replaces `getLinterForType(subdir).lint(ctx)`.
|
|
349
427
|
* `--fix` mutations (frontmatter rewrites inside `runBaseChecks`; stub deletes
|
|
350
428
|
* here) are applied when `ctx.fix` is set.
|
|
429
|
+
*
|
|
430
|
+
* The workflow parse/compile frontend is NOT one of these rules — it is one
|
|
431
|
+
* pass feeding two channels, so {@link lintAkmSweep} owns it (see
|
|
432
|
+
* {@link appendWorkflowStubIssue}).
|
|
351
433
|
*/
|
|
352
434
|
export function lintAssetFile(ctx, subdir) {
|
|
353
435
|
const issues = runBaseChecks(ctx);
|
|
@@ -368,7 +450,7 @@ export function lintAssetFile(ctx, subdir) {
|
|
|
368
450
|
appendMemoryStubIssue(ctx, issues);
|
|
369
451
|
break;
|
|
370
452
|
case "workflows":
|
|
371
|
-
|
|
453
|
+
appendWorkflowStubIssue(ctx, issues);
|
|
372
454
|
break;
|
|
373
455
|
// knowledge / lessons / skills: base checks only (skill directory-level
|
|
374
456
|
// `missing-skill-md` runs separately, per-subdir, in the sweep loop).
|
|
@@ -387,14 +469,17 @@ export function lintAssetFile(ctx, subdir) {
|
|
|
387
469
|
*/
|
|
388
470
|
function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
|
|
389
471
|
const fix = options.fix === true;
|
|
472
|
+
if (fix)
|
|
473
|
+
assertFixTargetWritable(stashRoot, sources);
|
|
390
474
|
const fixed = [];
|
|
391
475
|
const flagged = [];
|
|
476
|
+
const warnings = [];
|
|
392
477
|
const dirsToScan = options.typeFilter ? STASH_SUBDIRS.filter((d) => d === options.typeFilter) : STASH_SUBDIRS;
|
|
393
478
|
for (const subdir of dirsToScan) {
|
|
394
479
|
const dirPath = path.join(stashRoot, subdir);
|
|
395
480
|
// Tasks are .yml files; everything else (including workflows, one
|
|
396
481
|
// markdown format now) is .md
|
|
397
|
-
const files = subdir === "tasks" ?
|
|
482
|
+
const files = subdir === "tasks" ? collectTaskFiles(dirPath) : collectMarkdownFiles(dirPath, true);
|
|
398
483
|
const assetFiles = subdir === "workflows" ? files.filter((file) => path.basename(file).toLowerCase() !== "readme.md") : files;
|
|
399
484
|
// Directory-level check: skills require a SKILL.md entry point (was
|
|
400
485
|
// SkillLinter.lintDirectory). Run once per direct subdirectory before the
|
|
@@ -430,15 +515,32 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
|
|
|
430
515
|
let data;
|
|
431
516
|
let body;
|
|
432
517
|
let frontmatter;
|
|
518
|
+
// File-identity findings the per-type rules cannot produce: they describe
|
|
519
|
+
// the FILE (its extension, whether it parsed at all), not its fields.
|
|
520
|
+
const fileIssues = [];
|
|
433
521
|
if (subdir === "tasks") {
|
|
434
522
|
// Task files are pure YAML — parseFrontmatter returns empty data for them.
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
523
|
+
const parsed = parseTaskYaml(raw);
|
|
524
|
+
data = parsed.data;
|
|
525
|
+
if (!parsed.ok) {
|
|
526
|
+
// A parse failure used to fall through as `data = {}`, and every task
|
|
527
|
+
// rule short-circuits on an empty mapping — so an unparseable task
|
|
528
|
+
// file reported a CLEAN scan. Report the parse failure itself
|
|
529
|
+
// (issue #760).
|
|
530
|
+
fileIssues.push({
|
|
531
|
+
file: relPath,
|
|
532
|
+
issue: "invalid-task-yaml",
|
|
533
|
+
detail: taskYamlParseDetail(parsed.error),
|
|
534
|
+
fixed: false,
|
|
535
|
+
});
|
|
439
536
|
}
|
|
440
|
-
|
|
441
|
-
|
|
537
|
+
if (isNearMissTaskFileName(relPath)) {
|
|
538
|
+
fileIssues.push({
|
|
539
|
+
file: relPath,
|
|
540
|
+
issue: "invalid-task-yaml",
|
|
541
|
+
detail: taskExtensionDetail(relPath),
|
|
542
|
+
fixed: false,
|
|
543
|
+
});
|
|
442
544
|
}
|
|
443
545
|
body = raw;
|
|
444
546
|
frontmatter = null;
|
|
@@ -446,13 +548,40 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
|
|
|
446
548
|
else {
|
|
447
549
|
({ data, content: body, frontmatter } = parseFrontmatter(raw));
|
|
448
550
|
}
|
|
449
|
-
|
|
551
|
+
// One file's checks — including its `--fix` mutations — must never abort
|
|
552
|
+
// the sweep: an uncaught throw here left the caller with an exception and
|
|
553
|
+
// no record of which earlier files had ALREADY been rewritten on disk
|
|
554
|
+
// (issue #761). A failure is reported per-file, in-band, and the sweep
|
|
555
|
+
// continues so the rest of the bundle is still linted.
|
|
556
|
+
let issues;
|
|
557
|
+
try {
|
|
558
|
+
issues = [
|
|
559
|
+
...fileIssues,
|
|
560
|
+
...lintAssetFile({ filePath, relPath, raw, data, body, frontmatter, fix, stashRoot, extraStashRoots }, subdir),
|
|
561
|
+
];
|
|
562
|
+
}
|
|
563
|
+
catch (e) {
|
|
564
|
+
flagged.push(...fileIssues, {
|
|
565
|
+
file: relPath,
|
|
566
|
+
issue: "lint-failed",
|
|
567
|
+
detail: `lint ${fix ? "--fix " : ""}failed for this file: ${e instanceof Error ? e.message : String(e)}`,
|
|
568
|
+
fixed: fix ? "failed" : false,
|
|
569
|
+
});
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
450
572
|
let fileDeleted = false;
|
|
451
573
|
for (const issue of issues) {
|
|
452
574
|
if (isFileDeletion(issue)) {
|
|
453
575
|
fileDeleted = true;
|
|
454
576
|
fixed.push(issue);
|
|
455
577
|
}
|
|
578
|
+
else if (isAdvisoryLintIssue(issue)) {
|
|
579
|
+
// `lintAssetFile` returns errors only today, so this branch is
|
|
580
|
+
// reached by no current producer — it is here so that an advisory
|
|
581
|
+
// added to a per-type check later cannot silently become a
|
|
582
|
+
// `--fail-on-flagged` failure, the way the unclassified default does.
|
|
583
|
+
warnings.push(issue);
|
|
584
|
+
}
|
|
456
585
|
else if (issue.fixed === true) {
|
|
457
586
|
fixed.push(issue);
|
|
458
587
|
}
|
|
@@ -463,6 +592,26 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
|
|
|
463
592
|
}
|
|
464
593
|
if (fileDeleted)
|
|
465
594
|
continue; // file is gone — skip any remaining checks
|
|
595
|
+
// The workflow frontend is ONE parse+compile whose output feeds BOTH
|
|
596
|
+
// channels, so it runs here — once per file — rather than inside
|
|
597
|
+
// `lintAssetFile`, which is an errors-only surface (pinned by the lint
|
|
598
|
+
// golden). Which channel a finding lands in is decided by
|
|
599
|
+
// `ADVISORY_LINT_ISSUES`, never by which half of the pass produced it, so
|
|
600
|
+
// a future compile-warning kind carrying a fatal code cannot slip past
|
|
601
|
+
// `--fail-on-flagged`.
|
|
602
|
+
// NB: the CLI passes the ABSOLUTE filePath to parseWorkflow (matching the
|
|
603
|
+
// old WorkflowLinter), whereas the adapter passes the change relPath.
|
|
604
|
+
if (subdir === "workflows") {
|
|
605
|
+
const frontend = workflowFrontendDiagnostics(relPath, raw, filePath);
|
|
606
|
+
for (const finding of [...frontend.errors, ...frontend.warnings]) {
|
|
607
|
+
if (isAdvisoryLintIssue(finding)) {
|
|
608
|
+
warnings.push(finding);
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
flagged.push(finding);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
466
615
|
}
|
|
467
616
|
}
|
|
468
617
|
// ── Env dangerous-key pass ─────────────────────────────────────────────────
|
|
@@ -483,7 +632,8 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
|
|
|
483
632
|
ok: true,
|
|
484
633
|
fixed,
|
|
485
634
|
flagged,
|
|
486
|
-
|
|
635
|
+
warnings,
|
|
636
|
+
summary: { fixed: fixed.length, flagged: flagged.length, warnings: warnings.length },
|
|
487
637
|
};
|
|
488
638
|
}
|
|
489
639
|
/**
|
|
@@ -1,4 +1,25 @@
|
|
|
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
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The issue codes that are ADVISORY: surfaced in lint output but never routed
|
|
6
|
+
* into `flagged`, so `--fail-on-flagged` cannot fail a run over one.
|
|
7
|
+
*
|
|
8
|
+
* ONE home for that decision. EVERY routing point consults it — the adapter
|
|
9
|
+
* path (`lint/index.ts#lintViaAdapter`), the sweep's per-file loop, and the
|
|
10
|
+
* sweep's workflow-frontend pass — so a new advisory code cannot be classified
|
|
11
|
+
* correctly in one place and land in `flagged` (exit 1) in another; a finding
|
|
12
|
+
* is never filed by which producer emitted it. A future advisory belongs in
|
|
13
|
+
* BOTH this set and {@link LintIssueType}: an unrecognized code is folded onto
|
|
14
|
+
* `adapter-diagnostic` at the adapter boundary, which is deliberately NOT
|
|
15
|
+
* advisory, so a code missing from the union cannot be routed by this set.
|
|
16
|
+
*
|
|
17
|
+
* Advisory-ness is deliberately NOT a field on {@link LintIssue}: issues are
|
|
18
|
+
* serialized verbatim by `--format json`, and a new key on every advisory
|
|
19
|
+
* would change every consumer's output to restate what `issue` already says.
|
|
20
|
+
*/
|
|
21
|
+
export const ADVISORY_LINT_ISSUES = new Set(["workflow-warning"]);
|
|
22
|
+
/** True when `issue` belongs to the advisory channel — see {@link ADVISORY_LINT_ISSUES}. */
|
|
23
|
+
export function isAdvisoryLintIssue(issue) {
|
|
24
|
+
return ADVISORY_LINT_ISSUES.has(issue.issue);
|
|
25
|
+
}
|
|
@@ -54,6 +54,7 @@ import { _setTxnMutationHookForTests, advanceTxn, beginTxn, canonicalTxnRoot, cl
|
|
|
54
54
|
import { canonicalBundleIdForTarget, resolveBundleWriteTarget } from "../../core/mutation-target.js";
|
|
55
55
|
import { withImmediateTransaction, withStateDb } from "../../core/state-db.js";
|
|
56
56
|
import { warn } from "../../core/warn.js";
|
|
57
|
+
import { recordWrittenPath } from "../../core/write-provenance.js";
|
|
57
58
|
import { assertAkmAssetWrite, assertWriteTargetPathsClean, captureGitPublication, captureWriteTargetPathSnapshot, prepareWriteTargetForMutation, publishWriteTargetTransaction, resolveWriteTarget, } from "../../core/write-source.js";
|
|
58
59
|
import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
|
|
59
60
|
import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
|
|
@@ -901,6 +902,9 @@ function rollbackPreparedProposalTransaction(txn) {
|
|
|
901
902
|
if (p.originalHash === null) {
|
|
902
903
|
if (currentHash === p.publishedHash && sameProposalFile(p.assetPath, p.publishPath)) {
|
|
903
904
|
fs.unlinkSync(p.assetPath);
|
|
905
|
+
// #652: un-publishing is a mutation of this run's own write — journal
|
|
906
|
+
// it so the sync stages the FINAL state of a written-then-reverted path.
|
|
907
|
+
recordWrittenPath(p.assetPath);
|
|
904
908
|
}
|
|
905
909
|
else if (currentHash !== null) {
|
|
906
910
|
throw new Error(`Cannot roll back proposal transaction: target was created externally.`);
|
|
@@ -912,8 +916,10 @@ function rollbackPreparedProposalTransaction(txn) {
|
|
|
912
916
|
cleanupProposalPublication(p);
|
|
913
917
|
return;
|
|
914
918
|
}
|
|
915
|
-
if (currentHash === p.publishedHash)
|
|
919
|
+
if (currentHash === p.publishedHash) {
|
|
916
920
|
fs.unlinkSync(p.assetPath);
|
|
921
|
+
recordWrittenPath(p.assetPath);
|
|
922
|
+
}
|
|
917
923
|
else if (currentHash !== null && currentHash !== p.originalHash) {
|
|
918
924
|
throw new Error(`Cannot roll back proposal transaction: ${p.assetPath} diverged.`);
|
|
919
925
|
}
|
|
@@ -922,6 +928,9 @@ function rollbackPreparedProposalTransaction(txn) {
|
|
|
922
928
|
throw new Error(`Cannot restore proposal backup: ${p.assetPath} is occupied.`);
|
|
923
929
|
}
|
|
924
930
|
fs.linkSync(p.displacedPath, p.assetPath);
|
|
931
|
+
// #652: restoring the displaced original still leaves the path in a state
|
|
932
|
+
// this run produced; journal it so the final on-disk bytes are staged.
|
|
933
|
+
recordWrittenPath(p.assetPath);
|
|
925
934
|
}
|
|
926
935
|
cleanupProposalPublication(p);
|
|
927
936
|
}
|
|
@@ -1023,6 +1032,10 @@ function persistProposalEvent(txn, proposal, ctx) {
|
|
|
1023
1032
|
async function finalizeProposalTransaction(txn, target, proposal, ctx) {
|
|
1024
1033
|
const p = txn.journal.payload;
|
|
1025
1034
|
validatePublishedProposal(p);
|
|
1035
|
+
// #652: finalizing an `asset-published` transaction that a CRASHED earlier
|
|
1036
|
+
// run left behind is this run adopting that write — journal the asset so the
|
|
1037
|
+
// adopting run's auto-sync commits it instead of leaving it stranded.
|
|
1038
|
+
recordWrittenPath(p.assetPath);
|
|
1026
1039
|
cleanupProposalPublication(p);
|
|
1027
1040
|
if (txn.journal.phase === "asset-published") {
|
|
1028
1041
|
const commitRoot = target.source.repoPath ?? target.source.path;
|
|
@@ -1343,6 +1356,9 @@ function publishProposalAsset(txn, target) {
|
|
|
1343
1356
|
}
|
|
1344
1357
|
}
|
|
1345
1358
|
fs.linkSync(p.publishPath, p.assetPath);
|
|
1359
|
+
// #652: the accepted-proposal (and revert) target is the run's headline
|
|
1360
|
+
// write — journal it the instant the asset lands, before the txn advances.
|
|
1361
|
+
recordWrittenPath(p.assetPath);
|
|
1346
1362
|
fsyncTxnDir(path.dirname(p.assetPath));
|
|
1347
1363
|
const snapshot = captureWriteTargetPathSnapshot(target, p.assetPath);
|
|
1348
1364
|
if (snapshot)
|
|
@@ -1,10 +1,10 @@
|
|
|
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
|
-
import fs from "node:fs";
|
|
5
4
|
import { placementTypes } from "../../core/asset/asset-placement.js";
|
|
6
5
|
import { resolveStashDir } from "../../core/common.js";
|
|
7
6
|
import { getSources, loadConfig } from "../../core/config/config.js";
|
|
7
|
+
import { classifyPathAccess, describeInaccessiblePath } from "../../core/path-access.js";
|
|
8
8
|
import { getDbPath } from "../../core/paths.js";
|
|
9
9
|
import { error } from "../../core/warn.js";
|
|
10
10
|
import { getEffectiveSemanticStatus, readSemanticStatus } from "../../indexer/search/semantic-status.js";
|
|
@@ -83,8 +83,18 @@ function readIndexStats(resolvedPath) {
|
|
|
83
83
|
hasEmbeddings: false,
|
|
84
84
|
vecAvailable: false,
|
|
85
85
|
};
|
|
86
|
-
|
|
86
|
+
// "Absent" is the ordinary first-run state; "inaccessible" is a fault that
|
|
87
|
+
// must not present as an empty index (#791). `akm info` is the command an
|
|
88
|
+
// operator reaches for to DIAGNOSE this, so it reports rather than throws —
|
|
89
|
+
// but it says so explicitly instead of returning zeros that look healthy.
|
|
90
|
+
const { access, code } = classifyPathAccess(resolvedPath);
|
|
91
|
+
if (access === "absent")
|
|
87
92
|
return EMPTY;
|
|
93
|
+
if (access === "inaccessible") {
|
|
94
|
+
const detail = describeInaccessiblePath(resolvedPath, code);
|
|
95
|
+
error(`[akm info] index database is not readable: ${detail}`);
|
|
96
|
+
return { ...EMPTY, unreadable: detail };
|
|
97
|
+
}
|
|
88
98
|
let db;
|
|
89
99
|
try {
|
|
90
100
|
db = openExistingDatabase(resolvedPath);
|
|
@@ -18,6 +18,7 @@ import path from "node:path";
|
|
|
18
18
|
import { isWithin, resolveStashDir } from "../../core/common.js";
|
|
19
19
|
import { getSources, loadConfig } from "../../core/config/config.js";
|
|
20
20
|
import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
|
|
21
|
+
import { isPathAbsent } from "../../core/path-access.js";
|
|
21
22
|
import { getDbPath } from "../../core/paths.js";
|
|
22
23
|
import { warn } from "../../core/warn.js";
|
|
23
24
|
import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
|
|
@@ -125,7 +126,11 @@ function describeLock(entry) {
|
|
|
125
126
|
function readBundleCounts() {
|
|
126
127
|
const counts = new Map();
|
|
127
128
|
const dbPath = getDbPath();
|
|
128
|
-
|
|
129
|
+
// An empty map renders as `itemCount: 0` for every bundle — indistinguishable
|
|
130
|
+
// from "these bundles really are empty". Only a never-built index gets to say
|
|
131
|
+
// that silently; an unreadable one falls through to the opener and is
|
|
132
|
+
// reported by the `warn` in the catch below (#791).
|
|
133
|
+
if (isPathAbsent(dbPath))
|
|
129
134
|
return counts;
|
|
130
135
|
let db;
|
|
131
136
|
try {
|
|
@@ -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
|
}
|