akm-cli 0.9.11 → 0.9.12

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 (127) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/STABILITY.md +6 -1
  3. package/dist/assets/hints/cli-hints-full.md +1 -1
  4. package/dist/assets/improve-strategies/consolidate.json +1 -1
  5. package/dist/assets/improve-strategies/default.json +1 -1
  6. package/dist/assets/improve-strategies/thorough.json +1 -2
  7. package/dist/cli/shared.js +16 -4
  8. package/dist/cli.js +15 -13
  9. package/dist/commands/agent/agent-dispatch.js +8 -0
  10. package/dist/commands/command/execution-source-loader.js +25 -22
  11. package/dist/commands/command/portable-template.js +4 -26
  12. package/dist/commands/config-cli.js +10 -4
  13. package/dist/commands/env/env-binding.js +10 -3
  14. package/dist/commands/env/env-cli.js +7 -0
  15. package/dist/commands/env/secret-cli.js +15 -4
  16. package/dist/commands/health/checks.js +186 -71
  17. package/dist/commands/health.js +16 -4
  18. package/dist/commands/improve/distill/quality-gate.js +2 -2
  19. package/dist/commands/improve/distill.js +28 -12
  20. package/dist/commands/improve/execution.js +1 -2
  21. package/dist/commands/improve/extract.js +82 -56
  22. package/dist/commands/improve/improve-strategies.js +26 -8
  23. package/dist/commands/improve/improve.js +13 -0
  24. package/dist/commands/improve/preparation.js +9 -6
  25. package/dist/commands/improve/reflect.js +61 -77
  26. package/dist/commands/lint/index.js +3 -1
  27. package/dist/commands/migrate-cli.js +6 -4
  28. package/dist/commands/proposal/drain-policies.js +22 -2
  29. package/dist/commands/proposal/repository.js +4 -4
  30. package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
  31. package/dist/commands/proposal/validators/proposals.js +10 -19
  32. package/dist/commands/read/show.js +42 -31
  33. package/dist/commands/registry-cli.js +4 -2
  34. package/dist/commands/sources/init.js +4 -8
  35. package/dist/commands/sources/self-update.js +2 -2
  36. package/dist/commands/sources/source-clone.js +5 -7
  37. package/dist/commands/sources/sources-cli.js +3 -5
  38. package/dist/commands/tasks/tasks-cli.js +4 -12
  39. package/dist/commands/tasks/tasks.js +38 -35
  40. package/dist/commands/workflow-cli.js +17 -15
  41. package/dist/core/activation-policy.js +31 -3
  42. package/dist/core/adapter/execution-source.js +39 -11
  43. package/dist/core/asset/stash-meta.js +7 -41
  44. package/dist/core/common.js +8 -17
  45. package/dist/core/config/config-schema.js +3 -23
  46. package/dist/core/config/config-walker.js +50 -4
  47. package/dist/core/config/config.js +21 -5
  48. package/dist/core/config/legacy-source-shape-shim.js +79 -0
  49. package/dist/core/config/schema/embedding.js +2 -2
  50. package/dist/core/config/schema/engines.js +2 -2
  51. package/dist/core/config/schema/index-config.js +19 -21
  52. package/dist/core/config/schema/primitives.js +21 -10
  53. package/dist/core/config/schema/sources-bundles.js +1 -6
  54. package/dist/core/errors.js +2 -3
  55. package/dist/core/improve-types.js +17 -0
  56. package/dist/core/json-schema.js +1 -11
  57. package/dist/core/maintenance-barrier.js +17 -2
  58. package/dist/core/paths.js +12 -15
  59. package/dist/core/state/migrations.js +28 -0
  60. package/dist/core/state-db.js +28 -1
  61. package/dist/core/write-source.js +6 -6
  62. package/dist/indexer/bundle-identity-guard.js +3 -0
  63. package/dist/indexer/ensure-index.js +5 -0
  64. package/dist/indexer/indexer.js +11 -3
  65. package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
  66. package/dist/indexer/passes/metadata.js +16 -5
  67. package/dist/indexer/search/search-fields.js +1 -30
  68. package/dist/integrations/agent/engine-resolution.js +15 -1
  69. package/dist/integrations/agent/model-map.js +16 -10
  70. package/dist/integrations/agent/prompts.js +13 -6
  71. package/dist/integrations/lockfile.js +22 -7
  72. package/dist/llm/client.js +16 -0
  73. package/dist/llm/index-passes.js +3 -2
  74. package/dist/output/shapes/passthrough.js +9 -3
  75. package/dist/output/shapes.js +5 -3
  76. package/dist/output/text/workflow-format.js +8 -1
  77. package/dist/scripts/akm-migrate-node.js +1461 -1267
  78. package/dist/scripts/akm-migrate.js +1460 -1266
  79. package/dist/setup/setup.js +14 -21
  80. package/dist/sources/include.js +150 -20
  81. package/dist/sources/providers/git-install.js +14 -12
  82. package/dist/sources/providers/git-provider.js +3 -3
  83. package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
  84. package/dist/sources/website-url.js +12 -4
  85. package/dist/storage/engines/sqlite-migrations.js +40 -10
  86. package/dist/storage/like-pattern.js +7 -0
  87. package/dist/storage/repositories/extract-sessions-repository.js +23 -0
  88. package/dist/storage/repositories/index-connection.js +27 -10
  89. package/dist/storage/repositories/index-entry-schema.js +19 -2
  90. package/dist/storage/repositories/index-schema.js +30 -9
  91. package/dist/storage/repositories/proposals-repository.js +2 -1
  92. package/dist/storage/repositories/task-history-repository.js +14 -7
  93. package/dist/storage/repositories/workflow-runs-repository.js +15 -1
  94. package/dist/storage/sqlite-read-snapshot.js +11 -9
  95. package/dist/tasks/backends/cron.js +34 -5
  96. package/dist/tasks/backends/launchd.js +23 -26
  97. package/dist/tasks/backends/schtasks.js +50 -3
  98. package/dist/tasks/frozen-script.js +2 -0
  99. package/dist/tasks/prepare/prepare.js +2 -7
  100. package/dist/tasks/prepare/script-capture.js +38 -6
  101. package/dist/tasks/schedule.js +154 -13
  102. package/dist/tasks/source/task-source-v3-frozen.js +0 -1
  103. package/dist/tasks/source/task-source-v4.js +0 -1
  104. package/dist/workflows/exec/child-workflow.js +2 -3
  105. package/dist/workflows/exec/exec-unit.js +3 -4
  106. package/dist/workflows/exec/run-workflow.js +19 -10
  107. package/dist/workflows/exec/step-work.js +35 -56
  108. package/dist/workflows/freeze/resolve-steps.js +19 -11
  109. package/dist/workflows/freeze/source-freeze.js +7 -0
  110. package/dist/workflows/freeze/targets/child-workflow.js +12 -18
  111. package/dist/workflows/freeze/targets/command.js +14 -2
  112. package/dist/workflows/ir/environment-v4.js +4 -2
  113. package/dist/workflows/ir/freeze-v4.js +2 -5
  114. package/dist/workflows/ir/plan-hash.js +0 -3
  115. package/dist/workflows/ir/schema-v4.js +14 -9
  116. package/dist/workflows/ir/schema.js +1 -3
  117. package/dist/workflows/resource-limits.js +35 -48
  118. package/dist/workflows/runtime/plan-classifier.js +89 -41
  119. package/dist/workflows/runtime/run-outputs.js +1 -21
  120. package/dist/workflows/runtime/runs.js +76 -150
  121. package/dist/workflows/source-files.js +28 -54
  122. package/dist/workflows/source-ir/program.js +2 -2
  123. package/dist/workflows/source-ir/semantics.js +5 -23
  124. package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
  125. package/docs/reference/cli.md +74 -17
  126. package/package.json +1 -1
  127. package/schemas/akm-config.json +5 -10
