akm-cli 0.9.0 → 0.9.1-beta.2

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 (140) hide show
  1. package/CHANGELOG.md +724 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/cli/parse-args.js +7 -1
  5. package/dist/commands/agent/contribute-cli.js +1 -1
  6. package/dist/commands/env/child-env.js +14 -0
  7. package/dist/commands/feedback-cli.js +7 -1
  8. package/dist/commands/health/llm-usage.js +2 -1
  9. package/dist/commands/health/surfaces.js +4 -77
  10. package/dist/commands/health.js +65 -11
  11. package/dist/commands/improve/distill/quality-gate.js +6 -1
  12. package/dist/commands/improve/eligibility.js +7 -1
  13. package/dist/commands/improve/eval-cases.js +2 -0
  14. package/dist/commands/improve/improve.js +126 -10
  15. package/dist/commands/improve/locks.js +7 -0
  16. package/dist/commands/improve/memory/memory-improve.js +9 -0
  17. package/dist/commands/improve/run-context.js +5 -0
  18. package/dist/commands/improve/session-asset.js +4 -0
  19. package/dist/commands/lint/base-linter.js +31 -7
  20. package/dist/commands/lint/index.js +205 -51
  21. package/dist/commands/lint/types.js +22 -1
  22. package/dist/commands/proposal/repository.js +17 -1
  23. package/dist/commands/sources/add-cli.js +8 -2
  24. package/dist/commands/sources/info.js +12 -2
  25. package/dist/commands/sources/installed-stashes.js +6 -1
  26. package/dist/commands/sources/migration-help.js +12 -3
  27. package/dist/commands/sources/self-update.js +9 -1
  28. package/dist/commands/tasks/tasks.js +8 -2
  29. package/dist/commands/workflow-cli.js +17 -11
  30. package/dist/core/abort-deadline.js +28 -0
  31. package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
  32. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  33. package/dist/core/adapter/adapters/akm-lint.js +78 -22
  34. package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  36. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  37. package/dist/core/asset/frontmatter.js +10 -1
  38. package/dist/core/common.js +147 -9
  39. package/dist/core/concurrent.js +32 -0
  40. package/dist/core/config/config-io.js +5 -45
  41. package/dist/core/config/schema/engines.js +14 -3
  42. package/dist/core/config/schema/workflow.js +11 -0
  43. package/dist/core/errors.js +25 -0
  44. package/dist/core/events.js +30 -24
  45. package/dist/core/extra-params.js +11 -0
  46. package/dist/core/file-lock.js +7 -1
  47. package/dist/core/fs-txn.js +15 -2
  48. package/dist/core/improve-result.js +5 -0
  49. package/dist/core/json-schema.js +344 -9
  50. package/dist/core/loopback.js +89 -0
  51. package/dist/core/migration-operation.js +17 -2
  52. package/dist/core/path-access.js +107 -0
  53. package/dist/core/paths.js +16 -2
  54. package/dist/core/redaction.js +86 -18
  55. package/dist/core/spawn-env.js +234 -0
  56. package/dist/core/state-db-scope.js +134 -0
  57. package/dist/core/state-db.js +1 -0
  58. package/dist/core/subprocess.js +181 -37
  59. package/dist/core/write-provenance.js +85 -0
  60. package/dist/core/write-source.js +33 -2
  61. package/dist/indexer/db/graph-db.js +17 -6
  62. package/dist/indexer/ensure-index.js +10 -3
  63. package/dist/indexer/index-written-assets.js +17 -2
  64. package/dist/indexer/indexer.js +86 -21
  65. package/dist/indexer/passes/memory-inference.js +4 -0
  66. package/dist/indexer/search/db-search.js +25 -17
  67. package/dist/indexer/walk/walker.js +6 -1
  68. package/dist/integrations/agent/detect.js +13 -1
  69. package/dist/integrations/agent/engine-resolution.js +24 -11
  70. package/dist/integrations/agent/model-aliases.js +1 -1
  71. package/dist/integrations/agent/profiles.js +9 -1
  72. package/dist/integrations/agent/spawn.js +15 -87
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  74. package/dist/integrations/lockfile.js +55 -2
  75. package/dist/llm/client.js +14 -19
  76. package/dist/llm/embedder.js +23 -3
  77. package/dist/llm/embedders/remote.js +27 -2
  78. package/dist/output/html-render.js +40 -1
  79. package/dist/output/text/lint-format.js +17 -4
  80. package/dist/runtime.js +23 -1
  81. package/dist/scripts/akm-migrate-node.js +1714 -836
  82. package/dist/scripts/akm-migrate.js +1682 -804
  83. package/dist/setup/setup.js +22 -7
  84. package/dist/sources/providers/git-install.js +25 -2
  85. package/dist/sources/providers/git-stash.js +19 -0
  86. package/dist/sources/providers/git.js +1 -1
  87. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  88. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  89. package/dist/storage/database.js +71 -7
  90. package/dist/storage/engines/sqlite-migrations.js +61 -2
  91. package/dist/storage/managed-db.js +19 -0
  92. package/dist/storage/repositories/index-connection.js +39 -4
  93. package/dist/storage/repositories/index-entries-repository.js +6 -1
  94. package/dist/storage/repositories/index-meta-repository.js +11 -0
  95. package/dist/storage/repositories/index-schema.js +17 -2
  96. package/dist/storage/repositories/index-vec-repository.js +43 -5
  97. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  98. package/dist/storage/sqlite-pragmas.js +12 -1
  99. package/dist/tasks/log-redaction.js +156 -0
  100. package/dist/tasks/parser.js +82 -5
  101. package/dist/tasks/runner.js +222 -17
  102. package/dist/tasks/scheduler-invocation.js +19 -0
  103. package/dist/tasks/schema.js +86 -1
  104. package/dist/text-import-hook.mjs +1 -1
  105. package/dist/workflows/concurrency-policy.js +95 -1
  106. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  107. package/dist/workflows/exec/exec-unit.js +542 -0
  108. package/dist/workflows/exec/frozen-judge.js +114 -42
  109. package/dist/workflows/exec/native-executor.js +465 -238
  110. package/dist/workflows/exec/param-secrets.js +4 -3
  111. package/dist/workflows/exec/run-workflow.js +424 -219
  112. package/dist/workflows/exec/step-work.js +506 -167
  113. package/dist/workflows/exec/unit-dispatch.js +31 -1
  114. package/dist/workflows/exec/unit-writer.js +53 -13
  115. package/dist/workflows/exec/worktree.js +454 -41
  116. package/dist/workflows/ir/compile.js +26 -2
  117. package/dist/workflows/ir/freeze.js +82 -15
  118. package/dist/workflows/ir/schema.js +105 -20
  119. package/dist/workflows/parser.js +242 -19
  120. package/dist/workflows/program/schema.js +24 -0
  121. package/dist/workflows/renderer.js +32 -4
  122. package/dist/workflows/resource-limits.js +182 -0
  123. package/dist/workflows/runtime/runs.js +146 -6
  124. package/dist/workflows/validate-summary.js +17 -2
  125. package/docs/README.md +74 -32
  126. package/docs/migration/release-notes/0.9.0.md +2 -1
  127. package/docs/migration/v0.7-to-v0.8.md +2 -1
  128. package/docs/migration/v0.8-to-v0.9.md +3 -1
  129. package/docs/reference/README.md +11 -4
  130. package/docs/reference/bundle-types.md +19 -0
  131. package/docs/reference/cli.md +105 -16
  132. package/docs/reference/configuration.md +15 -2
  133. package/docs/reference/data-and-telemetry.md +30 -10
  134. package/docs/reference/supported-formats.md +50 -0
  135. package/docs/reference/workflow-schema.md +1014 -0
  136. package/docs/reference/workflows.md +37 -633
  137. package/package.json +13 -6
  138. package/schemas/akm-config.json +18 -5
  139. package/schemas/akm-task.json +27 -5
  140. package/schemas/akm-workflow.json +92 -13
