dsh-continual-evolve 0.4.0 → 0.6.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/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,28 @@ 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";
68
+ /**
69
+ * Negative-valence counter (P1 效价反馈): how many times an assessment marked
70
+ * this entry contradicted by newer evidence (user correction, overridden
71
+ * fact). Injection ranking demotes entries with a positive counter and the
72
+ * wrapup/fate assessors see the count so they prefer archiving the entry.
73
+ */
74
+ export declare const VALENCE_NEGATIVE_KEY = "valenceNegative";
75
+ /**
76
+ * Provenance of entries merged INTO this one by the consolidation command
77
+ * (P1 反膨胀): array of `<kind>:<id>` — the near-duplicate sources whose
78
+ * content was appended before they archived. The trail keeps an unarchive of
79
+ * the source explainable and lets audits find the merge provenance.
80
+ */
81
+ export declare const MERGED_FROM_KEY = "mergedFrom";
60
82
  /**
61
83
  * True when the entry is archived (hidden from injection, restorable).
62
84
  * Absent or empty archivedAt means the entry is active.
package/lib/types.js CHANGED
@@ -43,6 +43,28 @@ 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";
54
+ /**
55
+ * Negative-valence counter (P1 效价反馈): how many times an assessment marked
56
+ * this entry contradicted by newer evidence (user correction, overridden
57
+ * fact). Injection ranking demotes entries with a positive counter and the
58
+ * wrapup/fate assessors see the count so they prefer archiving the entry.
59
+ */
60
+ export const VALENCE_NEGATIVE_KEY = "valenceNegative";
61
+ /**
62
+ * Provenance of entries merged INTO this one by the consolidation command
63
+ * (P1 反膨胀): array of `<kind>:<id>` — the near-duplicate sources whose
64
+ * content was appended before they archived. The trail keeps an unarchive of
65
+ * the source explainable and lets audits find the merge provenance.
66
+ */
67
+ export const MERGED_FROM_KEY = "mergedFrom";
46
68
  /**
47
69
  * True when the entry is archived (hidden from injection, restorable).
48
70
  * Absent or empty archivedAt means the entry is active.
package/lib/usage.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { HarnessState, RefinementKind } from "./types.js";
1
+ import type { RefinementKind } from "./types.js";
2
2
  export interface UsageStore {
3
3
  /** Injection count per entry key (`kind:id`). */
4
4
  counts: Record<string, number>;
