akm-cli 0.10.261012-beta → 0.10.261013-beta

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 (146) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/SECURITY.md +2 -2
  3. package/STABILITY.md +2 -2
  4. package/dist/assets/hints/cli-hints-full.md +6 -7
  5. package/dist/assets/hints/cli-hints-short.md +3 -4
  6. package/dist/commands/command/execution-source-loader.js +10 -14
  7. package/dist/commands/env/env-binding.js +47 -58
  8. package/dist/commands/env/env-cli.js +20 -22
  9. package/dist/commands/env/env.js +10 -58
  10. package/dist/commands/env/secret-cli.js +9 -27
  11. package/dist/commands/env/secret.js +3 -10
  12. package/dist/commands/health/scheduler-binary.js +1 -1
  13. package/dist/commands/improve/distill.js +0 -2
  14. package/dist/commands/improve/eligibility.js +0 -2
  15. package/dist/commands/lint/env-key-rules.js +7 -6
  16. package/dist/commands/lint/index.js +1 -1
  17. package/dist/commands/proposal/validators/proposal-validators.js +1 -4
  18. package/dist/commands/read/show.js +4 -25
  19. package/dist/commands/sources/add-cli.js +8 -63
  20. package/dist/commands/sources/bundle-cli.js +8 -9
  21. package/dist/commands/sources/installed-stashes.js +117 -61
  22. package/dist/commands/sources/schema-repair.js +0 -2
  23. package/dist/commands/sources/source-clone.js +31 -7
  24. package/dist/commands/tasks/explain.js +1 -1
  25. package/dist/commands/tasks/tasks.js +38 -56
  26. package/dist/commands/ui/redact.js +4 -4
  27. package/dist/commands/ui/workspace-actions.js +1 -1
  28. package/dist/commands/workflow/plan.js +47 -8
  29. package/dist/commands/workflow-cli.js +37 -41
  30. package/dist/core/adapter/adapters/akm-adapter.js +3 -3
  31. package/dist/core/adapter/adapters/akm-lint.js +3 -4
  32. package/dist/core/adapter/adapters/akm-metadata.js +2 -2
  33. package/dist/core/adapter/adapters/akm-task-adapter.js +5 -6
  34. package/dist/core/adapter/adapters/dotenv-adapter.js +4 -3
  35. package/dist/core/asset/resolve-ref.js +5 -0
  36. package/dist/core/bundle-rename.js +12 -4
  37. package/dist/core/common.js +4 -52
  38. package/dist/core/config/config-walker.js +1 -1
  39. package/dist/core/config/config.js +55 -18
  40. package/dist/core/config/schema/primitives.js +12 -6
  41. package/dist/core/config/schema/sources-bundles.js +2 -2
  42. package/dist/core/env-secret-ref.js +84 -73
  43. package/dist/core/errors.js +1 -1
  44. package/dist/core/paths.js +1 -6
  45. package/dist/core/redaction.js +16 -3
  46. package/dist/core/ref-token.js +82 -0
  47. package/dist/core/secret-value.js +14 -0
  48. package/dist/core/write-source.js +20 -4
  49. package/dist/indexer/indexer.js +27 -36
  50. package/dist/indexer/installations.js +1 -8
  51. package/dist/indexer/lookup/adapter-concept-owner.js +40 -14
  52. package/dist/indexer/materialize-embeddings.js +1 -1
  53. package/dist/indexer/scan/drain-dir.js +3 -3
  54. package/dist/indexer/search/search-source.js +4 -2
  55. package/dist/indexer/walk/path-resolver.js +13 -68
  56. package/dist/indexer/walk/walker.js +18 -22
  57. package/dist/integrations/agent/engine-resolution.js +9 -7
  58. package/dist/integrations/agent/runner-dispatch.js +4 -5
  59. package/dist/integrations/agent/runner.js +2 -0
  60. package/dist/llm/client.js +1 -1
  61. package/dist/llm/embedders/remote.js +4 -3
  62. package/dist/output/renderers.js +3 -4
  63. package/dist/output/shapes/helpers.js +0 -2
  64. package/dist/output/text/command-format.js +12 -2
  65. package/dist/output/text/show-directives.js +16 -28
  66. package/dist/output/text/workflow-format.js +37 -7
  67. package/dist/scripts/akm-migrate-node.js +45865 -44692
  68. package/dist/scripts/akm-migrate.js +45804 -44630
  69. package/dist/setup/steps/tasks.js +1 -2
  70. package/dist/sources/providers/provider-utils.js +12 -0
  71. package/dist/sources/resolve.js +12 -16
  72. package/dist/sources/snapshot-fetchers/secret-seam.js +2 -4
  73. package/dist/sources/snapshot-fetchers/x.js +1 -1
  74. package/dist/storage/repositories/proposals-repository.js +2 -2
  75. package/dist/storage/repositories/task-history-repository.js +2 -2
  76. package/dist/storage/repositories/workflow-runs-repository.js +38 -116
  77. package/dist/tasks/embedded.js +1 -1
  78. package/dist/tasks/prepare/prepare-support.js +3 -2
  79. package/dist/tasks/prepare/prepare.js +3 -4
  80. package/dist/tasks/prepare/script-capture.js +8 -22
  81. package/dist/tasks/run/load-task.js +46 -14
  82. package/dist/tasks/run/run-native-task.js +0 -8
  83. package/dist/tasks/run/run-workflow-task.js +2 -2
  84. package/dist/tasks/scheduler-sync.js +0 -1
  85. package/dist/tasks/source/bounded-document.js +0 -41
  86. package/dist/tasks/source/parse-task-source.js +0 -1
  87. package/dist/tasks/source/task-source-v4.js +1 -4
  88. package/dist/workflows/compile.js +1 -15
  89. package/dist/workflows/exec/child-workflow.js +1 -2
  90. package/dist/workflows/exec/dispatch-redaction.js +24 -6
  91. package/dist/workflows/exec/environment.js +11 -74
  92. package/dist/workflows/exec/exec-unit.js +31 -23
  93. package/dist/workflows/exec/frozen-judge.js +17 -3
  94. package/dist/workflows/exec/native-executor.js +70 -26
  95. package/dist/workflows/exec/run-workflow.js +31 -18
  96. package/dist/workflows/exec/step-work.js +4 -3
  97. package/dist/workflows/exec/unit-dispatch.js +1 -8
  98. package/dist/workflows/freeze/environment.js +14 -119
  99. package/dist/workflows/freeze/freeze.js +0 -1
  100. package/dist/workflows/freeze/step-values.js +1 -2
  101. package/dist/workflows/freeze/targets/child-workflow.js +3 -5
  102. package/dist/workflows/freeze/targets/command.js +6 -9
  103. package/dist/workflows/freeze/targets/shell.js +0 -3
  104. package/dist/workflows/parser.js +6 -25
  105. package/dist/workflows/plan.js +6 -2
  106. package/dist/workflows/renderer.js +77 -4
  107. package/dist/workflows/runtime/run-plan.js +37 -23
  108. package/dist/workflows/runtime/runs.js +41 -84
  109. package/dist/workflows/runtime/workflow-asset-loader.js +55 -86
  110. package/dist/workflows/source-files.js +7 -19
  111. package/docs/architecture/0.10-process-rules.md +6 -6
  112. package/docs/architecture/architecture.md +6 -6
  113. package/docs/architecture/comparisons/claude-code-vs-akm-workflows-full.md +12 -18
  114. package/docs/architecture/decisions/0003-child-env-allowlist-and-provenance.md +6 -8
  115. package/docs/architecture/internals/storage-locations.md +4 -4
  116. package/docs/architecture/persisted-data-compat.md +3 -2
  117. package/docs/architecture/specs/0.9.0-public-api-issue-backlog.md +1 -1
  118. package/docs/architecture/specs/akm-format-neutral-bundle-workspace-spec.md +3 -3
  119. package/docs/architecture/testing/manual-testing-checklist.md +46 -78
  120. package/docs/architecture/testing/task-workflow-manual-tests.md +22 -40
  121. package/docs/architecture/testing/testing-workflow.md +9 -11
  122. package/docs/architecture/workflow-engine.md +41 -27
  123. package/docs/guides/author-bundles.md +14 -17
  124. package/docs/guides/author-workflows.md +43 -9
  125. package/docs/guides/bundles.md +16 -11
  126. package/docs/guides/claude-code-vs-akm-workflows.md +1 -1
  127. package/docs/guides/run-workflows.md +25 -13
  128. package/docs/guides/scheduling.md +1 -1
  129. package/docs/maintainers/README.md +2 -3
  130. package/docs/maintainers/release-checklist.md +73 -126
  131. package/docs/migration/v0.9-to-v0.10.md +5 -1
  132. package/docs/reference/cli.md +124 -112
  133. package/docs/reference/configuration.md +16 -9
  134. package/docs/reference/data-and-telemetry.md +1 -1
  135. package/docs/reference/env-and-secrets.md +66 -15
  136. package/docs/reference/registry.md +6 -11
  137. package/docs/reference/tasks.md +11 -3
  138. package/docs/reference/ui-api.md +3 -3
  139. package/docs/reference/workflow-schema.md +109 -36
  140. package/package.json +1 -1
  141. package/schemas/akm-task.json +2 -4
  142. package/dist/commands/sources/dangerous-env-audit.js +0 -134
  143. package/dist/core/activation-policy.js +0 -67
  144. package/dist/execution/directory-identity.js +0 -78
  145. package/dist/workflows/authoring/scope-key.js +0 -79
  146. package/dist/workflows/source-semantics.js +0 -110
