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/README.md +12 -6
- package/README.zh.md +12 -6
- package/lib/apply.d.ts +0 -2
- package/lib/apply.js +18 -8
- package/lib/auto.d.ts +8 -6
- package/lib/auto.js +42 -26
- package/lib/autocase.d.ts +38 -0
- package/lib/autocase.js +76 -0
- package/lib/benchmark-command.js +25 -1
- package/lib/benchmark.d.ts +0 -5
- package/lib/benchmark.js +1 -24
- package/lib/command.d.ts +2 -0
- package/lib/command.js +41 -5
- package/lib/consolidate.d.ts +90 -0
- package/lib/consolidate.js +180 -0
- package/lib/failures.d.ts +4 -1
- package/lib/failures.js +2 -1
- package/lib/index.d.ts +12 -0
- package/lib/index.js +11 -1
- package/lib/inject.d.ts +13 -17
- package/lib/inject.js +49 -37
- package/lib/mount.js +9 -0
- package/lib/promotion.d.ts +38 -6
- package/lib/promotion.js +93 -24
- package/lib/rollback.js +7 -0
- package/lib/search.d.ts +83 -0
- package/lib/search.js +136 -0
- package/lib/service.js +74 -1
- package/lib/state.d.ts +0 -12
- package/lib/state.js +4 -19
- package/lib/store.js +3 -1
- package/lib/tool.js +8 -1
- package/lib/types.d.ts +22 -0
- package/lib/types.js +22 -0
- package/lib/usage.d.ts +1 -10
- package/lib/usage.js +0 -17
- package/lib/validate.d.ts +10 -3
- package/lib/validate.js +52 -5
- package/lib/wrapup-command.js +17 -1
- package/lib/wrapup.d.ts +25 -1
- package/lib/wrapup.js +57 -6
- package/package.json +1 -1
package/lib/command.js
CHANGED
|
@@ -8,11 +8,13 @@ import { saveHarnessState } from "./state.js";
|
|
|
8
8
|
import { appendResult, storePaths } from "./store.js";
|
|
9
9
|
import { entrySourceOf } from "./source.js";
|
|
10
10
|
import { filterLogBySession, formatLogLine, pluginLogFilePath } from "./logfile.js";
|
|
11
|
-
import {
|
|
11
|
+
import { collectFailureSummary, formatFailureSummary } from "./failures.js";
|
|
12
12
|
import { executeGoalCommand } from "./goal-command.js";
|
|
13
13
|
import { executeMountCommand, executeUnmountCommand } from "./mount-command.js";
|
|
14
14
|
import { executeBenchmarkCommand } from "./benchmark-command.js";
|
|
15
15
|
import { executeWrapupCommand } from "./wrapup-command.js";
|
|
16
|
+
import { loadUsage } from "./usage.js";
|
|
17
|
+
import { planConsolidation } from "./consolidate.js";
|
|
16
18
|
const USAGE = `Usage:
|
|
17
19
|
/evolve show this help and the current local store
|
|
18
20
|
/evolve list [global] list entries (add "global" for the cross-session store)
|
|
@@ -24,6 +26,10 @@ const USAGE = `Usage:
|
|
|
24
26
|
/evolve archive <id> [global] hide an entry from injection (data kept, restorable)
|
|
25
27
|
/evolve unarchive <id> [global] restore an archived entry
|
|
26
28
|
/evolve demote <id> hide a (global) entry from injection, keep data
|
|
29
|
+
/evolve consolidate [apply] [merge]
|
|
30
|
+
report (or apply) a batch archive of conflict-hinted
|
|
31
|
+
and stale zero-use global entries; "merge" folds
|
|
32
|
+
near-duplicate content into the surviving original
|
|
27
33
|
/evolve log [tail N] show the recent plugin log (default 50 lines)
|
|
28
34
|
/evolve failures aggregated failure counts (gate + benchmark, by class)
|
|
29
35
|
/evolve export [global] <path> backup a store to a JSON file
|
|
@@ -172,13 +178,43 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
|
|
|
172
178
|
}, { scope });
|
|
173
179
|
return success(renderResult(result));
|
|
174
180
|
}
|
|
181
|
+
case "consolidate": {
|
|
182
|
+
// R3: deterministic global-store hygiene. Report by default;
|
|
183
|
+
// `apply` re-scans fresh state and lands the whole batch as ONE
|
|
184
|
+
// refinement (single snapshot + audit record, fully rollback-able).
|
|
185
|
+
// `merge` (P1 反膨胀) additionally merges conflict-pair content
|
|
186
|
+
// into the surviving original instead of only archiving.
|
|
187
|
+
const apply = rest[0] === "apply";
|
|
188
|
+
const merge = rest.includes("merge");
|
|
189
|
+
const state = engine.load("global", undefined);
|
|
190
|
+
const { candidates, edits } = planConsolidation(state, loadUsage(engine.baseDir), Date.now(), { mergeDuplicates: merge });
|
|
191
|
+
if (candidates.length === 0) {
|
|
192
|
+
return success("global store is already consolidated — no conflict-hinted or stale zero-use entries.");
|
|
193
|
+
}
|
|
194
|
+
const mergeCount = merge ? candidates.filter((candidate) => candidate.mergeInto).length : 0;
|
|
195
|
+
const report = candidates
|
|
196
|
+
.map((candidate, index) => {
|
|
197
|
+
const mergeNote = candidate.mergeInto && merge ? ` → 内容并入 ${candidate.mergeInto.id}` : "";
|
|
198
|
+
return `${index + 1}. [${candidate.kind}:${candidate.id}] ${candidate.title}${mergeNote}\n ${candidate.reason}`;
|
|
199
|
+
})
|
|
200
|
+
.join("\n");
|
|
201
|
+
if (!apply) {
|
|
202
|
+
return success(`consolidation plan — ${candidates.length} archive candidate(s):\n${report}\n(run "/evolve consolidate apply" to archive all of them in one refinement; add "merge" to fold near-duplicate content into the survivors)`);
|
|
203
|
+
}
|
|
204
|
+
const result = engine.apply("global", undefined, {
|
|
205
|
+
summary: `Consolidate global store: archive ${candidates.length} entries${mergeCount > 0 ? `, merge ${mergeCount} into survivors` : ""}`,
|
|
206
|
+
rationale: "Human-invoked batch consolidation via /evolve consolidate apply.",
|
|
207
|
+
expectedOutcome: "Candidate entries are hidden from injection (data kept; restorable via /evolve unarchive). Merged survivors carry the near-duplicate content with a mergedFrom provenance stamp.",
|
|
208
|
+
edits,
|
|
209
|
+
}, { scope: "global" });
|
|
210
|
+
return success(`${report}\n\napplied:\n${renderResult(result)}`);
|
|
211
|
+
}
|
|
175
212
|
case "failures": {
|
|
176
213
|
// /evolve failures — failure-signature aggregation (D1 observation):
|
|
177
214
|
// failed review-gate records + failed benchmark cells, counted by class.
|
|
178
|
-
const
|
|
179
|
-
const summary = summarizeFailures(failed);
|
|
215
|
+
const { summary, records } = collectFailureSummary(engine.baseDir);
|
|
180
216
|
const parts = formatFailureSummary(summary).split("\n");
|
|
181
|
-
const recent =
|
|
217
|
+
const recent = records
|
|
182
218
|
.sort((a, b) => (b.timestamp ?? "").localeCompare(a.timestamp ?? ""))
|
|
183
219
|
.slice(0, 10)
|
|
184
220
|
.map((f) => ` [${f.timestamp ?? "(benchmark)"}] ${f.kind} · ${f.source}: ${f.message.slice(0, 140)}`);
|
|
@@ -234,7 +270,7 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
|
|
|
234
270
|
refinements: state.refinements,
|
|
235
271
|
history,
|
|
236
272
|
};
|
|
237
|
-
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
273
|
+
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
238
274
|
return success(`exported ${scope} store (${Object.values(state.entries).reduce((n, e) => n + Object.keys(e).length, 0)} entries, ${history.length} refinements) to ${path}`);
|
|
239
275
|
}
|
|
240
276
|
case "import": {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global-store consolidation (R3): turn the two hygiene signals — R2's
|
|
3
|
+
* `conflictHint` stamps and usage v2's zero-injection staleness — into one
|
|
4
|
+
* deterministic, human-approved batch of archive edits (`/evolve consolidate`).
|
|
5
|
+
*
|
|
6
|
+
* Design (ADR implemented/feature/2026-08-24-consolidation-command.md):
|
|
7
|
+
* - pure functions only; the command layer owns loading, reporting, and the
|
|
8
|
+
* apply phase, and ALWAYS re-scans fresh state before applying (the report
|
|
9
|
+
* and the apply are two separate invocations — prefer under-archiving over
|
|
10
|
+
* mis-archiving when state moved in between);
|
|
11
|
+
* - archiving keeps data (ARCHIVED_AT_KEY stamp, restorable via
|
|
12
|
+
* `/evolve unarchive`) and preserves every existing metadata key including
|
|
13
|
+
* the conflictHint itself, so unarchived entries keep their trail;
|
|
14
|
+
* - no LLM call anywhere: code proposes, the human disposes.
|
|
15
|
+
*/
|
|
16
|
+
import type { HarnessState, RefinementEdit, RefinementKind } from "./types.js";
|
|
17
|
+
import { type UsageStore } from "./usage.js";
|
|
18
|
+
/** Zero-use entries at least this old are stale candidates (30d, matching the injection recency half-life scale). */
|
|
19
|
+
export declare const STALE_MIN_AGE_MS: number;
|
|
20
|
+
/** Parsed `<kind>:<id>:<score>` value of a {@link CONFLICT_HINT_KEY} stamp. */
|
|
21
|
+
export interface ConflictHint {
|
|
22
|
+
kind: RefinementKind;
|
|
23
|
+
id: string;
|
|
24
|
+
score: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Parse one conflictHint metadata value. Returns undefined for anything that
|
|
28
|
+
* is not exactly `<kind>:<id>:<score>` with a known kind and a score in
|
|
29
|
+
* [0, 1] — foreign or legacy junk must never crash the scan.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseConflictHint(value: unknown): ConflictHint | undefined;
|
|
32
|
+
/** One planned archive with its human-readable justification. */
|
|
33
|
+
export interface ConsolidationCandidate {
|
|
34
|
+
kind: RefinementKind;
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
reason: string;
|
|
38
|
+
/**
|
|
39
|
+
* Merge tier (P1 反膨胀): set on conflict-pair candidates — the entry's
|
|
40
|
+
* content merges into this survivor (pointed-to original) before the
|
|
41
|
+
* entry itself archives, so nothing readable is lost by the archive. The
|
|
42
|
+
* plan only EMITS the merge when `mergeDuplicates` is on.
|
|
43
|
+
*/
|
|
44
|
+
mergeInto?: {
|
|
45
|
+
kind: RefinementKind;
|
|
46
|
+
id: string;
|
|
47
|
+
title: string;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Entries stamped by the write-time conflict guard whose target still exists
|
|
52
|
+
* and is still active. The hinted (newer) entry is the archive candidate —
|
|
53
|
+
* the pointed-to original stays as the live copy.
|
|
54
|
+
*/
|
|
55
|
+
export declare function findConflictPairs(state: HarnessState): ConsolidationCandidate[];
|
|
56
|
+
/**
|
|
57
|
+
* Active global entries never injected in any session and untouched for at
|
|
58
|
+
* least `minAgeMs`: prime staleness candidates. Operates on whatever state
|
|
59
|
+
* it is handed (callers pass the global store).
|
|
60
|
+
*/
|
|
61
|
+
export declare function findStaleEntries(state: HarnessState, store: UsageStore, now: number, minAgeMs?: number): ConsolidationCandidate[];
|
|
62
|
+
/**
|
|
63
|
+
* Append a source entry's content to a survivor with an attributed divider,
|
|
64
|
+
* so the survivor's reader can see which part came from which entry.
|
|
65
|
+
*/
|
|
66
|
+
export declare function mergeContent(target: string, source: string, sourceRef: string, dateIso: string): string;
|
|
67
|
+
/**
|
|
68
|
+
* Merge both scans (conflict reason wins on overlap), deduped by kind:id,
|
|
69
|
+
* and build the batch archive edits. Each edit preserves the entry's full
|
|
70
|
+
* content and existing metadata — only ARCHIVED_AT_KEY is added — so
|
|
71
|
+
* `/evolve unarchive` restores everything intact.
|
|
72
|
+
*
|
|
73
|
+
* With `opts.mergeDuplicates`, conflict-pair candidates additionally merge
|
|
74
|
+
* their content INTO the pointed-to survivor (an attributed `mergeContent`
|
|
75
|
+
* section + a `mergedFrom` provenance stamp) before archiving — the #11
|
|
76
|
+
* candidate-c tier the 2026-08-28 ecosystem review promoted. Per-target
|
|
77
|
+
* accumulation composes multiple hints into one survivor (update edits
|
|
78
|
+
* replace content/metadata wholesale, so parallel merge edits would clobber
|
|
79
|
+
* each other).
|
|
80
|
+
*
|
|
81
|
+
* @param now Epoch ms used for the archivedAt stamp (injected for tests).
|
|
82
|
+
*/
|
|
83
|
+
export declare function planConsolidation(state: HarnessState, store: UsageStore, now?: number, opts?: {
|
|
84
|
+
minAgeMs?: number;
|
|
85
|
+
mergeDuplicates?: boolean;
|
|
86
|
+
}): {
|
|
87
|
+
candidates: ConsolidationCandidate[];
|
|
88
|
+
edits: RefinementEdit[];
|
|
89
|
+
};
|
|
90
|
+
//# sourceMappingURL=consolidate.d.ts.map
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { ARCHIVED_AT_KEY, CONFLICT_HINT_KEY, MERGED_FROM_KEY, isArchived } from "./types.js";
|
|
2
|
+
import { getUsageCount } from "./usage.js";
|
|
3
|
+
/** Zero-use entries at least this old are stale candidates (30d, matching the injection recency half-life scale). */
|
|
4
|
+
export const STALE_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
5
|
+
const KINDS = ["prompt", "memory", "skill", "subagent"];
|
|
6
|
+
/**
|
|
7
|
+
* Parse one conflictHint metadata value. Returns undefined for anything that
|
|
8
|
+
* is not exactly `<kind>:<id>:<score>` with a known kind and a score in
|
|
9
|
+
* [0, 1] — foreign or legacy junk must never crash the scan.
|
|
10
|
+
*/
|
|
11
|
+
export function parseConflictHint(value) {
|
|
12
|
+
if (typeof value !== "string") {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const parts = value.split(":");
|
|
16
|
+
if (parts.length !== 3) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const [kind, id, rawScore] = parts;
|
|
20
|
+
if (!kind || !id || !rawScore || !KINDS.includes(kind)) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const score = Number(rawScore);
|
|
24
|
+
if (!Number.isFinite(score) || score < 0 || score > 1) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return { kind: kind, id, score };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Entries stamped by the write-time conflict guard whose target still exists
|
|
31
|
+
* and is still active. The hinted (newer) entry is the archive candidate —
|
|
32
|
+
* the pointed-to original stays as the live copy.
|
|
33
|
+
*/
|
|
34
|
+
export function findConflictPairs(state) {
|
|
35
|
+
const candidates = [];
|
|
36
|
+
for (const kind of KINDS) {
|
|
37
|
+
for (const entry of Object.values(state.entries[kind])) {
|
|
38
|
+
if (isArchived(entry))
|
|
39
|
+
continue;
|
|
40
|
+
const hint = parseConflictHint(entry.metadata[CONFLICT_HINT_KEY]);
|
|
41
|
+
if (!hint || hint.kind !== kind)
|
|
42
|
+
continue;
|
|
43
|
+
const target = state.entries[hint.kind]?.[hint.id];
|
|
44
|
+
if (!target || isArchived(target))
|
|
45
|
+
continue;
|
|
46
|
+
candidates.push({
|
|
47
|
+
kind,
|
|
48
|
+
id: entry.id,
|
|
49
|
+
title: entry.title,
|
|
50
|
+
reason: `near-duplicate of ${hint.id} 「${target.title}」 (${Math.round(hint.score * 100)}%) — keep the original`,
|
|
51
|
+
mergeInto: { kind: hint.kind, id: hint.id, title: target.title },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return candidates;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Active global entries never injected in any session and untouched for at
|
|
59
|
+
* least `minAgeMs`: prime staleness candidates. Operates on whatever state
|
|
60
|
+
* it is handed (callers pass the global store).
|
|
61
|
+
*/
|
|
62
|
+
export function findStaleEntries(state, store, now, minAgeMs = STALE_MIN_AGE_MS) {
|
|
63
|
+
const candidates = [];
|
|
64
|
+
for (const kind of KINDS) {
|
|
65
|
+
for (const entry of Object.values(state.entries[kind])) {
|
|
66
|
+
if (isArchived(entry))
|
|
67
|
+
continue;
|
|
68
|
+
if (getUsageCount(store, kind, entry.id) !== 0)
|
|
69
|
+
continue;
|
|
70
|
+
const updatedAt = Date.parse(entry.updated_at);
|
|
71
|
+
if (Number.isNaN(updatedAt) || now - updatedAt < minAgeMs)
|
|
72
|
+
continue;
|
|
73
|
+
candidates.push({
|
|
74
|
+
kind,
|
|
75
|
+
id: entry.id,
|
|
76
|
+
title: entry.title,
|
|
77
|
+
reason: `0 injections since ${entry.updated_at.slice(0, 10)} (stale)`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return candidates;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Append a source entry's content to a survivor with an attributed divider,
|
|
85
|
+
* so the survivor's reader can see which part came from which entry.
|
|
86
|
+
*/
|
|
87
|
+
export function mergeContent(target, source, sourceRef, dateIso) {
|
|
88
|
+
const body = source.trim();
|
|
89
|
+
if (body.length === 0) {
|
|
90
|
+
return target;
|
|
91
|
+
}
|
|
92
|
+
return `${target.trimEnd()}\n\n---\n[Merged from ${sourceRef} on ${dateIso.slice(0, 10)} — near-duplicate consolidated]\n${body}`;
|
|
93
|
+
}
|
|
94
|
+
/** Existing `<kind>:<id>` strings of a target's {@link MERGED_FROM_KEY} trail. */
|
|
95
|
+
function existingMergedFrom(metadata) {
|
|
96
|
+
const value = metadata[MERGED_FROM_KEY];
|
|
97
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Merge both scans (conflict reason wins on overlap), deduped by kind:id,
|
|
101
|
+
* and build the batch archive edits. Each edit preserves the entry's full
|
|
102
|
+
* content and existing metadata — only ARCHIVED_AT_KEY is added — so
|
|
103
|
+
* `/evolve unarchive` restores everything intact.
|
|
104
|
+
*
|
|
105
|
+
* With `opts.mergeDuplicates`, conflict-pair candidates additionally merge
|
|
106
|
+
* their content INTO the pointed-to survivor (an attributed `mergeContent`
|
|
107
|
+
* section + a `mergedFrom` provenance stamp) before archiving — the #11
|
|
108
|
+
* candidate-c tier the 2026-08-28 ecosystem review promoted. Per-target
|
|
109
|
+
* accumulation composes multiple hints into one survivor (update edits
|
|
110
|
+
* replace content/metadata wholesale, so parallel merge edits would clobber
|
|
111
|
+
* each other).
|
|
112
|
+
*
|
|
113
|
+
* @param now Epoch ms used for the archivedAt stamp (injected for tests).
|
|
114
|
+
*/
|
|
115
|
+
export function planConsolidation(state, store, now = Date.now(), opts) {
|
|
116
|
+
const byKey = new Map();
|
|
117
|
+
for (const candidate of [...findConflictPairs(state), ...findStaleEntries(state, store, now, opts?.minAgeMs)]) {
|
|
118
|
+
const key = `${candidate.kind}:${candidate.id}`;
|
|
119
|
+
if (!byKey.has(key)) {
|
|
120
|
+
byKey.set(key, candidate);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const candidates = [...byKey.values()];
|
|
124
|
+
const dateIso = new Date(now).toISOString();
|
|
125
|
+
// A merge survivor must not itself be an archive candidate in the same
|
|
126
|
+
// batch: its archive edit is built from the ORIGINAL state snapshot, so it
|
|
127
|
+
// would overwrite the merge edit's content/mergedFrom wholesale and then
|
|
128
|
+
// archive the survivor (review audit 2026-08-28 S2). Survivors stay live;
|
|
129
|
+
// their merge lands, the source archives.
|
|
130
|
+
const mergeTargets = new Set(candidates.filter((candidate) => candidate.mergeInto).map((candidate) => `${candidate.mergeInto.kind}:${candidate.mergeInto.id}`));
|
|
131
|
+
const archiveCandidates = candidates.filter((candidate) => !mergeTargets.has(`${candidate.kind}:${candidate.id}`));
|
|
132
|
+
const edits = archiveCandidates.map((candidate) => {
|
|
133
|
+
const entry = state.entries[candidate.kind][candidate.id];
|
|
134
|
+
const metadata = { ...entry?.metadata, [ARCHIVED_AT_KEY]: dateIso };
|
|
135
|
+
return {
|
|
136
|
+
action: "update",
|
|
137
|
+
kind: candidate.kind,
|
|
138
|
+
id: candidate.id,
|
|
139
|
+
title: candidate.title,
|
|
140
|
+
content: entry?.content ?? "",
|
|
141
|
+
metadata,
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
if (opts?.mergeDuplicates) {
|
|
145
|
+
const merges = new Map();
|
|
146
|
+
for (const candidate of candidates) {
|
|
147
|
+
if (!candidate.mergeInto)
|
|
148
|
+
continue;
|
|
149
|
+
const source = state.entries[candidate.kind][candidate.id];
|
|
150
|
+
const target = state.entries[candidate.mergeInto.kind]?.[candidate.mergeInto.id];
|
|
151
|
+
if (!source || !target)
|
|
152
|
+
continue;
|
|
153
|
+
const key = `${candidate.mergeInto.kind}:${candidate.mergeInto.id}`;
|
|
154
|
+
const acc = merges.get(key) ??
|
|
155
|
+
{
|
|
156
|
+
kind: candidate.mergeInto.kind,
|
|
157
|
+
id: candidate.mergeInto.id,
|
|
158
|
+
title: target.title,
|
|
159
|
+
content: target.content,
|
|
160
|
+
mergedFrom: existingMergedFrom(target.metadata),
|
|
161
|
+
metadata: target.metadata,
|
|
162
|
+
};
|
|
163
|
+
acc.content = mergeContent(acc.content, source.content ?? "", `${candidate.kind}:${candidate.id}`, dateIso);
|
|
164
|
+
acc.mergedFrom.push(`${candidate.kind}:${candidate.id}`);
|
|
165
|
+
merges.set(key, acc);
|
|
166
|
+
}
|
|
167
|
+
for (const acc of merges.values()) {
|
|
168
|
+
edits.unshift({
|
|
169
|
+
action: "update",
|
|
170
|
+
kind: acc.kind,
|
|
171
|
+
id: acc.id,
|
|
172
|
+
title: acc.title,
|
|
173
|
+
content: acc.content,
|
|
174
|
+
metadata: { ...acc.metadata, [MERGED_FROM_KEY]: acc.mergedFrom },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { candidates, edits };
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=consolidate.js.map
|
package/lib/failures.d.ts
CHANGED
|
@@ -33,7 +33,10 @@ export declare function readReviewFailures(baseDir: string): FailureRecord[];
|
|
|
33
33
|
*/
|
|
34
34
|
export declare function readBenchmarkFailures(baseDir: string): FailureRecord[];
|
|
35
35
|
/** Combine both sources into one summary. */
|
|
36
|
-
export declare function collectFailureSummary(baseDir: string):
|
|
36
|
+
export declare function collectFailureSummary(baseDir: string): {
|
|
37
|
+
summary: FailureSummary;
|
|
38
|
+
records: FailureRecord[];
|
|
39
|
+
};
|
|
37
40
|
/** Human-readable report for the command line. */
|
|
38
41
|
export declare function formatFailureSummary(summary: FailureSummary): string;
|
|
39
42
|
//# sourceMappingURL=failures.d.ts.map
|
package/lib/failures.js
CHANGED
|
@@ -143,7 +143,8 @@ export function readBenchmarkFailures(baseDir) {
|
|
|
143
143
|
}
|
|
144
144
|
/** Combine both sources into one summary. */
|
|
145
145
|
export function collectFailureSummary(baseDir) {
|
|
146
|
-
|
|
146
|
+
const records = [...readReviewFailures(baseDir), ...readBenchmarkFailures(baseDir)];
|
|
147
|
+
return { summary: summarizeFailures(records), records };
|
|
147
148
|
}
|
|
148
149
|
/** Human-readable report for the command line. */
|
|
149
150
|
export function formatFailureSummary(summary) {
|
package/lib/index.d.ts
CHANGED
|
@@ -34,6 +34,12 @@ export declare const Config: z<Schemastery.ObjectS<{
|
|
|
34
34
|
logMaxBytes: z<number, number>;
|
|
35
35
|
/** After a benchmark decision rejects a candidate, roll the refinement back automatically. */
|
|
36
36
|
autoRollbackOnReject: z<boolean, boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* P1 auto-case capture: failed evolution attempts (benchmark-rejected
|
|
39
|
+
* candidates, gate proposals without consent) land as draft cases in the
|
|
40
|
+
* auto-regression container benchmark, seeding the regression loop.
|
|
41
|
+
*/
|
|
42
|
+
autoCase: z<boolean, boolean>;
|
|
37
43
|
/**
|
|
38
44
|
* Gap C1: optional model override for the review gate (cheaper model).
|
|
39
45
|
* Format: "provider/model" or just "model" (same provider as the agent).
|
|
@@ -97,6 +103,12 @@ export declare const Config: z<Schemastery.ObjectS<{
|
|
|
97
103
|
logMaxBytes: z<number, number>;
|
|
98
104
|
/** After a benchmark decision rejects a candidate, roll the refinement back automatically. */
|
|
99
105
|
autoRollbackOnReject: z<boolean, boolean>;
|
|
106
|
+
/**
|
|
107
|
+
* P1 auto-case capture: failed evolution attempts (benchmark-rejected
|
|
108
|
+
* candidates, gate proposals without consent) land as draft cases in the
|
|
109
|
+
* auto-regression container benchmark, seeding the regression loop.
|
|
110
|
+
*/
|
|
111
|
+
autoCase: z<boolean, boolean>;
|
|
100
112
|
/**
|
|
101
113
|
* Gap C1: optional model override for the review gate (cheaper model).
|
|
102
114
|
* Format: "provider/model" or just "model" (same provider as the agent).
|
package/lib/index.js
CHANGED
|
@@ -53,6 +53,12 @@ export const Config = z.object({
|
|
|
53
53
|
logMaxBytes: z.natural().default(5 * 1024 * 1024),
|
|
54
54
|
/** After a benchmark decision rejects a candidate, roll the refinement back automatically. */
|
|
55
55
|
autoRollbackOnReject: z.boolean().default(true),
|
|
56
|
+
/**
|
|
57
|
+
* P1 auto-case capture: failed evolution attempts (benchmark-rejected
|
|
58
|
+
* candidates, gate proposals without consent) land as draft cases in the
|
|
59
|
+
* auto-regression container benchmark, seeding the regression loop.
|
|
60
|
+
*/
|
|
61
|
+
autoCase: z.boolean().default(true),
|
|
56
62
|
/**
|
|
57
63
|
* Gap C1: optional model override for the review gate (cheaper model).
|
|
58
64
|
* Format: "provider/model" or just "model" (same provider as the agent).
|
|
@@ -127,9 +133,11 @@ export function apply(ctx, config) {
|
|
|
127
133
|
minPromoteChars: config.promotionMinChars,
|
|
128
134
|
});
|
|
129
135
|
registerEvolveTools(ctx, engine, gate);
|
|
136
|
+
const rubricKey = resolveRubricKey(baseDir, config.rubricKey, process.env, (m) => ctx.logger("continual-evolve").warn(m));
|
|
130
137
|
registerEvolveCommand(ctx, engine, gate, {
|
|
131
|
-
rubricKey
|
|
138
|
+
rubricKey,
|
|
132
139
|
autoRollbackOnReject: config.autoRollbackOnReject ?? true,
|
|
140
|
+
autoCase: config.autoCase ?? true,
|
|
133
141
|
promotionPolicy,
|
|
134
142
|
});
|
|
135
143
|
// Plugin-owned file logging: every cordis log message lands in
|
|
@@ -155,6 +163,8 @@ export function apply(ctx, config) {
|
|
|
155
163
|
fateIntervalTurns: config.fateIntervalTurns ?? config.reviewIntervalTurns ?? 6,
|
|
156
164
|
goalBlockedWrapupTurns: config.goalBlockedWrapupTurns ?? 3,
|
|
157
165
|
promotionPolicy,
|
|
166
|
+
autoCase: config.autoCase ?? true,
|
|
167
|
+
rubricKey,
|
|
158
168
|
...(config.reviewModel ? { reviewModel: config.reviewModel } : {}),
|
|
159
169
|
});
|
|
160
170
|
ctx.logger("continual-evolve").info(`continual-evolve auto-review enabled (every ${config.reviewIntervalTurns ?? 6} turns; local-fate ${config.localFate ?? true ? "on" : "off"} every ${config.fateIntervalTurns ?? config.reviewIntervalTurns ?? 6} turns)`);
|
package/lib/inject.d.ts
CHANGED
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import type { HarnessEntry, HarnessState } from "./types.js";
|
|
21
21
|
import type { EvolutionEngine } from "./service.js";
|
|
22
|
+
import { tokenize } from "./search.js";
|
|
23
|
+
/** CJK-bigram tokenizer re-exported for ranking consumers (see search.ts). */
|
|
24
|
+
export { tokenize };
|
|
22
25
|
/** Prompt sections render at most this many entries per kind. */
|
|
23
26
|
export declare const MAX_INJECTED_ENTRIES_PER_KIND = 6;
|
|
24
27
|
/** Per-entry content budget inside the injected block (matches render.ts). */
|
|
@@ -60,17 +63,6 @@ export interface AgentLike {
|
|
|
60
63
|
export interface InjectContext {
|
|
61
64
|
agent?: AgentLike;
|
|
62
65
|
}
|
|
63
|
-
/**
|
|
64
|
-
* Lowercase tokenization for the keyword relevance scorer: runs of ASCII
|
|
65
|
-
* alphanumerics and CJK characters become tokens (CJK is not split so whole
|
|
66
|
-
* Chinese words/characters stay comparable), everything else is a separator.
|
|
67
|
-
*/
|
|
68
|
-
export declare function tokenize(text: string): string[];
|
|
69
|
-
/**
|
|
70
|
-
* Keyword hit count of `query` tokens inside an entry: title hits weigh 2×,
|
|
71
|
-
* content/path hits 1×. BM25-level relevance without any external service.
|
|
72
|
-
*/
|
|
73
|
-
export declare function relevanceHits(entry: HarnessEntry, query: string): number;
|
|
74
66
|
/**
|
|
75
67
|
* Normalized recency in [0, 1]: 1 when the entry was just updated, decaying
|
|
76
68
|
* linearly to 0 after {@link RECENCY_HALF_LIFE_MS}. Unparseable timestamps
|
|
@@ -78,12 +70,16 @@ export declare function relevanceHits(entry: HarnessEntry, query: string): numbe
|
|
|
78
70
|
*/
|
|
79
71
|
export declare function recencyScore(entry: HarnessEntry, now: number): number;
|
|
80
72
|
/**
|
|
81
|
-
* Rank entries for injection, best first. With no query the ranking is
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
73
|
+
* Rank entries for injection, best first. With no query the ranking is
|
|
74
|
+
* negative-valence first (contradicted entries last), then pure recency
|
|
75
|
+
* (newest first). With a query, entries are scored once against a per-call
|
|
76
|
+
* BM25 index (CJK bigrams; field-weighted title ×2 — see search.ts): any
|
|
77
|
+
* entry with a positive score (≥1 matched token) outranks every hit-less
|
|
78
|
+
* entry (score exactly 0), scores decide the order among relevant entries,
|
|
79
|
+
* the negative-valence counter breaks remaining ties (contradicted entries
|
|
80
|
+
* sink), recency breaks remaining ties, and the stable dictionary order is
|
|
81
|
+
* the final tiebreak, so the result is deterministic. The input is never
|
|
82
|
+
* mutated.
|
|
87
83
|
*/
|
|
88
84
|
export declare function rankEntries(entries: readonly HarnessEntry[], query?: string, now?: number): HarnessEntry[];
|
|
89
85
|
/**
|
package/lib/inject.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { isArchived } from "./types.js";
|
|
1
|
+
import { isArchived, VALENCE_NEGATIVE_KEY } from "./types.js";
|
|
2
2
|
import { mergeHarnessStates } from "./state.js";
|
|
3
3
|
import { entryLine } from "./render.js";
|
|
4
4
|
import { recordInjection } from "./usage.js";
|
|
5
|
+
import { buildRelevanceIndex, relevanceScore, tokenize } from "./search.js";
|
|
6
|
+
/** CJK-bigram tokenizer re-exported for ranking consumers (see search.ts). */
|
|
7
|
+
export { tokenize };
|
|
5
8
|
/** Prompt sections render at most this many entries per kind. */
|
|
6
9
|
export const MAX_INJECTED_ENTRIES_PER_KIND = 6;
|
|
7
10
|
/** Per-entry content budget inside the injected block (matches render.ts). */
|
|
@@ -18,31 +21,6 @@ export const MAX_QUERY_CHARS = 400;
|
|
|
18
21
|
function stableCompare(a, b) {
|
|
19
22
|
return [a.path, a.title, a.id].join("\0").localeCompare([b.path, b.title, b.id].join("\0"));
|
|
20
23
|
}
|
|
21
|
-
/**
|
|
22
|
-
* Lowercase tokenization for the keyword relevance scorer: runs of ASCII
|
|
23
|
-
* alphanumerics and CJK characters become tokens (CJK is not split so whole
|
|
24
|
-
* Chinese words/characters stay comparable), everything else is a separator.
|
|
25
|
-
*/
|
|
26
|
-
export function tokenize(text) {
|
|
27
|
-
return text
|
|
28
|
-
.toLowerCase()
|
|
29
|
-
.split(/[^a-z0-9\u4e00-\u9fff]+/)
|
|
30
|
-
.filter((token) => token.length > 0);
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Keyword hit count of `query` tokens inside an entry: title hits weigh 2×,
|
|
34
|
-
* content/path hits 1×. BM25-level relevance without any external service.
|
|
35
|
-
*/
|
|
36
|
-
export function relevanceHits(entry, query) {
|
|
37
|
-
const titleTokens = tokenize(entry.title);
|
|
38
|
-
const bodyTokens = tokenize(`${entry.content} ${entry.path}`);
|
|
39
|
-
let hits = 0;
|
|
40
|
-
for (const token of tokenize(query)) {
|
|
41
|
-
hits += titleTokens.filter((t) => t === token).length * 2;
|
|
42
|
-
hits += bodyTokens.filter((t) => t === token).length;
|
|
43
|
-
}
|
|
44
|
-
return hits;
|
|
45
|
-
}
|
|
46
24
|
/**
|
|
47
25
|
* Normalized recency in [0, 1]: 1 when the entry was just updated, decaying
|
|
48
26
|
* linearly to 0 after {@link RECENCY_HALF_LIFE_MS}. Unparseable timestamps
|
|
@@ -60,21 +38,55 @@ export function recencyScore(entry, now) {
|
|
|
60
38
|
return Math.max(0, 1 - age / RECENCY_HALF_LIFE_MS);
|
|
61
39
|
}
|
|
62
40
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
41
|
+
* Negative-valence counter (P1 效价反馈): entries contradicted by later
|
|
42
|
+
* assessments sink below clean ones at equal relevance/recency — the
|
|
43
|
+
* behavioral analog of a confidence penalty (pi-continuous-learning's
|
|
44
|
+
* contradicted −0.15), without inventing a new score axis.
|
|
45
|
+
*/
|
|
46
|
+
function negativeValence(entry) {
|
|
47
|
+
const value = entry.metadata[VALENCE_NEGATIVE_KEY];
|
|
48
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Rank entries for injection, best first. With no query the ranking is
|
|
52
|
+
* negative-valence first (contradicted entries last), then pure recency
|
|
53
|
+
* (newest first). With a query, entries are scored once against a per-call
|
|
54
|
+
* BM25 index (CJK bigrams; field-weighted title ×2 — see search.ts): any
|
|
55
|
+
* entry with a positive score (≥1 matched token) outranks every hit-less
|
|
56
|
+
* entry (score exactly 0), scores decide the order among relevant entries,
|
|
57
|
+
* the negative-valence counter breaks remaining ties (contradicted entries
|
|
58
|
+
* sink), recency breaks remaining ties, and the stable dictionary order is
|
|
59
|
+
* the final tiebreak, so the result is deterministic. The input is never
|
|
60
|
+
* mutated.
|
|
69
61
|
*/
|
|
70
62
|
export function rankEntries(entries, query, now = Date.now()) {
|
|
71
63
|
const q = (query ?? "").trim();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
return
|
|
64
|
+
if (q.length === 0) {
|
|
65
|
+
return [...entries].sort((a, b) => {
|
|
66
|
+
const valenceDelta = negativeValence(a) - negativeValence(b);
|
|
67
|
+
if (valenceDelta !== 0) {
|
|
68
|
+
return valenceDelta;
|
|
69
|
+
}
|
|
70
|
+
const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
|
|
71
|
+
if (recencyDelta !== 0) {
|
|
72
|
+
return recencyDelta;
|
|
77
73
|
}
|
|
74
|
+
return stableCompare(a, b);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// Precompute scores once: the old comparator re-tokenized both sides on
|
|
78
|
+
// every comparison (O(n log n) tokenizations); one index + one score per
|
|
79
|
+
// entry turns the pass into table lookups.
|
|
80
|
+
const index = buildRelevanceIndex(entries);
|
|
81
|
+
const scores = new Map(entries.map((entry) => [entry, relevanceScore(index, entry, q)]));
|
|
82
|
+
return [...entries].sort((a, b) => {
|
|
83
|
+
const relevanceDelta = (scores.get(b) ?? 0) - (scores.get(a) ?? 0);
|
|
84
|
+
if (relevanceDelta !== 0) {
|
|
85
|
+
return relevanceDelta;
|
|
86
|
+
}
|
|
87
|
+
const valenceDelta = negativeValence(a) - negativeValence(b);
|
|
88
|
+
if (valenceDelta !== 0) {
|
|
89
|
+
return valenceDelta;
|
|
78
90
|
}
|
|
79
91
|
const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
|
|
80
92
|
if (recencyDelta !== 0) {
|
package/lib/mount.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { skillNameOf } from "./skill.js";
|
|
19
|
+
import { secretLeakReason } from "./promotion.js";
|
|
19
20
|
export function mountedDir(baseDir) {
|
|
20
21
|
return join(baseDir, "evolve", "mounted");
|
|
21
22
|
}
|
|
@@ -41,6 +42,14 @@ export function saveLedger(baseDir, ledger) {
|
|
|
41
42
|
}
|
|
42
43
|
/** Generate the plugin package files for one skill entry; returns the package dir. */
|
|
43
44
|
export function renderMountPackage(baseDir, entry) {
|
|
45
|
+
// Secret-leak quarantine: the entry content AND its reference contract are
|
|
46
|
+
// embedded verbatim into the generated index.js, so a credential in either
|
|
47
|
+
// would be written to disk (and executed in-process). Block before ANY
|
|
48
|
+
// file is created (review audit 2026-08-28 B2: reference was unscreened).
|
|
49
|
+
const secret = secretLeakReason([entry.title, entry.content, JSON.stringify(entry.reference ?? {}), JSON.stringify(entry.arguments ?? {})].join("\n"));
|
|
50
|
+
if (secret) {
|
|
51
|
+
throw new Error(`mount blocked: ${secret}`);
|
|
52
|
+
}
|
|
44
53
|
const dir = join(mountedDir(baseDir), skillNameOf(entry.id));
|
|
45
54
|
mkdirSync(dir, { recursive: true });
|
|
46
55
|
const toolName = `skill_${skillNameOf(entry.id)}`;
|