@@ -16,11 +16,11 @@ import { collectWorkflowWarnings } from "../ir/compile.js";
16
16
  import { compileResolveFreezeWorkflowV4 } from "../ir/freeze-v4.js";
17
17
  import { materializeWorkflowParameterFlags, validateWorkflowParams } from "../ir/params.js";
18
18
  import { canonicalPlanJson, computePlanHash } from "../ir/plan-hash.js";
19
- import { clip, utf8Bytes, WORKFLOW_EVIDENCE_TRUNCATION_PREVIEW_CHARS, WORKFLOW_MAX_EVIDENCE_JSON_BYTES, WORKFLOW_UNIT_DIAGNOSTIC_CLIP, } from "../resource-limits.js";
19
+ import { clip, WORKFLOW_UNIT_DIAGNOSTIC_CLIP } from "../resource-limits.js";
20
20
  import { validateStepSummary } from "../validate-summary.js";
21
21
  import { resolveAgentIdentity } from "./agent-identity.js";
22
22
  import { evaluateCheckin } from "./checkin.js";
23
- import { assertWorkflowSpineMatchesPlan, classifyWorkflowRunPlan, frozenStepRows, requireExecutableWorkflowPlan, } from "./plan-classifier.js";
23
+ import { assertRunStatusMatchesSpine, classifyWorkflowRunPlan, frozenStepRows, reconcileWorkflowSpineWithPlan, requireExecutableWorkflowPlan, } from "./plan-classifier.js";
24
24
  import { resolveWorkflowRunOutputs } from "./run-outputs.js";
25
25
  import { evaluateStaleUnits } from "./unit-checkin.js";
26
26
  import { canonicalizeWorkflowRefInput, loadWorkflowAsset, resolveWorkflowEntryId } from "./workflow-asset-loader.js";
@@ -192,9 +192,6 @@ export async function getWorkflowStatus(runId, opts) {
192
192
  return detail;
193
193
  });
194
194
  }