@@ -8,9 +8,7 @@
8
8
  * group, hard break, no alias kept at the top level:
9
9
  * - `create` (= old top-level `init`, src/commands/sources/init.ts)
10
10
  * - `add` (= old top-level `add`, src/commands/sources/add-cli.ts — the
11
- * command definition moved here; `add-cli.ts` keeps its exported helpers,
12
- * including `auditInstalledStashForDangerousKeys`, which a test imports
13
- * directly)
11
+ * command definition moved here; `add-cli.ts` keeps its exported helpers)
14
12
  * - `list` (= old top-level `list`)
15
13
  * - `remove` (= old top-level `remove`)
16
14
  * - `update` (= old top-level `update`)
@@ -134,18 +132,21 @@ const updateCommand = defineJsonCommand({
134
132
  description: "Exit successfully when index/state database contention shows another akm operation is active",
135
133
  default: false,
136
134
  },
135
+ // Deprecated no-op, accepted through 0.10 so scripts that pass it do not
136
+ // fail with UNKNOWN_FLAG. Installing a bundle is the trust decision: an
137
+ // update no longer scans env files for process-hijacking keys.
137
138
  "allow-dangerous-env-keys": {
138
139
  type: "boolean",
139
- description: "Allow an update containing dangerous env keys (e.g. LD_PRELOAD, PATH). Use only after explicitly reviewing the staged bundle.",
140
+ description: "Deprecated, no effect: update no longer scans env files for process-hijacking keys. Accepted through 0.10 for existing scripts.",
140
141
  default: false,
141
142
  },
142
143
  // Deprecated no-op, accepted through 0.10 so scripts that pass it do not
143
- // fail with UNKNOWN_FLAG. Deleting the previous directory of a read-only
144
- // install whose content moved no longer asks: it is a re-fetchable cache.
144
+ // fail with UNKNOWN_FLAG. Pruning old install directories of a read-only
145
+ // bundle no longer asks: they are a re-fetchable cache.
145
146
  yes: {
146
147
  type: "boolean",
147
148
  alias: "y",
148
- description: "Deprecated, no effect: update no longer asks before deleting a moved read-only install directory. Accepted through 0.10 for existing scripts.",
149
+ description: "Deprecated, no effect: update no longer asks before pruning old install directories of a read-only bundle. Accepted through 0.10 for existing scripts.",
149
150
  default: false,
150
151
  },
151
152
  },
