akm-cli 0.9.0-beta.36 → 0.9.0-beta.38

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Added
10
+
11
+ - **Per-type SOFT authoring conventions are now user-editable stash facts.** A
12
+ third authoring-guidance layer joins the hard rules (#645) and general stash
13
+ standards (#642): a stash owner can author
14
+ `facts/conventions/assets/<type>.md` (e.g. `…/skill.md`, `…/command.md`) to
15
+ capture soft, type-specific guidance — voice, structure, length *preference*,
16
+ naming style. When an agent authors a `skill:x`, the body of
17
+ `fact:conventions/assets/skill` is injected (type-scoped — authoring
18
+ `command:y` pulls the `command` convention, never the `skill` one), labeled
19
+ as soft guidance and kept separate from the validator-enforced hard rules.
20
+ The basename must be a `getAssetTypes()`-validated asset type; facts are read
21
+ straight from disk (no index rebuild) and degrade to empty safely. When no
22
+ per-type fact exists, the built-in `TYPE_HINTS` fallback is unchanged (no
23
+ regression). These facts carry soft conventions only and can never weaken the
24
+ authoring contract the gate enforces (`authoringRulesForType` remains the sole
25
+ source of validator-rejecting rules). The general convention/meta resolver now
26
+ excludes `facts/conventions/assets/*` so per-type guidance never leaks
27
+ un-type-scoped into other authoring flows. (#646)
28
+
9
29
  ## [0.9.0-beta.36] — 2026-06-22
10
30
 
11
31
  ### Added
@@ -54,7 +54,7 @@ import { DEFAULT_DUE_DAYS, DEFAULT_MAX_PER_RUN, filterProactiveDue, selectProact
54
54
  import { akmProcedural } from "./procedural.js";
55
55
  import { akmRecombine } from "./recombine.js";
56
56
  import { akmReflect } from "./reflect.js";
57
- import { buildRankChangeReport, computeSalience, getAllRankScores, getAssetSalience, getConsecutiveNoOps, getLastUseMsByRef, recordNoOp, resetConsecutiveNoOps, SALIENCE_NO_OP_DAMPEN_FACTOR, SALIENCE_NO_OP_DAMPEN_THRESHOLD, upsertAssetSalience, } from "./salience.js";
57
+ import { buildRankChangeReport, computeSalience, getAllRankScores, getAssetSalience, getConsecutiveNoOps, getLastUseMsByRef, isContentEncodingRow, recordNoOp, resetConsecutiveNoOps, SALIENCE_NO_OP_DAMPEN_FACTOR, SALIENCE_NO_OP_DAMPEN_THRESHOLD, upsertAssetSalience, } from "./salience.js";
58
58
  // #607 Lock Decomposition: fine-grained per-process locks replace the single
59
59
  // `improve.lock`. Three independent locks allow concurrent improve runs when
60
60
  // they touch different subsystems (e.g. quick-shredder consolidate can run
@@ -2733,6 +2733,37 @@ async function runImprovePreparationStage(args) {
2733
2733
  const outcomeWeightEnabled = salienceConfig?.outcomeWeightEnabled === true;
2734
2734
  const salienceMap = new Map();
2735
2735
  const nowForSalience = Date.now();
2736
+ // #644 — preserve content-derived encoding scores across runs.
2737
+ //
2738
+ // Before computing the salience vector, load each ref's stored encoding score
2739
+ // and its provenance. When the stored row carries a genuine content-derived
2740
+ // score (written by the distill path via `scoreEncodingSalience`), pass that
2741
+ // value back in as `inputs.encodingSalience` so `computeSalience` does NOT fall
2742
+ // back to the type-weight stub — keeping both the persisted `encoding_salience`
2743
+ // AND the derived `rank_score` keyed on real novelty/magnitude/prediction-error.
2744
+ // Refs that have never been content-scored keep the type-weight stub fallback.
2745
+ const storedEncodingByRef = new Map();
2746
+ {
2747
+ let dbForStoredEncoding;
2748
+ try {
2749
+ dbForStoredEncoding = openStateDatabase(eventsCtx?.dbPath);
2750
+ for (const r of mergedRefs) {
2751
+ const type = r.ref.includes(":") ? r.ref.slice(0, r.ref.indexOf(":")) : "";
2752
+ const row = getAssetSalience(dbForStoredEncoding, r.ref);
2753
+ if (row && isContentEncodingRow(row, type)) {
2754
+ storedEncodingByRef.set(r.ref, row.encoding_salience);
2755
+ }
2756
+ }
2757
+ }
2758
+ catch (err) {
2759
+ rethrowIfTestIsolationError(err);
2760
+ // best-effort: if DB unavailable, fall back to type-weight stub (prior behaviour)
2761
+ }
2762
+ finally {
2763
+ if (dbForStoredEncoding)
2764
+ dbForStoredEncoding.close();
2765
+ }
2766
+ }
2736
2767
  for (const r of mergedRefs) {
2737
2768
  const type = r.ref.includes(":") ? r.ref.slice(0, r.ref.indexOf(":")) : "";
2738
2769
  const sizeBytes = (() => {
@@ -2746,9 +2777,13 @@ async function runImprovePreparationStage(args) {
2746
2777
  return undefined;
2747
2778
  }
2748
2779
  })();
2780
+ const storedEncoding = storedEncodingByRef.get(r.ref);
2749
2781
  const vector = computeSalience({
2750
2782
  ref: r.ref,
2751
2783
  type,
2784
+ // #644: pass the stored content-derived score (if any) so the type-weight
2785
+ // stub is NOT re-asserted over a real distill-written encoding score.
2786
+ ...(storedEncoding !== undefined ? { encodingSalience: storedEncoding } : {}),
2752
2787
  retrievalFreq: retrievalCounts.get(r.ref) ?? 0,
2753
2788
  lastUseMs: lastUseMsByRef.get(r.ref),
2754
2789
  utilityScore: utilityMap.get(r.ref),
@@ -126,6 +126,7 @@ export function computeSalience(inputs) {
126
126
  // Fall back to the type-importance stub only when the caller has not yet
127
127
  // computed a content-based score (e.g. on older assets before the first
128
128
  // staleness refresh runs).
129
+ const encodingSource = inputs.encodingSalience !== undefined ? "content" : "type-stub";
129
130
  const encoding = inputs.encodingSalience !== undefined
130
131
  ? Math.min(1, Math.max(0, inputs.encodingSalience))
131
132
  : (DEFAULT_TYPE_ENCODING_WEIGHTS[inputs.type] ?? DEFAULT_ENCODING_SALIENCE);
@@ -213,24 +214,75 @@ export function computeSalience(inputs) {
213
214
  }
214
215
  const rawRankScore = (we * encoding + wo * outcome + wr * retrieval) * sizePenalty;
215
216
  const rankScore = Math.min(1, Math.max(0, rawRankScore));
216
- return { encoding, outcome, retrieval, rankScore };
217
+ return { encoding, outcome, retrieval, rankScore, encodingSource };
218
+ }
219
+ /**
220
+ * Does this row carry a genuine content-derived `encoding_salience` (#644)?
221
+ *
222
+ * Returns true when the provenance flag is `"content"`. For legacy rows
223
+ * (`encoding_source === null`, written before migration 015) we apply a
224
+ * conservative heuristic: treat the stored value as content-derived only when it
225
+ * does NOT equal the pure type-weight stub for the asset's type — because before
226
+ * the #644 fix every run overwrote real scores with the stub, a value that still
227
+ * differs from the stub must have been content-written and never re-clobbered.
228
+ * When the type cannot be determined (no `type` given) a null-provenance row is
229
+ * treated as a stub (the safe default).
230
+ */
231
+ export function isContentEncodingRow(row, type) {
232
+ if (row.encoding_source === "content")
233
+ return true;
234
+ if (row.encoding_source === "type-stub")
235
+ return false;
236
+ // Legacy NULL provenance: differ-from-stub heuristic.
237
+ if (!type)
238
+ return false;
239
+ const stub = DEFAULT_TYPE_ENCODING_WEIGHTS[type] ?? DEFAULT_ENCODING_SALIENCE;
240
+ return Math.abs(row.encoding_salience - stub) > 1e-9;
217
241
  }
218
242
  /**
219
243
  * Upsert salience scores for one asset into state.db.
220
244
  *
221
- * Idempotent: safe to call every run; updates all columns on conflict.
245
+ * Idempotent: safe to call every run; updates the outcome / retrieval / rank
246
+ * columns on conflict.
247
+ *
248
+ * #644 — encoding provenance guard: the `encoding_salience` + `encoding_source`
249
+ * columns are NOT lowered from a real content-derived score to a type-weight
250
+ * stub. When the stored row is `encoding_source = 'content'` and the incoming
251
+ * vector is a `type-stub` fallback, the stored encoding score and its provenance
252
+ * are preserved (only the other sub-scores and `rank_score` advance). A `content`
253
+ * write always wins; a `type-stub` write only seeds a row that has no content
254
+ * score yet. This stops the improve loop's type-weight fallback re-asserting the
255
+ * stub over a distill-written score on every run.
256
+ *
257
+ * NOTE: when the guard preserves the stored encoding score, the incoming
258
+ * `vector.rankScore` (computed from the stub encoding) is still written. Callers
259
+ * that want the rank_score to reflect the preserved content score should pass the
260
+ * stored content score back in as `inputs.encodingSalience` to `computeSalience`
261
+ * — which the improve loop does. The guard here is the defensive backstop.
222
262
  */
223
263
  export function upsertAssetSalience(db, ref, vector, now) {
224
264
  const ts = now ?? Date.now();
225
265
  db.prepare(`INSERT INTO asset_salience
226
- (asset_ref, encoding_salience, outcome_salience, retrieval_salience, rank_score, consecutive_no_ops, updated_at)
227
- VALUES (?, ?, ?, ?, ?, 0, ?)
266
+ (asset_ref, encoding_salience, outcome_salience, retrieval_salience, rank_score, consecutive_no_ops, updated_at, encoding_source)
267
+ VALUES (?, ?, ?, ?, ?, 0, ?, ?)
228
268
  ON CONFLICT(asset_ref) DO UPDATE SET
229
- encoding_salience = excluded.encoding_salience,
269
+ -- #644: never lower a real content-derived score to a type-weight stub.
270
+ -- Keep the stored encoding score + provenance when the stored row is
271
+ -- 'content' and the incoming write is a 'type-stub' fallback.
272
+ encoding_salience = CASE
273
+ WHEN asset_salience.encoding_source = 'content' AND excluded.encoding_source = 'type-stub'
274
+ THEN asset_salience.encoding_salience
275
+ ELSE excluded.encoding_salience
276
+ END,
277
+ encoding_source = CASE
278
+ WHEN asset_salience.encoding_source = 'content' AND excluded.encoding_source = 'type-stub'
279
+ THEN asset_salience.encoding_source
280
+ ELSE excluded.encoding_source
281
+ END,
230
282
  outcome_salience = excluded.outcome_salience,
231
283
  retrieval_salience = excluded.retrieval_salience,
232
284
  rank_score = excluded.rank_score,
233
- updated_at = excluded.updated_at`).run(ref, vector.encoding, vector.outcome, vector.retrieval, vector.rankScore, ts);
285
+ updated_at = excluded.updated_at`).run(ref, vector.encoding, vector.outcome, vector.retrieval, vector.rankScore, ts, vector.encodingSource ?? "type-stub");
234
286
  }
235
287
  /**
236
288
  * Load the salience row for one asset, or undefined if not yet computed.
@@ -238,7 +290,7 @@ export function upsertAssetSalience(db, ref, vector, now) {
238
290
  export function getAssetSalience(db, ref) {
239
291
  const row = db
240
292
  .prepare(`SELECT asset_ref, encoding_salience, outcome_salience, retrieval_salience,
241
- rank_score, consecutive_no_ops, updated_at
293
+ rank_score, consecutive_no_ops, updated_at, encoding_source
242
294
  FROM asset_salience WHERE asset_ref = ?`)
243
295
  .get(ref);
244
296
  // Bun SQLite returns null (not undefined) when no row found.
@@ -17,8 +17,24 @@
17
17
  */
18
18
  import { extractWikiNameFromRef, INDEX_MD, LOG_MD, loadWikiSchema, SCHEMA_MD } from "../../wiki/wiki.js";
19
19
  import { resolveStashStandards } from "./resolve-stash-standards.js";
20
+ import { resolveTypeConventions, typeConventionRef } from "./resolve-type-conventions.js";
20
21
  /** Wiki infra files that are not authored pages (relative to the wiki root). */
21
22
  const WIKI_INFRA_BASENAMES = new Set([SCHEMA_MD, INDEX_MD, LOG_MD]);
23
+ /**
24
+ * Extract the asset type from a canonical ref (`[origin//]type:name`) without
25
+ * throwing. Returns `undefined` for refs that have no `type:` prefix. Kept local
26
+ * and lenient — the per-type resolver validates the result against
27
+ * `getAssetTypes()`, so a bogus prefix here simply yields no convention.
28
+ */
29
+ function refType(ref) {
30
+ if (!ref)
31
+ return undefined;
32
+ const body = ref.includes("//") ? ref.slice(ref.indexOf("//") + 2) : ref;
33
+ const colon = body.indexOf(":");
34
+ if (colon <= 0)
35
+ return undefined;
36
+ return body.slice(0, colon).trim() || undefined;
37
+ }
22
38
  /**
23
39
  * Resolve the standards context for a write target identified by its asset ref.
24
40
  *
@@ -30,8 +46,23 @@ const WIKI_INFRA_BASENAMES = new Set([SCHEMA_MD, INDEX_MD, LOG_MD]);
30
46
  export function resolveStandardsContext(ref, stashRoot) {
31
47
  const wikiName = ref ? extractWikiNameFromRef(ref) : undefined;
32
48
  if (!wikiName) {
33
- // Non-wiki asset target → Feature B (stash authoring standards).
34
- return resolveStashStandards(stashRoot);
49
+ // Non-wiki asset target → Feature B (general stash standards) plus the
50
+ // per-type SOFT conventions layer (#646), type-scoped to the write target.
51
+ const general = resolveStashStandards(stashRoot);
52
+ const type = refType(ref);
53
+ // A non-empty body here guarantees `type` is a `getAssetTypes()`-validated
54
+ // string (the resolver returns "" otherwise).
55
+ const typeConventions = type ? resolveTypeConventions(stashRoot, type) : "";
56
+ if (!typeConventions || !type)
57
+ return general;
58
+ // Soft, type-scoped guidance — clearly labeled and kept separate from the
59
+ // HARD (validator-enforced) rules that `authoringRulesForType` injects
60
+ // downstream. These facts are advice only; they never weaken the gate.
61
+ const softSection = [
62
+ `# ${typeConventionRef(type)} (soft per-type conventions — guidance, not enforced)`,
63
+ typeConventions,
64
+ ].join("\n");
65
+ return general ? `${general}\n\n${softSection}` : softSection;
35
66
  }
36
67
  // Wiki target. Extract the page path after `wiki:<name>/`.
37
68
  const prefix = `wiki:${wikiName}/`;
@@ -21,6 +21,13 @@ import { parseFrontmatter } from "../asset/frontmatter.js";
21
21
  const STANDARD_CATEGORIES = new Set(["convention", "meta"]);
22
22
  /** Directory (under the stash root) where `fact` assets live. */
23
23
  const FACTS_SUBDIR = "facts";
24
+ /**
25
+ * Per-type SOFT convention facts (`facts/conventions/assets/<type>.md`, #646)
26
+ * are surfaced **type-scoped** through `resolveTypeConventions`, so they must
27
+ * NOT leak into this un-type-scoped general layer (authoring a `command` must
28
+ * not pull the `skill` convention). Excluded by relative path (POSIX form).
29
+ */
30
+ const TYPE_CONVENTIONS_REL = "conventions/assets/";
24
31
  /**
25
32
  * Recursively collect `.md` files under `dir` in stable (sorted) enumeration
26
33
  * order. Returns absolute paths. Missing dir → `[]`.
@@ -59,6 +66,11 @@ export function resolveStashStandards(stashRoot) {
59
66
  const factsRoot = path.join(stashRoot, FACTS_SUBDIR);
60
67
  const sections = [];
61
68
  for (const absPath of collectMarkdownFiles(factsRoot)) {
69
+ // Per-type SOFT conventions are delivered type-scoped (#646); skip them
70
+ // here so they never leak un-type-scoped into every authoring flow.
71
+ const relPosix = path.relative(factsRoot, absPath).split(path.sep).join("/");
72
+ if (relPosix.startsWith(TYPE_CONVENTIONS_REL))
73
+ continue;
62
74
  let raw;
63
75
  try {
64
76
  raw = fs.readFileSync(absPath, "utf8");
@@ -0,0 +1,66 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Resolve **per-type SOFT authoring conventions** (#646) — the third and final
6
+ * authoring-guidance layer:
7
+ *
8
+ * 1. HARD rules (validator-rejecting, code-sourced) → `authoringRulesForType()`
9
+ * (`src/core/authoring-rules.ts`, #645). Never editable; the gate enforces them.
10
+ * 2. General stash standards (cross-type naming/tag conventions) →
11
+ * `resolveStashStandards()` `category: convention|meta` facts (#642).
12
+ * 3. **Per-type SOFT conventions** (voice, structure, length *preference* for
13
+ * *this* asset type) → user-editable `facts/conventions/assets/<type>.md`
14
+ * (THIS module). Augments the built-in `TYPE_HINTS` fallback for display.
15
+ *
16
+ * These facts are **soft only** — advice, not contract. They MUST NOT carry
17
+ * hard, validator-rejecting rules: a user editing or deleting one must never be
18
+ * able to weaken the authoring contract the gate enforces (#645 boundary).
19
+ *
20
+ * Selection is by a `getAssetTypes()`-validated basename: only
21
+ * `facts/conventions/assets/<known-type>.md` resolves. Read directly from disk
22
+ * (no index rebuild); any missing dir/file, unknown type, or read error degrades
23
+ * to `""` and never throws.
24
+ */
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { getAssetTypes } from "../asset/asset-spec.js";
28
+ import { parseFrontmatter } from "../asset/frontmatter.js";
29
+ /** Sub-path (under the stash root) for per-type SOFT convention facts. */
30
+ export const TYPE_CONVENTIONS_SUBDIR = path.join("facts", "conventions", "assets");
31
+ /** The `fact:` ref prefix for a per-type convention, e.g. `fact:conventions/assets/skill`. */
32
+ export function typeConventionRef(type) {
33
+ return `fact:conventions/assets/${type}`;
34
+ }
35
+ /**
36
+ * Read the SOFT authoring-convention body for asset type `type`, if a stash
37
+ * owner has authored `facts/conventions/assets/<type>.md`.
38
+ *
39
+ * @returns the trimmed markdown body (frontmatter stripped), or `""` when the
40
+ * type is unknown, the file is absent, or anything goes wrong.
41
+ */
42
+ export function resolveTypeConventions(stashRoot, type) {
43
+ if (!stashRoot || !type)
44
+ return "";
45
+ // Basename MUST be a known asset type — never resolve an arbitrary file.
46
+ if (!getAssetTypes().includes(type))
47
+ return "";
48
+ const abs = path.join(stashRoot, TYPE_CONVENTIONS_SUBDIR, `${type}.md`);
49
+ let raw;
50
+ try {
51
+ raw = fs.readFileSync(abs, "utf8");
52
+ }
53
+ catch {
54
+ return ""; // missing dir/file or read error → degrade to empty
55
+ }
56
+ let body = "";
57
+ try {
58
+ body = parseFrontmatter(raw).content;
59
+ }
60
+ catch {
61
+ // Malformed frontmatter: fall back to the whole file (parseFrontmatter
62
+ // normally returns whole content as body, but guard defensively).
63
+ body = raw;
64
+ }
65
+ return body.trim();
66
+ }
@@ -791,6 +791,31 @@ const MIGRATIONS = [
791
791
  ON recombine_hypotheses(last_seen_at);
792
792
  `,
793
793
  },
794
+ // ── Migration 015 — asset_salience: encoding_source provenance column (#644) ──
795
+ //
796
+ // Records HOW the stored `encoding_salience` was derived so an improve run's
797
+ // type-weight fallback can no longer clobber a real content-derived score:
798
+ //
799
+ // "content" — written by the distill path from `scoreEncodingSalience`
800
+ // (novelty·0.40 + magnitude·0.35 + predictionError·0.25).
801
+ // "type-stub" — written by `computeSalience`'s `DEFAULT_TYPE_ENCODING_WEIGHTS`
802
+ // fallback (no content-based score available for this ref yet).
803
+ // NULL — legacy row written before this migration; provenance unknown.
804
+ // Treated as a stub by readers UNLESS its stored value differs
805
+ // from the pure type-weight (best-effort heuristic for old data).
806
+ //
807
+ // Why a column rather than inference: before #644, every improve run overwrote
808
+ // the distill-written content score with the type-weight stub, so the stored
809
+ // value alone cannot distinguish a real score from a stub. The provenance flag
810
+ // is the single source of truth going forward; `upsertAssetSalience` refuses to
811
+ // lower a "content" row to a "type-stub" so the high-salience gate (#608) keys
812
+ // on genuine novelty/magnitude/prediction-error, not the asset type.
813
+ {
814
+ id: "015-asset-salience-encoding-source",
815
+ up: `
816
+ ALTER TABLE asset_salience ADD COLUMN encoding_source TEXT DEFAULT NULL;
817
+ `,
818
+ },
794
819
  ];
795
820
  /**
796
821
  * Apply every pending migration in a single transaction per migration.
@@ -9978,6 +9978,12 @@ var init_state_db = __esm(() => {
9978
9978
  CREATE INDEX IF NOT EXISTS idx_recombine_hypotheses_last_seen
9979
9979
  ON recombine_hypotheses(last_seen_at);
9980
9980
  `
9981
+ },
9982
+ {
9983
+ id: "015-asset-salience-encoding-source",
9984
+ up: `
9985
+ ALTER TABLE asset_salience ADD COLUMN encoding_source TEXT DEFAULT NULL;
9986
+ `
9981
9987
  }
9982
9988
  ];
9983
9989
  });
@@ -9108,6 +9108,12 @@ var MIGRATIONS = [
9108
9108
  CREATE INDEX IF NOT EXISTS idx_recombine_hypotheses_last_seen
9109
9109
  ON recombine_hypotheses(last_seen_at);
9110
9110
  `
9111
+ },
9112
+ {
9113
+ id: "015-asset-salience-encoding-source",
9114
+ up: `
9115
+ ALTER TABLE asset_salience ADD COLUMN encoding_source TEXT DEFAULT NULL;
9116
+ `
9111
9117
  }
9112
9118
  ];
9113
9119
  function runMigrations2(db) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.0-beta.36",
3
+ "version": "0.9.0-beta.38",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Management) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
6
6
  "keywords": [