@cortexkit/aft-bridge 0.63.5 → 0.64.0

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/dist/bash-host-fallback.d.ts +4 -8
  2. package/dist/bash-host-fallback.d.ts.map +1 -1
  3. package/dist/bash-host-fallback.js +7 -8
  4. package/dist/bash-host-fallback.js.map +1 -1
  5. package/dist/bash-permissions.d.ts +1 -0
  6. package/dist/bash-permissions.d.ts.map +1 -1
  7. package/dist/bash-permissions.js +23 -11
  8. package/dist/bash-permissions.js.map +1 -1
  9. package/dist/callgraph-format.d.ts.map +1 -1
  10. package/dist/callgraph-format.js +64 -6
  11. package/dist/callgraph-format.js.map +1 -1
  12. package/dist/config-watch.d.ts +3 -1
  13. package/dist/config-watch.d.ts.map +1 -1
  14. package/dist/config-watch.js +90 -6
  15. package/dist/config-watch.js.map +1 -1
  16. package/dist/feature-config.d.ts +128 -11
  17. package/dist/feature-config.d.ts.map +1 -1
  18. package/dist/feature-config.js +351 -33
  19. package/dist/feature-config.js.map +1 -1
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/lsp-npm-policy.d.ts.map +1 -1
  25. package/dist/lsp-npm-policy.js +4 -3
  26. package/dist/lsp-npm-policy.js.map +1 -1
  27. package/dist/lsp-npm-selection.d.ts +22 -0
  28. package/dist/lsp-npm-selection.d.ts.map +1 -0
  29. package/dist/lsp-npm-selection.js +97 -0
  30. package/dist/lsp-npm-selection.js.map +1 -0
  31. package/dist/npm-resolver.d.ts +4 -0
  32. package/dist/npm-resolver.d.ts.map +1 -1
  33. package/dist/npm-resolver.js +20 -0
  34. package/dist/npm-resolver.js.map +1 -1
  35. package/dist/subc-transport.d.ts.map +1 -1
  36. package/dist/subc-transport.js +16 -3
  37. package/dist/subc-transport.js.map +1 -1
  38. package/package.json +1 -1
@@ -3,14 +3,16 @@
3
3
  *
4
4
  * Mirrors `crates/aft/src/feature_config.rs`: the literal tool inventory, the
5
5
  * table of retired keys, the per-block translation of those keys into
6
- * canonical `disabled_tools` / `indexes` / `github` / `lsp` leaves (retired
7
- * keys are never refused),
6
+ * canonical `disabled_tools` / `indexes` / `github` / `lsp` / `idle` leaves
7
+ * (the names retired in 0.62 or earlier, and the data-layer keys from
8
+ * `DATA_LAYER_KEYS_REFUSED_FROM` on, are refused by `removedConfigKeyErrors`),
8
9
  * validation of a resolved configuration, and the migration-notice projection
9
10
  * digest. The config parity fixtures and the shared notice-projection fixture
10
11
  * (`crates/aft/tests/fixtures/feature_config/notice_projection.json`) keep the
11
12
  * two implementations equal.
12
13
  */
13
14
  import { createHash } from "node:crypto";
15
+ import { createRequire } from "node:module";
14
16
  /** Every agent-visible tool name a registration adapter may publish (sorted). */
