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/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 { readBenchmarkFailures, readReviewFailures, summarizeFailures, formatFailureSummary } from "./failures.js";
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] report (or apply) a batch archive of conflict-hinted
30
- and stale zero-use global entries
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 report = candidates.map((candidate, index) => `${index + 1}. [${candidate.kind}:${candidate.id}] ${candidate.title}\n ${candidate.reason}`).join("\n");
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 failed = [...readReviewFailures(engine.baseDir), ...readBenchmarkFailures(engine.baseDir)];
205
- const summary = summarizeFailures(failed);
215
+ const { summary, records } = collectFailureSummary(engine.baseDir);
206
216
  const parts = formatFailureSummary(summary).split("\n");
207
- const recent = failed
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": {
@@ -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[];
@@ -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 edits = candidates.map((candidate) => {
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]: new Date(now).toISOString() };
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): FailureSummary;
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
- return summarizeFailures([...readReviewFailures(baseDir), ...readBenchmarkFailures(baseDir)]);
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: resolveRubricKey(baseDir, config.rubricKey, process.env, (m) => ctx.logger("continual-evolve").warn(m)),
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 pure
74
- * recency (newest first). With a query, entries are scored once against a
75
- * per-call BM25 index (CJK bigrams; field-weighted title ×2 see
76
- * search.ts): any entry with a positive score (≥1 matched token) outranks
77
- * every hit-less entry (score exactly 0), scores decide the order among
78
- * relevant entries, recency breaks remaining ties, and the stable dictionary
79
- * order is the final tiebreak, so the result is deterministic. The input is
80
- * never mutated.
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
- * Rank entries for injection, best first. With no query the ranking is pure
42
- * recency (newest first). With a query, entries are scored once against a
43
- * per-call BM25 index (CJK bigrams; field-weighted title ×2 — see
44
- * search.ts): any entry with a positive score (≥1 matched token) outranks
45
- * every hit-less entry (score exactly 0), scores decide the order among
46
- * relevant entries, recency breaks remaining ties, and the stable dictionary
47
- * order is the final tiebreak, so the result is deterministic. The input is
48
- * never mutated.
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)}`;
@@ -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
- * Tokenize for cheap similarity: ASCII words plus CJK character bigrams
54
- * (single CJK chars are too ambiguous; bigrams survive segmentation-free
55
- * Chinese text). Lowercased; single-char ASCII tokens dropped as noise.
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
- * Tokenize for cheap similarity: ASCII words plus CJK character bigrams
59
- * (single CJK chars are too ambiguous; bigrams survive segmentation-free
60
- * Chinese text). Lowercased; single-char ASCII tokens dropped as noise.
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
- const lowered = text.toLowerCase();
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
  };