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
@@ -1,13 +1,39 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
- import { createHash } from "node:crypto";
4
+ /**
5
+ * How a native scheduler row invokes akm.
6
+ *
7
+ * A row is `<launcher…> task run <id> --bundle <bundle> --scheduled
8
+ * [--<input> <value>…]`, or `<launcher…> workflow run <qualified-ref>`, and it
9
+ * carries its own context: the environment the scheduled process needs,
10
+ * set inline by the row itself (a cron `VAR=value` prefix, a launchd
11
+ * `EnvironmentVariables` entry, a PowerShell `$env:` assignment).
12
+ *
13
+ * - `AKM_BUNDLE_DIR`: the working stash of the process that ran
14
+ * `akm task sync`, on every row. The scheduled run uses the same working
15
+ * stash, `--bundle <name>` finds a stash no config names, and sync
16
+ * attributes the row to the installation that wrote it (#846).
17
+ * - `AKM_CONFIG_DIR`, `AKM_DATA_DIR`, `AKM_CACHE_DIR` and `AKM_STATE_DIR`
18
+ * only when the process that ran `akm task sync` set them explicitly. A
19
+ * default resolves at fire time exactly as it does for an interactive
20
+ * command.
21
+ *
22
+ * That is exactly what the `--scheduler-context` descriptor held.
23
+ *
24
+ * `PATH` is the scheduler's own: the crontab's `# akm:env` block, the plist's
25
+ * `EnvironmentVariables`.
26
+ *
27
+ * Rows written by 0.9.0 through 0.9.17-alpha.6 name a
28
+ * `--scheduler-context <descriptor>` file instead. The CLI still accepts that
29
+ * argument and applies the file's environment, so such a row keeps firing
30
+ * until the next `akm task sync` rewrites it.
31
+ */
5
32
  import fs from "node:fs";
6
33
  import path from "node:path";
7
34
  import { bundleRefToString, parseBundleRef } from "../core/asset/asset-ref.js";
8
35
  import { resolveStashDir } from "../core/common.js";
9
36
  import { ConfigError } from "../core/errors.js";
10
- import { getTaskContextDir } from "../core/paths.js";
11
37
  import { INPUT_NAME_PATTERN } from "../execution/input-contract.js";
12
38
  import { normaliseTaskConceptId } from "./task-id.js";