15
17
  export const CANONICAL_TOOLS = [
16
18
  "aft_callgraph",
@@ -73,24 +75,190 @@ export const REMOVED_OPENCODE_PATHS = [
73
75
  "opencode.server_url",
74
76
  "opencode.server_password_env",
75
77
  ];
76
- /** Removed transport settings fail only in the base and first-level harness blocks. */
77
- export function removedConfigKeyErrors(value) {
78
+ /**
79
+ * Keys the per-repository data store leaves with nothing to do. A config file
80
+ * that sets one gets a notice saying it no longer does anything, and
81
+ * `doctor --fix` deletes it, but the automatic rewrite of the user file
82
+ * leaves it in place. Until the engine code that reads them (index views, and
83
+ * the path that serves a worktree from another checkout's indexes) is deleted,
84
+ * the values a file supplies still reach the engine, so translation keeps them in
85
+ * the document. From `DATA_LAYER_KEYS_REFUSED_FROM` a config that sets one is
86
+ * refused. Mirrors `REMOVED_DATA_LAYER_PATHS` in
87
+ * `crates/aft/src/feature_config.rs`.
88
+ */
89
+ export const REMOVED_DATA_LAYER_PATHS = [
90
+ "views.callgraph_copies",
91
+ "views.enabled",
92
+ "worktree.ram_overlay",
93
+ ];
94
+ /**
95
+ * `views.idle_checkout_hours` and the key that replaces it. Translated like any
96
+ * retired key (and the user file rewritten) until
97
+ * `DATA_LAYER_KEYS_REFUSED_FROM`, then refused.
98
+ */
99
+ export const RETIRED_IDLE_CHECKOUT_HOURS = [
100
+ "views.idle_checkout_hours",
101
+ "idle.checkout_hours",
102
+ ];
103
+ /**
104
+ * First release, as [major, minor], that refuses the keys in
105
+ * `DATA_LAYER_RETIRED_PATHS` with `removed_config_key`. Earlier releases
106
+ * translate or keep them, with a notice.
107
+ */
108
+ export const DATA_LAYER_KEYS_REFUSED_FROM = [0, 65];
109
+ /** Every key the data-layer cutover retires, refused from `DATA_LAYER_KEYS_REFUSED_FROM`. */
110
+ export const DATA_LAYER_RETIRED_PATHS = [
111
+ "views.callgraph_copies",
112
+ "views.enabled",
113
+ "views.idle_checkout_hours",
114
+ "worktree.ram_overlay",
115
+ ];
116
+ /**
117
+ * The `views` block a plugin sends to configure: the resolved block without
118
+ * `enabled`, or `undefined` when nothing else is left. `views.enabled` does
119
+ * nothing from 0.64; the file that sets it still gets the removed-key notice,
120
+ * but the key never reaches the resolved config, as in the Rust resolver.
121
+ */
122
+ export function viewsForConfigure(views) {
123
+ if (views === undefined)
124
+ return undefined;
125
+ const { enabled: _enabled, ...rest } = views;
126
+ return Object.keys(rest).length > 0 ? rest : undefined;
127
+ }
128
+ /** `idle.checkout_hours` default and range; mirrors `crates/aft/src/config.rs`. */
129
+ export const IDLE_CHECKOUT_HOURS_DEFAULT = 168;
130
+ export const IDLE_CHECKOUT_HOURS_MIN = 1;
131
+ export const IDLE_CHECKOUT_HOURS_MAX = 24 * 365;
132
+ /** The [major, minor] release a version string names, ignoring patch and suffixes. */
133
+ export function parseRelease(version) {
134
+ const match = /^(\d+)\.(\d+)(?:\.|$)/.exec(version);
135
+ return match ? [Number(match[1]), Number(match[2])] : undefined;
136
+ }
137
+ /** Manifests that carry this code's release: the bridge, or a bundle that inlines it. */
138
+ const AFT_PACKAGE_NAMES = new Set([
139
+ "@cortexkit/aft-bridge",
140
+ "@cortexkit/aft-opencode",
141
+ "@cortexkit/aft-pi",
142
+ "@cortexkit/aft",
143
+ ]);
144
+ /**
145
+ * This code's release, read from the nearest AFT package manifest above it.
146
+ * The bridge is also bundled into the plugins and the CLI at different
147
+ * depths, and every AFT package is released with the same version, so the
148
+ * first AFT manifest found names the release. [0, 0] when none is found,
149
+ * which refuses nothing.
150
+ */
151
+ function readRunningRelease() {
152
+ const req = createRequire(import.meta.url);
153
+ for (const candidate of ["../package.json", "../../package.json", "../../../package.json"]) {
154
+ try {
155
+ const manifest = req(candidate);
156
+ if (manifest.name && AFT_PACKAGE_NAMES.has(manifest.name) && manifest.version) {
157
+ return parseRelease(manifest.version) ?? [0, 0];
158
+ }
159
+ }
160
+ catch {
161
+ // Not at this depth; try the next.
162
+ }
163
+ }
164
+ return [0, 0];
165
+ }
166
+ let runningRelease;
167
+ /** The [major, minor] release of this package, read once. */
168
+ export function getRunningRelease() {
169
+ runningRelease ??= readRunningRelease();
170
+ return runningRelease;
171
+ }
172
+ function releaseAtLeast(release, floor) {
173
+ return release[0] !== floor[0] ? release[0] > floor[0] : release[1] >= floor[1];
174
+ }
175
+ /** `0.65.0` for `DATA_LAYER_KEYS_REFUSED_FROM`, as notices print it. */
176
+ function refusedFromVersion() {
177
+ return `${DATA_LAYER_KEYS_REFUSED_FROM[0]}.${DATA_LAYER_KEYS_REFUSED_FROM[1]}.0`;
178
+ }
179
+ /**
180
+ * Removed settings fail only in the base and first-level harness blocks. The
181
+ * data-layer keys fail only from `DATA_LAYER_KEYS_REFUSED_FROM`, judged by
182
+ * `release` (this package's own release unless a test names one). The names
183
+ * retired in 0.62 or earlier (`REFUSED_RETIRED_PATHS` and the prefixed tool
184
+ * names) fail only when `refuseRetired` is true. The plugins pass false for
185
+ * both files: the AFT binary rewrites the user file to current keys when it
186
+ * loads it, and a project file loads with a notice rather than leaving a
187
+ * repository without AFT. Mirrors `removed_config_key_errors` in
188
+ * `crates/aft/src/feature_config.rs`, which always refuses them; the binary
189
+ * applies it to the user file only.
190
+ */
191
+ export function removedConfigKeyErrors(value, release = getRunningRelease(), refuseRetired = true) {
78
192
  if (!isRecord(value))
79
193
  return [];
80
- const errors = removedOpencodeKeyErrors(value);
194
+ const errors = removedBlockKeyErrors(value, release, refuseRetired);
81
195
  if (isRecord(value.harnesses)) {
82
196
  for (const [name, block] of Object.entries(value.harnesses)) {
83
197
  if (!isRecord(block))
84
198
  continue;
85
- for (const error of removedOpencodeKeyErrors(block)) {
199
+ for (const error of removedBlockKeyErrors(block, release, refuseRetired)) {
86
200
  errors.push(error.replace("removed_config_key:", `removed_config_key:harnesses.${name}.`));
87
201
  }
88
202
  }
89
203
  }
90
204
  return errors;
91
205
  }
92
- function removedOpencodeKeyErrors(value) {
93
- return REMOVED_OPENCODE_PATHS.filter((path) => hasPath(value, path)).map((path) => `removed_config_key:${path}; run aft doctor --fix`);
206
+ function removedBlockKeyErrors(value, release, refuseRetired) {
207
+ const paths = REMOVED_OPENCODE_PATHS.filter((path) => hasPath(value, path));
208
+ if (refuseRetired)
209
+ paths.push(...refusedRetiredNames(value));
210
+ if (releaseAtLeast(release, DATA_LAYER_KEYS_REFUSED_FROM))
211
+ paths.push(...DATA_LAYER_RETIRED_PATHS.filter((path) => hasPath(value, path)));
212
+ return paths.map((path) => `removed_config_key:${path}; run npx @cortexkit/aft doctor --fix`);
213
+ }
214
+ /**
215
+ * Names retired in 0.62 or earlier, refused from 0.64, in the order a refusal
216
+ * lists them. A config that still sets one is refused with
217
+ * `removed_config_key`; `doctor --fix` (or the automatic rewrite of the user
218
+ * file) replaces it with its current equivalent or deletes it. Names retired
219
+ * more recently (`bridge.hang_threshold` and the data-layer keys) are not
220
+ * listed: they are still translated, with a notice. Mirrors
221
+ * `REFUSED_RETIRED_PATHS` in `crates/aft/src/feature_config.rs`.
222
+ */
223
+ export const REFUSED_RETIRED_PATHS = [
224
+ "sandbox.enabled",
225
+ "tool_surface",
226
+ "hoist_builtin_tools",
227
+ "enabled",
228
+ "search_index",
229
+ "experimental_search_index",
230
+ "semantic_search",
231
+ "experimental_semantic_search",
232
+ "callgraph_store",
233
+ "github.enabled",
234
+ "gh_read",
235
+ "gh_shim.enabled",
236
+ "experimental_lsp_ty",
237
+ "experimental_bash_rewrite",
238
+ "experimental_bash_compress",
239
+ "experimental_bash_background",
240
+ "experimental.bash",
241
+ "idle.lsp_ttl_minutes",
242
+ "inspect.tier2_soft_deadline_ms",
243
+ "inspect.max_drill_down_items",
244
+ ];
245
+ /**
246
+ * The refused retired names one block sets: the paths in
247
+ * `REFUSED_RETIRED_PATHS`, then each prefixed tool name (`LEGACY_TOOL_ALIASES`)
248
+ * its `disabled_tools` list contains, once each and by its bare name, as the
249
+ * retired-key notices list it.
250
+ */
251
+ function refusedRetiredNames(block) {
252
+ const names = REFUSED_RETIRED_PATHS.filter((path) => hasPath(block, path));
253
+ if (Array.isArray(block.disabled_tools)) {
254
+ for (const name of block.disabled_tools) {
255
+ if (typeof name !== "string" || !Object.hasOwn(LEGACY_TOOL_ALIASES, name))
256
+ continue;
257
+ if (!names.includes(name))
258
+ names.push(name);
259
+ }
260
+ }
261
+ return names;
94
262
  }
95
263
  /** Retired config paths and their replacement. */
96
264
  export const RETIRED_PATHS = [
@@ -284,10 +452,16 @@ function translateInspectLspPaths(map, prefix, out) {
284
452
  * dropped on load and removed by `doctor --fix`, and the notice says so.
285
453
  * `bridge.hang_threshold` counted request timeouts toward restarting the
286
454
  * bridge; a timeout now fails only its own request, and the bridge's hang
287
- * watchdog judges a stuck child by its silence instead. Mirrors
288
- * `REMOVED_NO_EFFECT_PATHS` in `crates/aft/src/feature_config.rs`.
455
+ * watchdog judges a stuck child by its silence instead.
456
+ * `inspect.tier2_idle_minutes` delayed the whole-repository analyses the
457
+ * OpenCode plugin started when a session went idle; those analyses now run
458
+ * only when `aft_inspect` is called. Mirrors `REMOVED_NO_EFFECT_PATHS` in
459
+ * `crates/aft/src/feature_config.rs`.
289
460
  */
290
- export const REMOVED_NO_EFFECT_PATHS = ["bridge.hang_threshold"];
461
+ export const REMOVED_NO_EFFECT_PATHS = [
462
+ "bridge.hang_threshold",
463
+ "inspect.tier2_idle_minutes",
464
+ ];
291
465
  /** Drop the retired keys that no longer do anything, exactly as `doctor --fix` removes them. */
292
466
  function dropNoEffectPaths(map, prefix, out) {
293
467
  for (const path of REMOVED_NO_EFFECT_PATHS) {
@@ -298,9 +472,67 @@ function dropNoEffectPaths(map, prefix, out) {
298
472
  }
299
473
  }
300
474
  /**
301
- * Translate flat experimental aliases to nested settings. Graduating
302
- * experimental.bash explicitly sets omitted feature flags to false;
303
- * otherwise the current default-on bash settings would silently enable them.
475
+ * The `idle.checkout_hours` value a retired `views.idle_checkout_hours` value
476
+ * becomes: a whole number clamped to the current range, or the default for
477
+ * anything else. Mirrors `retired_idle_checkout_hours` in
478
+ * `crates/aft/src/feature_config.rs`.
479
+ */
480
+ export function retiredIdleCheckoutHours(value) {
481
+ const raw = typeof value === "number" && Number.isSafeInteger(value) ? value : IDLE_CHECKOUT_HOURS_DEFAULT;
482
+ return Math.min(IDLE_CHECKOUT_HOURS_MAX, Math.max(IDLE_CHECKOUT_HOURS_MIN, raw));
483
+ }
484
+ /**
485
+ * Translate the keys the data-layer cutover retires in one block:
486
+ * `views.idle_checkout_hours` becomes `idle.checkout_hours` (clamped to its
487
+ * range) unless that is already set, exactly as `doctor --fix` rewrites it.
488
+ * The removed keys (`REMOVED_DATA_LAYER_PATHS`) are only listed: their values
489
+ * stay in the document until the code that reads them is deleted.
490
+ */
491
+ function translateDataLayerPaths(map, prefix, out) {
492
+ const [retired, replacement] = RETIRED_IDLE_CHECKOUT_HOURS;
493
+ const hours = takeNested(map, "views", "idle_checkout_hours");
494
+ if (hours.found) {
495
+ recordRetired(out, prefix, retired);
496
+ if (isRecord(map.idle) && map.idle.checkout_hours !== undefined) {
497
+ superseded(out, `${prefix}${retired}`, `${retired} is ignored because ${replacement} is set`);
498
+ }
499
+ else {
500
+ if (!Object.hasOwn(map, "idle"))
501
+ map.idle = {};
502
+ if (isRecord(map.idle))
503
+ map.idle.checkout_hours = retiredIdleCheckoutHours(hours.value);
504
+ }
505
+ }
506
+ for (const path of REMOVED_DATA_LAYER_PATHS) {
507
+ if (hasPath(map, path))
508
+ out.removedKeys.push(`${prefix}${path}`);
509
+ }
510
+ }
511
+ /**
512
+ * Delete the removed data-layer keys from a translated document, base and
513
+ * harness blocks alike, emptying containers as `doctor --fix` does. Used to
514
+ * preview what `doctor --fix` will remove; load-time translation keeps them.
515
+ */
516
+ export function dropRemovedDataLayerKeys(map) {
517
+ const blocks = [map];
518
+ if (isRecord(map.harnesses)) {
519
+ for (const block of Object.values(map.harnesses))
520
+ if (isRecord(block))
521
+ blocks.push(block);
522
+ }
523
+ for (const block of blocks) {
524
+ for (const path of REMOVED_DATA_LAYER_PATHS) {
525
+ const [container, leaf] = path.split(".");
526
+ if (container && leaf)
527
+ takeNested(block, container, leaf);
528
+ }
529
+ }
530
+ }
531
+ /**
532
+ * Translate flat experimental aliases to nested settings and move the
533
+ * `experimental.bash` block to top-level `bash`. Graduating a block that sets
534
+ * a feature flag explicitly sets its omitted flags to false; otherwise the
535
+ * current default-on bash settings would silently enable them.
304
536
  */
305
537
  function translateExperimentalPaths(map, prefix, out) {
306
538
  for (const [oldKey, leaf] of [
@@ -335,24 +567,29 @@ function translateExperimentalPaths(map, prefix, out) {
335
567
  const experimental = map.experimental;
336
568
  const legacy = experimental.bash;
337
569
  if (isRecord(legacy)) {
338
- if (!["rewrite", "compress", "background"].some((leaf) => Object.hasOwn(legacy, leaf)))
339
- return;
570
+ if (Object.keys(legacy).length === 0)
571
+ recordRetired(out, prefix, "experimental.bash");
340
572
  for (const leaf of Object.keys(legacy))
341
573
  recordRetired(out, prefix, `experimental.bash.${leaf}`);
342
574
  if (Object.hasOwn(map, "bash")) {
343
575
  superseded(out, "experimental.bash", 'experimental.bash is ignored because top-level "bash" is already set');
344
576
  }
345
577
  else {
346
- const bash = {
347
- rewrite: legacy.rewrite === true,
348
- compress: legacy.compress === true,
349
- background: legacy.background === true,
350
- };
578
+ const bash = {};
579
+ // A block with only reminder settings never opted into the feature
580
+ // flags, so they keep their current defaults instead of being written
581
+ // as false.
582
+ if (["rewrite", "compress", "background"].some((leaf) => Object.hasOwn(legacy, leaf))) {
583
+ bash.rewrite = legacy.rewrite === true;
584
+ bash.compress = legacy.compress === true;
585
+ bash.background = legacy.background === true;
586
+ }
351
587
  for (const leaf of ["long_running_reminder_enabled", "long_running_reminder_interval_ms"]) {
352
588
  if (Object.hasOwn(legacy, leaf))
353
589
  bash[leaf] = legacy[leaf];
354
590
  }
355
- map.bash = bash;
591
+ if (Object.keys(bash).length > 0)
592
+ map.bash = bash;
356
593
  }
357
594
  }
358
595
  else {
@@ -376,6 +613,7 @@ userBase, blockLabel, out) {
376
613
  const prefix = blockLabel === "base" ? "" : `${blockLabel}.`;
377
614
  translateGithubAliases(map, prefix, out);
378
615
  translateInspectLspPaths(map, prefix, out);
616
+ translateDataLayerPaths(map, prefix, out);
379
617
  dropNoEffectPaths(map, prefix, out);
380
618
  translateExperimentalPaths(map, prefix, out);
381
619
  if (isRecord(map.sandbox) && Object.hasOwn(map.sandbox, "enabled")) {
@@ -414,11 +652,9 @@ userBase, blockLabel, out) {
414
652
  return host;
415
653
  });
416
654
  }
655
+ // `experimental.bash` was already recorded, leaf by leaf, and removed by
656
+ // `translateExperimentalPaths`.
417
657
  for (const [path] of RETIRED_PATHS) {
418
- // A block with only reminder settings never opted into experimental
419
- // bash features. Leave it unchanged instead of adding false flags.
420
- if (path === "experimental.bash")
421
- continue;
422
658
  if (hasPath(map, path))
423
659
  recordRetired(out, prefix, path);
424
660
  }
@@ -573,6 +809,10 @@ export const NO_EFFECT_RETIRED_NOTES = [
573
809
  "inspect.max_drill_down_items",
574
810
  "inspect.max_drill_down_items never had an effect, so the setting can be removed.",
575
811
  ],
812
+ [
813
+ "inspect.tier2_idle_minutes",
814
+ "inspect.tier2_idle_minutes no longer does anything: AFT runs whole-repository analyses only when aft_inspect is called, so the setting can be removed.",
815
+ ],
576
816
  [
577
817
  "inspect.tier2_soft_deadline_ms",
578
818
  "inspect.tier2_soft_deadline_ms never had an effect, so the setting can be removed.",
@@ -580,15 +820,78 @@ export const NO_EFFECT_RETIRED_NOTES = [
580
820
  ];
581
821
  /** The no-effect sentence for a retired key, which may carry a `harnesses.<id>.` prefix. */
582
822
  function noEffectNote(key) {
583
- return NO_EFFECT_RETIRED_NOTES.find(([path]) => key === path || key.endsWith(`.${path}`))?.[1];
823
+ return NO_EFFECT_RETIRED_NOTES.find(([path]) => namesPath(key, path))?.[1];
824
+ }
825
+ /** Whether `key`, which may carry a `harnesses.<id>.` prefix, is `path`. */
826
+ function namesPath(key, path) {
827
+ return key === path || key.endsWith(`.${path}`);
828
+ }
829
+ /**
830
+ * Why each removed data-layer key no longer does anything, in
831
+ * `REMOVED_DATA_LAYER_PATHS` order. Mirrors `REMOVED_DATA_LAYER_NOTES` in
832
+ * `crates/aft/src/feature_config.rs`.
833
+ */
834
+ export const REMOVED_DATA_LAYER_NOTES = [
835
+ [
836
+ "views.callgraph_copies",
837
+ "views.callgraph_copies is gone because no checkout borrows another checkout's call graph any more.",
838
+ ],
839
+ [
840
+ "views.enabled",
841
+ "views.enabled is gone because AFT now keeps one index store per repository and always uses it.",
842
+ ],
843
+ [
844
+ "worktree.ram_overlay",
845
+ "worktree.ram_overlay is gone because every worktree now has its own index state, so there is no borrowed index to patch in memory.",
846
+ ],
847
+ ];
848
+ /**
849
+ * The sentence saying a file that still names `views.idle_checkout_hours` will
850
+ * be refused. Mirrors `retired_idle_checkout_hours_note` in
851
+ * `crates/aft/src/feature_config.rs`.
852
+ */
853
+ export function retiredIdleCheckoutHoursNote() {
854
+ const [retired, replacement] = RETIRED_IDLE_CHECKOUT_HOURS;
855
+ return `${retired} is now ${replacement}; from ${refusedFromVersion()} AFT refuses a config file that still sets ${retired}.`;
856
+ }
857
+ /**
858
+ * The once-per-identity notice for a config file, user or project, that still
859
+ * sets removed data-layer keys. Empty when it sets none. The file is never
860
+ * rewritten for them automatically; `doctor --fix` deletes them. Mirrors
861
+ * `removed_keys_notice` in `crates/aft/src/feature_config.rs`.
862
+ */
863
+ export function removedConfigKeysNoticeMessage(configPath, translation) {
864
+ const removed = translation.removedKeys ?? [];
865
+ if (removed.length === 0)
866
+ return "";
867
+ const parts = [
868
+ `${configPath} sets removed keys (${removed.join(", ")}); AFT ignores them, and from ${refusedFromVersion()} it refuses a config file that sets them. Run \`npx @cortexkit/aft doctor --fix\` to remove them.`,
869
+ ];
870
+ for (const [path, note] of REMOVED_DATA_LAYER_NOTES) {
871
+ if (removed.some((key) => namesPath(key, path)))
872
+ parts.push(note);
873
+ }
874
+ return parts.join(" ");
875
+ }
876
+ /**
877
+ * Identity of the user-file notice for removed data-layer keys: the set of
878
+ * keys it names. The rewrite of other retired keys in the same file then does
879
+ * not repeat it, and adding another removed key does.
880
+ */
881
+ export function removedConfigKeysNoticeDigest(translation) {
882
+ return noticeDigest({
883
+ notice: "removed_data_layer_keys_v1",
884
+ keys: sortedUnique(translation.removedKeys),
885
+ });
584
886
  }
585
887
  /**
586
888
  * The once-per-identity notice for a project config file whose retired keys
587
889
  * were translated in memory. The file itself is never written: it is shared
588
890
  * through its repository. Keys that no longer do anything get only their own
589
891
  * sentence, after the sentence about the keys whose current equivalents were
590
- * applied (omitted when there are none). Mirrors `project_retired_keys_notice`
591
- * in `crates/aft/src/feature_config.rs`.
892
+ * applied (omitted when there are none); removed data-layer keys the file
893
+ * sets follow with their own notice. Mirrors `project_retired_keys_notice` in
894
+ * `crates/aft/src/feature_config.rs`.
592
895
  */
593
896
  export function legacyConfigNoticeMessage(configPath, translation) {
594
897
  const equivalents = translation.retiredKeys.filter((key) => noEffectNote(key) === undefined);
@@ -598,18 +901,26 @@ export function legacyConfigNoticeMessage(configPath, translation) {
598
901
  parts.push(`${configPath} uses retired keys (${equivalents.join(", ")}); AFT applied their current equivalents, with the same limits a project config has for those keys. Run \`npx @cortexkit/aft doctor --fix\` to update the file.`);
599
902
  if (translation.retiredEnabledFalse)
600
903
  parts.push(RETIRED_ENABLED_FALSE_INDEXES_NOTE);
904
+ if (equivalents.some((key) => namesPath(key, RETIRED_IDLE_CHECKOUT_HOURS[0]))) {
905
+ parts.push(retiredIdleCheckoutHoursNote());
906
+ }
601
907
  }
602
908
  for (const [, note] of NO_EFFECT_RETIRED_NOTES) {
603
909
  if (noEffect.has(note))
604
910
  parts.push(note);
605
911
  }
912
+ const removed = removedConfigKeysNoticeMessage(configPath, translation);
913
+ if (removed)
914
+ parts.push(removed);
606
915
  return parts.join(" ");
607
916
  }
608
917
  /**
609
918
  * Translate every block (base plus each `harnesses.<id>`) in place. Retired
610
- * keys are never refused: their current equivalents then pass through the
919
+ * keys are not refused here: their current equivalents then pass through the
611
920
  * normal user/project trust rules, exactly as if the file had spelled them
612
- * out.
921
+ * out. (`removedConfigKeyErrors` refuses the names retired in 0.62 or earlier
922
+ * and the data-layer keys from `DATA_LAYER_KEYS_REFUSED_FROM`, before
923
+ * translation runs; `doctor --fix` still uses this translation for them.)
613
924
  *
614
925
  * Only the user file's base block receives the absent-base default
615
926
  * (`aft_move`/`aft_delete`) when its legacy keys generate disables: that
@@ -622,6 +933,7 @@ export function translateConfigDocument(map, tier) {
622
933
  warnings: [],
623
934
  legacyInput: false,
624
935
  retiredKeys: [],
936
+ removedKeys: [],
625
937
  retiredEnabledFalse: false,
626
938
  };
627
939
  translateBlock(map, tier === "user", "base", out);
@@ -632,6 +944,7 @@ export function translateConfigDocument(map, tier) {
632
944
  }
633
945
  }
634
946
  out.retiredKeys = sortedUnique(out.retiredKeys);
947
+ out.removedKeys = sortedUnique(out.removedKeys);
635
948
  return out;
636
949
  }
637
950
  /** Distinct unknown names in a resolved disabled list, sorted. */
@@ -675,8 +988,13 @@ function projectBlock(block) {
675
988
  const legacyPaths = {};
676
989
  // Retired keys with no replacement are projected too: otherwise adding one
677
990
  // to a file whose notice was already acknowledged would keep the old digest
678
- // and the saved record would hide the new notice.
679
- for (const path of [...RETIRED_PATHS.map(([retired]) => retired), ...REMOVED_NO_EFFECT_PATHS]) {
991
+ // and the saved record would hide the new notice. The data-layer keys are
992
+ // projected for the same reason.
993
+ for (const path of [
994
+ ...RETIRED_PATHS.map(([retired]) => retired),
995
+ ...REMOVED_NO_EFFECT_PATHS,
996
+ ...DATA_LAYER_RETIRED_PATHS,
997
+ ]) {
680
998
  const dot = path.indexOf(".");
681
999
  if (dot < 0) {
682
1000
  if (Object.hasOwn(block, path))