akm-cli 0.9.17-alpha.2 → 0.9.17-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.17-alpha.3] - 2026-09-24
10
+
11
+ ### Fixed
12
+
13
+ - **Unscoped `akm task sync` no longer aborts the whole host on the first
14
+ bundle root that happens to contain any symlink.** `captureGuardedDirectoryManifest`
15
+ threw for every symbolic directory entry it listed, even one the scheduler
16
+ never reads (e.g. a third-party skill repo's `CLAUDE.md -> AGENTS.md`) —
17
+ `SchedulerSourceCollector` manifests every scanned bundle's root, so one
18
+ such bundle among many enabled ones failed sync entirely, dry-run included.
19
+ A symlink that stays inside its bundle root is now recorded in the guarded
20
+ directory manifest as its own `"symlink"` kind, identified without
21
+ following it (its `readlink` text plus its no-follow `lstat` identity), so
22
+ change detection still works; it is never read or descended into. A symlink
23
+ sitting exactly where a task or workflow source lives (a `.yml` under
24
+ `tasks/`, any `.yml` under an `akm-task` bundle, or a workflow-named file
25
+ under `workflows/`) is reported as its own per-source failure — "is a
26
+ symbolic source; guarded reads require a regular no-follow owner" — and its
27
+ ref is not scheduled, even when a real sibling file shares that ref, while
28
+ every other task and workflow still reconciles. A
29
+ symlink that resolves outside the bundle root, or one that is broken and
30
+ cannot be identified safely, is still refused, and a bundle root whose
31
+ `tasks` or `workflows` entry is itself a symlink still refuses loudly,
32
+ since that is a schedulable source location.
33
+
9
34
  ## [0.9.17-alpha.2] - 2026-09-24
10
35
 
11
36
  ### Fixed
@@ -179,6 +179,18 @@ export function captureGuardedDirectoryManifest(directoryPathInput, containmentR
179
179
  if (relative.startsWith("..") || path.isAbsolute(relative)) {
180
180
  throw new UsageError(`${entryPath} resolves outside the bundle root through a symbolic source.`, "PATH_ESCAPE_VIOLATION");
181
181
  }
182
+ // A symlink that stays inside the containment root is recorded as
183
+ // its own kind, identified without following it (readlink text
184
+ // plus its own no-follow lstat identity), so change detection
185
+ // still works. It is never a directory or file candidate to any
186
+ // manifest consumer.
187
+ return Object.freeze({
188
+ name: entry.name,
189
+ kind: "symlink",
190
+ physicalIdentity: physicalIdentity(entryPath, entryStat),
191
+ version: statVersion(entryStat),
192
+ target: fs.readlinkSync(entryPath),
193
+ });
182
194
  }
183
195
  throw new UsageError(`${entryPath} is a symbolic source with a physical source identity collision; guarded reads require one no-follow owner.`, "RESOURCE_ALREADY_EXISTS");
184
196
  }
@@ -323,8 +335,10 @@ export class GuardedExecutionSourceCollector {
323
335
  const candidate = path.join(directory, entry.name);
324
336
  if (entry.kind === "directory")
325
337
  visit(candidate);
326
- else
338
+ else if (entry.kind === "file")
327
339
  files.push(candidate);
340
+ // A "symlink" entry is recorded for change detection but is never
341
+ // read, descended into, or made a candidate source.
328
342
  }
329
343
  };
330
344
  visit(path.resolve(directoryPath));
