akm-cli 0.9.0-rc.1 → 0.9.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/CHANGELOG.md +223 -9
  2. package/SECURITY.md +23 -24
  3. package/dist/assets/help/help-improve.md +10 -10
  4. package/dist/assets/hints/cli-hints-full.md +44 -10
  5. package/dist/assets/hints/cli-hints-short.md +6 -2
  6. package/dist/assets/{profiles → improve-strategies}/default.json +1 -0
  7. package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +1 -1
  8. package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +2 -3
  9. package/dist/assets/{profiles → improve-strategies}/reflect-distill.json +3 -4
  10. package/dist/assets/stash-skeleton/README.md +28 -0
  11. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
  12. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
  13. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
  14. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
  23. package/dist/assets/tasks/core/backup.yml +1 -0
  24. package/dist/assets/tasks/core/extract.yml +1 -0
  25. package/dist/assets/tasks/core/improve.yml +1 -0
  26. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  27. package/dist/assets/tasks/core/sync.yml +1 -0
  28. package/dist/assets/tasks/core/version-check.yml +1 -0
  29. package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
  30. package/dist/assets/templates/html/health.html +5 -1
  31. package/dist/cli/config-migrate.js +31 -138
  32. package/dist/cli/config-validate.js +10 -8
  33. package/dist/cli.js +48 -14
  34. package/dist/commands/agent/agent-dispatch.js +17 -16
  35. package/dist/commands/agent/agent-support.js +0 -24
  36. package/dist/commands/agent/contribute-cli.js +5 -15
  37. package/dist/commands/backup-cli.js +54 -0
  38. package/dist/commands/config-cli.js +45 -159
  39. package/dist/commands/env/secret.js +8 -5
  40. package/dist/commands/health/checks.js +130 -83
  41. package/dist/commands/health/html-report.js +4 -0
  42. package/dist/commands/health/improve-metrics.js +30 -32
  43. package/dist/commands/health/llm-usage.js +19 -19
  44. package/dist/commands/health/md-report.js +4 -0
  45. package/dist/commands/health/metrics.js +2 -1
  46. package/dist/commands/health/surfaces.js +5 -4
  47. package/dist/commands/health.js +1 -1
  48. package/dist/commands/improve/consolidate/chunking.js +2 -2
  49. package/dist/commands/improve/consolidate.js +28 -25
  50. package/dist/commands/improve/distill/promote-memory.js +5 -12
  51. package/dist/commands/improve/distill/quality-gate.js +5 -7
  52. package/dist/commands/improve/distill.js +16 -5
  53. package/dist/commands/improve/eligibility.js +22 -12
  54. package/dist/commands/improve/extract-cli.js +47 -19
  55. package/dist/commands/improve/extract.js +110 -62
  56. package/dist/commands/improve/improve-cli.js +38 -16
  57. package/dist/commands/improve/improve-result-file.js +30 -24
  58. package/dist/commands/improve/improve-strategies.js +137 -0
  59. package/dist/commands/improve/improve.js +60 -30
  60. package/dist/commands/improve/locks.js +66 -45
  61. package/dist/commands/improve/loop-stages.js +75 -33
  62. package/dist/commands/improve/memory/memory-belief.js +79 -7
  63. package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
  64. package/dist/commands/improve/preparation.js +71 -73
  65. package/dist/commands/improve/procedural.js +3 -2
  66. package/dist/commands/improve/recombine.js +2 -1
  67. package/dist/commands/improve/reflect.js +119 -214
  68. package/dist/commands/improve/shared.js +11 -5
  69. package/dist/commands/lint/base-linter.js +152 -42
  70. package/dist/commands/mv-cli.js +809 -0
  71. package/dist/commands/proposal/proposal-cli.js +18 -8
  72. package/dist/commands/proposal/propose.js +64 -69
  73. package/dist/commands/read/knowledge.js +436 -4
  74. package/dist/commands/read/remember-cli.js +39 -2
  75. package/dist/commands/read/search-cli.js +6 -1
  76. package/dist/commands/registry-cli.js +29 -14
  77. package/dist/commands/remember.js +2 -0
  78. package/dist/commands/sources/init.js +13 -14
  79. package/dist/commands/sources/schema-repair.js +2 -4
  80. package/dist/commands/sources/source-add.js +62 -73
  81. package/dist/commands/sources/source-manage.js +50 -46
  82. package/dist/commands/sources/stash-cli.js +41 -4
  83. package/dist/commands/tasks/default-tasks.js +12 -12
  84. package/dist/commands/tasks/tasks-cli.js +7 -3
  85. package/dist/commands/tasks/tasks.js +113 -18
  86. package/dist/commands/wiki-cli.js +9 -10
  87. package/dist/core/asset/frontmatter.js +12 -2
  88. package/dist/core/common.js +5 -3
  89. package/dist/core/config/config-io.js +28 -17
  90. package/dist/core/config/config-schema.js +358 -66
  91. package/dist/core/config/config-types.js +3 -3
  92. package/dist/core/config/config-version.js +29 -0
  93. package/dist/core/config/config-walker.js +98 -27
  94. package/dist/core/config/config.js +132 -266
  95. package/dist/core/config/deep-merge.js +41 -0
  96. package/dist/core/config/engine-semantics.js +32 -0
  97. package/dist/core/errors.js +2 -2
  98. package/dist/core/extra-params.js +61 -0
  99. package/dist/core/file-lock.js +201 -56
  100. package/dist/core/improve-result.js +178 -0
  101. package/dist/core/maintenance-barrier.js +119 -0
  102. package/dist/core/migration-backup.js +416 -0
  103. package/dist/core/paths.js +3 -0
  104. package/dist/core/redaction.js +358 -0
  105. package/dist/core/state/migrations.js +17 -2
  106. package/dist/core/state-db.js +44 -1
  107. package/dist/indexer/db/db.js +116 -1
  108. package/dist/indexer/graph/graph-extraction.js +28 -16
  109. package/dist/indexer/index-writer-lock.js +31 -24
  110. package/dist/indexer/index-written-assets.js +15 -6
  111. package/dist/indexer/indexer.js +47 -2
  112. package/dist/indexer/passes/memory-inference.js +10 -6
  113. package/dist/indexer/passes/metadata.js +250 -0
  114. package/dist/indexer/search/db-search.js +111 -44
  115. package/dist/indexer/search/fts-query.js +41 -0
  116. package/dist/indexer/search/ranking-contributors.js +48 -0
  117. package/dist/indexer/search/ranking.js +36 -23
  118. package/dist/indexer/search/search-fields.js +11 -1
  119. package/dist/integrations/agent/builder-shared.js +7 -0
  120. package/dist/integrations/agent/builders.js +5 -52
  121. package/dist/integrations/agent/config.js +3 -143
  122. package/dist/integrations/agent/detect.js +17 -2
  123. package/dist/integrations/agent/engine-resolution.js +202 -0
  124. package/dist/integrations/agent/index.js +1 -2
  125. package/dist/integrations/agent/model-aliases.js +7 -2
  126. package/dist/integrations/agent/profiles.js +6 -99
  127. package/dist/integrations/agent/runner-dispatch.js +76 -13
  128. package/dist/integrations/agent/runner.js +76 -207
  129. package/dist/integrations/agent/spawn.js +4 -6
  130. package/dist/integrations/harnesses/aider/agent-builder.js +2 -3
  131. package/dist/integrations/harnesses/aider/index.js +0 -1
  132. package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -3
  133. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  134. package/dist/integrations/harnesses/claude/agent-builder.js +2 -3
  135. package/dist/integrations/harnesses/claude/index.js +0 -2
  136. package/dist/integrations/harnesses/codex/agent-builder.js +2 -3
  137. package/dist/integrations/harnesses/codex/index.js +0 -1
  138. package/dist/integrations/harnesses/copilot/agent-builder.js +2 -3
  139. package/dist/integrations/harnesses/copilot/index.js +0 -1
  140. package/dist/integrations/harnesses/gemini/agent-builder.js +2 -3
  141. package/dist/integrations/harnesses/gemini/index.js +0 -1
  142. package/dist/integrations/harnesses/index.js +1 -24
  143. package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
  144. package/dist/integrations/harnesses/opencode/index.js +0 -6
  145. package/dist/integrations/harnesses/opencode-sdk/harness.js +1 -6
  146. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +274 -191
  147. package/dist/integrations/harnesses/openhands/agent-builder.js +2 -3
  148. package/dist/integrations/harnesses/openhands/index.js +0 -1
  149. package/dist/integrations/harnesses/pi/agent-builder.js +2 -3
  150. package/dist/integrations/harnesses/pi/index.js +0 -1
  151. package/dist/integrations/harnesses/types.js +1 -32
  152. package/dist/integrations/lockfile.js +32 -21
  153. package/dist/llm/client.js +48 -14
  154. package/dist/llm/feature-gate.js +15 -47
  155. package/dist/llm/graph-extract.js +1 -1
  156. package/dist/llm/index-passes.js +8 -42
  157. package/dist/llm/memory-infer-impl.js +1 -1
  158. package/dist/llm/usage-persist.js +4 -0
  159. package/dist/llm/usage-telemetry.js +35 -5
  160. package/dist/output/shapes/helpers.js +2 -1
  161. package/dist/output/shapes/passthrough.js +2 -0
  162. package/dist/output/text/helpers.js +3 -1
  163. package/dist/schemas/akm-config.json +11013 -8600
  164. package/dist/schemas/akm-task.json +87 -0
  165. package/dist/schemas/akm-workflow.json +57 -13
  166. package/dist/scripts/migrate-storage.js +8591 -509
  167. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8623 -484
  168. package/dist/setup/detected-engines.js +142 -0
  169. package/dist/setup/engine-config.js +89 -0
  170. package/dist/setup/setup.js +236 -132
  171. package/dist/setup/steps/connection.js +61 -32
  172. package/dist/setup/steps/platforms.js +4 -4
  173. package/dist/setup/steps.js +3 -2
  174. package/dist/storage/database.js +13 -1
  175. package/dist/storage/engines/sqlite-migrations.js +1 -0
  176. package/dist/storage/repositories/improve-runs-repository.js +5 -5
  177. package/dist/storage/repositories/task-history-repository.js +78 -0
  178. package/dist/storage/repositories/workflow-runs-repository.js +9 -8
  179. package/dist/tasks/parser.js +138 -52
  180. package/dist/tasks/runner.js +71 -75
  181. package/dist/tasks/schema.js +1 -1
  182. package/dist/tasks/validator.js +11 -6
  183. package/dist/wiki/wiki.js +9 -8
  184. package/dist/workflows/authoring/workflow-program-template.yaml +3 -3
  185. package/dist/workflows/concurrency-policy.js +15 -0
  186. package/dist/workflows/db.js +65 -13
  187. package/dist/workflows/exec/brief.js +18 -24
  188. package/dist/workflows/exec/frozen-judge.js +47 -0
  189. package/dist/workflows/exec/native-executor.js +124 -65
  190. package/dist/workflows/exec/report.js +93 -33
  191. package/dist/workflows/exec/run-workflow.js +38 -25
  192. package/dist/workflows/exec/scheduler.js +12 -41
  193. package/dist/workflows/exec/step-work.js +91 -35
  194. package/dist/workflows/ir/compile.js +13 -26
  195. package/dist/workflows/ir/freeze.js +243 -0
  196. package/dist/workflows/ir/plan-hash.js +40 -5
  197. package/dist/workflows/ir/schema.js +542 -1
  198. package/dist/workflows/parser.js +7 -0
  199. package/dist/workflows/program/parser.js +132 -23
  200. package/dist/workflows/program/project.js +3 -4
  201. package/dist/workflows/program/schema.js +2 -2
  202. package/dist/workflows/resource-limits.js +20 -0
  203. package/dist/workflows/runtime/plan-classifier.js +187 -0
  204. package/dist/workflows/runtime/runs.js +54 -47
  205. package/dist/workflows/runtime/workflow-asset-loader.js +0 -22
  206. package/dist/workflows/validator.js +25 -0
  207. package/docs/data-and-telemetry.md +2 -2
  208. package/docs/migration/release-notes/0.6.0.md +1 -1
  209. package/docs/migration/release-notes/0.7.0.md +5 -4
  210. package/docs/migration/v0.8-to-v0.9.md +401 -0
  211. package/package.json +4 -2
  212. package/schemas/akm-config.json +16638 -0
  213. package/schemas/akm-task.json +87 -0
  214. package/schemas/akm-workflow.json +372 -0
  215. package/dist/commands/improve/improve-profiles.js +0 -168
  216. package/dist/core/config/config-migration.js +0 -602
  217. package/dist/core/deep-merge.js +0 -38
  218. package/dist/llm/call-ai.js +0 -62
  219. package/dist/setup/legacy-config.js +0 -106
  220. package/docs/README.md +0 -104
  221. /package/dist/assets/{profiles → improve-strategies}/catchup.json +0 -0
  222. /package/dist/assets/{profiles → improve-strategies}/consolidate.json +0 -0
  223. /package/dist/assets/{profiles → improve-strategies}/frequent.json +0 -0
  224. /package/dist/assets/{profiles → improve-strategies}/memory-focus.json +0 -0
  225. /package/dist/assets/{profiles → improve-strategies}/quick.json +0 -0
  226. /package/dist/assets/{profiles → improve-strategies}/recombine-only.json +0 -0
  227. /package/dist/assets/{profiles → improve-strategies}/synthesize.json +0 -0
  228. /package/dist/assets/{profiles → improve-strategies}/thorough.json +0 -0
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { deriveCanonicalAssetName, deriveCanonicalAssetNameFromStashRoot, isRelevantAssetFile, } from "../../core/asset/asset-spec.js";
7
7
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
8
8
  import { asNonEmptyString, isAssetType, writeFileAtomic } from "../../core/common.js";
