dsh-continual-evolve 0.6.1 → 0.7.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/inject.js CHANGED
@@ -1,5 +1,6 @@
1
- import { isArchived, VALENCE_NEGATIVE_KEY } from "./types.js";
1
+ import { MEMORY_TYPE_KEY, isArchived, isMemoryType, VALENCE_NEGATIVE_KEY } from "./types.js";
2
2
  import { mergeHarnessStates } from "./state.js";
3
+ import { projectKeyOf } from "./project.js";
3
4
  import { entryLine } from "./render.js";
4
5
  import { recordInjection } from "./usage.js";
5
6
  import { buildRelevanceIndex, relevanceScore, tokenize } from "./search.js";
@@ -214,27 +215,81 @@ export function formatSubagentSpecsSection(entries, query) {
214
215
  }
215
216
  /**
216
217
  * Gap B3: a lightweight directory of ALL non-archived entries across all
217
- * kinds — one line per entry (`- [kind:id] title`), no content. This gives
218
- * the model a zero-cost overview of what exists so it can ask for full text
219
- * via `evolve_list` or `/evolve list`. The directory is appended after the
220
- * curated top-N injection sections and adds minimal tokens.
218
+ * kinds — one line per entry, no content. Memory lines carry their recall
219
+ * type (`- [memory:feedback:id] title`) so the model can judge relevance
220
+ * from the index alone. This gives the model a zero-cost overview of what
221
+ * exists so it can ask for full text via `evolve_list` or `/evolve list`.
222
+ * The directory is appended after the curated top-N injection sections and
223
+ * adds minimal tokens.
221
224
  *
222
225
  * 2026-08-22 throttle: the directory is CAPPED at {@link DEFAULT_DIRECTORY_LINES}
223
- * lines (oldest-sorted stable order) with the remainder folded into a single
224
- * counter line — an uncapped directory across a polluted global store was
225
- * measured at ~2K chars of every build in every project.
226
+ * lines with the remainder folded into a single counter line — an uncapped
227
+ * directory across a polluted global store was measured at ~2K chars of
228
+ * every build in every project.
229
+ *
230
+ * 2026-09-23 relevance order: the directory is sorted by
231
+ * {@link rankEntries} against the assembly's relevance query, so the lines
232
+ * folded below the cap are the least relevant — not the dictionary tail.
226
233
  */
227
234
  export const DEFAULT_DIRECTORY_LINES = 15;
228
- /** How many of the variadic arrays are content-section kinds (prompt, subagent). */
235
+ /** One directory index line: memory lines carry their recall-type hook. */
236
+ export function directoryLine(entry) {
237
+ if (entry.kind === "memory") {
238
+ const type = entry.metadata[MEMORY_TYPE_KEY];
239
+ const hook = isMemoryType(type) ? `:${type}` : "";
240
+ return `- [memory${hook}:${entry.id}] ${entry.title}`;
241
+ }
242
+ return `- [${entry.kind}:${entry.id}] ${entry.title}`;
243
+ }
244
+ /** Positional contract for the directory variadics: the first two arrays
245
+ * must be the content-section kinds (prompt, subagent) — the redundancy
246
+ * check counts exactly those as already visible. Callers pass
247
+ * (prompt, subagent, memory, skill); any other order miscounts. */
229
248
  const CONTENT_SECTION_KINDS = 2;
230
249
  export function formatEntriesDirectory(...kindEntries) {
231
250
  return formatEntriesDirectoryCapped(DEFAULT_DIRECTORY_LINES, ...kindEntries);
232
251
  }
233
252
  /** {@link formatEntriesDirectory} with an explicit cap (configurable). */
