akm-cli 0.9.3 → 0.9.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +233 -1
  2. package/README.md +1 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/cli.js +5 -5
  8. package/dist/commands/health/improve-metrics.js +17 -0
  9. package/dist/commands/health/windows.js +2 -2
  10. package/dist/commands/health.js +2 -2
  11. package/dist/commands/improve/anti-collapse.js +4 -91
  12. package/dist/commands/improve/preparation.js +8 -1
  13. package/dist/commands/lint/index.js +3 -7
  14. package/dist/commands/proposal/validators/proposal-validators.js +12 -0
  15. package/dist/commands/read/search.js +14 -24
  16. package/dist/commands/tasks/tasks-cli.js +81 -3
  17. package/dist/commands/tasks/tasks.js +117 -2
  18. package/dist/core/adapter/adapters/akm-adapter.js +23 -14
  19. package/dist/core/adapter/adapters/akm-lint.js +3 -2
  20. package/dist/core/adapter/adapters/akm-task-adapter.js +9 -6
  21. package/dist/core/adapter/adapters/dotenv-adapter.js +13 -11
  22. package/dist/core/adapter/recognize-match.js +1 -20
  23. package/dist/core/asset/asset-placement.js +21 -2
  24. package/dist/core/common.js +21 -1
  25. package/dist/core/config/config-version-shim.js +101 -0
  26. package/dist/core/config/config.js +6 -6
  27. package/dist/core/improve-result.js +35 -14
  28. package/dist/execution/guarded-source.js +0 -10
  29. package/dist/indexer/lookup/adapter-concept-owner.js +6 -89
  30. package/dist/indexer/passes/metadata.js +12 -4
  31. package/dist/indexer/scan/doc-to-entry.js +2 -0
  32. package/dist/indexer/search/db-search.js +6 -0
  33. package/dist/indexer/search/search-fields.js +16 -1
  34. package/dist/indexer/walk/matchers.js +0 -22
  35. package/dist/output/shapes/helpers.js +19 -1
  36. package/dist/output/shapes/passthrough.js +18 -5
  37. package/dist/output/text/command-format.js +4 -0
  38. package/dist/registry/pinned-request-helper.js +2 -2
  39. package/dist/registry/pinned-transport.js +6 -6
  40. package/dist/scripts/akm-migrate-node.js +12678 -12601
  41. package/dist/scripts/akm-migrate.js +12678 -12601
  42. package/dist/storage/repositories/proposals-repository.js +65 -7
  43. package/dist/storage/repositories/task-history-repository.js +22 -10
  44. package/dist/tasks/run/task-history.js +23 -3
  45. package/dist/tasks/scheduler-binding.js +15 -5
  46. package/dist/tasks/scheduler-sync-preview.js +45 -0
  47. package/dist/tasks/scheduler-sync.js +77 -41
  48. package/dist/tasks/source/bounded-document.js +1 -1
  49. package/dist/tasks/source/parse-task-source.js +77 -11
  50. package/dist/tasks/source/task-source-v3-frozen.js +428 -0
  51. package/dist/tasks/source/task-to-v3.js +512 -0
  52. package/dist/tasks/source/task-to-v4.js +457 -0
  53. package/docs/reference/cli.md +33 -6
  54. package/docs/reference/configuration.md +27 -6
  55. package/docs/reference/tasks.md +10 -0
  56. package/package.json +4 -4
