@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.
- package/lib/index.js +3776 -2300
- package/lib/types/evolution-events.d.ts +6 -2
- package/lib/types/evolution-events.js +6 -0
- package/lib/types/extract-plan.js +17 -11
- package/lib/types/extract-prompt.d.ts +1 -5
- package/lib/types/extract-prompt.js +2 -0
- package/lib/types/extract-request.d.ts +32 -0
- package/lib/types/extract-request.js +155 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/index.js +4 -0
- package/lib/types/mutation-storage-adapter.d.ts +14 -0
- package/lib/types/mutation-storage-adapter.js +107 -0
- package/lib/types/mutation-unit.d.ts +164 -0
- package/lib/types/mutation-unit.js +509 -0
- package/lib/types/prompts.d.ts +3 -3
- package/lib/types/prompts.js +8 -8
- package/lib/types/proposal-view.d.ts +4 -0
- package/lib/types/proposal-view.js +3 -1
- package/lib/types/skill-health.d.ts +5 -0
- package/lib/types/skill-health.js +3 -0
- package/lib/types/skill-history.d.ts +2 -0
- package/lib/types/skill-history.js +3 -0
- package/lib/types/skill-lint.d.ts +2 -0
- package/lib/types/skill-lint.js +13 -2
- package/lib/types/skill-model.d.ts +45 -0
- package/lib/types/skill-model.js +74 -0
- package/lib/types/skill-store.d.ts +22 -1
- package/lib/types/skill-store.js +415 -274
- package/lib/types/unit-members.js +3 -3
- package/lib/types/unit-storage.d.ts +10 -0
- package/lib/types/unit-transaction.d.ts +1 -1
- package/lib/types/unit-undo-plan.js +5 -3
- package/lib/types/unit-undo.d.ts +1 -1
- package/lib/types/unit-undo.js +18 -3
- package/package.json +1 -1
- package/persisted-write-inventory.json +3 -3
package/lib/types/skill-store.js
CHANGED
|
@@ -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
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
2921
|
+
roots: [{ name, protection: 'write' }],
|
|
2922
|
+
rootDirs: { [name]: dir },
|
|
2923
|
+
ops,
|
|
2805
2924
|
auditAction: 'restructure',
|
|
2806
|
-
auditSummary:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
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
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
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;
|