akm-cli 0.9.10 → 0.9.12

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 (145) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/STABILITY.md +28 -15
  3. package/dist/assets/hints/cli-hints-full.md +1 -1
  4. package/dist/assets/improve-strategies/consolidate.json +1 -1
  5. package/dist/assets/improve-strategies/default.json +1 -1
  6. package/dist/assets/improve-strategies/thorough.json +1 -2
  7. package/dist/cli/shared.js +16 -4
  8. package/dist/cli.js +15 -13
  9. package/dist/commands/agent/agent-dispatch.js +8 -0
  10. package/dist/commands/command/execution-source-loader.js +25 -22
  11. package/dist/commands/command/portable-template.js +4 -26
  12. package/dist/commands/config-cli.js +10 -4
  13. package/dist/commands/env/env-binding.js +10 -3
  14. package/dist/commands/env/env-cli.js +7 -0
  15. package/dist/commands/env/secret-cli.js +15 -4
  16. package/dist/commands/health/checks.js +209 -78
  17. package/dist/commands/health/improve-metrics.js +12 -0
  18. package/dist/commands/health.js +16 -4
  19. package/dist/commands/improve/distill/quality-gate.js +15 -7
  20. package/dist/commands/improve/distill.js +28 -12
  21. package/dist/commands/improve/eval-cases.js +9 -2
  22. package/dist/commands/improve/execution.js +1 -2
  23. package/dist/commands/improve/extract.js +82 -56
  24. package/dist/commands/improve/improve-strategies.js +26 -8
  25. package/dist/commands/improve/improve.js +32 -4
  26. package/dist/commands/improve/loop-stages.js +13 -3
  27. package/dist/commands/improve/preparation.js +9 -6
  28. package/dist/commands/improve/reflect.js +61 -77
  29. package/dist/commands/lint/index.js +3 -1
  30. package/dist/commands/migrate-cli.js +6 -4
  31. package/dist/commands/proposal/drain-policies.js +22 -2
  32. package/dist/commands/proposal/repository.js +4 -4
  33. package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
  34. package/dist/commands/proposal/validators/proposals.js +10 -19
  35. package/dist/commands/read/show.js +42 -31
  36. package/dist/commands/registry-cli.js +4 -2
  37. package/dist/commands/sources/init.js +4 -8
  38. package/dist/commands/sources/self-update.js +2 -2
  39. package/dist/commands/sources/source-clone.js +5 -7
  40. package/dist/commands/sources/sources-cli.js +3 -5
  41. package/dist/commands/tasks/tasks-cli.js +36 -12
  42. package/dist/commands/tasks/tasks.js +38 -35
  43. package/dist/commands/tasks/validate.js +186 -0
  44. package/dist/commands/url-checker.js +75 -16
  45. package/dist/commands/workflow-cli.js +17 -15
  46. package/dist/core/activation-policy.js +31 -3
  47. package/dist/core/adapter/execution-source.js +39 -11
  48. package/dist/core/asset/stash-meta.js +7 -41
  49. package/dist/core/bundle-id.js +7 -1
  50. package/dist/core/common.js +8 -17
  51. package/dist/core/config/config-schema.js +3 -23
  52. package/dist/core/config/config-walker.js +50 -4
  53. package/dist/core/config/config.js +21 -5
  54. package/dist/core/config/legacy-source-shape-shim.js +79 -0
  55. package/dist/core/config/schema/embedding.js +2 -2
  56. package/dist/core/config/schema/engines.js +19 -2
  57. package/dist/core/config/schema/index-config.js +19 -21
  58. package/dist/core/config/schema/primitives.js +21 -10
  59. package/dist/core/config/schema/sources-bundles.js +1 -6
  60. package/dist/core/errors.js +2 -3
  61. package/dist/core/improve-result.js +8 -0
  62. package/dist/core/improve-types.js +17 -0
  63. package/dist/core/json-schema.js +1 -11
  64. package/dist/core/maintenance-barrier.js +17 -2
  65. package/dist/core/paths.js +124 -15
  66. package/dist/core/state/migrations.js +28 -0
  67. package/dist/core/state-db.js +28 -1
  68. package/dist/core/write-source.js +6 -6
  69. package/dist/indexer/bundle-identity-guard.js +3 -0
  70. package/dist/indexer/ensure-index.js +5 -0
  71. package/dist/indexer/indexer.js +11 -3
  72. package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
  73. package/dist/indexer/passes/metadata.js +16 -5
  74. package/dist/indexer/search/search-fields.js +1 -30
  75. package/dist/indexer/search/search-source.js +3 -2
  76. package/dist/integrations/agent/engine-resolution.js +107 -4
  77. package/dist/integrations/agent/execution-lowering.js +15 -2
  78. package/dist/integrations/agent/model-map.js +16 -10
  79. package/dist/integrations/agent/prompts.js +13 -6
  80. package/dist/integrations/agent/runner-dispatch.js +16 -3
  81. package/dist/integrations/agent/runner.js +2 -0
  82. package/dist/integrations/lockfile.js +22 -7
  83. package/dist/llm/client.js +16 -0
  84. package/dist/llm/index-passes.js +3 -2
  85. package/dist/output/shapes/passthrough.js +10 -3
  86. package/dist/output/shapes.js +5 -3
  87. package/dist/output/text/workflow-format.js +8 -1
  88. package/dist/scripts/akm-migrate-node.js +2028 -1613
  89. package/dist/scripts/akm-migrate.js +2027 -1612
  90. package/dist/setup/setup.js +14 -21
  91. package/dist/sources/include.js +150 -20
  92. package/dist/sources/providers/git-install.js +14 -12
  93. package/dist/sources/providers/git-provider.js +3 -3
  94. package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
  95. package/dist/sources/website-url.js +12 -4
  96. package/dist/storage/engines/sqlite-migrations.js +40 -10
  97. package/dist/storage/like-pattern.js +7 -0
  98. package/dist/storage/repositories/extract-sessions-repository.js +23 -0
  99. package/dist/storage/repositories/index-connection.js +27 -10
  100. package/dist/storage/repositories/index-entry-schema.js +19 -2
  101. package/dist/storage/repositories/index-schema.js +30 -9
  102. package/dist/storage/repositories/proposals-repository.js +2 -1
  103. package/dist/storage/repositories/task-history-repository.js +14 -7
  104. package/dist/storage/repositories/workflow-runs-repository.js +15 -1
  105. package/dist/storage/sqlite-read-snapshot.js +11 -9
  106. package/dist/tasks/backends/cron.js +34 -5
  107. package/dist/tasks/backends/launchd.js +23 -26
  108. package/dist/tasks/backends/schtasks.js +50 -3
  109. package/dist/tasks/frozen-script.js +2 -0
  110. package/dist/tasks/prepare/prepare.js +2 -7
  111. package/dist/tasks/prepare/script-capture.js +38 -6
  112. package/dist/tasks/schedule.js +154 -13
  113. package/dist/tasks/scheduler-sync.js +51 -25
  114. package/dist/tasks/source/task-source-v3-frozen.js +0 -1
  115. package/dist/tasks/source/task-source-v4.js +0 -1
  116. package/dist/workflows/exec/child-workflow.js +2 -3
  117. package/dist/workflows/exec/dispatch-redaction.js +21 -7
  118. package/dist/workflows/exec/exec-unit.js +3 -4
  119. package/dist/workflows/exec/run-workflow.js +19 -10
  120. package/dist/workflows/exec/step-work.js +35 -56
  121. package/dist/workflows/freeze/resolve-steps.js +19 -11
  122. package/dist/workflows/freeze/source-freeze.js +7 -0
  123. package/dist/workflows/freeze/targets/child-workflow.js +12 -18
  124. package/dist/workflows/freeze/targets/command.js +14 -2
  125. package/dist/workflows/ir/environment-v4.js +4 -2
  126. package/dist/workflows/ir/freeze-v4.js +2 -5
  127. package/dist/workflows/ir/plan-hash.js +0 -3
  128. package/dist/workflows/ir/schema-v4.js +14 -9
  129. package/dist/workflows/ir/schema.js +1 -3
  130. package/dist/workflows/resource-limits.js +35 -48
  131. package/dist/workflows/runtime/plan-classifier.js +89 -41
  132. package/dist/workflows/runtime/run-outputs.js +1 -21
  133. package/dist/workflows/runtime/runs.js +76 -150
  134. package/dist/workflows/source-files.js +28 -54
  135. package/dist/workflows/source-ir/program.js +2 -2
  136. package/dist/workflows/source-ir/semantics.js +5 -23
  137. package/docs/integration/bundling-akm.md +1 -1
  138. package/docs/migration/v0.8-to-v0.9.md +32 -0
  139. package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
  140. package/docs/reference/cli.md +105 -22
  141. package/docs/reference/configuration.md +12 -2
  142. package/docs/reference/data-and-telemetry.md +1 -1
  143. package/docs/reference/tasks.md +8 -0
  144. package/package.json +1 -1
  145. package/schemas/akm-config.json +11 -8
