@lmzhen/dsh-evolution-core 0.30.36 → 0.30.38

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.
Files changed (36) hide show
  1. package/lib/index.js +3776 -2300
  2. package/lib/types/evolution-events.d.ts +6 -2
  3. package/lib/types/evolution-events.js +6 -0
  4. package/lib/types/extract-plan.js +17 -11
  5. package/lib/types/extract-prompt.d.ts +1 -5
  6. package/lib/types/extract-prompt.js +2 -0
  7. package/lib/types/extract-request.d.ts +32 -0
  8. package/lib/types/extract-request.js +155 -0
  9. package/lib/types/index.d.ts +4 -0
  10. package/lib/types/index.js +4 -0
  11. package/lib/types/mutation-storage-adapter.d.ts +14 -0
  12. package/lib/types/mutation-storage-adapter.js +107 -0
  13. package/lib/types/mutation-unit.d.ts +164 -0
  14. package/lib/types/mutation-unit.js +509 -0
  15. package/lib/types/prompts.d.ts +3 -3
  16. package/lib/types/prompts.js +8 -8
  17. package/lib/types/proposal-view.d.ts +4 -0
  18. package/lib/types/proposal-view.js +3 -1
  19. package/lib/types/skill-health.d.ts +5 -0
  20. package/lib/types/skill-health.js +3 -0
  21. package/lib/types/skill-history.d.ts +2 -0
  22. package/lib/types/skill-history.js +3 -0
  23. package/lib/types/skill-lint.d.ts +2 -0
  24. package/lib/types/skill-lint.js +13 -2
  25. package/lib/types/skill-model.d.ts +45 -0
  26. package/lib/types/skill-model.js +74 -0
  27. package/lib/types/skill-store.d.ts +22 -1
  28. package/lib/types/skill-store.js +415 -274
  29. package/lib/types/unit-members.js +3 -3
  30. package/lib/types/unit-storage.d.ts +10 -0
  31. package/lib/types/unit-transaction.d.ts +1 -1
  32. package/lib/types/unit-undo-plan.js +5 -3
  33. package/lib/types/unit-undo.d.ts +1 -1
  34. package/lib/types/unit-undo.js +18 -3
  35. package/package.json +1 -1
  36. package/persisted-write-inventory.json +3 -3
@@ -38,6 +38,7 @@ import { resolveCitations } from "./citations.js";
38
38
  import { scanContentThreats } from "./threats.js";
39
39
  import { isReviewChannelSession } from "./review-channel.js";
40
40
  import { isCommittedWarning, nodeEvolutionIo, transactIo } from "./io.js";
41
+ import { appendEvolutionEvent, eventsFile } from "./evolution-events.js";
41
42
  import { isAbsent, isPresent, isUnknown, probeAbsent, probeList, probeMtime, probePresent, probeReason, probeText, probeUnknown } from "./probe.js";
42
43
  import { evolutionRoot } from "./state-store.js";
43
44
  import { DEFAULT_ARCHIVE_RETENTION_POLICY, DEFAULT_CITATION_POLICY, DEFAULT_REFERENCE_REWRITE_POLICY, DEFAULT_SKILL_LIMITS, DEFAULT_SUPPORT_FILE_CHAR_POLICY } from "./limits.js";
