akm-cli 0.9.0 → 0.9.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +724 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/cli/parse-args.js +7 -1
  5. package/dist/commands/agent/contribute-cli.js +1 -1
  6. package/dist/commands/env/child-env.js +14 -0
  7. package/dist/commands/feedback-cli.js +7 -1
  8. package/dist/commands/health/llm-usage.js +2 -1
  9. package/dist/commands/health/surfaces.js +4 -77
  10. package/dist/commands/health.js +65 -11
  11. package/dist/commands/improve/distill/quality-gate.js +6 -1
  12. package/dist/commands/improve/eligibility.js +7 -1
  13. package/dist/commands/improve/eval-cases.js +2 -0
  14. package/dist/commands/improve/improve.js +126 -10
  15. package/dist/commands/improve/locks.js +7 -0
  16. package/dist/commands/improve/memory/memory-improve.js +9 -0
  17. package/dist/commands/improve/run-context.js +5 -0
  18. package/dist/commands/improve/session-asset.js +4 -0
  19. package/dist/commands/lint/base-linter.js +31 -7
  20. package/dist/commands/lint/index.js +205 -51
  21. package/dist/commands/lint/types.js +22 -1
  22. package/dist/commands/proposal/repository.js +17 -1
  23. package/dist/commands/sources/add-cli.js +8 -2
  24. package/dist/commands/sources/info.js +12 -2
  25. package/dist/commands/sources/installed-stashes.js +6 -1
  26. package/dist/commands/sources/migration-help.js +12 -3
  27. package/dist/commands/sources/self-update.js +9 -1
  28. package/dist/commands/tasks/tasks.js +8 -2
  29. package/dist/commands/workflow-cli.js +17 -11
  30. package/dist/core/abort-deadline.js +28 -0
  31. package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
  32. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  33. package/dist/core/adapter/adapters/akm-lint.js +78 -22
  34. package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  36. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  37. package/dist/core/asset/frontmatter.js +10 -1
  38. package/dist/core/common.js +147 -9
  39. package/dist/core/concurrent.js +32 -0
  40. package/dist/core/config/config-io.js +5 -45
  41. package/dist/core/config/schema/engines.js +14 -3
  42. package/dist/core/config/schema/workflow.js +11 -0
  43. package/dist/core/errors.js +25 -0
  44. package/dist/core/events.js +30 -24
  45. package/dist/core/extra-params.js +11 -0
  46. package/dist/core/file-lock.js +7 -1
  47. package/dist/core/fs-txn.js +15 -2
  48. package/dist/core/improve-result.js +5 -0
  49. package/dist/core/json-schema.js +344 -9
  50. package/dist/core/loopback.js +89 -0
  51. package/dist/core/migration-operation.js +17 -2
  52. package/dist/core/path-access.js +107 -0
  53. package/dist/core/paths.js +16 -2
  54. package/dist/core/redaction.js +86 -18
  55. package/dist/core/spawn-env.js +234 -0
  56. package/dist/core/state-db-scope.js +134 -0
  57. package/dist/core/state-db.js +1 -0
  58. package/dist/core/subprocess.js +181 -37
  59. package/dist/core/write-provenance.js +85 -0
  60. package/dist/core/write-source.js +33 -2
  61. package/dist/indexer/db/graph-db.js +17 -6
  62. package/dist/indexer/ensure-index.js +10 -3
  63. package/dist/indexer/index-written-assets.js +17 -2
  64. package/dist/indexer/indexer.js +86 -21
  65. package/dist/indexer/passes/memory-inference.js +4 -0
  66. package/dist/indexer/search/db-search.js +25 -17
  67. package/dist/indexer/walk/walker.js +6 -1
  68. package/dist/integrations/agent/detect.js +13 -1
  69. package/dist/integrations/agent/engine-resolution.js +24 -11
  70. package/dist/integrations/agent/model-aliases.js +1 -1
  71. package/dist/integrations/agent/profiles.js +9 -1
  72. package/dist/integrations/agent/spawn.js +15 -87
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  74. package/dist/integrations/lockfile.js +55 -2
  75. package/dist/llm/client.js +14 -19
  76. package/dist/llm/embedder.js +23 -3
  77. package/dist/llm/embedders/remote.js +27 -2
  78. package/dist/output/html-render.js +40 -1
  79. package/dist/output/text/lint-format.js +17 -4
  80. package/dist/runtime.js +23 -1
  81. package/dist/scripts/akm-migrate-node.js +1714 -836
  82. package/dist/scripts/akm-migrate.js +1682 -804
  83. package/dist/setup/setup.js +22 -7
  84. package/dist/sources/providers/git-install.js +25 -2
  85. package/dist/sources/providers/git-stash.js +19 -0
  86. package/dist/sources/providers/git.js +1 -1
  87. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  88. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  89. package/dist/storage/database.js +71 -7
  90. package/dist/storage/engines/sqlite-migrations.js +61 -2
  91. package/dist/storage/managed-db.js +19 -0
  92. package/dist/storage/repositories/index-connection.js +39 -4
  93. package/dist/storage/repositories/index-entries-repository.js +6 -1
  94. package/dist/storage/repositories/index-meta-repository.js +11 -0
  95. package/dist/storage/repositories/index-schema.js +17 -2
  96. package/dist/storage/repositories/index-vec-repository.js +43 -5
  97. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  98. package/dist/storage/sqlite-pragmas.js +12 -1
  99. package/dist/tasks/log-redaction.js +156 -0
  100. package/dist/tasks/parser.js +82 -5
  101. package/dist/tasks/runner.js +222 -17
  102. package/dist/tasks/scheduler-invocation.js +19 -0
  103. package/dist/tasks/schema.js +86 -1
  104. package/dist/text-import-hook.mjs +1 -1
  105. package/dist/workflows/concurrency-policy.js +95 -1
  106. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  107. package/dist/workflows/exec/exec-unit.js +542 -0
  108. package/dist/workflows/exec/frozen-judge.js +114 -42
  109. package/dist/workflows/exec/native-executor.js +465 -238
  110. package/dist/workflows/exec/param-secrets.js +4 -3
  111. package/dist/workflows/exec/run-workflow.js +424 -219
  112. package/dist/workflows/exec/step-work.js +506 -167
  113. package/dist/workflows/exec/unit-dispatch.js +31 -1
  114. package/dist/workflows/exec/unit-writer.js +53 -13
  115. package/dist/workflows/exec/worktree.js +454 -41
  116. package/dist/workflows/ir/compile.js +26 -2
  117. package/dist/workflows/ir/freeze.js +82 -15
  118. package/dist/workflows/ir/schema.js +105 -20
  119. package/dist/workflows/parser.js +242 -19
  120. package/dist/workflows/program/schema.js +24 -0
  121. package/dist/workflows/renderer.js +32 -4
  122. package/dist/workflows/resource-limits.js +182 -0
  123. package/dist/workflows/runtime/runs.js +146 -6
  124. package/dist/workflows/validate-summary.js +17 -2
  125. package/docs/README.md +74 -32
  126. package/docs/migration/release-notes/0.9.0.md +2 -1
  127. package/docs/migration/v0.7-to-v0.8.md +2 -1
  128. package/docs/migration/v0.8-to-v0.9.md +3 -1
  129. package/docs/reference/README.md +11 -4
  130. package/docs/reference/bundle-types.md +19 -0
  131. package/docs/reference/cli.md +105 -16
  132. package/docs/reference/configuration.md +15 -2
  133. package/docs/reference/data-and-telemetry.md +30 -10
  134. package/docs/reference/supported-formats.md +50 -0
  135. package/docs/reference/workflow-schema.md +1014 -0
  136. package/docs/reference/workflows.md +37 -633
  137. package/package.json +13 -6
  138. package/schemas/akm-config.json +18 -5
  139. package/schemas/akm-task.json +27 -5
  140. package/schemas/akm-workflow.json +92 -13
