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.
Files changed (109) hide show
  1. package/CHANGELOG.md +707 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/commands/agent/contribute-cli.js +1 -1
  5. package/dist/commands/feedback-cli.js +7 -1
  6. package/dist/commands/health/llm-usage.js +2 -1
  7. package/dist/commands/health/surfaces.js +4 -77
  8. package/dist/commands/health.js +65 -11
  9. package/dist/commands/improve/distill/quality-gate.js +6 -1
  10. package/dist/commands/improve/eligibility.js +7 -1
  11. package/dist/commands/improve/improve.js +126 -10
  12. package/dist/commands/improve/locks.js +7 -0
  13. package/dist/commands/improve/memory/memory-improve.js +8 -0
  14. package/dist/commands/improve/run-context.js +5 -0
  15. package/dist/commands/improve/session-asset.js +4 -0
  16. package/dist/commands/lint/base-linter.js +31 -7
  17. package/dist/commands/lint/index.js +200 -50
  18. package/dist/commands/lint/types.js +22 -1
  19. package/dist/commands/proposal/repository.js +17 -1
  20. package/dist/commands/sources/info.js +12 -2
  21. package/dist/commands/sources/installed-stashes.js +6 -1
  22. package/dist/commands/tasks/tasks.js +8 -2
  23. package/dist/commands/workflow-cli.js +17 -11
  24. package/dist/core/abort-deadline.js +28 -0
  25. package/dist/core/adapter/adapters/agent-skills-adapter.js +67 -5
  26. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  27. package/dist/core/adapter/adapters/akm-lint.js +72 -20
  28. package/dist/core/adapter/adapters/akm-task-adapter.js +40 -19
  29. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  30. package/dist/core/asset/frontmatter.js +4 -0
  31. package/dist/core/common.js +66 -6
  32. package/dist/core/concurrent.js +32 -0
  33. package/dist/core/config/schema/workflow.js +11 -0
  34. package/dist/core/errors.js +25 -0
  35. package/dist/core/events.js +30 -24
  36. package/dist/core/file-lock.js +7 -1
  37. package/dist/core/improve-result.js +5 -0
  38. package/dist/core/json-schema.js +327 -9
  39. package/dist/core/loopback.js +89 -0
  40. package/dist/core/migration-operation.js +17 -2
  41. package/dist/core/path-access.js +107 -0
  42. package/dist/core/redaction.js +64 -17
  43. package/dist/core/spawn-env.js +234 -0
  44. package/dist/core/state-db-scope.js +134 -0
  45. package/dist/core/subprocess.js +181 -37
  46. package/dist/core/write-provenance.js +85 -0
  47. package/dist/core/write-source.js +7 -0
  48. package/dist/indexer/db/graph-db.js +17 -6
  49. package/dist/indexer/ensure-index.js +10 -3
  50. package/dist/indexer/index-written-assets.js +17 -2
  51. package/dist/indexer/indexer.js +55 -15
  52. package/dist/indexer/passes/memory-inference.js +4 -0
  53. package/dist/indexer/search/db-search.js +8 -15
  54. package/dist/integrations/agent/engine-resolution.js +24 -11
  55. package/dist/integrations/agent/model-aliases.js +1 -1
  56. package/dist/integrations/agent/profiles.js +9 -1
  57. package/dist/integrations/agent/spawn.js +15 -87
  58. package/dist/integrations/lockfile.js +45 -2
  59. package/dist/output/text/lint-format.js +17 -4
  60. package/dist/scripts/akm-migrate-node.js +1435 -753
  61. package/dist/scripts/akm-migrate.js +1403 -721
  62. package/dist/sources/providers/git-stash.js +19 -0
  63. package/dist/sources/providers/git.js +1 -1
  64. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  65. package/dist/storage/database.js +6 -1
  66. package/dist/storage/managed-db.js +19 -0
  67. package/dist/storage/repositories/index-connection.js +28 -3
  68. package/dist/storage/repositories/index-entries-repository.js +6 -1
  69. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  70. package/dist/tasks/log-redaction.js +156 -0
  71. package/dist/tasks/parser.js +82 -5
  72. package/dist/tasks/runner.js +139 -11
  73. package/dist/tasks/schema.js +65 -0
  74. package/dist/workflows/concurrency-policy.js +95 -1
  75. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  76. package/dist/workflows/exec/exec-unit.js +542 -0
  77. package/dist/workflows/exec/frozen-judge.js +114 -42
  78. package/dist/workflows/exec/native-executor.js +457 -238
  79. package/dist/workflows/exec/param-secrets.js +4 -3
  80. package/dist/workflows/exec/run-workflow.js +424 -219
  81. package/dist/workflows/exec/step-work.js +496 -165
  82. package/dist/workflows/exec/unit-dispatch.js +31 -1
  83. package/dist/workflows/exec/unit-writer.js +53 -13
  84. package/dist/workflows/exec/worktree.js +454 -41
  85. package/dist/workflows/ir/compile.js +26 -2
  86. package/dist/workflows/ir/freeze.js +82 -15
  87. package/dist/workflows/ir/schema.js +105 -20
  88. package/dist/workflows/parser.js +216 -18
  89. package/dist/workflows/program/schema.js +24 -0
  90. package/dist/workflows/renderer.js +32 -4
  91. package/dist/workflows/resource-limits.js +182 -0
  92. package/dist/workflows/runtime/runs.js +146 -6
  93. package/dist/workflows/validate-summary.js +17 -2
  94. package/docs/README.md +74 -32
  95. package/docs/migration/release-notes/0.9.0.md +2 -1
  96. package/docs/migration/v0.7-to-v0.8.md +2 -1
  97. package/docs/migration/v0.8-to-v0.9.md +3 -1
  98. package/docs/reference/README.md +11 -4
  99. package/docs/reference/bundle-types.md +19 -0
  100. package/docs/reference/cli.md +105 -16
  101. package/docs/reference/configuration.md +15 -2
  102. package/docs/reference/data-and-telemetry.md +30 -10
  103. package/docs/reference/supported-formats.md +50 -0
  104. package/docs/reference/workflow-schema.md +1014 -0
  105. package/docs/reference/workflows.md +37 -633
  106. package/package.json +13 -6
  107. package/schemas/akm-config.json +8 -0
  108. package/schemas/akm-task.json +27 -5
  109. 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 { parse as parseYaml } from "yaml";
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
- function collectYamlFiles(dir) {
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(...collectYamlFiles(full));
50
+ results.push(...collectTaskFiles(full));
42
51
  }