@@ -0,0 +1,457 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Pure, byte-producing task-v3 to task-source-v4 migration planner (spec
6
+ * docs/plans/specs/p2b-input-bindings.md §1.3, §1.7 C-N1, §5). Mirrors
7
+ * `task-to-v3.ts`'s fail-closed ladder exactly: the INPUT side is read as a
8
+ * raw record by a vendored bounded-YAML reader (never the typed
9
+ * `parseTaskV3Yaml`, which would normalize away exactly the value bytes this
10
+ * migrator must preserve — a duration string like "5m", or a bare numeric
11
+ * `timeout`, would be converted to milliseconds by the real parser). The
12
+ * OUTPUT side is validated through the REAL `parseTaskSourceV4` before a
13
+ * "changed" outcome is ever handed back (C-N1, B-71).
14
+ *
15
+ * `inputs:` is never invented — the migrator translates structure, not
16
+ * intent (spec §5.3).
17
+ */
18
+ import crypto from "node:crypto";
19
+ import path from "node:path";
20
+ import { LineCounter, parseDocument, stringify as stringifyYaml } from "yaml";
21
+ import { assertBoundedTaskYamlDocument, TASK_V3_MAX_SOURCE_BYTES } from "./bounded-document.js";
22
+ import { classifyTaskV3Uses } from "./task-source-v3-frozen.js";
23
+ import { parseTaskSourceV4 } from "./task-source-v4.js";
24
+ /** The closed v3 top-level key set (`src/tasks/source-v3.ts`'s own, vendored — not exported there). */
25
+ const V3_TOP_LEVEL_KEYS = new Set([
26
+ "version",
27
+ "name",
28
+ "uses",
29
+ "run",
30
+ "with",
31
+ "env",
32
+ "shell",
33
+ "working-directory",
34
+ "akm",
35
+ "on",
36
+ ]);
37
+ /** The closed v3 `akm.*` key set, vendored from `src/tasks/source-v3.ts`. */
38
+ const V3_AKM_KEYS = new Set([
39
+ "schedule",
40
+ "enabled",
41
+ "description",
42
+ "when_to_use",
43
+ "tags",
44
+ "agent",
45
+ "engine",
46
+ "model",
47
+ "inference",
48
+ "outputSchema",
49
+ "tools",
50
+ "timeout",
51
+ "redact",
52
+ "maxSteps",
53
+ "maxRetries",
54
+ ]);
55
+ /** The closed v3 `on.*` key set, vendored from `src/tasks/source-v3.ts`. */
56
+ const V3_ON_KEYS = new Set(["schedule", "workflow_dispatch"]);
57
+ /** `akm.*` keys hoisted verbatim to the identical top-level v4 key (schedule/enabled handled separately). */
58
+ const AKM_HOIST_KEYS = [
59
+ "description",
60
+ "when_to_use",
61
+ "tags",
62
+ "agent",
63
+ "engine",
64
+ "model",
65
+ "inference",
66
+ "tools",
67
+ "timeout",
68
+ "redact",
69
+ "maxSteps",
70
+ "maxRetries",
71
+ ];
72
+ function hash(bytes) {
73
+ return crypto.createHash("sha256").update(bytes).digest("hex");
74
+ }
75
+ function causeMessage(cause) {
76
+ return cause instanceof Error ? cause.message : String(cause);
77
+ }
78
+ function base(input) {
79
+ return {
80
+ filePath: input.filePath,
81
+ before: Buffer.from(input.bytes),
82
+ beforeHash: hash(input.bytes),
83
+ mode: input.mode,
84
+ writable: input.writable,
85
+ ...(input.onDiskWritable !== undefined ? { onDiskWritable: input.onDiskWritable } : {}),
86
+ ...(input.containmentRoot ? { containmentRoot: input.containmentRoot } : {}),
87
+ };
88
+ }
89
+ function blocked(input, reason, detail) {
90
+ return Object.freeze({ status: "blocked", ...base(input), reason, ...(detail ? { detail } : {}) });
91
+ }
92
+ function plainRecord(value, label) {
93
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
94
+ throw new Error(`${label} must be a mapping`);
95
+ }
96
+ const prototype = Object.getPrototypeOf(value);
97
+ if (prototype !== Object.prototype && prototype !== null) {
98
+ throw new Error(`${label} must use a plain or null prototype`);
99
+ }
100
+ return value;
101
+ }
102
+ function exactString(value, label, nonempty = false) {
103
+ if (typeof value !== "string" || (nonempty && value.trim().length === 0)) {
104
+ throw new Error(`${label} must be ${nonempty ? "a non-empty " : "a "}string`);
105
+ }
106
+ return value;
107
+ }
108
+ /**
109
+ * Vendored raw-record reader (mirrors `task-to-v3.ts`'s `parseLegacyTaskYaml`
110
+ * exactly). Reading the RAW decoded record — rather than the typed
111
+ * `parseTaskV3Yaml` — keeps every field's original value bytes (a duration
112
+ * string, a bare millisecond integer, an env value's exact type) intact for
113
+ * verbatim re-emission; a typed v3 parse would normalize several of these
114
+ * away (C-N1).
115
+ */
116
+ function parseV3RawYaml(input) {
117
+ if (input.bytes.byteLength > TASK_V3_MAX_SOURCE_BYTES) {
118
+ throw new Error(`task YAML exceeds the 1 MiB (${TASK_V3_MAX_SOURCE_BYTES}-byte) source resource limit`);
119
+ }
120
+ let source;
121
+ try {
122
+ source = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(input.bytes);
123
+ }
124
+ catch {
125
+ throw new Error("task YAML contains invalid UTF-8 bytes");
126
+ }
127
+ const lineCounter = new LineCounter();
128
+ let document;
129
+ try {
130
+ document = parseDocument(source, { lineCounter, uniqueKeys: true });
131
+ }
132
+ catch (cause) {
133
+ throw new Error(`invalid YAML: ${causeMessage(cause)}`);
134
+ }
135
+ const [parseError] = document.errors;
136
+ if (parseError)
137
+ throw new Error(`invalid YAML: ${parseError.message.split("\n")[0]}`);
138
+ const [parseWarning] = document.warnings;
139
+ if (parseWarning)
140
+ throw new Error(`unsupported YAML construct: ${parseWarning.message}`);
141
+ assertBoundedTaskYamlDocument(document, {
142
+ filePath: input.filePath,
143
+ sourceLabel: "task v3 migration source",
144
+ lineCounter,
145
+ });
146
+ return { data: plainRecord(document.toJS({ maxAliasCount: 0 }), "task YAML"), source };
147
+ }
148
+ /** Convert one already-validated v3 raw record to final task source v4 bytes. */
149
+ function planV3DataToV4(input, data) {
150
+ const unknownTop = Object.keys(data).filter((key) => !V3_TOP_LEVEL_KEYS.has(key));
151
+ if (unknownTop.length > 0) {
152
+ return blocked(input, "invalid-v3-task", `unknown v3 field(s): ${unknownTop.join(", ")}`);
153
+ }
154
+ const hasUses = Object.hasOwn(data, "uses");
155
+ const hasRun = Object.hasOwn(data, "run");
156
+ if (hasUses === hasRun) {
157
+ return blocked(input, "invalid-v3-task", "requires exactly one executable selector: uses or run");
158
+ }
159
+ let akm;
160
+ if (Object.hasOwn(data, "akm")) {
161
+ try {
162
+ akm = plainRecord(data.akm, "akm");
163
+ }
164
+ catch (cause) {
165
+ return blocked(input, "invalid-v3-task", causeMessage(cause));
166
+ }
167
+ const unknownAkm = Object.keys(akm).filter((key) => !V3_AKM_KEYS.has(key));
168
+ if (unknownAkm.length > 0) {
169
+ return blocked(input, "unrecognized-akm-member", `akm has unknown field(s): ${unknownAkm.join(", ")}`);
170
+ }
171
+ if (Object.hasOwn(akm, "enabled") && typeof akm.enabled !== "boolean") {
172
+ return blocked(input, "invalid-v3-task", "akm.enabled must be a boolean");
173
+ }
174
+ if (Object.hasOwn(akm, "schedule") && typeof akm.schedule !== "string") {
175
+ return blocked(input, "invalid-v3-task", "akm.schedule must be a string");
176
+ }
177
+ }
178
+ const hasOn = Object.hasOwn(data, "on");
179
+ let onRecord;
180
+ if (hasOn) {
181
+ try {
182
+ onRecord = plainRecord(data.on, "on");
183
+ }
184
+ catch (cause) {
185
+ return blocked(input, "invalid-v3-task", causeMessage(cause));
186
+ }
187
+ // Mirrors the frozen v3 reader's own `parseOn` gates exactly
188
+ // (task-source-v3-frozen.ts:395-427): an empty `on: {}` declares no
189
+ // trigger at all, and `on.workflow_dispatch` accepts only null or an
190
+ // empty mapping (inputs are unsupported in v3). The frozen parser
191
+ // rejects both shapes outright, so this migrator must too — translating
192
+ // a document the v3 oracle would refuse to parse into runnable v4 bytes
193
+ // would launder invalid input into a valid, schedule-less task.
194
+ if (Object.keys(onRecord).length === 0) {
195
+ return blocked(input, "invalid-v3-task", "on must declare schedule and/or workflow_dispatch.");
196
+ }
197
+ const unknownOn = Object.keys(onRecord).filter((key) => !V3_ON_KEYS.has(key));
198
+ if (unknownOn.length > 0) {
199
+ return blocked(input, "invalid-v3-task", `on has unknown field(s): ${unknownOn.join(", ")}`);
200
+ }
201
+ if (Object.hasOwn(onRecord, "workflow_dispatch") && onRecord.workflow_dispatch !== null) {
202
+ let dispatchMapping;
203
+ try {
204
+ dispatchMapping = plainRecord(onRecord.workflow_dispatch, "on.workflow_dispatch");
205
+ }
206
+ catch (cause) {
207
+ return blocked(input, "invalid-v3-task", causeMessage(cause));
208
+ }
209
+ if (Object.keys(dispatchMapping).length > 0) {
210
+ return blocked(input, "invalid-v3-task", "on.workflow_dispatch must be null or an empty mapping; inputs are unsupported.");
211
+ }
212
+ }
213
+ }
214
+ const hasAkmSchedule = akm !== undefined && Object.hasOwn(akm, "schedule");
215
+ if (hasAkmSchedule && hasOn) {
216
+ return blocked(input, "ambiguous-scheduling-source", "declares both akm.schedule and on:; task v3 requires exactly one scheduling source and the migrator will not guess which one wins.");
217
+ }
218
+ if (!hasAkmSchedule && !hasOn) {
219
+ return blocked(input, "invalid-v3-task", "requires exactly one scheduling source: akm.schedule or on.");
220
+ }
221
+ const hasWith = Object.hasOwn(data, "with");
222
+ let usesTarget;
223
+ if (hasUses) {
224
+ let usesValue;
225
+ try {
226
+ usesValue = exactString(data.uses, "uses", true);
227
+ }
228
+ catch (cause) {
229
+ return blocked(input, "invalid-v3-task", causeMessage(cause));
230
+ }
231
+ try {
232
+ usesTarget = classifyTaskV3Uses(usesValue);
233
+ }
234
+ catch (cause) {
235
+ return blocked(input, "invalid-v3-task", causeMessage(cause));
236
+ }
237
+ if (usesTarget.kind === "github-action") {
238
+ return blocked(input, "github-action-target-removed", `"${usesValue}" is a github-action target; the github-action uses: variant was removed in task source v4. Use commands/, scripts/, workflows/, or akm/command instead.`);
239
+ }
240
+ if (hasWith && usesTarget.kind !== "builtin-command") {
241
+ return blocked(input, "with-on-non-command-target", `a with: block on "${usesValue}" (a non-akm/command target) has no task source v4 equivalent; task-call inputs are declared and bound separately.`);
242
+ }
243
+ }
244
+ else if (hasWith) {
245
+ return blocked(input, "invalid-v3-task", "with is legal only with uses");
246
+ }
247
+ if (!input.writable || input.onDiskWritable === false) {
248
+ return blocked(input, "read-only-source", !input.writable ? "the owning source is not writable" : "the source file or publication directory is read-only");
249
+ }
250
+ const enabledFalse = akm !== undefined && akm.enabled === false;
251
+ let scheduleField;
252
+ // Several independent translation facts can need reporting on the SAME
253
+ // file (a manual-only trigger AND a dropped output schema, say), so
254
+ // notices accumulate and are joined into the single `notice` string the
255
+ // outcome carries.
256
+ const notices = [];
257
+ if (hasAkmSchedule) {
258
+ const cron = akm.schedule;
259
+ scheduleField = enabledFalse ? [{ cron, enabled: false }] : cron;
260
+ }
261
+ else {
262
+ const rawSchedule = onRecord !== undefined && Object.hasOwn(onRecord, "schedule") ? onRecord.schedule : undefined;
263
+ if (rawSchedule !== undefined) {
264
+ if (!Array.isArray(rawSchedule) || rawSchedule.length === 0) {
265
+ return blocked(input, "invalid-v3-task", "on.schedule must be a non-empty list of {cron} records");
266
+ }
267
+ const crons = [];
268
+ for (const entry of rawSchedule) {
269
+ let record;
270
+ try {
271
+ record = plainRecord(entry, "on.schedule[]");
272
+ }
273
+ catch (cause) {
274
+ return blocked(input, "invalid-v3-task", causeMessage(cause));
275
+ }
276
+ const keys = Object.keys(record);
277
+ if (keys.length !== 1 || keys[0] !== "cron" || typeof record.cron !== "string" || record.cron.length === 0) {
278
+ return blocked(input, "invalid-v3-task", "each on.schedule entry must be exactly {cron: <non-empty string>}");
279
+ }
280
+ crons.push(record.cron);
281
+ }
282
+ scheduleField = crons.map((cron) => (enabledFalse ? { cron, enabled: false } : { cron }));
283
+ }
284
+ else if (enabledFalse) {
285
+ return blocked(input, "enabled-false-has-no-schedule-entry", "akm.enabled: false has no schedule entry to attach to (the only trigger is on.workflow_dispatch); task source v4 has no top-level enabled flag.");
286
+ }
287
+ else {
288
+ notices.push("schedule: is absent from the migrated document — the source's only trigger was on.workflow_dispatch (manual dispatch); task source v4 tasks are always runnable manually via `akm task run`, so no schedule: entry was emitted.");
289
+ }
290
+ }
291
+ const out = { version: 4 };
292
+ if (Object.hasOwn(data, "name"))
293
+ out.name = data.name;
294
+ if (hasUses)
295
+ out.uses = data.uses;
296
+ else
297
+ out.run = data.run;
298
+ if (Object.hasOwn(data, "shell"))
299
+ out.shell = data.shell;
300
+ if (hasWith)
301
+ out.with = data.with;
302
+ if (Object.hasOwn(data, "env"))
303
+ out.env = data.env;
304
+ if (Object.hasOwn(data, "working-directory"))
305
+ out["working-directory"] = data["working-directory"];
306
+ if (scheduleField !== undefined)
307
+ out.schedule = scheduleField;
308
+ if (akm) {
309
+ for (const key of AKM_HOIST_KEYS) {
310
+ if (Object.hasOwn(akm, key))
311
+ out[key] = akm[key];
312
+ }
313
+ // v3's `akm.outputSchema: null` means "no schema" (accepted verbatim by
314
+ // the frozen v3 reader, task-source-v3-frozen.ts:256-258); v4's
315
+ // `output:` has no null form (parseOutputSchema always requires a
316
+ // mapping). Omitting the key is the faithful v4 equivalent of an
317
+ // explicit v3 null — emitting `output: null` would fail the real
318
+ // parseTaskSourceV4 validation below and block the whole file.
319
+ if (Object.hasOwn(akm, "outputSchema") && akm.outputSchema !== null) {
320
+ // v4 accepts `output:` ONLY on a command target — `uses: commands/<ref>`
321
+ // or `uses: akm/command` (src/tasks/source/task-source-v4.ts's
322
+ // `targetConsumesOutputSchema`). v3 enforced no such rule: the frozen v3
323
+ // reader accepts `akm.outputSchema` on ANY target kind
324
+ // (task-source-v3-frozen.ts:256-263), and on `run:`/`uses: scripts/`/
325
+ // `uses: workflows/` it was equally inert there — nothing ever consumed
326
+ // it. Hoisting it unconditionally would therefore emit bytes the real
327
+ // parseTaskSourceV4 below rejects, blocking a valid, previously-runnable
328
+ // v3 file — and one blocked file aborts the whole plan
329
+ // (`applyTaskToV4MigrationPlan`, ./task-files-to-v4.ts). Dropping an
330
+ // already-inert field and SAYING SO is the faithful translation, and
331
+ // keeps spec row B-66 / §5.3's `changed` guarantee intact.
332
+ if (usesTarget !== undefined && (usesTarget.kind === "command" || usesTarget.kind === "builtin-command")) {
333
+ out.output = akm.outputSchema;
334
+ }
335
+ else {
336
+ const targetLabel = usesTarget === undefined ? "a run: target" : `the "${usesTarget.ref}" target`;
337
+ notices.push(`akm.outputSchema was dropped rather than hoisted to output: — task source v4 accepts output: only with a command target (uses: commands/<ref> or uses: akm/command), and ${targetLabel} never consumed the schema in v3 either, so nothing enforceable was lost.`);
338
+ }
339
+ }
340
+ }
341
+ const afterYaml = stringifyYaml(out);
342
+ const after = Buffer.from(afterYaml, "utf8");
343
+ try {
344
+ parseTaskSourceV4({
345
+ yaml: afterYaml,
346
+ filePath: input.filePath,
347
+ ...(input.containmentRoot ? { workspaceRoot: input.containmentRoot } : {}),
348
+ });
349
+ }
350
+ catch (cause) {
351
+ return blocked(input, "generated-v4-validation-failed", causeMessage(cause));
352
+ }
353
+ const notice = notices.join(" ");
354
+ return Object.freeze({
355
+ status: "changed",
356
+ ...base(input),
357
+ reason: "task-converted",
358
+ after,
359
+ afterHash: hash(after),
360
+ ...(notice ? { notice } : {}),
361
+ });
362
+ }
363
+ /** Plan exactly one source file without touching disk. */
364
+ export function planTaskToV4File(input) {
365
+ let data;
366
+ let source;
367
+ try {
368
+ ({ data, source } = parseV3RawYaml(input));
369
+ }
370
+ catch (cause) {
371
+ return blocked(input, "invalid-task-yaml", causeMessage(cause));
372
+ }
373
+ if (data.version === 4) {
374
+ try {
375
+ parseTaskSourceV4({
376
+ yaml: source,
377
+ filePath: input.filePath,
378
+ ...(input.containmentRoot ? { workspaceRoot: input.containmentRoot } : {}),
379
+ });
380
+ return Object.freeze({ status: "skipped", ...base(input), reason: "already-v4" });
381
+ }
382
+ catch (cause) {
383
+ return blocked(input, "invalid-v4-task", causeMessage(cause));
384
+ }
385
+ }
386
+ // Not this generation's document to validate — v2 grammar is entirely
387
+ // generation 1's domain (task-to-v3.ts). Reported skipped, not blocked
388
+ // (see TaskToV4Skipped's own header).
389
+ if (data.version === 2) {
390
+ return Object.freeze({
391
+ status: "skipped",
392
+ ...base(input),
393
+ reason: "pending-v2-to-v3-migration",
394
+ });
395
+ }
396
+ if (data.version !== 3) {
397
+ return blocked(input, "unsupported-task-version", `expected version 2, 3, or 4, got ${String(data.version)}`);
398
+ }
399
+ return planV3DataToV4(input, data);
400
+ }
401
+ function generationFor(files) {
402
+ const digest = crypto.createHash("sha256");
403
+ digest.update("akm-task-to-v4-plan-v1\0");
404
+ for (const file of files) {
405
+ digest.update(file.filePath);
406
+ digest.update("\0");
407
+ digest.update(file.status);
408
+ digest.update("\0");
409
+ digest.update(file.reason);
410
+ digest.update("\0");
411
+ digest.update(String(file.mode));
412
+ digest.update("\0");
413
+ digest.update(file.writable ? "writable" : "read-only");
414
+ digest.update("\0");
415
+ digest.update(file.onDiskWritable === false ? "disk-read-only" : "disk-writable-or-unspecified");
416
+ digest.update("\0");
417
+ if (file.containmentRoot)
418
+ digest.update(file.containmentRoot);
419
+ digest.update("\0");
420
+ digest.update(file.beforeHash);
421
+ digest.update("\0");
422
+ if (file.status === "changed")
423
+ digest.update(file.afterHash);
424
+ digest.update("\0");
425
+ if (file.detail)
426
+ digest.update(file.detail);
427
+ digest.update("\0");
428
+ if (file.status === "changed" && file.notice)
429
+ digest.update(file.notice);
430
+ digest.update("\0");
431
+ }
432
+ return digest.digest("hex");
433
+ }
434
+ /** Build/fingerprint a plan from already-derived immutable outcomes. */
435
+ export function taskToV4PlanFromOutcomes(outcomes) {
436
+ const files = [...outcomes].sort((left, right) => left.filePath < right.filePath ? -1 : left.filePath > right.filePath ? 1 : 0);
437
+ for (let index = 1; index < files.length; index += 1) {
438
+ const previous = files[index - 1];
439
+ const current = files[index];
440
+ if (previous && current && path.resolve(previous.filePath) === path.resolve(current.filePath)) {
441
+ throw new Error(`duplicate task migration file path: ${current.filePath}`);
442
+ }
443
+ }
444
+ return Object.freeze({ schemaVersion: 1, generation: generationFor(files), files: Object.freeze(files) });
445
+ }
446
+ /** Plan a complete, stable file set. Input order cannot change the result. */
447
+ export function planTaskToV4Migration(inputs) {
448
+ const sorted = [...inputs].sort((left, right) => left.filePath < right.filePath ? -1 : left.filePath > right.filePath ? 1 : 0);
449
+ let previous;
450
+ for (const current of sorted) {
451
+ if (previous && path.resolve(previous.filePath) === path.resolve(current.filePath)) {
452
+ throw new Error(`duplicate task migration file path: ${current.filePath}`);
453
+ }
454
+ previous = current;
455
+ }
456
+ return taskToV4PlanFromOutcomes(sorted.map(planTaskToV4File));
457
+ }
@@ -393,6 +393,11 @@ availability:
393
393
  - **`origin`** -- The source bundle (e.g. `npm:@scope/pkg`), present only for
