@lmzhen/dsh-evolution-core 0.22.0 → 0.23.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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * THE error-code table: E-301…E-319 with the exact text each one emits (E-317…E-319 are the
2
+ * THE error-code table: E-301…E-322 with the exact text each one emits (E-317…E-319 are the
3
3
  * write-admission gates' own refusals).
4
4
  *
5
5
  * The codes used to be spelled out at 33 call sites across two packages, so one
@@ -36,6 +36,7 @@ export declare const EVOLUTION_ERRORS: Readonly<{
36
36
  readonly 'e-317-skill-write-not-confirmed': "E-317: skill \"%a1%\" was not %a2% — the confirmation prompt was declined or dismissed, so nothing was written. The prompt appears on every create and bare delete; repeat the call only if the operator asks for it.";
37
37
  readonly 'e-318-skill-write-without-a-read': "E-318: skill \"%a1%\" was not read in this session, so this write is refused. Read it with the `skill` tool first (a read that failed does not count), then repeat the write.";
38
38
  readonly 'e-320-the-profile-home-is-not': "E-320: the profile home is not reported by this host, so the legacy settings document cannot be located. /evolution migrate reads `<profile home>/settings.yaml.imported`; run it from a profile-launched session.";
39
+ readonly 'e-322-a-parameter-would-break-a': "E-322: %a1% must be at least %a2%, but this write sets it to %a3%. Nothing was written.";
39
40
  readonly 'e-321-no-io-provider-is': "E-321: no evolution-io provider is mounted, so the legacy settings document cannot be read. Mount the evolution-io row (evolution-host/evolution-all) and retry.";
40
41
  readonly 'e-319-skill-write-confirm-timed-out': "E-319: skill \"%a1%\" was not %a2% — the confirmation prompt went unanswered for %a3% second(s), so the write was cancelled. Nobody was there to decide: set `skillWriteConfirm` to `auto` to write without a prompt, keep `ask` to wait until someone answers, or raise `skillWriteConfirmTimeoutSeconds`.";
41
42
  }>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The family's plain-object predicate, in ONE place (v46 G7.1 / audit A8).
3
+ *
4
+ * The audit found the same guard — "a non-null object that is not an array" — rewritten fifteen
5
+ * times across nine packages, four times inside core itself, and with two byte-identical `asRecord`
6
+ * copies in evolution-policy and evolution-threat. This module is the single definition; callers
7
+ * import it instead of spelling the clause again.
8
+ *
9
+ * @module @lmzhen/dsh-evolution-core
10
+ */
11
+ /**
12
+ * Is this a non-null, non-array object (what JSON calls an object)?
13
+ * @param value - the value to test.
14
+ * @returns whether it is a plain object.
15
+ */
16
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
17
+ /**
18
+ * Read a value as a record, or undefined when it is not one.
19
+ * @param value - the value to read.
20
+ * @returns the value typed as a record, or undefined.
21
+ */
22
+ export declare function asRecord(value: unknown): Record<string, unknown> | undefined;
23
+ //# sourceMappingURL=guards.d.ts.map
@@ -38,6 +38,7 @@ export * from './curator.ts';
38
38
  export * from './evolution-events.ts';
39
39
  export * from './fuzzy-match.ts';
40
40
  export * from './gates.ts';
41
+ export * from './guards.ts';
41
42
  export * from './http-routes.ts';
42
43
  export * from './events.ts';
43
44
  export * from './env.ts';
package/lib/types/io.d.ts CHANGED
@@ -98,18 +98,6 @@ export interface TransactTaskGuard {
98
98
  export declare function transactTaskGuard(what: string): TransactTaskGuard;
99
99
  /** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
100
100
  export declare function evolutionIoAdapter(provider: () => EvolutionIoLike): EvolutionIoLike;
101
- /**
102
- * F-367 (②): lock paths whose release (the finally `rm`) failed. The next write
103
- * to the same file proactively recycles our own leftover lock — the holder is
104
- * us, so a leftover is stale by definition. Module-level by design: it must
105
- * survive across `nodeEvolutionIo()` instances for the self-heal to be
106
- * effective. (Not a pure function — the cross-call state is the intent.)
107
- * V4-05: this set — not an mtime heuristic — is the ONLY signal that a
108
- * same-pid lock is a leftover rather than a live in-process task, so it is the
109
- * sole gate for the self-pid recycle branch. Exported (read-only in practice)
110
- * so `io.spec.ts` can drive the self-heal path deterministically.
111
- */
112
- export declare const pendingSelfCleanup: Map<string, string>;
113
101
  /**
114
102
  * Retry a rename that a peer is temporarily holding on Windows (EPERM/EBUSY):
115
103
  * exponential backoff (50ms doubling, capped at 800ms) with a ~2s total
@@ -159,57 +147,6 @@ export declare function writeDurableTmp(target: string, content: string, openImp
159
147
  * stranded-lock sweep must use the same liveness rule.
160
148
  */
161
149
  export declare function isProcessAlive(pid: number): boolean;
162
- /** F-17 (v18): the write-lock protocol is a cross-module contract — the lock
163
- * file is `<target>.lock` and its body is `<pid>:<token>`. This module creates
164
- * them (`withWriteLock`) and `skill-store`'s probes/sweepers parse them, so both
165
- * consume these two constants instead of repeating the literals. */
166
- export declare const LOCK_SUFFIX = ".lock";
167
- /** Writer-lock body shape: a decimal pid, a colon, then the claim token. A
168
- * torn body (no parsable pid) still matches the `\\d+:` prefix rule only when
169
- * the pid part is intact, which is what the takeover probe needs. The capture
170
- * group feeds `parseLockBody` (v20 A-1: skill-store's sweepers consume this
171
- * helper instead of re-inlining a third regex copy — F-17 single-source). */
172
- export declare const LOCK_BODY_RE: RegExp;
173
- /** Parse a writer-lock body into its holder pid. `null` when the body does
174
- * not have the `pid:token` shape at all (e.g. a user support file named
175
- * `*.lock`) — callers leave such files alone. A shape-matching body always
176
- * yields a number, possibly `0` (state corruption / hand edit): there is no
177
- * pid 0, so a `0` holder must be treated as DEAD by the caller —
178
- * `isProcessAlive(0)` signals the caller's own process group on POSIX and
179
- * answers true, so probe sites must guard `holder > 0` before probing. */
180
- export declare function parseLockBody(body: string): number | null;
181
- /**
182
- * V27 G0.2 (EVO-IO-01): the write lock named by this claim is no longer ours
183
- * at the commit point — a takeover reclaimed it while we were inside the
184
- * critical section. The lock layer converts this into a retry of the whole
185
- * read-modify-write; it must never reach a caller as a successful write.
186
- */
187
- export declare class LostWriteLock extends Error {
188
- constructor();
189
- }
190
- /** V27 G1.1: the three takeover windows, as protocol constants at ONE place
191
- * (the inline copies that used to live in the acquisition loop, plus the dead
192
- * branch's bare `1000`, are gone). */
193
- /** A named holder that is gone, past this age, is reclaimed. Fits inside the
194
- * `lockAttempts * 50ms` retry budget so a dead holder's lock is reachable
195
- * within one budget (v19 gate arithmetic: budget >= 2 x threshold). */
196
- export declare const DEAD_LOCK_TAKEOVER_MS = 1000;
197
- /** No body at all: nothing attributes the lock to a holder, so this is the one
198
- * branch that cannot probe liveness — it must outlast any plausible stall
199
- * between create and body write (V27 G0.2: 1s was below what a loaded machine
200
- * actually took, which let a peer delete a live holder's lock). */
201
- export declare const EMPTY_LOCK_TAKEOVER_MS = 30000;
202
- /** A body with no parseable pid (crash mid-write): 1h, far above any legal hold
203
- * and far below "forever". */
204
- export declare const LOCK_TEAR_TAKEOVER_MS = 3600000;
205
- /** A2 (audit P1-2): even a lock whose holder pid probes ALIVE is reclaimable
206
- * past this age. Liveness-by-pid cannot distinguish the original holder from
207
- * an unrelated process the OS later assigned the same pid, so the plain alive
208
- * probe let one recycled pid brick every writer of one state file forever.
209
- * No write in this family holds a lock for more than minutes (the longest is
210
- * the ~120s review window), so a day-old "alive" lock is a recycled pid, not
211
- * a live writer. */
212
- export declare const ALIVE_LOCK_TAKEOVER_MS = 86400000;
213
150
  /**
214
151
  * V27 G1.3: the error `commitTmp` throws when the rename landed but the parent
215
152
  * directory fsync failed — the bytes ARE visible, only their durability is
@@ -219,49 +156,53 @@ export declare const ALIVE_LOCK_TAKEOVER_MS = 86400000;
219
156
  * shape as SUCCESS-with-warning, never as a rejection.
220
157
  */
221
158
  export declare function isCommittedWarning(error: unknown): boolean;
222
- /** V27 G1.1: the takeover branches, as a value. */
223
- type TakeoverDecision = 'none' | 'dead' | 'empty' | 'corrupt';
224
- /** V27 G1.1: one lock observation, plus the liveness probe for its pid. */
225
- interface TakeoverProbe {
226
- /** Raw lock body. An empty string means the file exists with no content. */
227
- body: string;
228
- /** Lock mtime in epoch ms. */
229
- mtimeMs: number;
230
- /** Liveness probe for a pid (injected so the decision is a pure function). */
231
- alive: (pid: number) => boolean;
232
- /** Evaluation instant (defaults to now). */
233
- nowMs?: number;
234
- /** Threshold overrides — production callers use the protocol defaults. */
235
- deadAfterMs?: number;
236
- emptyAfterMs?: number;
237
- corruptAfterMs?: number;
238
- aliveAfterMs?: number;
239
- }
240
159
  /**
241
- * V27 G1.1: the lock-takeover decision as ONE pure function, so the protocol is
242
- * testable and exhaustive instead of being an inline expression inside the
243
- * acquisition loop:
244
- * - `none` the lock is fresh, or its holder is alive within the alive
245
- * window → wait, never steal;
246
- * - `dead` a named holder that is gone past the dead threshold, OR whose
247
- * pid still probes alive but whose lock is older than the alive
248
- * window (A2: a recycled pid must not hold the file forever);
249
- * - `empty` no body at all: nothing attributes it to a holder, so only the
250
- * wide `emptyAfterMs` window may reclaim it;
251
- * - `corrupt` a body with no parseable pid (a crash mid-write), past the 1h
252
- * tear threshold.
253
- * The age thresholds compare against `nowMs - mtimeMs` with `>` so a lock whose
254
- * age EQUALS the threshold is not yet reclaimed (the boundary the v19 gate fix
255
- * pinned).
160
+ * Build the Node IO backend: plain fs promises, durable tmp + rename commits (crash atomicity) and
161
+ * ONE concurrency guarantee of its own — writes to the same path are serialized in-process (see
162
+ * `inProcessWrite`). G2 S4 removed the cross-process write lock and its takeover protocol; the
163
+ * plugin path gets cross-process safety from the platform provider (`ctx.fs`).
256
164
  */
257
- export declare function decideTakeover(probe: TakeoverProbe): TakeoverDecision;
165
+ export declare function nodeEvolutionIo(): EvolutionIoLike;
166
+ /** F-211: the fs surface atomicWriteFiles uses — injectable so the commit-phase
167
+ * rename-failure path is deterministically testable. Defaults to node:fs.
168
+ * `mtime` (optional) probes a path's last-write time so the failed-commit
169
+ * recovery message can name the generation it restored from. */
170
+ interface FsOps {
171
+ writeFileSync(path: string, data: string | Uint8Array): void;
172
+ existsSync(path: string): boolean;
173
+ copyFileSync(from: string, to: string): void;
174
+ renameSync(from: string, to: string): void;
175
+ rmSync(path: string, options?: {
176
+ force?: boolean;
177
+ recursive?: boolean;
178
+ }): void;
179
+ mtime?(path: string): number | null;
180
+ }
258
181
  /**
259
- * Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
260
- * (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
261
- * while contention TESTS on a loaded runner may raise it (e.g. 240 ≈ 12s) —
262
- * V10-06 integration: full-suite parallel load made 32-way takeover bursts
263
- * exceed the default budget and fail loud.
182
+ * S6.3 (E-40): atomically replace a set of files within one directory.
183
+ * Each file is staged to a sibling `<name>.tmp`, then renamed into place
184
+ * (atomic on the same filesystem); a pre-existing file keeps a single
185
+ * `<name>.bak`. A failure at ANY staging step removes the staged temps and
186
+ * leaves the previous files untouched — a half-written target is impossible
187
+ * up to the staging and backup phases.
188
+ *
189
+ * F-211: the rename COMMIT phase does not carry the same guarantee. When a
190
+ * filesystem refuses to overwrite the destination, the code removes it and
191
+ * retries the rename; if the retry ALSO fails the destination is gone. The
192
+ * single `.bak` created above still holds the previous content, so the target
193
+ * is restored from it (best-effort) before the original rename error is
194
+ * rethrown, so a failed commit never leaves the file missing.
195
+ *
196
+ * V4-17: the `.bak` is refreshed to the committed content after every
197
+ * successful commit, so a later failed commit always recovers to the most
198
+ * recent good generation (a once-at-first-install `.bak` could otherwise
199
+ * restore content several installs old). The recovery error names the `.bak`
200
+ * source (and mtime when available) so the caller knows the generation it
201
+ * restored from.
264
202
  */
265
- export declare function nodeEvolutionIo(lockAttempts?: number): EvolutionIoLike;
203
+ export declare function atomicWriteFiles(targetDir: string, writes: Array<{
204
+ name: string;
205
+ content: string | Uint8Array;
206
+ }>, fs?: FsOps, logger?: (message: string) => void): void;
266
207
  export {};
267
208
  //# sourceMappingURL=io.d.ts.map
@@ -112,6 +112,16 @@ export interface ParamExposure {
112
112
  /** The registered reason this row has NO card. Required on every non-E3 row; mutually exclusive
113
113
  * with `card`. This is what makes "an E3 row that no card renders" unspellable. */
114
114
  why?: ParamWhy;
115
+ /**
116
+ * A cross-field constraint this row must satisfy, declared ONCE here (U-4). The card checks it
117
+ * before a save is attempted and the Host write path derives the same check from the same column,
118
+ * so "保存前就知道输入合不合法" needs no second rule table. Only relations with real behaviour
119
+ * behind them are declared; this is not a constraint language.
120
+ */
121
+ relation?: {
122
+ /** The sibling id in the SAME group whose value this row may not go below. */
123
+ readonly atLeast?: string;
124
+ };
115
125
  /** Unit suffix shown next to a number (empty when it carries none). */
116
126
  unit?: string;
117
127
  /** Pipe-joined allowed values; non-empty exactly when control is 'select'. */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Every platform service the family reaches for with `ctx.get`, and what degrades without it.
3
3
  *
4
- * O-2 (v46 audit): the family probes 26 platform services optionally — the platform has no
4
+ * O-2 (v46 audit): the family probes 28 platform services optionally — the platform has no
5
5
  * formalized "optional dependency", so an ABSENT service is a silent downgrade and the diagnostic
6
6
  * surface is the only place a reader can learn about it. `/evolution doctor` used to judge six of
7
7
  * them; this table is the single source it judges the rest from, and the arch guard cross-checks it
@@ -20,7 +20,7 @@ export interface PlatformServiceProbe {
20
20
  /** Where the probe runs. */
21
21
  readonly side: 'host' | 'client';
22
22
  }
23
- /** The 26 probes, in service-name order (the guard checks this list against the tree). */
23
+ /** The 28 probes, in service-name order (the guard checks this list against the tree). */
24
24
  export declare const PLATFORM_SERVICE_PROBES: readonly PlatformServiceProbe[];
25
25
  /**
26
26
  * The HOST-side probes that do not resolve in this runtime — what `/evolution doctor` prints as
@@ -58,11 +58,29 @@ interface RowOverride {
58
58
  }
59
59
  /**
60
60
  * Read and validate the shared override table.
61
+ * G6.1/M1: the path is a PARAMETER (default: the table beside this module). The source installer
62
+ * runs the generated port from `scripts/`, so its own root has to be passed in — a module-relative
63
+ * read would look for `scripts/../row-overrides.json` and fail.
64
+ * @param path - the table to read.
61
65
  * @returns the entries, in file order. A malformed table fails LOUD: a composer
62
66
  * that silently skipped an entry would ship a preset running the platform
63
67
  * default while nothing downstream reported it.
64
68
  */
65
- export declare function loadRowOverrides(): RowOverride[];
69
+ export declare function loadRowOverrides(path?: string): RowOverride[];
70
+ /**
71
+ * Row ids of a composition fragment (line scan, no YAML library).
72
+ *
73
+ * PLAN S5.9 (2026-09-16, audit P2-27): the id extraction accepts INDENTED
74
+ * `- id:` rows too, so a collision hidden in a nested group is still caught —
75
+ * the old `^- id:` anchored at column 0 and was blind to exactly the rows an
76
+ * upstream group nesting would produce. Twin of `rowIds` in
77
+ * `scripts/install-layered.mjs` (installer.spec pins detection parity).
78
+ * Boundary (current, deliberate): DETECTION covers nested rows, while the
79
+ * override INJECTION anchors (`applyOneOverride` below) still match top-level
80
+ * rows only — the injection indent contract (`^ {2}key:`) is defined against a
81
+ * column-0 row.
82
+ */
83
+ export declare function compositionRowIds(composition: string): Set<string>;
66
84
  /** The platform package a DECLARATIVE preset row names (0.2.x replaced preset directories with it). */
67
85
  export declare const AGENT_PRESET_PACKAGE = "@deepseek-ai/dsh-agent-preset";
68
86
  /** Identity and display metadata of one generated preset row. */
@@ -78,6 +96,31 @@ export interface PresetRowIdentity {
78
96
  /** Sort order among the registered presets. */
79
97
  readonly order: number;
80
98
  }
99
+ /**
100
+ * Compose the DECLARATIVE preset row from the platform base composition and the family delta.
101
+ *
102
+ * 0.2.x removed the directory mechanism: `dsh-agent-preset` is an ordinary composition row whose
103
+ * `config.plugins` carries the preset's complete child plugin list
104
+ * (`packages/preset/agent-preset/src/index.ts`, `agent-preset-registry/src/definition.ts` — the
105
+ * definition has NO base/extends field, which is why the platform base is merged in here).
106
+ *
107
+ * The composition itself is {@link composePresetComposition}: same row-collision contract and the
108
+ * same `row-overrides.json` injection, applied while the composed rows are still at column 0 — the
109
+ * override anchors are defined against a column-0 `- id:` row, so the indentation that puts them
110
+ * under `plugins:` must come last.
111
+ * @param baseComposition - the platform base preset patch/composition rows.
112
+ * @param deltaComposition - the family delta rows.
113
+ * @param identity - row id, preset id, display fields and order.
114
+ * @returns the row text, newline-terminated, ready to merge into a profile patch.
115
+ */
116
+ /**
117
+ * Drop the blank lines that merely separate block-sequence items, keeping the ones INSIDE a block
118
+ * literal (`key: |` / `>`): there a blank line is part of the value, and filtering it flattened a
119
+ * platform base preset's multi-paragraph prompt into one paragraph (G6 review, P1).
120
+ * @param text - the composition rows.
121
+ * @returns the rows with the separating blank lines removed.
122
+ */
123
+ export declare function denseBlockSequence(text: string): string;
81
124
  export declare function composePresetRow(baseComposition: string, deltaComposition: string, identity: PresetRowIdentity): string;
82
125
  /**
83
126
  * Wrap a composed row as the patch entry a profile patch needs.
@@ -183,6 +226,36 @@ export interface BasePresetPatchSources {
183
226
  * @throws {Error} when no candidate exists.
184
227
  */
185
228
  export declare function resolveBasePresetPatch(base: string, sources: BasePresetPatchSources): string;
229
+ /**
230
+ * Probe every `@deepseek-ai/dsh-*` package under one unpacked resource root.
231
+ * @param resourceRoot - e.g. `<install>/resources/app.asar.unpacked`.
232
+ * @param file - the patch file name to look for.
233
+ * @param probes - the caller's probe log, appended to for the failure report.
234
+ * @returns the patch path when one exists.
235
+ */
236
+ export declare function probeDesktopScope(resourceRoot: string, file: string, probes: string[]): string | undefined;
237
+ /**
238
+ * Subdirectories of one directory, or none when it does not exist: a missing level is an ordinary
239
+ * miss on a candidate walk, not a failure.
240
+ * @param dir - the directory to list.
241
+ * @returns the directory names.
242
+ */
243
+ export declare function listDirectories(dir: string): string[];
244
+ /**
245
+ * The block of one row id: the enclosing column-0 list item and the row's own first line.
246
+ *
247
+ * An installer-written row sits inside `- insert:` (four spaces deeper); a row the Web editor
248
+ * saved is that column-0 item itself. Both must be replaced whole, so the span reaches back to the
249
+ * enclosing item and forward to the next one.
250
+ * @param lines - the patch text's lines.
251
+ * @param rowId - the row id to locate.
252
+ * @returns the span, or null when the patch carries no such row.
253
+ */
254
+ export declare function rowBlockSpan(lines: string[], rowId: string): {
255
+ start: number;
256
+ end: number;
257
+ rowStart: number;
258
+ } | null;
186
259
  /**
187
260
  * Remove one row from a patch, taking its `- insert:` item with it when that item holds nothing
188
261
  * else (an empty insert entry is left-over structure the platform would keep parsing).
@@ -191,6 +264,14 @@ export declare function resolveBasePresetPatch(base: string, sources: BasePreset
191
264
  * @returns the patch text, newline-terminated; byte-identical when the id is absent.
192
265
  */
193
266
  export declare function removePresetRow(patchText: string, rowId: string): string;
267
+ /**
268
+ * The end of one nested row's own block inside a patch entry.
269
+ * @param lines - the patch text's lines.
270
+ * @param rowStart - the row's `- id:` line.
271
+ * @param itemEnd - the enclosing item's end.
272
+ * @returns the index of the next sibling row, or the item's end.
273
+ */
274
+ export declare function rowSiblingEnd(lines: string[], rowStart: number, itemEnd: number): number;
194
275
  /**
195
276
  * Merge one generated row into a composition (a profile patch) by row id.
196
277
  *
@@ -212,6 +293,8 @@ export declare function removePresetRow(patchText: string, rowId: string): strin
212
293
  export declare function mergePresetRow(patchText: string, row: string, rowId: string): string;
213
294
  /** The empty patch list the platform seeds a profile patch with; see {@link mergePresetRow}. */
214
295
  export declare const EMPTY_PATCH_SEED = "[]";
296
+ /** Drop trailing blank lines only: a kept line's own terminator (a CRLF file's `\r`) is never touched. */
297
+ export declare function trimTrailingBlankLines(lines: string[]): string[];
215
298
  /**
216
299
  * The text to write when a patch is left with no entries.
217
300
  *
@@ -57,6 +57,8 @@ export interface VersionRowFacts {
57
57
  readonly charsDelta?: number;
58
58
  readonly path?: string;
59
59
  readonly summary?: string;
60
+ /** U-5: the body hash the host recorded for this version (the row shows a short code, hover = full). */
61
+ readonly hash?: string;
60
62
  }
61
63
  /** The facts a skill row needs. The client's `SkillRow` satisfies this structurally. */
62
64
  export interface SkillRowFacts {
@@ -19,7 +19,7 @@
19
19
  * itself in `this.serial` (archive/restoreFromArchive deliberately do not).
20
20
  * 2. **Per-directory write lock** (io.ts LOCK_*) — cross-process mutual
21
21
  * exclusion plus in-process crash ownership (tickets, takeover). Checked
22
- * with `hasWriteLock` before any destructive move (archive/restore/
22
+ * with `supportDirsUnlistable` before any destructive move (archive/restore/
23
23
  * snapshot); held inside transactIo by byte writers.
24
24
  * 3. **CAS baseline (`expected:`)** — any read whose bytes feed a later write
25
25
  * must either live inside the serial section that commits the write, or
@@ -306,9 +306,8 @@ export declare class SkillLibrary {
306
306
  * found no SKILL.md — remove the possibly-resurrected directory ONLY when it
307
307
  * holds nothing but this path's own write-lock file. The BR-5 rule from
308
308
  * setPinnedCore/createCore applies unchanged: a concurrent mover can land a
309
- * full directory between the list probe and the remove, so anything beyond
310
- * the lock file (support files, a fresh restore) must never be recursed
311
- * away. Best-effort: a failed cleanup leaves the "not found" result
309
+ * full directory between the list probe and the remove, so ANY content at all (support files, a
310
+ * fresh restore) must never be recursed away. Best-effort: a failed cleanup leaves the "not found" result
312
311
  * unchanged (the operator-facing ghost-dir refusal is fail-loud already).
313
312
  */
314
313
  private cleanupGhostDir;
@@ -481,58 +480,19 @@ export declare class SkillLibrary {
481
480
  * P2 (v16): the write-lock probe for the DESTRUCTIVE MOVERS (archive /
482
481
  * restoreFromArchive). A byte-writer mid-flight is the ghost-generator —
483
482
  * after the move its transact commit re-creates `<dir>/…` (mkdir
484
- * recursive) and the tree ends half-archived. The signal is the writer's
485
- * own lock file, and its PLACEMENT (inside the moved directory) is why the
486
- * mover must PROBE-and-REFUSE instead of acquiring it: an acquired lock
487
- * would be renamed into `.archive` with the tree, stranding a phantom live
488
- * lock (the v16 audit proved the probe→rename TOCTOU does exactly that,
489
- * and restore would later move the residue back into the live root).
490
- * Coverage: `SKILL.md.lock` (update/patch of the body) plus one level of
491
- * each support dir (write_file's lock sits next to its file). Residual:
492
- * NESTED support-subdir locks and the probe→rename TOCTOU itself remain
493
- * fail-safe (renameWithRetry rides the write out; the writer's locked
494
- * re-read refuses on the moved-away file). v37 (P2-8): a residue `.lock` whose
495
- * holder is GONE is not a writer — `decideTakeover` owns that verdict.
483
+ * recursive) and the tree ends half-archived.
484
+ * G2 S4: the writer-lock probe that used to answer this question is retired — writes go through
485
+ * the provider's versioned writes, so a writer that raced the move fails its own write loudly
486
+ * instead of landing on a moved-away path. What REMAINS load-bearing here is the fail-closed
487
+ * listing rule, which is about the filesystem misbehaving rather than about writers.
496
488
  */
497
- private hasWriteLock;
498
- /** P2 (v17): a `*.lock` file counts as a writer lock only when its body
499
- * carries the io layer's protocol — `pid:token`, a bare pid, or an empty body
500
- * (a creator between create and body write); anything else is user content
501
- * (suffix-only matching refused archiving and deleted user files on restore).
502
- * v37 (P2-8): the verdict is `decideTakeover`'s, so the mover refuses exactly
503
- * the lock the io layer refuses to reclaim, the 30s empty window included. */
504
- private isWriterLock;
505
- /** P2 (v16): best-effort removal of lock residue inside a RESTORED tree.
506
- * A1-2/A1-7 (v18): the sweep now covers the marker locks the probe checks
507
- * (`SKILL.md.lock`/`.pinned.lock`/`.hermes-managed.lock`) and only removes
508
- * a lock whose holder pid is NOT alive — a live writer's lock is never
509
- * stolen by the sweep. A dead-pid residue would otherwise permanently
510
- * refuse archive/restore. */
511
- private deleteStrandedLocks;
512
- /** R2 follow-up (audit of A2): the recycled-pid window from io's
513
- * `ALIVE_LOCK_TAKEOVER_MS`, applied to the stranded-lock sweepers. A lock
514
- * whose holder pid probes alive but whose mtime is older than the alive
515
- * window is a recycled pid, not a live writer (no write in this family
516
- * holds a lock for more than minutes) — without this, one recycled pid
517
- * blocked whole-tree snapshot recovery forever with a "retry once the
518
- * write completes" message that could never become true. A backend without
519
- * `mtime` keeps the conservative refuse-on-alive posture. */
520
- private lockHolderAgedOut;
521
- /** Remove `lockPath` only when its body has the writer-lock `pid:token`
522
- * shape AND the holder pid is not alive (or is an aged-out recycled pid —
523
- * R2 follow-up); anything else (a user support file or a live writer's
524
- * lock) is left untouched. */
525
- private sweepLockIfStranded;
489
+ private supportDirsUnlistable;
526
490
  /** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
527
491
  * only a single, non-traversing path component is safe. Dotfiles
528
492
  * (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed.
529
493
  * P2-4 (v19): a non-string entry (`skills: [123]`) is refused structurally
530
494
  * instead of throwing `name.includes is not a function`. */
531
495
  private safeSnapshotEntryName;
532
- /** A1-7 (v18): a root-level lock whose holder is alive must refuse the
533
- * restore; a dead residue is swept so a crashed writer cannot block
534
- * recovery. A non-lock body shape is left alone (user file). */
535
- private refuseLiveLockOrSweep;
536
496
  archive(rawName: string, options?: ArchiveOptions): Promise<SkillActionResult>;
537
497
  /**
538
498
  * V2 (design §16.7): what an apply-mode consolidation would do for ONE source,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-core",
3
3
  "description": "Shared stores, prompts, signals and lifecycle logic for the dsh-evolution plugin family (community build)",
4
- "version": "0.22.0",
4
+ "version": "0.23.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -122,10 +122,10 @@
122
122
  "id": "skill-tree",
123
123
  "path": "<skillsRoot>/**",
124
124
  "writer": "evolution-core/src/skill-store.ts",
125
- "serializedBy": "write-lock",
126
- "marker": "LOCK_SUFFIX",
125
+ "serializedBy": "transact",
126
+ "marker": "this.transact(this.io, path",
127
127
  "state": [],
128
- "note": "The skill tree itself. Per-target `<path>.lock` claims (pid:token body) guard every byte writer; destructive movers refuse a live lock."
128
+ "note": "The skill tree itself. G2 S4: the family write-lock protocol is retired; every byte writer goes through the provider's versioned read-modify-write (`transact`), so this entry is serialized by the same seam as the rest. Destructive movers refuse only when a support directory cannot be listed (fail-closed)."
129
129
  },
130
130
  {
131
131
  "id": "activity-log",