@lmzhen/dsh-evolution-core 0.4.0 → 0.4.1

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/index.js CHANGED
@@ -328,7 +328,10 @@ const LOCK_BODY_RE = /^(\d+):[0-9a-f]*$/;
328
328
  /** Parse a writer-lock body into its holder pid. `null` when the body does
329
329
  * not have the `pid:token` shape at all (e.g. a user support file named
330
330
  * `*.lock`) — callers leave such files alone. A shape-matching body always
331
- * yields a number (possibly `0`, which `isProcessAlive` treats as dead). */
331
+ * yields a number, possibly `0` (state corruption / hand edit): there is no
332
+ * pid 0, so a `0` holder must be treated as DEAD by the caller —
333
+ * `isProcessAlive(0)` signals the caller's own process group on POSIX and
334
+ * answers true, so probe sites must guard `holder > 0` before probing. */
332
335
  function parseLockBody(body) {
333
336
  const match = LOCK_BODY_RE.exec(body.trim());
334
337
  return match === null ? null : Number(match[1]);
@@ -365,6 +368,14 @@ const EMPTY_LOCK_TAKEOVER_MS = 3e4;
365
368
  /** A body with no parseable pid (crash mid-write): 1h, far above any legal hold
366
369
  * and far below "forever". */
367
370
  const LOCK_TEAR_TAKEOVER_MS = 36e5;
371
+ /** A2 (audit P1-2): even a lock whose holder pid probes ALIVE is reclaimable
372
+ * past this age. Liveness-by-pid cannot distinguish the original holder from
373
+ * an unrelated process the OS later assigned the same pid, so the plain alive
374
+ * probe let one recycled pid brick every writer of one state file forever.
375
+ * No write in this family holds a lock for more than minutes (the longest is
376
+ * the ~120s review window), so a day-old "alive" lock is a recycled pid, not
377
+ * a live writer. */
378
+ const ALIVE_LOCK_TAKEOVER_MS = 864e5;
368
379
  /** P2-27 (v37): the commit-point ownership re-read. A transient read failure
369
380
  * (EACCES/EMFILE/antivirus hold) must not abort a valid RMW, so the read is
370
381
  * retried in place; only a still-unreadable lock fails the attempt. */
@@ -385,8 +396,11 @@ function isCommittedWarning(error) {
385
396
  * V27 G1.1: the lock-takeover decision as ONE pure function, so the protocol is
386
397
  * testable and exhaustive instead of being an inline expression inside the
387
398
  * acquisition loop:
388
- * - `none` the lock is fresh, or its holder is alive wait, never steal;
389
- * - `dead` a named holder that is gone, past the dead threshold;
399
+ * - `none` the lock is fresh, or its holder is alive within the alive
400
+ * window wait, never steal;
401
+ * - `dead` a named holder that is gone past the dead threshold, OR whose
402
+ * pid still probes alive but whose lock is older than the alive
403
+ * window (A2: a recycled pid must not hold the file forever);
390
404
  * - `empty` no body at all: nothing attributes it to a holder, so only the
391
405
  * wide `emptyAfterMs` window may reclaim it;
392
406
  * - `corrupt` a body with no parseable pid (a crash mid-write), past the 1h
@@ -401,7 +415,7 @@ function decideTakeover(probe) {
401
415
  const namedHolder = Number.isInteger(holder) && holder > 0;
402
416
  if (probe.body === "") return age > (probe.emptyAfterMs ?? 3e4) ? "empty" : "none";
403
417
  if (!namedHolder) return age > (probe.corruptAfterMs ?? 36e5) ? "corrupt" : "none";
404
- if (probe.alive(holder)) return "none";
418
+ if (probe.alive(holder)) return age > (probe.aliveAfterMs ?? 864e5) ? "dead" : "none";
405
419
  return age > (probe.deadAfterMs ?? 1e3) ? "dead" : "none";
406
420
  }
407
421
  /**
@@ -1692,6 +1706,9 @@ async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE
1692
1706
  let refuseMessage = "";
1693
1707
  let parsedBody = null;
1694
1708
  await transactIo(io, path, async (current) => {
1709
+ parsedBody = null;
1710
+ refuseMessage = "";
1711
+ assigned = 0;
1695
1712
  if (current !== null && current.trim() !== "") {
1696
1713
  let shape;
1697
1714
  try {
@@ -3887,9 +3904,10 @@ const SECRET_PATTERNS = [
3887
3904
  ["stripe key", /[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g],
3888
3905
  ["github fine-grained token", /github_pat_[A-Za-z0-9_]{20,}/g],
3889
3906
  ["google api key", /AIza[0-9A-Za-z_-]{30,}/g],
3890
- ["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi]
3907
+ ["bearer credential", /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi],
3908
+ ["basic credential", /\bBasic[\s]+[a-z0-9._~+/=]{16,}/gi]
3891
3909
  ];
3892
- const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]*[_\\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\\-][\\w-]*)?)\\b([\"\\']?[\\t ]*[:=][\\t ]*)([^\\r\\n]+)", "gi");
3910
+ const INLINE_ASSIGNMENT_PATTERN = /* @__PURE__ */ new RegExp("(^|[^\\w-])([\\w-]*[_\\-])?((?:token|api[_-]?key|secret|password|passwd|authorization)(?:[_\\-][\\w-]*)?)\\b([\"\\']?[\\t ]*[:=][\\t ]*)([^\\r\\n]+)", "gi");
3893
3911
  const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]{0,63}:\/\/[^\s:/@]+:)([^\s/@]+)@/gi;