43
- else if (entry.isFile() && entry.name.endsWith(".yml")) {
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 { file: diag.file, issue: "adapter-diagnostic", detail: `[${diag.issue}] ${diag.detail}`, fixed: diag.fixed };
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 flagged = diagnostics.map(diagnosticToLintIssue);
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 { ok: true, fixed: [], flagged, summary: { fixed: 0, flagged: flagged.length } };
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
- /** WorkflowLinter's `placeholder-stub` (WITH `--fix` delete) + `invalid-workflow-structure` (workflow-linter.ts:22-79). */
312
- function appendWorkflowIssues(ctx, issues) {
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
- if (ctx.fix) {
316
- try {
317
- fs.unlinkSync(ctx.filePath);
318
- issues.push({
319
- file: ctx.relPath,
320
- issue: "placeholder-stub",
321
- detail: `deleted: found "${placeholder}"`,
322
- fixed: true,
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
- issues.push({
336
- file: ctx.relPath,
337
- issue: "placeholder-stub",
338
- detail: `placeholder text: "${placeholder}"`,
339
- fixed: false,
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
- // NB: the CLI passes the ABSOLUTE filePath to parseWorkflow (matching the old
343
- // WorkflowLinter), whereas the adapter passes the change relPath.
344
- issues.push(...workflowStructureDiagnostics(ctx.relPath, ctx.raw, ctx.filePath));
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
- appendWorkflowIssues(ctx, issues);
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" ? collectYamlFiles(dirPath) : collectMarkdownFiles(dirPath, true);
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
- try {
436
- const parsed = parseYaml(raw);
437
- data =
438
- parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
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
- catch {
441
- data = {};
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
- const issues = lintAssetFile({ filePath, relPath, raw, data, body, frontmatter, fix, stashRoot, extraStashRoots }, subdir);
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
- summary: { fixed: fixed.length, flagged: flagged.length },
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
- export {};
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
- if (!fs.existsSync(resolvedPath))
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
- if (!fs.existsSync(dbPath))
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
- if (input.workflow && (input.engine !== undefined || input.model !== undefined || input.timeoutMs !== undefined)) {
44
- throw new UsageError("Workflow tasks accept only --params; engine, model, and timeout are prompt-task fields.", "INVALID_FLAG_VALUE");
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
- const timer = timeoutMs === undefined
166
- ? undefined
167
- : setTimeout(() => {
168
- timedOut = true;
169
- controller.abort(new Error(`Workflow run timed out after ${timeoutMs}ms.`));
170
- }, timeoutMs);
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
- if (result.run.status === "failed" || result.gateRejection || result.aborted) {
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
- if (timer)
188
- clearTimeout(timer);
194
+ deadline.disarm();
189
195
  process.off("SIGINT", onSigint);
190
196
  process.off("SIGTERM", onSigterm);
191
197
  }