@@ -26,14 +26,18 @@ export function validateProposal(proposal) {
26
26
  * structure and applies {@link repairTruncatedDescription} to a truncated
27
27
  * description when one is detected.
28
28
  *
29
- * Repairs performed (in order):
30
- * 1. Strip body lines that restate frontmatter fields as pseudo-frontmatter
31
- * (e.g. `**description**: …` or `when_to_use: …` in the body).
32
- * 2. Remove stray body `---` horizontal-rule lines (leaving exactly the two
33
- * frontmatter fences when the content has a valid frontmatter block).
34
- * 3. Apply {@link repairTruncatedDescription} to a truncated/hanging
29
+ * Repairs performed:
30
+ * 1. Apply {@link repairTruncatedDescription} to a truncated/hanging
35
31
  * `description` field in the frontmatter.
36
32
  *
33
+ * It deliberately does NOT delete body lines. Two earlier repairs dropped
34
+ * every body line that restated a frontmatter key and every `---` in a body
35
+ * with frontmatter. Both fired inside fenced code blocks, so any asset
36
+ * documenting frontmatter — a note about akm, Claude Code skills, Jekyll,
37
+ * Hugo — was silently gutted on `proposal accept`, and the repaired bytes
38
+ * were written back over the original in the proposals database. A repair
39
+ * that can destroy content is not a repair.
40
+ *
37
41
  * Returns the repaired content string. When no repairs apply the input is
38
42
  * returned byte-identical so callers can use strict equality to detect
39
43
  * whether a repair actually happened.
@@ -81,19 +85,6 @@ export function repairProposalContent(content) {
81
85
  repairedLines.push(line);
82
86
  continue;
83
87
  }
84
- // Repair 1: Strip pseudo-frontmatter restatements in the body.
85
- // Matches lines like `**description**: …` or `when_to_use: …`.
86
- if (/^\s*(\*\*|__)?\s*(description|when_to_use)\s*(\*\*|__)?\s*:/i.test(line)) {
87
- // Drop the line — it is a structural defect, not user content.
88
- continue;
89
- }
90
- // Repair 2: Remove stray `---` horizontal-rule lines in the body.
91
- // We keep these only when the content has NO frontmatter (in that case
92
- // `---` is a legitimate thematic break in plain-body content).
93
- if (isFence && hasFrontmatter) {
94
- // Drop: these are extra `---` fences beyond the two frontmatter delimiters.
95
- continue;
96
- }
97
88
  repairedLines.push(line);
98
89
  }
99
90
  let repaired = repairedLines.join("\n");