@@ -15,7 +15,10 @@
15
15
  * 3. Skip disabled tasks only when the invocation is scheduler-generated;
16
16
  * explicit manual runs are allowed for catch-up and testing.
17
17
  * 4. Dispatch by target kind:
18
- * • workflow → `runWorkflowSteps({ target: ref, params })`
18
+ * • workflow → `runWorkflowSteps({ target: ref, params, signal, … })`
19
+ * under a whole-run timeout (issue 11): an unattended run
20
+ * gets the same abort path `akm workflow run --timeout`
21
+ * gives an interactive one.
19
22
  * • prompt → `executeRunner(engine, prompt, { stdio: "captured" })`
20
23
  * 5. Capture stdout / stderr as structured rows in logs.db (task_logs) and,
21
24
  * transitionally, as a flat text tail at `<cacheDir>/tasks/logs/<id>/<ts>.log`
@@ -28,6 +31,7 @@
28
31
  import fs from "node:fs";
29
32
  import os from "node:os";
30
33
  import path from "node:path";
34
+ import { armAbortDeadline } from "../core/abort-deadline.js";
31
35
  import { shouldSkipUnactivatedTask } from "../core/activation-policy.js";
32
36
  import { assertNever } from "../core/assert.js";
33
37
  import { placementSpecFor } from "../core/asset/asset-placement.js";
@@ -36,7 +40,7 @@ import { loadConfig } from "../core/config/config.js";
36
40
  import { AkmError, NotFoundError, rethrowIfTestIsolationError } from "../core/errors.js";
37
41
  import { buildTaskRunId, insertTaskLogLines, openLogsDatabase, } from "../core/logs-db.js";
38
42
  import { getTaskLogDir } from "../core/paths.js";
39
- import { redactCredentialPatterns } from "../core/redaction.js";
43
+ import { redactCredentialPatterns, redactSensitiveText } from "../core/redaction.js";
40
44
  import { withStateDb } from "../core/state-db.js";
41
45
  import { runManagedSubprocess } from "../core/subprocess.js";
42
46
  import { fallbackAnnouncement, NO_ENGINE_MESSAGE_SUFFIX, NO_ENGINE_REMEDY, withEngineFallback, } from "../integrations/agent/engine-fallback.js";
@@ -45,11 +49,13 @@ import { resolveModel } from "../integrations/agent/model-aliases.js";
45
49
  import { executeRunner } from "../integrations/agent/runner-dispatch.js";
46
50
  import { chatCompletion } from "../llm/client.js";
47
51
  import { resolveAssetPath } from "../sources/resolve.js";
48
- import { decodeTaskHistoryMetadata, finalizeTaskHistoryAttempt, getTaskHistory, queryTaskHistory, reserveTaskHistoryAttempt, upsertTaskHistory, } from "../storage/repositories/task-history-repository.js";
52
+ import { decodeTaskHistoryMetadata, finalizeTaskHistoryAttempt, getTaskHistory, getTaskHistoryRuns, queryTaskHistory, reserveTaskHistoryAttempt, upsertTaskHistory, } from "../storage/repositories/task-history-repository.js";
49
53
  import { runWorkflowSteps } from "../workflows/exec/run-workflow.js";
50
54
  import { findBareAkmExecutableIndex } from "./command-executable.js";
55
+ import { collectTaskLogSensitiveValues } from "./log-redaction.js";
51
56
  import { parseTaskDocument } from "./parser.js";
52
57
  import { resolveAkmInvocation } from "./resolve-akm-bin.js";
58
+ import { scheduledTaskContextEnv } from "./scheduler-invocation.js";
53
59
  import { validateTaskId } from "./task-id.js";
54
60
  export const INVALID_TASK_ATTEMPT_ID = "_invalid-task-id";
55
61
  export async function runTask(id, options) {
@@ -109,6 +115,7 @@ export async function runTask(id, options) {
109
115
  logPath,
110
116
  fileText: `${disabledLine}\n`,
111
117
  dbLines: [{ line: disabledLine }],
118
+ redactNames: task.redact,
112
119
  });