195
- export async function hasWorkflowRun(runId) {
196
- return withWorkflowRunsRepo((repo) => repo.hasRun(runId));
197
- }
198
195
  export async function listWorkflowRuns(input) {
199
196
  const scopeKey = getCurrentWorkflowScopeKey();
200
197
  const activeOnly = input?.activeOnly === true;
@@ -248,13 +245,15 @@ export async function listWorkflowRuns(input) {
248
245
  }
249
246
  export async function getNextWorkflowStep(specifier, params, options) {
250
247
  return withWorkflowRunsRepo(async (repo) => {
251
- const { run, autoStarted, startWarnings } = await resolveRunSpecifier(repo, specifier, params, options?.parameterFlags);
248
+ const { run, autoStarted, resumed, startWarnings } = await resolveRunSpecifier(repo, specifier, params, options?.parameterFlags, options?.newRun);
252
249
  const steps = readWorkflowRunSteps(repo, run.id);
253
250
  const plan = requireExecutableWorkflowPlan(run);
254
- assertWorkflowSpineMatchesPlan(plan, run, steps);
251
+ reconcileWorkflowSpineWithPlan(plan, run, steps);
252
+ assertRunStatusMatchesSpine(run, steps);
255
253
  return {
256
254
  ...projectNextResult(run, steps),
257
255
  ...(autoStarted ? { autoStarted: true } : {}),
256
+ ...(resumed ? { resumed: true } : {}),
258
257
  ...(startWarnings?.length ? { startWarnings } : {}),
259
258
  };
260
259
  });
@@ -291,14 +290,15 @@ function projectNextResult(run, steps) {
291
290
  }
292
291
  export async function resumeWorkflowRun(runId) {
293
292
  return withWorkflowRunsRepo((repo) => {
294
- const run = readWorkflowRun(repo, runId);
293
+ const run = readWorkflowRunOrPrefix(repo, runId);
295
294
  const storedPlan = requireExecutableWorkflowPlan(run);
296
295
  const steps = readWorkflowRunSteps(repo, run.id);
297
- assertWorkflowSpineMatchesPlan(storedPlan, run, steps);
296
+ reconcileWorkflowSpineWithPlan(storedPlan, run, steps);
298
297
  if (run.status === "completed") {
299
298
  throw new UsageError(`Workflow run ${run.id} is already completed and cannot be resumed.`);
300
299
  }
301
300
  if (run.status === "active") {
301
+ assertRunStatusMatchesSpine(run, steps);
302
302
  return buildWorkflowRunDetail(repo, run, steps);
303
303
  }
304
304
  // blocked or failed → flip back to active and re-open the current step so
@@ -312,6 +312,7 @@ export async function resumeWorkflowRun(runId) {
312
312
  });
313
313
  const updated = { ...run, status: "active", updated_at: now };
314
314
  const refreshedSteps = readWorkflowRunSteps(repo, run.id);
315
+ assertRunStatusMatchesSpine(updated, refreshedSteps);
315
316
  return buildWorkflowRunDetail(repo, updated, refreshedSteps);
316
317
  });
317
318
  }
@@ -325,7 +326,7 @@ export async function abandonWorkflowRun(runId) {
325
326
  return withWorkflowRunsRepo((repo) => {
326
327
  const now = new Date().toISOString();
327
328
  const run = repo.immediateTransaction((db) => {
328
- const current = readWorkflowRun(repo, runId);
329
+ const current = readWorkflowRunOrPrefix(repo, runId);
329
330
  if (current.status === "completed" || current.status === "failed") {
330
331
  throw new UsageError(`Workflow run ${current.id} is already ${current.status}.`);
331
332
  }
@@ -354,115 +355,6 @@ export async function abandonWorkflowRun(runId) {
354
355
  return detail;
355
356
  });
356
357
  }
357
- // ── Step-evidence persistence bound (issue C) ────────────────────────────────
358
- /**
359
- * Marker key stamped on every value this module replaced because it did not fit
360
- * in `workflow_run_steps.evidence_json`. It is deliberately ugly and unique so a
361
- * truncated value can NEVER be mistaken for real workflow data by a downstream
362
- * `steps.<id>.output…` reference, by `akm workflow status`, or by a human
363
- * reading the row.
364
- */
365
- export const WORKFLOW_EVIDENCE_TRUNCATED_MARKER = "__akm_evidence_truncated__";
366
- function truncatedEvidenceValue(json, what, limitBytes, withPreview) {
367
- return {
368
- [WORKFLOW_EVIDENCE_TRUNCATED_MARKER]: true,
369
- reason: `${what} exceeded the ${limitBytes}-byte evidence_json persistence cap and was NOT stored. ` +
370
- `The complete value existed only in the live step result; it cannot be recovered from this row. ` +
371
- `Reduce the step's fan-out or have it emit a reference (path, id) instead of inline bulk data.`,
372
- originalBytes: utf8Bytes(json),
373
- limitBytes,
374
- ...(withPreview ? { preview: json.slice(0, WORKFLOW_EVIDENCE_TRUNCATION_PREVIEW_CHARS) } : {}),
375
- };
376
- }
377
- /**
378
- * True when `value` is the {@link TruncatedEvidenceValue} envelope persisted in
379
- * place of an over-cap evidence entry.
380
- *
381
- * A LIVE invocation never sees one: the engine threads each step's complete
382
- * in-memory evidence to the rest of its own run. A RESUMED invocation rebuilds
383
- * the downstream scope from these rows, so `exec/step-work.ts` tests every
384
- * whole-value reference with this predicate — otherwise a reference INTO the
385
- * envelope reports a generic missing property and a reference AT it silently
386
- * hands the envelope to a unit as if it were the artifact.
387
- */
388
- export function isTruncatedEvidence(value) {
389
- return (typeof value === "object" &&
390
- value !== null &&
391
- value[WORKFLOW_EVIDENCE_TRUNCATED_MARKER] === true);
392
- }
393
- /**
394
- * Bound what a step's evidence costs in ONE SQLite row.
395
- *
396
- * `buildEvidence` (exec/step-work.ts) promotes `evidence.output` UNCLIPPED by
397
- * design: gates judge the full promoted artifact and the in-memory
398
- * {@link StepExecutionResult} carries it to the caller intact. Nothing bounded
399
- * the PERSISTED form, though — a `collect` reducer over an unbounded fan-out
400
- * can serialize to hundreds of megabytes. This is the write boundary, so the
401
- * bound lives here rather than in the shared step-semantics module.
402
- *
403
- * Over-cap values are REPLACED (largest top-level entry BY UTF-8 BYTES first —
404
- * the unit the cap is measured in — until the row fits) with a
405
- * {@link TruncatedEvidenceValue} envelope. Nothing is silently shortened: a
406
- * consumer either sees the real value or sees an object whose marker key says
407
- * the data is gone. `preview` is intentionally not shaped like the original, so
408
- * an expression reaching INTO a truncated artifact (`steps.x.output.files`)
409
- * cannot quietly resolve against a half-array; a resumed run's reference is
410
- * rejected by name through {@link isTruncatedEvidence}.
411
- *
412
- * Returns the JSON to persist plus the keys that were replaced (empty in the
413
- * overwhelmingly common case, where nothing is copied or re-serialized twice).
414
- */
415
- export function clipStepEvidenceForPersistence(evidence, limitBytes = WORKFLOW_MAX_EVIDENCE_JSON_BYTES) {
416
- if (!evidence)
417
- return { json: null, truncatedKeys: [] };
418
- // Throws exactly as the previous inline `JSON.stringify` did on unserializable
419
- // evidence — that contract is unchanged. Every stringify below operates on a
420
- // subtree of a value already proven serializable here.
421
- let json = JSON.stringify(evidence);
422
- if (json === undefined)
423
- return { json: null, truncatedKeys: [] };
424
- let bytes = utf8Bytes(json);
425
- if (bytes <= limitBytes)
426
- return { json, truncatedKeys: [] };
427
- const clipped = { ...evidence };
428
- const truncatedKeys = [];
429
- // Ordered by UTF-8 BYTES, the unit the cap itself is measured in: ordering by
430
- // `json.length` (UTF-16 code units) sacrifices the char-largest key rather
431
- // than the byte-largest one, so multibyte-heavy evidence loses extra keys the
432
- // cap never required.
433
- const bySizeDesc = Object.keys(evidence)
434
- .map((key) => {
435
- const json = JSON.stringify(evidence[key]) ?? "null";
436
- return { key, json, bytes: utf8Bytes(json) };
437
- })
438
- .sort((a, b) => b.bytes - a.bytes);
439
- for (const [index, entry] of bySizeDesc.entries()) {
440
- const envelope = truncatedEvidenceValue(entry.json, `Step evidence "${entry.key}"`, limitBytes, true);
441
- clipped[entry.key] = envelope;
442
- truncatedKeys.push(entry.key);
443
- // Track the row size arithmetically from the per-key sizes already computed
444
- // for the sort, so a run of replacements costs ONE whole-object
445
- // serialization rather than one per replaced key. The total is an ESTIMATE
446
- // — a key whose value is `undefined` is charged the `"null"` the sort used
447
- // but is OMITTED from the serialized row — so it decides only WHEN to
448
- // measure. Whether the row FITS is settled by an exact serialization every
449
- // time, the last key included, so an exhausted loop falls through to the
450
- // whole-object marker on measurement rather than on drift.
451
- bytes += utf8Bytes(JSON.stringify(envelope)) - entry.bytes;
452
- if (bytes > limitBytes && index < bySizeDesc.length - 1)
453
- continue;
454
- json = JSON.stringify(clipped);
455
- bytes = utf8Bytes(json);
456
- if (bytes <= limitBytes)
457
- return { json, truncatedKeys };
458
- }
459
- // Pathological shape (so many keys that even the envelopes overflow): persist
460
- // ONE whole-object marker. Still unambiguous, still bounded.
461
- return {
462
- json: JSON.stringify(truncatedEvidenceValue(JSON.stringify(evidence), "Step evidence", limitBytes, false)),
463
- truncatedKeys: Object.keys(evidence),
464
- };
465
- }
466
358
  export async function completeWorkflowStep(input) {
467
359
  // Read the step (read-only) up front so the LLM validation gate runs OUTSIDE
468
360
  // the write transaction — a slow/hung LLM must never hold a db write lock.
@@ -470,7 +362,8 @@ export async function completeWorkflowStep(input) {
470
362
  const run = readWorkflowRun(repo, input.runId);
471
363
  const storedPlan = requireExecutableWorkflowPlan(run);
472
364
  const steps = readWorkflowRunSteps(repo, run.id);
473
- assertWorkflowSpineMatchesPlan(storedPlan, run, steps);
365
+ reconcileWorkflowSpineWithPlan(storedPlan, run, steps);
366
+ assertRunStatusMatchesSpine(run, steps);
474
367
  if (run.status !== "active") {
475
368
  throw new UsageError(`Workflow run ${run.id} is ${run.status} and cannot be updated.`);
476
369
  }
@@ -535,11 +428,13 @@ export async function completeWorkflowStep(input) {
535
428
  return withWorkflowRunsRepo((repo) => {
536
429
  let updatedRun;
537
430
  let refreshedSteps = [];
431
+ let outputWarnings;
538
432
  repo.transaction(() => {
539
433
  const run = readWorkflowRun(repo, input.runId);
540
434
  const plan = requireExecutableWorkflowPlan(run);
541
435
  const spine = readWorkflowRunSteps(repo, run.id);
542
- assertWorkflowSpineMatchesPlan(plan, run, spine);
436
+ reconcileWorkflowSpineWithPlan(plan, run, spine);
437
+ assertRunStatusMatchesSpine(run, spine);
543
438
  if (run.status !== "active") {
544
439
  throw new UsageError(`Workflow run ${run.id} is ${run.status} and cannot be updated.`);
545
440
  }
@@ -560,26 +455,18 @@ export async function completeWorkflowStep(input) {
560
455
  if (input.signal?.aborted)
561
456
  throw interruptionReason(input.signal);
562
457
  const completedAt = new Date().toISOString();
563
- // Bound the single-row cost of the promoted artifact (issue C). The
564
- // caller's in-memory evidence object is never mutated a clipped COPY is
565
- // serialized so the live step result and the gate's artifact judging
566
- // keep the complete value. The DOWNSTREAM scope keeps it only because the
567
- // engine threads this same in-memory evidence forward (`driveRun` prefers
568
- // it over the re-read row): what the clip actually costs is a LATER
569
- // invocation, which has nothing but these rows to rebuild the scope from.
570
- const persistedEvidence = clipStepEvidenceForPersistence(input.evidence);
571
- if (persistedEvidence.truncatedKeys.length > 0) {
572
- warn(`Workflow run ${run.id} step "${input.stepId}": evidence exceeded the ` +
573
- `${WORKFLOW_MAX_EVIDENCE_JSON_BYTES}-byte persistence cap; ` +
574
- `${persistedEvidence.truncatedKeys.map((k) => `"${k}"`).join(", ")} ` +
575
- `${persistedEvidence.truncatedKeys.length === 1 ? "was" : "were"} stored as a truncation marker. ` +
576
- `The rest of THIS invocation still reads the complete value, but a run resumed from these rows will ` +
577
- `fail loudly when a later step references this step's output rather than read partial data.`);
578
- }
458
+ // The promoted artifact is persisted WHOLE, unclipped (issue C): a
459
+ // step artifact that does not fit some cap used to be replaced by a
460
+ // truncation marker at this exact write, and the run looked fine right
461
+ // up until a LATER invocation (a resume, or any downstream step
462
+ // referencing it) found the marker instead of the value and failed
463
+ // permanently, with every prior paid step now unrecoverable. Persisting
464
+ // the real value here is what makes it readable again on resume.
465
+ const evidenceJson = input.evidence ? JSON.stringify(input.evidence) : null;
579
466
  repo.updateStepCompletion({
580
467
  status: input.status,
581
468
  notes: input.notes?.trim() || null,
582
- evidenceJson: persistedEvidence.json,
469
+ evidenceJson,
583
470
  summary: summary || null,
584
471
  completedAt,
585
472
  runId: run.id,
@@ -597,12 +484,9 @@ export async function completeWorkflowStep(input) {
597
484
  let outputsJson; // undefined = untouched, keep the row's existing value
598
485
  if (state.status === "completed" && plan.outputs) {
599
486
  const resolved = resolveWorkflowRunOutputs(plan, refreshedSteps);
600
- if (!resolved.ok) {
601
- throw new UsageError(`Workflow run ${run.id} completed its final step but its declared outputs could not be resolved:\n` +
602
- resolved.errors.map((e) => ` - ${e}`).join("\n"), "WORKFLOW_OUTPUT_INVALID");
603
- }
604
487
  outputsJson = JSON.stringify(resolved.outputs);
605
488
  repo.setRunOutputs(run.id, outputsJson);
489
+ outputWarnings = resolved.errors;
606
490
  }
607
491
  // Re-arm the check-in on every state change: a healthy, progressing run
608
492
  // keeps pushing the stall window forward so the directive never fires.
@@ -625,6 +509,12 @@ export async function completeWorkflowStep(input) {
625
509
  };
626
510
  });
627
511
  const detail = buildWorkflowRunDetail(repo, updatedRun, refreshedSteps);
512
+ if (outputWarnings?.length) {
513
+ const messages = outputWarnings.map((e) => `Workflow run ${input.runId} declared ${e}`);
514
+ for (const message of messages)
515
+ warn(message);
516
+ detail.warnings = [...(detail.warnings ?? []), ...messages];
517
+ }
628
518
  // #11: emit `workflow_step_completed` ONLY for a genuine `completed`
629
519
  // transition; every other non-pending status (failed/skipped/blocked)
630
520
  // carries the honest `workflow_step_updated` name. The status is ALWAYS
@@ -642,10 +532,15 @@ export async function completeWorkflowStep(input) {
642
532
  return detail;
643
533
  });
644
534
  }
645
- async function resolveRunSpecifier(repo, specifier, params, parameterFlags) {
535
+ async function resolveRunSpecifier(repo, specifier, params, parameterFlags, forceNew) {
646
536
  const hasParameters = (params && Object.keys(params).length > 0) || (parameterFlags?.length ?? 0) > 0;
647
- const explicitRun = repo.getRunById(specifier);
537
+ const explicitRun = findRunByIdOrPrefix(repo, specifier);
648
538
  if (explicitRun) {
539
+ // `--new` starts a fresh run FROM A REF; it never makes sense against a
540
+ // run id (or an id prefix), which already names the run to act on (#919).
541
+ if (forceNew) {
542
+ throw new UsageError(`--new starts a fresh run from a workflow ref; "${specifier}" already names a run id.`, "INVALID_FLAG_VALUE");
543
+ }
649
544
  if (hasParameters) {
650
545
  throw new UsageError(`Workflow parameter flags can only be used when starting a new run, not with existing run id "${specifier}".`);
651
546
  }
@@ -655,7 +550,7 @@ async function resolveRunSpecifier(repo, specifier, params, parameterFlags) {
655
550
  const exactRef = specifier.trim();
656
551
  const parsedExact = parseBundleRef(exactRef);
657
552
  const qualifiedExact = parsedExact.bundle !== undefined && parsedExact.fragment === undefined;
658
- const detached = qualifiedExact ? repo.getActiveRunRowForScope(exactRef, scopeKey) : undefined;
553
+ const detached = qualifiedExact && !forceNew ? repo.getActiveRunRowForScope(exactRef, scopeKey) : undefined;
659
554
  let ref;
660
555
  try {
661
556
  ref = await canonicalizeWorkflowSpecifier(specifier);
@@ -663,24 +558,29 @@ async function resolveRunSpecifier(repo, specifier, params, parameterFlags) {
663
558
  catch (error) {
664
559
  if (detached) {
665
560
  if (hasParameters) {
666
- throw new UsageError(`Workflow parameter flags can only be set on a new run; ${specifier} is already active.`);
561
+ throw new UsageError(`Workflow parameter flags can only be set on a new run; ${specifier} is already active.`, "INVALID_FLAG_VALUE", `Pass --new to start a separate run, or run "akm workflow abandon ${detached.id}" to free up ${specifier} first.`);
667
562
  }
668
- return { run: detached, autoStarted: false };
563
+ return { run: detached, autoStarted: false, resumed: true };
669
564
  }
670
565
  if (error instanceof NotFoundError && !specifier.includes(":") && !specifier.includes("/")) {
671
566
  throw new NotFoundError(`Workflow run or workflow "${specifier}" not found.`, "WORKFLOW_NOT_FOUND");
672
567
  }
673
568
  throw error;
674
569
  }
675
- const active = repo.getActiveRunRowForScope(await workflowRunRefSet(ref, exactRef), scopeKey);
570
+ const active = forceNew ? undefined : repo.getActiveRunRowForScope(await workflowRunRefSet(ref, exactRef), scopeKey);
676
571
  if (active) {
677
572
  if (hasParameters) {
678
- throw new UsageError(`Workflow parameter flags can only be set on a new run; ${ref} is already active.`);
573
+ throw new UsageError(`Workflow parameter flags can only be set on a new run; ${ref} is already active.`, "INVALID_FLAG_VALUE", `Pass --new to start a separate run, or run "akm workflow abandon ${active.id}" to free up ${ref} first.`);
679
574
  }
680
- return { run: active, autoStarted: false };
575
+ return { run: active, autoStarted: false, resumed: true };
681
576
  }
577
+ // `force` (#485's own escape hatch) is what lets `--new` create a second
578
+ // active run for this (ref, scope) pair instead of the concurrency guard
579
+ // refusing it — the caller explicitly asked for a fresh run, so the guard
580
+ // that exists to catch an ACCIDENTAL second run does not apply (#919).
682
581
  const started = await startWorkflowRun(ref, params ?? {}, {
683
582
  ...(parameterFlags !== undefined ? { parameterFlags } : {}),
583
+ ...(forceNew ? { force: true } : {}),
684
584
  });
685
585
  return {
686
586
  run: readWorkflowRun(repo, started.run.id),
@@ -713,6 +613,32 @@ async function workflowRunRefSet(canonicalRef, exactRef) {
713
613
  }
714
614
  return [...refs];
715
615
  }
616
+ /**
617
+ * A workflow run id (or an accepted prefix of one, #919) is hex digits and
618
+ * hyphens, 8+ characters. A workflow ref always contains at least one
619
+ * character outside that set (a `/` path segment, at minimum), so this
620
+ * never mistakes a ref for an id — matching input is ALWAYS resolved as an
621
+ * id/prefix, never falls through to ref resolution.
622
+ */
623
+ const RUN_ID_PREFIX_PATTERN = /^[0-9a-f-]{8,}$/;
624
+ /** The run `specifier` names: by exact id, or by unique id prefix (#919) when it is id-shaped; `undefined` for a workflow ref. */
625
+ function findRunByIdOrPrefix(repo, specifier) {
626
+ const exact = repo.getRunById(specifier);
627
+ if (exact || !RUN_ID_PREFIX_PATTERN.test(specifier))
628
+ return exact;
629
+ return repo.getRunById(repo.resolveRunIdPrefix(specifier));
630
+ }
631
+ /** For verbs that take a run id/prefix OR a workflow ref (`status`): the run id, or `undefined` to fall through to ref resolution. */
632
+ export async function resolveWorkflowRunTarget(specifier) {
633
+ return withWorkflowRunsRepo((repo) => findRunByIdOrPrefix(repo, specifier)?.id);
634
+ }
635
+ /** User-facing read: accepts an id prefix. Internal reads use {@link readWorkflowRun} with an exact id. */
636
+ function readWorkflowRunOrPrefix(repo, specifier) {
637
+ const run = findRunByIdOrPrefix(repo, specifier);
638
+ if (!run)
639
+ throw new NotFoundError(`Workflow run "${specifier}" not found.`, "WORKFLOW_NOT_FOUND");
640
+ return run;
641
+ }
716
642
  function readWorkflowRun(repo, runId) {
717
643
  const run = repo.getRunById(runId);
718
644
  if (!run) {
@@ -15,6 +15,7 @@ import path from "node:path";
15
15
  import { compareCodePoints, toPosix } from "../core/common.js";
16
16
  import { UsageError } from "../core/errors.js";
17
17
  import { canonicalizeWorkflowName, WORKFLOW_EXTENSIONS } from "../core/recognition-util.js";
18
+ import { warnOnce } from "../core/warn.js";
18
19
  export class WorkflowSourceRejectionError extends UsageError {
19
20
  sourcePaths;
20
21
  constructor(message, code, sourcePaths) {
@@ -34,23 +35,6 @@ export class WorkflowSourceCollisionError extends WorkflowSourceRejectionError {
34
35
  Object.setPrototypeOf(this, new.target.prototype);
35
36
  }
36
37
  }
37
- export class WorkflowSourceDomainError extends WorkflowSourceRejectionError {
38
- canonicalName;
39
- constructor(canonicalName, sourcePaths, issues, collidingSourcePaths) {
40
- const sortedPaths = [...sourcePaths].sort(compareCodePoints);
41
- const sortedCollisions = [...collidingSourcePaths].sort(compareCodePoints);
42
- const code = issues.some((issue) => issue.code === "PATH_ESCAPE_VIOLATION")
43
- ? "PATH_ESCAPE_VIOLATION"
44
- : "WORKFLOW_SOURCE_INVALID";
45
- const collisionDetail = sortedCollisions.length > 1 ? ` Valid owners also collide: ${sortedCollisions.join(", ")}.` : "";
46
- super(`Workflow "${canonicalName}" has an invalid source ownership domain across candidates: ${sortedPaths.join(", ")}. ` +
47
- `Problems: ${issues.map((issue) => issue.message).join(" ")}${collisionDetail} ` +
48
- "Every candidate in the canonical domain is rejected until all invalid or duplicate sources are removed.", code, sortedPaths);
49
- this.name = "WorkflowSourceDomainError";
50
- this.canonicalName = canonicalName;
51
- Object.setPrototypeOf(this, new.target.prototype);
52
- }
53
- }
54
38
  export class WorkflowSourceIdentityError extends UsageError {
55
39
  constructor(ref, indexedPath, authoritativePath) {
56
40
  super(`Indexed workflow source identity for "${ref}" points to ${indexedPath}, but the authoritative source is ${authoritativePath}. ` +
@@ -171,11 +155,7 @@ export function listWorkflowSourceFiles(sourceRoot, adapterId, name) {
171
155
  });
172
156
  }
173
157
  candidates.sort((left, right) => compareCodePoints(left.relativePath, right.relativePath));
174
- const { sources, issues } = inspectWorkflowSourceDomain(candidates, canonicalName, realRoot);
175
- if (issues.length > 0) {
176
- throw workflowSourceDomainError(adapterId, canonicalName, candidates, sources, issues);
177
- }
178
- return sources;
158
+ return inspectWorkflowSourceDomain(candidates, canonicalName, realRoot);
179
159
  }
180
160
  /**
181
161
  * Resolve a pre-enumerated set of authored workflow candidates in one batch.
@@ -233,47 +213,44 @@ export function resolveWorkflowSourceDomains(sourceRoot, adapterId, sourcePaths)
233
213
  for (const canonicalName of [...candidatesByName.keys()].sort(compareCodePoints)) {
234
214
  const candidates = candidatesByName.get(canonicalName) ?? [];
235
215
  candidates.sort((left, right) => compareCodePoints(left.relativePath, right.relativePath));
236
- const { sources, issues } = inspectWorkflowSourceDomain(candidates, canonicalName, realRoot);
216
+ const sources = inspectWorkflowSourceDomain(candidates, canonicalName, realRoot);
237
217
  const sourcePaths = candidates.map((candidate) => candidate.relativePath);
238
- if (issues.length > 0) {
239
- resolutions.push({
240
- canonicalName,
241
- sourcePaths,
242
- rejection: workflowSourceDomainError(adapterId, canonicalName, candidates, sources, issues),
243
- });
244
- continue;
245
- }
246
- if (sources.length > 1) {
247
- resolutions.push({
248
- canonicalName,
249
- sourcePaths,
250
- rejection: new WorkflowSourceCollisionError(adapterId === "akm" ? `workflows/${canonicalName}` : canonicalName, sources.map((source) => source.relativePath)),
251
- });
252
- continue;
253
- }
254
- resolutions.push({ canonicalName, sourcePaths, source: sources[0] });
218
+ resolutions.push({
219
+ canonicalName,
220
+ sourcePaths,
221
+ source: pickWorkflowSource(adapterId, canonicalName, sources),
222
+ });
255
223
  }
256
224
  return resolutions;
257
225
  }
258
226
  function inspectWorkflowSourceDomain(candidates, canonicalName, realRoot) {
259
227
  const sources = [];
260
- const issues = [];
261
228
  for (const candidate of candidates) {
262
229
  const inspection = inspectWorkflowSourceCandidate(candidate, canonicalName, realRoot);
263
- if (inspection.source)
230
+ if (inspection.source) {
264
231
  sources.push(inspection.source);
265
- issues.push(...inspection.issues);
232
+ continue;
233
+ }
234
+ for (const issue of inspection.issues) {
235
+ warnOnce(`workflow-source-invalid:${issue.sourcePaths.join(",")}`, issue.message);
236
+ }
266
237
  }
267
- return { sources, issues };
238
+ return sources;
268
239
  }
269
- function workflowSourceDomainError(adapterId, canonicalName, candidates, sources, issues) {
270
- return new WorkflowSourceDomainError(adapterId === "akm" ? `workflows/${canonicalName}` : canonicalName, candidates.map((candidate) => candidate.relativePath), issues, sources.map((source) => source.relativePath));
240
+ function pickWorkflowSource(adapterId, canonicalName, sources) {
241
+ if (sources.length <= 1)
242
+ return sources[0];
243
+ const winner = [...sources].sort((left, right) => left.format === right.format ? 0 : left.format === "markdown" ? -1 : 1)[0];
244
+ const shadowed = sources.filter((source) => source !== winner);
245
+ const displayName = adapterId === "akm" ? `workflows/${canonicalName}` : canonicalName;
246
+ warnOnce(`workflow-source-collision:${displayName}`, `Workflow "${displayName}" has both a .md and .yml source (${shadowed
247
+ .map((source) => source.relativePath)
248
+ .join(", ")} shadowed by ${winner?.relativePath}); using the .md source. Remove the shadowed sibling to ` +
249
+ "silence this warning.");
250
+ return winner;
271
251
  }
272
252
  function inspectWorkflowSourceCandidate(candidate, canonicalName, realRoot) {
273
253
  const issues = [];
274
- const nestedSuffix = WORKFLOW_EXTENSIONS.find((suffix) => candidate.extensionlessStem.toLowerCase().endsWith(suffix));
275
- if (nestedSuffix)
276
- issues.push(new WorkflowSourceNameError(candidate.relativePath, nestedSuffix));
277
254
  let authoredStat;
278
255
  try {
279
256
  authoredStat = fs.lstatSync(candidate.path);
@@ -327,11 +304,8 @@ function inspectWorkflowSourceCandidate(candidate, canonicalName, realRoot) {
327
304
  /** Return the sole owner, throw on a collision, or return undefined when absent. */
328
305
  export function resolveUniqueWorkflowSource(sourceRoot, adapterId, name) {
329
306
  const sources = listWorkflowSourceFiles(sourceRoot, adapterId, name);
330
- if (sources.length > 1) {
331
- const canonicalName = sources[0]?.canonicalName ?? canonicalizeWorkflowName(normalizeName(name));
332
- throw new WorkflowSourceCollisionError(adapterId === "akm" ? `workflows/${canonicalName}` : canonicalName, sources.map((source) => source.relativePath));
333
- }
334
- return sources[0];
307
+ const canonicalName = sources[0]?.canonicalName ?? canonicalizeWorkflowName(normalizeName(name));
308
+ return pickWorkflowSource(adapterId, canonicalName, sources);
335
309
  }
336
310
  /** Compare an indexed path with the single authoritative on-disk source. */
337
311
  export function assertIndexedWorkflowSourceIdentity(ref, indexedPath, authoritative) {
@@ -2,7 +2,7 @@
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 { parseBuiltinCommandAction } from "../../commands/command/builtin-action.js";
5
- import { applyPortableCommandArguments } from "../../commands/command/portable-template.js";
5
+ import { PORTABLE_ARGUMENTS_PLACEHOLDER } from "../../commands/command/portable-template.js";
6
6
  /** Lower one adapter-neutral source-IR step into the shared execution unit. */
7
7
  export function sourceStepProgramUnit(source) {
8
8
  const unit = {
@@ -32,7 +32,7 @@ export function sourceStepInstructions(source) {
32
32
  }
33
33
  if (source.commandMode === "literal")
34
34
  return action.content;
35
- return applyPortableCommandArguments(action.content, action.arguments, "inline workflow command").content;
35
+ return action.content.split(PORTABLE_ARGUMENTS_PLACEHOLDER).join(action.arguments ?? "");
36
36
  }
37
37
  if (source.uses !== undefined)
38
38
  return `Invoke local target ${source.uses}.`;
@@ -5,10 +5,8 @@
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { parseBuiltinCommandAction } from "../../commands/command/builtin-action.js";
8
- import { validatePortableCommandTemplate } from "../../commands/command/portable-template.js";
9
8
  import { parseSchedule } from "../../tasks/schedule.js";
10
9
  import { classifyWorkflowSourceUses } from "./uses.js";
11
- const TOKEN_SAFE_RUN = /^[A-Za-z0-9_./:@+=,-]+(?: [A-Za-z0-9_./:@+=,-]+)*$/;
12
10
  export class WorkflowSourceSemanticError extends Error {
13
11
  code;
14
12
  constructor(code, message) {
@@ -34,17 +32,10 @@ export function canonicalizeWorkflowRun(value) {
34
32
  if (value.includes("${{")) {
35
33
  throw new WorkflowSourceSemanticError("unsupported-github-expression", "GitHub expressions and contexts are not supported.");
36
34
  }
37
- if (value.includes("\n") || value.includes("\r")) {
38
- throw new WorkflowSourceSemanticError("unsafe-run-syntax", "Local run accepts only whitespace-separated safe tokens; shell expansion and operators are unsupported.");
35
+ if (value.includes("\0")) {
36
+ throw new WorkflowSourceSemanticError("invalid-exec-argv", "Local run may not contain NUL bytes.");
39
37
  }
40
- const canonical = value
41
- .trim()
42
- .split(/[ \t]+/)
43
- .join(" ");
44
- if (!TOKEN_SAFE_RUN.test(canonical)) {
45
- throw new WorkflowSourceSemanticError("unsafe-run-syntax", "Local run accepts only whitespace-separated safe tokens; shell expansion and operators are unsupported.");
46
- }
47
- return canonical;
38
+ return value;
48
39
  }
49
40
  export function canonicalizeWorkflowWorkingDirectory(value, workspaceRoot) {
50
41
  if (hasControlCharacter(value)) {
@@ -125,17 +116,8 @@ export function validateWorkflowBuiltinCommand(value, mode) {
125
116
  if (effectiveMode === "stored-ref") {
126
117
  throw new WorkflowSourceSemanticError("builtin-command-inputs", "Inline akm/command content cannot use commandMode stored-ref.");
127
118
  }
128
- if (effectiveMode === "literal") {
129
- if (action.arguments !== undefined) {
130
- throw new WorkflowSourceSemanticError("builtin-command-inputs", "Literal akm/command content cannot declare arguments because no substitution occurs.");
131
- }
132
- return action;
133
- }
134
- try {
135
- validatePortableCommandTemplate(action.content, "inline workflow command");
136
- }
137
- catch (cause) {
138
- throw new WorkflowSourceSemanticError("builtin-command-inputs", cause instanceof Error ? cause.message : "Invalid portable command template.");
119
+ if (effectiveMode === "literal" && action.arguments !== undefined) {
120
+ throw new WorkflowSourceSemanticError("builtin-command-inputs", "Literal akm/command content cannot declare arguments because no substitution occurs.");
139
121
  }
140
122
  return action;
141
123
  }
@@ -303,6 +303,26 @@ one akm version reading a given `state.db` at a time; do not alternate
303
303
  versions against the same state directory, and do not downgrade below
304
304
  0.9.2 once a 0.9.2-or-later akm has recorded task history there.
305
305
 
306
+ ## Harness id rename: `claude-code` -> `claude`
307
+
308
+ 0.9.2 also renamed the Claude Code harness id from `claude-code` to
309
+ `claude` — the id used for both agent dispatch and the per-session
310
+ extraction ledger (`state.db`'s `extract_sessions_seen.harness` and
311
+ `workflow_runs.agent_harness`). The 0.9.2 release did not carry a state
312
+ migration for this rename, so any row a pre-0.9.2 akm wrote stayed
313
+ keyed under `claude-code`, invisible to anything querying by the new
314
+ name — a script or dashboard filtering `extract_sessions_seen` or
315
+ `workflow_runs` on `harness = 'claude-code'` (or
316
+ `agent_harness = 'claude-code'`) sees those rows disappear from that
317
+ query, not deleted, once you're on a release carrying the 0.9.12 fix.
318
+ 0.9.12 adds state migration
319
+ `027-extract-sessions-seen-harness-rename`, which runs automatically
320
+ on the next managed `state.db` open (no separate command needed) and
321
+ renames every such row to `claude` in place — conflict-tolerant
322
+ against a session already recorded under `claude`, which is kept as
323
+ the authoritative row. After upgrading to 0.9.12 or later, re-point
324
+ any external query at `harness = 'claude'` / `agent_harness = 'claude'`.
325
+
306
326
  ## Workflow cutover
307
327
 
308
328
  Markdown `.md` and GitHub-shaped `.yml` are peer workflow source formats in