9
+ import { loadUserConfig } from "../../core/config/config.js";
9
10
  import { isVerbose, warn } from "../../core/warn.js";
10
11
  import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "../walk/file-context.js";
11
12
  import { applyMetadataContributors } from "./metadata-contributors.js";
@@ -182,6 +183,12 @@ export function validateStashEntry(entry) {
182
183
  const sources = normalizeNonEmptyStringList(e.sources);
183
184
  if (sources)
184
185
  result.sources = sources;
186
+ // SPEC-6: `category` must survive the whitelist or .stash.json-declared
187
+ // facts lose their convention/meta marker on the round-trip. Non-string
188
+ // values are dropped, not coerced.
189
+ if (typeof e.category === "string" && e.category.trim().length > 0) {
190
+ result.category = e.category.trim();
191
+ }
185
192
  if (typeof e.beliefState === "string" && e.beliefState.trim().length > 0) {
186
193
  result.beliefState = e.beliefState.trim();
187
194
  }
@@ -218,6 +225,12 @@ export function validateStashEntry(entry) {
218
225
  if (typeof e.derivedFrom === "string" && e.derivedFrom.trim().length > 0) {
219
226
  result.derivedFrom = e.derivedFrom.trim();
220
227
  }
228
+ // SPEC-8: `bodyOpening` must survive the whitelist so entries round-tripped
229
+ // through `.stash.json` keep their captured opening. Preserved verbatim —
230
+ // the extractor already trimmed and capped it at capture time.
231
+ if (typeof e.bodyOpening === "string" && e.bodyOpening.trim().length > 0) {
232
+ result.bodyOpening = e.bodyOpening;
233
+ }
221
234
  if (typeof e.scope === "object" && e.scope !== null && !Array.isArray(e.scope)) {
222
235
  const scope = normalizeScopeObject(e.scope);
223
236
  if (scope)
@@ -342,6 +355,12 @@ export function applyCuratedFrontmatter(entry, fmData) {
342
355
  const quality = asNonEmptyString(fmData.quality);
343
356
  if (quality)
344
357
  entry.quality = normalizeQuality(quality);
358
+ // SPEC-6 capture step: the `category:` frontmatter key (e.g. `convention`,
359
+ // `meta` on facts) must land on the indexed entry so category-keyed
360
+ // policies can see it. Trimmed; blank/non-string values are ignored.
361
+ const category = asNonEmptyString(fmData.category);
362
+ if (category)
363
+ entry.category = category;
345
364
  const beliefState = asNonEmptyString(fmData.beliefState);
346
365
  if (beliefState)
347
366
  entry.beliefState = beliefState;
@@ -805,6 +824,192 @@ export function isEnrichmentComplete(entry) {
805
824
  const hasSearchHints = Array.isArray(entry.searchHints) && entry.searchHints.length > 0;
806
825
  return hasDescription && hasTags && hasSearchHints;
807
826
  }
827
+ // ── Body-opening extraction (stash-conventions SPEC-8) ──────────────────────
828
+ /**
829
+ * Maximum length of a captured self-situating body opening. Bounds index-size
830
+ * growth and keeps a single verbose opening from dominating the low-weight
831
+ * `content` FTS column.
832
+ */
833
+ export const BODY_OPENING_MAX_CHARS = 280;
834
+ /**
835
+ * Minimum characters retained when the cap truncates at a word boundary. A
836
+ * boundary cut that would retain less than this falls back to a hard cut, so
837
+ * one pathological long token cannot gut the capture.
838
+ */
839
+ const BODY_OPENING_MIN_RETAINED_CHARS = 250;
840
+ /**
841
+ * True when `index.indexBodyOpening` is enabled in the user config.
842
+ *
843
+ * The gate is the GLOBAL user config, read directly by the metadata pass so
844
+ * every indexing entry point (stash walk, flat walk, write-path indexing)
845
+ * honors the flag without parameter plumbing. Fail-open: an unreadable or
846
+ * invalid config must never break indexing (CLI entry points surface config
847
+ * errors loudly on their own), so any load failure reads as "off".
848
+ */
849
+ function isBodyOpeningIndexingEnabled() {
850
+ try {
851
+ return loadUserConfig().index?.indexBodyOpening === true;
852
+ }
853
+ catch {
854
+ return false;
855
+ }
856
+ }
857
+ /**
858
+ * Locate a leading nested frontmatter block in a body: up to three blank
859
+ * lines, then a `---` line, closed by a later `---` line. Mirrors the
860
+ * base-linter's `parseInnerFrontmatterBlock` recognition — when `akm
861
+ * remember` wraps a session-capture hook's file in its own frontmatter, the
862
+ * hook's `---\nakm_memory_kind: …\n---` block survives at the top of the
863
+ * body. Returns the open/close line indexes, or `null` when no block opens.
864
+ * Location only — callers apply their own interior checks (marker scan in
865
+ * {@link hasSessionMemoryMarker}, shape test in {@link isFrontmatterShaped}).
866
+ */
867
+ function findInnerFrontmatterBlock(lines) {
868
+ let i = 0;
869
+ while (i < lines.length && i < 3 && lines[i].trim() === "")
870
+ i += 1;
871
+ if (lines[i] !== "---")
872
+ return null;
873
+ for (let j = i + 1; j < lines.length; j += 1) {
874
+ if (lines[j] === "---")
875
+ return { open: i, close: j };
876
+ }
877
+ return null;
878
+ }
879
+ /**
880
+ * True when the document carries the session-capture `akm_memory_kind`
881
+ * marker — in the outer frontmatter data OR in a nested inner block at the
882
+ * top of the body (both producer layouts exist; see base-linter's
883
+ * `extractFrontmatterRefs`). Session bodies are raw transcripts, never a
884
+ * self-situating opening.
885
+ */
886
+ function hasSessionMemoryMarker(fmData, body) {
887
+ if (typeof fmData.akm_memory_kind === "string")
888
+ return true;
889
+ const lines = body.split(/\r?\n/);
890
+ const block = findInnerFrontmatterBlock(lines);
891
+ if (!block)
892
+ return false;
893
+ for (let i = block.open + 1; i < block.close; i += 1) {
894
+ if (/^akm_memory_kind:\s*\S/.test(lines[i]))
895
+ return true;
896
+ }
897
+ return false;
898
+ }
899
+ /**
900
+ * True when the interior of a candidate inner block (located by
901
+ * {@link findInnerFrontmatterBlock}) actually reads as YAML frontmatter:
902
+ * every line is blank, indented (a continuation or nested value), or shaped
903
+ * like a top-level `key:` mapping entry. Ordinary prose bracketed by two
904
+ * thematic-break `---` lines fails this test, so a decorative opening
905
+ * callout is treated as the paragraph it is instead of being discarded
906
+ * (review finding on SPEC-8 — the block finder alone accepts ANY content up
907
+ * to an arbitrarily distant closing `---`).
908
+ */
909
+ function isFrontmatterShaped(lines, block) {
910
+ for (let i = block.open + 1; i < block.close; i += 1) {
911
+ const line = lines[i];
912
+ if (line.trim() === "")
913
+ continue;
914
+ if (/^\s/.test(line))
915
+ continue; // indented continuation / nested value
916
+ if (/^[A-Za-z0-9_.-]+:(\s|$)/.test(line))
917
+ continue; // top-level key
918
+ return false;
919
+ }
920
+ return true;
921
+ }
922
+ /**
923
+ * Extract the first prose paragraph of a markdown body (frontmatter already
924
+ * stripped by the caller): skip blank lines, ATX headings, setext `=`
925
+ * underlines (discarding the heading text above them), thematic breaks,
926
+ * fenced code blocks (``` or ~~~, including their contents), and a leading
927
+ * nested frontmatter block — skipped only when its interior is actually
928
+ * frontmatter-shaped, so prose wrapped in decorative `---` lines is still
929
+ * captured; then collect consecutive non-blank lines until the paragraph
930
+ * ends. Deliberate asymmetry: a `---` row after captured prose ENDS the
931
+ * paragraph and keeps it (favoring the callout/thematic-break reading over
932
+ * CommonMark's setext-H2), while a `=+` row can only be a setext underline
933
+ * and so discards the pending lines as heading text. The result is capped at
934
+ * {@link BODY_OPENING_MAX_CHARS} chars — truncated at the last word boundary
935
+ * that still retains a substantial prefix, with a trailing ellipsis. Returns
936
+ * `undefined` when the body has no prose (frontmatter-only files,
937
+ * headings/fences-only bodies).
938
+ */
939
+ export function extractBodyOpening(body) {
940
+ const lines = body.split(/\r?\n/);
941
+ const innerBlock = findInnerFrontmatterBlock(lines);
942
+ const start = innerBlock && isFrontmatterShaped(lines, innerBlock) ? innerBlock.close + 1 : 0;
943
+ const paragraph = [];
944
+ let inFence = false;
945
+ let fenceChar = "";
946
+ let inHtmlComment = false;
947
+ for (let i = start; i < lines.length; i += 1) {
948
+ const trimmed = lines[i].trim();
949
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
950
+ if (inFence) {
951
+ // Fence interiors are never prose (and may be secrets-adjacent command
952
+ // text); skip until the matching closing marker.
953
+ if (fenceMatch && fenceMatch[1][0] === fenceChar)
954
+ inFence = false;
955
+ continue;
956
+ }
957
+ if (inHtmlComment) {
958
+ // Comment interiors are machinery (the skeleton convention facts open
959
+ // with a <!-- SOFT guidance --> block), never orientation prose.
960
+ if (trimmed.includes("-->"))
961
+ inHtmlComment = false;
962
+ continue;
963
+ }
964
+ if (fenceMatch) {
965
+ if (paragraph.length > 0)
966
+ break; // a fence ends an open paragraph
967
+ inFence = true;
968
+ fenceChar = fenceMatch[1][0];
969
+ continue;
970
+ }
971
+ if (trimmed.startsWith("<!--")) {
972
+ if (paragraph.length > 0)
973
+ break; // a comment ends an open paragraph
974
+ if (!trimmed.includes("-->"))
975
+ inHtmlComment = true;
976
+ continue;
977
+ }
978
+ if (/^=+$/.test(trimmed)) {
979
+ // A row of `=` is a setext H1 underline: the pending lines are heading
980
+ // text, not prose — discard them and keep searching. (A bare `=` row
981
+ // with nothing pending is skipped like any other non-prose divider.)
982
+ paragraph.length = 0;
983
+ continue;
984
+ }
985
+ const isBlank = trimmed === "";
986
+ const isHeading = /^#{1,6}(\s|$)/.test(trimmed);
987
+ const isThematicBreak = /^(-{3,}|\*{3,}|_{3,})$/.test(trimmed);
988
+ if (isBlank || isHeading || isThematicBreak) {
989
+ if (paragraph.length > 0)
990
+ break; // paragraph complete
991
+ continue; // still searching for the first prose line
992
+ }
993
+ paragraph.push(trimmed);
994
+ }
995
+ const text = paragraph.join("\n");
996
+ if (!text)
997
+ return undefined;
998
+ if (text.length <= BODY_OPENING_MAX_CHARS)
999
+ return text;
1000
+ // Cap: prefer a word-boundary cut (never mid-token), but only when it keeps
1001
+ // a substantial prefix; append a one-char ellipsis inside the budget.
1002
+ const slice = text.slice(0, BODY_OPENING_MAX_CHARS - 1);
1003
+ const lastBoundary = Math.max(slice.lastIndexOf(" "), slice.lastIndexOf("\n"), slice.lastIndexOf("\t"));
1004
+ let cut = lastBoundary >= BODY_OPENING_MIN_RETAINED_CHARS ? slice.slice(0, lastBoundary) : slice;
1005
+ // slice() counts UTF-16 code units, so the no-boundary fallback can end on
1006
+ // the high half of a surrogate pair — a lone surrogate that corrupts the
1007
+ // FTS/embedding text. Drop it rather than emit invalid UTF-16.
1008
+ const lastCode = cut.charCodeAt(cut.length - 1);
1009
+ if (lastCode >= 0xd800 && lastCode <= 0xdbff)
1010
+ cut = cut.slice(0, -1);
1011
+ return `${cut.trimEnd()}…`;
1012
+ }
808
1013
  // ── Metadata Generation ─────────────────────────────────────────────────────
809
1014
  /**
810
1015
  * Shared pipeline (steps 2-6) for building a single StashEntry from a file.
@@ -861,6 +1066,17 @@ async function buildEntryFromFile(file, assetType, canonicalName, dirPath, pkgMe
861
1066
  entry.parameters = fmParams;
862
1067
  // Pass wiki-pattern frontmatter through onto the entry
863
1068
  applyWikiFrontmatter(entry, parsed.data);
1069
+ // Stash-organization conventions (SPEC-8): config-gated capture of the
1070
+ // self-situating body opening. Default off — enabling it changes indexed
1071
+ // text (collapse-detector canary baselines shift, and embeddings for
1072
+ // already-embedded entries are NOT regenerated; see docs/configuration.md).
1073
+ // Session-kind memories are raw transcripts and are never captured;
1074
+ // secrets never reach this branch (guard above) and env files are not .md.
1075
+ if (isBodyOpeningIndexingEnabled() && !hasSessionMemoryMarker(parsed.data, parsed.content)) {
1076
+ const bodyOpening = extractBodyOpening(parsed.content);
1077
+ if (bodyOpening)
1078
+ entry.bodyOpening = bodyOpening;
1079
+ }
864
1080
  // Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
865
1081
  if (entry.type === "command") {
866
1082
  const cmdParams = extractCommandParameters(parsed.content);
@@ -911,6 +1127,17 @@ async function buildEntryFromFile(file, assetType, canonicalName, dirPath, pkgMe
911
1127
  if (!entry.tags || entry.tags.length === 0) {
912
1128
  entry.tags = extractTagsFromPath(file, dirPath);
913
1129
  }
1130
+ // Stash-organization conventions (SPEC-2): directory (scope/domain) tokens
1131
+ // always reach the tags column, even when the author set explicit tags, so
1132
+ // nested assets keep the exact-tag ranking boost for their scope token.
1133
+ // Derived from canonicalName (the ref subpath) rather than the filesystem
1134
+ // path so the stash-walk and flat-walk indexing paths agree (the flat walk
1135
+ // passes `path.dirname(file)` as dirPath, which strips directory segments
1136
+ // from the fallback above). Filename tokens are deliberately NOT merged when
1137
+ // explicit tags exist — they already live in the FTS name column and in
1138
+ // aliases, and merging them would inflate exact-tag matches for every
1139
+ // filename word. `normalizeTerms` below dedupes author-restated tokens.
1140
+ entry.tags = [...(entry.tags ?? []), ...extractDirTagsFromName(canonicalName)];
914
1141
  entry.tags = normalizeTerms(entry.tags ?? []);
915
1142
  entry.aliases = mergeAliases(entry.aliases, buildAliases(canonicalName, entry.tags));
916
1143
  // Search hints are only generated when LLM is configured (via enhanceStashWithLlm)
@@ -1127,3 +1354,26 @@ export function extractTagsFromPath(filePath, rootDir) {
1127
1354
  }
1128
1355
  return Array.from(tags);
1129
1356
  }
1357
+ /**
1358
+ * Extract scope/domain tags from the DIRECTORY segments of a canonical asset
1359
+ * name (the ref subpath — e.g. `"projectA/auth-tip"` → `["projecta"]`).
1360
+ *
1361
+ * Unlike {@link extractTagsFromPath} this never tokenizes the filename
1362
+ * segment: it exists so a nested asset's directory (scope/domain) tokens can
1363
+ * be merged into explicit author tags without dragging every filename word
1364
+ * into exact-tag matching (SPEC-2, docs/design/stash-conventions-code-spec.md).
1365
+ * Tokenization mirrors `extractTagsFromPath`: each segment splits on `-`/`_`/
1366
+ * `.`, lowercased, single-character tokens dropped. A name with no directory
1367
+ * segments yields no tags.
1368
+ */
1369
+ export function extractDirTagsFromName(name) {
1370
+ const tags = new Set();
1371
+ for (const segment of name.split("/").slice(0, -1)) {
1372
+ for (const token of segment.split(/[-_.]+/)) {
1373
+ const clean = token.toLowerCase().trim();
1374
+ if (clean && clean.length > 1)
1375
+ tags.add(clean);
1376
+ }
1377
+ }
1378
+ return Array.from(tags);
1379
+ }
@@ -17,6 +17,7 @@ import fs from "node:fs";
17
17
  import { buildActionFromContributors, defaultActionContributors } from "../../core/action-contributors.js";
18
18
  import { makeAssetRef } from "../../core/asset/asset-ref.js";
19
19
  import { defaultRendererRegistry } from "../../core/asset/asset-registry.js";
20
+ import { getAssetTypes } from "../../core/asset/asset-spec.js";
20
21
  import { getDbPath } from "../../core/paths.js";
21
22
  import { warn } from "../../core/warn.js";
22
23
  import { getCurrentWorkflowScopeKey } from "../../workflows/authoring/scope-key.js";
@@ -25,6 +26,7 @@ import { ensureIndex } from "../ensure-index.js";
25
26
  import { collectGraphRelatedHit, computeGraphBoost, loadGraphBoostContext, } from "../graph/graph-boost.js";
26
27
  import { isProposedQuality } from "../passes/metadata.js";
27
28
  import { resolveProjectContext } from "../walk/project-context.js";
29
+ import { parseRefPrefixQuery } from "./fts-query.js";
28
30
  import { applyRankingRules, combineSearchScores, normalizeFtsScores } from "./ranking.js";
29
31
  import { enrichSearchHit } from "./search-hit-enrichers.js";
30
32
  import { buildEditHint, findSourceForPath, isEditable } from "./search-source.js";
@@ -169,58 +171,53 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
169
171
  // `includeExcludedTypes`. When the config key is ABSENT a built-in default of
170
172
  // ['session'] is applied; an explicit empty list disables exclusion.
171
173
  const defaultExcludes = searchType === "any" && !includeExcludedTypes ? (config.search?.defaultExcludeTypes ?? ["session"]) : [];
174
+ // SPEC-4 — ref-prefix queries (`<type>:` / `<type>:<prefix>/`) translate to
175
+ // a typed enumeration narrowed by name prefix, instead of degenerating into
176
+ // the AND-token FTS query their sanitized form would produce ("memory
177
+ // projecta" — noise). The branch fires only on the untyped path: an explicit
178
+ // `--type` flag expresses stronger intent and wins. The PARSED type is
179
+ // itself explicit intent, so `defaultExcludeTypes` does not apply — a bare
180
+ // `session:` enumerates sessions exactly like `--type session` does.
181
+ const refPrefix = searchType === "any" ? parseRefPrefixQuery(query, getAssetTypes()) : null;
182
+ if (refPrefix) {
183
+ return enumerateEntries({
184
+ db,
185
+ query,
186
+ typeFilter: refPrefix.type,
187
+ excludeTypes: [],
188
+ namePrefix: refPrefix.namePrefix,
189
+ limit,
190
+ stashDir,
191
+ allSourceDirs,
192
+ sources,
193
+ config,
194
+ rendererRegistry,
195
+ filters,
196
+ includeProposed,
197
+ beliefFilter,
198
+ restrictToSources,
199
+ });
200
+ }
172
201
  // Empty queries — including ones that sanitize down to no searchable FTS
173
202
  // tokens such as "." — should enumerate matching entries instead of
174
203
  // returning an empty result set from FTS.
175
204
  if (!hasSearchableTokens) {
176
- const typeFilter = searchType === "any" ? undefined : searchType;
177
- const allEntries = getAllEntries(db, typeFilter, defaultExcludes);
178
- // Deduplicate by file path — multiple entries can share the same file
179
- const seenFilePaths = new Set();
180
- const uniqueEntries = allEntries.filter((ie) => {
181
- if (seenFilePaths.has(ie.filePath))
182
- return false;
183
- seenFilePaths.add(ie.filePath);
184
- return true;
185
- });
186
- // Source filter: when the caller narrowed `sources` via `--source <name>`,
187
- // drop entries whose filePath does not live under any of the requested
188
- // sources. The FTS index spans every configured source, so without this
189
- // filter a narrowed --source request would still leak results.
190
- const sourceFiltered = restrictToSources
191
- ? uniqueEntries.filter((ie) => findSourceForPath(ie.filePath, sources) !== undefined)
192
- : uniqueEntries;
193
- // Scope filter: drop entries whose stored scope does not satisfy every
194
- // supplied scope key. Filtering happens BEFORE the limit slice so a
195
- // restrictive filter still returns up to `limit` results.
196
- const scopeFiltered = filters
197
- ? sourceFiltered.filter((ie) => entryMatchesScope(ie.entry.scope, filters))
198
- : sourceFiltered;
199
- // Proposed-quality filter (v1 spec §4.2): exclude entries with
200
- // `quality: "proposed"` unless the caller explicitly opts in.
201
- const qualityFiltered = includeProposed
202
- ? scopeFiltered
203
- : scopeFiltered.filter((ie) => !isProposedQuality(ie.entry.quality));
204
- // 03-R3: derived twins inherit their base's demoting belief state here too,
205
- // so the belief FILTER (and the reported hit state) stays consistent on the
206
- // enumerate/browse path — not only on the FTS-scored path below.
207
- inheritDerivedTwinBeliefStates(db, qualityFiltered);
208
- const beliefFiltered = qualityFiltered.filter((ie) => matchBeliefFilter(ie.entry.beliefState, beliefFilter));
209
- const selected = beliefFiltered.slice(0, limit);
210
- const hits = await Promise.all(selected.map((ie) => buildDbHit({
211
- entry: ie.entry,
212
- path: ie.filePath,
213
- score: 1,
205
+ return enumerateEntries({
206
+ db,
214
207
  query,
215
- rankingMode: "fts",
216
- defaultStashDir: stashDir,
208
+ typeFilter: searchType === "any" ? undefined : searchType,
209
+ excludeTypes: defaultExcludes,
210
+ limit,
211
+ stashDir,
217
212
  allSourceDirs,
218
213
  sources,
219
214
  config,
220
215
  rendererRegistry,
221
- db,
222
- })));
223
- return { hits };
216
+ filters,
217
+ includeProposed,
218
+ beliefFilter,
219
+ restrictToSources,
220
+ });
224
221
  }
225
222
  // Start the async embedding request without awaiting, then run FTS
226
223
  // synchronously while the HTTP/local embedding request is in-flight.
@@ -323,8 +320,14 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
323
320
  // Drop semantic-only hits (cosine-only, no FTS match) whose score falls
324
321
  // below the configured floor. FTS hits and hybrid hits are always kept.
325
322
  // Default floor: 0.2. Set search.minScore = 0 in config to disable.
323
+ // Judged on the PRE-ceiling score when a demoting belief state clamped the
324
+ // item (`preCeilingScore`): the belief ceilings can sit below this floor
325
+ // (archived 0.15 < 0.2), and a demotion must rank the hit last, not
326
+ // silently remove a result that would otherwise have listed.
326
327
  const minScore = config.search?.minScore ?? 0.2;
327
- const preFilter = minScore > 0 ? scored.filter((item) => item.rankingMode !== "semantic" || item.score >= minScore) : scored;
328
+ const preFilter = minScore > 0
329
+ ? scored.filter((item) => item.rankingMode !== "semantic" || (item.preCeilingScore ?? item.score) >= minScore)
330
+ : scored;
328
331
  // Deterministic tiebreaker on equal scores.
329
332
  //
330
333
  // CRITICAL: sort on the SAME clamped+rounded value the user sees (see the
@@ -393,6 +396,70 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
393
396
  }));
394
397
  return { embedMs, rankMs, hits };
395
398
  }
