akm-cli 0.9.0-rc.3 → 0.9.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +12 -4
  2. package/README.md +7 -8
  3. package/SECURITY.md +5 -3
  4. package/dist/akm +44 -33
  5. package/dist/akm-migrate-storage +44 -33
  6. package/dist/assets/backends/schtasks-template.xml +2 -1
  7. package/dist/assets/help/help-improve.md +3 -0
  8. package/dist/cli.js +25 -10
  9. package/dist/commands/health/html-report.js +23 -2
  10. package/dist/commands/health/improve-metrics.js +45 -6
  11. package/dist/commands/health/md-report.js +10 -1
  12. package/dist/commands/health/windows.js +1 -1
  13. package/dist/commands/health.js +1 -1
  14. package/dist/commands/improve/distill/promote-memory.js +6 -2
  15. package/dist/commands/improve/distill/quality-gate.js +94 -31
  16. package/dist/commands/improve/distill.js +7 -2
  17. package/dist/commands/improve/extract.js +5 -2
  18. package/dist/commands/improve/improve-auto-accept.js +2 -2
  19. package/dist/commands/improve/improve.js +158 -176
  20. package/dist/commands/improve/locks.js +22 -94
  21. package/dist/commands/improve/loop-stages.js +15 -10
  22. package/dist/commands/improve/preparation.js +21 -17
  23. package/dist/commands/improve/proactive-maintenance.js +4 -6
  24. package/dist/commands/improve/reflect.js +40 -67
  25. package/dist/commands/tasks/default-tasks.js +5 -5
  26. package/dist/commands/tasks/tasks-cli.js +7 -3
  27. package/dist/commands/tasks/tasks.js +261 -68
  28. package/dist/core/extra-params.js +1 -0
  29. package/dist/core/improve-result.js +87 -7
  30. package/dist/core/maintenance-barrier.js +16 -0
  31. package/dist/core/state-db.js +1 -0
  32. package/dist/core/write-source.js +1 -0
  33. package/dist/indexer/ensure-index.js +6 -4
  34. package/dist/llm/client.js +7 -5
  35. package/dist/schemas/akm-task.json +2 -2
  36. package/dist/schemas/akm-workflow.json +2 -2
  37. package/dist/scripts/migrate-storage.js +23 -4
  38. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +91 -9
  39. package/dist/storage/repositories/task-history-repository.js +30 -3
  40. package/dist/tasks/backends/cron.js +71 -38
  41. package/dist/tasks/backends/exec-utils.js +55 -3
  42. package/dist/tasks/backends/launchd.js +241 -50
  43. package/dist/tasks/backends/schtasks.js +434 -59
  44. package/dist/tasks/command-executable.js +93 -0
  45. package/dist/tasks/parser.js +142 -6
  46. package/dist/tasks/resolve-akm-bin.js +19 -4
  47. package/dist/tasks/runner.js +258 -93
  48. package/dist/tasks/schedule.js +108 -19
  49. package/dist/tasks/scheduler-invocation.js +86 -0
  50. package/dist/tasks/task-id.js +37 -0
  51. package/docs/migration/release-notes/0.9.0.md +4 -2
  52. package/docs/migration/v0.8-to-v0.9.md +64 -9
  53. package/package.json +2 -3
  54. package/schemas/akm-task.json +2 -2
  55. package/schemas/akm-workflow.json +2 -2
@@ -29,6 +29,7 @@ import { ConfigError } from "../../core/errors.js";
29
29
  import { getTaskLogDir } from "../../core/paths.js";
30
30
  import { resolveAkmInvocation } from "../resolve-akm-bin.js";
31
31
  import { parseSchedule, translateToLaunchd } from "../schedule.js";
32
+ import { buildScheduledTaskInvocation, resolveScheduledTaskContext, } from "../scheduler-invocation.js";
32
33
  import { escapeXml, nodeExec, nodeFs } from "./exec-utils.js";
33
34
  export const LAUNCHD_LABEL_PREFIX = "com.akm.task.";