@@ -156,7 +157,6 @@ const updateCommand = defineJsonCommand({
156
157
  target: args.target,
157
158
  all: args.all,
158
159
  force: args.force,
159
- allowDangerousEnvKeys: args["allow-dangerous-env-keys"],
160
160
  });
161
161
  }
162
162
  catch (error) {
@@ -173,7 +173,6 @@ const updateCommand = defineJsonCommand({
173
173
  target: args.target ?? null,
174
174
  all: args.all === true,
175
175
  force: args.force === true,
176
- allowDangerousEnvKeys: args["allow-dangerous-env-keys"] === true,
177
176
  processed: Array.isArray(result.processed)
178
177
  ? result.processed.length
179
178
  : 0,
@@ -22,20 +22,19 @@ import { AkmError, ConfigError, NotFoundError, UsageError } from "../../core/err
22
22
  import { isPathAbsent } from "../../core/path-access.js";
23
23
  import { getDbPath, getRegistryCacheDir } from "../../core/paths.js";
24
24
  import { warn } from "../../core/warn.js";
25
- import { resolveGitContentRoot } from "../../core/write-source.js";
26
25
  import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
27
26
  import { akmIndex } from "../../indexer/indexer.js";
28
27
  import { readLockfile, upsertLockEntry } from "../../integrations/lockfile.js";
29
- import { parseRegistryRef } from "../../registry/resolve.js";
28
+ import { parseRegistryRef, resolveRegistryArtifact } from "../../registry/resolve.js";
30
29
  import { getCachePaths, parseGitRepoUrl, runGit, syncMirroredRepo } from "../../sources/providers/git.js";
31
30
  import { replaceDirectory } from "../../sources/providers/git-install.js";
31
+ import { installRevisionDir, isDirectory } from "../../sources/providers/provider-utils.js";
32
32
  import { syncFromRef } from "../../sources/providers/sync-from-ref.js";
33
33
  import { createWebsiteProvider } from "../../sources/providers/website.js";
34
34
  import { storeSecretResolver } from "../../sources/snapshot-fetchers/secret-seam.js";
35
35
  import { ensureWebsiteMirror } from "../../sources/snapshot-fetchers/website-ingest.js";
36
36
  import { closeDatabase, openReadonlyExistingDatabase } from "../../storage/repositories/index-connection.js";
37
37
  import { getAllEntries } from "../../storage/repositories/index-entries-repository.js";
38
- import { auditStashForDangerousKeys } from "./dangerous-env-audit.js";
39
38
  import { removeInstalledRegistryEntry } from "./source-add.js";
40
39
  import { removeStash } from "./source-manage.js";
41
40
  /** Enumerate the registry-managed installs (lock-backed bundles) in a config. */
@@ -55,6 +54,7 @@ function listManagedInstalls(config) {
55
54
  source: lock.source,
56
55
  ref: lock.ref,
57
56
  localRoot: lock.localRoot ?? "",
57
+ installedAt: lock.installedAt ?? "",
58
58
  resolvedVersion: lock.resolvedVersion,
59
59
  resolvedRevision: lock.resolvedRevision,
60
60
  writable: componentWritable ?? bundle.writable === true,
@@ -379,18 +379,6 @@ function bundleComponent(config, id) {
379
379
  const component = bundleComponentConfig(bundle);
380
380
  return { root: component?.root ?? ".", writable: component?.writable ?? bundle?.writable === true };
381
381
  }
382
- async function auditStagedUpdate(auditRoot, ref, allowDangerousEnvKeys) {
383
- const decision = await auditStashForDangerousKeys({
384
- stashRoot: auditRoot,
385
- ref,
386
- allowDangerousKeys: allowDangerousEnvKeys,
387
- isTTY: process.stdin.isTTY === true,
388
- operation: "update",
389
- renderBlockedError: false,
390
- });
391
- if (decision.blocked)
392
- throw new NotFoundError(decision.error, decision.code);
393
- }
394
382
  function gitOrThrow(repoDir, args) {
395
383
  const result = runGit(["-C", repoDir, ...args], { timeout: 120_000 });
396
384
  if (result.status !== 0) {
@@ -399,7 +387,7 @@ function gitOrThrow(repoDir, args) {
399
387
  return result.stdout.trim();
400
388
  }
401
389
  /**
402
- * Publish an audited writable checkout: fast-forward the live checkout to
390
+ * Publish a staged writable checkout: fast-forward the live checkout to
403
391
  * exactly the staged commit. Git refuses a non-fast-forward or a merge that
404
392
  * would overwrite local work, and changes nothing in that case.
405
393
  */
@@ -423,15 +411,61 @@ function publishAndIndex(stashDir, publish) {
423
411
  return reindexAfterUpdate(stashDir);
424
412
  });
425
413
  }
414
+ function managedCredential(managed) {
415
+ return managed.credential ? resolveSecret(managed.credential, storeSecretResolver.resolveSecret) : undefined;
416
+ }
417
+ /**
418
+ * The unchanged result for a read-only git or npm install whose upstream still
419
+ * resolves to the locked revision while its `<rev>` directory is in place, so
420
+ * there is nothing to fetch, publish or replace. Undefined when the install
421
+ * must be updated: `--force`, a writable or lockless install, a missing
422
+ * directory, or a new upstream revision.
423
+ */
424
+ async function unchangedManagedItem(managed, force) {
425
+ if (force || managed.writable || !managed.resolvedRevision || !managed.localRoot)
426
+ return undefined;
427
+ if (managed.source !== "git" && managed.source !== "github" && managed.source !== "npm")
428
+ return undefined;
429
+ const revisionDir = installRevisionDir(getRegistryCacheDir(), managed.localRoot);
430
+ if (!revisionDir || !isDirectory(managed.localRoot))
431
+ return undefined;
432
+ const resolved = await resolveRegistryArtifact(parseRegistryRef(managed.ref), {
433
+ gitCredential: managedCredential(managed),
434
+ });
435
+ if (resolved.resolvedRevision !== managed.resolvedRevision)
436
+ return undefined;
437
+ return {
438
+ id: managed.installId,
439
+ source: managed.source,
440
+ ref: managed.ref,
441
+ previous: {
442
+ resolvedVersion: managed.resolvedVersion,
443
+ resolvedRevision: managed.resolvedRevision,
444
+ cacheDir: managed.localRoot,
445
+ },
446
+ installed: {
447
+ id: managed.installId,
448
+ source: managed.source,
449
+ ref: managed.ref,
450
+ artifactUrl: resolved.artifactUrl,
451
+ resolvedVersion: managed.resolvedVersion,
452
+ resolvedRevision: managed.resolvedRevision,
453
+ stashRoot: managed.localRoot,
454
+ cacheDir: revisionDir,
455
+ installedAt: managed.installedAt,
456
+ writable: false,
457
+ extractedDir: path.join(revisionDir, "extracted"),
458
+ },
459
+ changed: { version: false, revision: false, any: false },
460
+ };
461
+ }
426
462
  /** Fetch a registry-managed install into staging without touching its live content. */
427
463
  async function stageManagedUpdate(managed, force) {
428
- const credential = managed.credential
429
- ? resolveSecret(managed.credential, storeSecretResolver.resolveSecret)
430
- : undefined;
464
+ const credential = managedCredential(managed);
431
465
  if (managed.writable && managed.localRoot) {
432
466
  // A writable install is a checkout that may carry local commits: the
433
- // provider fast-forwards a copy, the copy is audited, and the live
434
- // checkout is then fast-forwarded to exactly the audited commit.
467
+ // provider fast-forwards a copy, and the live checkout is then
468
+ // fast-forwarded to exactly that fetched commit.
435
469
  const topLevel = runGit(["-C", managed.localRoot, "rev-parse", "--show-toplevel"]);
436
470
  if (topLevel.status !== 0 || !topLevel.stdout.trim()) {
437
471
  throw new UsageError(`Writable Git install at ${managed.localRoot} is not a checkout; refusing to update it.`);
@@ -452,7 +486,6 @@ async function stageManagedUpdate(managed, force) {
452
486
  return {
453
487
  synced: { ...fetched, contentDir: managed.localRoot, cacheDir: liveRepo, extractedDir: liveRepo },
454
488
  staged: {
455
- auditRoot: path.resolve(fetched.contentDir, managed.componentRoot),
456
489
  publish: () => fastForwardCheckout(liveRepo, stagedRepo),
457
490
  cleanup: () => cleanupDirectoryBestEffort(stagingParent, "bundle update"),
458
491
  },
@@ -468,7 +501,7 @@ async function stageManagedUpdate(managed, force) {
468
501
  const synced = await syncFromRef(managed.ref, { force, writable: managed.writable });
469
502
  return {
470
503
  synced,
471
- staged: { auditRoot: path.resolve(synced.contentDir, managed.componentRoot), publish() { }, cleanup() { } },
504
+ staged: { publish() { }, cleanup() { } },
472
505
  };
473
506
  }
474
507
  const cacheRoot = getRegistryCacheDir();
@@ -490,7 +523,6 @@ async function stageManagedUpdate(managed, force) {
490
523
  extractedDir: toLive(fetched.extractedDir),
491
524
  },
492
525
  staged: {
493
- auditRoot: path.resolve(fetched.contentDir, managed.componentRoot),
494
526
  publish: () => replaceDirectory(fetched.cacheDir, liveCacheDir),
495
527
  cleanup: () => cleanupDirectoryBestEffort(stagingParent, "bundle update"),
496
528
  },
@@ -501,8 +533,8 @@ async function stageManagedUpdate(managed, force) {
501
533
  throw error;
502
534
  }
503
535
  }
504
- /** Fetch a plain git source into staging, audit it, publish it, and reindex. */
505
- async function syncGitPlainSource(config, gitSource, stashDir, allowDangerousEnvKeys) {
536
+ /** Fetch a plain git source into staging, publish it, and reindex. */
537
+ async function syncGitPlainSource(config, gitSource, stashDir) {
506
538
  const id = gitSource.name ?? gitSource.url ?? "";
507
539
  const ref = gitSource.url ?? "";
508
540
  if (!gitSource.url)
@@ -514,7 +546,7 @@ async function syncGitPlainSource(config, gitSource, stashDir, allowDangerousEnv
514
546
  try {
515
547
  const stagedPaths = getCachePaths(canonicalUrl, stagingParent);
516
548
  // A writable mirror may carry local commits: pull into a copy, then
517
- // fast-forward the live checkout once the copy passes the audit.
549
+ // fast-forward the live checkout to it.
518
550
  const liveCheckout = component.writable && fs.existsSync(livePaths.repoDir);
519
551
  if (liveCheckout)
520
552
  fs.cpSync(livePaths.rootDir, stagedPaths.rootDir, { recursive: true, preserveTimestamps: true });
@@ -526,7 +558,6 @@ async function syncGitPlainSource(config, gitSource, stashDir, allowDangerousEnv
526
558
  ? { credential: resolveSecret(gitSource.credential, storeSecretResolver.resolveSecret) }
527
559
  : {}),
528
560
  });
529
- await auditStagedUpdate(path.resolve(resolveGitContentRoot(stagedPaths.repoDir), component.root), ref, allowDangerousEnvKeys);
530
561
  const index = await publishAndIndex(stashDir, () => {
531
562
  if (liveCheckout)
532
563
  fastForwardCheckout(livePaths.repoDir, stagedPaths.repoDir);
@@ -543,7 +574,7 @@ async function syncGitPlainSource(config, gitSource, stashDir, allowDangerousEnv
543
574
  * Re-crawl a website source and reindex. The website provider already builds
544
575
  * its snapshot in a staging directory and swaps it in, so a killed refresh
545
576
  * keeps the previous mirror; a snapshot holds only knowledge pages, so there
546
- * is no env file to audit.
577
+ * is no env file in it.
547
578
  */
548
579
  async function syncWebsitePlainSource(websiteSource, stashDir) {
549
580
  const id = websiteSource.name ?? websiteSource.url ?? "";
@@ -556,8 +587,8 @@ async function syncWebsitePlainSource(websiteSource, stashDir) {
556
587
  return { item: { id, kind: "website", ref: websiteSource.url ?? "" }, index };
557
588
  }
558
589
  /** Sync a git-mirrored (plain) source and return an UpdateResponse (single-target path). */
559
- async function updateGitSource(config, stashDir, target, all, gitSource, allowDangerousEnvKeys) {
560
- const synced = await syncGitPlainSource(config, gitSource, stashDir, allowDangerousEnvKeys);
590
+ async function updateGitSource(config, stashDir, target, all, gitSource) {
591
+ const synced = await syncGitPlainSource(config, gitSource, stashDir);
561
592
  return buildUpdateResponse(stashDir, target, all, [], { plainSynced: [synced.item], index: synced.index });
562
593
  }
563
594
  /** Re-crawl a website (plain) source and return an UpdateResponse (single-target path). */
@@ -602,6 +633,7 @@ function managedInstallViewOfPlainNpm(config, npmSource) {
602
633
  source: "npm",
603
634
  ref: spec.startsWith("npm:") ? spec : `npm:${spec}`,
604
635
  localRoot: "",
636
+ installedAt: "",
605
637
  writable: false,
606
638
  componentRoot: bundleComponent(config, id).root,
607
639
  requiredRoots: [],
@@ -609,18 +641,21 @@ function managedInstallViewOfPlainNpm(config, npmSource) {
609
641
  }
610
642
  /**
611
643
  * Sync a single registry-managed install and return the processed record:
612
- * fetch into staging, audit, publish, write its lock entry, reindex.
644
+ * fetch into staging, publish, write its lock entry, reindex.
613
645
  *
614
- * Read-only installs are keyed by resolved revision, so every upstream change
615
- * resolves a new content directory, and the previous one is deleted once the
616
- * new one is published. That directory is a re-fetchable cache: local and
617
- * writable installs never take this path, and a directory another bundle or
618
- * lock entry still references is kept. So deleting it never asks for confirmation.
646
+ * Read-only installs are keyed by resolved revision. An unchanged revision
647
+ * returns early: nothing is fetched or published. After a real change the new
648
+ * <rev> directory is published, the previous <rev> directory is kept, and older
649
+ * ones are pruned, unless another bundle or lock entry still references them.
650
+ * Those directories are a re-fetchable cache (local and writable installs never
651
+ * take this path), so none of this prompts.
619
652
  */
620
- async function updateManagedInstall(managed, force, stashDir, allowDangerousEnvKeys) {
653
+ async function updateManagedInstall(managed, force, stashDir) {
654
+ const unchanged = await unchangedManagedItem(managed, force);
655
+ if (unchanged)
656
+ return { item: unchanged };
621
657
  const { synced, staged } = await stageManagedUpdate(managed, force);
622
658
  try {
623
- await auditStagedUpdate(staged.auditRoot, managed.ref, allowDangerousEnvKeys);
624
659
  const installedEntry = {
625
660
  id: managed.installId,
626
661
  // Preserve the original source classification. syncFromRef() re-derives the
@@ -655,14 +690,8 @@ async function updateManagedInstall(managed, force, stashDir, allowDangerousEnvK
655
690
  installedAt: synced.syncedAt,
656
691
  });
657
692
  });
658
- if (movedRoot) {
659
- if (referencesRoot(managed.localRoot, loadConfig(), readLockfile())) {
660
- warn(`[akm bundle update] kept previous install directory at ${managed.localRoot} because another configured or locked bundle still references it.`);
661
- }
662
- else {
663
- cleanupDirectoryBestEffort(managed.localRoot, "update");
664
- }
665
- }
693
+ if (movedRoot)
694
+ pruneOlderRevisions(managed.localRoot, synced.cacheDir);
666
695
  const versionChanged = (managed.resolvedVersion ?? "") !== (synced.resolvedVersion ?? "");
667
696
  const revisionChanged = (managed.resolvedRevision ?? "") !== (synced.resolvedRevision ?? "");
668
697
  return {
@@ -689,6 +718,31 @@ async function updateManagedInstall(managed, force, stashDir, allowDangerousEnvK
689
718
  staged.cleanup();
690
719
  }
691
720
  }
721
+ /**
722
+ * After a real revision change, keep the previous `<rev>` directory (a process
723
+ * may still be running from it) and remove every older sibling in the install's
724
+ * directory, unless another configured or locked bundle still uses it.
725
+ */
726
+ function pruneOlderRevisions(previousRoot, currentCacheDir) {
727
+ const previousDir = installRevisionDir(getRegistryCacheDir(), previousRoot);
728
+ const installDir = path.dirname(path.resolve(currentCacheDir));
729
+ if (!previousDir || path.dirname(previousDir) !== installDir)
730
+ return;
731
+ const keep = new Set([previousDir, path.resolve(currentCacheDir)]);
732
+ const config = loadConfig();
733
+ const locks = readLockfile();
734
+ for (const entry of fs.readdirSync(installDir, { withFileTypes: true })) {
735
+ const dir = path.join(installDir, entry.name);
736
+ if (!entry.isDirectory() || keep.has(dir))
737
+ continue;
738
+ if (referencesRoot(dir, config, locks)) {
739
+ warn(`[akm bundle update] kept older install directory at ${dir} because another configured or locked bundle still references it.`);
740
+ }
741
+ else {
742
+ cleanupDirectoryBestEffort(dir, "update");
743
+ }
744
+ }
745
+ }
692
746
  function referencesRoot(target, config, locks) {
693
747
  const resolvedTarget = path.resolve(target);
694
748
  const locksById = new Map(locks.map((entry) => [entry.id, entry]));
@@ -716,15 +770,16 @@ export async function akmUpdate(input) {
716
770
  const target = input?.target?.trim();
717
771
  const all = input?.all === true;
718
772
  const force = input?.force === true;
719
- const allowDangerousEnvKeys = input?.allowDangerousEnvKeys === true;
720
773
  const config = loadConfig();
721
774
  const managedInstalls = listManagedInstalls(config);
722
775
  if (target && !all) {
723
776
  // Registry-managed install (lock-backed) — re-download from its locator.
724
777
  const managed = resolveManagedTarget(config, target);
725
778
  if (managed) {
726
- const updated = await updateManagedInstall(managed, force, stashDir, allowDangerousEnvKeys);
727
- return buildUpdateResponse(stashDir, target, all, [updated.item], { index: updated.index });
779
+ const updated = await updateManagedInstall(managed, force, stashDir);
780
+ return buildUpdateResponse(stashDir, target, all, [updated.item], {
781
+ index: updated.index ?? readCurrentIndexSummary(),
782
+ });
728
783
  }
729
784
  // Plain git / website source (bundles without a lock) — provider re-sync.
730
785
  const stashes = getSources(config);
@@ -752,7 +807,7 @@ export async function akmUpdate(input) {
752
807
  return false;
753
808
  });
754
809
  if (gitMatch)
755
- return updateGitSource(config, stashDir, target, all, gitMatch, allowDangerousEnvKeys);
810
+ return updateGitSource(config, stashDir, target, all, gitMatch);
756
811
  const websiteMatch = stashes.find((s) => {
757
812
  if (s.type !== "website")
758
813
  return false;
@@ -780,8 +835,10 @@ export async function akmUpdate(input) {
780
835
  return false;
781
836
  });
782
837
  if (npmMatch) {
783
- const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(config, npmMatch), force, stashDir, allowDangerousEnvKeys);
784
- return buildUpdateResponse(stashDir, target, all, [updated.item], { index: updated.index });
838
+ const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(config, npmMatch), force, stashDir);
839
+ return buildUpdateResponse(stashDir, target, all, [updated.item], {
840
+ index: updated.index ?? readCurrentIndexSummary(),
841
+ });
785
842
  }
786
843
  const filesystemMatch = stashes.find((source) => {
787
844
  if (source.type !== "filesystem")
@@ -803,9 +860,9 @@ export async function akmUpdate(input) {
803
860
  let latestIndex;
804
861
  for (const managed of selected) {
805
862
  try {
806
- const updated = await updateManagedInstall(managed, force, stashDir, allowDangerousEnvKeys);
863
+ const updated = await updateManagedInstall(managed, force, stashDir);
807
864
  processed.push(updated.item);
808
- latestIndex = updated.index;
865
+ latestIndex = updated.index ?? latestIndex;
809
866
  }
810
867
  catch (error) {
811
868
  if (!all)
@@ -828,14 +885,14 @@ export async function akmUpdate(input) {
828
885
  const id = plain.name ?? plain.path ?? plain.url ?? "";
829
886
  try {
830
887
  if (plain.type === "git") {
831
- const updated = await syncGitPlainSource(config, plain, stashDir, allowDangerousEnvKeys);
888
+ const updated = await syncGitPlainSource(config, plain, stashDir);
832
889
  plainSynced.push(updated.item);
833
890
  latestIndex = updated.index;
834
891
  }
835
892
  else if (plain.type === "npm") {
836
- const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(config, plain), force, stashDir, allowDangerousEnvKeys);
893
+ const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(config, plain), force, stashDir);
837
894
  processed.push(updated.item);
838
- latestIndex = updated.index;
895
+ latestIndex = updated.index ?? latestIndex;
839
896
  }
840
897
  else if (plain.type === "website") {
841
898
  const updated = await syncWebsitePlainSource(plain, stashDir);
@@ -896,14 +953,13 @@ function sourceKindForInstall(source) {
896
953
  }
897
954
  function updateFailureOutcome(id, kind, error) {
898
955
  const code = error instanceof AkmError ? error.code : undefined;
899
- const status = code === "DANGEROUS_ENV_KEY" || code === "DANGEROUS_ENV_AUDIT_FAILED" ? "blocked" : "failed";
900
956
  const message = error instanceof Error ? error.message : String(error);
901
957
  return {
902
958
  id,
903
959
  kind,
904
- status,
960
+ status: "failed",
905
961
  ...(code ? { code } : {}),
906
- reason: `${status}: ${message}`,
962
+ reason: `failed: ${message}`,
907
963
  };
908
964
  }
909
965
  function selectManagedTargets(config, installs, target, all) {
@@ -244,7 +244,5 @@ async function defaultFindFilePath(ref, stashDir) {
244
244
  return resolveAssetPath(ref, {
245
245
  stashDir,
246
246
  mode: "index-first",
247
- directoryIndexNames: ["SKILL.md", "index.md", "README.md"],
248
- preserveDirectNameFallback: true,
249
247
  });
250
248
  }
@@ -12,7 +12,7 @@ import { isBundleSlug } from "../../core/asset/asset-ref.js";
12
12
  import { serializeFrontmatter } from "../../core/asset/asset-serialize.js";
13
13
  import { parseFrontmatter, replaceFrontmatterBlocks } from "../../core/asset/frontmatter.js";
14
14
  import { conceptIdFromTypeName, displayRef, parseQualifiedRefInput } from "../../core/asset/resolve-ref.js";
15
- import { isWithin } from "../../core/common.js";
15
+ import { isWithin, safeRealpath } from "../../core/common.js";
16
16
  import { loadConfig } from "../../core/config/config.js";
17
17
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
18
18
  import { defaultBundleForTarget } from "../../core/mutation-target.js";
@@ -23,6 +23,7 @@ import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
23
23
  import { resolveAdapterConceptOwner } from "../../indexer/lookup/adapter-concept-owner.js";
24
24
  import { findSourceForPath, resolveSourceEntries } from "../../indexer/search/search-source.js";
25
25
  import { buildFileContext } from "../../indexer/walk/file-context.js";
26
+ import { isFileOrFileSymlink } from "../../indexer/walk/walker.js";
26
27
  import { isRemoteOrigin, resolveSourcesForLocator, resolveSourcesForOrigin } from "../../registry/origin-resolve.js";
27
28
  import { syncFromRef } from "../../sources/providers/sync-from-ref.js";
28
29
  import { resolveAssetPath } from "../../sources/resolve.js";
@@ -161,18 +162,20 @@ export async function akmClone(options) {
161
162
  : writeTarget?.selector
162
163
  ? `in target "${writeTarget.selector}"`
163
164
  : "in working stash";
164
- // Guard against self-clone
165
+ // Guard against self-clone. Compare physical paths: sourcePath is the
166
+ // authored path, and a source that is a symlink to the destination is the
167
+ // same file (or skill) even though the authored paths differ.
165
168
  if (isSkill) {
166
- const sourceSkillDir = path.resolve(path.dirname(sourcePath));
167
- const destSkillDir = path.resolve(path.join(destRoot, typeDir, destName));
169
+ const sourceSkillDir = safeRealpath(path.dirname(sourcePath));
170
+ const destSkillDir = safeRealpath(path.join(destRoot, typeDir, destName));
168
171
  if (sourceSkillDir === destSkillDir) {
169
172
  throw new Error(`Source and destination are the same path. Use --name to provide a new name for the clone.`);
170
173
  }
171
174
  }
172
175
  else {
173
- const resolvedSource = path.resolve(sourcePath);
176
+ const resolvedSource = safeRealpath(sourcePath);
174
177
  const effectiveDestName = !path.extname(destFileName) && sourceExt ? destFileName + sourceExt : destFileName;
175
- const resolvedDest = path.resolve(path.join(destRoot, typeDir, effectiveDestName));
178
+ const resolvedDest = safeRealpath(path.join(destRoot, typeDir, effectiveDestName));
176
179
  if (resolvedSource === resolvedDest) {
177
180
  throw new Error(`Source and destination are the same path. Use --name to provide a new name for the clone.`);
178
181
  }
@@ -182,7 +185,9 @@ export async function akmClone(options) {
182
185
  let overwritten;
183
186
  let operationPaths;
184
187
  if (isSkill) {
185
- const sourceSkillDir = path.dirname(sourcePath);
188
+ // sourcePath is the authored path; a symlinked skill directory must be
189
+ // copied by content, never as the link.
190
+ const sourceSkillDir = fs.realpathSync(path.dirname(sourcePath));
186
191
  const destSkillDir = path.join(destRoot, typeDir, destName);
187
192
  assertNoDestinationSymlinkParent(destRoot, destSkillDir);
188
193
  const existing = lstatIfExists(destSkillDir);
@@ -195,7 +200,11 @@ export async function akmClone(options) {
195
200
  // final symlink is removed as an entry, never traversed.
196
201
  const stagingDir = `${destSkillDir}.tmp-${randomUUID()}`;
197
202
  try {
203
+ // Directory links are copied as links and never descended (as the walker
204
+ // does); each file link is then replaced by its content, so the stamp
205
+ // below never writes through a link.
198
206
  fs.cpSync(sourceSkillDir, stagingDir, { recursive: true, errorOnExist: true, force: false });
207
+ materializeFileLinks(stagingDir);
199
208
  stampCloneOrigin(path.join(stagingDir, "SKILL.md"), originRef);
200
209
  if (overwritten)
201
210
  fs.rmSync(destSkillDir, { recursive: true, force: true });
@@ -348,6 +357,21 @@ function assertNoDestinationSymlinkParent(root, destination) {
348
357
  throw new UsageError(`Clone destination has a symbolic-link parent outside the selected target boundary: ${parentDir}.`, "PATH_ESCAPE_VIOLATION");
349
358
  }
350
359
  }
360
+ /** Replace each file symlink under `root` with a regular file holding its target's bytes. Directory links are left as links. */
361
+ function materializeFileLinks(root) {
362
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
363
+ const entryPath = path.join(root, entry.name);
364
+ if (entry.isDirectory()) {
365
+ materializeFileLinks(entryPath);
366
+ }
367
+ else if (entry.isSymbolicLink() && isFileOrFileSymlink(entryPath)) {
368
+ const content = fs.readFileSync(entryPath);
369
+ const { mode } = fs.statSync(entryPath);
370
+ fs.rmSync(entryPath);
371
+ fs.writeFileSync(entryPath, content, { mode });
372
+ }
373
+ }
374
+ }
351
375
  function listTreeFiles(root) {
352
376
  const files = [];
353
377
  for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
@@ -247,7 +247,7 @@ export async function akmTaskExplain(ref, options = {}) {
247
247
  }
248
248
  const sourcePath = owner.path;
249
249
  const yaml = fs.readFileSync(sourcePath, "utf8");
250
- const parsed = parseTaskSource({ yaml, filePath: sourcePath, workspaceRoot: bundle.source.path });
250
+ const parsed = parseTaskSource({ yaml, filePath: sourcePath });
251
251
  const inputContract = parsed.v4.inputs ?? {};
252
252
  // Code-review finding (explain.ts:299, B-N4): `akm task run`'s
253
253
  // load-task.ts copies this same materialize -> applyInputDefaults ->