399
+ // ── Enumeration (browse) path ────────────────────────────────────────────────
400
+ /**
401
+ * Enumerate index entries without FTS scoring — the browse path shared by
402
+ * empty/unsearchable queries and SPEC-4 ref-prefix queries (`<type>:` /
403
+ * `<type>:<prefix>/`). Applies the same post-ranking filters as the scored
404
+ * path (source narrowing, scope, proposed-quality, belief) before the limit
405
+ * slice. Hits carry the fixed browse score 1 in insertion order — this is a
406
+ * deterministic listing, not a relevance ranking.
407
+ */
408
+ async function enumerateEntries(opts) {
409
+ const { db, query, sources, config, rendererRegistry, filters, beliefFilter } = opts;
410
+ const allEntries = getAllEntries(db, opts.typeFilter, opts.excludeTypes);
411
+ // SPEC-4: narrow to the requested subtree. `startsWith` on the full
412
+ // slash-retaining prefix is exact — "projecta/" cannot match a sibling
413
+ // "projectalpha/…" scope.
414
+ const namePrefix = opts.namePrefix?.toLowerCase() ?? "";
415
+ const prefixFiltered = namePrefix.length > 0 ? allEntries.filter((ie) => ie.entry.name.toLowerCase().startsWith(namePrefix)) : allEntries;
416
+ // Deduplicate by file path — multiple entries can share the same file
417
+ const seenFilePaths = new Set();
418
+ const uniqueEntries = prefixFiltered.filter((ie) => {
419
+ if (seenFilePaths.has(ie.filePath))
420
+ return false;
421
+ seenFilePaths.add(ie.filePath);
422
+ return true;
423
+ });
424
+ // Source filter: when the caller narrowed `sources` via `--source <name>`,
425
+ // drop entries whose filePath does not live under any of the requested
426
+ // sources. The FTS index spans every configured source, so without this
427
+ // filter a narrowed --source request would still leak results.
428
+ const sourceFiltered = opts.restrictToSources
429
+ ? uniqueEntries.filter((ie) => findSourceForPath(ie.filePath, sources) !== undefined)
430
+ : uniqueEntries;
431
+ // Scope filter: drop entries whose stored scope does not satisfy every
432
+ // supplied scope key. Filtering happens BEFORE the limit slice so a
433
+ // restrictive filter still returns up to `limit` results.
434
+ const scopeFiltered = filters
435
+ ? sourceFiltered.filter((ie) => entryMatchesScope(ie.entry.scope, filters))
436
+ : sourceFiltered;
437
+ // Proposed-quality filter (v1 spec §4.2): exclude entries with
438
+ // `quality: "proposed"` unless the caller explicitly opts in.
439
+ const qualityFiltered = opts.includeProposed
440
+ ? scopeFiltered
441
+ : scopeFiltered.filter((ie) => !isProposedQuality(ie.entry.quality));
442
+ // 03-R3: derived twins inherit their base's demoting belief state here too,
443
+ // so the belief FILTER (and the reported hit state) stays consistent on the
444
+ // enumerate/browse path — not only on the FTS-scored path.
445
+ inheritDerivedTwinBeliefStates(db, qualityFiltered);
446
+ const beliefFiltered = qualityFiltered.filter((ie) => matchBeliefFilter(ie.entry.beliefState, beliefFilter));
447
+ const selected = beliefFiltered.slice(0, opts.limit);
448
+ const hits = await Promise.all(selected.map((ie) => buildDbHit({
449
+ entry: ie.entry,
450
+ path: ie.filePath,
451
+ score: 1,
452
+ query,
453
+ rankingMode: "fts",
454
+ defaultStashDir: opts.stashDir,
455
+ allSourceDirs: opts.allSourceDirs,
456
+ sources,
457
+ config,
458
+ rendererRegistry,
459
+ db,
460
+ })));
461
+ return { hits };
462
+ }
396
463
  /**
397
464
  * 03-R3: let each `.derived` twin inherit its base memory's demoting belief
398
465
  * state for this ranking pass, so a stale flag-free twin is demoted like its
@@ -6,6 +6,8 @@
6
6
  *
7
7
  * These transform a raw user query into an FTS5-safe MATCH expression. They
8
8
  * touch no database state, so they are unit-testable with zero DB setup.
9
+ * `parseRefPrefixQuery` is the one non-FTS helper: it decides whether a raw
10
+ * query should bypass FTS entirely (SPEC-4 ref-prefix enumeration).
9
11
  */
