dsh-continual-evolve 0.2.0 → 0.3.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 +45 -7
- package/README.zh.md +44 -7
- package/lib/apply.js +1 -1
- package/lib/approval.d.ts +6 -0
- package/lib/approval.js +9 -1
- package/lib/auto.d.ts +38 -4
- package/lib/auto.js +58 -5
- package/lib/benchmark-command.d.ts +9 -0
- package/lib/benchmark-command.js +331 -0
- package/lib/benchmark.d.ts +70 -0
- package/lib/benchmark.js +107 -1
- package/lib/command.js +25 -442
- package/lib/evaluate.d.ts +7 -0
- package/lib/evaluate.js +22 -7
- package/lib/evolve-event.d.ts +38 -0
- package/lib/evolve-event.js +49 -0
- package/lib/failures.d.ts +39 -0
- package/lib/failures.js +170 -0
- package/lib/fate.d.ts +3 -1
- package/lib/fate.js +8 -4
- package/lib/goal-command.d.ts +7 -0
- package/lib/goal-command.js +37 -0
- package/lib/index.d.ts +29 -25
- package/lib/index.js +14 -0
- package/lib/inject.d.ts +8 -0
- package/lib/inject.js +51 -4
- package/lib/llm-text.d.ts +30 -0
- package/lib/llm-text.js +49 -0
- package/lib/mount-command.d.ts +10 -0
- package/lib/mount-command.js +48 -0
- package/lib/plan.js +5 -0
- package/lib/planner.d.ts +1 -1
- package/lib/planner.js +13 -39
- package/lib/render.d.ts +1 -3
- package/lib/render.js +0 -4
- package/lib/review.d.ts +4 -1
- package/lib/review.js +10 -38
- package/lib/rollback.d.ts +1 -3
- package/lib/rollback.js +0 -8
- package/lib/score.d.ts +15 -0
- package/lib/score.js +74 -5
- package/lib/service.d.ts +2 -2
- package/lib/service.js +5 -2
- package/lib/skill-render.d.ts +15 -0
- package/lib/skill-render.js +30 -0
- package/lib/skill.d.ts +2 -5
- package/lib/skill.js +2 -29
- package/lib/skillquality.d.ts +1 -2
- package/lib/skillquality.js +2 -2
- package/lib/store.d.ts +1 -3
- package/lib/store.js +0 -7
- package/lib/tool.js +22 -1
- package/lib/types.d.ts +8 -0
- package/lib/usage.d.ts +32 -0
- package/lib/usage.js +84 -0
- package/lib/validate.d.ts +12 -2
- package/lib/validate.js +26 -1
- package/lib/wrapup-command.d.ts +8 -0
- package/lib/wrapup-command.js +211 -0
- package/lib/wrapup.d.ts +14 -9
- package/lib/wrapup.js +24 -36
- package/package.json +8 -8
package/lib/skillquality.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { existsSync, readFileSync } from "node:fs";
|
|
23
23
|
import { join } from "node:path";
|
|
24
|
-
import { renderSkillMarkdown
|
|
24
|
+
import { renderSkillMarkdown } from "./skill-render.js";
|
|
25
25
|
/** Skill-name regex the platform enforces (skill-filesystem). */
|
|
26
26
|
const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
27
27
|
const TRUE_WORDS = new Set(["true", "yes", "on", "1"]);
|
|
@@ -307,5 +307,5 @@ export function skillResourceRefs(content) {
|
|
|
307
307
|
return [...refs];
|
|
308
308
|
}
|
|
309
309
|
/** Kebab-case name under which the entry materializes (exported for diagnostics). */
|
|
310
|
-
export { skillNameOf };
|
|
310
|
+
export { skillNameOf } from "./skill-render.js";
|
|
311
311
|
//# sourceMappingURL=skillquality.js.map
|
package/lib/store.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HarnessScope,
|
|
1
|
+
import type { HarnessScope, RefinementResult } from "./types.js";
|
|
2
2
|
export declare const EVOLVE_DIR = "evolve";
|
|
3
3
|
export interface StorePaths {
|
|
4
4
|
/** Directory holding harness_state.json. */
|
|
@@ -15,6 +15,4 @@ export declare function snapshotBefore(paths: StorePaths, refinementId: string):
|
|
|
15
15
|
export declare function appendResult(paths: StorePaths, result: RefinementResult): void;
|
|
16
16
|
/** Read the applied results history; malformed lines are skipped, never fatal. */
|
|
17
17
|
export declare function loadResults(paths: StorePaths): RefinementResult[];
|
|
18
|
-
/** Load a state file into memory, returning empty state when absent. */
|
|
19
|
-
export declare function loadStateFile(paths: StorePaths): HarnessState;
|
|
20
18
|
//# sourceMappingURL=store.d.ts.map
|
package/lib/store.js
CHANGED
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { join } from "node:path";
|
|
17
|
-
import { emptyHarnessState } from "./types.js";
|
|
18
17
|
import { stateFilePath } from "./state.js";
|
|
19
18
|
export const EVOLVE_DIR = "evolve";
|
|
20
19
|
export function storePaths(baseDir, scope, sessionId) {
|
|
@@ -65,10 +64,4 @@ export function loadResults(paths) {
|
|
|
65
64
|
function isResult(data) {
|
|
66
65
|
return typeof data === "object" && data !== null && "id" in data && "appliedEdits" in data;
|
|
67
66
|
}
|
|
68
|
-
/** Load a state file into memory, returning empty state when absent. */
|
|
69
|
-
export function loadStateFile(paths) {
|
|
70
|
-
return existsSync(stateFilePath(paths.stateDir))
|
|
71
|
-
? JSON.parse(readFileSync(stateFilePath(paths.stateDir), "utf8"))
|
|
72
|
-
: emptyHarnessState();
|
|
73
|
-
}
|
|
74
67
|
//# sourceMappingURL=store.js.map
|
package/lib/tool.js
CHANGED
|
@@ -2,6 +2,8 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
|
2
2
|
import { formatHarnessStateForPrompt } from "./render.js";
|
|
3
3
|
import { requireGlobalApproval } from "./approval.js";
|
|
4
4
|
import { entrySourceOf } from "./source.js";
|
|
5
|
+
import { getUsageCount, loadUsage } from "./usage.js";
|
|
6
|
+
import { buildEvolveCompleteEvent, emitEvolveComplete } from "./evolve-event.js";
|
|
5
7
|
const SCOPES = ["local", "global"];
|
|
6
8
|
/** Accept both the boolean tool parameter (`global: true`) and the string form. */
|
|
7
9
|
export function scopeOf(value, fallback) {
|
|
@@ -32,7 +34,22 @@ export function registerEvolveTools(ctx, engine, opts) {
|
|
|
32
34
|
execute: async (args, exec) => {
|
|
33
35
|
const scope = scopeOf(args.scope, "local");
|
|
34
36
|
const state = engine.load(scope, sessionIdOf(exec));
|
|
35
|
-
|
|
37
|
+
const text = formatHarnessStateForPrompt(state);
|
|
38
|
+
// Append injection usage counts (gap B1).
|
|
39
|
+
const usage = loadUsage(engine.baseDir);
|
|
40
|
+
const usageLines = [];
|
|
41
|
+
for (const kind of Object.keys(state.entries)) {
|
|
42
|
+
for (const entry of Object.values(state.entries[kind])) {
|
|
43
|
+
const count = getUsageCount(usage, kind, entry.id);
|
|
44
|
+
if (count > 0) {
|
|
45
|
+
usageLines.push(`${kind}:${entry.id} — injected ${count}×`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (usageLines.length > 0) {
|
|
50
|
+
return textResult(`${text}\n\n# Injection Usage\n${usageLines.join("\n")}`);
|
|
51
|
+
}
|
|
52
|
+
return textResult(text);
|
|
36
53
|
},
|
|
37
54
|
}));
|
|
38
55
|
ctx.tools.register(defineTool({
|
|
@@ -154,6 +171,10 @@ function applyEditsText(engine, scope, sessionId, edits, agent) {
|
|
|
154
171
|
: { scope });
|
|
155
172
|
const applied = result.appliedEdits.filter((e) => e.applied);
|
|
156
173
|
const failed = result.appliedEdits.filter((e) => !e.applied);
|
|
174
|
+
// Gap C4: emit structured evolve_complete event for third-party consumers.
|
|
175
|
+
if (applied.length > 0 && sessionId) {
|
|
176
|
+
emitEvolveComplete(engine.baseDir, buildEvolveCompleteEvent(result, "manual_tool", sessionId));
|
|
177
|
+
}
|
|
157
178
|
const lines = [`refinement ${result.id}: ${applied.length} applied, ${failed.length} failed`];
|
|
158
179
|
for (const e of applied) {
|
|
159
180
|
lines.push(`- ${e.action} ${e.kind}:${e.id} (v${(e.after?.version ?? e.before?.version) ?? "?"})`);
|
package/lib/types.d.ts
CHANGED
|
@@ -124,6 +124,14 @@ export interface RefinementEdit {
|
|
|
124
124
|
skill_kind?: SkillKind;
|
|
125
125
|
metadata?: Record<string, unknown>;
|
|
126
126
|
reason?: string;
|
|
127
|
+
/**
|
|
128
|
+
* Gap C2: blast-radius annotation — how broadly this edit applies.
|
|
129
|
+
* Values: "general" (cross-project tactical), "project" (single project),
|
|
130
|
+
* "session" (one-off session-specific). The review gate checks that
|
|
131
|
+
* local-scope edits are "session" or "project" and global-scope edits
|
|
132
|
+
* are "general" or "project".
|
|
133
|
+
*/
|
|
134
|
+
blastRadius?: "general" | "project" | "session";
|
|
127
135
|
}
|
|
128
136
|
/** The structured output of a planning pass. */
|
|
129
137
|
export interface RefinementProposal {
|
package/lib/usage.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { HarnessState, RefinementKind } from "./types.js";
|
|
2
|
+
export interface UsageStore {
|
|
3
|
+
/** Injection count per entry key (`kind:id`). */
|
|
4
|
+
counts: Record<string, number>;
|
|
5
|
+
}
|
|
6
|
+
/** Load the usage store from disk; returns an empty store when absent or corrupt. */
|
|
7
|
+
export declare function loadUsage(baseDir: string): UsageStore;
|
|
8
|
+
/** Persist the usage store atomically. */
|
|
9
|
+
export declare function saveUsage(baseDir: string, store: UsageStore): void;
|
|
10
|
+
/** Build the usage key for an entry. */
|
|
11
|
+
export declare function usageKey(kind: RefinementKind, id: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Increment injection counts for the entries that were actually injected.
|
|
14
|
+
* Called after `entriesSectionText` renders the injected block. Keys not
|
|
15
|
+
* present in the store are initialized to 1; existing keys are incremented.
|
|
16
|
+
*/
|
|
17
|
+
export declare function recordInjection(baseDir: string, injectedKeys: string[]): void;
|
|
18
|
+
/**
|
|
19
|
+
* Get the injection count for a specific entry. Returns 0 when the entry
|
|
20
|
+
* has never been injected (absent from the store).
|
|
21
|
+
*/
|
|
22
|
+
export declare function getUsageCount(store: UsageStore, kind: RefinementKind, id: string): number;
|
|
23
|
+
/**
|
|
24
|
+
* Find entries with zero injection usage. Returns `{kind, id, title}` for
|
|
25
|
+
* each entry that has never been injected — prime candidates for archival.
|
|
26
|
+
*/
|
|
27
|
+
export declare function zeroUsageEntries(state: HarnessState, store: UsageStore): {
|
|
28
|
+
kind: RefinementKind;
|
|
29
|
+
id: string;
|
|
30
|
+
title: string;
|
|
31
|
+
}[];
|
|
32
|
+
//# sourceMappingURL=usage.d.ts.map
|
package/lib/usage.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry usage tracking (gap B1): records how many times each entry has been
|
|
3
|
+
* injected into system prompts. The counts are durable (persisted to disk)
|
|
4
|
+
* and exposed in `evolve_list` and the gate's archive-candidate reporting,
|
|
5
|
+
* so "zero-usage stale entries" can be surfaced for cleanup.
|
|
6
|
+
*
|
|
7
|
+
* Storage: `<baseDir>/evolve/usage.json` — a flat JSON object mapping
|
|
8
|
+
* `kind:id` to an integer count. Reads are tolerant of missing/corrupt files;
|
|
9
|
+
* writes are atomic (tmp + rename).
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
const USAGE_FILE = "usage.json";
|
|
14
|
+
function usagePath(baseDir) {
|
|
15
|
+
return join(baseDir, "evolve", USAGE_FILE);
|
|
16
|
+
}
|
|
17
|
+
/** Load the usage store from disk; returns an empty store when absent or corrupt. */
|
|
18
|
+
export function loadUsage(baseDir) {
|
|
19
|
+
const path = usagePath(baseDir);
|
|
20
|
+
try {
|
|
21
|
+
if (!existsSync(path))
|
|
22
|
+
return { counts: {} };
|
|
23
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
24
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
25
|
+
return { counts: raw };
|
|
26
|
+
}
|
|
27
|
+
return { counts: {} };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { counts: {} };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Persist the usage store atomically. */
|
|
34
|
+
export function saveUsage(baseDir, store) {
|
|
35
|
+
const dir = join(baseDir, "evolve");
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
const path = usagePath(baseDir);
|
|
38
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
39
|
+
writeFileSync(tmp, `${JSON.stringify(store.counts, null, 2)}\n`, "utf8");
|
|
40
|
+
renameSync(tmp, path);
|
|
41
|
+
}
|
|
42
|
+
/** Build the usage key for an entry. */
|
|
43
|
+
export function usageKey(kind, id) {
|
|
44
|
+
return `${kind}:${id}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Increment injection counts for the entries that were actually injected.
|
|
48
|
+
* Called after `entriesSectionText` renders the injected block. Keys not
|
|
49
|
+
* present in the store are initialized to 1; existing keys are incremented.
|
|
50
|
+
*/
|
|
51
|
+
export function recordInjection(baseDir, injectedKeys) {
|
|
52
|
+
if (injectedKeys.length === 0)
|
|
53
|
+
return;
|
|
54
|
+
const store = loadUsage(baseDir);
|
|
55
|
+
for (const key of injectedKeys) {
|
|
56
|
+
store.counts[key] = (store.counts[key] ?? 0) + 1;
|
|
57
|
+
}
|
|
58
|
+
saveUsage(baseDir, store);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get the injection count for a specific entry. Returns 0 when the entry
|
|
62
|
+
* has never been injected (absent from the store).
|
|
63
|
+
*/
|
|
64
|
+
export function getUsageCount(store, kind, id) {
|
|
65
|
+
return store.counts[usageKey(kind, id)] ?? 0;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Find entries with zero injection usage. Returns `{kind, id, title}` for
|
|
69
|
+
* each entry that has never been injected — prime candidates for archival.
|
|
70
|
+
*/
|
|
71
|
+
export function zeroUsageEntries(state, store) {
|
|
72
|
+
const results = [];
|
|
73
|
+
for (const kind of Object.keys(state.entries)) {
|
|
74
|
+
for (const entry of Object.values(state.entries[kind])) {
|
|
75
|
+
if (entry.scope !== "local")
|
|
76
|
+
continue;
|
|
77
|
+
if (getUsageCount(store, kind, entry.id) === 0) {
|
|
78
|
+
results.push({ kind, id: entry.id, title: entry.title });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return results;
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=usage.js.map
|
package/lib/validate.d.ts
CHANGED
|
@@ -4,8 +4,18 @@
|
|
|
4
4
|
* the base system prompt, required fields per action, and the executable
|
|
5
5
|
* contract skill entries must carry.
|
|
6
6
|
*/
|
|
7
|
-
import type { RefinementEdit } from "./types.js";
|
|
7
|
+
import type { HarnessScope, RefinementEdit } from "./types.js";
|
|
8
8
|
export declare const BASE_SYSTEM_PROMPT_ID = "base_system_prompt";
|
|
9
|
+
/**
|
|
10
|
+
* Gap C2: mechanical check that an edit's declared blast radius is coherent
|
|
11
|
+
* with the scope it targets. A session-scoped edit claiming "general" would
|
|
12
|
+
* silently read like a cross-project tactical rule; a global edit claiming
|
|
13
|
+
* "session" would contradict its persistence. Absent blastRadius is NOT
|
|
14
|
+
* rejected (pre-C2 data and manual edits stay compatible) — the planner is
|
|
15
|
+
* instructed to always declare it, and this rule catches what it declares
|
|
16
|
+
* incoherently.
|
|
17
|
+
*/
|
|
18
|
+
export declare function validateBlastRadiusScope(scope: HarnessScope, blastRadius: "general" | "project" | "session"): string | undefined;
|
|
9
19
|
/** Returns a human-readable failure reason, or undefined when the edit passes. */
|
|
10
|
-
export declare function validateEdit(edit: RefinementEdit, computedId: string | undefined): string | undefined;
|
|
20
|
+
export declare function validateEdit(edit: RefinementEdit, computedId: string | undefined, scope?: HarnessScope): string | undefined;
|
|
11
21
|
//# sourceMappingURL=validate.d.ts.map
|
package/lib/validate.js
CHANGED
|
@@ -2,8 +2,26 @@ import { validateSkillEntryContent } from "./skillquality.js";
|
|
|
2
2
|
const ACTIONS = new Set(["create", "update", "delete", "archive"]);
|
|
3
3
|
const KINDS = new Set(["prompt", "memory", "skill", "subagent"]);
|
|
4
4
|
export const BASE_SYSTEM_PROMPT_ID = "base_system_prompt";
|
|
5
|
+
/**
|
|
6
|
+
* Gap C2: mechanical check that an edit's declared blast radius is coherent
|
|
7
|
+
* with the scope it targets. A session-scoped edit claiming "general" would
|
|
8
|
+
* silently read like a cross-project tactical rule; a global edit claiming
|
|
9
|
+
* "session" would contradict its persistence. Absent blastRadius is NOT
|
|
10
|
+
* rejected (pre-C2 data and manual edits stay compatible) — the planner is
|
|
11
|
+
* instructed to always declare it, and this rule catches what it declares
|
|
12
|
+
* incoherently.
|
|
13
|
+
*/
|
|
14
|
+
export function validateBlastRadiusScope(scope, blastRadius) {
|
|
15
|
+
if (scope === "local" && blastRadius === "general") {
|
|
16
|
+
return "local-scope edit must declare blastRadius \"session\" or \"project\" (\"general\" would claim a cross-project rule)";
|
|
17
|
+
}
|
|
18
|
+
if (scope === "global" && blastRadius === "session") {
|
|
19
|
+
return "global-scope edit must declare blastRadius \"general\" or \"project\" (\"session\" contradicts cross-session persistence)";
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
5
23
|
/** Returns a human-readable failure reason, or undefined when the edit passes. */
|
|
6
|
-
export function validateEdit(edit, computedId) {
|
|
24
|
+
export function validateEdit(edit, computedId, scope) {
|
|
7
25
|
if (!ACTIONS.has(edit.action)) {
|
|
8
26
|
return `unsupported action ${String(edit.action)}`;
|
|
9
27
|
}
|
|
@@ -16,6 +34,13 @@ export function validateEdit(edit, computedId) {
|
|
|
16
34
|
if (edit.action !== "create" && !edit.id) {
|
|
17
35
|
return `${edit.action} requires id`;
|
|
18
36
|
}
|
|
37
|
+
// Gap C2: blast-radius/scope coherence is a mechanical property of the
|
|
38
|
+
// edit payload itself — checked for every action, not only create/update.
|
|
39
|
+
if (scope && edit.blastRadius !== undefined) {
|
|
40
|
+
const blastError = validateBlastRadiusScope(scope, edit.blastRadius);
|
|
41
|
+
if (blastError)
|
|
42
|
+
return blastError;
|
|
43
|
+
}
|
|
19
44
|
// Archive only names an existing entry: no title/content payload, and the
|
|
20
45
|
// base system prompt stays immutable under every action.
|
|
21
46
|
if (edit.action === "archive") {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/evolve wrapup` subcommand handler. Extracted from command.ts (P2-2).
|
|
3
|
+
*/
|
|
4
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
5
|
+
import type { CommandInvocation, CommandResult } from "@deepseek-ai/dsh-commands";
|
|
6
|
+
import type { EvolutionEngine } from "./service.js";
|
|
7
|
+
export declare function executeWrapupCommand(ctx: Context, engine: EvolutionEngine, invocation: CommandInvocation): Promise<CommandResult>;
|
|
8
|
+
//# sourceMappingURL=wrapup-command.d.ts.map
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { questionServiceOf, requireGlobalApproval } from "./approval.js";
|
|
2
|
+
import { assessLocalEntries, candidateKey, filterPromotable, listLocalCandidates, splitArchiveGuards, splitPromoteBlocked, splitPromoteProposals, wholePromoteProposals } from "./wrapup.js";
|
|
3
|
+
function success(text) {
|
|
4
|
+
return { kind: "success", text };
|
|
5
|
+
}
|
|
6
|
+
export async function executeWrapupCommand(ctx, engine, invocation) {
|
|
7
|
+
const sessionId = invocation.agent.id;
|
|
8
|
+
const localState = engine.load("local", sessionId);
|
|
9
|
+
const globalState = engine.load("global", undefined);
|
|
10
|
+
const candidates = listLocalCandidates(localState, globalState, engine.baseDir);
|
|
11
|
+
if (candidates.length === 0) {
|
|
12
|
+
return success(`(nothing to wrap up: ${sessionId}'s local store has no active, un-promoted entries — use /evolve list to inspect it)`);
|
|
13
|
+
}
|
|
14
|
+
// 1. Classify: the model judges each audited candidate's fate.
|
|
15
|
+
const assessment = await assessLocalEntries(ctx, invocation.agent, candidates, { signal: invocation.signal });
|
|
16
|
+
const byKey = new Map(candidates.map((candidate) => [candidateKey(candidate.kind, candidate.id), candidate]));
|
|
17
|
+
// 2. Partition by action. Deterministic guards re-check the LIVE global
|
|
18
|
+
// store right before anything lands (state may have changed mid-call).
|
|
19
|
+
const { promotable, skipped } = filterPromotable(assessment.items, globalState, candidates);
|
|
20
|
+
const promoteItems = promotable.filter((item) => item.verdict === "promote");
|
|
21
|
+
const archiveItems = assessment.items.filter((item) => item.verdict === "archive");
|
|
22
|
+
// Split promotion (A-form): archive a mixed entry but promote ONLY the
|
|
23
|
+
// cleaned durable part the model extracted. Guarded the same way as whole
|
|
24
|
+
// promotes — a split that would duplicate a globally covered topic is
|
|
25
|
+
// dropped and the entry archives plain.
|
|
26
|
+
const splitItems = [];
|
|
27
|
+
const splitSkipped = [];
|
|
28
|
+
for (const item of archiveItems) {
|
|
29
|
+
if (!item.promote)
|
|
30
|
+
continue;
|
|
31
|
+
const candidate = byKey.get(item.key);
|
|
32
|
+
if (!candidate) {
|
|
33
|
+
splitSkipped.push({ key: item.key, reason: "not in the audited candidate list" });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const blocked = splitPromoteBlocked(item, globalState, candidate.kind);
|
|
37
|
+
if (blocked) {
|
|
38
|
+
splitSkipped.push({ key: item.key, reason: blocked });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
splitItems.push({ item, candidate });
|
|
42
|
+
}
|
|
43
|
+
// Plain archives (no split payload): the symmetric guard — an archive that
|
|
44
|
+
// is NOT globally covered AND was distilled from real user messages must
|
|
45
|
+
// not proceed silently.
|
|
46
|
+
const plainArchives = archiveItems.filter((item) => !item.promote);
|
|
47
|
+
const { silent: silentArchives, review: reviewArchives } = splitArchiveGuards(plainArchives, candidates);
|
|
48
|
+
const keepItems = assessment.items.filter((item) => item.verdict === "keep");
|
|
49
|
+
// 3. Report the assessment before touching anything.
|
|
50
|
+
const lines = [
|
|
51
|
+
`wrapup assessment (${sessionId}): ${candidates.length} candidates${candidates.some((c) => c.coveredGlobally) ? `, ${candidates.filter((c) => c.coveredGlobally).length} covered globally` : ""}`,
|
|
52
|
+
`${assessment.rationale}`,
|
|
53
|
+
];
|
|
54
|
+
for (const [heading, items] of [
|
|
55
|
+
["PROMOTE (to global)", promoteItems],
|
|
56
|
+
["SPLIT (archive + promote durable part)", splitItems.map((split) => split.item)],
|
|
57
|
+
["ARCHIVE", silentArchives],
|
|
58
|
+
["ARCHIVE (needs review)", reviewArchives],
|
|
59
|
+
["KEEP", keepItems],
|
|
60
|
+
]) {
|
|
61
|
+
lines.push(`${heading}: ${items.length}`);
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
const candidate = byKey.get(item.key);
|
|
64
|
+
const title = candidate ? candidate.title : item.key;
|
|
65
|
+
const splitNote = item.promote ? ` → 拆出提升「${item.promote.title}」` : "";
|
|
66
|
+
lines.push(`- ${item.key} "${title}"${splitNote} — ${item.reason}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const skip of skipped) {
|
|
70
|
+
lines.push(`- promote skipped: ${skip.key} — ${skip.reason}`);
|
|
71
|
+
}
|
|
72
|
+
for (const skip of splitSkipped) {
|
|
73
|
+
lines.push(`- split skipped: ${skip.key} — ${skip.reason}`);
|
|
74
|
+
}
|
|
75
|
+
lines.push("");
|
|
76
|
+
const applied = [];
|
|
77
|
+
// 4. Global writes: governed resource — ONE human approval gate covers
|
|
78
|
+
// every create (whole promotes AND split promotions). On approval:
|
|
79
|
+
// - whole promote → create global copy + stamp local promotedTo+archivedAt;
|
|
80
|
+
// - split → create the cleaned durable part + archive the original with
|
|
81
|
+
// promotedTo. On rejection: whole promotes are not written, and each
|
|
82
|
+
// split's original STILL archives plain (its snapshot half deserves
|
|
83
|
+
// the archive; the durable half is reported for manual handling).
|
|
84
|
+
const wholeCreates = promoteItems.map((item) => ({ item, candidate: byKey.get(item.key) }));
|
|
85
|
+
const splitCreates = splitItems;
|
|
86
|
+
const allCreates = new Set([...wholeCreates.map((c) => c.item.key), ...splitCreates.map((c) => c.item.key)]);
|
|
87
|
+
if (allCreates.size > 0) {
|
|
88
|
+
const what = `wrapup 将写入跨会话 global store(共 ${allCreates.size} 条:${promoteItems.length} 条整条提升 + ${splitItems.length} 条拆解提升):\n${[
|
|
89
|
+
...promoteItems.map((item) => `- 整条提升 ${item.key} "${byKey.get(item.key)?.title ?? item.key}"`),
|
|
90
|
+
...splitItems.map((split) => `- 拆解提升 ${split.item.key} → 清洗「${split.item.promote?.title}」(原条目随之归档)`),
|
|
91
|
+
].join("\n")}`;
|
|
92
|
+
let promoteAllowed = true;
|
|
93
|
+
try {
|
|
94
|
+
await requireGlobalApproval(ctx, invocation.agent, invocation.signal, what);
|
|
95
|
+
}
|
|
96
|
+
catch (cause) {
|
|
97
|
+
promoteAllowed = false;
|
|
98
|
+
const message = `global 写入未批准 — 整条提升与拆解提升均未写入 (${cause instanceof Error ? cause.message : String(cause)})`;
|
|
99
|
+
applied.push(message);
|
|
100
|
+
lines.push(message);
|
|
101
|
+
}
|
|
102
|
+
if (promoteAllowed) {
|
|
103
|
+
// Whole promotes: create global entry, retire the local copy.
|
|
104
|
+
// Shared proposal builders keep the wrap-up command and the gate's
|
|
105
|
+
// local-fate dimension writing IDENTICAL edits.
|
|
106
|
+
for (const { item, candidate } of wholeCreates) {
|
|
107
|
+
if (!candidate)
|
|
108
|
+
continue;
|
|
109
|
+
const proposals = wholePromoteProposals(item, candidate, sessionId);
|
|
110
|
+
const globalResult = engine.apply("global", undefined, proposals.global, { scope: "global" });
|
|
111
|
+
const createdId = globalResult.appliedEdits.find((edit) => edit.applied)?.id ?? candidate.id;
|
|
112
|
+
const localResult = engine.apply("local", sessionId, proposals.localStamp(createdId), {
|
|
113
|
+
scope: "local",
|
|
114
|
+
baselineState: localState,
|
|
115
|
+
});
|
|
116
|
+
applied.push(`promoted ${item.key} → global:${createdId} (${globalResult.id}; local stamped ${localResult.id})`);
|
|
117
|
+
}
|
|
118
|
+
// Split promotions: create the cleaned durable part, retire the
|
|
119
|
+
// original local entry (its snapshot half is archived along).
|
|
120
|
+
for (const { item, candidate } of splitCreates) {
|
|
121
|
+
if (!item.promote)
|
|
122
|
+
continue;
|
|
123
|
+
const proposals = splitPromoteProposals(item, candidate, sessionId);
|
|
124
|
+
const globalResult = engine.apply("global", undefined, proposals.global, { scope: "global" });
|
|
125
|
+
const createdId = globalResult.appliedEdits.find((edit) => edit.applied)?.id ?? candidate.id;
|
|
126
|
+
const localResult = engine.apply("local", sessionId, proposals.localStamp(createdId), {
|
|
127
|
+
scope: "local",
|
|
128
|
+
baselineState: localState,
|
|
129
|
+
});
|
|
130
|
+
applied.push(`split ${item.key}: promoted cleaned part → global:${createdId} (${globalResult.id}); original archived (${localResult.id})`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// Rejected: whole promotes stay un-written; each split's original
|
|
135
|
+
// still archives plain (reported, data restorable).
|
|
136
|
+
for (const { item, candidate } of splitCreates) {
|
|
137
|
+
if (!candidate)
|
|
138
|
+
continue;
|
|
139
|
+
const result = engine.apply("local", sessionId, {
|
|
140
|
+
summary: `wrapup: split promotion not approved — archive original ${item.key} plain`,
|
|
141
|
+
rationale: item.reason,
|
|
142
|
+
expectedOutcome: `The original leaves injection; the cleaned part was NOT written (reported for manual handling).`,
|
|
143
|
+
edits: [{ action: "archive", kind: candidate.kind, id: candidate.id }],
|
|
144
|
+
}, { scope: "local", baselineState: localState });
|
|
145
|
+
applied.push(`split ${item.key}: promotion not approved — original archived plain (${result.id})`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// 5. Silent archives: deterministic local action (hidden from injection,
|
|
150
|
+
// data kept restorable) — covered topics and operational entries need no
|
|
151
|
+
// confirmation, matching the original behavior.
|
|
152
|
+
for (const item of silentArchives) {
|
|
153
|
+
const candidate = byKey.get(item.key);
|
|
154
|
+
if (!candidate)
|
|
155
|
+
continue;
|
|
156
|
+
const result = engine.apply("local", sessionId, {
|
|
157
|
+
summary: `wrapup: archive local ${item.key} — ${item.reason}`,
|
|
158
|
+
rationale: item.reason,
|
|
159
|
+
expectedOutcome: `The entry stops being injected but stays restorable.`,
|
|
160
|
+
edits: [{ action: "archive", kind: candidate.kind, id: candidate.id }],
|
|
161
|
+
}, { scope: "local", baselineState: localState });
|
|
162
|
+
applied.push(`archived ${item.key} (${result.id})`);
|
|
163
|
+
}
|
|
164
|
+
// 6. Review archives (symmetric guard): not covered globally + distilled
|
|
165
|
+
// from real user messages — the user decides before this content is
|
|
166
|
+
// hidden from future sessions. No question service → conservative keep.
|
|
167
|
+
const userQuestions = questionServiceOf(ctx);
|
|
168
|
+
for (const item of reviewArchives) {
|
|
169
|
+
const candidate = byKey.get(item.key);
|
|
170
|
+
if (!candidate)
|
|
171
|
+
continue;
|
|
172
|
+
if (!userQuestions) {
|
|
173
|
+
applied.push(`kept ${item.key} — archive pending user confirmation (no question service)`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const questionId = "evolve-wrapup-archive-review";
|
|
177
|
+
let archiveConfirmed = false;
|
|
178
|
+
try {
|
|
179
|
+
const answer = await userQuestions.ask({
|
|
180
|
+
questions: [
|
|
181
|
+
{
|
|
182
|
+
id: questionId,
|
|
183
|
+
question: `wrapup:条目「${candidate.title}」未被全局覆盖且源自真实对话,直接归档会隐藏它(数据保留、可恢复)。确认归档?`,
|
|
184
|
+
options: [{ label: "归档" }, { label: "保留" }],
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
agent: invocation.agent,
|
|
188
|
+
signal: invocation.signal,
|
|
189
|
+
});
|
|
190
|
+
archiveConfirmed = answer.answers?.find((entry) => entry.id === questionId)?.selected?.includes("归档") ?? false;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
archiveConfirmed = false;
|
|
194
|
+
}
|
|
195
|
+
if (archiveConfirmed) {
|
|
196
|
+
const result = engine.apply("local", sessionId, {
|
|
197
|
+
summary: `wrapup: archive local ${item.key} (user-confirmed) — ${item.reason}`,
|
|
198
|
+
rationale: item.reason,
|
|
199
|
+
expectedOutcome: `The entry stops being injected but stays restorable.`,
|
|
200
|
+
edits: [{ action: "archive", kind: candidate.kind, id: candidate.id }],
|
|
201
|
+
}, { scope: "local", baselineState: localState });
|
|
202
|
+
applied.push(`archived ${item.key} (user-confirmed, ${result.id})`);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
applied.push(`kept ${item.key} — user declined the archive`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
lines.push(...(applied.length > 0 ? applied : ["(no changes applied — all entries kept)"]));
|
|
209
|
+
return success(lines.join("\n"));
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=wrapup-command.js.map
|
package/lib/wrapup.d.ts
CHANGED
|
@@ -74,6 +74,18 @@ export interface WrapupCandidate {
|
|
|
74
74
|
* really covers the local content.
|
|
75
75
|
*/
|
|
76
76
|
globalHints: GlobalHint[];
|
|
77
|
+
/**
|
|
78
|
+
* Injection usage count (gap B1): how many times this entry was included
|
|
79
|
+
* in a system-prompt assembly. Zero means the entry was never used — a
|
|
80
|
+
* strong staleness signal the assessor can weigh.
|
|
81
|
+
*/
|
|
82
|
+
injectionCount: number;
|
|
83
|
+
/**
|
|
84
|
+
* Staleness flag (gap B2): true when the entry has both zero injection
|
|
85
|
+
* usage AND a recency score below the staleness threshold (old + unused).
|
|
86
|
+
* The assessor is instructed to prefer "archive" for stale entries.
|
|
87
|
+
*/
|
|
88
|
+
stale: boolean;
|
|
77
89
|
}
|
|
78
90
|
export declare function candidateKey(kind: RefinementKind, id: string): string;
|
|
79
91
|
/**
|
|
@@ -100,14 +112,7 @@ export declare function globalCoverageDetected(globalState: HarnessState, kind:
|
|
|
100
112
|
* bare boolean. Bounded: a handful of best matches, never the whole store.
|
|
101
113
|
*/
|
|
102
114
|
export declare function globalHintsFor(globalState: HarnessState, kind: RefinementKind, entry: Pick<HarnessEntry, "id" | "title">): GlobalHint[];
|
|
103
|
-
|
|
104
|
-
* The auditable local candidates of a session: every non-archived local
|
|
105
|
-
* entry that has not already been promoted (a promoted entry's lifecycle is
|
|
106
|
-
* finished — the global copy is the live one). Each carries its
|
|
107
|
-
* `coveredGlobally` flag so the assessor never wastes a promote on a topic
|
|
108
|
-
* the global store already owns.
|
|
109
|
-
*/
|
|
110
|
-
export declare function listLocalCandidates(state: HarnessState, globalState: HarnessState): WrapupCandidate[];
|
|
115
|
+
export declare function listLocalCandidates(state: HarnessState, globalState: HarnessState, baseDir?: string): WrapupCandidate[];
|
|
111
116
|
/**
|
|
112
117
|
* Parse and validate the model's assessment JSON. Defense is mechanical:
|
|
113
118
|
* keys outside the candidate list are dropped, verdicts outside the enum
|
|
@@ -194,7 +199,7 @@ export declare function splitPromoteProposals(item: WrapupItem, candidate: Wrapu
|
|
|
194
199
|
global: RefinementProposal;
|
|
195
200
|
localStamp: (createdId: string) => RefinementProposal;
|
|
196
201
|
};
|
|
197
|
-
export declare const WRAPUP_ASSESS_SYSTEM_PROMPT = "You are the /evolve session wrap-up assessor.\n\nA session is ending and its local harness entries need a fate. Classify each\nlisted entry exactly once:\n\n- \"promote\" \u2014 the content is a stable, durable, CROSS-SESSION reusable lesson:\n a durable user preference, a project-level fact or convention, a reusable\n procedure or skill. Future sessions would benefit from seeing it.\n- \"archive\" \u2014 the content is session-specific task progress, one-off noise,\n superseded or obsolete, or already covered by the global store (note\n \"covered globally\" in the reason).\n- \"keep\" \u2014 still actively useful to this session, or genuinely uncertain.\n\nRules:\n- When an entry is marked \"covered globally\" in the listing, prefer \"archive\"\n or \"keep\" over \"promote\" \u2014 promoting a duplicate gains nothing.\n- Do not promote local task state, work-in-progress notes, or content tied to\n one session's ephemeral details.\n- Skills: only \"promote\" a skill entry that is a genuinely reusable procedure\n meeting the DSH skill quality standard; one-off workflows are \"archive\" or\n \"keep\".\n- SPLIT PROMOTION: when an entry mixes a stable, cross-session-reusable part\n WITH session-specific snapshot details, do NOT promote it whole. Instead\n give verdict \"archive\" WITH a \"promote\" sub-object holding a CLEANED\n version of only the durable part (a stable title + the persistent facts,\n stripped of dates/states/one-off figures). Ephemeral snapshot content stays\n out of the sub-object \u2014 it is left behind in the archive. A sub-object is\n only meaningful on \"archive\" verdicts.\n\nReturn JSON only:\n{\n \"rationale\": \"one or two sentences\",\n \"items\": [\n {\"key\": \"memory:foo\", \"verdict\": \"promote|archive|keep\", \"reason\": \"why\"},\n {\"key\": \"memory:bar\", \"verdict\": \"archive\", \"reason\": \"why\",\n \"promote\": {\"title\": \"cleaned stable title\", \"content\": \"cleaned durable part only\"}}\n ]\n}\nOnly keys from the provided list are allowed; any entry you omit defaults to \"keep\".";
|
|
202
|
+
export declare const WRAPUP_ASSESS_SYSTEM_PROMPT = "You are the /evolve session wrap-up assessor.\n\nA session is ending and its local harness entries need a fate. Classify each\nlisted entry exactly once:\n\n- \"promote\" \u2014 the content is a stable, durable, CROSS-SESSION reusable lesson:\n a durable user preference, a project-level fact or convention, a reusable\n procedure or skill. Future sessions would benefit from seeing it.\n- \"archive\" \u2014 the content is session-specific task progress, one-off noise,\n superseded or obsolete, or already covered by the global store (note\n \"covered globally\" in the reason), or stale (old + never injected \u2014 note\n \"stale (injectionCount=0, recency low)\" in the reason).\n- \"keep\" \u2014 still actively useful to this session, or genuinely uncertain.\n\nRules:\n- When an entry is marked \"covered globally\" in the listing, prefer \"archive\"\n or \"keep\" over \"promote\" \u2014 promoting a duplicate gains nothing.\n- When an entry is marked \"stale\" (injectionCount=0 and low recency), prefer\n \"archive\" \u2014 the entry has never been used and is old, so it is unlikely to\n be needed again. Only \"keep\" if the content is clearly valuable despite low\n usage (e.g. a safety policy that rarely triggers but is critical).\n- Do not promote local task state, work-in-progress notes, or content tied to\n one session's ephemeral details.\n- Skills: only \"promote\" a skill entry that is a genuinely reusable procedure\n meeting the DSH skill quality standard; one-off workflows are \"archive\" or\n \"keep\".\n- SPLIT PROMOTION: when an entry mixes a stable, cross-session-reusable part\n WITH session-specific snapshot details, do NOT promote it whole. Instead\n give verdict \"archive\" WITH a \"promote\" sub-object holding a CLEANED\n version of only the durable part (a stable title + the persistent facts,\n stripped of dates/states/one-off figures). Ephemeral snapshot content stays\n out of the sub-object \u2014 it is left behind in the archive. A sub-object is\n only meaningful on \"archive\" verdicts.\n\nReturn JSON only:\n{\n \"rationale\": \"one or two sentences\",\n \"items\": [\n {\"key\": \"memory:foo\", \"verdict\": \"promote|archive|keep\", \"reason\": \"why\"},\n {\"key\": \"memory:bar\", \"verdict\": \"archive\", \"reason\": \"why\",\n \"promote\": {\"title\": \"cleaned stable title\", \"content\": \"cleaned durable part only\"}}\n ]\n}\nOnly keys from the provided list are allowed; any entry you omit defaults to \"keep\".";
|
|
198
203
|
export interface AssessOptions {
|
|
199
204
|
/** Output token budget for the assessment call. */
|
|
200
205
|
maxOutputTokens?: number;
|