akm-cli 0.9.0-beta.40 → 0.9.0-beta.42

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,89 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Fixed
10
+
11
+ - **improve/recombine: cap-aware decay — the `maxClustersPerRun` cap no longer
12
+ traps recurring hypotheses below `confirmThreshold` (#658).** Recombine is a
13
+ two-pass design: a cluster must be re-induced on `confirmThreshold` (=2) runs
14
+ before its `type:hypothesis` proposal promotes to an auto-accepted
15
+ `type:lesson`. But only the top-`maxClustersPerRun` (=5) clusters are
16
+ processed per run, and `decayUnseenRecombineHypotheses` hard-reset the
17
+ confirmation streak of every hypothesis not processed that run. A cluster that
18
+ genuinely re-forms every run but is displaced out of the top-5 (slots are tied
19
+ on member-count and broken by an arbitrary alphabetical tiebreak) had its
20
+ streak zeroed — it could never win two consecutive slots, so its proposal sat
21
+ pending forever (6 such proposals were stuck in one production stash).
22
+ `decayUnseenRecombineHypotheses` is now **cap-aware**: `recombine.ts` passes
23
+ the FULL pre-cap cluster set, and a hypothesis is spared from reset when its
24
+ cluster still Jaccard-matches a present cluster (same signature, overlap ≥ 0.7
25
+ — the same rule used for re-induction). Only hypotheses with no matching
26
+ current cluster (the corpus stopped supporting them) decay. This does **not**
27
+ lower the recurrence bar: the confirmation count is still advanced only by
28
+ genuine re-induction in the processed slice (`recordRecombineInduction`);
29
+ sparing merely avoids an artificial reset, so a genuinely non-recurring
30
+ hypothesis still decays to 0 and never confirms (no new bland-hypothesis churn,
31
+ cf. #632/#633). No schema change. The cap now lives in a new `capClusters`
32
+ helper split out of `buildRelatednessClusters` so the full ranked set stays
33
+ available for the decay sweep.
34
+ - **`improve` reflect no longer emits proposals doomed to fail the
35
+ `invalid-description` gate when the source asset has no frontmatter
36
+ `description` (#636).** Reflect echoed the source frontmatter, so for assets
37
+ that carry other keys but no `description` (notably scraped docs:
38
+ `source`/`title`/`scraped`) the proposal inherited the missing/empty
39
+ description and the promote-time validator (`isValidDescription`, 20–400
40
+ chars) rejected it — observed as ~14/16 rejects in one triage pass, blocking
41
+ the whole scraped-doc/knowledge cluster from reflect improvement. The fix is
42
+ **generation-time only**: (1) `buildReflectPrompt` now injects an explicit
43
+ "synthesize a `description`" instruction whenever the source lacks a non-empty
44
+ `description` and the asset type requires one (per `authoring-rules.ts`
45
+ `DESCRIPTION_TYPES`), telling the model it MUST author a valid 20–400-char
46
+ plain-prose description from the asset's `title:`/first `# Heading`/opening
47
+ body; and (2) a deterministic reflect-side belt-and-suspenders in
48
+ `sanitizeReflectPayload` — if a source that already had frontmatter still ends
49
+ up with a missing/empty description after generation, reflect derives one
50
+ deterministically from `title:`/first heading (validated against
51
+ `isValidDescription`, never free-form invention) **before** the proposal is
52
+ created. The validator, `authoring-rules.ts` bounds, `repairProposalContent`,
53
+ and the drain are unchanged — nothing in the validator/promote path fabricates
54
+ content to pass itself.
55
+ - **The high-salience improve admission lane (#608) now requires a
56
+ content-derived encoding score, not the per-type weight stub (#655,
57
+ #608/#644 follow-up).** The lane previously admitted any zero-feedback ref
58
+ whose `asset_salience.encoding_salience >= salienceThreshold` (default 0.75).
59
+ But for assets distill has not content-scored, `encoding_salience` is just the
60
+ per-type WEIGHT STUB (skill/agent 0.9, command/workflow 0.8, lesson 0.75), so
61
+ "high-salience" degenerated into "is a skill/agent/command/lesson" — which
62
+ selected the type-stub `lore-writer` agent on every run (prod: 1 content-scored
63
+ / 37 type-stub / 1826 NULL-legacy rows). The gate now also requires
64
+ `isContentEncodingRow(row, parseAssetRef(ref).type)` (the #644 provenance
65
+ helper), so only genuinely content-scored assets qualify. This preserves
66
+ #608's intent — distilled assets, the lane's real targets, keep their real
67
+ content score and still qualify — while cutting the type-stub waste; type-stub
68
+ rows must earn retrieval/feedback signal via the other lanes. NULL-legacy rows
69
+ follow `isContentEncodingRow`'s differs-from-stub heuristic. An aggregated log
70
+ line now reports how many refs the lane admitted so lane composition is
71
+ observable. The threshold, type-weight table, 10% cap, and `isContentEncodingRow`
72
+ are unchanged.
73
+
74
+ - **Auto-sync no longer refuses to commit akm's own changes when unrelated
75
+ non-akm files are present in the stash working tree.** When a stash root is
76
+ shared with a project repo, stray files written into the stash root (e.g. a
77
+ `tasks.bak-…` backup dir or report artifacts like `data.js`,
78
+ `akm-health-report.html`, `reports/`) previously tripped the #476 safety
79
+ guard, which threw `refusing to push: … has uncommitted non-akm changes` on
80
+ **every** `akm improve` end-of-run auto-sync, `akm sync`, and `akm push`. In
81
+ one production incident this silently blocked all commits for ~1.5 days while
82
+ akm kept accepting proposals it never persisted. `saveGitStash` now **scopes
83
+ what it stages** instead of refusing: (1) an explicit modified-file list when
84
+ the caller passes `opts.paths`, else (2) the akm-managed pathspecs
85
+ (`TYPE_DIRS` values + `.akm`) that exist on disk — which by construction never
86
+ stages non-akm WIP, preserving the #476 protection without an all-or-nothing
87
+ refusal — and only as a last resort (3) `git add -A` when no managed pathspec
88
+ can be resolved. If nothing akm-managed is staged the run returns
89
+ `nothing to commit` (no empty commit, no throw). Unrelated non-akm files are
90
+ left untouched and uncommitted.
91
+
9
92
  ### Changed
10
93
 
11
94
  - **BEHAVIOR CHANGE — `akm init --dir <path>` no longer silently repoints your
@@ -2471,6 +2471,18 @@ async function runImprovePreparationStage(args) {
2471
2471
  // noFeedbackCandidates), burning LLM calls and churning the asset. This
2472
2472
  // mirrors the P0-A high-retrieval gate's `!lastReflectProposalTs.has(r.ref)`
2473
2473
  // guard above so all "rescue" lanes share the same once-per-asset semantics.
2474
+ //
2475
+ // Content-provenance gate (#644 follow-up): the row must ALSO carry a genuine
2476
+ // content-derived encoding score (`isContentEncodingRow`). Otherwise the lane
2477
+ // admits the per-type WEIGHT STUB (skill/agent 0.9, command/workflow 0.8,
2478
+ // lesson 0.75 from DEFAULT_TYPE_ENCODING_WEIGHTS) for every distill-unscored
2479
+ // asset — i.e. "high-salience" degenerates into "is a skill/agent/command/
2480
+ // lesson", which selected the lore-writer type-stub agent on every run. Only
2481
+ // content-scored assets earn the high-salience rescue; type-stub rows must
2482
+ // earn retrieval/feedback signal via the other lanes. This PRESERVES #608's
2483
+ // intent — distilled assets (the lane's real targets) keep their real content
2484
+ // score and still qualify — while cutting the type-stub waste. See §5 F1 of
2485
+ // docs/design/improve-salience-working-reference.md and #608/#644.
2474
2486
  const highSalienceRefs = [];
2475
2487
  const salienceCfg = (options.config ?? loadConfig()).improve?.salience;
2476
2488
  const salienceThreshold = salienceCfg?.salienceThreshold ?? 0.75;
@@ -2486,7 +2498,10 @@ async function runImprovePreparationStage(args) {
2486
2498
  if (highSalienceRefs.length >= highSalienceCap)
2487
2499
  break;
2488
2500
  const row = getAssetSalience(dbForHighSalience, r.ref);
2489
- if (row && row.encoding_salience >= salienceThreshold && !lastReflectProposalTs.has(r.ref)) {
2501
+ if (row &&
2502
+ isContentEncodingRow(row, parseAssetRef(r.ref).type) &&
2503
+ row.encoding_salience >= salienceThreshold &&
2504
+ !lastReflectProposalTs.has(r.ref)) {
2490
2505
  highSalienceRefs.push(r);
2491
2506
  }
2492
2507
  }
@@ -2499,6 +2514,10 @@ async function runImprovePreparationStage(args) {
2499
2514
  if (dbForHighSalience)
2500
2515
  dbForHighSalience.close();
2501
2516
  }
2517
+ if (highSalienceRefs.length > 0) {
2518
+ info(`[improve] high-salience lane admitted ${highSalienceRefs.length} content-scored ref(s) ` +
2519
+ `(threshold=${salienceThreshold}, requires content-derived encoding_source)`);
2520
+ }
2502
2521
  }
2503
2522
  // Record an in-memory skip action for every zero-feedback ref that the
2504
2523
  // partition loop deferred to P0-A but P0-A then declined (retrievalCount below
@@ -144,8 +144,11 @@ export function isJunkTag(tag) {
144
144
  * - `"both"` — union of the tag and entity grouping keys.
145
145
  *
146
146
  * A cluster is a signal whose member set is >= `minClusterSize`. Overlapping
147
- * clusters are de-duplicated by member-set identity, and the result is capped
148
- * to `maxClustersPerRun` by member-count descending.
147
+ * clusters are de-duplicated by member-set identity, and the result is RANKED
148
+ * by member-count descending (deterministic alphabetical tiebreak). The
149
+ * `maxClustersPerRun` cap is NOT applied here — call {@link capClusters} on the
150
+ * result for the processed slice; the full ranked list is retained so the
151
+ * cap-aware decay sweep can tell cap-displacement from corpus absence (#658).
149
152
  */
150
153
  export function buildRelatednessClusters(entries, opts) {
151
154
  // Only consolidation-eligible memories participate (exclude `.derived`).
@@ -213,9 +216,24 @@ export function buildRelatednessClusters(entries, opts) {
213
216
  seenMemberKeys.add(memberKey);
214
217
  return true;
215
218
  });
216
- // Cap to maxClustersPerRun, largest clusters first (deterministic tiebreak).
219
+ // Rank largest-first (deterministic alphabetical tiebreak). The cap is applied
220
+ // by the caller via {@link capClusters}, NOT here, so the FULL formed set
221
+ // (every cluster that genuinely re-forms this run) stays available for the
222
+ // cap-aware decay sweep — a cluster displaced by the cap must not be confused
223
+ // with a cluster that vanished from the corpus (#658).
217
224
  clusters.sort((a, b) => b.members.length - a.members.length || a.signature.localeCompare(b.signature));
218
- return clusters.slice(0, Math.max(0, opts.maxClustersPerRun));
225
+ return clusters;
226
+ }
227
+ /**
228
+ * #658 — apply the `maxClustersPerRun` cap to a largest-first ranked cluster
229
+ * list. Split out from {@link buildRelatednessClusters} so callers retain the
230
+ * full pre-cap set: the clusters BELOW the cap still re-formed this run and must
231
+ * spare their hypotheses from decay (cap-displacement is a SCHEDULING miss, not
232
+ * a substance miss). Callers that only need the processed slice call this; the
233
+ * full ranked list feeds {@link decayUnseenRecombineHypotheses}.
234
+ */
235
+ export function capClusters(ranked, maxClustersPerRun) {
236
+ return ranked.slice(0, Math.max(0, maxClustersPerRun));
219
237
  }
220
238
  // ── Prompt + ref derivation ───────────────────────────────────────────────────
221
239
  /** Read a memory body (frontmatter stripped) for the cluster prompt. */
@@ -380,14 +398,18 @@ export async function akmRecombine(opts) {
380
398
  if (db)
381
399
  closeDatabase(db);
382
400
  }
383
- const clusters = buildRelatednessClusters(entries, {
401
+ // #658 `rankedClusters` is the FULL set that re-formed this run (ranked,
402
+ // pre-cap); `clusters` is the processed top-`maxClustersPerRun` slice. The
403
+ // decay sweep below uses the full set so a cap-displaced (but present)
404
+ // cluster spares its hypothesis from reset.
405
+ const rankedClusters = buildRelatednessClusters(entries, {
384
406
  minClusterSize,
385
- maxClustersPerRun,
386
407
  relatednessSource,
387
408
  ...(entityByEntryId ? { entityByEntryId } : {}),
388
409
  ...(opts.maxClusterSize != null ? { maxClusterSize: opts.maxClusterSize } : {}),
389
410
  ...(opts.excludeTags ? { excludeTags: opts.excludeTags } : {}),
390
411
  });
412
+ const clusters = capClusters(rankedClusters, maxClustersPerRun);
391
413
  let clustersFormed = 0;
392
414
  let proposalsEmitted = 0;
393
415
  let lessonsPromoted = 0;
@@ -570,8 +592,22 @@ export async function akmRecombine(opts) {
570
592
  }
571
593
  // #625 — decay hypotheses NOT re-induced this run (reset their consecutive
572
594
  // streak) so confirmation is per-consecutive-run and conservative (AC4).
595
+ // #658 — but a hypothesis whose cluster genuinely re-formed this run and was
596
+ // merely cap-displaced (outside the top-`maxClustersPerRun` slice) must NOT
597
+ // be decayed — that is a scheduling miss, not a substance miss. We pass
598
+ // EVERY cluster that formed this run (the full pre-cap `rankedClusters`) as
599
+ // `presentClusters`; decay spares any row that Jaccard-matches a present
600
+ // cluster under the SAME overlap rule used for re-induction. Only rows with
601
+ // no matching current cluster (the corpus stopped supporting them) decay.
573
602
  if (stateDb) {
574
- const decayedCount = decayUnseenRecombineHypotheses(stateDb, sourceRun, [...seenThisRun]);
603
+ const presentClusters = rankedClusters.map((c) => ({
604
+ signature: c.signature,
605
+ memberKey: recombineMemberKey(c),
606
+ }));
607
+ const decayedCount = decayUnseenRecombineHypotheses(stateDb, sourceRun, [...seenThisRun], {
608
+ presentClusters,
609
+ minOverlap: DEFAULT_RECOMBINE_OVERLAP,
610
+ });
575
611
  if (decayedCount > 0) {
576
612
  appendEvent({
577
613
  eventType: "recombine_invoked",
@@ -29,6 +29,7 @@ import { parseAssetRef } from "../../core/asset/asset-ref.js";
29
29
  import { assembleAssetFromString, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
30
30
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
31
31
  import { stripMarkdownFences } from "../../core/asset/markdown.js";
32
+ import { DESCRIPTION_MAX_CHARS, requiresDescription } from "../../core/authoring-rules.js";
32
33
  import { resolveStashDir } from "../../core/common.js";
33
34
  import { loadConfig } from "../../core/config/config.js";
34
35
  import { ConfigError, UsageError } from "../../core/errors.js";
@@ -44,7 +45,7 @@ import { runOpencodeSdk } from "../../integrations/harnesses/opencode-sdk/index.
44
45
  import { chatCompletion } from "../../llm/client.js";
45
46
  import { isLlmFeatureEnabled } from "../../llm/feature-gate.js";
46
47
  import { baseFailureFields, enoentHintMessage, isEnoentFailure, loadAgentConfigFromDisk, resolveAgentProfile, } from "../agent/agent-support.js";
47
- import { checkReflectSize } from "../proposal/validators/proposal-quality-validators.js";
48
+ import { checkReflectSize, isValidDescription } from "../proposal/validators/proposal-quality-validators.js";
48
49
  import { createProposal, isProposalSkipped, listProposals, } from "../proposal/validators/proposals.js";
49
50
  import { deriveLessonRef, runLessonQualityJudge } from "./distill.js";
50
51
  import { classifyReflectChange } from "./reflect-noise.js";
@@ -403,6 +404,82 @@ function stripAppendedFrontmatter(body) {
403
404
  return body;
404
405
  return body.slice(0, body.indexOf(match[0])).replace(/\s+$/, "");
405
406
  }
407
+ /**
408
+ * #636 — deterministically derive a valid `description` from an asset's existing
409
+ * metadata when one is missing. Sources, in priority order: the `title:`
410
+ * frontmatter field, the first `# Heading` in the (proposed or source) body, and
411
+ * the first sentence of the opening body paragraph. The candidate is normalized
412
+ * (whitespace collapsed, trailing punctuation/markdown stripped, clamped to the
413
+ * description max) and only returned if it PASSES `isValidDescription` — so this
414
+ * never produces a heading-fragment, truncated, or otherwise gate-failing value.
415
+ * Returns `undefined` when nothing usable can be derived (caller leaves the
416
+ * proposal as-is rather than fabricating prose).
417
+ *
418
+ * This is intentionally deterministic and lives in the reflect proposal-build
419
+ * path — it does NOT touch the validators or the promote-time repair.
420
+ */
421
+ function deriveDescriptionFromAsset(title, proposedBody, sourceBody, targetRef) {
422
+ // Each candidate is tagged with its kind. A title or `# Heading` is a bare
423
+ // fragment ("Paged.js — Named Page") that reads poorly as a description even
424
+ // when it is long enough to pass the length gate, so for those we prefer the
425
+ // padded sentence form. A prose sentence is already a sentence, so it is used
426
+ // as-is (padding it would double-wrap an already-complete sentence).
427
+ const candidates = [];
428
+ // 1. title: frontmatter
429
+ if (typeof title === "string" && title.trim())
430
+ candidates.push({ text: title.trim(), kind: "fragment" });
431
+ // 2. first `# Heading` (proposed body first, then source body)
432
+ for (const body of [proposedBody, sourceBody]) {
433
+ const headingMatch = body.match(/^#{1,6}\s+(.+?)\s*$/m);
434
+ if (headingMatch?.[1])
435
+ candidates.push({ text: headingMatch[1].trim(), kind: "fragment" });
436
+ }
437
+ // 3. first sentence of the opening prose paragraph (skip headings, fences,
438
+ // list markers, blockquotes — those are not prose).
439
+ for (const body of [proposedBody, sourceBody]) {
440
+ const firstSentence = firstProseSentence(body);
441
+ if (firstSentence)
442
+ candidates.push({ text: firstSentence, kind: "prose" });
443
+ }
444
+ for (const { text, kind } of candidates) {
445
+ const normalized = normalizeDescriptionCandidate(text);
446
+ if (!normalized)
447
+ continue;
448
+ // For a title/heading fragment, try the padded sentence form FIRST so the
449
+ // result reads as a sentence rather than a bare fragment — a short but valid
450
+ // title like "Paged.js — Named Page" (21 chars) would otherwise be returned
451
+ // verbatim. Fall back to the bare form only if the padded form fails the
452
+ // gate. A prose candidate is already a sentence, so it is used as-is.
453
+ const variants = kind === "fragment" ? [`Reference notes on ${normalized}.`, normalized] : [normalized];
454
+ for (const v of variants) {
455
+ const clamped = v.length > DESCRIPTION_MAX_CHARS ? v.slice(0, DESCRIPTION_MAX_CHARS).trimEnd() : v;
456
+ if (isValidDescription(clamped, targetRef, { skipRefTailCheck: true }).ok)
457
+ return clamped;
458
+ }
459
+ }
460
+ return undefined;
461
+ }
462
+ /** Extract the first prose sentence from a markdown body, or `""` if none. */
463
+ function firstProseSentence(body) {
464
+ for (const rawLine of body.split(/\r?\n/)) {
465
+ const line = rawLine.trim();
466
+ if (!line)
467
+ continue;
468
+ if (/^(#{1,6}\s|```|~~~|[-*+]\s|\d+\.\s|>|\||<!--)/.test(line))
469
+ continue;
470
+ const sentenceMatch = line.match(/^(.+?[.!?])(\s|$)/);
471
+ return (sentenceMatch?.[1] ?? line).trim();
472
+ }
473
+ return "";
474
+ }
475
+ /** Normalize a description candidate: strip markdown markers, collapse space. */
476
+ function normalizeDescriptionCandidate(raw) {
477
+ return raw
478
+ .replace(/`/g, "")
479
+ .replace(/^[#>*\-\s]+/, "")
480
+ .replace(/\s+/g, " ")
481
+ .trim();
482
+ }
406
483
  /**
407
484
  * Reflect post-processor — enforces the safety rails described at the top of
408
485
  * this file:
@@ -428,7 +505,7 @@ function stripAppendedFrontmatter(body) {
428
505
  * from `payload.frontmatter` so identity fields can be enforced. Size guard
429
506
  * is skipped because there is no source to compare against.
430
507
  */
431
- function sanitizeReflectPayload(payload, sourceContent, targetRef) {
508
+ export function sanitizeReflectPayload(payload, sourceContent, targetRef) {
432
509
  const warnings = [];
433
510
  const { fmText: sourceFmText, body: sourceBody } = sourceContent
434
511
  ? splitFrontmatter(sourceContent)
@@ -470,6 +547,34 @@ function sanitizeReflectPayload(payload, sourceContent, targetRef) {
470
547
  }
471
548
  }
472
549
  const cleanedBody = stripAppendedFrontmatter(rawLlmBody.replace(/^\s+/, ""));
550
+ // #636 — deterministic description fallback (reflect-side belt-and-suspenders).
551
+ // If the type requires a `description` and the merged frontmatter is still
552
+ // MISSING one (source had none AND the model didn't author one), derive a
553
+ // description DETERMINISTICALLY from the existing `title:` frontmatter or the
554
+ // first `# Heading` / opening body sentence — never free-form invention. This
555
+ // runs in the reflect proposal-build path, BEFORE the proposal is created, so
556
+ // the validator/promote path is left untouched (no gate fabricates content).
557
+ //
558
+ // Scope is the issue's target: a source asset that ALREADY carries frontmatter
559
+ // (e.g. scraped docs: `source`/`title`/`scraped`) but has a MISSING/empty
560
+ // `description`. We deliberately do NOT fire when:
561
+ // - the source has no frontmatter block at all (injecting one would be a
562
+ // structural change and would defeat the #580 no-op/cosmetic noise gate
563
+ // for a pure body echo), or
564
+ // - a present-but-otherwise-invalid description exists (too short, a heading
565
+ // fragment) — overwriting authored content is out of scope; the prompt
566
+ // instruction handles improving it instead.
567
+ const refType = targetRef.includes(":") ? (targetRef.split(":")[0] ?? "") : "";
568
+ const mergedDesc = mergedFm.description;
569
+ const descIsMissing = typeof mergedDesc !== "string" || mergedDesc.trim().length === 0;
570
+ const sourceHadFrontmatter = sourceFmText !== null && Object.keys(sourceFm).length > 0;
571
+ if (refType && requiresDescription(refType) && descIsMissing && sourceHadFrontmatter) {
572
+ const derived = deriveDescriptionFromAsset(mergedFm.title, cleanedBody, sourceBody, targetRef);
573
+ if (derived) {
574
+ mergedFm.description = derived;
575
+ warnings.push("Synthesized a deterministic `description` from title/heading (#636) — source and proposal lacked one.");
576
+ }
577
+ }
473
578
  // Size guard — only when source body is meaningfully large. The pure
474
579
  // predicate lives in `core/proposal-quality-validators` so the same check
475
580
  // also runs inside `runProposalValidators` on `proposal accept`.
@@ -70,6 +70,15 @@ const WHEN_TO_USE_TYPES = new Set(["lesson"]);
70
70
  * Inject this VERBATIM into every improve/authoring prompt that creates or edits
71
71
  * an asset of `type`, so the agent is told exactly what the gate will reject.
72
72
  */
73
+ /**
74
+ * Whether an asset of `type` carries a `description` that the validator
75
+ * (`isValidDescription` / `validateProposalFrontmatter`) treats as required.
76
+ * Reflect uses this to decide when a description-synthesis instruction (and the
77
+ * deterministic reflect-side fallback) must fire for a description-less source.
78
+ */
79
+ export function requiresDescription(type) {
80
+ return DESCRIPTION_TYPES.has(type);
81
+ }
73
82
  export function authoringRulesForType(type) {
74
83
  const rules = [...FRONTMATTER_BODY_RULES];
75
84
  if (DESCRIPTION_TYPES.has(type))
@@ -1708,6 +1708,35 @@ export function getRecombineHypothesis(db, hypothesisRef) {
1708
1708
  export function markRecombineHypothesisPromoted(db, hypothesisRef, promotedAt) {
1709
1709
  db.prepare("UPDATE recombine_hypotheses SET promoted_at = ?, consecutive_count = 0 WHERE hypothesis_ref = ?").run(promotedAt, hypothesisRef);
1710
1710
  }
1711
+ /**
1712
+ * #658 — does any current-run cluster match this hypothesis row under the SAME
1713
+ * signature + Jaccard-overlap rule used for re-induction? A match means the
1714
+ * cluster genuinely re-formed this run (it was merely cap-displaced out of the
1715
+ * processed top-`maxClustersPerRun` slice), so its streak must NOT be reset.
1716
+ */
1717
+ function hypothesisMatchesAnyPresentCluster(row, presentClusters, minOverlap) {
1718
+ const rowMembers = row.member_key.split("|").filter((m) => m.length > 0);
1719
+ if (rowMembers.length === 0)
1720
+ return false;
1721
+ const rowSet = new Set(rowMembers);
1722
+ for (const cluster of presentClusters) {
1723
+ if (cluster.signature !== row.signature)
1724
+ continue;
1725
+ const clusterMembers = cluster.memberKey.split("|").filter((m) => m.length > 0);
1726
+ if (clusterMembers.length === 0)
1727
+ continue;
1728
+ let intersection = 0;
1729
+ for (const m of clusterMembers) {
1730
+ if (rowSet.has(m))
1731
+ intersection += 1;
1732
+ }
1733
+ const union = rowSet.size + clusterMembers.length - intersection;
1734
+ const overlap = union === 0 ? 0 : intersection / union;
1735
+ if (overlap >= minOverlap)
1736
+ return true;
1737
+ }
1738
+ return false;
1739
+ }
1711
1740
  /**
1712
1741
  * Decay-to-zero every NON-promoted hypothesis NOT re-induced in the current run.
1713
1742
  *
@@ -1718,9 +1747,50 @@ export function markRecombineHypothesisPromoted(db, hypothesisRef, promotedAt) {
1718
1747
  *
1719
1748
  * Only rows whose `hypothesis_ref` is NOT in `seenRefs` AND whose `last_run` is
1720
1749
  * NOT the current run are decayed. Already-promoted rows are left alone.
1750
+ *
1751
+ * #658 — CAP-AWARE decay. The recombine pass only re-inducts (and thus marks
1752
+ * `seen`) the top-`maxClustersPerRun` clusters, but a cluster genuinely
1753
+ * re-forms every run even when it is displaced below that cap. Resetting such a
1754
+ * row treats a SCHEDULING miss as a SUBSTANCE miss and traps the hypothesis
1755
+ * below `confirmThreshold` forever. When `opts.presentClusters` is supplied, a
1756
+ * row is SPARED from decay if it Jaccard-matches any present cluster (same
1757
+ * signature, overlap >= `opts.minOverlap`) — i.e. its cluster re-formed this run
1758
+ * but was cap-displaced. This does NOT advance the streak (only re-induction in
1759
+ * the processed slice does that, via {@link recordRecombineInduction}), so the
1760
+ * recurrence bar for promotion is unchanged; it only stops the cap from
1761
+ * manufacturing artificial misses. Omitting `presentClusters` preserves the
1762
+ * pre-#658 hard-reset-after-one-miss behaviour exactly.
1763
+ *
1721
1764
  * Returns the number of rows reset.
1722
1765
  */
1723
- export function decayUnseenRecombineHypotheses(db, currentRun, seenRefs) {
1766
+ export function decayUnseenRecombineHypotheses(db, currentRun, seenRefs, opts) {
1767
+ // #658 — when cap-aware sparing is requested, fold the cap-displaced rows into
1768
+ // the "seen" exclusion set: the underlying reset SQL already protects every
1769
+ // ref it is given, so sparing == treating a spared row exactly like a seen
1770
+ // row for this sweep (its count is left untouched, never advanced).
1771
+ let effectiveSeen = seenRefs;
1772
+ if (opts && opts.presentClusters.length > 0) {
1773
+ const candidates = db
1774
+ .prepare("SELECT hypothesis_ref, signature, member_key FROM recombine_hypotheses WHERE promoted_at IS NULL AND (last_run IS NULL OR last_run != ?) AND consecutive_count != 0")
1775
+ .all(currentRun);
1776
+ const seenSet = new Set(seenRefs);
1777
+ for (const row of candidates) {
1778
+ if (seenSet.has(row.hypothesis_ref))
1779
+ continue;
1780
+ if (hypothesisMatchesAnyPresentCluster(row, opts.presentClusters, opts.minOverlap)) {
1781
+ seenSet.add(row.hypothesis_ref);
1782
+ }
1783
+ }
1784
+ effectiveSeen = [...seenSet];
1785
+ }
1786
+ return decayUnseenRecombineHypothesesInner(db, currentRun, effectiveSeen);
1787
+ }
1788
+ /**
1789
+ * The raw reset sweep shared by the cap-aware wrapper above. Resets every
1790
+ * non-promoted row from a prior run whose ref is NOT in `seenRefs`. Kept private
1791
+ * so the param-ceiling chunking logic lives in one place.
1792
+ */
1793
+ function decayUnseenRecombineHypothesesInner(db, currentRun, seenRefs) {
1724
1794
  // Reset every eligible row, then exclude the seen refs in chunks to respect
1725
1795
  // the ~999 SQLite param ceiling. With no seen refs we reset all non-promoted
1726
1796
  // rows from prior runs in a single statement.
@@ -1732,9 +1802,16 @@ export function decayUnseenRecombineHypotheses(db, currentRun, seenRefs) {
1732
1802
  }
1733
1803
  // A single NOT IN keeps the exclusion atomic (a chunked NOT IN would let a ref
1734
1804
  // excluded by one chunk still be reset by another chunk's statement). The
1735
- // recombine pass caps re-induced clusters at `maxClustersPerRun` (a handful),
1736
- // so `seenRefs` is far under SQLite's ~999 param ceiling in practice; we cap
1737
- // defensively and fall back to resetting all when somehow exceeded.
1805
+ // recombine pass caps RE-INDUCED clusters at `maxClustersPerRun` (a handful)
1806
+ // but with #658 cap-aware sparing the caller folds every cap-displaced
1807
+ // (present-but-unprocessed) hypothesis into `effectiveSeen` too, so on a large
1808
+ // stash `seenRefs` here can carry MANY spared refs, not just the handful that
1809
+ // were processed. We cap defensively at ~900 (under SQLite's ~999 param
1810
+ // ceiling): if `effectiveSeen` somehow exceeds it we fall back to resetting all
1811
+ // eligible rows — which re-introduces the cap-displacement trap for THAT run
1812
+ // (spared rows get decayed because the NOT IN protection is dropped). That is a
1813
+ // rare, bounded degradation; a stash with >900 simultaneously-spared
1814
+ // hypotheses is far beyond current scale.
1738
1815
  if (seenRefs.length > 900) {
1739
1816
  const res = db
1740
1817
  .prepare("UPDATE recombine_hypotheses SET consecutive_count = 0 WHERE promoted_at IS NULL AND (last_run IS NULL OR last_run != ?) AND consecutive_count != 0")
@@ -26,7 +26,7 @@
26
26
  * during validation. We carry it through if the agent supplies it.
27
27
  */
28
28
  import { TYPE_DIRS } from "../../core/asset/asset-spec.js";
29
- import { authoringRulesForType } from "../../core/authoring-rules.js";
29
+ import { authoringRulesForType, DESCRIPTION_MAX_CHARS, DESCRIPTION_MIN_CHARS, requiresDescription, } from "../../core/authoring-rules.js";
30
30
  import { parseEmbeddedJsonResponse, stripCodeFences, stripThinkBlocks } from "../../core/parse.js";
31
31
  /**
32
32
  * Per-asset-type frontmatter / authoring hints surfaced in the prompt so
@@ -111,6 +111,30 @@ export function extractDraftConfidence(stdout) {
111
111
  return undefined;
112
112
  return value;
113
113
  }
114
+ /**
115
+ * Whether the source asset content has a non-empty `description:` key in its
116
+ * YAML frontmatter. Used by {@link buildReflectPrompt} (#636) to decide whether
117
+ * to inject the synthesize-a-description instruction. Uses an inline regex to
118
+ * avoid pulling the full YAML parser into the prompt module (mirrors the
119
+ * existing inline frontmatter handling here).
120
+ */
121
+ function sourceHasNonEmptyDescription(assetContent) {
122
+ if (!assetContent)
123
+ return false;
124
+ const fmMatch = assetContent.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
125
+ if (!fmMatch)
126
+ return false;
127
+ const fmBlock = fmMatch[1] ?? "";
128
+ // Match a top-level `description:` line and capture its inline value.
129
+ const descMatch = fmBlock.match(/^description\s*:\s*(.*)$/m);
130
+ if (!descMatch)
131
+ return false;
132
+ const value = (descMatch[1] ?? "")
133
+ .trim()
134
+ .replace(/^['"]|['"]$/g, "")
135
+ .trim();
136
+ return value.length > 0;
137
+ }
114
138
  /**
115
139
  * Build the prompt for `akm reflect [ref]`. Asks the agent to review an
116
140
  * existing asset (plus any negative feedback / lint findings) and propose
@@ -167,6 +191,23 @@ export function buildReflectPrompt(input) {
167
191
  if (authoringRules) {
168
192
  sections.push(authoringRules);
169
193
  }
194
+ // #636 — synthesize-a-description instruction. Many source assets (notably
195
+ // scraped docs: `source`/`title`/`scraped`) carry frontmatter but NO
196
+ // `description`. Reflect echoes the source frontmatter, so the proposal
197
+ // inherits the missing description and the promote-time validator
198
+ // (isValidDescription, 20–400 chars) rejects it. The fix is at GENERATION
199
+ // time: when the source lacks a non-empty `description` and the type
200
+ // requires one, tell the model — unmissably — that it MUST author a valid
201
+ // `description`. (The validator/promote path is NOT changed: it must never
202
+ // fabricate content to pass itself.)
203
+ if (resolvedType && requiresDescription(resolvedType) && !sourceHasNonEmptyDescription(input.assetContent)) {
204
+ sections.push([
205
+ "REQUIRED — synthesize a `description` (the source asset has none):",
206
+ `- The source frontmatter does NOT include a non-empty \`description\`, but a ${resolvedType} asset REQUIRES one or the proposal will be rejected at promote time.`,
207
+ `- You MUST author a valid \`description\` in the proposal frontmatter: ${DESCRIPTION_MIN_CHARS}–${DESCRIPTION_MAX_CHARS} characters of plain-prose sentence summarizing what this asset is about.`,
208
+ '- Synthesize it from the asset\'s `title:` frontmatter, its first `# Heading`, or the opening body sentence. Do NOT copy a bare heading fragment (e.g. "Overview", "Named Page", "Key Insight") and do NOT emit a truncated phrase that ends on `:`/`;`/`,` or a hanging connector word.',
209
+ ].join("\n"));
210
+ }
170
211
  }
171
212
  if (input.assetContent?.trim()) {
172
213
  // Cap at 12 000 chars to stay well under OS ARG_MAX when the prompt is
@@ -9520,7 +9520,46 @@ function getRecombineHypothesis(db, hypothesisRef) {
9520
9520
  function markRecombineHypothesisPromoted(db, hypothesisRef, promotedAt) {
9521
9521
  db.prepare("UPDATE recombine_hypotheses SET promoted_at = ?, consecutive_count = 0 WHERE hypothesis_ref = ?").run(promotedAt, hypothesisRef);
9522
9522
  }
9523
- function decayUnseenRecombineHypotheses(db, currentRun, seenRefs) {
9523
+ function hypothesisMatchesAnyPresentCluster(row, presentClusters, minOverlap) {
9524
+ const rowMembers = row.member_key.split("|").filter((m) => m.length > 0);
9525
+ if (rowMembers.length === 0)
9526
+ return false;
9527
+ const rowSet = new Set(rowMembers);
9528
+ for (const cluster of presentClusters) {
9529
+ if (cluster.signature !== row.signature)
9530
+ continue;
9531
+ const clusterMembers = cluster.memberKey.split("|").filter((m) => m.length > 0);
9532
+ if (clusterMembers.length === 0)
9533
+ continue;
9534
+ let intersection = 0;
9535
+ for (const m of clusterMembers) {
9536
+ if (rowSet.has(m))
9537
+ intersection += 1;
9538
+ }
9539
+ const union = rowSet.size + clusterMembers.length - intersection;
9540
+ const overlap = union === 0 ? 0 : intersection / union;
9541
+ if (overlap >= minOverlap)
9542
+ return true;
9543
+ }
9544
+ return false;
9545
+ }
9546
+ function decayUnseenRecombineHypotheses(db, currentRun, seenRefs, opts) {
9547
+ let effectiveSeen = seenRefs;
9548
+ if (opts && opts.presentClusters.length > 0) {
9549
+ const candidates = db.prepare("SELECT hypothesis_ref, signature, member_key FROM recombine_hypotheses WHERE promoted_at IS NULL AND (last_run IS NULL OR last_run != ?) AND consecutive_count != 0").all(currentRun);
9550
+ const seenSet = new Set(seenRefs);
9551
+ for (const row of candidates) {
9552
+ if (seenSet.has(row.hypothesis_ref))
9553
+ continue;
9554
+ if (hypothesisMatchesAnyPresentCluster(row, opts.presentClusters, opts.minOverlap)) {
9555
+ seenSet.add(row.hypothesis_ref);
9556
+ }
9557
+ }
9558
+ effectiveSeen = [...seenSet];
9559
+ }
9560
+ return decayUnseenRecombineHypothesesInner(db, currentRun, effectiveSeen);
9561
+ }
9562
+ function decayUnseenRecombineHypothesesInner(db, currentRun, seenRefs) {
9524
9563
  if (seenRefs.length === 0) {
9525
9564
  const res2 = db.prepare("UPDATE recombine_hypotheses SET consecutive_count = 0 WHERE promoted_at IS NULL AND (last_run IS NULL OR last_run != ?) AND consecutive_count != 0").run(currentRun);
9526
9565
  return Number(res2.changes);
@@ -479,28 +479,32 @@ export function saveGitStash(name, message, writableOverride, options) {
479
479
  if (!statusResult.stdout.trim()) {
480
480
  return { committed: false, pushed: false, skipped: false, output: "nothing to commit, working tree clean" };
481
481
  }
482
- // Safety check (#476): when the stash dir is shared with a non-akm project
483
- // (stash root == project repo root), `git add -A` would stage every dirty
484
- // file in the user's working tree and push their unrelated WIP to the
485
- // stash's remote. Refuse if any dirty path is outside the known akm-
486
- // managed subtrees (TYPE_DIRS + `.akm/` state).
487
- const nonAkmDirty = collectNonAkmDirtyPaths(statusResult.stdout);
488
- if (nonAkmDirty.length > 0) {
489
- const sample = nonAkmDirty.slice(0, 10);
490
- const more = nonAkmDirty.length > sample.length ? `\n ...and ${nonAkmDirty.length - sample.length} more` : "";
491
- throw new Error(`refusing to push: stash repo at ${repoDir} has uncommitted non-akm changes:\n` +
492
- sample.map((p) => ` ${p}`).join("\n") +
493
- more +
494
- `\nCommit or stash these manually before running an akm push. ` +
495
- `Akm-managed paths are: ${Object.values(TYPE_DIRS).join(", ")}, .akm/`);
496
- }
497
- // Stage and commit — supply fallback identity so fresh environments without
482
+ // Scoped staging (#476 + the auto-sync incident): NEVER refuse akm's commit
483
+ // because unrelated non-akm files exist in the working tree. When the stash
484
+ // dir is shared with a non-akm project (stash root == project repo root), a
485
+ // blunt `git add -A` would sweep the user's unrelated WIP into the stash's
486
+ // remote. We avoid that by SCOPING what we stage, not by refusing the commit.
487
+ //
488
+ // Precedence:
489
+ // 1. Explicit modified-file list (`options.paths`) — stage exactly those.
490
+ // 2. Managed pathspecs (TYPE_DIRS values + `.akm`) that exist on disk
491
+ // stages everything akm owns and, by construction, never stages non-akm
492
+ // WIP. This preserves the #476 protection WITHOUT refusing.
493
+ // 3. Last-resort `git add -A` — ONLY when neither an explicit list nor any
494
+ // managed pathspec can be resolved. This is the maintainer-approved
495
+ // "or all files if we cannot determine the exact file list" fallback and
496
+ // is the one (rare) path that could include unrelated non-akm files.
497
+ if (!stageScopedChanges(repoDir, options?.paths)) {
498
+ throw new Error(`git add failed while staging akm changes in ${repoDir}`);
499
+ }
500
+ // Nothing actually staged → don't create an empty commit. This happens when
501
+ // only non-akm files were dirty (precedence 2 staged nothing).
502
+ const stagedResult = runGit(["-C", repoDir, "diff", "--cached", "--quiet"]);
503
+ if (stagedResult.status === 0) {
504
+ return { committed: false, pushed: false, skipped: false, output: "nothing to commit" };
505
+ }
506
+ // Commit — supply fallback identity so fresh environments without
498
507
  // user.name/user.email configured can always commit to the default stash.
499
- // `add -A` is safe here because nonAkmDirty was just verified empty.
500
- const addResult = runGit(["-C", repoDir, "add", "-A"]);
501
- if (addResult.status !== 0) {
502
- throw new Error(`git add failed: ${addResult.stderr?.trim() || "unknown error"}`);
503
- }
504
508
  const commitResult = runGit([
505
509
  "-C",
506
510
  repoDir,
@@ -535,6 +539,51 @@ export function saveGitStash(name, message, writableOverride, options) {
535
539
  output: (commitResult.stdout + pushResult.stdout).trim() || "changes committed and pushed",
536
540
  };
537
541
  }
542
+ /**
543
+ * Stage akm's changes in `repoDir` using the scoped-staging precedence
544
+ * documented at the call site (#476). Returns `false` only when a `git add`
545
+ * subprocess fails; returns `true` otherwise (including when nothing matched —
546
+ * the caller then detects "nothing staged" via `git diff --cached --quiet`).
547
+ *
548
+ * @param paths Optional explicit repo-relative paths akm wrote this run. When
549
+ * provided and non-empty, exactly those are staged (chunked to stay under
550
+ * argv length limits). Otherwise we fall back to the managed pathspecs, and
551
+ * finally to `git add -A` only if no managed pathspec exists on disk.
552
+ */
553
+ function stageScopedChanges(repoDir, paths) {
554
+ // Precedence 1: explicit modified-file list.
555
+ const explicit = (paths ?? []).filter((p) => typeof p === "string" && p.length > 0);
556
+ if (explicit.length > 0) {
557
+ return addPathspecsChunked(repoDir, explicit);
558
+ }
559
+ // Precedence 2: managed pathspecs that exist on disk (TYPE_DIRS + `.akm`).
560
+ const managed = [...Object.values(TYPE_DIRS), ".akm"].filter((dir) => fs.existsSync(path.join(repoDir, dir)));
561
+ if (managed.length > 0) {
562
+ return addPathspecsChunked(repoDir, managed);
563
+ }
564
+ // Precedence 3 (last resort): nothing akm-managed resolved on disk. Stage
565
+ // everything. This is the ONLY branch that can include unrelated non-akm
566
+ // files — it is the explicit, maintainer-approved "or all files if we cannot
567
+ // determine the exact file list" fallback and should be rare/never in
568
+ // practice (a git-backed stash always has at least one managed subtree once
569
+ // akm has written to it).
570
+ const addAll = runGit(["-C", repoDir, "add", "-A"]);
571
+ return addAll.status === 0;
572
+ }
573
+ /**
574
+ * Run `git add -- <pathspec>...` in chunks so a very large path list never
575
+ * exceeds the OS argv-length limit. Each chunk must succeed.
576
+ */
577
+ function addPathspecsChunked(repoDir, pathspecs) {
578
+ const CHUNK = 500;
579
+ for (let i = 0; i < pathspecs.length; i += CHUNK) {
580
+ const chunk = pathspecs.slice(i, i + CHUNK);
581
+ const result = runGit(["-C", repoDir, "add", "--", ...chunk]);
582
+ if (result.status !== 0)
583
+ return false;
584
+ }
585
+ return true;
586
+ }
538
587
  function findGitStashByTarget(stashes, target) {
539
588
  return stashes.find((stash) => matchesGitStashTarget(stash, target));
540
589
  }
@@ -620,45 +669,6 @@ export function classifyCloneFailure(url, stderr, spawnError) {
620
669
  const detail = raw || spawnMsg || "unknown error";
621
670
  return `Failed to clone ${url}: ${detail}`;
622
671
  }
623
- // ── Stash-safety helpers (#476) ──────────────────────────────────────────────
624
- /**
625
- * Inspect `git status --porcelain` output and return every dirty path that is
626
- * NOT inside an akm-managed subtree. Used by `runUpstreamPush` to refuse
627
- * pushing unrelated WIP when a writable stash shares its root with a project
628
- * repo.
629
- *
630
- * Porcelain v1 format: `XY <path>` or `XY <orig> -> <new>` for renames. We
631
- * key off the post-rename path (or the only path) — that is the working-tree
632
- * file at risk of being staged by `git add -A`.
633
- */
634
- function collectNonAkmDirtyPaths(porcelainOutput) {
635
- const akmDirs = new Set(Object.values(TYPE_DIRS));
636
- const result = [];
637
- for (const rawLine of porcelainOutput.split("\n")) {
638
- const line = rawLine.replace(/\r$/, "");
639
- if (line.length === 0)
640
- continue;
641
- // Skip the 2-char status code + 1 space.
642
- let p = line.length > 3 ? line.slice(3) : "";
643
- // Renames / copies: `from -> to`. Stage decision applies to `to`.
644
- const arrow = p.lastIndexOf(" -> ");
645
- if (arrow !== -1) {
646
- p = p.slice(arrow + 4);
647
- }
648
- // Strip surrounding quotes for paths with special chars.
649
- if (p.startsWith('"') && p.endsWith('"') && p.length >= 2) {
650
- p = p.slice(1, -1);
651
- }
652
- if (!p)
653
- continue;
654
- const segments = p.split("/");
655
- const top = segments[0];
656
- if (top === ".akm" || akmDirs.has(top))
657
- continue;
658
- result.push(p);
659
- }
660
- return result;
661
- }
662
672
  // ── Exports ─────────────────────────────────────────────────────────────────
663
- export { collectNonAkmDirtyPaths, ensureGitMirror, GitSourceProvider, getCachePaths, parseGitRepoUrl };
673
+ export { ensureGitMirror, GitSourceProvider, getCachePaths, parseGitRepoUrl };
664
674
  // resolveWritableOverride is exported at its declaration above.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.0-beta.40",
3
+ "version": "0.9.0-beta.42",
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": [