34
35
  export function LAUNCHD_BACKEND(options = {}) {
@@ -37,57 +38,157 @@ export function LAUNCHD_BACKEND(options = {}) {
37
38
  const agentsDir = options.agentsDir ?? defaultAgentsDir();
38
39
  const logDir = options.logDir ?? getTaskLogDir();
39
40
  const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
41
+ const scheduledContext = options.scheduledContext ?? resolveScheduledTaskContext();
40
42
  const plistPath = (id) => path.join(agentsDir, `${LAUNCHD_LABEL_PREFIX}${id}.plist`);
41
43
  const label = (id) => `${LAUNCHD_LABEL_PREFIX}${id}`;
42
44
  const target = (id) => `gui/${exec.uid()}/${label(id)}`;
45
+ const pathEnv = () => {
46
+ if (options.envPath === false)
47
+ return undefined;
48
+ if (typeof options.envPath === "string")
49
+ return options.envPath;
50
+ return process.env.PATH ?? "";
51
+ };
52
+ const setEnableState = (id, enabled) => {
53
+ const verb = enabled ? "enable" : "disable";
54
+ const result = exec.run(["launchctl", verb, target(id)]);
55
+ if (result.status !== 0) {
56
+ throw new ConfigError(`launchctl ${verb} failed: ${result.stderr || result.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
57
+ }
58
+ };
43
59
  return {
44
60
  name: "launchd",
45
61
  install(task) {
46
62
  // Capture PATH at install time so launchd (which strips the environment
47
63
  // aggressively) can find the same binaries the user sees interactively.
48
- let pathEnv;
49
- if (options.envPath === false) {
50
- pathEnv = undefined;
51
- }
52
- else if (typeof options.envPath === "string") {
53
- pathEnv = options.envPath;
54
- }
55
- else {
56
- pathEnv = process.env.PATH ?? "";
64
+ const xml = buildPlistXml(task, akmArgv, logDir, pathEnv(), scheduledContext);
65
+ const file = plistPath(task.id);
66
+ const previousPlist = fsLike.exists(file) ? fsLike.readFile(file) : undefined;
67
+ let previousEnabled = true;
68
+ if (previousPlist !== undefined) {
69
+ const disabledLabels = readDisabledLabels(exec);
70
+ if (disabledLabels === undefined) {
71
+ throw new ConfigError(`launchctl print-disabled failed; cannot safely replace existing task "${task.id}".`, "INVALID_CONFIG_FILE");
72
+ }
73
+ previousEnabled = !disabledLabels.has(label(task.id));
57
74
  }
58
- const xml = buildPlistXml(task, akmArgv, logDir, pathEnv);
59
75
  fsLike.ensureDir(agentsDir);
60
76
  // launchd refuses to start a job when StandardOutPath/StandardErrorPath
61
77
  // points at a non-existent directory; create it before bootstrap.
62
78
  fsLike.ensureDir(logDir);
63
- fsLike.writeFile(plistPath(task.id), xml);
64
- const bootout = exec.run(["launchctl", "bootout", target(task.id)]);
65
- // bootout returning non-zero is fine — agent might not be loaded.
66
- void bootout;
67
- const bootstrap = exec.run(["launchctl", "bootstrap", `gui/${exec.uid()}`, plistPath(task.id)]);
68
- if (bootstrap.status !== 0) {
69
- throw new ConfigError(`launchctl bootstrap failed (exit ${bootstrap.status}): ${bootstrap.stderr || bootstrap.stdout || "no output"}.`, "INVALID_CONFIG_FILE", "Ensure `launchctl` is available; on macOS it is part of the base system.");
79
+ const tempFile = path.join(agentsDir, `.${task.id}.${Date.now()}.tmp`);
80
+ fsLike.writeFile(tempFile, xml);
81
+ let bootoutCompleted = false;
82
+ let previousWasLoaded = false;
83
+ let fileReplaced = false;
84
+ let enableStateTouched = false;
85
+ try {
86
+ const bootout = exec.run(["launchctl", "bootout", target(task.id)]);
87
+ if (bootout.status !== 0 && !isServiceNotFoundResult(bootout)) {
88
+ throw new ConfigError(`launchctl bootout failed (exit ${bootout.status}): ${bootout.stderr || bootout.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
89
+ }
90
+ bootoutCompleted = true;
91
+ previousWasLoaded = previousPlist !== undefined && bootout.status === 0;
92
+ fsLike.replaceFile(tempFile, file);
93
+ fileReplaced = true;
94
+ // A disable override survives bootout and plist replacement. Clear it
95
+ // before bootstrap, then apply the desired state after registration.
96
+ enableStateTouched = true;
97
+ setEnableState(task.id, true);
98
+ const bootstrap = exec.run(["launchctl", "bootstrap", `gui/${exec.uid()}`, file]);
99
+ if (bootstrap.status !== 0) {
100
+ throw new ConfigError(`launchctl bootstrap failed (exit ${bootstrap.status}): ${bootstrap.stderr || bootstrap.stdout || "no output"}.`, "INVALID_CONFIG_FILE", "Ensure `launchctl` is available; on macOS it is part of the base system.");
101
+ }
102
+ if (!task.enabled) {
103
+ setEnableState(task.id, false);
104
+ }
70
105
  }
71
- if (!task.enabled) {
72
- const disable = exec.run(["launchctl", "disable", target(task.id)]);
73
- if (disable.status !== 0) {
74
- throw new ConfigError(`launchctl disable failed: ${disable.stderr || disable.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
106
+ catch (err) {
107
+ if (!bootoutCompleted)
108
+ throw err;
109
+ const rollbackErrors = [];
110
+ let priorFileRestored = !fileReplaced;
111
+ if (fileReplaced) {
112
+ let replacementUnloaded = false;
113
+ try {
114
+ const rollbackBootout = exec.run(["launchctl", "bootout", target(task.id)]);
115
+ replacementUnloaded = rollbackBootout.status === 0 || isServiceNotFoundResult(rollbackBootout);
116
+ if (!replacementUnloaded) {
117
+ rollbackErrors.push(new ConfigError(`launchctl bootout during rollback failed: ${rollbackBootout.stderr || rollbackBootout.stdout || "no output"}.`, "INVALID_CONFIG_FILE"));
118
+ }
119
+ }
120
+ catch (rollbackError) {
121
+ rollbackErrors.push(rollbackError);
122
+ }
123
+ if (replacementUnloaded) {
124
+ try {
125
+ if (previousPlist === undefined) {
126
+ if (fsLike.exists(file))
127
+ fsLike.removeFile(file);
128
+ }
129
+ else {
130
+ fsLike.writeFile(tempFile, previousPlist);
131
+ fsLike.replaceFile(tempFile, file);
132
+ }
133
+ priorFileRestored = true;
134
+ }
135
+ catch (rollbackError) {
136
+ rollbackErrors.push(rollbackError);
137
+ }
138
+ }
75
139
  }
140
+ if (previousPlist !== undefined && previousWasLoaded && priorFileRestored) {
141
+ try {
142
+ setEnableState(task.id, true);
143
+ const restore = exec.run(["launchctl", "bootstrap", `gui/${exec.uid()}`, file]);
144
+ if (restore.status !== 0) {
145
+ rollbackErrors.push(new ConfigError(`launchctl bootstrap during rollback failed: ${restore.stderr || restore.stdout || "no output"}.`, "INVALID_CONFIG_FILE"));
146
+ }
147
+ }
148
+ catch (rollbackError) {
149
+ rollbackErrors.push(rollbackError);
150
+ }
151
+ if (!previousEnabled) {
152
+ try {
153
+ setEnableState(task.id, false);
154
+ }
155
+ catch (rollbackError) {
156
+ rollbackErrors.push(rollbackError);
157
+ }
158
+ }
159
+ }
160
+ else if (enableStateTouched) {
161
+ try {
162
+ setEnableState(task.id, previousPlist === undefined || previousEnabled);
163
+ }
164
+ catch (rollbackError) {
165
+ rollbackErrors.push(rollbackError);
166
+ }
167
+ }
168
+ if (rollbackErrors.length > 0) {
169
+ const message = err instanceof Error ? err.message : String(err);
170
+ throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for launchd task "${task.id}" was incomplete.`);
171
+ }
172
+ throw err;
173
+ }
174
+ finally {
175
+ if (fsLike.exists(tempFile))
176
+ fsLike.removeFile(tempFile);
76
177
  }
77
178
  },
78
179
  uninstall(id) {
79
- // Bootout first (may fail if agent never loaded — that's fine).
80
- exec.run(["launchctl", "bootout", target(id)]);
180
+ const bootout = exec.run(["launchctl", "bootout", target(id)]);
181
+ if (bootout.status !== 0 && !isServiceNotFoundResult(bootout)) {
182
+ throw new ConfigError(`launchctl bootout failed (exit ${bootout.status}): ${bootout.stderr || bootout.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
183
+ }
184
+ // launchctl disable overrides persist after the plist is removed.
185
+ setEnableState(id, true);
81
186
  const file = plistPath(id);
82
187
  if (fsLike.exists(file))
83
188
  fsLike.removeFile(file);
84
189
  },
85
190
  setEnabled(id, enabled) {
86
- const verb = enabled ? "enable" : "disable";
87
- const r = exec.run(["launchctl", verb, target(id)]);
88
- if (r.status !== 0) {
89
- throw new ConfigError(`launchctl ${verb} failed: ${r.stderr || r.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
90
- }
191
+ setEnableState(id, enabled);
91
192
  },
92
193
  list() {
93
194
  if (!fsLike.exists(agentsDir))
@@ -98,48 +199,132 @@ export function LAUNCHD_BACKEND(options = {}) {
98
199
  ids.push(file.slice(LAUNCHD_LABEL_PREFIX.length, -".plist".length));
99
200
  }
100
201
  }
101
- return ids.map((id) => ({ id }));
202
+ if (ids.length === 0)
203
+ return [];
204
+ const disabledLabels = readDisabledLabels(exec);
205
+ return ids.map((id) => {
206
+ if (!disabledLabels)
207
+ return { id };
208
+ try {
209
+ const loaded = exec.run(["launchctl", "print", target(id)]);
210
+ if (loaded.status !== 0)
211
+ return { id };
212
+ }
213
+ catch {
214
+ return { id };
215
+ }
216
+ try {
217
+ const xml = fsLike
218
+ .readFile(plistPath(id))
219
+ .replace(/<!-- akm-enabled:(?:true|false) -->/, `<!-- akm-enabled:${!disabledLabels.has(label(id))} -->`);
220
+ return { id, signature: normalizeSignature(xml) };
221
+ }
222
+ catch {
223
+ return { id };
224
+ }
225
+ });
226
+ },
227
+ expectedSignature(task) {
228
+ return normalizeSignature(buildPlistXml(task, akmArgv, logDir, pathEnv(), scheduledContext));
102
229
  },
103
230
  };
104
231
  }
105
232
  // ── XML builder (exported for tests) ────────────────────────────────────────
106
- export function buildPlistXml(task, akmArgv, logDir, pathEnv) {
233
+ export function buildPlistXml(task, akmArgv, logDir, pathEnv, scheduledContext) {
107
234
  const spec = parseSchedule(task.schedule, "launchd");
108
235
  const trigger = translateToLaunchd(spec);
109
- const argv = [...akmArgv, "tasks", "run", task.id];
236
+ const invocation = buildScheduledTaskInvocation(akmArgv, task.id, scheduledContext);
237
+ const argv = invocation.argv;
110
238
  const programArgs = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
111
239
  const logPath = path.join(logDir, `${task.id}.log`);
112
240
  const triggerXml = renderLaunchdTrigger(trigger);
113
- const envVarsXml = pathEnv !== undefined
114
- ? ` <key>EnvironmentVariables</key>\n <dict>\n <key>PATH</key>\n <string>${escapeXml(pathEnv)}</string>\n </dict>\n`
115
- : "";
116
- return launchdTemplate
241
+ const environment = { ...invocation.environment };
242
+ if (pathEnv !== undefined)
243
+ environment.PATH = pathEnv;
244
+ const envEntries = Object.entries(environment)
245
+ .map(([key, value]) => ` <key>${key}</key>\n <string>${escapeXml(value)}</string>`)
246
+ .join("\n");
247
+ const envVarsXml = ` <key>EnvironmentVariables</key>\n <dict>\n${envEntries}\n </dict>\n`;
248
+ const xml = launchdTemplate
249
+ .replace("<dict>\n", `<dict>\n <!-- akm-enabled:${task.enabled} -->\n`)
117
250
  .replace("{{LABEL}}", LAUNCHD_LABEL_PREFIX + escapeXml(task.id))
118
251
  .replace("{{PROGRAM_ARGS}}", programArgs)
119
252
  .replaceAll("{{LOG_PATH}}", escapeXml(logPath))
120
253
  .replace("{{ENV_VARS}}", envVarsXml)
121
254
  .replace("{{TRIGGER_XML}}", triggerXml);
255
+ for (const char of xml) {
256
+ const code = char.codePointAt(0) ?? 0;
257
+ if (code <= 0x08 || code === 0x0b || code === 0x0c || (code >= 0x0e && code <= 0x1f)) {
258
+ throw new ConfigError("Launchd plist values must not contain XML-forbidden control characters.", "INVALID_CONFIG_FILE");
259
+ }
260
+ }
261
+ return xml;
122
262
  }
123
263
  function renderLaunchdTrigger(trigger) {
124
- if (trigger.intervalSeconds !== undefined) {
125
- return ` <key>StartInterval</key>
126
- <integer>${trigger.intervalSeconds}</integer>`;
264
+ if (trigger.calendars !== undefined) {
265
+ const lines = [" <key>StartCalendarInterval</key>", " <array>"];
266
+ for (const calendar of trigger.calendars) {
267
+ lines.push(...renderCalendar(calendar, " "));
268
+ }
269
+ lines.push(" </array>");
270
+ return lines.join("\n");
127
271
  }
128
272
  const cal = trigger.calendar ?? {};
129
- const lines = [" <key>StartCalendarInterval</key>", " <dict>"];
130
- if (cal.Minute !== undefined)
131
- lines.push(` <key>Minute</key><integer>${cal.Minute}</integer>`);
132
- if (cal.Hour !== undefined)
133
- lines.push(` <key>Hour</key><integer>${cal.Hour}</integer>`);
134
- if (cal.Day !== undefined)
135
- lines.push(` <key>Day</key><integer>${cal.Day}</integer>`);
136
- if (cal.Month !== undefined)
137
- lines.push(` <key>Month</key><integer>${cal.Month}</integer>`);
138
- if (cal.Weekday !== undefined)
139
- lines.push(` <key>Weekday</key><integer>${cal.Weekday}</integer>`);
140
- lines.push(" </dict>");
273
+ const lines = [" <key>StartCalendarInterval</key>", ...renderCalendar(cal, " ")];
141
274
  return lines.join("\n");
142
275
  }
276
+ function renderCalendar(calendar, indent) {
277
+ const valueIndent = `${indent} `;
278
+ const lines = [`${indent}<dict>`];
279
+ if (calendar.Minute !== undefined) {
280
+ lines.push(`${valueIndent}<key>Minute</key><integer>${calendar.Minute}</integer>`);
281
+ }
282
+ if (calendar.Hour !== undefined)
283
+ lines.push(`${valueIndent}<key>Hour</key><integer>${calendar.Hour}</integer>`);
284
+ if (calendar.Day !== undefined)
285
+ lines.push(`${valueIndent}<key>Day</key><integer>${calendar.Day}</integer>`);
286
+ if (calendar.Month !== undefined)
287
+ lines.push(`${valueIndent}<key>Month</key><integer>${calendar.Month}</integer>`);
288
+ if (calendar.Weekday !== undefined) {
289
+ lines.push(`${valueIndent}<key>Weekday</key><integer>${calendar.Weekday}</integer>`);
290
+ }
291
+ lines.push(`${indent}</dict>`);
292
+ return lines;
293
+ }
294
+ function normalizeSignature(xml) {
295
+ return xml.replace(/\r\n/g, "\n").trim();
296
+ }
297
+ function readDisabledLabels(exec) {
298
+ try {
299
+ const result = exec.run(["launchctl", "print-disabled", `gui/${exec.uid()}`]);
300
+ if (result.status !== 0)
301
+ return undefined;
302
+ return parseDisabledLabels(result.stdout);
303
+ }
304
+ catch {
305
+ return undefined;
306
+ }
307
+ }
308
+ function parseDisabledLabels(output) {
309
+ const envelope = /^\s*disabled services\s*=\s*\{([\s\S]*)\}\s*$/.exec(output);
310
+ if (!envelope)
311
+ return undefined;
312
+ const disabled = new Set();
313
+ let body = envelope[1];
314
+ while (body.trim()) {
315
+ const entry = /^\s*"([^"\r\n]+)"\s*=>\s*(true|false|enabled|disabled)\s*/.exec(body);
316
+ if (!entry)
317
+ return undefined;
318
+ if (entry[2] === "true" || entry[2] === "disabled")
319
+ disabled.add(entry[1]);
320
+ body = body.slice(entry[0].length);
321
+ }
322
+ return disabled;
323
+ }
324
+ function isServiceNotFoundResult(result) {
325
+ const output = `${result.stderr ?? ""}\n${result.stdout ?? ""}`;
326
+ return /could not find service\b|service\b.*\bnot found\b|\bno such process\b/i.test(output);
327
+ }
143
328
  function defaultAgentsDir() {
144
329
  // launchd's per-user LaunchAgents live under the user's home directory.
145
330
  // If we can't determine HOME, refuse rather than silently producing a
@@ -162,9 +347,15 @@ function defaultLaunchdExec() {
162
347
  function defaultLaunchdFs() {
163
348
  return {
164
349
  ...nodeFs(),
350
+ readFile(file) {
351
+ return fs.readFileSync(file, "utf8");
352
+ },
165
353
  removeFile(file) {
166
354
  fs.rmSync(file, { force: true });
167
355
  },
356
+ replaceFile(source, destination) {
357
+ fs.renameSync(source, destination);
358
+ },
168
359
  list(dir) {
169
360
  try {
170
361
  return fs.readdirSync(dir);