akm-cli 0.10.261016-beta → 0.10.261018-beta

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 (113) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/README.md +8 -10
  3. package/SECURITY.md +7 -99
  4. package/STABILITY.md +57 -98
  5. package/dist/assets/opencode/akm-plugin.js +103 -1
  6. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +2 -2
  7. package/dist/assets/stash-skeleton/facts/conventions/organization.md +11 -17
  8. package/dist/cli.js +32 -41
  9. package/dist/commands/feedback-cli.js +5 -4
  10. package/dist/commands/health.js +15 -16
  11. package/dist/commands/proposal/proposal-cli.js +1 -1
  12. package/dist/commands/read/registry-search.js +4 -1
  13. package/dist/commands/read/search.js +6 -6
  14. package/dist/commands/sources/self-update.js +9 -5
  15. package/dist/commands/tasks/tasks.js +12 -13
  16. package/dist/core/adapter/adapters/agent-skills-adapter.js +7 -7
  17. package/dist/core/adapter/adapters/akm-task-adapter.js +1 -1
  18. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  19. package/dist/core/adapter/adapters/claude-adapter.js +1 -1
  20. package/dist/core/adapter/adapters/dotenv-adapter.js +1 -1
  21. package/dist/core/adapter/adapters/generic-files-adapter.js +1 -1
  22. package/dist/core/adapter/adapters/llm-wiki-adapter.js +1 -1
  23. package/dist/core/adapter/adapters/opencode-adapter.js +1 -1
  24. package/dist/core/adapter/adapters/website-snapshot-adapter.js +1 -1
  25. package/dist/core/bundle-rename.js +17 -7
  26. package/dist/core/config/convert-engine-references.js +23 -7
  27. package/dist/core/config/convert-engines.js +94 -28
  28. package/dist/core/config/convert-models-overlay.js +14 -2
  29. package/dist/indexer/bundle-identity-guard.js +6 -6
  30. package/dist/indexer/index-written-assets.js +3 -3
  31. package/dist/indexer/indexer.js +12 -8
  32. package/dist/indexer/materialize-embeddings.js +12 -2
  33. package/dist/indexer/passes/metadata.js +103 -46
  34. package/dist/indexer/search/ranking.js +7 -3
  35. package/dist/indexer/search/search-fields.js +1 -1
  36. package/dist/indexer/usage/usage-events.js +30 -8
  37. package/dist/indexer/walk/walker.js +1 -1
  38. package/dist/integrations/agent/spawn.js +7 -2
  39. package/dist/integrations/harnesses/opencode/agent-builder.js +10 -1
  40. package/dist/integrations/harnesses/opencode/runtime/binary.js +6 -3
  41. package/dist/integrations/harnesses/opencode/runtime/index.js +18 -9
  42. package/dist/integrations/harnesses/opencode/session-log-v2.js +11 -1
  43. package/dist/output/shapes/helpers.js +3 -3
  44. package/dist/output/text/command-format.js +5 -6
  45. package/dist/scripts/akm-migrate-node.js +122 -36
  46. package/dist/scripts/akm-migrate.js +122 -36
  47. package/dist/sources/providers/git-provider.js +2 -4
  48. package/dist/sources/providers/provider-utils.js +8 -13
  49. package/dist/sources/snapshot-fetchers/website-ingest.js +6 -7
  50. package/dist/storage/repositories/events-repository.js +15 -0
  51. package/dist/storage/repositories/improve-ledger-repository.js +13 -0
  52. package/dist/storage/repositories/index-entries-repository.js +43 -5
  53. package/dist/storage/repositories/outcome-repository.js +12 -0
  54. package/dist/storage/repositories/proposals-repository.js +5 -5
  55. package/dist/storage/repositories/salience-repository.js +13 -0
  56. package/dist/storage/repositories/task-history-repository.js +8 -7
  57. package/dist/storage/repositories/workflow-runs-repository.js +10 -8
  58. package/docs/README.md +24 -60
  59. package/docs/agents/README.md +3 -8
  60. package/docs/architecture/README.md +18 -20
  61. package/docs/architecture/adapters.md +40 -43
  62. package/docs/architecture/architecture.md +180 -154
  63. package/docs/architecture/improvement.md +95 -91
  64. package/docs/architecture/internals/classification.md +101 -119
  65. package/docs/architecture/internals/fresh-host-rebuild-runbook.md +33 -35
  66. package/docs/architecture/internals/health-advisories.md +59 -46
  67. package/docs/architecture/internals/improve-workflow.md +166 -693
  68. package/docs/architecture/internals/indexing.md +65 -465
  69. package/docs/architecture/internals/storage-locations.md +301 -848
  70. package/docs/architecture/persisted-data-compat.md +6 -6
  71. package/docs/architecture/specs/okf-support.md +67 -95
  72. package/docs/architecture/specs/ref.md +67 -125
  73. package/docs/architecture/testing/testing-workflow.md +108 -168
  74. package/docs/architecture/workflow-engine.md +34 -33
  75. package/docs/guides/README.md +5 -5
  76. package/docs/guides/author-bundles.md +72 -98
  77. package/docs/guides/author-workflows.md +11 -18
  78. package/docs/guides/bundles.md +91 -123
  79. package/docs/guides/capture-knowledge.md +10 -9
  80. package/docs/guides/concepts.md +45 -30
  81. package/docs/guides/discover-and-load.md +39 -32
  82. package/docs/guides/getting-started.md +24 -24
  83. package/docs/guides/improve-the-library.md +98 -80
  84. package/docs/guides/recipes/headless-install.md +4 -4
  85. package/docs/guides/recipes/website-source.md +9 -5
  86. package/docs/guides/run-workflows.md +10 -11
  87. package/docs/guides/scheduling.md +11 -17
  88. package/docs/guides/use-with-any-agent.md +15 -14
  89. package/docs/guides/wikis.md +6 -9
  90. package/docs/integration/bundling-akm.md +21 -26
  91. package/docs/maintainers/README.md +2 -2
  92. package/docs/maintainers/local-development.md +3 -16
  93. package/docs/migration/README.md +2 -1
  94. package/docs/migration/release-notes/0.10.0.md +196 -23
  95. package/docs/migration/release-notes/README.md +2 -2
  96. package/docs/migration/v0.9-to-v0.10.md +103 -12
  97. package/docs/product-surface.md +10 -8
  98. package/docs/reference/README.md +26 -15
  99. package/docs/reference/asset-types.md +11 -12
  100. package/docs/reference/bundle-types.md +6 -5
  101. package/docs/reference/cli.md +52 -28
  102. package/docs/reference/configuration.md +91 -73
  103. package/docs/reference/data-and-telemetry.md +204 -421
  104. package/docs/reference/env-and-secrets.md +80 -83
  105. package/docs/reference/memory.md +1 -1
  106. package/docs/reference/refs.md +23 -36
  107. package/docs/reference/registry.md +138 -348
  108. package/docs/reference/supported-formats.md +8 -8
  109. package/docs/reference/tasks.md +13 -19
  110. package/docs/reference/website-sources.md +23 -15
  111. package/docs/reference/workflow-schema.md +28 -47
  112. package/docs/reference/workflows.md +33 -37
  113. package/package.json +1 -1