@@ -33,13 +33,4 @@ export declare function recordInjection(baseDir: string, injectedKeys: readonly
33
33
  * has never been injected (absent from the store).
34
34
  */
35
35
  export declare function getUsageCount(store: UsageStore, kind: RefinementKind, id: string): number;
36
- /**
37
- * Find entries with zero injection usage. Returns `{kind, id, title}` for
38
- * each entry that has never been injected — prime candidates for archival.
39
- */
40
- export declare function zeroUsageEntries(state: HarnessState, store: UsageStore): {
41
- kind: RefinementKind;
42
- id: string;
43
- title: string;
44
- }[];
45
36
  //# sourceMappingURL=usage.d.ts.map
package/lib/usage.js CHANGED
@@ -95,21 +95,4 @@ export function recordInjection(baseDir, injectedKeys, sessionId) {
95
95
  export function getUsageCount(store, kind, id) {
96
96
  return store.counts[usageKey(kind, id)] ?? 0;
97
97
  }
98
- /**
99
- * Find entries with zero injection usage. Returns `{kind, id, title}` for
100
- * each entry that has never been injected — prime candidates for archival.
101
- */
102
- export function zeroUsageEntries(state, store) {
103
- const results = [];
104
- for (const kind of Object.keys(state.entries)) {
105
- for (const entry of Object.values(state.entries[kind])) {
106
- if (entry.scope !== "local")
107
- continue;
108
- if (getUsageCount(store, kind, entry.id) === 0) {
109
- results.push({ kind, id: entry.id, title: entry.title });
110
- }
111
- }
112
- }
113
- return results;
114
- }
115
98
  //# sourceMappingURL=usage.js.map
package/lib/validate.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * the base system prompt, required fields per action, and the executable
5
5
  * contract skill entries must carry.
6
6
  */
7
- import type { HarnessScope, RefinementEdit } from "./types.js";
7
+ import type { HarnessEntry, HarnessScope, RefinementEdit } from "./types.js";
8
8
  export declare const BASE_SYSTEM_PROMPT_ID = "base_system_prompt";
9
9
  /**
10
10
  * Gap C2: mechanical check that an edit's declared blast radius is coherent
@@ -16,6 +16,13 @@ export declare const BASE_SYSTEM_PROMPT_ID = "base_system_prompt";
16
16
  * incoherently.
17
17
  */
18
18
  export declare function validateBlastRadiusScope(scope: HarnessScope, blastRadius: "general" | "project" | "session"): string | undefined;
19
- /** Returns a human-readable failure reason, or undefined when the edit passes. */
20
- export declare function validateEdit(edit: RefinementEdit, computedId: string | undefined, scope?: HarnessScope): string | undefined;
19
+ /**
20
+ * Returns a human-readable failure reason, or undefined when the edit passes.
21
+ *
22
+ * `before` (the entry the edit targets, absent for creates/unknown ids) lets
23
+ * update rules distinguish "carrying the persisted value" from "changing it"
24
+ * — e.g. a rollback inverse re-carries the stored skill_kind, which must
25
+ * pass, while a genuine executable↔guidance switch must not.
26
+ */
27
+ export declare function validateEdit(edit: RefinementEdit, computedId: string | undefined, scope?: HarnessScope, before?: HarnessEntry): string | undefined;
21
28
  //# sourceMappingURL=validate.d.ts.map
package/lib/validate.js CHANGED
@@ -20,8 +20,15 @@ export function validateBlastRadiusScope(scope, blastRadius) {
20
20
  }
21
21
  return undefined;
22
22
  }
23
- /** Returns a human-readable failure reason, or undefined when the edit passes. */
24
- export function validateEdit(edit, computedId, scope) {
23
+ /**
24
+ * Returns a human-readable failure reason, or undefined when the edit passes.
25
+ *
26
+ * `before` (the entry the edit targets, absent for creates/unknown ids) lets
27
+ * update rules distinguish "carrying the persisted value" from "changing it"
28
+ * — e.g. a rollback inverse re-carries the stored skill_kind, which must
29
+ * pass, while a genuine executable↔guidance switch must not.
30
+ */
31
+ export function validateEdit(edit, computedId, scope, before) {
25
32
  if (!ACTIONS.has(edit.action)) {
26
33
  return `unsupported action ${String(edit.action)}`;
27
34
  }
@@ -46,10 +53,25 @@ export function validateEdit(edit, computedId, scope) {
46
53
  if (edit.action === "archive") {
47
54
  return undefined;
48
55
  }
49
- if (edit.action !== "delete" && (!edit.title || !edit.content)) {
50
- return `${edit.action} requires title and content`;
56
+ // Create needs the full payload (nothing to fall back to). Update may
57
+ // carry any subset apply merges with `?? before` — but must carry at
58
+ // least one change (review audit 2026-08-28 B3/B5: the old blanket
59
+ // "update requires title and content" broke every partial-update path:
60
+ // skill archive/demote, consolidate, and the evolve_update tool contract
61
+ // all construct payload-subset updates).
62
+ if (edit.action === "create" && (!edit.title || !edit.content)) {
63
+ return "create requires title and content";
64
+ }
65
+ if (edit.action === "update" &&
66
+ edit.title === undefined &&
67
+ edit.content === undefined &&
68
+ edit.path === undefined &&
69
+ edit.metadata === undefined &&
70
+ edit.reference === undefined &&
71
+ edit.arguments === undefined) {
72
+ return "update carries no changes";
51
73
  }
52
- if (edit.action !== "delete" && edit.kind === "skill") {
74
+ if (edit.action === "create" && edit.kind === "skill") {
53
75
  // Guidance skills are SKILL.md documents: no python reference (a
54
76
  // reference on a guidance skill would be an invented contract) and
55
77
  // no arguments contract. Executable skills keep the full contract.
@@ -75,6 +97,31 @@ export function validateEdit(edit, computedId, scope) {
75
97
  return contentProblems.join("; ");
76
98
  }
77
99
  }
100
+ if (edit.action === "update" && edit.kind === "skill") {
101
+ // skill_kind is immutable on update: a switch without the matching
102
+ // contract pair would leave an invalid entry (guidance carrying a
103
+ // python reference, or an executable with none). Re-carrying the
104
+ // persisted value (rollback inverses do) passes. Recreate the entry
105
+ // to change the kind.
106
+ if (edit.skill_kind !== undefined && before !== undefined && before.skill_kind !== edit.skill_kind) {
107
+ return "update cannot change skill_kind — delete and recreate the entry instead";
108
+ }
109
+ // A carried non-empty reference replaces the contract wholesale, so it
110
+ // must be a complete executable contract on its own. Empty references
111
+ // (guidance skills) and absent payloads pass through — apply keeps
112
+ // the persisted values.
113
+ if (edit.reference !== undefined && Object.keys(edit.reference).length > 0) {
114
+ const contractError = validateSkillContract(edit);
115
+ if (contractError)
116
+ return contractError;
117
+ }
118
+ if (edit.content !== undefined) {
119
+ const contentProblems = validateSkillEntryContent(edit.content);
120
+ if (contentProblems.length > 0) {
121
+ return contentProblems.join("; ");
122
+ }
123
+ }
124
+ }
78
125
  return undefined;
79
126
  }
80
127
  function validateSkillContract(edit) {
@@ -1,5 +1,5 @@
1
1
  import { questionServiceOf, requireGlobalApproval } from "./approval.js";
2
- import { assessLocalEntries, candidateKey, filterPromotable, listLocalCandidates, splitArchiveGuards, splitPromoteBlocked, splitPromoteProposals, wholePromoteProposals } from "./wrapup.js";
2
+ import { assessLocalEntries, candidateKey, filterPromotable, listLocalCandidates, splitArchiveGuards, splitPromoteBlocked, splitPromoteProposals, valenceStampProposal, wholePromoteProposals } from "./wrapup.js";
3
3
  import { DEFAULT_PROMOTION_POLICY } from "./promotion.js";
4
4
  function success(text) {
5
5
  return { kind: "success", text };
@@ -206,6 +206,22 @@ export async function executeWrapupCommand(ctx, engine, invocation, policy = DEF
206
206
  applied.push(`kept ${item.key} — user declined the archive`);
207
207
  }
208
208
  }
209
+ // 7. Valence stamps (P1 效价反馈): keep-verdict entries the assessor marked
210
+ // contradicted get their negative counter bumped — the entry stays live
211
+ // but sinks in injection ranking and the next assessment prefers
212
+ // archiving it. Deterministic local action, no approval needed.
213
+ for (const item of keepItems) {
214
+ if (!item.contradicted)
215
+ continue;
216
+ const candidate = byKey.get(item.key);
217
+ if (!candidate)
218
+ continue;
219
+ const proposal = valenceStampProposal(item, candidate);
220
+ if (!proposal)
221
+ continue;
222
+ const result = engine.apply("local", sessionId, proposal, { scope: "local", baselineState: localState });
223
+ applied.push(`contradicted stamp ${item.key} → valenceNegative=${candidate.negativeCount + 1} (${result.id})`);
224
+ }
209
225
  lines.push(...(applied.length > 0 ? applied : ["(no changes applied — all entries kept)"]));
210
226
  return success(lines.join("\n"));
211
227
  }
package/lib/wrapup.d.ts CHANGED
@@ -40,6 +40,14 @@ export interface WrapupItem {
40
40
  title: string;
41
41
  content: string;
42
42
  };
43
+ /**
44
+ * Valence signal (P1 效价反馈): the assessor found trajectory or global
45
+ * evidence contradicting this entry's content (user correction, overridden
46
+ * fact). Strictly parsed — only an explicit JSON `true` sets it. Keep
47
+ * verdicts with this flag get a negative-valence stamp; the assessor is
48
+ * instructed to prefer "archive" for contradicted entries.
49
+ */
50
+ contradicted?: boolean;
43
51
  }
44
52
  /** A real global entry worth showing the assessor for the same topic. */
45
53
  export interface GlobalHint {
@@ -87,6 +95,13 @@ export interface WrapupCandidate {
87
95
  * The assessor is instructed to prefer "archive" for stale entries.
88
96
  */
89
97
  stale: boolean;
98
+ /**
99
+ * Negative-valence counter (P1 效价反馈): how many prior assessments marked
100
+ * this entry contradicted (metadata {@link VALENCE_NEGATIVE_KEY}). Shown to
101
+ * the assessor so it can prefer "archive"; also demotes the entry in
102
+ * injection ranking (inject.ts rankEntries).
103
+ */
104
+ negativeCount: number;
90
105
  }
91
106
  export declare function candidateKey(kind: RefinementKind, id: string): string;
92
107
  /**
@@ -208,7 +223,16 @@ export declare function splitPromoteProposals(item: WrapupItem, candidate: Wrapu
208
223
  global: RefinementProposal;
209
224
  localStamp: (createdId: string) => RefinementProposal;
210
225
  };
211
- export declare const WRAPUP_ASSESS_SYSTEM_PROMPT = "You are the /evolve session wrap-up assessor.\n\nA session is ending and its local harness entries need a fate. Classify each\nlisted entry exactly once:\n\n- \"promote\" \u2014 the content is a stable, durable, CROSS-SESSION reusable lesson:\n a durable user preference, a project-level fact or convention, a reusable\n procedure or skill. Future sessions would benefit from seeing it.\n- \"archive\" \u2014 the content is session-specific task progress, one-off noise,\n superseded or obsolete, or already covered by the global store (note\n \"covered globally\" in the reason), or stale (old + never injected \u2014 note\n \"stale (injectionCount=0, recency low)\" in the reason).\n- \"keep\" \u2014 still actively useful to this session, or genuinely uncertain.\n\nRules:\n- When an entry is marked \"covered globally\" in the listing, prefer \"archive\"\n or \"keep\" over \"promote\" \u2014 promoting a duplicate gains nothing.\n- When an entry is marked \"stale\" (injectionCount=0 and low recency), prefer\n \"archive\" \u2014 the entry has never been used and is old, so it is unlikely to\n be needed again. Only \"keep\" if the content is clearly valuable despite low\n usage (e.g. a safety policy that rarely triggers but is critical).\n- Do not promote local task state, work-in-progress notes, or content tied to\n one session's ephemeral details.\n- Skills: only \"promote\" a skill entry that is a genuinely reusable procedure\n meeting the DSH skill quality standard; one-off workflows are \"archive\" or\n \"keep\".\n- SPLIT PROMOTION: when an entry mixes a stable, cross-session-reusable part\n WITH session-specific snapshot details, do NOT promote it whole. Instead\n give verdict \"archive\" WITH a \"promote\" sub-object holding a CLEANED\n version of only the durable part (a stable title + the persistent facts,\n stripped of dates/states/one-off figures). Ephemeral snapshot content stays\n out of the sub-object \u2014 it is left behind in the archive. A sub-object is\n only meaningful on \"archive\" verdicts.\n\nReturn JSON only:\n{\n \"rationale\": \"one or two sentences\",\n \"items\": [\n {\"key\": \"memory:foo\", \"verdict\": \"promote|archive|keep\", \"reason\": \"why\"},\n {\"key\": \"memory:bar\", \"verdict\": \"archive\", \"reason\": \"why\",\n \"promote\": {\"title\": \"cleaned stable title\", \"content\": \"cleaned durable part only\"}}\n ]\n}\nOnly keys from the provided list are allowed; any entry you omit defaults to \"keep\".";
226
+ /**
227
+ * Valence stamp (P1 效价反馈): a keep-verdict item the assessor marked
228
+ * contradicted gets its negative-valence counter bumped — the entry stays
229
+ * live but sinks in injection ranking and the next assessment prefers
230
+ * archiving it. Promotes pass untouched (the human-approved global copy is
231
+ * the live truth); archives don't need the stamp (they already leave
232
+ * injection). Undefined when nothing applies.
233
+ */
234
+ export declare function valenceStampProposal(item: WrapupItem, candidate: WrapupCandidate): RefinementProposal | undefined;
235
+ export declare const WRAPUP_ASSESS_SYSTEM_PROMPT = "You are the /evolve session wrap-up assessor.\nA session is ending and its local harness entries need a fate. Classify each\nlisted entry exactly once:\n\n- \"promote\" \u2014 the content is a stable, durable, CROSS-SESSION reusable lesson:\n a durable user preference, a project-level fact or convention, a reusable\n procedure or skill. Future sessions would benefit from seeing it.\n- \"archive\" \u2014 the content is session-specific task progress, one-off noise,\n superseded or obsolete, or already covered by the global store (note\n \"covered globally\" in the reason), or stale (old + never injected \u2014 note\n \"stale (injectionCount=0, recency low)\" in the reason), or CONTRADICTED \u2014\n newer evidence in the trajectory or the global store disproves or overrides\n the entry's content (note what contradicts it in the reason).\n- \"keep\" \u2014 still actively useful to this session, or genuinely uncertain.\n\nRules:\n- CONTRADICTED: when evidence contradicts an entry, set \"contradicted\": true\n on that item (besides the verdict). Entries marked \"(contradicted N\u00D7\n before)\" were contradicted in earlier assessments \u2014 prefer \"archive\" for\n them unless you have concrete evidence the content is valid again.\n- When an entry is marked \"covered globally\" in the listing, prefer \"archive\"\n or \"keep\" over \"promote\" \u2014 promoting a duplicate gains nothing.\n- When an entry is marked \"stale\" (injectionCount=0 and low recency), prefer\n \"archive\" \u2014 the entry has never been used and is old, so it is unlikely to\n be needed again. Only \"keep\" if the content is clearly valuable despite low\n usage (e.g. a safety policy that rarely triggers but is critical).\n- Do not promote local task state, work-in-progress notes, or content tied to\n one session's ephemeral details.\n- Skills: only \"promote\" a skill entry that is a genuinely reusable procedure\n meeting the DSH skill quality standard; one-off workflows are \"archive\" or\n \"keep\".\n- SPLIT PROMOTION: when an entry mixes a stable, cross-session-reusable part\n WITH session-specific snapshot details, do NOT promote it whole. Instead\n give verdict \"archive\" WITH a \"promote\" sub-object holding a CLEANED\n version of only the durable part (a stable title + the persistent facts,\n stripped of dates/states/one-off figures). Ephemeral snapshot content stays\n out of the sub-object \u2014 it is left behind in the archive. A sub-object is\n only meaningful on \"archive\" verdicts.\n\nReturn JSON only:\n{\n \"rationale\": \"one or two sentences\",\n \"items\": [\n {\"key\": \"memory:foo\", \"verdict\": \"promote|archive|keep\", \"reason\": \"why\"},\n {\"key\": \"memory:bar\", \"verdict\": \"archive\", \"reason\": \"why\", \"contradicted\": true,\n \"promote\": {\"title\": \"cleaned stable title\", \"content\": \"cleaned durable part only\"}}\n ]\n}\nOnly keys from the provided list are allowed; any entry you omit defaults to \"keep\".";
212
236
  export interface AssessOptions {
213
237
  /** Output token budget for the assessment call. */
214
238
  maxOutputTokens?: number;
package/lib/wrapup.js CHANGED
@@ -1,10 +1,10 @@
1
- import { ARCHIVED_AT_KEY, PROMOTED_AT_KEY, PROMOTED_TO_KEY, SOURCE_SEQS_KEY, SOURCE_SESSION_KEY, SOURCED_FROM_KEY, isArchived } from "./types.js";
1
+ import { ARCHIVED_AT_KEY, PROMOTED_AT_KEY, PROMOTED_TO_KEY, SOURCE_SEQS_KEY, SOURCE_SESSION_KEY, SOURCED_FROM_KEY, VALENCE_NEGATIVE_KEY, isArchived } from "./types.js";
2
2
  import { extractJsonObject } from "./plan.js";
3
3
  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
+ import { DEFAULT_PROMOTION_POLICY, mostSimilarGlobalEntry, projectScopedReason, secretLeakReason } from "./promotion.js";
8
8
  export function candidateKey(kind, id) {
9
9
  return `${kind}:${id}`;
10
10
  }
@@ -90,6 +90,8 @@ export function listLocalCandidates(state, globalState, baseDir) {
90
90
  continue;
91
91
  const injectionCount = usage ? getUsageCount(usage, kind, entry.id) : 0;
92
92
  const stale = injectionCount === 0 && recencyScore(entry, now) < STALE_RECENCY_THRESHOLD;
93
+ const rawNegative = entry.metadata[VALENCE_NEGATIVE_KEY];
94
+ const negativeCount = typeof rawNegative === "number" && Number.isFinite(rawNegative) && rawNegative > 0 ? rawNegative : 0;
93
95
  candidates.push({
94
96
  kind,
95
97
  id: entry.id,
@@ -102,6 +104,7 @@ export function listLocalCandidates(state, globalState, baseDir) {
102
104
  globalHints: globalHintsFor(globalState, kind, entry),
103
105
  injectionCount,
104
106
  stale,
107
+ negativeCount,
105
108
  });
106
109
  }
107
110
  }
@@ -136,6 +139,9 @@ export function parseWrapupAssessment(text, candidates) {
136
139
  continue;
137
140
  const verdict = item["verdict"] === "promote" || item["verdict"] === "archive" ? item["verdict"] : "keep";
138
141
  const built = { key, verdict, reason: typeof item["reason"] === "string" ? item["reason"] : "" };
142
+ if (item["contradicted"] === true) {
143
+ built.contradicted = true;
144
+ }
139
145
  if (verdict === "archive" && typeof item["promote"] === "object" && item["promote"] !== null) {
140
146
  const sub = item["promote"];
141
147
  const subTitle = typeof sub["title"] === "string" ? sub["title"].trim() : "";
@@ -189,6 +195,15 @@ export function filterPromotable(items, globalState, candidates, policy = DEFAUL
189
195
  skipped.push({ key: item.key, reason: scoped });
190
196
  continue;
191
197
  }
198
+ // Metadata screens too: wholePromoteProposals copies candidate.metadata
199
+ // verbatim into the global entry, so a credential planted in local
200
+ // metadata must not ride the promotion path into the shared store
201
+ // (review audit 2026-08-28 B2).
202
+ const secret = secretLeakReason([candidate.title, candidate.content, JSON.stringify(candidate.metadata ?? {})].join("\n"));
203
+ if (secret) {
204
+ skipped.push({ key: item.key, reason: secret });
205
+ continue;
206
+ }
192
207
  if (candidate.content.length < policy.minPromoteChars) {
193
208
  skipped.push({
194
209
  key: item.key,
@@ -265,6 +280,9 @@ export function splitPromoteBlocked(item, globalState, kind, policy = DEFAULT_PR
265
280
  const scoped = projectScopedReason(`${item.promote.title}\n${item.promote.content}`, policy);
266
281
  if (scoped)
267
282
  return `split promotion is ${scoped}`;
283
+ const secret = secretLeakReason([item.promote.title, item.promote.content].join("\n"));
284
+ if (secret)
285
+ return `split promotion blocked: ${secret}`;
268
286
  if (item.promote.content.length < policy.minPromoteChars) {
269
287
  return `split promotion too thin (${item.promote.content.length} < ${policy.minPromoteChars} chars)`;
270
288
  }
@@ -381,8 +399,34 @@ export function splitPromoteProposals(item, candidate, sessionId) {
381
399
  }),
382
400
  };
383
401
  }
402
+ /**
403
+ * Valence stamp (P1 效价反馈): a keep-verdict item the assessor marked
404
+ * contradicted gets its negative-valence counter bumped — the entry stays
405
+ * live but sinks in injection ranking and the next assessment prefers
406
+ * archiving it. Promotes pass untouched (the human-approved global copy is
407
+ * the live truth); archives don't need the stamp (they already leave
408
+ * injection). Undefined when nothing applies.
409
+ */
410
+ export function valenceStampProposal(item, candidate) {
411
+ if (item.verdict !== "keep" || !item.contradicted)
412
+ return undefined;
413
+ return {
414
+ summary: `wrapup: stamp contradicted valence on ${item.key} (${candidate.negativeCount + 1})`,
415
+ rationale: item.reason,
416
+ expectedOutcome: "The entry's negative-valence counter rises; injection downranks it and the next assessment prefers archiving it.",
417
+ edits: [
418
+ {
419
+ action: "update",
420
+ kind: candidate.kind,
421
+ id: candidate.id,
422
+ title: candidate.title,
423
+ content: candidate.content,
424
+ metadata: { ...candidate.metadata, [VALENCE_NEGATIVE_KEY]: candidate.negativeCount + 1 },
425
+ },
426
+ ],
427
+ };
428
+ }
384
429
  export const WRAPUP_ASSESS_SYSTEM_PROMPT = `You are the /evolve session wrap-up assessor.
385
-
386
430
  A session is ending and its local harness entries need a fate. Classify each
387
431
  listed entry exactly once:
388
432
 
@@ -392,10 +436,16 @@ listed entry exactly once:
392
436
  - "archive" — the content is session-specific task progress, one-off noise,
393
437
  superseded or obsolete, or already covered by the global store (note
394
438
  "covered globally" in the reason), or stale (old + never injected — note
395
- "stale (injectionCount=0, recency low)" in the reason).
439
+ "stale (injectionCount=0, recency low)" in the reason), or CONTRADICTED —
440
+ newer evidence in the trajectory or the global store disproves or overrides
441
+ the entry's content (note what contradicts it in the reason).
396
442
  - "keep" — still actively useful to this session, or genuinely uncertain.
397
443
 
398
444
  Rules:
445
+ - CONTRADICTED: when evidence contradicts an entry, set "contradicted": true
446
+ on that item (besides the verdict). Entries marked "(contradicted N×
447
+ before)" were contradicted in earlier assessments — prefer "archive" for
448
+ them unless you have concrete evidence the content is valid again.
399
449
  - When an entry is marked "covered globally" in the listing, prefer "archive"
400
450
  or "keep" over "promote" — promoting a duplicate gains nothing.
401
451
  - When an entry is marked "stale" (injectionCount=0 and low recency), prefer
@@ -420,7 +470,7 @@ Return JSON only:
420
470
  "rationale": "one or two sentences",
421
471
  "items": [
422
472
  {"key": "memory:foo", "verdict": "promote|archive|keep", "reason": "why"},
423
- {"key": "memory:bar", "verdict": "archive", "reason": "why",
473
+ {"key": "memory:bar", "verdict": "archive", "reason": "why", "contradicted": true,
424
474
  "promote": {"title": "cleaned stable title", "content": "cleaned durable part only"}}
425
475
  ]
426
476
  }
@@ -442,10 +492,11 @@ export async function assessLocalEntries(ctx, agent, candidates, options = {}) {
442
492
  const key = candidateKey(candidate.kind, candidate.id);
443
493
  const covered = candidate.coveredGlobally ? " (covered globally)" : "";
444
494
  const stale = candidate.stale ? ` (stale: injectionCount=${candidate.injectionCount}, recency low)` : "";
495
+ const negated = candidate.negativeCount > 0 ? ` (contradicted ${candidate.negativeCount}× before)` : "";
445
496
  const hints = candidate.globalHints.length > 0
446
497
  ? ` | global≈${candidate.globalHints.map((hint) => hint.id + ":" + hint.title).join(", ")}`
447
498
  : "";
448
- return `- ${key} [${candidate.path}, v${candidate.version}] "${candidate.title}"${covered}${stale}${hints}: ${compactText(candidate.content, 220)}`;
499
+ return `- ${key} [${candidate.path}, v${candidate.version}] "${candidate.title}"${covered}${stale}${negated}${hints}: ${compactText(candidate.content, 220)}`;
449
500
  })
450
501
  .join("\n");
451
502
  const userPrompt = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-continual-evolve",
3
- "version": "0.4.0",
3
+ "version": "0.6.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": {