113
120
  appendHistory(result, attempt.historyReserved);
114
121
  return result;
@@ -121,6 +128,8 @@ export async function runTask(id, options) {
121
128
  now,
122
129
  runWorkflowStepsImpl,
123
130
  historyReserved: attempt.historyReserved,
131
+ ...(options.setTimeoutFn ? { setTimeoutFn: options.setTimeoutFn } : {}),
132
+ ...(options.clearTimeoutFn ? { clearTimeoutFn: options.clearTimeoutFn } : {}),
124
133
  });
125
134
  }
126
135
  if (task.target.kind === "command") {
@@ -167,7 +176,8 @@ async function runCommandTask(input) {
167
176
  throw new Error("invariant: command target");
168
177
  const { cmd } = task.target;
169
178
  const spawnCmd = resolveNestedAkmCommand(cmd);
170
- const timeoutMs = task.timeoutMs !== undefined ? task.timeoutMs : null;
179
+ // Unset the unattended default; `null` the explicit no-timeout opt-out.
180
+ const timeoutMs = task.timeoutMs !== undefined ? task.timeoutMs : DEFAULT_SCHEDULED_TASK_TIMEOUT_MS;
171
181
  const header = `[akm task] task=${task.id} kind=command cmd=${cmd.join(" ")}`;
172
182
  const logLines = [header];
173
183
  const dbLines = [{ line: header }];
@@ -225,6 +235,7 @@ async function runCommandTask(input) {
225
235
  logPath,
226
236
  fileText: `${logLines.join("\n")}\n`,
227
237
  dbLines,
238
+ redactNames: task.redact,
228
239
  });
229
240
  const status = exitCode === 0 ? "completed" : "failed";
230
241
  const result = {
@@ -248,22 +259,86 @@ function resolveNestedAkmCommand(cmd) {
248
259
  return [...cmd.slice(0, akmIndex), ...resolveAkmInvocation().argv, ...cmd.slice(akmIndex + 1)];
249
260
  }
250
261
  // ── workflow target ─────────────────────────────────────────────────────────
262
+ /**
263
+ * Whole-run timeout applied to a workflow-bound task that does not declare its
264
+ * own `timeoutMs` — six hours.
265
+ *
266
+ * `akm workflow run` deliberately has NO default `--timeout`: a human is
267
+ * watching, and Ctrl-C aborts the very same signal the flag's timer would.
268
+ * A scheduled task has nobody watching. Without a default, its only bound is
269
+ * the per-unit timeout — and a frozen plan may set `timeout: null` (unbounded),
270
+ * so one wedged agent unit hangs the run until the machine reboots, holding the
271
+ * run lease and silently skipping every later firing (issue 11).
272
+ *
273
+ * Six hours is deliberately generous rather than tight: the abort is graceful
274
+ * (the engine breaks at the next step boundary and the run stays resumable), so
275
+ * the cost of over-waiting is bounded while the cost of cutting a legitimate
276
+ * long run short is a lost step. It matches the 6h idle window `akm health`
277
+ * already uses to call a run stale (`commands/health/report-view-model.ts`),
278
+ * and it lands well inside a `@daily` cadence, so a wedged run can never still
279
+ * be holding the lease when the next day's firing arrives.
280
+ *
281
+ * An explicit `timeoutMs:` in the task file always wins; `timeoutMs: null` is
282
+ * the explicit opt-out back to unbounded.
283
+ */
284
+ export const DEFAULT_WORKFLOW_TASK_TIMEOUT_MS = 6 * 60 * 60 * 1000;
285
+ /**
286
+ * The same unattended default for command and prompt tasks.
287
+ *
288
+ * The reasoning above is about SCHEDULED runs, not about workflows: nobody is
289
+ * watching, and one wedged run silently stops the schedule. Command tasks
290
+ * defaulted to `null` (no kill timer) and prompt tasks inherited
291
+ * DEFAULT_AGENT_TIMEOUT_MS, also null — so a hung `curl`, a prompting agent
292
+ * waiting on stdin, or a stuck engine wedged the task forever while the
293
+ * workflow arm was protected. Same value, same opt-out: an explicit
294
+ * `timeoutMs:` wins, and `timeoutMs: null` restores unbounded.
295
+ */
296
+ export const DEFAULT_SCHEDULED_TASK_TIMEOUT_MS = DEFAULT_WORKFLOW_TASK_TIMEOUT_MS;
251
297
  async function runWorkflowTask(input) {
252
298
  const { task, logPath, startedAt, now, runWorkflowStepsImpl, historyReserved } = input;
253
299
  if (task.target.kind !== "workflow")
254
300
  throw new Error("invariant: workflow target");
255
- const ref = parseRefInput(task.target.ref);
301
+ const workflowTarget = task.target;
302
+ const ref = parseRefInput(workflowTarget.ref);
256
303
  if (ref.type !== "workflow") {
257
- throw new NotFoundError(`Task "${task.id}" workflow target must be a workflow ref (got "${task.target.ref}").`, "WORKFLOW_NOT_FOUND");
258
- }
304
+ throw new NotFoundError(`Task "${task.id}" workflow target must be a workflow ref (got "${workflowTarget.ref}").`, "WORKFLOW_NOT_FOUND");
305
+ }
306
+ // Unset → the unattended default; `null` → the explicit no-timeout opt-out.
307
+ const timeoutMs = workflowTarget.timeoutMs === undefined ? DEFAULT_WORKFLOW_TASK_TIMEOUT_MS : workflowTarget.timeoutMs;
308
+ // The shared deadline `akm workflow run --timeout` also arms
309
+ // ({@link armAbortDeadline}): one AbortController for the run's lifetime,
310
+ // aborted by a timer. The engine reads `options.signal` at every step
311
+ // boundary and breaks GRACEFULLY — in-flight units are cancelled, the journal
312
+ // and the run lease are retained, and the run is left `active`, i.e.
313
+ // resumable with `akm workflow resume`.
314
+ const controller = new AbortController();
315
+ const deadline = armAbortDeadline(controller, {
316
+ timeoutMs,
317
+ reason: `Workflow task "${task.id}" timed out after ${timeoutMs}ms.`,
318
+ ...(input.setTimeoutFn ? { setTimeoutFn: input.setTimeoutFn } : {}),
319
+ ...(input.clearTimeoutFn ? { clearTimeoutFn: input.clearTimeoutFn } : {}),
320
+ });
259
321
  let detail;
260
322
  let gateError;
261
323
  let error;
262
324
  // The prompt path logs the engine-fallback announcement; a workflow-backed
263
325
  // task must leave the same trace rather than silently using a chosen engine.
264
326
  let runWarnings = [];
327
+ // Stamp task-runner provenance for the duration of the run (DRIFT-6), as the
328
+ // command and prompt arms do. This arm executes IN-PROCESS, so the stamp goes
329
+ // on process.env — child akm invocations made by workflow steps inherit it.
330
+ // Without it, workflow-task traffic was recorded as user demand. A more
331
+ // specific stamp already present wins, matching the command arm.
332
+ const priorEventSource = process.env.AKM_EVENT_SOURCE;
333
+ process.env.AKM_EVENT_SOURCE = priorEventSource ?? "task";
265
334
  try {
266
- const execution = await runWorkflowStepsImpl({ target: task.target.ref, params: task.target.params });
335
+ const execution = await runWorkflowStepsImpl({
336
+ target: workflowTarget.ref,
337
+ params: workflowTarget.params,
338
+ signal: controller.signal,
339
+ ...(workflowTarget.maxSteps !== undefined ? { maxSteps: workflowTarget.maxSteps } : {}),
340
+ ...(workflowTarget.maxRetries !== undefined ? { maxRetries: workflowTarget.maxRetries } : {}),
341
+ });
267
342
  detail = execution.run;
268
343
  runWarnings = execution.warnings ?? [];
269
344
  if (execution.gateRejection) {
@@ -275,13 +350,41 @@ async function runWorkflowTask(input) {
275
350
  throw e;
276
351
  error = e instanceof Error ? e : new Error(String(e));
277
352
  }
353
+ finally {
354
+ deadline.disarm();
355
+ if (priorEventSource === undefined)
356
+ delete process.env.AKM_EVENT_SOURCE;
357
+ else
358
+ process.env.AKM_EVENT_SOURCE = priorEventSource;
359
+ }
360
+ // A timeout is a failed ATTEMPT even though the engine stopped cleanly: the
361
+ // aborted run comes back `active` (resumable), which on its own would map to
362
+ // task status "active" and a 0 exit code, telling the OS scheduler nothing
363
+ // went wrong. Surface it like the command target's `timed_out=true` instead.
364
+ //
365
+ // Unless the run COMPLETED anyway. The abort is observed between steps, so a
366
+ // deadline landing in the run's final bookkeeping can set the flag on a run
367
+ // that then finishes — and reporting that as a failure would tell an operator
368
+ // to resume a run with nothing left to resume.
369
+ const ranToCompletion = detail?.status === "completed";
370
+ const timedOutAfterMs = deadline.timedOut() && timeoutMs !== null && !ranToCompletion ? timeoutMs : undefined;
371
+ const timeoutError = timedOutAfterMs === undefined
372
+ ? undefined
373
+ : new Error(`Workflow run timed out after ${timedOutAfterMs}ms and was aborted at a step boundary` +
374
+ (detail?.id ? ` — resume it with \`akm workflow resume ${detail.id}\`.` : "."));
375
+ // One failure value for the three sinks below (status, log line, history
376
+ // detail): a thrown error outranks a gate rejection, which outranks the
377
+ // deadline. Re-laddering per sink is how a log line ends up naming a
378
+ // different cause than the history row it was written beside.
379
+ const failure = error ?? (gateError ? new Error(gateError) : timeoutError);
278
380
  const finishedAt = finishAttempt(startedAt, now());
279
- const status = error || gateError ? "failed" : mapWorkflowStatus(detail?.status);
381
+ const status = failure ? "failed" : mapWorkflowStatus(detail?.status);
280
382
  const log = renderWorkflowLog({
281
383
  task,
282
384
  detail,
283
- error: error ?? (gateError ? new Error(gateError) : undefined),
385
+ error: failure,
284
386
  warnings: runWarnings,
387
+ ...(timedOutAfterMs !== undefined ? { timedOutAfterMs } : {}),
285
388
  });
286
389
  persistRunLog({
287
390
  taskId: task.id,
@@ -290,6 +393,7 @@ async function runWorkflowTask(input) {
290
393
  logPath,
291
394
  fileText: log.fileText,
292
395
  dbLines: log.dbLines,
396
+ redactNames: task.redact,
293
397
  });
294
398
  const result = {
295
399
  id: task.id,
@@ -301,7 +405,7 @@ async function runWorkflowTask(input) {
301
405
  target: { kind: "workflow", ref: task.target.ref },
302
406
  detail: {
303
407
  runId: detail?.id,
304
- ...(error ? { error: error.message } : gateError ? { error: gateError } : {}),
408
+ ...(failure ? { error: failure.message } : {}),
305
409
  },
306
410
  };
307
411
  appendHistory(result, historyReserved);
@@ -345,6 +449,9 @@ function renderWorkflowLog(input) {
345
449
  ];
346
450
  for (const warning of input.warnings ?? [])
347
451
  dbLines.push({ level: "warn", line: warning });
452
+ if (input.timedOutAfterMs !== undefined) {
453
+ dbLines.push({ level: "error", line: `timed_out=true timeout_ms=${input.timedOutAfterMs}` });
454
+ }
348
455
  if (input.detail) {
349
456
  dbLines.push({ line: `run_id=${input.detail.id} status=${input.detail.status}` });
350
457
  dbLines.push({ line: `workflow_title=${input.detail.workflowTitle}` });
@@ -401,7 +508,10 @@ async function runPromptTask(input) {
401
508
  runner = {
402
509
  ...runner,
403
510
  profile: { ...runner.profile, ...(model ? { model, modelIsExact: true } : {}) },
404
- ...(promptTarget.timeoutMs !== undefined ? { timeoutMs: promptTarget.timeoutMs } : {}),
511
+ // Unset the unattended default (DEFAULT_AGENT_TIMEOUT_MS is null, which
512
+ // let a prompting or wedged agent CLI hang the schedule); `null` → the
513
+ // explicit no-timeout opt-out.
514
+ timeoutMs: promptTarget.timeoutMs !== undefined ? promptTarget.timeoutMs : DEFAULT_SCHEDULED_TASK_TIMEOUT_MS,
405
515
  };
406
516
  }
407
517
  const promptText = await resolvePromptText(task, stashDir);
@@ -412,7 +522,13 @@ async function runPromptTask(input) {
412
522
  // Stamp task-runner provenance for any akm invocation the agent makes
413
523
  // (DRIFT-6: agent-task traffic must not be recorded as user demand).
414
524
  // Caller-supplied env still wins on conflicts.
415
- env: { AKM_EVENT_SOURCE: "task", ...agentOptions?.env },
525
+ //
526
+ // The agent child env is built from an allowlist, not inherited, so the
527
+ // scheduler's AKM_* directory context was dropped here — an agent's `akm`
528
+ // sub-commands then targeted the DEFAULT stash and DB rather than the
529
+ // ones the scheduled run was configured for. The command arm keeps this
530
+ // context because it inherits process.env; forward it explicitly.
531
+ env: { AKM_EVENT_SOURCE: "task", ...scheduledTaskContextEnv(), ...agentOptions?.env },
416
532
  }, {
417
533
  ...(input.runAgentImpl ? { runAgent: input.runAgentImpl } : {}),
418
534
  llm: async (spec, prompt, options) => {
@@ -432,6 +548,7 @@ async function runPromptTask(input) {
432
548
  logPath,
433
549
  fileText: log.fileText,
434
550
  dbLines: log.dbLines,
551
+ redactNames: task.redact,
435
552
  });
436
553
  const status = result.ok ? "completed" : "failed";
437
554
  const out = {
@@ -515,12 +632,49 @@ function resolveTaskLogPath(logDir, taskId, startedAtIso) {
515
632
  return "";
516
633
  }
517
634
  }
635
+ /**
636
+ * Redact logs.db rows against the SAME contiguous text the file sink sees.
637
+ *
638
+ * The rows arrive already split on "\n" (see {@link streamLines}), but the
639
+ * redaction needles are whole env values — and a needle containing a newline
640
+ * can never match inside a single line. Scrubbing row-by-row therefore left
641
+ * multi-line secrets (PEM keys, multi-line service-account credentials) intact
642
+ * in logs.db while the flat .log was correctly scrubbed, defeating all three
643
+ * tiers including the explicit `redact:` opt-in.
644
+ *
645
+ * Consecutive rows sharing a stream and level are rejoined, scrubbed as one
646
+ * string, and re-split, so a needle spanning lines matches. Collapsing a
647
+ * multi-line secret into a single [REDACTED] row is the intended outcome.
648
+ */
649
+ export function scrubDbLines(dbLines, scrub) {
650
+ const out = [];
651
+ for (let i = 0; i < dbLines.length;) {
652
+ const { stream, level } = dbLines[i];
653
+ let end = i;
654
+ while (end < dbLines.length && dbLines[end].stream === stream && dbLines[end].level === level)
655
+ end++;
656
+ const joined = dbLines
657
+ .slice(i, end)
658
+ .map((entry) => entry.line)
659
+ .join("\n");
660
+ for (const line of scrub(joined).split("\n")) {
661
+ if (line.length > 0)
662
+ out.push({ stream, level, line });
663
+ }
664
+ i = end;
665
+ }
666
+ return out;
667
+ }
518
668
  /** Split captured pipe output into per-line logs.db rows (blank lines dropped). */
519
669
  function streamLines(text, stream, level) {
520
- return text
670
+ return (text
521
671
  .split("\n")
672
+ // Windows child output is CRLF-terminated. Splitting on "\n" alone left a
673
+ // trailing "\r" on every row and turned blank CRLF lines into phantom
674
+ // rows containing just "\r" (length 1 passes the filter below).
675
+ .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
522
676
  .filter((line) => line.length > 0)
523
- .map((line) => ({ stream, level, line }));
677
+ .map((line) => ({ stream, level, line })));
524
678
  }
525
679
  /**
526
680
  * Persist a finished run's log: the flat text file (so `log_path` in
@@ -535,11 +689,56 @@ function streamLines(text, stream, level) {
535
689
  * The DB write is best-effort, mirroring {@link appendHistory}: an unwritable
536
690
  * logs.db must never fail a task run.
537
691
  */
692
+ /**
693
+ * Exact secret values to scrub from this run's persisted output (#755).
694
+ *
695
+ * Best-effort by construction: this runs on the persistence path of a run that
696
+ * has already finished, so a config that will not load must degrade to
697
+ * "pattern-based redaction only" rather than fail the run. It does NOT degrade
698
+ * to "log it anyway with no redaction at all" — `redactCredentialPatterns`
699
+ * still runs unconditionally in the caller.
700
+ */
701
+ function taskLogSensitiveValues(redactNames) {
702
+ try {
703
+ return collectTaskLogSensitiveValues({
704
+ env: process.env,
705
+ config: loadConfig(),
706
+ declaredNames: redactNames,
707
+ });
708
+ }
709
+ catch (error) {
710
+ rethrowIfTestIsolationError(error);
711
+ // No config — the name heuristic and the task's own `redact:` list still apply.
712
+ try {
713
+ return collectTaskLogSensitiveValues({ env: process.env, declaredNames: redactNames });
714
+ }
715
+ catch (fallbackError) {
716
+ rethrowIfTestIsolationError(fallbackError);
717
+ return [];
718
+ }
719
+ }
720
+ }
538
721
  function persistRunLog(input) {
539
- const fileText = redactCredentialPatterns(input.fileText);
540
- const dbLines = input.dbLines.map((entry) => ({ ...entry, line: redactCredentialPatterns(entry.line) }));
722
+ // Two arms, and both are needed. `redactCredentialPatterns` catches
723
+ // credential SHAPES nobody listed; the exact-value pass catches configured
724
+ // secrets whose value is shaped like nothing in particular (#755). The
725
+ // command target had only the first, so a scheduled command that echoed an
726
+ // ordinary-looking secret persisted it verbatim to both sinks. Applying the
727
+ // exact pass here — the one sink all three target kinds funnel through —
728
+ // covers every arm once rather than per-arm; prompt/workflow runs already
729
+ // scrub upstream, and redaction is idempotent, so the overlap is free.
730
+ const sensitive = taskLogSensitiveValues(input.redactNames);
731
+ const scrub = (text) => sensitive.length > 0
732
+ ? redactSensitiveText(redactCredentialPatterns(text), sensitive)
733
+ : redactCredentialPatterns(text);
734
+ const fileText = scrub(input.fileText);
735
+ const dbLines = scrubDbLines(input.dbLines, scrub);
541
736
  if (input.logPath) {
542
737
  try {
738
+ // Written at the process umask. #756 pinned 0600/0700 here; that went out
739
+ // with the rest of akm's permission enforcement (#791) — the operator owns
740
+ // the mode of their own data directory, and akm neither sets nor reports
741
+ // on it.
543
742
  fs.mkdirSync(path.dirname(input.logPath), { recursive: true });
544
743
  fs.writeFileSync(input.logPath, fileText);
545
744
  }
@@ -692,6 +891,12 @@ export function readTaskHistory(options = {}) {
692
891
  if (options.limit === 0)
693
892
  return [];
694
893
  if (options.id) {
894
+ // An id-scoped query used the single-row helper, so `--limit` was silently
895
+ // discarded and `akm task history --id X --limit 20` always returned one
896
+ // run. The CLI documents --limit as "Maximum rows to return"; honour it.
897
+ if (options.limit !== undefined && options.limit > 0) {
898
+ return getTaskHistoryRuns(db, options.id, options.limit).map(taskHistoryRowToResult);
899
+ }
695
900
  const row = getTaskHistory(db, options.id);
696
901
  return row ? [taskHistoryRowToResult(row)] : [];
697
902
  }
@@ -14,6 +14,25 @@ export const SCHEDULED_TASK_CONTEXT_KEYS = [
14
14
  "AKM_CACHE_DIR",
15
15
  "AKM_STATE_DIR",
16
16
  ];
17
+ /**
18
+ * The AKM_* directory context currently in effect, as a plain env fragment.
19
+ *
20
+ * A scheduled run restores these into `process.env` from its
21
+ * `--scheduler-context` descriptor precisely because such installs have
22
+ * non-default directories. Paths that build a child environment from an
23
+ * allowlist rather than inheriting (the agent spawn) must forward this
24
+ * explicitly, or the child's `akm` sub-commands silently target the default
25
+ * stash and DB.
26
+ */
27
+ export function scheduledTaskContextEnv(env = process.env) {
28
+ const out = {};
29
+ for (const key of SCHEDULED_TASK_CONTEXT_KEYS) {
30
+ const value = env[key];
31
+ if (value)
32
+ out[key] = value;
33
+ }
34
+ return out;
35
+ }
17
36
  export const SCHEDULER_CONTEXT_ARG = "--scheduler-context";
18
37
  /** Resolve the complete non-secret AKM directory context captured by schedulers. */
19
38
  export function resolveScheduledTaskContext(env = process.env, platform = process.platform) {
@@ -12,7 +12,35 @@
12
12
  * Tasks are stored as pure YAML files at `<stash>/tasks/<id>.yml`. Multi-line
13
13
  * inline prompts use a YAML block scalar (`prompt: |`).
14
14
  */
15
+ import { parse as parseYaml } from "yaml";
16
+ import { WORKFLOW_MAX_EXEC_PASS_ENV, WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/resource-limits.js";
15
17
  export const TASK_SCHEMA_VERSION = 2;
18
+ /** The ONE recognized on-disk task extension (spec §6 task row). */
19
+ export const TASK_EXTENSION = ".yml";
20
+ /**
21
+ * The near-miss spelling. `.yaml` is NOT a task extension: the indexer's
22
+ * `tasks` matcher (`indexer/walk/matchers.ts`) gates on `.yml`, so a
23
+ * `tasks/<id>.yaml` file is never indexed, never scheduled, and never runs.
24
+ * It is recognized HERE only so lint can say so out loud instead of walking
25
+ * past it (issue #760).
26
+ */
27
+ export const TASK_NEAR_MISS_EXTENSION = ".yaml";
28
+ /**
29
+ * Largest expressible `timeoutMs` — `setTimeout`'s 32-bit signed ceiling
30
+ * (2^31-1, ~24.8 days). A larger delay overflows and fires almost immediately,
31
+ * which would silently abort a run seconds after it started instead of hours
32
+ * later. One definition with the workflow bound (`WORKFLOW_MAX_TIMEOUT_MS`) —
33
+ * it is a platform fact, not a per-surface policy. Mirrored as `maximum` on
34
+ * `timeoutMs` in `schemas/akm-task.json`.
35
+ */
36
+ export const TASK_MAX_TIMEOUT_MS = WORKFLOW_MAX_TIMEOUT_MS;
37
+ /**
38
+ * Most names a task's `redact:` list may carry. Shares its bound with exec
39
+ * units' `pass_env:` — both are "name the one or two the defaults miss", not a
40
+ * way to declare the whole environment secret. Mirrored as `maxItems` on
41
+ * `redact` in `schemas/akm-task.json`.
42
+ */
43
+ export const TASK_MAX_REDACT_NAMES = WORKFLOW_MAX_EXEC_PASS_ENV;
16
44
  /**
17
45
  * Lint-level shape problems for a parsed task YAML mapping: the field rules
18
46
  * `src/tasks/parser.ts` enforces at load time, phrased as diagnostics. The ONE
@@ -24,8 +52,28 @@ export const TASK_SCHEMA_VERSION = 2;
24
52
  * at runtime with TASK_SCHEMA_VERSION_UNSUPPORTED). `schemas/akm-task.json`
25
53
  * agrees with the parser: `required: [version, schedule]`, `version:
26
54
  * {const: 2}`, `enabled` optional but boolean. Target-arity rules stay with
27
- * each caller (they legitimately differ: at-least-one vs exactly-one).
55
+ * each caller (they legitimately differ: at-least-one vs exactly-one), but
56
+ * what COUNTS as a target is shared — see {@link isPresentTarget}.
57
+ */
58
+ /**
59
+ * Whether a task-target field counts as declared.
60
+ *
61
+ * The arity rules differ between the two linters, but the presence test must
62
+ * not: the runtime parser treats `""` as absent, so a key-existence check let
63
+ * `workflow: ""` lint clean and then die with MISSING_REQUIRED_ARGUMENT. An
64
+ * array target (`command`) counts only when it has entries, for the same
65
+ * reason. Kept beside {@link taskFieldProblems} so all three definitions of
66
+ * "valid task" stay in one file.
28
67
  */
68
+ export function isPresentTarget(value) {
69
+ if (value === undefined || value === null)
70
+ return false;
71
+ if (typeof value === "string")
72
+ return value.trim() !== "";
73
+ if (Array.isArray(value))
74
+ return value.length > 0;
75
+ return true;
76
+ }
29
77
  export function taskFieldProblems(data) {
30
78
  const problems = [];
31
79
  if (data.version !== TASK_SCHEMA_VERSION)
@@ -36,3 +84,40 @@ export function taskFieldProblems(data) {
36
84
  problems.push("enabled (must be a boolean when present)");
37
85
  return problems;
38
86
  }
87
+ /**
88
+ * Parse a task YAML document into a plain mapping. Non-mapping documents (a
89
+ * scalar, a sequence, an empty file) are NOT a parse failure — they parse fine
90
+ * and simply carry no fields, which the field rules above already describe.
91
+ *
92
+ * The ONE parse used by all three task-lint surfaces (`commands/lint/index.ts`'s
93
+ * akm sweep, the `akm` adapter's `validate`, and the `akm-task` adapter) so a
94
+ * malformed file cannot be a finding on one surface and silence on another.
95
+ */
96
+ export function parseTaskYaml(raw) {
97
+ try {
98
+ const doc = parseYaml(raw);
99
+ if (doc && typeof doc === "object" && !Array.isArray(doc))
100
+ return { ok: true, data: doc };
101
+ return { ok: true, data: {} };
102
+ }
103
+ catch (e) {
104
+ // yaml's errors carry a multi-line source excerpt; keep the first line so
105
+ // the diagnostic stays one readable finding.
106
+ const message = (e instanceof Error ? e.message : String(e)).split("\n")[0]?.trim() ?? "unknown parse error";
107
+ return { ok: false, data: {}, error: message };
108
+ }
109
+ }
110
+ /** The `invalid-task-yaml` detail for a file whose YAML could not be parsed. */
111
+ export function taskYamlParseDetail(error) {
112
+ return `task YAML does not parse: ${error}`;
113
+ }
114
+ /**
115
+ * The `invalid-task-yaml` detail for a task file using the `.yaml` near-miss
116
+ * spelling. See {@link TASK_NEAR_MISS_EXTENSION} for why this is an error and
117
+ * not a style nit.
118
+ */
119
+ export function taskExtensionDetail(relPath) {
120
+ const base = relPath.replace(/\.yaml$/i, "");
121
+ return (`task file uses the ${TASK_NEAR_MISS_EXTENSION} extension; akm recognizes tasks only as ` +
122
+ `${TASK_EXTENSION}, so this file is never indexed or scheduled — rename it to ${base}${TASK_EXTENSION}.`);
123
+ }
@@ -16,7 +16,7 @@
16
16
  import { readFile } from "node:fs/promises";
17
17
  import { fileURLToPath } from "node:url";
18
18
 
19
- const TEXT_EXTENSIONS = new Set([".md", ".xml", ".txt", ".sql", ".yaml", ".yml"]);
19
+ const TEXT_EXTENSIONS = new Set([".md", ".xml", ".txt", ".sql", ".yaml", ".yml", ".html"]);
20
20
 
21
21
  function isTextImport(url, importAttributes) {
22
22
  if (importAttributes && importAttributes.type === "text") return true;
@@ -2,7 +2,16 @@
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
4
  import os from "node:os";
5
- export const WORKFLOW_MAX_CONCURRENCY_CEILING = 64;
5
+ import { isLoopbackEndpoint } from "../core/loopback.js";
6
+ import { WORKFLOW_MAX_CONCURRENCY } from "./resource-limits.js";
7
+ /**
8
+ * Run-level ceiling on `workflow.maxConcurrency`. It is deliberately the SAME
9
+ * value the frozen-plan decoder enforces on `execution.maxConcurrency` and on
10
+ * per-step `map.concurrency` — a clamp above the decoder's bound would freeze
11
+ * plans the decoder then rejects — so it reads the single shared constant
12
+ * (`./resource-limits`) rather than repeating the literal.
13
+ */
14
+ export const WORKFLOW_MAX_CONCURRENCY_CEILING = WORKFLOW_MAX_CONCURRENCY;
6
15
  export function cpuDerivedUnitConcurrency(cpuCount = os.cpus()?.length ?? 4) {
7
16
  return Math.min(16, Math.max(1, cpuCount - 2));
8
17
  }
@@ -13,3 +22,88 @@ export function clampMaxConcurrency(value) {
13
22
  export function workflowMaxConcurrency(configured, cpuCount = os.cpus()?.length ?? 4) {
14
23
  return configured === undefined ? cpuDerivedUnitConcurrency(cpuCount) : clampMaxConcurrency(configured);
15
24
  }
25
+ // ── Fan-out defaults ─────────────────────────────────────────────────────────
26
+ //
27
+ // Four independent limits clamp a `map` step's real width, and the effective
28
+ // value is their minimum:
29
+ //
30
+ // 1. the step's own `map.concurrency` (this file's default below)
31
+ // 2. the run's frozen `execution.maxConcurrency` ({@link workflowMaxConcurrency})
32
+ // 3. the selected LLM engine's frozen concurrency ({@link defaultLlmEngineConcurrency})
33
+ // 4. the CURRENT host's CPU safety cap ({@link cpuDerivedUnitConcurrency})
34
+ //
35
+ // (1) and (3) both defaulted to 1 before 0.9.1, which made every fan-out serial
36
+ // unless the author opted in at BOTH layers — so (2) and (4), the limits that
37
+ // actually encode machine capacity, never bound anything. The defaults below
38
+ // replace those two 1s. They are deliberately modest rather than "as wide as
39
+ // the host allows": a `map` is independent by construction, but its units call
40
+ // out to rate-limited providers and RAM-hungry agent processes, so the value
41
+ // that a plan freezes should be one a laptop and a CI box can both survive.
42
+ /**
43
+ * Default width of a `map` step that declares no `concurrency:` (0.9.1+).
44
+ *
45
+ * 4 is chosen over the host cap on purpose. It is a real, predictable speedup
46
+ * (4× on any fan-out longer than four items) while staying below
47
+ * {@link cpuDerivedUnitConcurrency} on every machine with ≥6 cores, so the
48
+ * frozen number — not the host — is what an author reasons about, and a plan
49
+ * frozen on a 32-core CI box behaves the same when it resumes on a laptop.
50
+ *
51
+ * Overridable in both directions:
52
+ * - per step: `map.concurrency: <n>` (an explicit `1` still means serial),
53
+ * - per install: `workflow.defaultMapConcurrency` — set it to `1` to restore
54
+ * the pre-0.9.1 serial default for every workflow at once.
55
+ */
56
+ export const DEFAULT_MAP_CONCURRENCY = 4;
57
+ /**
58
+ * Default `engines.<name>.concurrency` for an LLM engine on a LOOPBACK
59
+ * endpoint. Stays at 1, matching `getDefaultLlmConcurrency`
60
+ * (`src/indexer/indexer.ts`) and AGENTS.md's "lowest common denominator — a
61
+ * slow local model on a single-threaded server" rule. A local model server
62
+ * (LM Studio, Ollama) holds ONE loaded model; parallel inference triggers
63
+ * reload thrash and HTTP 500s, which is a hard failure, not a slow one.
64
+ */
65
+ export const DEFAULT_LOCAL_LLM_ENGINE_CONCURRENCY = 1;
66
+ /**
67
+ * Default `engines.<name>.concurrency` for an LLM engine on a REMOTE endpoint.
68
+ *
69
+ * Deliberately equal to {@link DEFAULT_MAP_CONCURRENCY} so this limit does not
70
+ * silently re-serialize a fan-out the author already asked for: the step's own
71
+ * `concurrency:` stays the number that decides. Indexing's remote default is a
72
+ * lower 2 because indexing fans out implicitly over the whole stash; a
73
+ * workflow `map` is an explicit, bounded, author-declared fan-out, and four
74
+ * concurrent completions sit far inside any hosted provider's entry tier.
75
+ * Rate-limited installs set `engines.<name>.concurrency` to pin their own.
76
+ */
77
+ export const DEFAULT_REMOTE_LLM_ENGINE_CONCURRENCY = 4;
78
+ // ── Loopback classification ──────────────────────────────────────────────────
79
+ //
80
+ // Everything above turns on ONE question: does this endpoint name a model
81
+ // server running on THIS machine? The classifier lives in `core/loopback.ts`
82
+ // (shared with the indexer's LLM pool default); the re-export keeps this
83
+ // module the policy surface workflow callers and the boundary-case table in
84
+ // `tests/workflows/concurrency-defaults.test.ts` import from. The check is
85
+ // purely syntactic — no DNS, no interface list — so freeze produces the same
86
+ // plan on a laptop, on CI, and on a machine with no network.
87
+ export { isLoopbackEndpoint, isLoopbackHost } from "../core/loopback.js";
88
+ /**
89
+ * Concurrency to freeze for an LLM engine. An explicit
90
+ * `engines.<name>.concurrency` always wins (clamped into the decoder's
91
+ * `[1, 64]` range so a fat-fingered config cannot freeze an unloadable plan);
92
+ * otherwise the endpoint decides.
93
+ */
94
+ export function defaultLlmEngineConcurrency(endpoint, configured) {
95
+ if (typeof configured === "number" && Number.isFinite(configured))
96
+ return clampMaxConcurrency(configured);
97
+ return isLoopbackEndpoint(endpoint) ? DEFAULT_LOCAL_LLM_ENGINE_CONCURRENCY : DEFAULT_REMOTE_LLM_ENGINE_CONCURRENCY;
98
+ }
99
+ /**
100
+ * Width to freeze for a `map` step that declared no `concurrency:`. `configured`
101
+ * is `workflow.defaultMapConcurrency`; unset means {@link DEFAULT_MAP_CONCURRENCY}.
102
+ * An explicit `map.concurrency` never reaches this function — the caller keeps
103
+ * "author wrote 1" distinguishable from "author wrote nothing".
104
+ */
105
+ export function defaultMapConcurrency(configured) {
106
+ return configured === undefined || !Number.isFinite(configured)
107
+ ? DEFAULT_MAP_CONCURRENCY
108
+ : clampMaxConcurrency(configured);
109
+ }