dsh-continual-evolve 0.3.0 → 0.5.0

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/lib/service.js CHANGED
@@ -1,7 +1,10 @@
1
+ import { CONFLICT_HINT_KEY } from "./types.js";
1
2
  import { applyRefinementProposal } from "./apply.js";
3
+ import { randomUUID } from "node:crypto";
2
4
  import { rollbackProposal } from "./rollback.js";
3
5
  import { loadHarnessState, saveHarnessState } from "./state.js";
4
6
  import { appendResult, loadResults, snapshotBefore, storePaths } from "./store.js";
7
+ import { CONFLICT_BLOCK_SCORE, CONFLICT_WARN_SCORE, buildConflictNotice, mostSimilarEntry } from "./promotion.js";
5
8
  export function createEvolutionEngine(baseDir, hooks = {}) {
6
9
  function load(scope, sessionId) {
7
10
  return loadHarnessState(storePaths(baseDir, scope, sessionId).stateDir, scope);
@@ -9,7 +12,29 @@ export function createEvolutionEngine(baseDir, hooks = {}) {
9
12
  function apply(scope, sessionId, proposal, context) {
10
13
  const paths = storePaths(baseDir, scope, sessionId);
11
14
  const state = context?.baselineState ?? load(scope, sessionId);
12
- const id = `evolve_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
15
+ // Write-time conflict guard (R2): global creates are checked against
16
+ // the existing same-kind entries BEFORE any side effect — a
17
+ // near-duplicate is rejected with an actionable error (evolve_update
18
+ // instead), a moderate overlap proceeds stamped with
19
+ // CONFLICT_HINT_KEY. Rollbacks bypass the guard: re-creating an entry
20
+ // that resembles its successor is the point of rollback. Local scope
21
+ // is never blocked (scratch space); the wrapup/fate promotion path
22
+ // already enforces its own overlap policy there.
23
+ const warnHits = new Map();
24
+ if (scope === "global" && !context?.rollbackOf) {
25
+ for (const [index, edit] of proposal.edits.entries()) {
26
+ if (edit.action !== "create")
27
+ continue;
28
+ const hit = mostSimilarEntry(Object.values(state.entries[edit.kind]), edit.title ?? "", edit.content ?? "", CONFLICT_WARN_SCORE);
29
+ if (!hit)
30
+ continue;
31
+ if (hit.score >= CONFLICT_BLOCK_SCORE) {
32
+ throw new Error(`create blocked: ${buildConflictNotice(hit)} already lives in the global ${edit.kind} store — use evolve_update on it instead of adding a duplicate`);
33
+ }
34
+ warnHits.set(index, hit);
35
+ }
36
+ }
37
+ const id = `evolve_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
13
38
  // Code-enforced snapshot: runs before any mutation, cannot be skipped by the model.
14
39
  snapshotBefore(paths, id);
15
40
  const result = applyRefinementProposal(state, proposal, {
@@ -19,6 +44,21 @@ export function createEvolutionEngine(baseDir, hooks = {}) {
19
44
  ...(context?.baselineState ? { baselineState: context.baselineState } : {}),
20
45
  ...(context?.rollbackOf ? { rollbackOf: context.rollbackOf } : {}),
21
46
  });
47
+ // Stamp warn-tier conflicts onto the freshly created entries (both the
48
+ // live state and the result's after-snapshot stay coherent).
49
+ for (const [index, hit] of warnHits) {
50
+ const applied = result.appliedEdits[index];
51
+ if (!applied?.applied || applied.action !== "create" || !applied.id)
52
+ continue;
53
+ const hint = `${applied.kind}:${hit.id}:${hit.score.toFixed(2)}`;
54
+ const live = state.entries[applied.kind][applied.id];
55
+ if (live) {
56
+ live.metadata[CONFLICT_HINT_KEY] = hint;
57
+ if (applied.after) {
58
+ applied.after.metadata[CONFLICT_HINT_KEY] = hint;
59
+ }
60
+ }
61
+ }
22
62
  saveHarnessState(paths.stateDir, state);
23
63
  appendResult(paths, result);
24
64
  hooks.onApplied?.(result);
@@ -10,6 +10,14 @@
10
10
  import type { HarnessEntry } from "./types.js";
11
11
  /** Convert a harness entry id (underscore slug) to a kebab-case skill name. */
12
12
  export declare function skillNameOf(id: string): string;
13
- /** Render a harness skill entry as a discoverable SKILL.md document. */
13
+ /**
14
+ * Render a harness skill entry as a discoverable SKILL.md document.
15
+ *
16
+ * 2026-08-22: the frontmatter description now carries a ROUTING HINT —
17
+ * title plus the first meaningful content line — instead of the bare title.
18
+ * The skill catalog matches on description; a title-only description gave
19
+ * loaders nothing to route on (observed: materialized skills were 7-line
20
+ * stubs with a one-line description and no use-when signal).
21
+ */
14
22
  export declare function renderSkillMarkdown(entry: HarnessEntry): string;
15
23
  //# sourceMappingURL=skill-render.d.ts.map
@@ -2,12 +2,50 @@
2
2
  export function skillNameOf(id) {
3
3
  return id.toLowerCase().replace(/_/g, "-");
4
4
  }
5
- /** Render a harness skill entry as a discoverable SKILL.md document. */
5
+ /**
6
+ * First content line usable as a routing hint: non-empty, not a Markdown
7
+ * heading, not a list marker, not frontmatter. Undefined when the body is
8
+ * effectively empty.
9
+ */
10
+ function routingHint(content) {
11
+ for (const rawLine of content.split("\n")) {
12
+ const line = rawLine.trim();
13
+ if (line.length === 0)
14
+ continue;
15
+ if (line.startsWith("#") || line.startsWith("---") || line.startsWith("-") || line.startsWith("*"))
16
+ continue;
17
+ return oneLine(line);
18
+ }
19
+ return undefined;
20
+ }
21
+ /** Max rendered frontmatter description length (loaders truncate anyway). */
22
+ const MAX_DESCRIPTION_LENGTH = 240;
23
+ /**
24
+ * Render a harness skill entry as a discoverable SKILL.md document.
25
+ *
26
+ * 2026-08-22: the frontmatter description now carries a ROUTING HINT —
27
+ * title plus the first meaningful content line — instead of the bare title.
28
+ * The skill catalog matches on description; a title-only description gave
29
+ * loaders nothing to route on (observed: materialized skills were 7-line
30
+ * stubs with a one-line description and no use-when signal).
31
+ */
6
32
  export function renderSkillMarkdown(entry) {
33
+ const hint = routingHint(entry.content);
34
+ const base = oneLine(entry.title);
35
+ let description;
36
+ if (base.length === 0) {
37
+ description = (hint ?? "").slice(0, MAX_DESCRIPTION_LENGTH);
38
+ }
39
+ else if (hint !== undefined && !base.toLowerCase().includes(hint.toLowerCase())) {
40
+ description = `${base} — use when: ${hint}`.slice(0, MAX_DESCRIPTION_LENGTH);
41
+ }
42
+ else {
43
+ description = base.slice(0, MAX_DESCRIPTION_LENGTH);
44
+ }
7
45
  const lines = [
8
46
  "---",
9
47
  `name: ${skillNameOf(entry.id)}`,
10
- `description: ${oneLine(entry.title)}`,
48
+ `description: ${description}`,
11
49
  "---",
12
50
  "",
13
51
  entry.content.trim(),
package/lib/state.js CHANGED
@@ -80,8 +80,13 @@ export function loadHarnessState(stateDir, scope = "global") {
80
80
  scope: normalizeScope(entry["scope"], scope),
81
81
  reference: objectRecord(entry["reference"]) ?? {},
82
82
  arguments: objectRecord(entry["arguments"]) ?? {},
83
+ // skill_kind must survive persistence: /evolve mount and the
84
+ // listing render branch on it (guidance vs executable).
85
+ ...(entry["skill_kind"] === "guidance" || entry["skill_kind"] === "executable"
86
+ ? { skill_kind: entry["skill_kind"] }
87
+ : {}),
83
88
  metadata: objectRecord(entry["metadata"]) ?? {},
84
- source: entry["source"] === "evolve" ? "evolve" : "evolve",
89
+ source: "evolve",
85
90
  created_at: typeof entry["created_at"] === "string" ? entry["created_at"] : new Date(0).toISOString(),
86
91
  updated_at: typeof entry["updated_at"] === "string" ? entry["updated_at"] : new Date(0).toISOString(),
87
92
  version: typeof entry["version"] === "number" ? entry["version"] : 1,
package/lib/tool.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { defineTool } from "@deepseek-ai/dsh-tools";
2
2
  import { formatHarnessStateForPrompt } from "./render.js";
3
3
  import { requireGlobalApproval } from "./approval.js";
4
+ import { CONFLICT_WARN_SCORE, buildConflictNotice, mostSimilarEntry } from "./promotion.js";
4
5
  import { entrySourceOf } from "./source.js";
5
6
  import { getUsageCount, loadUsage } from "./usage.js";
6
7
  import { buildEvolveCompleteEvent, emitEvolveComplete } from "./evolve-event.js";
@@ -72,7 +73,13 @@ export function registerEvolveTools(ctx, engine, opts) {
72
73
  execute: async (args, exec) => {
73
74
  const scope = scopeOf(args.global, "local");
74
75
  if (scope === "global" && opts.requireGlobalApproval) {
75
- await requireGlobalApproval(ctx, exec.agent, exec.signal, `evolve_add ${args.kind} "${args.title}" 跨会话全局 store`);
76
+ // Informed approval: surface a similarity hit against the
77
+ // existing global store BEFORE the human decides — the
78
+ // engine's write-time guard still has the final say.
79
+ const globalState = engine.load("global", undefined);
80
+ const hit = mostSimilarEntry(Object.values(globalState.entries[args.kind]), args.title ?? "", args.content ?? "", CONFLICT_WARN_SCORE);
81
+ const conflictNote = hit ? ` ⚠️ ${buildConflictNotice(hit)}——建议改用 evolve_update` : "";
82
+ await requireGlobalApproval(ctx, exec.agent, exec.signal, `evolve_add ${args.kind} "${args.title}" → 跨会话全局 store${conflictNote}`);
76
83
  }
77
84
  const edit = {
78
85
  action: "create",
package/lib/types.d.ts CHANGED
@@ -57,6 +57,14 @@ export declare const PROMOTED_AT_KEY = "promotedAt";
57
57
  * cross-session copy back to the session it was distilled from.
58
58
  */
59
59
  export declare const SOURCED_FROM_KEY = "sourcedFromLocal";
60
+ /**
61
+ * Metadata key stamped on a GLOBAL entry that was created despite a
62
+ * moderate-similarity overlap with an existing entry (write-time conflict
63
+ * guard, warn tier): `<kind>:<id>:<score>` — points at the entry it may
64
+ * duplicate so wrapup/fate can consolidate later. Creates at/above the block
65
+ * threshold never reach this stamp; they are rejected outright.
66
+ */
67
+ export declare const CONFLICT_HINT_KEY = "conflictHint";
60
68
  /**
61
69
  * True when the entry is archived (hidden from injection, restorable).
62
70
  * Absent or empty archivedAt means the entry is active.
package/lib/types.js CHANGED
@@ -43,6 +43,14 @@ export const PROMOTED_AT_KEY = "promotedAt";
43
43
  * cross-session copy back to the session it was distilled from.
44
44
  */
45
45
  export const SOURCED_FROM_KEY = "sourcedFromLocal";
46
+ /**
47
+ * Metadata key stamped on a GLOBAL entry that was created despite a
48
+ * moderate-similarity overlap with an existing entry (write-time conflict
49
+ * guard, warn tier): `<kind>:<id>:<score>` — points at the entry it may
50
+ * duplicate so wrapup/fate can consolidate later. Creates at/above the block
51
+ * threshold never reach this stamp; they are rejected outright.
52
+ */
53
+ export const CONFLICT_HINT_KEY = "conflictHint";
46
54
  /**
47
55
  * True when the entry is archived (hidden from injection, restorable).
48
56
  * Absent or empty archivedAt means the entry is active.
package/lib/usage.d.ts CHANGED
@@ -2,19 +2,32 @@ import type { HarnessState, RefinementKind } from "./types.js";
2
2
  export interface UsageStore {
3
3
  /** Injection count per entry key (`kind:id`). */
4
4
  counts: Record<string, number>;
5
+ /** Session dedup marker: the last session id each key was counted in (v2). */
6
+ lastSession?: Record<string, string>;
5
7
  }
6
- /** Load the usage store from disk; returns an empty store when absent or corrupt. */
8
+ /**
9
+ * Load the usage store from disk; returns an empty store when absent or
10
+ * corrupt. Accepts BOTH on-disk shapes:
11
+ * - legacy (≤0.3.x): a flat `{ "kind:id": count }` map,
12
+ * - v2: `{ version: 2, counts, lastSession }` with per-session dedup.
13
+ */
7
14
  export declare function loadUsage(baseDir: string): UsageStore;
8
- /** Persist the usage store atomically. */
15
+ /** Persist the usage store atomically (always the v2 shape). */
9
16
  export declare function saveUsage(baseDir: string, store: UsageStore): void;
10
17
  /** Build the usage key for an entry. */
11
18
  export declare function usageKey(kind: RefinementKind, id: string): string;
12
19
  /**
13
20
  * Increment injection counts for the entries that were actually injected.
14
21
  * Called after `entriesSectionText` renders the injected block. Keys not
15
- * present in the store are initialized to 1; existing keys are incremented.
22
+ * present in the store are initialized to 1.
23
+ *
24
+ * Session dedup (2026-08-22): with a sessionId, each key counts AT MOST
25
+ * ONCE per session — the old per-build counting produced meaningless
26
+ * numbers (one entry hit 2311× in a week) and hid the real "how many
27
+ * sessions found this useful" signal that staleness decay needs. Without
28
+ * a sessionId the call degrades to legacy always-increment behavior.
16
29
  */
17
- export declare function recordInjection(baseDir: string, injectedKeys: string[]): void;
30
+ export declare function recordInjection(baseDir: string, injectedKeys: readonly string[], sessionId?: string): void;
18
31
  /**
19
32
  * Get the injection count for a specific entry. Returns 0 when the entry
20
33
  * has never been injected (absent from the store).
package/lib/usage.js CHANGED
@@ -14,29 +14,44 @@ const USAGE_FILE = "usage.json";
14
14
  function usagePath(baseDir) {
15
15
  return join(baseDir, "evolve", USAGE_FILE);
16
16
  }
17
- /** Load the usage store from disk; returns an empty store when absent or corrupt. */
17
+ /**
18
+ * Load the usage store from disk; returns an empty store when absent or
19
+ * corrupt. Accepts BOTH on-disk shapes:
20
+ * - legacy (≤0.3.x): a flat `{ "kind:id": count }` map,
21
+ * - v2: `{ version: 2, counts, lastSession }` with per-session dedup.
22
+ */
18
23
  export function loadUsage(baseDir) {
19
24
  const path = usagePath(baseDir);
20
25
  try {
21
26
  if (!existsSync(path))
22
- return { counts: {} };
27
+ return { counts: {}, lastSession: {} };
23
28
  const raw = JSON.parse(readFileSync(path, "utf8"));
24
29
  if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
25
- return { counts: raw };
30
+ const record = raw;
31
+ if (record["version"] === 2 && typeof record["counts"] === "object" && record["counts"] !== null) {
32
+ return {
33
+ counts: record["counts"],
34
+ lastSession: typeof record["lastSession"] === "object" && record["lastSession"] !== null
35
+ ? record["lastSession"]
36
+ : {},
37
+ };
38
+ }
39
+ // Legacy flat map: every key is a count.
40
+ return { counts: raw, lastSession: {} };
26
41
  }
27
- return { counts: {} };
42
+ return { counts: {}, lastSession: {} };
28
43
  }
29
44
  catch {
30
- return { counts: {} };
45
+ return { counts: {}, lastSession: {} };
31
46
  }
32
47
  }
33
- /** Persist the usage store atomically. */
48
+ /** Persist the usage store atomically (always the v2 shape). */
34
49
  export function saveUsage(baseDir, store) {
35
50
  const dir = join(baseDir, "evolve");
36
51
  mkdirSync(dir, { recursive: true });
37
52
  const path = usagePath(baseDir);
38
53
  const tmp = `${path}.${process.pid}.tmp`;
39
- writeFileSync(tmp, `${JSON.stringify(store.counts, null, 2)}\n`, "utf8");
54
+ writeFileSync(tmp, `${JSON.stringify({ version: 2, counts: store.counts, lastSession: store.lastSession ?? {} }, null, 2)}\n`, "utf8");
40
55
  renameSync(tmp, path);
41
56
  }
42
57
  /** Build the usage key for an entry. */
@@ -46,16 +61,32 @@ export function usageKey(kind, id) {
46
61
  /**
47
62
  * Increment injection counts for the entries that were actually injected.
48
63
  * Called after `entriesSectionText` renders the injected block. Keys not
49
- * present in the store are initialized to 1; existing keys are incremented.
64
+ * present in the store are initialized to 1.
65
+ *
66
+ * Session dedup (2026-08-22): with a sessionId, each key counts AT MOST
67
+ * ONCE per session — the old per-build counting produced meaningless
68
+ * numbers (one entry hit 2311× in a week) and hid the real "how many
69
+ * sessions found this useful" signal that staleness decay needs. Without
70
+ * a sessionId the call degrades to legacy always-increment behavior.
50
71
  */
51
- export function recordInjection(baseDir, injectedKeys) {
72
+ export function recordInjection(baseDir, injectedKeys, sessionId) {
52
73
  if (injectedKeys.length === 0)
53
74
  return;
54
75
  const store = loadUsage(baseDir);
76
+ let dirty = false;
55
77
  for (const key of injectedKeys) {
78
+ if (sessionId !== undefined && store.lastSession?.[key] === sessionId) {
79
+ continue;
80
+ }
56
81
  store.counts[key] = (store.counts[key] ?? 0) + 1;
82
+ if (store.lastSession && sessionId !== undefined) {
83
+ store.lastSession[key] = sessionId;
84
+ }
85
+ dirty = true;
86
+ }
87
+ if (dirty) {
88
+ saveUsage(baseDir, store);
57
89
  }
58
- saveUsage(baseDir, store);
59
90
  }
60
91
  /**
61
92
  * Get the injection count for a specific entry. Returns 0 when the entry
@@ -4,5 +4,6 @@
4
4
  import type { Context } from "@deepseek-ai/cordis";
5
5
  import type { CommandInvocation, CommandResult } from "@deepseek-ai/dsh-commands";
6
6
  import type { EvolutionEngine } from "./service.js";
7
- export declare function executeWrapupCommand(ctx: Context, engine: EvolutionEngine, invocation: CommandInvocation): Promise<CommandResult>;
7
+ import { type PromotionPolicy } from "./promotion.js";
8
+ export declare function executeWrapupCommand(ctx: Context, engine: EvolutionEngine, invocation: CommandInvocation, policy?: PromotionPolicy): Promise<CommandResult>;
8
9
  //# sourceMappingURL=wrapup-command.d.ts.map
@@ -1,9 +1,10 @@
1
1
  import { questionServiceOf, requireGlobalApproval } from "./approval.js";
2
2
  import { assessLocalEntries, candidateKey, filterPromotable, listLocalCandidates, splitArchiveGuards, splitPromoteBlocked, splitPromoteProposals, wholePromoteProposals } from "./wrapup.js";
3
+ import { DEFAULT_PROMOTION_POLICY } from "./promotion.js";
3
4
  function success(text) {
4
5
  return { kind: "success", text };
5
6
  }
6
- export async function executeWrapupCommand(ctx, engine, invocation) {
7
+ export async function executeWrapupCommand(ctx, engine, invocation, policy = DEFAULT_PROMOTION_POLICY) {
7
8
  const sessionId = invocation.agent.id;
8
9
  const localState = engine.load("local", sessionId);
9
10
  const globalState = engine.load("global", undefined);
@@ -16,7 +17,7 @@ export async function executeWrapupCommand(ctx, engine, invocation) {
16
17
  const byKey = new Map(candidates.map((candidate) => [candidateKey(candidate.kind, candidate.id), candidate]));
17
18
  // 2. Partition by action. Deterministic guards re-check the LIVE global
18
19
  // store right before anything lands (state may have changed mid-call).
19
- const { promotable, skipped } = filterPromotable(assessment.items, globalState, candidates);
20
+ const { promotable, skipped } = filterPromotable(assessment.items, globalState, candidates, policy);
20
21
  const promoteItems = promotable.filter((item) => item.verdict === "promote");
21
22
  const archiveItems = assessment.items.filter((item) => item.verdict === "archive");
22
23
  // Split promotion (A-form): archive a mixed entry but promote ONLY the
@@ -33,7 +34,7 @@ export async function executeWrapupCommand(ctx, engine, invocation) {
33
34
  splitSkipped.push({ key: item.key, reason: "not in the audited candidate list" });
34
35
  continue;
35
36
  }
36
- const blocked = splitPromoteBlocked(item, globalState, candidate.kind);
37
+ const blocked = splitPromoteBlocked(item, globalState, candidate.kind, policy);
37
38
  if (blocked) {
38
39
  splitSkipped.push({ key: item.key, reason: blocked });
39
40
  continue;
package/lib/wrapup.d.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  import type { Context } from "@deepseek-ai/cordis";
22
22
  import type { Agent } from "@deepseek-ai/dsh-agent";
23
23
  import type { HarnessEntry, HarnessState, RefinementKind, RefinementProposal } from "./types.js";
24
+ import { type PromotionPolicy } from "./promotion.js";
24
25
  /** What should happen to one local entry at session end. */
25
26
  export type WrapupVerdict = "promote" | "archive" | "keep";
26
27
  /** A classified local entry: `key` matches one audited candidate exactly. */
@@ -138,9 +139,16 @@ export interface PromotableSplit {
138
139
  * Apply-time deterministic guard: re-check every promote verdict against the
139
140
  * global store right before it lands. The LLM classification may be stale
140
141
  * (a gate ran while assessing) or wrong; this ensures a promote never writes
141
- * a duplicate global entry. Pure and unit-tested.
142
+ * a duplicate, project-scoped, or too-thin global entry. Pure and unit-tested.
143
+ *
144
+ * Guards (2026-08-22 promotion policy):
145
+ * - audited candidate list + title coverage (pre-existing),
146
+ * - project-scoped content markers (absolute paths / session ids) — the
147
+ * global store is shared across projects and must stay portable,
148
+ * - thin content below the policy floor (framing outweighs the fact),
149
+ * - near-duplicate of an existing global entry by content overlap.
142
150
  */
143
- export declare function filterPromotable(items: readonly WrapupItem[], globalState: HarnessState, candidates: readonly WrapupCandidate[]): PromotableSplit;
151
+ export declare function filterPromotable(items: readonly WrapupItem[], globalState: HarnessState, candidates: readonly WrapupCandidate[], policy?: PromotionPolicy): PromotableSplit;
144
152
  export interface ArchiveReviewSplit {
145
153
  /** Archives that may proceed silently: topic already covered, no real
146
154
  * distillation source, or the archive half of an already-approved split. */
@@ -171,11 +179,12 @@ export declare function needsArchiveReview(item: WrapupItem, candidate: WrapupCa
171
179
  export declare function splitArchiveGuards(items: readonly WrapupItem[], candidates: readonly WrapupCandidate[]): ArchiveReviewSplit;
172
180
  /**
173
181
  * Apply-time guard for a split promotion (archive + promote sub-object):
174
- * the cleaned title must not duplicate a topic already covered globally. A
175
- * duplicate split is dropped (the entry still archives plain) rather than
176
- * half-promoting a redundancy.
182
+ * the cleaned payload must pass the same promotion policy as a whole
183
+ * promote no global coverage duplicate, no project-scoped content, not
184
+ * too thin, no near-duplicate global entry. A blocked split is dropped (the
185
+ * entry still archives plain) rather than half-promoting a redundancy.
177
186
  */
178
- export declare function splitPromoteBlocked(item: WrapupItem, globalState: HarnessState, kind: RefinementKind): string | undefined;
187
+ export declare function splitPromoteBlocked(item: WrapupItem, globalState: HarnessState, kind: RefinementKind, policy?: PromotionPolicy): string | undefined;
179
188
  /**
180
189
  * Shared proposal builders for a WHOLE promotion — used by both the
181
190
  * `/evolve wrapup` command and the gate's local-fate dimension so the two
package/lib/wrapup.js CHANGED
@@ -4,6 +4,7 @@ import { compactText } from "./render.js";
4
4
  import { streamText } from "./llm-text.js";
5
5
  import { getUsageCount, loadUsage } from "./usage.js";
6
6
  import { recencyScore } from "./inject.js";
7
+ import { DEFAULT_PROMOTION_POLICY, mostSimilarGlobalEntry, projectScopedReason } from "./promotion.js";
7
8
  export function candidateKey(kind, id) {
8
9
  return `${kind}:${id}`;
9
10
  }
@@ -158,9 +159,16 @@ export function parseWrapupAssessment(text, candidates) {
158
159
  * Apply-time deterministic guard: re-check every promote verdict against the
159
160
  * global store right before it lands. The LLM classification may be stale
160
161
  * (a gate ran while assessing) or wrong; this ensures a promote never writes
161
- * a duplicate global entry. Pure and unit-tested.
162
+ * a duplicate, project-scoped, or too-thin global entry. Pure and unit-tested.
163
+ *
164
+ * Guards (2026-08-22 promotion policy):
165
+ * - audited candidate list + title coverage (pre-existing),
166
+ * - project-scoped content markers (absolute paths / session ids) — the
167
+ * global store is shared across projects and must stay portable,
168
+ * - thin content below the policy floor (framing outweighs the fact),
169
+ * - near-duplicate of an existing global entry by content overlap.
162
170
  */
163
- export function filterPromotable(items, globalState, candidates) {
171
+ export function filterPromotable(items, globalState, candidates, policy = DEFAULT_PROMOTION_POLICY) {
164
172
  const byKey = new Map(candidates.map((candidate) => [candidateKey(candidate.kind, candidate.id), candidate]));
165
173
  const promotable = [];
166
174
  const skipped = [];
@@ -176,6 +184,26 @@ export function filterPromotable(items, globalState, candidates) {
176
184
  skipped.push({ key: item.key, reason: "already covered globally" });
177
185
  continue;
178
186
  }
187
+ const scoped = projectScopedReason(`${candidate.title}\n${candidate.content}`, policy);
188
+ if (scoped) {
189
+ skipped.push({ key: item.key, reason: scoped });
190
+ continue;
191
+ }
192
+ if (candidate.content.length < policy.minPromoteChars) {
193
+ skipped.push({
194
+ key: item.key,
195
+ reason: `too thin to promote (${candidate.content.length} < ${policy.minPromoteChars} chars) — keep local or merge`,
196
+ });
197
+ continue;
198
+ }
199
+ const similar = mostSimilarGlobalEntry(globalState, candidate.kind, candidate.title, candidate.content, policy);
200
+ if (similar) {
201
+ skipped.push({
202
+ key: item.key,
203
+ reason: `near-duplicate of global ${candidate.kind}:${similar.id} "${similar.title}" (overlap ${similar.score.toFixed(2)}) — update that entry instead`,
204
+ });
205
+ continue;
206
+ }
179
207
  promotable.push(item);
180
208
  }
181
209
  return { promotable, skipped };
@@ -223,16 +251,27 @@ export function splitArchiveGuards(items, candidates) {
223
251
  }
224
252
  /**
225
253
  * Apply-time guard for a split promotion (archive + promote sub-object):
226
- * the cleaned title must not duplicate a topic already covered globally. A
227
- * duplicate split is dropped (the entry still archives plain) rather than
228
- * half-promoting a redundancy.
254
+ * the cleaned payload must pass the same promotion policy as a whole
255
+ * promote no global coverage duplicate, no project-scoped content, not
256
+ * too thin, no near-duplicate global entry. A blocked split is dropped (the
257
+ * entry still archives plain) rather than half-promoting a redundancy.
229
258
  */
230
- export function splitPromoteBlocked(item, globalState, kind) {
259
+ export function splitPromoteBlocked(item, globalState, kind, policy = DEFAULT_PROMOTION_POLICY) {
231
260
  if (!item.promote)
232
261
  return "no split payload";
233
262
  if (globalCoverageDetected(globalState, kind, { id: "", title: item.promote.title })) {
234
263
  return "split promotion duplicates a globally covered topic";
235
264
  }
265
+ const scoped = projectScopedReason(`${item.promote.title}\n${item.promote.content}`, policy);
266
+ if (scoped)
267
+ return `split promotion is ${scoped}`;
268
+ if (item.promote.content.length < policy.minPromoteChars) {
269
+ return `split promotion too thin (${item.promote.content.length} < ${policy.minPromoteChars} chars)`;
270
+ }
271
+ const similar = mostSimilarGlobalEntry(globalState, kind, item.promote.title, item.promote.content, policy);
272
+ if (similar) {
273
+ return `split promotion near-duplicates global ${kind}:${similar.id} "${similar.title}" (overlap ${similar.score.toFixed(2)})`;
274
+ }
236
275
  return undefined;
237
276
  }
238
277
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-continual-evolve",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Continual self-evolution plugin for DeepSeek Harness: versioned, auditable, rollback-safe harness state (prompt notes, memories, skills, subagent specs) refined from session trajectories.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -50,6 +50,7 @@
50
50
  "dev": "tsc -p tsconfig.json --watch",
51
51
  "typecheck": "tsc -p tsconfig.json --noEmit",
52
52
  "test": "vitest run",
53
+ "test:coverage": "vitest run --coverage",
53
54
  "test:watch": "vitest",
54
55
  "lint": "oxlint src test",
55
56
  "clean": "rm -rf lib"
@@ -62,14 +63,15 @@
62
63
  },
63
64
  "devDependencies": {
64
65
  "@deepseek-ai/cordis": "^4.0.1",
65
- "@deepseek-ai/dsh-agent": "0.1.0-rc.7",
66
- "@deepseek-ai/dsh-commands": "0.1.0-rc.7",
67
- "@deepseek-ai/dsh-home-paths": "0.1.0-rc.7",
68
- "@deepseek-ai/dsh-llm": "0.1.0-rc.7",
69
- "@deepseek-ai/dsh-system-prompt": "0.1.0-rc.7",
70
- "@deepseek-ai/dsh-tools": "0.1.0-rc.7",
66
+ "@deepseek-ai/dsh-agent": "0.1.1-rc.2",
67
+ "@deepseek-ai/dsh-commands": "0.1.1-rc.2",
68
+ "@deepseek-ai/dsh-home-paths": "0.1.1-rc.2",
69
+ "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
70
+ "@deepseek-ai/dsh-system-prompt": "0.1.1-rc.2",
71
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.2",
71
72
  "@deepseek-ai/schemastery": "^3.18.1",
72
73
  "@types/node": "^22.10.0",
74
+ "@vitest/coverage-v8": "^3.2.0",
73
75
  "oxlint": "^0.16.0",
74
76
  "typescript": "^5.9.0",
75
77
  "vitest": "^3.2.0"