234
253
  export function formatEntriesDirectoryCapped(maxLines, ...kindEntries) {
254
+ return formatEntriesDirectoryRanked(maxLines, undefined, ...kindEntries);
255
+ }
256
+ /**
257
+ * {@link formatEntriesDirectoryCapped} with a relevance query: entries are
258
+ * ordered by {@link rankEntries} (relevance first, then recency) so the cap
259
+ * folds the least relevant entries — never the dictionary tail. An empty
260
+ * query degrades to the valence/recency/stable order, still deterministic.
261
+ */
262
+ export function formatEntriesDirectoryRanked(maxLines, query, ...kindEntries) {
263
+ const total = kindEntries.flat().filter((e) => !isArchived(e)).length;
264
+ if (total === 0) {
265
+ return "";
266
+ }
267
+ // Suppressed (every entry already content-visible) renders as "" — the
268
+ // prompt renderer then drops the section entirely.
269
+ const shown = rankedDirectoryEntries(maxLines, query, ...kindEntries);
270
+ if (shown.length === 0) {
271
+ return "";
272
+ }
273
+ const lines = ["# Continual Harness — Entry Directory", "All entries (use evolve_list for full text of any entry):"];
274
+ for (const entry of shown) {
275
+ lines.push(directoryLine(entry));
276
+ }
277
+ const hidden = total - shown.length;
278
+ if (hidden > 0) {
279
+ lines.push(`- …and ${hidden} more entries (evolve_list for the full index)`);
280
+ }
281
+ return lines.join("\n");
282
+ }
283
+ /**
284
+ * The directory entries actually shown under the cap, in display order —
285
+ * the single source for both the rendered text and usage accounting (so a
286
+ * memory's injection IS its directory line even with the `:type` hook).
287
+ * Returns [] when the directory is suppressed or empty.
288
+ */
289
+ export function rankedDirectoryEntries(maxLines, query, ...kindEntries) {
235
290
  const allEntries = kindEntries.flat().filter((e) => !isArchived(e));
236
291
  if (allEntries.length === 0) {
237
- return "";
292
+ return [];
238
293
  }
239
294
  // Skip the directory only when EVERY entry is already content-visible.
240
295
  // Only the first two arrays (prompt, subagent) have curated sections —
@@ -245,19 +300,9 @@ export function formatEntriesDirectoryCapped(maxLines, ...kindEntries) {
245
300
  .slice(0, CONTENT_SECTION_KINDS)
246
301
  .reduce((sum, entries) => sum + Math.min(entries.filter((e) => !isArchived(e)).length, MAX_INJECTED_ENTRIES_PER_KIND), 0);
247
302
  if (allEntries.length <= contentVisible) {
248
- return "";
249
- }
250
- const sorted = [...allEntries].sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));
251
- const lines = ["# Continual Harness — Entry Directory", "All entries (use evolve_list for full text of any entry):"];
252
- const shown = sorted.slice(0, Math.max(maxLines, 1));
253
- for (const entry of shown) {
254
- lines.push(`- [${entry.kind}:${entry.id}] ${entry.title}`);
255
- }
256
- const hidden = sorted.length - shown.length;
257
- if (hidden > 0) {
258
- lines.push(`- …and ${hidden} more entries (evolve_list for the full index)`);
303
+ return [];
259
304
  }
260
- return lines.join("\n");
305
+ return rankEntries(allEntries, query).slice(0, Math.max(maxLines, 1));
261
306
  }
