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
@@ -49,6 +49,23 @@ export function classifyImproveAction(mode) {
49
49
  return assertNever(mode);
50
50
  }
51
51
  }
52
+ /**
53
+ * #912 — skip reasons that mean infrastructure failed to run the extraction,
54
+ * not that a session was legitimately uninteresting. `already_extracted`,
55
+ * `too_short`, and `triaged_out` are the ledger and pre-filter doing their
56
+ * job and are deliberately excluded; `malformed_model_output` already gets
57
+ * its own per-session forwarding into the envelope's `warnings[]` and is
58
+ * excluded here to avoid double-reporting the same failure two ways.
59
+ *
60
+ * Typed against `ExtractedSessionResult["skipReason"]` so a future reason
61
+ * cannot be added to one union without a compiler error surfacing here.
62
+ */
63
+ export const EXTRACT_INFRASTRUCTURE_SKIP_REASONS = [
64
+ "llm_unavailable",
65
+ "read_failed",
66
+ "exception",
67
+ "locked_concurrent",
68
+ ];
52
69
  /** Upper bound on retained sample refs PER reason in {@link DistillSkippedAggregate}. */
53
70
  export const DISTILL_SKIPPED_SAMPLE_CAP_PER_REASON = 3;
54
71
  /**
@@ -61,15 +61,9 @@ import { isRecord } from "./common.js";
61
61
  * same schema tree, so they share one bound.
62
62
  */
63
63
  const MAX_DEFINITION_DEPTH = 64;
64
- /** Total (schema node × value node) visits one {@link validateJsonSchemaSubset} call may make. */
65
- const MAX_VALIDATION_NODES = 100_000;
66
64
  export function validateJsonSchemaSubset(value, schema, options) {
67
65
  const errors = [];
68
- const budget = { nodes: MAX_VALIDATION_NODES };
69
- validateNode(value, schema, "$", { errors, budget, depth: 0, redactValues: options?.redactValues ?? false });
70
- if (budget.nodes < 0) {
71
- errors.push(`$: schema evaluation exceeded the limit of ${MAX_VALIDATION_NODES} checks and was stopped`);
72
- }
66
+ validateNode(value, schema, "$", { errors, depth: 0, redactValues: options?.redactValues ?? false });
73
67
  return errors;
74
68
  }
75
69
  /** Human-readable list of the keywords {@link validateJsonSchemaSubset} enforces (for error messages). */
@@ -372,10 +366,6 @@ function validateNode(value, schema, path, ctx) {
372
366
  errors.push(`${path}: schema nesting exceeds the depth limit of ${MAX_DEFINITION_DEPTH}`);
373
367
  return;
374
368
  }
375
- // Fail CLOSED: a truncated evaluation never returns "valid" — the counter
376
- // going negative is what the wrapper turns into a top-level error.
377
- if (--ctx.budget.nodes < 0)
378
- return;
379
369
  const actual = typeOf(value);
380
370
  const declared = schema.type;
