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/README.md +85 -409
- package/README.zh.md +86 -272
- package/lib/apply.js +7 -1
- package/lib/auto.d.ts +17 -0
- package/lib/auto.js +5 -2
- package/lib/benchmark-command.js +2 -0
- package/lib/command.d.ts +3 -0
- package/lib/command.js +66 -2
- package/lib/consolidate.d.ts +65 -0
- package/lib/consolidate.js +113 -0
- package/lib/fate.d.ts +2 -1
- package/lib/fate.js +5 -4
- package/lib/index.d.ts +22 -0
- package/lib/index.js +19 -1
- package/lib/inject.d.ts +26 -17
- package/lib/inject.js +82 -49
- package/lib/promotion.d.ts +83 -0
- package/lib/promotion.js +127 -0
- package/lib/search.d.ts +83 -0
- package/lib/search.js +136 -0
- package/lib/service.js +41 -1
- package/lib/skill-render.d.ts +9 -1
- package/lib/skill-render.js +40 -2
- package/lib/state.js +6 -1
- package/lib/tool.js +8 -1
- package/lib/types.d.ts +8 -0
- package/lib/types.js +8 -0
- package/lib/usage.d.ts +17 -4
- package/lib/usage.js +41 -10
- package/lib/wrapup-command.d.ts +2 -1
- package/lib/wrapup-command.js +4 -3
- package/lib/wrapup.d.ts +15 -6
- package/lib/wrapup.js +45 -6
- package/package.json +9 -7
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
|
-
|
|
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);
|
package/lib/skill-render.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
package/lib/skill-render.js
CHANGED
|
@@ -2,12 +2,50 @@
|
|
|
2
2
|
export function skillNameOf(id) {
|
|
3
3
|
return id.toLowerCase().replace(/_/g, "-");
|
|
4
4
|
}
|
|
5
|
-
/**
|
|
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: ${
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
package/lib/wrapup-command.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/lib/wrapup-command.js
CHANGED
|
@@ -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
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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
|
+
"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.
|
|
66
|
-
"@deepseek-ai/dsh-commands": "0.1.
|
|
67
|
-
"@deepseek-ai/dsh-home-paths": "0.1.
|
|
68
|
-
"@deepseek-ai/dsh-llm": "0.1.
|
|
69
|
-
"@deepseek-ai/dsh-system-prompt": "0.1.
|
|
70
|
-
"@deepseek-ai/dsh-tools": "0.1.
|
|
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"
|