10
12
  /**
11
13
  * Sanitize a raw user query into an FTS5-safe implicit-AND expression.
@@ -49,3 +51,42 @@ export function buildPrefixQuery(ftsQuery) {
49
51
  return null;
50
52
  return prefixTokens.join(" ");
51
53
  }
54
+ /**
55
+ * SPEC-4 — parse a ref-prefix query (`akm search "<type>:<prefix>/"`).
56
+ *
57
+ * Decides whether a raw query is a typed subtree-enumeration request rather
58
+ * than an ordinary keyword search. Matching is deliberately conservative: the
59
+ * trimmed query must be EXACTLY
60
+ *
61
+ * - `<known-type>:` → enumerate the whole type (namePrefix `""`), or
62
+ * - `<known-type>:<prefix>/` → enumerate names under `<prefix>/`.
63
+ *
64
+ * The trailing slash is REQUIRED for a non-empty prefix — and is RETAINED in
65
+ * the returned `namePrefix` — so that a plain `entry.name.startsWith(namePrefix)`
66
+ * check gives exact `/`-boundary subtree semantics (`"projecta/"` cannot match
67
+ * a sibling `projectalpha/…` scope). Bare refs like `memory:a/b` therefore
68
+ * stay ordinary searches (resolving one ref is `akm show` territory), and any
69
+ * interior whitespace disqualifies (prose mentioning a ref is still prose).
70
+ *
71
+ * `knownTypes` is passed in by the caller (e.g. `getAssetTypes()`) to keep
72
+ * this module dependency-free.
73
+ *
74
+ * Returns `null` when the query is not a ref-prefix request.
75
+ */
76
+ export function parseRefPrefixQuery(query, knownTypes) {
77
+ const trimmed = query.trim();
78
+ if (trimmed.length === 0 || /\s/.test(trimmed))
79
+ return null;
80
+ const colon = trimmed.indexOf(":");
81
+ if (colon <= 0)
82
+ return null;
83
+ const type = trimmed.slice(0, colon);
84
+ if (!knownTypes.includes(type))
85
+ return null;
86
+ const rest = trimmed.slice(colon + 1);
87
+ if (rest === "")
88
+ return { type, namePrefix: "" };
89
+ if (rest.endsWith("/"))
90
+ return { type, namePrefix: rest };
91
+ return null;
92
+ }
@@ -61,6 +61,54 @@ function beliefStateBoost(item) {
61
61
  return 0.06;
62
62
  return 0;
63
63
  }