381
371
  if (typeof declared === "string" || Array.isArray(declared)) {
@@ -10,6 +10,20 @@ import { ConfigError } from "./errors.js";
10
10
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "./file-lock.js";
11
11
  import { getMaintenanceBarrierPath } from "./paths.js";
12
12
  const heldBarrierContext = new AsyncLocalStorage();
13
+ /**
14
+ * The barrier is meant to be held only for the short critical section that
15
+ * registers one lock/lease/activity — a process that still holds it past
16
+ * this age is wedged (crashed mid-section, deadlocked, killed without
17
+ * cleanup), not doing legitimate long-running work. Without an age bound, a
18
+ * probe only reclaims a lock whose holder PID has verifiably died; a wedged
19
+ * — but still-alive — holder (or a PID a container/namespace boundary
20
+ * reused, making `isProcessAlive` see the wrong process as live) locked
21
+ * every other akm invocation out of ANY maintenance registration forever,
22
+ * with no recovery but killing the holder by hand. 5 minutes matches the
23
+ * stale-lock window already used for the improve extract-session lock
24
+ * (`commands/improve/extract.ts`).
25
+ */
26
+ const MAINTENANCE_BARRIER_STALE_AFTER_MS = 5 * 60 * 1000;
13
27
  /**
14
28
  * Serialize the short critical section that creates each long-lived AKM lock,
15
29
  * lease, or state activity. The operation keeps its own ownership record; this
@@ -23,7 +37,7 @@ export function tryAcquireMaintenanceBarrier() {
23
37
  if (ownership) {
24
38
  return () => releaseLock(ownership);
25
39
  }
26
- const probe = probeLock(lockPath);
40
+ const probe = probeLock(lockPath, { staleAfterMs: MAINTENANCE_BARRIER_STALE_AFTER_MS });
27
41
  if (probe.state !== "stale" || !reclaimStaleLock(lockPath, probe))
28
42
  return undefined;
29
43
  }
@@ -33,7 +47,8 @@ export function acquireMaintenanceBarrier() {
33
47
  const release = tryAcquireMaintenanceBarrier();
34
48
  if (release)
35
49
  return release;
36
- throw new ConfigError(`AKM maintenance is in progress (barrier ${getMaintenanceBarrierPath()}); retry after it completes.`, "INVALID_CONFIG_FILE");
50
+ throw new ConfigError(`AKM maintenance is in progress (barrier ${getMaintenanceBarrierPath()}); retry after it completes. ` +
51
+ `A sentinel older than ${MAINTENANCE_BARRIER_STALE_AFTER_MS / 60_000} minute(s) is reclaimed automatically on the next attempt.`, "INVALID_CONFIG_FILE");
37
52
  }
38
53
  export function withMaintenanceStartBarrier(run) {
39
54
  if (heldBarrierContext.getStore()?.active)
@@ -13,6 +13,7 @@ import path from "node:path";
13
13
  import { shortHash } from "./bundle-id.js";
14
14
  import { ConfigError } from "./errors.js";
15
15
  import { IS_WINDOWS } from "./platform.js";
16
+ import { warnOnce } from "./warn.js";
16
17
  /**
17
18
  * Returns true when the current process appears to be running under
18
19
  * `bun test` (either via the BUN_TEST sentinel Bun sets on the test
@@ -424,7 +425,6 @@ export function assertSafeStashDir(stashDir, env = process.env) {
424
425
  const SYSTEM_ROOTS = new Set([
425
426
  "/etc",
426
427
  "/var",
427
- "/var/tmp",
428
428
  "/usr",
429
429
  "/usr/local",
430
430
  "/opt",
@@ -448,6 +448,9 @@ export function assertSafeStashDir(stashDir, env = process.env) {
448
448
  if (SYSTEM_ROOTS.has(resolved)) {
449
449
  throw new ConfigError(`Refusing stashDir at system path (${resolved}). Pick a path inside your home directory.`, "UNSAFE_STASH_DIR");
450
450
  }
451
+ if (resolved === "/var/tmp") {
452
+ warnOnce("stash-dir:var-tmp", `Stash directory is at ${resolved}, a shared scratch directory system cleanup jobs may periodically empty; using it as configured.`);
453
+ }
451
454
  // User home — exact match only. Subdirs (~/akm, ~/work/stash) are fine.
452
455
  // Check BOTH the env-controlled home and the OS-reported home, so the
453
456
  // refusal can't be bypassed by unsetting HOME, and so it still fires
@@ -465,27 +468,21 @@ export function assertSafeStashDir(stashDir, env = process.env) {
465
468
  catch {
466
469
  // os.homedir() can throw on misconfigured systems; ignore.
467
470
  }
468
- const HIDDEN_USER_PARENTS = [
469
- ".config",
470
- ".local",
471
- ".cache",
472
- ".ssh",
473
- ".gnupg",
474
- ".aws",
475
- ".kube",
476
- ".docker",
477
- "Documents",
478
- "Downloads",
479
- "AppData",
480
- ];
471
+ const CREDENTIAL_USER_PARENTS = [".config", ".local", ".cache", ".ssh", ".gnupg", ".aws", ".kube", ".docker"];
472
+ const PLAIN_USER_DATA_PARENTS = ["Documents", "Downloads", "AppData"];
481
473
  for (const home of candidateHomes) {
482
474
  if (resolved === home) {
483
475
  throw new ConfigError(`Refusing stashDir at your home directory (${resolved}). Pick a subdirectory like ~/akm.`, "UNSAFE_STASH_DIR");
484
476
  }
485
- for (const sub of HIDDEN_USER_PARENTS) {
477
+ for (const sub of CREDENTIAL_USER_PARENTS) {
486
478
  if (resolved === path.join(home, sub)) {
487
479
  throw new ConfigError(`Refusing stashDir at sensitive user directory (${resolved}). Pick a subdirectory or a dedicated workspace.`, "UNSAFE_STASH_DIR");
488
480
  }
489
481
  }
482
+ for (const sub of PLAIN_USER_DATA_PARENTS) {
483
+ if (resolved === path.join(home, sub)) {
484
+ warnOnce(`stash-dir:plain-user-data:${sub}`, `Stash directory is at ${resolved}, your ${sub} folder; using it as configured, though it is usually a large, unrelated-content directory to index.`);
485
+ }
486
+ }
490
487
  }
491
488
  }
@@ -37,6 +37,7 @@ export const STATE_MIGRATION_SAFETY_BY_ID = Object.freeze({
37
37
  "024-workflow-run-outputs": "additive",
38
38
  "025-task-history-vocabulary-backfill": "data-preserving-rebuild",
39
39
  "026-proposals-strip-legacy-fragment-refs": "data-preserving-rebuild",
40
+ "027-extract-sessions-seen-harness-rename": "data-preserving-rebuild",
40
41
  });
41
42
  export const STATE_MIGRATIONS = [
42
43
  // ── Migration 001 — initial schema ──────────────────────────────────────────
@@ -1149,6 +1150,33 @@ export const STATE_MIGRATIONS = [
1149
1150
  WHERE ref LIKE '%#%';
1150
1151
  `,
1151
1152
  },
1153
+ // ── Migration 027 — claude-code -> claude harness rename (#915) ──
1154
+ //
1155
+ // The 0.9.2 rename shipped without a data migration, so rows written under
1156
+ // the old key were invisible to every reader keyed on "claude".
1157
+ //
1158
+ // `extract_sessions_seen` has PRIMARY KEY (harness, session_id), so a
1159
+ // session already recorded under "claude" (written by a run that really
1160
+ // happened post-rename) collides with its "claude-code" counterpart on
1161
+ // rename. `UPDATE OR IGNORE` keeps the newer "claude" row exactly as it
1162
+ // was — the older row's outcome is superseded, not more correct — and the
1163
+ // trailing DELETE drops that now-unreachable duplicate so the "claude-code"
1164
+ // key space is fully empty afterwards, not just mostly-migrated.
1165
+ //
1166
+ // `workflow_runs.agent_harness` (migration 020) has no uniqueness
1167
+ // constraint on that column, so a plain UPDATE is sufficient there. Both
1168
+ // statements are kept in one migration so the rename is a single ledger
1169
+ // event. `improve_runs.result_json` is deliberately untouched: the harness
1170
+ // name embedded in those JSON blobs is reporting data about a past run, not
1171
+ // a lookup key, so rewriting it would not fix anything a reader depends on.
1172
+ {
1173
+ id: "027-extract-sessions-seen-harness-rename",
1174
+ up: `
1175
+ UPDATE OR IGNORE extract_sessions_seen SET harness = 'claude' WHERE harness = 'claude-code';
1176
+ DELETE FROM extract_sessions_seen WHERE harness = 'claude-code';
1177
+ UPDATE workflow_runs SET agent_harness = 'claude' WHERE agent_harness = 'claude-code';
1178
+ `,
1179
+ },
1152
1180
  ];
1153
1181
  assertMigrationRegistry(STATE_MIGRATIONS);
1154
1182
  function assertStateMigrationSafetyRegistry() {
@@ -69,9 +69,11 @@ import { sleepSync } from "../runtime.js";
69
69
  import { openDatabase } from "../storage/database.js";
70
70
  import { assertMigrationLedger } from "../storage/engines/sqlite-migrations.js";
71
71
  import { openManagedDatabase, withManagedDb } from "../storage/managed-db.js";
72
+ import { pkgVersion } from "../version.js";
72
73
  import { acquireMaintenanceActivitySync } from "./maintenance-barrier.js";
73
74
  import { getDataDir } from "./paths.js";
74
75
  import { runMigrations, STATE_MIGRATIONS } from "./state/migrations.js";
76
+ import { warnOnce } from "./warn.js";
75
77
  // ── Path helper ──────────────────────────────────────────────────────────────
76
78
  /**
77
79
  * Default path: `<dataDir>/state.db`.
@@ -370,6 +372,25 @@ function createHistoricalStateSafetyCopy(source, migrationId) {
370
372
  * matches the value used in openDatabase() for index.db; 5 s proved too
371
373
  * narrow when a post-inference reindex overlapped a parallel event write.
372
374
  */
375
+ /**
376
+ * Tell the operator once when state.db was migrated by a newer akm than the
377
+ * one running. The open proceeds: every migration this binary knows is already
378
+ * applied, so it reads and writes the tables it knows. Commands that depend on
379
+ * something a later migration changed may still report less than the truth,
380
+ * which is why this is said out loud rather than swallowed.
381
+ */
382
+ function warnNewerStateLedger(ledger) {
383
+ if (ledger.status !== "newer")
384
+ return;
385
+ warnOnce("state-db-newer-ledger", `[state.db] This akm (v${pkgVersion}) is older than the state database: ${ledger.detail}. ` +
386
+ "Continuing with the schema this version knows; upgrade akm if its output looks incomplete.");
387
+ }
388
+ function unversionedDatabaseHasNoTables(db) {
389
+ const tables = db
390
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != ?")
391
+ .get("schema_migrations");
392
+ return !tables;
393
+ }
373
394
  export function openStateDatabase(dbPath, options) {
374
395
  const canonicalPath = getStateDbPath();
375
396
  const resolvedPath = dbPath ?? canonicalPath;
@@ -392,6 +413,7 @@ export function openStateDatabase(dbPath, options) {
392
413
  let existingSource;
393
414
  let openedDb;
394
415
  let existingUnversionedDatabase = false;
416
+ let treatUnversionedAsFresh = false;
395
417
  let stateSafetyCopyCreated = false;
396
418
  try {
397
419
  fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
@@ -404,7 +426,12 @@ export function openStateDatabase(dbPath, options) {
404
426
  try {
405
427
  preflight.exec("PRAGMA busy_timeout = 30000");
406
428
  const ledger = assertMigrationLedger(preflight, STATE_MIGRATIONS);
429
+ warnNewerStateLedger(ledger);
407
430
  existingUnversionedDatabase = ledger.migrationIds.length === 0;
431
+ if (existingUnversionedDatabase && unversionedDatabaseHasNoTables(preflight)) {
432
+ existingUnversionedDatabase = false;
433
+ treatUnversionedAsFresh = true;
434
+ }
408
435
  if (existingUnversionedDatabase && !options?.allowHistoricalDestructiveStateUpgrade) {
409
436
  throw new Error("Refusing to migrate an existing unversioned state.db during an ordinary managed open. " +
410
437
  "Run `akm upgrade` (or `akm migrate apply`) to create a verified snapshot before migration 001 " +
@@ -421,7 +448,7 @@ export function openStateDatabase(dbPath, options) {
421
448
  pragmas: { dataDir: path.dirname(resolvedPath) },
422
449
  init: (db) => {
423
450
  runMigrations(db, {
424
- freshDatabase: !!freshReservation,
451
+ freshDatabase: !!freshReservation || treatUnversionedAsFresh,
425
452
  existingUnversionedDatabase,
426
453
  allowHistoricalDestructiveStateUpgrade: options?.allowHistoricalDestructiveStateUpgrade,
427
454
  beforeExistingUnversionedStateMigration: options?.allowHistoricalDestructiveStateUpgrade
@@ -37,7 +37,7 @@ import { existingFileMode, isWithin, resolveStashDir, writeFileAtomic } from "./
37
37
  import { bundleContentRoot, resolveConfiguredSources } from "./config/config.js";
38
38
  import { ConfigError, UsageError } from "./errors.js";
39
39
  import { sanitizeCommitMessage } from "./git-message.js";
40
- import { warn } from "./warn.js";
40
+ import { warn, warnOnce } from "./warn.js";
41
41
  import { recordWrittenPath } from "./write-provenance.js";
42
42
  /**
43
43
  * Source kinds that the loader is allowed to mark `writable: true`. Anything
@@ -872,10 +872,10 @@ export function prepareWriteTargetForMutation(target, options = {}) {
872
872
  }
873
873
  const upstream = inspectGitUpstream(repoPath);
874
874
  if (upstream.behind > 0) {
875
- throw new UsageError(`Writable Git target "${target.source.name}" is behind ${upstream.upstream}; run \`akm bundle update ${target.source.name}\` before writing.`, "INVALID_FLAG_VALUE");
875
+ warnOnce(`write-source:git-behind:${realRepoPath}`, `Writable Git target "${target.source.name}" is ${upstream.behind} commit(s) behind ${upstream.upstream}; writing anyway. Run \`akm bundle update ${target.source.name}\` to catch up.`);
876
876
  }
877
- if (upstream.ahead > 0 && options.allowAhead !== true) {
878
- throw new UsageError(`Writable Git target "${target.source.name}" has unpushed commits; push or reconcile them before AKM writes another commit.`, "INVALID_FLAG_VALUE");
877
+ if (upstream.ahead > 0) {
878
+ warnOnce(`write-source:git-ahead:${realRepoPath}`, `Writable Git target "${target.source.name}" has ${upstream.ahead} unpushed commit(s); writing another on top. Push or reconcile them when convenient.`);
879
879
  }
880
880
  return {
881
881
  ...target,
@@ -997,7 +997,7 @@ const WINDOWS_RESERVED_DEVICE_NAMES = new Set([
997
997
  function resolveAssetFilePath(source, ref) {
998
998
  const basename = path.posix.basename(ref.name.replaceAll("\\", "/")).replace(/\.md$/i, "").toLowerCase();
999
999
  if (basename === "index" || basename === "log") {
1000
- throw new UsageError(`Reserved concept name "${basename}" cannot be written.`, "INVALID_FLAG_VALUE");
1000
+ warnOnce(`write-source:reserved-basename:${basename}`, `Concept name "${basename}" collides with a reserved word some tooling treats specially; writing it anyway.`);
1001
1001
  }
1002
1002
  // Windows resolves these names as DEVICES no matter the directory or the
1003
1003
  // extension, so `CON.md` is not a file — a write goes to the console and a
@@ -1005,7 +1005,7 @@ function resolveAssetFilePath(source, ref) {
1005
1005
  // portable: an asset authored on Linux must not become unopenable when the
1006
1006
  // same bundle is used on Windows.
1007
1007
  if (WINDOWS_RESERVED_DEVICE_NAMES.has(basename)) {
1008
- throw new UsageError(`Asset name "${basename}" is a reserved Windows device name and cannot be written.`, "INVALID_FLAG_VALUE");
1008
+ warnOnce(`write-source:windows-device-name:${basename}`, `Asset name "${basename}" is a reserved Windows device name; writing it anyway, but this bundle will not be portable to Windows.`);
1009
1009
  }
1010
1010
  const typeDir = stashDirFor(ref.type);
1011
1011
  if (!typeDir) {
@@ -33,6 +33,7 @@ import { rethrowIfTestIsolationError } from "../core/errors.js";
33
33
  import { getDbPath } from "../core/paths.js";
34
34
  import { warn } from "../core/warn.js";
35
35
  import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
36
+ import { isCanonicalIndexGeneration } from "../storage/repositories/index-entry-schema.js";
36
37
  let guardSettled = false;
37
38
  /** TEST-ONLY: re-arm the once-per-process guard between cases. */
38
39
  export function resetBundleIdentityGuardForTests() {
@@ -47,6 +48,8 @@ function indexBundlePrefixes(dbPath) {
47
48
  db = openReadonlyExistingDatabase(dbPath);
48
49
  if (!db)
49
50
  return undefined;
51
+ if (!isCanonicalIndexGeneration(db))
52
+ return undefined;
50
53
  return db
51
54
  .prepare("SELECT DISTINCT bundle_id AS b FROM entries WHERE bundle_id IS NOT NULL AND bundle_id != ''")
52
55
  .all().map((row) => row.b);
@@ -26,6 +26,7 @@ import { classifyPathAccess } from "../core/path-access.js";
26
26
  import { getDbPath } from "../core/paths.js";
27
27
  import { assertIndexPathReadable, closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
28
28
  import { getEntryCount, getIndexedFilePaths } from "../storage/repositories/index-entries-repository.js";
29
+ import { isCanonicalIndexGeneration } from "../storage/repositories/index-entry-schema.js";
29
30
  import { getMeta } from "../storage/repositories/index-meta-repository.js";
30
31
  import { warnOnBundleRenameDrift } from "./bundle-identity-guard.js";
31
32
  function getIndexableFiles(root, spec) {
@@ -118,6 +119,8 @@ export function isIndexStale(stashDir) {
118
119
  let db;
119
120
  try {
120
121
  db = openExistingDatabase(dbPath);
122
+ if (!isCanonicalIndexGeneration(db))
123
+ return true;
121
124
  const entryCount = getEntryCount(db);
122
125
  if (entryCount === 0)
123
126
  return true;
@@ -164,6 +167,8 @@ function indexCanServeStash(stashDir) {
164
167
  let db;
165
168
  try {
166
169
  db = openExistingDatabase(dbPath);
170
+ if (!isCanonicalIndexGeneration(db))
171
+ return false;
167
172
  if (getEntryCount(db) === 0)
168
173
  return false;
169
174
  const storedStashDir = getMeta(db, "stashDir");
@@ -1770,9 +1770,17 @@ async function lookupBundleRefWithResolutionUsing(ref, openLookupDatabase) {
1770
1770
  const entry = readLookupEntry(db, id, ref.conceptId, source.path);
1771
1771
  if (entry) {
1772
1772
  if (owner.workflowSource) {
1773
- assertIndexedWorkflowSourceIdentity(inputRef, entry.filePath, owner.workflowSource);
1774
- if (entry.adapterId !== adapterId) {
1775
- throw new WorkflowSourceIdentityError(inputRef, entry.filePath, owner.path);
1773
+ try {
1774
+ assertIndexedWorkflowSourceIdentity(inputRef, entry.filePath, owner.workflowSource);
1775
+ if (entry.adapterId !== adapterId) {
1776
+ throw new WorkflowSourceIdentityError(inputRef, entry.filePath, owner.path);
1777
+ }
1778
+ }
1779
+ catch (error) {
1780
+ if (!(error instanceof WorkflowSourceIdentityError))
1781
+ throw error;
1782
+ warn(`${error.message} Falling back to the physical owner.`);
1783
+ return { entry: null, owner, ...(indexError === undefined ? {} : { indexError }) };
1776
1784
  }
1777
1785
  }
1778
1786
  else if (entry.adapterId !== adapterId || !indexedPathMatchesOwner(entry.filePath, owner)) {
@@ -7,6 +7,7 @@ import { adapterForId } from "../../core/adapter/registry.js";
7
7
  import { compareCodePoints, hasErrnoCode, isWithin } from "../../core/common.js";
8
8
  import { ConfigError, UsageError } from "../../core/errors.js";
9
9
  import { canonicalizeWorkflowName } from "../../core/recognition-util.js";
10
+ import { warnOnce } from "../../core/warn.js";
10
11
  import { resolveUniqueWorkflowSource, workflowNameForConceptId, } from "../../workflows/source-files.js";
11
12
  import { buildFileContext } from "../walk/file-context.js";
12
13
  const CONTENT_READ_REQUIRED = Symbol("adapter ownership probe requires content");
@@ -123,7 +124,7 @@ function claimsWithoutContent(adapter, component, owner) {
123
124
  * adapter supplies its own read placements and is probed with a byte-denying
124
125
  * FileContext so path-level abstention remains authoritative.
125
126
  */
126
- export function resolveAdapterConceptOwner(sourcePath, adapterId, conceptId) {
127
+ export function resolveAdapterConceptOwner(sourcePath, adapterId, conceptId, options) {
127
128
  const adapter = adapterForId(adapterId);
128
129
  const normalized = normalizedConceptId(conceptId);
129
130
  if (!adapter || !normalized)
@@ -185,9 +186,19 @@ export function resolveAdapterConceptOwner(sourcePath, adapterId, conceptId) {
185
186
  if (claimsWithoutContent(adapter, component, candidate))
186
187
  ownersByIdentity.set(identity, candidate);
187
188
  }
188
- const owners = [...ownersByIdentity.values()].filter((owner) => owner.conceptId === resolutionConceptId);
189
+ const owners = [...ownersByIdentity.values()]
190
+ .filter((owner) => owner.conceptId === resolutionConceptId)
191
+ .sort((left, right) => compareCodePoints(left.path, right.path));
189
192
  if (owners.length > 1) {
190
- throw new AdapterConceptCollisionError(adapterId, resolutionConceptId, owners.map((owner) => path.relative(sourcePath, owner.path).replaceAll("\\", "/")));
193
+ if (options?.mode !== "read") {
194
+ throw new AdapterConceptCollisionError(adapterId, resolutionConceptId, owners.map((owner) => path.relative(sourcePath, owner.path).replaceAll("\\", "/")));
195
+ }
196
+ const [winner, ...losers] = owners;
197
+ warnOnce(`adapter-concept-collision:${adapterId}:${resolutionConceptId}`, `Adapter "${adapterId}" has multiple physical owners for "${resolutionConceptId}": ` +
198
+ `${owners.map((owner) => path.relative(sourcePath, owner.path).replaceAll("\\", "/")).join(", ")}. ` +
199
+ `Reading "${path.relative(sourcePath, winner.path).replaceAll("\\", "/")}" and ignoring ` +
200
+ `${losers.map((owner) => path.relative(sourcePath, owner.path).replaceAll("\\", "/")).join(", ")}.`);
201
+ return winner;
191
202
  }
192
203
  return owners[0];
193
204
  }
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import { parseBundleRef } from "../../core/asset/asset-ref.js";
7
7
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
8
8
  import { asNonEmptyString } from "../../core/common.js";
9
- import { isVerbose, warn, warnVerbose } from "../../core/warn.js";
9
+ import { isVerbose, warn } from "../../core/warn.js";
10
10
  export const SCOPE_KEYS = ["user", "agent", "run", "channel"];
11
11
  // ── Quality semantics (v1 spec §4.2) ────────────────────────────────────────
12
12
  /**
@@ -835,10 +835,17 @@ export function isEnrichmentComplete(entry) {
835
835
  // ── Native Markdown search projection ──────────────────────────────────────
836
836
  /**
837
837
  * Maximum native Markdown prose carried by the low-weight `content` field.
838
- * Structured fields remain separate and higher-weighted; this bound prevents
839
- * large documents from dominating index size or embedding inputs.
838
+ *
839
+ * Raised far past any real authored document:
840
+ * this used to sit at 16_384 chars, tight enough that ordinary long-form
841
+ * skills/knowledge docs lost their tail from both FTS and the embedding
842
+ * input with no visible signal (the cut was reported via `warnVerbose`,
843
+ * silent unless `AKM_VERBOSE` was set). The remaining bound exists only to
844
+ * stop a truly pathological single file (a committed data dump, a decompressed
845
+ * log) from ballooning index size — not to shave real content — so a caller
846
+ * that hits it is always told, unconditionally.
840
847
  */
841
- export const MARKDOWN_CONTENT_MAX_CHARS = 16_384;
848
+ export const MARKDOWN_CONTENT_MAX_CHARS = 1_000_000;
842
849
  /**
843
850
  * Locate a leading nested frontmatter block in a body: up to three blank
844
851
  * lines, then a `---` line, closed by a later `---` line. Mirrors the
@@ -1220,7 +1227,11 @@ export function applyPreContributorFields(entry, file, ctx, pkgMeta) {
1220
1227
  entry.content = contentProjection;
1221
1228
  if (truncationInfo.truncated) {
1222
1229
  entry.contentTruncated = true;
1223
- warnVerbose(`${file}: indexed content truncated to ${MARKDOWN_CONTENT_MAX_CHARS} chars`);
1230
+ // Unconditional, not warnVerbose: this bound now sits far past any
1231
+ // real document, so tripping it means
1232
+ // something unusual is in the bundle and the operator should see
1233
+ // it without having to pass --verbose.
1234
+ warn(`${file}: indexed content truncated to ${MARKDOWN_CONTENT_MAX_CHARS} chars`);
1224
1235
  }
1225
1236
  }
1226
1237
  }
@@ -1,18 +1,6 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
- /**
5
- * Per-field search text extraction for FTS5 indexing.
6
- *
7
- * Extracted from indexer.ts to break the circular dependency:
8
- * db.ts -> indexer.ts -> db.ts
9
- *
10
- * This module imports only from metadata.ts (for the IndexDocument type),
11
- * so it can be safely imported by both db.ts and indexer.ts.
12
- */
13
- import { warnVerbose } from "../../core/warn.js";
14
- /** Structured metadata plus bounded body text supplied to embedding providers. */
15
- export const SEARCH_TEXT_MAX_CHARS = 8_192;
16
4
  /**
17
5
  * Return per-field search text for multi-column FTS5 indexing.
18
6
  *
@@ -90,25 +78,8 @@ export function buildSearchText(entry) {
90
78
  const structured = [fields.name, fields.description, fields.tags, fields.hints]
91
79
  .filter((field) => field.length > 0)
92
80
  .join(" ");
93
- if (structured.length >= SEARCH_TEXT_MAX_CHARS) {
94
- warnVerbose(`${entry.ref ?? entry.name}: search text truncated to ${SEARCH_TEXT_MAX_CHARS} chars (content dropped entirely)`);
95
- return truncateUnicodeSafe(structured, SEARCH_TEXT_MAX_CHARS);
96
- }
97
81
  if (!fields.content)
98
82
  return structured;
99
83
  const separator = structured ? " " : "";
100
- const remaining = SEARCH_TEXT_MAX_CHARS - structured.length - separator.length;
101
- if (fields.content.length > remaining) {
102
- warnVerbose(`${entry.ref ?? entry.name}: search text truncated to ${SEARCH_TEXT_MAX_CHARS} chars`);
103
- }
104
- return `${structured}${separator}${truncateUnicodeSafe(fields.content, remaining)}`;
105
- }
106
- function truncateUnicodeSafe(text, maxChars) {
107
- if (text.length <= maxChars)
108
- return text;
109
- let cut = text.slice(0, maxChars);
110
- const lastCode = cut.charCodeAt(cut.length - 1);
111
- if (lastCode >= 0xd800 && lastCode <= 0xdbff)
112
- cut = cut.slice(0, -1);
113
- return cut.trimEnd();
84
+ return `${structured}${separator}${fields.content}`;
114
85
  }
@@ -8,6 +8,7 @@ import { deepMergeConfig } from "../../core/config/deep-merge.js";
8
8
  import { ConfigError } from "../../core/errors.js";
9
9
  import { formatExtraParamsIssue, validateExtraParams } from "../../core/extra-params.js";
10
10
  import { collectSensitiveValues } from "../../core/redaction.js";
11
+ import { warn } from "../../core/warn.js";
11
12
  import { getHarness } from "../harnesses/index.js";
12
13
  import { DEFAULT_AGENT_TIMEOUT_MS, DEFAULT_LLM_TIMEOUT_MS } from "./config.js";
13
14
  import { getBuiltinAgentProfile } from "./profiles.js";
@@ -205,7 +206,20 @@ export function resolveLlmEngineUse(config, layers, options = {}) {
205
206
  }
206
207
  const engine = resolveEngineConfig(name, config);
207
208
  if (engine.kind !== "llm") {
208
- throw new ConfigError(`Engine "${name}" is not an LLM engine.`, "INVALID_CONFIG_FILE");
209
+ const defaults = ownValue(config, "defaults");
210
+ const fallbackName = ownValue(engine, "llmEngine") ?? (defaults ? ownValue(defaults, "llmEngine") : undefined);
211
+ const fallbackEngine = fallbackName ? resolveEngineConfig(fallbackName, config) : undefined;
212
+ if (!fallbackEngine || fallbackEngine.kind !== "llm") {
213
+ if (options.optional)
214
+ return undefined;
215
+ throw new ConfigError(fallbackName
216
+ ? `Engine "${name}" is not an LLM engine, and its llmEngine fallback "${fallbackName}" is not one either.`
217
+ : `Engine "${name}" is not an LLM engine, and has no llmEngine fallback configured.`, "INVALID_CONFIG_FILE");
218
+ }
219
+ warn(`[akm] Engine "${name}" is an agent engine, not an LLM engine; using its llmEngine "${fallbackName}" instead.`);
220
+ return options.optional
221
+ ? resolveLlmEngineUse(config, [{ engine: fallbackName }], { optional: true })
222
+ : resolveLlmEngineUse(config, [{ engine: fallbackName }]);
209
223
  }
210
224
  let connection = rawLlmConnection(engine);
211
225
  for (const layer of layers) {
@@ -9,6 +9,7 @@ import { writeFileAtomic } from "../../core/common.js";
9
9
  import { ENGINE_NAME_PATTERN_SOURCE } from "../../core/config/engine-semantics.js";
10
10
  import { ConfigError, UsageError } from "../../core/errors.js";
11
11
  import { getConfigDir } from "../../core/paths.js";
12
+ import { warnOnce } from "../../core/warn.js";
12
13
  import { cloneExecutionJsonObject } from "../../execution/json.js";
13
14
  /**
14
15
  * Installed and operator-owned model intent aliases (#802 / WP2).
@@ -218,10 +219,16 @@ export function resolveModelMapAlias(input, engine, map) {
218
219
  const profile = ownValue(tier, selectedEngine);
219
220
  if (profile !== undefined)
220
221
  return selectionFromProfile(input, profile);
221
- if (tier !== undefined) {
222
- throw new ConfigError(`Known alias ${JSON.stringify(input)} has no model mapping for selected engine ${JSON.stringify(engine)}.`, "INVALID_CONFIG_FILE", `Add $.aliases.${alias}.${engine} to models.json.`);
222
+ const knownAliasUnmappedForEngine = tier !== undefined;
223
+ if (knownAliasUnmappedForEngine) {
224
+ warnOnce(`model-map-alias-no-engine-mapping:${alias}:${selectedEngine}`, `[akm] Model alias ${JSON.stringify(input)} has no mapping for engine ${JSON.stringify(engine)}; using ${JSON.stringify(input)} as the literal model name. Add $.aliases.${alias}.${engine} to models.json to map it.`);
223
225
  }
224
- return Object.freeze({ input, interpretation: "exact", model: input });
226
+ return Object.freeze({
227
+ input,
228
+ interpretation: "exact",
229
+ model: input,
230
+ ...(knownAliasUnmappedForEngine ? { unmappedForEngine: true } : {}),
231
+ });
225
232
  }
226
233
  export function userModelMapPath(env = process.env) {
227
234
  return path.join(getConfigDir(env), "models.json");
@@ -235,25 +242,24 @@ function modelMapFileError(label, filePath, action) {
235
242
  * absence; dangling links and every non-regular type are configuration errors.
236
243
  */
237
244
  function readModelMapFile(filePath, label, optional) {
238
- let linkStat;
245
+ let targetStat;
239
246
  try {
240
- linkStat = fs.lstatSync(filePath);
247
+ targetStat = fs.statSync(filePath);
241
248
  }
242
249
  catch (error) {
243
250
  if (optional && error?.code === "ENOENT")
244
251
  return undefined;
245
252
  throw modelMapFileError(label, filePath, optional ? "inspected" : "found");
246
253
  }
247
- if (!linkStat.isFile()) {
248
- throw new ConfigError(`Unable to read ${label.toLowerCase()} because it is not a readable regular file: ${filePath}.`, "INVALID_CONFIG_FILE", "Move the symlink or non-regular target aside, or replace it with a readable regular models.json file.");
254
+ if (!targetStat.isFile()) {
255
+ throw new ConfigError(`Unable to read ${label.toLowerCase()} because it is not a readable regular file: ${filePath}.`, "INVALID_CONFIG_FILE", "Replace it with a readable regular models.json file, or a symlink to one.");
249
256
  }
250
- const noFollow = process.platform !== "win32" && typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
251
257
  const nonblock = process.platform !== "win32" && typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0;
252
258
  let fd;
253
259
  try {
254
- fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow | nonblock);
260
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | nonblock);
255
261
  const openedStat = fs.fstatSync(fd);
256
- if (!sameFileIdentity(linkStat, openedStat)) {
262
+ if (!sameFileIdentity(targetStat, openedStat)) {
257
263
  throw new ConfigError(`${label} changed while it was being opened: ${filePath}.`, "INVALID_CONFIG_FILE", "Retry after ensuring no other process is replacing models.json.");
258
264
  }
259
265
  const text = fs.readFileSync(fd, "utf8");
@@ -315,9 +315,18 @@ export function buildReflectPrompt(input) {
315
315
  // asset content into shorter prose, drops concrete structure, or strips
316
316
  // load-bearing frontmatter. Loud and explicit so small models follow.
317
317
  //
318
- // maxOutputChars is hoisted so the return value can include it for callers
319
- // on the LLM path that want to set a hard max_tokens cap on the request.
320
- let maxOutputChars;
318
+ // Guard-audit finding 15: this used to also hand back a maxOutputChars
319
+ // value so an LLM-path caller could convert it into a hard `max_tokens`
320
+ // cap on the API request. llm/client.ts's own doc comment (and
321
+ // commands/improve/reflect.ts's recorded history of responses actually
322
+ // getting cut off) is explicit that a character-derived max_tokens causes
323
+ // silent truncation — a real model's output is measured in tokens, not
324
+ // characters, and the ratio between the two varies enough that any fixed
325
+ // conversion either truncates legitimate output or provides no real cap at
326
+ // all. The size policy below is already enforced twice more (the prompt
327
+ // rules the model reads, and the post-processor's own size check), so nothing
328
+ // is lost by not adding a THIRD, byte-derived enforcement point that can
329
+ // only ever cut a response off early, never usefully re-check it.
321
330
  if (input.ref && input.assetContent?.trim()) {
322
331
  // Strip frontmatter to get source body length — mirrors checkReflectSize which
323
332
  // compares body-only lengths. Inline regex avoids importing parseFrontmatter.
@@ -332,8 +341,6 @@ export function buildReflectPrompt(input) {
332
341
  const showCharBounds = sourceBodyLen >= 200;
333
342
  const minChars = Math.max(Math.round(0.5 * sourceBodyLen), 150);
334
343
  const maxChars = Math.min(Math.max(Math.round(2.5 * sourceBodyLen), 2500), 25000);
335
- if (showCharBounds)
336
- maxOutputChars = maxChars;
337
344
  sections.push([
338
345
  "## Content preservation rules (MUST follow)",
339
346
  "1. PRESERVE ALL concrete content: code blocks, fenced snippets, CLI commands, numbered/bulleted checklists, tables, YAML/JSON examples, file paths, configuration keys, environment variable names, and CSS/HTML selectors. These are load-bearing — do NOT replace them with prose summaries.",
@@ -353,7 +360,7 @@ export function buildReflectPrompt(input) {
353
360
  sections.push(`IMPORTANT: The JSON "ref" field is REQUIRED. It MUST be exactly: "${input.ref}"`);
354
361
  }
355
362
  sections.push(reflectResponseContract(input));
356
- return { prompt: sections.join("\n\n"), ...(maxOutputChars !== undefined ? { maxOutputChars } : {}) };
363
+ return { prompt: sections.join("\n\n") };
357
364
  }
358
365
  /**
359
366
  * Build the prompt for `akm propose <type> <name> --task ...`. Asks the