262
307
  /**
263
308
  * Walk the parent-session chain from `agent` upward and return the nearest
@@ -281,14 +326,17 @@ export function nearestLocalStateWithEntries(engine, agent) {
281
326
  }
282
327
  /**
283
328
  * Compose the full injected block for one assembling agent: global entries
284
- * merged with the nearest carrying local store (local wins on id collision).
329
+ * merged with this project's store (when the session cwd resolves one) and
330
+ * the nearest carrying local store (precedence global < project < local).
285
331
  * The optional `query` — when absent, derived from the agent's most recent
286
332
  * direct user messages — ranks which entries fill the per-kind cap
287
333
  * (relevance first, then recency; see {@link rankEntries}). Returns "" when
288
334
  * nothing is injectable — the prompt renderer then drops the section, so an
289
335
  * empty store adds zero tokens to every assembly.
290
336
  *
291
- * `opts.directoryLines` caps the entry-directory index (2026-08-22 throttle).
337
+ * `opts.directoryLines` caps the entry-directory index (2026-08-22 throttle);
338
+ * `opts.projectKey` pins the project layer explicitly (tests, tools) —
339
+ * otherwise it is derived from the agent's session cwd, best-effort.
292
340
  * Usage recording covers ALL kinds — memories and skills appear as directory
293
341
  * lines, prompts/subagents as content — and is deduped per session so the
294
342
  * counts read "how many sessions saw this", not "how many prompt builds".
@@ -298,8 +346,10 @@ export function entriesSectionText(engine, agent, query, opts) {
298
346
  return "";
299
347
  }
300
348
  const globalState = engine.load("global", undefined);
349
+ const projectKey = opts?.projectKey ?? projectKeyOf(agent);
350
+ const projectState = projectKey ? engine.load("project", projectKey) : undefined;
301
351
  const localState = nearestLocalStateWithEntries(engine, agent);
302
- const merged = localState ? mergeHarnessStates(globalState, localState) : globalState;
352
+ const merged = mergeHarnessStates(globalState, localState, projectState ? { projectState } : undefined);
303
353
  const promptEntries = Object.values(merged.entries.prompt);
304
354
  const subagentEntries = Object.values(merged.entries.subagent);
305
355
  const relevanceQuery = (query ?? recentUserText(agent)).trim();
@@ -318,18 +368,18 @@ export function entriesSectionText(engine, agent, query, opts) {
318
368
  }
319
369
  // Gap B3: lightweight directory of ALL entries (id+title, one line each).
320
370
  // Zero-cost index so the model knows what exists and can ask for full text.
321
- const directoryText = formatEntriesDirectoryCapped(opts?.directoryLines ?? DEFAULT_DIRECTORY_LINES, Object.values(merged.entries.prompt), Object.values(merged.entries.memory), Object.values(merged.entries.skill), Object.values(merged.entries.subagent));
371
+ const directoryLines = opts?.directoryLines ?? DEFAULT_DIRECTORY_LINES;
372
+ const promptKind = Object.values(merged.entries.prompt);
373
+ const subagentKind = Object.values(merged.entries.subagent);
374
+ const memoryKind = Object.values(merged.entries.memory);
375
+ const skillKind = Object.values(merged.entries.skill);
376
+ const directoryText = formatEntriesDirectoryRanked(directoryLines, relevanceQuery, promptKind, subagentKind, memoryKind, skillKind);
322
377
  // Directory-visible keys count too: a memory's injection IS its directory
323
- // line. Set semantics keep content-injected entries single-counted.
324
- for (const kind of ["prompt", "memory", "skill", "subagent"]) {
325
- for (const entry of Object.values(merged.entries[kind])) {
326
- if (isArchived(entry))
327
- continue;
328
- const key = `${kind}:${entry.id}`;
329
- if (injectedKeys.has(key) || directoryText.includes(`[${key}]`)) {
330
- injectedKeys.add(key);
331
- }
332
- }
378
+ // line. Set semantics keep content-injected entries single-counted. Keys
379
+ // come from the single display-ordered source (type hooks included), not
380
+ // substring matching on the rendered text.
381
+ for (const entry of rankedDirectoryEntries(directoryLines, relevanceQuery, promptKind, subagentKind, memoryKind, skillKind)) {
382
+ injectedKeys.add(`${entry.kind}:${entry.id}`);
333
383
  }
334
384
  // Record usage durably (best-effort: failure never blocks injection).
335
385
  // Deduped per session — see recordInjection.
package/lib/planner.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import type { Context } from "@deepseek-ai/cordis";
10
10
  import type { Agent } from "@deepseek-ai/dsh-agent";
11
11
  import type { HarnessState, RefinementProposal, RefinementResult } from "./types.js";
12
- export declare const PLANNER_SYSTEM_PROMPT = "You are the /evolve continual harness subsystem.\n\nYour job is to improve the editable continual harness state. Instead of\nsummarizing the conversation you emit precise Create, Update, or Delete edits\nto reusable state: prompt notes, memories, skills, and subagent specs.\n\nRules:\n- The base system prompt is immutable and MUST NOT be rewritten (never edit id \"base_system_prompt\").\n- Prefer small evidence-backed edits. If no useful edit is justified, return an empty edits array.\n- prompt = narrow behavioral policy addendums; memory = durable facts/preferences/failures;\n skill = repeatable procedures (must carry a python reference {type:\"python\", import, callable}\n and an arguments object); subagent = reusable delegation roles.\n- Skill entries are authored to the DSH skill quality standard\n (skill-creator, distilled from the official deepseek-harness 11 skills;\n the full facts are provided in the <skill_quality_standard> block below):\n only for a REAL trigger scenario grounded in the trajectory\n (who, in what real task, what signal) \u2014 never invent one to pad the store;\n never duplicate the official 11 skills or existing entries; content is a\n SKILL.md document (frontmatter routing with \"use when / do not use when\"\n description, boundary declaration, prerequisites and exclusions, layered\n information, verifiable completion criteria). Self-check every proposed\n skill against the 7 structural features and state the result in its\n reason field.\n- Repeated multi-step workflows (session start/end routines, recurring\n wrap-up or handoff procedures) may be proposed as guidance skills:\n kind=skill, skill_kind=\"guidance\", content = a SKILL.md document (no\n python reference \u2014 executable skills keep requiring reference +\n arguments). Only propose with repeated evidence in the trajectory, never\n for one-off flows. Guidance skills materialize as discoverable SKILL.md\n files under <skillsRoot>/<kebab-name>/SKILL.md and are always offered to\n the user for a decision before they land.\n- Local edits are session-scoped; global edits persist across sessions.\n- Gap C2: EVERY edit MUST include a \"blastRadius\" field indicating how broadly\n the edit applies: \"general\" (cross-project tactical rule, applies everywhere),\n \"project\" (valid for the current project/repo), or \"session\" (one-off,\n specific to this session's context). The review gate validates that\n local-scope edits use \"session\" or \"project\", and global-scope edits use\n \"general\" or \"project\". When in doubt, prefer narrower blast radius.\n- Ground every edit in evidence: the session trajectory (recent direct user\n messages) is provided when available; prefer edits backed by it over\n speculation, and never invent preferences the user did not express.\n- Stale entries (superseded by newer ones, never referenced in recent\n trajectories, obsolete facts): propose action \"archive\" instead of\n \"delete\" \u2014 archive hides the entry from injection while keeping its data\n restorable; it requires only kind + id.\n- Output JSON only, exactly this shape:\n{\n \"summary\": \"one sentence\",\n \"rationale\": \"why these edits are justified by the evidence\",\n \"expectedOutcome\": \"what should improve and how to validate it\",\n \"edits\": [\n {\n \"action\": \"create|update|delete\",\n \"kind\": \"prompt|memory|skill|subagent\",\n \"id\": \"stable id for update/delete, optional for create\",\n \"title\": \"required for create/update except delete\",\n \"content\": \"required for create/update except delete\",\n \"path\": \"optional grouping path\",\n \"reference\": {\"type\":\"python\",\"import\":\"pkg.mod\",\"callable\":\"fn\"} ,\n \"arguments\": {\"name\": {\"type\":\"string\",\"required\":true,\"description\":\"...\"}},\n \"skill_kind\": \"executable|guidance (optional; skill kind only \u2014 guidance = SKILL.md document without reference)\",\n \"metadata\": {},\n \"reason\": \"why this edit is useful\",\n \"blastRadius\": \"general|project|session\"\n }\n ]\n}";
12
+ export declare const PLANNER_SYSTEM_PROMPT = "You are the /evolve continual harness subsystem.\n\nYour job is to improve the editable continual harness state. Instead of\nsummarizing the conversation you emit precise Create, Update, or Delete edits\nto reusable state: prompt notes, memories, skills, and subagent specs.\n\nRules:\n- The base system prompt is immutable and MUST NOT be rewritten (never edit id \"base_system_prompt\").\n- Prefer small evidence-backed edits. If no useful edit is justified, return an empty edits array.\n- prompt = narrow behavioral policy addendums; memory = ONE FACT per entry, typed via metadata.memoryType\n- (user = who the user is; feedback = a pitfall/correction or confirmed approach, MUST carry Why + How to apply;\n- project = work goal/constraint not derivable from the repo; reference = URL/dashboard/ticket pointer).\n- Memory rules: one fact per entry (title = one-line retrieval hook, content = the fact alone);\n- never save what the repo already records (code structure, past fixes, git history, AGENTS.md) or what only\n- matters to this conversation \u2014 save the non-obvious point instead; before creating, update the existing\n- entry when one already covers the fact (creates carry metadata {memoryType} or they are rejected);\n- skill = repeatable procedures (must carry a python reference {type:\"python\", import, callable}\n and an arguments object); subagent = reusable delegation roles.\n- Skill entries are authored to the DSH skill quality standard\n (skill-creator, distilled from the official deepseek-harness 11 skills;\n the full facts are provided in the <skill_quality_standard> block below):\n only for a REAL trigger scenario grounded in the trajectory\n (who, in what real task, what signal) \u2014 never invent one to pad the store;\n never duplicate the official 11 skills or existing entries; content is a\n SKILL.md document (frontmatter routing with \"use when / do not use when\"\n description, boundary declaration, prerequisites and exclusions, layered\n information, verifiable completion criteria). Self-check every proposed\n skill against the 7 structural features and state the result in its\n reason field.\n- Repeated multi-step workflows (session start/end routines, recurring\n wrap-up or handoff procedures) may be proposed as guidance skills:\n kind=skill, skill_kind=\"guidance\", content = a SKILL.md document (no\n python reference \u2014 executable skills keep requiring reference +\n arguments). Only propose with repeated evidence in the trajectory, never\n for one-off flows. Guidance skills materialize as discoverable SKILL.md\n files under <skillsRoot>/<kebab-name>/SKILL.md and are always offered to\n the user for a decision before they land.\n- Local edits are session-scoped; global edits persist across sessions.\n- Gap C2: EVERY edit MUST include a \"blastRadius\" field indicating how broadly\n the edit applies: \"general\" (cross-project tactical rule, applies everywhere),\n \"project\" (valid for the current project/repo), or \"session\" (one-off,\n specific to this session's context). The review gate validates that\n local-scope edits use \"session\" or \"project\", and global-scope edits use\n \"general\" or \"project\". When in doubt, prefer narrower blast radius.\n- Ground every edit in evidence: the session trajectory (recent direct user\n messages) is provided when available; prefer edits backed by it over\n speculation, and never invent preferences the user did not express.\n- Stale entries (superseded by newer ones, never referenced in recent\n trajectories, obsolete facts): propose action \"archive\" instead of\n \"delete\" \u2014 archive hides the entry from injection while keeping its data\n restorable; it requires only kind + id.\n- Output JSON only, exactly this shape:\n{\n \"summary\": \"one sentence\",\n \"rationale\": \"why these edits are justified by the evidence\",\n \"expectedOutcome\": \"what should improve and how to validate it\",\n \"edits\": [\n {\n \"action\": \"create|update|delete\",\n \"kind\": \"prompt|memory|skill|subagent\",\n \"id\": \"stable id for update/delete, optional for create\",\n \"title\": \"required for create/update except delete\",\n \"content\": \"required for create/update except delete\",\n \"path\": \"optional grouping path\",\n \"reference\": {\"type\":\"python\",\"import\":\"pkg.mod\",\"callable\":\"fn\"} ,\n \"arguments\": {\"name\": {\"type\":\"string\",\"required\":true,\"description\":\"...\"}},\n \"skill_kind\": \"executable|guidance (optional; skill kind only \u2014 guidance = SKILL.md document without reference)\",\n \"metadata\": {},\n \"reason\": \"why this edit is useful\",\n \"blastRadius\": \"general|project|session\"\n }\n ]\n}";
13
13
  export interface PlanOptions {
14
14
  agent: Agent;
15
15
  state: HarnessState;
package/lib/planner.js CHANGED
@@ -12,8 +12,14 @@ to reusable state: prompt notes, memories, skills, and subagent specs.
12
12
  Rules:
13
13
  - The base system prompt is immutable and MUST NOT be rewritten (never edit id "base_system_prompt").
14
14
  - Prefer small evidence-backed edits. If no useful edit is justified, return an empty edits array.
15
- - prompt = narrow behavioral policy addendums; memory = durable facts/preferences/failures;
16
- skill = repeatable procedures (must carry a python reference {type:"python", import, callable}
15
+ - prompt = narrow behavioral policy addendums; memory = ONE FACT per entry, typed via metadata.memoryType
16
+ - (user = who the user is; feedback = a pitfall/correction or confirmed approach, MUST carry Why + How to apply;
17
+ - project = work goal/constraint not derivable from the repo; reference = URL/dashboard/ticket pointer).
18
+ - Memory rules: one fact per entry (title = one-line retrieval hook, content = the fact alone);
19
+ - never save what the repo already records (code structure, past fixes, git history, AGENTS.md) or what only
20
+ - matters to this conversation — save the non-obvious point instead; before creating, update the existing
21
+ - entry when one already covers the fact (creates carry metadata {memoryType} or they are rejected);
22
+ - skill = repeatable procedures (must carry a python reference {type:"python", import, callable}
17
23
  and an arguments object); subagent = reusable delegation roles.
18
24
  - Skill entries are authored to the DSH skill quality standard
19
25
  (skill-creator, distilled from the official deepseek-harness 11 skills;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Stable project key for a workspace. `workspaceIdentity` (when set)
3
+ * replaces the path as the hash source so renamed checkouts of the same
4
+ * logical project keep one store.
5
+ */
6
+ export declare function resolveProjectKey(workspacePath: string, workspaceIdentity?: string): string;
7
+ /** Defense for externally supplied keys (explicit opts): single safe segment. */
8
+ export declare function sanitizeProjectKey(key: string): string;
9
+ /** Minimal agent shape the project-key derivation needs (duck-typed). */
10
+ export interface ProjectKeyAgentLike {
11
+ session?: {
12
+ header?: {
13
+ cwd?: unknown;
14
+ meta?: {
15
+ cwd?: unknown;
16
+ };
17
+ };
18
+ };
19
+ }
20
+ /**
21
+ * Derive the project key from an assembling agent's session cwd. Returns
22
+ * undefined when no validated absolute cwd is present — the caller then
23
+ * skips the project layer rather than failing the whole assembly.
24
+ */
25
+ export declare function projectKeyOf(agent: unknown, opts?: {
26
+ workspaceIdentity?: string;
27
+ }): string | undefined;
28
+ //# sourceMappingURL=project.d.ts.map
package/lib/project.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Project scope identity: the third harness layer between session-local
3
+ * staging and the cross-project global store.
4
+ *
5
+ * A project is identified by the session's validated absolute `cwd`
6
+ * (stable slug + sha256, mirroring ZCode's `project-root.ts`), so every
7
+ * session opened in the same workspace shares one project store while
8
+ * different checkouts never collide. All helpers are pure and never throw —
9
+ * a missing cwd simply means "no project layer" (the caller degrades to
10
+ * global+local, the same shape as an empty project store).
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import { basename, isAbsolute, resolve } from "node:path";
14
+ /**
15
+ * Stable project key for a workspace. `workspaceIdentity` (when set)
16
+ * replaces the path as the hash source so renamed checkouts of the same
17
+ * logical project keep one store.
18
+ */
19
+ export function resolveProjectKey(workspacePath, workspaceIdentity) {
20
+ const identity = workspaceIdentity?.trim();
21
+ const normalized = resolve(workspacePath);
22
+ const keySource = identity && identity.length > 0 ? identity : process.platform === "win32" ? normalized.toLowerCase() : normalized;
23
+ const hash = createHash("sha256").update(keySource).digest("hex").slice(0, 16);
24
+ const slug = identity && identity.length > 0 ? "project" : sanitizeSlug(basename(normalized) || "project");
25
+ return `${slug}-${hash}`;
26
+ }
27
+ function sanitizeSlug(value) {
28
+ const slug = value
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9._-]+/g, "-")
31
+ .replace(/^-+|-+$/g, "")
32
+ .slice(0, 48);
33
+ return slug.length > 0 ? slug : "project";
34
+ }
35
+ /** Defense for externally supplied keys (explicit opts): single safe segment. */
36
+ export function sanitizeProjectKey(key) {
37
+ const cleaned = key
38
+ .trim()
39
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
40
+ .replace(/^-+|-+$/g, "")
41
+ .slice(0, 64);
42
+ return cleaned.length > 0 ? cleaned : "project";
43
+ }
44
+ /**
45
+ * Derive the project key from an assembling agent's session cwd. Returns
46
+ * undefined when no validated absolute cwd is present — the caller then
47
+ * skips the project layer rather than failing the whole assembly.
48
+ */
49
+ export function projectKeyOf(agent, opts) {
50
+ try {
51
+ const header = agent?.session?.header;
52
+ const raw = header?.cwd ?? header?.meta?.cwd;
53
+ if (typeof raw !== "string" || raw.trim().length === 0) {
54
+ return undefined;
55
+ }
56
+ const cwd = raw.trim();
57
+ if (!isAbsolute(cwd)) {
58
+ return undefined;
59
+ }
60
+ return resolveProjectKey(cwd, opts?.workspaceIdentity);
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ //# sourceMappingURL=project.js.map
package/lib/service.js CHANGED
@@ -6,6 +6,11 @@ import { loadHarnessState, saveHarnessState } from "./state.js";
6
6
  import { appendResult, loadResults, snapshotBefore, storePaths } from "./store.js";
7
7
  import { CONFLICT_BLOCK_SCORE, CONFLICT_WARN_SCORE, buildConflictNotice, mostSimilarEntry, secretLeakReason } from "./promotion.js";
8
8
  export function createEvolutionEngine(baseDir, hooks = {}) {
9
+ /**
10
+ * Load a scope's state. For `project` the `sessionId` parameter carries
11
+ * the project key (see `resolveProjectKey` in project.ts); callers
12
+ * derive it with `projectKeyOf(agent)` and pass it through unchanged.
13
+ */
9
14
  function load(scope, sessionId) {
10
15
  return loadHarnessState(storePaths(baseDir, scope, sessionId).stateDir, scope);
11
16
  }
package/lib/state.d.ts CHANGED
@@ -7,11 +7,15 @@ export declare function stateFilePath(stateDir: string): string;
7
7
  */
8
8
  export declare function loadHarnessState(stateDir: string, scope?: HarnessScope): HarnessState;
9
9
  /**
10
- * Merge global and local state into the view the model sees. Local entries
11
- * win over same-id global entries; a colliding local id is prefixed
12
- * `local:` so both remain addressable.
10
+ * Merge global, project, and local state into the view the model sees.
11
+ * Precedence is global < project < local: a colliding project id is
12
+ * prefixed `project:` so both remain addressable, and a colliding local id
13
+ * is prefixed `local:` (local always wins — it is the session's live
14
+ * staging copy). Called with two args the project layer is simply absent.
13
15
  */
14
- export declare function mergeHarnessStates(globalState: HarnessState, localState: HarnessState | undefined): HarnessState;
16
+ export declare function mergeHarnessStates(globalState: HarnessState, localState?: HarnessState | undefined, opts?: {
17
+ projectState?: HarnessState | undefined;
18
+ }): HarnessState;
15
19
  /**
16
20
  * Persist state atomically: write to a temp file, fsync-free but rename-based,
17
21
  * preserving the mode of an existing file (defaults to 0o600 for new files).
package/lib/state.js CHANGED
@@ -20,7 +20,7 @@ export function stateFilePath(stateDir) {
20
20
  return join(stateDir, "harness_state.json");
21
21
  }
22
22
  function normalizeScope(value, fallback) {
23
- return value === "global" || value === "local" ? value : fallback;
23
+ return value === "global" || value === "project" || value === "local" ? value : fallback;
24
24
  }
25
25
  function objectRecord(value) {
26
26
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -95,24 +95,31 @@ export function loadHarnessState(stateDir, scope = "global") {
95
95
  return state;
96
96
  }
97
97
  /**
98
- * Merge global and local state into the view the model sees. Local entries
99
- * win over same-id global entries; a colliding local id is prefixed
100
- * `local:` so both remain addressable.
98
+ * Merge global, project, and local state into the view the model sees.
99
+ * Precedence is global < project < local: a colliding project id is
100
+ * prefixed `project:` so both remain addressable, and a colliding local id
101
+ * is prefixed `local:` (local always wins — it is the session's live
102
+ * staging copy). Called with two args the project layer is simply absent.
101
103
  */
102
- export function mergeHarnessStates(globalState, localState) {
104
+ export function mergeHarnessStates(globalState, localState, opts) {
103
105
  const merged = emptyHarnessState();
104
- merged.schema = Math.max(globalState.schema, localState?.schema ?? 1);
106
+ merged.schema = Math.max(globalState.schema, opts?.projectState?.schema ?? 1, localState?.schema ?? 1);
105
107
  for (const kind of Object.keys(merged.entries)) {
106
108
  for (const [id, entry] of Object.entries(globalState.entries[kind])) {
107
109
  merged.entries[kind][id] = { ...entry, scope: "global" };
108
110
  }
111
+ for (const [id, entry] of Object.entries(opts?.projectState?.entries[kind] ?? {})) {
112
+ const scoped = { ...entry, scope: "project" };
113
+ const mergedId = merged.entries[kind][id] ? `project:${id}` : id;
114
+ merged.entries[kind][mergedId] = scoped;
115
+ }
109
116
  for (const [id, entry] of Object.entries(localState?.entries[kind] ?? {})) {
110
117
  const scoped = { ...entry, scope: "local" };
111
118
  const mergedId = merged.entries[kind][id] ? `local:${id}` : id;
112
119
  merged.entries[kind][mergedId] = scoped;
113
120
  }
114
121
  }
115
- merged.refinements = [...globalState.refinements, ...(localState?.refinements ?? [])];
122
+ merged.refinements = [...globalState.refinements, ...(opts?.projectState?.refinements ?? []), ...(localState?.refinements ?? [])];
116
123
  return merged;
117
124
  }
118
125
  /**
package/lib/store.d.ts CHANGED
@@ -8,6 +8,12 @@ export interface StorePaths {
8
8
  /** JSONL path for applied refinement results. */
9
9
  resultsPath: string;
10
10
  }
11
+ /**
12
+ * Resolve store paths for a scope. For `project` the `sessionId` parameter
13
+ * carries the project key (see {@link resolveProjectKey} in project.ts) —
14
+ * it is sanitized to a single path segment so an externally supplied key
15
+ * can never traverse out of `evolve/projects/`.
16
+ */
11
17
  export declare function storePaths(baseDir: string, scope: HarnessScope, sessionId?: string): StorePaths;
12
18
  /** Snapshot the current state file before a mutation, if one exists. */
13
19
  export declare function snapshotBefore(paths: StorePaths, refinementId: string): void;
package/lib/store.js CHANGED
@@ -4,9 +4,10 @@
4
4
  * Layout (self-contained under the DSH home; no dependency on session
5
5
  * persistence internals):
6
6
  *
7
- * <dshHome>/evolve/global/harness_state.json cross-session store
7
+ * <dshHome>/evolve/global/harness_state.json cross-project store
8
8
  * <dshHome>/evolve/global/refinements.jsonl applied results (rollback source)
9
- * <dshHome>/evolve/local/<sessionId>/... per-session store
9
+ * <dshHome>/evolve/projects/<slug-hash>/... per-project store
10
+ * <dshHome>/evolve/local/<sessionId>/... per-session staging store
10
11
  *
11
12
  * Snapshot discipline is code-enforced: before any mutating apply, the
12
13
  * pre-apply state is copied to `snapshots/<refinementId>.json`. The model has
@@ -14,10 +15,17 @@
14
15
  */
15
16
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
17
  import { join } from "node:path";
18
+ import { sanitizeProjectKey } from "./project.js";
17
19
  import { stateFilePath } from "./state.js";
18
20
  export const EVOLVE_DIR = "evolve";
21
+ /**
22
+ * Resolve store paths for a scope. For `project` the `sessionId` parameter
23
+ * carries the project key (see {@link resolveProjectKey} in project.ts) —
24
+ * it is sanitized to a single path segment so an externally supplied key
25
+ * can never traverse out of `evolve/projects/`.
26
+ */
19
27
  export function storePaths(baseDir, scope, sessionId) {
20
- const scopeDir = scope === "global" ? "global" : join("local", sessionId ?? "anonymous");
28
+ const scopeDir = scope === "global" ? "global" : scope === "project" ? join("projects", sanitizeProjectKey(sessionId ?? "project")) : join("local", sessionId ?? "anonymous");
21
29
  const stateDir = join(baseDir, EVOLVE_DIR, scopeDir);
22
30
  return {
23
31
  stateDir,
package/lib/tool.d.ts CHANGED
@@ -6,7 +6,10 @@
6
6
  import type { Context } from "@deepseek-ai/cordis";
7
7
  import type { HarnessScope } from "./types.js";
8
8
  import type { EvolutionEngine } from "./service.js";
9
- /** Accept both the boolean tool parameter (`global: true`) and the string form. */
9
+ /**
10
+ * Accept the string form (`scope: "project"`) with the legacy boolean tool
11
+ * parameter (`global: true`) as fallback. `scope` wins when both are given.
12
+ */
10
13
  export declare function scopeOf(value: unknown, fallback: HarnessScope): HarnessScope;
11
14
  export interface ToolGateOptions {
12
15
  requireGlobalApproval: boolean;