13
39
  export const SCHEDULED_TASK_CONTEXT_KEYS = [
@@ -18,20 +44,18 @@ export const SCHEDULED_TASK_CONTEXT_KEYS = [
18
44
  "AKM_STATE_DIR",
19
45
  ];
20
46
  /**
21
- * Directories a descriptor carries ONLY when the process that ran `task sync`
22
- * had them set explicitly in its environment. They are never resolved from
23
- * defaults: a descriptor written from inside an app whose environment pointed
24
- * `$STATE` somewhere private (an OpenCode desktop session, 2026-08 → 2026-09)
25
- * froze that directory into eight cron rows, and the scheduled improve runs
26
- * then held their locks in a `locks/` directory no interactive command could
27
- * see. At fire time an absent key resolves exactly as it does for an
28
- * interactive command on the same host.
47
+ * Directories a row carries ONLY when the process that ran `task sync` had
48
+ * them set explicitly in its environment. They are never resolved from
49
+ * defaults: a sync run from inside an app whose environment pointed `$STATE`
50
+ * somewhere private (an OpenCode desktop session, 2026-08 → 2026-09) froze
51
+ * that directory into eight cron rows, and the scheduled improve runs then
52
+ * held their locks in a `locks/` directory no interactive command could see.
29
53
  */
30
54
  const EXPLICIT_CONTEXT_KEYS = ["AKM_CONFIG_DIR", "AKM_DATA_DIR", "AKM_CACHE_DIR", "AKM_STATE_DIR"];
31
55
  /**
32
- * The AKM directory context currently restored from a scheduler descriptor.
33
- * Child environments that are built from an allowlist must forward this
34
- * closed set explicitly so nested AKM commands stay in the same installation.
56
+ * The AKM directory context of the current process. Child environments that
57
+ * are built from an allowlist must forward this closed set explicitly so
58
+ * nested AKM commands stay in the same installation.
35
59
  */
36
60
  export function scheduledTaskContextEnv(env = process.env) {
37
61
  const out = {};
@@ -42,141 +66,112 @@ export function scheduledTaskContextEnv(env = process.env) {
42
66
  }
43
67
  return out;
44
68
  }
45
- export const SCHEDULER_CONTEXT_ARG = "--scheduler-context";
46
69
  /**
47
- * The directory context a scheduler descriptor carries: the resolved bundle
48
- * path, plus whichever `AKM_*_DIR` overrides the syncing process had set
49
- * explicitly. Resolved defaults are deliberately not captured — see
50
- * {@link EXPLICIT_CONTEXT_KEYS}.
70
+ * The environment every row a sync writes sets inline: `AKM_BUNDLE_DIR`,
71
+ * the syncing process's working stash, plus whichever `AKM_*_DIR` overrides
72
+ * it set explicitly. With no working stash to resolve (no default bundle, no
73
+ * `AKM_BUNDLE_DIR`), rows name their bundle by `--bundle` alone.
51
74
  */
52
- export function resolveScheduledTaskContext(env = process.env) {
53
- const context = { AKM_BUNDLE_DIR: path.resolve(resolveStashDir(env)) };
75
+ export function scheduledRowEnvironment(env = process.env) {
76
+ const out = {};
77
+ try {
78
+ out.AKM_BUNDLE_DIR = path.resolve(resolveStashDir(env));
79
+ }
80
+ catch {
81
+ // No working stash: there is no primary bundle to attribute rows to either.
82
+ }
54
83
  for (const key of EXPLICIT_CONTEXT_KEYS) {
55
84
  const value = env[key]?.trim();
56
85
  if (value)
57
- context[key] = path.resolve(value);
86
+ out[key] = path.resolve(value);
58
87
  }
59
- return canonicalContext(context);
60
- }
61
- /** Build an installed argv from one already-validated public scheduler tail. */
62
- export function buildScheduledBindingInvocation(akmArgv, contextPath, invocation) {
63
- const parsed = parsePublicSchedulerInvocation(invocation);
64
- if (!parsed)
65
- throw invalidSchedulerInvocation();
66
- return {
67
- argv: [...akmArgv, SCHEDULER_CONTEXT_ARG, assertAbsolutePath(contextPath), ...parsed.invocation],
68
- };
69
- }
70
- export function schedulerContextDescriptor(context = resolveScheduledTaskContext()) {
71
- return { version: 1, environment: canonicalContext(context) };
72
- }
73
- export function schedulerContextPath(descriptor) {
74
- const bytes = serializeDescriptor(descriptor);
75
- const digest = createHash("sha256").update(bytes).digest("hex");
76
- // The descriptor lives under the data directory it names when it names
77
- // one, otherwise under this process's own.
78
- return path.join(getTaskContextDir({ ...process.env, ...descriptor.environment }), `${digest}.json`);
88
+ return out;
79
89
  }
80
- /** Write a content-addressed descriptor without ever replacing existing content. */
81
- export function writeSchedulerContextDescriptor(descriptor = schedulerContextDescriptor()) {
82
- const file = schedulerContextPath(descriptor);
83
- const bytes = serializeDescriptor(descriptor);
84
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
85
- if (process.platform !== "win32") {
86
- fs.chmodSync(path.dirname(file), 0o700);
87
- }
88
- if (lstatIfExists(file)) {
89
- if (serializeDescriptor(validateSchedulerContextDescriptor(file)) !== bytes)
90
- throw invalidSchedulerContext();
91
- return file;
92
- }
93
- const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
94
- try {
95
- fs.writeFileSync(temp, bytes, { encoding: "utf8", mode: 0o600, flag: "wx" });
96
- try {
97
- fs.renameSync(temp, file);
98
- }
99
- catch (error) {
100
- if (!lstatIfExists(file) || serializeDescriptor(validateSchedulerContextDescriptor(file)) !== bytes)
101
- throw error;
102
- }
103
- restrictDescriptor(file);
104
- validateSchedulerContextDescriptor(file);
105
- }
106
- finally {
107
- fs.rmSync(temp, { force: true });
90
+ /** A row environment's entries, in {@link SCHEDULED_TASK_CONTEXT_KEYS} order. */
91
+ export function scheduledRowEnvironmentEntries(environment) {
92
+ const entries = [];
93
+ for (const key of SCHEDULED_TASK_CONTEXT_KEYS) {
94
+ const value = environment?.[key];
95
+ if (value)
96
+ entries.push([key, value]);
108
97
  }
109
- return file;
98
+ return entries;
110
99
  }
111
- export function loadSchedulerContextDescriptor(file, env = process.env) {
112
- const descriptor = validateSchedulerContextDescriptor(file);
100
+ /** Keep the {@link SCHEDULED_TASK_CONTEXT_KEYS} a parsed row set; `undefined` when there are none. */
101
+ export function scheduledRowEnvironmentFrom(variables) {
102
+ const out = {};
113
103
  for (const key of SCHEDULED_TASK_CONTEXT_KEYS) {
114
- const value = descriptor.environment[key];
115
- if (value !== undefined)
116
- env[key] = value;
104
+ const value = variables[key];
105
+ if (value)
106
+ out[key] = value;
117
107
  }
118
- if (descriptor.environment.PATH !== undefined)
119
- env.PATH = descriptor.environment.PATH;
108
+ return Object.keys(out).length > 0 ? out : undefined;
120
109
  }
121
- export function validateSchedulerContextDescriptor(file) {
122
- const absolute = assertAbsolutePath(file);
123
- let linkStat;
124
- try {
125
- linkStat = fs.lstatSync(absolute);
126
- }
127
- catch (error) {
128
- throw schedulerContextFileError(absolute, error instanceof Error ? error.message : String(error));
110
+ const SCHEDULER_CONTEXT_ARG = "--scheduler-context";
111
+ /** The installed argv: the launcher, then one already-validated public scheduler tail. */
112
+ export function buildScheduledInvocation(akmArgv, invocation) {
113
+ const parsed = parsePublicSchedulerInvocation(invocation);
114
+ if (!parsed)
115
+ throw invalidSchedulerInvocation();
116
+ return [...akmArgv, ...parsed.invocation];
117
+ }
118
+ /**
119
+ * Parse an installed row's argv (after any inline environment): the current
120
+ * shape, the `--scheduler-context <descriptor>` shape 0.9.0 – 0.9.17-alpha.6
121
+ * wrote, or the bare shape before that. A row that carries
122
+ * `--scheduler-context` but does not parse around it is never reread as
123
+ * another shape.
124
+ */
125
+ export function parseScheduledInvocationArgv(argv) {
126
+ const contextIndex = argv.indexOf(SCHEDULER_CONTEXT_ARG);
127
+ if (contextIndex !== -1) {
128
+ if (contextIndex < 1 || argv.indexOf(SCHEDULER_CONTEXT_ARG, contextIndex + 1) !== -1)
129
+ return undefined;
130
+ const contextPath = argv[contextIndex + 1];
131
+ if (!contextPath)
132
+ return undefined;
133
+ const publicInvocation = parsePublicSchedulerInvocation(argv.slice(contextIndex + 2));
134
+ if (!publicInvocation)
135
+ return undefined;
136
+ return { binding: argv.slice(0, contextIndex), contextPath, ...publicInvocation };
129
137
  }
130
- if (linkStat.isSymbolicLink())
131
- throw schedulerContextFileError(absolute, "symbolic links are not allowed");
132
- if (!linkStat.isFile())
133
- throw schedulerContextFileError(absolute, "path is not a regular file");
134
- let descriptorBytes;
135
- let fd;
136
- try {
137
- const noFollow = process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW;
138
- fd = fs.openSync(absolute, fs.constants.O_RDONLY | noFollow);
139
- const stat = fs.fstatSync(fd);
140
- if (!stat.isFile())
141
- throw schedulerContextFileError(absolute, "path is not a regular file");
142
- if (process.platform !== "win32") {
143
- if (typeof process.getuid !== "function") {
144
- throw schedulerContextFileError(absolute, "current uid is unavailable for ownership verification");
145
- }
146
- const uid = process.getuid();
147
- if (stat.uid !== uid)
148
- throw schedulerContextFileError(absolute, `file owner ${stat.uid} does not match uid ${uid}`);
149
- if ((stat.mode & 0o077) !== 0) {
150
- throw schedulerContextFileError(absolute, "group or other permissions must be disabled");
151
- }
138
+ for (let index = 1; index < argv.length - 1; index += 1) {
139
+ if ((argv[index] === "task" || argv[index] === "workflow") && argv[index + 1] === "run") {
140
+ const publicInvocation = parsePublicSchedulerInvocation(argv.slice(index));
141
+ return publicInvocation ? { binding: argv.slice(0, index), ...publicInvocation } : undefined;
152
142
  }
153
- descriptorBytes = fs.readFileSync(fd);
154
- }
155
- catch (error) {
156
- if (error instanceof ConfigError)
157
- throw error;
158
- throw schedulerContextFileError(absolute, error instanceof Error ? error.message : String(error));
159
- }
160
- finally {
161
- if (fd !== undefined)
162
- fs.closeSync(fd);
163
- }
164
- const filename = path.basename(absolute);
165
- const match = /^([a-f0-9]{64})\.json$/.exec(filename);
166
- const digest = createHash("sha256").update(descriptorBytes).digest("hex");
167
- if (!match || match[1] !== digest) {
168
- throw schedulerContextFileError(absolute, "content SHA-256 does not match the descriptor filename");
169
143
  }
144
+ return undefined;
145
+ }
146
+ /**
147
+ * The environment a `--scheduler-context` descriptor names: its `AKM_*_DIR`
148
+ * keys and, in one written before 0.9.17, `PATH`. It is read as plain JSON —
149
+ * the file is this user's own, next to the data it names.
150
+ */
151
+ export function readLegacySchedulerContext(file) {
170
152
  let parsed;
171
153
  try {
172
- parsed = JSON.parse(descriptorBytes.toString("utf8"));
154
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
173
155
  }
174
156
  catch (error) {
175
- throw schedulerContextFileError(absolute, error instanceof Error ? error.message : String(error));
157
+ throw new ConfigError(`Cannot read scheduler context "${file}": ${error instanceof Error ? error.message : String(error)}. ` +
158
+ "Run `akm task sync` to rewrite this scheduled row.", "INVALID_CONFIG_FILE");
159
+ }
160
+ const environment = parsed !== null && typeof parsed === "object" ? parsed.environment : undefined;
161
+ const out = {};
162
+ if (environment === null || typeof environment !== "object")
163
+ return out;
164
+ for (const key of [...SCHEDULED_TASK_CONTEXT_KEYS, "PATH"]) {
165
+ const value = environment[key];
166
+ if (typeof value === "string" && value.length > 0)
167
+ out[key] = value;
176
168
  }
177
- return canonicalDescriptor(parsed);
169
+ return out;
178
170
  }
179
- /** Load and remove the hidden descriptor argument before citty parses argv. */
171
+ /**
172
+ * Apply and remove a row's `--scheduler-context <descriptor>` before citty
173
+ * parses argv. Only a row written by 0.9.0 – 0.9.17-alpha.6 passes it.
174
+ */
180
175
  export function consumeSchedulerContextArg(argv, env = process.env) {
181
176
  const separator = argv.indexOf("--");
182
177
  const index = argv.slice(0, separator === -1 ? argv.length : separator).indexOf(SCHEDULER_CONTEXT_ARG);
@@ -184,37 +179,16 @@ export function consumeSchedulerContextArg(argv, env = process.env) {
184
179
  return argv;
185
180
  const file = argv[index + 1];
186
181
  if (!file) {
187
- throw new ConfigError(`${SCHEDULER_CONTEXT_ARG} requires an absolute descriptor path.`, "INVALID_CONFIG_FILE");
182
+ throw new ConfigError(`${SCHEDULER_CONTEXT_ARG} requires a descriptor path.`, "INVALID_CONFIG_FILE");
188
183
  }
189
- loadSchedulerContextDescriptor(file, env);
184
+ Object.assign(env, readLegacySchedulerContext(file));
190
185
  return [...argv.slice(0, index), ...argv.slice(index + 2)];
191
186
  }
192
- /** Parse either the public scheduled task or qualified workflow invocation. */
193
- export function parseScheduledBindingArgv(argv) {
194
- const contextIndex = argv.indexOf(SCHEDULER_CONTEXT_ARG);
195
- if (contextIndex < 1 || argv.indexOf(SCHEDULER_CONTEXT_ARG, contextIndex + 1) !== -1)
196
- return undefined;
197
- const contextPath = argv[contextIndex + 1];
198
- if (!contextPath)
199
- return undefined;
200
- const publicInvocation = parsePublicSchedulerInvocation(argv.slice(contextIndex + 2));
201
- if (!publicInvocation)
202
- return undefined;
203
- return {
204
- binding: [...argv.slice(0, contextIndex)],
205
- contextPath: assertAbsolutePath(contextPath),
206
- invocation: publicInvocation.invocation,
207
- ...(publicInvocation.target !== undefined ? { target: publicInvocation.target } : {}),
208
- };
209
- }
210
187
  /**
211
- * Parse just the public `task run …` / `workflow run …` tail, with no
212
- * `--scheduler-context` wrapper. Exported so a backend can recognize a
213
- * pre-`--scheduler-context` invocation still sitting inside akm's own
214
- * ownership-marked block (see `extractCronInvocation` in
215
- * `src/tasks/backends/cron.ts`) without re-implementing this grammar.
188
+ * Parse just the public `task run …` / `workflow run …` tail, the part of a
189
+ * row that names what it runs.
216
190
  */
217
- export function parsePublicSchedulerInvocation(invocation) {
191
+ function parsePublicSchedulerInvocation(invocation) {
218
192
  if (invocation[0] === "task" && invocation[1] === "run" && invocation[2]) {
219
193
  try {
220
194
  if (normaliseTaskConceptId(invocation[2]) !== invocation[2])
@@ -308,88 +282,6 @@ function isValidSchedulerInputFlagTail(tail) {
308
282
  }
309
283
  return true;
310
284
  }
311
- function canonicalContext(input) {
312
- if (Object.keys(input).some((key) => !SCHEDULED_TASK_CONTEXT_KEYS.includes(key))) {
313
- throw invalidSchedulerContext();
314
- }
315
- const context = {};
316
- for (const key of SCHEDULED_TASK_CONTEXT_KEYS) {
317
- const value = input[key];
318
- if (value === undefined) {
319
- if (key === "AKM_BUNDLE_DIR")
320
- throw invalidSchedulerContext();
321
- continue;
322
- }
323
- if (typeof value !== "string" ||
324
- value.trim().length === 0 ||
325
- containsControlCharacter(value) ||
326
- (!path.posix.isAbsolute(value) && !path.win32.isAbsolute(value))) {
327
- throw invalidSchedulerContext();
328
- }
329
- context[key] = value;
330
- }
331
- return context;
332
- }
333
- function canonicalDescriptor(input) {
334
- if (typeof input !== "object" || input === null || Array.isArray(input))
335
- throw invalidSchedulerContext();
336
- const record = input;
337
- if (Object.keys(record).length !== 2 || record.version !== 1)
338
- throw invalidSchedulerContext();
339
- const rawEnvironment = record.environment;
340
- if (typeof rawEnvironment !== "object" || rawEnvironment === null || Array.isArray(rawEnvironment)) {
341
- throw invalidSchedulerContext();
342
- }
343
- const { PATH, ...rawContext } = rawEnvironment;
344
- const context = canonicalContext(rawContext);
345
- return {
346
- version: 1,
347
- environment: PATH === undefined ? context : { ...context, PATH: validatePathValue(PATH) },
348
- };
349
- }
350
- function serializeDescriptor(descriptor) {
351
- return `${JSON.stringify(canonicalDescriptor(descriptor))}\n`;
352
- }
353
- function validatePathValue(value) {
354
- if (typeof value !== "string" || containsControlCharacter(value))
355
- throw invalidSchedulerContext();
356
- return value;
357
- }
358
- function assertAbsolutePath(value) {
359
- if (containsControlCharacter(value) || (!path.posix.isAbsolute(value) && !path.win32.isAbsolute(value))) {
360
- throw invalidSchedulerContext();
361
- }
362
- return value;
363
- }
364
- function restrictDescriptor(file) {
365
- if (process.platform !== "win32")
366
- fs.chmodSync(file, 0o600);
367
- }
368
- function lstatIfExists(file) {
369
- try {
370
- return fs.lstatSync(file);
371
- }
372
- catch (error) {
373
- if (error.code === "ENOENT")
374
- return undefined;
375
- throw error;
376
- }
377
- }
378
- function schedulerContextFileError(file, reason) {
379
- return new ConfigError(`Invalid scheduler context descriptor "${file}": ${reason}.`, "INVALID_CONFIG_FILE");
380
- }
381
- function containsControlCharacter(value) {
382
- for (const char of value) {
383
- const code = char.codePointAt(0) ?? 0;
384
- if (code < 0x20 || (code >= 0x7f && code <= 0x9f))
385
- return true;
386
- }
387
- return false;
388
- }
389
- function invalidSchedulerContext() {
390
- return new ConfigError("Invalid scheduler context; expected AKM_BUNDLE_DIR as an absolute path, " +
391
- `optionally with ${EXPLICIT_CONTEXT_KEYS.join(", ")} as absolute paths.`, "INVALID_CONFIG_FILE");
392
- }
393
285
  function invalidSchedulerInvocation() {
394
286
  return new ConfigError("Invalid scheduler invocation; expected public " +
395
287
  "`task run <id> [--bundle <bundle>] --scheduled [--<input> <value>…]` or `workflow run <qualified-ref>` argv.", "INVALID_CONFIG_FILE");
@@ -263,9 +263,10 @@ export function scheduledInvocationBundle(invocation) {
263
263
  /**
264
264
  * The scope that owns an installed row, or none. A row written before
265
265
  * `--bundle` existed names no bundle and belongs to the primary scope
266
- * unless its descriptor says otherwise; a row that names a bundle belongs
267
- * to the scope of that name — for the primary scope only when its
268
- * descriptor also proves the path.
266
+ * unless the bundle path it carries says otherwise; a row that names a
267
+ * bundle belongs to the scope of that name — for the primary scope only
268
+ * when its `AKM_BUNDLE_DIR` (inline, or in an older row's descriptor) also
269
+ * proves the path.
269
270
  */
270
271
  export function installedRowScope(row, scopes) {
271
272
  const bundle = row.target ?? scheduledInvocationBundle(row.invocation);
@@ -393,12 +394,13 @@ export function planSchedulerSync(input) {
393
394
  failures: Object.freeze(failures),
394
395
  });
395
396
  }
396
- /** An installed row keeps its own launcher unless `rebind`; the descriptor always follows the current policy. */
397
+ /** An installed row keeps its own launcher unless `rebind`; its environment always follows the current policy. */
397
398
  function installOptionsFor(input, current) {
398
399
  if (current && !input.rebind) {
400
+ const environment = input.installOptions?.environment ?? current.environment;
399
401
  return Object.freeze({
400
402
  binding: Object.freeze([...current.binding]),
401
- contextPath: input.installOptions?.contextPath ?? current.contextPath,
403
+ ...(environment !== undefined ? { environment } : {}),
402
404
  });
403
405
  }
404
406
  return input.installOptions ? Object.freeze({ ...input.installOptions }) : undefined;
@@ -417,9 +419,12 @@ function installedRowRef(row, scope) {
417
419
  /** Where an installed row was scheduled from, for messages. */
418
420
  export function installedRowOwner(row) {
419
421
  const bundle = row.target ?? scheduledInvocationBundle(row.invocation);
420
- if (row.ownerBundlePath)
421
- return `the bundle at ${JSON.stringify(row.ownerBundlePath)}`;
422
- return bundle ? `bundle ${JSON.stringify(bundle)}` : "an installation this sync cannot attribute";
422
+ const installation = row.ownerBundlePath
423
+ ? `the installation whose working stash is ${JSON.stringify(row.ownerBundlePath)}`
424
+ : undefined;
425
+ if (bundle)
426
+ return installation ? `bundle ${JSON.stringify(bundle)} of ${installation}` : `bundle ${JSON.stringify(bundle)}`;
427
+ return installation ?? "an installation this sync cannot attribute";
423
428
  }
424
429
  function foreignRowMessage(nativeId, row) {
425
430
  return `Scheduler id ${JSON.stringify(nativeId)} is already scheduled from ${installedRowOwner(row)}; desired source ids must not collide across bundles. Leaving it out of this sync.`;