394
394
  managed source assets; surfaced at `full` only
395
395
  - **`id`** -- Registry-level identifier (registry hits only)
396
+ - **`matchStage`** -- Which stage of the progressive AND->OR lexical search
397
+ ladder produced the hit: `exact` (strict AND), `prefix` (prefix AND), or
398
+ `relaxed` (OR/prefix-OR recovery). Omitted for hits with no FTS component
399
+ (e.g. a pure-semantic hybrid match) and for registry hits; surfaced at
400
+ `normal`, `full`, and `--shape agent`
396
401
 
397
402
  The default brief shape is intentionally small. The exact field set per
398
403
  detail level (and per `--shape`) is authoritative in
@@ -402,9 +407,9 @@ assembled into the shape registry by the `src/output/shapes.ts` barrel:
402
407
  | Level | Local bundle hits | Registry hits |
403
408
  | --- | --- | --- |
404
409
  | `brief` (default) | `type`, `name`, `ref`, `action`, `estimatedTokens` | `name`, `installRef`, `score` |
405
- | `normal` | `type`, `name`, `description`, `action`, `score`, `estimatedTokens`, optional `warnings`/`quality`/`keys` | `name`, `description`, `action`, `installRef`, `score`, optional `warnings` |
406
- | `full` | full hit object (includes `ref`, `origin`, `tags`, `whyMatched`, optional `warnings`, optional `quality`, timings, bundle metadata) | full hit object |
407
- | `--shape agent` | `name`, `ref`, `type`, `path`, `editable`, conditional `editHint`, `description`, `action`, `score`, optional `estimatedTokens`/`keys` | no local access fields |
410
+ | `normal` | `type`, `name`, `description`, `action`, `score`, `estimatedTokens`, optional `warnings`/`quality`/`keys`/`matchStage` | `name`, `description`, `action`, `installRef`, `score`, optional `warnings` |
411
+ | `full` | full hit object (includes `ref`, `origin`, `tags`, `whyMatched`, optional `warnings`, optional `quality`, optional `matchStage`, timings, bundle metadata) | full hit object |
412
+ | `--shape agent` | `name`, `ref`, `type`, `path`, `editable`, conditional `editHint`, `description`, `action`, `score`, optional `estimatedTokens`/`keys`/`matchStage` | no local access fields |
408
413
 
