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 +25 -0
- package/dist/execution/guarded-source.js +15 -1
- package/dist/tasks/scheduler-sync.js +88 -14
- package/package.json +1 -1
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.
|
|
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": [
|