64
+ /**
65
+ * Post-boost score ceilings for the demoting belief states (SPEC-5,
66
+ * stash-conventions-code-spec.md — corrections demotion).
67
+ *
68
+ * Why the additive {@link beliefStateBoost} penalties alone are not enough:
69
+ * keyword base scores are min-max normalized into [0.3, 1.0]
70
+ * (`normalizeFtsScores`), so the spread between the best FTS hit and its
71
+ * runner-up can be as large as 0.7 — and the boost sum then MULTIPLIES the
72
+ * base (`score *= 1 + boostSum`, {@link applyScoreContributors}). A
73
+ * superseded incumbent that is the best keyword match for a query therefore
74
+ * stays clamp-pinned at 1.0 above its own correction no matter what additive
75
+ * penalty it receives — defeating the corrections pattern's point ("so the
76
+ * ranker demotes the stale version instead of letting it outrank your fix").
77
+ *
78
+ * The ceilings guarantee the demotion while keeping flagged entries VISIBLE:
79
+ * un-demoted keyword hits floor at a 0.3 base, so any un-demoted hit outranks
80
+ * a ceilinged one; demoted entries still list (belief FILTERING stays a
81
+ * separate opt-in axis, `--belief`), and scores already below a ceiling keep
82
+ * their relative ordering. Ceiling order mirrors the additive-penalty
83
+ * severity order pinned in tests/belief-state-phase1a.test.ts:
84
+ * deprecated (mildest) > superseded > contradicted > archived.
85
+ */
86
+ const BELIEF_STATE_SCORE_CEILINGS = {
87
+ deprecated: 0.28,
88
+ superseded: 0.25,
89
+ contradicted: 0.2,
90
+ archived: 0.15,
91
+ };
92
+ /**
93
+ * Clamp a ranked entry's FINAL score (after every additive and utility boost)
94
+ * to its demoting belief state's ceiling. No-op for `asserted`/`active`/unset
95
+ * entries. Applied once per item at the end of `applyRankingRules` so sort
96
+ * order and displayed scores stay consistent (single scoring pipeline).
97
+ *
98
+ * When the ceiling clamps, the pre-clamp score is recorded as
99
+ * `preCeilingScore` so db-search's semantic-only `minScore` floor can judge
100
+ * the hit by what it would have scored WITHOUT the demotion — a ceiling below
101
+ * the floor (archived 0.15 < default minScore 0.2) must demote a hit to last
102
+ * place, never silently drop it from the results.
103
+ */
104
+ export function applyBeliefStateScoreCeiling(item) {
105
+ const state = item.entry.beliefState;
106
+ const ceiling = state !== undefined ? BELIEF_STATE_SCORE_CEILINGS[state] : undefined;
107
+ if (ceiling !== undefined && item.score > ceiling) {
108
+ item.preCeilingScore = item.score;
109
+ item.score = ceiling;
110
+ }
111
+ }
64
112
  const exactNameRankingContributor = {
65
113
  name: "exact-name-ranking",
66
114
  appliesTo: () => true,