409
414
  `--shape summary` is **not valid on `search`** — see
410
415
  [`--shape summary`](#--shape-summary) above; it is a usage error (exit 2)
@@ -2418,9 +2423,10 @@ shell commands. It manages on-disk task definitions under
2418
2423
  (cron / launchd / schtasks). Task source v4 YAML (`version: 4`) is the only
2419
2424
  executable source contract this release accepts; `akm task add` writes v4 —
2420
2425
  see the canonical [Tasks reference](tasks.md). The
2421
- group is `add | run | explain | sync | doctor | history` — there is no `list`
2422
- or `remove`; use `akm search --type task` / `akm show tasks/<id>` to inspect,
2423
- and edit the file + `akm task sync` to change or remove a schedule.
2426
+ group is `add | run | explain | sync | doctor | history | prune` — there is
2427
+ no `list` or `remove`; use `akm search --type task` / `akm show tasks/<id>`
2428
+ to inspect, and edit the file + `akm task sync` to change or remove a
2429
+ schedule.
2424
2430
 
2425
2431
  ```sh
2426
2432
  akm search --type task # List tasks (cross-bundle)
@@ -2434,8 +2440,12 @@ akm task run <id> # Execute now (what the scheduler ca
2434
2440
  akm task explain <ref> # Read-only: declared inputs, target, schedule — spawns nothing
2435
2441
  akm task history [--id <id>] [--limit <n>] # Recent runs from state.db
2436
2442
  akm task sync # Reconcile on-disk YAML with scheduler
2443
+ akm task sync --dry-run # Preview the reconcile — zero scheduler writes
2437
2444
  akm task sync --rebind # Also capture the current installed runtime
2438
2445
  akm task doctor # Report scheduler backend + paths
2446
+ akm task prune # Preview orphaned scheduler entries — zero writes
2447
+ akm task prune --yes # Remove every currently-computed orphan
2448
+ akm task prune --id ghost,stale --yes # Remove only the named orphan ids
2439
2449
  ```
2440
2450
 
2441
2451
  `task add` also accepts `--disabled` (register but leave off in the OS
@@ -2461,6 +2471,23 @@ schedule-binding) and run `akm task sync`. To remove one, delete its file
2461
2471
  (`<bundle>/tasks/<id>.yml`) and run `akm task sync` — sync uninstalls the
2462
2472
  orphaned scheduler entry.
2463
2473
 
2474
+ `akm task sync --dry-run` prints the planned adds/updates/removes (removals
2475
+ carry their owning bundle) without touching the scheduler — zero writes.
2476
+ Exits non-zero when removals are pending, so it can gate a CI/health check
2477
+ on "sync would change something."
2478
+
2479
+ `akm task prune` reclaims installed scheduler entries that `sync` can never
2480
+ clean up on its own: entries whose own `--scheduler-context` descriptor no
2481
+ longer resolves to a live bundle (a corrupt/missing descriptor, or the
2482
+ bundle directory it pointed at is gone). It never touches an entry that
2483
+ still resolves to a live bundle — that's `sync`'s job. Like `sync
2484
+ --dry-run`, the default is a dry-run preview (zero scheduler writes) that
2485
+ exits non-zero when there are candidates to remove; `--yes` executes the
2486
+ printed plan, and `--id <id1,id2,...>` narrows a `--yes` run (or a preview)
2487
+ to specific binding ids — naming an id that isn't a current orphan
2488
+ candidate (not installed, or it still resolves to a live bundle) is
2489
+ refused with a usage error and removes nothing.
2490
+
2464
2491
  Scheduler activation captures the installed akm runtime. Ordinary `task sync`
2465
2492
  reconciles definitions, schedules, and enabled state while preserving that
2466
2493
  runtime binding. Use `task sync --rebind` only after intentionally moving or
@@ -7,12 +7,33 @@ directory. Project `.akm/config.json` files are not merged.
7
7
 
8
8
  ## Version 0.9
9
9
 
10
- A present configuration file must set `configVersion` to exactly `"0.9.0"`.
11
- Missing, older, newer, numeric, and malformed versions are rejected by ordinary
12
- commands without rewriting the file. Pre-0.9 config and database layouts are
13
- not runtime inputs and are not migrated by `akm upgrade`. Configure the current
14
- schema directly. The standalone migrator exists only for explicit task
15
- migration: task v2 to task v3, then task v3 to task source v4, in one pass.
10
+ A present configuration file must set `configVersion` to a version this
11
+ binary knows: the current `"0.9.0"`, or a known older version it can
12
+ auto-upgrade in memory (see "Version read shim" below). Missing, newer,
13
+ numeric, and any other unrecognized version are rejected by ordinary
14
+ commands without rewriting the file — an older binary never guesses at a
15
+ newer, unknown shape. Pre-0.9 config and database layouts are not runtime
16
+ inputs and are not migrated by `akm upgrade`. Configure the current schema
17
+ directly. The standalone migrator exists only for explicit task migration:
18
+ task v2 to task v3, then task v3 to task source v4, in one pass.
19
+
20
+ ### Version read shim
21
+
22
+ Like the task-source v2/v3 auto-shim (`akm migrate apply`'s in-memory
23
+ counterpart, documented under Migration below), a known older `configVersion`
24
+ is converted to the current shape in memory on load — with a one-line stderr
25
+ deprecation warning — rather than hard-failing every command. Nothing is
26
+ written back to disk by the shim itself; the very next config-mutating
27
+ command (`akm config set`, etc.) persists the upgrade for free, since every
28
+ config write already forces `configVersion` to the current value, which
29
+ silences the warning. A `configVersion` this binary does not recognize at
30
+ all — including anything newer than current — still fails closed with
31
+ `UNSUPPORTED_CONFIG_VERSION`.
32
+
33
+ As of this writing `"0.9.0"` is the only `configVersion` akm has ever
34
+ shipped, so there is no real older shape for the shim to convert yet; the
35
+ mechanism (`src/core/config/config-version-shim.ts`) is established ahead of
36
+ the first bump that will need it, per #863.
16
37
 
17
38
  ```jsonc
18
39
  {
@@ -389,6 +389,16 @@ for full before/after examples and recovery guidance.
389
389
  - Disable a binding by editing the source and syncing: set that schedule
390
390
  entry's own `enabled: false`.
391
391
  - Delete the `.yml` source and sync to remove its derived binding(s).
392
+ - `akm task sync --dry-run` previews the reconcile (adds/updates/removes,
393
+ removals annotated with their owning bundle) without writing to the
394
+ scheduler; exits non-zero when removals are pending.
395
+ - `akm task prune` removes installed scheduler entries `sync` cannot reach
396
+ because their own descriptor no longer resolves to a live bundle
397
+ (corrupt/missing `--scheduler-context`, or the owning bundle directory is
398
+ gone). It never touches an entry that still resolves to a live bundle.
399
+ Defaults to a dry-run preview (zero writes); `--yes` executes it; `--id
400
+ <id1,id2,...>` scopes to specific ids and refuses any id that isn't a
401
+ current orphan candidate.
392
402
  - Use `akm task sync --rebind` only when deliberately changing the captured
393
403
  AKM runtime, then verify with `akm task doctor`.
394
404
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
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": [
@@ -36,7 +36,7 @@
36
36
  "license": "MPL-2.0",
37
37
  "pinNotes": {
38
38
  "@opencode-ai/sdk@1.2.20": "Exact pin. The SDK surface we use (createOpencodeClient + session.create/prompt/delete in src/integrations/harnesses/opencode-sdk/sdk-runner.ts) is stable across 1.x, but the SDK has shipped 5+ minor versions of unrelated provider/registry churn. akm-cli is a global CLI install so the pin is isolated from user-project deps. Re-test sdk-runner before bumping. Note this package is an HTTP client only — it declares no dependencies and its own createOpencodeServer spawns `opencode serve` — so it does NOT make the opencode binary available; that install is separate and is what every SDK-path probe checks for.",
39
- "better-sqlite3@12.11.1": "Exact pin (#790). This is the SQLite driver akm loads on Node (src/storage/database.ts); Bun never touches it. The pin is about PREBUILT BINARIES, not API surface. better-sqlite3 ships one prebuild per Node ABI as a GitHub release asset and its install script is `prebuild-install || node-gyp rebuild` — so any (version, Node ABI) pair with no prebuild silently COMPILES FROM SOURCE against the headers of whatever Node is on the machine that day. The 11.x line predates Node 24 and declares no `engines` at all: its newest release (11.10.0, 2025-05-08) publishes ABI 108/115/127/131 (Node 18/20/22/23) and nothing for ABI 137 (Node 24). Node 24.19.0 then changed the public `node_object_wrap.h` so `node::ObjectWrap`'s ctor/dtor register and unregister an environment cleanup hook; a from-source 11.x build against those headers aborts at teardown in `Statement::~Statement()` with `RemoveEnvironmentCleanupHook ... Assertion (env) != nullptr`, intermittently, depending on GC timing. 12.11.1 publishes ABI 127/137/141/147 (Node 22/24/25/26) and declares `engines: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x`, so akm's supported Node 24 line installs a prebuilt binary and never compiles. Before bumping: confirm the target version publishes a prebuild for EVERY Node major in `engines` (probe https://github.com/WiseLibs/better-sqlite3/releases/download/vX.Y.Z/better-sqlite3-vX.Y.Z-node-vABI-linux-x64.tar.gz), not just that the version is newer. 13.x is the eventual destination — it moved to node-addon-api/N-API with prebuilds bundled in the npm tarball and no install script, which retires this failure mode entirely — but it is a fresh major rewrite of the binding, so it wants its own soak, not a patch release. The CI node-smoke job installs this exact string by reading it back out of this file (.github/workflows/ci.yml), so the two cannot drift.",
39
+ "better-sqlite3@12.11.1": "Exact pin (#790). This is the SQLite driver akm loads on Node (src/storage/database.ts); Bun never touches it. The pin is about PREBUILT BINARIES, not API surface. better-sqlite3 ships one prebuild per Node ABI as a GitHub release asset and its install script is `prebuild-install || node-gyp rebuild` — so any (version, Node ABI) pair with no prebuild silently COMPILES FROM SOURCE against the headers of whatever Node is on the machine that day. The 11.x line predates Node 24 and declares no `engines` at all: its newest release (11.10.0, 2025-05-08) publishes ABI 108/115/127/131 (Node 18/20/22/23) and nothing for ABI 137 (Node 24). Node 24.19.0 then changed the public `node_object_wrap.h` so `node::ObjectWrap`'s ctor/dtor register and unregister an environment cleanup hook; a from-source 11.x build against those headers aborts at teardown in `Statement::~Statement()` with `RemoveEnvironmentCleanupHook ... Assertion (env) != nullptr`, intermittently, depending on GC timing. 12.11.1 publishes ABI 127/137/141/147 (Node 22/24/25/26) and declares `engines: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x`, so every Node akm supports installs a prebuilt binary and never compiles. Before bumping: confirm the target version publishes a prebuild for EVERY Node major in `engines` (probe https://github.com/WiseLibs/better-sqlite3/releases/download/vX.Y.Z/better-sqlite3-vX.Y.Z-node-vABI-linux-x64.tar.gz), not just that the version is newer. 13.x is the eventual destination — it moved to node-addon-api/N-API with prebuilds bundled in the npm tarball and no install script, which retires this failure mode entirely — but it is a fresh major rewrite of the binding, so it wants its own soak, not a patch release. The CI node-smoke job installs this exact string by reading it back out of this file (.github/workflows/ci.yml), so the two cannot drift.",
40
40
  "@huggingface/transformers@4.2.0": "Exact semantic-search dependency pin. Re-run the real-model semantic gate before changing it."
41
41
  },
42
42
  "files": [
@@ -66,7 +66,7 @@
66
66
  "akm-migrate": "dist/akm-migrate"
67
67
  },
68
68
  "scripts": {
69
- "preinstall": "node -e \"var v=(process.versions.node||'0').split('.').map(function(n){return parseInt(n,10)||0});var ok=v[0]>=24;if(ok){process.exit(0)}console.error('\\n ERROR: the akm-cli npm package requires Node.js >= 24.\\n A working Bun >= 1.0 on PATH is optional and preferred for akm and akm-migrate.\\n Upgrade Node.js (https://nodejs.org), or install the runtime-free standalone binary:\\n curl -fsSL https://github.com/itlackey/akm/releases/latest/download/install.sh | bash\\n');process.exit(1)\"",
69
+ "preinstall": "node -e \"var v=(process.versions.node||'0').split('.').map(function(n){return parseInt(n,10)||0});var ok=v[0]>=22;if(ok){process.exit(0)}console.error('\\n ERROR: the akm-cli npm package requires Node.js >= 22.\\n A working Bun >= 1.0 on PATH is optional and preferred for akm and akm-migrate.\\n Upgrade Node.js (https://nodejs.org), or install the runtime-free standalone binary:\\n curl -fsSL https://github.com/itlackey/akm/releases/latest/download/install.sh | bash\\n');process.exit(1)\"",
70
70
  "build": "rm -rf dist && bun scripts/gen-config-schema.ts &&bun run tsc --project ./tsconfig.build.json && bun scripts/copy-assets.ts && bun scripts/fix-esm-extensions.ts",
71
71
  "check": "bun run lint && bunx tsc --noEmit && bun run test:unit && bun run test:integration",
72
72
  "check:fast": "bun run lint && bunx tsc --noEmit && bun run test:unit",
@@ -114,7 +114,7 @@
114
114
  "sqlite-vec": "^0.1.9"
115
115
  },
116
116
  "engines": {
117
- "node": ">=24"
117
+ "node": ">=22"
118
118
  },
119
119
  "dependencies": {
120
120
  "@clack/prompts": "^1.3.0",