@lmzhen/dsh-evolution-core 0.30.30 → 0.30.32

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.
@@ -300,7 +300,7 @@ export declare function loadVersionContent(root: string, io: EvolutionIoLike, na
300
300
  */
301
301
  export declare function recordVersions(root: string, io: EvolutionIoLike, input: VersionRecordInput, keep: number): Promise<RecordedVersions | null>;
302
302
  /** What kind of change one recorded version carries, as a CLOSED set. */
303
- export type VersionActionKind = 'baseline' | 'create' | 'patch' | 'update' | 'restore' | 'delete' | 'archive' | 'consolidate' | 'restructure' | 'merge' | 'support-write' | 'support-remove' | 'other';
303
+ export type VersionActionKind = 'baseline' | 'create' | 'patch' | 'update' | 'restore' | 'delete' | 'archive' | 'consolidate' | 'restructure' | 'extract' | 'merge' | 'support-write' | 'support-remove' | 'other';
304
304
  /**
305
305
  * Classify one version action for display. Fail-closed like {@link versionTarget}: an action this
306
306
  * module does not know reads as `other` rather than being guessed, and `tests/skill-history.spec.ts`
@@ -85,7 +85,7 @@ export function orderVersions(versions) {
85
85
  /** The actions that record a SUPPORT FILE's bytes (they share the skill's version index). */
86
86
  const SUPPORT_ACTIONS = ['write_file', 'remove_file'];
87
87
  /** The actions that record the skill's own SKILL.md body. */
88
- const CONTENT_ACTIONS = ['create', 'update', 'patch', 'consolidate', 'restructure', 'restore', 'merge'];
88
+ const CONTENT_ACTIONS = ['create', 'update', 'patch', 'consolidate', 'restructure', 'extract', 'restore', 'merge'];
89
89
  /**
90
90
  * Which artifact one recorded version holds, from the action that produced it.
91
91
  * @param action - the entry's action label.
@@ -610,6 +610,7 @@ export function versionActionKind(action) {
610
610
  case 'archive': return 'archive';
611
611
  case 'consolidate': return 'consolidate';
612
612
  case 'restructure': return 'restructure';
613
+ case 'extract': return 'extract';
613
614
  case 'merge': return 'merge';
614
615
  case 'write_file': return 'support-write';
615
616
  case 'remove_file': return 'support-remove';
@@ -26,7 +26,7 @@ export function sessionReadSkillNames(ctx, session) {
26
26
  }
27
27
  /** Actions whose target must have been read first (create is authorship, so it is exempt). */
28
28
  export const READ_REQUIRED_ACTIONS = Object.freeze([
29
- 'edit', 'update', 'patch', 'delete', 'write_file', 'remove_file', 'restructure',
29
+ 'edit', 'update', 'patch', 'delete', 'write_file', 'remove_file', 'restructure', 'extract',
30
30
  ]);
31
31
  /**
32
32
  * Does this one op write a skill the caller never read?
@@ -36,6 +36,7 @@ import { type WriteAuthority } from './write-authority.ts';
36
36
  import { transactIo, type EvolutionIoLike } from './io.ts';
37
37
  import { type Probe } from './probe.ts';
38
38
  import type { SkillLimits } from './limits.ts';
39
+ import { type ExtractPlanDraft } from './extract-plan.ts';
39
40
  import { type MutationRecord } from './mutations.ts';
40
41
  import { type RevisionAnchor, type RevisionFile } from './skill-revision.ts';
41
42
  import { type UnitHead, type SkillVersion } from './skill-history.ts';
@@ -323,18 +324,6 @@ export declare function markerEntryName(marker: 'bundled' | 'hub-installed' | 'p
323
324
  * Single source shared with the plan validator. */
324
325
  export declare function validateRestructureTarget(filePath: string): string | null;
325
326
  export declare function validateSupportPath(filePath: string): string | null;
326
- /**
327
- * Support-directory references in a markdown body (009 kernel): `references/…`,
328
- * `templates/…`, `scripts/…`, `assets/…` relative links — any extension and
329
- * nested paths (v7 audit P3-1: `.md`-only matching missed `scripts/run.sh` and
330
- * `references/sub/x.md`, both of which dangle just like a `.md` link once the
331
- * source package is archived). Pure — who checks them and what the verdict is
332
- * belongs to the caller's context (a moved/appended body whose references
333
- * travel with an archived source is a dangling link; a restructure pointer is
334
- * a fresh link to a file written in the same plan). `..` traversal is a
335
- * different defect class (path validation owns it) and is not a support link.
336
- */
337
- export declare function supportRefs(content: string): string[];
338
327
  export declare class SkillLibrary {
339
328
  readonly root: string;
340
329
  readonly limits: SkillLimits;
@@ -681,6 +670,15 @@ export declare class SkillLibrary {
681
670
  * restructure (and future reference-mode consolidations) never implement
682
671
  * two-phase commit themselves.
683
672
  */
673
+ /**
674
+ * Apply one G1 ExtractPlan as one cross-skill unit. This is a thin adapter:
675
+ * selection, residual, pointer and support closure were already decided by
676
+ * planExtract; this method only projects them into ordered kernel operations.
677
+ * @param plan - pure plan from extract-plan.ts.
678
+ * @param origin - write face recorded in the audit ledger.
679
+ * @returns the kernel result; a refusal writes nothing.
680
+ */
681
+ extract(plan: ExtractPlanDraft, origin?: WriteOrigin): Promise<SkillActionResult>;
684
682
  private applyTreeChange;
685
683
  /**
686
684
  * Restore one skill from `.archive/` back to the active root. Hermes-style
@@ -32,7 +32,7 @@
32
32
  * re-probes after its copies so a mid-copy writer demotes to `skipped`
33
33
  * (OPT-07); the movers' probe→rename window is owned by the io.ts protocol.
34
34
  */
35
- import { basename, dirname, join, resolve } from 'node:path';
35
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
36
36
  import { authorityFrom } from "./write-authority.js";
37
37
  import { resolveCitations } from "./citations.js";
38
38
  import { scanContentThreats } from "./threats.js";
@@ -45,7 +45,9 @@ import { applyReferenceRewrite, describeReferenceRewrite, planReferenceRewrite,
45
45
  import { exceedsContentLimit, frontmatterBlock, normalizeFrontmatter, parseFrontmatter, shrinksOverLimit, validateFrontmatter } from "./frontmatter.js";
46
46
  import { FUZZY_MAX_PATTERN_CHARS, FUZZY_MAX_WORK, trimPatternBoundaries, fuzzyPatch } from "./fuzzy-match.js";
47
47
  import { makeSerialQueue } from "./serial.js";
48
- import { h2Sections } from "./document-body.js";
48
+ import { supportRefs } from "./support-refs.js";
49
+ import { selectSections } from "./section-selection.js";
50
+ import { projectExtractBodies } from "./extract-plan.js";
49
51
  import { diffFindings } from "./skill-lint.js";
50
52
  import { contentHash, loadMutations, recordMutation } from "./mutations.js";
51
53
  import { revisionFingerprint, revisionSupportDigest } from "./skill-revision.js";
@@ -383,58 +385,24 @@ export function validateSupportPath(filePath) {
383
385
  }
384
386
  return null;
385
387
  }
386
- /**
387
- * Support-directory references in a markdown body (009 kernel): `references/…`,
388
- * `templates/…`, `scripts/…`, `assets/…` relative links — any extension and
389
- * nested paths (v7 audit P3-1: `.md`-only matching missed `scripts/run.sh` and
390
- * `references/sub/x.md`, both of which dangle just like a `.md` link once the
391
- * source package is archived). Pure — who checks them and what the verdict is
392
- * belongs to the caller's context (a moved/appended body whose references
393
- * travel with an archived source is a dangling link; a restructure pointer is
394
- * a fresh link to a file written in the same plan). `..` traversal is a
395
- * different defect class (path validation owns it) and is not a support link.
396
- */
397
- export function supportRefs(content) {
398
- const refs = [];
399
- for (const match of content.matchAll(/\b(?:references|templates|scripts|assets)\/[A-Za-z0-9._/-]+/g)) {
400
- const ref = match[0];
401
- if (ref.includes('..'))
402
- continue;
403
- refs.push(ref);
404
- }
405
- return refs;
406
- }
407
388
  function planRestructureSections(body, moves) {
408
- // G3: the anchor scan has ONE home — document-body's `h2Sections`. A candidate list built from
409
- // that function can never name a heading this planner refuses (missing / ambiguous), and the H2
410
- // boundary rule (a section spans to the next H2 or EOF) lives there too. The four rejections
411
- // below are the store's contract and keep their exact wording.
389
+ // One scan (h2Sections) and one selection rule; extract reuses the same
390
+ // selector for start/end sub-blocks, so restructure and extract cannot drift
391
+ // on heading ambiguity, empty sections or duplicate moves.
392
+ const selection = selectSections(body, moves.map(move => ({ heading: move.heading })));
393
+ if (!selection.ok)
394
+ return { error: selection.message };
412
395
  const lines = body.split('\n');
413
- const sections = h2Sections(body);
414
- const spans = [];
415
- for (const move of moves) {
416
- const wanted = move.heading.trim();
417
- const matching = sections.filter(section => section.title === wanted);
418
- if (matching.length === 0)
419
- return { error: `no "## ${wanted}" heading in the body` };
420
- if (matching.length > 1)
421
- return { error: `heading "## ${wanted}" appears ${matching.length} times (ambiguous anchor)` };
422
- const section = matching[0];
423
- if (section === undefined)
424
- return { error: `no "## ${wanted}" heading in the body` };
425
- const { start, end } = section;
426
- if (end === start + 1)
427
- return { error: `heading "## ${wanted}" has an empty section` };
428
- if (spans.some(span => start === span.start))
429
- return { error: `heading "## ${wanted}" is moved twice` };
430
- spans.push({
431
- start,
432
- end,
433
- rel: move.toFile,
434
- heading: wanted,
435
- text: lines.slice(start, end).join('\n'),
436
- });
437
- }
396
+ const spans = selection.sections.map(section => {
397
+ const move = moves[section.requestIndex];
398
+ return {
399
+ start: section.start,
400
+ end: section.end,
401
+ rel: move?.toFile ?? '',
402
+ heading: section.heading,
403
+ text: section.text,
404
+ };
405
+ });
438
406
  // Rebuild the body line-wise: a moved section collapses to its pointer line.
439
407
  const byStart = new Map(spans.map(span => [span.start, span]));
440
408
  const rebuilt = [];
@@ -2849,45 +2817,151 @@ export class SkillLibrary {
2849
2817
  * restructure (and future reference-mode consolidations) never implement
2850
2818
  * two-phase commit themselves.
2851
2819
  */
2820
+ /**
2821
+ * Apply one G1 ExtractPlan as one cross-skill unit. This is a thin adapter:
2822
+ * selection, residual, pointer and support closure were already decided by
2823
+ * planExtract; this method only projects them into ordered kernel operations.
2824
+ * @param plan - pure plan from extract-plan.ts.
2825
+ * @param origin - write face recorded in the audit ledger.
2826
+ * @returns the kernel result; a refusal writes nothing.
2827
+ */
2828
+ async extract(plan, origin = this.authority.origin) {
2829
+ if (plan.outcome === 'owner-gap' || plan.owner.mode !== 'covered')
2830
+ return { ok: false, message: '提取被拒绝:owner-gap 不是写操作。' };
2831
+ if (plan.support.unresolved.length > 0 || plan.support.dangling.length > 0)
2832
+ return { ok: false, message: '提取被拒绝:support closure 存在 unresolved/dangling。' };
2833
+ if (plan.parts.length === 0)
2834
+ return { ok: false, message: '提取被拒绝:没有 part。' };
2835
+ const source = plan.source.trim();
2836
+ const target = plan.target.name.trim();
2837
+ if (source === target)
2838
+ return { ok: false, message: '提取被拒绝:source 与 target 相同。' };
2839
+ if (plan.target.exists !== true)
2840
+ return { ok: false, message: '提取被拒绝:target 不存在;owner-gap 不自动 create。' };
2841
+ const sourceDir = this.dirOf(source);
2842
+ const targetDir = this.dirOf(target);
2843
+ const sourceMd = await this.io.readText(join(sourceDir, 'SKILL.md'));
2844
+ const targetMd = await this.io.readText(join(targetDir, 'SKILL.md'));
2845
+ if (sourceMd === null)
2846
+ return { ok: false, message: '技能「' + source + '」不存在。' };
2847
+ if (targetMd === null)
2848
+ return { ok: false, message: '技能「' + target + '」不存在。' };
2849
+ if (plan.anchors !== undefined) {
2850
+ const sourceHash = contentHash(sourceMd);
2851
+ const targetHash = contentHash(targetMd);
2852
+ if (sourceHash !== plan.anchors.sourceMdSha256 || targetHash !== plan.anchors.targetMdSha256) {
2853
+ return { ok: false, message: '提取被拒绝:source/target 在计划之后发生了变化;请重新计划。' };
2854
+ }
2855
+ }
2856
+ const projected = projectExtractBodies(plan, sourceMd, targetMd);
2857
+ if (!projected.ok)
2858
+ return { ok: false, message: '提取被拒绝:' + projected.message };
2859
+ const sourceNew = projected.projection.sourceAfter;
2860
+ const targetNew = projected.projection.targetAfter;
2861
+ const ops = [];
2862
+ for (const move of [...plan.support.shared, ...plan.support.exclusive]) {
2863
+ const fromPath = join(sourceDir, ...move.from.split('/'));
2864
+ const toPath = join(targetDir, ...move.to.split('/'));
2865
+ const content = await this.io.readText(fromPath);
2866
+ if (content === null)
2867
+ return { ok: false, message: '提取被拒绝:support 文件不存在 ' + move.from };
2868
+ const previous = await this.readForCas(toPath);
2869
+ ops.push({ kind: 'write', target: toPath, content, expected: previous, skill: target });
2870
+ }
2871
+ for (const file of plan.support.external) {
2872
+ if (file.content === undefined)
2873
+ return { ok: false, message: '提取被拒绝:external support 缺少已验证的内容 ' + file.from };
2874
+ if (file.to.startsWith('/') || file.to.includes('..') || !/^(?:references|templates|scripts|assets)\//.test(file.to)) {
2875
+ return { ok: false, message: '提取被拒绝:external target 不安全 ' + file.to };
2876
+ }
2877
+ const toPath = join(targetDir, ...file.to.split('/'));
2878
+ const previous = await this.readForCas(toPath);
2879
+ ops.push({ kind: 'write', target: toPath, content: file.content, expected: previous, skill: target });
2880
+ }
2881
+ ops.push({ kind: 'write', target: join(targetDir, 'SKILL.md'), content: targetNew, expected: targetMd, skill: target });
2882
+ for (const move of plan.support.exclusive) {
2883
+ const sourcePath = join(sourceDir, ...move.from.split('/'));
2884
+ const previous = await this.readForCas(sourcePath);
2885
+ if (previous === null)
2886
+ return { ok: false, message: '提取被拒绝:待删除 support 不存在 ' + move.from };
2887
+ ops.push({ kind: 'delete', target: sourcePath, expected: previous, skill: source });
2888
+ }
2889
+ ops.push({ kind: 'write', target: join(sourceDir, 'SKILL.md'), content: sourceNew, expected: sourceMd, skill: source });
2890
+ return this.applyTreeChange({
2891
+ name: source,
2892
+ origin,
2893
+ protection: 'write',
2894
+ roots: [{ name: source, protection: 'write' }, { name: target, protection: 'write' }],
2895
+ writes: [],
2896
+ ops,
2897
+ auditAction: 'extract',
2898
+ auditSummary: 'extract ' + plan.parts.map(part => part.heading).join(', ') + ' from ' + source + ' to ' + target,
2899
+ eventAction: 'extract',
2900
+ });
2901
+ }
2852
2902
  async applyTreeChange(plan) {
2853
- const name = plan.name.trim();
2854
- const badName = this.badName(name, { allowReserved: true });
2855
- if (badName)
2856
- return { ok: false, message: badName };
2857
- const dir = this.dirOf(name);
2858
- const md = await this.io.readText(join(dir, 'SKILL.md'));
2859
- if (!md)
2860
- return { ok: false, message: `技能「${name}」不存在。` };
2861
- const protection = plan.protection === 'write'
2862
- ? await this.writeProtection(name, plan.origin)
2863
- : plan.protection === 'delete'
2864
- ? await this.deleteProtection(name)
2865
- : null;
2866
- if (protection)
2867
- return { ok: false, message: `技能「${name}」受保护(${protection})。` };
2868
- // Pre-read EVERY write target: the rollback bytes are kernel-owned and the
2869
- // caller cannot fabricate them. The same read feeds the append semantics of
2870
- // restructure (the caller re-reads for its own construction — kernel reads
2871
- // again because the bytes it restores must be the bytes on disk at commit).
2903
+ const primaryName = plan.name.trim();
2904
+ const declared = plan.roots ?? [{ name: primaryName, protection: plan.protection }];
2905
+ const roots = [];
2906
+ for (const root of declared) {
2907
+ const name = root.name.trim();
2908
+ if (roots.some(entry => entry.name === name))
2909
+ return { ok: false, message: '目录树变更声明了重复的根「' + name + '」。' };
2910
+ const badName = this.badName(name, { allowReserved: true });
2911
+ if (badName)
2912
+ return { ok: false, message: badName };
2913
+ const dir = this.dirOf(name);
2914
+ const body = await this.io.readText(join(dir, 'SKILL.md'));
2915
+ if (!body)
2916
+ return { ok: false, message: '技能「' + name + '」不存在。' };
2917
+ const protection = root.protection === 'write'
2918
+ ? await this.writeProtection(name, plan.origin)
2919
+ : root.protection === 'delete'
2920
+ ? await this.deleteProtection(name)
2921
+ : null;
2922
+ if (protection)
2923
+ return { ok: false, message: '技能「' + name + '」受保护(' + protection + ')。' };
2924
+ roots.push({ name, protection: root.protection, dir });
2925
+ }
2926
+ if (!roots.some(root => root.name === primaryName)) {
2927
+ return { ok: false, message: '目录树变更的 primary skill「' + primaryName + '」不在 roots 中。' };
2928
+ }
2929
+ const provided = plan.ops ?? [
2930
+ ...plan.writes.map(write => ({ kind: 'write', ...write })),
2931
+ ...(plan.deletes ?? []).map(del => ({ kind: 'delete', ...del })),
2932
+ ];
2933
+ if (provided.length === 0)
2934
+ return { ok: false, message: '目录树变更为空。' };
2872
2935
  const landing = [];
2873
- for (const write of plan.writes) {
2874
- // A1-11 (v18): a read failure (EACCES/EIO) is NOT "missing" —
2875
- // treating it as null made the rollback delete a file it could not
2876
- // restore. Refuse the whole plan before any write.
2936
+ for (const op of provided) {
2937
+ const owner = roots.find(root => {
2938
+ const rel = relative(root.dir, op.target);
2939
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
2940
+ });
2941
+ if (owner === undefined)
2942
+ return { ok: false, message: '目标不属于任何声明的技能根:' + op.target };
2943
+ const skill = op.skill ?? owner.name;
2944
+ if (!roots.some(root => root.name === skill))
2945
+ return { ok: false, message: '目标声明的技能「' + skill + '」不在 roots 中:' + op.target };
2877
2946
  let previous;
2878
2947
  try {
2879
- previous = await this.io.readText(write.target);
2948
+ previous = await this.io.readText(op.target);
2880
2949
  }
2881
2950
  catch (error) {
2882
- return { ok: false, message: `拒绝目录树变更:无法安全预读 ${write.target}(${error instanceof Error ? error.message : String(error)});未执行任何写入` };
2951
+ return { ok: false, message: '拒绝目录树变更:无法安全预读 ' + op.target + '(' + (error instanceof Error ? error.message : String(error)) + ');未执行任何写入' };
2883
2952
  }
2884
- if (Buffer.byteLength(write.content, 'utf8') > this.limits.maxSkillFileBytes) {
2885
- return { ok: false, message: `写入超过 ${this.limits.maxSkillFileBytes} 字节:${write.target}` };
2953
+ if (op.kind === 'delete' && previous === null)
2954
+ return { ok: false, message: '拒绝目录树变更:删除目标不存在 ' + op.target };
2955
+ if (op.kind === 'write') {
2956
+ if (Buffer.byteLength(op.content, 'utf8') > this.limits.maxSkillFileBytes) {
2957
+ return { ok: false, message: '写入超过 ' + this.limits.maxSkillFileBytes + ' 字节:' + op.target };
2958
+ }
2959
+ const threat = this.contentThreatBlock(op.content);
2960
+ if (threat)
2961
+ return { ok: false, message: threat };
2886
2962
  }
2887
- const threat = this.contentThreatBlock(write.content);
2888
- if (threat)
2889
- return { ok: false, message: threat };
2890
- landing.push({ target: write.target, content: write.content, previous, expected: write.expected });
2963
+ const rel = relative(owner.dir, op.target).split(String.fromCharCode(92)).join('/');
2964
+ landing.push({ kind: op.kind, target: op.target, content: op.kind === 'write' ? op.content : null, previous, expected: op.expected, skill, dir: owner.dir, rel });
2891
2965
  }
2892
2966
  const written = [];
2893
2967
  let durabilityWarning = '';
@@ -2895,61 +2969,31 @@ export class SkillLibrary {
2895
2969
  for (const entry of landing) {
2896
2970
  try {
2897
2971
  if (this.transact) {
2898
- // v22 (LOCK-1): CAS write — the previous blind writeText committed
2899
- // plan-time bytes over whatever was on disk, so a concurrent
2900
- // patch/update (another process, shared DSH_HOME) between the
2901
- // pre-read above and this write was silently overwritten (lost
2902
- // update; both writers reported success). The task only commits
2903
- // when the disk still holds the pre-read baseline; any drift
2904
- // aborts the plan into the rollback below.
2905
- // v24 (V24-01): the CAS baseline is the caller's PLAN-TIME bytes
2906
- // (`expected`) when the write carries them — the pre-read above
2907
- // only supplies the rollback bytes. Lock-1's own pre-read baseline
2908
- // still left a window for appends/merges built from an earlier
2909
- // read: a concurrent write landing between planning and the
2910
- // pre-read became the CAS baseline, so plan-time content
2911
- // (composed WITHOUT it) committed cleanly over it — lost update,
2912
- // both writers successful. Comparing against the plan-time bytes
2913
- // closes the whole planning→commit span.
2914
- // P3 (v22; gate fix 0.3.66): the flag lives in a holder so the
2915
- // control-flow analysis (which cannot see the transact callback's
2916
- // assignment) does not narrow it to a literal `false`.
2917
2972
  const baseline = entry.expected === undefined ? entry.previous : entry.expected;
2918
2973
  const drift = { seen: false };
2919
- // S1.4 (v37 P2-7): a transact backend that never invokes the task
2920
- // (the V6-19 contract) would leave BOTH holders at their initial
2921
- // values and this write would be reported as a success.
2922
2974
  const ran = { done: false };
2923
2975
  await this.transact(this.io, entry.target, (current) => {
2924
- // v43 audit (FLOW3-1): per-attempt reset. This callback re-runs on a
2925
- // lock-loss retry, and a stale `drift.seen` from the previous
2926
- // attempt threw "concurrent modification" even when the retry's own
2927
- // write had landed — a partial tree change reported as a failure.
2928
2976
  drift.seen = false;
2929
2977
  ran.done = true;
2930
2978
  if (current !== baseline) {
2931
2979
  drift.seen = true;
2932
2980
  return current;
2933
2981
  }
2934
- return entry.content;
2982
+ return entry.kind === 'delete' ? null : entry.content;
2935
2983
  });
2936
- if (!ran.done) {
2937
- throw new Error(`internal error: the write transaction for ${entry.target} did not invoke the task; nothing was written`);
2938
- }
2939
- if (drift.seen) {
2940
- throw new Error(`concurrent modification detected: ${entry.target} changed after the plan was computed (a concurrent writer won the race); no further writes were performed`);
2941
- }
2984
+ if (!ran.done)
2985
+ throw new Error('internal error: the write transaction for ' + entry.target + ' did not invoke the task; nothing was written');
2986
+ if (drift.seen)
2987
+ throw new Error('concurrent modification detected: ' + entry.target + ' changed after the plan was computed (a concurrent writer won the race); no further writes were performed');
2988
+ }
2989
+ else if (entry.kind === 'delete') {
2990
+ await this.io.remove(entry.target);
2942
2991
  }
2943
2992
  else {
2944
- // No transact backend: the previous blind write (the class was
2945
- // constructed without cross-process exclusion anyway).
2946
- await this.io.writeText(entry.target, entry.content);
2993
+ await this.io.writeText(entry.target, entry.content ?? '');
2947
2994
  }
2948
2995
  }
2949
2996
  catch (error) {
2950
- // A1-15 (v18): a post-rename dir-fsync failure means the bytes DID
2951
- // land — rolling back would delete a visible write, and the audit/
2952
- // event below must still run. Record it and keep going.
2953
2997
  if (!isCommittedOnly(error))
2954
2998
  throw error;
2955
2999
  durabilityWarning = error instanceof Error ? error.message : String(error);
@@ -2958,11 +3002,6 @@ export class SkillLibrary {
2958
3002
  }
2959
3003
  }
2960
3004
  catch (error) {
2961
- // V27 G8.3: the rollback is best-effort (the .backups snapshot is the
2962
- // recovery path when the caller took one), so the result must not claim a
2963
- // rollback that did not happen. Track which targets failed to roll back
2964
- // and name them; a silent `.catch` made the message a promise the code
2965
- // never made.
2966
3005
  const stuck = [];
2967
3006
  for (const entry of written.reverse()) {
2968
3007
  try {
@@ -2977,56 +3016,41 @@ export class SkillLibrary {
2977
3016
  }
2978
3017
  const reason = error instanceof Error ? error.message : String(error);
2979
3018
  if (stuck.length === 0)
2980
- return { ok: false, message: `目录树变更失败,已回滚:${reason}` };
2981
- return { ok: false, message: `目录树变更失败:${reason}。回滚无法恢复 ${stuck.join(', ')}:可恢复性由当前快照决定:${await this.snapshotRecoveryAvailability()} —— 若上面不是「最新快照」,请重新应用这次变更。` };
2982
- }
2983
- // A7: both sides come from the KERNEL's own pre-read of the SKILL.md target — the baseline the
2984
- // CAS write compared against and the bytes the rollback restores — instead of the earlier `md`
2985
- // read, which names a state the commit did not necessarily replace. (In every current caller the
2986
- // CAS refuses a drift between the two reads, so this is the same value on every committing run.)
2987
- // G3a: ONE tree change is ONE unit — every path it wrote is audited under a shared unit id.
2988
- // The old form picked only the SKILL.md landing, so a support file the same change moved got
2989
- // no version entry at all and the undo could not restore the file set as a unit.
3019
+ return { ok: false, message: '目录树变更失败,已回滚:' + reason };
3020
+ return { ok: false, message: '目录树变更失败:' + reason + '。回滚无法恢复 ' + stuck.join(', ') + ':可恢复性由当前快照决定:' + (await this.snapshotRecoveryAvailability()) + ' —— 若上面不是「最新快照」,请重新应用这次变更。' };
3021
+ }
2990
3022
  const unitId = plan.unitId ?? 'u-' + Date.now().toString(36) + '-' + (nextUnitSeq += 1).toString(36);
2991
- for (const land of landing) {
2992
- const isBody = land.target.split(/[\\/]/).pop() === 'SKILL.md';
2993
- const rel = isBody ? undefined : land.target.slice(dir.length + 1).split('\\').join('/');
2994
- // G3b(A22):代表片=第一条 landing;kind 判 null(previous 的取值域只有 string/null)。
2995
- const unitHead = land === landing[0]
2996
- ? (plan.unitHead ?? {
2997
- moves: landing
2998
- .filter(l => l.target.split(/[\\/]/).pop() !== 'SKILL.md')
2999
- .map((l) => {
3000
- const p = l.target.slice(dir.length + 1).split(String.fromCharCode(92)).join(String.fromCharCode(47));
3001
- return {
3002
- path: p,
3003
- kind: l.previous === null ? 'created' : 'moved',
3004
- to: p,
3005
- ...l.previous === null ? {} : { beforeRef: contentHash(l.previous) },
3006
- afterRef: contentHash(l.content),
3007
- };
3008
- })
3009
- // F1 (independent review, 2026-10-14): when one plan writes the SAME path twice, keep the
3010
- // LAST write -- first-wins left `afterRef` describing the earlier bytes (stale). `beforeRef`
3011
- // and `kind` are unaffected: both come from the same per-path pre-read.
3012
- .filter((m, idx, all) => all.map(x => x.path).lastIndexOf(m.path) === idx),
3013
- sources: [{ skill: name }],
3014
- })
3015
- : undefined;
3016
- // A22: previous is string | null (a read failure rejects the whole plan), so the old
3017
- // `=== undefined ? md : previous` was an unreachable branch -- the type is the truth.
3018
- const auditedBefore = land.previous;
3019
- const auditedAfter = land.content;
3020
- // G5: the plan already carries the write face (`origin`) — put it in the ledger; G3a adds the unit.
3021
- await this.audit(name, plan.auditAction, auditedBefore, auditedAfter, plan.auditSummary, rel, unitId, unitHead, plan.origin);
3022
- }
3023
- this.notifyMutation({ action: plan.eventAction, name, skillDir: dir });
3023
+ const defaultHead = {
3024
+ moves: landing
3025
+ .filter(entry => basename(entry.target) !== 'SKILL.md')
3026
+ .map(entry => ({
3027
+ path: entry.rel,
3028
+ kind: entry.kind === 'delete' ? 'removed' : entry.previous === null ? 'created' : 'moved',
3029
+ ...(entry.previous === null ? {} : { beforeRef: contentHash(entry.previous) }),
3030
+ ...(entry.kind === 'delete' || entry.content === null ? {} : { afterRef: contentHash(entry.content) }),
3031
+ })),
3032
+ sources: roots.map(root => ({ skill: root.name })),
3033
+ };
3034
+ const affected = new Set();
3035
+ for (let index = 0; index < landing.length; index += 1) {
3036
+ const entry = landing[index];
3037
+ if (entry === undefined)
3038
+ continue;
3039
+ await this.audit(entry.skill, plan.auditAction, entry.previous, entry.kind === 'delete' ? null : entry.content, plan.auditSummary, entry.rel === 'SKILL.md' ? undefined : entry.rel, unitId, index === 0 ? (plan.unitHead ?? defaultHead) : undefined, plan.origin);
3040
+ affected.add(entry.skill);
3041
+ }
3042
+ for (const skill of affected) {
3043
+ const dir = roots.find(root => root.name === skill)?.dir;
3044
+ if (dir !== undefined)
3045
+ this.notifyMutation({ action: plan.eventAction, name: skill, skillDir: dir });
3046
+ }
3047
+ const primaryDir = roots.find(root => root.name === primaryName)?.dir ?? roots[0]?.dir ?? '';
3024
3048
  return {
3025
3049
  ok: true,
3026
3050
  message: durabilityWarning === ''
3027
- ? `${plan.eventAction} "${name}" succeeded.`
3028
- : `${plan.eventAction} "${name}" succeeded (警告:写入已落盘,但目录 fsync 失败 —— 持久性未确认:${durabilityWarning})`,
3029
- path: dir,
3051
+ ? plan.eventAction + ' "' + primaryName + '" succeeded.'
3052
+ : plan.eventAction + ' "' + primaryName + '" succeeded (警告:写入已落盘,但目录 fsync 失败 —— 持久性未确认:' + durabilityWarning + ')',
3053
+ path: primaryDir,
3030
3054
  };
3031
3055
  }
3032
3056
  /**
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Support-directory references in a markdown body (009 kernel): references/…,
3
+ * templates/…, scripts/…, assets/… relative links — any extension and nested
4
+ * paths. Pure: who checks them and what the verdict is belongs to the caller.
5
+ * `..` traversal is a different defect class (path validation owns it) and is
6
+ * not a support link.
7
+ */
8
+ export declare function supportRefs(content: string): string[];
9
+ /**
10
+ * References that leave the skill root: parent traversals or absolute support paths.
11
+ * Extract must not silently treat these as relative support files; the caller reports
12
+ * them (bounded external copy is a separate, explicit policy).
13
+ * @param content - markdown body.
14
+ * @returns external-looking support references, de-duplicated in first-seen order.
15
+ */
16
+ export declare function externalRefs(content: string): string[];
17
+ //# sourceMappingURL=support-refs.d.ts.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Support-directory references in a markdown body (009 kernel): references/…,
3
+ * templates/…, scripts/…, assets/… relative links — any extension and nested
4
+ * paths. Pure: who checks them and what the verdict is belongs to the caller.
5
+ * `..` traversal is a different defect class (path validation owns it) and is
6
+ * not a support link.
7
+ */
8
+ export function supportRefs(content) {
9
+ const refs = [];
10
+ for (const match of content.matchAll(/(?<![A-Za-z0-9._/-])(?:references|templates|scripts|assets)\/[A-Za-z0-9._/-]+/g)) {
11
+ const ref = match[0];
12
+ if (ref.includes('..'))
13
+ continue;
14
+ refs.push(ref);
15
+ }
16
+ return refs;
17
+ }
18
+ /**
19
+ * References that leave the skill root: parent traversals or absolute support paths.
20
+ * Extract must not silently treat these as relative support files; the caller reports
21
+ * them (bounded external copy is a separate, explicit policy).
22
+ * @param content - markdown body.
23
+ * @returns external-looking support references, de-duplicated in first-seen order.
24
+ */
25
+ export function externalRefs(content) {
26
+ const refs = [];
27
+ for (const match of content.matchAll(/(?:^|[\s("'`])((?:\.\.\/)+[A-Za-z0-9._/-]+|\/[A-Za-z0-9._/-]+)/g)) {
28
+ const ref = match[1]?.replace(/[.,;:]+$/, '');
29
+ if (ref === undefined || ref.length === 0 || refs.includes(ref))
30
+ continue;
31
+ refs.push(ref);
32
+ }
33
+ return refs;
34
+ }
35
+ //# sourceMappingURL=support-refs.js.map
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.30.30",
4
+ "version": "0.30.32",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },