akm-cli 0.9.17-alpha.6 → 0.9.17-alpha.7

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 (38) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/STABILITY.md +2 -2
  3. package/dist/akm +7 -7
  4. package/dist/assets/prompts/retrieval-relevance-judge.md +6 -0
  5. package/dist/commands/improve/consolidate.js +11 -0
  6. package/dist/commands/improve/improve-cli.js +27 -7
  7. package/dist/commands/improve/ledger.js +7 -3
  8. package/dist/commands/improve/preparation.js +40 -10
  9. package/dist/commands/improve/reflect.js +46 -22
  10. package/dist/commands/improve/retrieval-gate.js +127 -0
  11. package/dist/commands/improve/retrieval-scope.js +77 -0
  12. package/dist/commands/read/curate.js +1 -17
  13. package/dist/commands/tasks/tasks-cli.js +10 -12
  14. package/dist/commands/tasks/tasks.js +57 -56
  15. package/dist/commands/tasks/validate.js +27 -46
  16. package/dist/core/adapter/adapters/akm-task-adapter.js +29 -8
  17. package/dist/core/improve-result.js +4 -1
  18. package/dist/core/non-task-input.js +20 -0
  19. package/dist/core/paths.js +0 -4
  20. package/dist/indexer/indexer.js +1 -3
  21. package/dist/indexer/usage/usage-events.js +34 -0
  22. package/dist/scripts/akm-migrate-node.js +5822 -5833
  23. package/dist/scripts/akm-migrate.js +6301 -6312
  24. package/dist/storage/repositories/proposals-repository.js +4 -0
  25. package/dist/tasks/backends/cron.js +80 -43
  26. package/dist/tasks/backends/launchd.js +28 -15
  27. package/dist/tasks/backends/schtasks.js +25 -10
  28. package/dist/tasks/run/load-task.js +1 -1
  29. package/dist/tasks/scheduler-binding.js +4 -2
  30. package/dist/tasks/scheduler-invocation.js +127 -235
  31. package/dist/tasks/scheduler-sync.js +13 -8
  32. package/dist/tasks/source/parse-task-source.js +22 -126
  33. package/dist/tasks/source/task-to-v3.js +1 -55
  34. package/dist/tasks/source/task-to-v4.js +1 -13
  35. package/docs/migration/v0.9.1-to-v0.9.2.md +7 -3
  36. package/docs/reference/cli.md +4 -3
  37. package/docs/reference/tasks.md +58 -40
  38. package/package.json +1 -1
@@ -0,0 +1,127 @@
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
+ * The retrieval regression gate (#722): a reflect rewrite of an existing asset
6
+ * must not grade lower on the queries that actually retrieved it.
7
+ *
8
+ * Measured before it was built: of 60 accepted reflect rewrites judged against
9
+ * their own queries, 14 graded lower (23%, 95% CI 14–35%) and 12 higher. The
10
+ * old and new content are graded one query at a time, blind, with the
11
+ * retrieval-eval judge's prompt (kappa 0.83 against human grades) and its
12
+ * document shape: type, ref, name, description and the first 1,500 characters
13
+ * of the body.
14
+ */
15
+ import relevanceJudgePrompt from "../../assets/prompts/retrieval-relevance-judge.md" with { type: "text" };
16
+ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
17
+ import { parseRefInput } from "../../core/asset/resolve-ref.js";
18
+ import { nonTaskInput } from "../../core/non-task-input.js";
19
+ import { parseEmbeddedJsonResponse } from "../../core/parse.js";
20
+ import { listRetrievalQueries } from "../../indexer/usage/usage-events.js";
21
+ import { readLedgerDb, stripBundle } from "./ledger.js";
22
+ import { callStage } from "./stage.js";
23
+ /** Queries graded per rewrite, as measured. */
24
+ const MAX_QUERIES = 5;
25
+ /** The retrieval suite treats a longer input as a paste, not a query; the measurement did the same. */
26
+ const MAX_QUERY_CHARS = 2000;
27
+ /** The judge sees this much of the body, as in the retrieval eval. */
28
+ const MAX_DOC_CHARS = 1500;
29
+ const GRADE_SCHEMA = {
30
+ type: "object",
31
+ required: ["grade", "reason"],
32
+ additionalProperties: false,
33
+ properties: { grade: { type: "integer", minimum: 0, maximum: 3 }, reason: { type: "string" } },
34
+ };
35
+ /** Up to five distinct task queries, in the given order, whitespace collapsed. */
36
+ export function usableRetrievalQueries(raw) {
37
+ const out = [];
38
+ for (const text of raw) {
39
+ const query = text.replace(/\s+/g, " ").trim();
40
+ if (!query || query.length > MAX_QUERY_CHARS || nonTaskInput(query) || out.includes(query))
41
+ continue;
42
+ out.push(query);
43
+ if (out.length === MAX_QUERIES)
44
+ break;
45
+ }
46
+ return out;
47
+ }
48
+ /** The asset's own retrieval queries from the usage log (none when state.db cannot be read). */
49
+ export function loadRetrievalQueries(access, ref) {
50
+ try {
51
+ return usableRetrievalQueries(readLedgerDb(access, (db) => listRetrievalQueries(db, stripBundle(ref))) ?? []);
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ }
57
+ function judgeDocument(ref, raw) {
58
+ const conceptId = stripBundle(ref);
59
+ const parsed = parseRefInput(conceptId);
60
+ const { data, content } = parseFrontmatter(raw);
61
+ const text = content
62
+ .replace(/\n{3,}/g, "\n\n")
63
+ .trim()
64
+ .slice(0, MAX_DOC_CHARS);
65
+ const name = typeof data.name === "string" && data.name ? data.name : parsed.name;
66
+ const description = typeof data.description === "string" ? data.description : "";
67
+ return [
68
+ "Candidate asset:",
69
+ `Type: ${parsed.type}`,
70
+ `Ref: ${conceptId}`,
71
+ `Name: ${name}`,
72
+ `Description: ${description}`,
73
+ "",
74
+ "Content:",
75
+ text,
76
+ ].join("\n");
77
+ }
78
+ /**
79
+ * Grade `before` and `after` on each query; refuse the rewrite when the new
80
+ * content's mean grade is lower. Fails closed, like the quality judge: a grade
81
+ * that cannot be obtained refuses the rewrite.
82
+ */
83
+ export async function runRetrievalRegressionGate(args) {
84
+ if (args.queries.length === 0)
85
+ return { pass: true, queries: 0, reason: "no retrieval queries to compare on" };
86
+ const documents = { old: judgeDocument(args.ref, args.before), new: judgeDocument(args.ref, args.after) };
87
+ const totals = { old: 0, new: 0 };
88
+ for (const query of args.queries) {
89
+ for (const version of ["old", "new"]) {
90
+ const outcome = await callStage({
91
+ feature: "proposal_quality_gate",
92
+ runner: args.runner,
93
+ system: relevanceJudgePrompt.trim(),
94
+ prompt: `Query: ${query}\n\n${documents[version]}`,
95
+ request: {
96
+ enableThinking: false,
97
+ temperature: 0,
98
+ responseSchema: GRADE_SCHEMA,
99
+ ...(args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}),
100
+ ...(args.signal ? { signal: args.signal } : {}),
101
+ ...(args.chat ? { chat: args.chat } : {}),
102
+ },
103
+ ...(args.onNotices ? { onNotices: args.onNotices } : {}),
104
+ });
105
+ const grade = outcome.ok ? parseEmbeddedJsonResponse(outcome.raw)?.grade : undefined;
106
+ if (typeof grade !== "number" || !Number.isInteger(grade) || grade < 0 || grade > 3) {
107
+ return {
108
+ pass: false,
109
+ queries: args.queries.length,
110
+ reason: `retrieval check could not grade the ${version} content${outcome.ok ? "" : ` (${outcome.reason})`}`,
111
+ };
112
+ }
113
+ totals[version] += grade;
114
+ }
115
+ }
116
+ const oldMean = totals.old / args.queries.length;
117
+ const newMean = totals.new / args.queries.length;
118
+ const pass = newMean >= oldMean;
119
+ const grades = `${oldMean.toFixed(2)} -> ${newMean.toFixed(2)} over ${args.queries.length} retrieval ${args.queries.length === 1 ? "query" : "queries"}`;
120
+ return {
121
+ pass,
122
+ queries: args.queries.length,
123
+ oldMean,
124
+ newMean,
125
+ reason: pass ? `retrieval grade ${grades}` : `retrieval regression: grade ${grades}`,
126
+ };
127
+ }
@@ -0,0 +1,77 @@
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
+ * What improve may rework (#986): an asset that retrieval returned inside the
6
+ * usage window, or newly captured material no improve stage has processed.
7
+ *
8
+ * Fresh feedback and an explicit `--scope <ref>` are usage evidence of their
9
+ * own, so the signal-delta and scope lanes need no check. The fallback lanes
10
+ * (proactive maintenance, high salience, forgetting safety) and consolidation
11
+ * pick assets without such evidence, so they pick only inside this scope.
12
+ */
13
+ import fs from "node:fs";
14
+ import { daysToMs } from "../../core/common.js";
15
+ import { warn } from "../../core/warn.js";
16
+ import { listUsedEntryRefs, USAGE_EVENT_RETENTION_DAYS } from "../../indexer/usage/usage-events.js";
17
+ import { listImproveLedgerRows } from "../../storage/repositories/improve-ledger-repository.js";
18
+ import { listProposalRefSources } from "../../storage/repositories/proposals-repository.js";
19
+ import { readLedgerDb, stripBundle } from "./ledger.js";
20
+ /**
21
+ * Ledger and proposal sources that bring material in. Every other source is an
22
+ * improve stage reworking an asset (reflect, distill, consolidate, schema repair).
23
+ */
24
+ const CAPTURE_SOURCES = new Set(["extract", "propose", "remember", "import"]);
25
+ /**
26
+ * Load the scope from state.db. The window is the usage log's retention: the
27
+ * log keeps nothing older, and anything shorter would drop assets read less
28
+ * often than the window. A `.derived` hit counts for its parent memory, whose
29
+ * facts it carries. `undefined` (every asset eligible, as before #986) only
30
+ * when state.db cannot be read.
31
+ */
32
+ export function loadRetrievalScope(access, stashDir) {
33
+ const sinceMs = Date.now() - daysToMs(USAGE_EVENT_RETENTION_DAYS);
34
+ const used = new Set();
35
+ const processed = new Set();
36
+ try {
37
+ readLedgerDb(access, (db) => {
38
+ for (const entryRef of listUsedEntryRefs(db, new Date(sinceMs).toISOString())) {
39
+ const conceptId = stripBundle(entryRef);
40
+ used.add(conceptId);
41
+ if (conceptId.endsWith(".derived"))
42
+ used.add(conceptId.slice(0, -".derived".length));
43
+ }
44
+ if (!stashDir)
45
+ return;
46
+ for (const row of [...listImproveLedgerRows(db, stashDir), ...listProposalRefSources(db, stashDir)]) {
47
+ if (!CAPTURE_SOURCES.has(row.source))
48
+ processed.add(stripBundle(row.ref));
49
+ }
50
+ });
51
+ }
52
+ catch (error) {
53
+ warn(`[improve] usage history unreadable, so every asset stays eligible: ${error instanceof Error ? error.message : String(error)}`);
54
+ return undefined;
55
+ }
56
+ return { used, processed, sinceMs };
57
+ }
58
+ /**
59
+ * Whether improve may rework `ref` (whose file is `filePath`) under `scope`. An
60
+ * unprocessed asset whose file cannot be read is left to the disk check that
61
+ * follows, which reports it as missing.
62
+ */
63
+ export function isInRetrievalScope(scope, ref, filePath) {
64
+ if (!scope)
65
+ return true;
66
+ const conceptId = stripBundle(ref);
67
+ if (scope.used.has(conceptId))
68
+ return true;
69
+ if (scope.processed.has(conceptId))
70
+ return false;
71
+ try {
72
+ return filePath === undefined || fs.statSync(filePath).mtimeMs >= scope.sinceMs;
73
+ }
74
+ catch {
75
+ return true;
76
+ }
77
+ }
@@ -18,6 +18,7 @@
18
18
  import { loadConfig } from "../../core/config/config.js";
19
19
  import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
20
20
  import { appendEvent } from "../../core/events.js";
21
+ import { nonTaskInput } from "../../core/non-task-input.js";
21
22
  import { redactCredentialPatterns } from "../../core/redaction.js";
22
23
  import { withStateDbTelemetry } from "../../core/state-db.js";
23
24
  import { searchHitContent } from "../../indexer/search/db-search.js";
@@ -33,8 +34,6 @@ import { akmSearch, parseSearchSource } from "./search.js";
33
34
  import { akmShowUnified } from "./show.js";
34
35
  const DEFAULT_CURATE_LIMIT = 4;
35
36
  const MAX_CURATE_SUPPORT_REFS = 2;
36
- /** The line of `src/assets/stash-skeleton/README.md` that reaches curate verbatim as a query. */
37
- const STASH_README_LINE = "This is an **AKM stash** — a structured knowledge repository that stores reusable";
38
37
  /** Fused candidates the reranker reorders when `search.curateRerank.topN` is unset. */
39
38
  const DEFAULT_CURATE_RERANK_TOP_N = 30;
40
39
  /** Characters of name, description and content sent to the reranker per candidate. */
@@ -131,21 +130,6 @@ export async function akmCurate(options) {
131
130
  }
132
131
  return result;
133
132
  }
134
- /**
135
- * What the (trimmed) curate input is when it is not a task, else undefined.
136
- * Harness and tool envelopes (`<task-notification>…`, `<system-reminder>…`,
137
- * `<cross-session-message …>…`) start with a tag and close one, and the stash
138
- * README line arrives verbatim; on the retrieval suite neither shape occurs in
139
- * a real query. Length is not a signal: prompts over 2,000 characters found
140
- * relevant assets at about the rate of shorter long prompts.
141
- */
142
- function nonTaskInput(query) {
143
- if (query.startsWith("<") && query.includes("</"))
144
- return "a harness or tool envelope";
145
- if (query === STASH_README_LINE)
146
- return "the akm stash README boilerplate";
147
- return undefined;
148
- }
149
133
  export async function curateSearchResults(query, result, limit, selectedType, eventSource) {
150
134
  const allStashHits = result.hits.filter((hit) => hit.type !== "registry");
151
135
  const registryHits = result.registryHits ?? [];
@@ -435,17 +435,15 @@ const tasksDoctorCommand = defineJsonCommand({
435
435
  },
436
436
  });
437
437
  /**
438
- * #907: `akm task validate`'s exit-code contract — `valid` and `converts`
439
- * (a v2/v3 document the in-memory shim converted successfully) are both
440
- * successful (exit 0); `blocked`/`invalid`/`not-a-task` are diagnosed
441
- * defects the caller must act on (exit 1, mirroring `task sync`'s own
442
- * `failures.length > 0 -> EXIT_CODES.GENERAL`). A missing path or an
443
- * unreadable file never reaches this function at all — `akmTaskValidate`
444
- * throws a `UsageError` for those, which `defineJsonCommand`'s wrapping
445
- * already maps to exit 2.
438
+ * #907: `akm task validate`'s exit-code contract — `valid` succeeds (exit
439
+ * 0); `blocked`/`invalid`/`not-a-task` are diagnosed defects the caller must
440
+ * act on (exit 1, mirroring `task sync`'s own `failures.length > 0 ->
441
+ * EXIT_CODES.GENERAL`). A missing path or an unreadable file never reaches
442
+ * this function at all — `akmTaskValidate` throws a `UsageError` for those,
443
+ * which `defineJsonCommand`'s wrapping already maps to exit 2.
446
444
  */
447
445
  export function taskValidateExitCode(result) {
448
- return result.outcome === "valid" || result.outcome === "converts" ? undefined : EXIT_CODES.GENERAL;
446
+ return result.outcome === "valid" ? undefined : EXIT_CODES.GENERAL;
449
447
  }
450
448
  const tasksValidateCommand = defineJsonCommand({
451
449
  meta: {
@@ -478,9 +476,9 @@ export function taskPruneExitCode(result) {
478
476
  const tasksPruneCommand = defineJsonCommand({
479
477
  meta: {
480
478
  name: "prune",
481
- description: "Remove installed scheduler entries `sync` can never reclaim because their own descriptor no longer " +
482
- "resolves to a live bundle (corrupt/missing --scheduler-context, or the owning bundle directory is " +
483
- "gone). Defaults to a dry-run preview — zero scheduler writes. Requires --yes to remove anything; " +
479
+ description: "Remove installed scheduler entries `sync` can never reclaim because they no longer resolve to a live " +
480
+ "bundle (the bundle directory a row names is gone, or an older row's --scheduler-context descriptor " +
481
+ "cannot be read). Defaults to a dry-run preview — zero scheduler writes. Requires --yes to remove anything; " +
484
482
  "--id narrows removal to specific binding ids (comma-separated).",
485
483
  },
486
484
  args: {
@@ -41,7 +41,7 @@ import { readTaskHistory } from "../../tasks/run/task-history.js";
41
41
  import { exitCodeForStatus } from "../../tasks/run/task-result.js";
42
42
  import { parseSchedule, SCHEDULE_SUPPORTED_SUBSET_HINT } from "../../tasks/schedule.js";
43
43
  import { compileTaskSchedulerBindings, schedulerBindingNativeId, } from "../../tasks/scheduler-binding.js";
44
- import { schedulerContextDescriptor, schedulerContextPath, validateSchedulerContextDescriptor, writeSchedulerContextDescriptor, } from "../../tasks/scheduler-invocation.js";
44
+ import { readLegacySchedulerContext, scheduledRowEnvironment } from "../../tasks/scheduler-invocation.js";
45
45
  import { withSchedulerLock } from "../../tasks/scheduler-lock.js";
46
46
  import { compileSchedulerSources, installedRowNativeId, installedRowOwner, installedRowScope, planSchedulerSync, renderSchedulerPlanPreview, scheduledInvocationBundle, } from "../../tasks/scheduler-sync.js";
47
47
  import { parseTaskSource } from "../../tasks/source/parse-task-source.js";
@@ -301,9 +301,7 @@ export async function akmTasksHistory(input) {
301
301
  */
302
302
  export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
303
303
  return withSchedulerLock(async () => {
304
- const { sched, plan, publish, warnings } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
305
- if (publish && plan.operations.some((operation) => operation.kind !== "remove"))
306
- publish();
304
+ const { sched, plan, warnings } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
307
305
  const failed = new Set();
308
306
  const failures = [...plan.failures];
309
307
  for (const operation of plan.operations) {
@@ -343,7 +341,7 @@ export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
343
341
  }
344
342
  /**
345
343
  * `akm task sync --dry-run` (#849): the exact plan `akmTasksSync` would
346
- * apply, previewed. It writes nothing: no config, no descriptor, no row.
344
+ * apply, previewed. It writes nothing: no config, no row.
347
345
  */
348
346
  export async function akmTasksSyncPlan(deps = {}, bundleTarget, options = {}) {
349
347
  const { sched, plan } = await buildSchedulerSyncPlan(deps, bundleTarget, { ...options, dryRun: true });
@@ -440,7 +438,7 @@ async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
440
438
  installed,
441
439
  scopes,
442
440
  ...(sched.expectedSignature ? { expectedSignature: sched.expectedSignature.bind(sched) } : {}),
443
- ...(runtime.options ? { installOptions: runtime.options } : {}),
441
+ installOptions: runtime.options,
444
442
  rebind: options.rebind === true,
445
443
  extraRemovals,
446
444
  keepRefs,
@@ -450,53 +448,45 @@ async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
450
448
  ? plan.operations.some((operation) => operation.kind !== "remove")
451
449
  : plan.installed.length > 0;
452
450
  if (runtime.via === "checkout" && writesLauncher) {
453
- warnings.push(`Scheduled tasks now run akm from a source checkout (${runtime.options?.binding?.join(" ")}); they run whatever the checkout holds when they fire. Install akm with \`npm install --global akm-cli\` or a standalone release, then run \`akm task sync --rebind\`.`);
451
+ warnings.push(`Scheduled tasks now run akm from a source checkout (${runtime.options.binding?.join(" ")}); they run whatever the checkout holds when they fire. Install akm with \`npm install --global akm-cli\` or a standalone release, then run \`akm task sync --rebind\`.`);
454
452
  }
455
- return {
456
- sched,
457
- plan: { ...plan, failures: [...failures, ...plan.failures] },
458
- ...(runtime.publish ? { publish: runtime.publish } : {}),
459
- warnings,
460
- };
453
+ return { sched, plan: { ...plan, failures: [...failures, ...plan.failures] }, warnings };
461
454
  }
462
455
  /**
463
- * The launcher and descriptor rows are written with. The descriptor follows
464
- * the current policy on every sync; a row that is already installed keeps its
465
- * launcher unless `--rebind` (see `installOptionsFor`). An injected backend
466
- * (tests) renders with its own defaults.
456
+ * The launcher and environment rows are written with. The environment
457
+ * follows the current policy on every sync; a row that is already installed
458
+ * keeps its launcher unless `--rebind` (see `installOptionsFor`). An injected
459
+ * backend (tests) renders with its own launcher.
467
460
  */
468
461
  function prepareSchedulerRuntime(deps) {
462
+ const environment = scheduledRowEnvironment();
469
463
  if (deps.schedulerRuntime) {
470
464
  const runtime = deps.schedulerRuntime();
471
- return {
472
- options: { binding: runtime.binding, contextPath: runtime.contextPath },
473
- ...(runtime.via ? { via: runtime.via } : {}),
474
- };
465
+ return { options: { binding: runtime.binding, environment }, ...(runtime.via ? { via: runtime.via } : {}) };
475
466
  }
476
467
  if (deps.backend)
477
- return {};
478
- const descriptor = schedulerContextDescriptor();
468
+ return { options: { environment } };
479
469
  const invocation = resolveAkmInvocation();
480
- return {
481
- options: { binding: invocation.argv, contextPath: schedulerContextPath(descriptor) },
482
- // The content-addressed descriptor is written once, before the first row that references it.
483
- publish: () => {
484
- writeSchedulerContextDescriptor(descriptor);
485
- },
486
- via: invocation.via,
487
- };
470
+ return { options: { binding: invocation.argv, environment }, via: invocation.via };
488
471
  }
489
- /** One read of the akm-owned rows, each attributed to the bundle path its own descriptor names (#846). */
472
+ /** One read of the akm-owned rows, each attributed to the bundle path it names (#846). */
490
473
  async function listInstalledRows(sched) {
491
474
  return (await sched.list()).map((row) => {
492
- const ownerBundlePath = row.contextPath ? resolveInstalledOwnerPath(row.contextPath) : undefined;
475
+ const ownerBundlePath = installedRowBundleDir(row);
493
476
  return ownerBundlePath !== undefined ? { ...row, ownerBundlePath } : row;
494
477
  });
495
478
  }
496
- /** Best-effort recovery of an installed binding's owning bundle path (#846). */
497
- function resolveInstalledOwnerPath(contextPath) {
479
+ /**
480
+ * The `AKM_BUNDLE_DIR` an installed row names: its inline one, or the one in
481
+ * the descriptor a row written before 0.9.17-alpha.7 references. Undefined
482
+ * when it names none, or its descriptor cannot be read — which never means
483
+ * "mine".
484
+ */
485
+ function installedRowBundleDir(row) {
486
+ if (row.contextPath === undefined)
487
+ return row.environment?.AKM_BUNDLE_DIR;
498
488
  try {
499
- return validateSchedulerContextDescriptor(contextPath).environment.AKM_BUNDLE_DIR;
489
+ return readLegacySchedulerContext(row.contextPath).AKM_BUNDLE_DIR;
500
490
  }
501
491
  catch {
502
492
  return undefined;
@@ -515,26 +505,34 @@ function installedSchedulerBundle(config, row) {
515
505
  return bundleKeyForContentRoot(config, row.ownerBundlePath);
516
506
  }
517
507
  /**
518
- * Why `akm task prune` (#851) would remove an installed row: its own
519
- * descriptor does not load (`invalid-context`) or names a bundle directory
520
- * that is gone (`dead-bundle-path`). A row that still resolves to a live
521
- * bundle is never a candidate.
508
+ * Why `akm task prune` (#851) would remove an installed row: a row written
509
+ * before 0.9.17-alpha.7 whose descriptor cannot be read (`invalid-context`),
510
+ * or one naming a bundle directory that is gone (`dead-bundle-path`). A row
511
+ * that still resolves to a live bundle is never a candidate.
522
512
  */
523
513
  function classifyPruneCandidate(entry) {
524
- let ownerBundlePath;
525
- try {
526
- ownerBundlePath = validateSchedulerContextDescriptor(entry.contextPath).environment.AKM_BUNDLE_DIR;
514
+ let bundleDir;
515
+ if (entry.contextPath !== undefined) {
516
+ try {
517
+ bundleDir = readLegacySchedulerContext(entry.contextPath).AKM_BUNDLE_DIR;
518
+ }
519
+ catch {
520
+ return "invalid-context";
521
+ }
522
+ // Every descriptor akm wrote names the bundle; one that does not cannot be attributed.
523
+ if (bundleDir === undefined)
524
+ return "invalid-context";
527
525
  }
528
- catch {
529
- return "invalid-context";
526
+ else {
527
+ bundleDir = entry.environment?.AKM_BUNDLE_DIR;
530
528
  }
531
- if (ownerBundlePath !== undefined && !fs.existsSync(ownerBundlePath))
529
+ if (bundleDir !== undefined && !fs.existsSync(bundleDir))
532
530
  return "dead-bundle-path";
533
531
  return undefined;
534
532
  }
535
533
  /**
536
534
  * `akm task prune` (#851): remove installed rows `sync` can never reclaim
537
- * because their descriptor no longer resolves to a live bundle. It scans
535
+ * because they no longer resolve to a live bundle. It scans
538
536
  * every installed row, not one bundle's. Without `--yes` it only previews and
539
537
  * writes nothing; `--id` narrows it to named candidates.
540
538
  */
@@ -655,7 +653,7 @@ function groupInstalledBindings(entries) {
655
653
  }
656
654
  groups.set(key, {
657
655
  argv,
658
- contextPath: entry.contextPath,
656
+ ...(entry.contextPath !== undefined ? { contextPath: entry.contextPath } : {}),
659
657
  taskIds: [entry.id],
660
658
  status,
661
659
  });
@@ -669,13 +667,15 @@ function inspectInstalledBinding(entry) {
669
667
  status.push("checkout");
670
668
  if (binding.some((part) => part === "akm" || part === "bun" || part === "node"))
671
669
  status.push("path-selected");
672
- try {
673
- validateSchedulerContextDescriptor(entry.contextPath);
674
- }
675
- catch {
676
- status.push("invalid-context");
670
+ if (entry.contextPath !== undefined) {
671
+ try {
672
+ readLegacySchedulerContext(entry.contextPath);
673
+ }
674
+ catch {
675
+ status.push("invalid-context");
676
+ }
677
677
  }
678
- const absolutePaths = [...binding.filter((part) => path.isAbsolute(part)), entry.contextPath];
678
+ const absolutePaths = binding.filter((part) => path.isAbsolute(part));
679
679
  if (absolutePaths.some((part) => !fs.existsSync(part)))
680
680
  status.push("missing-path");
681
681
  if (status.length === 0)
@@ -734,8 +734,9 @@ export function resolveTaskReadBundle(refBundle, flagBundle) {
734
734
  * New scheduler bindings always carry a canonical `--bundle <owner>` token,
735
735
  * including an env-selected working stash. That stash need not be persisted in
736
736
  * config (CI, one-shot tools, and fresh installs commonly use only
737
- * AKM_BUNDLE_DIR), so its scheduled child must accept precisely its derived
738
- * owner name after the scheduler context restores the environment.
737
+ * AKM_BUNDLE_DIR), so its scheduled child — every row sets AKM_BUNDLE_DIR to
738
+ * the working stash it was synced from — must accept precisely its derived
739
+ * owner name.
739
740
  *
740
741
  * This is intentionally narrower than an unknown-bundle fallback: a configured
741
742
  * source always wins, and an unconfigured selector is accepted only when it is
@@ -9,23 +9,20 @@
9
9
  * a bundle/adapter/concept id at all — it reads exactly the path it was
10
10
  * given and classifies it.
11
11
  *
12
- * Reuses the exact version-routing shim `parseTaskSource`
13
- * (`src/tasks/source/parse-task-source.ts`) already applies for every other
14
- * task-source reader (`akm task sync`'s `compileTaskSources` included) —
15
- * this module never forks a second parser or a second v2/v3 migration
16
- * planner. `readBoundedTaskSourceYaml` / `peekTaskSourceVersion` / `own` are
17
- * the SAME front-end helpers that shim itself calls first; they are used
18
- * here only to recover the file's ORIGINALLY DECLARED schema version for
19
- * the report, because `parseTaskSource`'s own `ParsedTaskSource.version` is
20
- * always `4` post-shim — it cannot answer "was this a v2/v3/v4 file?" on
21
- * its own once a v2/v3 source has been converted in memory.
12
+ * Reuses the exact version router `parseTaskSource`
13
+ * (`src/tasks/source/parse-task-source.ts`) every other task-source reader
14
+ * uses (`akm task sync`'s `compileTaskSources` included) — this module never
15
+ * forks a second parser. `readBoundedTaskSourceYaml` /
16
+ * `peekTaskSourceVersion` / `own` are the SAME front-end helpers the router
17
+ * calls first; they are used here only to recover the file's DECLARED schema
18
+ * version for the report and to tell `blocked` from `invalid`.
22
19
  *
23
20
  * Beyond parsing, this module also runs the SAME two per-source gates
24
21
  * `akm task sync`'s `compileTaskSources` runs before it ever installs a
25
22
  * schedule — `assertTaskScheduleInputsSatisfyContract` and
26
23
  * `assertTaskScheduleCronValid` (both extracted from `scheduler-sync.ts` for
27
24
  * exactly this reuse) — so a file `sync` would reject can never
28
- * be reported `valid`/`converts` here. Cron dialect is checked against
25
+ * be reported `valid` here. Cron dialect is checked against
29
26
  * `backendNameForPlatform()`, the same platform default `sync` falls back
30
27
  * to whenever it has no injected/native-inspected backend to hand (see
31
28
  * `akmTasksAdd`, `src/commands/tasks/tasks.ts`); a bare file was never
@@ -46,27 +43,18 @@
46
43
  *
47
44
  * Outcome classification (mirrors `parse-task-source.ts`'s own routing
48
45
  * table in its header, extended for the two gates above):
49
- * - `valid` — parses as task source v4 directly (declared `version: 4`)
50
- * and passes both sync gates.
51
- * - `converts` — declared `version: 2` or `3`; the deterministic
52
- * in-memory migrator produced a valid v4 document that
53
- * passes both sync gates. A declared `version: 4`
54
- * document whose only defect is a retired
55
- * `schedule[].enabled` key also reports `converts`
56
- * (`sourceVersion` is still `4`) — it read through the
57
- * same in-memory shim, not the direct v4 parse path.
58
- * - `blocked` — declared `version: 2` or `3`; the migrator itself
59
- * could not convert it (an ambiguous/unmigratable
60
- * shape) — the ONLY way `parseTaskSource` ever throws
61
- * for those two version numbers, so no message-text
62
- * sniffing is needed to tell this apart from `invalid`.
46
+ * - `valid` — parses as task source v4 and passes both sync gates.
47
+ * - `blocked` — declared `version: 2` or `3`, or a `version: 4`
48
+ * document still carrying the retired
49
+ * `schedule[].enabled`: what `akm migrate apply`
50
+ * rewrites. The runtime reads only v4 (#987), so the
51
+ * reason names that command.
63
52
  * - `invalid` — the document declares SOME version (`4`, or anything
64
53
  * other than 2/3/4) but fails to parse/validate, OR it
65
- * parsed (directly or via a SUCCESSFUL v2/v3
66
- * conversion) but fails one of the two sync gates
67
- * above, OR the YAML itself does not parse at all
68
- * (a genuine syntax error, not merely a non-task
69
- * shape) — reported with the parser's own reason.
54
+ * parsed but fails one of the two sync gates above, OR
55
+ * the YAML itself does not parse at all (a genuine
56
+ * syntax error, not merely a non-task shape) — reported
57
+ * with the parser's own reason.
70
58
  * - `not-a-task` — the document parses as YAML but never declares a
71
59
  * `version:` field at all (or isn't a YAML mapping) —
72
60
  * the strongest signal available that the file was
@@ -142,10 +130,11 @@ export async function akmTaskValidate(filePath) {
142
130
  if (!(cause instanceof UsageError))
143
131
  throw cause;
144
132
  const reason = cause.message;
145
- // `parseTaskSource` only ever throws for a declared version 2/3 via the
146
- // unmigratable-conversion branch (see this file's header) — no separate
147
- // message check needed to recognize "blocked" here.
148
- if (declaredVersion === 2 || declaredVersion === 3) {
133
+ // What `akm migrate apply` rewrites is the only refusal for these
134
+ // shapes (`parse-task-source.ts`), so no message-text sniffing is needed.
135
+ if (declaredVersion === 2 ||
136
+ declaredVersion === 3 ||
137
+ (declaredVersion === 4 && v4ScheduleHasRetiredEnabledKey(root))) {
149
138
  return { ok: false, path: resolvedPath, sourceVersion: declaredVersion, outcome: "blocked", reason };
150
139
  }
151
140
  if (peekFailed) {
@@ -163,13 +152,10 @@ export async function akmTaskValidate(filePath) {
163
152
  };
164
153
  }
165
154
  // Success is unreachable from any path that leaves `declaredVersion`
166
- // undefined — the router requires a numeric 2/3/4 version to reach here.
155
+ // undefined — the router requires a numeric version 4 to reach here.
167
156
  const sourceVersion = declaredVersion ?? 4;
168
- // The document itself parsed (directly, or via a successful v2/v3
169
- // conversion) — now the two gates `compileTaskSources` runs before
170
- // accepting it. A violation here is `invalid`, never `blocked`: the
171
- // migrator already succeeded, so this is the same kind of defect a
172
- // native v4 document with the identical schedule would have.
157
+ // The document itself parsed — now the two gates `compileTaskSources`
158
+ // runs before accepting it. A violation here is `invalid`.
173
159
  try {
174
160
  assertTaskScheduleInputsSatisfyContract(parsed.v4, resolvedPath);
175
161
  assertTaskScheduleCronValid(parsed.v4, backend);
@@ -179,17 +165,12 @@ export async function akmTaskValidate(filePath) {
179
165
  throw cause;
180
166
  return { ok: false, path: resolvedPath, sourceVersion, outcome: "invalid", reason: cause.message };
181
167
  }
182
- // A declared `version: 4` document with a retired `schedule[].enabled`
183
- // key parsed through `parseTaskSource`'s in-memory shim, not the direct
184
- // v4 path, even though `sourceVersion` reads `4` — report it the same
185
- // way a converted v2/v3 document is reported.
186
- const convertedFromRetiredScheduleEnabled = sourceVersion === 4 && !peekFailed && v4ScheduleHasRetiredEnabledKey(root);
187
168
  const id = path.parse(resolvedPath).name;
188
169
  return {
189
170
  ok: true,
190
171
  path: resolvedPath,
191
172
  sourceVersion,
192
- outcome: sourceVersion === 2 || sourceVersion === 3 || convertedFromRetiredScheduleEnabled ? "converts" : "valid",
173
+ outcome: "valid",
193
174
  resolved: buildResolved(id, parsed.v4),
194
175
  };
195
176
  }