@@ -136,7 +136,7 @@ export function validateStashEntry(entry) {
136
136
  const sources = normalizeNonEmptyStringList(e.sources);
137
137
  if (sources)
138
138
  result.sources = sources;
139
- // SPEC-6: `category` must survive the projection. Non-string values are
139
+ // `category` must survive the projection. Non-string values are
140
140
  // dropped, not coerced.
141
141
  if (typeof e.category === "string" && e.category.trim().length > 0) {
142
142
  result.category = e.category.trim();
@@ -346,7 +346,7 @@ export function applyCuratedFrontmatter(entry, fmData) {
346
346
  const quality = asNonEmptyString(fmData.quality);
347
347
  if (quality)
348
348
  entry.quality = normalizeQuality(quality);
349
- // SPEC-6 capture step: the `category:` frontmatter key (e.g. `convention`,
349
+ // Capture the `category:` frontmatter key (e.g. `convention`,
350
350
  // `meta` on facts) must land on the indexed entry so category-keyed
351
351
  // policies can see it. Trimmed; blank/non-string values are ignored.
352
352
  const category = asNonEmptyString(fmData.category);
@@ -384,13 +384,13 @@ export function applyCuratedFrontmatter(entry, fmData) {
384
384
  entry.evidenceSources = evidenceSources;
385
385
  if (typeof fmData.evidence === "string" && fmData.evidence.trim().length > 0)
386
386
  entry.evidence = fmData.evidence;
387
- // Phase 5A / Advantage D5: capture parent ref for derived memories.
387
+ // Capture the parent ref for derived memories.
388
388
  // Memory-inference writes `source: "memories/<parent>"` and `inferred: true`
389
389
  // (and a derived child name suffix `.derived`). We mirror that source ref into
390
390
  // `entry.derivedFrom` so the indexer can populate the dedicated `derived_from`
391
- // column. Group-C item 2: the column is stored in the 0.9.0 `memories/<name>`
392
- // conceptId grammar — moving in lockstep with the `getDerivedForParent` lookup
393
- // key (search-hit-enrichers) so producer + consumer speak one grammar.
391
+ // column. The column is stored as the `memories/<name>` conceptId, matching
392
+ // the `getDerivedForParent` lookup key (search-hit-enrichers) so producer and
393
+ // consumer speak one grammar.
394
394
  // Non-derived entries leave this field unset.
395
395
  if (entry.type === "memory") {
396
396
  const isDerivedByName = entry.name.toLowerCase().endsWith(".derived");
@@ -473,7 +473,7 @@ function isWikiRole(value) {
473
473
  /**
474
474
  * Parse one `verified:` family value into the `IndexDocument.provenance` shape.
475
475
  * Accepts OKF v0.2's list form and its documented single-mapping shorthand
476
- * ("consumers MUST treat a bare mapping as a one-element list", per OKF v0.2).
476
+ * ("consumers MUST treat a bare mapping as a one-element list", OKF v0.2 §5.2).
477
477
  * Tolerant: an entry without a usable `by` is dropped individually.
478
478
  */
479
479
  function parseVerifiedFamily(value) {
@@ -491,9 +491,9 @@ function parseVerifiedFamily(value) {
491
491
  }
492
492
  /**
493
493
  * Extract the OKF v0.2 provenance families that `promoteProposal` stamps onto
494
- * accepted AKM-native proposals (D2, #730), and apply them to the entry.
494
+ * accepted AKM-native proposals (#730), and apply them to the entry.
495
495
  *
496
- * The on-disk shape is deliberately **hybrid** (owner decision, #730 review):
496
+ * The on-disk shape is deliberately **hybrid** (#730 review):
497
497
  * `generated:` and `verified:` are stamped BARE at the top level, exactly as
498
498
  * OKF v0.2 spells them, because neither has any pre-existing AKM consumer — so
499
499
  * a third-party OKF v0.2 reader treating an AKM stash as an OKF bundle sees
@@ -505,8 +505,8 @@ function parseVerifiedFamily(value) {
505
505
  * non-strings).
506
506
  *
507
507
  * The nested `provenance.generatedBy` / `.generatedAt` / `.verified` spellings
508
- * are still read as a fallback so assets stamped by an earlier build of this
509
- * branch keep resolving. Tolerant throughout: any malformed sub-field is
508
+ * are still read as a fallback so assets stamped by an earlier build keep
509
+ * resolving. Tolerant throughout: any malformed sub-field is
510
510
  * dropped individually rather than rejecting the whole block.
511
511
  */
512
512
  export function applyProvenanceFrontmatter(entry, fmData) {
@@ -545,8 +545,8 @@ export function applyProvenanceFrontmatter(entry, fmData) {
545
545
  entry.provenance = result;
546
546
  }
547
547
  // AKM-stash indexing policy (env/vaults/secrets sensitive-marker + wiki-infra
548
- // exclusions) moved to the `akm` adapter's `recognize` as path/stat-based
549
- // abstention (owner ruling 2026-07-21 — adapter-owned filtering). See
548
+ // exclusions) lives in the `akm` adapter's `recognize` as path/stat-based
549
+ // abstention. See
550
550
  // `akmStashAbstains` in `src/core/adapter/adapters/akm-adapter.ts`.
551
551
  /**
552
552
  * Extract `@param` JSDoc tags from a script file's leading comment block.
@@ -764,11 +764,9 @@ function mergeAliases(existing, generated) {
764
764
  /**
765
765
  * Maximum native Markdown prose carried by the low-weight `content` field.
766
766
  *
767
- * Raised far past any real authored document:
768
- * this used to sit at 16_384 chars, tight enough that ordinary long-form
769
- * skills/knowledge docs lost their tail from both FTS and the embedding
770
- * input with no visible signal (the cut was reported via `warnVerbose`,
771
- * silent unless `AKM_VERBOSE` was set). The remaining bound exists only to
767
+ * Set far past any real authored document, so ordinary long-form
768
+ * skills/knowledge docs never lose their tail from FTS or the embedding
769
+ * input. The bound exists only to
772
770
  * stop a truly pathological single file (a committed data dump, a decompressed
773
771
  * log) from ballooning index size — not to shave real content — so a caller
774
772
  * that hits it is always told, unconditionally.
@@ -873,6 +871,58 @@ function isMarkdownFenceClosing(line, fence) {
873
871
  return false;
874
872
  return /^[\t ]*$/.test(line.slice(cursor));
875
873
  }
874
+ /**
875
+ * Version of what {@link projectMarkdownContent} indexes. It is part of every
876
+ * directory's index fingerprint, so bumping it makes the next `akm index`
877
+ * re-project every directory once instead of keeping the old text for
878
+ * unchanged files. 2: identifiers from fenced code (akm#1260).
879
+ */
880
+ export const CONTENT_PROJECTION_VERSION = 2;
881
+ /** At or above this length a token with a digit reads as a key or hash, not a name. */
882
+ const TOKEN_LIKE_MIN_LENGTH = 20;
883
+ /**
884
+ * The searchable identifiers of one line of fenced code, without its literal
885
+ * values (akm#1260). Fenced code holds the names agents look for (functions,
886
+ * classes, CLI flags, config keys, environment variable names, paths) and
887
+ * also example secrets, so it keeps names and drops values:
888
+ *
889
+ * - a quoted string survives only as a key (`"name":`) or an absolute path;
890
+ * every other quoted literal is dropped;
891
+ * - the value of an assignment (`KEY=value`, `key: value`, `"key": value`,
892
+ * `Authorization: Bearer token`) is dropped unless it is a dotted code
893
+ * reference (`process.env.CI`); the name stays;
894
+ * - long token-like strings with digits (keys, hashes) are dropped, base64
895
+ * included: its `/`, `+` and `=` do not cut it into short kept pieces;
896
+ * - camelCase, snake_case, dotted and dashed names also add their parts.
897
+ *
898
+ * Numbers, punctuation and operators never reach the result.
899
+ */
900
+ export function fencedCodeIdentifiers(line) {
901
+ const cleaned = line
902
+ .replace(/(["'`])((?:\\.|(?!\1).)*)\1(\s*:)?/g, (match, _quote, body, colon, offset) => {
903
+ // Keep a key's colon when an unquoted value follows, so the assignment rule below drops that value.
904
+ if (colon)
905
+ return /^\s*["'`]/.test(line.slice(offset + match.length)) ? ` ${body} ` : ` ${body}: `;
906
+ return /^\/[\w./-]+$/.test(body) ? ` ${body} ` : " ";
907
+ })
908
+ .replace(/([A-Za-z_][A-Za-z0-9_.-]*\s*[:=]\s*)((?:[Bb]earer|[Bb]asic|[Tt]oken|[Dd]igest)\s+\S+|\S+)/g, (_match, name, value) => /^[!(]*[A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)+[()\]}]*[,;]?$/.test(value) ? `${name} ${value}` : name)
909
+ // Base64 is one token: its `/`, `+` and `=` would otherwise cut a key body into short pieces that are kept.
910
+ .replace(/(?<![\w.-])[A-Za-z0-9+/=]{20,}(?![\w.-])/g, (run) => /[a-z]/.test(run) && /[A-Z]/.test(run) && /\d/.test(run) ? " " : run);
911
+ const words = [];
912
+ for (let token of cleaned.match(/--?[A-Za-z][A-Za-z0-9-]*|[A-Za-z_][A-Za-z0-9_.-]*/g) ?? []) {
913
+ token = token.replace(/[.-]+$/, "");
914
+ const bare = token.replace(/^-+/, "");
915
+ if (bare.length < 2)
916
+ continue;
917
+ if (bare.length >= TOKEN_LIKE_MIN_LENGTH && /\d/.test(bare))
918
+ continue;
919
+ words.push(token);
920
+ const parts = bare.split(/[_.-]|(?<=[a-z0-9])(?=[A-Z])/).filter((part) => part.length > 1);
921
+ if (parts.length > 1)
922
+ words.push(...parts);
923
+ }
924
+ return words.join(" ");
925
+ }
876
926
  function stripMarkdownHtmlComments(line, state) {
877
927
  let cursor = 0;
878
928
  let visible = "";
@@ -1048,17 +1098,23 @@ function stripMarkdownLinkDestinations(text, nesting = 0) {
1048
1098
  }
1049
1099
  /**
1050
1100
  * Derive the one low-weight search projection for an AKM-native Markdown body.
1051
- * Frontmatter is removed by the caller. This projection drops comments,
1052
- * fenced code, raw link targets, and structural punctuation while retaining
1053
- * prose, headings, link labels, and inline identifiers. It never receives
1054
- * secret/env/session bytes; that policy is enforced at the adapter metadata
1055
- * boundary below.
1101
+ * Frontmatter is removed by the caller. This projection drops comments, raw
1102
+ * link targets, and structural punctuation while retaining prose, headings,
1103
+ * link labels, and inline identifiers. From fenced code it keeps only the
1104
+ * identifiers ({@link fencedCodeIdentifiers}): names, flags, keys and paths,
1105
+ * never literal values (akm#1260). It never receives secret/env/session bytes;
1106
+ * that policy is enforced at the adapter metadata boundary below.
1107
+ *
1108
+ * Changing what this returns changes the indexed text of every Markdown entry,
1109
+ * so bump {@link CONTENT_PROJECTION_VERSION} with it: the indexer re-projects
1110
+ * every directory once on the next `akm index`.
1056
1111
  */
1057
1112
  export function projectMarkdownContent(body, truncationInfo) {
1058
1113
  const lines = body.split(/\r?\n/);
1059
1114
  const innerBlock = findInnerFrontmatterBlock(lines);
1060
1115
  const start = innerBlock && isFrontmatterShaped(lines, innerBlock) ? innerBlock.close + 1 : 0;
1061
1116
  const projected = [];
1117
+ const fenced = [];
1062
1118
  forEachVisibleMarkdownLine(lines, start, (_index, visible) => {
1063
1119
  let trimmed = visible.trim();
1064
1120
  if (!trimmed || /^(-{3,}|\*{3,}|_{3,}|=+)$/.test(trimmed))
@@ -1077,8 +1133,13 @@ export function projectMarkdownContent(body, truncationInfo) {
1077
1133
  trimmed = trimmed.replace(/\s+/g, " ").trim();
1078
1134
  if (trimmed)
1079
1135
  projected.push(trimmed);
1136
+ }, (_index, fencedLine) => {
1137
+ const identifiers = fencedCodeIdentifiers(fencedLine);
1138
+ if (identifiers)
1139
+ fenced.push(identifiers);
1080
1140
  });
1081
- const text = projected.join(" ").replace(/\s+/g, " ").trim();
1141
+ // Fenced identifiers follow the prose, so a head-truncated embedding input keeps the prose first.
1142
+ const text = [...projected, ...fenced].join(" ").replace(/\s+/g, " ").trim();
1082
1143
  if (!text)
1083
1144
  return undefined;
1084
1145
  if (truncationInfo)
@@ -1088,8 +1149,10 @@ export function projectMarkdownContent(body, truncationInfo) {
1088
1149
  /**
1089
1150
  * Visit each line from `start` that lies outside fenced code, with HTML
1090
1151
  * comments removed (`visible`); fence lines themselves are never visited.
1152
+ * `visitFenced`, when given, receives each raw line inside a fence (not the
1153
+ * opening or closing fence line), from the same fence state.
1091
1154
  */
1092
- function forEachVisibleMarkdownLine(lines, start, visit) {
1155
+ function forEachVisibleMarkdownLine(lines, start, visit, visitFenced) {
1093
1156
  let fence;
1094
1157
  const htmlComment = { inComment: false };
1095
1158
  for (let index = start; index < lines.length; index += 1) {
@@ -1097,6 +1160,8 @@ function forEachVisibleMarkdownLine(lines, start, visit) {
1097
1160
  if (fence) {
1098
1161
  if (isMarkdownFenceClosing(rawLine, fence))
1099
1162
  fence = undefined;
1163
+ else
1164
+ visitFenced?.(index, rawLine);
1100
1165
  continue;
1101
1166
  }
1102
1167
  // A leading fence owns the whole line, including any info string that
@@ -1174,13 +1239,12 @@ export function projectMarkdownFragmentContent(raw) {
1174
1239
  /**
1175
1240
  * Priorities 1-2 of the metadata pipeline — package.json (P1), `.md`
1176
1241
  * frontmatter (P2), and script `@param`/comment metadata (P2b) — everything
1177
- * that runs BEFORE the renderer-contributor step (P3). Extracted (Chunk 5 M-b)
1178
- * so the `akm` adapter's synchronous `recognize` shares this exact assembly
1179
- * with the live index-drain path; the two differ ONLY in how they obtain the
1242
+ * that runs BEFORE the renderer-contributor step (P3). Shared by the `akm`
1243
+ * adapter's synchronous `recognize` and the live index-drain path; the two
1244
+ * differ ONLY in how they obtain the
1180
1245
  * P3 renderer metadata (the adapter uses the synchronous
1181
1246
  * `foldRecognizedMetadata`), guaranteeing P1/P2/P4 parity by construction.
1182
- * Behavior-preserving: this is a verbatim lift of the former inline P1/P2/P2b
1183
- * block. Mutates `entry` in place.
1247
+ * Mutates `entry` in place.
1184
1248
  */
1185
1249
  export function applyPreContributorFields(entry, file, ctx, pkgMeta) {
1186
1250
  const ext = path.extname(file).toLowerCase();
@@ -1207,7 +1271,7 @@ export function applyPreContributorFields(entry, file, ctx, pkgMeta) {
1207
1271
  entry.parameters = fmParams;
1208
1272
  // Pass wiki-pattern frontmatter through onto the entry
1209
1273
  applyWikiFrontmatter(entry, parsed.data);
1210
- // D2 (#730): reread the namespaced `provenance:` block promoteProposal stamps.
1274
+ // #730: reread the namespaced `provenance:` block promoteProposal stamps.
1211
1275
  applyProvenanceFrontmatter(entry, parsed.data);
1212
1276
  // Native Markdown has one bounded low-weight body projection. Sensitive
1213
1277
  // types and raw session/checkpoint material never cross this boundary.
@@ -1220,7 +1284,7 @@ export function applyPreContributorFields(entry, file, ctx, pkgMeta) {
1220
1284
  entry.content = contentProjection;
1221
1285
  if (truncationInfo.truncated) {
1222
1286
  entry.contentTruncated = true;
1223
- // Unconditional, not warnVerbose: this bound now sits far past any
1287
+ // Unconditional, not warnVerbose: this bound sits far past any
1224
1288
  // real document, so tripping it means
1225
1289
  // something unusual is in the bundle and the operator should see
1226
1290
  // it without having to pass --verbose.
@@ -1252,8 +1316,8 @@ export function applyPreContributorFields(entry, file, ctx, pkgMeta) {
1252
1316
  * Priority 4 of the metadata pipeline — filename-heuristic fallbacks (P4) that
1253
1317
  * run AFTER the renderer-contributor step (P3): a filename description when none
1254
1318
  * was set, path/dir-derived tags, tag normalization, and alias generation.
1255
- * Extracted (Chunk 5 M-b) alongside {@link applyPreContributorFields} so both
1256
- * pipeline paths share it. Behavior-preserving verbatim lift. Mutates `entry`.
1319
+ * Shared with {@link applyPreContributorFields} by both pipeline paths.
1320
+ * Mutates `entry`.
1257
1321
  */
1258
1322
  export function applyPostContributorFields(entry, file, canonicalName, dirPath) {
1259
1323
  const ext = path.extname(file).toLowerCase();
@@ -1267,7 +1331,7 @@ export function applyPostContributorFields(entry, file, canonicalName, dirPath)
1267
1331
  if (!entry.tags || entry.tags.length === 0) {
1268
1332
  entry.tags = extractTagsFromPath(file, dirPath);
1269
1333
  }
1270
- // Stash-organization conventions (SPEC-2): directory (scope/domain) tokens
1334
+ // Stash-organization conventions: directory (scope/domain) tokens
1271
1335
  // always reach the tags column, even when the author set explicit tags, so
1272
1336
  // nested assets keep the exact-tag ranking boost for their scope token.
1273
1337
  // Derived from canonicalName (the ref subpath) rather than the filesystem
@@ -1284,13 +1348,6 @@ export function applyPostContributorFields(entry, file, canonicalName, dirPath)
1284
1348
  // Heuristic search hints are too noisy to be useful for search quality
1285
1349
  entry.filename = path.basename(file);
1286
1350
  }
1287
- // The pre-0.9.0 flat-walk matcher-pass metadata source was DELETED in Chunk 5
1288
- // F4a M-core-3. Its role (recognize a stash root's files into durable entries)
1289
- // is now the `akm` adapter's `recognize`, drained by `indexer/scan/drain-dir.ts`
1290
- // (`drainDirDocuments` for the live indexer, `recognizeStashEntries` for the
1291
- // `manifest` fallback / `registry` index builder / metadata unit tests). The
1292
- // flip is the F4 engine swap; the shadow-parity gate proved recognize produced
1293
- // the identical entries before the old pass was removed.
1294
1351
  export function buildMetadataSkipWarning(filePath, assetType, error) {
1295
1352
  const detail = error instanceof Error ? error.message : String(error);
1296
1353
  // Workflow errors are already multi-line `path:line — message` blocks; print
@@ -1327,9 +1384,9 @@ function normalizeTerms(values) {
1327
1384
  if (!cleaned)
1328
1385
  continue;
1329
1386
  normalized.add(cleaned);
1330
- // De-pluralization heuristic removed: the FTS5 porter stemmer (configured
1331
- // with `tokenize='porter unicode61'`) handles stemming correctly, including
1332
- // edge cases like "kubernetes" and "status" that the naive s-strip mangled.
1387
+ // No de-pluralization here: the FTS5 porter stemmer (configured with
1388
+ // `tokenize='porter unicode61'`) handles stemming correctly, including
1389
+ // edge cases like "kubernetes" and "status" that a naive s-strip mangles.
1333
1390
  }
1334
1391
  return Array.from(normalized);
1335
1392
  }
@@ -1437,7 +1494,7 @@ export function extractTagsFromPath(filePath, rootDir) {
1437
1494
  * Unlike {@link extractTagsFromPath} this never tokenizes the filename
1438
1495
  * segment: it exists so a nested asset's directory (scope/domain) tokens can
1439
1496
  * be merged into explicit author tags without dragging every filename word
1440
- * into exact-tag matching (SPEC-2, docs/architecture/specs/stash-conventions-code-spec.md).
1497
+ * into exact-tag matching.
1441
1498
  * Tokenization mirrors `extractTagsFromPath`: each segment splits on `-`/`_`/
1442
1499
  * `.`, lowercased, single-character tokens dropped. A name with no directory
1443
1500
  * segments yields no tags.
@@ -41,9 +41,13 @@ export function reciprocalRankFusion(channels) {
41
41
  * 1 + w·(ln(1+pos) − ln(1+neg)) moves a score by about 2% / 3% / 5% / 9% for
42
42
  * 1 / 2 / 5 / 20 positives at 0.03. Measured on the retrieval suite's fused
43
43
  * lists, 5 positives lift a hit from rank 10 by a median of 3 places (from rank 5,
44
- * by 2) and 20 by 4 to 5, so a voted hit passes its near neighbours and a clearly
45
- * better match stays ahead. At 0.05 the same votes lift it 4 to 5 and 6 to 7
46
- * places, and two wrong votes cost about 1.6 times the nDCG@10 they cost at 0.03.
44
+ * by 2) and 20 by 4 to 5. What a vote can pass depends on the channels. In
45
+ * keyword-only search every fused score is exactly 1 / (RRF_K + rank), so
46
+ * neighbours in the first 20 sit 1.3 to 1.6% apart and one vote (about 2%) moves
47
+ * a hit one place (through rank 38) whatever the keyword margin between them. When both channels contribute, their
48
+ * ranks spread the scores and a vote passes only near-ties. At 0.05 the same
49
+ * votes lift it 4 to 5 and 6 to 7 places, and two wrong votes cost about 1.6
50
+ * times the nDCG@10 they cost at 0.03.
47
51
  */
48
52
  export const FEEDBACK_WEIGHT = 0.03;
49
53
  /**
@@ -13,7 +13,7 @@
13
13
  * - tags: tags + aliases joined
14
14
  * - hints: searchHints + examples + usage + intent fields
15
15
  * - content: bounded native/adapter body projection + TOC headings + parameters
16
- * (lowest-weight catch-all)
16
+ * (the catch-all; BM25 weighs all five fields equally)
17
17
  */
18
18
  export function buildSearchFields(entry) {
19
19
  const name = entry.name.replace(/[-_]/g, " ");
@@ -69,20 +69,28 @@ export function insertUsageEventStrict(db, event) {
69
69
  // ── Query ────────────────────────────────────────────────────────────────────
70
70
  /**
71
71
  * User feedback counts for many assets at once, keyed by durable `entry_ref`.
72
- * Refs with no user feedback are absent from the map. Read in one query per
73
- * chunk of refs.
72
+ * A negative that carried an exact fix does not count once an accepted
73
+ * `feedback` proposal for the asset exists at or after it (the rule `akm
74
+ * improve` uses for a fix already acted on); reverting that proposal makes it
75
+ * count again. Refs with no user feedback are absent from the map. Read in one
76
+ * query per chunk of refs.
74
77
  */
75
78
  export function countFeedbackSignalsByRef(db, refs) {
76
79
  const counts = new Map();
77
80
  for (let i = 0; i < refs.length; i += SQLITE_CHUNK_SIZE) {
78
81
  const chunk = refs.slice(i, i + SQLITE_CHUNK_SIZE);
79
82
  const rows = db
80
- .prepare(`SELECT entry_ref,
81
- SUM(CASE WHEN signal = 'positive' THEN 1 ELSE 0 END) AS pos,
82
- SUM(CASE WHEN signal = 'negative' THEN 1 ELSE 0 END) AS neg
83
- FROM usage_events
84
- WHERE event_type = 'feedback' AND source = 'user' AND entry_ref IN (${chunk.map(() => "?").join(",")})
85
- GROUP BY entry_ref`)
83
+ .prepare(`SELECT u.entry_ref,
84
+ SUM(CASE WHEN u.signal = 'positive' THEN 1 ELSE 0 END) AS pos,
85
+ SUM(CASE WHEN u.signal = 'negative' AND NOT (
86
+ json_extract(u.metadata, '$.fix') IS NOT NULL AND EXISTS (
87
+ SELECT 1 FROM proposals p
88
+ WHERE p.ref = u.entry_ref AND p.source = 'feedback' AND p.status = 'accepted'
89
+ AND julianday(p.created_at) >= julianday(u.created_at))
90
+ ) THEN 1 ELSE 0 END) AS neg
91
+ FROM usage_events u
92
+ WHERE u.event_type = 'feedback' AND u.source = 'user' AND u.entry_ref IN (${chunk.map(() => "?").join(",")})
93
+ GROUP BY u.entry_ref`)
86
94
  .all(...chunk);
87
95
  for (const row of rows)
88
96
  counts.set(row.entry_ref, { pos: row.pos, neg: row.neg });
@@ -150,3 +158,17 @@ export function purgeOldUsageEvents(db, retentionDays) {
150
158
  /* Table may not exist yet */
151
159
  }
152
160
  }
161
+ /**
162
+ * Rewrite every `usage_events.entry_ref` naming `oldBundleId` to `newBundleId`
163
+ * (`akm bundle rename`). Search ranking counts feedback by `entry_ref`, so an
164
+ * event left under the old prefix stops counting for the renamed bundle's
165
+ * assets. `entry_id` is untouched: the rename keeps every entry's row id. A
166
+ * legacy unqualified ref is not matched by the `<old>//` prefix and is left alone.
167
+ * Returns the number of rows rewritten.
168
+ */
169
+ export function renameUsageEventRefs(db, oldBundleId, newBundleId) {
170
+ const prefix = `${oldBundleId}//`;
171
+ return Number(db
172
+ .prepare(`UPDATE usage_events SET entry_ref = ? || substr(entry_ref, length(?) + 1) WHERE substr(entry_ref, 1, length(?)) = ?`)
173
+ .run(newBundleId, oldBundleId, prefix, prefix).changes);
174
+ }
@@ -112,7 +112,7 @@ function walkStashGit(stashRoot, options) {
112
112
  ? ignoredResult.stdout.toString("utf8")
113
113
  : String(ignoredResult.stdout ?? "");
114
114
  // Data-hygiene filename skips: the legacy metadata sidecar (never indexed as
115
- // content — Chunk-8 folds it into the bundle format) plus git dot-files.
115
+ // content) plus git dot-files.
116
116
  const SKIP_FILES = new Set([".stash.json", ".gitignore", ".gitattributes"]);
117
117
  const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : String(result.stdout ?? "");
118
118
  const files = [...stdout.split("\0"), ...ignoredStdout.split("\0")]
@@ -14,6 +14,7 @@
14
14
  * The command comes from the harness (`harness.build`); this module never
15
15
  * touches a model. Agents are reachable only via shell-out.
16
16
  */
17
+ import path from "node:path";
17
18
  import { ConfigError } from "../../core/errors.js";
18
19
  import { childProcessEnv } from "../../core/paths.js";
19
20
  import { runManagedSubprocess, streamCaptureFailure } from "../../core/subprocess.js";
@@ -86,10 +87,14 @@ export async function runBuiltCommand(label, { argv, env, stdin }, options, stdi
86
87
  // and the optional stdin payload. This keeps the agent-specific surface:
87
88
  // the failure taxonomy.
88
89
  const start = Date.now();
90
+ // A program started after `cd <dir>` has PWD equal to <dir>, and OpenCode takes its directory from PWD before its
91
+ // own working directory: the PWD akm inherits names where akm was started, so a run given a directory names it. One
92
+ // path is both, so they agree even where the kernel would resolve a `..` after a symbolic link elsewhere.
93
+ const cwd = options.cwd ? path.resolve(options.cwd) : undefined;
89
94
  const result = await runManagedSubprocess([...argv], {
90
95
  capture: stdioMode === "captured",
91
- env: { ...env },
92
- ...(options.cwd ? { cwd: options.cwd } : {}),
96
+ env: { ...env, ...(cwd ? { PWD: cwd } : {}) },
97
+ ...(cwd ? { cwd } : {}),
93
98
  ...(stdin !== undefined ? { stdin } : {}),
94
99
  timeoutMs,
95
100
  ...(options.signal ? { signal: options.signal } : {}),
@@ -60,9 +60,17 @@ const MODEL_WORK_AGENT_DEFINITION = {
60
60
  ],
61
61
  };
62
62
  const CONFIG_CONTENT_ENV = "OPENCODE_CONFIG_CONTENT";
63
+ /**
64
+ * OpenCode's built-in browser plugin, removed from model work (a `-` entry in `plugins` removes a plugin, OpenCode 2.0.0
65
+ * and later). From 2.2.0 it appends a deny of its own skill after every agent's rules, and a rule on one skill keeps
66
+ * the `skill` tool listed for an agent that denies every tool; without the plugin, the `*` deny removes the tool again.
67
+ * Model work denies the browser anyway.
68
+ */
69
+ const WITHOUT_BROWSER_PLUGIN = "-opencode.browser";
63
70
  /**
64
71
  * The inline config model work runs with: the user's own `OPENCODE_CONFIG_CONTENT` as it is, with akm's agent set on
65
- * `agents.akm-model-work`, the one key akm's definition wins. OpenCode 2 reads that native `agents` map over the
72
+ * `agents.akm-model-work`, the one key akm's definition wins, and {@link WITHOUT_BROWSER_PLUGIN} after the user's
73
+ * `plugins`. OpenCode 2 reads that native `agents` map over the
66
74
  * legacy `agent` one, so a same-named entry the user keeps in `agent` is left out rather than left to conflict. The
67
75
  * child's value is built from the parent's, so a provider, model or key the user keeps only there stays. It is read as
68
76
  * OpenCode reads it, as JSONC (comments and trailing commas), and written back as JSON; content that reads only once
@@ -78,6 +86,7 @@ function modelWorkConfig() {
78
86
  }
79
87
  const config = {
80
88
  ...user,
89
+ plugins: [...(Array.isArray(user?.plugins) ? user.plugins : []), WITHOUT_BROWSER_PLUGIN],
81
90
  agents: { ...asRecord(user?.agents), [MODEL_WORK_AGENT]: MODEL_WORK_AGENT_DEFINITION },
82
91
  };
83
92
  const legacy = asRecord(user?.agent);
@@ -25,10 +25,13 @@ import { info } from "../../../../core/warn.js";
25
25
  * The OpenCode release akm's own runtime runs. It is pinned because the runtime depends on things no OpenCode release
26
26
  * promises: the inline config's shape, the `--standalone` flag, the plugin API, the text `run` prints, and the
27
27
  * `opencode.db` tables that `../session-log-v2.ts` reads. To move it, change this version and `PACKAGES` if the release
28
- * adds or drops a platform package, then run `tests/integration/opencode-runtime-sessions.test.ts` against the new
29
- * binary (it fetches it) and read `docs/reference/configuration.md` ("Without a harness") for what the version changes.
28
+ * adds or drops a platform package, then run `tests/integration/opencode-runtime-sessions.test.ts` and
29
+ * `tests/integration/opencode-runtime-loop-guard.test.ts` against the new binary (they fetch it; the plugin ends a
30
+ * looping run by throwing from a hook, which OpenCode might one day handle differently, and tells the model by changing
31
+ * the body of a request, which it might one day stop letting a plugin do) and read
32
+ * `docs/reference/configuration.md` ("Without a harness") for what the version changes.
30
33
  */
31
- export const OPENCODE_RUNTIME_VERSION = "2.1.4";
34
+ export const OPENCODE_RUNTIME_VERSION = "2.2.0";
32
35
  /** The platform packages of `@opencode/cli` at {@link OPENCODE_RUNTIME_VERSION}. */
33
36
  const PACKAGES = new Set([
34
37
  "@opencode/cli-linux-x64",
@@ -23,7 +23,7 @@ import { openDatabase } from "../../../../storage/database.js";
23
23
  import { resolveAkmInvocation } from "../../../../tasks/resolve-akm-bin.js";
24
24
  import { buildChildEnv, runBuiltCommand, } from "../../../agent/spawn.js";
25
25
  import { akmOpencodeDatabasePath, akmRuntimeDir } from "../roots.js";
26
- import { v2SessionTokens } from "../session-log-v2.js";
26
+ import { v2SessionFailure, v2SessionTokens } from "../session-log-v2.js";
27
27
  import { ensureRuntimeBinary, OPENCODE_RUNTIME_VERSION, runtimeBinaryPath } from "./binary.js";
28
28
  import { runtimeArgv, runtimeConfig, runtimeEnv, runtimePrepareArgv } from "./command.js";
29
29
  /**
@@ -61,10 +61,11 @@ function installPlugin(directory) {
61
61
  /**
62
62
  * OpenCode makes and migrates its database when a process starts, and two processes doing that to one database break
63
63
  * each other (one fails with "Standalone server exited before reporting readiness"; on 2.1.4 two runs started together
64
- * on a fresh directory lose one of them every time). So the first start of this OpenCode version is a preparing run of
65
- * its own, under a lock file in the runtime directory that every run, of this akm process or another, waits on, and it
66
- * leaves a marker naming the version; a run that finds the marker and the database starts at once. A dead holder's
67
- * lock is reclaimed. The run itself reports anything the preparation does not.
64
+ * on a fresh directory lose one of them every time; on 2.2.0, 4 of 24 starts in groups of eight failed). So the first
65
+ * start of this OpenCode version is a preparing run of its own, under a lock file in the runtime directory that every
66
+ * run, of this akm process or another, waits on, and it leaves a marker naming the version; a run that finds the marker
67
+ * and the database starts at once. A dead holder's lock is reclaimed. The run itself reports anything the preparation
68
+ * does not.
68
69
  */
69
70
  async function prepareDatabase(directory, command, opts) {
70
71
  const marker = path.join(directory, `prepared-${OPENCODE_RUNTIME_VERSION}`);
@@ -100,9 +101,9 @@ async function prepareDatabase(directory, command, opts) {
100
101
  }
101
102
  }
102
103
  /**
103
- * The run's session as OpenCode recorded it, read once the process has ended: its id and the tokens its turn used,
104
- * which a workflow's `budget.max_tokens` and the unit journal count. Nothing when the run made no session. Accounting
105
- * is best-effort: a database that cannot be read leaves the run's result as it is.
104
+ * The run's session as OpenCode recorded it, read once the process has ended: its id, the tokens its turn used, which a
105
+ * workflow's `budget.max_tokens` and the unit journal count, and the message its turn failed with, if it did. Nothing
106
+ * when the run made no session. Best-effort: a database that cannot be read leaves the run's result as it is.
106
107
  */
107
108
  function sessionRecord(sessionId) {
108
109
  try {
@@ -111,9 +112,11 @@ function sessionRecord(sessionId) {
111
112
  const tokens = v2SessionTokens(db, sessionId);
112
113
  if (!tokens)
113
114
  return {};
115
+ const failure = v2SessionFailure(db, sessionId);
114
116
  return {
115
117
  sessionId,
116
118
  usage: { inputTokens: tokens.input, outputTokens: tokens.output, reasoningTokens: tokens.reasoning },
119
+ ...(failure ? { failure } : {}),
117
120
  };
118
121
  }
119
122
  finally {
@@ -160,7 +163,13 @@ export const opencodeRuntime = {
160
163
  writeKeyFile(keyFile, connection.apiKey);
161
164
  await prepareDatabase(directory, { argv: runtimePrepareArgv(binary), env }, opts);
162
165
  const result = await runBuiltCommand(label, { argv: runtimeArgv(connection, dispatch, binary, session), env, stdin: dispatch.prompt }, opts, "captured");
163
- return { ...result, ...sessionRecord(session) };
166
+ const { failure, ...record } = sessionRecord(session);
167
+ return {
168
+ ...result,
169
+ ...record,
170
+ // OpenCode prints why the turn failed on stderr, which a workflow does not keep; the error carries it.
171
+ ...(result.reason === "non_zero_exit" && failure ? { error: `${result.error}: ${failure}` } : {}),
172
+ };
164
173
  }
165
174
  finally {
166
175
  fs.rmSync(keyFile, { force: true });
@@ -141,7 +141,7 @@ export function listV2Sessions(db, sinceMs) {
141
141
  }
142
142
  /**
143
143
  * The tokens one session's turns used: OpenCode records them on each assistant message and keeps their sums on the
144
- * session row (equal, measured on 2.1.4). Undefined when the session is not in the file.
144
+ * session row (equal, measured on 2.1.4 and 2.2.0). Undefined when the session is not in the file.
145
145
  */
146
146
  export function v2SessionTokens(db, sessionId) {
147
147
  const row = db
@@ -149,6 +149,16 @@ export function v2SessionTokens(db, sessionId) {
149
149
  .get(sessionId);
150
150
  return row ? { input: row.tokens_input, output: row.tokens_output, reasoning: row.tokens_reasoning } : undefined;
151
151
  }
152
+ /**
153
+ * The message a session's turn failed with, as OpenCode recorded it on the session's `idle` message (an error a plugin
154
+ * hook threw, a provider's refusal). Undefined when the session is not in the file or its turn did not fail.
155
+ */
156
+ export function v2SessionFailure(db, sessionId) {
157
+ const row = db
158
+ .prepare("SELECT json_extract(data, '$.error.message') AS message FROM session_message WHERE session_id = ? AND type = 'idle' AND json_extract(data, '$.outcome') = 'failed' ORDER BY seq DESC LIMIT 1")
159
+ .get(sessionId);
160
+ return typeof row?.message === "string" && row.message.trim() !== "" ? row.message : undefined;
161
+ }
152
162
  /** One session's events, from its messages in order. Throws when the session is not in the file. */
153
163
  export function readV2Session(db, harness, sessionId, filePath) {
154
164
  const row = db
@@ -71,7 +71,7 @@ export function shapeProposalEntry(entry, detail) {
71
71
  if (detail === "normal") {
72
72
  // `confidence` and `gateDecision` (#577) explain why a proposal is pending,
73
73
  // so they are projected at `normal` for `akm proposal list/show` when present.
74
- // `retirement` (alpha.9) is the same kind of field for a consolidate
74
+ // `retirement` is the same kind of field for a consolidate
75
75
  // `retire` proposal: without it, "list/show" carries no reason a reviewer
76
76
  // could act on (its `payload.content` is empty by design — a retire
77
77
  // proposal deletes, it does not write).
@@ -278,7 +278,7 @@ export function shapeSearchHit(hit, detail) {
278
278
  }
279
279
  // Stash hit (local or remote)
280
280
  // `ref` is included at `brief` so agents can run `akm show <ref>` without
281
- // needing --detail full or --detail agent (REC-03).
281
+ // needing --detail full or --detail agent.
282
282
  if (detail === "brief") {
283
283
  return pickFields(hit, ["type", "name", "ref", "action", "estimatedTokens", "keys"]);
284
284
  }
@@ -376,7 +376,7 @@ export function shapeShowOutput(result, detail, shape = "human") {
376
376
  const base = pickFields(result, [
377
377
  "type",
378
378
  "name",
379
- // ref is always projected, same as path/editable below (R-020).
379
+ // ref is always projected, same as path/editable below.
380
380
  "ref",
381
381
  "origin",
382
382
  "action",
@@ -7,8 +7,7 @@
7
7
  * events/history, search/curate, and the stash-lifecycle verbs (init,
8
8
  * index, list, add, remove, update, upgrade, clone).
9
9
  *
10
- * Split out of `helpers.ts` (formerly 1418 lines / 59 fns) as its own
11
- * sibling module: unlike `show`/`workflow`/`proposal` (each a stateful,
10
+ * Unlike `show`/`workflow`/`proposal` (each a stateful,
12
11
  * multi-branch renderer with its own module), these are flat
13
12
  * field-extraction formatters with no shared state.
14
13
  */
@@ -308,7 +307,7 @@ export function formatSearchPlain(r, detail) {
308
307
  if (parts.length > 0)
309
308
  lines.push(`timing: ${parts.join(", ")}`);
310
309
  }
311
- // REC-02: When stash hits exist, tell the agent the next required step so it
310
+ // When stash hits exist, tell the agent the next required step so it
312
311
  // doesn't skip `akm show` and write from training memory instead.
313
312
  if (hits.length >= 1) {
314
313
  // Prefer skill/command/agent type hits for the "Next:" ref — knowledge docs are
@@ -517,7 +516,7 @@ export function formatAddPlain(r) {
517
516
  // writes a desired locator to config — nothing is synced until a later
518
517
  // `akm bundle update`. Force-fitting the declarative shape's `added`/`entry`/`message`
519
518
  // fields into the "Installed <ref> (N scanned...)" wording produced
520
- // "Installed undefined (0 directories scanned...)" (R-014) because it
519
+ // "Installed undefined (0 directories scanned...)" because it
521
520
  // implied a sync that never happened. `AddResponse` always carries an
522
521
  // `index` block (even when zero entries were scanned); `SourceAddResult`
523
522
  // never does — that is the reliable shape discriminator used below to
@@ -630,11 +629,11 @@ export function formatBundleRenamePlain(r) {
630
629
  return lines.join("\n");
631
630
  }
632
631
  export function formatUpdatePlain(r) {
633
- // R-015: `processed` alone conflated three cases into one empty array —
632
+ // `processed` alone would conflate three cases into one empty array —
634
633
  // a true no-op (--all with nothing configured), a source this call flatly
635
634
  // never looked at (a plain source under --all), and a plain git/website
636
635
  // source that WAS just synced successfully but has no `UpdateResultItem`
637
- // shape to report (no version/lock to diff). All three rendered as the
636
+ // shape to report (no version/lock to diff). All three would render as the
638
637
  // same misleading "nothing to update". `plainSynced` and `skipped` (see
639
638
  // sources/types.ts) cover the other two so this can report accurately.
640
639
  const processed = r.processed;