akm-cli 0.9.4 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,166 @@ 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.9.5] - 2026-08-30
8
+
9
+ ### Action required after upgrading
10
+
11
+ - **Run `akm index --full` once (#862).** Search ranking previously counted
12
+ every search *hit* as a small win for that entry's utility score, whether
13
+ or not you ever opened it — a plain impression, not a selection. Over
14
+ enough repeat searches this compounded into a real feedback loop: appear
15
+ in results -> score goes up -> rank higher next time -> appear again. On
16
+ the install this was diagnosed against, one entry had been searched 19
17
+ times, opened 0 times, and still carried a utility score of 0.83 — on par
18
+ with entries a user had actually picked every time; the single
19
+ highest-utility entry in the whole table had an 8% select rate. That bias
20
+ is baked into every existing install's stored scores and does not go away
21
+ on its own — the live bump has been removed (search impressions no longer
22
+ write utility scores at all; only an actual `akm show`/select or explicit
23
+ `akm feedback` feeds the offline recompute now), but the already-inflated
24
+ numbers stay in `utility_scores`/`utility_scores_scoped` until you
25
+ recompute them. Run `akm index --full` once after upgrading to rebuild
26
+ scores purely from selection rate and feedback. **Search result order will
27
+ change after that rebuild — that is intended**, not a regression.
28
+ - **The first `akm task sync` after upgrading may report a large number of
29
+ updates.** This is a backlog of reconciles that were being silently
30
+ refused, not new changes to your tasks. Two independent bugs combined to
31
+ make `sync` refuse work it should have done: (1) an installed scheduler
32
+ entry written by a pre-`--bundle` akm release couldn't prove ownership
33
+ under the newer, stricter check and was reported `(unproven owner)`,
34
+ which made `sync` refuse the *entire* run rather than reconcile everything
35
+ else; (2) separately, one task or workflow source that failed to compile
36
+ (e.g. a task still on schema v2 in a shape the shim can't convert) also
37
+ blocked every other, unrelated task from reconciling. Both are fixed —
38
+ ownership is now re-derived from the entry's own akm markers instead of
39
+ requiring a literal `--bundle` token, and a source that fails to compile
40
+ is now excluded and reported in `failed`/`failures` while every source
41
+ that DID compile still reconciles. On the install this was diagnosed
42
+ against, all 18 scheduled tasks showed up as updates on the first `sync`
43
+ after the fix — that is the backlog, not a sign your tasks changed.
44
+
45
+ ### Added
46
+
47
+ - **`akm task prune` reclaims orphaned scheduler entries `sync` cannot reach
48
+ (#851).** `akm task sync` only ever reconciles entries that resolve to a
49
+ desired task/workflow definition in a live bundle; an entry whose own
50
+ `--scheduler-context` descriptor is corrupt/missing, or whose owning
51
+ bundle directory has since been deleted, was permanently invisible to it
52
+ and had to be removed by hand-editing the crontab/launchd plist/Task
53
+ Scheduler. `akm task prune` finds those and nothing else: it never
54
+ touches an entry that still resolves to a live bundle, and there is no
55
+ `--force`/"remove everything silently" mode. Defaults to a dry-run
56
+ preview that makes zero scheduler writes and exits non-zero when it finds
57
+ removal candidates (usable as a CI/health-check guard, mirroring `task
58
+ sync --dry-run`'s exit-code convention); `--yes` executes the printed
59
+ plan; `--id a,b` narrows a run to specific binding ids and refuses (with
60
+ no writes) any id that isn't a current orphan candidate — including a
61
+ live entry, which cannot be pruned even if you name it explicitly.
62
+ - **`configVersion` read shim (#863).** Config loading now tolerates a
63
+ known older `configVersion` by upgrading the parsed document in memory
64
+ (never rewriting the file), with a one-line stderr warning naming the old
65
+ and new versions. The upgrade is silenced the next time any command
66
+ writes the config (`akm config set`, etc.), since every config write
67
+ already stamps the current version. Anything else — unknown, newer, or
68
+ malformed `configVersion` — still fails closed with the same actionable
69
+ error as before. No current release needed this shim yet (akm has only
70
+ ever shipped `configVersion: "0.9.0"`); it's in place now so the next
71
+ real bump doesn't break every existing config on upgrade.
72
+ - **Truncated indexed content is no longer silent.** The two indexer caps
73
+ that truncate an entry's content before it's written to the search index
74
+ (a markdown-body cap and a search-text cap) previously truncated without
75
+ any signal. Indexed entries now carry a `contentTruncated: true` flag
76
+ when either cap fired, and truncation logs a `--verbose` diagnostic
77
+ naming the entry, so an unexpectedly-worse search match on a very large
78
+ file has a visible cause instead of a silent one. The cap sizes
79
+ themselves are unchanged.
80
+
81
+ ### Fixed
82
+
83
+ - **`akm health --report` and `akm proposal list --status accepted` no
84
+ longer crash, and `improve`'s accepted-proposal counts are no longer
85
+ silently zero, on proposals written before every optional envelope field
86
+ existed (#859).** A prior fix already tolerated a missing `changes` key;
87
+ this closes the same gap for `proposedTarget`, which was still hard-required
88
+ at decode and — on the real archive this was checked against — absent
89
+ from 93% of accepted rows. Decoding a proposal now tolerates a genuinely
90
+ *absent* `proposedTarget` (accept-time resolution already had a
91
+ ref-derived fallback for this case, previously unreachable because decode
92
+ threw first); a *present but malformed* value still throws, so real
93
+ corruption is never silently accepted. Writing a new (`pending`) proposal
94
+ still requires the full envelope — this only widens what can be *read*,
95
+ not what akm will *write*.
96
+ - **`akm task sync` no longer refuses to reconcile an entry it can't fully
97
+ re-prove ownership of, and no longer lets one broken task/workflow source
98
+ block every other source (#867, plus the scheduler-ownership fix
99
+ described above).** The v2 task-source shim also no longer hard-fails a
100
+ command wrapped in `env NAME=value... cmd`, a shape that's common for
101
+ cron entries (e.g. `env AKM_BIN=/path/akm bash script.sh`) — the shim now
102
+ looks past a leading `env` and its assignments to the real command before
103
+ deciding whether the conversion to v3/v4 is safe, instead of checking
104
+ `env` itself.
105
+ - **`akm task sync --dry-run` no longer crashes on a real install with a
106
+ pre-`--bundle` cron entry.** The reconcile fix above made those entries
107
+ reachable for the first time, and the dry-run preview's frozen result
108
+ object was then mutated in place while stamping output metadata, throwing
109
+ "Attempting to define property on object that is not extensible" (exit
110
+ 70). Output stamping now copies instead of mutating, so it tolerates a
111
+ frozen (or any) result uniformly.
112
+ - **`akm migrate apply` no longer aborts an entire batch because one file in
113
+ it is blocked (#866).** The task v2->v3 and v3->v4 migrators refused to
114
+ write *any* file — including files with no problem at all — the moment a
115
+ single file in the batch was classified `blocked`. Both migrators now
116
+ skip and report the blocked file(s) and still migrate everything else;
117
+ the run still exits non-zero whenever anything was skipped.
118
+ - Two flaky integration/unit tests fixed at the root cause rather than
119
+ quarantined (#864): a `state.db` byte-identity assertion that raced
120
+ SQLite's own WAL checkpoint timing (now forces a checkpoint before
121
+ comparing, so the assertion reflects real durable mutations only), and a
122
+ "resolveProjectContext when cwd is the home directory" test that did
123
+ real, un-mocked filesystem walks against the actual `$HOME` (now isolates
124
+ `os.homedir()` and the stash/XDG dirs like the rest of the suite).
125
+
126
+ ### Changed
127
+
128
+ - **`test:integration` is green-by-default (#861).** Verified there is no
129
+ CI mechanism silently swallowing a failing test (no `continue-on-error`,
130
+ no lost exit codes, no allowlist of "expected" failures) — the fixes in
131
+ this release were the actual cause of the prior red baseline. Raised the
132
+ integration suite's minimum-test-count floor (`AKM_MIN_INTEGRATION_TESTS`,
133
+ 5500 -> 5700) so a large silent test loss is still caught with headroom
134
+ for ordinary future deletions, and documented in `AGENTS.md` that `TMPDIR`
135
+ must be a real `/tmp`-family path for the suite to pass — some guards
136
+ (stash-path safety, `akm-eval`'s Docker twin) intentionally hardcode that
137
+ assumption rather than reading `TMPDIR`.
138
+ - Simplified several areas flagged in the #866 complexity review without
139
+ behavior changes: `improve_runs.result_json`'s `schemaVersion` decoding is
140
+ now an explicit, extensible table instead of one large inline
141
+ conditional, ready for a future schema bump to add a case rather than
142
+ restructure the function.
143
+
144
+ ### Removed
145
+
146
+ - **Three defensive checks that only ever refused work a human explicitly
147
+ asked for, with no evidence (via telemetry) they ever caught a real
148
+ problem, were removed:**
149
+ - The 1 MiB hard cap on reading a bundle file (command/agent/script/task/
150
+ workflow source, or a frozen workflow secret/env source) into memory.
151
+ Reads remain exact and integrity-checked (hashed, CAS-verified, fail-
152
+ closed on read-time mutation) — there is simply no longer a size ceiling
153
+ that refuses to read a file you put in your own bundle.
154
+ - The task migrator's inode/hard-link-count/change-time identity
155
+ fencing on top of its existing lockfile + backup-before-write +
156
+ byte-for-byte drift check. The extra fencing modeled a multi-tenant
157
+ race that doesn't apply to a single-user CLI holding an exclusive lock,
158
+ and its failure mode (refusing to migrate a file that's byte-identical
159
+ to what was already previewed, or refusing to touch a file merely
160
+ because it has a hard link elsewhere) was worse than the risk it
161
+ guarded against. Symlink/path-escape rejection — a real hazard — is
162
+ unchanged.
163
+ - Dead anti-collapse "hard refusal" guard functions
164
+ (`checkGenerationGuard`, `checkMergeInformationFloor`) that had zero
165
+ production callers.
166
+
7
167
  ## [0.9.4] - 2026-08-30
8
168
 
9
169
  ### Changed
@@ -4,11 +4,11 @@
4
4
  /**
5
5
  * WS-3b Step 8 — Anti-collapse merge guards.
6
6
  *
7
- * (a) Generation counter: merged.generation = max(sources)+1; refuse merge
8
- * of two assets both above generation N (default 2); merges cite sources.
7
+ * (a) Generation counter: merged.generation = max(sources)+1; merges cite
8
+ * sources. `over_generation_count` (collapse-detector.ts) tracks assets
9
+ * above the generation threshold as an advisory metric only — there is
10
+ * no merge-refusal path wired in.
9
11
  * (b) Lexical-diversity check: low n-gram diversity ⇒ raise merge threshold.
10
- * (c) Merge-information floor (R5 §4.2): provenance union must not shrink and
11
- * the merged body must retain a minimum fraction of the source tokens.
12
12
  * (d) Occasional random non-similar cluster in the pool.
13
13
  *
14
14
  * @module anti-collapse
@@ -37,93 +37,6 @@ export function computeMergedGeneration(sourceGenerations) {
37
37
  return 1;
38
38
  return Math.max(...sourceGenerations) + 1;
39
39
  }
40
- /**
41
- * Check whether a merge of the given assets should be refused due to the
42
- * anti-collapse generation guard.
43
- *
44
- * Returns `{ refused: true, reason }` when BOTH assets have generation > maxGeneration.
45
- * Returns `{ refused: false }` when the merge is allowed.
46
- *
47
- * @param sourceGenerations - Generation values for all merge participants.
48
- * @param config - Anti-collapse config.
49
- */
50
- export function checkGenerationGuard(sourceGenerations, config) {
51
- // R5: default ON — only an explicit opt-out disables the guard.
52
- if (config.enabled === false)
53
- return { refused: false };
54
- const maxGen = config.maxGeneration ?? DEFAULT_MAX_GENERATION;
55
- const highGenCount = sourceGenerations.filter((g) => g > maxGen).length;
56
- if (highGenCount >= 2) {
57
- return {
58
- refused: true,
59
- reason: `Anti-collapse: ${highGenCount} merge participants have generation > ${maxGen} (${sourceGenerations.join(", ")}); refusing to merge over-consolidated assets.`,
60
- };
61
- }
62
- return { refused: false };
63
- }
64
- /** Distinct-token retention floor default (R5 §4.2). */
65
- export const DEFAULT_MIN_SPECIFICITY_RETENTION = 0.6;
66
- function distinctTokens(text) {
67
- // Same lowercase whitespace tokenization computeBigramDiversity uses.
68
- return new Set(text
69
- .toLowerCase()
70
- .split(/\s+/)
71
- .filter((w) => w.length > 0));
72
- }
73
- /**
74
- * A merge must strictly increase information (R5 §4.2):
75
- * 1. Provenance: the merged asset's `xrefs` must be a superset of the union of
76
- * all participants' `xrefs` plus the participant refs
77
- * themselves — provenance never shrinks through a merge.
78
- * 2. Specificity: distinctTokens(mergedBody) ≥ minSpecificityRetention ×
79
- * |union(distinctTokens(participant bodies))| — a merge that only
80
- * shortens/genericizes fails.
81
- *
82
- * Pure and deterministic; ADVISORY in v1 (the caller counts violations, it
83
- * does not refuse the merge). Returns `passed: true` immediately when the
84
- * anti-collapse suite or the floor itself is opted out.
85
- */
86
- export function checkMergeInformationFloor(mergedBody, mergedSourceRefs, participants, config) {
87
- if (config.enabled === false || config.mergeInformationFloor === false || participants.length === 0) {
88
- return { passed: true, provenanceBefore: 0, provenanceAfter: 0, specificityRetention: 1 };
89
- }
90
- // 1. Provenance union: participants + everything they already cited.
91
- const required = new Set();
92
- for (const p of participants) {
93
- required.add(p.ref);
94
- for (const xref of p.xrefs)
95
- required.add(xref);
96
- }
97
- const after = new Set(mergedSourceRefs);
98
- const missing = [...required].filter((r) => !after.has(r));
99
- // 2. Specificity retention over the union of source tokens.
100
- const sourceTokens = new Set();
101
- for (const p of participants) {
102
- for (const t of distinctTokens(p.body))
103
- sourceTokens.add(t);
104
- }
105
- const mergedTokens = distinctTokens(mergedBody);
106
- // Clamped at computation so the pass/fail decision, the reason string, and
107
- // the reported field all describe the same value.
108
- const specificityRetention = Math.min(1, sourceTokens.size === 0 ? 1 : mergedTokens.size / sourceTokens.size);
109
- const minRetention = config.minSpecificityRetention ?? DEFAULT_MIN_SPECIFICITY_RETENTION;
110
- const provenanceOk = missing.length === 0;
111
- const specificityOk = specificityRetention >= minRetention;
112
- const reasons = [];
113
- if (!provenanceOk) {
114
- reasons.push(`provenance shrank: merged xrefs missing ${missing.length} ref(s) (e.g. ${missing[0]})`);
115
- }
116
- if (!specificityOk) {
117
- reasons.push(`specificity retention ${specificityRetention.toFixed(2)} < ${minRetention} (merge genericized/shortened)`);
118
- }
119
- return {
120
- passed: provenanceOk && specificityOk,
121
- provenanceBefore: required.size,
122
- provenanceAfter: after.size,
123
- specificityRetention,
124
- ...(reasons.length > 0 ? { reason: reasons.join("; ") } : {}),
125
- };
126
- }
127
40
  /**
128
41
  * Compute the bigram n-gram diversity of a text string.
129
42
  * Returns a value in [0, 1] where 0 = all identical bigrams, 1 = all unique.
@@ -67,6 +67,18 @@ const canonicalProposalValidators = {
67
67
  const content = proposalContent(proposal);
68
68
  if (!content.trim())
69
69
  return [];
70
+ // #859: proposedTarget is absent on legacy archived rows, but this
71
+ // validator only ever runs on a proposal about to be minted or promoted
72
+ // (both always carry proposedTarget — see the Proposal.proposedTarget
73
+ // doc comment) — so hitting this is a genuine defect, not a legacy gap.
74
+ if (!proposal.proposedTarget) {
75
+ return [
76
+ {
77
+ kind: "invalid-workflow-structure",
78
+ message: `Workflow proposal ${proposal.id} (${proposal.ref}) is missing proposedTarget and cannot be validated.`,
79
+ },
80
+ ];
81
+ }
70
82
  const sourcePath = proposal.changes[0]?.path || proposal.ref;
71
83
  const result = compileWorkflowSource(content, {
72
84
  path: sourcePath,
@@ -14,13 +14,10 @@
14
14
  import { getSources, loadConfig } from "../../core/config/config.js";
15
15
  import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
16
16
  import { appendEvent } from "../../core/events.js";
17
- import { isTransientStashPath } from "../../core/paths.js";
18
17
  import { resolveReadSources } from "../../indexer/read-preflight.js";
19
18
  import { searchLocal } from "../../indexer/search/db-search.js";
20
19
  import { getSearchHitAttribution, usageEventAttributionMetadata, } from "../../indexer/search/search-attribution.js";
21
20
  import { getEntryIdByFilePath, getItemRefById } from "../../storage/repositories/index-entries-repository.js";
22
- import { bumpUtilityScoresBatch } from "../../storage/repositories/index-utility-repository.js";
23
- import { getCurrentWorkflowScopeKey } from "../../workflows/authoring/scope-key.js";
24
21
  // Eagerly import source providers to trigger self-registration before the
25
22
  // indexer or path-resolution code runs.
26
23
  import "../../sources/providers/index.js";
@@ -188,7 +185,7 @@ function usageSearchMode(mode) {
188
185
  function maybeLogSearchEvent(input, query, response, mode) {
189
186
  if (input.skipLogging)
190
187
  return;
191
- logSearchEvent(query, response, mode, input.eventSource, input.disableScopedUtility === true, input.attributionProjection);
188
+ logSearchEvent(query, response, mode, input.eventSource, input.attributionProjection);
192
189
  }
193
190
  /**
194
191
  * Resolve entry IDs by file_path lookup (exact match, not LIKE).
@@ -229,7 +226,7 @@ function resolveEntryIds(db, hits) {
229
226
  * Per-entry events are recorded only for stash hits because registry hits
230
227
  * have no local entry_id to reference.
231
228
  */
232
- function logSearchEvent(query, response, mode = "keyword", eventSource = "user", disableScopedUtility = false, attributionProjection = "full") {
229
+ function logSearchEvent(query, response, mode = "keyword", eventSource = "user", attributionProjection = "full") {
233
230
  // Emit a structured event to events.jsonl so workflow-trace consumers
234
231
  // detect akm search invocations without relying on stdout scraping.
235
232
  const stashHits = response.hits.filter((h) => h.type !== "registry");
@@ -276,25 +273,18 @@ function logSearchEvent(query, response, mode = "keyword", eventSource = "user",
276
273
  source: eventSource,
277
274
  });
278
275
  }, TELEMETRY_BUSY_TIMEOUT_MS);
279
- // Bump utility scores for all resolved entries (MemRL retrieval signal).
280
- // The indexer overwrites these at next reindex; bumps are temporary hints.
281
- // Gated to user-sourced events: pipeline searches (improve probes, task
282
- // runner) must not feed the utility signal (meta-review 05 DRIFT-6 —
283
- // the bump previously fired unconditionally, so even correctly-tagged
284
- // machine traffic inflated utility). utility_scores stays in index.db.
285
- const resolvedIds = eventSource === "user" ? resolved.map((r) => r.entryId).filter((id) => id !== undefined) : [];
286
- if (resolvedIds.length > 0) {
287
- let scopeKey;
288
- try {
289
- const stashPath = response.bundleDir;
290
- const disabled = disableScopedUtility || (stashPath && isTransientStashPath(stashPath));
291
- scopeKey = disabled ? undefined : getCurrentWorkflowScopeKey();
292
- }
293
- catch {
294
- // Non-fatal — fall back to global-only bumps on any error.
295
- }
296
- bumpUtilityScoresBatch(db, resolvedIds, 1.0, 0.1, scopeKey);
297
- }
276
+ // No live utility_scores/utility_scores_scoped write here (#862): a
277
+ // search result is an impression, not a signal that the asset was
278
+ // useful. Rewarding every returned hit created a feedback loop where
279
+ // merely appearing in results inflated future ranking — assets
280
+ // surfaced because they'd surfaced before, not because a user acted
281
+ // on them. Retrieval counts are still recorded above via
282
+ // insertUsageEvent (search_count) and rolled into utility_scores by
283
+ // the offline `recomputeUtilityScores` pass (`akm index`), which uses
284
+ // the show/search *select rate* — a ratio that requires an actual
285
+ // `show`/select event, not raw impressions. Explicit signal comes
286
+ // from `akm feedback` (applyFeedbackToUtilityScore) and from
287
+ // selection (recordShowUsage / the `select` event derived from it).
298
288
  }, { busyTimeoutMs: TELEMETRY_BUSY_TIMEOUT_MS });
299
289
  }
300
290
  catch (err) {
@@ -31,7 +31,7 @@ import { defineGroupCommand, defineJsonCommand, EXIT_CODES, GLOBAL_OUTPUT_ARGS,
31
31
  import { UsageError } from "../../core/errors.js";
32
32
  import { TASK_RUN_BOOLEAN_FLAGS, TASK_RUN_VALUE_FLAGS } from "../../tasks/task-run-reserved-flags.js";
33
33
  import { akmTaskExplain } from "./explain.js";
34
- import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksRun, akmTasksSync, akmTasksSyncPlan } from "./tasks.js";
34
+ import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksPrune, akmTasksRun, akmTasksSync, akmTasksSyncPlan, } from "./tasks.js";
35
35
  /** Shared `--bundle <bundle>` arg wired onto every task subcommand. */
36
36
  const bundleArg = {
37
37
  bundle: {
@@ -307,10 +307,13 @@ const tasksHistoryCommand = defineJsonCommand({
307
307
  * #849: `task sync --dry-run`'s exit-code contract, "non-zero when the plan
308
308
  * contains removals" — factored out as a pure function (rather than left
309
309
  * inline in the command's `run()`) so it's directly unit-testable without
310
- * driving the whole CLI through a real scheduler backend.
310
+ * driving the whole CLI through a real scheduler backend. #867: also
311
+ * non-zero when any source failed to parse/prepare — sync degrades (still
312
+ * reconciles the tasks/workflows that DID parse) rather than rejecting the
313
+ * whole set, but a dropped source must still surface as a failing exit.
311
314
  */
312
315
  export function taskSyncDryRunExitCode(preview) {
313
- return preview.hasRemovals ? EXIT_CODES.GENERAL : undefined;
316
+ return preview.hasRemovals || (preview.failures?.length ?? 0) > 0 ? EXIT_CODES.GENERAL : undefined;
314
317
  }
315
318
  const tasksSyncCommand = defineJsonCommand({
316
319
  meta: {
@@ -345,6 +348,12 @@ const tasksSyncCommand = defineJsonCommand({
345
348
  }
346
349
  const result = await akmTasksSync({}, args.bundle, { rebind });
347
350
  output("task-sync", result);
351
+ // #867: sync degrades — sources that failed to parse/prepare are
352
+ // excluded from reconciliation and reported in `result.failed` rather
353
+ // than poisoning the whole sync, but their presence must still fail
354
+ // the command's exit code so the breakage stays visible.
355
+ if (result.failed.length > 0)
356
+ process.exitCode = EXIT_CODES.GENERAL;
348
357
  },
349
358
  });
350
359
  // ── `akm task explain` — read-only introspection (P2b Lane B, spec
@@ -389,6 +398,49 @@ const tasksDoctorCommand = defineJsonCommand({
389
398
  output("task-doctor", result);
390
399
  },
391
400
  });
401
+ /**
402
+ * #851: `akm task prune`'s exit-code contract mirrors `task sync --dry-run`'s
403
+ * (`taskSyncDryRunExitCode` above) — non-zero whenever the preview lists
404
+ * removals the invocation didn't (or couldn't, without `--yes`) execute, so
405
+ * `akm task prune` is usable as a CI/health check the same way `sync
406
+ * --dry-run` is.
407
+ */
408
+ export function taskPruneExitCode(result) {
409
+ return result.dryRun && result.preview.hasRemovals ? EXIT_CODES.GENERAL : undefined;
410
+ }
411
+ const tasksPruneCommand = defineJsonCommand({
412
+ meta: {
413
+ name: "prune",
414
+ description: "Remove installed scheduler entries `sync` can never reclaim because their own descriptor no longer " +
415
+ "resolves to a live bundle (corrupt/missing --scheduler-context, or the owning bundle directory is " +
416
+ "gone). Defaults to a dry-run preview — zero scheduler writes. Requires --yes to remove anything; " +
417
+ "--id narrows removal to specific binding ids (comma-separated).",
418
+ },
419
+ args: {
420
+ yes: {
421
+ type: "boolean",
422
+ description: "Execute removal of every currently-computed orphan (the plan is still printed first)",
423
+ default: false,
424
+ },
425
+ id: {
426
+ type: "string",
427
+ description: "Comma-separated scheduler binding id(s) to limit pruning to (must already be orphan candidates)",
428
+ },
429
+ },
430
+ async run({ args }) {
431
+ const id = args.id
432
+ ? args.id
433
+ .split(",")
434
+ .map((value) => value.trim())
435
+ .filter(Boolean)
436
+ : undefined;
437
+ const result = await akmTasksPrune({}, { yes: args.yes === true, id });
438
+ output("task-prune", result);
439
+ const exitCode = taskPruneExitCode(result);
440
+ if (exitCode !== undefined)
441
+ process.exitCode = exitCode;
442
+ },
443
+ });
392
444
  export const taskCommand = defineGroupCommand({
393
445
  meta: {
394
446
  name: "task",
@@ -400,6 +452,7 @@ export const taskCommand = defineGroupCommand({
400
452
  explain: tasksExplainCommand,
401
453
  history: tasksHistoryCommand,
402
454
  sync: tasksSyncCommand,
455
+ prune: tasksPruneCommand,
403
456
  doctor: tasksDoctorCommand,
404
457
  },
405
458
  // Bare `akm task` reports scheduler diagnostics. Inspection of individual
@@ -35,8 +35,8 @@ import { exitCodeForStatus } from "../../tasks/run/task-result.js";
35
35
  import { parseSchedule, SCHEDULE_SUPPORTED_SUBSET_HINT } from "../../tasks/schedule.js";
36
36
  import { assertSchedulerMutationArtifact, assertSchedulerNativeArtifactCardinality, compileTaskSchedulerBindings, schedulerBindingNativeId, schedulerBindingOrdinal, schedulerNativeArtifactKey, schedulerNativeBindingId, } from "../../tasks/scheduler-binding.js";
37
37
  import { schedulerContextDescriptor, schedulerContextPath, validateSchedulerContextDescriptor, writeSchedulerContextDescriptor, } from "../../tasks/scheduler-invocation.js";
38
- import { assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot, finalizeSchedulerSyncPlan, prepareSchedulerSyncSourceSet, } from "../../tasks/scheduler-sync.js";
39
- import { renderSchedulerSyncPlanPreview } from "../../tasks/scheduler-sync-preview.js";
38
+ import { assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot, buildSchedulerRemoveOperation, finalizeSchedulerSyncPlan, prepareSchedulerSyncSourceSet, } from "../../tasks/scheduler-sync.js";
39
+ import { renderSchedulerPlanPreview, renderSchedulerSyncPlanPreview, } from "../../tasks/scheduler-sync-preview.js";
40
40
  import { parseTaskSource } from "../../tasks/source/parse-task-source.js";
41
41
  import { projectTaskSourceV4 } from "../../tasks/source/project-v4.js";
42
42
  import { TASK_V3_MAX_SOURCE_BYTES } from "../../tasks/source-v3.js";
@@ -417,6 +417,7 @@ export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
417
417
  unchanged: [...plan.unchanged],
418
418
  skipped: [],
419
419
  backend: sched.name,
420
+ failed: plan.failures.map((failure) => ({ ...failure })),
420
421
  ...(warnings.length > 0 ? { warnings } : {}),
421
422
  };
422
423
  }
@@ -434,6 +435,92 @@ export async function akmTasksSyncPlan(deps = {}, bundleTarget, options = {}) {
434
435
  const { sched, plan } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
435
436
  return renderSchedulerSyncPlanPreview(sched.name, plan);
436
437
  }
438
+ /**
439
+ * Classify one installed scheduler binding as a prune candidate (#851), using
440
+ * the same two signals `doctor`'s `inspectInstalledBinding` already computes
441
+ * — deliberately narrower than that function's full `status` set. Only an
442
+ * entry whose ownership can NEVER be resolved (`invalid-context`) or whose
443
+ * resolved owner no longer exists on disk (`dead-bundle-path`) is a
444
+ * candidate; `missing-path` (e.g. the akm binary itself moved) is a
445
+ * different failure mode and is intentionally NOT folded in here, per the
446
+ * scoping in #851 — an entry that still resolves to a live bundle is never a
447
+ * candidate, full stop.
448
+ */
449
+ function classifyPruneCandidate(entry) {
450
+ let ownerBundlePath;
451
+ try {
452
+ ownerBundlePath = validateSchedulerContextDescriptor(entry.contextPath).environment.AKM_BUNDLE_DIR;
453
+ }
454
+ catch {
455
+ return "invalid-context";
456
+ }
457
+ if (ownerBundlePath !== undefined && !fs.existsSync(ownerBundlePath))
458
+ return "dead-bundle-path";
459
+ return undefined;
460
+ }
461
+ async function buildTaskPrunePlan(deps = {}, options = {}) {
462
+ const sched = deps.backend ?? selectBackend();
463
+ if (!sched.inspectBindings) {
464
+ throw new ConfigError(`Scheduler backend "${sched.name}" cannot provide one coherent inspection for prune.`, "INVALID_CONFIG_FILE");
465
+ }
466
+ const inspection = await sched.inspectBindings({});
467
+ const candidates = new Map();
468
+ for (const entry of inspection.installed) {
469
+ const reason = classifyPruneCandidate(entry);
470
+ if (reason)
471
+ candidates.set(entry.id, reason);
472
+ }
473
+ const requestedIds = options.id?.filter((id) => id.length > 0) ?? [];
474
+ for (const id of requestedIds) {
475
+ if (!candidates.has(id)) {
476
+ throw new UsageError(`Scheduler binding ${JSON.stringify(id)} is not an orphaned prune candidate ` +
477
+ "(either not installed, or it still resolves to a live bundle) — refusing to prune it.", "INVALID_FLAG_VALUE");
478
+ }
479
+ }
480
+ const idFilter = requestedIds.length > 0 ? new Set(requestedIds) : undefined;
481
+ const resolved = resolveTaskReadBundle(undefined, undefined);
482
+ const bundleContext = {
483
+ adapterId: resolved.source.adapterId ?? detectAdapterId(resolved.source.path),
484
+ bundleName: resolved.source.name,
485
+ };
486
+ const operations = [];
487
+ for (const entry of inspection.installed) {
488
+ const reason = candidates.get(entry.id);
489
+ if (!reason)
490
+ continue;
491
+ if (idFilter && !idFilter.has(entry.id))
492
+ continue;
493
+ const operation = buildSchedulerRemoveOperation(entry.id, entry, inspection.artifacts, bundleContext);
494
+ operations.push(Object.freeze({ ...operation, reason }));
495
+ }
496
+ return { sched, operations: Object.freeze(operations) };
497
+ }
498
+ /**
499
+ * `akm task prune` (#851): remove installed scheduler bindings `sync` can
500
+ * never reclaim because their own `--scheduler-context` descriptor doesn't
501
+ * resolve to a live bundle. Defaults to dry-run — no `--yes` and no `--id`
502
+ * means zero backend calls that could mutate anything, matching
503
+ * `akmTasksSyncPlan`'s zero-write guarantee. `--id` (one or more) narrows
504
+ * execution to exactly those bindings; `--yes` alone executes every
505
+ * currently-computed candidate. Both still return the full preview so the
506
+ * plan is never silent about what it did.
507
+ */
508
+ export async function akmTasksPrune(deps = {}, options = {}) {
509
+ const { sched, operations } = await buildTaskPrunePlan(deps, options);
510
+ const preview = renderSchedulerPlanPreview(sched.name, operations);
511
+ if (!options.yes) {
512
+ return { backend: sched.name, dryRun: true, preview, removed: [] };
513
+ }
514
+ await applySchedulerTransaction(sched, operations, {
515
+ initialExpectations: operations.map((operation) => operation.expected),
516
+ });
517
+ return {
518
+ backend: sched.name,
519
+ dryRun: false,
520
+ preview,
521
+ removed: operations.map((operation) => operation.id),
522
+ };
523
+ }
437
524
  export async function akmTasksDoctor(deps = {}) {
438
525
  const warnings = [];
439
526
  let invocation = {
@@ -223,6 +223,8 @@ function indexDocumentFromEntry(entry, base, rendererName) {
223
223
  doc.tags = entry.tags;
224
224
  if (entry.content !== undefined)
225
225
  doc.content = entry.content;
226
+ if (entry.contentTruncated !== undefined)
227
+ doc.contentTruncated = entry.contentTruncated;
226
228
  if (entry.aliases !== undefined)
227
229
  doc.aliases = entry.aliases;
228
230
  if (entry.searchHints !== undefined)