3894
3912
  const PEM_PRIVATE_KEY_PATTERN = new RegExp(`-----BEGIN\\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\\s+)?PRIVATE\\s+KEY(?:\\s+BLOCK)?-----[\\s\\S]*?-----END\\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\\s+)?PRIVATE\\s+KEY(?:\\s+BLOCK)?-----`, "g");
3895
3913
  const BLOCK_KEY_ONLY_LINE = /(?:^|\s)([\w-]*[_\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\-][\w-]*)?)\s*:(?:\r)?$/i;
@@ -4996,15 +5014,18 @@ function frontmatterBlock(content) {
4996
5014
  * strict catalog cannot load". `frontmatterCatalogInvalid` publishes it and
4997
5015
  * `normalizeFrontmatter` decides each rewrite with the same predicate
4998
5016
  * (`yamlPlainScalarNeedsQuotes`), so the audit verdict and the write path can
4999
- * never disagree. Only single-line `key: value` entries are judged; a line with
5000
- * embedded breaks is skipped.
5001
- */
5002
- function unsafeFrontmatterEntries(block, nl) {
5017
+ * never disagree. A9 (audit P2-12): the scan is ENDING-AGNOSTIC lines are
5018
+ * split on `\n` with the per-line CR stripped, exactly the iteration the
5019
+ * rewrite path uses. The former split on the block's FIRST-LINE ending turned
5020
+ * a mixed-ending block (first line CRLF, entries LF) into one unsplit chunk
5021
+ * whose entries were all skipped, so an unquoted ` #` value read as CLEAN
5022
+ * while the catalog silently dropped it and the next edit would have quoted
5023
+ * it. Only single-line `key: value` entries are judged.
5024
+ */
5025
+ function unsafeFrontmatterEntries(block) {
5003
5026
  const found = [];
5004
- for (const line of block.split(nl)) {
5005
- const clean = withoutCr(line);
5006
- if (clean.includes("\n") || clean.includes("\r")) continue;
5007
- const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(clean);
5027
+ for (const rawLine of block.split("\n")) {
5028
+ const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(withoutCr(rawLine));
5008
5029
  if (!match) continue;
5009
5030
  const key = match[1];
5010
5031
  if (key === void 0) continue;
@@ -5153,7 +5174,7 @@ function readFrontmatterBlock(content) {
5153
5174
  return {
5154
5175
  frontmatter,
5155
5176
  body,
5156
- unsafeValues: unsafeFrontmatterEntries(found.block, found.nl),
5177
+ unsafeValues: unsafeFrontmatterEntries(found.block),
5157
5178
  strictFailed: strict === null,
5158
5179
  platformStringSplit: strict?.split ?? []
5159
5180
  };
@@ -6199,20 +6220,28 @@ var SkillLibrary = class {
6199
6220
  * Structure-health facts for one skill (rc.73 A1, 008 design): body
6200
6221
  * chars/density from SKILL.md, support groups from countSupportDirs, plus
6201
6222
  * optional usage counts (A2 churn dimension) when the caller has them.
6202
- * Derived, never persisted; null when the skill is unreadable.
6223
+ * Derived, never persisted. CONTRACT: `null` whenever the skill cannot be
6224
+ * read — a missing file AND any read failure (EACCES/EIO/…) both answer
6225
+ * null, so a whole health view degrades one ROW instead of throwing out of
6226
+ * its per-skill loop (A6, audit P2-10: the former code absorbed only
6227
+ * missing/EISDIR and let a transient win32 hold kill the entire view).
6203
6228
  */
6204
6229
  async assessHealth(rawName, thresholds = DEFAULT_HEALTH_THRESHOLDS, counts) {
6205
- const name = rawName.trim();
6206
- const content = await this.read(name);
6207
- if (content === null) return null;
6208
- return assessStructureHealth({
6209
- skillName: name,
6210
- bodyChars: content.length,
6211
- bodyText: content,
6212
- supportGroups: await this.countSupportDirs(name),
6213
- patchCount: counts?.patchCount,
6214
- readCount: counts?.readCount
6215
- }, thresholds);
6230
+ try {
6231
+ const name = rawName.trim();
6232
+ const content = await this.read(name);
6233
+ if (content === null) return null;
6234
+ return assessStructureHealth({
6235
+ skillName: name,
6236
+ bodyChars: content.length,
6237
+ bodyText: content,
6238
+ supportGroups: await this.countSupportDirs(name),
6239
+ patchCount: counts?.patchCount,
6240
+ readCount: counts?.readCount
6241
+ }, thresholds);
6242
+ } catch {
6243
+ return null;
6244
+ }
6216
6245
  }
6217
6246
  /** Best-effort audit trail entry; never blocks the mutation. */
6218
6247
  async audit(skillName, action, before, after, summary) {
@@ -6388,13 +6417,13 @@ var SkillLibrary = class {
6388
6417
  ok: false,
6389
6418
  message: `Skill "${normalized}" already exists.`
6390
6419
  };
6420
+ let markerWarning = "";
6391
6421
  if (origin !== "foreground") {
6392
- let markerDurabilityWarning = "";
6393
6422
  try {
6394
6423
  await this.io.writeText(markerPath(dir, "hermes-managed"), "");
6395
6424
  } catch (error) {
6396
- if (!isCommittedOnly(error)) throw error;
6397
- markerDurabilityWarning = error instanceof Error ? error.message : String(error);
6425
+ if (isCommittedOnly(error)) markerWarning = `the hermes-managed marker landed but its directory fsync failed — durability unconfirmed: ${error instanceof Error ? error.message : String(error)}`;
6426
+ else markerWarning = `hermes-managed marker write failed (${error instanceof Error ? error.message : String(error)}); the skill landed but is NOT lifecycle-managed — recreate or pin it manually`;
6398
6427
  }
6399
6428
  if (!await this.io.exists(createPath)) {
6400
6429
  await this.io.remove(markerPath(dir, "hermes-managed")).catch(() => {});
@@ -6404,7 +6433,6 @@ var SkillLibrary = class {
6404
6433
  message: `Skill "${normalized}" was archived concurrently while being created; the partial marker was removed — retry once the mover settles.`
6405
6434
  };
6406
6435
  }
6407
- if (markerDurabilityWarning !== "") createDurabilityWarning = createDurabilityWarning === "" ? markerDurabilityWarning : `${createDurabilityWarning}; marker: ${markerDurabilityWarning}`;
6408
6436
  }
6409
6437
  await this.audit(normalized, "create", null, onDisk, "created");
6410
6438
  this.notifyMutation({
@@ -6412,9 +6440,10 @@ var SkillLibrary = class {
6412
6440
  name: normalized,
6413
6441
  skillDir: dir
6414
6442
  });
6443
+ const warnings = [createDurabilityWarning !== "" ? `the write landed but the directory fsync failed — durability unconfirmed: ${createDurabilityWarning}` : "", markerWarning].filter((warning) => warning !== "");
6415
6444
  return {
6416
6445
  ok: true,
6417
- message: `Skill "${normalized}" created.${createDurabilityWarning === "" ? "" : ` (warning: the write landed but the directory fsync failed — durability unconfirmed: ${createDurabilityWarning})`}`,
6446
+ message: `Skill "${normalized}" created.${warnings.length === 0 ? "" : ` (warning: ${warnings.join("; ")})`}`,
6418
6447
  path: dir,
6419
6448
  ...norm.changed ? { normalizedFrontmatterFields: norm.fields } : {}
6420
6449
  };
@@ -6772,15 +6801,28 @@ var SkillLibrary = class {
6772
6801
  for (const entry of entries) if (entry.endsWith(".lock")) await this.sweepLockIfStranded(join(dir, supportDir, entry));
6773
6802
  }
6774
6803
  }
6804
+ /** R2 follow-up (audit of A2): the recycled-pid window from io's
6805
+ * `ALIVE_LOCK_TAKEOVER_MS`, applied to the stranded-lock sweepers. A lock
6806
+ * whose holder pid probes alive but whose mtime is older than the alive
6807
+ * window is a recycled pid, not a live writer (no write in this family
6808
+ * holds a lock for more than minutes) — without this, one recycled pid
6809
+ * blocked whole-tree snapshot recovery forever with a "retry once the
6810
+ * write completes" message that could never become true. A backend without
6811
+ * `mtime` keeps the conservative refuse-on-alive posture. */
6812
+ async lockHolderAgedOut(lockPath) {
6813
+ const mtime = await this.io.mtime?.(lockPath).catch(() => null);
6814
+ return typeof mtime === "number" && Date.now() - mtime > 864e5;
6815
+ }
6775
6816
  /** Remove `lockPath` only when its body has the writer-lock `pid:token`
6776
- * shape AND the holder pid is not alive; anything else (a user support file
6777
- * or a live writer's lock) is left untouched. */
6817
+ * shape AND the holder pid is not alive (or is an aged-out recycled pid —
6818
+ * R2 follow-up); anything else (a user support file or a live writer's
6819
+ * lock) is left untouched. */
6778
6820
  async sweepLockIfStranded(lockPath) {
6779
6821
  const body = await this.io.readText(lockPath).catch(() => null);
6780
6822
  if (body === null) return;
6781
6823
  const pid = parseLockBody(body);
6782
6824
  if (pid === null) return;
6783
- if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return;
6825
+ if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid) && !await this.lockHolderAgedOut(lockPath)) return;
6784
6826
  await this.io.remove(lockPath).catch(() => {});
6785
6827
  }
6786
6828
  /** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
@@ -6805,7 +6847,7 @@ var SkillLibrary = class {
6805
6847
  if (body === null) return;
6806
6848
  const pid = parseLockBody(body);
6807
6849
  if (pid === null) return;
6808
- if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) throw new Error(`snapshot restore refused: ${label} is being written (write lock present); retry once the write completes`);
6850
+ if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid) && !await this.lockHolderAgedOut(lockPath)) throw new Error(`snapshot restore refused: ${label} is being written (write lock present); retry once the write completes`);
6809
6851
  await this.io.remove(lockPath).catch(() => {});
6810
6852
  }
6811
6853
  async archive(rawName, options = {}) {
@@ -7975,4 +8017,4 @@ function sessionAudited(ctx, sessionId, sessionScoped) {
7975
8017
  return false;
7976
8018
  }
7977
8019
  //#endregion
7978
- export { AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEAD_LOCK_TAKEOVER_MS, DEDUP_MAX_PAIR_COMPARISONS, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_MODEL, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MEMORY_REVIEW_MODEL, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_MODEL, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DISPATCH_EVENT_TYPES, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, EMPTY_LOCK_TAKEOVER_MS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FAMILY_SESSION_TOOL_NAMES, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, INSTANCE_KEYS, LOCK_BODY_RE, LOCK_SUFFIX, LOCK_TEAR_TAKEOVER_MS, LOW_QUALITY_THRESHOLD, LostWriteLock, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MAX_TIMER_DELAY_MS, MEMORY_GUIDANCE_SECTION_ORDER, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, NATIVE_CALL_EVENT, NATIVE_RESULT_EVENT, PATTERN_OVERLAP, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, PTC_DISPATCH_EVENT, PTC_DISPATCH_START_EVENT, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILLS_GUIDANCE_SECTION_ORDER, SKILL_ACTION_REQUIRED_FIELDS, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, THREAT_EXEMPTION_HINT, ToolDispatchNormalizer, ToolDispatchPayloadError, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorLifecycleFields, applyCuratorMetaFields, assertDispatchPayload, assertSkillsRootAliasRetired, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, callingScope, claimInstance, clampedNumber, clearReviewChannel, collectReadSkillNames, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, countDispatches, createGateSet, decideTakeover, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldToolDispatches, foldTurn, frontmatterBlock, frontmatterCatalogInvalid, getRecord, instanceClaimKey, instanceClaimedWriteSites, instanceHolder, isAbsent, isCommittedWarning, isMissingPath, isPresent, isProcessAlive, isProgramToolName, isReviewChannelSession, isSkillReadToolName, isSkillToolName, isUnknown, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadRowOverrides, loadSuppressedNames, loadUsage, makeSerialQueue, mapProbe, markAgentCreated, markReviewChannel, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, neutralizePromptVariables, newSkillLibrary, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, pendingSelfCleanup, persistedWriteSite, persistedWriteSites, probeAbsent, probeList, probeMtime, probePresent, probeReason, probeUnknown, readDispatchSignal, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, releaseInstance, renameWithRetry, renderCuratorReportMarkdown, resolveExecOrigins, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, scopedProbeReport, sessionAudited, sessionSeesFamilyTools, skillReadNameOf, skillsRoot, suppressedFile, sweepReviewChannelSessions, transactIo, transactTaskGuard, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, valueOr, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
8020
+ export { ALIVE_LOCK_TAKEOVER_MS, AUTHORING_DESCRIPTION_BAR, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEAD_LOCK_TAKEOVER_MS, DEDUP_MAX_PAIR_COMPARISONS, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_MODEL, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MEMORY_REVIEW_MODEL, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_MODEL, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, DISPATCH_EVENT_TYPES, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, EMPTY_LOCK_TAKEOVER_MS, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FAMILY_SESSION_TOOL_NAMES, FORBIDDEN_CONTROL_KEYS, HEALTH_STAMP_RE, INSTANCE_KEYS, LOCK_BODY_RE, LOCK_SUFFIX, LOCK_TEAR_TAKEOVER_MS, LOW_QUALITY_THRESHOLD, LostWriteLock, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MAX_TIMER_DELAY_MS, MEMORY_GUIDANCE_SECTION_ORDER, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, NATIVE_CALL_EVENT, NATIVE_RESULT_EVENT, PATTERN_OVERLAP, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, PTC_DISPATCH_EVENT, PTC_DISPATCH_START_EVENT, QUALITY_WEIGHTS, RESTRUCTURE_TARGET_RE, SKILLS_GUIDANCE, SKILLS_GUIDANCE_SECTION_ORDER, SKILL_ACTION_REQUIRED_FIELDS, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, THREAT_EXEMPTION_HINT, ToolDispatchNormalizer, ToolDispatchPayloadError, advanceReview, allowRowCollisions, appendEvolutionEvent, applyCuratorLifecycleFields, applyCuratorMetaFields, assertDispatchPayload, assertSkillsRootAliasRetired, assessStructureHealth, authoringFeedback, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpUse, bumpView, callingScope, claimInstance, clampedNumber, clearReviewChannel, collectReadSkillNames, composePresetComposition, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, countDispatches, createGateSet, decideTakeover, duplicateHeadings, emptyRecord, evaluateThreat, eventsFile, evolutionEventPayloadIssue, evolutionHome, evolutionIoAdapter, evolutionRoot, findDriftSignal, foldCuratorFields, foldToolDispatches, foldTurn, frontmatterBlock, frontmatterCatalogInvalid, getRecord, instanceClaimKey, instanceClaimedWriteSites, instanceHolder, isAbsent, isCommittedWarning, isMissingPath, isPresent, isProcessAlive, isProgramToolName, isReviewChannelSession, isSkillReadToolName, isSkillToolName, isUnknown, latestActivityAt, lifecycleCandidate, listEventArchives, loadMutations, loadRowOverrides, loadSuppressedNames, loadUsage, makeSerialQueue, mapProbe, markAgentCreated, markReviewChannel, markerEntryName, memoryRoot, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, neutralizePromptVariables, newSkillLibrary, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, observeEvent, overlongLines, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseLockBody, pendingSelfCleanup, persistedWriteSite, persistedWriteSites, probeAbsent, probeList, probeMtime, probePresent, probeReason, probeUnknown, readDispatchSignal, readEvolutionEvents, readEvolutionTimeline, recordMutation, redactSecrets, relatedSkillNames, releaseInstance, renameWithRetry, renderCuratorReportMarkdown, resolveExecOrigins, resolveOrigins, resolveRootConfig, resolveSkillsRoot, retainEventArchives, reviewPrompt, saveSuppressedNames, saveUsage, scanContentThreats, scanMemoryThreats, scanThreats, scopedProbeReport, sessionAudited, sessionSeesFamilyTools, skillReadNameOf, skillsRoot, suppressedFile, sweepReviewChannelSessions, transactIo, transactTaskGuard, updateSuppressedNames, usageFile, usageObserved, validateFrontmatter, validateRestructureTarget, valueOr, verifyPromptBundle, writeDurableTmp, yamlPlainScalarNeedsQuotes };
package/lib/types/io.d.ts CHANGED
@@ -173,7 +173,10 @@ export declare const LOCK_BODY_RE: RegExp;
173
173
  /** Parse a writer-lock body into its holder pid. `null` when the body does
174
174
  * not have the `pid:token` shape at all (e.g. a user support file named
175
175
  * `*.lock`) — callers leave such files alone. A shape-matching body always
176
- * yields a number (possibly `0`, which `isProcessAlive` treats as dead). */
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. */
177
180
  export declare function parseLockBody(body: string): number | null;
178
181
  /**
179
182
  * V27 G0.2 (EVO-IO-01): the write lock named by this claim is no longer ours
@@ -199,6 +202,14 @@ export declare const EMPTY_LOCK_TAKEOVER_MS = 30000;
199
202
  /** A body with no parseable pid (crash mid-write): 1h, far above any legal hold
200
203
  * and far below "forever". */
201
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;
202
213
  /**
203
214
  * V27 G1.3: the error `commitTmp` throws when the rename landed but the parent
204
215
  * directory fsync failed — the bytes ARE visible, only their durability is
@@ -224,13 +235,17 @@ interface TakeoverProbe {
224
235
  deadAfterMs?: number;
225
236
  emptyAfterMs?: number;
226
237
  corruptAfterMs?: number;
238
+ aliveAfterMs?: number;
227
239
  }
228
240
  /**
229
241
  * V27 G1.1: the lock-takeover decision as ONE pure function, so the protocol is
230
242
  * testable and exhaustive instead of being an inline expression inside the
231
243
  * acquisition loop:
232
- * - `none` the lock is fresh, or its holder is alive wait, never steal;
233
- * - `dead` a named holder that is gone, past the dead threshold;
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);
234
249
  * - `empty` no body at all: nothing attributes it to a holder, so only the
235
250
  * wide `emptyAfterMs` window may reclaim it;
236
251
  * - `corrupt` a body with no parseable pid (a crash mid-write), past the 1h
@@ -372,7 +372,11 @@ export declare class SkillLibrary {
372
372
  * Structure-health facts for one skill (rc.73 A1, 008 design): body
373
373
  * chars/density from SKILL.md, support groups from countSupportDirs, plus
374
374
  * optional usage counts (A2 churn dimension) when the caller has them.
375
- * Derived, never persisted; null when the skill is unreadable.
375
+ * Derived, never persisted. CONTRACT: `null` whenever the skill cannot be
376
+ * read — a missing file AND any read failure (EACCES/EIO/…) both answer
377
+ * null, so a whole health view degrades one ROW instead of throwing out of
378
+ * its per-skill loop (A6, audit P2-10: the former code absorbed only
379
+ * missing/EISDIR and let a transient win32 hold kill the entire view).
376
380
  */
377
381
  assessHealth(rawName: string, thresholds?: SkillHealthThresholds, counts?: {
378
382
  patchCount?: number;
@@ -436,9 +440,19 @@ export declare class SkillLibrary {
436
440
  * stolen by the sweep. A dead-pid residue would otherwise permanently
437
441
  * refuse archive/restore. */
438
442
  private deleteStrandedLocks;
443
+ /** R2 follow-up (audit of A2): the recycled-pid window from io's
444
+ * `ALIVE_LOCK_TAKEOVER_MS`, applied to the stranded-lock sweepers. A lock
445
+ * whose holder pid probes alive but whose mtime is older than the alive
446
+ * window is a recycled pid, not a live writer (no write in this family
447
+ * holds a lock for more than minutes) — without this, one recycled pid
448
+ * blocked whole-tree snapshot recovery forever with a "retry once the
449
+ * write completes" message that could never become true. A backend without
450
+ * `mtime` keeps the conservative refuse-on-alive posture. */
451
+ private lockHolderAgedOut;
439
452
  /** Remove `lockPath` only when its body has the writer-lock `pid:token`
440
- * shape AND the holder pid is not alive; anything else (a user support file
441
- * or a live writer's lock) is left untouched. */
453
+ * shape AND the holder pid is not alive (or is an aged-out recycled pid —
454
+ * R2 follow-up); anything else (a user support file or a live writer's
455
+ * lock) is left untouched. */
442
456
  private sweepLockIfStranded;
443
457
  /** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
444
458
  * only a single, non-traversing path component is safe. Dotfiles
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.4.0",
4
+ "version": "0.4.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },