dsh-continual-evolve 0.5.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 -8
- package/README.zh.md +12 -8
- 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 +22 -12
- package/lib/consolidate.d.ts +25 -0
- package/lib/consolidate.js +70 -3
- 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 +10 -8
- package/lib/inject.js +29 -9
- package/lib/mount.js +9 -0
- package/lib/promotion.d.ts +14 -3
- package/lib/promotion.js +62 -18
- package/lib/rollback.js +7 -0
- package/lib/service.js +37 -3
- package/lib/state.d.ts +0 -12
- package/lib/state.js +4 -19
- package/lib/store.js +3 -1
- package/lib/types.d.ts +14 -0
- package/lib/types.js +14 -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,7 +8,7 @@ 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";
|
|
@@ -26,8 +26,10 @@ const USAGE = `Usage:
|
|
|
26
26
|
/evolve archive <id> [global] hide an entry from injection (data kept, restorable)
|
|
27
27
|
/evolve unarchive <id> [global] restore an archived entry
|
|
28
28
|
/evolve demote <id> hide a (global) entry from injection, keep data
|
|
29
|
-
/evolve consolidate [apply]
|
|
30
|
-
|
|
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
|
|
31
33
|
/evolve log [tail N] show the recent plugin log (default 50 lines)
|
|
32
34
|
/evolve failures aggregated failure counts (gate + benchmark, by class)
|
|
33
35
|
/evolve export [global] <path> backup a store to a JSON file
|
|
@@ -180,20 +182,29 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
|
|
|
180
182
|
// R3: deterministic global-store hygiene. Report by default;
|
|
181
183
|
// `apply` re-scans fresh state and lands the whole batch as ONE
|
|
182
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.
|
|
183
187
|
const apply = rest[0] === "apply";
|
|
188
|
+
const merge = rest.includes("merge");
|
|
184
189
|
const state = engine.load("global", undefined);
|
|
185
|
-
const { candidates, edits } = planConsolidation(state, loadUsage(engine.baseDir));
|
|
190
|
+
const { candidates, edits } = planConsolidation(state, loadUsage(engine.baseDir), Date.now(), { mergeDuplicates: merge });
|
|
186
191
|
if (candidates.length === 0) {
|
|
187
192
|
return success("global store is already consolidated — no conflict-hinted or stale zero-use entries.");
|
|
188
193
|
}
|
|
189
|
-
const
|
|
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");
|
|
190
201
|
if (!apply) {
|
|
191
|
-
return success(`consolidation plan — ${candidates.length} archive candidate(s):\n${report}\n(run "/evolve consolidate apply" to archive all of them in one refinement)`);
|
|
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)`);
|
|
192
203
|
}
|
|
193
204
|
const result = engine.apply("global", undefined, {
|
|
194
|
-
summary: `Consolidate global store: archive ${candidates.length} entries`,
|
|
205
|
+
summary: `Consolidate global store: archive ${candidates.length} entries${mergeCount > 0 ? `, merge ${mergeCount} into survivors` : ""}`,
|
|
195
206
|
rationale: "Human-invoked batch consolidation via /evolve consolidate apply.",
|
|
196
|
-
expectedOutcome: "Candidate entries are hidden from injection (data kept; restorable via /evolve unarchive).",
|
|
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.",
|
|
197
208
|
edits,
|
|
198
209
|
}, { scope: "global" });
|
|
199
210
|
return success(`${report}\n\napplied:\n${renderResult(result)}`);
|
|
@@ -201,10 +212,9 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
|
|
|
201
212
|
case "failures": {
|
|
202
213
|
// /evolve failures — failure-signature aggregation (D1 observation):
|
|
203
214
|
// failed review-gate records + failed benchmark cells, counted by class.
|
|
204
|
-
const
|
|
205
|
-
const summary = summarizeFailures(failed);
|
|
215
|
+
const { summary, records } = collectFailureSummary(engine.baseDir);
|
|
206
216
|
const parts = formatFailureSummary(summary).split("\n");
|
|
207
|
-
const recent =
|
|
217
|
+
const recent = records
|
|
208
218
|
.sort((a, b) => (b.timestamp ?? "").localeCompare(a.timestamp ?? ""))
|
|
209
219
|
.slice(0, 10)
|
|
210
220
|
.map((f) => ` [${f.timestamp ?? "(benchmark)"}] ${f.kind} · ${f.source}: ${f.message.slice(0, 140)}`);
|
|
@@ -260,7 +270,7 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
|
|
|
260
270
|
refinements: state.refinements,
|
|
261
271
|
history,
|
|
262
272
|
};
|
|
263
|
-
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
273
|
+
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
264
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}`);
|
|
265
275
|
}
|
|
266
276
|
case "import": {
|
package/lib/consolidate.d.ts
CHANGED
|
@@ -35,6 +35,17 @@ export interface ConsolidationCandidate {
|
|
|
35
35
|
id: string;
|
|
36
36
|
title: string;
|
|
37
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
|
+
};
|
|
38
49
|
}
|
|
39
50
|
/**
|
|
40
51
|
* Entries stamped by the write-time conflict guard whose target still exists
|
|
@@ -48,16 +59,30 @@ export declare function findConflictPairs(state: HarnessState): ConsolidationCan
|
|
|
48
59
|
* it is handed (callers pass the global store).
|
|
49
60
|
*/
|
|
50
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;
|
|
51
67
|
/**
|
|
52
68
|
* Merge both scans (conflict reason wins on overlap), deduped by kind:id,
|
|
53
69
|
* and build the batch archive edits. Each edit preserves the entry's full
|
|
54
70
|
* content and existing metadata — only ARCHIVED_AT_KEY is added — so
|
|
55
71
|
* `/evolve unarchive` restores everything intact.
|
|
56
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
|
+
*
|
|
57
81
|
* @param now Epoch ms used for the archivedAt stamp (injected for tests).
|
|
58
82
|
*/
|
|
59
83
|
export declare function planConsolidation(state: HarnessState, store: UsageStore, now?: number, opts?: {
|
|
60
84
|
minAgeMs?: number;
|
|
85
|
+
mergeDuplicates?: boolean;
|
|
61
86
|
}): {
|
|
62
87
|
candidates: ConsolidationCandidate[];
|
|
63
88
|
edits: RefinementEdit[];
|
package/lib/consolidate.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ARCHIVED_AT_KEY, CONFLICT_HINT_KEY, isArchived } from "./types.js";
|
|
1
|
+
import { ARCHIVED_AT_KEY, CONFLICT_HINT_KEY, MERGED_FROM_KEY, isArchived } from "./types.js";
|
|
2
2
|
import { getUsageCount } from "./usage.js";
|
|
3
3
|
/** Zero-use entries at least this old are stale candidates (30d, matching the injection recency half-life scale). */
|
|
4
4
|
export const STALE_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -48,6 +48,7 @@ export function findConflictPairs(state) {
|
|
|
48
48
|
id: entry.id,
|
|
49
49
|
title: entry.title,
|
|
50
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 },
|
|
51
52
|
});
|
|
52
53
|
}
|
|
53
54
|
}
|
|
@@ -79,12 +80,36 @@ export function findStaleEntries(state, store, now, minAgeMs = STALE_MIN_AGE_MS)
|
|
|
79
80
|
}
|
|
80
81
|
return candidates;
|
|
81
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
|
+
}
|
|
82
99
|
/**
|
|
83
100
|
* Merge both scans (conflict reason wins on overlap), deduped by kind:id,
|
|
84
101
|
* and build the batch archive edits. Each edit preserves the entry's full
|
|
85
102
|
* content and existing metadata — only ARCHIVED_AT_KEY is added — so
|
|
86
103
|
* `/evolve unarchive` restores everything intact.
|
|
87
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
|
+
*
|
|
88
113
|
* @param now Epoch ms used for the archivedAt stamp (injected for tests).
|
|
89
114
|
*/
|
|
90
115
|
export function planConsolidation(state, store, now = Date.now(), opts) {
|
|
@@ -96,9 +121,17 @@ export function planConsolidation(state, store, now = Date.now(), opts) {
|
|
|
96
121
|
}
|
|
97
122
|
}
|
|
98
123
|
const candidates = [...byKey.values()];
|
|
99
|
-
const
|
|
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) => {
|
|
100
133
|
const entry = state.entries[candidate.kind][candidate.id];
|
|
101
|
-
const metadata = { ...entry?.metadata, [ARCHIVED_AT_KEY]:
|
|
134
|
+
const metadata = { ...entry?.metadata, [ARCHIVED_AT_KEY]: dateIso };
|
|
102
135
|
return {
|
|
103
136
|
action: "update",
|
|
104
137
|
kind: candidate.kind,
|
|
@@ -108,6 +141,40 @@ export function planConsolidation(state, store, now = Date.now(), opts) {
|
|
|
108
141
|
metadata,
|
|
109
142
|
};
|
|
110
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
|
+
}
|
|
111
178
|
return { candidates, edits };
|
|
112
179
|
}
|
|
113
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
|
@@ -70,14 +70,16 @@ export interface InjectContext {
|
|
|
70
70
|
*/
|
|
71
71
|
export declare function recencyScore(entry: HarnessEntry, now: number): number;
|
|
72
72
|
/**
|
|
73
|
-
* Rank entries for injection, best first. With no query the ranking is
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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.
|
|
81
83
|
*/
|
|
82
84
|
export declare function rankEntries(entries: readonly HarnessEntry[], query?: string, now?: number): HarnessEntry[];
|
|
83
85
|
/**
|
package/lib/inject.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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";
|
|
@@ -38,19 +38,35 @@ export function recencyScore(entry, now) {
|
|
|
38
38
|
return Math.max(0, 1 - age / RECENCY_HALF_LIFE_MS);
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
49
61
|
*/
|
|
50
62
|
export function rankEntries(entries, query, now = Date.now()) {
|
|
51
63
|
const q = (query ?? "").trim();
|
|
52
64
|
if (q.length === 0) {
|
|
53
65
|
return [...entries].sort((a, b) => {
|
|
66
|
+
const valenceDelta = negativeValence(a) - negativeValence(b);
|
|
67
|
+
if (valenceDelta !== 0) {
|
|
68
|
+
return valenceDelta;
|
|
69
|
+
}
|
|
54
70
|
const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
|
|
55
71
|
if (recencyDelta !== 0) {
|
|
56
72
|
return recencyDelta;
|
|
@@ -68,6 +84,10 @@ export function rankEntries(entries, query, now = Date.now()) {
|
|
|
68
84
|
if (relevanceDelta !== 0) {
|
|
69
85
|
return relevanceDelta;
|
|
70
86
|
}
|
|
87
|
+
const valenceDelta = negativeValence(a) - negativeValence(b);
|
|
88
|
+
if (valenceDelta !== 0) {
|
|
89
|
+
return valenceDelta;
|
|
90
|
+
}
|
|
71
91
|
const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
|
|
72
92
|
if (recencyDelta !== 0) {
|
|
73
93
|
return recencyDelta;
|
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)}`;
|
package/lib/promotion.d.ts
CHANGED
|
@@ -33,6 +33,13 @@ export declare const DEFAULT_PROMOTION_POLICY: PromotionPolicy;
|
|
|
33
33
|
export declare const CONFLICT_BLOCK_SCORE = 0.8;
|
|
34
34
|
/** Similarity at/above this stamps {@link CONFLICT_HINT_KEY} but lets the write proceed. */
|
|
35
35
|
export declare const CONFLICT_WARN_SCORE = 0.5;
|
|
36
|
+
/**
|
|
37
|
+
* First reason the content reads as carrying a live credential, or undefined
|
|
38
|
+
* when it screens clean. Unlike {@link projectScopedReason} this guard is
|
|
39
|
+
* NOT policy-configurable — it protects every sink at once (promotion path,
|
|
40
|
+
* global writes at the engine throat, mount materialization).
|
|
41
|
+
*/
|
|
42
|
+
export declare function secretLeakReason(content: string): string | undefined;
|
|
36
43
|
/**
|
|
37
44
|
* Build a policy from config values (schemastery strings compiled here so
|
|
38
45
|
* the config layer never touches RegExp). Invalid patterns are skipped —
|
|
@@ -50,9 +57,13 @@ export declare function resolvePromotionPolicy(options: {
|
|
|
50
57
|
*/
|
|
51
58
|
export declare function projectScopedReason(content: string, policy: PromotionPolicy): string | undefined;
|
|
52
59
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
60
|
+
* Token set for cheap similarity, built on the search module's tokenizer —
|
|
61
|
+
* one home for "how this project splits words" (ASCII words lowercased plus
|
|
62
|
+
* CJK character bigrams; bigrams survive segmentation-free Chinese text).
|
|
63
|
+
* The set form feeds Jaccard overlap (review audit 2026-08-28 C: the
|
|
64
|
+
* previous private tokenizer duplicated the definition; the calibrated
|
|
65
|
+
* 0.6/0.8 thresholds are judgment values, not data-fitted, so the merge
|
|
66
|
+
* costs at most a test re-pin).
|
|
56
67
|
*/
|
|
57
68
|
export declare function normalizedTokens(text: string): Set<string>;
|
|
58
69
|
/** Jaccard similarity of two texts' normalized token sets (0..1). */
|
package/lib/promotion.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isArchived } from "./types.js";
|
|
2
|
+
import { tokenize } from "./search.js";
|
|
2
3
|
/** Regex sources that mark content as project-scoped (never global). */
|
|
3
4
|
const DEFAULT_BLOCK_PATTERNS = [
|
|
4
5
|
String.raw `\/(?:mnt|home|Users)\/`, // absolute POSIX paths: "/home/…", "/mnt/…", "/Users/…"
|
|
@@ -19,6 +20,59 @@ export const DEFAULT_PROMOTION_POLICY = {
|
|
|
19
20
|
export const CONFLICT_BLOCK_SCORE = 0.8;
|
|
20
21
|
/** Similarity at/above this stamps {@link CONFLICT_HINT_KEY} but lets the write proceed. */
|
|
21
22
|
export const CONFLICT_WARN_SCORE = 0.5;
|
|
23
|
+
/**
|
|
24
|
+
* Fixed secret-detection patterns — a security invariant, deliberately NOT
|
|
25
|
+
* part of the configurable {@link PromotionPolicy}: a user pattern typo must
|
|
26
|
+
* never be able to disable secret screening. Each entry pairs a human label
|
|
27
|
+
* with a regex tuned for low false positives (placeholders like
|
|
28
|
+
* "YOUR_API_KEY_HERE" don't match; realistic mixed literals do).
|
|
29
|
+
*/
|
|
30
|
+
const SECRET_PATTERNS = [
|
|
31
|
+
{ label: "AnySearch API key", regex: /\bas_sk_[A-Za-z0-9]{8,}\b/ },
|
|
32
|
+
{ label: "Anthropic API key", regex: /\bsk-ant-[A-Za-z0-9_-]{16,}\b/ },
|
|
33
|
+
{ label: "OpenAI-style API key", regex: /\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}\b/ },
|
|
34
|
+
{ label: "GitHub token", regex: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/ },
|
|
35
|
+
{ label: "GitHub fine-grained PAT", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/ },
|
|
36
|
+
{ label: "AWS access key", regex: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
|
37
|
+
{ label: "Google API key", regex: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
38
|
+
{ label: "Slack token", regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
39
|
+
{ label: "npm grant token", regex: /\bnpm_[A-Za-z0-9]{36}\b/ },
|
|
40
|
+
{ label: "private key block", regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/ },
|
|
41
|
+
{
|
|
42
|
+
label: "credential assignment",
|
|
43
|
+
// The credential-name group is manually case-insensitive ("apiKey" in
|
|
44
|
+
// JSON, "API_KEY" in env examples) so the placeholder lookahead stays
|
|
45
|
+
// case-sensitive: it excludes only ALL-CAPS placeholders
|
|
46
|
+
// ("YOUR_KEY_HERE"), not real mixed/lowercase literals.
|
|
47
|
+
regex: /\b(?:[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Pp][Aa][Ss][Ss][Ww][Dd]|[Pp][Ww][Dd])\b["']?\s*[:=]\s*["'](?!["']*[A-Z0-9_]+["'])[A-Za-z0-9+/_-]{16,}["']/,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* Redact a matched secret for error/audit surfaces: enough to identify the
|
|
52
|
+
* credential family, never the full value (reasons land in reviews.jsonl,
|
|
53
|
+
* question texts, and logs).
|
|
54
|
+
*/
|
|
55
|
+
function redactSecret(matched) {
|
|
56
|
+
if (matched.length <= 10) {
|
|
57
|
+
return `${matched.slice(0, 3)}…`;
|
|
58
|
+
}
|
|
59
|
+
return `${matched.slice(0, 6)}…${matched.slice(-3)}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* First reason the content reads as carrying a live credential, or undefined
|
|
63
|
+
* when it screens clean. Unlike {@link projectScopedReason} this guard is
|
|
64
|
+
* NOT policy-configurable — it protects every sink at once (promotion path,
|
|
65
|
+
* global writes at the engine throat, mount materialization).
|
|
66
|
+
*/
|
|
67
|
+
export function secretLeakReason(content) {
|
|
68
|
+
for (const { label, regex } of SECRET_PATTERNS) {
|
|
69
|
+
const match = regex.exec(content);
|
|
70
|
+
if (match?.[0]) {
|
|
71
|
+
return `possible ${label} ("${redactSecret(match[0])}") — secrets must never be sedimented into harness state; rotate the credential and keep it out of the store`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
22
76
|
/**
|
|
23
77
|
* Build a policy from config values (schemastery strings compiled here so
|
|
24
78
|
* the config layer never touches RegExp). Invalid patterns are skipped —
|
|
@@ -55,26 +109,16 @@ export function projectScopedReason(content, policy) {
|
|
|
55
109
|
return undefined;
|
|
56
110
|
}
|
|
57
111
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
112
|
+
* Token set for cheap similarity, built on the search module's tokenizer —
|
|
113
|
+
* one home for "how this project splits words" (ASCII words lowercased plus
|
|
114
|
+
* CJK character bigrams; bigrams survive segmentation-free Chinese text).
|
|
115
|
+
* The set form feeds Jaccard overlap (review audit 2026-08-28 C: the
|
|
116
|
+
* previous private tokenizer duplicated the definition; the calibrated
|
|
117
|
+
* 0.6/0.8 thresholds are judgment values, not data-fitted, so the merge
|
|
118
|
+
* costs at most a test re-pin).
|
|
61
119
|
*/
|
|
62
120
|
export function normalizedTokens(text) {
|
|
63
|
-
|
|
64
|
-
const tokens = new Set();
|
|
65
|
-
for (const match of lowered.matchAll(/[a-z0-9_]{2,}/g)) {
|
|
66
|
-
tokens.add(match[0] ?? "");
|
|
67
|
-
}
|
|
68
|
-
let previous;
|
|
69
|
-
for (const match of lowered.matchAll(/[\u3400-\u9fff]/g)) {
|
|
70
|
-
const char = match[0] ?? "";
|
|
71
|
-
if (previous !== undefined) {
|
|
72
|
-
tokens.add(`${previous}${char}`);
|
|
73
|
-
}
|
|
74
|
-
previous = char;
|
|
75
|
-
}
|
|
76
|
-
tokens.delete("");
|
|
77
|
-
return tokens;
|
|
121
|
+
return new Set(tokenize(text));
|
|
78
122
|
}
|
|
79
123
|
/** Jaccard similarity of two texts' normalized token sets (0..1). */
|
|
80
124
|
export function contentOverlap(a, b) {
|
package/lib/rollback.js
CHANGED
|
@@ -19,6 +19,8 @@ export function rollbackProposal(target) {
|
|
|
19
19
|
function inverseEdit(edit, refinementId) {
|
|
20
20
|
if (edit.before && edit.after) {
|
|
21
21
|
// Forward action was an update: restore the before snapshot.
|
|
22
|
+
// skill_kind rides along (review audit 2026-08-28 B4): without it a
|
|
23
|
+
// rollback could flip the skill form or fail validation on re-apply.
|
|
22
24
|
return {
|
|
23
25
|
action: "update",
|
|
24
26
|
kind: edit.kind,
|
|
@@ -28,12 +30,16 @@ function inverseEdit(edit, refinementId) {
|
|
|
28
30
|
path: edit.before.path,
|
|
29
31
|
reference: edit.before.reference,
|
|
30
32
|
arguments: edit.before.arguments,
|
|
33
|
+
...(edit.before.skill_kind !== undefined ? { skill_kind: edit.before.skill_kind } : {}),
|
|
31
34
|
metadata: edit.before.metadata,
|
|
32
35
|
reason: `Rollback ${refinementId}`,
|
|
33
36
|
};
|
|
34
37
|
}
|
|
35
38
|
if (edit.before) {
|
|
36
39
|
// Forward action was a delete: re-create the entry from the snapshot.
|
|
40
|
+
// skill_kind is required for the re-created skill to pass the same
|
|
41
|
+
// contract validation as the original create (B4: a deleted guidance
|
|
42
|
+
// skill was previously unrecoverable via rollback).
|
|
37
43
|
return {
|
|
38
44
|
action: "create",
|
|
39
45
|
kind: edit.kind,
|
|
@@ -43,6 +49,7 @@ function inverseEdit(edit, refinementId) {
|
|
|
43
49
|
path: edit.before.path,
|
|
44
50
|
reference: edit.before.reference,
|
|
45
51
|
arguments: edit.before.arguments,
|
|
52
|
+
...(edit.before.skill_kind !== undefined ? { skill_kind: edit.before.skill_kind } : {}),
|
|
46
53
|
metadata: edit.before.metadata,
|
|
47
54
|
reason: `Rollback ${refinementId}`,
|
|
48
55
|
};
|