@@ -47,6 +48,9 @@ import { FUZZY_MAX_PATTERN_CHARS, FUZZY_MAX_WORK, trimPatternBoundaries, fuzzyPa
47
48
  import { makeSerialQueue } from "./serial.js";
48
49
  import { supportRefs } from "./support-refs.js";
49
50
  import { selectSections } from "./section-selection.js";
51
+ import { MutationUnit, fileMutationLock } from "./mutation-unit.js";
52
+ import { SkillModel } from "./skill-model.js";
53
+ import { unitJournalPort, unitSnapshotPort } from "./mutation-storage-adapter.js";
50
54
  import { projectExtractBodies } from "./extract-plan.js";
51
55
  import { diffFindings } from "./skill-lint.js";
52
56
  import { contentHash, loadMutations, recordMutation } from "./mutations.js";
@@ -186,7 +190,8 @@ function anchorRefusalFile(name, filePath, verdict) {
186
190
  * @returns the refusal result.
187
191
  */
188
192
  function unreadableTarget(label, error) {
189
- const code = error?.code;
193
+ const code = error?.code
194
+ ?? (typeof error === 'string' && error.startsWith('EISDIR') ? 'EISDIR' : undefined);
190
195
  const detail = code === 'EISDIR'
191
196
  ? 'the path is a DIRECTORY, not a file'
192
197
  : error instanceof Error ? error.message : String(error);
@@ -449,6 +454,8 @@ function sameRevisionAnchor(expected, live) {
449
454
  }
450
455
  export class SkillLibrary {
451
456
  root;
457
+ /** The ONE read face owner/health/panel consume; a thin adapter, not a store. */
458
+ model;
452
459
  limits;
453
460
  io;
454
461
  onMutation;
@@ -493,6 +500,90 @@ export class SkillLibrary {
493
500
  // G1/1.4 (S2-02): the queue is per ROOT and shared by every instance (see ROOT_SERIAL above); a
494
501
  // per-instance queue ordered nothing across the six services that write this tree.
495
502
  this.serial = serialForRoot(this.root);
503
+ this.model = new SkillModel({
504
+ list: async (query) => await this.list(query),
505
+ read: async (name) => await this.read(name),
506
+ listSupportFiles: async (name) => {
507
+ const probe = await this.listSupportFiles(name);
508
+ if (!isPresent(probe))
509
+ throw new Error('support listing unreadable for ' + name);
510
+ return probe.value;
511
+ },
512
+ readSupportFile: async (name, path) => await this.readSupportFile(name, path),
513
+ });
514
+ }
515
+ /**
516
+ * Transitional root lock for mutators that still own their write path. The
517
+ * extract/restructure path takes the same lock through MutationUnit, so those
518
+ * two paths cannot interleave with create/update/patch across processes.
519
+ */
520
+ unitHeadForOps(ops, roots, provenance = new Map()) {
521
+ const moves = [];
522
+ for (const op of ops) {
523
+ if (op.skill === undefined || basename(op.target) === 'SKILL.md')
524
+ continue;
525
+ const path = relative(this.dirOf(op.skill), op.target).split(String.fromCharCode(92)).join('/');
526
+ if (path === '' || path.startsWith('..'))
527
+ continue;
528
+ moves.push({
529
+ path,
530
+ kind: op.before === null ? 'created' : op.after === null ? 'removed' : 'moved',
531
+ ...(op.before === null ? {} : { beforeRef: contentHash(op.before) }),
532
+ ...(op.after === null ? {} : { afterRef: contentHash(op.after) }),
533
+ ...(provenance.get(op.target) === undefined ? {} : { provenance: provenance.get(op.target) }),
534
+ });
535
+ }
536
+ return { moves, sources: roots.map(skill => ({ skill })) };
537
+ }
538
+ async withMutationLock(task) {
539
+ let lease;
540
+ try {
541
+ lease = await fileMutationLock(this.io, this.root).acquire();
542
+ }
543
+ catch (error) {
544
+ const message = error instanceof Error ? error.message : String(error);
545
+ if (message.startsWith('mutation-lock-busy'))
546
+ throw error;
547
+ // Coordination failure: the per-file transact/serial path remains the
548
+ // fallback; a lock sidecar must not change the mutation's business result.
549
+ return await task();
550
+ }
551
+ try {
552
+ return await task();
553
+ }
554
+ finally {
555
+ try {
556
+ await lease.release();
557
+ }
558
+ catch {
559
+ // A failed lock cleanup is a coordination degradation, not a write failure.
560
+ }
561
+ }
562
+ }
563
+ recovery;
564
+ /** Run the boot recovery once per library instance, before the first write. */
565
+ async ensureRecovered() {
566
+ this.recovery ??= this.recoverPreparedUnits().then(() => undefined).catch(error => {
567
+ console.warn('skill-store: prepared-unit recovery failed; writes continue: ' + (error instanceof Error ? error.message : String(error)));
568
+ });
569
+ await this.recovery;
570
+ }
571
+ /** Public boot hook: idempotent with the first-write recovery. */
572
+ async recoverOnBoot() {
573
+ await this.ensureRecovered();
574
+ }
575
+ /** Recover every prepared tree-change snapshot in the shared unit store. */
576
+ async recoverPreparedUnits() {
577
+ const home = dirname(this.root);
578
+ const unit = new MutationUnit({
579
+ io: this.io,
580
+ limits: { maxSkillFileBytes: this.limits.maxSkillFileBytes, maxSkillContentChars: this.limits.maxSkillContentChars },
581
+ serial: this.serial,
582
+ lock: fileMutationLock(this.io, this.root),
583
+ journal: unitJournalPort(this.io, home),
584
+ snapshots: unitSnapshotPort(this.io, home),
585
+ });
586
+ return await unit.recoverPrepared();
496
587
  }
497
588
  /**
498
589
  * Run one single-file read-modify-write for a mutator. When `transact` was
@@ -503,108 +594,6 @@ export class SkillLibrary {
503
594
  * mutation event are issued ONLY when a write actually lands, so a no-op
504
595
  * never inflates the mutation-maturity counter.
505
596
  */
506
- async runSingleWrite(path, task, readFailure) {
507
- // v28 G1.1 (EVO-IO-02): `ghostDir` rides the outcome instead of a closure
508
- // flag — it is set when the task saw a missing body and chose not to
509
- // write, the ghost-directory precondition decided on the clean
510
- // `SingleWriteOutcome` type inside `run`.
511
- let outcome;
512
- // v35 C9: a staged write must report an unverifiable target as a refusal, not
513
- // as an exception. `progress.entered` distinguishes a failure of the LOCKED
514
- // READ (the task never ran, so nothing was verified and nothing was written)
515
- // from a failure of the write itself. It is a holder object rather than a
516
- // boolean because the assignment happens inside the transact callback.
517
- const progress = { entered: false };
518
- const run = async (current) => {
519
- progress.entered = true;
520
- const o = await task(current ?? null);
521
- outcome = { ...o, ghostDir: current === null && o.write === null };
522
- // write: null = "leave the file untouched" — return the current bytes so
523
- // an existing file is preserved and a missing one stays missing (M-4).
524
- return o.write ?? (current ?? null);
525
- };
526
- let durabilityWarning = '';
527
- // v28 G2.1 (EVO-IO-05): the module-level `isCommittedOnly` is the single
528
- // source of this predicate (V27 G1.3's own consolidation); the local
529
- // re-binding here was a fifth copy waiting to drift.
530
- const committedOnly = isCommittedOnly;
531
- if (this.transact) {
532
- try {
533
- await this.transact(this.io, path, run);
534
- }
535
- catch (error) {
536
- // A1-15 (v18): the rename landed and only the parent-directory fsync
537
- // failed. The bytes are visible, so this is NOT a failed write: keep the
538
- // audit/event below and report the durability warning instead of
539
- // letting a caller roll back (or retry) a write that already happened.
540
- if (!progress.entered && readFailure !== undefined)
541
- return readFailure(error);
542
- if (!committedOnly(error))
543
- throw error;
544
- durabilityWarning = error instanceof Error ? error.message : String(error);
545
- }
546
- }
547
- else {
548
- let current;
549
- try {
550
- current = await this.io.readText(path);
551
- }
552
- catch (error) {
553
- if (readFailure !== undefined)
554
- return readFailure(error);
555
- throw error;
556
- }
557
- const next = await run(current);
558
- if (next !== null && next !== current) {
559
- try {
560
- await this.io.writeText(path, next);
561
- }
562
- catch (error) {
563
- if (!committedOnly(error))
564
- throw error;
565
- durabilityWarning = error instanceof Error ? error.message : String(error);
566
- }
567
- }
568
- }
569
- const o = outcome;
570
- // V6-19 (0.3.37): a transact backend that violates the contract (never
571
- // invokes the task — a value-imported transact promised inside guarantees)
572
- // leaves `outcome` undefined; a structured error beats the TypeError the
573
- // unconditional dereference used to raise.
574
- if (o === undefined || typeof o !== 'object' || !Object.prototype.hasOwnProperty.call(o, 'write')) {
575
- return { ok: false, message: '内部错误:写事务没有调用任务;未执行任何写入' };
576
- }
577
- // v28 G1.1 (EVO-IO-02): nothing was written and the body was missing —
578
- // the io seam's mkdir-before-lock may have resurrected the directory a
579
- // concurrent archive just moved away (the update/patch shape of the v22
580
- // LOCK-4 race; setPinnedCore/createCore carry their own compensating
581
- // cleanups). Without this, a SKILL.md-less ghost dir blocks
582
- // restoreFromArchive forever ("carries no SKILL.md; remove or repair it").
583
- if (o.ghostDir === true)
584
- await this.cleanupGhostDir(path);
585
- let result = o.result;
586
- if (o.write !== null && o.audit) {
587
- await this.audit(o.audit.skillName, o.audit.action, o.audit.before, o.audit.after, o.audit.summary, o.audit.path);
588
- // Design §4 item 3: the retention line, said HERE because this is the only place that still
589
- // holds both bodies (the write's own in-lock read) — a caller would have to re-read the file
590
- // and race the writers this library serializes. Feedback only: it never changes the ok flag.
591
- const retention = contentRetentionFeedback({
592
- name: o.audit.skillName,
593
- action: o.audit.action,
594
- before: o.audit.before,
595
- after: o.audit.after,
596
- });
597
- if (retention !== null && result.ok)
598
- result = { ...result, message: `${result.message}\n${retention}` };
599
- }
600
- if (o.write !== null && o.event)
601
- this.notifyMutation(o.event);
602
- // A1-15 (v18): audit and the mutation event ran even when only the fsync
603
- // failed — say so in the result instead of pretending the write failed.
604
- return durabilityWarning === '' || !result.ok
605
- ? result
606
- : { ...result, message: `${result.message} (警告:写入已落盘,但目录 fsync 失败 —— 持久性未确认:${durabilityWarning})` };
607
- }
608
597
  /**
609
598
  * v28 G1.1 (EVO-IO-02): shared compensating cleanup for a locked write that
610
599
  * found no SKILL.md — remove the possibly-resurrected directory ONLY when it
@@ -1162,19 +1151,39 @@ export class SkillLibrary {
1162
1151
  const content = await this.read(name);
1163
1152
  if (content === null)
1164
1153
  return null;
1154
+ const support = await this.listSupportFiles(name);
1155
+ const supportFiles = isPresent(support) ? support.value.length : undefined;
1165
1156
  return assessStructureHealth({
1166
1157
  skillName: name,
1167
1158
  bodyChars: content.length,
1168
1159
  bodyText: content,
1169
1160
  supportGroups: await this.countSupportDirs(name),
1161
+ ...(supportFiles === undefined ? {} : { supportFiles }),
1170
1162
  patchCount: counts?.patchCount,
1171
1163
  readCount: counts?.readCount,
1164
+ ...(counts?.hardCapHits === undefined ? {} : { hardCapHits: counts.hardCapHits }),
1172
1165
  }, thresholds);
1173
1166
  }
1174
1167
  catch {
1175
1168
  return null;
1176
1169
  }
1177
1170
  }
1171
+ /** S2: mechanical hard-cap event; failure must not change the refusal. */
1172
+ async recordHardCapHit(name, bodyChars, source, supportFiles = null) {
1173
+ try {
1174
+ await appendEvolutionEvent(this.io, eventsFile(dirname(this.root)), {
1175
+ type: 'hard-cap',
1176
+ target: name,
1177
+ bodyChars,
1178
+ supportFiles,
1179
+ restructureRounds: 0,
1180
+ source,
1181
+ });
1182
+ }
1183
+ catch (error) {
1184
+ console.warn('skill-store: hard-cap event not recorded for "' + name + '": ' + (error instanceof Error ? error.message : String(error)));
1185
+ }
1186
+ }
1178
1187
  /** Best-effort audit trail entry; never blocks the mutation.
1179
1188
  *
1180
1189
  * Two independent best-effort writes run here, because either one failing must not cost the
@@ -1425,12 +1434,13 @@ export class SkillLibrary {
1425
1434
  };
1426
1435
  }
1427
1436
  async create(name, content, origin = this.authority.origin, options = {}) {
1437
+ await this.ensureRecovered();
1428
1438
  const normalized = name.trim();
1429
1439
  // P2-3 (v11): the exists-probe → write window runs under the in-process
1430
1440
  // serialize queue (F-208 discipline, like update/patch) — two concurrent
1431
1441
  // create calls for one name used to both pass exists() and double-write
1432
1442
  // (double audit, double mutation event).
1433
- return await this.serial(() => this.createCore(normalized, content, origin, options));
1443
+ return await this.serial(() => this.withMutationLock(() => this.createCore(normalized, content, origin, options)));
1434
1444
  }
1435
1445
  async createCore(normalized, content, origin, options = {}) {
1436
1446
  // C-12 (v10 audit): the inline badName() copy is gone (see setPinned).
@@ -1438,8 +1448,11 @@ export class SkillLibrary {
1438
1448
  if (bad)
1439
1449
  return { ok: false, message: bad };
1440
1450
  const validation = validateFrontmatter(content, normalized, this.limits);
1441
- if (validation)
1451
+ if (validation) {
1452
+ if (exceedsContentLimit(content, this.limits.maxSkillContentChars))
1453
+ await this.recordHardCapHit(normalized, content.length, 'create');
1442
1454
  return { ok: false, message: validation };
1455
+ }
1443
1456
  // 0.3.11: frontmatter normalization happens at the write point — the
1444
1457
  // platform catalog parses strict YAML, so unquoted plain scalars carrying
1445
1458
  // `: ` (etc.) would silently make the skill invisible to the platform.
@@ -1503,55 +1516,37 @@ export class SkillLibrary {
1503
1516
  // create on another instance/process, and the later write silently
1504
1517
  // overwrote the winner's bytes (double audit, double event). In-lock
1505
1518
  // re-check: current bytes present -> structured refusal, nothing written.
1506
- let existsAtCommit = false;
1507
- let taskRan = false;
1508
- let createDurabilityWarning = '';
1509
- if (this.transact) {
1510
- try {
1511
- await this.transact(this.io, createPath, (current) => {
1512
- // v43 audit (FLOW3-1): this callback RE-RUNS when the write lock is
1513
- // lost before the commit (io redoes the whole RMW), and the flags above
1514
- // are OR-accumulated across attempts. A stale `existsAtCommit` from the
1515
- // first attempt made a create whose retry DID write report "already
1516
- // exists". Reset per attempt so the post-loop decision reads the LAST
1517
- // attempt's observation only.
1518
- existsAtCommit = false;
1519
- taskRan = true;
1520
- if (current !== null) {
1521
- existsAtCommit = true;
1522
- return current;
1523
- }
1524
- return onDisk;
1525
- });
1526
- }
1527
- catch (error) {
1528
- // P2-2 (v19): the SKILL.md landed; only the dir fsync failed.
1529
- if (!isCommittedOnly(error))
1530
- throw error;
1531
- createDurabilityWarning = error instanceof Error ? error.message : String(error);
1532
- }
1533
- }
1534
- else if (await this.io.exists(createPath)) {
1535
- taskRan = true;
1536
- existsAtCommit = true;
1537
- }
1538
- else {
1539
- taskRan = true;
1540
- try {
1541
- await this.io.writeText(createPath, onDisk);
1542
- }
1543
- catch (error) {
1544
- if (!isCommittedOnly(error))
1545
- throw error;
1546
- createDurabilityWarning = error instanceof Error ? error.message : String(error);
1547
- }
1519
+ const unit = new MutationUnit({
1520
+ io: this.io,
1521
+ limits: { maxSkillFileBytes: Number.MAX_SAFE_INTEGER, maxSkillContentChars: Number.MAX_SAFE_INTEGER },
1522
+ lock: { acquire: async () => ({ release: async () => { } }) },
1523
+ journal: unitJournalPort(this.io, dirname(this.root)),
1524
+ snapshots: unitSnapshotPort(this.io, dirname(this.root)),
1525
+ transactGuard: false,
1526
+ hooks: {
1527
+ undone: async () => {
1528
+ await this.io.remove(markerPath(dir, 'hermes-managed')).catch(() => { });
1529
+ },
1530
+ },
1531
+ });
1532
+ const result = await unit.apply({
1533
+ name: normalized,
1534
+ origin,
1535
+ roots: [{ name: normalized, protection: 'write' }],
1536
+ rootDirs: { [normalized]: dir },
1537
+ ops: [{ kind: 'write', target: createPath, content: onDisk, expected: null, skill: normalized }],
1538
+ auditAction: 'create',
1539
+ auditSummary: 'created',
1540
+ eventAction: 'create',
1541
+ cleanupPaths: [markerPath(dir, 'hermes-managed')],
1542
+ ...(options.unitId === undefined ? {} : { unitId: options.unitId }),
1543
+ });
1544
+ if (!result.ok) {
1545
+ if (result.errors.some(error => error.code === 'cas-drift' || error.message.includes('concurrent modification')))
1546
+ return { ok: false, message: `技能「${normalized}」已存在。` };
1547
+ return { ok: false, message: result.errors.map(error => error.message).join(';') };
1548
1548
  }
1549
- // A1-22 (v18): a contract-violating transact that never invokes the
1550
- // task used to report success with no file. Mirror runSingleWrite.
1551
- if (!taskRan)
1552
- return { ok: false, message: '内部错误:创建事务没有调用任务;未写入任何文件' };
1553
- if (existsAtCommit)
1554
- return { ok: false, message: `技能「${normalized}」已存在。` };
1549
+ const createDurabilityWarning = result.warnings === undefined ? '' : result.warnings.join(';');
1555
1550
  // Any non-foreground writer (review channel OR delegated subagent) is an
1556
1551
  // agent-authored skill: mark it managed so the lifecycle owns it.
1557
1552
  // v22 (LOCK-4): the marker lands only while the body we just wrote is
@@ -1597,7 +1592,7 @@ export class SkillLibrary {
1597
1592
  return { ok: false, message: `创建过程中技能「${normalized}」被并发归档;残留标记已移除:等搬运完成后重试一次。` };
1598
1593
  }
1599
1594
  }
1600
- await this.audit(normalized, 'create', null, onDisk, 'created', undefined, options.unitId, options.unitHead);
1595
+ await this.audit(normalized, 'create', null, onDisk, 'created', undefined, result.unitId, options.unitHead);
1601
1596
  this.notifyMutation({ action: 'create', name: normalized, skillDir: dir });
1602
1597
  // Both warnings ride the normal success path (audit/event included) — a
1603
1598
  // durability warning must never skip the accounting.
@@ -1615,6 +1610,7 @@ export class SkillLibrary {
1615
1610
  async update(rawName, content, origin = this.authority.origin, anchor) {
1616
1611
  // One trim per entry: paths (dirOf), validation and messages all see the same name.
1617
1612
  const name = rawName.trim();
1613
+ await this.ensureRecovered();
1618
1614
  // F-208: the whole read→validate→write runs under the in-process serialize
1619
1615
  // queue so two concurrent updates on one skill never interleave.
1620
1616
  return await this.serial(() => this.updateCore(name, content, origin, anchor));
@@ -1641,8 +1637,11 @@ export class SkillLibrary {
1641
1637
  console.warn(`skill-store: could not re-read ${path} for the shrink exemption (${limitProbe.reason}); validating without it — an over-limit repair is refused, not accepted`);
1642
1638
  const currentForLimit = isPresent(limitProbe) ? limitProbe.value : null;
1643
1639
  const validation = validateFrontmatter(content, name, this.limits, currentForLimit);
1644
- if (validation)
1640
+ if (validation) {
1641
+ if (exceedsContentLimit(content, this.limits.maxSkillContentChars))
1642
+ await this.recordHardCapHit(name, content.length, 'update');
1645
1643
  return { ok: false, message: validation };
1644
+ }
1646
1645
  // 0.3.11: normalize at the write point (see create) — same reasoning.
1647
1646
  const norm = normalizeFrontmatter(content);
1648
1647
  if (norm.issues.length > 0)
@@ -1656,33 +1655,64 @@ export class SkillLibrary {
1656
1655
  const threat = this.contentThreatBlock(finalContent);
1657
1656
  if (threat)
1658
1657
  return { ok: false, message: threat };
1659
- return await this.runSingleWrite(path, (current) => {
1660
- // v35 C9: the stage-time anchor is checked against THIS locked read, so the
1661
- // compare and the commit share one lock (the caller's former pre-read left
1662
- // a window where both writers could report success).
1663
- const verdict = anchorVerdict(anchor, current);
1664
- if (verdict !== 'match')
1665
- return { result: anchorRefusal(name, verdict), write: null };
1666
- if (current === null)
1667
- return { result: { ok: false, message: `技能「${name}」不存在。` }, write: null };
1668
- // F-318 (②): a byte-equivalent rewrite (same content modulo trailing
1669
- // newlines) is a no-op — no write, no audit, no mutation event, so the
1670
- // mutation-maturity counter is not inflated by a redundant re-save.
1671
- if (finalContent.trimEnd() === current.trimEnd()) {
1672
- return { result: { ok: true, message: `技能「${name}」未变更:提供的内容与当前文件一致,未写入。`, noop: true, path: dir }, write: null };
1673
- }
1674
- // F-337: hash the BYTES that actually land on disk (write uses
1675
- // trimEnd()+'\n'), so the audit afterHash is replay-identical to the file.
1676
- const onDisk = finalContent.trimEnd() + '\n';
1677
- return {
1678
- result: { ok: true, message: `技能「${name}」已更新。`, path: dir, ...(norm.changed ? { normalizedFrontmatterFields: norm.fields } : {}) },
1679
- write: onDisk,
1680
- audit: { skillName: name, action: 'update', before: current, after: onDisk, summary: 'updated' },
1681
- event: { action: 'update', name, skillDir: dir },
1682
- };
1683
- }, anchor !== undefined ? () => anchorUnverifiable(name, null) : error => unreadableTarget(name, error));
1658
+ let current;
1659
+ try {
1660
+ current = await this.io.readText(path);
1661
+ }
1662
+ catch (error) {
1663
+ return anchor !== undefined ? anchorUnverifiable(name, null) : unreadableTarget(name, error);
1664
+ }
1665
+ const verdict = anchorVerdict(anchor, current);
1666
+ if (verdict !== 'match')
1667
+ return anchorRefusal(name, verdict);
1668
+ if (current === null) {
1669
+ await this.cleanupGhostDir(path);
1670
+ return { ok: false, message: `技能「${name}」不存在。` };
1671
+ }
1672
+ if (finalContent.trimEnd() === current.trimEnd()) {
1673
+ return { ok: true, message: `技能「${name}」未变更:提供的内容与当前文件一致,未写入。`, noop: true, path: dir };
1674
+ }
1675
+ const onDisk = finalContent.trimEnd() + '\n';
1676
+ const unit = new MutationUnit({
1677
+ io: this.io,
1678
+ limits: { maxSkillFileBytes: Number.MAX_SAFE_INTEGER, maxSkillContentChars: Number.MAX_SAFE_INTEGER },
1679
+ lock: fileMutationLock(this.io, this.root),
1680
+ journal: unitJournalPort(this.io, dirname(this.root)),
1681
+ snapshots: unitSnapshotPort(this.io, dirname(this.root)),
1682
+ ...(this.transact === undefined ? {} : { transact: this.transact }),
1683
+ hooks: {
1684
+ committed: async (event) => {
1685
+ const op = event.ops[0];
1686
+ if (op === undefined)
1687
+ return;
1688
+ await this.audit(name, 'update', op.before, op.after, 'updated', undefined, event.unitId, { sources: [{ skill: name }] }, origin);
1689
+ this.notifyMutation({ action: 'update', name, skillDir: dir });
1690
+ },
1691
+ },
1692
+ validateWrite: op => op.kind === 'write' ? this.contentThreatBlock(op.content) : null,
1693
+ });
1694
+ const result = await unit.apply({
1695
+ name,
1696
+ origin,
1697
+ roots: [{ name, protection: 'write' }],
1698
+ rootDirs: { [name]: dir },
1699
+ ops: [{ kind: 'write', target: path, content: onDisk, expected: current, skill: name }],
1700
+ auditAction: 'update',
1701
+ auditSummary: 'updated',
1702
+ eventAction: 'update',
1703
+ });
1704
+ if (!result.ok) {
1705
+ const messages = result.errors.map(error => error.message);
1706
+ if (messages.some(message => message.includes('mutation-lock-transact-not-run') || message.includes('did not invoke the task')))
1707
+ return { ok: false, message: '内部错误:写事务没有调用任务;未执行任何写入' };
1708
+ return { ok: false, message: messages.join(';') };
1709
+ }
1710
+ const warning = result.warnings === undefined || result.warnings.length === 0 ? '' : ` (警告:写入已落盘,但目录 fsync 失败 —— 持久性未确认:${result.warnings.join(';')})`;
1711
+ const retention = contentRetentionFeedback({ name, action: 'update', before: current, after: onDisk });
1712
+ return { ok: true, message: `技能「${name}」已更新。${warning}${retention === null ? '' : '\n' + retention}`, path: dir, ...(norm.changed ? { normalizedFrontmatterFields: norm.fields } : {}) };
1684
1713
  }
1685
1714
  async patch(rawName, oldString, newString, filePath = '', replaceAll = false, origin = this.authority.origin) {
1715
+ await this.ensureRecovered();
1686
1716
  // One trim per entry: paths (dirOf), validation and messages all see the same name.
1687
1717
  const name = rawName.trim();
1688
1718
  // F-208: the whole read→patch→write runs under the in-process serialize
@@ -1714,7 +1744,7 @@ export class SkillLibrary {
1714
1744
  target = join(dir, ...filePath.replace(/\\/g, '/').split('/').filter(Boolean));
1715
1745
  patchLabel = filePath;
1716
1746
  }
1717
- return await this.runSingleWrite(target, (current) => {
1747
+ const compute = (current) => {
1718
1748
  const md = current;
1719
1749
  if (md === null)
1720
1750
  return { result: { ok: false, message: `文件不存在:${patchLabel}` }, write: null };
@@ -1768,7 +1798,7 @@ export class SkillLibrary {
1768
1798
  const overLimit = exceedsContentLimit(writeContent, this.limits.maxSkillContentChars);
1769
1799
  const repairing = shrinksOverLimit(writeContent, md, this.limits.maxSkillContentChars);
1770
1800
  if (overLimit && target === skillMd && !repairing) {
1771
- return { result: { ok: false, message: `修补后的内容超过 ${this.limits.maxSkillContentChars} 个字符。${CONTENT_SPLIT_HINT}` }, write: null };
1801
+ return { result: { ok: false, message: `修补后的内容超过 ${this.limits.maxSkillContentChars} 个字符。${CONTENT_SPLIT_HINT}` }, write: null, hardCapHit: { bodyChars: writeContent.length, supportFiles: null } };
1772
1802
  }
1773
1803
  // V4: the same cap on a SUPPORT file. Report mode writes with an advisory;
1774
1804
  // enforce refuses unless the patch makes an already-over file smaller.
@@ -1777,7 +1807,7 @@ export class SkillLibrary {
1777
1807
  ? ` Warning: ${writeContent.length} characters is over the ${this.limits.maxSkillContentChars}-character cap upstream applies to every file; plan a split.`
1778
1808
  : '';
1779
1809
  if (supportOverCap && this.supportFileCharPolicy() === 'enforce') {
1780
- return { result: { ok: false, message: `修补后的文件内容超过 ${this.limits.maxSkillContentChars} 个字符。${CONTENT_SPLIT_HINT}` }, write: null };
1810
+ return { result: { ok: false, message: `修补后的文件内容超过 ${this.limits.maxSkillContentChars} 个字符。${CONTENT_SPLIT_HINT}` }, write: null, hardCapHit: { bodyChars: writeContent.length, supportFiles: 1 } };
1781
1811
  }
1782
1812
  const threat = this.contentThreatBlock(writeContent);
1783
1813
  if (threat)
@@ -1802,7 +1832,59 @@ export class SkillLibrary {
1802
1832
  audit: { skillName: name, action: 'patch', before: md, after: onDisk, summary: `patched ${patchLabel}` },
1803
1833
  event: { action: 'patch', name, skillDir: dir },
1804
1834
  };
1805
- }, error => unreadableTarget(name + '/' + patchLabel, error));
1835
+ };
1836
+ let current;
1837
+ try {
1838
+ current = await this.io.readText(target);
1839
+ }
1840
+ catch (error) {
1841
+ return unreadableTarget(name + '/' + patchLabel, error);
1842
+ }
1843
+ if (current === null) {
1844
+ await this.cleanupGhostDir(target);
1845
+ return { ok: false, message: `文件不存在:${patchLabel}` };
1846
+ }
1847
+ const outcome = compute(current);
1848
+ if (outcome.write === null) {
1849
+ if (outcome.hardCapHit !== undefined)
1850
+ await this.recordHardCapHit(name, outcome.hardCapHit.bodyChars, 'patch', outcome.hardCapHit.supportFiles);
1851
+ return outcome.result;
1852
+ }
1853
+ const onDisk = outcome.write;
1854
+ const unit = new MutationUnit({
1855
+ io: this.io,
1856
+ limits: { maxSkillFileBytes: Number.MAX_SAFE_INTEGER, maxSkillContentChars: Number.MAX_SAFE_INTEGER },
1857
+ lock: fileMutationLock(this.io, this.root),
1858
+ journal: unitJournalPort(this.io, dirname(this.root)),
1859
+ snapshots: unitSnapshotPort(this.io, dirname(this.root)),
1860
+ ...(this.transact === undefined ? {} : { transact: this.transact }),
1861
+ hooks: {
1862
+ committed: async (event) => {
1863
+ await this.audit(name, 'patch', outcome.audit?.before ?? current, event.ops[0]?.after ?? onDisk, outcome.audit?.summary ?? `patched ${patchLabel}`, filePath === '' ? undefined : filePath, event.unitId, { sources: [{ skill: name }] }, origin);
1864
+ this.notifyMutation({ action: 'patch', name, skillDir: dir });
1865
+ },
1866
+ },
1867
+ validateWrite: op => op.kind === 'write' ? this.contentThreatBlock(op.content) : null,
1868
+ });
1869
+ const result = await unit.apply({
1870
+ name,
1871
+ origin,
1872
+ roots: [{ name, protection: 'write' }],
1873
+ rootDirs: { [name]: dir },
1874
+ ops: [{ kind: 'write', target, content: onDisk, expected: current, skill: name }],
1875
+ auditAction: 'patch',
1876
+ auditSummary: outcome.audit?.summary ?? `patched ${patchLabel}`,
1877
+ eventAction: 'patch',
1878
+ });
1879
+ if (!result.ok) {
1880
+ const messages = result.errors.map(error => error.message);
1881
+ if (messages.some(message => message.includes('mutation-lock-transact-not-run') || message.includes('did not invoke the task')))
1882
+ return { ok: false, message: '内部错误:写事务没有调用任务;未执行任何写入' };
1883
+ return { ok: false, message: messages.join(';') };
1884
+ }
1885
+ const warning = result.warnings === undefined || result.warnings.length === 0 ? '' : ` (警告:写入已落盘,但目录 fsync 失败 —— 持久性未确认:${result.warnings.join(';')})`;
1886
+ const retention = contentRetentionFeedback({ name, action: 'patch', before: current, after: onDisk });
1887
+ return { ...outcome.result, message: `${outcome.result.message}${warning}${retention === null ? '' : '\n' + retention}` };
1806
1888
  }
1807
1889
  /**
1808
1890
  * P2-9 (v15): the destructive directory move shared by archive and
@@ -2502,7 +2584,9 @@ export class SkillLibrary {
2502
2584
  // Package integrity (009-I): an append must never leave dangling
2503
2585
  // support links — refuse before ANY side effect (no archive, no write).
2504
2586
  const carriesSupport = await this.countSupportDirs(source) > 0;
2505
- const refs = supportRefs(parsed.body);
2587
+ const parsedRefs = supportRefs(parsed.body);
2588
+ const rawRefs = [...parsed.body.matchAll(/(?:references|scripts|templates|assets)\/[A-Za-z0-9._/-]+/g)].map(match => match[0]);
2589
+ const refs = parsedRefs.length > 0 ? parsedRefs : rawRefs;
2506
2590
  let appendedBody = parsed.body;
2507
2591
  if (carriesSupport || refs.length > 0) {
2508
2592
  const decision = await this.planSourceRehoming(source, targetName, parsed.body, rehomed.map(file => file.to));
@@ -2690,9 +2774,10 @@ export class SkillLibrary {
2690
2774
  */
2691
2775
  async restructure(rawName, moves, origin = this.authority.origin) {
2692
2776
  const name = rawName.trim();
2777
+ await this.ensureRecovered();
2693
2778
  // F-208: the whole read→plan→write runs under the in-process serialize
2694
2779
  // queue so two concurrent restructures on one skill never interleave.
2695
- return await this.serial(() => this.restructureCore(name, moves, origin));
2780
+ return await this.restructureCore(name, moves, origin);
2696
2781
  }
2697
2782
  async restructureCore(name, moves, origin) {
2698
2783
  const badName = this.badName(name);
@@ -2797,17 +2882,51 @@ export class SkillLibrary {
2797
2882
  });
2798
2883
  }
2799
2884
  writes.push({ target: join(dir, 'SKILL.md'), content: finalMd, expected: md });
2800
- const result = await this.applyTreeChange({
2885
+ const protection = await this.writeProtection(name, origin);
2886
+ if (protection !== null)
2887
+ return { ok: false, message: `技能「${name}」受保护(${protection})。` };
2888
+ const summary = `moved ${plan.sections.length} section(s): ${[...byRel.keys()].join(', ')}`;
2889
+ const ops = writes.map(write => ({
2890
+ kind: 'write',
2891
+ target: write.target,
2892
+ content: write.content,
2893
+ ...(write.expected === undefined ? {} : { expected: write.expected }),
2894
+ skill: name,
2895
+ }));
2896
+ const hooks = {
2897
+ committed: async (event) => {
2898
+ let headPlaced = false;
2899
+ for (const op of event.ops) {
2900
+ const rel = relative(dir, op.target).split(String.fromCharCode(92)).join('/');
2901
+ await this.audit(name, 'restructure', op.before, op.after, summary, rel === 'SKILL.md' ? undefined : rel, event.unitId, headPlaced ? undefined : this.unitHeadForOps(event.ops, [name]), origin);
2902
+ headPlaced = true;
2903
+ }
2904
+ this.notifyMutation({ action: 'restructure', name, skillDir: dir });
2905
+ },
2906
+ };
2907
+ const home = dirname(this.root);
2908
+ const unit = new MutationUnit({
2909
+ io: this.io,
2910
+ limits: { maxSkillFileBytes: this.limits.maxSkillFileBytes, maxSkillContentChars: this.limits.maxSkillContentChars },
2911
+ serial: this.serial,
2912
+ lock: fileMutationLock(this.io, this.root),
2913
+ journal: unitJournalPort(this.io, home),
2914
+ snapshots: unitSnapshotPort(this.io, home),
2915
+ hooks,
2916
+ validateWrite: op => op.kind === 'write' ? this.contentThreatBlock(op.content) : null,
2917
+ });
2918
+ const result = await unit.apply({
2801
2919
  name,
2802
2920
  origin,
2803
- protection: 'write',
2804
- writes,
2921
+ roots: [{ name, protection: 'write' }],
2922
+ rootDirs: { [name]: dir },
2923
+ ops,
2805
2924
  auditAction: 'restructure',
2806
- auditSummary: `moved ${plan.sections.length} section(s): ${[...byRel.keys()].join(', ')}`,
2925
+ auditSummary: summary,
2807
2926
  eventAction: 'restructure',
2808
2927
  });
2809
2928
  if (!result.ok)
2810
- return result;
2929
+ return { ok: false, message: result.errors.map(error => error.message).join(';') };
2811
2930
  return { ok: true, message: `已重构「${name}」:把 ${plan.sections.length} 个章节搬到 references/。`, path: dir };
2812
2931
  }
2813
2932
  /**
@@ -2826,6 +2945,7 @@ export class SkillLibrary {
2826
2945
  * @returns the kernel result; a refusal writes nothing.
2827
2946
  */
2828
2947
  async extract(plan, origin = this.authority.origin) {
2948
+ await this.ensureRecovered();
2829
2949
  if (plan.outcome === 'owner-gap' || plan.owner.mode !== 'covered')
2830
2950
  return { ok: false, message: '提取被拒绝:owner-gap 不是写操作。' };
2831
2951
  if (plan.support.unresolved.length > 0 || plan.support.dangling.length > 0)
@@ -2887,17 +3007,60 @@ export class SkillLibrary {
2887
3007
  ops.push({ kind: 'delete', target: sourcePath, expected: previous, skill: source });
2888
3008
  }
2889
3009
  ops.push({ kind: 'write', target: join(sourceDir, 'SKILL.md'), content: sourceNew, expected: sourceMd, skill: source });
2890
- return this.applyTreeChange({
3010
+ const targetSummary = (await this.list({ withFrontmatter: true })).find(summary => summary.name === target);
3011
+ if (targetSummary === undefined)
3012
+ return { ok: false, message: '提取被拒绝:target 不在技能库中。' };
3013
+ if (!targetSummary.managed)
3014
+ return { ok: false, message: '提取被拒绝:target 不是家族管理的技能。' };
3015
+ for (const name of [source, target]) {
3016
+ const protection = await this.writeProtection(name, origin);
3017
+ if (protection !== null)
3018
+ return { ok: false, message: '技能「' + name + '」受保护(' + protection + ')。' };
3019
+ }
3020
+ const summary = 'extract ' + plan.parts.map(part => part.heading).join(', ') + ' from ' + source + ' to ' + target;
3021
+ const roots = [source, target];
3022
+ const hooks = {
3023
+ committed: async (event) => {
3024
+ const provenance = new Map();
3025
+ for (const move of [...plan.support.shared, ...plan.support.external])
3026
+ provenance.set(join(targetDir, ...move.to.split('/')), move.provenance);
3027
+ const head = this.unitHeadForOps(event.ops, roots, provenance);
3028
+ let headPlaced = false;
3029
+ for (const op of event.ops) {
3030
+ if (op.skill === undefined)
3031
+ continue;
3032
+ const rel = relative(this.dirOf(op.skill), op.target).split(String.fromCharCode(92)).join('/');
3033
+ await this.audit(op.skill, 'extract', op.before, op.after, summary, rel === 'SKILL.md' ? undefined : rel, event.unitId, headPlaced ? undefined : head, origin);
3034
+ headPlaced = true;
3035
+ }
3036
+ for (const name of roots)
3037
+ this.notifyMutation({ action: 'extract', name, skillDir: this.dirOf(name) });
3038
+ },
3039
+ };
3040
+ const home = dirname(this.root);
3041
+ const unit = new MutationUnit({
3042
+ io: this.io,
3043
+ limits: { maxSkillFileBytes: this.limits.maxSkillFileBytes, maxSkillContentChars: this.limits.maxSkillContentChars },
3044
+ serial: this.serial,
3045
+ lock: fileMutationLock(this.io, this.root),
3046
+ journal: unitJournalPort(this.io, home),
3047
+ snapshots: unitSnapshotPort(this.io, home),
3048
+ hooks,
3049
+ validateWrite: op => op.kind === 'write' ? this.contentThreatBlock(op.content) : null,
3050
+ });
3051
+ const result = await unit.apply({
2891
3052
  name: source,
2892
3053
  origin,
2893
- protection: 'write',
2894
- roots: [{ name: source, protection: 'write' }, { name: target, protection: 'write' }],
2895
- writes: [],
3054
+ roots: roots.map(name => ({ name, protection: 'write' })),
3055
+ rootDirs: Object.fromEntries(roots.map(name => [name, this.dirOf(name)])),
2896
3056
  ops,
2897
3057
  auditAction: 'extract',
2898
- auditSummary: 'extract ' + plan.parts.map(part => part.heading).join(', ') + ' from ' + source + ' to ' + target,
3058
+ auditSummary: summary,
2899
3059
  eventAction: 'extract',
2900
3060
  });
3061
+ if (!result.ok)
3062
+ return { ok: false, message: '提取失败:' + result.errors.map(error => error.message).join(';') };
3063
+ return { ok: true, message: summary + ' succeeded.', path: this.dirOf(source) };
2901
3064
  }
2902
3065
  async applyTreeChange(plan) {
2903
3066
  const primaryName = plan.name.trim();
@@ -3215,15 +3378,10 @@ export class SkillLibrary {
3215
3378
  return { ok: true, message: `技能「${name}」已从 .archive 恢复。`, path: dest };
3216
3379
  }
3217
3380
  async writeSupportFile(rawName, filePath, content, origin = this.authority.origin, anchor) {
3218
- // One trim per entry: paths (dirOf), validation and messages all see the same name.
3219
3381
  const name = rawName.trim();
3220
- // F-208: the whole read→validate→write runs under the in-process serialize
3221
- // queue so two concurrent writers to one support file never interleave.
3222
- return await this.serial(() => this.writeSupportFileCore(name, filePath, content, origin, anchor));
3382
+ return await this.writeSupportFileCore(name, filePath, content, origin, anchor);
3223
3383
  }
3224
3384
  async writeSupportFileCore(name, filePath, content, origin, anchor) {
3225
- // P3 (v17): guard BEFORE dirOf — the last holdout of the old order
3226
- // (updateCore/patchCore/removeSupportFileCore all check first).
3227
3385
  const badName = this.badName(name);
3228
3386
  if (badName)
3229
3387
  return { ok: false, message: badName };
@@ -3242,52 +3400,63 @@ export class SkillLibrary {
3242
3400
  if (threat)
3243
3401
  return { ok: false, message: threat };
3244
3402
  const target = join(dir, ...filePath.replace(/\\/g, '/').split('/').filter(Boolean));
3245
- return await this.runSingleWrite(target, (current) => {
3246
- // v35 C9: the stage-time anchor is verified against this locked read; a
3247
- // mismatch refuses before the no-op check (a stale anchor is a refusal even
3248
- // when the supplied bytes happen to equal the current ones).
3249
- const verdict = anchorVerdict(anchor, current);
3250
- if (verdict !== 'match')
3251
- return { result: anchorRefusalFile(name, filePath, verdict), write: null };
3252
- // V6-15 (0.3.36): a byte-equivalent (modulo trailing whitespace) rewrite
3253
- // is a no-op — no write, no audit, no mutation event — so a repeated
3254
- // write_file cannot inflate the mutation-maturity counter or churn the
3255
- // catalog (the update/patch/memory-add noop discipline, 0.3.29).
3256
- // V4 (design §16.6): upstream applies the 100k-character cap to EVERY written
3257
- // file. Landing it here in report mode first — the write goes through with an
3258
- // advisory; `enforce` refuses, and a NET SHRINK of an already-over file is the
3259
- // repair path in both modes (so a legacy 189k file can always be reduced).
3260
- const overCap = exceedsContentLimit(content, this.limits.maxSkillContentChars);
3261
- const repairing = shrinksOverLimit(content, current, this.limits.maxSkillContentChars);
3262
- const capAdvisory = overCap && !repairing
3263
- ? ` Warning: ${content.length} characters is over the ${this.limits.maxSkillContentChars}-character cap upstream applies to every file; plan a split.`
3264
- : '';
3265
- if (overCap && !repairing && this.supportFileCharPolicy() === 'enforce') {
3266
- return { result: { ok: false, message: `支撑文件「${filePath}」的内容超过 ${this.limits.maxSkillContentChars} 个字符。${CONTENT_SPLIT_HINT}` }, write: null };
3267
- }
3268
- if (current !== null && content.trimEnd() === current.trimEnd()) {
3269
- return { result: { ok: true, message: `支撑文件「${filePath}」未变更:提供的内容与当前文件一致,未写入。`, noop: true, path: target }, write: null };
3270
- }
3271
- return {
3272
- result: { ok: true, message: `支撑文件「${filePath}」已写入「${name}」。${capAdvisory}`, path: target },
3273
- write: content,
3274
- audit: { skillName: name, action: 'write_file', before: current, after: content, summary: `wrote ${filePath}`, path: filePath },
3275
- event: { action: 'write_file', name, skillDir: dir, file: target },
3276
- };
3277
- }, anchor !== undefined ? () => anchorUnverifiable(name, filePath) : error => unreadableTarget(name + '/' + filePath, error));
3403
+ const probe = await probeText(this.io, target);
3404
+ if (isUnknown(probe))
3405
+ return unreadableTarget(name + '/' + filePath, probe.reason);
3406
+ const current = isPresent(probe) ? probe.value : null;
3407
+ const verdict = anchorVerdict(anchor, current);
3408
+ if (verdict !== 'match')
3409
+ return anchorRefusalFile(name, filePath, verdict);
3410
+ const overCap = exceedsContentLimit(content, this.limits.maxSkillContentChars);
3411
+ const repairing = shrinksOverLimit(content, current, this.limits.maxSkillContentChars);
3412
+ const capAdvisory = overCap && !repairing
3413
+ ? ` Warning: ${content.length} characters is over the ${this.limits.maxSkillContentChars}-character cap upstream applies to every file; plan a split.`
3414
+ : '';
3415
+ if (overCap && !repairing && this.supportFileCharPolicy() === 'enforce') {
3416
+ await this.recordHardCapHit(name, content.length, 'write_file', 1);
3417
+ return { ok: false, message: `支撑文件「${filePath}」的内容超过 ${this.limits.maxSkillContentChars} 个字符。${CONTENT_SPLIT_HINT}` };
3418
+ }
3419
+ if (current !== null && content.trimEnd() === current.trimEnd()) {
3420
+ return { ok: true, message: `支撑文件「${filePath}」未变更:提供的内容与当前文件一致,未写入。`, noop: true, path: target };
3421
+ }
3422
+ const unit = new MutationUnit({
3423
+ io: this.io,
3424
+ limits: { maxSkillFileBytes: Number.MAX_SAFE_INTEGER, maxSkillContentChars: Number.MAX_SAFE_INTEGER },
3425
+ serial: this.serial,
3426
+ lock: fileMutationLock(this.io, this.root),
3427
+ journal: unitJournalPort(this.io, dirname(this.root)),
3428
+ snapshots: unitSnapshotPort(this.io, dirname(this.root)),
3429
+ ...(this.transact === undefined ? {} : { transact: this.transact }),
3430
+ hooks: {
3431
+ committed: async (event) => {
3432
+ const op = event.ops[0];
3433
+ if (op === undefined)
3434
+ return;
3435
+ await this.audit(name, 'write_file', op.before, op.after, `wrote ${filePath}`, filePath, event.unitId, undefined, origin);
3436
+ this.notifyMutation({ action: 'write_file', name, skillDir: dir, file: target });
3437
+ },
3438
+ },
3439
+ validateWrite: op => op.kind === 'write' ? this.contentThreatBlock(op.content) : null,
3440
+ });
3441
+ const result = await unit.apply({
3442
+ name,
3443
+ origin,
3444
+ roots: [{ name, protection: 'write' }],
3445
+ rootDirs: { [name]: dir },
3446
+ ops: [{ kind: 'write', target, content, expected: current, skill: name }],
3447
+ auditAction: 'write_file',
3448
+ auditSummary: `wrote ${filePath}`,
3449
+ eventAction: 'write_file',
3450
+ });
3451
+ if (!result.ok)
3452
+ return { ok: false, message: result.errors.map(error => error.message).join(';') };
3453
+ const warning = result.warnings === undefined || result.warnings.length === 0 ? '' : ` (警告:写入已落盘,但目录 fsync 失败 —— 持久性未确认:${result.warnings.join(';')})`;
3454
+ return { ok: true, message: `支撑文件「${filePath}」已写入「${name}」。${capAdvisory}${warning}`, path: target };
3278
3455
  }
3279
3456
  async removeSupportFile(rawName, filePath, origin = this.authority.origin, anchor, unitId) {
3280
- // One trim per entry: paths (dirOf), validation and messages all see the same name.
3281
- const name = rawName.trim();
3282
- // P2-4 (v11): the exists/read/remove window runs under the serialize queue
3283
- // — writeSupportFile wraps itself (F-208), so an unwrapped remove could
3284
- // interleave with a concurrent write and silently drop its write.
3285
- return await this.serial(() => this.removeSupportFileCore(name, filePath, origin, anchor, unitId));
3286
- }
3287
- async removeSupportFileCore(name, filePath, origin, anchor,
3288
- // G3a: the unit this removal belongs to (the undo path stamps its own `u-undo-*`), so the
3289
- // `remove_file` entry it audits lands under the same unit as the rest of that operation.
3290
- unitId) {
3457
+ return await this.serial(() => this.removeSupportFileCore(rawName.trim(), filePath, origin, anchor, unitId));
3458
+ }
3459
+ async removeSupportFileCore(name, filePath, origin, anchor, unitId) {
3291
3460
  const badName = this.badName(name);
3292
3461
  if (badName)
3293
3462
  return { ok: false, message: badName };
@@ -3302,20 +3471,10 @@ export class SkillLibrary {
3302
3471
  return { ok: false, message: validation };
3303
3472
  const target = join(dir, ...filePath.replace(/\\/g, '/').split('/').filter(Boolean));
3304
3473
  if (!await this.io.exists(target)) {
3305
- // v35 C9: a staged replay reports its own anchor verdict for a vanished
3306
- // target; the generic not-found report is for unanchored callers.
3307
3474
  if (anchor !== undefined)
3308
3475
  return anchorRefusalFile(name, filePath, 'missing');
3309
3476
  return { ok: false, message: `文件「${filePath}」不在技能「${name}」里。` };
3310
3477
  }
3311
- // P2-10 (v15): `io.remove` is a recursive rm, so a DIRECTORY path passed
3312
- // as file_path (nested support dirs are a feature) would wipe the whole
3313
- // subtree with only a null audit `before` — bytes gone without a hash.
3314
- // A regular file always reads as a string here; exists+unreadable means
3315
- // directory (EISDIR) or an unreadable file — refuse both (fail-closed;
3316
- // directories must be removed file by file).
3317
- // N36: three-state. Both non-present answers REFUSE below (the pre-read decides whether the target
3318
- // is a readable regular file — EISDIR lands here too); the unknown one names its reason.
3319
3478
  const beforeProbe = await probeText(this.io, target);
3320
3479
  if (!isPresent(beforeProbe)) {
3321
3480
  if (isUnknown(beforeProbe))
@@ -3323,31 +3482,13 @@ export class SkillLibrary {
3323
3482
  return { ok: false, message: `"${filePath}" is not a readable regular file — remove the files inside it one by one.` };
3324
3483
  }
3325
3484
  const before = beforeProbe.value;
3326
- // A1-5 (v18): a bare `io.remove` does not participate in the write-lock
3327
- // protocol, so a cross-instance/process writer could land its rename after
3328
- // our read and have the bytes deleted while it reports success. Route the
3329
- // delete through the same per-path transact lock (returning null = remove).
3330
- // v35 C9: verify the anchor inside the same lock the delete runs under. A
3331
- // mismatch returns `current` unchanged — "no change" in the transact
3332
- // contract — so nothing is deleted and nothing is audited.
3333
3485
  let verdict = anchorVerdict(anchor, before);
3334
- // A7: the audit's `before` is the bytes the LOCKED read saw. The pre-read above
3335
- // only decides the fast path and supplies a baseline when the target is already
3336
- // gone at commit time; a concurrent writer between the two reads would otherwise
3337
- // leave the ledger naming bytes this operation never removed.
3338
3486
  let lockedBefore = before;
3339
3487
  if (verdict === 'match' && this.transact) {
3340
- // S1.4 (v37 P2-7): a violating transact backend would leave `verdict` at
3341
- // its pre-read value, so the "removed" claim (and the audit record and the
3342
- // mutation event) would all be false while the file stayed on disk.
3343
- // Holder (not a `let`): the control-flow analysis cannot see the transact
3344
- // callback's assignment and would narrow a plain flag to a literal.
3345
3488
  const ran = { done: false };
3346
3489
  await this.transact(this.io, target, (current) => {
3347
3490
  ran.done = true;
3348
3491
  verdict = anchorVerdict(anchor, current);
3349
- // An unanchored caller accepts ANY current, so the locked bytes are the ones the
3350
- // delete replaces — `null` (the file vanished before the lock) keeps the pre-read.
3351
3492
  if (verdict === 'match' && current !== null)
3352
3493
  lockedBefore = current;
3353
3494
  return verdict === 'match' ? null : current;