@@ -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
@@ -417,7 +502,11 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
417
502
  }
418
503
  for (const filePath of assetFiles) {
419
504
  // Skip registry-cached read-only files — --fix must not mutate them.
420
- if (filePath.includes("/.cache/") || filePath.includes("/registry/"))
505
+ // Compare on a separator-normalized copy: on Windows these paths carry
506
+ // backslashes, so the forward-slash substring never matched and --fix
507
+ // rewrote files inside the registry cache.
508
+ const posixPath = filePath.replace(/\\/g, "/");
509
+ if (posixPath.includes("/.cache/") || posixPath.includes("/registry/"))
421
510
  continue;
422
511
  const relPath = path.relative(stashRoot, filePath);
423
512
  let raw;
@@ -430,15 +519,32 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
430
519
  let data;
431
520
  let body;
432
521
  let frontmatter;
522
+ // File-identity findings the per-type rules cannot produce: they describe
523
+ // the FILE (its extension, whether it parsed at all), not its fields.
524
+ const fileIssues = [];
433
525
  if (subdir === "tasks") {
434
526
  // 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 : {};
527
+ const parsed = parseTaskYaml(raw);
528
+ data = parsed.data;
529
+ if (!parsed.ok) {
530
+ // A parse failure used to fall through as `data = {}`, and every task
531
+ // rule short-circuits on an empty mapping — so an unparseable task
532
+ // file reported a CLEAN scan. Report the parse failure itself
533
+ // (issue #760).
534
+ fileIssues.push({
535
+ file: relPath,
536
+ issue: "invalid-task-yaml",
537
+ detail: taskYamlParseDetail(parsed.error),
538
+ fixed: false,
539
+ });
439
540
  }
440
- catch {
441
- data = {};
541
+ if (isNearMissTaskFileName(relPath)) {
542
+ fileIssues.push({
543
+ file: relPath,
544
+ issue: "invalid-task-yaml",
545
+ detail: taskExtensionDetail(relPath),
546
+ fixed: false,
547
+ });
442
548
  }
443
549
  body = raw;
444
550
  frontmatter = null;
@@ -446,13 +552,40 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
446
552
  else {
447
553
  ({ data, content: body, frontmatter } = parseFrontmatter(raw));
448
554
  }
449
- const issues = lintAssetFile({ filePath, relPath, raw, data, body, frontmatter, fix, stashRoot, extraStashRoots }, subdir);
555
+ // One file's checks including its `--fix` mutations must never abort
556
+ // the sweep: an uncaught throw here left the caller with an exception and
557
+ // no record of which earlier files had ALREADY been rewritten on disk
558
+ // (issue #761). A failure is reported per-file, in-band, and the sweep
559
+ // continues so the rest of the bundle is still linted.
560
+ let issues;
561
+ try {
562
+ issues = [
563
+ ...fileIssues,
564
+ ...lintAssetFile({ filePath, relPath, raw, data, body, frontmatter, fix, stashRoot, extraStashRoots }, subdir),
565
+ ];
566
+ }
567
+ catch (e) {
568
+ flagged.push(...fileIssues, {
569
+ file: relPath,
570
+ issue: "lint-failed",
571
+ detail: `lint ${fix ? "--fix " : ""}failed for this file: ${e instanceof Error ? e.message : String(e)}`,
572
+ fixed: fix ? "failed" : false,
573
+ });
574
+ continue;
575
+ }
450
576
  let fileDeleted = false;
451
577
  for (const issue of issues) {
452
578
  if (isFileDeletion(issue)) {
453
579
  fileDeleted = true;
454
580
  fixed.push(issue);
455
581
  }
582
+ else if (isAdvisoryLintIssue(issue)) {
583
+ // `lintAssetFile` returns errors only today, so this branch is
584
+ // reached by no current producer — it is here so that an advisory
585
+ // added to a per-type check later cannot silently become a
586
+ // `--fail-on-flagged` failure, the way the unclassified default does.
587
+ warnings.push(issue);
588
+ }
456
589
  else if (issue.fixed === true) {
457
590
  fixed.push(issue);
458
591
  }
@@ -463,6 +596,26 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
463
596
  }
464
597
  if (fileDeleted)
465
598
  continue; // file is gone — skip any remaining checks
599
+ // The workflow frontend is ONE parse+compile whose output feeds BOTH
600
+ // channels, so it runs here — once per file — rather than inside
601
+ // `lintAssetFile`, which is an errors-only surface (pinned by the lint
602
+ // golden). Which channel a finding lands in is decided by
603
+ // `ADVISORY_LINT_ISSUES`, never by which half of the pass produced it, so
604
+ // a future compile-warning kind carrying a fatal code cannot slip past
605
+ // `--fail-on-flagged`.
606
+ // NB: the CLI passes the ABSOLUTE filePath to parseWorkflow (matching the
607
+ // old WorkflowLinter), whereas the adapter passes the change relPath.
608
+ if (subdir === "workflows") {
609
+ const frontend = workflowFrontendDiagnostics(relPath, raw, filePath);
610
+ for (const finding of [...frontend.errors, ...frontend.warnings]) {
611
+ if (isAdvisoryLintIssue(finding)) {
612
+ warnings.push(finding);
613
+ }
614
+ else {
615
+ flagged.push(finding);
616
+ }
617
+ }
618
+ }
466
619
  }
467
620
  }
468
621
  // ── Env dangerous-key pass ─────────────────────────────────────────────────
@@ -483,7 +636,8 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
483
636
  ok: true,
484
637
  fixed,
485
638
  flagged,
486
- summary: { fixed: fixed.length, flagged: flagged.length },
639
+ warnings,
640
+ summary: { fixed: fixed.length, flagged: flagged.length, warnings: warnings.length },
487
641
  };
488
642
  }
489
643
  /**
@@ -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)
@@ -10,6 +10,7 @@ import { decideDangerousKeyInstall } from "../../core/activation-policy.js";
10
10
  import { UsageError } from "../../core/errors.js";
11
11
  import { appendEvent } from "../../core/events.js";
12
12
  import { warn } from "../../core/warn.js";
13
+ import { sanitizeString } from "../../sources/providers/provider-utils.js";
13
14
  import { akmRemove } from "./installed-stashes.js";
14
15
  import { akmAdd } from "./source-add.js";
15
16
  import { addStash } from "./source-manage.js";
@@ -180,9 +181,14 @@ export async function auditInstalledStashForDangerousKeys(opts) {
180
181
  groupedByEnv.set(f.envRef, existing);
181
182
  }
182
183
  for (const [envRef, keys] of groupedByEnv) {
183
- warn(`[warn] Env "${envRef}" in stash "${stashLabel}" contains potentially dangerous keys:`);
184
+ // envRef and keys come from filenames and KEY names inside a downloaded or
185
+ // cloned bundle, i.e. attacker-controllable. Tar validation rejects NUL but
186
+ // not ESC/CSI, and git checkout allows them in filenames on Linux/macOS —
187
+ // so printing them raw let a crafted bundle rewrite this security prompt
188
+ // with terminal escapes right before an "Install anyway?" confirmation.
189
+ warn(`[warn] Env "${sanitizeString(envRef)}" in stash "${sanitizeString(stashLabel)}" contains potentially dangerous keys:`);
184
190
  for (const key of keys) {
185
- warn(` - ${key}: can hijack process execution via \`akm env run\``);
191
+ warn(` - ${sanitizeString(key)}: can hijack process execution via \`akm env run\``);
186
192
  }
187
193
  }
188
194
  const confirmed = await p.confirm({
@@ -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 {
@@ -3,6 +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 embeddedChangelog from "../../../CHANGELOG.md" with { type: "text" };
6
7
  import { getDirname } from "../../runtime.js";
7
8
  const CHANGELOG_URL = "https://github.com/itlackey/akm/blob/main/CHANGELOG.md";
8
9
  const MIGRATION_DOC_URL = "https://github.com/itlackey/akm/blob/main/docs/migration/v0.5-to-v0.6.md";
@@ -24,9 +25,13 @@ function loadChangelog() {
24
25
  }
25
26
  }
26
27
  catch {
27
- // fall through to bundled notes
28
+ // fall through to the embedded copy
28
29
  }
29
- return undefined;
30
+ // In the `bun build --compile` standalone binary, import.meta.url points into
31
+ // the virtual /$bunfs tree and every existsSync above misses, so `akm help
32
+ // migrate <version>` degraded to the generic "no dedicated note" message for
33
+ // EVERY version. Only assets imported `with { type: "text" }` are embedded.
34
+ return embeddedChangelog.length > 0 ? embeddedChangelog : undefined;
30
35
  }
31
36
  /**
32
37
  * Load the bundled migration note for a specific version, if one exists.
@@ -87,7 +92,11 @@ function resolveLatestVersion(changelog) {
87
92
  return undefined;
88
93
  }
89
94
  function extractChangelogSection(changelog, version) {
90
- const pattern = new RegExp(`^## \\[${escapeRegexString(version)}\\][^\\n]*\\n([\\s\\S]*?)(?=^## \\[|\\Z)`, "m");
95
+ // `\Z` is not a JavaScript anchor — it matches a literal "Z", which truncated
96
+ // the section at the first capital Z in the body (and failed outright for the
97
+ // last entry). `$` with the `m` flag would stop at the first line end, so the
98
+ // end-of-input alternative has to be an explicit lookahead for the input end.
99
+ const pattern = new RegExp(`^## \\[${escapeRegexString(version)}\\][^\\n]*\\n([\\s\\S]*?)(?=^## \\[|$(?![\\s\\S]))`, "m");
91
100
  const match = changelog.match(pattern);
92
101
  if (!match)
93
102
  return undefined;
@@ -10,6 +10,7 @@ import { ConfigError } from "../../core/errors.js";
10
10
  import { warn } from "../../core/warn.js";
11
11
  import { githubHeaders } from "../../integrations/github.js";
12
12
  import { getDirname, mainPath, semverOrder } from "../../runtime.js";
13
+ import { resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
13
14
  const REPO = "itlackey/akm";
14
15
  const DEFAULT_PACKAGE_NAME = "akm-cli";
15
16
  const NODE_MODULES_SEGMENT = "/node_modules/";
@@ -474,7 +475,14 @@ function readInstalledCliVersion(akmBin) {
474
475
  return match?.[0];
475
476
  }
476
477
  function runRequiredCommand(akmBin, args, label) {
477
- const result = childProcess.spawnSync(akmBin, args, {
478
+ // A bare "akm" is not spawnable on Windows: npm/pnpm/yarn install a global CLI
479
+ // as akm.cmd / akm.ps1 shims, and spawnSync without a shell does not apply
480
+ // PATHEXT — so the package-manager upgrade arm died with ENOENT before it ever
481
+ // ran. resolveAkmInvocation returns a concrete argv (launcher, runtime + main
482
+ // script, or a standalone binary) for however this install actually runs.
483
+ // An explicit path (the standalone arm passes one) is used as given.
484
+ const [command, ...prefixArgs] = path.isAbsolute(akmBin) ? [akmBin] : resolveAkmInvocation().argv;
485
+ const result = childProcess.spawnSync(command ?? akmBin, [...prefixArgs, ...args], {
478
486
  encoding: "utf8",
479
487
  env: process.env,
480
488
  stdio: "pipe",