akm-cli 0.8.3 → 0.8.5
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 +41 -0
- package/dist/commands/improve-cli.js +7 -0
- package/dist/commands/improve.js +34 -13
- package/dist/commands/tasks.js +32 -8
- package/dist/tasks/backends/cron.js +46 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,47 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
+
## [0.8.5] - 2026-06-09
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Consolidation starved merge recall; the memory pool grew unbounded.** Commit
|
|
12
|
+
`633ece41` made the `incrementalSince` narrowing unconditional, so every
|
|
13
|
+
consolidation run only judged memories changed since the last run plus their
|
|
14
|
+
immediate vector-neighbors. Stale-but-unmerged duplicate clusters were never
|
|
15
|
+
re-examined, so the eligible pool grew monotonically and never shrank, and
|
|
16
|
+
contradiction detection (which rides on the consolidation pass) went dark.
|
|
17
|
+
Consolidation only runs on the nightly default-profile pass (`quick`/`frequent`
|
|
18
|
+
disable it), so a full-pool sweep is correct and affordable; the override is
|
|
19
|
+
removed. `lastConsolidateTs` still gates whether the pass runs.
|
|
20
|
+
|
|
21
|
+
## [0.8.4] - 2026-06-08
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **`akm tasks sync` ignored schedule changes.** Sync classified any task already
|
|
26
|
+
present in the OS scheduler as "unchanged" without comparing its installed
|
|
27
|
+
entry, so editing a task's `schedule:` in the `.yml` never reached the crontab —
|
|
28
|
+
the only way to apply a new schedule was to `remove` and re-`add` the task. The
|
|
29
|
+
same gap affected `tasks enable`/`disable`, which merely toggled the existing
|
|
30
|
+
cron line's comment and so re-enabled a stale schedule. Sync now compares the
|
|
31
|
+
backend's installed signature against the signature the current definition would
|
|
32
|
+
produce and reinstalls on drift (reported in a new `updated[]` field);
|
|
33
|
+
`enable`/`disable` reinstall from the current `.yml` instead of toggling in
|
|
34
|
+
place. Backends that can't cheaply read their installed form fall back to an
|
|
35
|
+
idempotent reinstall, so the fix is correct on launchd/schtasks too. The cron
|
|
36
|
+
backend gains `expectedSignature()` and a signature on each `list()` entry.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`akm improve --skip-if-locked`.** When another improve run already holds the
|
|
41
|
+
lock, the run logs and exits 0 with a no-op result (`skipped.reason:
|
|
42
|
+
"lock-held"`) instead of failing with the "already running" config error
|
|
43
|
+
(exit 78). Intended for high-frequency scheduled runs (e.g. an every-30-min
|
|
44
|
+
`quick` pass) that would otherwise pile up exit-78 failures whenever a longer
|
|
45
|
+
run overlaps them. Default off — the hard error is preserved for interactive
|
|
46
|
+
use. The result is still recorded so the skip is auditable.
|
|
47
|
+
|
|
7
48
|
## [0.8.3] - 2026-06-08
|
|
8
49
|
|
|
9
50
|
### Fixed
|
|
@@ -54,6 +54,11 @@ export const improveCommand = defineCommand({
|
|
|
54
54
|
description: "Emit the full JSON result on stdout (legacy behaviour). (0.8.0+: full result is recorded in the improve_runs table of state.db and stdout is empty; use this flag for the prior behaviour, e.g. `akm improve --json-to-stdout | jq`.)",
|
|
55
55
|
default: false,
|
|
56
56
|
},
|
|
57
|
+
"skip-if-locked": {
|
|
58
|
+
type: "boolean",
|
|
59
|
+
description: "If another improve run already holds the lock, skip gracefully (exit 0) instead of failing with 'already running' (exit 78). Use for high-frequency scheduled runs so they don't pile up failures while a longer run is in progress.",
|
|
60
|
+
default: false,
|
|
61
|
+
},
|
|
57
62
|
profile: {
|
|
58
63
|
type: "string",
|
|
59
64
|
description: "Named improve profile from profiles.improve or built-in profiles (default, quick, thorough, memory-focus, graph-refresh). Controls which sub-processes run and which asset types are processed.",
|
|
@@ -92,6 +97,7 @@ export const improveCommand = defineCommand({
|
|
|
92
97
|
const minRetrievalCountRaw = getHyphenatedArg(args, "min-retrieval-count");
|
|
93
98
|
const minRetrievalCount = parseNonNegativeIntFlag(minRetrievalCountRaw, "--min-retrieval-count");
|
|
94
99
|
const requireFeedbackSignal = getHyphenatedBoolean(args, "require-feedback-signal");
|
|
100
|
+
const skipIfLocked = getHyphenatedBoolean(args, "skip-if-locked");
|
|
95
101
|
const profileArg = getStringArg(args, "profile");
|
|
96
102
|
// Only set the keys the user actually passed (citty leaves the flag
|
|
97
103
|
// undefined unless `--sync`/`--no-sync` / `--push`/`--no-push` appears),
|
|
@@ -167,6 +173,7 @@ export const improveCommand = defineCommand({
|
|
|
167
173
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
168
174
|
...(minRetrievalCount !== undefined ? { minRetrievalCount } : {}),
|
|
169
175
|
...(requireFeedbackSignal ? { requireFeedbackSignal } : {}),
|
|
176
|
+
...(skipIfLocked ? { skipIfLocked } : {}),
|
|
170
177
|
...(profileArg !== undefined ? { profile: profileArg } : {}),
|
|
171
178
|
...(Object.keys(syncOverride).length > 0 ? { sync: syncOverride } : {}),
|
|
172
179
|
consolidateOptions: {
|
package/dist/commands/improve.js
CHANGED
|
@@ -435,7 +435,7 @@ export async function akmImprove(options = {}) {
|
|
|
435
435
|
fs.mkdirSync(path.dirname(resolvedLockPath), { recursive: true });
|
|
436
436
|
const lockPayload = () => JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
437
437
|
if (tryAcquireLockSync(resolvedLockPath, lockPayload()))
|
|
438
|
-
return;
|
|
438
|
+
return "acquired";
|
|
439
439
|
// Lock file already exists — probe to determine whether it's still held
|
|
440
440
|
// or whether the prior run died without cleaning up.
|
|
441
441
|
const probe = probeLock(resolvedLockPath, { staleAfterMs: MAX_LOCK_AGE_MS });
|
|
@@ -470,9 +470,19 @@ export async function akmImprove(options = {}) {
|
|
|
470
470
|
}
|
|
471
471
|
releaseLock(resolvedLockPath);
|
|
472
472
|
if (tryAcquireLockSync(resolvedLockPath, lockPayload()))
|
|
473
|
-
return;
|
|
473
|
+
return "acquired";
|
|
474
|
+
// Lost the race to another run that grabbed the freed stale lock.
|
|
475
|
+
if (options.skipIfLocked) {
|
|
476
|
+
warn("[improve] another run acquired the lock during stale recovery; skipping (--skip-if-locked)");
|
|
477
|
+
return "skipped";
|
|
478
|
+
}
|
|
474
479
|
throw new ConfigError(`akm improve is already running. Delete ${resolvedLockPath} to force.`, "INVALID_CONFIG_FILE");
|
|
475
480
|
}
|
|
481
|
+
// Lock is held by a live run within the staleness window.
|
|
482
|
+
if (options.skipIfLocked) {
|
|
483
|
+
warn(`[improve] another improve run holds the lock (PID ${lock?.pid}, started ${lock?.startedAt}); skipping (--skip-if-locked)`);
|
|
484
|
+
return "skipped";
|
|
485
|
+
}
|
|
476
486
|
throw new ConfigError(`akm improve is already running (PID ${lock?.pid}, started ${lock?.startedAt}). Delete ${resolvedLockPath} to force.`, "INVALID_CONFIG_FILE");
|
|
477
487
|
};
|
|
478
488
|
// Phase 4 lock-leak guard (§7 ordering hazard): hoisting `improve.lock` above
|
|
@@ -519,7 +529,21 @@ export async function akmImprove(options = {}) {
|
|
|
519
529
|
// The dry-run branch below produces plannedRefs/memorySummary WITHOUT the lock
|
|
520
530
|
// or triage (decision: dry-run never mutates the queue).
|
|
521
531
|
if (!options.dryRun) {
|
|
522
|
-
acquireLock()
|
|
532
|
+
if (acquireLock() === "skipped") {
|
|
533
|
+
// Another improve holds the lock and the caller asked to skip rather
|
|
534
|
+
// than fail. Return a clean no-op result (exit 0) before any index/DB
|
|
535
|
+
// work — never registered the exit listener, never set lockAcquired,
|
|
536
|
+
// so we release nothing belonging to the run that owns the lock.
|
|
537
|
+
return {
|
|
538
|
+
schemaVersion: 1,
|
|
539
|
+
ok: true,
|
|
540
|
+
scope,
|
|
541
|
+
dryRun: false,
|
|
542
|
+
skipped: { reason: "lock-held" },
|
|
543
|
+
memorySummary: { eligible: 0, derived: 0 },
|
|
544
|
+
plannedRefs: [],
|
|
545
|
+
};
|
|
546
|
+
}
|
|
523
547
|
lockAcquired = true;
|
|
524
548
|
// Backstop release on process.exit() (signal handler / budget watchdog),
|
|
525
549
|
// which skips the finally below. Removed in that finally on the normal path.
|
|
@@ -2138,16 +2162,13 @@ async function runImprovePostLoopStage(args) {
|
|
|
2138
2162
|
// Tie consolidate proposals back to this improve invocation so
|
|
2139
2163
|
// accept-rate-per-run aggregation works. Mirrors reflect/propose/extract.
|
|
2140
2164
|
sourceRun: `consolidate-${Date.now()}`,
|
|
2141
|
-
//
|
|
2142
|
-
//
|
|
2143
|
-
//
|
|
2144
|
-
//
|
|
2145
|
-
//
|
|
2146
|
-
//
|
|
2147
|
-
//
|
|
2148
|
-
// volumeTriggered=true on every run, permanently forcing full 12-chunk
|
|
2149
|
-
// scans (~264s) instead of the intended 1-2 chunk incremental path (~44s).
|
|
2150
|
-
incrementalSince: lastConsolidateTs,
|
|
2165
|
+
// Full-pool sweep: consolidation only runs on the nightly default-profile
|
|
2166
|
+
// pass (quick/frequent disable it), so a complete re-cluster is correct and
|
|
2167
|
+
// affordable here. Do NOT pass incrementalSince — the time-window narrowing
|
|
2168
|
+
// it triggers permanently excludes stale-but-unmerged duplicate clusters,
|
|
2169
|
+
// starving merge recall and letting the pool grow unbounded. (The narrowing
|
|
2170
|
+
// was a band-aid for an every-30-min consolidation cadence that the profile
|
|
2171
|
+
// split has since eliminated.) lastConsolidateTs still gates whether we run.
|
|
2151
2172
|
maxChunkSize: improveProfile?.processes?.consolidate?.maxChunkSize,
|
|
2152
2173
|
// Honor profile.autoAccept (already merged into options.autoAccept at the
|
|
2153
2174
|
// top of akmImprove). The CLI parser always supplies 90 when --auto-accept
|
package/dist/commands/tasks.js
CHANGED
|
@@ -218,7 +218,13 @@ export async function akmTasksSetEnabled(id, enabled) {
|
|
|
218
218
|
fs.writeFileSync(filePath, updated, "utf8");
|
|
219
219
|
const sched = selectBackend();
|
|
220
220
|
try {
|
|
221
|
-
|
|
221
|
+
// Reinstall from the (just-updated) definition rather than only toggling
|
|
222
|
+
// the comment. A plain toggle leaves a stale schedule in place if the
|
|
223
|
+
// .yml's `schedule:` changed while the task was disabled — re-enabling
|
|
224
|
+
// would silently keep the old cron line. install() renders the block with
|
|
225
|
+
// both the current schedule and the new enabled state, and is idempotent.
|
|
226
|
+
const task = parseTaskDocument({ yaml: updated, filePath, id: normalised });
|
|
227
|
+
await sched.install(task);
|
|
222
228
|
}
|
|
223
229
|
catch (err) {
|
|
224
230
|
// Roll the file back so the YAML source-of-truth and the OS
|
|
@@ -254,9 +260,12 @@ export async function akmTasksHistory(input) {
|
|
|
254
260
|
* Reconcile the on-disk task files with the OS scheduler.
|
|
255
261
|
* • install missing tasks (after validating them — invalid files are
|
|
256
262
|
* skipped with a per-task reason rather than aborting the whole sync)
|
|
263
|
+
* • reinstall tasks whose schedule or enabled state changed in the .yml
|
|
264
|
+
* (drift detected by comparing the backend's installed signature against
|
|
265
|
+
* the signature the current definition would produce)
|
|
257
266
|
* • remove orphan scheduler entries that no longer have a backing file
|
|
258
267
|
*/
|
|
259
|
-
export async function akmTasksSync() {
|
|
268
|
+
export async function akmTasksSync(deps = {}) {
|
|
260
269
|
const stashDir = resolveStashDir();
|
|
261
270
|
const typeRoot = path.join(stashDir, "tasks");
|
|
262
271
|
if (fs.existsSync(typeRoot))
|
|
@@ -267,10 +276,13 @@ export async function akmTasksSync() {
|
|
|
267
276
|
.filter((f) => f.endsWith(".yml"))
|
|
268
277
|
.map((f) => f.slice(0, -4))
|
|
269
278
|
: [];
|
|
270
|
-
const sched = selectBackend();
|
|
279
|
+
const sched = deps.backend ?? selectBackend();
|
|
271
280
|
const backend = backendNameForPlatform();
|
|
272
|
-
|
|
281
|
+
// Map id → installed signature so sync can detect schedule/enabled drift on
|
|
282
|
+
// tasks that already exist in the scheduler, not just presence/absence.
|
|
283
|
+
const present = new Map((await sched.list()).map((t) => [t.id, t.signature]));
|
|
273
284
|
const installed = [];
|
|
285
|
+
const updated = [];
|
|
274
286
|
const unchanged = [];
|
|
275
287
|
const skipped = [];
|
|
276
288
|
for (const id of fileIds) {
|
|
@@ -290,22 +302,34 @@ export async function akmTasksSync() {
|
|
|
290
302
|
skipped.push({ id, reason: err instanceof Error ? err.message : String(err) });
|
|
291
303
|
continue;
|
|
292
304
|
}
|
|
293
|
-
if (present.has(id)) {
|
|
305
|
+
if (!present.has(id)) {
|
|
306
|
+
await sched.install(task);
|
|
307
|
+
installed.push(id);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
// Already installed — reconcile against the current definition. Compare the
|
|
311
|
+
// installed signature to what this task would render to; reinstall on drift.
|
|
312
|
+
// When the backend can't produce a signature (no expectedSignature, or it
|
|
313
|
+
// didn't record one), reinstall unconditionally — install() is idempotent,
|
|
314
|
+
// so the cost is one crontab write and correctness is guaranteed.
|
|
315
|
+
const installedSig = present.get(id);
|
|
316
|
+
const expectedSig = sched.expectedSignature?.(task);
|
|
317
|
+
if (installedSig !== undefined && expectedSig !== undefined && installedSig === expectedSig) {
|
|
294
318
|
unchanged.push(id);
|
|
295
319
|
}
|
|
296
320
|
else {
|
|
297
321
|
await sched.install(task);
|
|
298
|
-
|
|
322
|
+
updated.push(id);
|
|
299
323
|
}
|
|
300
324
|
}
|
|
301
325
|
const removed = [];
|
|
302
|
-
for (const installedId of present) {
|
|
326
|
+
for (const installedId of present.keys()) {
|
|
303
327
|
if (!fileIds.includes(installedId)) {
|
|
304
328
|
await sched.uninstall(installedId);
|
|
305
329
|
removed.push(installedId);
|
|
306
330
|
}
|
|
307
331
|
}
|
|
308
|
-
return { installed, removed, unchanged, skipped, backend: sched.name };
|
|
332
|
+
return { installed, updated, removed, unchanged, skipped, backend: sched.name };
|
|
309
333
|
}
|
|
310
334
|
export async function akmTasksDoctor() {
|
|
311
335
|
const warnings = [];
|
|
@@ -64,13 +64,11 @@ export function CRON_BACKEND(options = {}) {
|
|
|
64
64
|
},
|
|
65
65
|
list() {
|
|
66
66
|
const existing = readCrontab(exec);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
return ids.map((id) => ({ id }));
|
|
67
|
+
return listBlocks(existing).map(({ id, body }) => ({ id, signature: normalizeSignature(body) }));
|
|
68
|
+
},
|
|
69
|
+
expectedSignature(task) {
|
|
70
|
+
const cronLine = buildCronLine(task, akmArgv, logDir);
|
|
71
|
+
return normalizeSignature(cronBlockBody(cronLine, task.enabled));
|
|
74
72
|
},
|
|
75
73
|
};
|
|
76
74
|
}
|
|
@@ -82,9 +80,48 @@ export function buildCronLine(task, akmArgv, logDir) {
|
|
|
82
80
|
const cmd = [...akmArgv, "tasks", "run", task.id].map((part) => quoteForCron(part)).join(" ");
|
|
83
81
|
return `${cronExpr} ${cmd} >> ${quoteForCron(logPath)} 2>&1`;
|
|
84
82
|
}
|
|
83
|
+
/** The crontab line as it appears inside a block — commented when disabled. */
|
|
84
|
+
export function cronBlockBody(cronLine, enabled) {
|
|
85
|
+
return enabled ? cronLine : `${DISABLED_PREFIX}${cronLine}`;
|
|
86
|
+
}
|
|
85
87
|
export function renderBlock(id, cronLine, enabled) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
return [BEGIN(id), cronBlockBody(cronLine, enabled), END(id)].join("\n");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse the akm-owned blocks out of a crontab, returning each task id with the
|
|
92
|
+
* raw body line(s) between its BEGIN/END markers. Used by `list()` to build a
|
|
93
|
+
* drift signature, and exported for tests.
|
|
94
|
+
*/
|
|
95
|
+
export function listBlocks(existing) {
|
|
96
|
+
const out = [];
|
|
97
|
+
const lines = existing.split(/\r?\n/);
|
|
98
|
+
let currentId = null;
|
|
99
|
+
let body = [];
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
const begin = line.match(BLOCK_RE);
|
|
102
|
+
if (begin) {
|
|
103
|
+
currentId = begin[1];
|
|
104
|
+
body = [];
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (currentId !== null && line === END(currentId)) {
|
|
108
|
+
out.push({ id: currentId, body: body.join("\n") });
|
|
109
|
+
currentId = null;
|
|
110
|
+
body = [];
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (currentId !== null)
|
|
114
|
+
body.push(line);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** Collapse incidental whitespace so signature comparison ignores it. */
|
|
119
|
+
function normalizeSignature(body) {
|
|
120
|
+
return body
|
|
121
|
+
.split(/\r?\n/)
|
|
122
|
+
.map((l) => l.trim())
|
|
123
|
+
.filter((l) => l.length > 0)
|
|
124
|
+
.join("\n");
|
|
88
125
|
}
|
|
89
126
|
export function upsertBlock(existing, id, block) {
|
|
90
127
|
const trimmed = existing.replace(/\s+$/g, "");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akm-cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "akm (Agent Knowledge Management) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
|
|
6
6
|
"keywords": [
|