@@ -31,7 +31,7 @@ import { NotFoundError, rethrowIfDataDirUnreadable, rethrowIfTestIsolationError,
31
31
  import { appendEvent } from "../../core/events.js";
32
32
  import { SCRIPT_EXTENSIONS } from "../../core/recognition-util.js";
33
33
  import { presentationFor } from "../../core/type-presentation.js";
34
- import { warn } from "../../core/warn.js";
34
+ import { warn, warnOnce } from "../../core/warn.js";
35
35
  import { hasGraphData } from "../../indexer/db/graph-db.js";
36
36
  import { listRelatedPathsForFile } from "../../indexer/graph/graph-boost.js";
37
37
  import { extractGraphForSingleFile } from "../../indexer/graph/graph-extraction.js";
@@ -71,12 +71,11 @@ export async function akmShowUnified(input) {
71
71
  if (metaRef)
72
72
  return showStashMeta(metaRef);
73
73
  }
74
- // Env/secret bodies have no safe fragment surface. Reject from the canonical
75
- // ref namespace before auto-index or lookup can touch authored bytes. This is
76
- // deliberately independent of on-disk suffix probing: secret filenames keep
77
- // their natural extension, and a misspelled extensionless ref must not move
78
- // the sensitive-fragment policy behind a not-found result.
79
- assertSensitiveFragmentUnsupported(parseBundleRef(ref));
74
+ // Env/secret bodies have no safe fragment surface, and a fragment cannot
75
+ // widen what the env/secret renderers expose: both always omit the body
76
+ // (env key names only; secret never rendered), fragment or not. Warn
77
+ // and ignore the fragment rather than refusing the whole show.
78
+ warnSensitiveFragmentUnsupported(parseBundleRef(ref));
80
79
  // Auto-index when stale so the index is current before lookup.
81
80
  const { primarySource } = resolveReadSources();
82
81
  await ensurePrimaryIndexForRead(primarySource);
@@ -177,7 +176,7 @@ function enforceScopeOrThrow(filePath, ref, scope) {
177
176
  /** @internal Use akmShowUnified() for all external callers. */
178
177
  export async function showLocal(input) {
179
178
  const parsed = parseBundleRef(input.ref);
180
- assertSensitiveFragmentUnsupported(parsed);
179
+ warnSensitiveFragmentUnsupported(parsed);
181
180
  const assetParts = typeNameFromConceptId(parsed.conceptId);
182
181
  const config = loadConfig();
183
182
  const allSources = resolveSourceEntries(input.stashDir);
@@ -194,12 +193,19 @@ export async function showLocal(input) {
194
193
  `Stash "${parsed.bundle}" is not installed. Run: ${installCmd}`);
195
194
  }
196
195
  if (!indexedEntry && !resolution.owner) {
197
- const unsupportedExtension = existingUnsupportedScriptExtension(assetParts, searchSources);
198
- if (unsupportedExtension !== undefined) {
199
- const displayExtension = unsupportedExtension || "no extension";
200
- throw new NotFoundError(`Script ref "${makeBundleRef(parsed.bundle, parsed.conceptId)}" resolves to an existing file with ` +
201
- `unsupported extension "${displayExtension}". Script refs must use a supported script extension: ` +
202
- `${[...SCRIPT_EXTENSIONS].join(", ")}.`, "ASSET_NOT_FOUND");
196
+ const unrecognized = findUnrecognizedScriptSource(assetParts, searchSources);
197
+ if (unrecognized) {
198
+ const displayExtension = unrecognized.extension || "no extension";
199
+ warn(`Script ref "${makeBundleRef(parsed.bundle, parsed.conceptId)}" has extension "${displayExtension}", which is outside the recognized set used for indexing (${[...SCRIPT_EXTENSIONS].join(", ")}); showing it as plain text.`);
200
+ const fileCtx = buildFileContext(unrecognized.sourceRoot, unrecognized.path);
201
+ const renderer = await getRenderer("script-source");
202
+ if (renderer) {
203
+ const match = { type: "script", specificity: 0, renderer: "script-source" };
204
+ const renderCtx = buildRenderContext(fileCtx, match, allSourceDirs);
205
+ const response = renderer.buildShowResponse(renderCtx);
206
+ response.name = assetParts?.name ?? response.name;
207
+ return response;
208
+ }
203
209
  }
204
210
  }
205
211
  if (!indexedEntry || !assetPath) {
@@ -242,9 +248,11 @@ export async function showLocal(input) {
242
248
  response = renderer.buildShowResponse(renderCtx);
243
249
  if (parsed.fragment !== undefined) {
244
250
  if (!match.renderer.endsWith("-md")) {
245
- throw new UsageError(`Fragments are not supported for ${makeBundleRef(parsed.bundle, parsed.conceptId)}. Only Markdown documents support heading fragments.`, "INVALID_FLAG_VALUE");
251
+ warn(`Fragment "#${parsed.fragment}" was ignored: ${makeBundleRef(parsed.bundle, parsed.conceptId)} is not a Markdown document, so heading fragments do not apply. Showing the whole asset.`);
252
+ }
253
+ else {
254
+ applyMarkdownFragment(response, fileCtx.content(), parsed.fragment, presentedName);
246
255
  }
247
- applyMarkdownFragment(response, fileCtx.content(), parsed.fragment, presentedName);
248
256
  }
249
257
  }
250
258
  }
@@ -318,22 +326,22 @@ export async function showLocal(input) {
318
326
  }
319
327
  return fullResponse;
320
328
  }
321
- /** Reject body fragments for namespaces whose authored bytes are sensitive. */
322
- function assertSensitiveFragmentUnsupported(ref) {
329
+ /**
330
+ * Warn and ignore body fragments for namespaces whose authored bytes are
331
+ * sensitive. `warnOnce`-keyed on the exact ref: `akmShowUnified` calls this
332
+ * before delegating to `showLocal`, which calls it again as its own
333
+ * defense-in-depth for callers that use `showLocal` directly — a single
334
+ * request must not print the same warning twice.
335
+ */
336
+ function warnSensitiveFragmentUnsupported(ref) {
323
337
  if (ref.fragment === undefined)
324
338
  return;
325
339
  const type = typeNameFromConceptId(ref.conceptId)?.type;
326
340
  if (type !== "env" && type !== "secret")
327
341
  return;
328
- throw new UsageError(`Fragments are not supported for ${makeBundleRef(ref.bundle, ref.conceptId)}. Sensitive ${type} assets do not expose body fragments.`, "INVALID_FLAG_VALUE");
342
+ warnOnce(`sensitive-fragment:${makeBundleRef(ref.bundle, ref.conceptId)}#${ref.fragment}`, `Fragment "#${ref.fragment}" was ignored: sensitive ${type} assets do not expose body fragments. Showing ${makeBundleRef(ref.bundle, ref.conceptId)} in full.`);
329
343
  }
330
- /**
331
- * Return the unsupported extension only when the exact canonical AKM script
332
- * path exists as a contained regular file. Physical owner arbitration runs
333
- * first; this is a diagnostic for its miss, never an alternate owner or
334
- * runnable-file classifier. No authored bytes are read.
335
- */
336
- function existingUnsupportedScriptExtension(assetParts, sources) {
344
+ function findUnrecognizedScriptSource(assetParts, sources) {
337
345
  if (assetParts?.type !== "script")
338
346
  return undefined;
339
347
  const extension = path.extname(assetParts.name);
@@ -357,7 +365,7 @@ function existingUnsupportedScriptExtension(assetParts, sources) {
357
365
  const realCandidate = fs.realpathSync(candidate);
358
366
  if (!isWithin(realCandidate, realRoot) || !fs.statSync(realCandidate).isFile())
359
367
  continue;
360
- return extension;
368
+ return { extension, path: realCandidate, sourceRoot: realRoot };
361
369
  }
362
370
  catch {
363
371
  // Missing, unreadable, dangling, or otherwise unsafe paths remain normal
@@ -447,7 +455,7 @@ async function maybeExtractGraphInline(config, sourceStashDir, assetPath) {
447
455
  export async function showByRef(ref) {
448
456
  const parsed = parseBundleRef(ref);
449
457
  if (parsed.fragment !== undefined) {
450
- throw new UsageError(`Fragments are not accepted by raw show: ${ref}`, "INVALID_FLAG_VALUE");
458
+ warn(`Fragment "#${parsed.fragment}" was ignored by raw show: ${ref}. Returning the whole file.`);
451
459
  }
452
460
  const entry = await lookupBundleRef(parsed);
453
461
  if (!entry) {
@@ -488,12 +496,15 @@ function indexedMatch(entry, renderer) {
488
496
  return { type: entry.type, specificity: Number.MAX_SAFE_INTEGER, renderer, meta: { name: entry.name } };
489
497
  }
490
498
  function buildIndexedProjectionResponse(entry, assetPath, fragment) {
491
- if (fragment !== undefined && path.extname(assetPath).toLowerCase() !== ".md") {
492
- throw new UsageError(`Fragments are not supported for ${entry.conceptId}. Only Markdown documents support heading fragments.`, "INVALID_FLAG_VALUE");
499
+ const isMarkdown = path.extname(assetPath).toLowerCase() === ".md";
500
+ if (fragment !== undefined && !isMarkdown) {
501
+ warn(`Fragment "#${fragment}" was ignored: ${entry.conceptId} is not a Markdown document, so heading fragments do not apply. Showing the whole asset.`);
493
502
  }
494
503
  const raw = fs.readFileSync(assetPath, "utf8");
495
504
  const parsed = parseFrontmatter(raw);
496
- const content = fragment ? requireMarkdownSection(parsed.content, fragment, entry.name).content : parsed.content;
505
+ const content = fragment !== undefined && isMarkdown
506
+ ? requireMarkdownSection(parsed.content, fragment, entry.name).content
507
+ : parsed.content;
497
508
  const description = entry.document?.description ?? asNonEmptyString(parsed.data.description);
498
509
  const tags = entry.document?.tags ??
499
510
  (Array.isArray(parsed.data.tags)
@@ -4,7 +4,7 @@
4
4
  import { defineGroupCommand, defineJsonCommand, output } from "../cli/shared.js";
5
5
  import { getEffectiveRegistries, loadUserConfig, mutateConfig } from "../core/config/config.js";
6
6
  import { NotFoundError, UsageError } from "../core/errors.js";
7
- import { formatRegistryUrl, hasRegistryUrlCredentials, REGISTRY_CREDENTIALS_UNSUPPORTED, registryEntryForOutput, } from "../core/registry-url.js";
7
+ import { formatRegistryLabel, formatRegistryUrl, hasRegistryUrlCredentials, registryEntryForOutput, } from "../core/registry-url.js";
8
8
  import { warn } from "../core/warn.js";
9
9
  export const registryCommand = defineGroupCommand({
10
10
  meta: { name: "registry", description: "Manage bundle registries" },
@@ -33,7 +33,9 @@ export const registryCommand = defineGroupCommand({
33
33
  },
34
34
  async run({ args }) {
35
35
  if (hasRegistryUrlCredentials(args.url)) {
36
- throw new UsageError(REGISTRY_CREDENTIALS_UNSUPPORTED);
36
+ warn(`Registry ${formatRegistryLabel({ url: args.url, name: args.name })} was added, but its URL's ` +
37
+ "username/password is ignored: authenticated registries are not supported by the built-in " +
38
+ "static-index or skills-sh providers.");
37
39
  }
38
40
  if (!args.url.startsWith("http")) {
39
41
  throw new UsageError("Registry URL must start with http:// or https://");
@@ -12,8 +12,8 @@ import fs from "node:fs";
12
12
  import path from "node:path";
13
13
  import { stashDirNames } from "../../core/asset/asset-placement.js";
14
14
  import { mutateConfig } from "../../core/config/config.js";
15
- import { ConfigError } from "../../core/errors.js";
16
15
  import { assertSafeStashDir, getConfigPath, getDefaultStashDir } from "../../core/paths.js";
16
+ import { warnOnce } from "../../core/warn.js";
17
17
  import { primaryBundlePath, withPrimaryBundle } from "./bundle-config-ops.js";
18
18
  import { copyStashSkeleton, ensureStashGitignore, scaffoldStashMeta } from "./stash-skeleton.js";
19
19
  /**
@@ -37,11 +37,9 @@ import { copyStashSkeleton, ensureStashGitignore, scaffoldStashMeta } from "./st
37
37
  function assertInitSandbox(stashDir, dirExplicitlyProvided) {
38
38
  if (!dirExplicitlyProvided)
39
39
  return; // Only guard explicit --dir, not default HOME resolution.
40
- const isUnderTest = isUnderTestRunner();
40
+ const isUnderTest = process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test";
41
41
  if (!isUnderTest)
42
42
  return;
43
- if (process.env.AKM_FORCE_INIT_TMP_STASH === "1")
44
- return;
45
43
  const isTmp = stashDir.startsWith("/tmp/") ||
46
44
  stashDir === "/tmp" ||
47
45
  stashDir.startsWith("/var/tmp/") ||
@@ -50,10 +48,8 @@ function assertInitSandbox(stashDir, dirExplicitlyProvided) {
50
48
  stashDir.startsWith("/private/tmp/");
51
49
  if (!isTmp)
52
50
  return;
53
- throw new ConfigError(`refusing to persist --dir stashDir to a temporary path while under test runner; set AKM_FORCE_INIT_TMP_STASH=1 if you really mean it (stashDir=${stashDir})`, "INIT_TMP_STASH_REFUSED");
54
- }
55
- function isUnderTestRunner() {
56
- return process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test";
51
+ warnOnce(`init-tmp-stash:${stashDir}`, `Persisting --dir stashDir to a temporary path (${stashDir}) while a test-runner env var (BUN_TEST/NODE_ENV=test) is set; ` +
52
+ "the OS may reap this path, leaving the next run pointing at a deleted bundle.");
57
53
  }
58
54
  // ── Test seam ────────────────────────────────────────────────────────────────
59
55
  // Swap-and-restore override. Inert in production; only tests call the setter.
@@ -346,8 +346,8 @@ export async function performUpgrade(check, opts, dependencies) {
346
346
  }
347
347
  }
348
348
  if (fs.existsSync(backupPath)) {
349
- removeFileBestEffort(stagedPath);
350
- throw new ConfigError(`Refusing to overwrite retained previous binary at ${backupPath}.`, "UPGRADE_BLOCKED");
349
+ warn(`A previous upgrade left a stale backup at ${backupPath}; overwriting it.`);
350
+ removeFileBestEffort(backupPath);
351
351
  }
352
352
  try {
353
353
  if (IS_WINDOWS)
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { stashDirFor } from "../../core/asset/asset-placement.js";
8
8
  import { isBundleSlug } from "../../core/asset/asset-ref.js";
9
9
  import { displayRef, parseQualifiedRefInput } from "../../core/asset/resolve-ref.js";
10
+ import { isWithin } from "../../core/common.js";
10
11
  import { loadConfig } from "../../core/config/config.js";
11
12
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
12
13
  import { defaultBundleForTarget } from "../../core/mutation-target.js";
@@ -224,18 +225,15 @@ function lstatIfExists(filePath) {
224
225
  }
225
226
  function assertNoDestinationSymlinkParent(root, destination) {
226
227
  const resolvedRoot = path.resolve(root);
227
- const relativeParent = path.relative(resolvedRoot, path.dirname(path.resolve(destination)));
228
+ const parentDir = path.dirname(path.resolve(destination));
229
+ const relativeParent = path.relative(resolvedRoot, parentDir);
228
230
  if (relativeParent === "" || relativeParent === ".")
229
231
  return;
230
232
  if (relativeParent.startsWith("..") || path.isAbsolute(relativeParent)) {
231
233
  throw new UsageError(`Clone destination escapes the selected target: ${destination}.`, "PATH_ESCAPE_VIOLATION");
232
234
  }
233
- let current = resolvedRoot;
234
- for (const segment of relativeParent.split(path.sep)) {
235
- current = path.join(current, segment);
236
- if (lstatIfExists(current)?.isSymbolicLink()) {
237
- throw new UsageError(`Clone destination has a symbolic-link parent outside the selected target boundary: ${current}.`, "PATH_ESCAPE_VIOLATION");
238
- }
235
+ if (!isWithin(parentDir, resolvedRoot)) {
236
+ throw new UsageError(`Clone destination has a symbolic-link parent outside the selected target boundary: ${parentDir}.`, "PATH_ESCAPE_VIOLATION");
239
237
  }
240
238
  }
241
239
  function listTreeFiles(root) {
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { defineCommand } from "citty";
29
29
  import { getParsedInvocation } from "../../cli/invocation.js";
30
- import { defineJsonCommand, EXIT_CODES, GLOBAL_OUTPUT_ARGS, output, runWithJsonErrors } from "../../cli/shared.js";
30
+ import { defineJsonCommand, EXIT_CODES, GLOBAL_OUTPUT_ARGS, output, outputWithExitCode, runWithJsonErrors, } from "../../cli/shared.js";
31
31
  import { loadConfig } from "../../core/config/config.js";
32
32
  import { UsageError } from "../../core/errors.js";
33
33
  import { appendEvent } from "../../core/events.js";
@@ -54,12 +54,10 @@ export const upgradeCommand = defineJsonCommand({
54
54
  }
55
55
  const skipPostUpgrade = args["skip-post-upgrade"];
56
56
  const result = await performUpgrade(check, { force: args.force, skipPostUpgrade });
57
- output("upgrade", result);
58
57
  // The install may have succeeded, but an upgrade whose migration is
59
58
  // blocked or could not run is not done: exit like `akm migrate apply` does.
60
- if (result.migration?.status === "blocked" || result.migration?.status === "failed") {
61
- process.exitCode = EXIT_CODES.GENERAL;
62
- }
59
+ const migrationFailed = result.migration?.status === "blocked" || result.migration?.status === "failed";
60
+ outputWithExitCode("upgrade", result, migrationFailed ? EXIT_CODES.GENERAL : undefined);
63
61
  },
64
62
  });
65
63
  // `sync` body, standalone so the git-commit/push logic stays in one place.
@@ -27,11 +27,12 @@
27
27
  import { defineCommand } from "citty";
28
28
  import { getParsedInvocation } from "../../cli/invocation.js";
29
29
  import { parsePositiveIntFlag } from "../../cli/parse-args.js";
30
- import { defineGroupCommand, defineJsonCommand, EXIT_CODES, GLOBAL_OUTPUT_ARGS, output, runWithJsonErrors, } from "../../cli/shared.js";
30
+ import { defineGroupCommand, defineJsonCommand, EXIT_CODES, GLOBAL_OUTPUT_ARGS, output, outputWithExitCode, runWithJsonErrors, } from "../../cli/shared.js";
31
31
  import { UsageError } from "../../core/errors.js";
32
32
  import { TASK_RUN_BOOLEAN_FLAGS, TASK_RUN_VALUE_FLAGS } from "../../tasks/task-run-reserved-flags.js";
33
33
  import { akmTaskExplain } from "./explain.js";
34
34
  import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksPrune, akmTasksRun, akmTasksSync, akmTasksSyncPlan, } from "./tasks.js";
35
+ import { akmTaskValidate } from "./validate.js";
35
36
  /** Shared `--bundle <bundle>` arg wired onto every task subcommand. */
36
37
  const bundleArg = {
37
38
  bundle: {
@@ -355,21 +356,16 @@ const tasksSyncCommand = defineJsonCommand({
355
356
  const rebind = args.rebind === true;
356
357
  if (args["dry-run"] === true) {
357
358
  const preview = await akmTasksSyncPlan({}, args.bundle, { rebind });
358
- output("task-sync-dry-run", preview);
359
- const exitCode = taskSyncDryRunExitCode(preview);
360
- if (exitCode !== undefined)
361
- process.exitCode = exitCode;
359
+ outputWithExitCode("task-sync-dry-run", preview, taskSyncDryRunExitCode(preview));
362
360
  return;
363
361
  }
364
362
  const result = await akmTasksSync({}, args.bundle, { rebind });
365
- output("task-sync", result);
366
363
  // #867: sync degrades — sources that failed to parse/prepare are
367
364
  // excluded from reconciliation and reported in `result.failures` rather
368
365
  // than poisoning the whole sync, but their presence must still fail
369
366
  // the command's exit code so the breakage stays visible. (#906: this key
370
367
  // matches the `--dry-run` preview's `failures` field — no separate name.)
371
- if (result.failures.length > 0)
372
- process.exitCode = EXIT_CODES.GENERAL;
368
+ outputWithExitCode("task-sync", result, result.failures.length > 0 ? EXIT_CODES.GENERAL : undefined);
373
369
  },
374
370
  });
375
371
  // ── `akm task explain` — read-only introspection (P2b Lane B, spec
@@ -414,6 +410,36 @@ const tasksDoctorCommand = defineJsonCommand({
414
410
  output("task-doctor", result);
415
411
  },
416
412
  });
413
+ /**
414
+ * #907: `akm task validate`'s exit-code contract — `valid`/`converts` are
415
+ * successful outcomes (exit 0); `blocked`/`invalid`/`not-a-task` are
416
+ * diagnosed defects the caller must act on (exit 1, mirroring `task sync`'s
417
+ * own `failures.length > 0 -> EXIT_CODES.GENERAL`). A missing path or an
418
+ * unreadable file never reaches this function at all — `akmTaskValidate`
419
+ * throws a `UsageError` for those, which `defineJsonCommand`'s wrapping
420
+ * already maps to exit 2.
421
+ */
422
+ export function taskValidateExitCode(result) {
423
+ return result.outcome === "valid" || result.outcome === "converts" ? undefined : EXIT_CODES.GENERAL;
424
+ }
425
+ const tasksValidateCommand = defineJsonCommand({
426
+ meta: {
427
+ name: "validate",
428
+ description: "Parse a single task file by filesystem path — not a concept ref, and the file need not live in a " +
429
+ "configured bundle — and report the same diagnostic `akm task sync` would produce for it. Read-only; " +
430
+ "never touches the scheduler.",
431
+ },
432
+ args: {
433
+ path: { type: "positional", description: "Filesystem path to a task source YAML file", required: true },
434
+ },
435
+ async run({ args }) {
436
+ const result = await akmTaskValidate(args.path);
437
+ output("task-validate", result);
438
+ const exitCode = taskValidateExitCode(result);
439
+ if (exitCode !== undefined)
440
+ process.exitCode = exitCode;
441
+ },
442
+ });
417
443
  /**
418
444
  * #851: `akm task prune`'s exit-code contract mirrors `task sync --dry-run`'s
419
445
  * (`taskSyncDryRunExitCode` above) — non-zero whenever the preview lists
@@ -451,10 +477,7 @@ const tasksPruneCommand = defineJsonCommand({
451
477
  .filter(Boolean)
452
478
  : undefined;
453
479
  const result = await akmTasksPrune({}, { yes: args.yes === true, id });
454
- output("task-prune", result);
455
- const exitCode = taskPruneExitCode(result);
456
- if (exitCode !== undefined)
457
- process.exitCode = exitCode;
480
+ outputWithExitCode("task-prune", result, taskPruneExitCode(result));
458
481
  },
459
482
  });
460
483
  export const taskCommand = defineGroupCommand({
@@ -466,6 +489,7 @@ export const taskCommand = defineGroupCommand({
466
489
  add: tasksAddCommand,
467
490
  run: tasksRunCommand,
468
491
  explain: tasksExplainCommand,
492
+ validate: tasksValidateCommand,
469
493
  history: tasksHistoryCommand,
470
494
  sync: tasksSyncCommand,
471
495
  prune: tasksPruneCommand,
@@ -22,6 +22,7 @@ import { resolveConfiguredSources } from "../../core/config/config-sources.js";
22
22
  import { IMPROVE_AUTONOMY_CONFIG_KEY, isImproveAutonomyEnabled } from "../../core/config/experimental.js";
23
23
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
24
24
  import { getTaskHistoryDir, getTaskLogDir } from "../../core/paths.js";
25
+ import { warn } from "../../core/warn.js";
25
26
  import { commitWriteTargetBoundary, deleteAssetFromSource, prepareWriteTargetForMutation, resolveWorkingStashTarget, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
26
27
  import { withEngineFallback } from "../../integrations/agent/engine-fallback.js";
27
28
  import { resolveAssetPath } from "../../sources/resolve.js";
@@ -391,7 +392,7 @@ async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
391
392
  const expectedSignature = sched.expectedSignature?.bind(sched);
392
393
  const needsRuntime = preflight.operations.some((operation) => operation.kind !== "remove" && operation.options?.binding === undefined);
393
394
  const prepared = needsRuntime
394
- ? prepareSchedulerSyncRuntime(syncTarget ? { target: syncTarget } : undefined, deps, options.rebind === true, "reconcile native scheduler bindings", warnings, allEntries.map((entry) => entry.binding))
395
+ ? prepareSchedulerSyncRuntime(syncTarget ? { target: syncTarget } : undefined, deps, warnings, allEntries.map((entry) => entry.binding))
395
396
  : undefined;
396
397
  const plan = finalizeSchedulerSyncPlan({
397
398
  ...common,
@@ -768,22 +769,24 @@ async function prepareTaskAddSchedulerTransaction(input) {
768
769
  contextPath: installedEntry.contextPath,
769
770
  },
770
771
  }
771
- : prepareSchedulerSyncRuntime(input.installOpts, input.deps, input.rebind, `create scheduler entry for task "${input.id}"`, []);
772
+ : prepareSchedulerSyncRuntime(input.installOpts, input.deps, []);
772
773
  const runtimeOpts = preparedRuntime.options;
773
774
  const removals = taskEntries.map((entry) => {
774
775
  const invocation = entry.invocation;
775
- if (!invocation) {
776
- throw new UsageError(`Installed scheduler binding ${JSON.stringify(entry.id)} has no exact parsed owner; refusing replacement.`, "RESOURCE_ALREADY_EXISTS");
777
- }
778
776
  const nativeId = entry.nativeId ?? schedulerNativeBindingId(entry.id);
779
777
  const artifact = assertSchedulerNativeArtifactCardinality(nativeArtifacts, nativeId, 1);
780
778
  if (!artifact?.fingerprint || artifact.bindingId !== entry.id) {
781
779
  throw new UsageError(`Installed scheduler binding ${JSON.stringify(entry.id)} has no exact coherent fingerprint.`, "RESOURCE_ALREADY_EXISTS");
782
780
  }
783
781
  const logicalSource = primary.logicalSource;
784
- const ordinal = schedulerBindingOrdinal(entry.id, logicalSource, invocation);
785
- if (ordinal === undefined) {
786
- throw new UsageError(`Installed scheduler binding ${JSON.stringify(entry.id)} has no exact schedule ordinal; refusing replacement.`, "RESOURCE_ALREADY_EXISTS");
782
+ const ordinal = invocation ? schedulerBindingOrdinal(entry.id, logicalSource, invocation) : undefined;
783
+ if (!invocation || ordinal === undefined) {
784
+ warn(`Installed scheduler binding ${JSON.stringify(entry.id)} (native id ${JSON.stringify(nativeId)}) could not be exactly parsed — likely a hand-edited entry; replacing it without a compare-and-swap guard.`);
785
+ return Object.freeze({
786
+ kind: "remove",
787
+ id: entry.id,
788
+ nativeId,
789
+ });
787
790
  }
788
791
  return Object.freeze({
789
792
  kind: "remove",
@@ -824,6 +827,8 @@ async function prepareTaskAddSchedulerTransaction(input) {
824
827
  for (const removal of removals) {
825
828
  if (removal.kind !== "remove")
826
829
  continue;
830
+ if (!removal.expected)
831
+ continue;
827
832
  initialByKey.set(schedulerNativeArtifactKey(removal.nativeId), Object.freeze({ ...removal.expected, state: "present" }));
828
833
  }
829
834
  for (const install of installs) {
@@ -841,16 +846,16 @@ async function prepareTaskAddSchedulerTransaction(input) {
841
846
  publishOperationIndex: removals.length,
842
847
  });
843
848
  }
844
- function prepareSchedulerSyncRuntime(base, deps, explicitRebind, operation, warnings, installedBindings = []) {
849
+ function prepareSchedulerSyncRuntime(base, deps, warnings, installedBindings = []) {
845
850
  if (deps.backend && !deps.schedulerRuntime)
846
851
  return base ? { options: base } : {};
847
852
  if (deps.schedulerRuntime) {
848
853
  const runtime = deps.schedulerRuntime();
849
- warnIneligibleRebind(runtime, explicitRebind, warnings, installedBindings);
854
+ warnIneligibleRebind(runtime, warnings, installedBindings);
850
855
  return { options: { ...base, binding: runtime.binding, contextPath: runtime.contextPath } };
851
856
  }
852
- const invocation = resolveAndValidateSchedulerInvocation(explicitRebind, operation);
853
- warnIneligibleRebind(invocation, explicitRebind, warnings, installedBindings);
857
+ const invocation = resolveAndValidateSchedulerInvocation();
858
+ warnIneligibleRebind(invocation, warnings, installedBindings);
854
859
  const descriptor = schedulerContextDescriptor();
855
860
  const contextPath = schedulerContextPath(descriptor);
856
861
  return {
@@ -863,24 +868,21 @@ function prepareSchedulerSyncRuntime(base, deps, explicitRebind, operation, warn
863
868
  },
864
869
  };
865
870
  }
866
- function resolveAndValidateSchedulerInvocation(explicitRebind, operation) {
871
+ function resolveAndValidateSchedulerInvocation() {
867
872
  const invocation = resolveAkmInvocation();
868
- if (!invocation.eligible && !explicitRebind) {
869
- throw new UsageError(`Refusing to ${operation} from an ineligible ${invocation.kind ?? "unknown"} invocation (${invocation.argv.join(" ")}).`, "INVALID_FLAG_VALUE", "npm-global ownership could not be verified. Run `npm install --global akm-cli` and use that launcher, use a standalone installation, or explicitly repeat the operation with --rebind.");
870
- }
871
873
  return { binding: invocation.argv, contextPath: "", eligible: invocation.eligible, kind: invocation.kind };
872
874
  }
873
- function warnIneligibleRebind(runtime, explicitRebind, warnings, installedBindings) {
874
- if (!explicitRebind || runtime.eligible !== false || warnings.length > 0)
875
+ function warnIneligibleRebind(runtime, warnings, installedBindings) {
876
+ if (runtime.eligible !== false || warnings.length > 0)
875
877
  return;
876
- // #868 residue: a `--rebind` that binds every currently-installed
877
- // entry to the SAME invocation it already carries changes nothing — this
878
- // is the steady state of an image-baked install re-running `task sync
879
- // --rebind` on a timer. Only warn when the rebind actually moves an entry
880
- // to a different invocation.
878
+ // #868 residue: binding every currently-installed entry to the SAME
879
+ // invocation it already carries changes nothing — this is the steady
880
+ // state of an image-baked install re-running `task sync` on a timer.
881
+ // Only warn when the bind actually moves an entry to a different
882
+ // invocation.
881
883
  if (installedBindings.length > 0 && installedBindings.every((bound) => sameArgv(bound, runtime.binding)))
882
884
  return;
883
- warnings.push(`--rebind bound scheduled tasks to an ineligible ${runtime.kind ?? "unknown"} invocation (${runtime.binding.join(" ")}); scheduled runs will invoke a mutable, unproven binary. Install akm via \`npm install --global akm-cli\` or a standalone release, then re-run \`akm task sync --rebind\`.`);
885
+ warnings.push(`Scheduled tasks are bound to an ineligible ${runtime.kind ?? "unknown"} invocation (${runtime.binding.join(" ")}); scheduled runs will invoke a mutable, unproven binary. Install akm via \`npm install --global akm-cli\` or a standalone release, then re-run \`akm task sync --rebind\`.`);
884
886
  }
885
887
  function groupInstalledBindings(entries, invocation) {
886
888
  const groups = new Map();
@@ -972,16 +974,20 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
972
974
  const common = { filePath, rootRealPath };
973
975
  let descriptor;
974
976
  try {
975
- const noFollow = "O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0;
976
- descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
977
- const before = fs.fstatSync(descriptor, { bigint: true });
977
+ descriptor = fs.openSync(filePath, fs.constants.O_RDONLY);
978
+ let before = fs.fstatSync(descriptor, { bigint: true });
978
979
  if (!before.isFile()) {
979
980
  throw new UsageError(`${filePath} is not a regular task source.`, "INVALID_FLAG_VALUE");
980
981
  }
981
- const bytes = fs.readFileSync(descriptor);
982
- const after = fs.fstatSync(descriptor, { bigint: true });
983
- if (!sameTaskSourceStat(before, after) || BigInt(bytes.byteLength) !== before.size) {
984
- throw new UsageError(`${filePath} changed while its guarded bytes were read.`, "RESOURCE_ALREADY_EXISTS");
982
+ let bytes = fs.readFileSync(descriptor);
983
+ const torn = !sameTaskSourceStat(before, fs.fstatSync(descriptor, { bigint: true })) ||
984
+ BigInt(bytes.byteLength) !== before.size;
985
+ if (torn) {
986
+ fs.closeSync(descriptor);
987
+ descriptor = fs.openSync(filePath, fs.constants.O_RDONLY);
988
+ before = fs.fstatSync(descriptor, { bigint: true });
989
+ bytes = fs.readFileSync(descriptor);
990
+ warn(`${filePath} changed while its guarded bytes were read; retried once and proceeding with the latest read (its SHA-256 is re-verified before anything is published).`);
985
991
  }
986
992
  const realPath = fs.realpathSync(filePath);
987
993
  const physicalRelative = path.relative(rootRealPath, realPath);
@@ -1011,9 +1017,6 @@ function captureTaskSourceExpectation(filePathInput, rootInput) {
1011
1017
  }
1012
1018
  if (cause instanceof UsageError)
1013
1019
  throw cause;
1014
- if (cause.code === "ELOOP") {
1015
- throw new UsageError(`${filePath} must not be a symbolic task source.`, "RESOURCE_ALREADY_EXISTS");
1016
- }
1017
1020
  throw new UsageError(`${filePath} could not be guarded as a contained regular task source: ${errorMessage(cause)}`, "PATH_ESCAPE_VIOLATION");
1018
1021
  }
1019
1022
  finally {
@@ -1264,7 +1267,7 @@ function assertInlineTaskPrompt(input) {
1264
1267
  (!/\s/.test(value) && /[\\/]/.test(value) && path.extname(value) !== "");
1265
1268
  if (!isFullRefInput(value) && !pathShaped)
1266
1269
  return;
1267
- throw new UsageError("--prompt accepts inline text only; asset refs and file paths are not prompt content. Use --workflow or an authored command ref where appropriate.", "INVALID_FLAG_VALUE");
1270
+ warn(`--prompt "${input}" looks like an asset ref or file path; --prompt sends it as literal text, not a reference. Did you mean --workflow?`);
1268
1271
  }
1269
1272
  function parseJsonObjectArg(raw) {
1270
1273
  let parsed;