@@ -299,6 +299,15 @@ async function compileDesiredSourceSet(input, collector) {
299
299
  async function compileTaskSources(input, collector, out, failures) {
300
300
  if (input.adapterId !== "akm" && input.adapterId !== "akm-task")
301
301
  return;
302
+ for (const symlink of collector.symlinkSources()) {
303
+ if (!isAuthoredTaskRelativePath(input.adapterId, symlink.relativePath))
304
+ continue;
305
+ const qualifiedRef = makeBundleRef(input.bundleName, symlink.relativePath.slice(0, -4));
306
+ if (input.enabledActivations && !input.enabledActivations.has(schedulerActivationKey("task", qualifiedRef))) {
307
+ continue;
308
+ }
309
+ failures.push(taskFailure(symlink.sourcePath, qualifiedRef, symbolicSourceError(symlink.sourcePath)));
310
+ }
302
311
  const physicalOwners = new Map();
303
312
  for (const guarded of collector.authoredTaskSources(input.adapterId)) {
304
313
  const sourcePath = guarded.sourcePath;
@@ -531,6 +540,26 @@ function enumerateWorkflowLookups(input, collector, failures) {
531
540
  owners.push(guarded);
532
541
  lookups.set(canonicalName, owners);
533
542
  }
543
+ for (const symlink of collector.symlinkSources()) {
544
+ if (!isAuthoredWorkflowRelativePath(input.adapterId, symlink.relativePath))
545
+ continue;
546
+ if (path.basename(symlink.sourcePath).toLowerCase() === "readme.md")
547
+ continue;
548
+ const authoredName = workflowNameForSourcePath(input.sourceRoot, input.adapterId, symlink.sourcePath);
549
+ if (authoredName === undefined)
550
+ continue;
551
+ const canonicalName = canonicalizeWorkflowName(authoredName);
552
+ // A real sibling sharing this name must not compile either: runtime
553
+ // resolution follows the symlink and may pick it over the file the
554
+ // binding was compiled from. The symbolic failure below is the ref's
555
+ // only report.
556
+ lookups.delete(canonicalName);
557
+ const failureRef = makeBundleRef(input.bundleName, input.adapterId === "akm" ? `workflows/${canonicalName}` : canonicalName);
558
+ if (input.enabledActivations && !input.enabledActivations.has(schedulerActivationKey("workflow", failureRef))) {
559
+ continue;
560
+ }
561
+ failures.push(workflowFailure(symlink.sourcePath, failureRef, symbolicSourceError(symlink.sourcePath)));
562
+ }
534
563
  return new Map([...lookups]
535
564
  .sort(([left], [right]) => compareCodePoints(left, right))
536
565
  .map(([name, sources]) => [name, Object.freeze(sources.sort(compareGuardedSources))]));
@@ -596,6 +625,14 @@ function assertUniqueInstalledIds(installed) {
596
625
  seen.add(binding.id);
597
626
  }
598
627
  }
628
+ /**
629
+ * The reason recorded for a symlinked task/workflow source: it stays a
630
+ * per-source failure (like the read boundary it replaces), never a silent
631
+ * drop and never a follow.
632
+ */
633
+ function symbolicSourceError(sourcePath) {
634
+ return new UsageError(`${sourcePath} is a symbolic source; guarded reads require a regular no-follow owner.`, "RESOURCE_ALREADY_EXISTS");
635
+ }
599
636
  function taskFailure(file, ref, cause) {
600
637
  const detail = taskSourceErrorDetail(cause);
601
638
  const reason = detail === errorMessage(cause) ? `${file}: ${detail}` : detail;
@@ -629,6 +666,29 @@ export function assertSchedulerSourceSnapshot(snapshot) {
629
666
  throw new UsageError(`Scheduler desired source read set changed after projection; refusing native mutation: ${errorMessage(cause)}`, "RESOURCE_ALREADY_EXISTS");
630
667
  }
631
668
  }
669
+ /**
670
+ * Shared with the symlink classification in {@link compileTaskSources}: a
671
+ * `.yml` under `tasks/` (or, for `akm-task`, anywhere) is a task candidate —
672
+ * one classifier for both a captured file and an uncaptured symlink entry.
673
+ */
674
+ function isAuthoredTaskRelativePath(adapterId, relativePath) {
675
+ if (!relativePath.endsWith(".yml"))
676
+ return false;
677
+ if (adapterId === "akm-task")
678
+ return true;
679
+ return path.posix.dirname(relativePath) === "tasks";
680
+ }
681
+ /**
682
+ * Shared with the symlink classification in {@link enumerateWorkflowLookups}:
683
+ * anything under `workflows/` (or, for `akm-workflow`, anywhere) is a
684
+ * workflow candidate — one classifier for both a captured file and an
685
+ * uncaptured symlink entry.
686
+ */
687
+ function isAuthoredWorkflowRelativePath(adapterId, relativePath) {
688
+ if (adapterId === "akm-workflow")
689
+ return true;
690
+ return relativePath.startsWith("workflows/");
691
+ }
632
692
  class SchedulerSourceCollector {
633
693
  #adapterId;
634
694
  #sourceRoot;
@@ -637,6 +697,14 @@ class SchedulerSourceCollector {
637
697
  this.#adapterId = input.adapterId;
638
698
  this.#sourceRoot = path.resolve(input.sourceRoot);
639
699
  const root = this.#collector.trackDirectory(this.#sourceRoot, this.#sourceRoot);
700
+ if (input.adapterId === "akm") {
701
+ for (const scheduledName of ["tasks", "workflows"]) {
702
+ const entry = root.entries.find((candidate) => candidate.name === scheduledName);
703
+ if (entry?.kind === "symlink") {
704
+ throw new UsageError(`${path.join(this.#sourceRoot, scheduledName)} is a symbolic source with a physical source identity collision; guarded reads require one no-follow owner.`, "RESOURCE_ALREADY_EXISTS");
705
+ }
706
+ }
707
+ }
640
708
  const rootDirectories = new Set(root.entries.filter((entry) => entry.kind === "directory").map((entry) => entry.name));
641
709
  const candidates = [];
642
710
  if (input.adapterId === "akm") {
@@ -669,27 +737,33 @@ class SchedulerSourceCollector {
669
737
  authoredTaskSources(adapterId) {
670
738
  return this.#collector
671
739
  .snapshot()
672
- .sources.filter((file) => {
673
- if (!file.authored || !file.relativePath.endsWith(".yml"))
674
- return false;
675
- if (adapterId === "akm-task")
676
- return true;
677
- return path.posix.dirname(file.relativePath) === "tasks";
678
- })
740
+ .sources.filter((file) => file.authored && isAuthoredTaskRelativePath(adapterId, file.relativePath))
679
741
  .sort(compareGuardedSources);
680
742
  }
681
743
  authoredWorkflowSources(adapterId) {
682
744
  return this.#collector
683
745
  .snapshot()
684
- .sources.filter((file) => {
685
- if (!file.authored)
686
- return false;
687
- if (adapterId === "akm-workflow")
688
- return true;
689
- return file.relativePath.startsWith("workflows/");
690
- })
746
+ .sources.filter((file) => file.authored && isAuthoredWorkflowRelativePath(adapterId, file.relativePath))
691
747
  .sort(compareGuardedSources);
692
748
  }
749
+ /**
750
+ * Every `kind: "symlink"` entry across the directory manifests captured so
751
+ * far (never read, never followed — see `captureGuardedDirectoryManifest`),
752
+ * with its path relative to the source root so callers can classify it
753
+ * with the same predicates as a real, captured file.
754
+ */
755
+ symlinkSources() {
756
+ const entries = [];
757
+ for (const manifest of this.#collector.snapshot().directoryManifests) {
758
+ for (const entry of manifest.entries) {
759
+ if (entry.kind !== "symlink")
760
+ continue;
761
+ const sourcePath = path.join(manifest.directoryPath, entry.name);
762
+ entries.push({ sourcePath, relativePath: toPosix(path.relative(this.#sourceRoot, sourcePath)) });
763
+ }
764
+ }
765
+ return entries;
766
+ }
693
767
  readBytes(file, containmentRoot) {
694
768
  this.#trackAncestors(file, containmentRoot);
695
769
  return this.#collector.readBytes(file, containmentRoot);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.17-alpha.2",
3
+ "version": "0.9.17-alpha.3",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [